重构: MessageList 拆分(提取 MessageItem+useMessageScroll, 1552->1386行)
- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮) - 提取 MessageItem.vue(按 role 渲染单条消息) - 清理 MessageList.vue 中已移至子组件/ composable 的死代码
This commit is contained in:
@@ -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">▊</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>
|
||||
Reference in New Issue
Block a user