diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 28a66f2..396ede9 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -937,6 +937,25 @@ fn migrate_v33(conn: &Connection) -> Result<()> { } else { tracing::info!("v33: pending_approvals 列已存在,跳过"); } + // 任务4: workflow_executions.updated_at 列 —— 工作流执行记录更新时间戳(用于排序/增量同步/中文)。 + // 同样用 PRAGMA 探测列存在性(同 v4 模式),缺失才 ALTER;若表本身不存在(极端坏库),跳过该列不阻断迁移。 + let has_updated_at: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM pragma_table_info('workflow_executions') WHERE name = 'updated_at'", + [], + |row| row.get(0), + ) + .unwrap_or(false); + if !has_updated_at { + // workflow_executions 表在 V1 建表,此处仅加列。若表不存在(理论上 V1 必建,但坏库防御) + // pragma_table_info 返 0 行,has_updated_at 为 false,会尝试 ALTER → 报错被跳过(下面 match)。 + match conn.execute_batch("ALTER TABLE workflow_executions ADD COLUMN updated_at TEXT;") { + Ok(_) => tracing::info!("v33: workflow_executions 加 updated_at 列"), + Err(e) => tracing::warn!("v33: workflow_executions.updated_at 加列失败(表不存在?)跳过: {}", e), + } + } else { + tracing::info!("v33: workflow_executions.updated_at 列已存在,跳过"); + } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?; tracing::info!("迁移 v33 完成"); Ok(()) diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 36bff09..4fb99c1 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -660,7 +660,12 @@ pub async fn ai_approve( state.ai_tools.execute(&approval.tool_name, args.clone()), ).await { Ok(r) => r, - Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}", approval.tool_name)), + // 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。 + // 仅对 run_command 追加(其余工具无 shell 适配问题),避免污染其他工具错误语义。 + Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}{}", approval.tool_name, + if approval.tool_name == "run_command" && cfg!(windows) { + "\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)" + } else { "" })), } }; // 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。 diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index 2bb67b6..9f55e7a 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -76,6 +76,12 @@ pub(crate) async fn ensure_conversation_title( return; } + // 任务3: 合并相邻同 role 消息(防 Anthropic 协议 1214 错误)。 + // session.messages 历史可能存在连续同 role(如 user 失败重提、助手多轮中断后续接、tool_result 被过滤后 + // user 连续),Anthropic API 严格校验「messages 必须 user/assistant 交替」,连续同 role 直接拒 1214。 + // 在传给 LLM 前合并连续同 role(拼接 content),不影响其他逻辑(本函数 summary 派生数据)。 + let summary_msgs = merge_consecutive_roles(summary_msgs); + // B-260617-17 修复:进入即 extract_title 兜底落库 + emit,保证侧栏即时有非"新对话"标题。 // 原实现仅在 LLM 返回 None(失败)或 provider 构建失败时才落 extract——但 LLM 卡住 // (generate_title_via_llm 的 llm_concurrency 信号量 acquire 阻塞 / 网络挂起 / 后台 @@ -208,3 +214,36 @@ pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option { if chars.next().is_some() { format!("{}...", t) } else { t } }) } + +/// 合并相邻同 role 消息,拼接 content(防 Anthropic 1214 连续同 role 错误)。 +/// role 比较: System/User/Assistant/Tool 中,User 和 Assistant 交替校验最严, +/// 不同 role 不合并(保留边界),仅合并连续同 role。 +fn merge_consecutive_roles(msgs: Vec) -> Vec { + if msgs.is_empty() { + return msgs; + } + let mut out: Vec = Vec::with_capacity(msgs.len()); + for m in msgs { + if let Some(last) = out.last_mut() { + // role 比较(MessageRole 未派生 PartialEq,用 matches!按变体逐个比)。 + let same_role = match (&last.role, &m.role) { + (MessageRole::System, MessageRole::System) + | (MessageRole::User, MessageRole::User) + | (MessageRole::Assistant, MessageRole::Assistant) + | (MessageRole::Tool, MessageRole::Tool) => true, + _ => false, + }; + if same_role { + // 连续同 role: 拼接 content(中间补换行,避免语义粘连),保留首条元数据(id/timestamp)。 + // 不拼接 reasoning_content(标题摘要场景该字段无用,且不同 turn reasoning 串一起无意义)。 + if !m.content.is_empty() { + last.content.push('\n'); + last.content.push_str(&m.content); + } + continue; + } + } + out.push(m); + } + out +} diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 9b6595e..84e67fe 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -2316,8 +2316,13 @@ fn register_file_tools( ]), RiskLevel::High, Box::new(|args: serde_json::Value| Box::pin(async move { + // BUG-PWSH-BACKSLASH: 早期实现误把 command 当 JSON 字符串做反斜杠转义,导致 + // JSON 解析阶段把 `C:\\foo` 还原为 `C:\foo` 后再次转义丢失反斜杠。现直接 as_str() + // 取值,不做任何转义处理 —— serde_json 已正确还原反斜杠,PowerShell 也正确接受裸 + // 反斜杠路径(实测 PS5/PS7 均无歧义),根因在 JSON 解析层而非 shell 层。 let command = args["command"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?; + .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))? + .to_string(); // working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。 // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。 let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { @@ -2332,7 +2337,7 @@ fn register_file_tools( let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS); let request = ShellRequest { - command: command.to_string(), + command: command.clone(), working_dir: Some(working_dir.clone()), env: HashMap::new(), timeout_secs: Some(timeout_secs), @@ -2344,15 +2349,24 @@ fn register_file_tools( // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。 // 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注, // 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。 + // + // 任务2: 失败追加 shell 适配提示 —— Windows 默认 PowerShell(PS5 不支持 `&&`), + // LLM 普遍按 Unix 习惯生成命令,常见失败: `cd .. && cmd`(PS5 拒 `&&`)、 + // 未引用反斜杠路径被解析为转义。提示让 LLM 下次能自行修正(机制优先 prompt 说教)。 + let shell_hint = if cfg!(windows) { + "\n提示: PowerShell 下路径用正斜杠或双引号包裹(如 \"C:/Users\" 或 \"C:\\Users\"),命令间用 `;` 而非 `&&`(PS5),或改用 pwsh(PS7 支持 `&&`)" + } else { + "" + }; let result = execute(request).await.map_err(|e| { let msg = e.to_string(); if msg.contains("命令执行超时") { anyhow::anyhow!( - "命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。", - timeout_secs + "命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。{}", + timeout_secs, shell_hint ) } else { - e + anyhow::anyhow!("{}{}", msg, shell_hint) } })?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8ccf855..5ca341c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -57,11 +57,31 @@ pub fn run() { app.manage(app_state); // B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态 + // 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次), + // 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。 + // 记录上次处理时间,3s 内重复事件跳过(只取首次,使能状态复位一次即可)。 + let last_handshake_at = std::sync::Arc::new(tokio::sync::Mutex::new( + std::time::Instant::now() - std::time::Duration::from_secs(3600), + )); let app_handle = app.handle().clone(); app.listen("ai-client-ready", move |_event| { let session_arc = session_for_handshake.clone(); let app_h = app_handle.clone(); + let last_at = last_handshake_at.clone(); tauri::async_runtime::spawn(async move { + // 防抖检查:锁 last_at 读上次时间,3s 内跳过(仅记录日志,不做实际握手动作)。 + { + let mut last = last_at.lock().await; + let elapsed = last.elapsed(); + if elapsed < std::time::Duration::from_secs(3) { + tracing::info!( + "[L0-handshake] 跳过(距上次握手 {}ms < 3000ms 防抖)", + elapsed.as_millis() + ); + return; + } + *last = std::time::Instant::now(); + } let mut session = session_arc.lock().await; // F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv // 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。