diff --git a/src/components/ToolResultBody.vue b/src/components/ToolResultBody.vue index 30329d3..8396961 100644 --- a/src/components/ToolResultBody.vue +++ b/src/components/ToolResultBody.vue @@ -6,7 +6,7 @@
- {{ shortPath(parsed?.path || '') }} + @@ -36,7 +36,7 @@
- {{ shortPath(parsed?.path || '') }} + {{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }} {{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}
@@ -59,7 +59,7 @@
- {{ shortPath(parsed?.path || '') }} + 「{{ parsed?.pattern }}」 {{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}
@@ -77,7 +77,7 @@
- {{ shortPath(parsed?.path || '') }} + 「{{ parsed?.pattern }}」 {{ grepHitLabel }} {{ $t('aiTool.grepTruncated') }} @@ -125,7 +125,7 @@
- {{ shortPath(parsed?.path || '') }} + {{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }} {{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}
@@ -257,6 +257,39 @@ const emit = defineEmits<{ 'install-obscura': [] }>() +// ── R6 结果路径可复制 ── +// AI 读过/写过的文件,用户想拿去本地操作(编辑器打开/再 grep)只能手动重敲路径。 +// file-bar 的 path 改为可点击:复制完整路径(parsed.path 原文,非 shortPath 截断展示), +// 按钮文字短暂变「✓」作反馈(1.2s 还原,同 R4 代码块复制反馈节奏)。 +let _pathCopiedTimer: ReturnType | null = null +async function copyResultPath(e: MouseEvent, path: string): Promise { + 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)默认展开便于看错误,成功默认折叠 @@ -403,6 +436,14 @@ watch(() => props.tc.status, (s) => { .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 { diff --git a/src/components/ai/MessageItem.vue b/src/components/ai/MessageItem.vue index cb3c985..addc404 100644 --- a/src/components/ai/MessageItem.vue +++ b/src/components/ai/MessageItem.vue @@ -34,11 +34,14 @@ defineProps<{ msg: AiMessage /** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */ canEdit?: boolean + /** I6:是否显示重新生成按钮(父合成同 canEdit 时机;作用于末条 user 触发的回复) */ + canRegenerate?: boolean }>() const emit = defineEmits<{ (e: 'copy', msg: AiMessage): void (e: 'start-edit', msg: AiMessage): void + (e: 'regenerate', msg: AiMessage): void }>() // ── Input Augmentation: 按 mention 区间切段 ── @@ -122,6 +125,10 @@ function imgAlt(img: ImageContentPart): string {
+ +
$2
', + (_m: string, attrs: string, body: string) => { + const lang = /language-([a-z0-9+#.-]+)/i.exec(attrs)?.[1] ?? '' + const langAttr = lang ? ` data-lang="${lang}"` : '' + return `
${body}
` + }, ) if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear() _decoCache.set(raw, out) @@ -718,6 +724,15 @@ async function handleRetry(): Promise { } } +/** + * I6 重新生成按钮:与错误气泡「重试」同链路(store.regenerate,后端弹末尾 AI 回复取触发 + * user 消息重跑),入口场景不同——用户对已有回复不满意主动重问,挂在末条 user 气泡 hover。 + * 与隐藏快捷键 Ctrl+R(AiChat.vue)等价,补齐 UI 入口(i18n aiChat.regenerate 文案已备)。 + */ +async function handleRegenerate(): Promise { + await handleRetry() +} + /** * fetch_url obscura 未装时"AI 帮装"按钮:自动触发 AI 对话让 AI run_command 装流式。 * 提示语足够具体(给出 cargo install + GitHub releases 两种装法 + 装完告知用户重试), @@ -1530,8 +1545,10 @@ defineExpose({ v-else-if="item.msg.role === 'user'" :msg="item.msg" :can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId" + :can-regenerate="isLastUser(item.msg) && !store.state.streaming" @copy="copyMsgContent" @start-edit="(m) => emit('start-edit', m)" + @regenerate="handleRegenerate" /> @@ -2100,6 +2117,31 @@ defineExpose({ color: var(--df-danger); border-color: var(--df-danger); } +/* R4 语言角标:左上角,hover 随复制按钮一起显现(hover pre 块时) */ +.ai-md :deep(.md-code-block)::before { + content: attr(data-lang); + position: absolute; + top: 6px; + left: 8px; + z-index: 1; + font-size: 9px; + line-height: 1.4; + letter-spacing: 0.03em; + color: var(--df-text-dim); + background: color-mix(in srgb, var(--df-bg) 82%, transparent); + padding: 1px 6px; + border-radius: var(--df-radius-sm); + opacity: 0; + transition: opacity 0.15s var(--df-ease); + pointer-events: none; +} +.ai-md :deep(.md-code-block:hover)::before { + opacity: 1; +} +/* 无 data-lang 的裸块不渲染空角标 */ +.ai-md :deep(.md-code-block:not([data-lang]))::before { + content: none; +} /* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */ .ai-msg-actions { diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index faa2096..62a8a57 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -225,10 +225,12 @@ export function handleEvent(event: AiChatEvent) { if (event.type === 'AiCompleted') { emit('ai-drain-queue', { conversationId: event.conversation_id }) } else if (event.type === 'AiError') { + // 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列 + // 会误伤其他会话排队消息,改保留 + console.warn 留痕 if (event.conversation_id) { state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id) } else { - state.queue = [] + console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)') } } } else if (event.type === 'AiTextDelta' && convId && switchingConvs.has(convId)) { diff --git a/src/composables/ai/useAiLifecycleEvents.ts b/src/composables/ai/useAiLifecycleEvents.ts index f0b9410..627cce0 100644 --- a/src/composables/ai/useAiLifecycleEvents.ts +++ b/src/composables/ai/useAiLifecycleEvents.ts @@ -208,8 +208,9 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean { state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id) notifyQueueCleared(dropped) } else { - notifyQueueCleared(state.queue.length) - state.queue = [] + // 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列 + // 会误伤其他会话排队消息,改保留 + console.warn 留痕(不再弹「已清 N 条」误导用户) + console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)') } state.messages.push({ id: `err-${nextMsgId()}` as MessageId, diff --git a/src/composables/ai/useToolApproval.ts b/src/composables/ai/useToolApproval.ts index 51398ec..7e44c89 100644 --- a/src/composables/ai/useToolApproval.ts +++ b/src/composables/ai/useToolApproval.ts @@ -9,6 +9,7 @@ */ import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue' import { useI18n } from 'vue-i18n' +import i18n from '@/i18n' import { useConfirm } from '@/composables/useConfirm' import { aiApi } from '@/api' import { getApprovalRemainingMs } from './aiShared' @@ -47,8 +48,9 @@ export function syncHighRiskTools(tools: { name: string; risk: string }[]): void if (high.length > 0) registryHighRisk = new Set(high) } -/** 审批二次确认门控:优先注册表投影,未拉到元数据时退兜底名单 */ -function isHighRiskTool(name: string): boolean { +/** 审批二次确认门控:优先注册表投影,未拉到元数据时退兜底名单。 + * V4 导出:系统浮窗 ApprovalPopup 的批准路径与 ToolCard 同门槛复用。 */ +export function isHighRiskTool(name: string): boolean { return (registryHighRisk ?? HIGH_RISK_TOOLS_FALLBACK).has(name) } @@ -80,6 +82,21 @@ export function dispatchApprovalIPC( return aiApi.approve(tc.id, approved) } +/** V4:High 风险工具二次确认文案(模块级,供 ApprovalPopup 在无组件上下文复用; + * i18n 全局实例取 t,与 useI18n 同源 locale) */ +export function highRiskConfirmText(name: string): string { + // i18n.global 整体访问触发 vue-i18n 泛型递归 TS2589,先断言窄化再取 t + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = i18n.global as any + const gt = g.t as (key: string) => string + if (name === 'run_command') return gt('aiTool.confirmHighExec') + if (name === 'http_request') return gt('aiTool.confirmHighHttp') + if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') { + return gt('aiTool.confirmHighDelete') + } + return gt('aiTool.confirmHighGeneric') +} + /** useToolApproval emit 签名 — 补全类型替代 any */ interface ToolApprovalEmit { (event: 'approve', payload: { id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }): void @@ -145,14 +162,9 @@ export function useToolApproval(tc: Ref, emit: ToolApprovalEmit) _approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000) } - /** High 风险工具的二次确认文案(按操作类型) */ + /** High 风险工具的二次确认文案(按操作类型;V4 提升为模块级导出,浮窗复用) */ function highRiskConfirmMsg(name: string): string { - if (name === 'run_command') return t('aiTool.confirmHighExec') - if (name === 'http_request') return t('aiTool.confirmHighHttp') - if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') { - return t('aiTool.confirmHighDelete') - } - return t('aiTool.confirmHighGeneric') + return highRiskConfirmText(name) } /** diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts index a49f8c5..d752981 100644 --- a/src/i18n/en/aiChat.ts +++ b/src/i18n/en/aiChat.ts @@ -112,12 +112,12 @@ export default { clearSkill: 'Clear skill', skillBadgeTip: 'Injected full skill playbook: {name}', placeholderWithSkill: 'Skill selected. Type arguments and press Enter', - placeholderDefault: "Type a message... (/ for skills, {'@'} to mention)", + placeholderDefault: "Type a message... (/ for skills, @ to mention)", skillNoMatch: 'No matching skill', skillNotLoaded: 'No local skills loaded', // ── @ entity mention (@ opens popover, select inserts [Type:Name] token) ── - mentionHint: "{'@'} to mention project/task", + mentionHint: "@ to mention project/task", mentionGroupProject: 'Projects', mentionGroupTask: 'Tasks', mentionGroupIdea: 'Ideas', @@ -207,7 +207,7 @@ export default { // ── 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.", + searchFilesBlocked: "search_files only works inside authorized dirs. Use @[project] to reference or provide an absolute path.", // /skill hot reload (ai_reload_skills IPC) success toast skillReloaded: 'Skills reloaded', // @mention entity resolve failed (id points to a project/task/idea/skill that was deleted or unavailable) @@ -288,7 +288,7 @@ export default { // ── TopBar goal/project context labels ── goalsCount: 'Goals ({n})', none: 'None', - projectLabel: "{'@'}Projects", + projectLabel: "@Projects", // ── 3c UX batch addition (I5 classified send-failure messages): friendly text by // error kind; {msg} keeps the raw error string for troubleshooting ── diff --git a/src/i18n/en/aiTool.ts b/src/i18n/en/aiTool.ts index 814c062..796ceaa 100644 --- a/src/i18n/en/aiTool.ts +++ b/src/i18n/en/aiTool.ts @@ -15,6 +15,8 @@ export default { // tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success) executionFailed: 'Failed', executionFailedHint: 'Tool execution failed, see details below', + // R6: click-to-copy full result path (hover title suffix) + clickToCopy: 'Click to copy full path', // Singular expand/collapse (ToolCard single-card content, command output toggle) expand: 'Expand', collapse: 'Collapse', diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts index 3805fe5..6409895 100644 --- a/src/i18n/zh-CN/aiChat.ts +++ b/src/i18n/zh-CN/aiChat.ts @@ -112,12 +112,12 @@ export default { clearSkill: '取消技能', skillBadgeTip: '已注入技能「{name}」的完整规程', placeholderWithSkill: '已选技能,输入参数后回车', - placeholderDefault: "输入消息...(/ 触发技能,{'@'} 引用实体)", + placeholderDefault: "输入消息...(/ 触发技能,@ 引用实体)", skillNoMatch: '无匹配技能', skillNotLoaded: '未读取到本机技能', // ── @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记) ── - mentionHint: "{'@'} 引用项目/任务", + mentionHint: "@ 引用项目/任务", mentionGroupProject: '项目', mentionGroupTask: '任务', mentionGroupIdea: '灵感', @@ -208,7 +208,7 @@ export default { // ── Input Augmentation 层: mention/skill 注入反馈 ── // search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径) - searchFilesBlocked: "search_files 仅在已授权目录可用,请用 {'@'}[项目] 引用或提供绝对路径", + searchFilesBlocked: "search_files 仅在已授权目录可用,请用 @[项目] 引用或提供绝对路径", // /技能 热重载(ai_reload_skills IPC)成功后提示 skillReloaded: '技能已重新加载', // @引用 实体 resolve 失败(id 指向的项目/任务/灵感/技能已被删除或不可用) @@ -289,7 +289,7 @@ export default { // ── TopBar 目标/项目上下文标签 ── goalsCount: '目标({n})', none: '无', - projectLabel: "{'@'}项目", + projectLabel: "@项目", // ── 3c UX 批追加(I5 发送失败错误分类文案):按错误内容映射友好提示, // {msg} 保留原始错误串供排查,不丢线索 ── diff --git a/src/i18n/zh-CN/aiTool.ts b/src/i18n/zh-CN/aiTool.ts index 0c701d1..a170f1e 100644 --- a/src/i18n/zh-CN/aiTool.ts +++ b/src/i18n/zh-CN/aiTool.ts @@ -15,6 +15,8 @@ export default { // 工具执行失败视觉标识(AR-6 下 status=completed,需红色区分于成功) executionFailed: '执行失败', executionFailedHint: '工具执行失败,详情见下方', + // R6:工具结果路径可点击复制(hover title 后半段) + clickToCopy: '点击复制完整路径', collapse: '收起', expand: '展开', written: '已写入', diff --git a/src/views/ApprovalPopup.vue b/src/views/ApprovalPopup.vue index fef93f8..9ec8cf8 100644 --- a/src/views/ApprovalPopup.vue +++ b/src/views/ApprovalPopup.vue @@ -151,7 +151,7 @@ import { useI18n } from 'vue-i18n' import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { toolDisplayName } from '@/composables/ai/useToolCardHeader' -import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval' +import { dispatchApprovalIPC, isHighRiskTool, highRiskConfirmText } from '@/composables/ai/useToolApproval' import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup' import { formatWaitTime, waitLevelOf } from '@/utils/time' import ConfirmDialog from '@/components/ConfirmDialog.vue' @@ -170,20 +170,24 @@ const { toast, showToast } = useToast() /** 拖动态(样式反馈) */ const isDragging = ref(false) -// ── M5:path 类 deny 二次确认(独立窗口本地 confirm;拒绝不可逆——工具返 Err、loop 续跑不再重试)── -// 浮窗是独立 WebviewWindow,与主窗口 JS 上下文隔离,不复用 useConfirm 跨窗口 reactive。 -// pendingDenyTc 持有「待确认拒绝」的 tc;answerConfirm 据 result 决定续跑 runApproval 或放弃。 -const confirmState = reactive<{ visible: boolean; msg: string; tc: AiToolCallInfo | null }>({ +// ── M5/V4:浮窗本地二次确认(独立窗口本地 confirm;与主窗口 JS 上下文隔离,不复用 useConfirm)── +// M5:path 类 deny(拒绝不可逆——工具返 Err、loop 续跑不再重试)。 +// V4:High 风险工具 risk 批准前置确认(对齐 ToolCard 安全语义:注意力分散场景数字键「1」 +// 单键放行 run_command/delete_file 太危险)。action 记录「待确认动作」供 answerConfirm 分派。 +const confirmState = reactive<{ visible: boolean; msg: string; tc: AiToolCallInfo | null; action: 'deny' | 'approve' }>({ visible: false, msg: '', tc: null, + action: 'deny', }) function answerConfirm(ok: boolean) { const tc = confirmState.tc + const action = confirmState.action confirmState.visible = false confirmState.tc = null if (ok && tc) { - void runApproval(tc, false, 'deny') + if (action === 'approve') void runApproval(tc, true) + else void runApproval(tc, false, 'deny') } } @@ -406,8 +410,16 @@ async function onApproveSession(tc: AiToolCallInfo) { async function onApproveAlways(tc: AiToolCallInfo) { await runApproval(tc, false, 'always') } -/** risk 类:批准(approved=true) */ +/** risk 类:批准(approved=true)。V4:High 风险工具先二次确认(按钮与数字键 1 同走本入口, + * 确认框打开时数字键守卫生效,不绕确认;拒绝仍按 M5 原逻辑)。 */ async function onApprove(tc: AiToolCallInfo) { + if (isHighRiskTool(tc.name)) { + confirmState.msg = highRiskConfirmText(tc.name) + confirmState.tc = tc + confirmState.action = 'approve' + confirmState.visible = true + return + } await runApproval(tc, true) } /** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派)。