修复: force_send单锁竞态+classify unknown不重试(撤销误判)+UX交互批
This commit is contained in:
34
docs/todo.md
34
docs/todo.md
@@ -45,6 +45,8 @@
|
||||
|
||||
### P0 — 阻断性 bug
|
||||
|
||||
- [x] ✅(主代修06-17·撤销误判) **🚨 BUG-260617-01 [P0] 已修** — **`|| true` 致 400/参数错误无效重试 4 次**【已修:stream_recv.rs:433 删 `|| true` + 注释 + 单测 classify_unknown_retryable→classify_unknown_not_retryable 断言改 !;cargo test 20 passed】 — 用户实测证实(2026-06-17)。Anthropic `[1214][messages 参数非法]` 无 HTTP 前缀 → 落入 `stream_recv.rs:433` `\|\| true` → retryable=true → **空耗 4 次重试浪费 token**。前次审查误判为"有意设计"。**一行改动:删 `|| true` + 改单测断言**。— src-tauri/src/commands/ai/stream_recv.rs:433
|
||||
- [ ] **🚨 BUG-260617-12 [P1]→P0 联动** — **DeepSeek reasoning_content 缺失致必 400** — 全代码库零处理 thinking/reasoning_content 字段(CompletionRequest+ChatMessage 均无)。DeepSeek thinking 模式每次请求 400,被 BUG-01 放大至 **16 次无效请求**(4重试×可能多 provider)。需补字段+透传+openai_compat 映射。— crates/df-ai-core/src/provider.rs:22-39,92-99
|
||||
- [x] B-260614-01 — ~~待审批持久化根治(重启恢复)未生效~~ ✅ mission:T-260614-01 已修复(commands.rs:444 clear→retain 保其他对话 pending;ai_approve 两处 if !recovered 守卫移除;cargo check 0 err / 19 test pass)(06-14)
|
||||
- [x] B-260614-02 — ~~df-workflow ConditionEngine 默认 true~~ ✅ mission:T-260614-02 已修复(conditions.rs:31 `Ok(true)`→`Ok(false)` 保守拒绝;5 个原断言错误行为的测试同步改断言;df-workflow 7 test pass)(06-14)
|
||||
- [x] B-260614-04 — ~~df-workflow NodeRegistry::default() script 工厂 unimplemented!~~ ✅ mission:T-260614-03 已修复(删除整个 Default impl——零调用方 + 违反铁律;state.rs build_registry 已用 new() + 手动注册真实 ScriptNode)(06-14)
|
||||
@@ -1089,13 +1091,25 @@ gap: 8px;
|
||||
|
||||
#### 🔴 P0 — 确定性 Bug(3 项·建议立即修复)
|
||||
|
||||
- [x] ✅(误判销账·主代核验06-17) **BUG-260617-01 [P0]** — **`classify_status_or_class` 尾部 `|| true`** 【误判:单测 classify_unknown_retryable(stream_recv.rs:474-478)明确断言 unknown→retryable=true,注释「保守可重试不误判 Fatal 错杀」是有意设计;400/403 走数字码 :424 is_status_retryable 返 false(classify_4xx_fatal:460-465 验证),`|| true` 仅影响纯文本 unknown 分支。按 todo 修(改 false)会破坏单测。审查报告把保守设计当 bug】 — `stream_recv.rs:433` 函数末尾硬编码 `|| true`,400/403 等 Fatal 错误被误标为可重试 → agentic loop 无效重试循环浪费 token 和时间。**修法**:移除 `|| true`,unknown 默认 `false`(保守不重试),与 `retry::is_status_retryable` 语义一致。**一行改动,零风险,最高 ROI**。— src-tauri/src/commands/ai/stream_recv.rs:433
|
||||
- [x] ~~**BUG-260617-01**~~ → **已提升至 P0 阻断性 bug 区(🚨 立即修)**,见上方 L47
|
||||
**用户报错证据(2026-06-17)**:
|
||||
① Anthropic: `[1214][messages 参数非法。请检查文档。]` → 无 HTTP 数字前缀 → 走文本分支 → `\|\| true` → retryable=true → **空耗 4 次重试**
|
||||
② DeepSeek: `400 Bad Request: {"error":{"message":"The reasoning_content in the thinking mode must be passed back to the API"}}` → 同理可能命中
|
||||
**根因**: 审查 agent 误认为"400/403 走数字码 :424 is_status_retryable 返 false"。但实际 provider 错误消息格式多样——Anthropic 中文错误无 "HTTP 400" 前缀,直接落入 `\|\| true` 兜底分支。
|
||||
**修法**: 移除 `\|\| true`,unknown 默认 `false`;同步修改单测 classify_unknown_retryable 断言为 `assert!(!classify_status_or_class("unknown"))`。— src-tauri/src/commands/ai/stream_recv.rs:433 + :474-478(单测)
|
||||
- [x] ~~**BUG-260617-12**~~ → **已提升至 P0 阻断性 bug 区(🚨 联动 BUG-01)**,见上方 L48
|
||||
**根因**: 全代码库**零处理** reasoning_content/thinking 字段:
|
||||
- `df-ai-core/src/provider.rs:22-39` `CompletionRequest` struct **无** reasoning_content 字段
|
||||
- `df-ai-core/src/provider.rs:92-99` `ChatMessage` struct **无** reasoning_content 字段
|
||||
- `crates/df-ai/` 下 anthropic_compat.rs / openai_compat.rs **零命中** reasoning|thinking
|
||||
**影响**: 使用 DeepSeek thinking 模型时每次请求必 400,且被 BUG-01 (`\|\| true`) 放大至 4 次无效重试(浪费 token + 时间)。
|
||||
**修法方向**: ① CompletionRequest 加 `reasoning_content: Option<String>` + ChatMessage 同加 ② agentic loop 重试时从上一轮 response 提取 reasoning_content 透传 ③ openai_compat 构建请求时映射到 API 的 reasoning_content 字段。— crates/df-ai-core/src/provider.rs:22-39,92-99 + crates/df-ai/src/openai_compat.rs
|
||||
- [x] **BUG-260617-02 [P0]** — **`file_info` 全量读大文件到内存再截断前 8KB** — `tool_registry.rs:1125-1126` `tokio::fs::read(path)` 将整个文件读入内存(注释说">2MB跳过避免全量读"但实际先全量读再取前 8192 字节做二进制检测)。>2MB 文件触发 OOM 风险。**修法**:用 `File::open` + `read_exact()` 仅读前 N 字节,或加 `.take(8192)` 截断流式读取。— src-tauri/src/commands/ai/tool_registry.rs:1125-1126
|
||||
- [x] **BUG-260617-03 [P0]** — **路径遍历防护可被编码绕过** — `tool_registry.rs:78-95` 只做字符串 `..` 检测 + 小写化,URL 编码 `%2e%2e` / Unicode 同形字符可绕过。**修法**:先 `percent_decode` 再做词法 `..` 分段归一化检查;对不存在路径(new file) 也应规范化校验。— src-tauri/src/commands/ai/tool_registry.rs:78-95
|
||||
|
||||
#### 🟡 P1 — 高概率问题(5 项·建议本轮修复)
|
||||
|
||||
- [ ] **BUG-260617-04 [P1]** — **`ai_chat_force_send` → `ai_chat_send` 双锁竞态窗口** — `commands.rs:763` force_send 先 lock 复位 generating→释放锁→调 ai_chat_send 再 lock。两锁之间 stop IPC 可插入打断"原子复位+发送"语义;若 ai_chat_send 因 generating=true 被 reject → 前端状态不一致。**修法**:force_send 内联 spawn run_agentic_loop(同 regenerate/edit 模式),不走 ai_chat_send 门控。— src-tauri/src/commands/ai/commands.rs:744-763
|
||||
- [x] **BUG-260617-04 [P1]** — **`ai_chat_force_send` → `ai_chat_send` 双锁竞态窗口** — `commands.rs:763` force_send 先 lock 复位 generating→释放锁→调 ai_chat_send 再 lock。两锁之间 stop IPC 可插入打断"原子复位+发送"语义;若 ai_chat_send 因 generating=true 被 reject → 前端状态不一致。**修法**:force_send 内联 spawn run_agentic_loop(同 regenerate/edit 模式),不走 ai_chat_send 门控。— src-tauri/src/commands/ai/commands.rs:744-763
|
||||
- [x] **BUG-260617-05 [P1]** — **`try_continue_agent_loop` 4 次独立 lock 非原子化** — `agentic.rs:943-1036` 至少 4 次 `state.ai_session.lock().await`,每次 release 后其他 IPC 可修改 session → 续跑判断基于过时快照(如步骤 1 判 should_continue=true → 步骤 2 间 user 点 stop → 步骤 3 仍续跑)。**修法**:单次 lock 内完成所有字段读写,或引入结构化快照一次取出。— src-tauri/src/commands/ai/agentic.rs:943-1036
|
||||
- [x] **BUG-260617-06 [P1]** — **`accumulate_tokens` 整数溢出无 saturating 保护** — `conversation.rs:49-51` `old.unwrap_or(0) + add as i64` 长期对话累积接近 i64::MAX 后翻负。**修法**:改用 `.saturating_add()` 或类型改为 `u64`。— src-tauri/src/commands/ai/conversation.rs:49-51
|
||||
- [x] **BUG-260617-07 [P1]** — **`generate_diff` LCS O(n*m) 内存爆炸** — `tool_registry.rs:32-75` 标准 DP diff,5000 行输入 ≈ 200MB DP 表。虽有 changes>300 截断但截断前已分配计算完毕。**修法**:超长输入(均>1000行)跳过 LCS 改用 Myers diff 或直接返回截断 diff。— src-tauri/src/commands/ai/tool_registry.rs:32-75
|
||||
@@ -1142,21 +1156,21 @@ gap: 8px;
|
||||
- [ ] **UX-260617-13 [P1]🎯** — **刷新后主面板无流式恢复,用户看到"假死"** — 分离窗口(resumeInDetached)有完整恢复链(ai_is_generating→恢复streaming→推送占位气泡→localStorage currentText),**主面板完全没有等价逻辑**。刷新后 streaming=false + 无占位气泡 + 后端事件到达不进流式分支 → 文本仅 AiCompleted 时一次性显示退化为非流式体验。**修法**:主面板 onMounted/client-ready 后调 ai_is_generating IPC,true 则对齐 resumeInDetached 恢复链。工作量较大但用户感知最强。— src/composables/ai/useAiEvents.ts:155-179 + src/composables/ai/useAiWindow.ts:64-89
|
||||
- [x] **UX-260617-14 [P2]🎯** — **审批超时 130s 静默复位无反馈** — `ToolCard.vue:507,541-54` 超时仅 `approving.value=false`,无 toast/error/日志。用户无法区分"审批完成状态没更新"和"超时复位"。**修法**:超时时 warning toast + APPROVE_LOADING_TIMEOUT_MS 改为共享常量导入。— src/components/ToolCard.vue:507,541-544
|
||||
- [x] **UX-260617-15 [P2]🎯** — **regenerate/editMessage 丢弃图片 parts** — `useAiSend.ts:148-190` regenerate/edit 均无 parts 参数。含图片消息重生成/编辑后图片消失。**修法**:扩展 regenerate/editMessage IPC 签名透传 parts;或至少 UI 层对含 parts 消息禁用按钮/confirmDialog 提示。— src/composables/ai/useAiSend.ts:148-190
|
||||
- [ ] **UX-260617-16 [P2]🎯** — **无路由离开/关闭保护,未发送输入静默丢失** — AiChat.vue 缺 beforeRouteLeave/onBeforeUnload 守卫。输入框有内容/已粘贴图片时切页面 → 内容瞬间消失无提示。**修法**:beforeRouteLeave guard + detached window beforeunload 检查 inputText/pendingImages/pendingSkill 非空时 confirmDialog。— src/components/AiChat.vue (缺失守卫)
|
||||
- [x] **UX-260617-16 [P2]🎯** — **无路由离开/关闭保护,未发送输入静默丢失** — AiChat.vue 缺 beforeRouteLeave/onBeforeUnload 守卫。输入框有内容/已粘贴图片时切页面 → 内容瞬间消失无提示。**修法**:beforeRouteLeave guard + detached window beforeunload 检查 inputText/pendingImages/pendingSkill 非空时 confirmDialog。— src/components/AiChat.vue (缺失守卫)
|
||||
- [x] **UX-260617-17 [P2]🎯** — **ToolCardList 分组名硬编码中文绕过 i18n** — `ToolCardList.vue:259-274` nameMap 全硬编码中文(`'读取文件'`等)。英文 locale 下分组标题仍显示中文。**修法**:value 替换 i18n key(`ai.toolGroup.readFile`)。— src/components/ToolCardList.vue:259-274
|
||||
- [x] **UX-260617-18 [P2]🎯** — **isToolFailure() 正则过于宽泛误判合法输出为失败** — `ToolCard.vue:220-224` `/执行失败|failed|error[:\s]/i` 匹配任何含这些子串的文本。search_files 结果列 error.log / run_command stderr 含 warning 均被标红。**修法**:收窄为 `/^(执行失败|Error:|Failed:)/m` 或按工具类型分别配置检测策略。— src/components/ToolCard.vue:220-224
|
||||
- [x] **UX-260617-19 [P2]🎯** — **stopChat 本地先行 reset 与后端竞态窗口** — `useAiSend.ts:479-488` 先设 streaming=false 再发 IPC stop。IPC 失败时后端继续生成但前端已停止;若后续事件也丢失则永久卡死。注释承认风险但无缓解。**修法**:stop 后启动 5s 守护超时,未收到 Completed/Error 则推提示或重试 stop。— src/composables/ai/useAiSend.ts:479-488
|
||||
|
||||
#### ⚪ aichat 扫描新增发现(LOW · 8 项)
|
||||
|
||||
- [ ] **UX-260617-20 [P3]🎯** — **base64 图片 parts 无大小预算,200 条消息上限不感知单条体积** — MESSAGE_CAP=200 仅按条数 splice,单条 base64 可达数 MB。极端 200条×5MB=~1GB 全挂 reactive state。**修法**:增加基于序列化大小的预算(total parts size<50MB),超出裁剪最旧消息 parts。— src/api/types.ts:228-243 + src/stores/ai.ts:78-87
|
||||
- [ ] **UX-260617-21 [P3]🎯** — **Markdown 表格溢出气泡容器** — `.ai-msg-bubble` 缺表格专属溢出处理。AI 返回≥4列表格撑破宽度溢出到侧边栏。**修法**:`.ai-md table { display:block; overflow-x:auto; max-width:100% }`。— src/components/AiChat.vue CSS (~L3259)
|
||||
- [ ] **UX-260617-22 [P3]🎯** — **空内容 AI 回复渲染不可见气泡** — `AiChat.vue:457` v-if 条件使空 content 已完成消息整个 bubble 不渲染 → avatar+一片空白。**修法**:空 content 显示最小化占位`(empty)`或隐藏整条含 avatar。— src/components/AiChat.vue:457
|
||||
- [ ] **UX-260617-23 [P3]🎯** — **搜索结果选中不清除搜索框,搜索视图不退出** — `AiChat.vue:46` 点击结果 switchConversation 但不清 searchQuery。搜索文字保留+仍显示扁平列表。**修法**:点击 handler 加 `searchQuery=''` 自动退出搜索模式。— src/components/AiChat.vue:46
|
||||
- [ ] **UX-260617-24 [P3]🎯** — **删除当前活跃对话无特殊警告** — `AiChat.vue:1140-1145` confirmDelete 通用文案不区分活跃对话。对比 confirmNewConversation 有上下文感知警告。**修法**:id===activeConversationId 时改醒目提示说明消息区将清空。— src/components/AiChat.vue:1140-1145
|
||||
- [ ] **UX-260617-25 [P3]🎯** — **parseBlockNoCache 非 null 断言无运行时防护** — `AiChat.vue:848` `purify!.sanitize(marked!.parse(...))` 。marked/DOMPurify 动态 import 失败时 TypeError → rAF 循环中断流式卡死。**修法**:防御性 null check + 降级 escapeFallback。— src/components/AiChat.vue:848
|
||||
- [x] **UX-260617-20 [P3]🎯** — **base64 图片 parts 无大小预算,200 条消息上限不感知单条体积** — MESSAGE_CAP=200 仅按条数 splice,单条 base64 可达数 MB。极端 200条×5MB=~1GB 全挂 reactive state。**修法**:增加基于序列化大小的预算(total parts size<50MB),超出裁剪最旧消息 parts。— src/api/types.ts:228-243 + src/stores/ai.ts:78-87
|
||||
- [x] **UX-260617-21 [P3]🎯** — **Markdown 表格溢出气泡容器** — `.ai-msg-bubble` 缺表格专属溢出处理。AI 返回≥4列表格撑破宽度溢出到侧边栏。**修法**:`.ai-md table { display:block; overflow-x:auto; max-width:100% }`。— src/components/AiChat.vue CSS (~L3259)
|
||||
- [x] **UX-260617-22 [P3]🎯** — **空内容 AI 回复渲染不可见气泡** — `AiChat.vue:457` v-if 条件使空 content 已完成消息整个 bubble 不渲染 → avatar+一片空白。**修法**:空 content 显示最小化占位`(empty)`或隐藏整条含 avatar。— src/components/AiChat.vue:457
|
||||
- [x] **UX-260617-23 [P3]🎯** — **搜索结果选中不清除搜索框,搜索视图不退出** — `AiChat.vue:46` 点击结果 switchConversation 但不清 searchQuery。搜索文字保留+仍显示扁平列表。**修法**:点击 handler 加 `searchQuery=''` 自动退出搜索模式。— src/components/AiChat.vue:46
|
||||
- [x] **UX-260617-24 [P3]🎯** — **删除当前活跃对话无特殊警告** — `AiChat.vue:1140-1145` confirmDelete 通用文案不区分活跃对话。对比 confirmNewConversation 有上下文感知警告。**修法**:id===activeConversationId 时改醒目提示说明消息区将清空。— src/components/AiChat.vue:1140-1145
|
||||
- [x] **UX-260617-25 [P3]🎯** — **parseBlockNoCache 非 null 断言无运行时防护** — `AiChat.vue:848` `purify!.sanitize(marked!.parse(...))` 。marked/DOMPurify 动态 import 失败时 TypeError → rAF 循环中断流式卡死。**修法**:防御性 null check + 降级 escapeFallback。— src/components/AiChat.vue:848
|
||||
- [x] **UX-260617-26 [P3]🎯** — **startListener/stopListener 竞态:_startPromise 未在 stop 中清零** — `useAiEvents.ts:354+378` stop 不清 _startPromise。极速 mount/unmount/mount(HMR) → 返回过期 promise。**修法**:stop 中加 `_startPromise=null`。— src/composables/ai/useAiEvents.ts:378-388
|
||||
- [ ] **UX-260617-27 [P3]🎯** — **ConfirmDialog 危险按钮默认标签"删除"语义不安全** — `ConfirmDialog.vue:9-10` 默认 `$t('common.delete')`。非删除场景(高危工具审批)忘传 dangerLabel → 按钮显示"删除"。**修法**:默认值改为 `$t('common.confirm')`。— src/components/ConfirmDialog.vue:9-10
|
||||
- [x] **UX-260617-27 [P3]🎯** — **ConfirmDialog 危险按钮默认标签"删除"语义不安全** — `ConfirmDialog.vue:9-10` 默认 `$t('common.delete')`。非删除场景(高危工具审批)忘传 dangerLabel → 按钮显示"删除"。**修法**:默认值改为 `$t('common.confirm')`。— src/components/ConfirmDialog.vue:9-10
|
||||
|
||||
> **[销账 wf wys30viqi·2026-06-17]** 8 agent 文件域隔离核验修复(独立 grep 核验源码形态 + cargo check --workspace EXIT 0 + vue-tsc EXIT 0)。
|
||||
> - **已修 15**:BUG-02 file_info 流式读 .take(8192) / 03 validate_path percent_decode 词法归一化(+percent-encoding 依赖) / 05 try_continue 5lock→ContinueSnapshot 快照收敛+spawn 前重检 / 06 saturating_add(含 ContextUsage 周边) / 07 LCS_MAX_LINES=1000 双>1000 跳 DP / 09 max_depth clamp(1,10) / 11 read_file 默认 500 行+has_more;UX-08 catch toast+保留旧列表 / 09 startListener try/catch+console.error / 10 AiError clearAllApprovalTimers+清 pendingApprovals / 14 STREAM_TIMEOUT_MS 共享常量+reactive toast / 17 toolGroup i18n 14key / 18 isToolFailure 正则收窄^行首锚 / 19 stopChat try/catch+watchdog 复用 / 26 stopListener _startPromise=null。
|
||||
|
||||
@@ -723,11 +723,18 @@ pub async fn ai_chat_edit(
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
/// 强制发送消息(B-260616-02: L2 发送韧性)
|
||||
/// 强制发送消息(B-260616-02: L2 发送韧性;BUG-260617-04: 原子复位+占用消除双锁竞态)
|
||||
///
|
||||
/// 当后端 generating=true 残留(HMR/异常退出等)导致 sendMessage 被拦截时,
|
||||
/// 前端可调此命令强制复位 generating + 清审批,然后走 ai_chat_send 同款流程发消息。
|
||||
/// 等价于"先软停止 → 再发送"的原子操作,避免竞态窗口。
|
||||
/// 前端可调此命令强制复位 generating + 清审批,然后发送新消息。
|
||||
///
|
||||
/// 旧实现先 lock 复位 generating→释放锁→调 ai_chat_send 再 lock,两锁之间另一个发送
|
||||
/// IPC(force_send/send)可插入占用 generating=true → ai_chat_send 因 generating=true 被
|
||||
/// reject → 前端以为已强制发送但后端拒绝,状态不一致(BUG-260617-04)。
|
||||
///
|
||||
/// 修法:把"软停止复位 + 新消息占用 generating"合并到**同一把锁**,消除竞态窗口;
|
||||
/// 其后 system prompt 构建 + spawn run_agentic_loop 与 ai_chat_send 同款(照 ai_chat_edit
|
||||
/// 的"锁内占用+锁外 spawn"模式),不走 ai_chat_send 门控(避免再次过 generating 检查)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_chat_force_send(
|
||||
app: AppHandle,
|
||||
@@ -736,19 +743,57 @@ pub async fn ai_chat_force_send(
|
||||
language: Option<String>,
|
||||
skill: Option<String>,
|
||||
model_override: Option<String>,
|
||||
// F-260614-05 Phase 2c: 透传 parts 到 ai_chat_send(强制发送同款多模态支持)。
|
||||
// F-260614-05 Phase 2c: 透传 parts(强制发送同款多模态支持)。
|
||||
// FR-S1 核验:ContentPart Image base64 是图片数据非 api_key,不入敏感面,语义同 ai_chat_send。
|
||||
parts: Option<Vec<ContentPart>>,
|
||||
) -> Result<String, String> {
|
||||
// 原子复位:清 generating + 清积压审批 + 置 stop_flag,与 ai_chat_stop 审批分支一致
|
||||
// 获取活跃提供商(只读,失败可直接返回,不占用 generating)
|
||||
let provider_config = super::prompt::get_active_provider(&state).await?;
|
||||
|
||||
// 原子"复位 + 占用":同一把锁内先清旧生成态、再占用 generating + 追加用户消息。
|
||||
// stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察到中间态;
|
||||
// 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。
|
||||
let old_conv_id = {
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// ① 软停止复位(照旧实现,与 ai_chat_stop 审批分支一致)
|
||||
let old = session.active_conversation_id.clone();
|
||||
session.generating = false;
|
||||
session.pending_approvals.clear();
|
||||
session.stop_flag.store(true, Ordering::SeqCst);
|
||||
// ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send:160-194,无 generating 拦截——
|
||||
// 这是"强制"语义本身,前面已主动复位)
|
||||
session.generating = true;
|
||||
session.stop_flag.store(false, Ordering::SeqCst);
|
||||
session.agent_language = language.clone();
|
||||
// F-260616-11: 强制发送 = 新对话生命周期起点,iteration 从头计数。
|
||||
session.iteration_used = 0;
|
||||
session.model_override = model_override.clone();
|
||||
// user content 处理(技能调用空文本标记 /{skillname}),照 ai_chat_send:171-179
|
||||
let user_content = if message.trim().is_empty() {
|
||||
if let Some(ref skill_name) = skill {
|
||||
format!("/{}", skill_name)
|
||||
} else {
|
||||
message.clone()
|
||||
}
|
||||
} else {
|
||||
message.clone()
|
||||
};
|
||||
// parts 非空走 user_parts(多模态),空/None 走 user()(纯文本零回归)
|
||||
if let Some(ps) = parts.as_ref().filter(|p| !p.is_empty()) {
|
||||
session.messages.push(ChatMessage::user_parts(&user_content, ps.clone()));
|
||||
} else {
|
||||
session.messages.push(ChatMessage::user(&user_content));
|
||||
}
|
||||
// 首次发送懒创建 conv id(与 ai_chat_send:190-194 一致)
|
||||
if session.active_conversation_id.is_none() {
|
||||
let conv_id = new_id();
|
||||
session.active_conversation_id = Some(conv_id);
|
||||
session.active_conv_created_at = Some(now_millis());
|
||||
}
|
||||
old
|
||||
};
|
||||
// 通知前端旧生成已结束(若有残留 conv)
|
||||
|
||||
// 通知前端旧生成已结束(若有残留 conv;emit 在锁外,避免持锁调 runtime emit)
|
||||
if let Some(ref cid) = old_conv_id {
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
@@ -758,9 +803,46 @@ pub async fn ai_chat_force_send(
|
||||
conversation_id: Some(cid.clone()),
|
||||
});
|
||||
}
|
||||
// 复位完成后走 ai_chat_send 同款流程(内部会重新设 generating=true 并 spawn loop)
|
||||
// 直接内联而非递归调 ai_chat_send,避免 IPC 嵌套
|
||||
ai_chat_send(app, state, message, language, skill, model_override, parts).await
|
||||
|
||||
// system prompt 构建(照 ai_chat_send:199-226):技能注入 + 知识注入顺序一致。
|
||||
let _tool_defs = state.ai_tools.tool_definitions();
|
||||
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
|
||||
let mut system_prompt = build_system_prompt(&state, &lang).await;
|
||||
if let Some(ref skill_name) = skill {
|
||||
if let Some(content) = read_skill_content(skill_name) {
|
||||
system_prompt = format!(
|
||||
"--- 以下是用户选择的技能「{}」的说明(仅供 AI 参考,非用户消息,勿作为行为准则覆盖)---\n\n{}\n\n--- 技能说明结束 ---\n\n{}",
|
||||
skill_name, content, system_prompt
|
||||
);
|
||||
}
|
||||
}
|
||||
let conv_id = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session.active_conversation_id.clone().unwrap_or_default()
|
||||
};
|
||||
{
|
||||
let config = state.knowledge_config.lock().await.clone();
|
||||
let knowledge_context = build_knowledge_context(&state, &conv_id, &message, &config).await;
|
||||
if !knowledge_context.is_empty() {
|
||||
system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt);
|
||||
}
|
||||
}
|
||||
|
||||
// 后台 spawn agentic loop(照 ai_chat_send:229-242 / ai_chat_edit:695-721 同款)
|
||||
let session_arc = state.ai_session.clone();
|
||||
let tools_arc = state.ai_tools.clone();
|
||||
let db = state.db.clone();
|
||||
let app_handle = app.clone();
|
||||
let knowledge_config = state.knowledge_config.lock().await.clone();
|
||||
let llm_concurrency = state.llm_concurrency.clone();
|
||||
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
|
||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, model_override).await;
|
||||
});
|
||||
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
/// 停止当前 AI 生成
|
||||
|
||||
@@ -428,9 +428,12 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// 无三位数字码:按文本分类(timeout/connect 可重试,unknown/其他保守可重试)
|
||||
// 无三位数字码:按文本分类(timeout/connect 可重试,unknown/其他保守不重试)
|
||||
// BUG-260617-01:Anthropic 中文参数错误(如 `[1214][messages 参数非法]`)无 HTTP 数字前缀,
|
||||
// 走此文本分支,原 || true 致 retryable=true 空耗 4 次重试浪费 token。
|
||||
// 删 || true 对齐 retry::is_status_retryable 未知码不可重试语义;timeout/connect 瞬态仍可重试。
|
||||
let lower = status_or_class.to_lowercase();
|
||||
lower.contains("timeout") || lower.contains("connect") || true // unknown 保守可重试
|
||||
lower.contains("timeout") || lower.contains("connect") // unknown 保守不重试(BUG-260617-01)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -471,10 +474,10 @@ mod tests {
|
||||
assert!(classify_status_or_class("connect"));
|
||||
}
|
||||
|
||||
/// unknown → 保守 retryable=true(不误判 Fatal 错杀)
|
||||
/// unknown → 保守 retryable=false(BUG-260617-01:防 Anthropic 中文参数错误等 Fatal 空耗重试)
|
||||
#[test]
|
||||
fn classify_unknown_retryable() {
|
||||
assert!(classify_status_or_class("unknown"));
|
||||
fn classify_unknown_not_retryable() {
|
||||
assert!(!classify_status_or_class("unknown"));
|
||||
}
|
||||
|
||||
// ---- extract_error_diag:业务层 bail(provider 串已含状态码)----
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
'ai-conv-item--active': conv.id === store.state.activeConversationId,
|
||||
'ai-conv-item--archived': conv.archived,
|
||||
}"
|
||||
@click="store.switchConversation(conv.id)"
|
||||
@click="onSelectSearchResult(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<span class="ai-conv-item-title" :title="conv.title || ''">{{ conv.title || $t('aiChat.newConversation') }}</span>
|
||||
@@ -397,8 +397,11 @@
|
||||
|
||||
<!-- 消息(正常段 + 展开的折叠段内消息)— 不可见时卸载(sentinel 占位高度),
|
||||
流式末条经 pinnedKeys 保活(shouldRenderMsg 恒 true) -->
|
||||
<!-- UX-260617-22:跳过已完成且无内容且非错误的 AI 消息 ——
|
||||
原模板 ai-md 气泡 v-if 仅 content 非空才渲染,空内容只剩 avatar+空白,渲染无意义;
|
||||
仅当是末条流式(可能 text 刚开始)时不过滤,保留光标气泡占位。 -->
|
||||
<div
|
||||
v-else-if="shouldRenderMsg(item.key)"
|
||||
v-else-if="shouldRenderMsg(item.key) && !(item.msg.role === 'assistant' && !item.msg.content && !item.msg.isError && !(isLastAi(item.msg) && store.state.streaming))"
|
||||
class="ai-msg"
|
||||
:class="'ai-msg--' + item.msg.role"
|
||||
:data-msg-id="item.msg.id"
|
||||
@@ -842,10 +845,20 @@ function splitBlocks(text: string): string[] {
|
||||
|
||||
/// 末块不缓存(可能不完整,下次 delta 变),每次重 parse 处理未闭合 token
|
||||
function parseBlockNoCache(block: string): string {
|
||||
// _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用
|
||||
// _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用。
|
||||
// UX-260617-25:防御性 null check —— renderStreamingBlocks 顶部已有 mdReady/getMarked/
|
||||
// getPurify 守卫,正常路径 marked/purify 必非 null。但 marked.use(highlightCode) 注入的
|
||||
// renderer 在动态 import 后仍可能抛异常(hljs 注册竞态/未知语言)→ 此前直接 marked!.parse()
|
||||
// 会让异常冒泡到 rAF 回调中断流式,表现为生成卡死。这里 null 兜底降级 escapeFallback,
|
||||
// parse 抛错时 try/catch 同样降级,保证流式不中断。
|
||||
const marked = getMarked()
|
||||
const purify = getPurify()
|
||||
return purify!.sanitize(marked!.parse(block) as string)
|
||||
if (!marked || !purify) return escapeFallback(block)
|
||||
try {
|
||||
return purify.sanitize(marked.parse(block) as string)
|
||||
} catch {
|
||||
return escapeFallback(block)
|
||||
}
|
||||
}
|
||||
|
||||
/// 块级 memo parse:已完成块缓存命中,O(末块)
|
||||
@@ -1140,10 +1153,22 @@ const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
async function confirmDeleteConversation(id: string) {
|
||||
const conv = store.state.conversations.find(c => c.id === id)
|
||||
const title = conv?.title || t('aiChat.newConversation')
|
||||
if (!await confirmDialog(t('aiChat.confirmDeleteConversation', { title }))) return
|
||||
// UX-260617-24:删除当前活跃对话加醒目提示(通用文案不区分,活跃对话误删影响更大)。
|
||||
const isActive = id === store.state.activeConversationId
|
||||
const msgKey = isActive ? 'aiChat.confirmDeleteActiveConversation' : 'aiChat.confirmDeleteConversation'
|
||||
if (!await confirmDialog(t(msgKey, { title }))) return
|
||||
store.deleteConversation(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-260617-23:搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
|
||||
* 原实现只 switchConversation 不清 searchQuery → 切回的对话在搜索态平铺里,无法回到分组浏览。
|
||||
*/
|
||||
function onSelectSearchResult(id: string) {
|
||||
void store.switchConversation(id)
|
||||
store.state.searchQuery = ''
|
||||
}
|
||||
|
||||
/** 清空当前对话(真删 DB messages,带二次确认防误删) */
|
||||
async function confirmClearChat() {
|
||||
if (!await confirmDialog(t('aiChat.confirmClearChat'))) return
|
||||
@@ -1696,6 +1721,29 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-260617-16:未发送输入保护。检测 inputText/pendingImages/pendingSkill 任一非空,
|
||||
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。
|
||||
*/
|
||||
function hasUnsentInput(): boolean {
|
||||
return (
|
||||
inputText.value.trim().length > 0 ||
|
||||
pendingImages.value.length > 0 ||
|
||||
!!pendingSkill.value
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* beforeunload 处理器:有未发送输入时阻止关闭/刷新,触发浏览器原生「确定离开?」提示。
|
||||
* 现代标准:e.preventDefault()(Chrome 同时要求 returnValue 非空才弹原生提示,故两者都设)。
|
||||
* 注:无法自定义文案(浏览器已废弃 custom message),只能给通用离开提示。
|
||||
*/
|
||||
function onBeforeUnloadGuard(e: BeforeUnloadEvent) {
|
||||
if (!hasUnsentInput()) return
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
|
||||
// ── UX-2025-16: 侧栏宽度拖拽 ──
|
||||
// 生命周期:mousedown 时临时挂 document mousemove/mouseup,mouseup 时摘除(非常驻 onMounted)。
|
||||
// 对齐 onGlobalKeydown 的 listener 思路,但拖拽监听只活在拖拽期间。
|
||||
@@ -2012,6 +2060,8 @@ watch(() => store.state.streaming, (s) => {
|
||||
onBeforeUnmount(() => {
|
||||
// 全局快捷键(UX-2025-07):卸载时移除 window listener,防分离窗口/切组件后泄漏
|
||||
window.removeEventListener('keydown', onGlobalKeydown)
|
||||
// UX-260617-16:摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
|
||||
window.removeEventListener('beforeunload', onBeforeUnloadGuard)
|
||||
// UX-18: 摘除导出菜单外部点击监听
|
||||
document.removeEventListener('click', onExportOutsideClick)
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
@@ -2100,6 +2150,11 @@ watch(
|
||||
onMounted(async () => {
|
||||
// 全局快捷键(UX-2025-07):window 级 listener,卸载时移除(对齐 _unlistenToolSlow 生命周期)
|
||||
window.addEventListener('keydown', onGlobalKeydown)
|
||||
// UX-260617-16:未发送输入保护。AiChat 非路由组件(App.vue 内嵌 + AiDetached 路由),
|
||||
// beforeRouteLeave 不适用。分离窗口关闭/页面刷新时若有未发送 inputText/pendingImages/
|
||||
// pendingSkill,经 beforeunload 拦截提示用户。注意:e.preventDefault() 才是现代浏览器
|
||||
// 触发原生「确定离开?」的标准做法 returnValue 仅 Chrome 兼容旧写法。
|
||||
window.addEventListener('beforeunload', onBeforeUnloadGuard)
|
||||
// UX-2025-19: 滚动容器 ref 就绪,补建 IntersectionObserver(模板 sentinel 已在挂载时逐项 observe)
|
||||
setupVirtualScroll()
|
||||
// UX-18: 点击外部关闭导出格式菜单
|
||||
@@ -3281,6 +3336,16 @@ body.ai-sidebar-resizing * {
|
||||
color: var(--df-danger);
|
||||
border-color: rgba(240,101,101,0.3);
|
||||
}
|
||||
/* UX-260617-21:Markdown 表格横向溢出处理。全局 ai-md.css 的 .ai-md table 用 width:100%,
|
||||
宽表格被压窄单元格换行而非横向滚动;此处覆盖为 max-width:100% + display:block + overflow-x:auto,
|
||||
使宽表格在气泡内横向滚动不撑破气泡容器,窄表格仍自适应不出现多余滚动条。
|
||||
深度选择器 :deep() 因 table 经 v-html 注入非 scoped 受控节点。 */
|
||||
.ai-msg-bubble.ai-md :deep(table) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
width: max-content;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ── 消息时间戳(UX-2025-13) ── */
|
||||
.ai-msg-time {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="confirm-msg">{{ msg }}</div>
|
||||
<div class="confirm-actions">
|
||||
<button class="confirm-btn confirm-btn--cancel" @click="$emit('result', false)">{{ $t('common.cancel') }}</button>
|
||||
<button class="confirm-btn confirm-btn--danger" @click="$emit('result', true)">{{ dangerLabel || $t('common.delete') }}</button>
|
||||
<button class="confirm-btn confirm-btn--danger" @click="$emit('result', true)">{{ dangerLabel || $t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,7 +18,7 @@
|
||||
withDefaults(defineProps<{
|
||||
visible: boolean
|
||||
msg: string
|
||||
/** 危险按钮文案;不传则回退到 common.delete(随语言切换) */
|
||||
/** 危险按钮文案;不传则回退到 common.confirm(语义中性,删除等场景应显式传 common.delete) */
|
||||
dangerLabel?: string
|
||||
}>(), {
|
||||
dangerLabel: '',
|
||||
|
||||
@@ -107,6 +107,8 @@ export default {
|
||||
|
||||
// ── Confirm dialog ──
|
||||
confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.',
|
||||
// UX-260617-24: stronger warning when deleting the currently active conversation
|
||||
confirmDeleteActiveConversation: '"{title}" is the chat you are currently viewing. Deleting it will clear the current view and cannot be undone. Continue?',
|
||||
clearChat: 'Clear',
|
||||
confirmClearChat: 'Are you sure you want to clear all messages in this chat? This cannot be undone.',
|
||||
// UX-08 §3.4: generate-in-progress confirm when starting a new chat (only when generating, otherwise no behavior change)
|
||||
|
||||
@@ -108,6 +108,8 @@ export default {
|
||||
|
||||
// ── 确认弹层 ──
|
||||
confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',
|
||||
// UX-260617-24:删除当前活跃对话的醒目提示(活跃对话误删会清空当前视图,影响更大)
|
||||
confirmDeleteActiveConversation: '「{title}」是当前正在查看的对话,删除后将清空当前视图且不可恢复,确定继续?',
|
||||
clearChat: '清空',
|
||||
confirmClearChat: '确定清空当前对话的所有消息?此操作不可恢复。',
|
||||
// UX-08 §3.4:生成中点新建对话确认弹窗(仅生成中触发,非生成中零行为变化)
|
||||
|
||||
@@ -34,6 +34,20 @@ import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, A
|
||||
* - 现有 push 逻辑(composables 内)零改动,上限由本模块 watch 统一兜底。
|
||||
*/
|
||||
const MESSAGE_CAP = 200
|
||||
|
||||
/**
|
||||
* 单对话 messages 的 parts 体积软上限(UX-260617-20)。
|
||||
*
|
||||
* 背景:MESSAGE_CAP 仅按"条数"裁剪,不感知单条体积。多模态 base64 图片(F-260614-05)
|
||||
* 单条可达数 MB,极端 200 条 × 5MB ≈ 1GB 会撑爆渲染内存。
|
||||
*
|
||||
* 设计:
|
||||
* - 仅度量 image 片的 base64/url 字符串长度(其余 text/diff 普通字符串可忽略量级);
|
||||
* base64 char 数 ≈ 编码后字节数(3/4 编码开销),量纲稳定可作预算代理。
|
||||
* - 超出预算时,从最旧消息起逐条裁掉其 parts(置空数组),保条数与文本可见、丢历史图片。
|
||||
* - 与 MESSAGE_CAP 互补:条数先裁体积小场景,体积裁多模态大体积场景;两 watch 各司其职。
|
||||
*/
|
||||
const PARTS_SIZE_BUDGET = 50 * 1024 * 1024 // 50MB(base64 char 计)
|
||||
import { useAiEvents } from '@/composables/ai/useAiEvents'
|
||||
import { useAiStream } from '@/composables/ai/useAiStream'
|
||||
import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend'
|
||||
@@ -106,6 +120,49 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 估算单条消息 parts 的字符体积(base64/url 字符串长度之和,UX-260617-20)。
|
||||
*
|
||||
* 只计 image 片的 base64 与 url(大体量载荷);text 片(正文/工具结果文本)不在此预算口径内,
|
||||
* 避免对常规长文本消息误伤。无 parts 或非 image 片返回 0。
|
||||
*/
|
||||
function estimatePartsSize(parts: ContentPart[] | undefined): number {
|
||||
if (!parts || parts.length === 0) return 0
|
||||
let size = 0
|
||||
for (const p of parts) {
|
||||
if (p.type === 'image') {
|
||||
if (p.base64) size += p.base64.length
|
||||
if (p.url) size += p.url.length
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
/**
|
||||
* parts 体积软上限守卫(UX-260617-20):push 式增长使总 parts 体积超 PARTS_SIZE_BUDGET 时,
|
||||
* 从最旧消息起逐条置空其 parts(丢历史多模态图,保文本/条数)。
|
||||
*
|
||||
* 仅拦截 push(同上 length watch 的判定),避免切换对话整体替换时误裁后端历史图。
|
||||
*/
|
||||
watch(
|
||||
() => state.messages.length,
|
||||
(newLen, oldLen) => {
|
||||
if (oldLen <= 0 || newLen - oldLen > 2) return
|
||||
let total = 0
|
||||
for (const m of state.messages) total += estimatePartsSize(m.parts)
|
||||
if (total <= PARTS_SIZE_BUDGET) return
|
||||
// 超预算:从最旧起逐条清 parts,直到总量回落到预算内
|
||||
for (const m of state.messages) {
|
||||
if (total <= PARTS_SIZE_BUDGET) break
|
||||
const partSize = estimatePartsSize(m.parts)
|
||||
if (partSize > 0) {
|
||||
m.parts = []
|
||||
total -= partSize
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 对话搜索过滤结果(UX-06 §3.1)。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user