优化: 清理无意义注释(走查编号前缀/过时历史标注/死代码注释)
This commit is contained in:
@@ -241,7 +241,7 @@ export function setConvState(convId: string | null | undefined, s: ConvState): v
|
||||
|
||||
// ── F-09 B 路线 per-conv 流式态(streaming/currentText) ──
|
||||
//
|
||||
// 背景(DEC-07a 父④):原 `state.streaming`(bool)/`state.currentText`(string)是 store 全局单例,
|
||||
// 背景(a 父④):原 `state.streaming`(bool)/`state.currentText`(string)是 store 全局单例,
|
||||
// F-09 多会话并发下:A 后台生成中切到 B 会话,B 末条 AI 气泡命中 `isLastAi && streaming &&
|
||||
// currentText` 会渲染 A 残留 currentText(BUG-260624-01 同源根因)。本批改 per-conv Map,各会话
|
||||
// 独立累积流式文本/流式开关,切会话即切到该会话的 stream state,真并发不串话。
|
||||
|
||||
@@ -22,7 +22,7 @@ import { findToolCall } from './aiShared'
|
||||
import { dispatchApprovalIPC } from './useToolApproval'
|
||||
|
||||
/**
|
||||
* F-260616-06: 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。
|
||||
* 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。
|
||||
* IPC 往返 <100ms,300ms 窗口足够覆盖竞态。
|
||||
*/
|
||||
const _pendingApprovalIds = new Set<string>()
|
||||
@@ -30,7 +30,7 @@ const _pendingApprovalIds = new Set<string>()
|
||||
/**
|
||||
* 工具审批:保持 pending_approval 让审批卡片可见(按钮 loading 由 ToolCard 本地 ref 持有),
|
||||
* IPC 失败时改 completed+错误文案。后端回事件后由 useAiEvents 转 completed/rejected。
|
||||
* B-260616-08:不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏),
|
||||
* 不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏),
|
||||
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。
|
||||
*
|
||||
* path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC——
|
||||
@@ -46,7 +46,7 @@ const _pendingApprovalIds = new Set<string>()
|
||||
* @param decision path 类专用('once'|'session'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
|
||||
*/
|
||||
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
|
||||
// F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
|
||||
// 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
|
||||
if (_pendingApprovalIds.has(toolCallId)) return
|
||||
_pendingApprovalIds.add(toolCallId)
|
||||
try {
|
||||
@@ -96,7 +96,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
||||
// IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态
|
||||
// TD-260621-03 per-conv:清审批 tc 所属会话的 timer(与上方 reset 同 conv,对称成对)。
|
||||
clearStreamWatchdog(approvalConvId)
|
||||
state.queue = [] // B-32:IPC 失败即对话结束,清队列防生成中入队消息静默丢失
|
||||
state.queue = [] // IPC 失败即对话结束,清队列防生成中入队消息静默丢失
|
||||
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
|
||||
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
|
||||
console.error('[AI] 审批操作未送达后端:', e)
|
||||
|
||||
@@ -45,7 +45,7 @@ async function loadConversations() {
|
||||
await switchConversation(pending)
|
||||
}
|
||||
} catch (e) {
|
||||
// UX-260617-08:网络抖动/IPC 断开时原静默失败,列表可能突空用户不知原因。
|
||||
// 网络抖动/IPC 断开时原静默失败,列表可能突空用户不知原因。
|
||||
// 此处保留旧列表(赋值语句抛出,旧 state.conversations 不被覆盖)+ 推错误气泡反馈。
|
||||
// 仅首次加载(state.conversations 为空)时推气泡,避免拉取/侧栏刷新重试时反复弹错。
|
||||
console.error('[AI] 加载会话列表失败:', e)
|
||||
@@ -126,7 +126,7 @@ async function newConversation() {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配(FR-R1)
|
||||
// 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配
|
||||
let _latestSwitchId = 0
|
||||
// 最近一次切换的切换窗口缓冲(成功提交路径恢复续显用;失败/过期路径丢弃,
|
||||
// 防陈旧文本在后续切换被误恢复造成内容重复)。由 switchConversation 的 finally 统一填充。
|
||||
@@ -169,7 +169,7 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
|
||||
}
|
||||
}
|
||||
return rawMsgs
|
||||
// 过滤 tool 消息 + truncated 软删消息(UX-09:编辑某条 user 后其后消息标 truncated,
|
||||
// 过滤 tool 消息 + truncated 软删消息编辑某条 user 后其后消息标 truncated,
|
||||
// 保留 DB 可追溯但从视图移除;前端无 status 字段故按原始 JSON 字段过滤)
|
||||
.filter((m: any) => m.role !== 'tool' && m.status !== 'truncated')
|
||||
.map((m: any, i: number) => ({
|
||||
@@ -177,7 +177,7 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
model: m.model,
|
||||
// 用后端持久化的真实时间(BUG-260618-01);老数据无 timestamp 字段回退 Date.now()
|
||||
// 用后端持久化的真实时间;老数据无 timestamp 字段回退 Date.now()
|
||||
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
|
||||
// 消息级 token 回显:assistant 消息若 DB 持久化了 prompt_tokens/completion_tokens
|
||||
// (V38 迁移后 push_assistant_message 落库的本轮 token),映射回 tokenUsage 供
|
||||
@@ -350,7 +350,7 @@ export async function switchConversation(id: string, force = false) {
|
||||
state.messages = parseConvMessages(rawMsgs, 'loaded')
|
||||
// parse 成功才置 active(保证 active 与 messages 一致)
|
||||
state.activeConversationId = id
|
||||
// CSW-P1-4: 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位
|
||||
// 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位
|
||||
// 轮末才入),末条是 user → 流式 currentText 无 assistant 承载(MessageList isLastAi 不命中,
|
||||
// flushCurrentText 遇 user break)整轮回复丢失。补前端占位 assistant 气泡承接 currentText,
|
||||
// 生成完成由 AiCompleted/AiAgentRound 收尾 flushCurrentText 回填 content。
|
||||
@@ -410,7 +410,7 @@ export async function switchConversation(id: string, force = false) {
|
||||
// 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批,
|
||||
// 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。
|
||||
// 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。
|
||||
// F2-1(AC-EFF-F2-1):纯文本会话跳过挂起查询(省 1 次 IPC + 后端全扫)——仅当目标会话消息
|
||||
// F2-1纯文本会话跳过挂起查询(省 1 次 IPC + 后端全扫)——仅当目标会话消息
|
||||
// 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。
|
||||
const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0)
|
||||
if (!targetGen && !hasToolCalls) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { AiMessage, AiProviderConfig } from '@/api/types'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
/** 侧栏宽度可拖拽范围(UX-2025-16)。120=窄屏友好,280=宽屏舒适 */
|
||||
/** 侧栏宽度可拖拽范围。120=窄屏友好,280=宽屏舒适 */
|
||||
export const SIDEBAR_WIDTH_MIN = 120
|
||||
export const SIDEBAR_WIDTH_MAX = 280
|
||||
export const SIDEBAR_WIDTH_DEFAULT = 200
|
||||
@@ -44,12 +44,12 @@ function applyUiState(s: any) {
|
||||
if (s.foldedGroups && typeof s.foldedGroups === 'object') {
|
||||
state.foldedGroups = { ...state.foldedGroups, ...s.foldedGroups }
|
||||
}
|
||||
// 侧栏宽度(UX-2025-16):数值字段,clamp 后写入(与上方 boolean 同级兜底)
|
||||
// 侧栏宽度数值字段,clamp 后写入(与上方 boolean 同级兜底)
|
||||
state.sidebarWidth = clampSidebarWidth(s.sidebarWidth)
|
||||
}
|
||||
|
||||
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局 + activeProvider(模块级单例 state)
|
||||
// F-260616-10: activeProvider 恢复对齐 df-ai-active-conv 模式;loadProviders 后还会二次校验
|
||||
// activeProvider 恢复对齐 df-ai-active-conv 模式;loadProviders 后还会二次校验
|
||||
// DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。
|
||||
function restoreUiState() {
|
||||
applyUiState(appSettings.get<any>('df-ai-ui', null))
|
||||
@@ -79,7 +79,7 @@ export function persistUiState() {
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
archivedCollapsed: state.archivedCollapsed,
|
||||
foldedGroups: state.foldedGroups,
|
||||
// 侧栏宽度(UX-2025-16):拖拽 mouseup 时由 AiChat 调 persistUiState 落盘
|
||||
// 侧栏宽度拖拽 mouseup 时由 AiChat 调 persistUiState 落盘
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
})
|
||||
}
|
||||
@@ -141,8 +141,8 @@ function cyclePanelMode() {
|
||||
async function loadProviders() {
|
||||
const list = await aiApi.listProviders()
|
||||
state.providers = list
|
||||
// F-260616-10: 重启后 activeProvider=null,从 DB is_default 恢复(后端链路正确,仅前端缺同步)
|
||||
// CR-34-1: 守卫加有效性检查 — savedProvider 指向已删除 provider 时残留无效 id
|
||||
// 重启后 activeProvider=null,从 DB is_default 恢复(后端链路正确,仅前端缺同步)
|
||||
// 守卫加有效性检查 — savedProvider 指向已删除 provider 时残留无效 id
|
||||
if (!state.activeProvider || !list.some((p: AiProviderConfig) => p.id === state.activeProvider)) {
|
||||
const def = list.find((p: AiProviderConfig) => p.is_default)
|
||||
if (def) state.activeProvider = def.id
|
||||
@@ -162,7 +162,7 @@ async function loadSkills() {
|
||||
async function setProvider(providerId: string) {
|
||||
await aiApi.setProvider(providerId)
|
||||
state.activeProvider = providerId
|
||||
// F-260616-10: 持久化到 appSettings,重启后恢复(对齐 df-ai-active-conv 模式)
|
||||
// 持久化到 appSettings,重启后恢复(对齐 df-ai-active-conv 模式)
|
||||
void appSettings.set('df-ai-active-provider', providerId)
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ export function useAiPanel() {
|
||||
loadSkills,
|
||||
setProvider,
|
||||
clearChat,
|
||||
// UX-2025-16:侧栏宽度拖拽
|
||||
// 侧栏宽度拖拽
|
||||
setSidebarWidth,
|
||||
persistUiState,
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTime
|
||||
/// 待发送队列上限(超过抛错提示用户)
|
||||
const QUEUE_LIMIT = 10
|
||||
|
||||
/// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等(B-260616-02)
|
||||
/// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等
|
||||
const QUEUE_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
@@ -134,7 +134,7 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成最后一条 AI 回复(UX-02)。
|
||||
* 重新生成最后一条 AI 回复。
|
||||
*
|
||||
* 与 doSend 的区别:
|
||||
* - 不 push 新 user 消息(触发它的 user 消息已在历史末尾)
|
||||
@@ -190,7 +190,7 @@ async function regenerate() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑最后一条 user 消息并重新生成(UX-09)。
|
||||
* 编辑最后一条 user 消息并重新生成。
|
||||
*
|
||||
* 与 regenerate 的区别:
|
||||
* - regenerate:末条须是 assistant,pop 旧 AI 回复后重跑
|
||||
@@ -497,7 +497,7 @@ async function sendQueuedNow(index: number) {
|
||||
/** 停止当前生成:本地先复位 streaming,再发停止信号。
|
||||
* 不依赖后端 AiCompleted 收尾——审批态看门狗已 clear(useAiEvents),若 AiCompleted 竞态丢失则 streaming 永久 true 卡死,故本地兜底。
|
||||
*
|
||||
* UX-260617-19: stop IPC 失败时的兜底。本地已先置 streaming=false,但若 stop 信号未送达后端,
|
||||
* stop IPC 失败时的兜底。本地已先置 streaming=false,但若 stop 信号未送达后端,
|
||||
* 后端会继续生成并发回 delta/Completed 事件,与用户已感知的「已停止」语义冲突;更坏情况是
|
||||
* 后端 hang 既不收尾也不发事件(stop_flag 未置位、无 watchdog 兜底则无恢复路径)。
|
||||
* 故 stop 失败时不 clearStreamWatchdog,反而重启看门狗:后端若继续生成,活跃 delta 会不断重置它
|
||||
@@ -551,7 +551,7 @@ export function useAiSend() {
|
||||
}
|
||||
}
|
||||
|
||||
// ARC-06: 模块级监听 ai-drain-queue 事件(由 useAiEvents AiCompleted case 发射)
|
||||
// 模块级监听 ai-drain-queue 事件(由 useAiEvents AiCompleted case 发射)
|
||||
// 打破 useAiEvents ↔ useAiSend 循环依赖:events 不再直接 import 调用 drainQueue
|
||||
let _drainUnlisten: (() => void) | null = null
|
||||
export async function initDrainQueueListener(): Promise<void> {
|
||||
|
||||
@@ -53,7 +53,7 @@ async function detachPanel(convId?: string) {
|
||||
return
|
||||
}
|
||||
// 快照当前生成态,供分离窗口接管(保持正在进行的对话)
|
||||
// F-09:用 per-conv key 写入(多会话各自快照互不覆盖)
|
||||
// 用 per-conv key 写入(多会话各自快照互不覆盖)
|
||||
// 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。
|
||||
// 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
|
||||
if (state.streaming && targetConv) {
|
||||
@@ -67,7 +67,7 @@ async function detachPanel(convId?: string) {
|
||||
// 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context,
|
||||
// stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。
|
||||
// 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。
|
||||
// F-09:仅从 convStates 移除该 conv(其他会话可能仍在生成),不全局清空。
|
||||
// 仅从 convStates 移除该 conv(其他会话可能仍在生成),不全局清空。
|
||||
// watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清,
|
||||
// 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。
|
||||
setStreaming(false, { convId: targetConv || null, reason: 'detachPanel-clear-main' })
|
||||
@@ -77,7 +77,7 @@ async function detachPanel(convId?: string) {
|
||||
const win = new WebviewWindow(label, {
|
||||
url,
|
||||
title: 'DevFlow AI',
|
||||
width: 600, // U-260618: 加宽(原 520 偏窄),与吸附态 AI_DOCK_WIDTH 一致
|
||||
width: 600, // 加宽(原 520 偏窄),与吸附态 AI_DOCK_WIDTH 一致
|
||||
height: 700,
|
||||
minWidth: 360,
|
||||
minHeight: 400,
|
||||
@@ -109,7 +109,7 @@ async function detachPanel(convId?: string) {
|
||||
}
|
||||
|
||||
/** 关闭分离窗口并回到内嵌面板(主窗口侧调用)。
|
||||
* F-09:多会话并发下可能存在多个 detached 窗口,全部关闭并清各自 per-conv 快照。 */
|
||||
* 多会话并发下可能存在多个 detached 窗口,全部关闭并清各自 per-conv 快照。 */
|
||||
async function reattachPanel() {
|
||||
stopFollowMain()
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
@@ -128,7 +128,7 @@ async function reattachPanel() {
|
||||
// 时序加固:reattach 关闭分离窗口后,显式停分离窗口残留 listener(分离窗口 close 异步,
|
||||
// onBeforeUnmount stopListener 可能延迟),防主面板重新挂载 startListener 时与旧 listener 重叠竞态。
|
||||
stopListener()
|
||||
// F-09:清所有 per-conv 快照(分离窗口已全关,快照无消费方)
|
||||
// 清所有 per-conv 快照(分离窗口已全关,快照无消费方)
|
||||
cleanupAllDetachedSnapshots()
|
||||
}
|
||||
|
||||
@@ -230,12 +230,12 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean; c
|
||||
async function resumeInDetached(convId: string | null) {
|
||||
// R5: 不自动 switchConversation —— 分离窗口可看历史列表,但不主动覆盖主窗口 activeConversationId
|
||||
// (避免主窗口期间发消息落错会话)。若需切换会话,由用户在分离窗口显式操作触发。
|
||||
// F-09: 透传 convId 给 restoreGeneratingState,定位正确的 per-conv 快照 key。
|
||||
// 透传 convId 给 restoreGeneratingState,定位正确的 per-conv 快照 key。
|
||||
await restoreGeneratingState({ convId: convId || undefined })
|
||||
}
|
||||
|
||||
/** 关闭分离窗口自身(分离窗口侧调用)。
|
||||
* F-09:从当前 webview 的 label 反推 convId,清自己的 per-conv 快照。 */
|
||||
* 从当前 webview 的 label 反推 convId,清自己的 per-conv 快照。 */
|
||||
async function closeDetachedWindow() {
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
@@ -275,7 +275,7 @@ async function dockDetached() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 吸附态 AI 窗口宽度/间隙常量(U-260618) */
|
||||
/** 吸附态 AI 窗口宽度/间隙常量*/
|
||||
const AI_DOCK_WIDTH = 600
|
||||
const AI_DOCK_GAP = 4
|
||||
// 防 syncToMain 改主窗口 size 触发 onResized 递归回调
|
||||
|
||||
@@ -26,9 +26,9 @@ export interface StreamBlock {
|
||||
}
|
||||
|
||||
export interface UseStreamRendererOptions {
|
||||
/** UX-2025-01:保存当前文字选区(v-html 替换前调) */
|
||||
/** 保存当前文字选区(v-html 替换前调) */
|
||||
saveSelection: () => void
|
||||
/** UX-2025-01:恢复之前保存的选区(DOM 重建后调) */
|
||||
/** 恢复之前保存的选区(DOM 重建后调) */
|
||||
restoreSelection: () => void
|
||||
/** 跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */
|
||||
isFollowingBottom: Ref<boolean> | ComputedRef<boolean>
|
||||
@@ -59,7 +59,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
const _blockCache = new Map<string, string>()
|
||||
const BLOCK_CACHE_LIMIT = 300
|
||||
// 流式渲染状态:rAF 节流(多 delta 合并一帧,60fps 封顶防主线程阻塞掉帧)
|
||||
// 分块渲染:已完成块 DOM 稳定不重建 → 选文字保持(UX-2025-01)
|
||||
// 分块渲染:已完成块 DOM 稳定不重建 → 选文字保持
|
||||
const streamingBlocks = ref<StreamBlock[]>([])
|
||||
let rafId: number | null = null
|
||||
let lastStreamText = ''
|
||||
@@ -82,7 +82,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
/// (跨双换行不切),非代码段按双换行切段。
|
||||
/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
|
||||
///
|
||||
/// CR-260615-04:旧实现用正则 /```[^\n]*\n[\s\S]*?(?:```|$)/ 切块,与 marked 围栏规则
|
||||
/// 旧实现用正则 /```[^\n]*\n[\s\S]*?(?:```|$)/ 切块,与 marked 围栏规则
|
||||
/// 不一致——不要求行首、固定 3 反引号,导致行中裸三反引号 / 4+ 反引号嵌套围栏切错,
|
||||
/// 前块缓存固化错误 html。改用 marked.lexer()(经 getMarked() 取 marked 实例)做围栏
|
||||
/// 感知切块:lexer 把代码围栏识别为单个 type==='code' token(.raw 含整段围栏),
|
||||
@@ -113,7 +113,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
/// 末块不缓存(可能不完整,下次 delta 变),每次重 parse 处理未闭合 token
|
||||
function parseBlockNoCache(block: string): string {
|
||||
// _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用。
|
||||
// UX-260617-25:防御性 null check —— renderStreamingBlocks 顶部已有 mdReady/getMarked/
|
||||
// 防御性 null check —— renderStreamingBlocks 顶部已有 mdReady/getMarked/
|
||||
// getPurify 守卫,正常路径 marked/purify 必非 null。但 marked.use(highlightCode) 注入的
|
||||
// renderer 在动态 import 后仍可能抛异常(hljs 注册竞态/未知语言)→ 此前直接 marked!.parse()
|
||||
// 会让异常冒泡到 rAF 回调中断流式,表现为生成卡死。这里 null 兜底降级 escapeFallback,
|
||||
@@ -187,7 +187,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
// 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted
|
||||
// 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。
|
||||
const degrade = isTail && isUnclosedCodeFence(b)
|
||||
// AC-EFF-S1-1:单块回复病态(整回复无 \n\n/围栏边界,末块=全文)超阈值 → 降级纯文本,
|
||||
// 单块回复病态(整回复无 \n\n/围栏边界,末块=全文)超阈值 → 降级纯文本,
|
||||
// 防每帧 marked.lexer+parse O(n) 卡顿(流式期间临时纯文本,AiCompleted 后完整 markdown)。
|
||||
const longSingleBlock = isTail && n === 1 && b.length > SINGLE_BLOCK_TEXT_LIMIT
|
||||
const html = (degrade || longSingleBlock)
|
||||
@@ -226,7 +226,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
if (rafId !== null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
// UX-2025-01: 末块 innerHTML 每帧替换,保存选区
|
||||
// 末块 innerHTML 每帧替换,保存选区
|
||||
opts.saveSelection()
|
||||
streamingBlocks.value = renderStreamingBlocks(lastStreamText)
|
||||
nextTick(() => {
|
||||
@@ -245,7 +245,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
|
||||
streamingBlocks.value = []
|
||||
lastStreamText = ''
|
||||
// AC-EFF-S1-1:清增量基线(防新轮/新会话误命中旧帧已完成块)。
|
||||
// 清增量基线(防新轮/新会话误命中旧帧已完成块)。
|
||||
_prevStreamText = ''
|
||||
_prevCompleted = []
|
||||
_prevTail = ''
|
||||
@@ -259,7 +259,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
|
||||
/// 清空实例级块缓存(内容寻址 Map + LRU)。会话切换/开关变化时调用,
|
||||
/// 防旧会话已缓存块被新会话命中(跨会话内容错乱)。仅清缓存,不动 streamingBlocks。
|
||||
/// AC-EFF-S1-1:同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。
|
||||
/// 同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。
|
||||
function resetStreamCache(): void {
|
||||
_blockCache.clear()
|
||||
_prevStreamText = ''
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* ToolCard 审批状态机 — 从 ToolCard.vue 抽离的审批 UI 逻辑。
|
||||
*
|
||||
* 职责:审批按钮 loading 态、审批挂起时长显示(BUG-260624-02)、
|
||||
* 审批超时兜底 toast(UX-260617-14)、approve/reject/path_auth 三选项处理、
|
||||
* 职责:审批按钮 loading 态、审批挂起时长显示、
|
||||
* 审批超时兜底 toast、approve/reject/path_auth 三选项处理、
|
||||
* status 变化自动复位 + pending 计时。
|
||||
*
|
||||
* 不含 store IPC 调用(useAiApproval 负责)和模板渲染——纯状态管理。
|
||||
@@ -14,14 +14,14 @@ import { aiApi } from '@/api'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
|
||||
/**
|
||||
* 审批按钮 loading 超时兜底(B-260616-08)。
|
||||
* 审批按钮 loading 超时兜底。
|
||||
* 后端回事件使 tc.status 离开 pending_approval → watch 复位。
|
||||
* 兜底计时到点复位 + toast 提示(后端 IPC 应在 1~3s 内返回;30s 兜底远足超时后允许用户重试)。
|
||||
* 与 stream 看门狗(130s)解耦:审批是按钮级交互,不是后台流,不需要对齐后端 120s 空闲超时。
|
||||
*/
|
||||
const APPROVE_LOADING_TIMEOUT_MS = 30_000
|
||||
|
||||
/** AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
|
||||
/** High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
|
||||
const HIGH_RISK_TOOLS = new Set<string>([
|
||||
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
|
||||
'http_request',
|
||||
@@ -64,11 +64,11 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
|
||||
const { t } = useI18n()
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
// ── B-260616-08: 审批 loading 态 ──
|
||||
// ── 审批 loading 态 ──
|
||||
const approving = ref(false)
|
||||
let approvingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── BUG-260624-02: 审批挂起时长显示 ──
|
||||
// ── 审批挂起时长显示 ──
|
||||
const pendingSince = ref<number | null>(null)
|
||||
const waitSecs = ref(0)
|
||||
let waitTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -103,7 +103,7 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
|
||||
return 'normal'
|
||||
})
|
||||
|
||||
// ── UX-260617-14: 审批超时兜底 toast ──
|
||||
// ── 审批超时兜底 toast ──
|
||||
const approveToast = reactive({ visible: false, msg: '' })
|
||||
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -114,7 +114,7 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
|
||||
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
|
||||
}
|
||||
|
||||
/** AE-2025-05:High 风险工具的二次确认文案(按操作类型) */
|
||||
/** High 风险工具的二次确认文案(按操作类型) */
|
||||
function highRiskConfirmMsg(name: string): string {
|
||||
if (name === 'run_command') return t('aiTool.confirmHighExec')
|
||||
if (name === 'http_request') return t('aiTool.confirmHighHttp')
|
||||
@@ -134,7 +134,7 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
|
||||
// 先锁防重入(含二次确认 await 期间)
|
||||
approving.value = true
|
||||
try {
|
||||
// AE-2025-05:High 风险 → 先二次确认
|
||||
// High 风险 → 先二次确认
|
||||
if (HIGH_RISK_TOOLS.has(tc.value.name)) {
|
||||
const ok = await confirmDialog(highRiskConfirmMsg(tc.value.name))
|
||||
if (!ok) {
|
||||
|
||||
@@ -26,7 +26,7 @@ import type { AiToolCallInfo } from '@/api/types'
|
||||
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
||||
export interface ToolResult {
|
||||
path?: string
|
||||
// A1-B5: 只读工具(git status/diff/log)失败路径后端返 {success:false,error} 合法 JSON 对象(非 Err),
|
||||
// 只读工具(git status/diff/log)失败路径后端返 {success:false,error} 合法 JSON 对象(非 Err),
|
||||
// 前端 isToolFailure 靠此字段判失败(防绿框假成功)
|
||||
success?: boolean
|
||||
error?: string
|
||||
@@ -89,9 +89,9 @@ export interface ToolResult {
|
||||
body?: string
|
||||
body_bytes?: number
|
||||
elapsed_ms?: number
|
||||
// read_file search 模式(BUG-260625-02):传 search 参数返命中行 matches[{line,content}] 非全文 content
|
||||
// read_file search 模式传 search 参数返命中行 matches[{line,content}] 非全文 content
|
||||
search?: string
|
||||
// grep 跨文件内容搜索结果字段(F-260621):
|
||||
// grep 跨文件内容搜索结果字段
|
||||
// - output_mode: content(默认)/ files_with_matches / count
|
||||
// - matches: content 模式命中行 [{file,line,content,context?}]
|
||||
// - files: files_with_matches 模式命中文件名数组
|
||||
@@ -131,9 +131,9 @@ export function shouldKeepOpen(tc: AiToolCallInfo): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-260616-01: 工具执行失败检测(completed 状态下的失败语义)。
|
||||
* 工具执行失败检测(completed 状态下的失败语义)。
|
||||
*
|
||||
* 后端 AR-6:Low 风险工具失败不 emit AiError,改 emit AiToolCallCompleted(result=错误),
|
||||
* 后端 Low 风险工具失败不 emit AiError,改 emit AiToolCallCompleted(result=错误),
|
||||
* 故失败工具 status 仍为 'completed',与成功工具视觉同态(原绿色边框+绿色结果框)。
|
||||
* 此函数区分三类失败:
|
||||
* - run_command exit_code≠0:结果 JSON 合法,parseResult 命中,看 exit_code 判失败
|
||||
@@ -164,7 +164,7 @@ export function isToolFailure(tc: AiToolCallInfo): boolean {
|
||||
if (!r) {
|
||||
const raw = typeof tc.result === 'string' ? tc.result : ''
|
||||
if (!raw) return false
|
||||
// UX-260617-18:收窄为行首锚定(原 /执行失败|failed|error[:\s]/i 匹配任意位置的子串,
|
||||
// 收窄为行首锚定(原 /执行失败|failed|error[:\s]/i 匹配任意位置的子串,
|
||||
// 误伤 search_files 命中 error.log / run_command stderr 含 "warning" 等正常产出)。
|
||||
// 仅当某行以「执行失败」(中文 err_msg 前缀)/「Error:」/「Failed:」开头才判失败。
|
||||
return /^(执行失败|Error:|Failed:)/m.test(raw)
|
||||
@@ -295,7 +295,7 @@ export function formatSizeDiff(diff: number | undefined | null): string {
|
||||
return n >= 0 ? `+${n}` : `${n}`
|
||||
}
|
||||
|
||||
/** SW-260618-19: 合并 stdout/stderr(空串跳过),供 cmdOutput 展开 + combineAndTruncateLines 截断共享 DRY */
|
||||
/** 合并 stdout/stderr(空串跳过),供 cmdOutput 展开 + combineAndTruncateLines 截断共享 DRY */
|
||||
export function combineOutputs(stdout: string | undefined, stderr: string | undefined): string {
|
||||
const parts: string[] = []
|
||||
if (stdout) parts.push(stdout)
|
||||
@@ -346,7 +346,7 @@ export function formatFileInfoResult(r: ToolResult): string {
|
||||
}
|
||||
let base = t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) })
|
||||
const extras: string[] = []
|
||||
// UX-260618-12: file_info modified 时间(epoch_ms → 客户端 toLocaleString)
|
||||
// file_info modified 时间(epoch_ms → 客户端 toLocaleString)
|
||||
if (typeof r.modified === 'number' && r.modified > 0) {
|
||||
extras.push(t('aiTool.fileInfoModified', { time: new Date(r.modified).toLocaleString() }))
|
||||
}
|
||||
@@ -365,17 +365,17 @@ export function formatFileInfoResult(r: ToolResult): string {
|
||||
|
||||
/**
|
||||
* 通用工具结果格式化(非文件类工具)。
|
||||
* UX-260616-04:统一 switch 覆盖全部工具产出人类可读摘要,不再走裸 JSON 兜底。
|
||||
* 统一 switch 覆盖全部工具产出人类可读摘要,不再走裸 JSON 兜底。
|
||||
* read_file/list_directory/write_file 有模板特化分支,不进此函数。
|
||||
*
|
||||
* P0-3: switch 17 case 拆为策略表 TOOL_FORMATTERS(name → 纯函数),formatToolResult 查表分发。
|
||||
* switch 17 case 拆为策略表 TOOL_FORMATTERS(name → 纯函数),formatToolResult 查表分发。
|
||||
* 新增工具只在此表加一行,零改 formatToolResult 主体。逻辑与原 switch 等价(零行为变更)。
|
||||
*/
|
||||
|
||||
// list_* 系列(list_projects/list_tasks/list_ideas/list_trash)共享格式化:
|
||||
// 渲染结构化列表替代裸 JSON,头行按 name 选 i18n key,条目取 name/title/id + status/stack 标注。
|
||||
function formatListResult(r: ToolResult, name: string): string {
|
||||
// UX-260618-12: list_* 后端返 {items,total,has_more} 对象(非数组)
|
||||
// list_* 后端返 {items,total,has_more} 对象(非数组)
|
||||
const items = Array.isArray(r) ? r : (Array.isArray(r.items) ? r.items : [])
|
||||
const total = typeof r.total === 'number' ? r.total : items.length
|
||||
const head = name === 'list_projects' ? t('aiTool.projectCount', { n: total })
|
||||
@@ -398,8 +398,8 @@ function formatListResult(r: ToolResult, name: string): string {
|
||||
|
||||
/** advance_task 共享格式化(body + summary 一致): review_rounds>0 显退回累加轮数,否则首推原文案 */
|
||||
function formatAdvanceTask(r: ToolResult): string {
|
||||
// UX-260618-09: review_rounds>0 显退回累加轮数,=0 回退原文案(首推无噪音)
|
||||
// UX-260618-14: 后端返 TaskRecord(含 title),优先显 title,无则回退 id(防裸 UUID)
|
||||
// review_rounds>0 显退回累加轮数,=0 回退原文案(首推无噪音)
|
||||
// 后端返 TaskRecord(含 title),优先显 title,无则回退 id(防裸 UUID)
|
||||
return typeof r.review_rounds === 'number' && r.review_rounds > 0
|
||||
? t('aiTool.advancedTaskWithRounds', { title: r.title || r.id || '', status: r.status || '', n: r.review_rounds })
|
||||
: t('aiTool.advancedTask', { title: r.title || r.id || '', status: r.status || '' })
|
||||
@@ -407,7 +407,7 @@ function formatAdvanceTask(r: ToolResult): string {
|
||||
|
||||
/** update_task 共享格式化: updated!==false 显字段更新,否则通用 updated */
|
||||
function formatUpdateTask(r: ToolResult): string {
|
||||
// UX-260619-04: title 优先(后端补返 title),无则回退 id(防裸 UUID),对齐 advance_task。
|
||||
// title 优先(后端补返 title),无则回退 id(防裸 UUID),对齐 advance_task。
|
||||
return r.updated !== false
|
||||
? t('aiTool.updatedTaskField', { title: r.title || r.id || '', field: r.field || '' })
|
||||
: t('aiTool.updated')
|
||||
@@ -444,12 +444,12 @@ function formatSearchFiles(r: ToolResult): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* grep 摘要/命中计数标签(F-260621):按 output_mode 取合适文案,三分支:
|
||||
* grep 摘要/命中计数标签按 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 三分支逐行等价,
|
||||
* 抽公共单一函数 原 ToolResultBody.vue grepHitLabel 与此处 formatGrep 三分支逐行等价,
|
||||
* 现 export 此函数,ToolResultBody.vue 复用,消除文案漂移风险。i18n 走 @/i18n/i18n-helpers 类型安全 t。
|
||||
*/
|
||||
export function formatGrep(r: ToolResult): string {
|
||||
@@ -489,7 +489,7 @@ function formatBindDir(r: ToolResult): string {
|
||||
|
||||
/** run_workflow 格式化(body 版):无 execution_id 走 formatJson,否则显完整三要素(task_id/target_status/exec) */
|
||||
function formatRunWorkflow(r: ToolResult): string {
|
||||
// UX-260618-05: 后端返 {task_id,target_status,execution_id,status,note},显 execution_id 对应执行实例
|
||||
// 后端返 {task_id,target_status,execution_id,status,note},显 execution_id 对应执行实例
|
||||
const execId = typeof r.execution_id === 'string' && r.execution_id
|
||||
? r.execution_id
|
||||
: (typeof r.id === 'string' ? r.id : '')
|
||||
@@ -613,10 +613,10 @@ export function formatFetchUrl(r: ToolResult): string {
|
||||
|
||||
/**
|
||||
* completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃)。
|
||||
* UX-260616-04:覆盖范围与 formatToolResult 对齐(header+body 一致);
|
||||
* 覆盖范围与 formatToolResult 对齐(header+body 一致);
|
||||
* read_file/list_directory/write_file 有模板特化分支,header 摘要仍显示关键计数。
|
||||
*
|
||||
* P0-3: switch 拆为策略表 TOOL_SUMMARIES(name → 纯函数),toolResultSummary 查表分发。
|
||||
* switch 拆为策略表 TOOL_SUMMARIES(name → 纯函数),toolResultSummary 查表分发。
|
||||
* 与 TOOL_FORMATTERS 不共用:summary 版部分 case 输出更短(create/update_project/restore/purge 仅 summary 有;
|
||||
* rename/bind 的 false 分支返空串而非 formatJson;run_workflow 用 workflowTriggeredShort;list_* 多 has_more 判断差异)。
|
||||
* 共享语义的纯函数(formatAdvanceTask/formatUpdateTask/formatDelete/formatDeleteFile/formatPatchFile/
|
||||
@@ -669,7 +669,7 @@ function summaryBindDir(r: ToolResult): string {
|
||||
|
||||
/** run_workflow summary 版:无 execution_id 返空串,有则显 workflowTriggeredShort(body 版显完整三要素) */
|
||||
function summaryRunWorkflow(r: ToolResult): string {
|
||||
// UX-260618-05: header 副标题显 execution_id 概要(原 workflowHint「请到工作流页面运行」误导——工作流已触发)
|
||||
// header 副标题显 execution_id 概要(原 workflowHint「请到工作流页面运行」误导——工作流已触发)
|
||||
const execId = typeof r.execution_id === 'string' && r.execution_id
|
||||
? r.execution_id
|
||||
: (typeof r.id === 'string' ? r.id : '')
|
||||
|
||||
@@ -122,7 +122,7 @@ export function toolDisplayName(tc: AiToolCallInfo): string {
|
||||
*/
|
||||
export function useToolCardHeader(getTc: () => AiToolCallInfo) {
|
||||
/**
|
||||
* 审批参数值展示(AR-3):对项目类工具的 id/project_id 特化——优先回显项目名,查不到则显示
|
||||
* 审批参数值展示对项目类工具的 id/project_id 特化——优先回显项目名,查不到则显示
|
||||
* 「项目已不存在」提示 + 原 id。非项目工具或其他 key 走通用 formatArgValue。
|
||||
*/
|
||||
function displayArgValue(arg: { key: string; val: unknown }): string {
|
||||
@@ -136,7 +136,7 @@ export function useToolCardHeader(getTc: () => AiToolCallInfo) {
|
||||
return tGlobal('aiTool.projectIdNotFound', { id })
|
||||
}
|
||||
}
|
||||
// UX-260618-14:advance_task/run_workflow 的 task_id → 任务标题
|
||||
// advance_task/run_workflow 的 task_id → 任务标题
|
||||
const taskArgKey = TASK_ID_TOOL_ARG[tc.name]
|
||||
if (taskArgKey && arg.key === taskArgKey) {
|
||||
const id = typeof arg.val === 'string' ? arg.val : ''
|
||||
|
||||
@@ -32,7 +32,7 @@ export function useConfirm() {
|
||||
|
||||
function confirmDialog(msg: string, dangerLabel = ''): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
// CR-260615-31:并发覆盖前一个 confirmDialog(连点删除/confirm 交错)时,
|
||||
// 并发覆盖前一个 confirmDialog(连点删除/confirm 交错)时,
|
||||
// 前一个 Promise 永挂(resolve 被覆盖)→ 先 resolve?.(false) 打断前一个视为取消
|
||||
confirmState.value.resolve?.(false)
|
||||
confirmState.value.msg = msg
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! 渲染结果不变。
|
||||
|
||||
import { computed, ref, type ComputedRef } from 'vue'
|
||||
// UX-2025-05: 代码块语法高亮 —— 用 highlight.js/lib/core + 按需 registerLanguage,
|
||||
// 代码块语法高亮 —— 用 highlight.js/lib/core + 按需 registerLanguage,
|
||||
// 只打常用 ~16 门语言进包(避免全量 import ~1MB);core+已注册语言 gzip 后远小于全量。
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
@@ -77,7 +77,7 @@ const mdReady = ref(false)
|
||||
const _mdCache = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* UX-2025-05: marked code renderer 注入 —— 优先按 infostring 指定语言高亮(需已注册),
|
||||
* marked code renderer 注入 —— 优先按 infostring 指定语言高亮(需已注册),
|
||||
* 否则 highlightAuto 在已注册语言里猜测。输出 <pre><code class="hljs language-{lang}">,
|
||||
* 保留 marked 原 code 块外层结构(pre/code),兼容现有 .ai-md pre code 样式与 DOMPurify。
|
||||
*
|
||||
@@ -114,7 +114,7 @@ function highlightCode(code: string, infostring: string): string {
|
||||
async function loadMarkdown() {
|
||||
const [{ marked }, dp] = await Promise.all([import('marked'), import('dompurify')])
|
||||
marked.setOptions({ breaks: true, gfm: true })
|
||||
// UX-2025-05: 注入 code renderer 做语法高亮(marked.use renderer 合并,仅覆盖 code,
|
||||
// 注入 code renderer 做语法高亮(marked.use renderer 合并,仅覆盖 code,
|
||||
// 其余 renderer 沿用 marked 默认)。
|
||||
marked.use({
|
||||
renderer: {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { reactive } from 'vue'
|
||||
import { useTimerOwnership } from './useTimerOwnership'
|
||||
|
||||
// P0-2: 加 'success'(保存成功用绿色 toast,此前成功/中性都用 info 致反馈不明确)
|
||||
// 加 'success'(保存成功用绿色 toast,此前成功/中性都用 info 致反馈不明确)
|
||||
export type ToastType = 'info' | 'error' | 'warning' | 'success'
|
||||
|
||||
export interface ToastState {
|
||||
|
||||
Reference in New Issue
Block a user