新增: 媒体引用化存储与消息溯源

- media_store 模块: sha256 去重落盘 + 阿里云 OSS 双写(签名手写), OSS 失败降级本机引用
- 飞书图片入站: image 消息下载装配 vision 片, 3 次退避+120s 超时+残件清理防线
- 出站回传: 报告 [交付] 路径行自动上传飞书原生附件, 凭据/密钥名拦截兜底
- 溯源 V52: ai_messages.source 列, 飞书 msg_id/chat_id/发送者随消息落库
- spawn offload: 桌面粘贴图 base64 统一转落盘引用, SQLite 不进大字符串
- 引用统一 url 字段(https/file://)不自造 scheme, image_ref 发送前按需展开
This commit is contained in:
lxy
2026-10-11 07:33:18 +08:00
parent 8aefe3254c
commit 18e50c5af7
23 changed files with 1542 additions and 105 deletions
+82 -21
View File
@@ -695,6 +695,7 @@ pub async fn ai_chat_send(
parts,
mention_spans,
true,
None, // #958:标题/内部路径无外部来源
)
.await
.map(|_| "ok".to_string())
@@ -772,6 +773,8 @@ async fn reply_status_summary(
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(target),
@@ -804,6 +807,8 @@ pub(crate) async fn spawn_agentic_session(
parts: Option<Vec<ContentPart>>,
mention_spans: Option<Vec<MentionSpanDto>>,
pin_active: bool,
// #958 溯源:外部渠道(飞书等)原始消息定位,None=桌面本端。随 user 消息落库
source: Option<df_ai::provider::MessageSource>,
) -> Result<String, String> {
// 获取活跃提供商(只读,失败可直接返回,不影响生成标志)
let provider_config = super::super::prompt::get_active_provider(state).await?;
@@ -820,6 +825,19 @@ pub(crate) async fn spawn_agentic_session(
other => (other, false),
};
// offload(#958 引用化,vision 判定后):存活图片片 base64 → media_store 落盘引用
// (hash 去重,OSS 配置在则 https 引用跨设备可解析)。桌面粘贴图/前端上传在此统一收口
// ——消息只存引用,SQLite 不进大 base64,历史重放零负担。落盘失败保 base64 片不丢图。
// 在锁前做(与上方预拉同窗口,不持锁 I/O)
let parts = match parts {
Some(ps) if !ps.is_empty() => {
let oss_cfg = crate::media_store::read_oss_config(&state.db).await;
let store = crate::media_store::MediaStore::new(state.data_dir.clone(), oss_cfg);
Some(crate::media_store::offload_image_parts(ps, &store).await)
}
other => other,
};
// 纯发图(无文本无技能)且模型非视觉:降级后无任何有效内容,原「[图片已忽略…]」占位串
// 会被当 user 消息喂模型(回复跑偏且浪费一轮 LLM 调用)。此处直接拒绝发送,错误经前端
// 发送失败通道透出;图文混合(有文本/技能)维持占位后缀现状。置于锁与消息 push 之前,
@@ -917,9 +935,13 @@ pub(crate) async fn spawn_agentic_session(
// F-260614-05 Phase 2c: parts 非空走 user_parts(多模态),空/None 走 user()(纯文本零回归)。
// user_parts 内部把 parts 挂到 ChatMessage.parts,provider 在 has_image() 为真时转 vision 端点。
if let Some(ps) = parts.as_ref().filter(|p| !p.is_empty()) {
conv.messages.push(ChatMessage::user_parts(&user_content, ps.clone()));
let mut m = ChatMessage::user_parts(&user_content, ps.clone());
m.source = source.clone();
conv.messages.push(m);
} else {
conv.messages.push(ChatMessage::user(&user_content));
let mut m = ChatMessage::user(&user_content);
m.source = source.clone();
conv.messages.push(m);
}
// 新用户轮把本请求钉扎为 WorkingContext 常驻目标(截断 200 字符,与 agentic G1 目标钉扎 GOAL_MAX_CHARS 同量级)。
// 喂原文 message 而非落库变换文本 user_content:begin_turn 契约是"本条用户请求",
@@ -1105,6 +1127,7 @@ pub(crate) fn orchestration_session_spawner() -> df_nodes::ai_agent_node::SpawnF
None,
None,
false,
None, // #958:编排路径无外部来源
)
.await
{
@@ -2267,6 +2290,16 @@ pub async fn ai_chat_force_send(
other => (other, false),
};
// offload(#958 引用化,同 ai_chat_send):重发路径同样收口 base64→引用
let parts = match parts {
Some(ps) if !ps.is_empty() => {
let oss_cfg = crate::media_store::read_oss_config(&state.db).await;
let store = crate::media_store::MediaStore::new(state.data_dir.clone(), oss_cfg);
Some(crate::media_store::offload_image_parts(ps, &store).await)
}
other => other,
};
// 纯发图(无文本无技能)且模型非视觉:同 spawn_agentic_session 的拒绝闸(占位串不喂模型),
// 置于锁与消息 push 之前,拒绝零副作用。
if degraded && message.trim().is_empty() && skill.is_none() {
@@ -2400,25 +2433,6 @@ pub async fn ai_chat_force_send(
session.conv(&conv_id).stop_flag.store(false, std::sync::atomic::Ordering::SeqCst);
}
// 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
if let Some(ref cid) = old_conv_id {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(),
};
emit_ai_chat_event(&app, ev);
}
// system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
@@ -2478,9 +2492,50 @@ pub async fn ai_chat_force_send(
sorted.first().map(|p| p.to_string_lossy().to_string())
};
// 看板 949 后端消竞态:旧任务终态 emit 挪到「新 loop 占用 generating 之后」。
// 原 emit 位于「复位+占用」与 spawn 之间:前端迟到收尾仲裁(useAiLifecycleEvents
// cleanupTerminatedConversation)收到旧 AiCompleted 时查 ai_is_generating,若新 loop 尚未
// spawn / 入口未迁移 Generating 会读到 false → 误判真收尾 → setStreaming(false),新 loop
// 内容照流(AiTextDelta 无条件累加)但发送按钮失态。此处 spawn 后短轮询等新 loop 入口
// GeneratingGuard 迁移 Generating(同步迁移,正常毫秒级),占用成功或 2s 到期后再 emit;
// 到期未占用也 emit(旧任务确已终局,前端按真收尾收敛语义仍正确,只是失去仲裁保护)。
let occupied_probe = conv_id.clone();
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch, h3_watch_dir).await;
});
let occupied = {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if state.conv_states.is_active(&occupied_probe) {
break true;
}
if std::time::Instant::now() >= deadline {
break false;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
};
tracing::debug!(conv_id = %occupied_probe, occupied, "[ai] force_send 旧终态 emit 前新 loop 占用检查");
// 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
if let Some(ref cid) = old_conv_id {
let ev = AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(),
};
emit_ai_chat_event(&app, ev);
}
Ok("ok".to_string())
}
@@ -2545,6 +2600,8 @@ pub async fn ai_chat_stop(
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(target.clone()),
@@ -2666,6 +2723,8 @@ pub async fn ai_chat_stop(
reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None,
incomplete_reason: None,
conversation_id: conv_id.clone(),
@@ -2864,6 +2923,8 @@ pub async fn ai_stop_loop(
reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
// 零 token 收敛信号,非截断(看板 953;枚举新字段,Rust 字面量强制显式赋值)。
truncated_by_max_tokens: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(conversation_id),
@@ -79,6 +79,11 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
.as_deref()
.filter(|s| !s.is_empty())
.and_then(|s| serde_json::from_str(s).ok());
let source = rec
.source
.as_deref()
.filter(|s| !s.is_empty())
.and_then(|s| serde_json::from_str(s).ok());
let tool_calls = rec
.tool_calls
.as_deref()
@@ -107,6 +112,7 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens,
reasoning_tokens: rec.reasoning_tokens,
is_estimated: rec.is_estimated,
source,
}
}
@@ -137,6 +143,10 @@ pub fn message_to_record(
.as_ref()
.map(|s| s.as_db_str().to_string())
.unwrap_or_else(|| "active".to_string());
let source = msg
.source
.as_ref()
.map(|s| serde_json::to_string(s).unwrap_or_default());
AiMessageRecord {
id,
conversation_id: conversation_id.to_string(),
@@ -157,6 +167,7 @@ pub fn message_to_record(
prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens,
reasoning_tokens: msg.reasoning_tokens,
is_estimated: msg.is_estimated,
source,
}
}
@@ -364,6 +375,7 @@ pub(crate) async fn recover_parked_inner(
None, // parts:纯文本
None, // mention_spans
true, // pin_active:恢复动作即用户当前意图
None, // #958:恢复路径无外部来源
)
.await
}
@@ -922,6 +934,8 @@ pub async fn ai_conversation_delete(
let zero_usage = df_ai::provider::TokenUsage::default();
crate::commands::ai::agentic::emit_ai_completed_once(
&app, &conversation_id, &zero_usage, false,
// 会话删除终态补发,非截断(看板 953)。
false,
Some(true), None, Some("deleted"), true,
&[], false,
).await;
@@ -1176,6 +1190,7 @@ mod tests {
id: Some("msg_test_1".into()),
role: MessageRole::User,
content: "你好".into(),
source: None,
parts: None,
tool_call_id: None,
tool_calls: None,
@@ -788,6 +788,7 @@ mod tests {
id: None,
role,
content: content.to_string(),
source: None,
parts: None,
tool_call_id: None,
tool_calls: None,
+416 -54
View File
@@ -10,8 +10,9 @@
//! - 单会话单轮一条:同会话同轮只派一条(串行保序),会话忙则本轮跳过(不丢,下轮再取)
//! - 开关默认关:未配置 `df-outbox-enabled` 时循环只读配置空转,零行为
//!
//! ⚠️ P0 地基:调度器与派发链已就位,但 **未接线**(lib.rs setup 挂载 + agentic 收敛出口钩子),
//! 故本模块暂整体 allow(dead_code)。接线清单见交付说明,不在此文件内改 lib.rs。
//! ⚠️ P0 地基 + P1 IPC 命令族均已就位,但 **未接线**(lib.rs setup 挂载 + generate_handler 注册
//! + agentic 收敛出口钩子),故本模块暂整体 allow(dead_code)。接线清单见交付说明,
//! 不在此文件内改 lib.rs。
//!
//! 配置面(Settings KV,通用 settings_get/settings_set IPC 可读写):
//! - `df-outbox-enabled` bool("true"/"1" 为开),默认 **false**(地基默认关,可观测空转)
@@ -23,10 +24,11 @@ use std::collections::HashSet;
use std::sync::OnceLock;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use tauri::{AppHandle, Emitter, Manager, State};
use df_ai::provider::ContentPart;
use df_storage::crud::OutboxRepo;
use df_storage::crud::{OutboxRepo, STATUS_CANCELED, STATUS_FAILED, STATUS_PENDING, STATUS_SENDING, STATUS_SENT};
use df_storage::models::AiOutboxRecord;
use df_types::augmentation::MentionSpanDto;
use crate::commands::ai::commands::chat::spawn_agentic_session;
@@ -195,65 +197,88 @@ async fn run_tick(app: &AppHandle, cfg: &OutboxConfig) {
continue;
}
}
let parts = parse_parts(item.parts_json.as_deref());
let spans = parse_spans(item.spans_json.as_deref());
let result = spawn_agentic_session(
app,
&state,
item.text.clone(),
None, // language 沿用默认
item.skill.clone(), // /技能名
item.model_override.clone(),
Some(conv.clone()), // 目标会话(定时/队列不误发到活跃会话)
parts,
spans,
false, // pin_active=false:不劫持用户活跃面板
)
.await;
match result {
Ok(_conv_id) => {
// Ok=受理成功即终态:轮次内失败(流断/工具错)由既有 loop 自愈,不回流 outbox。
let _ = repo.mark_sent(&item.id).await;
dispatched.insert(conv.clone());
emit(app, "ai-outbox-sent", &item.id, &conv, None, None);
emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
tracing::info!("[outbox] 派发 {} → 会话 {conv}", item.id);
}
Err(err) => {
if is_permanent_spawn_error(&err) {
// 派发 + 结果落状态收口在 dispatch_claimed(调度循环与 send_now 共用一条派发链)。
if dispatch_claimed(app, &state, &repo, cfg.max_attempts, item)
.await
.is_ok()
{
dispatched.insert(conv);
}
}
}
/// 派发单条已 claim 的项:spawn 正典链 + 按受理结果落状态。调度循环(run_tick)与
/// 立即派发(ai_outbox_send_now)共用,保证两条入口的失败分流/事件/通知语义一致。
///
/// Ok=受理成功(已 mark_sent);Err=受理失败的原始错误串(状态已按永久/瞬时落好)。
async fn dispatch_claimed(
app: &AppHandle,
state: &AppState,
repo: &OutboxRepo,
max_attempts: i64,
item: AiOutboxRecord,
) -> Result<String, String> {
let conv = item.conversation_id.clone();
let parts = parse_parts(item.parts_json.as_deref());
let spans = parse_spans(item.spans_json.as_deref());
let result = spawn_agentic_session(
app,
state,
item.text.clone(),
None, // language 沿用默认
item.skill.clone(), // /技能名
item.model_override.clone(),
Some(conv.clone()), // 目标会话(定时/队列不误发到活跃会话)
parts,
spans,
false, // pin_active=false:不劫持用户活跃面板
None, // #958:待发箱消息原桌面发起(来源已在原消息),续投不重标
)
.await;
match result {
Ok(conv_id) => {
// Ok=受理成功即终态:轮次内失败(流断/工具错)由既有 loop 自愈,不回流 outbox。
let _ = repo.mark_sent(&item.id).await;
emit(app, "ai-outbox-sent", &item.id, &conv, None, None);
emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
tracing::info!("[outbox] 派发 {} → 会话 {conv}", item.id);
Ok(conv_id)
}
Err(err) => {
if is_permanent_spawn_error(&err) {
let _ = repo.mark_failed(&item.id, &err).await;
emit(app, "ai-outbox-failed", &item.id, &conv, Some(&err), Some(true));
notify_session_event(
app,
NotifyKind::Failed,
&conv,
"定时/待发消息发送失败",
&format!("受理失败(不可重试): {err}"),
);
tracing::error!("[outbox] 受理永久失败 {}: {err}", item.id);
} else {
// 瞬时失败:回退 pending 保留下轮再试(网络超时/会话忙竞态天然重试)。
let attempts = repo.revert_pending(&item.id, Some(&err)).await.unwrap_or(0);
if attempts >= max_attempts {
let _ = repo.mark_failed(&item.id, &err).await;
emit(app, "ai-outbox-failed", &item.id, &conv, Some(&err), Some(true));
emit(app, "ai-outbox-failed", &item.id, &conv, Some(&err), Some(false));
notify_session_event(
app,
NotifyKind::Failed,
&conv,
"定时/待发消息发送失败",
&format!("受理失败(不可重试): {err}"),
&format!("连续 {attempts} 次受理失败: {err}"),
);
tracing::error!("[outbox] 受理永久失败 {}: {err}", item.id);
tracing::error!("[outbox] 重试达上限({attempts})转失败 {}: {err}", item.id);
} else {
// 瞬时失败:回退 pending 保留下轮再试(网络超时/会话忙竞态天然重试)。
let attempts = repo.revert_pending(&item.id, Some(&err)).await.unwrap_or(0);
if attempts >= cfg.max_attempts {
let _ = repo.mark_failed(&item.id, &err).await;
emit(app, "ai-outbox-failed", &item.id, &conv, Some(&err), Some(false));
notify_session_event(
app,
NotifyKind::Failed,
&conv,
"定时/待发消息发送失败",
&format!("连续 {attempts} 次受理失败: {err}"),
);
tracing::error!("[outbox] 重试达上限({attempts})转失败 {}: {err}", item.id);
} else {
emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
tracing::warn!(
"[outbox] 受理瞬时失败 {} (第 {attempts} 次),下轮重试: {err}",
item.id
);
}
emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
tracing::warn!(
"[outbox] 受理瞬时失败 {} (第 {attempts} 次),下轮重试: {err}",
item.id
);
}
}
Err(err)
}
}
}
@@ -308,7 +333,283 @@ fn emit(
}
// ============================================================
// 单测(纯函数:配置解析/永久失败判据)
// IPC 命令族(P1 应用层)
// ⚠️ 未注册:generate_handler 接线清单见交付说明(不在此文件内改 lib.rs)。
// Repo 经 State<AppState> 现构(与调度器 outbox_repo() 同源,最小改动不加 state 字段)。
// ============================================================
/// IPC 返回的待发项视图。字段与 `AiOutboxRecord` 同形(snake_case 原样透传,
/// 前端 TS interface 按同名对齐),仅收窄为对外可见面(隐去 parts/spans 原始 JSON)。
#[derive(Debug, Clone, serde::Serialize)]
pub struct AiOutboxItem {
pub id: String,
pub conversation_id: String,
pub kind: String,
pub text: String,
pub skill: Option<String>,
pub model_override: Option<String>,
pub due_at: Option<i64>,
pub status: String,
pub attempts: i64,
pub last_error: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub sent_at: Option<i64>,
}
impl From<AiOutboxRecord> for AiOutboxItem {
fn from(r: AiOutboxRecord) -> Self {
Self {
id: r.id,
conversation_id: r.conversation_id,
kind: r.kind,
text: r.text,
skill: r.skill,
model_override: r.model_override,
due_at: r.due_at,
status: r.status,
attempts: r.attempts,
last_error: r.last_error,
created_at: r.created_at,
updated_at: r.updated_at,
sent_at: r.sent_at,
}
}
}
// ── 参数校验(纯函数,单测直接覆盖;命令体只做校验→落库→事件三步) ──
/// text 非空(enqueue / update_text 共用;空白串视为空)。
fn validate_text(text: &str) -> Result<(), String> {
if text.trim().is_empty() {
return Err("消息内容不能为空".to_string());
}
Ok(())
}
/// conversation_id 非空(设计 §3.1 目标会话 NOT NULL,composer 处确定,后端不猜 active)。
fn validate_conv_id(conv: &str) -> Result<(), String> {
if conv.trim().is_empty() {
return Err("目标会话不能为空".to_string());
}
Ok(())
}
/// enqueue 的 due_at 必须严格晚于当前时刻(定时项到点即发,过去/当下时刻无意义)。
/// 注意:ai_outbox_reschedule **不做**此限制——改期到过去=「下轮立即派发」语义,属合法操作。
fn validate_due_at_future(due_at: i64, now_ms: i64) -> Result<(), String> {
if due_at <= now_ms {
return Err(format!("定时时间必须晚于当前时刻(due_at={due_at}, now={now_ms})"));
}
Ok(())
}
/// kind 由 due_at 有无唯一推导(queue=尽快 / scheduled=定时),单一推导点防裸串漂移。
fn kind_for_due(due_at: Option<i64>) -> &'static str {
if due_at.is_some() {
df_storage::crud::KIND_SCHEDULED
} else {
df_storage::crud::KIND_QUEUE
}
}
/// send_now 状态守卫:pending 直接派、failed 先重入队再派;在途/终态拒绝并说明原因。
fn ensure_sendable(status: &str) -> Result<(), String> {
match status {
STATUS_PENDING | STATUS_FAILED => Ok(()),
STATUS_SENDING => Err("该项正在发送中,请稍后".to_string()),
STATUS_SENT => Err("该项已发送成功,无需重复发送".to_string()),
STATUS_CANCELED => Err("该项已取消,无法发送".to_string()),
other => Err(format!("未知状态,拒绝派发: {other}")),
}
}
/// cancel 状态守卫:sent(已发出去的消息)不可「取消」,canceled 幂等拒绝;其余可取消。
fn ensure_cancellable(status: &str) -> Result<(), String> {
match status {
STATUS_SENT => Err("该项已发送成功,无法取消".to_string()),
STATUS_CANCELED => Err("该项已处于取消状态".to_string()),
_ => Ok(()),
}
}
/// 入队一条待发/定时消息。`due_at`=Some(epoch ms,必须未来)→ 定时项;None → 尽快队列项。
/// 返回新项 id。成功即唤醒调度器(尽快项可被立刻取走)并 emit ai-outbox-changed。
#[tauri::command]
pub async fn ai_outbox_enqueue(
app: AppHandle,
state: State<'_, AppState>,
text: String,
due_at: Option<i64>,
skill: Option<String>,
model_override: Option<String>,
conversation_id: String,
) -> Result<String, String> {
validate_text(&text)?;
validate_conv_id(&conversation_id)?;
let now = df_types::now_millis();
if let Some(due) = due_at {
validate_due_at_future(due, now)?;
}
let id = df_types::types::new_id();
let record = AiOutboxRecord {
id: id.clone(),
conversation_id: conversation_id.clone(),
kind: kind_for_due(due_at).to_string(),
text,
skill,
model_override,
// P1 定时入口纯文本;图片项待后续(parts 落库链已就绪,enqueue 参数面再扩)。
parts_json: None,
spans_json: None,
due_at,
status: STATUS_PENDING.to_string(),
attempts: 0,
last_error: None,
// created_at/updated_at 由 Repo.insert 以落库时刻覆盖,此处仅占位。
created_at: now,
updated_at: now,
sent_at: None,
};
OutboxRepo::new(&state.db)
.insert(record)
.await
.map_err(|e| format!("待发项写入失败: {e}"))?;
// 尽快项/到期项即时唤醒调度器(周期 tick 仅兜底);事件供各窗口视图刷新。
notify().notify_one();
emit(&app, "ai-outbox-changed", &id, &conversation_id, None, None);
Ok(id)
}
/// 列出待发项。`conv_id`=Some → 该会话;None → 跨会话全量(定时列表抽屉数据源)。
/// 恒含终态(前端显 pending|sent|failed|canceled 状态徽标)。
#[tauri::command]
pub async fn ai_outbox_list(
state: State<'_, AppState>,
conv_id: Option<String>,
) -> Result<Vec<AiOutboxItem>, String> {
let repo = OutboxRepo::new(&state.db);
let records = match conv_id {
Some(conv) if !conv.trim().is_empty() => repo.list_by_conv(&conv, true).await,
_ => repo.list_all(true).await,
};
let records = records.map_err(|e| format!("待发项查询失败: {e}"))?;
Ok(records.into_iter().map(AiOutboxItem::from).collect())
}
/// 取消待发项。已发送/已取消的项拒绝(幂等不假报成功)。
#[tauri::command]
pub async fn ai_outbox_cancel(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let repo = OutboxRepo::new(&state.db);
let item = repo
.get_by_id(&id)
.await
.map_err(|e| format!("待发项查询失败: {e}"))?
.ok_or_else(|| format!("待发项不存在: {id}"))?;
ensure_cancellable(&item.status)?;
let ok = repo.cancel(&id).await.map_err(|e| format!("取消失败: {e}"))?;
if ok {
emit(&app, "ai-outbox-changed", &id, &item.conversation_id, None, None);
}
Ok(ok)
}
/// 改期。允许过去时刻(=下轮立即派发语义,与 enqueue 的未来约束不同,见 validate_due_at_future 注)。
#[tauri::command]
pub async fn ai_outbox_reschedule(
app: AppHandle,
state: State<'_, AppState>,
id: String,
due_at: i64,
) -> Result<bool, String> {
let repo = OutboxRepo::new(&state.db);
let item = repo
.get_by_id(&id)
.await
.map_err(|e| format!("待发项查询失败: {e}"))?
.ok_or_else(|| format!("待发项不存在: {id}"))?;
let ok = repo
.reschedule(&id, Some(due_at))
.await
.map_err(|e| format!("改期失败: {e}"))?;
if ok {
// 改期到已到点的新时刻可被立即取走,唤醒调度器。
notify().notify_one();
emit(&app, "ai-outbox-changed", &id, &item.conversation_id, None, None);
}
Ok(ok)
}
/// 编辑待发文本。终态(sent/canceled)项拒绝——历史记录不可改写。
#[tauri::command]
pub async fn ai_outbox_update_text(
app: AppHandle,
state: State<'_, AppState>,
id: String,
text: String,
) -> Result<bool, String> {
validate_text(&text)?;
let repo = OutboxRepo::new(&state.db);
let item = repo
.get_by_id(&id)
.await
.map_err(|e| format!("待发项查询失败: {e}"))?
.ok_or_else(|| format!("待发项不存在: {id}"))?;
if item.status == STATUS_SENT || item.status == STATUS_CANCELED {
return Err(format!("该项已终态({}),不可编辑", item.status));
}
let ok = repo
.update_text(&id, &text)
.await
.map_err(|e| format!("编辑失败: {e}"))?;
if ok {
emit(&app, "ai-outbox-changed", &id, &item.conversation_id, None, None);
}
Ok(ok)
}
/// 立即派发一次:状态守卫 →(failed 先重入队)→ CAS claim → 走与调度器同一条派发链
/// (dispatch_claimed,失败分流/事件/通知语义完全一致)。
/// 显式用户动作,不受 `df-outbox-enabled` 开关约束(开关只管后台自动调度)。
#[tauri::command]
pub async fn ai_outbox_send_now(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<(), String> {
let repo = OutboxRepo::new(&state.db);
let item = repo
.get_by_id(&id)
.await
.map_err(|e| format!("待发项查询失败: {e}"))?
.ok_or_else(|| format!("待发项不存在: {id}"))?;
ensure_sendable(&item.status)?;
if item.status == STATUS_FAILED {
let requeued = repo.requeue(&id).await.map_err(|e| format!("重入队失败: {e}"))?;
if !requeued {
return Err("重入队失败(状态已变化),请刷新后重试".to_string());
}
}
if !repo.claim(&id).await.map_err(|e| format!("占取失败: {e}"))? {
return Err("该项已被调度占取,请稍后刷新".to_string());
}
// 占取后重取最新记录(sending 态),交给共用派发链落状态。
let fresh = repo
.get_by_id(&id)
.await
.map_err(|e| format!("待发项查询失败: {e}"))?
.ok_or_else(|| format!("待发项不存在: {id}"))?;
let max_attempts = read_config(&app).await.max_attempts;
dispatch_claimed(&app, &state, &repo, max_attempts, fresh).await?;
Ok(())
}
// ============================================================
// 单测(纯函数:配置解析/永久失败判据/命令参数校验与错误映射)
// ============================================================
#[cfg(test)]
@@ -368,4 +669,65 @@ mod tests {
fn status_constant_single_source() {
assert_eq!(df_storage::crud::STATUS_PENDING, "pending");
}
// ── P1 IPC 命令族:参数校验与错误映射(命令体的纯函数面) ──
/// enqueue/update_text 的 text 校验:空白串拒绝且错误信息明确,非空放行。
#[test]
fn validate_text_rejects_blank_with_message() {
assert!(validate_text("").is_err());
assert!(validate_text(" \n\t ").is_err());
assert!(validate_text("跑一下构建").is_ok());
let err = validate_text(" ").unwrap_err();
assert!(err.contains("不能为空"), "错误信息应可直读: {err}");
}
/// enqueue 的 conversation_id 校验:空/空白拒绝(目标会话 NOT NULL,后端不猜 active)。
#[test]
fn validate_conv_id_rejects_blank() {
assert!(validate_conv_id("").is_err());
assert!(validate_conv_id(" ").is_err());
assert!(validate_conv_id("conv-abc").is_ok());
}
/// enqueue 的 due_at 校验:过去/当下时刻拒绝,严格未来放行;
/// reschedule 特意不走此校验(过去时刻=立即派发语义),故仅 enqueue 面约束。
#[test]
fn validate_due_at_future_rejects_past_and_present() {
let now = 1_000_000;
assert!(validate_due_at_future(now - 1, now).is_err(), "过去时刻拒绝");
assert!(validate_due_at_future(now, now).is_err(), "当下时刻(非严格未来)拒绝");
assert!(validate_due_at_future(now + 1, now).is_ok());
let err = validate_due_at_future(42, 43).unwrap_err();
assert!(err.contains("必须晚于当前时刻"), "错误信息应可直读: {err}");
}
/// kind 单一推导点:due_at 有无唯一决定 scheduled/queue(防两处裸串漂移)。
#[test]
fn kind_for_due_maps_by_presence() {
assert_eq!(kind_for_due(Some(9_999_999_999_999)), df_storage::crud::KIND_SCHEDULED);
assert_eq!(kind_for_due(None), df_storage::crud::KIND_QUEUE);
}
/// send_now 状态守卫错误映射:pending/failed 可派;在途与各终态逐一拒绝且信息可直读。
#[test]
fn sendable_guard_maps_status_errors() {
assert!(ensure_sendable(STATUS_PENDING).is_ok());
assert!(ensure_sendable(STATUS_FAILED).is_ok(), "failed 经 requeue 后可手动重派");
assert!(ensure_sendable(STATUS_SENDING).is_err());
assert!(ensure_sendable(STATUS_SENT).is_err());
assert!(ensure_sendable(STATUS_CANCELED).is_err());
assert!(ensure_sendable("bogus").is_err(), "未知状态保守拒绝");
assert!(ensure_sendable(STATUS_SENT).unwrap_err().contains("已发送成功"));
}
/// cancel 状态守卫:sent 不可取消(消息已出)、canceled 幂等拒绝;pending/failed/sending 可取消。
#[test]
fn cancellable_guard_blocks_terminal_only() {
assert!(ensure_cancellable(STATUS_PENDING).is_ok());
assert!(ensure_cancellable(STATUS_SENDING).is_ok(), "在途也允许取消意图(取消落 canceled)");
assert!(ensure_cancellable(STATUS_FAILED).is_ok(), "失败项可放弃取消");
assert!(ensure_cancellable(STATUS_SENT).is_err());
assert!(ensure_cancellable(STATUS_CANCELED).is_err());
}
}
+1
View File
@@ -78,6 +78,7 @@ pub(crate) async fn ensure_conversation_title(
id: None, // 标题摘要派生消息:不落库不溯源,无需分配 ID(对齐老消息 None 语义)
role: m.role.clone(),
content: m.content.clone(),
source: None,
parts: None,
tool_call_id: None,
tool_calls: None,
+2
View File
@@ -1300,6 +1300,7 @@ async fn recover_one_parked(app: &AppHandle) -> Result<String, String> {
None,
None,
false,
None, // #958:引擎路径无外部来源
)
.await
.map(|_| conv_id)
@@ -1363,6 +1364,7 @@ async fn claim_and_spawn(app: &AppHandle, cfg: &EngineConfig) -> Result<(), Stri
None,
None,
false, // pin_active=false 不劫持活跃面板
None, // #958:引擎路径无外部来源
)
.await
{
+1
View File
@@ -231,6 +231,7 @@ async fn fire(app: &AppHandle, entry: &WakeEntry) -> bool {
None,
None,
false, // pin_active=false:唤醒不劫持用户活跃面板
None, // #958:唤醒路径无外部来源
)
.await
{
+359 -11
View File
@@ -22,6 +22,7 @@ use serde::Deserialize;
use df_storage::crud::SettingsRepo;
use df_types::types::new_id;
use df_ai::provider::ContentPart;
/// app_settings 键:飞书渠道配置(与设置面板 FeishuPanel 同键契约)
const KV_FEISHU_CONFIG: &str = "feishu-config";
@@ -371,6 +372,103 @@ fn parse_command(text: &str) -> Option<(String, String)> {
Some((cmd, tail))
}
/// 图片消息喂模型的任务语境(#958):飞书 image 为独立消息类型无法带文字,
/// 用引导词让 vision 模型知道「这是用户发来的图,按图作答」
const IMAGE_INBOUND_PROMPT: &str = "(用户通过飞书发来一张图片,请查看图片并按图片内容处理)";
/// 图片入站大小上限(#958):base64 转换膨胀 4/3,vision 端点普遍接受 ≤20MB 内嵌图,
/// 10MB 原图留足余量;超限引导用户压缩,防超大图烧 token 与转码内存
const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
/// 下载飞书图片消息资源并落盘,返回本地引用 ContentPart(#958 引用化存储)。
/// 存储面只存 `file://` 引用(base64 不进 SQLite,防大图随历史全量重放膨胀),
/// 发送请求时由 df-ai::image_ref 单点按需读盘转 base64(模型使用按需转换)。
/// 走 channel_messaging 便捷方法(与 reply/send 同风格),租户 token 由 client 自管。
/// media_type 由字节魔数判定(下载响应无可靠 MIME;飞书图床为 png/jpeg/webp/gif),
/// 未知格式拒收——错误 media_type 会被 vision 端点 400,宁早拒不晚炸。
async fn download_feishu_image(
client: &LarkClient,
store: &crate::media_store::MediaStore,
message_id: &str,
file_key: &str,
) -> Result<ContentPart, String> {
// 下载带防线(对齐 u-claw fetchResource 经验):3 次指数退避(1s/4s)+单次 120s 超时
// ——飞书资源接口偶发抖动,无重试时用户收到一次性失败;超时防接口挂起拖死受理链。
// 空响应与错误同待遇进重试(可能为服务端瞬时异常),耗尽后报最后一次原因
let resp = {
let mut last_err = String::from("下载失败: 未知错误");
let mut out = None;
for attempt in 0u32..3 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_secs(1 << (attempt - 1))).await;
}
let dl = tokio::time::timeout(
std::time::Duration::from_secs(120),
client.channel_messaging().download_message_resource(
message_id,
file_key,
"image",
&RequestOption::default(),
),
)
.await;
match dl {
Ok(Ok(r)) if !r.data.is_empty() => {
out = Some(r);
break;
}
Ok(Ok(_)) => last_err = "图片内容为空".into(),
Ok(Err(e)) => last_err = format!("下载失败: {e}"),
Err(_) => last_err = "下载超时(120s)".into(),
}
}
match out {
Some(r) => r,
None => return Err(last_err),
}
};
if resp.data.len() > MAX_IMAGE_BYTES {
return Err(format!(
"图片超过 10MB 上限(约 {}MB),请压缩后重发",
resp.data.len() / 1024 / 1024
));
}
let media_type = sniff_image_mime(&resp.data)
.ok_or_else(|| "不支持的图片格式(仅支持 png/jpeg/webp/gif)".to_string())?;
// 存储决策收口 media_store:hash 去重落盘,OSS 配置在则双写并引用 https(跨设备可解析),
// 失败降级本机 file://(不阻塞)。本函数只管飞书协议(下载+防线),不管存储。
// 飞书 image 消息无原始文件名(None=纯 hash 名)
let url = store.save(&resp.data, media_type, None).await?;
Ok(ContentPart::Image {
url: Some(url),
base64: None,
media_type: Some(media_type.into()),
alt: None,
})
}
/// 读取媒体 OSS 配置(KV `media-oss-config`;未配置/解析失败 → None=纯本地模式)
async fn read_media_oss_config(
db: &df_storage::db::Database,
) -> Option<crate::media_store::OssConfig> {
crate::media_store::read_oss_config(db).await
}
/// 图片格式魔数判定(#958):PNG/JPEG/GIF/WEBP 四态,与飞书图床常见格式对齐
fn sniff_image_mime(b: &[u8]) -> Option<&'static str> {
if b.starts_with(&[0x89, b'P', b'N', b'G']) {
Some("image/png")
} else if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some("image/jpeg")
} else if b.starts_with(b"GIF8") {
Some("image/gif")
} else if b.len() > 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP" {
Some("image/webp")
} else {
None
}
}
/// 入站文本:群聊需 @bot;白名单校验;/new 重置;其余走 ai-chat 会话链路
async fn handle_message(
ctx: Arc<Ctx>,
@@ -378,23 +476,47 @@ async fn handle_message(
) {
// D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳
ctx.last_alive.store(now_secs(), Ordering::SeqCst);
if msg.message_type != "text" {
// 类型分流(#958 图片入站):text 原样;image 走下载装配(vision ContentPart);
// 其余类型维持 #25 提示卡。image 消息 SDK 归一化已把 image_key 放入
// msg.resources(ChannelResource),无需自行解析 content JSON。
let image_key = if msg.message_type == "image" {
match msg.resources.iter().find(|r| r.resource_type == "image") {
Some(r) => Some(r.file_key.clone()),
None => {
let _ = reply_markdown_card(
&ctx.client,
&msg.message_id,
"⚠️ 图片消息缺少 image_key,无法处理。",
)
.await;
return;
}
}
} else if msg.message_type != "text" {
// #25 非文本消息静默丢修复:回提示卡(D7 媒体能力前的兜底),不再零反馈。
// 补替代路径引导:用户发图/文件往往是想让 AI 处理内容,直接告知改用文字描述
// 补替代路径引导:用户发文件等往往是想让 AI 处理内容,直接告知改用文字描述
let _ = reply_markdown_card(
&ctx.client,
&msg.message_id,
"📎 暂仅支持文本消息,图片/文件等媒体能力规划中。\n\n如需处理图片/文件内容,请用文字描述你的需求(如「把这段文字翻译成英文:…」)。",
"📎 暂支持文本与图片消息,文件/语音等其他类型规划中。\n\n如需处理文件内容,请用文字描述你的需求(如「把这段文字翻译成英文:…」)。",
)
.await;
return;
}
} else {
None
};
if msg.chat_type == "group" && !msg.mentioned_bot {
return;
}
let text = msg.text.clone().unwrap_or_default().trim().to_string();
// 飞书 @ 占位清洗(如 @_user_1):@ 语义已由 mentioned_bot 承载,占位串只污染文本
let text = strip_mention_placeholders(&text);
// 图片消息不带文字(飞书 image 为独立消息类型),喂固定引导词交代任务语境;
// msg.text 的 markdown 图链([图片](image_key))对模型无意义,不喂
let text = if image_key.is_some() {
IMAGE_INBOUND_PROMPT.to_string()
} else {
let text = msg.text.clone().unwrap_or_default().trim().to_string();
// 飞书 @ 占位清洗(如 @_user_1):@ 语义已由 mentioned_bot 承载,占位串只污染文本
strip_mention_placeholders(&text)
};
if text.is_empty() {
return;
}
@@ -427,6 +549,30 @@ async fn handle_message(
return;
}
// 图片入站(#958):下载消息资源→落盘→引用片。置于白名单后(未授权不下载,
// 不替白名单外用户烧流量)、占位发票前(失败早退,无占位卡需原地更新)
let parts = match &image_key {
Some(key) => {
let state = ctx.app.state::<crate::state::AppState>();
let oss_cfg = read_media_oss_config(&state.db).await;
let store = crate::media_store::MediaStore::new(state.data_dir.clone(), oss_cfg);
match download_feishu_image(&ctx.client, &store, &message_id, key).await {
Ok(part) => Some(vec![part]),
Err(e) => {
feishu_log(&format!("图片下载失败 chat={chat_id}: {e}"));
let _ = reply_markdown_card(
&ctx.client,
&message_id,
&format!("⚠️ 图片下载失败:{e}\n\n请重发,或直接用文字描述你的需求。"),
)
.await;
return;
}
}
}
None => None,
};
// 命令通路(#30 容错):全角/大小写/附言归一后识别,仅命令词走控制面,任务原文不动。
// 顺序约束:命令与审批决议须先于注入旁路——conv 活跃时这两类文本否则会被当补充
// 指令注入任务;未知命令词落回常规任务路径,零行为变更
@@ -578,7 +724,9 @@ async fn handle_message(
let state = ctx.app.state::<crate::state::AppState>();
let conv_id_now = ctx.chat_map.lock().await.get(&chat_id).cloned();
if let Some(cid) = conv_id_now {
if state.conv_states.is_active(&cid) {
// 图片消息不注入:inject_runtime_message 只收文本,注入则图被丢、模型只见
// 引导词空跑;落常规路径由受理守卫拦,语义诚实(任务占用中,稍后重发)
if image_key.is_none() && state.conv_states.is_active(&cid) {
match crate::commands::ai::commands::chat::inject_runtime_message(&state, &cid, &text).await {
Ok(true) => {
let _ = reply_markdown_card(&ctx.client, &message_id, "✅ 补充指令已注入运行中任务,下一轮生效").await;
@@ -654,9 +802,16 @@ async fn handle_message(
None,
None,
Some(conv_id.clone()),
None,
parts, // #958 图片入站:图片消息带 vision 片,文本消息 None;vision 能力判定/降级在 spawn 内
None,
false, // 不劫持用户活跃面板(编排节点同款语义)
// #958 溯源:飞书原始消息定位随 user 消息落库,可跳原消息/区分多端来源
Some(df_ai::provider::MessageSource {
channel: "feishu".into(),
message_id: message_id.clone(),
chat_id: Some(chat_id.clone()),
sender: if open_id.is_empty() { None } else { Some(open_id.clone()) },
}),
)
.await
{
@@ -717,10 +872,12 @@ async fn handle_message(
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
cancel_watchers().lock().await.insert(conv_id.clone(), cancel_tx);
let conv_watched = conv_id.clone();
let watched_chat = chat_id.clone();
tauri::async_runtime::spawn(async move {
run_completion_watcher(
ctx,
conv_id,
watched_chat,
ack,
inflight_guard,
cancel_rx,
@@ -745,6 +902,145 @@ struct CardFrame {
terminal: bool,
}
// ── 出站文件回传(#958,u-claw replyLocalFiles 同模式) ──
// 约定:AI 报告中写 `[交付] <绝对路径>` 行,渠道自动把文件上传飞书回发——
// 图片走图片消息、其余走文件消息(飞书原生附件体验,免用户去桌面端翻)。
// 正文中提及路径但无标记行=叙述,不发送(防止口述路径误上传)。
/// 提取报告中全部 `[交付] <路径>` 行的路径(纯函数)。容错:全半角冒号后空格可选,
/// 路径含空格整行取(行内唯一路径假设,与 AI 约定一致)
fn extract_delivery_paths(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let line = line.trim();
let rest = line.strip_prefix("[交付]")?;
let path = rest.trim().trim_start_matches(':').trim_start_matches(':').trim();
(!path.is_empty()).then(|| path.to_string())
})
.collect()
}
/// 敏感文件拦截(防御纵深,对齐 u-claw replyBlocked):名字命中凭据/密钥特征即拒,
/// AI 被诱导交付敏感文件时兜底。返回 Some(原因)=拦截
fn delivery_blocked(path: &str) -> Option<&'static str> {
let lower = path.to_lowercase();
const SENSITIVE: [&str; 8] = [
".env", "id_rsa", "credential", "password", "secret", "token", "api_key", "apikey",
];
SENSITIVE
.iter()
.find(|k| lower.contains(**k))
.map(|_| "敏感文件(凭据/密钥特征)")
}
/// 扩展名 → 飞书 file_type(上传接口必填;对齐飞书开放平台枚举)
fn feishu_file_type(ext: &str) -> &'static str {
match ext {
"pdf" => "pdf",
"xls" | "xlsx" | "csv" => "xls",
"doc" | "docx" | "rtf" => "doc",
"ppt" | "pptx" => "ppt",
"mp4" | "mov" | "avi" | "mkv" => "mp4",
"mp3" | "wav" | "m4a" | "aac" | "flac" => "mp3",
_ => "stream",
}
}
/// 上传回传全部交付文件。单文件失败只提示不中断(部分成功优于整体失败);
/// 文件不存在静默跳过并记日志(过滤残余误匹配,"写了路径文件不在"经日志可查)
async fn reply_deliveries(client: &LarkClient, chat_id: &str, text: &str) {
for path in extract_delivery_paths(text) {
if let Some(why) = delivery_blocked(&path) {
feishu_log(&format!("回传跳过({why}): {path}"));
send_text(client, chat_id, &format!("⚠️ 已拦截交付请求:{path}({why})")).await;
continue;
}
let p = std::path::Path::new(&path);
match tokio::fs::metadata(p).await {
Ok(m) if m.is_file() => {}
_ => {
feishu_log(&format!("回传跳过(文件不存在或为目录): {path}"));
continue;
}
}
let bytes = match tokio::fs::read(p).await {
Ok(b) => b,
Err(e) => {
feishu_log(&format!("回传读取失败({path}): {e}"));
continue;
}
};
let opt = &RequestOption::default();
let ext = p
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let is_image = matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp");
let sent: Result<(), String> = if is_image {
// 图片:上传 image → image 消息直发(直发不做 reply,占位卡已是回复链,
// 附件直发即可,避免嵌套回复噪声)
match client.im().image.create("message", bytes, opt).await {
Ok(resp) => match resp.data.as_ref().and_then(|d| d.image_key.clone()) {
Some(key) => {
let input = SendInput {
msg_type: Some("image".into()),
image_key: Some(key),
chat_id: Some(chat_id.to_string()),
..Default::default()
};
client
.channel_messaging()
.send(&input, opt)
.await
.map(|_| ())
.map_err(|e| format!("发送失败: {e}"))
}
None => Err("响应缺 image_key".into()),
},
Err(e) => Err(format!("图片上传失败: {e}")),
}
} else {
// 文件:上传 file → file 消息直发(飞书好友互发文件同款体验)
let name = p
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "file".into());
match client
.im()
.file
.create(feishu_file_type(&ext), &name, None, bytes, opt)
.await
{
Ok(resp) => match resp.data.as_ref().and_then(|d| d.file_key.clone()) {
Some(key) => {
let input = SendInput {
msg_type: Some("file".into()),
file_key: Some(key),
chat_id: Some(chat_id.to_string()),
..Default::default()
};
client
.channel_messaging()
.send(&input, opt)
.await
.map(|_| ())
.map_err(|e| format!("发送失败: {e}"))
}
None => Err("响应缺 file_key".into()),
},
Err(e) => Err(format!("文件上传失败: {e}")),
}
};
if let Err(e) = sent {
feishu_log(&format!("回传失败({path}): {e}"));
let base = path.rsplit(['/', '\\']).next().unwrap_or(path.as_str());
send_text(client, chat_id, &format!("⚠️ 文件回传失败:{base}({e})")).await;
} else {
feishu_log(&format!("回传完成: {path}"));
}
}
}
/// 完成事件订阅任务(#14 两段式):只做裁决——消费 ai_event_bus 的 AiAgentFinished(本 conv)
/// 决定终版文本,经帧通道交给预览任务原地落卡(占位卡唯一写者是预览任务,受理卡/终版/阶段
/// 提示全部经帧通道按序落卡)。引擎层 deliver_completion_report_if_waited 是广播单一发出点。
@@ -755,6 +1051,7 @@ struct CardFrame {
async fn run_completion_watcher(
ctx: Arc<Ctx>,
conv_id: String,
chat_id: String,
ack: String,
// #20:计数生命周期跟任务走,guard 持有至 watcher 退出
_inflight: InflightGuard,
@@ -855,6 +1152,10 @@ async fn run_completion_watcher(
}
};
feishu_log(&format!("watcher 终版 conv={conv_id} len={}", frame.text.len()));
// #958 出站回传(u-claw replyLocalFiles 同模式):报告内 [交付] 标记行的本地文件
// 自动上传飞书(图片→图片消息/其余→文件消息),用户在飞书直接收原生附件。
// 终版文本中的标记行本身保留(AI 写路径也是叙述的一部分,卡内可见)
reply_deliveries(&ctx.client, &chat_id, &frame.text).await;
let _ = frame_tx.send(frame).await;
let _ = stream_task.await; // 等预览任务落卡终版并退出,再释放 inflight
return;
@@ -1958,10 +2259,57 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
#[cfg(test)]
mod tests {
use super::{
heartbeat_due, is_terminal_push, md_to_lark_friendly, msg_after_acceptance, parse_command,
strip_mention_placeholders, stream_preview,
delivery_blocked, extract_delivery_paths, feishu_file_type, heartbeat_due,
is_terminal_push, md_to_lark_friendly, msg_after_acceptance, parse_command,
sniff_image_mime, strip_mention_placeholders, stream_preview,
};
#[test]
fn delivery_paths_extract_only_marked_lines() {
// #958 出站回传:只认 [交付] 标记行,叙述路径不发送;全半角冒号容错
let text = "方案如下。\n已生成图表:\n[交付] E:\\out\\chart.png\n另见 E:\\ref\\说明.docx\n[交付]:/home/u/报表 最终.xlsx\n[交付]";
assert_eq!(
extract_delivery_paths(text),
vec!["E:\\out\\chart.png".to_string(), "/home/u/报表 最终.xlsx".to_string()]
);
assert!(extract_delivery_paths("没有标记").is_empty());
}
#[test]
fn delivery_blocked_catches_credential_names() {
assert!(delivery_blocked("E:\\proj\\.env").is_some());
assert!(delivery_blocked("/home/u/id_rsa").is_some());
assert!(delivery_blocked("E:\\app\\API_KEY.txt").is_some());
assert_eq!(delivery_blocked("E:\\out\\chart.png"), None);
assert_eq!(delivery_blocked("E:\\out\\report.pdf"), None);
}
#[test]
fn feishu_file_type_maps_common_exts() {
assert_eq!(feishu_file_type("pdf"), "pdf");
assert_eq!(feishu_file_type("xlsx"), "xls");
assert_eq!(feishu_file_type("docx"), "doc");
assert_eq!(feishu_file_type("mp4"), "mp4");
assert_eq!(feishu_file_type("zip"), "stream");
assert_eq!(feishu_file_type(""), "stream");
}
#[test]
fn image_mime_sniff_four_formats() {
// #958:四格式魔数命中 + 未知格式拒收(宁早拒不晚炸 vision 端点 400)
assert_eq!(sniff_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A]), Some("image/png"));
assert_eq!(sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg"));
assert_eq!(sniff_image_mime(b"GIF89a"), Some("image/gif"));
assert_eq!(
sniff_image_mime(b"RIFF\x24\x08\x00\x00WEBPVP8 "),
Some("image/webp")
);
assert_eq!(sniff_image_mime(b"<html>"), None);
assert_eq!(sniff_image_mime(&[]), None);
// RIFF 但非 WEBP(avi/wav 等)不误判
assert_eq!(sniff_image_mime(b"RIFF\x24\x08\x00\x00AVI "), None);
}
#[test]
fn command_case_and_tail_tolerance() {
// 30:/new 大小写与附言容错,附言不烧 LLM
+5
View File
@@ -2,6 +2,8 @@
mod commands;
mod state;
// 媒体存储域(#958 引用化):hash 去重落盘/OSS 双写/引用形态,飞书与桌面粘贴图共用
mod media_store;
// Phase3: 桌面内嵌 HTTP MCP server + 托盘常驻 + 单实例
mod mcp;
// 本地工具代理统一网关:内嵌 axum 监听 127.0.0.1:8899,路由到 mysql/ssh/mongo/redis 4 代理端口
@@ -72,6 +74,9 @@ pub fn run() {
crate::commands::ai::tools::spawn_agent::init_app_handle(app.handle().clone());
// D2a 工具结果外置根目录注入(app_data 下 tool-results/;未注入时直通降级)
commands::ai::tool_result_offload::init_base(data_dir.clone());
// P1 上下文侧工具结果外置(同根目录,tool-results/ 内以 `ctx-` 前缀区分)。
// 未注入时 extract_key_info 退化为纯摘要 = 改造前行为(零回归)。
df_ai::tool_offload::init_base(data_dir.clone());
// 飞书直连渠道:配置启用时起长连接(读 feishu-config;未配置/disabled 静默跳过)
feishu_channel::start_if_enabled(app.handle().clone());
+309
View File
@@ -0,0 +1,309 @@
//! 媒体存储域(#958 图片入站引用化 + 跨设备文件服务)
//!
//! 单一归属:媒体字节 → 存储引用(`ContentPart::Image.url`)的全部决策收在本模块——
//! hash 去重落盘(同内容同文件,转发/重发不重复存储)、可选 OSS 双写(跨设备可解析)、
//! 引用形态选择(oss `https://` / 本机 `file://`)。飞书协议(下载/上传回传)留 feishu_channel,
//! 本模块零协议依赖。
//!
//! 引用设计:不自造 scheme——`https://`(OSS)是 OpenAI 端点原生直传格式,anthropic 侧
//! 已有 url→base64 预拉;`file://` 由 df-ai::image_ref 发送前展开为 base64。展开按 url
//! 形态自然分流,无自定义协议。
//!
//! OSS 失败语义:上传失败不阻塞——本机快照已落盘,引用降级 `file://`(本机可解析,
//! 跨设备解析降级为「图片文件缺失」文本,诚实不炸请求)。
use df_ai::provider::ContentPart;
use serde::Deserialize;
/// OSS 配置(app_settings KV `media-oss-config` JSON;缺省=纯本地模式)。
/// public_base_url 为空时取 `https://{bucket}.{endpoint}`(bucket 私有时需自备 CDN 域)
#[derive(Debug, Clone, Deserialize)]
pub struct OssConfig {
pub bucket: String,
/// 如 oss-cn-hangzhou.aliyuncs.com
pub endpoint: String,
pub access_key_id: String,
pub access_key_secret: String,
#[serde(default)]
pub public_base_url: String,
}
/// app_settings 键:媒体 OSS 配置(KV JSON,设置面板后续批;当前可手配)
pub const KV_MEDIA_OSS_CONFIG: &str = "media-oss-config";
/// 读 OSS 配置(KV;未配置/解析失败 → None=纯本地模式)。媒体域唯一读取口,
/// 飞书入站与 spawn offload 共用
pub async fn read_oss_config(db: &df_storage::db::Database) -> Option<OssConfig> {
let repo = df_storage::crud::SettingsRepo::new(db);
let raw = repo.get(KV_MEDIA_OSS_CONFIG).await.ok().flatten()?;
serde_json::from_str(&raw).ok()
}
/// base64 图片片 → 落盘引用片(spawn 统一 offload,#958 引用化):
/// 桌面粘贴图/前端上传在此收口,消息只存引用,SQLite 不进大 base64 字符串
/// (历史重放不再全量携带)。落盘失败保原 base64 片(不丢图,仅失去引用化收益);
/// 引用片/文本片原样透传。media_type 缺失的片无法规范落盘,原样保留
pub async fn offload_image_parts(parts: Vec<ContentPart>, store: &MediaStore) -> Vec<ContentPart> {
use base64::Engine as _;
let mut out = Vec::with_capacity(parts.len());
for p in parts {
out.push(match p {
ContentPart::Image { url, base64: Some(b64), media_type, alt } => {
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.ok()
.filter(|b| !b.is_empty());
match (decoded, media_type.clone()) {
(Some(bytes), Some(mt)) => match store.save(&bytes, &mt, None).await {
Ok(url) => ContentPart::Image {
url: Some(url),
base64: None,
media_type,
alt,
},
Err(e) => {
tracing::warn!(err = %e, "图片落盘失败,保留 base64 片");
ContentPart::Image { url, base64: Some(b64), media_type, alt }
}
},
// 解码失败/空图/无 media_type:原样透传,provider 层按既有语义处置
_ => ContentPart::Image { url, base64: Some(b64), media_type, alt },
}
}
other => other,
});
}
out
}
/// 媒体存储。`oss` 为 None 时纯本地模式(引用全部 file://)
pub struct MediaStore {
data_dir: std::path::PathBuf,
oss: Option<OssConfig>,
}
impl MediaStore {
pub fn new(data_dir: std::path::PathBuf, oss: Option<OssConfig>) -> Self {
Self { data_dir, oss }
}
/// 落盘并返回引用 url。本地快照必落(hash 去重,同内容幂等);OSS 配置在则上传,
/// 成功引用 `https://`(跨设备可解析),失败降级 `file://`(不阻塞,本机零损)。
///
/// `original_name`:原始文件名(文件消息带,图片消息通常 None)——缀在 hash 后保
/// 目录可读,清洗路径穿越字符防越界;None/空则纯 hash 名
pub async fn save(
&self,
bytes: &[u8],
media_type: &str,
original_name: Option<&str>,
) -> Result<String, String> {
let key = content_key(bytes, media_type, original_name);
let dir = self.data_dir.join("media");
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| format!("创建媒体目录失败: {e}"))?;
let path = dir.join(&key);
// 存在即命中(hash 去重):跳过重写,读侧幂等
if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
if let Err(e) = tokio::fs::write(&path, bytes).await {
// 半写残件不留盘(对齐 u-claw fetchResource 防线)
tokio::fs::remove_file(&path).await.ok();
return Err(format!("媒体写入失败: {e}"));
}
}
match &self.oss {
Some(cfg) => match upload_to_oss(cfg, &key, bytes, media_type).await {
Ok(()) => Ok(public_url(cfg, &key)),
Err(e) => {
tracing::warn!(key = %key, err = %e, "OSS 上传失败,引用降级本机 file://");
Ok(local_ref(&path))
}
},
None => Ok(local_ref(&path)),
}
}
}
/// 内容寻址文件名:`<sha256前16hex>[_<清洗原始名>]<ext>`(对齐 u-claw safeAttachName
/// 模式)——hash 前缀保去重(同内容必同文件)且排序聚簇,原始名缀后保目录可读。
/// 平铺不分会话目录:会话归属经 DB 消息引用反查,文件系统不编码会话(否则同图跨会话
/// 重复存储,去重失效);个人桌面应用媒体量级百~千,hash 前缀平铺可管理,分桶待量级上来再说
fn content_key(bytes: &[u8], media_type: &str, original_name: Option<&str>) -> String {
use sha2::Digest as _;
let digest = hex::encode(&sha2::Sha256::digest(bytes)[..8]);
// 扩展名:原始名自带 ext 优先(保真),否则按 media_type 规范化
let name = original_name.map(sanitize_name).filter(|s| !s.is_empty());
let ext = name
.as_deref()
.and_then(|n| {
std::path::Path::new(n)
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
})
.unwrap_or_else(|| ext_of(media_type).to_string());
match name {
Some(n) => format!("{digest}_{n}"),
None => format!("{digest}{ext}"),
}
}
/// 原始文件名清洗(防路径穿越/过长):/ \ .. → _,截 60 字符
fn sanitize_name(name: &str) -> String {
let cleaned: String = name
.replace('/', "_")
.replace('\\', "_")
.replace("..", "_")
.chars()
.take(60)
.collect();
cleaned.trim().to_string()
}
fn ext_of(media_type: &str) -> &'static str {
match media_type {
"image/png" => ".png",
"image/jpeg" => ".jpg",
"image/gif" => ".gif",
"image/webp" => ".webp",
_ => ".bin",
}
}
/// 本机引用(file:// + 原生绝对路径;df-ai::image_ref 展开端同前缀约定)
fn local_ref(path: &std::path::Path) -> String {
format!("file://{}", path.display())
}
/// OSS 公网引用:自定义 CDN 域优先,缺省桶域名
fn public_url(cfg: &OssConfig, key: &str) -> String {
if cfg.public_base_url.is_empty() {
format!("https://{}.{}/{}", cfg.bucket, cfg.endpoint, key)
} else {
format!("{}/{}", cfg.public_base_url.trim_end_matches('/'), key)
}
}
/// 阿里云 OSS PUT 上传(Header 签名版,无需 SDK):
/// Signature = base64(hmac-sha1(SK, "PUT\n\n{ctype}\n{date}\n/{bucket}/{key}"))
async fn upload_to_oss(
cfg: &OssConfig,
key: &str,
bytes: &[u8],
content_type: &str,
) -> Result<(), String> {
use hmac::Mac as _;
let url = format!("https://{}.{}/{}", cfg.bucket, cfg.endpoint, key);
let date = httpdate::fmt_http_date(std::time::SystemTime::now());
let resource = format!("/{}/{}", cfg.bucket, key);
let string_to_sign = format!("PUT\n\n{content_type}\n{date}\n{resource}");
let mut mac = hmac::Hmac::<sha1::Sha1>::new_from_slice(cfg.access_key_secret.as_bytes())
.map_err(|e| format!("OSS 签名初始化失败: {e}"))?;
mac.update(string_to_sign.as_bytes());
let sig = base64_sign(&mac.finalize().into_bytes());
let client = reqwest::Client::new();
let resp = client
.put(&url)
.header("Date", &date)
.header("Content-Type", content_type)
.header(
"Authorization",
format!("OSS {}:{sig}", cfg.access_key_id),
)
.body(bytes.to_vec())
.timeout(std::time::Duration::from_secs(60))
.send()
.await
.map_err(|e| format!("OSS 请求失败: {e}"))?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
Err(format!("OSS 上传失败 {status}: {}", truncate_body(&body)))
}
}
/// hmac 输出 → 标准 base64(签名编码)
fn base64_sign(digest: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(digest)
}
/// 错误体截断(防 XML 错误页刷日志)
fn truncate_body(s: &str) -> &str {
match s.char_indices().nth(200) {
Some((i, _)) => &s[..i],
None => s,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn save_dedupes_by_content_and_refs_local() {
let dir = std::env::temp_dir().join(format!("df-media-test-{}", df_types::types::new_id()));
let store = MediaStore::new(dir.clone(), None);
let url1 = store.save(b"\x89PNG same", "image/png", None).await.unwrap();
let url2 = store.save(b"\x89PNG same", "image/png", None).await.unwrap();
let url3 = store.save(b"\x89PNG other", "image/png", None).await.unwrap();
// 同内容同引用(hash 去重),不同内容不同引用
assert_eq!(url1, url2);
assert_ne!(url1, url3);
assert!(url1.starts_with("file://"), "纯本地模式引用 file://, 实际 {url1}");
let path = std::path::Path::new(&url1["file://".len()..]);
assert_eq!(tokio::fs::read(path).await.unwrap(), b"\x89PNG same");
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[tokio::test]
async fn original_name_kept_readable_and_sanitized() {
let dir = std::env::temp_dir().join(format!("df-media-test-{}", df_types::types::new_id()));
let store = MediaStore::new(dir.clone(), None);
let url = store
.save(b"file-bytes", "application/octet-stream", Some("../报表/最终 版本.xlsx"))
.await
.unwrap();
let name = &url["file://".len()..];
let fname = std::path::Path::new(name).file_name().unwrap().to_string_lossy();
assert!(fname.contains('_'), "hash 与原始名以 _ 分隔: {fname}");
assert!(!fname.contains("..") && !fname.contains('/'), "路径穿越字符已清洗: {fname}");
assert!(fname.ends_with(".xlsx"), "原始名自带 ext 优先: {fname}");
// 同内容同名 → 同文件(去重不因名字破坏)
let url2 = store
.save(b"file-bytes", "application/octet-stream", Some("../报表/最终 版本.xlsx"))
.await
.unwrap();
assert_eq!(url, url2);
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[test]
fn content_key_stable_and_ext_aware() {
let k1 = content_key(b"abc", "image/png", None);
let k2 = content_key(b"abc", "image/png", None);
let k3 = content_key(b"abc", "image/jpeg", None);
assert_eq!(k1, k2);
assert_ne!(k1, k3, "同字节不同 media_type 应不同文件(png/jpg 不混)");
assert!(k1.ends_with(".png") && k1.len() == 16 + 4);
}
#[test]
fn public_url_prefers_cdn_domain() {
let cfg = OssConfig {
bucket: "b".into(),
endpoint: "oss-cn-hangzhou.aliyuncs.com".into(),
access_key_id: "ak".into(),
access_key_secret: "sk".into(),
public_base_url: "https://cdn.example.com/".into(),
};
assert_eq!(public_url(&cfg, "k.png"), "https://cdn.example.com/k.png");
let cfg2 = OssConfig { public_base_url: String::new(), ..cfg };
assert_eq!(
public_url(&cfg2, "k.png"),
"https://b.oss-cn-hangzhou.aliyuncs.com/k.png"
);
}
}