新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理

后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
lxy
2026-06-21 20:51:26 +08:00
parent 330bb7f505
commit bd6a41fe6e
111 changed files with 11931 additions and 1033 deletions
+37 -24
View File
@@ -19,9 +19,10 @@ import { invoke } from '@tauri-apps/api/core'
import { ref } from 'vue'
import { aiApi } from '@/api'
import { state } from '@/stores/ai'
import type { ContentPart, AiMessage } from '@/api/types'
import type { ContentPart, AiMessage, MentionSpan } from '@/api/types'
import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { startListener } from './useAiEvents'
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang } from './aiShared'
@@ -64,8 +65,12 @@ const modelOverride = ref<string | null>(null)
* 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 doSend 仅把 parts 写入本地 push 的
* user 消息让用户气泡可见图,后端请求仍走 content 文本路径(无图)。
* Phase 2c 后端接 parts 后,本函数透传给 aiApi.sendMessage/forceSend 即可全链路生效。
*
* Input Augmentation: spans 透传给后端 ai_chat_send 的 mention_spans 参数(后端 resolve
* 投影成 Augmentation 注入)+ 写入本地 push 的 user 消息(MessageList 据此渲染 chip)。
* undefined/空数组 → 本地 user 消息 mentionSpans 置 undefined(纯文本气泡零回归)+ 后端不传。
*/
async function doSend(text: string, skill?: string, force = false, parts?: ContentPart[]) {
async function doSend(text: string, skill?: string, force = false, parts?: ContentPart[], spans?: MentionSpan[]) {
const userMsgId = `user-${nextMsgId()}`
state.messages.push({
id: userMsgId,
@@ -73,6 +78,8 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
content: text.trim(),
// 仅在非空时挂 parts(纯文本消息保持 undefined,渲染走原 content 路径零回归)
parts: parts && parts.length > 0 ? parts : undefined,
// Input Augmentation: 仅在非空时挂 mentionSpans(供 MessageList chip 渲染;空则 undefined 零回归)
mentionSpans: spans && spans.length > 0 ? spans : undefined,
timestamp: Date.now(),
})
@@ -84,7 +91,7 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
timestamp: Date.now(),
})
state.streaming = true
setStreaming(true, { convId: state.activeConversationId, reason: 'doSend' })
state.currentText = ''
resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底
@@ -97,14 +104,15 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
if (force) {
// F-05 Phase 2c: 透传 parts(图片)给后端 ai_chat_force_send,多模态全链生效
// F-260616-09 B 批4(决策 e):传 activeConversationId,操作指定 conv 的 per_conv。
await aiApi.forceSend(text.trim(), lang, skill, override, parts, state.activeConversationId)
// Input Augmentation: 透传 mentionSpans(spans 非空时后端 resolve_and_inject 注入)。
await aiApi.forceSend(text.trim(), lang, skill, override, parts, state.activeConversationId, spans)
} else {
await aiApi.sendMessage(text.trim(), lang, skill, override, parts, state.activeConversationId)
await aiApi.sendMessage(text.trim(), lang, skill, override, parts, state.activeConversationId, spans)
}
} catch (e) {
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
// 重新抛出由 handleSend 回填输入框,用户可重试
state.streaming = false
setStreaming(false, { convId: state.activeConversationId, reason: 'doSend-ipc-fail' })
clearStreamWatchdog() // 清看门狗,防 130s 后 onStreamTimeout 误推错误气泡
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
throw e
@@ -139,7 +147,7 @@ async function regenerate() {
timestamp: Date.now(),
})
state.streaming = true
setStreaming(true, { convId: state.activeConversationId, reason: 'regenerate' })
state.currentText = ''
state.queue = [] // 重生成视同新发,清残留队列
resetStreamWatchdog()
@@ -149,7 +157,7 @@ async function regenerate() {
const convId = state.activeConversationId
if (!convId) {
// 无活跃对话:回滚占位,报错
state.streaming = false
setStreaming(false, { reason: 'regenerate-no-conv' })
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw new Error(t('aiChat.regenerateUnavailable'))
@@ -158,7 +166,7 @@ async function regenerate() {
await aiApi.regenerate(convId, lang, modelOverride.value)
} catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(旧回复后端已弹但前端先弹了,保持弹后态)
state.streaming = false
setStreaming(false, { convId, reason: 'regenerate-ipc-fail' })
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw e
@@ -209,7 +217,7 @@ async function editMessage(newMessage: string) {
timestamp: Date.now(),
})
state.streaming = true
setStreaming(true, { convId: state.activeConversationId, reason: 'editMessage' })
state.currentText = ''
state.queue = [] // 编辑重生成视同新发,清残留队列
resetStreamWatchdog()
@@ -218,7 +226,7 @@ async function editMessage(newMessage: string) {
const lang = resolveAiLang()
const convId = state.activeConversationId
if (!convId) {
state.streaming = false
setStreaming(false, { reason: 'editMessage-no-conv' })
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw new Error(t('aiChat.editNotLastUser'))
@@ -227,7 +235,7 @@ async function editMessage(newMessage: string) {
await aiApi.editMessage(convId, newMessage.trim(), lang, modelOverride.value)
} catch (e) {
// IPC 失败:回滚 streaming + 移除占位气泡(被截断的旧回复后端已标 truncated,前端已移除)
state.streaming = false
setStreaming(false, { convId, reason: 'editMessage-ipc-fail' })
clearStreamWatchdog()
state.messages = state.messages.filter(m => m.id !== aiMsgId)
throw e
@@ -250,7 +258,7 @@ export function drainQueue() {
try {
await sendMessage(next.text, next.skill, false, next.parts)
} catch (e) {
state.streaming = false
setStreaming(false, { convId: state.activeConversationId, reason: 'drainQueue-fail' })
clearStreamWatchdog()
const errMsg = e instanceof Error ? e.message : String(e)
state.queue.unshift(next) // 回填失败消息到队首,保留剩余队列(不静默丢用户输入)
@@ -275,13 +283,18 @@ export function drainQueue() {
* 用户确认后重新进 sendMessage(forceMode=true)走 forceSend IPC
*
* forceMode=true 时跳过入队,直接走 ai_chat_force_send。
*
* Input Augmentation: spans 随消息透传(L0 直接 doSend / L2 force doSend);
* L1 入队时 queue item 当前不挂 spans(队列续发属异步重试,mention 区间在首次发送时
* 已与文本对齐,排队等待后用户可能改输入,续发用旧 spans 语义模糊;续发走无 mention 路径,
* 与现有 parts 入队续发的设计取舍一致——队列为韧性保内容,非保全部上下文元数据)。
*/
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[]) {
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[], spans?: MentionSpan[]) {
if (!text.trim() && !(parts && parts.length)) return
// ── L2 强制模式:跳过所有预检,直接 force_send ──
if (forceMode) {
await doSend(text, skill, true, parts)
await doSend(text, skill, true, parts, spans)
return
}
@@ -299,7 +312,7 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
if (state.queue.length >= QUEUE_LIMIT) {
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
}
state.streaming = true
setStreaming(true, { convId: state.activeConversationId, reason: 'L1-enqueue' })
// F-260614-05 Phase 2b: 入队项挂 parts(供 drainQueue 续发时本地 user 消息渲染图)
state.queue.push({
text: text.trim(),
@@ -311,7 +324,7 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
}
// ── L0:normal 直接发送 ──
await doSend(text, skill, false, parts)
await doSend(text, skill, false, parts, spans)
}
/** 排队是否已超时(任一条超即算,因队首阻塞导致后续全堵) */
@@ -349,7 +362,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>)
enqueuedAt: first.enqueuedAt,
parts: first.parts,
})
state.streaming = false
setStreaming(false, { convId: state.activeConversationId, reason: 'tryForceSend-fail' })
clearStreamWatchdog()
console.error('[AI] tryForceSend force_send 失败,消息已回填队首:', e)
return false
@@ -391,7 +404,7 @@ function editQueued(index: number, newText: string) {
* 否则 stopChat→AiCompleted→drainQueue 续发时该项还在队列致重复发。
* 2. 调 stopChat() 打断当前生成(stopChat UX-05 后已不清队列,故 splice 步骤前置必要)。
* 3. 直接 sendMessage(splicedItem.text, skill) 立即发这条(不等 drainQueue 轮到)。
* 当前未完成回复已由 stop 截断保留(agentic.rs:185 save_conversation),无需额外处理。
* 当前未完成回复已由 stop 截断保留(agentic/mod.rs save_conversation),无需额外处理。
*/
async function sendQueuedNow(index: number) {
if (index < 0 || index >= state.queue.length) return
@@ -399,9 +412,9 @@ async function sendQueuedNow(index: number) {
await stopChat()
// B-260617-01: forceMode=true 跳过 L1 预检(ai_is_generating)直走 force_send。
// stopChat() 仅 await stop IPC 发出(置 stop_flag=true),不等后端 loop 跑到
// agentic.rs:444/:819 检测点+guard.reset()(generating 才复位)。紧接 sendMessage
// agentic/mod.rs guard 检测点+guard.reset()(generating 才复位)。紧接 sendMessage
// 命中 L1 的 backendGenerating(后端真值仍 true)→ spliced 被入队而非立即发,
// 与"立即发送"语义不符。force_send(commands.rs:742-748)原子复位 generating=false
// 与"立即发送"语义不符。force_send(commands/chat.rs ai_chat_force_send)原子复位 generating=false
// 再发,无竞态窗口;stopChat 的 stop_flag 让旧 loop 在下个检测点退出,不冲突。
await sendMessage(spliced.text, spliced.skill, true, spliced.parts)
}
@@ -417,11 +430,11 @@ async function sendQueuedNow(index: number) {
* 同时推一条 stopLoopFailed 错误消息让用户知晓停止未生效(可再点或等待收尾)。
*/
async function stopChat() {
state.streaming = false // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死)
setStreaming(false, { convId: state.activeConversationId, reason: 'stopChat' }) // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死)
clearStreamWatchdog() // 清残留看门狗
// UX-260616-05 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
// 后端 agentic.rs:190 emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
// 当前未完成回复已入库(agentic.rs:185 save_conversation)保留为截断回复。
// 后端 agentic/mod.rs emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
// 当前未完成回复已入库(agentic/mod.rs save_conversation)保留为截断回复。
try {
// F-260616-09 B 批4(决策 e):传 activeConversationId,停止仅作用于当前 conv。
await aiApi.stopChat(state.activeConversationId)