- 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 前缀吞噬)
226 lines
8.4 KiB
Vue
226 lines
8.4 KiB
Vue
<script setup lang="ts">
|
|
//! 单条「用户」消息渲染 — 从 MessageList.vue 提取,降低 God 文件复杂度
|
|
//!
|
|
//! 职责(单一,仅 user 角色):渲染用户消息气泡,
|
|
//! 含 Mention chip + 图片附件。
|
|
//!
|
|
//! 设计说明:assistant / system / error 三角色由 MessageList 内联渲染
|
|
//! (含流式分块、工具卡片、token 用量、错误重试、压缩摘要置顶等聚合逻辑),
|
|
//! 本组件专注 user,不做 AI 消息渲染,避免两套渲染入口并存导致的死代码与职责混乱。
|
|
|
|
import { useI18n } from 'vue-i18n'
|
|
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
|
|
import { useAiStore } from '../../stores/ai'
|
|
|
|
const { t } = useI18n()
|
|
const store = useAiStore()
|
|
|
|
/**
|
|
* 前导 /技能名 解析(问题3 技能调用留痕的渲染侧):
|
|
* 落库文本形如 `/skill-name 正文`(后端 chat.rs user_content 统一加前缀),命中本机
|
|
* 已安装技能名单才渲染为技能 chip——普通以 / 开头的文本(路径、markdown)不误判。
|
|
* 返回 null = 非技能调用,走原纯文本路径零回归。
|
|
*/
|
|
function leadingSkill(msg: AiMessage): { name: string; rest: string } | null {
|
|
if (msg.role !== 'user') return null
|
|
const m = /^\/([A-Za-z0-9_-]+)(?:\s([\s\S]*))?$/.exec(msg.content)
|
|
if (!m) return null
|
|
const known = store.state.skills.some((s) => s.name === m[1])
|
|
if (!known) return null
|
|
return { name: m[1], rest: m[2] ?? '' }
|
|
}
|
|
|
|
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 区间切段 ──
|
|
type UserContentSegment =
|
|
| { type: 'text'; text: string }
|
|
| { type: 'chip'; span: MentionSpan }
|
|
|
|
function segmentUserContent(msg: AiMessage): UserContentSegment[] {
|
|
const content = msg.content
|
|
const spans = msg.mentionSpans
|
|
if (!spans || spans.length === 0) {
|
|
return [{ type: 'text', text: content }]
|
|
}
|
|
const sorted = [...spans].sort((a, b) => a.start - b.start)
|
|
const segments: UserContentSegment[] = []
|
|
let cursor = 0
|
|
for (const span of sorted) {
|
|
const start = span.start
|
|
const end = start + span.length
|
|
if (start < 0 || end > content.length) continue
|
|
if (start < cursor) continue
|
|
if (content.slice(start, end) !== span.label) continue
|
|
if (start > cursor) {
|
|
segments.push({ type: 'text', text: content.slice(cursor, start) })
|
|
}
|
|
segments.push({ type: 'chip', span })
|
|
cursor = end
|
|
}
|
|
if (cursor < content.length) {
|
|
segments.push({ type: 'text', text: content.slice(cursor) })
|
|
}
|
|
if (segments.length === 0 && content.length > 0) {
|
|
return [{ type: 'text', text: content }]
|
|
}
|
|
return segments
|
|
}
|
|
|
|
// ── ContentPart 图片处理 ──
|
|
type ImageContentPart = Extract<ContentPart, { type: 'image' }>
|
|
function msgImageParts(msg: AiMessage): ImageContentPart[] {
|
|
if (!msg.parts) return []
|
|
return msg.parts.filter((p): p is ImageContentPart => p.type === 'image')
|
|
}
|
|
function imageSrc(img: ImageContentPart): string {
|
|
if (img.base64) {
|
|
const mt = img.media_type || 'image/png'
|
|
return `data:${mt};base64,${img.base64}`
|
|
}
|
|
return img.url || ''
|
|
}
|
|
function imgAlt(img: ImageContentPart): string {
|
|
return img.alt || t('aiChat.imagePreviewAlt')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="ai-msg"
|
|
:class="'ai-msg--' + msg.role"
|
|
:data-msg-id="msg.id"
|
|
>
|
|
<!-- 用户消息(本组件唯一渲染角色;assistant/system/error 由 MessageList 内联渲染) -->
|
|
<div class="ai-msg-user">
|
|
<div class="ai-msg-avatar ai-msg-avatar--user">
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
</div>
|
|
<div class="ai-msg-bubble ai-msg-bubble--user">
|
|
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
|
|
<template v-if="leadingSkill(msg)">
|
|
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: leadingSkill(msg)!.name })">⚡ /{{ leadingSkill(msg)!.name }}</span>
|
|
<template v-if="leadingSkill(msg)!.rest">{{ ' ' + leadingSkill(msg)!.rest }}</template>
|
|
</template>
|
|
<template v-else>
|
|
<template v-for="(seg, idx) in segmentUserContent(msg)" :key="msg.id + '-seg-' + idx">
|
|
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
|
|
<span v-else class="ai-msg-chip" :class="'ai-msg-chip--' + seg.span.kind">{{ seg.span.label }}</span>
|
|
</template>
|
|
</template>
|
|
<template v-if="msgImageParts(msg).length">
|
|
<div class="ai-msg-images">
|
|
<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>
|
|
</button>
|
|
<button class="ai-copy-btn" :title="t('aiChat.copyMsg')" @click.stop="emit('copy', msg)">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* 用户消息:头像在右(整条靠右) */
|
|
.ai-msg-user {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: flex-start;
|
|
flex-direction: row-reverse;
|
|
}
|
|
/* 头像通用 */
|
|
.ai-msg-avatar {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.ai-msg-avatar--user {
|
|
background: var(--df-info-bg, rgba(100,181,246,0.15));
|
|
color: var(--df-info, #64b5f6);
|
|
}
|
|
/* 气泡通用 */
|
|
.ai-msg-bubble {
|
|
border-radius: 10px;
|
|
padding: 8px 12px;
|
|
word-break: break-word;
|
|
max-width: 80%;
|
|
}
|
|
.ai-msg-bubble--user {
|
|
background: var(--df-info-bg, rgba(100,181,246,0.1));
|
|
color: var(--df-text);
|
|
}
|
|
/* chip */
|
|
.ai-msg-chip {
|
|
display: inline-block;
|
|
padding: 1px 6px;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
margin: 0 2px;
|
|
background: var(--df-accent-bg, rgba(108,99,255,0.1));
|
|
color: var(--df-accent, #6c63ff);
|
|
}
|
|
/* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */
|
|
.ai-msg-chip--skill {
|
|
background: var(--df-accent-bg, rgba(108,99,255,0.18));
|
|
color: var(--df-accent, #6c63ff);
|
|
font-weight: 600;
|
|
}
|
|
/* 图片 */
|
|
.ai-msg-images {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
margin-top: 4px;
|
|
}
|
|
.ai-msg-image {
|
|
max-width: 200px;
|
|
max-height: 200px;
|
|
border-radius: 6px;
|
|
}
|
|
/* 复制/编辑/重新生成按钮(hover 显示,同栏同视觉;编辑/重新生成仅各自 can* 时渲染) */
|
|
.ai-copy-btn,
|
|
.ai-msg-regen-btn,
|
|
.ai-msg-edit-btn {
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 2px;
|
|
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>
|