Files
DevFlow/src/components/ai/MessageItem.vue
T
lxy 25ec9cf69a 优化: 前端u-agent令牌对齐与组件拆分续批
- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底
- 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、
  conversation-sidebar/ConversationList、settings/provider-panel
- 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补
  fill-draft父侧转发(重构半成品断链修复)
- 验收:vue-tsc EXIT 0
2026-10-18 23:15:57 +08:00

229 lines
8.6 KiB
Vue

<script setup lang="ts">
//! 单条「用户」消息渲染 — 从 MessageList.vue 提取,降低 God 文件复杂度
//!
//! 职责(单一,仅 user 角色):渲染用户消息气泡,
//! 含 Mention chip + 图片附件。
//!
//! 设计说明:assistant / system / error 三角色由 MessageList 内联渲染
//! (含流式分块、工具卡片、token 用量、错误重试、压缩摘要置顶等聚合逻辑),
//! 本组件专注 user,不做 AI 消息渲染,避免两套渲染入口并存导致的死代码与职责混乱。
import { computed } from 'vue'
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] ?? '' }
}
const props = defineProps<{
msg: AiMessage
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
canEdit?: boolean
}>()
const emit = defineEmits<{
(e: 'copy', msg: AiMessage): void
(e: 'start-edit', 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')
}
// 模板中同一渲染内多次引用的纯派生值 → computed 缓存一次求值
// (纯函数仅依赖 props.msg / store.state.skills 等响应式源,缓存语义与原先逐次调用等价)
const skill = computed(() => leadingSkill(props.msg))
const segments = computed(() => segmentUserContent(props.msg))
const imageParts = computed(() => msgImageParts(props.msg))
</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 aria-hidden="true" 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="skill">
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: skill!.name })">⚡ /{{ skill!.name }}</span>
<template v-if="skill!.rest">{{ ' ' + skill!.rest }}</template>
</template>
<template v-else>
<template v-for="(seg, idx) in segments" :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="imageParts.length">
<div class="ai-msg-images">
<img v-for="(img, idx) in imageParts" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
</div>
</template>
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" :aria-label="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" aria-hidden="true"><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')" :aria-label="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" aria-hidden="true"><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;
}
/* 头像通用 — workbench .av 范式:24px 圆角方块渐变,用户 warning→danger 系,
字色随主题底色反转保证渐变上对比;内部字符 9px 700(当前为 svg 图标,字号字重为字符兜底) */
.ai-msg-avatar {
width: 24px;
height: 24px;
border-radius: var(--df-radius);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 9px;
font-weight: 700;
}
.ai-msg-avatar--user {
background: linear-gradient(135deg, color-mix(in srgb, var(--df-warning) 70%, var(--df-danger)), var(--df-danger));
color: var(--df-bg);
}
/* 气泡通用 — workbench 聊天范式:用户气泡=bg-card 底+0.5px 边框+9px 圆角。
9px 圆角与 9px 13px 内距均为 u-agent workbench 移植值,勿规整 */
.ai-msg-bubble {
border-radius: 9px;
padding: 9px 13px;
word-break: break-word;
max-width: 80%;
}
.ai-msg-bubble--user {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
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);
color: var(--df-accent);
}
/* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */
.ai-msg-chip--skill {
background: var(--df-accent-bg);
color: var(--df-accent);
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 显示,同栏同视觉;编辑仅 canEdit 时渲染) */
.ai-copy-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-edit-btn,
/* UX第五轮 P2-M1:键盘聚焦可达(hover-only 修复)——Tab 聚到按钮或按钮内聚焦时显形 */
.ai-msg-user:focus-within .ai-copy-btn,
.ai-msg-user:focus-within .ai-msg-edit-btn {
opacity: 1;
}
</style>