fix: 1. 增加简易后台页面及相关接口 2.兼容vllm思考模式 3.rerank方法问题处理 4.增加余弦参数配置,解决幻觉问题 5.临时停用VL
This commit is contained in:
parent
75044940c7
commit
ccf8bd6902
|
|
@ -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 # 余弦相似度阈值
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
# 获取图片数据
|
# 获取图片数据
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
10
app/main.py
10
app/main.py
|
|
@ -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__":
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue