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
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">·</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>
|