ai-lightrag/app/static/admin.html

487 lines
24 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;">
<el-input v-model="queryInput" placeholder="请输入问题..." @keyup.enter="sendQuery"></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 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 })
});
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) {
const lines = block.split('\n');
let eventType = 'answer';
let dataText = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
dataText = data.text;
} catch (e) {}
}
}
if (eventType === 'system') {
assistantMsg.retrievalStatus = dataText;
} else if (dataText) {
if (eventType === 'thinking') {
assistantMsg.thinking += dataText;
} else if (eventType === 'answer') {
assistantMsg.content += dataText;
}
// 滚动到底部
if (chatBox.value) chatBox.value.scrollTop = chatBox.value.scrollHeight;
}
}
}
} 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 === 'retrieved' ? '#67c23a' : '#e6a23c';
const text = retrievalStatus === 'retrieved' ? '已检索到相关知识' : '未检索到相关知识,使用通用知识回答';
const icon = retrievalStatus === 'retrieved' ? '✔️' : '⚠️';
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
};
}
});
// 注册图标
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>