本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

797 lines
30 KiB

<template>
<div class="chat-shell">
<!-- 左侧角色面板 -->
<aside class="chat-sidebar">
<div class="agent-card">
<div class="agent-avatar">SB</div>
<div>
<div class="agent-name">Support Bot</div>
<div class="agent-status"><span></span>在线客服助手</div>
</div>
</div>
<div class="side-section side-section-grow">
<div class="side-label">客服助手</div>
<div class="assistant-list">
<div
v-for="role in roles"
:key="role.key"
:class="['assistant-card', selectedRole === role.key ? 'active' : '']"
@click="selectRole(role.key)"
>
{{ role.name }}
</div>
</div>
</div>
</aside>
<!-- 右侧对话区 -->
<section class="chat-main">
<!-- 顶栏 -->
<header class="chat-header">
<div class="chat-title">
<h2>{{ currentRole.name }}</h2>
<div class="chat-subline">
<span :class="['rag-dot', isRagMode ? 'on' : '']"></span>{{ ragStatusText }}
<span class="dot-sep">&middot;</span>
{{ selectedCategoryNames.length ? selectedCategoryNames.join('、') : '全部知识库' }}
</div>
</div>
<div class="chat-actions">
<div class="model-chip" :class="{ 'model-chip-error': modelLoadError }" :title="modelTitle">
<span>调用模型</span>
<strong>{{ activeModelText }}</strong>
</div>
<label class="rag-toggle">
<t-switch v-model="isRagMode" size="small" />
<span>RAG 检索</span>
</label>
<t-select
v-if="isRagMode"
v-model="ragStrategy"
class="rag-strategy"
size="small"
:options="ragStrategyOptions"
/>
<t-select v-model="mode" class="chat-mode" size="small" :options="modeOptions" />
</div>
</header>
<!-- 快捷提问 -->
<div class="quick-row" v-if="messages.length <= 1">
<t-tag
v-for="q in QUICK_QUESTIONS"
:key="q"
theme="default"
variant="light"
class="quick-tag"
@click="useQuickQuestion(q)"
>{{ q }}</t-tag>
</div>
<!-- 消息列表 -->
<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">
<!-- 编辑模式 -->
<template v-if="editingIndex === i">
<t-textarea
v-model="editingText"
:autosize="{ minRows: 2, maxRows: 4 }"
@keydown.enter.exact.prevent="submitEdit(i)"
/>
<div class="edit-actions">
<t-button theme="primary" size="small" @click="submitEdit(i)" :disabled="isSending || !editingText.trim()">重新发送</t-button>
<t-button variant="outline" size="small" @click="cancelEdit">取消</t-button>
<span class="edit-hint">将清除此条之后的全部对话并重发</span>
</div>
</template>
<!-- 正常显示模式 -->
<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>
<!-- AI 思考中 -->
<div v-else-if="m.role === 'assistant'" class="msg-bubble"><span class="thinking">正在思考</span></div>
<!-- 用户消息 -->
<div v-else class="msg-bubble">{{ m.content }}</div>
<!-- 引用来源 -->
<MessageSources v-if="m.role === 'assistant' && m.sources && m.sources.length" :sources="m.sources" />
<!-- 推荐问题(suggest-message-list) -->
<div class="suggest-row" v-if="i === messages.length - 1 && currentSuggestions.length && !isSending">
<span class="suggest-label">💡 推荐问题</span>
<t-tag
v-for="q in currentSuggestions"
:key="q"
theme="default"
variant="light"
class="quick-tag"
@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>
</div>
</div>
</div>
<!-- 输入区 -->
<footer class="chat-composer">
<div class="composer-box">
<t-textarea
v-model="userInput"
placeholder="输入问题,Enter 发送,Shift + Enter 换行"
:autosize="{ minRows: 2, maxRows: 4 }"
:disabled="isSending"
@keydown.enter.exact.prevent="send"
/>
<t-button theme="primary" class="send-btn" @click="send" :disabled="isSending || !userInput.trim()">
{{ isSending ? '发送中' : '发送' }}
</t-button>
</div>
</footer>
</section>
<!-- 点踩原因弹窗 -->
<t-dialog v-model:visible="feedbackReasonVisible" header="请告诉我们哪里做得不好?" width="400px" :footer="false">
<div class="feedback-reason-options">
<t-button
v-for="opt in REASON_OPTIONS"
:key="opt.key"
:variant="feedbackSelectedReason === opt.key ? undefined : 'outline'"
:theme="feedbackSelectedReason === opt.key ? 'primary' : undefined"
size="small"
@click="feedbackSelectedReason = opt.key"
>{{ opt.label }}</t-button>
</div>
<t-textarea
v-model="feedbackReasonComment"
placeholder="补充说明(选填,最多200字)"
:maxlength="200"
:autosize="{ minRows: 2, maxRows: 3 }"
/>
<div class="dialog-footer">
<t-button variant="outline" size="small" @click="cancelFeedbackReason">取消</t-button>
<t-button theme="primary" size="small" :disabled="!feedbackSelectedReason" @click="confirmFeedbackReason">提交</t-button>
</div>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, ragSources, fetchSuggestions } from '@/api/chat'
import { getRoleList } from '@/api/role'
import { getActiveModelConfig } from '@/api/model-config'
import { truncateConversation } from '@/api/conversation'
import { submitFeedback as submitFeedbackApi } from '@/api/feedback'
import { toast } from '@/utils/toast'
import { readSSEStream, readSSEStreamWithEvents } from '@/utils/sse'
import { renderMarkdown } from '@/utils/markdown'
import { useCategoryStore } from '@/stores/category'
import MessageSources from '@/components/MessageSources.vue'
const categoryStore = useCategoryStore()
// ==================== 常量 ====================
const FALLBACK_ROLE = {
id: '', key: 'general', name: '客服',
categoryIds: [] as string[],
}
const QUICK_QUESTIONS = [
'我的问题应该找哪个部门处理',
'如何办理业务申请',
'流程进度一直没更新怎么办',
'费用报销需要准备哪些材料',
'办公用品或资产怎么申请',
]
const ragStrategyOptions = [
{ label: '不重写', value: 'NONE' },
{ label: '查询重写', value: 'REWRITE' },
{ label: '翻译扩展', value: 'TRANSLATION' },
{ label: '查询压缩', value: 'COMPRESSION' },
{ label: '多路扩展', value: 'MULTI_QUERY' },
]
const modeOptions = [
{ label: '同步调用', value: 'sync' },
{ label: 'SSE 流式', value: 'sse' },
]
// ==================== 状态 ====================
const chatId = ref('')
const mode = ref('sse') // 默认 SSE 流式
const selectedRole = ref('general')
const roles = ref([FALLBACK_ROLE])
const isRagMode = ref(false)
const ragStrategy = ref('MULTI_QUERY')
const activeModel = ref<any>(null)
const modelLoadError = ref('')
const userInput = ref('')
const lastUserInput = ref('')
const isSending = ref(false)
const msgAreaRef = ref<HTMLElement | null>(null)
const editingIndex = ref(-1)
const editingText = ref('')
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(),
role: 'assistant',
content: '您好,我是智能客服助手。可以咨询客服、财务、行政相关问题;需要基于知识库回答时,可以在上方开启 RAG 检索。',
streaming: false,
time: formatTime(),
},
])
// ==================== 计算属性 ====================
const currentRole = computed(() => roles.value.find(r => r.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE)
const selectedCategoryIds = computed(() => (currentRole.value.categoryIds || []).map(String))
const selectedCategoryNames = computed(() => {
const selected = new Set(selectedCategoryIds.value)
return categoryStore.categories
.filter(c => selected.has(String(c.id)))
.map(c => c.name)
})
const activeModelText = computed(() => {
if (activeModel.value) {
const provider = providerLabel(activeModel.value.provider)
const modelName = activeModel.value.modelName || activeModel.value.model_name || '-'
return provider ? `${provider} / ${modelName}` : modelName
}
return modelLoadError.value ? '未配置' : '加载中'
})
const modelTitle = computed(() => {
if (activeModel.value) {
const model = activeModel.value
const name = model.name ? `${model.name}` : ''
const provider = providerLabel(model.provider)
const mn = model.modelName || model.model_name || '-'
return `对话模型:${name}${provider ? provider + ' / ' : ''}${mn}`
}
return modelLoadError.value || '正在加载对话模型'
})
const ragStatusText = computed(() => {
if (!isRagMode.value) return '普通对话'
const names: Record<string, string> = {
NONE: 'RAG:不重写', REWRITE: 'RAG:查询重写',
TRANSLATION: 'RAG:翻译扩展', COMPRESSION: 'RAG:查询压缩',
MULTI_QUERY: 'RAG:多路扩展',
}
return names[ragStrategy.value] || 'RAG 已启用'
})
// ==================== 工具函数 ====================
function formatTime(): string {
return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
function generateMsgId(): string {
return 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8)
}
function newChatId(): void {
chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
}
function providerLabel(provider: string): string {
const map: Record<string, string> = {
dashscope: '通义千问', openai: 'OpenAI', volcengine: '豆包',
zhipu: '智谱', baidu: '百度千帆',
}
return map[String(provider || '').toLowerCase()] || provider || ''
}
// ==================== 角色管理 ====================
function selectRole(roleKey: string): void {
selectedRole.value = roleKey
newChatId()
currentSuggestions.value = [] // 切换角色时清空推荐问题
const role = roles.value.find(r => r.key === roleKey) || FALLBACK_ROLE
messages.value = [{
id: generateMsgId(),
role: 'assistant',
content: `已切换到${role.name}。请直接输入你的问题。`,
streaming: false,
time: formatTime(),
}]
}
function currentRoleId(): string {
return currentRole.value && currentRole.value.id ? String(currentRole.value.id) : ''
}
// ==================== 数据加载 ====================
async function loadActiveModel(): Promise<void> {
try {
const json = await getActiveModelConfig('CHAT')
if (json.success) {
activeModel.value = json.data
modelLoadError.value = ''
} else {
activeModel.value = null
modelLoadError.value = json.message || '对话模型未配置'
}
} catch (e: any) {
activeModel.value = null
modelLoadError.value = e.message || '模型加载失败'
}
}
async function loadRoles(): Promise<void> {
try {
const json = await getRoleList()
if (json.success) {
roles.value = (json.data || []).map((role: any) => ({
id: role.id,
key: role.role_key || role.roleKey || String(role.id),
name: role.name,
categoryIds: (role.categoryIds || []).map(String),
}))
if (!roles.value.some(r => r.key === selectedRole.value)) {
selectedRole.value = roles.value[0]?.key || 'general'
}
}
} catch (e: any) {
toast('加载客服角色失败:' + e.message, 'error')
}
}
// ==================== 滚动 ====================
async function scrollToBottom(): Promise<void> {
await nextTick()
if (msgAreaRef.value) {
msgAreaRef.value.scrollTop = msgAreaRef.value.scrollHeight
}
}
// ==================== 快捷提问 ====================
function useQuickQuestion(question: string): void {
userInput.value = question
send()
}
// ==================== 核心:发送消息 ====================
async function send(): Promise<void> {
const text = userInput.value.trim()
if (!text || isSending.value) return
userInput.value = ''
lastUserInput.value = text
isSending.value = true
currentSuggestions.value = [] // 清空建议
// 追加用户消息
messages.value.push({
id: generateMsgId(), role: 'user', content: text,
streaming: false, time: formatTime(),
})
// 追加 AI 占位消息
const assistantMsg: ChatMessage = {
id: generateMsgId(), role: 'assistant', content: '',
streaming: true, time: formatTime(), sources: [], toolCalls: [],
}
messages.value.push(assistantMsg)
await scrollToBottom()
const cid = chatId.value || ('web_' + Date.now())
chatId.value = cid
const roleId = currentRoleId()
try {
// 取消上一个 SSE 请求并创建新的 AbortController
if (sseAbortController) { sseAbortController.abort() }
sseAbortController = new AbortController()
const signal = sseAbortController.signal
if (isRagMode.value && mode.value === 'sync') {
// RAG 同步
assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId)
} else if (isRagMode.value) {
// RAG SSE 流式
const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId)
await readSSEStreamWithEvents(url, {
onMessage: async (chunk: string) => {
assistantMsg.content += chunk
await scrollToBottom()
},
onToolCallStart: (data: any) => {
assistantMsg.toolCalls!.push({ tool: data.tool, input: data.input, status: 'running', result: null })
scrollToBottom()
},
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 }
scrollToBottom()
},
onError: (data: any) => {
assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错')
},
onDone: () => {},
}, undefined, signal)
} else if (mode.value === 'sync') {
// 普通同步
assistantMsg.content = await chatSync(text, cid, roleId)
} else {
// 普通 SSE 流式
const url = chatSSEUrl(text, cid, roleId)
await readSSEStream(url, async (chunk: string) => {
assistantMsg.content += chunk
await scrollToBottom()
}, () => {}, undefined, signal)
}
// RAG 模式下,拉取引用来源
if (isRagMode.value) {
try {
const sj = await ragSources(text, cid, ragStrategy.value, roleId)
if (sj && sj.success) assistantMsg.sources = sj.data || []
} catch (_) { /* 来源获取失败不影响主回答 */ }
}
} catch (e: any) {
// AbortError 不是真正的错误,不显示错误信息
if (e.name === 'AbortError') {
assistantMsg.content = assistantMsg.content || '已取消'
} else {
assistantMsg.content = '请求失败:' + e.message
assistantMsg.error = true
toast('对话失败:' + e.message, 'error')
}
} finally {
assistantMsg.streaming = false
isSending.value = false
// 拉取推荐问题(suggest-message-list);AbortError / 异常时不拉取
if (!sseAbortController?.signal.aborted && assistantMsg.content && !assistantMsg.error) {
fetchSuggestions(chatId.value).then(items => {
if (items.length) currentSuggestions.value = items
}).catch(() => {})
}
await scrollToBottom()
}
}
// ==================== 消息操作 ====================
function retryLast(): void {
if (!lastUserInput.value || isSending.value) return
userInput.value = lastUserInput.value
send()
}
function startEditMessage(i: number): void {
if (isSending.value) return
editingIndex.value = i
editingText.value = messages.value[i].content || ''
}
function cancelEdit(): void {
editingIndex.value = -1
editingText.value = ''
}
async function submitEdit(i: number): Promise<void> {
const text = editingText.value.trim()
if (!text || isSending.value) return
// 计算 userTurn(1-based)
let userTurn = 0
for (let j = 0; j <= i; j++) {
if (messages.value[j].role === 'user') userTurn++
}
// 截断
try {
const json = await truncateConversation(chatId.value, userTurn)
if (json && json.success === false) {
toast(json.message || '截断失败', 'error')
return
}
} catch (e: any) {
toast('截断失败:' + e.message, 'error')
return
}
// 切掉本地本条及其之后
messages.value = messages.value.slice(0, i)
editingIndex.value = -1
editingText.value = ''
userInput.value = text
await send()
}
async function regenerate(i: number): Promise<void> {
if (isSending.value) return
// 找上一条用户消息
let userIndex = -1
for (let j = i - 1; j >= 0; j--) {
if (messages.value[j].role === 'user') { userIndex = j; break }
}
if (userIndex < 0) return
const text = messages.value[userIndex].content
let userTurn = 0
for (let j = 0; j <= userIndex; j++) {
if (messages.value[j].role === 'user') userTurn++
}
try {
const json = await truncateConversation(chatId.value, userTurn)
if (json && json.success === false) { toast(json.message || '重新生成失败', 'error'); return }
} catch (e: any) { toast('重新生成失败:' + e.message, 'error'); return }
messages.value = messages.value.slice(0, userIndex)
userInput.value = text
await send()
}
async function copyMessage(content: string): Promise<void> {
try {
await navigator.clipboard.writeText(content)
toast('已复制回复', 'success')
} catch { toast('复制失败', 'error') }
}
// ==================== 消息反馈 ====================
// 点踩原因弹窗状态
const feedbackReasonVisible = ref(false)
const feedbackSelectedReason = ref('')
const feedbackReasonComment = ref('')
const feedbackTarget = ref<{ msgId: string; msgIndex: number; wasActive: boolean } | null>(null)
const REASON_OPTIONS = [
{ key: 'inaccurate', label: '不准确' },
{ key: 'irrelevant', label: '不相关' },
{ key: 'incomplete', label: '不完整' },
{ key: 'other', label: '其他' },
]
async function submitFeedback(msgId: string, type: 'up' | 'down'): Promise<void> {
const msgIndex = messages.value.findIndex(m => m.id === msgId && m.role === 'assistant')
if (msgIndex === -1) return
const msg = messages.value[msgIndex]
const wasActive = msg.feedback === type
const newFeedback = wasActive ? null : type
if (type === 'up') {
// 点赞:直接提交/取消
msg.feedback = newFeedback
if (!newFeedback) return
try {
await submitFeedbackApi({
messageId: String(msgId),
conversationId: chatId.value,
feedbackType: 'THUMBS_UP',
})
toast('感谢反馈 👍', 'success')
} catch (e) {
// 失败时回滚乐观更新
msg.feedback = wasActive ? 'up' : null
console.error('反馈提交失败:', e)
toast(e.message || '反馈提交失败', 'error')
}
} else {
// 点踩:弹出原因选择弹窗(不再乐观标记前端状态,等提交成功后再更新)
if (wasActive) {
msg.feedback = null
return
}
feedbackTarget.value = { msgId, msgIndex, wasActive }
feedbackSelectedReason.value = ''
feedbackReasonComment.value = ''
feedbackReasonVisible.value = true
}
}
async function confirmFeedbackReason(): Promise<void> {
const target = feedbackTarget.value
if (!target || !feedbackSelectedReason.value) return
const msg = messages.value[target.msgIndex]
const reasonCategory = feedbackSelectedReason.value
const reasonComment = feedbackReasonComment.value.trim() || undefined
// 提交时才乐观标记前端状态
msg.feedback = 'down'
try {
await submitFeedbackApi({
messageId: String(target.msgId),
conversationId: chatId.value,
feedbackType: 'THUMBS_DOWN',
reasonCategory,
reasonComment,
})
toast('感谢反馈,我们会持续改进', 'success')
} catch (e) {
// 失败时回滚
msg.feedback = null
console.error('反馈提交失败:', e)
toast(e.message || '反馈提交失败', 'error')
}
feedbackReasonVisible.value = false
feedbackTarget.value = null
}
function cancelFeedbackReason(): void {
feedbackReasonVisible.value = false
feedbackTarget.value = null
}
// ==================== 初始化 ====================
onMounted(() => {
newChatId()
categoryStore.loadCategories()
loadRoles()
loadActiveModel()
})
// 组件卸载时取消正在进行的 SSE 请求,释放网络资源,避免浏览器窗口切换异常
onBeforeUnmount(() => {
if (sseAbortController) {
sseAbortController.abort()
sseAbortController = null
}
})
</script>
<style scoped>
/* ==================== 布局 ==================== */
.chat-shell {
display: flex; height: calc(100vh - 48px); overflow: hidden;
}
.chat-sidebar {
width: 220px; background: var(--td-bg-color-page); border-right: 1px solid var(--td-border-level-1-color);
flex-shrink: 0; display: flex; flex-direction: column; overflow-y: auto; padding: 12px;
}
.chat-main {
flex: 1; display: flex; flex-direction: column; overflow: hidden; background: var(--td-bg-color-container);
}
/* ==================== 角色卡片 ==================== */
.agent-card {
display: flex; align-items: center; gap: 10px; padding: 0 0 12px 0;
border-bottom: 1px solid var(--td-border-level-1-color);
}
.agent-avatar {
width: 40px; height: 40px; border-radius: 10px; background: var(--td-brand-color);
color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px;
}
.agent-name { font-weight: 600; font-size: 14px; }
.agent-status { font-size: 12px; color: var(--td-success-color); display: flex; align-items: center; gap: 4px; }
.agent-status span { width: 6px; height: 6px; border-radius: 50%; background: var(--td-success-color); }
.side-label { font-size: 11px; color: var(--td-text-color-placeholder); text-transform: uppercase; margin: 12px 0 8px; letter-spacing: 1px; }
.side-section-grow { flex: 1; overflow-y: auto; }
.assistant-list { display: flex; flex-direction: column; gap: 6px; }
.assistant-card {
display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 8px;
cursor: pointer; transition: background .15s; border: 1px solid transparent;
}
.assistant-card:hover { background: var(--td-bg-color-component); }
.assistant-card.active { background: var(--td-brand-color-light); border-color: var(--td-brand-color); }
/* ==================== 顶栏 ==================== */
.chat-header {
display: flex; align-items: flex-start; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid var(--td-border-level-1-color);
flex-wrap: wrap; gap: 8px;
}
.chat-title h2 { font-size: 16px; margin: 0 0 4px; }
.chat-subline { font-size: 12px; color: var(--td-text-color-placeholder); display: flex; align-items: center; gap: 4px; }
.rag-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--td-text-color-disabled); flex-shrink: 0; }
.rag-dot.on { background: var(--td-success-color); }
.dot-sep { color: var(--td-border-level-2-color); }
.chat-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.model-chip {
display: flex; align-items: center; gap: 4px; font-size: 12px; background: var(--td-bg-color-component);
padding: 4px 10px; border-radius: 6px; white-space: nowrap;
}
.model-chip strong { color: var(--td-brand-color); max-width: 160px; overflow: hidden; text-overflow: ellipsis; }
.model-chip-error { background: var(--td-error-color-1); }
.model-chip-error strong { color: var(--td-error-color); }
.rag-toggle { display: flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer; }
.rag-strategy { width: 120px; }
.chat-mode { width: 100px; }
/* ==================== 快捷提问 ==================== */
.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; }
/* ==================== 消息区 ==================== */
.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: #fff; 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; }
@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: #1e1e1e; color: #d4d4d4; padding: 12px; border-radius: 8px; overflow-x: auto; }
.msg-bubble :deep(pre code) { background: none; padding: 0; color: inherit; }
/* ==================== MCP 工具调用 ==================== */
.mcp-tool-calls { margin-bottom: 8px; }
.mcp-tool-item { background: var(--td-bg-color-page); border: 1px solid var(--td-border-level-1-color); border-radius: 6px; padding: 6px 10px; margin-bottom: 4px; }
.mcp-tool-header { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.mcp-tool-icon { font-size: 14px; }
.mcp-tool-name { font-weight: 600; color: var(--td-brand-color); }
.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; }
.edit-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.edit-hint { 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; }
.chat-header { flex-direction: column; }
.chat-actions { flex-wrap: wrap; }
}
/* ==================== 点踩原因弹窗 ==================== */
.feedback-reason-options { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.dialog-footer { margin-top: 12px; display: flex; justify-content: flex-end; gap: 8px; }
</style>