新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端: - 工作流推进链(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:
@@ -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({
|
||||
|
||||
114
src/composables/ai/streamingGuard.ts
Normal file
114
src/composables/ai/streamingGuard.ts
Normal 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 = []
|
||||
}
|
||||
@@ -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 内部回滚该条状态)
|
||||
// 继续处理剩余项
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 消息触发已卸载组件
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user