优化: 上下文管理与提示词链路批
- T1 usage 口径: anthropic usage 两层容错+cache 三口径归一(prompt=input+read+creation) - T2 压缩水位判据: compress_should_trigger 双模式+ratio 钳位,开关关逐字节等价 - T3 跨会话检索: history_search 工具(两跳式)+message_repo 关键词检索+prompt 指引 - T4 代语义闭环: status 终局补写+飞书停靠/换代推卡+交付引导+watcher 兜底补漏 - 段序缓存重排: build_runtime_prompt 恒静段上移/工作上下文压尾(前缀缓存失效面收窄) - 智谱缓存解析: anthropic 臂补 prompt_tokens_details.cached_tokens 嵌套回退 - 工时禁令: 提示词双语加「禁止预估工时,AI Coding 工时无意义」 - 拖拽文件落盘: 非图片附件存引用行,防整段文本灌入输入框 - 事实修正: 提示词「40+ 工具」→「80+」(基线 81);tool_failure_memory 测试串行锁除竞态 - UI 拆分: TopBar/ToolResultBody 壳层化(top-bar/tool-result-body 子件目录)
This commit is contained in:
+6
-62
@@ -45,8 +45,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 审批待办浮层(应用内右上角,窗口聚焦/可见时作快速提示)。
|
||||
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)。
|
||||
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责。 -->
|
||||
仅在主窗口与 ai-detached 窗口挂载(file-explorer 分离窗不挂)。 -->
|
||||
<ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" />
|
||||
|
||||
<!-- 人工审批对话框(全局,任意路由响应 store.pendingApproval)。
|
||||
@@ -73,20 +72,16 @@ import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
|
||||
import ApprovalDialog from './components/project/ApprovalDialog.vue'
|
||||
import ErrorBoundary from './components/ErrorBoundary.vue'
|
||||
import AppLayout from './components/layout/AppLayout.vue'
|
||||
import { startApprovalPopup } from '@/composables/ai/useApprovalPopup'
|
||||
import { startUpdateAutoCheck } from '@/composables/useUpdateCheck'
|
||||
import { aiApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const isDetached = computed(() => route.path === '/ai-detached' || route.path === '/file-explorer-detached' || route.path === '/approval-popup')
|
||||
const isDetached = computed(() => route.path === '/ai-detached' || route.path === '/file-explorer-detached')
|
||||
const aiStore = useAiStore()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer / approval-popup 等其他分离窗口弹)
|
||||
// approval-popup 是系统级浮窗本身,内部已显式渲染 pending 列表,再挂 ApprovalOverlay 会重复
|
||||
const showApprovalOverlay = computed(() =>
|
||||
route.path !== '/file-explorer-detached' && route.path !== '/approval-popup'
|
||||
)
|
||||
// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer 等其他分离窗口弹)
|
||||
const showApprovalOverlay = computed(() => route.path !== '/file-explorer-detached')
|
||||
|
||||
/** 用户点击审批浮层 → 激活 AI 面板/分离窗口。
|
||||
* 主窗口:打开 AI 面板;分离窗口:提升焦点(窗口本身 alwaysOnTop 已置顶)。 */
|
||||
@@ -122,7 +117,6 @@ let _unlistenTrayHideNotice: (() => void) | null = null
|
||||
// ── 人工审批对话框(全局,工作流推进到 HumanNode 审批时,任意页面都应能响应)──
|
||||
// 原 ProjectDetail 局部渲染 → TaskDetail 无 ApprovalDialog 致 store.pendingApproval 被设但
|
||||
// 用户在 TaskDetail 页无法操作 → HumanNode 超时失败。提到 App.vue 全局化:任何路由响应审批。
|
||||
// (系统级浮窗 useApprovalPopup 是窗口失焦时的提示;此处是聚焦时弹的对话框,二者职责互补)
|
||||
const showApprovalDialog = ref(false)
|
||||
watch(() => projectStore.pendingApproval, (newApproval) => {
|
||||
if (newApproval) {
|
||||
@@ -146,7 +140,7 @@ const restoreMaximizedOnReturn = ref(false)
|
||||
|
||||
watch(() => route.path, (path) => {
|
||||
// 分离窗口不参与主面板最大化联动:分离窗是独立 JS context,state 与主窗无关;
|
||||
// 且其 path(/ai-detached、/file-explorer-detached、/approval-popup)不在 AI 域内,
|
||||
// 且其 path(/ai-detached、/file-explorer-detached)不在 AI 域内,
|
||||
// 若不守卫会因分离窗默认 maximized=true 误触发 setMaximized(false) 并落盘,
|
||||
// 覆盖主窗口的 df-ai-ui 布局
|
||||
if (isDetached.value) return
|
||||
@@ -189,28 +183,6 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 系统级审批浮窗(独立窗口):仅主窗口启动(ai-detached/approval-popup 自身不启动,避免重复 watch)
|
||||
let _stopApprovalPopup: (() => void) | null = null
|
||||
let _unlistenPopupReady: (() => void) | null = null
|
||||
async function activateForPopup() {
|
||||
// 浮窗「打开审批面板」按钮 → 聚焦主窗口 + 打开 AI 面板/激活分离窗口
|
||||
// 修复 BUG1:setFocus 先行(已授权,失焦场景有效),show 兜底(需 core:window:allow-show,
|
||||
// capabilities/default.json 已补)。原顺序 show→setFocus,show reject 直接吞掉 setFocus,
|
||||
// 主窗口最小化/隐藏时 show 失败 → 用户看到「点了没反应」。
|
||||
try {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
if (mainWin) {
|
||||
// setFocus 先行(对未聚焦但可见的窗口即可恢复焦点);show 兜底取消最小化/隐藏
|
||||
try { await mainWin.setFocus() } catch { /* setFocus 失败不阻塞 show */ }
|
||||
try { await mainWin.show() } catch { /* show 未授权或不可用,忽略(setFocus 已尽力) */ }
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
if (!aiStore.state.detached && !aiStore.state.panelOpen) {
|
||||
aiStore.togglePanel()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 1. 先把 SQLite 全表 KV 填进 appSettings 缓存(后续 get/useSetting 才能读到真实值)
|
||||
// 包 try/catch 防 loadAll reject 中断 onMounted 后续初始化(对齐下方各步 try/catch 模式)
|
||||
@@ -272,33 +244,9 @@ onMounted(async () => {
|
||||
} catch (e) {
|
||||
console.error('关窗后台运行提示监听失败:', e)
|
||||
}
|
||||
// 系统级审批浮窗:仅主窗口启动(approval-popup 自身路由不启动,避免重复 watch)。
|
||||
// 设计见 useApprovalPopup.ts:主窗口 watch pendingApprovals → 弹/更新/关浮窗(单一真相源)。
|
||||
if (route.path !== '/approval-popup') {
|
||||
_stopApprovalPopup = startApprovalPopup(
|
||||
() => aiStore.state.pendingApprovals,
|
||||
activateForPopup,
|
||||
)
|
||||
// 浮窗 webview 挂载后会 emit 'approval-popup-ready' 请求初始快照(防主窗口在浮窗 listen 前
|
||||
// emit 丢失)。此处 listen 后立即重推当前 pendingApprovals。
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
_unlistenPopupReady = await listen('approval-popup-ready', async () => {
|
||||
const popup = await WebviewWindow.getByLabel('approval-popup')
|
||||
if (popup) {
|
||||
await popup.emit('approval-popup-update', { approvals: aiStore.state.pendingApprovals })
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[ApprovalPopup] ready 监听失败:', e)
|
||||
}
|
||||
}
|
||||
// B2 应用内升级:自动检查调度(启动后 3s 首查 + 周期检查,同版本只提示一次)。
|
||||
// 幂等(模块级单例),仅主窗口启动;auto_check=false 时内部自动跳过。
|
||||
if (route.path !== '/approval-popup') {
|
||||
startUpdateAutoCheck()
|
||||
}
|
||||
startUpdateAutoCheck()
|
||||
})
|
||||
|
||||
/// 一次性迁移:遍历遗留的 localStorage key,若有值则导入 appSettings(SQLite),
|
||||
@@ -339,10 +287,6 @@ const knowledgeStore = useKnowledgeStore()
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
projectStore.stopDataChangedListener?.()
|
||||
_stopApprovalPopup?.()
|
||||
_unlistenPopupReady?.()
|
||||
_stopApprovalPopup = null
|
||||
_unlistenPopupReady = null
|
||||
_unlistenTrayHideNotice?.()
|
||||
_unlistenTrayHideNotice = null
|
||||
if (trayNoticeTimer) clearTimeout(trayNoticeTimer)
|
||||
|
||||
+2
-21
@@ -75,15 +75,6 @@ export const aiApi = {
|
||||
return invoke('ai_approve', { toolCallId, approved })
|
||||
},
|
||||
|
||||
/**
|
||||
* 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
|
||||
* decision: 'once'(本次单次,执行后清)/ 'session'(当前会话,切会话清)/ 'always'(始终,持久化)/ 'deny'(拒绝)。
|
||||
* 后端 remove pending → 写授权目录(once/session/persistent)→ 执行工具 → try_continue 恢复 loop。
|
||||
*/
|
||||
authorizeDir(toolCallId: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<string> {
|
||||
return invoke('ai_authorize_dir', { toolCallId, decision })
|
||||
},
|
||||
|
||||
/**
|
||||
* 续跑 agentic 循环(达 max_iterations 暂停态点继续)。
|
||||
* 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn
|
||||
@@ -107,8 +98,8 @@ export const aiApi = {
|
||||
},
|
||||
|
||||
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用)。
|
||||
* 返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */
|
||||
pendingToolCalls(conversationId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
|
||||
* 返 kind 字段(恒 'risk')与后端超时到期时刻(前端倒计时单一真值)。 */
|
||||
pendingToolCalls(conversationId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path'; expires_at_millis?: number | null }[]> {
|
||||
return invoke('ai_pending_tool_calls', { conversationId })
|
||||
},
|
||||
|
||||
@@ -290,21 +281,11 @@ export const aiApi = {
|
||||
return invoke('ai_reload_skills')
|
||||
},
|
||||
|
||||
/** 获取 AI 工具文件访问授权目录列表(含 workspace_root) */
|
||||
getAllowedDirs(): Promise<string[]> {
|
||||
return invoke<string[]>('ai_get_allowed_dirs')
|
||||
},
|
||||
|
||||
/** 列出全部已注册 AI 工具的名称与风险级(审批高风险名单的权威数据源) */
|
||||
listTools(): Promise<AiToolMeta[]> {
|
||||
return invoke('ai_list_tools')
|
||||
},
|
||||
|
||||
/** 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
|
||||
setAllowedDirs(dirs: string[]): Promise<string[]> {
|
||||
return invoke<string[]>('ai_set_allowed_dirs', { dirs })
|
||||
},
|
||||
|
||||
/** 监听 AI 聊天事件 */
|
||||
onEvent(callback: (event: AiChatEvent) => void): Promise<UnlistenFn> {
|
||||
return listen<AiChatEvent>('ai-chat-event', (e) => {
|
||||
|
||||
+5
-32
@@ -439,14 +439,10 @@ export type AiChatEvent = ({
|
||||
// stream: "stdout"|"stderr"(来源,前端差异化着色);line:累积文本块(多行 '\n' 连接)。
|
||||
type: 'AiCommandOutput'; id: string; stream: string; line: string
|
||||
} | {
|
||||
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分
|
||||
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批,
|
||||
// 走 approve/reject)。后端阶段3a 未在 wire 上给该事件加 kind(老 path 挂起仍走独立
|
||||
// AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk',
|
||||
// path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。
|
||||
// 开关 df-ai-unified-approval:true=归一进 pendingApprovals 带 kind;false=回退 DirAuthDialog 老链路。
|
||||
// 普通 RiskLevel 审批(approve/reject)。原 path_auth 双 kind 设计已随路径授权机制退役(2026-10-25)。
|
||||
// expires_at_millis:后端超时收敛到期时刻(ms Unix;超时收敛单一真值=后端,前端无本地自动拒绝)。
|
||||
type: 'AiApprovalRequired'; id: string; name: string; args: unknown; reason: string; diff?: string
|
||||
kind?: 'path' | 'risk'
|
||||
expires_at_millis?: number | null
|
||||
} | {
|
||||
type: 'AiApprovalResult'; id: string; approved: boolean
|
||||
} | {
|
||||
@@ -491,10 +487,6 @@ export type AiChatEvent = ({
|
||||
} | {
|
||||
// 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止
|
||||
type: 'AiMaxRoundsReached'
|
||||
} | {
|
||||
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
|
||||
// 前端弹窗四选项:"本次"(once 执行后清)/"当前会话"(session 切会话清)/"始终放行"(persistent)/"拒绝" → ai_authorize_dir IPC。
|
||||
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
|
||||
} | {
|
||||
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
// agent 断路器熔断 / 自省时发,结构化求助(区别于 prompt 教 AI 主动问用户——LLM 不可靠)。
|
||||
@@ -749,25 +741,6 @@ export interface AiToolCallInfo {
|
||||
/** write_file 行级 unified diff(旧文件 vs 新内容),审批卡即时预览。
|
||||
* 旧文件不存在(新建)或非 write_file 为 undefined,前端回退显新 content。 */
|
||||
diff?: string
|
||||
/**
|
||||
* path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。
|
||||
* - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/session/always/deny 四选项,
|
||||
* 调 aiApi.authorizeDir(once 写 once 层单次执行后清 / session 写会话临时 / always 写持久白名单 / deny 工具返 Err)。
|
||||
* - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项,
|
||||
* 调 aiApi.approve(一次性)。
|
||||
* undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。
|
||||
* 开关 df-ai-unified-approval 控制 path 类是否归一进 pendingApprovals 带此字段。
|
||||
*/
|
||||
kind?: 'path' | 'risk'
|
||||
/**
|
||||
* path_auth 审批链阶段3b:'path' 类挂起携带的目录信息(规范化后的待授权目录)。
|
||||
* AiDirAuthRequired 归一为 pendingApprovals 项时透传,ToolCard 显提示"LLM 想访问 X 目录"。
|
||||
* 'risk' 类为 undefined。
|
||||
*/
|
||||
dir?: string
|
||||
/** path_auth 审批链阶段3b:'path' 类挂起携带的原始路径(工具调用参数中的 path)。
|
||||
* ToolCard 审批提示文案展示用(LLM 想访问 path,父目录为 dir)。'risk' 类为 undefined。 */
|
||||
path?: string
|
||||
/** 工具开始执行时间(前端在 AiToolCallStarted 时记录,毫秒)。历史消息从 DB 回载无此字段,
|
||||
* 后端事件协议不含耗时,卡片耗时/轨迹总耗时由前端时间差计算,缺字段时隐藏不显 0。 */
|
||||
startedAt?: number
|
||||
@@ -782,11 +755,11 @@ export interface AiToolCallInfo {
|
||||
streamLines?: string[]
|
||||
/**
|
||||
* A2-B10 conv-scoped 审批收尾:挂起审批所属的会话 id(前端判定用,仅挂起项设置)。
|
||||
* 来源:AiApprovalRequired / AiDirAuthRequired(统一审批归一)事件透传 conversation_id
|
||||
* 来源:AiApprovalRequired 事件透传 conversation_id
|
||||
* (缺失时兜底 activeConversationId);switchConversation 恢复历史挂起时填目标会话 id。
|
||||
* cleanupTerminatedConversation 据此仅清目标会话的待审批项 + 对称清其审批超时计时器,
|
||||
* 不连累并发会话正在审批的卡(conv-scoped,误清其他会话审批 = 回归)。
|
||||
* undefined = 非挂起项或无法归属(保守保留,不清)。对齐 PendingDirAuth.conversationId。
|
||||
* undefined = 非挂起项或无法归属(保守保留,不清)。
|
||||
*/
|
||||
conversationId?: string
|
||||
}
|
||||
|
||||
@@ -31,10 +31,6 @@
|
||||
@provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')"
|
||||
/>
|
||||
|
||||
<!-- 「信任所有目录」放行横幅:开关开启期间常驻提示(设置-安全-AI 工具授权目录),
|
||||
提醒当前 AI 可直接访问任意非受保护目录;黑名单目录仍被拒绝 -->
|
||||
<div v-if="pathAuthBypass" class="ai-auth-bypass-banner">{{ $t('aiChat.pathAuthBypassBanner') }}</div>
|
||||
|
||||
<!-- 第四批抽离至 ai/MessageList.vue(消息列表 + 流式 Markdown 分块渲染 + 工具卡转发 +
|
||||
折叠归档段 + 滚动跟随/回底部,零行为变更,store/useMarkdown 单例共享) -->
|
||||
<MessageList
|
||||
@@ -62,11 +58,6 @@
|
||||
<HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- 无人值守安全停靠卡(AiTaskParked):继续=重新授权续跑,停止归档=不再续跑 -->
|
||||
<TaskParkedCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)。
|
||||
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
|
||||
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
|
||||
DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 -->
|
||||
<DirAuthDialog v-if="!unifiedApproval" @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目)。
|
||||
C4b 拆分至 ai-chat/queue-panel.vue(L2 发送韧性 + V7 排队时钟 + 队列编辑/回填随迁,
|
||||
confirm 自持同 ChatInput 模式;hasUnsentInput/fillDraft 经 props/emits 接 ChatInput ref) -->
|
||||
@@ -114,7 +105,6 @@ import MessageList from './ai/MessageList.vue'
|
||||
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
|
||||
import TaskParkedCard from './ai/TaskParkedCard.vue'
|
||||
import TaskBoundaryCard from './ai/TaskBoundaryCard.vue'
|
||||
import DirAuthDialog from './ai/DirAuthDialog.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
@@ -137,13 +127,7 @@ const router = useRouter()
|
||||
// B4: 自动压缩完成一次性可见信号(输入区上方 hint 行短暂显示,4s 自灭)
|
||||
const { autoCompressedFlash } = useAiContext()
|
||||
|
||||
// path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。
|
||||
// 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。
|
||||
// 关(false,兜底回退):挂 DirAuthDialog 独立弹窗老链路(useAiEvents AiDirAuthRequired case 走 pendingDirAuths)。
|
||||
const appSettings = useAppSettingsStore()
|
||||
const unifiedApproval = appSettings.useSetting<boolean>('df-ai-unified-approval', true)
|
||||
// 「信任所有目录」总开关(与设置页 AllowedDirsPanel 同一 KV):开启时对话区顶部常驻放行横幅
|
||||
const pathAuthBypass = appSettings.useSetting<boolean>('df-ai-path-auth-bypass', false)
|
||||
// 「信任所有目录」总开关横幅已随授权目录机制退役移除(2026-10-25 默认全放行,横幅失去信息量)
|
||||
|
||||
// ── 空状态示例问题随 MessageList 子组件迁移(examplePrompts 数组在子组件内);
|
||||
// 父仅保留 sendExamplePrompt 转发(子 emit 'send-example-prompt' → 父委托 ChatInput.sendExamplePrompt)。──
|
||||
@@ -739,16 +723,6 @@ async function initContextEventListeners(): Promise<void> {
|
||||
TopBar.vue scoped 自持 + .ai-btn-icon* 抽 components.css 全局共享,
|
||||
原 AiChat scoped 不渗透子组件根,删此重复块零视觉变更) */
|
||||
|
||||
/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .df-toast--warning) ── */
|
||||
.ai-auth-bypass-banner {
|
||||
padding: 5px 14px;
|
||||
background: var(--df-warning-bg);
|
||||
color: var(--df-warning);
|
||||
border-bottom: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ═══ Input ═══ */
|
||||
.ai-input-area {
|
||||
padding: 10px 14px;
|
||||
|
||||
@@ -84,41 +84,8 @@
|
||||
<div v-if="tc.diff" class="ai-tool-approval-diff">
|
||||
<pre class="df-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
||||
</div>
|
||||
<!-- path_auth 审批链阶段3b:按 tc.kind 分两组按钮。
|
||||
- kind='path'(路径授权挂起):once/session/always/deny 四选项,调 authorizeDir
|
||||
(与 DirAuthDialog.vue 同款四按钮;IPC authorizeDir 后端 once/session/always/deny 全支持)。
|
||||
- 其余(risk/缺省,普通 RiskLevel 审批):approve/reject 两选项,调 approve。 -->
|
||||
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onAuthorizeOnce">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
||||
{{ $t('aiChat.dirAuthOnce') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--session"
|
||||
:disabled="approving"
|
||||
@click="onAuthorizeSession">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
||||
{{ $t('aiChat.dirAuthSession') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--always"
|
||||
:disabled="approving"
|
||||
@click="onAuthorizeAlways">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
||||
{{ $t('aiChat.dirAuthAlways') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||
:disabled="approving"
|
||||
@click="onDeny">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CROSS" />
|
||||
{{ $t('aiChat.dirAuthDeny') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="ai-tool-actions">
|
||||
<!-- 普通 RiskLevel 审批:approve/reject 两选项(path_auth 四选项链已退役 2026-10-25)。 -->
|
||||
<div class="ai-tool-actions">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onApprove">
|
||||
@@ -171,14 +138,6 @@
|
||||
/>
|
||||
</div><!-- /.ai-tool-body -->
|
||||
|
||||
<!-- AE-2025-05:High 风险审批二次确认(本卡自治 useConfirm,不与 AiChat 父级 confirmState 串台) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="t('common.confirm')"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
|
||||
<!-- 审批 loading 超时兜底 toast(零依赖 reactive,复用 AiChat 同款样式) -->
|
||||
<Transition name="ai-tool-toast">
|
||||
<div v-if="approveToast.visible" class="ai-tool-toast ai-tool-toast--warning">{{ approveToast.msg }}</div>
|
||||
@@ -205,7 +164,6 @@ import { useTimerOwnership } from '@/composables/useTimerOwnership'
|
||||
import { callDurationText } from '@/composables/ai/useTurnTrace'
|
||||
import { formatDate, formatDuration } from '@/utils/time'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import ToolResultBody from './ToolResultBody.vue'
|
||||
import OffloadedResultBar from './OffloadedResultBar.vue'
|
||||
|
||||
@@ -234,18 +192,14 @@ const emit = defineEmits<{
|
||||
* 调 store.sendMessage 触发 AI 装流式。仅 fetch_url 工具会发此事件。
|
||||
*/
|
||||
'install-obscura': []
|
||||
/**
|
||||
* 审批按钮 → 父级转发 store.approveToolCall。
|
||||
* path_auth 审批链阶段3b:risk 类带 approved boolean;path 类带 decision('once'|'session'|'always'|'deny')。
|
||||
* 调用方(store.approveToolCall)按是否有 decision 分派 approve / authorizeDir。
|
||||
*/
|
||||
/** 审批按钮 → 父级转发 store.approveToolCall(一次性 approve/reject)。 */
|
||||
approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
|
||||
}>()
|
||||
|
||||
// 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。
|
||||
const { toolDisplayName, displayArgValue, argsEntries } = useToolCardHeader(() => props.tc)
|
||||
|
||||
// 审批状态机(按钮 loading/挂起时长/toast/approve+reject+path_auth 三选项)抽离至 composable。
|
||||
// 审批状态机(按钮 loading/挂起时长/toast/approve+reject)抽离至 composable。
|
||||
const {
|
||||
approving,
|
||||
approveToast,
|
||||
@@ -256,12 +210,6 @@ const {
|
||||
expireLevel,
|
||||
onApprove,
|
||||
onReject,
|
||||
onAuthorizeOnce,
|
||||
onAuthorizeSession,
|
||||
onAuthorizeAlways,
|
||||
onDeny,
|
||||
confirmState,
|
||||
answerConfirm,
|
||||
} = useToolApproval(toRef(props, 'tc'), emit)
|
||||
|
||||
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
|
||||
@@ -597,16 +545,6 @@ const visibleArgs = computed(() => {
|
||||
}
|
||||
.ai-tool-btn--approve { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); }
|
||||
/* M4:session 按钮(accent 描边态,介于 approve 弱背景与 always 强 warning 之间;对齐 DirAuthDialog session 语义) */
|
||||
.ai-tool-btn--session {
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
}
|
||||
.ai-tool-btn--session:hover { background: var(--df-accent); color: #fff; border-color: var(--df-accent); }
|
||||
/* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */
|
||||
.ai-tool-btn--always { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); }
|
||||
.ai-tool-btn--reject { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.ai-tool-btn--reject:hover { background: var(--df-danger); color: #fff; border-color: var(--df-danger); }
|
||||
/* 审批 loading禁用两按钮防重复点击,spinner 转在原位 */
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
<button class="ai-btn ai-btn--sm ai-btn--danger" :disabled="batchProcessing" @click="$emit('batch-approve', 'reject')">
|
||||
{{ $t('aiTool.rejectAll') }}
|
||||
</button>
|
||||
<!-- High 风险计数徽标:批量批准将二次确认的项数,提示操作后果(wave2 迁 .df-badge danger) -->
|
||||
<span v-if="highRiskPendingCount > 0" class="ai-batch-high-risk-hint df-badge df-badge--danger">
|
||||
{{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,14 +39,6 @@
|
||||
:class="{ 'ai-tool-card--group-hidden': isGroupHidden(tc, group, gi) }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 批量批准 High 风险二次确认(本组件自治 useConfirm,不与父级串台) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="t('common.confirm')"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -58,11 +46,8 @@
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ToolCard from './ToolCard.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { toolRiskLevel } from '@/composables/ai/useToolApproval'
|
||||
import { isToolFailure } from '@/composables/ai/useToolCard'
|
||||
|
||||
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
|
||||
@@ -74,10 +59,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* 审批按钮 → 父组件转发 store.approveToolCall。
|
||||
* path_auth 审批链阶段3b:path 类带 decision('once'|'session'|'always'|'deny'),risk 类仅 approved boolean。
|
||||
*/
|
||||
/** 审批按钮 → 父组件转发 store.approveToolCall(一次性 approve/reject)。 */
|
||||
approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
|
||||
/** 批量审批 → 父组件调用 approveAll/rejectAll */
|
||||
'batch-approve': [decision: 'approve' | 'reject']
|
||||
@@ -154,27 +136,15 @@ const pendingCount = computed(() =>
|
||||
props.toolCalls.filter(tc => tc.status === 'pending_approval').length
|
||||
)
|
||||
|
||||
// -- 批量批准 High 风险拦截(治安全缺口:原批量批准绕过单卡二次确认直接放行) --
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
/** 待审批中的 High 风险项数(徽标提示 + 确认门槛判定) */
|
||||
const highRiskPendingCount = computed(() =>
|
||||
props.toolCalls.filter(tc => tc.status === 'pending_approval' && toolRiskLevel(tc.name) === 'high').length
|
||||
)
|
||||
// 批量批准 High 风险二次确认已退役(2026-10-25 批C):审批按钮本身即确认(与单卡/浮窗同口径)。
|
||||
/** 批量处理中(透传 isBatchProcessing 不便——本组件直接用 pendingCount 收敛到 0 判断,简单可靠) */
|
||||
const batchProcessing = ref(false)
|
||||
|
||||
/**
|
||||
* 批量批准入口:含 High 风险项 → 先二次确认(文案对齐单卡 highRiskConfirmText 语义,
|
||||
* 列出将放行的高危工具数);确认后才向父级派发 batch-approve('approve')。
|
||||
* 批量批准入口:直接向父级派发 batch-approve('approve')。
|
||||
* 拒绝批量无破坏性,不拦。
|
||||
*/
|
||||
async function onBatchApprove() {
|
||||
if (highRiskPendingCount.value > 0) {
|
||||
const ok = await confirmDialog(
|
||||
t('aiTool.batchHighRiskConfirm', { n: highRiskPendingCount.value }),
|
||||
)
|
||||
if (!ok) return
|
||||
}
|
||||
batchProcessing.value = true
|
||||
emit('batch-approve', 'approve')
|
||||
// emit 后父级异步批量处理;本组件无完成回执,以 pendingCount 归零兜底复位
|
||||
@@ -366,7 +336,7 @@ function scrollToFirstPending(): void {
|
||||
// 自动展开折叠分组 + scroll 到 pending 卡。根因:统一审批开关 df-ai-unified-approval 默认开 →
|
||||
// path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(initDefaultCollapse)+ group-hidden
|
||||
// (display:none)→ 审批按钮对用户不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默
|
||||
// authorizeDir/approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。
|
||||
// 超时自动 approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。
|
||||
// scrollToFirstPending(:285)已能自动展开折叠组(offsetParent===null → collapsedGroups 清空),
|
||||
// 原仅 AiChat 头部徽标手动触发(:230);此处 pending 到达即自动调用,让审批卡必可见可操作。
|
||||
let _unlistenPending: UnlistenFn | null = null
|
||||
|
||||
@@ -1,221 +1,20 @@
|
||||
<template>
|
||||
<!-- 结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。
|
||||
分支顺序/条件/文案与原 ToolCard.vue 一字不变,仅搬运行内联模板。 -->
|
||||
|
||||
<!-- read_file 结果:全文预览(默认)/ 命中行(search 模式) -->
|
||||
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||
<!-- BUG-260625-02: search 模式返回 matches(命中行)非 content,
|
||||
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
|
||||
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 -->
|
||||
<span v-if="parsed?.search" class="ai-tool-file-meta">「{{ parsed?.search }}」 {{ $t('aiTool.grepHitsN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.matches?.length || 0) }) }}</span>
|
||||
<span v-else class="ai-tool-file-meta">{{ parsed?.has_more
|
||||
? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 })
|
||||
: `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }}</span>
|
||||
<button v-if="!parsed?.search" class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
|
||||
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- search 模式:命中行渲染(复用 grep content 样式;read_file matches 仅 {line,content} 无 file) -->
|
||||
<!-- 行号 gutter 与内容同一 flex row(IDE 风格),不再 head/content 分两行块级堆叠 -->
|
||||
<div v-if="parsed?.search && parsed?.matches?.length" class="ai-tool-grep-matches">
|
||||
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
|
||||
<div class="ai-tool-grep-match-main">
|
||||
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">{{ m.line }}</span>
|
||||
<code class="ai-tool-grep-content">{{ m.content }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 默认模式:全文预览 -->
|
||||
<pre v-else class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- list_directory 结果:文件树 -->
|
||||
<div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
|
||||
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}</span>
|
||||
</div>
|
||||
<div class="ai-tool-dir-entries">
|
||||
<div
|
||||
v-for="(entry, i) in parsed?.entries"
|
||||
:key="i"
|
||||
class="ai-tool-dir-entry"
|
||||
:class="'ai-tool-dir-entry--' + entry.type"
|
||||
:style="{ paddingLeft: `${12 + (entry.depth || 0) * 16}px` }"
|
||||
>
|
||||
<span class="ai-tool-dir-icon" :class="entry.type === 'directory' ? 'ai-tool-dir-icon--folder' : 'ai-tool-dir-icon--file'" v-html="entry.type === 'directory' ? dirIcon : fileIcon"></span>
|
||||
<span class="ai-tool-dir-name">{{ entry.name }}</span>
|
||||
<span v-if="entry.type === 'file'" class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- search_files 结果:匹配文件列表(对齐 list_directory 渲染,修 results 被丢弃致 body 仅显「找到 N 项」) -->
|
||||
<div v-else-if="tc.name === 'search_files' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
|
||||
<span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
|
||||
</div>
|
||||
<div class="ai-tool-dir-entries">
|
||||
<div v-for="(entry, i) in parsed?.results" :key="i" class="ai-tool-dir-entry">
|
||||
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
|
||||
<span class="ai-tool-dir-name">{{ entry.path }}</span>
|
||||
<span class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- grep 结果:跨文件内容搜索(content/files_with_matches/count 三模式,F-260621)
|
||||
对齐 search_files 文件栏样式;content 模式按命中行渲染(文件:行:内容),支持 -C 上下文折叠 -->
|
||||
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon" v-html="searchIcon"></span>
|
||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
|
||||
<span class="ai-tool-file-meta">{{ grepHitLabel }}</span>
|
||||
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.grepTruncated') }}</span>
|
||||
</div>
|
||||
<!-- files_with_matches 模式:仅文件列表 -->
|
||||
<div v-if="parsed?.output_mode === 'files_with_matches'" class="ai-tool-dir-entries">
|
||||
<div v-for="(f, i) in parsed?.files" :key="i" class="ai-tool-dir-entry">
|
||||
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
|
||||
<span class="ai-tool-dir-name">{{ f }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- count 模式:文件 + 命中行数 -->
|
||||
<div v-else-if="parsed?.output_mode === 'count'" class="ai-tool-dir-entries">
|
||||
<div v-for="(c, i) in parsed?.counts" :key="i" class="ai-tool-dir-entry">
|
||||
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
|
||||
<span class="ai-tool-dir-name">{{ c.file }}</span>
|
||||
<span class="ai-tool-dir-size">{{ $t('aiTool.grepCountLines', { n: c.count }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- content 模式:命中行(文件 | 行号 gutter | 内容 同行),支持上下文折叠 -->
|
||||
<!-- 文件名/行号/内容同一 flex row,不再 head 块 + content 块分两行 -->
|
||||
<div v-else class="ai-tool-grep-matches">
|
||||
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
|
||||
<div class="ai-tool-grep-match-main">
|
||||
<span class="ai-tool-grep-file">{{ m.file }}</span>
|
||||
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">{{ m.line }}</span>
|
||||
<code class="ai-tool-grep-content">{{ m.content }}</code>
|
||||
</div>
|
||||
<pre v-if="m.context" class="ai-tool-grep-context"><code>{{ m.context }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- write_file 结果 -->
|
||||
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
<span class="ai-tool-write-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
|
||||
<span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span>
|
||||
<span v-if="typeof parsed?.old_size === 'number' && parsed.old_size > 0" class="ai-tool-write-meta">{{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }}</span>
|
||||
<span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
|
||||
<!-- R12(2026-10-05):覆盖写入的行级 diff 回看(后端 diff 字段,新建/base64 为 null 不渲染) -->
|
||||
<div v-if="resultDiffLines.length" class="ai-tool-write-diff">
|
||||
<pre class="df-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
||||
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 df-diff-pre/--add/--del/--ctx 样式)
|
||||
后端 generate_diff 产完整 diff,result.diff 字段此前被丢弃,用户无法验证改了什么 → 此分支渲染 diff -->
|
||||
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
|
||||
<div class="ai-tool-file-bar">
|
||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
||||
<span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
|
||||
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
|
||||
</div>
|
||||
<div v-if="parsed?.warning" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ parsed.warning }}</div>
|
||||
<div v-if="resultDiffLines.length" class="ai-tool-approval-diff">
|
||||
<pre class="df-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
||||
<!-- R12a:长 diff 展开入口(默认预览 120 行,点开全量) -->
|
||||
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- run_command 结果:命令行常显 + 可折叠输出(执行后仍能看到跑了什么命令) -->
|
||||
<div
|
||||
v-else-if="tc.name === 'run_command' && tc.result && tc.status === 'completed'"
|
||||
class="ai-tool-cmd-result"
|
||||
>
|
||||
<div class="ai-tool-cmd-line" :class="{ 'ai-tool-cmd-line--failed': isFailed }">
|
||||
<span class="ai-tool-cmd-prompt">$</span>
|
||||
<code class="ai-tool-cmd-text">{{ argString(tc.args, 'command') }}</code>
|
||||
<button
|
||||
v-if="cmdOutput"
|
||||
class="ai-tool-cmd-toggle"
|
||||
:title="cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
|
||||
@click="cmdOutputExpanded = !cmdOutputExpanded"
|
||||
>{{ cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
|
||||
</div>
|
||||
<pre v-if="cmdOutputExpanded && cmdOutput" class="ai-tool-cmd-output"><code>{{ cmdOutput }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- http_request 结果:method + url + 状态码徽标 + 可折叠响应 body(对齐 run_command 模式 ) -->
|
||||
<div
|
||||
v-else-if="tc.name === 'http_request' && tc.result && tc.status === 'completed' && parsed"
|
||||
class="ai-tool-http-result"
|
||||
>
|
||||
<div class="ai-tool-http-line" :class="{ 'ai-tool-http-line--failed': isFailed }">
|
||||
<span class="ai-tool-http-method">{{ parsed?.method || 'GET' }}</span>
|
||||
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
|
||||
<span class="ai-tool-http-status" :class="{ 'ai-tool-http-status--ok': !isFailed }">{{ parsed?.status }}</span>
|
||||
<button
|
||||
v-if="parsed?.body"
|
||||
class="ai-tool-cmd-toggle"
|
||||
:title="httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
|
||||
@click="httpBodyExpanded = !httpBodyExpanded"
|
||||
>{{ httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
|
||||
</div>
|
||||
<div v-if="parsed?.truncated && !httpBodyExpanded" class="ai-tool-http-trunc-warn">
|
||||
<span aria-hidden="true">⚠</span> {{ $t('aiTool.httpTruncated', { bytes: formatBytes(parsed?.body_bytes) }) }}
|
||||
</div>
|
||||
<pre v-if="httpBodyExpanded && parsed?.body" class="ai-tool-cmd-output"><code>{{ parsed.body }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- fetch_url 结果:URL + title + render_mode 徽标 + 可折叠 markdown
|
||||
render_mode='static_fallback'(obscura 未装/失败)→ 显 obscura 未装提示 + "AI 帮装"按钮
|
||||
(按钮 emit install-obscura → MessageList 调 store.sendMessage 触发 AI 装流式) -->
|
||||
<div
|
||||
v-else-if="tc.name === 'fetch_url' && tc.result && tc.status === 'completed' && parsed"
|
||||
class="ai-tool-fetch-result"
|
||||
>
|
||||
<div class="ai-tool-fetch-line">
|
||||
<span class="ai-tool-http-method ai-tool-fetch-mode" :class="'ai-tool-fetch-mode--' + (parsed?.render_mode || 'static')">
|
||||
{{ parsed?.render_mode === 'obscura' ? $t('aiTool.fetchUrlRendered')
|
||||
: parsed?.render_mode === 'obscura_failed' ? $t('aiTool.fetchUrlFallback')
|
||||
: $t('aiTool.fetchUrlStatic') }}
|
||||
</span>
|
||||
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
|
||||
<span v-if="typeof parsed?.length === 'number' && parsed?.length > 0" class="ai-tool-file-meta">{{ parsed?.length }} {{ $t('aiTool.charsUnit') }}</span>
|
||||
<button
|
||||
v-if="parsed?.markdown"
|
||||
class="ai-tool-cmd-toggle"
|
||||
:title="fetchMdExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
|
||||
@click="fetchMdExpanded = !fetchMdExpanded"
|
||||
>{{ fetchMdExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
|
||||
</div>
|
||||
<div v-if="parsed?.title" class="ai-tool-fetch-title">{{ parsed?.title }}</div>
|
||||
<!-- obscura 未装引导(render=true 但回退静态):提示 + "AI 帮装"按钮 -->
|
||||
<div v-if="obscuraMissing" class="ai-tool-obscura-hint">
|
||||
<div class="ai-tool-obscura-hint-text"><span aria-hidden="true">⚠</span> {{ parsed?.hint || $t('aiTool.fetchUrlFallbackHint') }}</div>
|
||||
<button class="ai-tool-btn ai-tool-btn--install" @click="onInstallObscura">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
{{ $t('aiTool.installObscura') }}
|
||||
</button>
|
||||
</div>
|
||||
<pre v-if="fetchMdExpanded && parsed?.markdown" class="ai-tool-cmd-output"><code>{{ parsed.markdown }}</code></pre>
|
||||
</div>
|
||||
|
||||
分支顺序/条件/文案与原 ToolCard.vue 一字不变,仅搬运行内联模板。
|
||||
C4a 拆分:九个工具专属分支下沉 tool-result-body/ 两族子件,本组件持分族派发 + 通用兜底。 -->
|
||||
<!-- 读取浏览族:read_file / list_directory / search_files / grep(原链第 1-4 支) -->
|
||||
<FileBrowseResult
|
||||
v-if="browseBranch"
|
||||
:tc="tc"
|
||||
:is-content-expanded="isContentExpanded"
|
||||
@expand-content="emit('expand-content', $event)"
|
||||
/>
|
||||
<!-- 写入执行族:write_file / patch_file / run_command / http_request / fetch_url(原链第 5-9 支) -->
|
||||
<WriteRunResult
|
||||
v-else-if="writeRunBranch"
|
||||
:tc="tc"
|
||||
@install-obscura="emit('install-obscura')"
|
||||
/>
|
||||
<!-- 通用 CRUD 结果 -->
|
||||
<div
|
||||
v-else-if="tc.result && tc.status === 'completed'"
|
||||
@@ -228,24 +27,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
isToolFailure,
|
||||
parseResult,
|
||||
formatBytes,
|
||||
argString,
|
||||
formatSizeDiff,
|
||||
combineOutputs,
|
||||
formatToolResult,
|
||||
formatGrep,
|
||||
shortPath,
|
||||
dirIcon,
|
||||
fileIcon,
|
||||
searchIcon,
|
||||
DIR_TRUNCATE_LIMIT,
|
||||
} from '@/composables/ai/useToolCard'
|
||||
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
||||
/**
|
||||
* 工具结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。
|
||||
*
|
||||
* C4a 大文件拆分:九个工具专属分支分两族下沉 tool-result-body/
|
||||
* - FileBrowseResult = 读取浏览族(read_file / list_directory / search_files / grep,原链第 1-4 支)
|
||||
* - WriteRunResult = 写入执行族(write_file / patch_file / run_command / http_request / fetch_url,原链第 5-9 支)
|
||||
* 本组件持分族派发 + 通用 CRUD 兜底分支。
|
||||
*
|
||||
* 派发口径与原 v-else-if 链等价:各支条件原样,组判定=组内各支条件析取,组序=原链序——
|
||||
* 原链渲染首个命中支;同一支必落同组,组内首个命中支即该组渲染内容,故渲染结果一一对应。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { parseResult, formatToolResult } from '@/composables/ai/useToolCard'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import { resultIsFailed } from './tool-result-body/shared'
|
||||
import FileBrowseResult from './tool-result-body/FileBrowseResult.vue'
|
||||
import WriteRunResult from './tool-result-body/WriteRunResult.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 单条工具调用数据(结果展示只读消费) */
|
||||
@@ -264,135 +62,35 @@ const emit = defineEmits<{
|
||||
'install-obscura': []
|
||||
}>()
|
||||
|
||||
// ── R6 结果路径可复制 ──
|
||||
// AI 读过/写过的文件,用户想拿去本地操作(编辑器打开/再 grep)只能手动重敲路径。
|
||||
// file-bar 的 path 改为可点击:复制完整路径(parsed.path 原文,非 shortPath 截断展示),
|
||||
// 按钮文字短暂变「✓」作反馈(1.2s 还原,同 R4 代码块复制反馈节奏)。
|
||||
let _pathCopiedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
async function copyResultPath(e: MouseEvent, path: string): Promise<void> {
|
||||
if (!path) return
|
||||
const btn = e.currentTarget as HTMLButtonElement | null
|
||||
let ok = false
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(path)
|
||||
ok = true
|
||||
}
|
||||
} catch { /* 落 execCommand 兜底 */ }
|
||||
if (!ok) {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = path
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try { ok = document.execCommand('copy') } catch { ok = false }
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
if (btn) {
|
||||
const prev = btn.textContent
|
||||
btn.textContent = ok ? '✓' : '✕'
|
||||
if (_pathCopiedTimer) clearTimeout(_pathCopiedTimer)
|
||||
_pathCopiedTimer = setTimeout(() => { btn.textContent = prev }, 1200)
|
||||
}
|
||||
}
|
||||
|
||||
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
|
||||
const cmdOutputExpanded = ref(false)
|
||||
// http_request 响应 body 折叠态:失败(非 2xx)默认展开便于看错误,成功默认折叠
|
||||
const httpBodyExpanded = ref(false)
|
||||
// fetch_url markdown 折叠态:默认折叠(可能很长,头部已有 title + 截断字符数摘要)
|
||||
const fetchMdExpanded = ref(false)
|
||||
|
||||
/** 一次解析结果供模板多次读(模板引用多次,computed 避免每次 patch 重 parse) */
|
||||
const parsed = computed(() => parseResult(props.tc.result))
|
||||
|
||||
/**
|
||||
* 是否工具失败(completed 语义)。复用 parsed 不重 parse。逻辑与模块级 isToolFailure 同源,
|
||||
* 但因 run_command 失败判定依赖 parsed.exit_code(本组件已 computed),此处就近复用避免重复 parse。
|
||||
*/
|
||||
const isFailed = computed(() => {
|
||||
/** 是否工具失败(completed 语义,通用兜底分支失败态);判定逻辑本体在 shared.resultIsFailed */
|
||||
const isFailed = computed(() => resultIsFailed(props.tc, parsed.value))
|
||||
|
||||
/** 读取浏览族命中(原链第 1-4 支条件原样析取) */
|
||||
const browseBranch = computed(() => {
|
||||
const tc = props.tc
|
||||
if (tc.status !== 'completed') return false
|
||||
const r = parsed.value
|
||||
if (tc.name === 'run_command') {
|
||||
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
|
||||
if (r) return true
|
||||
}
|
||||
// 可解析 JSON 且显式 success===false(git status/diff/log 只读工具失败返
|
||||
// {success:false,error} 合法 JSON 对象非 Err)→ 判失败,防绿框假成功
|
||||
if (r && r.success === false) return true
|
||||
if (!r) {
|
||||
const raw = typeof tc.result === 'string' ? tc.result : ''
|
||||
if (!raw) return false
|
||||
return /^(执行失败|Error:|Failed:)/m.test(raw)
|
||||
}
|
||||
return false
|
||||
return (tc.name === 'read_file' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
|| (tc.name === 'list_directory' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
|| (tc.name === 'search_files' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
|| (tc.name === 'grep' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
})
|
||||
|
||||
/** run_command 展开态全量输出(stdout+stderr 合并);空串隐藏 toggle。复用 parsed 不重 parse */
|
||||
const cmdOutput = computed(() => {
|
||||
const r = parsed.value
|
||||
if (!r) return ''
|
||||
return combineOutputs(r.stdout, r.stderr)
|
||||
/** 写入执行族命中(原链第 5-9 支条件原样析取;run_command 原条件本就无 parsed 判定,照抄) */
|
||||
const writeRunBranch = computed(() => {
|
||||
const tc = props.tc
|
||||
return (tc.name === 'write_file' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
|| (tc.name === 'patch_file' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
|| (tc.name === 'run_command' && !!tc.result && tc.status === 'completed')
|
||||
|| (tc.name === 'http_request' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
|| (tc.name === 'fetch_url' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* patch_file / write_file 结果 diff 行拆解(复用 parseDiffLines +add/-del/ ctx 着色)。
|
||||
* 数据源:parsed.diff(工具执行结果字段,write_file 覆盖写入时后端同款生成)。
|
||||
* R12a:默认截断前 120 行防撑爆卡片,expanded 后全量渲染(toggle 入口在 diff 块尾部)。
|
||||
*/
|
||||
const DIFF_PREVIEW_LINES = 120
|
||||
const diffExpanded = ref(false)
|
||||
const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, diffExpanded.value ? undefined : DIFF_PREVIEW_LINES))
|
||||
/** diff 全量行数(不受预览截断影响),驱动展开按钮显隐与行数文案 */
|
||||
const diffTotal = computed(() => parseDiffLines(parsed.value?.diff).length)
|
||||
|
||||
/**
|
||||
* grep 命中计数标签G-1 复用 useToolCard.formatGrep 单一函数,
|
||||
* 消除原与 ToolResultBody 重复的三分支(files_with_matches/count/content 文案)。
|
||||
* parsed 为 null 返空串,等价原逻辑;非 null 委托 formatGrep。
|
||||
*/
|
||||
const grepHitLabel = computed(() => {
|
||||
const r = parsed.value
|
||||
return r ? formatGrep(r) : ''
|
||||
})
|
||||
|
||||
/**
|
||||
* fetch_url obscura 未装检测(render=true 但 obscura 未装):
|
||||
* 后端 render=true 且 obscura 未装 → result.render_mode='obscura_not_installed' + hint 引导。
|
||||
* 装了但失败/超时 → 'obscura_failed'(不弹引导,只 hint)。此 computed 仅未装弹"AI 帮装"。
|
||||
*/
|
||||
const obscuraMissing = computed(() => {
|
||||
if (props.tc.name !== 'fetch_url') return false
|
||||
const r = parsed.value
|
||||
if (!r) return false
|
||||
const mode = typeof r.render_mode === 'string' ? r.render_mode : ''
|
||||
return mode === 'obscura_not_installed'
|
||||
})
|
||||
|
||||
/**
|
||||
* fetch_url "AI 帮装 obscura" → emit 透传到 MessageList,调 store.sendMessage
|
||||
* 触发 AI run_command 装流式。安装完成后用户重试即可用 obscura 渲染。
|
||||
* 提示语足够具体(给出两种装法),AI 拿到能直接 run_command 不需再问。
|
||||
*/
|
||||
function onInstallObscura() {
|
||||
emit('install-obscura')
|
||||
}
|
||||
|
||||
// status 切到 completed 时按成功/失败初始化折叠态(失败默认展开便于看 stderr/错误 body)。
|
||||
// immediate 时若初始非 completed,两 ref 保持默认 false,无副作用。
|
||||
watch(() => props.tc.status, (s) => {
|
||||
if (s === 'completed') {
|
||||
cmdOutputExpanded.value = isToolFailure(props.tc)
|
||||
httpBodyExpanded.value = isToolFailure(props.tc)
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<!-- ═══ ToolResultBody scoped 样式(C4a 后本组件只持通用兜底分支样式;
|
||||
读取浏览/写入执行两族样式随子件迁 tool-result-body/,路径条族在 path-bar.css 两子件共享) ═══ -->
|
||||
<style scoped>
|
||||
/* 结果展示区样式(从 ToolCard.vue 迁移,仅含结果分支用到的 class;卡片容器/header/审批/骨架
|
||||
等样式留 ToolCard.vue)。失败横幅/通用结果失败态样式一并迁移,因结果区独立判定 isFailed。 */
|
||||
.ai-tool-failed-banner {
|
||||
padding: 4px 10px 2px;
|
||||
font-size: 11px;
|
||||
@@ -409,263 +107,6 @@ watch(() => props.tc.status, (s) => {
|
||||
color: var(--df-danger);
|
||||
}
|
||||
|
||||
/* -- File Content (read_file) -- */
|
||||
.ai-tool-file-content {
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-tool-file-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: color-mix(in srgb, var(--df-bg) 80%, transparent);
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-tool-file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--df-info);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-file-icon--dir {
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-tool-file-path {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-accent);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-tool-file-meta {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-file-meta--warn {
|
||||
color: var(--df-warning);
|
||||
}
|
||||
/* R6:路径可点击复制(按钮语义,视觉保持原 span 形态;hover 变 accent 提示可点) */
|
||||
.ai-tool-file-path--copy {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-tool-file-path--copy:hover {
|
||||
color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* grep 结果文件栏复用 ai-tool-file-bar,命中行用等宽渲染 */
|
||||
.ai-tool-grep-result {
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-tool-grep-matches {
|
||||
padding: 4px 0;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ai-tool-grep-match {
|
||||
padding: 3px 12px;
|
||||
border-bottom: 0.5px solid var(--df-hover);
|
||||
}
|
||||
.ai-tool-grep-match:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
/* 命中行主体 = 单行 flex row(文件名 | 行号 gutter | 内容 并排),
|
||||
取代原 head 块 + content 块的垂直两行堆叠(行号内容分行根因)。 */
|
||||
.ai-tool-grep-match-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-tool-grep-file {
|
||||
color: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.ai-tool-grep-line {
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
min-width: 2em;
|
||||
text-align: right;
|
||||
font-size: 10px;
|
||||
user-select: none;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.ai-tool-grep-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: rgba(61,219,160,0.05);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.ai-tool-grep-context {
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
background: var(--df-hover);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.ai-tool-grep-context code {
|
||||
display: block;
|
||||
padding: 4px 6px;
|
||||
white-space: pre;
|
||||
}
|
||||
.ai-tool-expand-btn {
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
background: var(--df-hover);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s var(--df-ease), background 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-tool-expand-btn:hover {
|
||||
color: var(--df-text-secondary);
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-color: var(--df-border-strong);
|
||||
}
|
||||
.ai-tool-file-pre {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--df-bg) 60%, transparent);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--df-text-secondary);
|
||||
overflow-x: auto;
|
||||
tab-size: 4;
|
||||
}
|
||||
.ai-tool-file-pre--collapsed {
|
||||
max-height: 180px;
|
||||
/* hidden→auto: 默认高度内可直接滚轮看完整内容,不必先点"展开全部" */
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
.ai-tool-file-pre--collapsed::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(transparent, color-mix(in srgb, var(--df-bg) 90%, transparent));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* -- Directory List (list_directory / search_files) -- */
|
||||
.ai-tool-dir-list {
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-tool-dir-entries {
|
||||
padding: 4px 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ai-tool-dir-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 12px;
|
||||
font-size: 11px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ai-tool-dir-entry:hover {
|
||||
background: var(--df-hover);
|
||||
}
|
||||
.ai-tool-dir-entry--directory .ai-tool-dir-name {
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-tool-dir-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-dir-icon--folder { color: var(--df-warning); }
|
||||
.ai-tool-dir-icon--file { color: var(--df-text-dim); }
|
||||
.ai-tool-dir-name {
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.ai-tool-dir-size {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* -- Write File Result -- */
|
||||
.ai-tool-write-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
background: rgba(61,219,160,0.04);
|
||||
}
|
||||
.ai-tool-write-path {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-accent);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-tool-write-meta {
|
||||
font-size: 10px;
|
||||
color: var(--df-success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -- patch_file diff 预览(复用审批 diff 同款 token) -- */
|
||||
.ai-tool-approval-reason {
|
||||
padding: 4px 10px 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--df-warning);
|
||||
background: var(--df-warning-bg);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
}
|
||||
.ai-tool-approval-diff {
|
||||
margin: 0 10px 8px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* diff 基类 .df-diff-pre(+code)/.df-diff-line 消费 global.css 共享定义(2026-10-18 整洁波
|
||||
双轨合一,与 ToolCard.vue 审批 diff 同源),此处仅留行配色变体 */
|
||||
.df-diff-line--add { color: var(--df-success); background: var(--df-success-bg); }
|
||||
.df-diff-line--del { color: var(--df-danger); background: var(--df-danger-bg); }
|
||||
.df-diff-line--ctx { color: var(--df-text-dim); }
|
||||
|
||||
/* -- Generic Result --
|
||||
max-height 100px→200px:通用结果常含 JSON/结构化文本,100px 仅约 5 行,默认滚动条挤压可读性;
|
||||
对齐 ai-tool-file-pre--collapsed(180px) 与 ai-tool-dir-entries(220px) 量级。 */
|
||||
@@ -683,195 +124,4 @@ watch(() => props.tc.status, (s) => {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* -- run_command 结果:命令行(常显) + 可折叠输出 -- */
|
||||
.ai-tool-cmd-result {
|
||||
margin: 0;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-tool-cmd-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(61,219,160,0.04);
|
||||
}
|
||||
.ai-tool-cmd-line--failed {
|
||||
background: var(--df-danger-bg);
|
||||
}
|
||||
.ai-tool-cmd-prompt {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: var(--df-success);
|
||||
}
|
||||
.ai-tool-cmd-line--failed .ai-tool-cmd-prompt {
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-tool-cmd-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-tool-cmd-toggle {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 4px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-tool-cmd-toggle:hover {
|
||||
color: var(--df-text);
|
||||
background: var(--df-hover);
|
||||
}
|
||||
.ai-tool-cmd-output {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
background: rgba(0,0,0,0.18);
|
||||
color: var(--df-text-dim);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
/* -- http_request 结果:method 徽标 + url + 状态码(复用 cmd-line 视觉 token) -- */
|
||||
.ai-tool-http-result {
|
||||
margin: 0;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-tool-http-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(61,219,160,0.04); /* 成功默认(对齐 cmd-line);失败由 --failed 覆盖 */
|
||||
}
|
||||
.ai-tool-http-line--failed {
|
||||
background: var(--df-danger-bg);
|
||||
}
|
||||
.ai-tool-http-method {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: var(--df-text);
|
||||
font-size: 10px;
|
||||
}
|
||||
.ai-tool-http-url {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.ai-tool-http-status {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-tool-http-status--ok {
|
||||
color: var(--df-success);
|
||||
}
|
||||
.ai-tool-http-trunc-warn {
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--df-warning);
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
/* -- fetch_url 结果:复用 http-line 视觉 token;render_mode 徽标按 mode 着色 -- */
|
||||
.ai-tool-fetch-result {
|
||||
margin: 0;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-tool-fetch-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(94,175,240,0.04);
|
||||
}
|
||||
.ai-tool-fetch-mode {
|
||||
/* 复用 .ai-tool-http-method 基样(template 已加该 class),此处仅覆盖 mode 着色 */
|
||||
font-size: 10px;
|
||||
}
|
||||
.ai-tool-fetch-mode--static,
|
||||
.ai-tool-fetch-mode--obscura {
|
||||
color: var(--df-success);
|
||||
}
|
||||
.ai-tool-fetch-mode--static_fallback {
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-tool-fetch-title {
|
||||
padding: 4px 10px 2px;
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
/* obscura 未装引导(static_fallback):warning 色调提示 + AI 帮装按钮 */
|
||||
.ai-tool-obscura-hint {
|
||||
margin: 4px 10px 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--df-warning-bg);
|
||||
border: 0.5px solid var(--df-warning);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-tool-obscura-hint-text {
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 10px;
|
||||
color: var(--df-warning);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* AI 帮装按钮(accent 色调,对齐 batch-approve 视觉) */
|
||||
.ai-tool-btn {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
border: none;
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-family: var(--df-font-sans);
|
||||
transition: filter 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-tool-btn:hover { filter: brightness(1.1); }
|
||||
|
||||
/* R12: write_file diff 回看 + 长 diff 展开按钮 */
|
||||
.ai-tool-write-diff { width: 100%; margin-top: 6px; }
|
||||
.ai-tool-diff-toggle {
|
||||
display: block;
|
||||
margin: 4px auto 0;
|
||||
background: transparent;
|
||||
border: 0.5px dashed var(--df-border);
|
||||
border-radius: 4px;
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 11px;
|
||||
padding: 3px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-card); }
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
<template>
|
||||
<!-- F-260619-03 Phase B + path_auth 审批链阶段1: 路径授权弹窗(LLM 想访问白名单外目录,
|
||||
后端挂起 loop 等用户决定)。阶段1 改遍历 pendingDirAuths(数组)——同轮多个文件工具落不同
|
||||
未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖。
|
||||
条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。
|
||||
每项独立四按钮:点"本次" → ai_authorize_dir(decision=once, 写 once 层单次授权,执行后清)→ 后端执行工具 + try_continue。
|
||||
点"当前会话" → ai_authorize_dir(decision=session, 写 session 层会话授权,切会话清)→ 执行 + 续 loop。
|
||||
点"始终放行" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。
|
||||
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
|
||||
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
|
||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||
<div v-for="item in visibleDirAuths" :key="item.id" ref="dirAuthRefs" class="ai-dir-auth">
|
||||
<span class="ai-dir-auth-text">{{ $t('aiChat.dirAuthRequired') }}</span>
|
||||
<span class="ai-dir-auth-hint">
|
||||
{{ $t('aiChat.dirAuthHint', { tool: item.tool, path: item.path }) }}
|
||||
</span>
|
||||
<div class="ai-dir-auth-actions">
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--once"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'once')"
|
||||
>{{ $t('aiChat.dirAuthOnce') }}</button>
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--session"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'session')"
|
||||
>{{ $t('aiChat.dirAuthSession') }}</button>
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--always"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'always')"
|
||||
>{{ $t('aiChat.dirAuthAlways') }}</button>
|
||||
<button
|
||||
class="ai-dir-auth-btn ai-dir-auth-btn--deny"
|
||||
:disabled="actingIds.has(item.id)"
|
||||
@click="handleAuthorize(item.id, 'deny')"
|
||||
>{{ $t('aiChat.dirAuthDeny') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- P1-e-c: deny 拒绝二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试;对齐 ToolCard/ApprovalPopup) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="$t('common.confirm')"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, watch, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingDirAuths } from '../../composables/ai/useAiPendingState'
|
||||
import { aiApi } from '../../api'
|
||||
import { useConfirm } from '../../composables/useConfirm'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
|
||||
}>()
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// P1-e-c: deny 拒绝二次确认统一弹层(useConfirm 状态机 + ConfirmDialog 组件,同 ToolCard/ApprovalPopup)
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
// F-260620 根治(授权挂起无声卡死):挂起态弹窗显示只依赖 isGenerating(conv),不依赖 streaming。
|
||||
// streaming 会被流式看门狗超时清(useAiStream onStreamTimeout),而 AiDirAuthRequired 挂起常发生在
|
||||
// 首轮慢响应(看门狗已超时清 streaming)之后——此时 streaming=false 但后端 generating=true、
|
||||
// generatingConvs 含 conv(useAiEvents handleEvent 对非完成/错误事件 add)。若守卫仍 && streaming,
|
||||
// 挂起弹窗永不显示 → loop 无声卡死(用户发消息无响应)。去 streaming,isGenerating(conv) 单条件覆盖。
|
||||
// 多会话并发 — isGenerating(active) 替代单值比对
|
||||
const isViewingGenerating = computed(() =>
|
||||
store.isGenerating(store.state.activeConversationId),
|
||||
)
|
||||
|
||||
// pendingDirAuths 非空 + 当前视图正在生成(防切走后误显)双重守卫。
|
||||
// 阶段1:数组化后,visibleDirAuths 是 pendingDirAuths 的派生视图(按 conv 过滤当前会话挂起项),
|
||||
// 与 showDirAuthCard 合并为单一 computed(非空即显)。
|
||||
const visibleDirAuths = computed(() => {
|
||||
if (!isViewingGenerating.value) return []
|
||||
// 仅显当前会话的挂起项(active conv);其他会话的挂起不在本视图弹窗。
|
||||
const active = store.state.activeConversationId
|
||||
return pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === active)
|
||||
})
|
||||
|
||||
// 阶段1:逐 id 追踪"处理中"态(数组多挂起并存,单 boolean 不够)。
|
||||
// reactive Set 的增删驱动按钮 disabled;Set 判定 O(1)。
|
||||
const actingIds = reactive(new Set<string>())
|
||||
|
||||
// M8:卡片根元素 ref 数组(v-for ref 收集)。挂起弹窗出现在输入区上方,常被消息流/进度条挤出视口,
|
||||
// 用户看不到按钮 → 挂起无声卡死。新增项时滚动定位到卡片让审批入口立即可见。
|
||||
const dirAuthRefs = ref<HTMLElement[]>([])
|
||||
|
||||
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
|
||||
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
|
||||
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
|
||||
async function handleAuthorize(id: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<void> {
|
||||
if (actingIds.has(id)) return
|
||||
actingIds.add(id)
|
||||
// P1-e-c: deny 拒绝前二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试;与 ToolCard onDeny 同款)。
|
||||
// 放 actingIds.add 之后、IPC 之前;取消则删 actingIds 复位处理中态(保持 pending 可重新决策),不调用 IPC。
|
||||
if (decision === 'deny') {
|
||||
const ok = await confirmDialog(t('aiChat.dirAuthDenyConfirm'))
|
||||
if (!ok) {
|
||||
actingIds.delete(id)
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
await aiApi.authorizeDir(id, decision)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
emit('toast', { msg: t('aiChat.dirAuthFailed', { msg }), type: 'error' })
|
||||
actingIds.delete(id) // IPC 失败:复位该 id,允许重试
|
||||
return
|
||||
}
|
||||
// F-260620 根治(授权按钮永转圈):IPC 成功后 try_continue spawn 续跑 loop,续跑 loop 可能立刻又
|
||||
// 触发 AiDirAuthRequired(同 id 不可能,但其他 id 可能)置新 pendingDirAuths 项;该 id 的项会由
|
||||
// AiApprovalResult 按 id filter 移除。这里不依赖 pendingDirAuths 状态复位 actingIds——改为
|
||||
// watch pendingDirAuths,某 id 离开数组(已被后端消费)即删 actingIds,精准复位。
|
||||
}
|
||||
|
||||
/** pendingDirAuths 变化时,离开数组(已被后端 AiApprovalResult/AiCompleted/AiError 按 id 移除)的
|
||||
* id 从 actingIds 删,精准复位"处理中"态(数组化后多挂起,单 watch===null 模式不再适用)。 */
|
||||
watch(() => pendingDirAuths.value, (items) => {
|
||||
const liveIds = new Set(items.map(p => p.id))
|
||||
for (const id of [...actingIds]) {
|
||||
if (!liveIds.has(id)) actingIds.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
/** M8:visibleDirAuths 出现新挂起项时,scrollIntoView 让审批入口立刻可见(挂起弹窗在输入区上方,
|
||||
* 易被消息流/进度条挤出视口,用户不滚动看不到 → 挂起无声卡死)。仅新增项触发滚动,避免已可见项
|
||||
* 反复跳动打扰。 */
|
||||
watch(() => visibleDirAuths.value.map(d => d.id).join('|'), async () => {
|
||||
await nextTick()
|
||||
// 滚到最新(数组末)项,block:'center' 让按钮组居中可见
|
||||
const last = dirAuthRefs.value[dirAuthRefs.value.length - 1]
|
||||
if (last) last.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-dir-auth {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-bottom: 6px;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-dir-auth-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-dir-auth-hint {
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ai-dir-auth-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ai-dir-auth-btn {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
transition: filter 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-dir-auth-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ai-dir-auth-btn--once {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.ai-dir-auth-btn--once:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.ai-dir-auth-btn--session {
|
||||
background: color-mix(in srgb, var(--df-accent) 15%, transparent);
|
||||
color: var(--df-accent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
}
|
||||
.ai-dir-auth-btn--session:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.ai-dir-auth-btn--always {
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
}
|
||||
.ai-dir-auth-btn--always:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.ai-dir-auth-btn--deny {
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-dir-auth-btn--deny:hover:not(:disabled) {
|
||||
color: var(--df-text);
|
||||
border-color: var(--df-text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
复用 MaxRoundsCard 模式:per-conv 守卫(pendingHelp.conversationId === active)+ 行为已记入本地 ref。
|
||||
显 reason(原因)+ context(最近错误 sample)+ options 按钮供用户选(换思路/授权路径/人工接管)。
|
||||
选 option → 仅关闭求助卡(后端 loop 已终止,用户重新发消息即"换思路/人工接管"入口,
|
||||
路径授权走 DirAuthDialog 既有链路)。机制优先 prompt 说教:代码强制熔断 + 结构化求助,
|
||||
机制优先 prompt 说教:代码强制熔断 + 结构化求助,
|
||||
非"教 AI 失败就问用户"(LLM 不可靠)。 -->
|
||||
<div v-if="helpActive" ref="helpRef" class="ai-help-required">
|
||||
<span class="ai-help-required-text">{{ helpActive.reason }}</span>
|
||||
@@ -64,7 +64,7 @@ watch(helpActive, async (p) => {
|
||||
|
||||
/** 选 option:关求助卡即可。后端 loop 已在 AiHelpRequired 时终止(guard.reset + return),
|
||||
* 用户选 option 后的"换思路/授权路径/人工接管"由用户重新发消息/操作触发(无独立 IPC)。
|
||||
* 点"授权路径"提示用户路径授权链路(DirAuthDialog 自动触发,无需此卡代发)。
|
||||
* 路径授权链路已退役(path_auth 2026-10-25),无需此卡代发。
|
||||
* 选后清 pendingHelp,卡片隐藏(对齐 MaxRoundsCard 点继续/停止后由事件清的语义,此处无续跑事件故立即清)。 */
|
||||
function handleSelectOption(opt: string): void {
|
||||
void opt // option 仅区分按钮文案,后端 loop 已终止无独立 IPC 续跑,选即清卡片
|
||||
|
||||
@@ -1782,7 +1782,7 @@ defineExpose({
|
||||
v-if="m.toolCalls && m.toolCalls.length > 0"
|
||||
ref="toolCardListRef"
|
||||
:toolCalls="m.toolCalls"
|
||||
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
|
||||
@approve="({ id, approved }) => store.approveToolCall(id, approved)"
|
||||
@batch-approve="(decision) => store.batchApprove(decision)"
|
||||
@install-obscura="onInstallObscura"
|
||||
@pending-out-of-view="onPendingOutOfView"
|
||||
@@ -1817,7 +1817,7 @@ defineExpose({
|
||||
<div v-if="m.toolCalls && m.toolCalls.length > 0" class="ai-trace-round">
|
||||
<ToolCardList
|
||||
:toolCalls="m.toolCalls"
|
||||
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
|
||||
@approve="({ id, approved }) => store.approveToolCall(id, approved)"
|
||||
@batch-approve="(decision) => store.batchApprove(decision)"
|
||||
@install-obscura="onInstallObscura"
|
||||
@pending-out-of-view="onPendingOutOfView"
|
||||
@@ -2124,13 +2124,11 @@ defineExpose({
|
||||
</div>
|
||||
|
||||
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->
|
||||
<!-- path_auth 审批链阶段3b:approve 事件透传 decision(path 类 once/always/deny),
|
||||
store.approveToolCall 据 decision 走 authorizeDir,缺省走 approve。 -->
|
||||
<ToolCardList
|
||||
v-if="item.msg.toolCalls && item.msg.toolCalls.length > 0"
|
||||
ref="toolCardListRef"
|
||||
:toolCalls="item.msg.toolCalls"
|
||||
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
|
||||
@approve="({ id, approved }) => store.approveToolCall(id, approved)"
|
||||
@batch-approve="(decision) => store.batchApprove(decision)"
|
||||
@install-obscura="onInstallObscura"
|
||||
@pending-out-of-view="onPendingOutOfView"
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
// 无人值守边界配置卡(P1 总装简化):授权面收敛单一档——风险上限固定 Medium、
|
||||
// 目录白名单不开放(空=不额外扩权,全局 allowed_dirs 照常生效),破坏性命令恒禁止;
|
||||
// 目录白名单不开放(全局路径授权已退役为默认全放行,2026-10-25),破坏性命令恒禁止;
|
||||
// 可调项只剩预算两项(最大轮次/最长运行时长),各带「不限」勾选(0 直传,后端 0=不限)。
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
+30
-108
@@ -7,15 +7,15 @@
|
||||
//! 下沉范围(严格限定为「无依赖、被多个 composable 共用」的成员):
|
||||
//! - nextMsgId / _msgCounter:events/stream/send/window 四个 composable 共用同一计数器
|
||||
//! - findToolCall:events/send 两个 composable 共用的纯查找函数(仅读 state.messages)
|
||||
//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers):
|
||||
//! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层
|
||||
//! - 审批到期登记(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers):
|
||||
//! 前端 auto-reject 定时器已退役(2026-10-25),现为后端透传到期时刻的登记/清除,供倒计时 UI 读
|
||||
|
||||
import { reactive, ref } from 'vue'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiMessage, AiToolCallInfo, ConvState, MessageId } from '@/api/types'
|
||||
import type { AiMessage, AiToolCallInfo, ConvState } from '@/api/types'
|
||||
|
||||
/** 通知会话列表刷新(newConversation/deleteConversation/rename 等触发侧栏更新) */
|
||||
export function notifyConversationChanged(payload?: { deletedConvId?: string }): void {
|
||||
@@ -64,7 +64,7 @@ let _messagesGetter: (() => AiMessage[]) | null = null
|
||||
|
||||
/**
|
||||
* @internal 由 stores/ai.ts 在 state 创建后调用,注入 getter 函数。
|
||||
* 本模块内 findToolCall / startApprovalTimer 通过 getMessages() 访问。
|
||||
* 本模块内 findToolCall 通过 getMessages() 访问。
|
||||
* 用 getter 而非缓存数组引用,确保 switchConversation 等整体替换 messages 后仍可读到最新数组。
|
||||
*/
|
||||
export function __bindMessages(getter: (() => AiMessage[]) | AiMessage[]): void {
|
||||
@@ -114,98 +114,45 @@ export function findToolCall(id: string): AiToolCallInfo | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ── 审批超时计时器(原 useAiSend,下沉打破 events↔send 循环依赖) ──
|
||||
// ── 审批超时到期时刻(原前端 auto-reject 计时器,2026-10-25 退役为后端单一真值) ──
|
||||
//
|
||||
// 超时收敛单一真值 = 后端(arm_approval_timeout_timers 到点主动取消 + cleanup_expired_approvals
|
||||
// 惰性兜底)。前端本地 setTimeout 自动 ai_approve(false) 定时器已退役:双定时器并存存在
|
||||
// 口径漂移(前端 Settings 阈值 vs 后端 approval_timeout_minutes 各算各的,先后到点互相打架),
|
||||
// 且后台会话超时串视图问题随自动拒绝一并消失。本 Map 仅存后端透传的到期时刻,供倒计时 UI 读取。
|
||||
|
||||
/// 审批超时阈值来源的 appSettings key(运行期可调,默认 15min)。
|
||||
/// 值语义:正整数=毫秒超时;0=不限时(跳过计时器);缺省=900000(15min)。
|
||||
const APPROVAL_TIMEOUT_KEY = 'df-approval-timeout'
|
||||
const APPROVAL_TIMEOUT_DEFAULT_MS = 900_000 // 15 分钟
|
||||
/** toolCallId → 后端透传的审批到期时刻(ms Unix 时间戳,AiApprovalRequired.expires_at_millis) */
|
||||
const _approvalExpiresAt = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* 读取当前审批超时阈值(ms):从 appSettings 取 `df-approval-timeout`,
|
||||
* 缺省 900000(15min),返回 0 表示用户配置为「不限时」(调用方应跳过计时器)。
|
||||
*
|
||||
* 每次 startApprovalTimer 调用时取最新值 —— 用户在 Settings 页调整后,下一笔审批即生效
|
||||
* (已在跑的计时器沿用旧值,符合「不影响已发起审批」的预期)。
|
||||
* 登记审批到期时刻(幂等:同 id 重复登记不覆盖)。
|
||||
* @param expiresAtMs 后端透传的到期时刻;null=超时禁用(approval_timeout_minutes=0)或不可得,
|
||||
* 不登记(倒计时回退挂起时长展示)。
|
||||
*/
|
||||
function getApprovalTimeoutMs(): number {
|
||||
const appSettings = useAppSettingsStore()
|
||||
const raw = appSettings.get<number>(APPROVAL_TIMEOUT_KEY, APPROVAL_TIMEOUT_DEFAULT_MS)
|
||||
// 防御:负数 / NaN 视为默认值;0 保留(语义=不限时)
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) {
|
||||
return APPROVAL_TIMEOUT_DEFAULT_MS
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** toolCallId → 审批超时计时器 + 起点信息(startedAt/timeoutMs 供前端到期倒计时读剩余) */
|
||||
interface ApprovalTimerInfo {
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
/** 计时器启动时刻(ms)——即本笔审批超时窗口的真实起点 */
|
||||
startedAt: number
|
||||
/** 本计时器生效的超时阈值(ms) */
|
||||
timeoutMs: number
|
||||
}
|
||||
const _approvalTimers = new Map<string, ApprovalTimerInfo>()
|
||||
|
||||
/**
|
||||
* 启动审批超时计时器(幂等:同 id 重复启动不重建)。
|
||||
* 到点回调按 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,
|
||||
kind: 'risk' | 'path' = 'risk',
|
||||
): void {
|
||||
if (_approvalTimers.has(toolCallId)) return
|
||||
// 0 = 用户配置为「不限时」,跳过计时器(对齐旧 Infinity 行为)
|
||||
const timeoutMs = getApprovalTimeoutMs()
|
||||
if (timeoutMs === 0) return
|
||||
const timer = setTimeout(() => {
|
||||
_approvalTimers.delete(toolCallId)
|
||||
// 按 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)
|
||||
})
|
||||
// UX-P2-A3:气泡推送改经 per-conv 守卫(函数追加在本文件尾部),后台会话超时不串当前视图
|
||||
pushApprovalTimeoutBubble(toolName, toolCallId)
|
||||
}, timeoutMs)
|
||||
_approvalTimers.set(toolCallId, { timer, startedAt: Date.now(), timeoutMs })
|
||||
export function startApprovalTimer(toolCallId: string, expiresAtMs: number | null): void {
|
||||
if (expiresAtMs === null) return
|
||||
if (!_approvalExpiresAt.has(toolCallId)) _approvalExpiresAt.set(toolCallId, expiresAtMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某审批距前端自动拒绝的剩余毫秒。
|
||||
* 审批超时是前端本计时器驱动的(到点才调 IPC 拒绝),故剩余值与实际拒绝时刻精确对齐,
|
||||
* 优于后端 created_at(不序列化透传)或 ToolCard 本地到达时间的近似。
|
||||
* 返回 null = 无活跃计时器(未挂起/已到期/用户配置不限时),调用方应回退其他展示。
|
||||
* 取某审批距后端超时收敛的剩余毫秒。
|
||||
* 到期时刻为后端 armed 时刻透传,与后端定时任务到点取消精确对齐。
|
||||
* 返回 null = 无到期记录(超时禁用/未挂起/已清),调用方回退挂起时长展示;过期后返回负数。
|
||||
*/
|
||||
export function getApprovalRemainingMs(toolCallId: string): number | null {
|
||||
const info = _approvalTimers.get(toolCallId)
|
||||
if (!info) return null
|
||||
return info.timeoutMs - (Date.now() - info.startedAt)
|
||||
const expiresAt = _approvalExpiresAt.get(toolCallId)
|
||||
if (expiresAt === undefined) return null
|
||||
return expiresAt - Date.now()
|
||||
}
|
||||
|
||||
/** 清除单个审批计时器 */
|
||||
/** 清除单个审批到期记录 */
|
||||
export function clearApprovalTimer(toolCallId: string): void {
|
||||
const info = _approvalTimers.get(toolCallId)
|
||||
if (info) {
|
||||
clearTimeout(info.timer)
|
||||
_approvalTimers.delete(toolCallId)
|
||||
}
|
||||
_approvalExpiresAt.delete(toolCallId)
|
||||
}
|
||||
|
||||
/** 清除全部审批计时器(整流超时收尾/组件卸载时调用) */
|
||||
/** 清除全部审批到期记录(整流超时收尾/组件卸载/切会话时调用) */
|
||||
export function clearAllApprovalTimers(): void {
|
||||
for (const info of _approvalTimers.values()) clearTimeout(info.timer)
|
||||
_approvalTimers.clear()
|
||||
_approvalExpiresAt.clear()
|
||||
}
|
||||
|
||||
// ── #14 任务式委托:per-conv 轮次追踪 convRounds(#14 桌面任务卡数据源) ──
|
||||
@@ -772,33 +719,8 @@ export async function backfillConvAgentFinished(convId: string | null | undefine
|
||||
}
|
||||
}
|
||||
|
||||
// ── UX-P2-A3 审批超时错误气泡 per-conv 守卫 ──
|
||||
//
|
||||
// 背景:startApprovalTimer 到点把「审批超时已自动拒绝」错误气泡直接推进 getMessages()
|
||||
// (当前活跃视图),后台会话 A 的审批超时,气泡落在用户正查看的会话 B 里(跨会话串扰)。
|
||||
// 本守卫按审批所属 conv 过滤:仅当审批所属会话是当前活跃会话(或无法归属)才推视图气泡;
|
||||
// 后台会话的超时拒绝 IPC 照常执行,只是不串视图。
|
||||
// 归属反查:到点时刻从 per-conv 待审批集合(convPendingApprovals)按 toolCallId 反查 convId——
|
||||
// 此时拒绝 IPC 才刚发出,审批结果事件未到,集合项必在;查不到(旧数据/已被清)保守回落为推,
|
||||
// 不改变无归属场景的原行为。
|
||||
export function pushApprovalTimeoutBubble(toolName: string, toolCallId: string): void {
|
||||
let convId: string | null = null
|
||||
for (const [k, arr] of convPendingApprovals) {
|
||||
if (arr.some(p => p.id === toolCallId)) {
|
||||
convId = k
|
||||
break
|
||||
}
|
||||
}
|
||||
const active = getState()?.activeConversationId ?? null
|
||||
if (convId && convId !== active) return
|
||||
getMessages().push({
|
||||
id: `approval-timeout-${nextMsgId()}` as MessageId,
|
||||
role: 'assistant',
|
||||
content: t('ai.approvalTimeout', { toolName }),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
// ── UX-P2-A3 审批超时错误气泡已随前端 auto-reject 定时器一并退役(2026-10-25) ──
|
||||
// 超时终态由后端定时任务 emit AiApprovalResult 收敛,不再注入本地错误气泡。
|
||||
|
||||
/** 队列被清空的统一提示(UX-P2-A2:AiError/regenerate/editMessage 清队从静默改 toast,
|
||||
* 清 0 条不提示;经 ai-toast 事件总线,AiChat 本地 toast 消费) */
|
||||
|
||||
@@ -594,7 +594,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
|
||||
if (_switchBufferedText) state.currentText = _switchBufferedText
|
||||
// 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批,
|
||||
// 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。
|
||||
// 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。
|
||||
// 恢复后置 pending_approval 态(path_auth 双 kind 已退役,2026-10-25)。
|
||||
// F2-1纯文本会话跳过挂起查询(省 1 次 IPC + 后端全扫)——仅当目标会话消息
|
||||
// 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。
|
||||
const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0)
|
||||
@@ -607,8 +607,6 @@ export async function switchConversation(id: string, force = false, opts?: { noC
|
||||
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals
|
||||
if (mySwitchId !== _latestSwitchId) return 'ok'
|
||||
if (pending.length) {
|
||||
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染
|
||||
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) {
|
||||
@@ -616,31 +614,17 @@ export async function switchConversation(id: string, force = false, opts?: { noC
|
||||
if (pendingIds.has(tc.id)) {
|
||||
tc.status = 'pending_approval'
|
||||
bumpToolEpoch()
|
||||
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,
|
||||
// 恢复的历史挂起带目标会话 id:会话终止收尾按此仅清本会话的待审批项,不连累并发会话。
|
||||
conversationId: id,
|
||||
})
|
||||
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过;
|
||||
// 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称)
|
||||
startApprovalTimer(tc.id, tc.name, kind)
|
||||
// 登记后端透传的到期时刻(保持与 AiApprovalRequired 事件处理对称;超时收敛单一真值=后端)
|
||||
const exp = pending.find(p => p.tool_call_id === tc.id)?.expires_at_millis
|
||||
startApprovalTimer(tc.id, typeof exp === 'number' ? exp : null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! - useAiStreamingEvents.handleStreamingEvent(流式)
|
||||
//! - useAiToolEvents.handleToolEvent(工具)
|
||||
//! - useAiLifecycleEvents.handleLifecycleEvent(生命周期)
|
||||
//! 跨域共享的 helper/状态(textIdle/pendingMaxRounds/pendingDirAuths/pendingHelp、工具慢执行
|
||||
//! 跨域共享的 helper/状态(textIdle/pendingMaxRounds/pendingHelp、工具慢执行
|
||||
//! 计时器、delta 去重 Map、flushCurrentText/friendlyError/notifyConversationChanged)已下沉到
|
||||
//! aiShared 或 useAiPendingState(叶子)。依赖方向:分派器 → 各叶子 handler → aiShared / 叶子(无环)。
|
||||
//!
|
||||
@@ -46,9 +46,9 @@ let _leaseCount = 0
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助/停靠由各自 case 内 clear)。
|
||||
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/求助/停靠由各自 case 内 clear)。
|
||||
// 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired', 'AiTaskParked'])
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiHelpRequired', 'AiTaskParked'])
|
||||
|
||||
// 后台会话完成 toast 的摘要截长(轻提醒只够判断「做完了/值不值得切回去看」)
|
||||
const AGENT_FINISHED_TOAST_SUMMARY_MAX = 40
|
||||
@@ -57,7 +57,7 @@ const AGENT_FINISHED_TOAST_SUMMARY_MAX = 40
|
||||
* 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv
|
||||
* 待审批集合(aiShared.addPendingApproval),不污染当前视图。 */
|
||||
function isGlobalApprovalEvent(event: AiChatEvent): boolean {
|
||||
return event.type === 'AiApprovalRequired' || event.type === 'AiDirAuthRequired'
|
||||
return event.type === 'AiApprovalRequired'
|
||||
}
|
||||
|
||||
/** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)。
|
||||
|
||||
@@ -11,7 +11,7 @@ import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { aiApi } from '@/api/ai'
|
||||
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared, clearConvRound } from './aiShared'
|
||||
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
||||
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
||||
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
|
||||
@@ -129,8 +129,6 @@ async function cleanupTerminatedConversation(convId: string, reason: 'AiComplete
|
||||
pendingTaskParked.delete(convId)
|
||||
// 无人值守进度快照:终态即清(事件即逝态,终止后不留陈旧「进行中」;下次 loop 每 5 轮重建)
|
||||
pendingTaskProgress.delete(convId)
|
||||
// 路径授权挂起仅清本 conv(终止只清本会话弹窗,不连累并发会话;无归属旧项保守保留)
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId)
|
||||
// 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响;
|
||||
// conversationId 缺失的旧项无法归属,保守保留防误清正在审批的卡)
|
||||
const removedApprovals = state.pendingApprovals.filter(p => p.conversationId === convId)
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
//! AI 待决/流式信号共享态(叶子)—— textIdle / pendingMaxRounds / pendingDirAuths / pendingHelp
|
||||
//! AI 待决/流式信号共享态(叶子)—— textIdle / pendingMaxRounds / pendingHelp
|
||||
//! 及各自的定时器与 push/remove。
|
||||
//!
|
||||
//! 叶子定位:仅被导入,不导入任何子 composable(依赖仅 vue + appSettings),故无环风险。
|
||||
//! 叶子定位:仅被导入,不导入任何子 composable(依赖仅 vue),故无环风险。
|
||||
//! useAiEvents 拆分后,streaming/tool/lifecycle 三域与组件(ChatInput/MaxRoundsCard/
|
||||
//! DirAuthDialog/HelpRequiredCard)统一从这里取这些跨域信号。
|
||||
//! HelpRequiredCard)统一从这里取这些跨域信号。
|
||||
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 文本空闲信号:streaming=true 且 300ms 无新 delta 时=true,按钮据此显「发送」而非「停止」。
|
||||
// 不动 streaming(streaming 是渲染态,控制 MessageList 流式 block 显隐,不能中途翻转)。
|
||||
@@ -35,37 +32,6 @@ export function clearTextIdleTimer(): void {
|
||||
// 消费方(MaxRoundsCard)按 pendingMaxRounds === activeConversationId 精确比对,多会话不串台。
|
||||
export const pendingMaxRounds = ref<string | null>(null)
|
||||
|
||||
// 路径授权挂起(后端 AiDirAuthRequired 置,用户决策后清)。数组 push 多条并存:
|
||||
// 同轮多个文件工具落不同未授权目录时,后端连发多条,单 ref 会被后到覆盖致弹窗丢失。
|
||||
export interface PendingDirAuth {
|
||||
id: string
|
||||
tool: string
|
||||
path: string
|
||||
dir: string
|
||||
conversationId?: string
|
||||
}
|
||||
export const pendingDirAuths = ref<PendingDirAuth[]>([])
|
||||
|
||||
// 多挂起并存开关(appSettings key `df-ai-multi-pending-auth`):开=数组 push 多条;
|
||||
// 关=单 ref 覆盖(后到覆盖先到,行为对齐旧版)。
|
||||
function isMultiPendingAuth(): boolean {
|
||||
return appSettings.get<boolean>('df-ai-multi-pending-auth', true)
|
||||
}
|
||||
|
||||
/** 向 pendingDirAuths 追加一条(开关控制多挂起并存 vs 单 ref 覆盖) */
|
||||
export function pushPendingDirAuth(p: PendingDirAuth): void {
|
||||
if (isMultiPendingAuth()) {
|
||||
pendingDirAuths.value.push(p)
|
||||
} else {
|
||||
pendingDirAuths.value = [p]
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 id 从 pendingDirAuths 移除一条 */
|
||||
export function removePendingDirAuth(id: string): void {
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => p.id !== id)
|
||||
}
|
||||
|
||||
// 求助协议挂起(agent 断路器熔断时后端 AiHelpRequired 置):显 reason + options 驱动求助卡。
|
||||
// 存挂起 convId,消费方按 activeConversationId 精确比对(多会话并发不串台)。
|
||||
export interface PendingHelp {
|
||||
@@ -78,7 +44,7 @@ export const pendingHelp = ref<PendingHelp | null>(null)
|
||||
|
||||
// 无人值守停靠挂起(后端 AiTaskParked 置):convId 键控 Map 而非单 ref。
|
||||
// 与 pendingHelp 单 ref 的差异原因:停靠是无人值守长任务的常态终局,多会话并发下
|
||||
// 多个会话可各自停靠,单 ref 后到覆盖先到会丢卡(待审批 pendingDirAuths 同款教训)。
|
||||
// 多个会话可各自停靠,单 ref 后到覆盖先到会丢卡(待审批卡多挂起并存同款教训)。
|
||||
// 消费方(TaskParkedCard)按 activeConversationId 取卡,per-conversation 归位不串台。
|
||||
// 幂等:同会话重复停靠按键覆盖(后端单出口单发,覆盖仅防监听重放双卡)。
|
||||
export interface PendingTaskParked {
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
//! 流式事件域 — handleStreamingEvent
|
||||
//!
|
||||
//! 处理:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiDirAuthRequired。
|
||||
//! 处理:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry。
|
||||
//! AiMaxRoundsReached 不处理:后端达 max 已改 AiCompleted(incomplete=true) 自动完成(暂停态
|
||||
//! 语义已废),该事件后端零 emit;pendingMaxRounds/MaxRoundsCard 续停操作卡为遗留死链待清理。
|
||||
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream,不导入任何同级子 composable。
|
||||
//! _lastDelta 去重 Map 与 flushCurrentText 因跨域共用已下沉 aiShared,这里仅经 accessor 读写。
|
||||
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText, addPendingApproval, setConvRound, getConvStreamState } from './aiShared'
|
||||
import { resetTextIdleTimer, clearTextIdleTimer, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState'
|
||||
import { clearStreamWatchdog, resetStreamWatchdog } from './useAiStream'
|
||||
import { nextMsgId, getLastDelta, setLastDelta, flushCurrentText, setConvRound, getConvStreamState } from './aiShared'
|
||||
import { resetTextIdleTimer, clearTextIdleTimer } from './useAiPendingState'
|
||||
import { resetStreamWatchdog } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { aiApi } from '@/api/ai'
|
||||
import { shouldSelfHealStreaming } from './streamSelfHeal'
|
||||
@@ -20,12 +19,6 @@ import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// path_auth 统一审批模型开关(appSettings key `df-ai-unified-approval`):开=path 挂起归一进
|
||||
// pendingApprovals(kind='path',ToolCard 内联审批);关=走 pendingDirAuths 独立弹窗(DirAuthDialog)。
|
||||
function isUnifiedApproval(): boolean {
|
||||
return appSettings.get<boolean>('df-ai-unified-approval', true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳自愈(火后不理,看板 949 兜底):前端流式态丢失而后端仍在生成时恢复 streaming + 重启看门狗。
|
||||
*
|
||||
@@ -131,54 +124,6 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiDirAuthRequired': {
|
||||
// 路径授权挂起(后端 generating 保持 true,等用户决策):看门狗跳过 reset,由本 case clear。
|
||||
clearStreamWatchdog(event.conversation_id || undefined)
|
||||
clearToolSlowTimer(event.id)
|
||||
// 统一审批模型开:path 挂起归一进待审批集合(kind='path'),ToolCard 内联审批。
|
||||
// per-conv 归会话追加(同 id 幂等):跨会话事件互不覆盖;首次到达才做工具卡改写等一次性副作用。
|
||||
if (isUnifiedApproval()) {
|
||||
const added = addPendingApproval({
|
||||
id: event.id,
|
||||
name: event.tool,
|
||||
args: { path: event.path },
|
||||
status: 'pending_approval',
|
||||
kind: 'path',
|
||||
dir: event.dir,
|
||||
path: event.path,
|
||||
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
|
||||
reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }),
|
||||
})
|
||||
if (added) {
|
||||
// 同步改对应工具卡的 tc,让 ToolCard 显 path 类审批按钮(once/always/deny)
|
||||
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 类挂起同样启动审批超时计时器(到点回调调 authorizeDir(id,'deny'))
|
||||
startApprovalTimer(event.id, event.tool, 'path')
|
||||
}
|
||||
// 通知 ToolCardList 自动展开折叠组 + scroll(挂起卡落折叠分组 display:none 用户不可见)
|
||||
void emit('ai-pending-arrived', { toolCallId: event.id })
|
||||
return true
|
||||
}
|
||||
// 统一审批模型关(兜底):走 DirAuthDialog 独立弹窗(push pendingDirAuths,开关控多挂起并存)
|
||||
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
|
||||
}
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { emit } from '@tauri-apps/api/event'
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { findToolCall, clearApprovalTimer, startApprovalTimer, startToolSlowTimer, clearToolSlowTimer, addPendingApproval, removePendingApprovalById, bumpToolEpoch } from './aiShared'
|
||||
import { removePendingDirAuth } from './useAiPendingState'
|
||||
import { clearStreamWatchdog } from './useAiStream'
|
||||
import type { AiChatEvent, AiToolCallInfo } from '@/api/types'
|
||||
|
||||
@@ -92,8 +91,7 @@ export function handleToolEvent(event: AiChatEvent): boolean {
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'pending_approval',
|
||||
kind: event.kind ?? 'risk',
|
||||
// reason 必须写入 info,否则浮窗 ApprovalPopup 的 tc.reason 永远空(审批说明丢失)
|
||||
// reason 必须写入 info,否则 ToolCard/浮层的 tc.reason 永远空(审批说明丢失)
|
||||
reason: event.reason,
|
||||
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
|
||||
}
|
||||
@@ -106,9 +104,10 @@ export function handleToolEvent(event: AiChatEvent): boolean {
|
||||
// write_file 审批注入行级 diff(旧文件 vs 新内容),前端审批卡预览
|
||||
if (event.diff) tc.diff = event.diff
|
||||
}
|
||||
// 进入审批等待 → 取消该工具慢执行计时器(审批耗时由用户主导,非执行慢),再启动审批超时计时器
|
||||
// 进入审批等待 → 取消该工具慢执行计时器(审批耗时由用户主导,非执行慢),
|
||||
// 再登记后端透传的到期时刻(超时收敛单一真值=后端,前端无本地自动拒绝)
|
||||
clearToolSlowTimer(event.id)
|
||||
startApprovalTimer(event.id, event.name, 'risk')
|
||||
startApprovalTimer(event.id, event.expires_at_millis ?? null)
|
||||
// 通知 ToolCardList 展开折叠组 + scroll(pending 卡落折叠分组不可见时)
|
||||
void emit('ai-pending-arrived', { toolCallId: event.id })
|
||||
}
|
||||
@@ -134,8 +133,6 @@ export function handleToolEvent(event: AiChatEvent): boolean {
|
||||
startToolSlowTimer(event.id, tc?.name || '')
|
||||
clearApprovalTimer(event.id)
|
||||
}
|
||||
// 路径授权挂起的工具收到 ApprovalResult → 按 id 移除该条(数组化后只清这一条)
|
||||
removePendingDirAuth(event.id)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
*
|
||||
* 两层导出:
|
||||
* - `toolDisplayName(tc)` 模块级纯函数(i18n 走 i18n-helpers 全局 t,project store 走单例):
|
||||
* 无 setup 上下文依赖,可被 ToolCard(经 composable 包装)与 ApprovalPopup(独立 WebviewWindow,
|
||||
* 无 composable 上下文)共用。抽离自原 composable 内部函数,消除 ApprovalPopup 的 _placeholder hack。
|
||||
* 无 setup 上下文依赖,ToolCard 与其他无 composable 上下文的调用方均可共用。
|
||||
* - `useToolCardHeader(getTc)` composable:封装 displayArgValue/argsEntries(依赖 getTc 取当前 tc,
|
||||
* 响应式由 store 保证),返回 { toolDisplayName(转发模块级), displayArgValue, argsEntries }。
|
||||
*
|
||||
@@ -68,8 +67,7 @@ function taskNameById(id: string): string | undefined {
|
||||
* 工具显示名称(含目标摘要);文件类按 path,项目类按 id 解析项目名,create_* 用固定文案。
|
||||
*
|
||||
* 模块级纯函数:无 setup 依赖(i18n 走 i18n-helpers 的全局 t,project store 走单例),
|
||||
* 故可被 ToolCard(经 useToolCardHeader 包装)与 ApprovalPopup(独立 WebviewWindow,无 store/emit
|
||||
* 上下文)共用,消除 ApprovalPopup 原 `_placeholder` 占位 hack(构造空 AiToolCallInfo 喂 composable)。
|
||||
* 故可被 ToolCard(经 useToolCardHeader 包装)及其他无 store/emit 上下文的调用方共用。
|
||||
*/
|
||||
export function toolDisplayName(tc: AiToolCallInfo): string {
|
||||
const p = argString(tc.args, 'path')
|
||||
|
||||
@@ -54,7 +54,6 @@ export default {
|
||||
queueWillContinue: 'Stopped. {n} queued message(s) will still send automatically.',
|
||||
// Success toast when a runtime supplement is merged into the in-flight answer (u-claw semantics: no new round)
|
||||
injectMerged: 'Merged into the current answer; takes effect next round.',
|
||||
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
|
||||
// conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent)
|
||||
loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
|
||||
switchConvFail: 'Failed to switch conversation. Please retry.',
|
||||
|
||||
@@ -81,13 +81,6 @@ export default {
|
||||
approvalWaitingTitle: 'Approval required',
|
||||
approvalWaitingDesc: 'Click to review and handle',
|
||||
|
||||
// ── System-level approval popup (standalone window, visible when app unfocused) ──
|
||||
popupTitle: 'Approval pending',
|
||||
popupTagPath: 'Path auth',
|
||||
popupTagRisk: 'Risk action',
|
||||
popupOpenPanel: 'Open approval panel',
|
||||
// 空态文案:正常空态会被 maybeAutoClose 即时关窗,此 hint 仅 close 失败时兜底
|
||||
popupEmptyHint: 'Closing…',
|
||||
|
||||
// ── Provider status ──
|
||||
clickToSwitchProvider: 'Click to switch provider',
|
||||
@@ -240,19 +233,6 @@ export default {
|
||||
// ── L1 help protocol (aichat experience & agent capability redesign §2.3, 2026-06-21): circuit-breaker help card ──
|
||||
helpRequiredAck: 'Choice noted. Please rephrase your request to try another approach / continue',
|
||||
|
||||
// ── F-260619-03 Phase B: path authorization dialog (LLM accessing dir outside whitelist) ──
|
||||
dirAuthRequired: 'Path authorization required',
|
||||
dirAuthHint: '{tool} wants to access: {path}',
|
||||
dirAuthOnce: 'This time',
|
||||
dirAuthSession: 'This session',
|
||||
dirAuthAlways: 'Always',
|
||||
dirAuthDeny: 'Deny',
|
||||
dirAuthFailed: 'Authorization failed: {msg}',
|
||||
// M5:path deny second confirmation (deny is irreversible — tool returns Err, loop continues without retry; asymmetry with approve, prevents mis-clicks)
|
||||
dirAuthDenyConfirm: 'Deny authorization? The tool will fail immediately and will not be retried this round.',
|
||||
// Persistent bypass banner shown at the top of the chat area while "trust all directories" is on
|
||||
pathAuthBypassBanner: 'Authorization fully bypassed (including sensitive directories): AI can access any file directly',
|
||||
|
||||
// ── Input Augmentation layer: mention/skill injection feedback ──
|
||||
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
|
||||
searchFilesBlocked: "search_files only works inside authorized dirs. Use {'@'}[project] to reference or provide an absolute path.",
|
||||
|
||||
@@ -12,13 +12,10 @@ export default {
|
||||
// args folding (multi-arg default shows first, "N more" expands)
|
||||
argsMore: '{n} more',
|
||||
argsCollapse: 'Collapse args',
|
||||
// batch approve high-risk interception (count badge + confirm text)
|
||||
batchHighRiskHint: '{n} high-risk',
|
||||
// R7 group-header failure badge: folded group contains failed tool cards
|
||||
groupFailCount: '{n} failed',
|
||||
// #14 batch approve partial failure toast
|
||||
batchPartialFail: 'Batch done: {ok} approved, {fail} failed.',
|
||||
batchHighRiskConfirm: 'Batch approval includes {n} high-risk operations (e.g. delete/command execution/network requests). Approve all?',
|
||||
rejectedHint: 'User rejected this action',
|
||||
// tool execution slow hint (frontend fallback, no backend cancel IPC)
|
||||
executionSlow: 'Tool "{name}" is taking longer than expected, please wait…',
|
||||
@@ -165,11 +162,6 @@ export default {
|
||||
taskLabel: '"{name}"',
|
||||
taskIdNotFound: '(task not in current list, id={id})',
|
||||
|
||||
// High-risk approval second confirm (ask once more before approve; misclick cancellable)
|
||||
confirmHighDelete: 'Permanently delete? This action cannot be undone',
|
||||
confirmHighExec: 'Run this command?',
|
||||
confirmHighGeneric: 'Proceed with this high-risk action?',
|
||||
|
||||
// Tool result detail format (plain-text result + approval placeholder appends arg summary)
|
||||
resultDetail: '{raw} ({detail})',
|
||||
|
||||
@@ -199,9 +191,6 @@ export default {
|
||||
// tool failure detection copy (regex narrowed to line-anchored err_msg prefix)
|
||||
// (reuse executionFailed/executionFailedHint, no new key)
|
||||
|
||||
// http_request High-risk confirm: external network call, data may leak via URL/body
|
||||
confirmHighHttp: 'Send this HTTP request to an external server?',
|
||||
|
||||
// Approval argument key labels
|
||||
argLabel: {
|
||||
id: 'ID',
|
||||
|
||||
@@ -54,7 +54,6 @@ export default {
|
||||
queueWillContinue: '已停止,{n} 条排队消息仍将自动发送',
|
||||
// 运行中补充注入成功(u-claw 语义:纯文本补充并进当前回答,不开新轮)的 toast 提示
|
||||
injectMerged: '已并入当前回答,下一轮生效',
|
||||
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
|
||||
// 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
|
||||
loadConvFail: '对话列表加载失败,请检查网络或后端连接',
|
||||
switchConvFail: '对话切换失败,请重试',
|
||||
|
||||
@@ -81,13 +81,6 @@ export default {
|
||||
approvalWaitingTitle: '有待审批请求',
|
||||
approvalWaitingDesc: '点击查看并处理',
|
||||
|
||||
// ── 系统级审批浮窗(独立窗口,应用失焦时可见)──
|
||||
popupTitle: '审批待办',
|
||||
popupTagPath: '路径授权',
|
||||
popupTagRisk: '风险操作',
|
||||
popupOpenPanel: '打开审批面板',
|
||||
// 空态文案:正常空态会被 maybeAutoClose 即时关窗,此 hint 仅 close 失败时兜底
|
||||
popupEmptyHint: '正在关闭…',
|
||||
|
||||
// ── Provider 状态 ──
|
||||
clickToSwitchProvider: '点击切换 Provider',
|
||||
@@ -241,19 +234,6 @@ export default {
|
||||
// ── L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):断路器熔断求助卡 ──
|
||||
helpRequiredAck: '已记录选择,请重新描述需求以换思路/继续',
|
||||
|
||||
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
|
||||
dirAuthRequired: '需要路径授权',
|
||||
dirAuthHint: '{tool} 想访问:{path}',
|
||||
dirAuthOnce: '本次',
|
||||
dirAuthSession: '当前会话',
|
||||
dirAuthAlways: '始终放行',
|
||||
dirAuthDeny: '拒绝',
|
||||
dirAuthFailed: '授权失败:{msg}',
|
||||
// M5:path 类拒绝二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试,与 approve 不对称,防误点)
|
||||
dirAuthDenyConfirm: '确定拒绝授权吗?该工具将立即失败,且本轮不会重试。',
|
||||
// 「信任所有目录」总开关开启期间的常驻放行横幅(对话区顶部)
|
||||
pathAuthBypassBanner: '授权已完全放行(含敏感目录):AI 可直接访问任意文件',
|
||||
|
||||
// ── Input Augmentation 层: mention/skill 注入反馈 ──
|
||||
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
|
||||
searchFilesBlocked: "search_files 仅在已授权目录可用,请用 {'@'}[项目] 引用或提供绝对路径",
|
||||
|
||||
@@ -12,13 +12,10 @@ export default {
|
||||
// args 折叠(多参数默认显首条,「更多 N 项」展开)
|
||||
argsMore: '更多 {n} 项',
|
||||
argsCollapse: '收起参数',
|
||||
// 批量批准 High 风险拦截(计数徽标 + 二次确认文案)
|
||||
batchHighRiskHint: '含 {n} 项高风险',
|
||||
// R7 组头失败徽标:折叠组内含失败工具卡
|
||||
groupFailCount: '{n} 失败',
|
||||
// #14 批量审批部分失败提示
|
||||
batchPartialFail: '批量完成:{ok} 项成功,{fail} 项失败',
|
||||
batchHighRiskConfirm: '批量批准包含 {n} 项高风险操作(如删除/命令执行/网络请求),确认全部放行?',
|
||||
rejectedHint: '用户拒绝了此操作',
|
||||
// 工具执行超时提示(前端降级,无后端取消 IPC)
|
||||
executionSlow: '工具「{name}」执行较久,请稍候…',
|
||||
@@ -167,13 +164,6 @@ export default {
|
||||
taskLabel: '「{name}」',
|
||||
taskIdNotFound: '(任务不在当前列表, id={id})',
|
||||
|
||||
// High 风险审批二次确认文案(批准前再问一次,误点可取消)
|
||||
confirmHighDelete: '确定永久删除?此操作不可恢复',
|
||||
confirmHighExec: '确定执行此命令?',
|
||||
confirmHighGeneric: '确定执行此高危操作?',
|
||||
// http_request 高危二次确认:外发网络请求,数据可经 URL/body 泄露
|
||||
confirmHighHttp: '确定向外部服务器发起此 HTTP 请求?',
|
||||
|
||||
// 工具结果详情格式(非 JSON 纯文本 + 审批占位时补参数摘要)
|
||||
resultDetail: '{raw}({detail})',
|
||||
|
||||
|
||||
+3
-4
@@ -18,8 +18,7 @@ app.config.errorHandler = (err, instance, info) => {
|
||||
|
||||
app.use(router).use(i18n);
|
||||
|
||||
// 等路由就绪再 mount:分离窗口(approval-popup / ai-detached / fe-detached)的 hash 路由须先解析完,
|
||||
// App.vue 的 isDetached(route.path === '/approval-popup' ...)才命中真值。否则初始帧 route.path='/'
|
||||
// → isDetached=false → AppLayout 把完整主布局(侧栏+主区+AI 面板)渲染进分离小窗
|
||||
// (approval-popup 380×480 尤为明显,看起来像「小小的完整 DevFlow 界面」)。
|
||||
// 等路由就绪再 mount:分离窗口(ai-detached / fe-detached)的 hash 路由须先解析完,
|
||||
// App.vue 的 isDetached(route.path === '/ai-detached' ...)才命中真值。否则初始帧 route.path='/'
|
||||
// → isDetached=false → AppLayout 把完整主布局(侧栏+主区+AI 面板)渲染进分离小窗。
|
||||
router.isReady().then(() => app.mount("#app"));
|
||||
|
||||
@@ -241,7 +241,6 @@ const LIST_COMMANDS = new Set([
|
||||
'knowledge_list',
|
||||
'knowledge_list_candidates',
|
||||
'knowledge_events',
|
||||
'ai_get_allowed_dirs',
|
||||
'ai_pending_tool_calls',
|
||||
'ai_list_tools',
|
||||
])
|
||||
|
||||
@@ -92,13 +92,6 @@ const routes = [
|
||||
component: () => import('../views/FileExplorerDetached.vue'),
|
||||
meta: { title: '文件浏览器' },
|
||||
},
|
||||
{
|
||||
// 系统级审批提示浮窗(独立 WebviewWindow 内容):无边框 + alwaysOnTop + skipTaskbar
|
||||
path: '/approval-popup',
|
||||
name: 'ApprovalPopup',
|
||||
component: () => import('../views/ApprovalPopup.vue'),
|
||||
meta: { title: '审批待办' },
|
||||
},
|
||||
// catch-all:无效路由重定向首页(防白屏)
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
|
||||
@@ -207,7 +207,7 @@ html, body, #app {
|
||||
/* ═══ 2026-10-18 整洁性波:跨文件共享样式抽取(共享才抽取原则) ═══
|
||||
来源: dup-style-scan.json(49 条跨文件重复规则),按文件族消费波替换引用 */
|
||||
|
||||
/* — Toast 族(双轨合一:AiChat/ApprovalPopup 的 .ai-toast-* 与 Knowledge/Settings 的 .toast-*) — */
|
||||
/* — Toast 族(双轨合一:AiChat 的 .ai-toast-* 与 Knowledge/Settings 的 .toast-*) — */
|
||||
.df-toast {
|
||||
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
|
||||
padding: 8px 16px; border-radius: var(--df-radius); font-size: var(--df-fs-ctrl);
|
||||
@@ -226,7 +226,7 @@ html, body, #app {
|
||||
@keyframes df-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes df-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
|
||||
/* — 确认弹层按钮行(ConfirmDialog/AdvancedSection/AllowedDirsPanel 共享) — */
|
||||
/* — 确认弹层按钮行(ConfirmDialog/AdvancedSection 共享) — */
|
||||
.df-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.df-confirm-enter-active, .df-confirm-leave-active { transition: opacity 0.15s; }
|
||||
.df-confirm-enter-from, .df-confirm-leave-to { opacity: 0; }
|
||||
@@ -346,7 +346,7 @@ html, body, #app {
|
||||
.btn-accent { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success-bd); }
|
||||
.btn-accent:hover { background: rgba(61,219,160,0.22); }
|
||||
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||
/* 2026-10-18 整洁波收敛:实底危险钮(AdvancedSection/AllowedDirsPanel 两处重复抽取;
|
||||
/* 2026-10-18 整洁波收敛:实底危险钮(AdvancedSection 重复抽取;
|
||||
Projects 页描边款 btn-danger 属本页特有,保留其本地覆盖) */
|
||||
.btn-danger { background: var(--df-danger); color: #fff; }
|
||||
.btn-danger:hover { filter: brightness(1.1); }
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ export function formatDuration(ms: number | undefined | null, precise = false):
|
||||
return `${hour}h ${min % 60}m`
|
||||
}
|
||||
|
||||
// ── 审批等待时长(ToolCard / ApprovalPopup 双写收敛) ──
|
||||
// ── 审批等待时长 ──
|
||||
|
||||
export type WaitLevel = 'normal' | 'long' | 'urgent'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user