306 lines
8.0 KiB
Vue
306 lines
8.0 KiB
Vue
<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>
|