优化: 前端走查 P1(MessageList/MessageItem 两套渲染收敛)

MessageItem 删 AI/system/error 死分支(301→175 行,仅 user 角色)+ 删悬空 regenerate/retry emits + 清孤立 props/isLastAi;MessageList 挂载清理。消除两套 AI 消息渲染并存(死代码+职责混乱)。
This commit is contained in:
lxy
2026-08-02 03:11:44 +08:00
parent fc249adf17
commit f719546b12
2 changed files with 12 additions and 144 deletions
+11 -138
View File
@@ -1,43 +1,26 @@
<script setup lang="ts">
//! 单条消息渲染 — 从 MessageList.vue 提取,降低 God 文件复杂度
//! 单条「用户」消息渲染 — 从 MessageList.vue 提取,降低 God 文件复杂度
//!
//! 职责:按 role(user/assistant/system)渲染单条消息气泡,
//! 含用户消息的 Mention chip + 图片、助理消息的流式分块/完成态、
//! 系统消息的压缩摘要。不涉滚动/折叠段/工具卡片等聚合逻辑。
//! 职责(单一,仅 user 角色):渲染用户消息气泡,
//! 含 Mention chip + 图片附件。
//!
//! 设计说明:assistant / system / error 三角色由 MessageList 内联渲染
//! (含流式分块、工具卡片、token 用量、错误重试、压缩摘要置顶等聚合逻辑),
//! 本组件专注 user,不做 AI 消息渲染,避免两套渲染入口并存导致的死代码与职责混乱。
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 }
@@ -97,14 +80,8 @@ function imgAlt(img: ImageContentPart): string {
: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">
<!-- 用户消息(本组件唯一渲染角色;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>
@@ -123,50 +100,6 @@ function imgAlt(img: ImageContentPart): string {
</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>
@@ -178,12 +111,6 @@ function imgAlt(img: ImageContentPart): string {
align-items: flex-start;
flex-direction: row-reverse;
}
/* AI 消息:头像在左 */
.ai-msg-ai {
display: flex;
gap: 8px;
align-items: flex-start;
}
/* 头像通用 */
.ai-msg-avatar {
width: 22px;
@@ -198,10 +125,6 @@ function imgAlt(img: ImageContentPart): string {
background: var(--df-info-bg, rgba(100,181,246,0.15));
color: var(--df-info, #64b5f6);
}
.ai-msg-avatar--ai {
background: var(--df-accent-bg, rgba(108,99,255,0.15));
color: var(--df-accent, #6c63ff);
}
/* 气泡通用 */
.ai-msg-bubble {
border-radius: 10px;
@@ -213,13 +136,6 @@ function imgAlt(img: ImageContentPart): string {
background: var(--df-info-bg, rgba(100,181,246,0.1));
color: var(--df-text);
}
.ai-msg-bubble--ai {
background: var(--df-bg-secondary, #1f1f1f);
color: var(--df-text);
}
.ai-msg-bubble--error {
border: 1px solid var(--df-danger, #f44336);
}
/* chip */
.ai-msg-chip {
display: inline-block;
@@ -242,7 +158,7 @@ function imgAlt(img: ImageContentPart): string {
max-height: 200px;
border-radius: 6px;
}
/* 复制按钮 */
/* 复制按钮(hover 显示) */
.ai-copy-btn {
opacity: 0;
transition: opacity 0.15s;
@@ -252,50 +168,7 @@ function imgAlt(img: ImageContentPart): string {
padding: 2px;
color: var(--df-text-dim);
}
.ai-msg-user:hover .ai-copy-btn,
.ai-msg-ai:hover .ai-copy-btn {
.ai-msg-user:hover .ai-copy-btn {
opacity: 1;
}
/* 操作按钮区 */
.ai-msg-actions {
display: flex;
gap: 4px;
margin-top: 4px;
}
.ai-regen-btn {
background: none;
border: none;
cursor: pointer;
padding: 2px;
color: var(--df-text-dim);
}
.ai-regen-btn:hover {
color: var(--df-text);
}
/* model badge */
.ai-msg-model-badge {
font-size: 10px;
color: var(--df-text-dim);
margin-left: 4px;
}
/* 光标 */
.ai-cursor-blink {
animation: blink 1s step-end infinite;
color: var(--df-accent);
}
@keyframes blink {
50% { opacity: 0; }
}
/* 系统消息 */
.ai-msg-system {
padding: 8px 12px;
border-left: 3px solid var(--df-accent);
background: var(--df-bg-secondary);
border-radius: 4px;
}
.ai-msg-system-label {
font-size: 11px;
color: var(--df-text-dim);
margin-bottom: 4px;
}
</style>
+1 -6
View File
@@ -678,16 +678,11 @@ defineExpose({
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
<div class="ai-msg-system-summary ai-md" v-html="renderMd(item.msg.content)"></div>
</div>
<!-- 用户消息(提取至 MessageItem.vue) -->
<!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染) -->
<MessageItem
v-else-if="item.msg.role === 'user'"
:msg="item.msg"
:is-last-ai="isLastAi(item.msg)"
:is-last-user="isLastUser(item.msg)"
:editing-msg-id="editingMsgId"
:is-viewing-generating="isViewingGenerating"
@copy="copyMsgContent"
@start-edit="(m) => emit('start-edit', m)"
/>
<!-- AI 消息 -->