fix: 1. 增加简易后台页面及相关接口 2.兼容vllm思考模式 3.rerank方法问题处理 4.增加余弦参数配置,解决幻觉问题 5.临时停用VL

This commit is contained in:
fuzhongyun 2026-01-19 14:56:03 +08:00
parent 75044940c7
commit ccf8bd6902
7 changed files with 569 additions and 17 deletions

View File

@ -33,4 +33,5 @@ DATA_DIR=./index_data
# RAG Configuration # RAG Configuration
EMBEDDING_DIM=1024 EMBEDDING_DIM=1024
MAX_TOKEN_SIZE=8192 MAX_TOKEN_SIZE=8192
MAX_RAG_INSTANCES=5 # 最大活跃 RAG 实例数 MAX_RAG_INSTANCES=5 # 最大活跃 RAG 实例数
COSINE_THRESHOLD=0.4 # 余弦相似度阈值

View File

@ -48,6 +48,53 @@ class QAPair(BaseModel):
# 接口实现 # 接口实现
# ========================================== # ==========================================
import secrets
import string
@router.get("/admin/tenants")
async def list_tenants(token: str):
"""
管理员接口获取租户列表
"""
if token != settings.ADMIN_TOKEN:
raise HTTPException(status_code=403, detail="Invalid admin token")
try:
if not os.path.exists(settings.DATA_DIR):
return {"tenants": []}
tenants = []
for entry in os.scandir(settings.DATA_DIR):
if entry.is_dir() and not entry.name.startswith("."):
tenant_id = entry.name
secret_file = os.path.join(entry.path, ".secret")
# 读取或生成租户专属 Secret
if os.path.exists(secret_file):
with open(secret_file, "r") as f:
secret = f.read().strip()
else:
# 生成16位随机字符串
alphabet = string.ascii_letters + string.digits
secret = ''.join(secrets.choice(alphabet) for i in range(16))
try:
with open(secret_file, "w") as f:
f.write(secret)
except Exception as e:
logging.error(f"Failed to write secret for tenant {tenant_id}: {e}")
continue
# 生成租户访问 Token (租户ID_随机串)
tenant_token = f"{tenant_id}_{secret}"
tenants.append({
"id": tenant_id,
"token": tenant_token
})
return {"tenants": tenants}
except Exception as e:
logging.error(f"Failed to list tenants: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/health") @router.get("/health")
async def health_check(): async def health_check():
"""健康检查接口""" """健康检查接口"""
@ -63,6 +110,7 @@ async def query_knowledge_base(
- query: 用户问题 - query: 用户问题
- mode: 检索模式 (推荐 hybrid 用于事实类查询) - mode: 检索模式 (推荐 hybrid 用于事实类查询)
- stream: 是否流式输出 (默认 False) - stream: 是否流式输出 (默认 False)
- think: 是否启用思考模式 (默认 False)
""" """
try: try:
# 构造查询参数 # 构造查询参数
@ -93,12 +141,24 @@ async def query_knowledge_base(
# 获取上下文 (这步耗时较长,包含图遍历) # 获取上下文 (这步耗时较长,包含图遍历)
context_resp = await rag.aquery(request.query, param=context_param) context_resp = await rag.aquery(request.query, param=context_param)
logging.info(f"Context Response: {context_resp}")
# 简单判断是否找到内容 # 判断检索状态
if not context_resp or "Sorry, I'm not able to answer" in context_resp: has_context = False
yield sse_pack("thinking", " (未找到相关上下文,将依赖 LLM 自身知识)") if context_resp and "[no-context]" not in context_resp and "None" not in context_resp:
else: has_context = True
# 判断是否开启think
think = request.think
if has_context:
yield sse_pack("system", "retrieved") # 发送系统事件:已检索到信息
yield sse_pack("thinking", f"2. 上下文已检索 (长度: {len(context_resp)} 字符).\n") yield sse_pack("thinking", f"2. 上下文已检索 (长度: {len(context_resp)} 字符).\n")
else:
yield sse_pack("system", "missed") # 发送系统事件:未检索到信息
yield sse_pack("thinking", "2. 未找到相关上下文,将依赖 LLM 自身知识\n")
think = False
yield sse_pack("thinking", "3. 答案生成中...\n") yield sse_pack("thinking", "3. 答案生成中...\n")
@ -115,7 +175,7 @@ async def query_knowledge_base(
request.query, request.query,
system_prompt=sys_prompt, system_prompt=sys_prompt,
stream=True, stream=True,
think=request.think, think=think,
hashing_kv=rag.llm_response_cache hashing_kv=rag.llm_response_cache
) )

View File

@ -38,6 +38,10 @@ class Settings(BaseSettings):
EMBEDDING_DIM: int = 1024 EMBEDDING_DIM: int = 1024
MAX_TOKEN_SIZE: int = 8192 MAX_TOKEN_SIZE: int = 8192
MAX_RAG_INSTANCES: int = 3 # 最大活跃 RAG 实例数 MAX_RAG_INSTANCES: int = 3 # 最大活跃 RAG 实例数
COSINE_THRESHOLD: float = 0.4 # 向量检索相似度阈值
# Admin & Security
ADMIN_TOKEN: str = "fzy"
class Config: class Config:
env_file = ".env" env_file = ".env"

View File

@ -70,7 +70,7 @@ async def process_pdf_with_images(file_bytes: bytes) -> str:
text_content += f"--- Page {page_num + 1} Text ---\n{page_text}\n\n" text_content += f"--- Page {page_num + 1} Text ---\n{page_text}\n\n"
# 2. 提取图片 # 2. 提取图片
if settings.VL_BINDING_HOST: if False and settings.VL_BINDING_HOST:
for count, image_file_object in enumerate(page.images): for count, image_file_object in enumerate(page.images):
try: try:
# 获取图片数据 # 获取图片数据

View File

@ -68,7 +68,9 @@ async def openai_llm_func(prompt, system_prompt=None, history_messages=[], **kwa
stream = kwargs.pop("stream", False) stream = kwargs.pop("stream", False)
# think 参数是 DeepSeek 特有的OpenAI 标准接口不支持,暂时忽略 # think 参数是 DeepSeek 特有的OpenAI 标准接口不支持,暂时忽略
kwargs.pop("think", None) think = kwargs.pop("think", None)
# 这里使用qwen3指定的chat_template_kwargs开启/禁用思考模式
kwargs["chat_template_kwargs"] = {"enable_thinking": think}
messages = [] messages = []
if system_prompt: if system_prompt:
@ -169,16 +171,17 @@ async def embedding_func(texts: list[str]) -> np.ndarray:
# Rerank Functions # Rerank Functions
# ============================================================================== # ==============================================================================
async def tei_rerank_func(query: str, documents: list[str]) -> np.ndarray: async def tei_rerank_func(query: str, documents: list[str], top_n: int = 10) -> list[dict]:
"""TEI Rerank 实现""" """TEI Rerank 实现"""
if not documents: if not documents:
return np.array([]) return []
url = f"{settings.RERANK_BINDING_HOST}/rerank" url = f"{settings.RERANK_BINDING_HOST}/rerank"
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
if settings.RERANK_KEY and settings.RERANK_KEY != "EMPTY": if settings.RERANK_KEY and settings.RERANK_KEY != "EMPTY":
headers["Authorization"] = f"Bearer {settings.RERANK_KEY}" headers["Authorization"] = f"Bearer {settings.RERANK_KEY}"
# TEI 不支持 top_n 参数,我们手动截断或忽略
payload = { payload = {
"query": query, "query": query,
"texts": documents, "texts": documents,
@ -191,14 +194,16 @@ async def tei_rerank_func(query: str, documents: list[str]) -> np.ndarray:
# TEI 返回: [{"index": 0, "score": 0.99}, {"index": 1, "score": 0.5}] # TEI 返回: [{"index": 0, "score": 0.99}, {"index": 1, "score": 0.5}]
results = response.json() results = response.json()
# LightRAG 期望返回一个分数数组,对应输入的 documents 顺序 # LightRAG 期望返回包含 index 和 relevance_score 的字典列表
# TEI 返回的结果是排序过的,我们需要根据 index 还原顺 # 这样 LightRAG 才能正确映射回原始文档并进行排
scores = np.zeros(len(documents)) formatted_results = []
for res in results: for res in results:
idx = res['index'] formatted_results.append({
scores[idx] = res['score'] "index": res['index'],
"relevance_score": res['score']
})
return scores return formatted_results
# ============================================================================== # ==============================================================================
# RAG Manager # RAG Manager
@ -237,7 +242,8 @@ class RAGManager:
func=embedding_func func=embedding_func
), ),
"embedding_func_max_async": 8, # TEI 并发强 "embedding_func_max_async": 8, # TEI 并发强
"enable_llm_cache": True "enable_llm_cache": True,
"cosine_threshold": settings.COSINE_THRESHOLD
} }
# 如果启用了 Rerank注入 rerank_model_func # 如果启用了 Rerank注入 rerank_model_func

View File

@ -1,10 +1,12 @@
import logging import logging
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from app.config import settings from app.config import settings
from app.core.rag import initialize_rag_manager from app.core.rag import initialize_rag_manager
from app.core.prompts import patch_prompts from app.core.prompts import patch_prompts
from app.api.routes import router from app.api.routes import router
import os
# 配置日志 # 配置日志
logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO) logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO)
@ -26,6 +28,14 @@ app = FastAPI(
lifespan=lifespan lifespan=lifespan
) )
# 确保静态目录存在
static_dir = os.path.join(os.path.dirname(__file__), "static")
if not os.path.exists(static_dir):
os.makedirs(static_dir)
# 挂载静态文件
app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(router) app.include_router(router)
if __name__ == "__main__": if __name__ == "__main__":

471
app/static/admin.html Normal file
View File

@ -0,0 +1,471 @@
<!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 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">
<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"
>
<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="请输入文本内容..."></el-input>
<div style="margin-top: 10px; text-align: right;">
<el-button type="primary" @click="uploadText">提交</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;"></el-input>
<el-input v-model="qa.answer" type="textarea" placeholder="回答 (Answer)"></el-input>
<el-button type="danger" link size="small" @click="removeQA(index)" v-if="qaList.length > 1">删除</el-button>
</div>
<el-button type="primary" plain style="width: 100%; margin-bottom: 10px;" @click="addQA">添加一组 QA</el-button>
<div style="text-align: right;">
<el-button type="primary" @click="uploadQA">提交所有 QA</el-button>
</div>
</el-tab-pane>
</el-tabs>
</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 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');
// 初始化
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';
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) => {
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('上传失败');
}
};
const uploadText = async () => {
if (!importText.value) return;
try {
await api.post('/ingest/text', { text: importText.value });
ElMessage.success('导入成功');
showImportDialog.value = false;
importText.value = '';
fetchDocuments();
} catch (e) {
ElMessage.error('导入失败');
}
};
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');
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('导入失败');
}
};
// 聊天逻辑
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,
showDocDialog, currentDocId, currentDocContent, docDetailLoading,
queryInput, chatHistory, chatLoading, chatBox,
goHome, refreshTenants, enterTenant, fetchDocuments,
viewDocument, deleteDocument, deleteCurrentDoc,
uploadFile, uploadText, addQA, removeQA, uploadQA,
sendQuery, renderMarkdown, formatDate
};
}
});
// 注册图标
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>