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

- 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
{