优化: 前端走查 P1(MessageList/MessageItem 两套渲染收敛)
MessageItem 删 AI/system/error 死分支(301→175 行,仅 user 角色)+ 删悬空 regenerate/retry emits + 清孤立 props/isLastAi;MessageList 挂载清理。消除两套 AI 消息渲染并存(死代码+职责混乱)。
This commit is contained in:
@@ -1,43 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
//! 单条消息渲染 — 从 MessageList.vue 提取,降低 God 文件复杂度
|
//! 单条「用户」消息渲染 — 从 MessageList.vue 提取,降低 God 文件复杂度
|
||||||
//!
|
//!
|
||||||
//! 职责:按 role(user/assistant/system)渲染单条消息气泡,
|
//! 职责(单一,仅 user 角色):渲染用户消息气泡,
|
||||||
//! 含用户消息的 Mention chip + 图片、助理消息的流式分块/完成态、
|
//! 含 Mention chip + 图片附件。
|
||||||
//! 系统消息的压缩摘要。不涉滚动/折叠段/工具卡片等聚合逻辑。
|
//!
|
||||||
|
//! 设计说明:assistant / system / error 三角色由 MessageList 内联渲染
|
||||||
|
//! (含流式分块、工具卡片、token 用量、错误重试、压缩摘要置顶等聚合逻辑),
|
||||||
|
//! 本组件专注 user,不做 AI 消息渲染,避免两套渲染入口并存导致的死代码与职责混乱。
|
||||||
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useMarkdown } from '../../composables/useMarkdown'
|
|
||||||
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
|
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { renderMd } = useMarkdown()
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
msg: AiMessage
|
msg: AiMessage
|
||||||
/** 是否为末条 assistant 消息(流式分块渲染判定) */
|
|
||||||
isLastAi: boolean
|
|
||||||
/** 是否为末条 user 消息(编辑按钮显示) */
|
|
||||||
isLastUser: boolean
|
|
||||||
/** 当前正在编辑的消息 id(隐藏编辑按钮) */
|
|
||||||
editingMsgId?: string | null
|
|
||||||
/** 流式渲染分块(末条 assistant 当前流式内容的分块结果) */
|
|
||||||
streamingBlocks?: { html: string; key: string }[]
|
|
||||||
/** 是否正在生成(控制光标闪烁) */
|
|
||||||
isViewingGenerating?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'start-edit', msg: AiMessage): void
|
|
||||||
(e: 'copy', 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 区间切段 ──
|
// ── Input Augmentation: 按 mention 区间切段 ──
|
||||||
type UserContentSegment =
|
type UserContentSegment =
|
||||||
| { type: 'text'; text: string }
|
| { type: 'text'; text: string }
|
||||||
@@ -97,14 +80,8 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
:class="'ai-msg--' + msg.role"
|
:class="'ai-msg--' + msg.role"
|
||||||
:data-msg-id="msg.id"
|
:data-msg-id="msg.id"
|
||||||
>
|
>
|
||||||
<!-- 系统消息:压缩摘要 -->
|
<!-- 用户消息(本组件唯一渲染角色;assistant/system/error 由 MessageList 内联渲染) -->
|
||||||
<div v-if="msg.role === 'system'" class="ai-msg-system">
|
<div class="ai-msg-user">
|
||||||
<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">
|
<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>
|
<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>
|
||||||
@@ -123,50 +100,6 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -178,12 +111,6 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
/* AI 消息:头像在左 */
|
|
||||||
.ai-msg-ai {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
/* 头像通用 */
|
/* 头像通用 */
|
||||||
.ai-msg-avatar {
|
.ai-msg-avatar {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
@@ -198,10 +125,6 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
background: var(--df-info-bg, rgba(100,181,246,0.15));
|
background: var(--df-info-bg, rgba(100,181,246,0.15));
|
||||||
color: var(--df-info, #64b5f6);
|
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 {
|
.ai-msg-bubble {
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -213,13 +136,6 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
background: var(--df-info-bg, rgba(100,181,246,0.1));
|
background: var(--df-info-bg, rgba(100,181,246,0.1));
|
||||||
color: var(--df-text);
|
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 */
|
/* chip */
|
||||||
.ai-msg-chip {
|
.ai-msg-chip {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -242,7 +158,7 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
/* 复制按钮 */
|
/* 复制按钮(hover 显示) */
|
||||||
.ai-copy-btn {
|
.ai-copy-btn {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
@@ -252,50 +168,7 @@ function imgAlt(img: ImageContentPart): string {
|
|||||||
padding: 2px;
|
padding: 2px;
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
}
|
}
|
||||||
.ai-msg-user:hover .ai-copy-btn,
|
.ai-msg-user:hover .ai-copy-btn {
|
||||||
.ai-msg-ai:hover .ai-copy-btn {
|
|
||||||
opacity: 1;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -678,16 +678,11 @@ defineExpose({
|
|||||||
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
|
<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 class="ai-msg-system-summary ai-md" v-html="renderMd(item.msg.content)"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 用户消息(提取至 MessageItem.vue) -->
|
<!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染) -->
|
||||||
<MessageItem
|
<MessageItem
|
||||||
v-else-if="item.msg.role === 'user'"
|
v-else-if="item.msg.role === 'user'"
|
||||||
:msg="item.msg"
|
: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"
|
@copy="copyMsgContent"
|
||||||
@start-edit="(m) => emit('start-edit', m)"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- AI 消息 -->
|
<!-- AI 消息 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user