新增: 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:
lxy
2026-10-05 00:35:03 +08:00
parent 83e86cd6b7
commit c10f0e46f0
11 changed files with 161 additions and 35 deletions
+13 -1
View File
@@ -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 {
<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>
</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 链回填输入框编辑重发 -->
<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>
@@ -195,8 +202,9 @@ function imgAlt(img: ImageContentPart): string {
max-height: 200px;
border-radius: 6px;
}
/* 复制/编辑按钮(hover 显示,同栏同视觉;编辑按钮仅 canEdit 时渲染) */
/* 复制/编辑/重新生成按钮(hover 显示,同栏同视觉;编辑/重新生成仅各自 can* 时渲染) */
.ai-copy-btn,
.ai-msg-regen-btn,
.ai-msg-edit-btn {
opacity: 0;
transition: opacity 0.15s;
@@ -207,7 +215,11 @@ function imgAlt(img: ImageContentPart): string {
color: var(--df-text-dim);
}
.ai-msg-user:hover .ai-copy-btn,
.ai-msg-user:hover .ai-msg-regen-btn,
.ai-msg-user:hover .ai-msg-edit-btn {
opacity: 1;
}
.ai-msg-regen-btn:hover {
color: var(--df-accent);
}
</style>
+44 -2
View File
@@ -620,10 +620,16 @@ function decorateCodeHtml(raw: string): string {
const hit = _decoCache.get(raw)
if (hit !== undefined) return hit
// 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(
/<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()
_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 装流式。
* 提示语足够具体(给出 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"
/>
<!-- AI 消息 -->
@@ -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 {