This commit is contained in:
2026-08-05 10:28:44 +08:00
commit 0bacf2e0a4
103 changed files with 9415 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+21
View File
@@ -0,0 +1,21 @@
import request from './request.js'
export function login(data) {
return request.post('/system-config/login', data)
}
export function getSystemConfig() {
return request.get('/system-config')
}
export function updateSystemConfig(data) {
return request.put('/system-config', data)
}
export function regenerateToken() {
return request.post('/system-config/regenerate-token')
}
export function getToken() {
return request.get('/system-config/token')
}
+73
View File
@@ -0,0 +1,73 @@
import request from './request.js'
export function listConversations() {
return request.get('/conversation/list')
}
export function saveConversation(data) {
return request.post('/conversation/save', data)
}
export function deleteConversation(id) {
return request.post('/conversation/delete', { id })
}
export function listMessages(conversationId) {
return request.get('/message/list', { params: { conversation_id: conversationId } })
}
// streamChat RAG 问答 SSEhandlers.onCitations / onDelta / onDone / onError
export async function streamChat(payload, handlers) {
const token = localStorage.getItem('token')
let resp
try {
resp = await fetch('/message/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? 'Bearer ' + token : ''
},
body: JSON.stringify(payload)
})
} catch (e) {
handlers.onError(new Error('网络错误,请检查服务是否启动'))
return
}
if (!resp.ok || !resp.body) {
handlers.onError(new Error('请求失败:' + resp.status))
return
}
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buf = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let idx
while ((idx = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, idx)
buf = buf.slice(idx + 2)
for (const line of block.split('\n')) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6).trim()
if (!data || data === '[DONE]') continue
let obj
try {
obj = JSON.parse(data)
} catch {
continue
}
if (obj.citations !== undefined) handlers.onCitations(obj)
else if (obj.content !== undefined) handlers.onDelta(obj.content)
else if (obj.status === 'ok') handlers.onDone()
else if (obj.message) handlers.onError(new Error(obj.message))
}
}
}
handlers.onDone()
} catch (e) {
handlers.onError(e)
}
}
+9
View File
@@ -0,0 +1,9 @@
import request from './request.js'
export function listChunks(params) {
return request.get('/chunk/list', { params })
}
export function updateChunk(data) {
return request.post('/chunk/update', data)
}
+13
View File
@@ -0,0 +1,13 @@
import request from './request.js'
export function listDatasets() {
return request.get('/dataset/list')
}
export function saveDataset(data) {
return request.post('/dataset/save', data)
}
export function deleteDataset(id) {
return request.post('/dataset/delete', { id })
}
+21
View File
@@ -0,0 +1,21 @@
import request from './request.js'
export function listDocuments(params) {
return request.get('/document/list', { params })
}
export function uploadDocument(formData) {
return request.post('/document/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
}
export function deleteDocument(id) {
return request.post('/document/delete', { id })
}
export function reembedDocument(id) {
return request.post('/document/reembed', { id })
}
export function retryParseTask(id) {
return request.post('/parse-task/retry', { id })
}
+9
View File
@@ -0,0 +1,9 @@
import request from './request.js'
export function listEntities(params) {
return request.get('/kg-entity/list', { params })
}
export function listRelations(params) {
return request.get('/kg-relation/list', { params })
}
+17
View File
@@ -0,0 +1,17 @@
import request from './request.js'
export function listModelConfigs(modelType) {
return request.get('/model-config/list', { params: { model_type: modelType } })
}
export function saveModelConfig(data) {
return request.post('/model-config/save', data)
}
export function deleteModelConfig(id) {
return request.post('/model-config/delete', { id })
}
export function testModelConfig(id) {
return request.post('/model-config/test', { id })
}
+43
View File
@@ -0,0 +1,43 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const request = axios.create({
timeout: 300000
})
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = 'Bearer ' + token
}
return config
})
function redirectLogin() {
localStorage.removeItem('token')
window.location.href = '/#/login'
}
request.interceptors.response.use(
response => {
const data = response.data
if (data.code !== 0) {
if (data.code === 401) {
redirectLogin()
return Promise.reject(new Error(data.message))
}
ElMessage.error(data.message || '请求失败')
return Promise.reject(new Error(data.message))
}
return data.data
},
error => {
if (error.response?.status === 401) {
redirectLogin()
}
ElMessage.error(error.message || '网络错误')
return Promise.reject(error)
}
)
export default request
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus, { locale: zhCn })
app.use(router)
app.mount('#app')
+69
View File
@@ -0,0 +1,69 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
},
{
path: '/',
component: () => import('../views/Layout.vue'),
redirect: '/datasets',
children: [
{
path: 'datasets',
name: 'DatasetList',
meta: { title: '数据集' },
component: () => import('../views/DatasetList.vue')
},
{
path: 'datasets/:id',
name: 'DatasetDetail',
meta: { title: '数据集详情' },
component: () => import('../views/DatasetDetail.vue')
},
{
path: 'chat',
name: 'Chat',
meta: { title: '问答' },
component: () => import('../views/Chat.vue')
},
{
path: 'kg',
name: 'KgGraph',
meta: { title: '知识图谱' },
component: () => import('../views/KgGraph.vue')
},
{
path: 'settings',
name: 'Settings',
meta: { title: '设置' },
component: () => import('../views/Settings.vue')
}
]
}
]
const router = createRouter({
// hash 路由:前后端合并部署后 SPA 路由与后端 API 前缀重叠,
// history 模式刷新会命中 API 路由;hash 模式页面始终请求 /,由后端静态服务返回 index.html
history: createWebHashHistory(),
routes
})
router.beforeEach((to, from, next) => {
const auth = useAuthStore()
if (to.path !== '/login' && !auth.isLoggedIn) {
next('/login')
return
}
if (to.path === '/login' && auth.isLoggedIn) {
next('/')
return
}
next()
})
export default router
+23
View File
@@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login } from '../api/auth.js'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const isLoggedIn = computed(() => !!token.value)
async function doLogin(accessToken) {
const res = await login({ token: accessToken })
token.value = res.token
localStorage.setItem('token', res.token)
return res
}
function logout() {
token.value = ''
localStorage.removeItem('token')
}
return { token, isLoggedIn, doLogin, logout }
})
+302
View File
@@ -0,0 +1,302 @@
<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(4) }}</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 {
datasets.value = await listDatasets()
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() {
conversations.value = await listConversations()
}
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 list = await listMessages(c.id)
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>
+264
View File
@@ -0,0 +1,264 @@
<template>
<div class="dd-page">
<div class="dd-head">
<el-button link @click="$router.push('/datasets')">
<el-icon><ArrowLeft /></el-icon> 返回
</el-button>
<span class="dd-title">{{ datasetName }}</span>
</div>
<el-upload class="dd-upload" drag :show-file-list="false" :http-request="doUpload" :disabled="uploading"
accept=".txt,.md,.pdf,.docx,.doc,.html,.htm">
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">拖拽文件到此处 <em>点击上传</em>txt/md/pdf/docx/html</div>
</el-upload>
<el-table :data="documents" v-loading="loading" empty-text="暂无文档">
<el-table-column prop="filename" label="文件名" min-width="200" show-overflow-tooltip />
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ formatSize(row.file_size) }}</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tooltip :content="row.error_msg || ''" placement="top">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="chunk_count" label="分块数" width="90" />
<el-table-column prop="created_at" label="上传时间" width="170" />
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openChunks(row)">分块</el-button>
<el-button v-if="row.status === 3" link type="warning" @click="retry(row)">重试</el-button>
<el-button link type="primary" @click="reembed(row)">重新向量化</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="dd-page-bar" layout="total, prev, pager, next" :total="total"
:page-size="pageSize" v-model:current-page="page" @current-change="load" />
<el-drawer v-model="chunkDrawer" :title="chunkDrawerTitle" size="55%">
<div class="chunk-toolbar">
<el-input v-model="chunkKeyword" placeholder="搜索分块内容" clearable style="width: 240px" @change="loadChunks" />
</div>
<el-table :data="chunks" v-loading="chunkLoading" size="small">
<el-table-column type="index" label="#" width="50" />
<el-table-column label="内容" min-width="300">
<template #default="{ row }">
<div class="chunk-content">{{ row.content }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="editChunk(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="dd-page-bar" layout="total, prev, pager, next" :total="chunkTotal"
:page-size="chunkPageSize" v-model:current-page="chunkPage" @current-change="loadChunks" />
</el-drawer>
<el-dialog v-model="editDialog" title="编辑分块" width="640px">
<el-input v-model="editContent" type="textarea" :rows="10" />
<div class="edit-tip">保存后重新分词并向量化向量模型未绑定时仅更新全文索引</div>
<template #footer>
<el-button @click="editDialog = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="saveChunk">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, UploadFilled } from '@element-plus/icons-vue'
import { listDatasets } from '../api/dataset.js'
import { listDocuments, uploadDocument, deleteDocument, reembedDocument, retryParseTask } from '../api/document.js'
import { listChunks, updateChunk } from '../api/chunk.js'
const route = useRoute()
const datasetId = computed(() => Number(route.params.id))
const datasetName = ref('')
const documents = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const loading = ref(false)
const uploading = ref(false)
const chunkDrawer = ref(false)
const chunkDrawerTitle = ref('')
const chunks = ref([])
const chunkTotal = ref(0)
const chunkPage = ref(1)
const chunkPageSize = 20
const chunkLoading = ref(false)
const chunkKeyword = ref('')
let currentDoc = null
const editDialog = ref(false)
const editContent = ref('')
const editSaving = ref(false)
let editingChunk = null
const statusMap = {
0: { text: '待处理', type: 'info' },
1: { text: '解析中', type: 'warning' },
2: { text: '已完成', type: 'success' },
3: { text: '失败', type: 'danger' }
}
function statusText(s) { return (statusMap[s] || {}).text || s }
function statusType(s) { return (statusMap[s] || {}).type || 'info' }
function formatSize(bytes) {
if (!bytes) return '-'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
onMounted(async () => {
try {
const ds = await listDatasets()
const cur = ds.find(x => x.id === datasetId.value)
datasetName.value = cur ? cur.name : '数据集'
} catch { /* 忽略 */ }
await load()
})
async function load() {
loading.value = true
try {
const d = await listDocuments({ dataset_id: datasetId.value, page: page.value, page_size: pageSize })
documents.value = d.list
total.value = d.total
} finally {
loading.value = false
}
}
async function doUpload({ file }) {
uploading.value = true
try {
const formData = new FormData()
formData.append('dataset_id', datasetId.value)
formData.append('file', file)
await uploadDocument(formData)
ElMessage.success('上传成功,正在解析')
await load()
} finally {
uploading.value = false
}
}
async function remove(row) {
try {
await ElMessageBox.confirm(`删除文档「${row.filename}」?将同时删除分块、向量与全文索引。`, '删除确认', { type: 'warning' })
} catch {
return
}
await deleteDocument(row.id)
ElMessage.success('已删除')
await load()
}
async function retry(row) {
await retryParseTask(row.id)
ElMessage.success('已重新入队')
await load()
}
async function reembed(row) {
try {
await ElMessageBox.confirm(`重新向量化「${row.filename}」的全部分块?`, '确认', { type: 'info' })
} catch {
return
}
await reembedDocument(row.id)
ElMessage.success('重新向量化完成')
}
async function openChunks(row) {
currentDoc = row
chunkDrawerTitle.value = `分块列表 - ${row.filename}`
chunkDrawer.value = true
chunkPage.value = 1
chunkKeyword.value = ''
await loadChunks()
}
async function loadChunks() {
if (!currentDoc) return
chunkLoading.value = true
try {
const d = await listChunks({ document_id: currentDoc.id, page: chunkPage.value, page_size: chunkPageSize })
chunks.value = d.list
chunkTotal.value = d.total
} finally {
chunkLoading.value = false
}
}
function editChunk(row) {
editingChunk = row
editContent.value = row.content
editDialog.value = true
}
async function saveChunk() {
if (!editContent.value.trim()) {
ElMessage.warning('内容不能为空')
return
}
editSaving.value = true
try {
await updateChunk({ id: editingChunk.id, content: editContent.value })
ElMessage.success('已保存并重新向量化')
editDialog.value = false
await loadChunks()
} finally {
editSaving.value = false
}
}
</script>
<style scoped>
.dd-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.dd-title {
font-size: 16px;
font-weight: 600;
}
.dd-upload {
margin-bottom: 12px;
}
.dd-page-bar {
margin-top: 10px;
justify-content: flex-end;
}
.chunk-toolbar {
margin-bottom: 10px;
}
.chunk-content {
max-height: 60px;
overflow: hidden;
font-size: 13px;
color: #606266;
line-height: 1.6;
}
.edit-tip {
margin-top: 6px;
font-size: 12px;
color: #909399;
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<template>
<div class="ds-page">
<div class="ds-head">
<el-button type="primary" @click="openCreate">新建数据集</el-button>
</div>
<el-table :data="datasets" v-loading="loading" empty-text="暂无数据集">
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="向量模型" width="200">
<template #default="{ row }">
<span v-if="row.embedding_cfg_id">{{ embeddingName(row.embedding_cfg_id) }}</span>
<el-tag v-else size="small" type="info">未绑定仅全文检索</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="$router.push('/datasets/' + row.id)">详情</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑数据集' : '新建数据集'" width="480px">
<el-form :model="form" label-width="90px">
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="如:产品手册" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="向量模型">
<el-select v-model="form.embedding_cfg_id" clearable placeholder="不绑定则仅全文检索" style="width: 100%">
<el-option v-for="m in embedders" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listDatasets, saveDataset, deleteDataset } from '../api/dataset.js'
import { listModelConfigs } from '../api/model_config.js'
const datasets = ref([])
const embedders = ref([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editing = ref(false)
const form = ref({ id: 0, name: '', description: '', embedding_cfg_id: 0 })
onMounted(async () => {
await load()
try {
embedders.value = await listModelConfigs('embedding')
} catch { /* 忽略 */ }
})
async function load() {
loading.value = true
try {
datasets.value = await listDatasets()
} finally {
loading.value = false
}
}
function embeddingName(id) {
const m = embedders.value.find(x => x.id === id)
return m ? m.name : '(已删除的配置)'
}
function openCreate() {
editing.value = false
form.value = { id: 0, name: '', description: '', embedding_cfg_id: 0 }
dialogVisible.value = true
}
async function save() {
if (!form.value.name.trim()) {
ElMessage.warning('请输入数据集名称')
return
}
saving.value = true
try {
await saveDataset(form.value)
ElMessage.success('保存成功')
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
async function remove(row) {
try {
await ElMessageBox.confirm(`删除数据集「${row.name}」?`, '删除确认', { type: 'warning' })
} catch {
return
}
try {
await deleteDataset(row.id)
ElMessage.success('已删除')
await load()
} catch { /* request.js 已提示 */ }
}
</script>
<style scoped>
.ds-head {
margin-bottom: 12px;
}
</style>
+113
View File
@@ -0,0 +1,113 @@
<template>
<div class="kg-page">
<div class="kg-head">
<el-select v-model="datasetId" placeholder="选择知识库" style="width: 260px" @change="load">
<el-option v-for="d in datasets" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
<span class="kg-tip">实体与关系由解析流水线中的 LLM 抽取生成问答时命中实体自动注入一跳邻居</span>
</div>
<el-row :gutter="12">
<el-col :span="10">
<el-card shadow="never" class="kg-card">
<template #header>实体{{ entityTotal }}</template>
<el-table :data="entities" size="small" v-loading="loading">
<el-table-column prop="name" label="实体" min-width="140" show-overflow-tooltip />
<el-table-column prop="entity_type" label="类型" width="110" />
<el-table-column prop="chunk_id" label="来源分块" width="100" />
</el-table>
<el-pagination class="kg-page-bar" small layout="prev, pager, next" :total="entityTotal"
:page-size="entityPageSize" v-model:current-page="entityPage" @current-change="loadEntities" />
</el-card>
</el-col>
<el-col :span="14">
<el-card shadow="never" class="kg-card">
<template #header>关系{{ relationTotal }}</template>
<el-table :data="relations" size="small" v-loading="loading">
<el-table-column prop="head" label="主体" min-width="140" show-overflow-tooltip />
<el-table-column prop="relation" label="关系" min-width="100" show-overflow-tooltip />
<el-table-column prop="tail" label="客体" min-width="140" show-overflow-tooltip />
<el-table-column prop="chunk_id" label="来源分块" width="100" />
</el-table>
<el-pagination class="kg-page-bar" small layout="prev, pager, next" :total="relationTotal"
:page-size="relationPageSize" v-model:current-page="relationPage" @current-change="loadRelations" />
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { listDatasets } from '../api/dataset.js'
import { listEntities, listRelations } from '../api/kg.js'
const datasets = ref([])
const datasetId = ref(null)
const loading = ref(false)
const entities = ref([])
const entityTotal = ref(0)
const entityPage = ref(1)
const entityPageSize = 20
const relations = ref([])
const relationTotal = ref(0)
const relationPage = ref(1)
const relationPageSize = 20
onMounted(async () => {
try {
datasets.value = await listDatasets()
if (datasets.value.length) datasetId.value = datasets.value[0].id
} catch { /* 忽略 */ }
await load()
})
async function load() {
entityPage.value = 1
relationPage.value = 1
await Promise.all([loadEntities(), loadRelations()])
}
async function loadEntities() {
if (!datasetId.value) return
loading.value = true
try {
const d = await listEntities({ dataset_id: datasetId.value, page: entityPage.value, page_size: entityPageSize })
entities.value = d.list
entityTotal.value = d.total
} finally {
loading.value = false
}
}
async function loadRelations() {
if (!datasetId.value) return
loading.value = true
try {
const d = await listRelations({ dataset_id: datasetId.value, page: relationPage.value, page_size: relationPageSize })
relations.value = d.list
relationTotal.value = d.total
} finally {
loading.value = false
}
}
</script>
<style scoped>
.kg-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.kg-tip {
font-size: 13px;
color: #909399;
}
.kg-page-bar {
margin-top: 10px;
justify-content: flex-end;
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<template>
<el-container class="layout">
<el-aside width="200px" class="aside">
<div class="logo">本地知识库</div>
<el-menu :default-active="activeMenu" router class="menu">
<el-menu-item index="/datasets">
<el-icon><Folder /></el-icon>
<span>数据集</span>
</el-menu-item>
<el-menu-item index="/chat">
<el-icon><ChatDotRound /></el-icon>
<span>问答</span>
</el-menu-item>
<el-menu-item index="/kg">
<el-icon><Share /></el-icon>
<span>知识图谱</span>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<span>设置</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<span>{{ route.meta.title || '' }}</span>
<el-button link type="danger" @click="handleLogout">退出登录</el-button>
</el-header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Folder, ChatDotRound, Setting, Share } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth.js'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const activeMenu = computed(() => {
if (route.path.startsWith('/datasets')) return '/datasets'
return route.path
})
function handleLogout() {
auth.logout()
router.push('/login')
}
</script>
<style scoped>
.layout {
height: 100vh;
}
.aside {
background: #001529;
}
.logo {
color: #fff;
font-size: 16px;
font-weight: 600;
text-align: center;
padding: 18px 0;
}
.menu {
border-right: none;
background: transparent;
--el-menu-text-color: rgba(255, 255, 255, 0.65);
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.1);
--el-menu-active-color: #fff;
--el-menu-bg-color: transparent;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
background: #fff;
}
.main {
background: #f5f7fa;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<template>
<div class="login-container">
<el-card class="login-card">
<h2 class="title">本地知识库</h2>
<p class="subtitle">请输入服务启动时打印在控制台的访问令牌</p>
<el-form @submit.prevent="handleLogin">
<el-form-item>
<el-input
v-model="accessToken"
type="password"
show-password
placeholder="访问令牌"
size="large"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-button
type="primary"
size="large"
class="login-btn"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '../stores/auth.js'
const router = useRouter()
const auth = useAuthStore()
const accessToken = ref('')
const loading = ref(false)
async function handleLogin() {
if (!accessToken.value) {
ElMessage.warning('请输入访问令牌')
return
}
loading.value = true
try {
await auth.doLogin(accessToken.value)
router.push('/')
} catch {
// 错误提示已由 request 拦截器处理
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 380px;
padding: 20px 10px;
}
.title {
text-align: center;
margin: 0 0 8px;
}
.subtitle {
text-align: center;
color: #909399;
font-size: 13px;
margin: 0 0 24px;
}
.login-btn {
width: 100%;
}
</style>
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="settings-page">
<el-tabs v-model="tab">
<el-tab-pane label="模型配置" name="models">
<div class="section-head">
<el-radio-group v-model="modelType" @change="loadModels">
<el-radio-button value="chat">对话模型</el-radio-button>
<el-radio-button value="embedding">向量模型</el-radio-button>
</el-radio-group>
<el-button type="primary" @click="openModelDialog()">新增配置</el-button>
</div>
<el-table :data="models" v-loading="modelLoading">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="model_name" label="模型" min-width="160" show-overflow-tooltip />
<el-table-column prop="endpoint_url" label="接口地址" min-width="220" show-overflow-tooltip />
<el-table-column prop="dimension" label="维度" width="80">
<template #default="{ row }">{{ row.model_type === 'embedding' ? row.dimension : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :loading="testingId === row.id" @click="test(row)">测试</el-button>
<el-button link type="primary" @click="openModelDialog(row)">编辑</el-button>
<el-button link type="danger" @click="removeModel(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="默认设置" name="defaults">
<el-form label-width="120px" style="max-width: 480px">
<el-form-item label="默认对话模型">
<el-select v-model="defaults.default_chat_model" clearable placeholder="选择默认对话模型" style="width: 100%">
<el-option v-for="m in chatModels" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="默认数据集">
<el-select v-model="defaults.default_dataset" clearable placeholder="选择默认数据集" style="width: 100%">
<el-option v-for="d in datasets" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="defaultSaving" @click="saveDefaults">保存</el-button>
</el-form-item>
</el-form>
<div class="defaults-tip">默认对话模型用于知识图谱抽取与问答默认数据集用于问答页初始选择</div>
</el-tab-pane>
<el-tab-pane label="访问令牌" name="token">
<el-form label-width="120px" style="max-width: 520px">
<el-form-item label="当前令牌">
<el-input :model-value="currentToken" readonly>
<template #append>
<el-button @click="copyToken">复制</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="danger" plain :loading="tokenRegenerating" @click="regenerate">重新生成令牌</el-button>
</el-form-item>
</el-form>
<div class="defaults-tip">重新生成后旧令牌立即失效当前会话将被登出需使用新令牌重新登录</div>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="modelDialog" :title="modelForm.id ? '编辑模型配置' : '新增模型配置'" width="520px">
<el-form :model="modelForm" label-width="90px">
<el-form-item label="名称" required>
<el-input v-model="modelForm.name" placeholder="如:本地 Ollama Qwen" />
</el-form-item>
<el-form-item label="类型">
<el-radio-group v-model="modelForm.model_type" :disabled="!!modelForm.id">
<el-radio value="chat">对话</el-radio>
<el-radio value="embedding">向量</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="模型名" required>
<el-input v-model="modelForm.model_name" placeholder="如:qwen2.5:7b" />
</el-form-item>
<el-form-item label="接口地址">
<el-input v-model="modelForm.endpoint_url" placeholder="如:http://localhost:11434/v1" />
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="modelForm.api_key" type="password" show-password placeholder="无鉴权可留空" />
</el-form-item>
<el-form-item v-if="modelForm.model_type === 'embedding'" label="向量维度">
<el-input-number v-model="modelForm.dimension" :min="1" :max="65536" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modelDialog = false">取消</el-button>
<el-button type="primary" :loading="modelSaving" @click="saveModel">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listModelConfigs, saveModelConfig, deleteModelConfig, testModelConfig } from '../api/model_config.js'
import { listDatasets } from '../api/dataset.js'
import { getSystemConfig, updateSystemConfig, regenerateToken, getToken } from '../api/auth.js'
const tab = ref('models')
const modelType = ref('chat')
const models = ref([])
const modelLoading = ref(false)
const testingId = ref(0)
const chatModels = ref([])
const modelDialog = ref(false)
const modelSaving = ref(false)
const modelForm = ref({ id: 0, name: '', model_type: 'chat', model_name: '', endpoint_url: '', api_key: '', dimension: 1024 })
const defaults = ref({ default_chat_model: 0, default_dataset: 0 })
const datasets = ref([])
const defaultSaving = ref(false)
const currentToken = ref('')
const tokenRegenerating = ref(false)
onMounted(async () => {
await loadModels()
try { chatModels.value = await listModelConfigs('chat') } catch { /* 忽略 */ }
try {
const d = await listDatasets()
datasets.value = d
} catch { /* 忽略 */ }
await loadDefaults()
await loadToken()
})
async function loadModels() {
modelLoading.value = true
try {
models.value = await listModelConfigs(modelType.value)
} finally {
modelLoading.value = false
}
}
async function loadDefaults() {
try {
defaults.value = await getSystemConfig()
} catch { /* 忽略 */ }
}
async function loadToken() {
try {
const res = await getToken()
currentToken.value = res.token
} catch { /* 忽略 */ }
}
function openModelDialog(row) {
if (row) {
modelForm.value = { ...row }
} else {
modelForm.value = { id: 0, name: '', model_type: modelType.value, model_name: '', endpoint_url: '', api_key: '', dimension: 1024 }
}
modelDialog.value = true
}
async function saveModel() {
if (!modelForm.value.name.trim() || !modelForm.value.model_name.trim()) {
ElMessage.warning('请填写名称与模型名')
return
}
modelSaving.value = true
try {
await saveModelConfig(modelForm.value)
ElMessage.success('保存成功')
modelDialog.value = false
await loadModels()
if (modelType.value === 'chat') chatModels.value = await listModelConfigs('chat')
} finally {
modelSaving.value = false
}
}
async function removeModel(row) {
try {
await ElMessageBox.confirm(`删除模型配置「${row.name}」?`, '删除确认', { type: 'warning' })
} catch {
return
}
try {
await deleteModelConfig(row.id)
ElMessage.success('已删除')
await loadModels()
} catch { /* request.js 已提示 */ }
}
async function test(row) {
testingId.value = row.id
try {
const res = await testModelConfig(row.id)
if (res.ok) {
ElMessage.success(`${row.name}」连接正常`)
} else {
ElMessage.error(`${row.name}」测试失败:${res.msg}`)
}
} finally {
testingId.value = 0
}
}
async function saveDefaults() {
defaultSaving.value = true
try {
await updateSystemConfig(defaults.value)
ElMessage.success('已保存')
} finally {
defaultSaving.value = false
}
}
async function regenerate() {
try {
await ElMessageBox.confirm('重新生成后旧令牌立即失效,确认继续?', '重新生成令牌', { type: 'warning' })
} catch {
return
}
tokenRegenerating.value = true
try {
const res = await regenerateToken()
currentToken.value = res.token
ElMessage.success('新令牌已生成,请用新令牌重新登录')
} finally {
tokenRegenerating.value = false
}
}
function copyToken() {
if (!currentToken.value) return
navigator.clipboard?.writeText(currentToken.value)
ElMessage.success('已复制')
}
</script>
<style scoped>
.settings-page {
max-width: 900px;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.defaults-tip {
font-size: 12px;
color: #909399;
}
</style>