fix: 1. 增加简易后台页面及相关接口 2.兼容vllm思考模式 3.rerank方法问题处理 4.增加余弦参数配置,解决幻觉问题 5.临时停用VL
This commit is contained in:
parent
75044940c7
commit
ccf8bd6902
|
|
@ -34,3 +34,4 @@ DATA_DIR=./index_data
|
|||
EMBEDDING_DIM=1024
|
||||
MAX_TOKEN_SIZE=8192
|
||||
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")
|
||||
async def health_check():
|
||||
"""健康检查接口"""
|
||||
|
|
@ -63,6 +110,7 @@ async def query_knowledge_base(
|
|||
- query: 用户问题
|
||||
- mode: 检索模式 (推荐 hybrid 用于事实类查询)
|
||||
- stream: 是否流式输出 (默认 False)
|
||||
- think: 是否启用思考模式 (默认 False)
|
||||
"""
|
||||
try:
|
||||
# 构造查询参数
|
||||
|
|
@ -94,11 +142,23 @@ async def query_knowledge_base(
|
|||
# 获取上下文 (这步耗时较长,包含图遍历)
|
||||
context_resp = await rag.aquery(request.query, param=context_param)
|
||||
|
||||
# 简单判断是否找到内容
|
||||
if not context_resp or "Sorry, I'm not able to answer" in context_resp:
|
||||
yield sse_pack("thinking", " (未找到相关上下文,将依赖 LLM 自身知识)")
|
||||
else:
|
||||
logging.info(f"Context Response: {context_resp}")
|
||||
|
||||
# 判断检索状态
|
||||
has_context = False
|
||||
if context_resp and "[no-context]" not in context_resp and "None" not in context_resp:
|
||||
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")
|
||||
else:
|
||||
yield sse_pack("system", "missed") # 发送系统事件:未检索到信息
|
||||
yield sse_pack("thinking", "2. 未找到相关上下文,将依赖 LLM 自身知识\n")
|
||||
think = False
|
||||
|
||||
yield sse_pack("thinking", "3. 答案生成中...\n")
|
||||
|
||||
|
|
@ -115,7 +175,7 @@ async def query_knowledge_base(
|
|||
request.query,
|
||||
system_prompt=sys_prompt,
|
||||
stream=True,
|
||||
think=request.think,
|
||||
think=think,
|
||||
hashing_kv=rag.llm_response_cache
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ class Settings(BaseSettings):
|
|||
EMBEDDING_DIM: int = 1024
|
||||
MAX_TOKEN_SIZE: int = 8192
|
||||
MAX_RAG_INSTANCES: int = 3 # 最大活跃 RAG 实例数
|
||||
COSINE_THRESHOLD: float = 0.4 # 向量检索相似度阈值
|
||||
|
||||
# Admin & Security
|
||||
ADMIN_TOKEN: str = "fzy"
|
||||
|
||||
class Config:
|
||||
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"
|
||||
|
||||
# 2. 提取图片
|
||||
if settings.VL_BINDING_HOST:
|
||||
if False and settings.VL_BINDING_HOST:
|
||||
for count, image_file_object in enumerate(page.images):
|
||||
try:
|
||||
# 获取图片数据
|
||||
|
|
|
|||
|
|
@ -68,7 +68,9 @@ async def openai_llm_func(prompt, system_prompt=None, history_messages=[], **kwa
|
|||
|
||||
stream = kwargs.pop("stream", False)
|
||||
# think 参数是 DeepSeek 特有的,OpenAI 标准接口不支持,暂时忽略
|
||||
kwargs.pop("think", None)
|
||||
think = kwargs.pop("think", None)
|
||||
# 这里使用qwen3指定的chat_template_kwargs,开启/禁用思考模式
|
||||
kwargs["chat_template_kwargs"] = {"enable_thinking": think}
|
||||
|
||||
messages = []
|
||||
if system_prompt:
|
||||
|
|
@ -169,16 +171,17 @@ async def embedding_func(texts: list[str]) -> np.ndarray:
|
|||
# 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 实现"""
|
||||
if not documents:
|
||||
return np.array([])
|
||||
return []
|
||||
|
||||
url = f"{settings.RERANK_BINDING_HOST}/rerank"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if settings.RERANK_KEY and settings.RERANK_KEY != "EMPTY":
|
||||
headers["Authorization"] = f"Bearer {settings.RERANK_KEY}"
|
||||
|
||||
# TEI 不支持 top_n 参数,我们手动截断或忽略
|
||||
payload = {
|
||||
"query": query,
|
||||
"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}]
|
||||
results = response.json()
|
||||
|
||||
# LightRAG 期望返回一个分数数组,对应输入的 documents 顺序
|
||||
# TEI 返回的结果是排序过的,我们需要根据 index 还原顺序
|
||||
scores = np.zeros(len(documents))
|
||||
# LightRAG 期望返回包含 index 和 relevance_score 的字典列表
|
||||
# 这样 LightRAG 才能正确映射回原始文档并进行排序
|
||||
formatted_results = []
|
||||
for res in results:
|
||||
idx = res['index']
|
||||
scores[idx] = res['score']
|
||||
formatted_results.append({
|
||||
"index": res['index'],
|
||||
"relevance_score": res['score']
|
||||
})
|
||||
|
||||
return scores
|
||||
return formatted_results
|
||||
|
||||
# ==============================================================================
|
||||
# RAG Manager
|
||||
|
|
@ -237,7 +242,8 @@ class RAGManager:
|
|||
func=embedding_func
|
||||
),
|
||||
"embedding_func_max_async": 8, # TEI 并发强
|
||||
"enable_llm_cache": True
|
||||
"enable_llm_cache": True,
|
||||
"cosine_threshold": settings.COSINE_THRESHOLD
|
||||
}
|
||||
|
||||
# 如果启用了 Rerank,注入 rerank_model_func
|
||||
|
|
|
|||
10
app/main.py
10
app/main.py
|
|
@ -1,10 +1,12 @@
|
|||
import logging
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from contextlib import asynccontextmanager
|
||||
from app.config import settings
|
||||
from app.core.rag import initialize_rag_manager
|
||||
from app.core.prompts import patch_prompts
|
||||
from app.api.routes import router
|
||||
import os
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO)
|
||||
|
|
@ -26,6 +28,14 @@ app = FastAPI(
|
|||
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)
|
||||
|
||||
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