Browse Source

refactor(chat): 使用 TDesign Chat 组件重构聊天面板

安装 @tdesign-vue-next/chat@0.6.0 并新增 chatAdapter.ts 统一消息格式适配;
将 ChatPanel.vue 的手写消息列表、输入框与操作栏替换为 ChatList/ChatSender/ChatActionbar,保留 SSE、RAG、MCP、反馈、推荐问题、编辑及重新生成等业务逻辑;
同步优化 vite.config.ts 的 manualChunks,将 tdesign-chat、tdesign-web-components 与 tdesign 拆分为独立 chunk。
master
wanghanlin 7 days ago
parent
commit
7f6b696885
  1. 1112
      frontend/package-lock.json
  2. 1
      frontend/package.json
  3. 56
      frontend/src/utils/chatAdapter.ts
  4. 238
      frontend/src/views/ChatPanel.vue
  5. 9
      frontend/vite.config.ts

1112
frontend/package-lock.json
File diff suppressed because it is too large
View File

1
frontend/package.json

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tdesign-vue-next/chat": "^0.6.0",
"axios": "^1.7.9",
"chart.js": "^4.4.7",
"dompurify": "^3.2.4",

56
frontend/src/utils/chatAdapter.ts

@ -0,0 +1,56 @@
/**
* ChatMessage @tdesign-vue-next/chat ChatItemMeta
*
* ChatList ChatMessage role / name / datetime / avatar / content / status
* 使 #content slot content ChatPanel.vue slot
*/
import type { TdChatItemMeta, AIMessageContent, UserMessageContent } from '@tdesign-vue-next/chat'
/** 项目内部消息模型(与 ChatPanel.vue 中 ChatMessage 保持一致) */
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
streaming: boolean
time: string
sources?: any[]
toolCalls?: any[]
error?: boolean
feedback?: string | null
}
/** 判断消息状态 */
function getStatus(msg: ChatMessage): 'pending' | 'streaming' | 'complete' | 'stop' | 'error' {
if (msg.error) return 'error'
if (msg.streaming) return 'streaming'
return 'complete'
}
/** 转换单条消息 */
export function toChatItemMeta(msg: ChatMessage): TdChatItemMeta {
const isUser = msg.role === 'user'
return {
role: isUser ? 'user' : 'assistant',
name: isUser ? '我' : 'Support Bot',
avatar: isUser ? '' : undefined,
datetime: msg.time,
content: isUser
? ([{ type: 'text', data: msg.content || '' }] as UserMessageContent[])
: ([{ type: 'markdown', data: msg.content || '' }] as AIMessageContent[]),
status: getStatus(msg),
}
}
/** 批量转换 */
export function toChatData(messages: ChatMessage[]): TdChatItemMeta[] {
return messages.map(toChatItemMeta)
}
/** 根据消息索引判断是否为最后一条 assistant 消息 */
export function isLastAssistant(index: number, messages: ChatMessage[]): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') return i === index
}
return false
}

238
frontend/src/views/ChatPanel.vue

@ -69,19 +69,38 @@
>{{ q }}</t-tag>
</div>
<!-- 消息列表 -->
<!-- 消息列表TDesign AI Chat -->
<div class="msg-area chat-msg-area" ref="msgAreaRef">
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role, m.streaming ? 'streaming' : '']">
<div class="msg-content">
<chat-list
:data="chatData"
layout="both"
:auto-scroll="false"
:is-stream-load="false"
:text-loading="false"
class="chat-list"
>
<template #content="{ index }">
<div class="chat-custom-content">
<!-- MCP 工具调用 -->
<div v-if="messages[index]?.toolCalls?.length" class="mcp-tool-calls">
<div v-for="(tc, j) in messages[index].toolCalls" :key="j" class="mcp-tool-item">
<div class="mcp-tool-header">
<span class="mcp-tool-icon">{{ tc.status === 'running' ? '⏳' : '✅' }}</span>
<span class="mcp-tool-name">{{ tc.tool }}</span>
<span v-if="tc.latencyMs" class="mcp-tool-latency">{{ tc.latencyMs }}ms</span>
</div>
</div>
</div>
<!-- 编辑模式 -->
<template v-if="editingIndex === i">
<template v-if="editingIndex === index">
<t-textarea
v-model="editingText"
:autosize="{ minRows: 2, maxRows: 4 }"
@keydown.enter.exact.prevent="submitEdit(i)"
@keydown.enter.exact.prevent="submitEdit(index)"
/>
<div class="edit-actions">
<t-button theme="primary" size="small" @click="submitEdit(i)" :disabled="isSending || !editingText.trim()">重新发送</t-button>
<t-button theme="primary" size="small" @click="submitEdit(index)" :disabled="isSending || !editingText.trim()">重新发送</t-button>
<t-button variant="outline" size="small" @click="cancelEdit">取消</t-button>
<span class="edit-hint">将清除此条之后的全部对话并重发</span>
</div>
@ -89,28 +108,18 @@
<!-- 正常显示模式 -->
<template v-else>
<!-- MCP 工具调用 -->
<div v-if="m.role === 'assistant' && m.toolCalls && m.toolCalls.length" class="mcp-tool-calls">
<div v-for="(tc, j) in m.toolCalls" :key="j" class="mcp-tool-item">
<div class="mcp-tool-header">
<span class="mcp-tool-icon">{{ tc.status === 'running' ? '⏳' : '✅' }}</span>
<span class="mcp-tool-name">{{ tc.tool }}</span>
<span v-if="tc.latencyMs" class="mcp-tool-latency">{{ tc.latencyMs }}ms</span>
</div>
</div>
</div>
<!-- AI 回复Markdown 渲染 -->
<div v-if="m.role === 'assistant' && m.content" class="msg-bubble markdown-body" v-html="renderMarkdown(m.content)"></div>
<div v-if="messages[index]?.role === 'assistant' && messages[index]?.content" class="markdown-body" v-html="renderMarkdown(messages[index].content)"></div>
<!-- AI 思考中 -->
<div v-else-if="m.role === 'assistant'" class="msg-bubble"><span class="thinking">正在思考</span></div>
<div v-else-if="messages[index]?.role === 'assistant'" class="thinking"><span>正在思考</span></div>
<!-- 用户消息 -->
<div v-else class="msg-bubble">{{ m.content }}</div>
<div v-else class="user-text">{{ messages[index]?.content }}</div>
<!-- 引用来源 -->
<MessageSources v-if="m.role === 'assistant' && m.sources && m.sources.length" :sources="m.sources" />
<MessageSources v-if="messages[index]?.role === 'assistant' && messages[index]?.sources?.length" :sources="messages[index].sources" />
<!-- 推荐问题suggest-message-list -->
<div class="suggest-row" v-if="i === messages.length - 1 && currentSuggestions.length && !isSending">
<!-- 推荐问题 -->
<div class="suggest-row" v-if="isLastAssistant(index, messages) && currentSuggestions.length && !isSending">
<span class="suggest-label">💡 推荐问题</span>
<t-tag
v-for="q in currentSuggestions"
@ -121,38 +130,40 @@
@click="useQuickQuestion(q)"
>{{ q }}</t-tag>
</div>
<!-- 消息操作栏 -->
<div class="msg-tools">
<span>{{ m.time }}</span>
<t-button v-if="m.role === 'assistant' && m.content" variant="text" size="small" @click="copyMessage(m.content)">复制</t-button>
<t-button v-if="m.role === 'assistant' && m.content && !m.streaming" variant="text" size="small" @click="regenerate(i)">重新生成</t-button>
<t-button v-if="m.role === 'user' && !m.streaming" variant="text" size="small" @click="startEditMessage(i)">编辑</t-button>
<t-button v-if="m.error" variant="text" size="small" @click="retryLast">重试</t-button>
<template v-if="m.role === 'assistant' && m.content && !m.streaming">
<t-button variant="text" size="small" :style="{ color: m.feedback === 'up' ? 'var(--td-brand-color)' : '' }" @click="submitFeedback(m.id, 'up')" title="有帮助">👍</t-button>
<t-button variant="text" size="small" :style="{ color: m.feedback === 'down' ? 'var(--td-error-color)' : '' }" @click="submitFeedback(m.id, 'down')" title="没帮助">👎</t-button>
</template>
</div>
</template>
<template #actions="{ index }">
<chat-actionbar
v-if="messages[index]?.role === 'assistant' && messages[index]?.content && !messages[index]?.streaming"
:is-good="messages[index]?.feedback === 'up'"
:is-bad="messages[index]?.feedback === 'down'"
:action-bar="['copy', 'replay', 'good', 'bad']"
@operation="(val) => onMessageAction(val, index)"
/>
<div v-else-if="messages[index]?.role === 'user' && !messages[index]?.streaming" class="msg-extra-actions">
<t-button variant="text" size="small" @click="startEditMessage(index)">编辑</t-button>
</div>
<div v-else-if="messages[index]?.error" class="msg-extra-actions">
<t-button variant="text" size="small" @click="retryLast">重试</t-button>
</div>
</template>
</chat-list>
</div>
<!-- 输入区 -->
<footer class="chat-composer">
<div class="composer-box">
<t-textarea
<chat-sender
v-model="userInput"
placeholder="输入问题,Enter 发送,Shift + Enter 换行"
:autosize="{ minRows: 2, maxRows: 4 }"
:loading="isSending"
:disabled="isSending"
@keydown.enter.exact.prevent="send"
:send-btn-disabled="!userInput.trim()"
placeholder="输入问题,Enter 发送,Shift + Enter 换行"
:textarea-props="{ autosize: { minRows: 2, maxRows: 4 } }"
@send="send"
@stop="abortChat"
/>
<t-button theme="primary" class="send-btn" @click="send" :disabled="isSending || !userInput.trim()">
{{ isSending ? '发送中' : '发送' }}
</t-button>
</div>
</footer>
</section>
@ -184,6 +195,12 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
import {
ChatList,
ChatSender,
ChatActionbar,
} from '@tdesign-vue-next/chat'
import '@tdesign-vue-next/chat/es/style/index.css'
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, ragSources, fetchSuggestions } from '@/api/chat'
import { getRoleList } from '@/api/role'
import { getActiveModelConfig } from '@/api/model-config'
@ -193,6 +210,8 @@ import { toast } from '@/utils/toast'
import { readSSEStream, readSSEStreamWithEvents } from '@/utils/sse'
import { renderMarkdown } from '@/utils/markdown'
import { useCategoryStore } from '@/stores/category'
import { toChatData, isLastAssistant } from '@/utils/chatAdapter'
import type { ChatMessage } from '@/utils/chatAdapter'
import MessageSources from '@/components/MessageSources.vue'
const categoryStore = useCategoryStore()
@ -243,18 +262,6 @@ let sseAbortController: AbortController | null = null
const currentSuggestions = ref<string[]>([]) // suggest-message-list
// ==================== ====================
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
streaming: boolean
time: string
sources?: any[]
toolCalls?: any[]
error?: boolean
feedback?: string | null
}
const messages = ref<ChatMessage[]>([
{
id: generateMsgId(),
@ -265,6 +272,8 @@ const messages = ref<ChatMessage[]>([
},
])
const chatData = computed(() => toChatData(messages.value))
// ==================== ====================
const currentRole = computed(() => roles.value.find(r => r.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE)
@ -440,6 +449,7 @@ async function send(): Promise<void> {
await readSSEStreamWithEvents(url, {
onMessage: async (chunk: string) => {
assistantMsg.content += chunk
messages.value = [...messages.value]
await scrollToBottom()
},
onToolCallStart: (data: any) => {
@ -449,10 +459,12 @@ async function send(): Promise<void> {
onToolCallResult: (data: any) => {
const tc = assistantMsg.toolCalls!.find(t => t.tool === data.tool && t.status === 'running')
if (tc) { tc.status = 'done'; tc.result = data.result; tc.latencyMs = data.latencyMs }
messages.value = [...messages.value]
scrollToBottom()
},
onError: (data: any) => {
assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错')
messages.value = [...messages.value]
},
onDone: () => {},
}, undefined, signal)
@ -464,6 +476,7 @@ async function send(): Promise<void> {
const url = chatSSEUrl(text, cid, roleId)
await readSSEStream(url, async (chunk: string) => {
assistantMsg.content += chunk
messages.value = [...messages.value]
await scrollToBottom()
}, () => {}, undefined, signal)
}
@ -487,6 +500,7 @@ async function send(): Promise<void> {
} finally {
assistantMsg.streaming = false
isSending.value = false
messages.value = [...messages.value]
// suggest-message-listAbortError /
if (!sseAbortController?.signal.aborted && assistantMsg.content && !assistantMsg.error) {
@ -499,7 +513,40 @@ async function send(): Promise<void> {
}
}
// ==================== ====================
function abortChat(): void {
if (sseAbortController) {
sseAbortController.abort()
}
isSending.value = false
const last = messages.value[messages.value.length - 1]
if (last && last.role === 'assistant' && last.streaming) {
last.streaming = false
if (!last.content) last.content = '已取消'
messages.value = [...messages.value]
}
}
// ==================== ====================
function onMessageAction(action: string, index: number): void {
const msg = messages.value[index]
if (!msg) return
switch (action) {
case 'copy':
copyMessage(msg.content)
break
case 'replay':
regenerate(index)
break
case 'good':
submitFeedback(msg.id, 'up')
break
case 'bad':
submitFeedback(msg.id, 'down')
break
}
}
function retryLast(): void {
if (!lastUserInput.value || isSending.value) return
userInput.value = lastUserInput.value
@ -605,7 +652,7 @@ async function submitFeedback(msgId: string, type: 'up' | 'down'): Promise<void>
feedbackType: 'THUMBS_UP',
})
toast('感谢反馈 👍', 'success')
} catch (e) {
} catch (e: any) {
//
msg.feedback = wasActive ? 'up' : null
console.error('反馈提交失败:', e)
@ -641,7 +688,7 @@ async function confirmFeedbackReason(): Promise<void> {
reasonComment,
})
toast('感谢反馈,我们会持续改进', 'success')
} catch (e) {
} catch (e: any) {
//
msg.feedback = null
console.error('反馈提交失败:', e)
@ -736,34 +783,55 @@ onBeforeUnmount(() => {
.quick-row { display: flex; gap: 8px; padding: 10px 16px; flex-wrap: wrap; border-bottom: 1px solid var(--td-border-level-1-color); }
.quick-tag { cursor: pointer; }
/* ==================== 消息区 ==================== */
/* ==================== 消息区(TDesign ChatList 容器) ==================== */
.msg-area { flex: 1; overflow-y: auto; padding: 16px; }
.msg { margin-bottom: 16px; }
.msg.user .msg-content { display: flex; flex-direction: column; align-items: flex-end; }
.msg.user .msg-bubble {
background: var(--td-brand-color); color: var(--td-text-color-anti); border-radius: 12px 12px 4px 12px;
padding: 10px 14px; max-width: 70%; font-size: 14px; line-height: 1.6; white-space: pre-wrap;
}
.msg.assistant .msg-bubble {
background: var(--td-bg-color-secondarycontainer); color: var(--td-text-color-primary); border-radius: 12px 12px 12px 4px;
padding: 12px 16px; max-width: 85%; font-size: 14px; line-height: 1.7;
}
.msg.streaming .msg-content::after { content: ''; }
.thinking { color: var(--td-text-color-placeholder); font-style: italic; animation: blink 1s infinite; }
.chat-list {
--td-chat-item-max-width: 85%;
}
.chat-list :deep(.t-chat__list) {
gap: 16px;
}
.chat-list :deep(.t-chat__inner) {
width: 100%;
}
.chat-list :deep(.t-chat__content) {
max-width: 85%;
}
.chat-list :deep(.t-chat__detail) {
padding: 12px 16px;
border-radius: 12px;
background: var(--td-bg-color-secondarycontainer);
}
.chat-list :deep(.t-chat__inner.user .t-chat__detail) {
background: var(--td-brand-color);
color: var(--td-text-color-anti);
}
.chat-list :deep(.t-chat__actions-margin) {
margin-top: 6px;
}
/* 自定义内容区 */
.chat-custom-content { font-size: 14px; line-height: 1.7; }
.chat-custom-content .user-text {
white-space: pre-wrap;
}
.chat-custom-content .thinking {
color: var(--td-text-color-placeholder); font-style: italic; animation: blink 1s infinite;
}
@keyframes blink { 50% { opacity: 0.5; } }
/* Markdown 在气泡内 */
.msg-bubble :deep(h1) { font-size: 1.3em; margin: 12px 0 6px; }
.msg-bubble :deep(h2) { font-size: 1.15em; margin: 10px 0 4px; }
.msg-bubble :deep(h3) { font-size: 1.05em; margin: 8px 0 4px; }
.msg-bubble :deep(p) { margin: 4px 0; }
.msg-bubble :deep(ul), .msg-bubble :deep(ol) { padding-left: 20px; margin: 6px 0; }
.msg-bubble :deep(table) { border-collapse: collapse; margin: 8px 0; width: 100%; }
.msg-bubble :deep(th), .msg-bubble :deep(td) { border: 1px solid var(--td-border-level-2-color); padding: 6px 10px; text-align: left; font-size: 13px; }
.msg-bubble :deep(th) { background: var(--td-bg-color-secondarycontainer); font-weight: 600; }
.msg-bubble :deep(code) { background: rgba(0,0,0,.06); padding: 2px 6px; border-radius: 3px; font-size: 13px; }
.msg-bubble :deep(pre) { background: var(--color-code-block-bg); color: var(--color-code-block-text); padding: 12px; border-radius: 8px; overflow-x: auto; }
.msg-bubble :deep(pre code) { background: none; padding: 0; color: inherit; }
.chat-custom-content :deep(h1) { font-size: 1.3em; margin: 12px 0 6px; }
.chat-custom-content :deep(h2) { font-size: 1.15em; margin: 10px 0 4px; }
.chat-custom-content :deep(h3) { font-size: 1.05em; margin: 8px 0 4px; }
.chat-custom-content :deep(p) { margin: 4px 0; }
.chat-custom-content :deep(ul), .chat-custom-content :deep(ol) { padding-left: 20px; margin: 6px 0; }
.chat-custom-content :deep(table) { border-collapse: collapse; margin: 8px 0; width: 100%; }
.chat-custom-content :deep(th), .chat-custom-content :deep(td) { border: 1px solid var(--td-border-level-2-color); padding: 6px 10px; text-align: left; font-size: 13px; }
.chat-custom-content :deep(th) { background: var(--td-bg-color-secondarycontainer); font-weight: 600; }
.chat-custom-content :deep(code) { background: rgba(0,0,0,.06); padding: 2px 6px; border-radius: 3px; font-size: 13px; }
.chat-custom-content :deep(pre) { background: var(--color-code-block-bg); color: var(--color-code-block-text); padding: 12px; border-radius: 8px; overflow-x: auto; }
.chat-custom-content :deep(pre code) { background: none; padding: 0; color: inherit; }
/* ==================== MCP 工具调用 ==================== */
.mcp-tool-calls { margin-bottom: 8px; }
@ -774,16 +842,16 @@ onBeforeUnmount(() => {
.mcp-tool-latency { font-size: 11px; color: var(--td-text-color-placeholder); margin-left: auto; }
/* ==================== 消息操作 ==================== */
.msg-tools { display: flex; align-items: center; gap: 6px; margin-top: 6px; font-size: 12px; color: var(--td-text-color-placeholder); }
.msg-tools span { margin-right: auto; }
.msg-extra-actions { display: inline-flex; }
.edit-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.edit-hint { font-size: 12px; color: var(--td-text-color-placeholder); }
/* ==================== 推荐问题 ==================== */
.suggest-row { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; align-items: center; }
.suggest-label { font-size: 12px; color: var(--td-text-color-placeholder); }
/* ==================== 输入区 ==================== */
.chat-composer { padding: 12px 16px; border-top: 1px solid var(--td-border-level-1-color); }
.composer-box { display: flex; gap: 10px; align-items: flex-end; }
.composer-box :deep(.t-textarea__inner) { min-height: 44px; }
.send-btn { flex-shrink: 0; height: 44px; }
@media (max-width: 768px) {
.chat-sidebar { display: none; }

9
frontend/vite.config.ts

@ -41,9 +41,12 @@ export default defineConfig({
rollupOptions: {
output: {
// 拆出独立 chunk,浏览器可长期缓存
manualChunks: {
'tdesign': ['tdesign-vue-next'],
'echarts': ['mermaid'], // mermaid 体积较大,独立打包
manualChunks(id) {
if (id.includes('@tdesign-vue-next/chat')) return 'tdesign-chat'
if (id.includes('tdesign-web-components')) return 'tdesign-web-components'
if (id.includes('tdesign-vue-next')) return 'tdesign'
// 注意:mermaid 不再单独拆包;引入 tdesign-web-components 后,
// mermaid 同时被 PipelineFlow 与 web-components 引用,单独拆出会导致循环 chunk 警告。
},
},
},

Loading…
Cancel
Save