506 lines
26 KiB
HTML
506 lines
26 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>LightRAG 管理后台</title>
|
|
<!-- Vue 3 -->
|
|
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
|
|
<!-- Element Plus -->
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus/dist/index.css" />
|
|
<script src="https://cdn.jsdelivr.net/npm/element-plus"></script>
|
|
<!-- Element Plus Icons -->
|
|
<script src="https://cdn.jsdelivr.net/npm/@element-plus/icons-vue"></script>
|
|
<!-- Axios -->
|
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
|
<!-- Marked -->
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
<style>
|
|
body { margin: 0; padding: 0; font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif; background-color: #f5f7fa; }
|
|
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
.header { background: #fff; padding: 20px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; }
|
|
.header h1 { margin: 0; font-size: 24px; color: #409EFF; }
|
|
.card { background: #fff; border-radius: 4px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
|
|
.chat-box { height: 500px; overflow-y: auto; border: 1px solid #EBEEF5; padding: 20px; border-radius: 4px; margin-bottom: 20px; background: #fafafa; }
|
|
.message { margin-bottom: 15px; display: flex; }
|
|
.message.user { justify-content: flex-end; }
|
|
.message.assistant { justify-content: flex-start; }
|
|
.message-content { max-width: 80%; padding: 10px 15px; border-radius: 4px; font-size: 14px; line-height: 1.6; }
|
|
.message.user .message-content { background: #409EFF; color: #fff; }
|
|
.message.assistant .message-content { background: #fff; border: 1px solid #EBEEF5; }
|
|
.markdown-body { font-size: 14px; }
|
|
.markdown-body pre { background: #f6f8fa; padding: 10px; border-radius: 4px; overflow-x: auto; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<div class="header">
|
|
<h1 @click="goHome" style="cursor: pointer;">LightRAG Admin</h1>
|
|
<div v-if="currentPage === 'tenant'">
|
|
<el-tag type="success" size="large">当前租户: {{ currentTenantId }}</el-tag>
|
|
<el-button v-if="isAdmin" type="primary" link @click="goHome">返回首页</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<!-- 首页:租户列表 -->
|
|
<div v-if="currentPage === 'home'">
|
|
<el-row :gutter="20">
|
|
<el-col :span="24">
|
|
<div class="card">
|
|
<div style="display: flex; justify-content: space-between; margin-bottom: 20px;">
|
|
<h3>租户列表</h3>
|
|
<el-button type="primary" @click="refreshTenants">刷新</el-button>
|
|
</div>
|
|
<el-table :data="tenants" stripe style="width: 100%">
|
|
<el-table-column prop="id" label="租户ID"></el-table-column>
|
|
<el-table-column label="操作" width="180">
|
|
<template #default="scope">
|
|
<el-button type="primary" size="small" @click="enterTenant(scope.row)">管理文档</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</div>
|
|
</el-col>
|
|
</el-row>
|
|
</div>
|
|
|
|
<!-- 租户页:文档管理与问答 -->
|
|
<div v-if="currentPage === 'tenant'">
|
|
<el-tabs v-model="activeTab" class="card">
|
|
<!-- 文档管理 Tab -->
|
|
<el-tab-pane label="文档管理" name="docs">
|
|
<div style="margin-bottom: 20px;">
|
|
<el-button type="primary" @click="showImportDialog = true">导入文档/QA</el-button>
|
|
<el-button @click="fetchDocuments">刷新列表</el-button>
|
|
</div>
|
|
<el-table :data="documents" v-loading="loadingDocs" style="width: 100%">
|
|
<el-table-column prop="id" label="文档ID" width="220"></el-table-column>
|
|
<el-table-column prop="summary" label="摘要" show-overflow-tooltip></el-table-column>
|
|
<el-table-column prop="length" label="长度" width="100"></el-table-column>
|
|
<el-table-column prop="created_at" label="创建时间" width="180">
|
|
<template #default="scope">
|
|
{{ formatDate(scope.row.created_at) }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="180">
|
|
<template #default="scope">
|
|
<el-button size="small" @click="viewDocument(scope.row.id)">详情</el-button>
|
|
<el-popconfirm title="确定删除吗?" @confirm="deleteDocument(scope.row.id)">
|
|
<template #reference>
|
|
<el-button size="small" type="danger">删除</el-button>
|
|
</template>
|
|
</el-popconfirm>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</el-tab-pane>
|
|
|
|
<!-- 知识检索 Tab -->
|
|
<el-tab-pane label="知识检索" name="chat">
|
|
<div class="chat-box" ref="chatBox">
|
|
<div v-for="(msg, index) in chatHistory" :key="index" :class="['message', msg.role]">
|
|
<div class="message-content markdown-body" v-html="renderMarkdown(msg.content, msg.thinking, msg.retrievalStatus)"></div>
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 10px; align-items: center;">
|
|
<el-checkbox v-model="onlyRag" label="仅使用知识库" border></el-checkbox>
|
|
<el-input v-model="queryInput" placeholder="请输入问题..." @keyup.enter="sendQuery" style="flex: 1;"></el-input>
|
|
<el-button type="primary" :loading="chatLoading" @click="sendQuery">发送</el-button>
|
|
</div>
|
|
</el-tab-pane>
|
|
</el-tabs>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 导入弹窗 -->
|
|
<el-dialog v-model="showImportDialog" title="导入知识" width="600px" :close-on-click-modal="!importing" :close-on-press-escape="!importing" :show-close="!importing">
|
|
<div v-loading="importing" element-loading-text="正在导入中,请稍候...(大文件可能需要较长时间)">
|
|
<el-tabs v-model="importType">
|
|
<el-tab-pane label="文件上传" name="file">
|
|
<el-upload
|
|
class="upload-demo"
|
|
drag
|
|
action="#"
|
|
:http-request="uploadFile"
|
|
:show-file-list="false"
|
|
:disabled="importing"
|
|
>
|
|
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
|
<div class="el-upload__text">拖拽文件到此处或 <em>点击上传</em></div>
|
|
<template #tip>
|
|
<div class="el-upload__tip">支持 .txt, .md, .pdf</div>
|
|
</template>
|
|
</el-upload>
|
|
</el-tab-pane>
|
|
<el-tab-pane label="纯文本" name="text">
|
|
<el-input v-model="importText" type="textarea" rows="10" placeholder="请输入文本内容..." :disabled="importing"></el-input>
|
|
<div style="margin-top: 10px; text-align: right;">
|
|
<el-button type="primary" @click="uploadText" :loading="importing">提交</el-button>
|
|
</div>
|
|
</el-tab-pane>
|
|
<el-tab-pane label="QA 问答" name="qa">
|
|
<div v-for="(qa, index) in qaList" :key="index" style="margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 15px;">
|
|
<el-input v-model="qa.question" placeholder="问题 (Question)" style="margin-bottom: 5px;" :disabled="importing"></el-input>
|
|
<el-input v-model="qa.answer" type="textarea" placeholder="回答 (Answer)" :disabled="importing"></el-input>
|
|
<el-button type="danger" link size="small" @click="removeQA(index)" v-if="qaList.length > 1" :disabled="importing">删除</el-button>
|
|
</div>
|
|
<el-button type="primary" plain style="width: 100%; margin-bottom: 10px;" @click="addQA" :disabled="importing">添加一组 QA</el-button>
|
|
<div style="text-align: right;">
|
|
<el-button type="primary" @click="uploadQA" :loading="importing">提交所有 QA</el-button>
|
|
</div>
|
|
</el-tab-pane>
|
|
</el-tabs>
|
|
</div>
|
|
</el-dialog>
|
|
|
|
<!-- 文档详情弹窗 -->
|
|
<el-dialog v-model="showDocDialog" title="文档详情" width="800px">
|
|
<div v-loading="docDetailLoading">
|
|
<pre style="white-space: pre-wrap; background: #f5f7fa; padding: 15px; border-radius: 4px; max-height: 500px; overflow-y: auto;">{{ currentDocContent }}</pre>
|
|
</div>
|
|
<template #footer>
|
|
<span class="dialog-footer">
|
|
<el-popconfirm title="确定删除此文档吗?" @confirm="deleteCurrentDoc">
|
|
<template #reference>
|
|
<el-button type="danger">删除文档</el-button>
|
|
</template>
|
|
</el-popconfirm>
|
|
<el-button @click="showDocDialog = false">关闭</el-button>
|
|
</span>
|
|
</template>
|
|
</el-dialog>
|
|
</div>
|
|
|
|
<script>
|
|
const { createApp, ref, onMounted, computed } = Vue;
|
|
const { ElMessage } = ElementPlus;
|
|
|
|
const app = createApp({
|
|
setup() {
|
|
// 状态
|
|
const currentPage = ref('home');
|
|
const tenants = ref([]);
|
|
const currentTenantId = ref('');
|
|
const currentToken = ref('');
|
|
|
|
// 文档管理
|
|
const activeTab = ref('docs');
|
|
const documents = ref([]);
|
|
const loadingDocs = ref(false);
|
|
const showImportDialog = ref(false);
|
|
const importing = ref(false);
|
|
const importType = ref('file');
|
|
const importText = ref('');
|
|
const qaList = ref([{ question: '', answer: '' }]);
|
|
|
|
// 文档详情
|
|
const showDocDialog = ref(false);
|
|
const currentDocId = ref('');
|
|
const currentDocContent = ref('');
|
|
const docDetailLoading = ref(false);
|
|
|
|
// 聊天
|
|
const queryInput = ref('');
|
|
const onlyRag = ref(false);
|
|
const chatHistory = ref([]);
|
|
const chatLoading = ref(false);
|
|
const chatBox = ref(null);
|
|
|
|
// API Base
|
|
const api = axios.create({ baseURL: '/' });
|
|
|
|
// 解析 URL 参数
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const tokenParam = urlParams.get('token');
|
|
const tenantParam = urlParams.get('tenant_id');
|
|
const isAdmin = ref(false);
|
|
|
|
// 初始化
|
|
onMounted(() => {
|
|
if (tenantParam && tokenParam) {
|
|
// 租户模式
|
|
if (!tokenParam.startsWith(tenantParam + '_')) {
|
|
ElMessage.error('Token 不匹配');
|
|
return;
|
|
}
|
|
enterTenant({ id: tenantParam, token: tokenParam }, false);
|
|
} else if (tokenParam === 'fzy') {
|
|
// 管理员模式
|
|
currentPage.value = 'home';
|
|
isAdmin.value = true;
|
|
refreshTenants();
|
|
} else {
|
|
ElMessage.warning('请在 URL 中提供有效的 token');
|
|
}
|
|
});
|
|
|
|
// 方法
|
|
const goHome = () => {
|
|
window.location.href = '?token=fzy';
|
|
};
|
|
|
|
const refreshTenants = async () => {
|
|
try {
|
|
const res = await api.get('/admin/tenants', { params: { token: 'fzy' } });
|
|
tenants.value = res.data.tenants;
|
|
} catch (e) {
|
|
ElMessage.error('获取租户列表失败');
|
|
}
|
|
};
|
|
|
|
const enterTenant = (tenant, updateUrl = true) => {
|
|
currentTenantId.value = tenant.id;
|
|
currentToken.value = tenant.token;
|
|
currentPage.value = 'tenant';
|
|
|
|
// 设置 Header
|
|
api.defaults.headers.common['X-Tenant-ID'] = tenant.id;
|
|
|
|
if (updateUrl) {
|
|
const newUrl = `${window.location.pathname}?page=tenant&tenant_id=${tenant.id}&token=${tenant.token}`;
|
|
window.history.pushState({}, '', newUrl);
|
|
}
|
|
|
|
fetchDocuments();
|
|
};
|
|
|
|
const fetchDocuments = async () => {
|
|
loadingDocs.value = true;
|
|
try {
|
|
const res = await api.get('/documents');
|
|
documents.value = res.data.docs;
|
|
} catch (e) {
|
|
ElMessage.error('获取文档失败');
|
|
} finally {
|
|
loadingDocs.value = false;
|
|
}
|
|
};
|
|
|
|
const deleteDocument = async (id) => {
|
|
try {
|
|
await api.delete(`/docs/${id}`);
|
|
ElMessage.success('删除成功');
|
|
fetchDocuments();
|
|
showDocDialog.value = false;
|
|
} catch (e) {
|
|
ElMessage.error('删除失败');
|
|
}
|
|
};
|
|
|
|
const viewDocument = async (id) => {
|
|
currentDocId.value = id;
|
|
showDocDialog.value = true;
|
|
docDetailLoading.value = true;
|
|
try {
|
|
const res = await api.get(`/documents/${id}`);
|
|
currentDocContent.value = res.data.content;
|
|
} catch (e) {
|
|
currentDocContent.value = '加载失败';
|
|
} finally {
|
|
docDetailLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const deleteCurrentDoc = () => {
|
|
deleteDocument(currentDocId.value);
|
|
};
|
|
|
|
// 导入逻辑
|
|
const uploadFile = async (param) => {
|
|
importing.value = true;
|
|
const formData = new FormData();
|
|
formData.append('file', param.file);
|
|
try {
|
|
await api.post('/ingest/file', formData);
|
|
ElMessage.success('上传成功');
|
|
showImportDialog.value = false;
|
|
fetchDocuments();
|
|
} catch (e) {
|
|
ElMessage.error('上传失败');
|
|
} finally {
|
|
importing.value = false;
|
|
}
|
|
};
|
|
|
|
const uploadText = async () => {
|
|
if (!importText.value) return;
|
|
importing.value = true;
|
|
try {
|
|
await api.post('/ingest/text', { text: importText.value });
|
|
ElMessage.success('导入成功');
|
|
showImportDialog.value = false;
|
|
importText.value = '';
|
|
fetchDocuments();
|
|
} catch (e) {
|
|
ElMessage.error('导入失败');
|
|
} finally {
|
|
importing.value = false;
|
|
}
|
|
};
|
|
|
|
const addQA = () => qaList.value.push({ question: '', answer: '' });
|
|
const removeQA = (idx) => qaList.value.splice(idx, 1);
|
|
|
|
const uploadQA = async () => {
|
|
const validQA = qaList.value.filter(q => q.question && q.answer);
|
|
if (validQA.length === 0) return ElMessage.warning('请填写 QA');
|
|
|
|
importing.value = true;
|
|
try {
|
|
await api.post('/ingest/batch_qa', validQA);
|
|
ElMessage.success(`成功导入 ${validQA.length} 条 QA`);
|
|
showImportDialog.value = false;
|
|
qaList.value = [{ question: '', answer: '' }];
|
|
fetchDocuments();
|
|
} catch (e) {
|
|
ElMessage.error('导入失败');
|
|
} finally {
|
|
importing.value = false;
|
|
}
|
|
};
|
|
|
|
// 聊天逻辑
|
|
const sendQuery = async () => {
|
|
if (!queryInput.value.trim()) return;
|
|
|
|
const q = queryInput.value;
|
|
chatHistory.value.push({ role: 'user', content: q });
|
|
queryInput.value = '';
|
|
chatLoading.value = true;
|
|
|
|
try {
|
|
// 使用流式
|
|
const response = await fetch('/query', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': currentTenantId.value
|
|
},
|
|
body: JSON.stringify({
|
|
query: q,
|
|
stream: true,
|
|
mode: 'mix',
|
|
think: true,
|
|
only_rag: onlyRag.value
|
|
})
|
|
});
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
// 创建消息对象并加入数组
|
|
chatHistory.value.push({ role: 'assistant', content: '', thinking: '', retrievalStatus: null });
|
|
// 获取响应式对象 (Proxy) 以便更新触发视图渲染
|
|
const assistantMsg = chatHistory.value[chatHistory.value.length - 1];
|
|
|
|
let buffer = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const blocks = buffer.split('\n\n');
|
|
buffer = blocks.pop(); // 保留最后一个可能不完整的块
|
|
|
|
for (const block of blocks) {
|
|
if (!block.trim() || block.trim() === 'data: [DONE]') continue;
|
|
|
|
const lines = block.split('\n');
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
try {
|
|
const jsonStr = line.slice(6);
|
|
const chunk = JSON.parse(jsonStr);
|
|
|
|
// 解析 OpenAI 兼容格式
|
|
if (chunk.choices && chunk.choices[0].delta) {
|
|
const delta = chunk.choices[0].delta;
|
|
|
|
// 处理 x_rag_status
|
|
if (delta.x_rag_status) {
|
|
assistantMsg.retrievalStatus = delta.x_rag_status;
|
|
}
|
|
|
|
// 处理思考过程
|
|
if (delta.reasoning_content) {
|
|
assistantMsg.thinking += delta.reasoning_content;
|
|
}
|
|
|
|
// 处理正文内容
|
|
if (delta.content) {
|
|
assistantMsg.content += delta.content;
|
|
}
|
|
}
|
|
|
|
// 滚动到底部
|
|
if (chatBox.value) chatBox.value.scrollTop = chatBox.value.scrollHeight;
|
|
|
|
} catch (e) {
|
|
console.error('JSON parse error:', e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
chatHistory.value.push({ role: 'assistant', content: '❌ 请求出错: ' + e.message });
|
|
} finally {
|
|
chatLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const renderMarkdown = (content, thinking, retrievalStatus) => {
|
|
let html = '';
|
|
|
|
if (retrievalStatus) {
|
|
const color = retrievalStatus === 'hit' ? '#67c23a' : '#e6a23c';
|
|
const text = retrievalStatus === 'hit' ? '已检索到相关知识' : '未检索到相关知识';
|
|
const icon = retrievalStatus === 'hit' ? '✔️' : '⚠️';
|
|
html += `<div style="margin-bottom: 8px; font-size: 12px; color: ${color}; font-weight: bold;">${icon} ${text}</div>`;
|
|
}
|
|
|
|
if (thinking) {
|
|
html += `<div class="thinking-box" style="background: #f0f9eb; padding: 10px; border-radius: 4px; margin-bottom: 10px; border: 1px solid #e1f3d8; color: #67c23a; white-space: pre-wrap; font-family: monospace; font-size: 12px;">${thinking}</div>`;
|
|
}
|
|
html += marked.parse(content || '');
|
|
return html;
|
|
};
|
|
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr) return '-';
|
|
try {
|
|
const date = new Date(dateStr);
|
|
return isNaN(date.getTime()) ? dateStr : date.toLocaleString();
|
|
} catch (e) {
|
|
return dateStr;
|
|
}
|
|
};
|
|
|
|
return {
|
|
currentPage, tenants, currentTenantId,
|
|
activeTab, documents, loadingDocs,
|
|
showImportDialog, importType, importText, qaList, importing,
|
|
showDocDialog, currentDocId, currentDocContent, docDetailLoading,
|
|
queryInput, chatHistory, chatLoading, chatBox,
|
|
goHome, refreshTenants, enterTenant, fetchDocuments,
|
|
viewDocument, deleteDocument, deleteCurrentDoc,
|
|
uploadFile, uploadText, addQA, removeQA, uploadQA,
|
|
sendQuery, renderMarkdown, formatDate, isAdmin, onlyRag
|
|
};
|
|
}
|
|
});
|
|
|
|
// 注册图标
|
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
|
app.component(key, component)
|
|
}
|
|
app.use(ElementPlus);
|
|
app.mount('#app');
|
|
</script>
|
|
<!-- 引入 Element Plus 图标 -->
|
|
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
|
|
</body>
|
|
</html>
|