新增: aichat前端体验四件与队列残留收口
- R4 代码块语言角标:MessageList decorateCodeHtml 捕获 language-xxx 输出 data-lang+CSS ::before 左上角标,hover 显,无语言裸块不渲染 - R6 工具结果路径可复制:ToolResultBody 五处 file-bar 的 path span→button(read_file/list_directory/search_files/grep/patch_file),点击复制完整路径+✓/✕ 1.2s 反馈+execCommand 降级链 - I6 重新生成 UI 入口:MessageItem user 气泡 hover 补重新生成按钮(canRegenerate=isLastUser&&非流式),复用 handleRetry→store.regenerate 链路 - V4 浮窗审批 High 风险二次确认:useToolApproval 导出 isHighRiskTool/highRiskConfirmText,ApprovalPopup.onApprove 前置 ConfirmDialog,数字键守卫不绕确认 - 9-28#5 两处全局清队列残留:useAiEvents/useAiLifecycleEvents 无 convId 的 AiError 分支改保留队列+console.warn(防误伤其他会话排队消息) - i18n zh/en 补 clickToCopy 键;裸 @ 改 @ 转义(vue-i18n linked-message 前缀吞噬)
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
|
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
|
||||||
<div class="ai-tool-file-bar">
|
<div class="ai-tool-file-bar">
|
||||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
||||||
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</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,
|
<!-- BUG-260625-02: search 模式返回 matches(命中行)非 content,
|
||||||
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
|
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
|
||||||
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 -->
|
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 -->
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
<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">
|
<div class="ai-tool-file-bar">
|
||||||
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
||||||
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</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 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>
|
<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>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<div v-else-if="tc.name === 'search_files' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
<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">
|
<div class="ai-tool-file-bar">
|
||||||
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
||||||
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</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 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>
|
<span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
|
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
|
||||||
<div class="ai-tool-file-bar">
|
<div class="ai-tool-file-bar">
|
||||||
<span class="ai-tool-file-icon" v-html="searchIcon"></span>
|
<span class="ai-tool-file-icon" v-html="searchIcon"></span>
|
||||||
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</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 v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
|
||||||
<span class="ai-tool-file-meta">{{ grepHitLabel }}</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>
|
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.grepTruncated') }}</span>
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
|
<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">
|
<div class="ai-tool-file-bar">
|
||||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
||||||
<span class="ai-tool-file-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</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 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>
|
<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>
|
||||||
@@ -257,6 +257,39 @@ const emit = defineEmits<{
|
|||||||
'install-obscura': []
|
'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),成功默认折叠
|
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
|
||||||
const cmdOutputExpanded = ref(false)
|
const cmdOutputExpanded = ref(false)
|
||||||
// http_request 响应 body 折叠态:失败(非 2xx)默认展开便于看错误,成功默认折叠
|
// http_request 响应 body 折叠态:失败(非 2xx)默认展开便于看错误,成功默认折叠
|
||||||
@@ -403,6 +436,14 @@ watch(() => props.tc.status, (s) => {
|
|||||||
.ai-tool-file-meta--warn {
|
.ai-tool-file-meta--warn {
|
||||||
color: var(--df-warning);
|
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,命中行用等宽渲染 */
|
/* grep 结果文件栏复用 ai-tool-file-bar,命中行用等宽渲染 */
|
||||||
.ai-tool-grep-result {
|
.ai-tool-grep-result {
|
||||||
|
|||||||
@@ -34,11 +34,14 @@ defineProps<{
|
|||||||
msg: AiMessage
|
msg: AiMessage
|
||||||
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
|
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
|
||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
|
/** I6:是否显示重新生成按钮(父合成同 canEdit 时机;作用于末条 user 触发的回复) */
|
||||||
|
canRegenerate?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'copy', msg: AiMessage): void
|
(e: 'copy', msg: AiMessage): void
|
||||||
(e: 'start-edit', msg: AiMessage): void
|
(e: 'start-edit', msg: AiMessage): void
|
||||||
|
(e: 'regenerate', msg: AiMessage): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// ── Input Augmentation: 按 mention 区间切段 ──
|
// ── Input Augmentation: 按 mention 区间切段 ──
|
||||||
@@ -122,6 +125,10 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
|
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- I6:重新生成按钮(hover,编辑按钮左侧);复用后端 regenerate 链路(弹末尾 AI 回复取触发 user 消息重跑) -->
|
||||||
|
<button v-if="canRegenerate" class="ai-msg-regen-btn" :title="t('aiChat.regenerate')" @click.stop="emit('regenerate', msg)">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
||||||
|
</button>
|
||||||
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
|
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
|
||||||
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)">
|
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
@@ -195,8 +202,9 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
/* 复制/编辑按钮(hover 显示,同栏同视觉;编辑按钮仅 canEdit 时渲染) */
|
/* 复制/编辑/重新生成按钮(hover 显示,同栏同视觉;编辑/重新生成仅各自 can* 时渲染) */
|
||||||
.ai-copy-btn,
|
.ai-copy-btn,
|
||||||
|
.ai-msg-regen-btn,
|
||||||
.ai-msg-edit-btn {
|
.ai-msg-edit-btn {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
@@ -207,7 +215,11 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
}
|
}
|
||||||
.ai-msg-user:hover .ai-copy-btn,
|
.ai-msg-user:hover .ai-copy-btn,
|
||||||
|
.ai-msg-user:hover .ai-msg-regen-btn,
|
||||||
.ai-msg-user:hover .ai-msg-edit-btn {
|
.ai-msg-user:hover .ai-msg-edit-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
.ai-msg-regen-btn:hover {
|
||||||
|
color: var(--df-accent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -620,10 +620,16 @@ function decorateCodeHtml(raw: string): string {
|
|||||||
const hit = _decoCache.get(raw)
|
const hit = _decoCache.get(raw)
|
||||||
if (hit !== undefined) return hit
|
if (hit !== undefined) return hit
|
||||||
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
|
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
|
||||||
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界
|
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界。
|
||||||
|
// R4 语言角标:code class 形如 hljs language-ts,捕获 language-xxx 输出到 .md-code-block
|
||||||
|
// 的 data-lang,CSS ::after 显示;无语言(裸块/auto 猜测无 language- 类)不显角标。
|
||||||
const out = raw.replace(
|
const out = raw.replace(
|
||||||
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
|
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
|
||||||
'<div class="md-code-block"><button type="button" class="md-code-copy">复制</button><pre><code$1>$2</code></pre></div>',
|
(_m: string, attrs: string, body: string) => {
|
||||||
|
const lang = /language-([a-z0-9+#.-]+)/i.exec(attrs)?.[1] ?? ''
|
||||||
|
const langAttr = lang ? ` data-lang="${lang}"` : ''
|
||||||
|
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">复制</button><pre><code${attrs}>${body}</code></pre></div>`
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
|
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
|
||||||
_decoCache.set(raw, out)
|
_decoCache.set(raw, out)
|
||||||
@@ -718,6 +724,15 @@ async function handleRetry(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I6 重新生成按钮:与错误气泡「重试」同链路(store.regenerate,后端弹末尾 AI 回复取触发
|
||||||
|
* user 消息重跑),入口场景不同——用户对已有回复不满意主动重问,挂在末条 user 气泡 hover。
|
||||||
|
* 与隐藏快捷键 Ctrl+R(AiChat.vue)等价,补齐 UI 入口(i18n aiChat.regenerate 文案已备)。
|
||||||
|
*/
|
||||||
|
async function handleRegenerate(): Promise<void> {
|
||||||
|
await handleRetry()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fetch_url obscura 未装时"AI 帮装"按钮:自动触发 AI 对话让 AI run_command 装流式。
|
* fetch_url obscura 未装时"AI 帮装"按钮:自动触发 AI 对话让 AI run_command 装流式。
|
||||||
* 提示语足够具体(给出 cargo install + GitHub releases 两种装法 + 装完告知用户重试),
|
* 提示语足够具体(给出 cargo install + GitHub releases 两种装法 + 装完告知用户重试),
|
||||||
@@ -1530,8 +1545,10 @@ defineExpose({
|
|||||||
v-else-if="item.msg.role === 'user'"
|
v-else-if="item.msg.role === 'user'"
|
||||||
:msg="item.msg"
|
:msg="item.msg"
|
||||||
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
|
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
|
||||||
|
:can-regenerate="isLastUser(item.msg) && !store.state.streaming"
|
||||||
@copy="copyMsgContent"
|
@copy="copyMsgContent"
|
||||||
@start-edit="(m) => emit('start-edit', m)"
|
@start-edit="(m) => emit('start-edit', m)"
|
||||||
|
@regenerate="handleRegenerate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- AI 消息 -->
|
<!-- AI 消息 -->
|
||||||
@@ -2100,6 +2117,31 @@ defineExpose({
|
|||||||
color: var(--df-danger);
|
color: var(--df-danger);
|
||||||
border-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 显示 / 错误气泡常显) ── */
|
/* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */
|
||||||
.ai-msg-actions {
|
.ai-msg-actions {
|
||||||
|
|||||||
@@ -225,10 +225,12 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
if (event.type === 'AiCompleted') {
|
if (event.type === 'AiCompleted') {
|
||||||
emit('ai-drain-queue', { conversationId: event.conversation_id })
|
emit('ai-drain-queue', { conversationId: event.conversation_id })
|
||||||
} else if (event.type === 'AiError') {
|
} else if (event.type === 'AiError') {
|
||||||
|
// 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列
|
||||||
|
// 会误伤其他会话排队消息,改保留 + console.warn 留痕
|
||||||
if (event.conversation_id) {
|
if (event.conversation_id) {
|
||||||
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
||||||
} else {
|
} else {
|
||||||
state.queue = []
|
console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === 'AiTextDelta' && convId && switchingConvs.has(convId)) {
|
} else if (event.type === 'AiTextDelta' && convId && switchingConvs.has(convId)) {
|
||||||
|
|||||||
@@ -208,8 +208,9 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
|
|||||||
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
||||||
notifyQueueCleared(dropped)
|
notifyQueueCleared(dropped)
|
||||||
} else {
|
} else {
|
||||||
notifyQueueCleared(state.queue.length)
|
// 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列
|
||||||
state.queue = []
|
// 会误伤其他会话排队消息,改保留 + console.warn 留痕(不再弹「已清 N 条」误导用户)
|
||||||
|
console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)')
|
||||||
}
|
}
|
||||||
state.messages.push({
|
state.messages.push({
|
||||||
id: `err-${nextMsgId()}` as MessageId,
|
id: `err-${nextMsgId()}` as MessageId,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
|
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import i18n from '@/i18n'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
import { getApprovalRemainingMs } from './aiShared'
|
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)
|
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)
|
return (registryHighRisk ?? HIGH_RISK_TOOLS_FALLBACK).has(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +82,21 @@ export function dispatchApprovalIPC(
|
|||||||
return aiApi.approve(tc.id, approved)
|
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 */
|
/** useToolApproval emit 签名 — 补全类型替代 any */
|
||||||
interface ToolApprovalEmit {
|
interface ToolApprovalEmit {
|
||||||
(event: 'approve', payload: { id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }): void
|
(event: 'approve', payload: { id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }): void
|
||||||
@@ -145,14 +162,9 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
|
|||||||
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
|
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** High 风险工具的二次确认文案(按操作类型) */
|
/** High 风险工具的二次确认文案(按操作类型;V4 提升为模块级导出,浮窗复用) */
|
||||||
function highRiskConfirmMsg(name: string): string {
|
function highRiskConfirmMsg(name: string): string {
|
||||||
if (name === 'run_command') return t('aiTool.confirmHighExec')
|
return highRiskConfirmText(name)
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -112,12 +112,12 @@ export default {
|
|||||||
clearSkill: 'Clear skill',
|
clearSkill: 'Clear skill',
|
||||||
skillBadgeTip: 'Injected full skill playbook: {name}',
|
skillBadgeTip: 'Injected full skill playbook: {name}',
|
||||||
placeholderWithSkill: 'Skill selected. Type arguments and press Enter',
|
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',
|
skillNoMatch: 'No matching skill',
|
||||||
skillNotLoaded: 'No local skills loaded',
|
skillNotLoaded: 'No local skills loaded',
|
||||||
|
|
||||||
// ── @ entity mention (@ opens popover, select inserts [Type:Name] token) ──
|
// ── @ entity mention (@ opens popover, select inserts [Type:Name] token) ──
|
||||||
mentionHint: "{'@'} to mention project/task",
|
mentionHint: "@ to mention project/task",
|
||||||
mentionGroupProject: 'Projects',
|
mentionGroupProject: 'Projects',
|
||||||
mentionGroupTask: 'Tasks',
|
mentionGroupTask: 'Tasks',
|
||||||
mentionGroupIdea: 'Ideas',
|
mentionGroupIdea: 'Ideas',
|
||||||
@@ -207,7 +207,7 @@ export default {
|
|||||||
|
|
||||||
// ── Input Augmentation layer: mention/skill injection feedback ──
|
// ── 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)
|
// 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
|
// /skill hot reload (ai_reload_skills IPC) success toast
|
||||||
skillReloaded: 'Skills reloaded',
|
skillReloaded: 'Skills reloaded',
|
||||||
// @mention entity resolve failed (id points to a project/task/idea/skill that was deleted or unavailable)
|
// @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 ──
|
// ── TopBar goal/project context labels ──
|
||||||
goalsCount: 'Goals ({n})',
|
goalsCount: 'Goals ({n})',
|
||||||
none: 'None',
|
none: 'None',
|
||||||
projectLabel: "{'@'}Projects",
|
projectLabel: "@Projects",
|
||||||
|
|
||||||
// ── 3c UX batch addition (I5 classified send-failure messages): friendly text by
|
// ── 3c UX batch addition (I5 classified send-failure messages): friendly text by
|
||||||
// error kind; {msg} keeps the raw error string for troubleshooting ──
|
// error kind; {msg} keeps the raw error string for troubleshooting ──
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default {
|
|||||||
// tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success)
|
// tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success)
|
||||||
executionFailed: 'Failed',
|
executionFailed: 'Failed',
|
||||||
executionFailedHint: 'Tool execution failed, see details below',
|
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)
|
// Singular expand/collapse (ToolCard single-card content, command output toggle)
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
collapse: 'Collapse',
|
collapse: 'Collapse',
|
||||||
|
|||||||
@@ -112,12 +112,12 @@ export default {
|
|||||||
clearSkill: '取消技能',
|
clearSkill: '取消技能',
|
||||||
skillBadgeTip: '已注入技能「{name}」的完整规程',
|
skillBadgeTip: '已注入技能「{name}」的完整规程',
|
||||||
placeholderWithSkill: '已选技能,输入参数后回车',
|
placeholderWithSkill: '已选技能,输入参数后回车',
|
||||||
placeholderDefault: "输入消息...(/ 触发技能,{'@'} 引用实体)",
|
placeholderDefault: "输入消息...(/ 触发技能,@ 引用实体)",
|
||||||
skillNoMatch: '无匹配技能',
|
skillNoMatch: '无匹配技能',
|
||||||
skillNotLoaded: '未读取到本机技能',
|
skillNotLoaded: '未读取到本机技能',
|
||||||
|
|
||||||
// ── @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记) ──
|
// ── @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记) ──
|
||||||
mentionHint: "{'@'} 引用项目/任务",
|
mentionHint: "@ 引用项目/任务",
|
||||||
mentionGroupProject: '项目',
|
mentionGroupProject: '项目',
|
||||||
mentionGroupTask: '任务',
|
mentionGroupTask: '任务',
|
||||||
mentionGroupIdea: '灵感',
|
mentionGroupIdea: '灵感',
|
||||||
@@ -208,7 +208,7 @@ export default {
|
|||||||
|
|
||||||
// ── Input Augmentation 层: mention/skill 注入反馈 ──
|
// ── Input Augmentation 层: mention/skill 注入反馈 ──
|
||||||
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
|
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
|
||||||
searchFilesBlocked: "search_files 仅在已授权目录可用,请用 {'@'}[项目] 引用或提供绝对路径",
|
searchFilesBlocked: "search_files 仅在已授权目录可用,请用 @[项目] 引用或提供绝对路径",
|
||||||
// /技能 热重载(ai_reload_skills IPC)成功后提示
|
// /技能 热重载(ai_reload_skills IPC)成功后提示
|
||||||
skillReloaded: '技能已重新加载',
|
skillReloaded: '技能已重新加载',
|
||||||
// @引用 实体 resolve 失败(id 指向的项目/任务/灵感/技能已被删除或不可用)
|
// @引用 实体 resolve 失败(id 指向的项目/任务/灵感/技能已被删除或不可用)
|
||||||
@@ -289,7 +289,7 @@ export default {
|
|||||||
// ── TopBar 目标/项目上下文标签 ──
|
// ── TopBar 目标/项目上下文标签 ──
|
||||||
goalsCount: '目标({n})',
|
goalsCount: '目标({n})',
|
||||||
none: '无',
|
none: '无',
|
||||||
projectLabel: "{'@'}项目",
|
projectLabel: "@项目",
|
||||||
|
|
||||||
// ── 3c UX 批追加(I5 发送失败错误分类文案):按错误内容映射友好提示,
|
// ── 3c UX 批追加(I5 发送失败错误分类文案):按错误内容映射友好提示,
|
||||||
// {msg} 保留原始错误串供排查,不丢线索 ──
|
// {msg} 保留原始错误串供排查,不丢线索 ──
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default {
|
|||||||
// 工具执行失败视觉标识(AR-6 下 status=completed,需红色区分于成功)
|
// 工具执行失败视觉标识(AR-6 下 status=completed,需红色区分于成功)
|
||||||
executionFailed: '执行失败',
|
executionFailed: '执行失败',
|
||||||
executionFailedHint: '工具执行失败,详情见下方',
|
executionFailedHint: '工具执行失败,详情见下方',
|
||||||
|
// R6:工具结果路径可点击复制(hover title 后半段)
|
||||||
|
clickToCopy: '点击复制完整路径',
|
||||||
collapse: '收起',
|
collapse: '收起',
|
||||||
expand: '展开',
|
expand: '展开',
|
||||||
written: '已写入',
|
written: '已写入',
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||||
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
|
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 { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
|
||||||
import { formatWaitTime, waitLevelOf } from '@/utils/time'
|
import { formatWaitTime, waitLevelOf } from '@/utils/time'
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
@@ -170,20 +170,24 @@ const { toast, showToast } = useToast()
|
|||||||
/** 拖动态(样式反馈) */
|
/** 拖动态(样式反馈) */
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
|
|
||||||
// ── M5:path 类 deny 二次确认(独立窗口本地 confirm;拒绝不可逆——工具返 Err、loop 续跑不再重试)──
|
// ── M5/V4:浮窗本地二次确认(独立窗口本地 confirm;与主窗口 JS 上下文隔离,不复用 useConfirm)──
|
||||||
// 浮窗是独立 WebviewWindow,与主窗口 JS 上下文隔离,不复用 useConfirm 跨窗口 reactive。
|
// M5:path 类 deny(拒绝不可逆——工具返 Err、loop 续跑不再重试)。
|
||||||
// pendingDenyTc 持有「待确认拒绝」的 tc;answerConfirm 据 result 决定续跑 runApproval 或放弃。
|
// V4:High 风险工具 risk 批准前置确认(对齐 ToolCard 安全语义:注意力分散场景数字键「1」
|
||||||
const confirmState = reactive<{ visible: boolean; msg: string; tc: AiToolCallInfo | null }>({
|
// 单键放行 run_command/delete_file 太危险)。action 记录「待确认动作」供 answerConfirm 分派。
|
||||||
|
const confirmState = reactive<{ visible: boolean; msg: string; tc: AiToolCallInfo | null; action: 'deny' | 'approve' }>({
|
||||||
visible: false,
|
visible: false,
|
||||||
msg: '',
|
msg: '',
|
||||||
tc: null,
|
tc: null,
|
||||||
|
action: 'deny',
|
||||||
})
|
})
|
||||||
function answerConfirm(ok: boolean) {
|
function answerConfirm(ok: boolean) {
|
||||||
const tc = confirmState.tc
|
const tc = confirmState.tc
|
||||||
|
const action = confirmState.action
|
||||||
confirmState.visible = false
|
confirmState.visible = false
|
||||||
confirmState.tc = null
|
confirmState.tc = null
|
||||||
if (ok && tc) {
|
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) {
|
async function onApproveAlways(tc: AiToolCallInfo) {
|
||||||
await runApproval(tc, false, 'always')
|
await runApproval(tc, false, 'always')
|
||||||
}
|
}
|
||||||
/** risk 类:批准(approved=true) */
|
/** risk 类:批准(approved=true)。V4:High 风险工具先二次确认(按钮与数字键 1 同走本入口,
|
||||||
|
* 确认框打开时数字键守卫生效,不绕确认;拒绝仍按 M5 原逻辑)。 */
|
||||||
async function onApprove(tc: AiToolCallInfo) {
|
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)
|
await runApproval(tc, true)
|
||||||
}
|
}
|
||||||
/** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派)。
|
/** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派)。
|
||||||
|
|||||||
Reference in New Issue
Block a user