- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮) - 提取 MessageItem.vue(按 role 渲染单条消息) - 清理 MessageList.vue 中已移至子组件/ composable 的死代码
172 lines
7.5 KiB
Vue
172 lines
7.5 KiB
Vue
<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>
|