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

后端:
- 工作流推进链(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:
2026-06-21 20:51:26 +08:00
parent 330bb7f505
commit bd6a41fe6e
111 changed files with 11932 additions and 1034 deletions

View File

@@ -77,13 +77,26 @@ const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
/**
* 启动审批超时计时器(幂等:同 id 重复启动不重建)。
* 到点回调:调 ai_approve(id, false) 自动拒绝 + push 系统错误消息
* 到点回调按 kind 分派拒绝路径 + push 系统错误消息:
* - kind='risk'(risk 类审批,如 write_file):调 aiApi.approve(id, false) → 后端 ai_approve(kind==Risk)。
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(id, 'deny') → 后端 ai_authorize_dir 续 loop 返 Err。
* 修复回归:path 类若错调 ai_approve,后端 ai_approve 校验 kind==Risk 直接拒,IPC 不进续 loop → 卡死。
* memory: aiShared.startApprovalTimer 回调按 kind 分派(risk→approve / path→authorizeDir('deny')),
* path 类错误复用 risk 的 ai_approve 致后端 kind 校验拒、续 loop 不触发 → 卡死。
*/
export function startApprovalTimer(toolCallId: string, toolName: string): void {
export function startApprovalTimer(
toolCallId: string,
toolName: string,
kind: 'risk' | 'path' = 'risk',
): void {
if (_approvalTimers.has(toolCallId)) return
const timer = setTimeout(() => {
_approvalTimers.delete(toolCallId)
aiApi.approve(toolCallId, false).catch(e => {
// 按 kind 分派拒绝路径,避免 path 类错调 ai_approve 致后端 kind 校验拒而卡死
const denyP = kind === 'path'
? aiApi.authorizeDir(toolCallId, 'deny')
: aiApi.approve(toolCallId, false)
denyP.catch(e => {
console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e)
})
state.messages.push({

View File

@@ -0,0 +1,114 @@
//! streaming 状态写收敛 guard(对齐 memory [[devflow-generating-statemachine]] 前端落地)。
//!
//! 背景:`state.streaming`(当前活跃视图的流式态)原本散布在 8+ 处直接赋值
//! (useAiSend/useAiEvents/useAiConversations/useAiWindow/useAiPanel/useAiStream),
//! 任一新增 return 漏写、或异常路径(前端 JS 错误/窗口崩溃恢复)跳过复位 →
//! streaming 永久 true 卡死输入框(stop 按钮常驻、新消息入队不发)。
//!
//! 本模块把写侧收敛到单一 `setStreaming` 入口,做三件事:
//! 1. 合法性观测:`true→true` / `false→false` 幂等场景记 debug 日志(不拒绝——
//! resetStreamWatchdog 等副作用场景需合法重入),异常模式(如 false 时仍在跑看门狗)
//! 记 warn 日志供排查卡死。
//! 2. 联动 generatingConvs:value=true 且带 convId → add(convId);value=false 且带 convId
//! → delete(convId)。不带 convId 的复位(全局兜底)不动 generatingConvs,因其由
//! AiCompleted/AiError 按 conversation_id 精确管理(避免误删并发会话生成态)。
//! 3. feature flag `df-ai-generating-statemachine`:关时 guard 退化为直接赋值,
//! 不校验不联动,回退原散布语义(灰度/回退开关)。
//!
//! 注:本 guard 是「写收敛 + 可观测 + 联动」三合一,不是拒绝式状态机——
//! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。
import { state } from '@/stores/ai'
import { useAppSettingsStore } from '@/stores/appSettings'
/// feature flag key(appSettings KV)。关=回退散布语义(灰度/回退用)。
const STREAMING_GUARD_FLAG = 'df-ai-generating-statemachine'
const appSettings = useAppSettingsStore()
/// guard 是否启用(读 appSettings,默认开=true)。
///
/// 默认开的理由:本 guard 是收敛性改造(行为等价 + 加可观测),非行为变更,
/// 默认开启直接收效;flag 仅作紧急回退通道(若发现联动 generatingConvs 引入回归,
/// 用户可在设置里关掉回退原散布语义)。
function isGuardEnabled(): boolean {
return appSettings.get<boolean>(STREAMING_GUARD_FLAG, true)
}
/**
* streaming 写收敛入口 —— 替代散布的 `state.streaming = true/false`(原 14 处,现全收敛至此)。
* @param value 目标流式态
* @param opts.convId 关联会话 id。value=true 时 add 进 generatingConvs;
* value=false 时 delete 出 generatingConvs。省略则不动 generatingConvs
* (全局兜底复位场景,generatingConvs 由 AiCompleted/AiError 精确管理)。
* @param opts.reason 调用方标注(日志可观测,如 'AiCompleted'/'stopChat'/'onStreamTimeout')。
*
* 行为:
* - flag 关 → 直接 `state.streaming = value`(回退散布语义,不联动不校验)。
* - flag 开 → 幂等检测(value===当前值记 debug)、联动 generatingConvs、写日志。
*/
export function setStreaming(
value: boolean,
opts: { convId?: string | null; reason?: string } = {},
): void {
const { convId, reason } = opts
// flag 关:回退散布语义,直接赋值不动其他。
if (!isGuardEnabled()) {
state.streaming = value
return
}
const prev = state.streaming
// 幂等观测:同值重复写不拒绝(resetStreamWatchdog 等副作用场景需合法重入),
// 仅记 debug 日志供排查「为何重复置位」的卡死场景。
if (prev === value) {
console.debug(
`[AI-Guard] setStreaming 幂等(prev=${prev}, convId=${convId ?? 'none'}, reason=${reason ?? 'unknown'})`,
)
} else {
console.debug(
`[AI-Guard] setStreaming ${prev}${value} (convId=${convId ?? 'none'}, reason=${reason ?? 'unknown'})`,
)
}
// 联动 generatingConvs:仅在有 convId 时联动。
// value=true:convId 入 Set(标记该会话生成中,侧栏/分离窗口据此显示)。
// value=false:convId 出 Set(该会话收尾)。无 convId 的全局复位不动 Set
// (并发会话生成态由各自 AiCompleted/AiError 精确 delete 管理,全局清会误杀)。
if (convId) {
if (value) {
state.generatingConvs.add(convId)
} else {
state.generatingConvs.delete(convId)
}
}
state.streaming = value
}
/**
* 强制复位 streaming=false 的兜底入口(panic/卡死恢复用)。
*
* 与 setStreaming(false) 区别:本函数额外清 currentText + queue,
* 专用于看门狗超时 / 前端异常恢复等「必须彻底清态」场景。
*
* TD-260621-01 per-conv:看门狗超时携带 convId 时,仅清该 conv 的 generatingConvs
* (经 setStreaming 联动 delete),不再全清误杀并发会话生成态。不带 convId(全局兜底,
* 如 stopListener 卸载场景)时 generatingConvs 不动,由各自会话收尾事件精确管理。
*
* 注:currentText/queue 仍是单例(F-09 域,本批不动),per-conv 超时在多会话并发下
* 会清当前累积的 currentText——但 currentText 仅活跃视图写入(useAiEvents handleEvent delta 累积),
* 而超时仅发生在该 conv 长时间无数据,此场景 currentText 多为空或已 flush,
* 实际误伤面远小于 generatingConvs 全清(队列 queue 的清理由 F-09 统一改,见 TD-260621-01 注释)。
*
* @param reason 兜底来源(日志可观测)
* @param convId 关联会话 id(看门狗 per-conv 超时传;省略=全局兜底)
*/
export function forceResetStreaming(reason: string, convId?: string): void {
console.warn(`[AI-Guard] forceResetStreaming(卡死兜底): ${reason} (convId=${convId ?? 'none'})`)
setStreaming(false, { convId: convId ?? null, reason })
state.currentText = ''
state.queue = []
}

View File

@@ -24,27 +24,56 @@ import { findToolCall } from './aiShared'
*/
const _pendingApprovalIds = new Set<string>()
/** 工具审批:保持 pending_approval 让审批卡片可见(按钮 loading 由 ToolCard 本地 ref 持有),
/**
* 工具审批:保持 pending_approval 让审批卡片可见(按钮 loading 由 ToolCard 本地 ref 持有),
* IPC 失败时改 completed+错误文案。后端回事件后由 useAiEvents 转 completed/rejected。
* B-260616-08:不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏),
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。 */
async function approveToolCall(toolCallId: string, approved: boolean) {
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。
*
* path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC——
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/always/deny。
* once=仅本次会话授权 / always=写持久白名单 / deny=拒绝。
* - kind='risk'(普通 RiskLevel 审批,默认):调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。
* approved=true→放行执行,approved=false→拒绝。
* 入口据 findToolCall(toolCallId).kind 判定,kind 缺省走 risk 老路径(兼容非统一开关 / 老 toolCall)。
* **注意**:决策分派由 tc.kind 决定,与调用方是否传 decision 入参无关——decision 仅 path 类消费,
* 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。
*
* @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。
* @param decision path 类专用('once'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
*/
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'always' | 'deny') {
// F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
if (_pendingApprovalIds.has(toolCallId)) return
_pendingApprovalIds.add(toolCallId)
try {
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
const tc = findToolCall(toolCallId)
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear,
// path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/always/deny);
// 其余(risk/缺省)走 approve(approve/reject)。
const isPathKind = !!tc && tc.kind === 'path'
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired/AiDirAuthRequired 已 clear,
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。
// 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
// 避免新增独立计时器与状态分支)
resetStreamWatchdog()
// TD-260621-03 per-conv:看门狗 timer 用**审批工具调用所属 conv**——tc 来自 findToolCall 反扫
// state.messages(当前活跃会话的单例视图),故 tc 所属 conv 即当前 activeConversationId。
// 切会话时 switchConversation 已整体替换 state.messages+pendingApprovals(单例,非 per-conv Map),
// 旧 conv 的 tc 不再可达,故 activeConversationId 是 tc 所属 conv 的正确等价表达。
// 复用此 convId 供 reset 与 catch 内 clear 对称使用(同会话 pair,不跨会话误清)。
const approvalConvId = state.activeConversationId || undefined
resetStreamWatchdog(approvalConvId)
try {
await aiApi.approve(toolCallId, approved)
if (isPathKind) {
// path 类:decision 缺省兜底 deny(调用方未传时安全侧拒绝,不静默放行)。
await aiApi.authorizeDir(toolCallId, decision ?? 'deny')
} else {
await aiApi.approve(toolCallId, approved)
}
} catch (e) {
// IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态
clearStreamWatchdog()
// TD-260621-03 per-conv:清审批 tc 所属会话的 timer(与上方 reset 同 conv,对称成对)。
clearStreamWatchdog(approvalConvId)
state.queue = [] // B-32:IPC 失败即对话结束,清队列防生成中入队消息静默丢失
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed不进此分支
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
@@ -60,14 +89,29 @@ async function approveToolCall(toolCallId: string, approved: boolean) {
}
}
/** 批量审批:遍历 pendingApprovals 逐个调用 approveToolCall */
/**
* 批量审批:遍历 pendingApprovals 逐个按 kind 分派调用 approveToolCall(决策:批量逐个,
* 非目录聚合——path 类每条独立决策,不合并同目录)。
*
* path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。
* 批量操作语义:
* - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权仅本次,
* 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
*/
async function batchApprove(decision: 'approve' | 'reject') {
const approved = decision === 'approve'
// 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短)
const ids = [...state.pendingApprovals].map(p => p.id)
for (const id of ids) {
const items = [...state.pendingApprovals]
for (const p of items) {
try {
await approveToolCall(id, approved)
if (p.kind === 'path') {
// path 类:approve→once(仅本次,不批量写持久白名单), reject→deny
await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny')
} else {
// risk 类:approve/reject boolean
await approveToolCall(p.id, decision === 'approve')
}
} catch {
// 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态)
// 继续处理剩余项

View File

@@ -9,6 +9,7 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { notifyConversationChanged } from './useAiEvents'
import { persistUiState } from './useAiPanel'
import { setStreaming } from './streamingGuard'
import { nextMsgId } from './aiShared'
import { t } from '@/i18n/i18n-helpers'
import type { AiMessage, AiToolCallInfo } from '@/api/types'
@@ -70,7 +71,7 @@ async function newConversation() {
state.messages = []
state.currentText = ''
state.pendingApprovals = []
state.streaming = false
setStreaming(false, { reason: 'newConversation' })
// F-09 决策e(真并发):newConversation 对齐 switchConversation 并行语义——旧 conv 后台 loop 继续,
// 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按
// conversation_id 正确收尾旧 conv)。原 state.generatingConvs.clear() 是 A 路线 F-260616-09
@@ -96,6 +97,12 @@ export async function switchConversation(id: string) {
if (mySwitchId !== _latestSwitchId) return
state.activeConversationId = id
void appSettings.set('df-ai-active-conv', id)
// P1#6 技术债审查(2026-06-21):streaming 是全局单值,切到非生成会话需按目标 conv 生成态重算,
// 否则残留 stop 按钮(ChatInput.vue:88 v-if=streaming)→ 点击 store.stopChat 传 activeConversationId
// 发错会话。对齐 newConversation 复位语义;目标在后台生成时保留 true(stop/流式显示正确)。
// 根治归 F-09 B 路线:streaming 改 per-conv 态。此处为 A 路线过渡补丁。
// 走 setStreaming guard 收敛写入口(convId=id 联动幂等——has(id) 决定值,add/delete 幂等无副作用)。
setStreaming(state.generatingConvs.has(id), { convId: id, reason: 'switchConversation-recompute' })
try {
const rawMsgs = typeof detail.messages === 'string'
@@ -181,18 +188,41 @@ export async function switchConversation(id: string) {
state.currentText = ''
// 恢复该对话积压的待审批:重启后后端从审计表重建了 pending_approvals,
// 此处查回并把对应 toolCard.status 置 pending_approval,使审批卡片重新可见
// 阶段4:按 IPC 返的 kind 渲染——'path' 类显 once/always/deny(tc.kind='path' + 推 path/dir 文案),
// 'risk' 类显 approve/reject(tc.kind='risk'/缺省)。对齐阶段3b 统一审批模型(两 kind 都可恢复)。
try {
const pending = await aiApi.pendingToolCalls(id)
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的 pending 覆写 B 的 pendingApprovals
if (mySwitchId !== _latestSwitchId) return
if (pending.length) {
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复 tc/pendingApprovals 按类型渲染
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind]))
const pendingIds = new Set(pending.map(p => p.tool_call_id))
const restored: AiToolCallInfo[] = []
for (const m of state.messages) {
for (const tc of (m.toolCalls || [])) {
if (pendingIds.has(tc.id)) {
tc.status = 'pending_approval'
restored.push({ id: tc.id, name: tc.name, args: tc.args, status: 'pending_approval' })
const kind = pendingKindMap.get(tc.id) ?? 'risk'
tc.kind = kind
// path 类审批:从 tc.args.path 推 path/dir 文案(后端 IPC 仅返 kind,无 dir/path;
// 此处从工具参数派生,供 ToolCard 审批提示展示)。缺 path 参数的 path 类回退空。
if (kind === 'path') {
const p = (tc.args as { path?: string } | null)?.path
tc.path = p
tc.dir = p ?? undefined
tc.reason = t('aiChat.dirAuthHint', { tool: tc.name, path: p ?? '' })
}
restored.push({
id: tc.id,
name: tc.name,
args: tc.args,
status: 'pending_approval',
kind,
path: tc.path,
dir: tc.dir,
reason: tc.reason,
})
}
}
}

View File

@@ -26,7 +26,8 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { resetStreamWatchdog, clearStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
@@ -95,14 +96,22 @@ function clearAllToolSlowTimers(): void {
// 独立事件源——AiMaxRoundsReached 与 AiApprovalRequired 不混)。用模块级 ref 导出,
// AiChat.vue 直接 import 读;case 内 push,true 表"有挂起询问"。
// 一次只追踪一个挂起询问(后端 AiSession 单例,达 max 后续轮次前用户必先决定),
// 故用 boolean ref 而非数组,语义更精确。
export const pendingMaxRounds = ref(false)
// 故用单值 ref 而非数组,语义更精确。
//
// TD-260621-02 per-conv:原 ref(false)(boolean)无 convId 维度,F-09 多会话并发下 A 达 max
// 挂起时切到 B 会话,MaxRoundsCard 守卫(仅判 isViewingGenerating)会把 A 的操作卡错显于 B。
// 改 ref<string|null>(存挂起 convId,null=无挂起)。消费方(MaxRoundsCard)守卫改精确比对
// pendingMaxRounds === activeConversationId。set 按事件 conversation_id 赋值;
// clear(AiCompleted/AiError)仅当 pendingMaxRounds===该 convId 才清 null(避免清错会话的挂起)。
export const pendingMaxRounds = ref<string | null>(null)
// F-260619-03 Phase B: 路径授权弹窗挂起态(后端 AiDirAuthRequired 事件置,用户决策后清)。
//
// 同 pendingMaxRounds 模式:模块级 ref(不进 store.state),AiChat.vue 的 DirAuthDialog 组件
// 直接 import 读。一次只追踪一个挂起询问(后端单 tool_call_id 挂起,用户决策后 try_continue),
// 故用对象 ref 而非数组,语义更精确。null = 无挂起
// path_auth 审批链阶段1(解卡死):单 ref → 数组。根因——同轮多个文件工具落不同未授权目录时,
// 后端连发多条 AiDirAuthRequired,单 ref 后到覆盖先到,先到的弹窗丢失→授权无法到达→loop 卡死。
// 改数组 push 多条并存,各 case(AiApprovalResult/AiCompleted/AiError)按 id filter 移除
// 开关 df-ai-multi-pending-auth 控制多挂起并存(true,默认)vs 单 ref 覆盖(false,兜底回退)。
// 模块级 ref(不进 store.state),AiChat.vue 的 DirAuthDialog 组件直接 import 读。
export interface PendingDirAuth {
id: string
tool: string
@@ -110,7 +119,7 @@ export interface PendingDirAuth {
dir: string
conversationId?: string
}
export const pendingDirAuth = ref<PendingDirAuth | null>(null)
export const pendingDirAuths = ref<PendingDirAuth[]>([])
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
export function notifyConversationChanged() {
@@ -146,6 +155,49 @@ function isShowTokenUsage(): boolean {
return appSettings.get<boolean>('df-show-token-usage', false)
}
/** path_auth 多挂起并行开关(appSettings key `df-ai-multi-pending-auth`)。
* path_auth 审批链阶段1:解卡死核心——同轮多个文件工具落不同未授权目录时,后端会连发多条
* AiDirAuthRequired。单 ref 会被后到的事件覆盖,先到的弹窗丢失→授权无法到达→loop 卡死。
* 开(true,默认):pendingDirAuths 数组,push 多条并存,按 id 定位消费。
* 关(false,兜底回退):退化单 ref 语义——后到覆盖先到(push 前清空数组),行为对齐旧版。
* 回退路径:appSettings.set('df-ai-multi-pending-auth', false) 即恢复单 ref 行为,无需改代码。 */
function isMultiPendingAuth(): boolean {
return appSettings.get<boolean>('df-ai-multi-pending-auth', true)
}
/**
* path_auth 审批链阶段3b(统一审批模型)开关(appSettings key `df-ai-unified-approval`)。
*
* 决策(plan 阶段3b):状态层合(单 pendingApprovals + kind 字段)+ 决策层分(path 走 authorizeDir /
* risk 走 approve)。开关控制 path 类挂起是否归一进 pendingApprovals 带 kind='path' 字段,
* 由 ToolCard 内联审批(once/always/deny);关时回退 DirAuthDialog 老链路(独立弹窗)。
*
* 开(true,默认):AiDirAuthRequired case 把 path 挂起转成 pendingApprovals 项(kind='path'),
* 不再 push pendingDirAuths;DirAuthDialog 在 AiChat 中按开关判断不挂载。ToolCard 按 kind
* 显 once/always/deny 三按钮,调 aiApi.authorizeDir。
* 关(false,兜底回退):退回阶段1老链路——AiDirAuthRequired push pendingDirAuths,DirAuthDialog
* 渲染弹窗,与阶段3a 后端合(单 HashMap + kind 字段)兼容(后端语义层已合,前端 UI 不切)。
* 回退路径:appSettings.set('df-ai-unified-approval', false) 即恢复 DirAuthDialog 弹窗,无需改代码。
*/
function isUnifiedApproval(): boolean {
return appSettings.get<boolean>('df-ai-unified-approval', true)
}
/** 向 pendingDirAuths 追加一条(开关控制:多挂起并存 vs 单 ref 覆盖) */
function pushPendingDirAuth(p: PendingDirAuth): void {
if (isMultiPendingAuth()) {
pendingDirAuths.value.push(p)
} else {
// 兜底单 ref 语义:后到覆盖先到(行为对齐旧版,数组只留最新一条)
pendingDirAuths.value = [p]
}
}
/** 按 id 从 pendingDirAuths 移除一条 */
function removePendingDirAuth(id: string): void {
pendingDirAuths.value = pendingDirAuths.value.filter(p => p.id !== id)
}
// ─── 事件域 handler(从原 handleEvent switch 抽出,按域分组)────────────────────────────
//
// 拆分策略:策略 C(抽 case 体为函数)+ 按域分组(streaming/tool/lifecycle)。
@@ -216,32 +268,74 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
case 'AiMaxRoundsReached':
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
// 后端已 save 落库,这里仅翻 pendingMaxRounds=true 驱动 UI 卡片;看门狗已由
// 后端已 save 落库,这里仅翻 pendingMaxRounds 驱动 UI 卡片;看门狗已由
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
pendingMaxRounds.value = true
// TD-260621-02 per-conv:存挂起 convId(非 boolean),消费方按 activeConversationId 精确比对。
// 注:无 convId 时**不**设 null(=无挂起,达 max 操作卡丢失)——保留原值兜底。
// 用 activeConversationId 兜底:达 max 事件理论上必带 convId,无时默认属当前活跃会话,
// 优于清空挂起导致 MaxRoundsCard 守卫误判无挂起(用户达 max 操作卡凭空消失)。
pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value
return true
case 'AiDirAuthRequired': {
// F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。
// pendingDirAuth 驱动 DirAuthDialog 弹窗;看门狗已由 NO_RESET_WATCHDOG 跳过 reset。
// 同时把工具卡置 pending_approval 态(与 AiApprovalRequired 一致,复用 ToolCard 渲染)。
clearStreamWatchdog()
const info: AiToolCallInfo = {
id: event.id,
name: event.tool,
args: { path: event.path },
status: 'pending_approval',
}
state.pendingApprovals.push(info)
const tc = findToolCall(event.id)
if (tc) tc.status = 'pending_approval'
// push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗;看门狗已由 NO_RESET_WATCHDOG 跳过 reset。
// F-260620 根治(错调 ai_approve 致卡死):path_auth 挂起**不**置工具卡 pending_approval——
// 工具卡 pending_approval 态有审批按钮调 ai_approve(为 RiskLevel 审批设计),path_auth 错调
// ai_approve 会被后端拦(path_auth 回滚+Err)→ 前端转圈卡死(authz-debug.log 铁证:同 tool_call_id
// ai_approve+ai_authorize_dir 双调)。path_auth 只走 DirAuthDialog(ai_authorize_dir),
// 工具卡保持 running(等授权结果,授权后 AiToolCallCompleted 转 completed)。
//
// path_auth 审批链阶段3b(统一审批模型):开关 df-ai-unified-approval 开时,path 挂起归一进
// pendingApprovals 带 kind='path',由 ToolCard 内联显 once/always/deny 三按钮(调 authorizeDir),
// 消除独立 DirAuthDialog 弹窗(单真相源:状态层合)。开关关时回退阶段1老链路(push pendingDirAuths,
// DirAuthDialog 渲染)。两路共存,兜底可随时回退。
clearStreamWatchdog(event.conversation_id || undefined)
clearToolSlowTimer(event.id)
pendingDirAuth.value = {
id: event.id,
tool: event.tool,
path: event.path,
dir: event.dir,
conversationId: event.conversation_id,
// 开关开:归一进 pendingApprovals(kind='path'),驱动 ToolCard 内联审批。
// 同 id 幂等:GLM anthropic_compat id 不稳或重发时,已有同 id 不重复 push(防残留重复卡)。
if (isUnifiedApproval()) {
if (!state.pendingApprovals.some(p => p.id === event.id)) {
state.pendingApprovals.push({
id: event.id,
name: event.tool,
args: { path: event.path },
status: 'pending_approval',
kind: 'path',
dir: event.dir,
path: event.path,
// path 类审批提示复用 aiChat.dirAuthHint(已存在 i18n,tool+path 文案);
// 不新增 key 避免 i18n 缺失 prod runtime 报错(memory: i18n-message-compile-blindspot)。
reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }),
})
// 同步改对应工具卡的 tc(让 ToolCard 显 path 类审批按钮 once/always/deny + status-dot pending 色)。
// F-260620 注释"工具卡保持 running"是为防 path_auth 错调 ai_approve 卡死——统一模式后 path 类
// 调 authorizeDir 不再错调,故可安全进 pending_approval(与 risk 类 AiApprovalRequired 同款流转)。
// 开关关(兜底)时仍保持老语义(tc 不动,走 DirAuthDialog)。
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'pending_approval'
tc.kind = 'path'
tc.dir = event.dir
tc.path = event.path
tc.reason = t('aiChat.dirAuthHint', { tool: event.tool, path: event.path })
}
// path 类挂起同样启动审批超时计时器(5min 不处理自动拒),与 risk 类一致。
// 传 kind='path' → 到点回调调 authorizeDir(id,'deny')(非 ai_approve,避免后端 kind==Risk 校验拒卡死)。
startApprovalTimer(event.id, event.tool, 'path')
}
return true
}
// 开关关(兜底回退):push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗。
// pushPendingDirAuth 据开关(df-ai-multi-pending-auth)决定多挂起并存(默认)还是单 ref 覆盖兜底。
if (!pendingDirAuths.value.some(p => p.id === event.id)) {
pushPendingDirAuth({
id: event.id,
tool: event.tool,
path: event.path,
dir: event.dir,
conversationId: event.conversation_id,
})
}
return true
}
@@ -298,12 +392,16 @@ function handleToolEvent(event: AiChatEvent): boolean {
}
case 'AiApprovalRequired': {
clearStreamWatchdog() // 等用户审批,不计超时
clearStreamWatchdog(event.conversation_id || undefined) // 等用户审批,不计超时
// path_auth 审批链阶段3b:event.kind 缺省默认 'risk'(后端阶段3a wire 未加 kind 字段,
// 老后端不传即 risk 类)。event.kind 为 'path' 时理论上 path 挂起也走此事件(后端未来可统一),
// 当前阶段3a 后端 path 挂起仍走独立 AiDirAuthRequired 事件,故此处 kind 恒 'risk'。
const info: AiToolCallInfo = {
id: event.id,
name: event.name,
args: event.args,
status: 'pending_approval',
kind: event.kind ?? 'risk',
}
state.pendingApprovals.push(info)
const tc = findToolCall(event.id)
@@ -316,7 +414,8 @@ function handleToolEvent(event: AiChatEvent): boolean {
// B-260616-12: 进入审批等待→取消该工具慢执行计时器(审批耗时由用户主导,非执行慢)
clearToolSlowTimer(event.id)
// AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝)
startApprovalTimer(event.id, event.name)
// 传 kind='risk' → 到点回调调 aiApi.approve(id,false)(后端 ai_approve kind==Risk 链路)。
startApprovalTimer(event.id, event.name, 'risk')
return true
}
@@ -336,12 +435,16 @@ function handleToolEvent(event: AiChatEvent): boolean {
} else {
// B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器
startToolSlowTimer(event.id, findToolCall(event.id)?.name || '')
// path_auth 审批链:path 类(once/always 通过)与 risk 类共用此分支。
// 补 clearApprovalTimer 防 timer 到期错调:授权通过但超时计时器未清,5min 后仍会触发拒绝回调
// (path 类调 authorizeDir('deny')/risk 类调 ai_approve(false))与已通过的执行态冲突。risk 类拒绝分支(:422)已清,
// 此处通过分支原漏清(回归:统一审批开关开后 path 类挂起也挂 timer,通过分支须对称清)。
clearApprovalTimer(event.id)
}
// F-260619-03 Phase B: 路径授权挂起的工具收到 ApprovalResult(once/always 通过 / deny 拒绝)
// → 清弹窗(后端 ai_authorize_dir 已 try_continue 续 loop)。
if (pendingDirAuth.value && pendingDirAuth.value.id === event.id) {
pendingDirAuth.value = null
}
// F-260619-03 Phase B + path_auth 审批链阶段1:路径授权挂起的工具收到 ApprovalResult
// (once/always 通过 / deny 拒绝)→ 按 id 从 pendingDirAuths 移除该条(后端 ai_authorize_dir
// 已 try_continue 续 loop)。数组化后只清这一条,不影响同轮其他未决策的挂起项。
removePendingDirAuth(event.id)
return true
}
@@ -354,15 +457,22 @@ function handleToolEvent(event: AiChatEvent): boolean {
function handleLifecycleEvent(event: AiChatEvent): boolean {
switch (event.type) {
case 'AiCompleted': {
clearStreamWatchdog()
clearStreamWatchdog(event.conversation_id || undefined)
clearAllToolSlowTimers() // B-260616-12: 整轮结束清全部工具慢执行计时器与已提示集合
flushCurrentText()
state.currentText = ''
state.streaming = false
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiCompleted' })
state.generatingConvs.delete(event.conversation_id || '')
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡
pendingDirAuth.value = null // F-260619-03 Phase B: 收尾清路径授权弹窗(防残留)
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
pendingMaxRounds.value = null
}
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(其他会话的弹窗不连累清空)。
// 批2-A 刚把 pendingMaxRounds per-conv,pendingDirAuths 漏跟;此处补齐——
// F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。
const doneConv2 = event.conversation_id || ''
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === doneConv2)
// UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true),
// 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。
// 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。
@@ -405,13 +515,13 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
}
case 'AiError': {
clearStreamWatchdog()
clearStreamWatchdog(event.conversation_id || undefined)
clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合
clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程
// UX-260619-06: 失败前先把流式累积的 currentText 回填到占位 assistant 气泡,
// 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。
flushCurrentText()
state.streaming = false
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiError' })
state.generatingConvs.delete(event.conversation_id || '')
state.currentText = ''
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
@@ -419,8 +529,13 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
// UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval,
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
state.pendingApprovals = []
pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡
pendingDirAuth.value = null // F-260619-03 Phase B: 异常中断清路径授权弹窗
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
pendingMaxRounds.value = null
}
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(异常中断只清本会话弹窗,不连累并发会话)。
const errConv2 = event.conversation_id || ''
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === errConv2)
// F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key)
const errConv = event.conversation_id || ''
if (errConv) {
@@ -451,6 +566,8 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
/** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */
export function handleEvent(event: AiChatEvent) {
const convId = event.conversation_id
// 注:F-260620 path_auth 审批链根治卡死后,原 [AI-DIRAUTH-DIAG] 高频诊断 console.log 已删除
// (使命完成;热路径污染 devtools)。后续如需事件流诊断用 df-ai-event-trace 开关控制。
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)
// 同步写 appSettings(SQLite):刷新页面后 loadConversations 据此恢复上次会话
if (convId && !state.activeConversationId) {
@@ -472,8 +589,9 @@ export function handleEvent(event: AiChatEvent) {
state.generatingConvs.add(convId)
}
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
// TD-260621-01 per-conv:传 convId 走 per-conv Map(各会话独立计时,互不顶替/连累)。
if (!NO_RESET_WATCHDOG.has(event.type)) {
resetStreamWatchdog()
resetStreamWatchdog(convId || undefined)
}
// 按域分派(streaming → tool → lifecycle);未命中任一域 → 无操作(保留原 switch 无 default 的语义)
if (handleStreamingEvent(event)) return
@@ -526,7 +644,7 @@ function stopListener() {
// _startPromise 可能仍在 pending(未 await 完即 unmount),再 mount 若命中并发去重分支会
// 返回这个已代表「旧注册」的过期 promise,新 listener 实际未注册。
_startPromise = null
clearStreamWatchdog()
clearAllStreamWatchdogs() // TD-260621-01: 卸载清全部 per-conv + legacy fallback timer,防回调改已卸载组件 state
clearAllToolSlowTimers() // B-260616-12: 卸载时释放所有工具慢执行计时器,防回调改 state 触发已卸载组件
clearAllApprovalTimers() // AE-2025-06: 卸载时释放所有审批超时计时器,防回调 push 消息触发已卸载组件
}

View File

@@ -12,6 +12,7 @@ import { watch } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { setStreaming } from './streamingGuard'
import type { AiProviderConfig } from '@/api/types'
const appSettings = useAppSettingsStore()
@@ -132,7 +133,7 @@ async function clearChat() {
state.messages = []
state.currentText = ''
state.pendingApprovals = []
state.streaming = false
setStreaming(false, { reason: 'clearChat' })
}
export function useAiPanel() {

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)

View File

@@ -14,23 +14,43 @@
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { nextMsgId } from './aiShared'
import { forceResetStreaming } from './streamingGuard'
import { emit } from '@tauri-apps/api/event'
/// ≥ 后端 STREAM_IDLE_TIMEOUT(120s, ai.rs:848)+余量;
/// ≥ 后端 STREAM_IDLE_TIMEOUT(120s, stream_recv.rs STREAM_IDLE_TIMEOUT)+余量;
/// 前端若短于后端,慢首 token 会被前端先误杀
export const STREAM_TIMEOUT_MS = 130000
let _streamWatchdog: ReturnType<typeof setTimeout> | null = null
// TD-260621-01 per-conv 看门狗:模块级单计时器 → convId → timer Map。
//
// 背景(F-09 多会话并发):原单 timer 下,A 会话 delta 重置计时器会顶掉 B 会话已挂起的 timer,
// 或 A 超时连累 B 的 generatingConvs(全清)。per-conv Map 后,各会话独立计时,互不顶替/连累。
//
// 向后兼容(避撞 F-09 禁动域):convId 可选。禁动域 useAiSend.ts/useAiApproval.ts 的 14 处调用
// 不传 convId,走 _legacyWatchdog fallback(模块级单 timer,行为对齐旧版);本批域 useAiEvents.ts
// 的调用点(已知 convId)传 convId 走 per-conv Map。两路并存,等 F-09 统一迁移调用点后可删 fallback。
//
// per-conv 路径与 legacy 路径互不干扰:convId 路径只动 Map,legacy 路径只动 _legacyWatchdog。
// 实际并发场景:F-09 已让 useAiEvents 按 conversation_id 路由事件(handleEvent:546 isCurrent 判定),
// 非 current conv 的事件不触发 reset/clear(reset/clear 在 isCurrent 分支内调用),故 per-conv
// 路径天然只跟踪当前展示会话的活跃 timer,跨会话切换时旧 conv 的 timer 由其 AiCompleted/AiError 精确清。
const _watchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
// 禁动域 fallback 单 timer(useAiSend/useAiApproval 无 convId 调用走此,行为对齐旧版)。
let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
/** 看门狗超时回调:收尾 streaming 态并补错误消息 */
export function onStreamTimeout() {
state.streaming = false
// F-09: 多会话并发 — 超时收尾清空全部生成态(整流超时是兜底场景,无法确定具体 conv,
// 保守全清避免幽灵;正常路径由各 conv 的 AiCompleted/AiError 精确 remove)
state.generatingConvs.clear()
state.currentText = ''
state.queue = [] // B-32:超时收尾同步清队列,防生成中入队的消息静默丢失
clearStreamWatchdog()
/**
* 看门狗超时回调:收尾 streaming 态并补错误消息(走 forceResetStreaming 兜底复位,
* 对齐 [[devflow-generating-statemachine]])。
*
* TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 仅清该 conv 的
* generatingConvs(经 setStreaming 联动 delete),不再全清误杀并发会话生成态。convId 为空
* (legacy fallback 路径)时不带 convId,generatingConvs 不动(全局兜底,行为对齐旧版)。
*/
export function onStreamTimeout(convId?: string) {
// 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。
// TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。
forceResetStreaming('onStreamTimeout(130s 无数据)', convId)
clearStreamWatchdog(convId)
// AE-2025-06: 整流超时收尾 → 广播清全部审批超时计时器。
// 本模块不 import useAiSend(会构成 useAiSend↔useAiStream 循环依赖,与现有破环原则冲突),
// 复用 ai-tool-slow-toast 同款事件总线模式:emit 由 useAiEvents.startListener listen 后调
@@ -44,16 +64,21 @@ export function onStreamTimeout() {
// 2) 探测任一 completed 卡片以区分错误文案(工具已执行完后续回复中断 vs 纯流式中断)。
// 反向扫尾部提前退出(同 findToolCall 策略);不复用 useAiEvents.findToolCall:本模块
// 不依赖 useAiEvents(已下沉 nextMsgId 到 aiShared 破环),且此处需全量回滚而非按 id 定位。
// 注:messages 遍历仍是单例(currentText 一样),per-conv 超时只切到当前 messages 视图;
// 多会话并发下其他会话的 messages 不在此 state.messages(切走即整体替换),无跨会话误回滚。
let hasCompletedTool = false
for (let i = state.messages.length - 1; i >= 0; i--) {
const toolCalls = state.messages[i].toolCalls
if (!toolCalls) continue
for (let j = 0; j < toolCalls.length; j++) {
const tc = toolCalls[j]
if (tc.status === 'running') {
// B-33:running 态无后续事件回流,置 rejected 释放骨架屏,恢复重审入口
tc.status = 'rejected'
} else if (tc.status === 'completed') {
// TD-260621-01 修复(用户实测卡死根因):不再把 running→rejected。
// path_auth/risk 挂起的 toolCall 停在 running(path_auth 不置 pending_approval),
// 看门狗超时自动拒绝会误杀用户未操作的审批(用户报"显示用户拒绝了此操作,但根本没操作")。
// 看门狗只做流断兜底(清 streaming/currentText/queue),工具/审批态由各自生命周期
// (AiToolCallCompleted/AiApprovalResult/ai_authorize_dir)管理。running 骨架屏由
// ToolCard 本地 approving 计时器兜底,或 path_auth 授权后 AiToolCallCompleted 转 completed。
if (tc.status === 'completed') {
hasCompletedTool = true
}
}
@@ -70,15 +95,48 @@ export function onStreamTimeout() {
})
}
/** 启动/重置看门狗(活跃事件或 sendMessage 时调用) */
export function resetStreamWatchdog() {
if (_streamWatchdog) clearTimeout(_streamWatchdog)
_streamWatchdog = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
/**
* 启动/重置看门狗(活跃事件或 sendMessage 时调用)。
*
* TD-260621-01 per-conv:传 convId → 走 per-conv Map(arm 前 clear 该 conv 旧 timer,防重入堆积);
* 省略 convId(禁动域 useAiSend/useAiApproval 调用)→ 走 _legacyWatchdog fallback(单 timer,行为对齐旧版)。
* setTimeout 回调闭包捕获 convId,到期 onStreamTimeout(convId) 精确清该 conv 态。
*/
export function resetStreamWatchdog(convId?: string) {
if (convId) {
const old = _watchdogTimers.get(convId)
if (old) clearTimeout(old)
const timer = setTimeout(() => onStreamTimeout(convId), STREAM_TIMEOUT_MS)
_watchdogTimers.set(convId, timer)
} else {
// 禁动域 fallback:行为对齐旧版单 timer。
if (_legacyWatchdog) clearTimeout(_legacyWatchdog)
_legacyWatchdog = setTimeout(() => onStreamTimeout(), STREAM_TIMEOUT_MS)
}
}
/** 清除看门狗(完成/错误/审批等待时调用) */
export function clearStreamWatchdog() {
if (_streamWatchdog) { clearTimeout(_streamWatchdog); _streamWatchdog = null }
/**
* 清除看门狗(完成/错误/审批等待时调用)。
*
* TD-260621-01 per-conv:传 convId → 仅清该 conv 的 timer;省略 → 清 _legacyWatchdog fallback。
* 注:省略 convId 时**不**清 Map(避免禁动域全局 clear 误清并发会话的 per-conv timer)。
* stopListener 卸载场景需清全部,调 clearAllStreamWatchdogs()。
*/
export function clearStreamWatchdog(convId?: string) {
if (convId) {
const timer = _watchdogTimers.get(convId)
if (timer) { clearTimeout(timer); _watchdogTimers.delete(convId) }
} else {
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
}
}
/** 清除全部看门狗 timer(per-conv Map + legacy fallback)—— stopListener 卸载场景调用,
* 防回调写已卸载组件的 state。 */
export function clearAllStreamWatchdogs() {
for (const timer of _watchdogTimers.values()) clearTimeout(timer)
_watchdogTimers.clear()
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
}
export function useAiStream() {
@@ -86,5 +144,6 @@ export function useAiStream() {
onStreamTimeout,
resetStreamWatchdog,
clearStreamWatchdog,
clearAllStreamWatchdogs,
}
}

View File

@@ -11,6 +11,7 @@
import { invoke } from '@tauri-apps/api/core'
import { state } from '@/stores/ai'
import { nextMsgId } from './aiShared'
import { setStreaming } from './streamingGuard'
import { persistUiState } from './useAiPanel'
// ── 主窗口事件跟随 unlistener ──
@@ -61,7 +62,7 @@ async function detachPanel(convId?: string) {
// F-09:仅从 Set 移除该 conv(其他会话可能仍在生成),不全局清空。
// watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清,
// 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。
state.streaming = false
setStreaming(false, { convId: targetConv || null, reason: 'detachPanel-clear-main' })
if (targetConv) state.generatingConvs.delete(targetConv)
const sep = targetConv ? `?conv=${encodeURIComponent(targetConv)}` : ''
const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
@@ -192,7 +193,7 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean; c
content: '',
timestamp: Date.now(),
})
state.streaming = true
setStreaming(true, { convId: gen, reason: 'restoreGeneratingState' })
state.generatingConvs.add(gen)
return true
}

View File

@@ -85,11 +85,25 @@ export interface ToolResult {
body?: string
body_bytes?: number
elapsed_ms?: number
// grep 跨文件内容搜索结果字段(F-260621):
// - output_mode: content(默认)/ files_with_matches / count
// - matches: content 模式命中行 [{file,line,content,context?}]
// - files: files_with_matches 模式命中文件名数组
// - counts: count 模式每文件命中行数 [{file,count}]
// - total: 命中总数(content 模式=命中行数,count 模式=命中行数,files_with_matches 模式=命中文件数)
// - total_files: count 模式命中文件数
output_mode?: string
matches?: Array<{ file?: string; line?: number | null; content?: string; context?: string }>
files?: string[]
counts?: Array<{ file?: string; count?: number }>
total_files?: number
}
/** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */
export const dirIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>'
export const fileIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'
/** grep 工具放大镜图标(跨文件内容搜索,F-260621) */
export const searchIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
/** 默认保持展开:执行中/待审批/已拒绝/write_file——折叠无收益 */
export function shouldKeepOpen(tc: AiToolCallInfo): boolean {
@@ -223,6 +237,7 @@ export function toolCategory(name: string): string {
export function toolIcon(name: string): string {
if (name === 'read_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
if (name === 'write_file') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>'
if (name === 'grep') return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
if (name === 'list_directory') return dirIcon
if (name.includes('create')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
if (name.includes('delete')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
@@ -405,6 +420,31 @@ function formatSearchFiles(r: ToolResult): string {
return t('aiTool.foundN', { n })
}
/**
* grep 摘要/命中计数标签(F-260621):按 output_mode 取合适文案,三分支:
* - content(默认):「命中 N 处」(total = 命中行数,total 缺省回退 matches.length)
* - files_with_matches:「命中 N 文件」(files.length)
* - count:「命中 N 处 / M 文件」(total 行数 + total_files)
*
* 抽公共单一函数 G-1:原 ToolResultBody.vue grepHitLabel 与此处 formatGrep 三分支逐行等价,
* 现 export 此函数,ToolResultBody.vue 复用,消除文案漂移风险。i18n 走 @/i18n/i18n-helpers 类型安全 t。
*/
export function formatGrep(r: ToolResult): string {
const mode = r.output_mode
if (mode === 'files_with_matches') {
const n = Array.isArray(r.files) ? r.files.length : 0
return t('aiTool.grepFilesN', { n })
}
if (mode === 'count') {
const total = typeof r.total === 'number' ? r.total : 0
const files = typeof r.total_files === 'number' ? r.total_files : 0
return t('aiTool.grepCountSummary', { n: total, files })
}
// content(默认)
const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.matches) ? r.matches.length : 0)
return t('aiTool.grepHitsN', { n })
}
/**
* rename_file 格式化(body 版): renamed===false 走 formatJson 兜底(summary 版走空串)。
* 故 body 与 summary 分别登记不同 formatter。
@@ -454,6 +494,7 @@ const TOOL_FORMATTERS: Record<string, (r: ToolResult) => string> = {
bind_directory: formatBindDir,
run_workflow: formatRunWorkflow,
http_request: formatHttpResult,
grep: formatGrep,
}
export function formatToolResult(tc: AiToolCallInfo): string {
@@ -611,6 +652,7 @@ const TOOL_SUMMARIES: Record<string, (r: ToolResult) => string> = {
bind_directory: summaryBindDir,
run_workflow: summaryRunWorkflow,
http_request: summaryHttpRequest,
grep: formatGrep,
}
export function toolResultSummary(tc: AiToolCallInfo): string {

View File

@@ -101,6 +101,12 @@ export function useToolCardHeader(getTc: () => AiToolCallInfo) {
case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback')
case 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback')
case 'write_file': return p ? `${t('aiTool.writePrefix')} ${shortPath(p)}` : t('aiTool.writeFallback')
case 'grep': {
// grep 头部:搜索根 + pattern(折叠态可见搜索什么)
const pat = argString(tc.args, 'pattern')
const head = p ? `${t('aiTool.grepPrefix')} ${shortPath(p)}` : t('aiTool.grepFallback')
return pat ? `${head}${pat}` : head
}
case 'delete_project':
case 'restore_project':
case 'purge_project':