Files
rag-local/ui-src/src/views/Chat.vue
T
2026-08-06 10:05:40 +08:00

306 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="chat-page">
<!-- 左侧会话列表 -->
<div class="chat-side">
<div class="side-head">
<el-button type="primary" style="width: 100%" @click="newConversation">新建会话</el-button>
<el-select v-model="datasetId" placeholder="选择知识库" style="width: 100%; margin-top: 10px"
@change="onDatasetChange">
<el-option v-for="d in datasets" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
</div>
<div class="conv-list">
<div v-for="c in conversations" :key="c.id" class="conv-item"
:class="{ active: c.id === currentConvId }" @click="openConversation(c)">
<span class="conv-title">{{ c.title || '新会话' }}</span>
<el-icon class="conv-del" title="删除" @click.stop="removeConversation(c)"><Delete /></el-icon>
</div>
<el-empty v-if="!conversations.length" description="暂无会话" :image-size="60" />
</div>
</div>
<!-- 右侧对话区 -->
<div class="chat-main">
<div ref="msgBox" class="msg-area">
<template v-for="(m, i) in messages" :key="i">
<div class="msg-row" :class="m.role">
<div class="msg-bubble">
<div class="msg-content">{{ m.content || (m.streaming ? '…' : '') }}</div>
<div v-if="m.citations && m.citations.length" class="msg-citations">
<el-collapse>
<el-collapse-item :title="'引用来源(' + m.citations.length + ''">
<div v-for="c in m.citations" :key="c.index" class="citation-item">
<div class="citation-head">
<span class="citation-idx">[{{ c.index }}]</span>
<el-tag v-for="s in c.sources" :key="s" size="small" type="info">{{ s }}</el-tag>
<span class="citation-score">得分 {{ c.score.toFixed(1) }}</span>
</div>
<div class="citation-content">{{ c.content }}</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</div>
</template>
</div>
<div class="msg-input">
<el-input v-model="input" type="textarea" :rows="3" resize="none" placeholder="输入问题,回车发送(Shift+Enter 换行)"
:disabled="streaming" @keydown.enter.exact.prevent="send" />
<el-button type="primary" :loading="streaming" :disabled="!input.trim()" @click="send" style="margin-left: 10px">
{{ streaming ? '生成中' : '发送' }}
</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, nextTick, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete } from '@element-plus/icons-vue'
import { listDatasets } from '../api/dataset.js'
import { listConversations, saveConversation, deleteConversation, listMessages, streamChat } from '../api/chat.js'
const conversations = ref([])
const datasets = ref([])
const currentConvId = ref(0)
const datasetId = ref(null)
const messages = ref([])
const input = ref('')
const streaming = ref(false)
const msgBox = ref(null)
onMounted(async () => {
try {
const d = await listDatasets()
if (d && d.list) datasets.value = d.list
if (datasets.value.length && !datasetId.value) {
datasetId.value = datasets.value[0].id
}
} catch { /* 忽略 */ }
await refreshConversations()
})
function scrollBottom() {
nextTick(() => {
if (msgBox.value) msgBox.value.scrollTop = msgBox.value.scrollHeight
})
}
async function refreshConversations() {
const c = await listConversations()
conversations.value = (c && c.list) || []
}
function newConversation() {
currentConvId.value = 0
messages.value = []
}
async function openConversation(c) {
currentConvId.value = c.id
if (c.dataset_id) datasetId.value = c.dataset_id
const r = await listMessages(c.id)
const list = (r && r.list) || []
messages.value = list.map(m => {
let citations = []
if (m.citations) {
try {
citations = JSON.parse(m.citations) || []
} catch { /* 忽略格式异常 */ }
}
return { role: m.role, content: m.content, citations }
})
scrollBottom()
}
async function removeConversation(c) {
try {
await ElMessageBox.confirm('删除会话将同时删除历史消息,确认?', '删除会话', { type: 'warning' })
} catch {
return
}
await deleteConversation(c.id)
if (currentConvId.value === c.id) newConversation()
await refreshConversations()
}
function onDatasetChange() {
// 切换知识库后,正在进行的会话归零(会话绑定数据集)
newConversation()
}
async function send() {
const question = input.value.trim()
if (!question || streaming.value) return
if (!datasetId.value) {
ElMessage.warning('请先选择知识库')
return
}
input.value = ''
messages.value.push({ role: 'user', content: question })
const aiMsg = ref({ role: 'assistant', content: '', citations: [], streaming: true })
messages.value.push(aiMsg.value)
scrollBottom()
streaming.value = true
const payload = { conversation_id: currentConvId.value, dataset_id: datasetId.value, question }
await streamChat(payload, {
onCitations(data) {
aiMsg.value.citations = data.citations || []
if (data.conversation_id && data.conversation_id !== currentConvId.value) {
currentConvId.value = data.conversation_id
refreshConversations()
}
scrollBottom()
},
onDelta(delta) {
aiMsg.value.content += delta
scrollBottom()
},
onDone() {
aiMsg.value.streaming = false
streaming.value = false
scrollBottom()
},
onError(err) {
aiMsg.value.streaming = false
streaming.value = false
aiMsg.value.content = aiMsg.value.content || ''
ElMessage.error(err.message || '生成失败')
}
})
}
</script>
<style scoped>
.chat-page {
display: flex;
height: calc(100vh - 110px);
gap: 12px;
}
.chat-side {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.side-head {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
}
.conv-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.conv-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
color: #606266;
}
.conv-item:hover, .conv-item.active {
background: #ecf5ff;
color: #409eff;
}
.conv-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conv-del {
visibility: hidden;
color: #909399;
}
.conv-item:hover .conv-del {
visibility: visible;
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.msg-area {
flex: 1;
overflow-y: auto;
padding: 16px;
background: #fafafa;
}
.msg-row {
display: flex;
margin-bottom: 14px;
}
.msg-row.user {
justify-content: flex-end;
}
.msg-bubble {
max-width: 72%;
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.msg-row.user .msg-bubble {
background: #409eff;
color: #fff;
border-top-right-radius: 2px;
}
.msg-row.assistant .msg-bubble {
background: #fff;
border: 1px solid #e4e7ed;
border-top-left-radius: 2px;
}
.msg-citations {
margin-top: 8px;
}
.citation-item {
padding: 4px 0;
border-bottom: 1px dashed #ebeef5;
}
.citation-item:last-child {
border-bottom: none;
}
.citation-head {
display: flex;
align-items: center;
gap: 6px;
}
.citation-idx {
font-weight: 600;
color: #409eff;
}
.citation-score {
font-size: 12px;
color: #909399;
}
.citation-content {
margin-top: 4px;
font-size: 13px;
color: #606266;
line-height: 1.6;
}
.msg-input {
display: flex;
padding: 12px;
border-top: 1px solid #f0f0f0;
}
.msg-input .el-textarea {
flex: 1;
}
</style>