新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端: - 工作流推进链(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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user