新增: 媒体引用化存储与消息溯源
- 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:
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -231,6 +231,7 @@ async fn fire(app: &AppHandle, entry: &WakeEntry) -> bool {
|
||||
None,
|
||||
None,
|
||||
false, // pin_active=false:唤醒不劫持用户活跃面板
|
||||
None, // #958:唤醒路径无外部来源
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user