重构: MessageList 拆分(提取 MessageItem+useMessageScroll, 1552->1386行)

- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮)

- 提取 MessageItem.vue(按 role 渲染单条消息)

- 清理 MessageList.vue 中已移至子组件/ composable 的死代码
This commit is contained in:
lxy
2026-07-01 12:21:40 +08:00
parent 6771d396f0
commit 2c2b2d710e
4 changed files with 307 additions and 205 deletions
+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
//! 单条消息渲染 — 从 MessageList.vue 提取,降低 God 文件复杂度
//!
//! 职责:按 role(user/assistant/system)渲染单条消息气泡,
//! 含用户消息的 Mention chip + 图片、助理消息的流式分块/完成态、
//! 系统消息的压缩摘要。不涉滚动/折叠段/工具卡片等聚合逻辑。
import { useI18n } from 'vue-i18n'
import { useMarkdown } from '../../composables/useMarkdown'
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
const { t } = useI18n()
const { renderMd } = useMarkdown()
defineProps<{
msg: AiMessage
/** 是否为末条 assistant 消息(流式分块渲染判定) */
isLastAi: boolean
/** 是否为末条 user 消息(编辑按钮显示) */
isLastUser: boolean
/** 当前正在编辑的消息 id(隐藏编辑按钮) */
editingMsgId?: string | null
/** 流式渲染分块(末条 assistant 当前流式内容的分块结果) */
streamingBlocks?: { html: string; key: string }[]
/** 是否正在生成(控制光标闪烁) */
isViewingGenerating?: boolean
}>()
const emit = defineEmits<{
(e: 'start-edit', msg: AiMessage): void
(e: 'copy', msg: AiMessage): void
(e: 'regenerate', msg: AiMessage): void
(e: 'retry', msg: AiMessage): void
}>()
/// 完成/历史消息用 renderMd(整段 string)
function renderContent(msg: AiMessage): string {
return renderMd(msg.content)
}
// ── 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"
>
<!-- 系统消息:压缩摘要 -->
<div v-if="msg.role === 'system'" class="ai-msg-system">
<span class="ai-msg-system-label">{{ t('aiChat.compressedSummaryLabel') }}</span>
<div class="ai-msg-system-summary ai-md" v-html="renderContent(msg)"></div>
</div>
<!-- 用户消息 -->
<div v-else-if="msg.role === 'user'" 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">
<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 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>
<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 v-else-if="msg.role === 'assistant'" class="ai-msg-ai">
<div class="ai-msg-avatar ai-msg-avatar--ai">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</div>
<div class="ai-msg-bubble ai-msg-bubble--ai ai-md">
<!-- 流式分块渲染(末条 assistant 生成中) -->
<template v-if="isLastAi && streamingBlocks && streamingBlocks.length">
<span v-for="(block, _bi) in streamingBlocks" :key="block.key" v-html="block.html"></span>
<span v-if="isViewingGenerating" class="ai-cursor-blink">&#x258A;</span>
</template>
<!-- 完成/历史消息:整段 Markdown 渲染 -->
<template v-else>
<span v-html="renderContent(msg)"></span>
</template>
<!-- Token 用量 -->
<span v-if="msg.model" class="ai-msg-model-badge" :title="'Model: ' + msg.model">
{{ msg.model }}
</span>
</div>
<!-- 操作按钮:复制/重新生成/编辑 -->
<div class="ai-msg-actions">
<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"><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>
<button v-if="isLastAi" class="ai-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"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
</button>
</div>
</div>
<!-- 错误消息 -->
<div v-else-if="msg.isError" class="ai-msg-ai ai-msg--error">
<div class="ai-msg-avatar ai-msg-avatar--ai">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--df-danger)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
</div>
<div class="ai-msg-bubble ai-msg-bubble--ai ai-msg-bubble--error">
<span>{{ msg.content }}</span>
<button class="ai-retry-btn" :title="t('aiChat.retry')" @click.stop="emit('retry', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
</button>
</div>
</div>
</div>
</template>