重构 zip 格式 markdown 入库以及其他风格优化 (#336)
* 更新日志 * refactor(docs): 重构 zip 格式的支持逻辑 --------- Co-authored-by: lihuan-coder <1120083712@qq.com>
This commit is contained in:
parent
0d20cf66db
commit
14bcb29d5b
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Wenjie Zhang
|
||||
Copyright (c) 2025 The Project Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@ -7,6 +7,26 @@
|
||||
- **MinerU Official**: 官方云服务 API,无需本地部署,开箱即用
|
||||
- **PaddleX**: 结构化解析,适合表格、票据等特殊格式
|
||||
|
||||
## 支持的文件类型
|
||||
|
||||
### 常规文档格式
|
||||
- **文本文档**: `.txt`, `.md`, `.html`, `.htm`
|
||||
- **Word 文档**: `.doc`, `.docx`
|
||||
- **PDF 文档**: `.pdf`
|
||||
- **电子表格**: `.csv`, `.xls`, `.xlsx`
|
||||
- **JSON 数据**: `.json`
|
||||
|
||||
### 图像格式(需要 OCR)
|
||||
- **常见图片**: `.jpg`, `.jpeg`, `.png`, `.bmp`, `.tiff`, `.tif`, `.gif`, `.webp`
|
||||
|
||||
### ZIP 压缩包
|
||||
- **ZIP 文档**: `.zip` - 支持包含 Markdown 文件和图片的压缩包
|
||||
- 自动提取和处理 ZIP 包中的 `.md` 文件
|
||||
- 自动处理 ZIP 包中的图片文件并上传到对象存储
|
||||
- 图片链接会自动替换为可访问的 URL
|
||||
- 优先处理名为 `full.md` 的文件,否则使用第一个 `.md` 文件
|
||||
- 支持图片目录的智能识别(`images/`、`../images/` 等)
|
||||
|
||||
## 快速配置
|
||||
|
||||
### 1. 基础 OCR (RapidOCR)
|
||||
|
||||
@ -12,11 +12,13 @@
|
||||
- 集成 LangFuse (观望) 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
|
||||
- 集成 neo4j mcp (或者自己构建工具)
|
||||
- 工具组件重构以支持 todo,files 等渲染。
|
||||
- 文档解析部分的 markdown 中的图片替换为内部可访问的链接
|
||||
|
||||
### Bugs
|
||||
- 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi-Know/issues/279)
|
||||
- DeepSeek 官方接口适配会出现问题
|
||||
- 当前版本如果调用结果为空的时候,工具调用状态会一直处于调用状态,尽管调用是成功的
|
||||
- 目前的知识库的图片存在公开访问风险
|
||||
|
||||
### 新增
|
||||
- 优化知识库详情页面,更加简洁清晰
|
||||
@ -24,6 +26,9 @@
|
||||
- 增强文件下载功能
|
||||
- 新增多模态模型支持(当前仅支持图片,详见文档)
|
||||
- 新建 DeepAgents 智能体(Demo)
|
||||
- 新增基于知识库文件生成思维导图功能([#335](https://github.com/xerrors/Yuxi-Know/pull/335#issuecomment-3530976425))
|
||||
- 新增基于知识库文件生成示例问题功能([#335](https://github.com/xerrors/Yuxi-Know/pull/335#issuecomment-3530976425))
|
||||
- 新增知识库支持文件夹/压缩包上传的功能([#335](https://github.com/xerrors/Yuxi-Know/pull/335#issuecomment-3530976425))
|
||||
|
||||
### 修复
|
||||
- 修复重排序模型实际未生效的问题
|
||||
|
||||
@ -5,6 +5,7 @@ from server.routers.chat_router import chat
|
||||
from server.routers.dashboard_router import dashboard
|
||||
from server.routers.graph_router import graph
|
||||
from server.routers.knowledge_router import knowledge
|
||||
from server.routers.mindmap_router import mindmap
|
||||
from server.routers.system_router import system
|
||||
from server.routers.task_router import tasks
|
||||
|
||||
@ -16,5 +17,6 @@ router.include_router(auth) # /api/auth/*
|
||||
router.include_router(chat) # /api/chat/*
|
||||
router.include_router(dashboard) # /api/dashboard/*
|
||||
router.include_router(knowledge) # /api/knowledge/*
|
||||
router.include_router(mindmap) # /api/mindmap/*
|
||||
router.include_router(graph) # /api/graph/*
|
||||
router.include_router(tasks) # /api/tasks/*
|
||||
|
||||
@ -2,6 +2,7 @@ import aiofiles
|
||||
import asyncio
|
||||
import os
|
||||
import traceback
|
||||
import textwrap
|
||||
from collections.abc import Mapping
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
@ -235,18 +236,13 @@ async def add_documents(
|
||||
progress = 5.0 + (idx / total) * 90.0 # 5% ~ 95%
|
||||
await context.set_progress(progress, f"正在处理第 {idx}/{total} 个文档")
|
||||
|
||||
# 处理单个文档
|
||||
try:
|
||||
result = await knowledge_base.add_content(db_id, [item], params=params)
|
||||
processed_items.extend(result)
|
||||
except Exception as doc_error:
|
||||
# 处理单个文档处理的所有异常(包括超时)
|
||||
logger.error(f"Document processing failed for {item}: {doc_error}")
|
||||
|
||||
# 判断是否是超时异常
|
||||
error_type = "timeout" if isinstance(doc_error, TimeoutError) else "processing_error"
|
||||
error_msg = "处理超时" if isinstance(doc_error, TimeoutError) else "处理失败"
|
||||
|
||||
processed_items.append(
|
||||
{
|
||||
"item": item,
|
||||
@ -815,6 +811,199 @@ async def get_knowledge_base_query_params(db_id: str, current_user: User = Depen
|
||||
return {"message": f"获取知识库查询参数失败 {e}", "params": {}}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === AI生成示例问题 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
SAMPLE_QUESTIONS_SYSTEM_PROMPT = """你是一个专业的知识库问答测试专家。
|
||||
|
||||
你的任务是根据知识库中的文件列表,生成有价值的测试问题。
|
||||
|
||||
要求:
|
||||
1. 问题要具体、有针对性,基于文件名称和类型推测可能的内容
|
||||
2. 问题要涵盖不同方面和难度
|
||||
3. 问题要简洁明了,适合用于检索测试
|
||||
4. 问题要多样化,包括事实查询、概念解释、操作指导等
|
||||
5. 问题长度控制在10-30字之间
|
||||
6. 直接返回JSON数组格式,不要其他说明
|
||||
|
||||
返回格式:
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
"问题1?",
|
||||
"问题2?",
|
||||
"问题3?"
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
@knowledge.post("/databases/{db_id}/sample-questions")
|
||||
async def generate_sample_questions(
|
||||
db_id: str,
|
||||
request_body: dict = Body(...),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""
|
||||
AI生成针对知识库的测试问题
|
||||
|
||||
Args:
|
||||
db_id: 知识库ID
|
||||
request_body: 请求体,包含 count 字段
|
||||
|
||||
Returns:
|
||||
生成的问题列表
|
||||
"""
|
||||
try:
|
||||
from src.models import select_model
|
||||
import json
|
||||
|
||||
# 从请求体中提取参数
|
||||
count = request_body.get("count", 10)
|
||||
|
||||
# 获取知识库信息
|
||||
db_info = knowledge_base.get_database_info(db_id)
|
||||
if not db_info:
|
||||
raise HTTPException(status_code=404, detail=f"知识库 {db_id} 不存在")
|
||||
|
||||
db_name = db_info.get("name", "")
|
||||
all_files = db_info.get("files", {})
|
||||
|
||||
if not all_files:
|
||||
raise HTTPException(status_code=400, detail="知识库中没有文件")
|
||||
|
||||
# 收集文件信息
|
||||
files_info = []
|
||||
for file_id, file_info in all_files.items():
|
||||
files_info.append(
|
||||
{
|
||||
"filename": file_info.get("filename", ""),
|
||||
"type": file_info.get("type", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# 构建AI提示词
|
||||
system_prompt = SAMPLE_QUESTIONS_SYSTEM_PROMPT
|
||||
|
||||
# 构建用户消息
|
||||
files_text = "\n".join(
|
||||
[
|
||||
f"- {f['filename']} ({f['type']})"
|
||||
for f in files_info[:20] # 最多列举20个文件
|
||||
]
|
||||
)
|
||||
|
||||
file_count_text = f"(共{len(files_info)}个文件)" if len(files_info) > 20 else ""
|
||||
|
||||
user_message = textwrap.dedent(f"""请为知识库"{db_name}"生成{count}个测试问题。
|
||||
|
||||
知识库文件列表{file_count_text}:
|
||||
{files_text}
|
||||
|
||||
请根据这些文件的名称和类型,生成{count}个有价值的测试问题。""")
|
||||
|
||||
# 调用AI生成
|
||||
logger.info(f"开始生成知识库问题,知识库: {db_name}, 文件数量: {len(files_info)}, 问题数量: {count}")
|
||||
|
||||
# 选择模型并调用
|
||||
model = select_model()
|
||||
messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_message}]
|
||||
response = model.call(messages, stream=False)
|
||||
|
||||
# 解析AI返回的JSON
|
||||
try:
|
||||
# 提取JSON内容
|
||||
content = response.content if hasattr(response, "content") else str(response)
|
||||
|
||||
# 尝试从markdown代码块中提取JSON
|
||||
if "```json" in content:
|
||||
json_start = content.find("```json") + 7
|
||||
json_end = content.find("```", json_start)
|
||||
content = content[json_start:json_end].strip()
|
||||
elif "```" in content:
|
||||
json_start = content.find("```") + 3
|
||||
json_end = content.find("```", json_start)
|
||||
content = content[json_start:json_end].strip()
|
||||
|
||||
questions_data = json.loads(content)
|
||||
questions = questions_data.get("questions", [])
|
||||
|
||||
if not questions or not isinstance(questions, list):
|
||||
raise ValueError("AI返回的问题格式不正确")
|
||||
|
||||
logger.info(f"成功生成{len(questions)}个问题")
|
||||
|
||||
# 保存问题到知识库元数据
|
||||
try:
|
||||
async with knowledge_base._metadata_lock:
|
||||
# 确保知识库元数据存在
|
||||
if db_id not in knowledge_base.global_databases_meta:
|
||||
knowledge_base.global_databases_meta[db_id] = {}
|
||||
# 保存问题到对应知识库
|
||||
knowledge_base.global_databases_meta[db_id]["sample_questions"] = questions
|
||||
knowledge_base._save_global_metadata()
|
||||
logger.info(f"成功保存 {len(questions)} 个问题到知识库 {db_id}")
|
||||
except Exception as save_error:
|
||||
logger.error(f"保存问题失败: {save_error}")
|
||||
|
||||
return {
|
||||
"message": "success",
|
||||
"questions": questions,
|
||||
"count": len(questions),
|
||||
"db_id": db_id,
|
||||
"db_name": db_name,
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"AI返回的JSON解析失败: {e}, 原始内容: {content}")
|
||||
raise HTTPException(status_code=500, detail=f"AI返回格式错误: {str(e)}")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"生成知识库问题失败: {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"生成问题失败: {str(e)}")
|
||||
|
||||
|
||||
@knowledge.get("/databases/{db_id}/sample-questions")
|
||||
async def get_sample_questions(db_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""
|
||||
获取知识库的测试问题
|
||||
|
||||
Args:
|
||||
db_id: 知识库ID
|
||||
|
||||
Returns:
|
||||
问题列表
|
||||
"""
|
||||
try:
|
||||
# 直接从全局元数据中读取
|
||||
if db_id not in knowledge_base.global_databases_meta:
|
||||
raise HTTPException(status_code=404, detail=f"知识库 {db_id} 不存在")
|
||||
|
||||
db_meta = knowledge_base.global_databases_meta[db_id]
|
||||
questions = db_meta.get("sample_questions", [])
|
||||
|
||||
if not questions:
|
||||
raise HTTPException(status_code=404, detail="该知识库还没有生成测试问题")
|
||||
|
||||
return {
|
||||
"message": "success",
|
||||
"questions": questions,
|
||||
"count": len(questions),
|
||||
"db_id": db_id,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取知识库问题失败: {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"获取问题失败: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === 文件管理分组 ===
|
||||
# =============================================================================
|
||||
@ -838,7 +1027,7 @@ async def upload_file(
|
||||
if ext == ".jsonl":
|
||||
if allow_jsonl is not True or db_id is not None:
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported file type: {ext}")
|
||||
elif not is_supported_file_extension(file.filename):
|
||||
elif not (is_supported_file_extension(file.filename) or ext == ".zip"):
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported file type: {ext}")
|
||||
|
||||
# 根据db_id获取上传路径,如果db_id为None则使用默认路径
|
||||
|
||||
401
server/routers/mindmap_router.py
Normal file
401
server/routers/mindmap_router.py
Normal file
@ -0,0 +1,401 @@
|
||||
"""
|
||||
思维导图路由模块
|
||||
|
||||
提供思维导图相关的API接口,包括:
|
||||
- 获取知识库文件列表
|
||||
- AI生成思维导图
|
||||
- 保存和加载思维导图配置
|
||||
"""
|
||||
|
||||
import json
|
||||
import traceback
|
||||
import textwrap
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from src.storage.db.models import User
|
||||
from server.utils.auth_middleware import get_admin_user
|
||||
from src import knowledge_base
|
||||
from src.models import select_model
|
||||
from src.utils import logger
|
||||
|
||||
mindmap = APIRouter(prefix="/mindmap", tags=["mindmap"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === 获取知识库文件列表 ===
|
||||
# =============================================================================
|
||||
MINDMAP_SYSTEM_PROMPT = """你是一个专业的知识整理助手。
|
||||
|
||||
你的任务是分析用户提供的文件列表,生成一个层次分明的思维导图结构。
|
||||
|
||||
**核心规则:每个文件名只能出现一次!不允许重复!**
|
||||
|
||||
要求:
|
||||
1. 思维导图要有清晰的层级结构(2-4层)
|
||||
2. 根节点是知识库名称
|
||||
3. 第一层是主要分类(如:技术文档、规章制度、数据资源等)
|
||||
4. 第二层是子分类
|
||||
5. **叶子节点必须是具体的文件名称**
|
||||
6. **每个文件名在整个思维导图中只能出现一次,不得重复!**
|
||||
7. 如果一个文件可能属于多个分类,只选择最合适的一个分类放置
|
||||
8. 使用合适的emoji图标增强可读性
|
||||
9. 返回JSON格式,遵循以下结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "知识库名称",
|
||||
"children": [
|
||||
{
|
||||
"content": "🎯 主分类1",
|
||||
"children": [
|
||||
{
|
||||
"content": "子分类1.1",
|
||||
"children": [
|
||||
{"content": "文件名1.txt", "children": []},
|
||||
{"content": "文件名2.pdf", "children": []}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "💻 主分类2",
|
||||
"children": [
|
||||
{"content": "文件名3.docx", "children": []},
|
||||
{"content": "文件名4.md", "children": []}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**重要约束:**
|
||||
- 每个文件名在整个JSON中只能出现一次
|
||||
- 不要按多个维度分类导致文件重复
|
||||
- 选择最主要、最合适的分类维度
|
||||
- 每个叶子节点的children必须是空数组[]
|
||||
- 分类名称要简洁明了
|
||||
- 使用emoji增强视觉效果
|
||||
"""
|
||||
|
||||
|
||||
@mindmap.get("/databases/{db_id}/files")
|
||||
async def get_database_files(db_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""
|
||||
获取指定知识库的所有文件列表
|
||||
|
||||
Args:
|
||||
db_id: 知识库ID
|
||||
|
||||
Returns:
|
||||
文件列表信息
|
||||
"""
|
||||
try:
|
||||
# 获取知识库详细信息
|
||||
db_info = knowledge_base.get_database_info(db_id)
|
||||
|
||||
if not db_info:
|
||||
raise HTTPException(status_code=404, detail=f"知识库 {db_id} 不存在")
|
||||
|
||||
# 提取文件信息
|
||||
files = db_info.get("files", {})
|
||||
|
||||
# 转换为列表格式
|
||||
file_list = []
|
||||
for file_id, file_info in files.items():
|
||||
file_list.append(
|
||||
{
|
||||
"file_id": file_id,
|
||||
"filename": file_info.get("filename", ""),
|
||||
"type": file_info.get("type", ""),
|
||||
"status": file_info.get("status", ""),
|
||||
"created_at": file_info.get("created_at", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "success",
|
||||
"db_id": db_id,
|
||||
"db_name": db_info.get("name", ""),
|
||||
"files": file_list,
|
||||
"total": len(file_list),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取知识库文件列表失败: {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"获取文件列表失败: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === AI生成思维导图 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mindmap.post("/generate")
|
||||
async def generate_mindmap(
|
||||
db_id: str = Body(..., description="知识库ID"),
|
||||
file_ids: list[str] = Body(default=[], description="选择的文件ID列表"),
|
||||
user_prompt: str = Body(default="", description="用户自定义提示词"),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""
|
||||
使用AI分析知识库文件,生成思维导图结构
|
||||
|
||||
Args:
|
||||
db_id: 知识库ID
|
||||
file_ids: 选择的文件ID列表(为空则使用所有文件)
|
||||
user_prompt: 用户自定义提示词
|
||||
|
||||
Returns:
|
||||
Markmap格式的思维导图数据
|
||||
"""
|
||||
try:
|
||||
# 获取知识库信息
|
||||
db_info = knowledge_base.get_database_info(db_id)
|
||||
|
||||
if not db_info:
|
||||
raise HTTPException(status_code=404, detail=f"知识库 {db_id} 不存在")
|
||||
|
||||
db_name = db_info.get("name", "知识库")
|
||||
all_files = db_info.get("files", {})
|
||||
|
||||
# 如果没有指定文件,则使用所有文件
|
||||
if not file_ids:
|
||||
file_ids = list(all_files.keys())
|
||||
|
||||
if not file_ids:
|
||||
raise HTTPException(status_code=400, detail="知识库中没有文件")
|
||||
|
||||
# 限制文件数量不超过100个,如果超过则选择前100个
|
||||
if len(file_ids) > 20:
|
||||
original_count = len(file_ids)
|
||||
file_ids = file_ids[:20]
|
||||
logger.info(f"文件数量超过限制,已从{original_count}个文件中选择前20个文件生成思维导图")
|
||||
|
||||
# 收集文件信息
|
||||
files_info = []
|
||||
for file_id in file_ids:
|
||||
if file_id in all_files:
|
||||
file_info = all_files[file_id]
|
||||
files_info.append(
|
||||
{
|
||||
"filename": file_info.get("filename", ""),
|
||||
"type": file_info.get("type", ""),
|
||||
}
|
||||
)
|
||||
|
||||
if not files_info:
|
||||
raise HTTPException(status_code=400, detail="选择的文件不存在")
|
||||
|
||||
# 构建AI提示词
|
||||
system_prompt = MINDMAP_SYSTEM_PROMPT
|
||||
|
||||
# 构建用户消息
|
||||
files_text = "\n".join([f"- {f['filename']} ({f['type']})" for f in files_info])
|
||||
|
||||
user_message = textwrap.dedent(f"""请为知识库"{db_name}"生成思维导图结构。
|
||||
|
||||
文件列表(共{len(files_info)}个文件):
|
||||
{files_text}
|
||||
|
||||
{f"用户补充说明:{user_prompt}" if user_prompt else ""}
|
||||
|
||||
**重要提醒:**
|
||||
1. 这个知识库共有{len(files_info)}个文件
|
||||
2. 每个文件名只能在思维导图中出现一次
|
||||
3. 不要让同一个文件出现在多个分类下
|
||||
4. 为每个文件选择最合适的唯一分类
|
||||
|
||||
请生成合理的思维导图结构。""")
|
||||
|
||||
# 调用AI生成
|
||||
logger.info(f"开始生成思维导图,知识库: {db_name}, 文件数量: {len(files_info)}")
|
||||
|
||||
# 选择模型并调用
|
||||
model = select_model()
|
||||
messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_message}]
|
||||
response = model.call(messages, stream=False)
|
||||
|
||||
# 解析AI返回的JSON
|
||||
try:
|
||||
# 提取JSON内容
|
||||
content = response.content if hasattr(response, "content") else str(response)
|
||||
|
||||
# 尝试从markdown代码块中提取JSON
|
||||
if "```json" in content:
|
||||
json_start = content.find("```json") + 7
|
||||
json_end = content.find("```", json_start)
|
||||
content = content[json_start:json_end].strip()
|
||||
elif "```" in content:
|
||||
json_start = content.find("```") + 3
|
||||
json_end = content.find("```", json_start)
|
||||
content = content[json_start:json_end].strip()
|
||||
|
||||
mindmap_data = json.loads(content)
|
||||
|
||||
# 验证结构
|
||||
if not isinstance(mindmap_data, dict) or "content" not in mindmap_data:
|
||||
raise ValueError("思维导图结构不正确")
|
||||
|
||||
logger.info("思维导图生成成功")
|
||||
|
||||
# 保存思维导图到知识库元数据
|
||||
try:
|
||||
async with knowledge_base._metadata_lock:
|
||||
if db_id in knowledge_base.global_databases_meta:
|
||||
knowledge_base.global_databases_meta[db_id]["mindmap"] = mindmap_data
|
||||
knowledge_base._save_global_metadata()
|
||||
logger.info(f"思维导图已保存到知识库: {db_id}")
|
||||
except Exception as save_error:
|
||||
logger.error(f"保存思维导图失败: {save_error}")
|
||||
# 不影响返回结果,只记录错误
|
||||
|
||||
return {
|
||||
"message": "success",
|
||||
"mindmap": mindmap_data,
|
||||
"db_id": db_id,
|
||||
"db_name": db_name,
|
||||
"file_count": len(files_info),
|
||||
"original_file_count": original_count if "original_count" in locals() else len(files_info),
|
||||
"truncated": len(files_info) < (original_count if "original_count" in locals() else len(files_info)),
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"AI返回的JSON解析失败: {e}, 原始内容: {content}")
|
||||
raise HTTPException(status_code=500, detail=f"AI返回格式错误: {str(e)}")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"生成思维导图失败: {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"生成思维导图失败: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === 获取所有知识库概览(用于选择) ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mindmap.get("/databases")
|
||||
async def get_databases_overview(current_user: User = Depends(get_admin_user)):
|
||||
"""
|
||||
获取所有知识库的概览信息,用于思维导图界面选择
|
||||
|
||||
Returns:
|
||||
知识库列表
|
||||
"""
|
||||
try:
|
||||
databases = knowledge_base.get_databases()
|
||||
|
||||
# databases["databases"] 是一个列表,每个元素已经包含了基本信息
|
||||
db_list_raw = databases.get("databases", [])
|
||||
|
||||
db_list = []
|
||||
for db_info in db_list_raw:
|
||||
db_id = db_info.get("db_id")
|
||||
if not db_id:
|
||||
continue
|
||||
|
||||
# 获取详细信息以获取文件数量
|
||||
detail_info = knowledge_base.get_database_info(db_id)
|
||||
file_count = len(detail_info.get("files", {})) if detail_info else 0
|
||||
|
||||
db_list.append(
|
||||
{
|
||||
"db_id": db_id,
|
||||
"name": db_info.get("name", ""),
|
||||
"description": db_info.get("description", ""),
|
||||
"kb_type": db_info.get("kb_type", ""),
|
||||
"file_count": file_count,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "success",
|
||||
"databases": db_list,
|
||||
"total": len(db_list),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取知识库列表失败: {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"获取知识库列表失败: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === 知识库关联的思维导图管理 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mindmap.get("/database/{db_id}")
|
||||
async def get_database_mindmap(db_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""
|
||||
获取知识库关联的思维导图
|
||||
|
||||
Args:
|
||||
db_id: 知识库ID
|
||||
|
||||
Returns:
|
||||
思维导图数据
|
||||
"""
|
||||
try:
|
||||
# 直接从全局元数据中读取思维导图
|
||||
if db_id not in knowledge_base.global_databases_meta:
|
||||
raise HTTPException(status_code=404, detail=f"知识库 {db_id} 不存在")
|
||||
|
||||
db_meta = knowledge_base.global_databases_meta[db_id]
|
||||
mindmap_data = db_meta.get("mindmap")
|
||||
|
||||
if not mindmap_data:
|
||||
raise HTTPException(status_code=404, detail="该知识库还没有生成思维导图")
|
||||
|
||||
return {
|
||||
"message": "success",
|
||||
"mindmap": mindmap_data,
|
||||
"db_id": db_id,
|
||||
"db_name": db_meta.get("name", ""),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取知识库思维导图失败: {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"获取思维导图失败: {str(e)}")
|
||||
|
||||
|
||||
@mindmap.post("/database/{db_id}")
|
||||
async def save_database_mindmap(
|
||||
db_id: str,
|
||||
mindmap: dict = Body(..., description="思维导图数据"),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""
|
||||
保存思维导图到知识库
|
||||
|
||||
Args:
|
||||
db_id: 知识库ID
|
||||
mindmap: 思维导图数据
|
||||
|
||||
Returns:
|
||||
保存结果
|
||||
"""
|
||||
try:
|
||||
# 检查知识库是否存在
|
||||
db_info = knowledge_base.get_database_info(db_id)
|
||||
if not db_info:
|
||||
raise HTTPException(status_code=404, detail=f"知识库 {db_id} 不存在")
|
||||
|
||||
# TODO: 将思维导图保存到知识库元数据中
|
||||
# 这里需要实现一个方法来更新知识库的元数据
|
||||
|
||||
return {
|
||||
"message": "success",
|
||||
"db_id": db_id,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"保存思维导图失败: {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"保存思维导图失败: {str(e)}")
|
||||
@ -112,6 +112,14 @@ class KnowledgeRetrieverModel(BaseModel):
|
||||
"查询的关键词,查询的时候,应该尽量以可能帮助回答这个问题的关键词进行查询,不要直接使用用户的原始输入去查询。"
|
||||
)
|
||||
)
|
||||
operation: str = Field(
|
||||
default="search",
|
||||
description=(
|
||||
"操作类型:'search' 表示检索知识库内容,'get_mindmap' 表示获取知识库的思维导图结构。"
|
||||
"当用户询问知识库的整体结构、文件分类、知识架构时,使用 'get_mindmap'。"
|
||||
"当用户需要查询具体内容时,使用 'search'。"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_kb_based_tools() -> list:
|
||||
@ -123,8 +131,44 @@ def get_kb_based_tools() -> list:
|
||||
def _create_retriever_wrapper(db_id: str, retriever_info: dict[str, Any]):
|
||||
"""创建检索器包装函数的工厂函数,避免闭包变量捕获问题"""
|
||||
|
||||
async def async_retriever_wrapper(query_text: str) -> Any:
|
||||
"""异步检索器包装函数"""
|
||||
async def async_retriever_wrapper(query_text: str, operation: str = "search") -> Any:
|
||||
"""异步检索器包装函数,支持检索和获取思维导图"""
|
||||
|
||||
# 获取思维导图
|
||||
if operation == "get_mindmap":
|
||||
try:
|
||||
logger.debug(f"Getting mindmap for database {db_id}")
|
||||
|
||||
# 从知识库元数据中获取思维导图
|
||||
if db_id not in knowledge_base.global_databases_meta:
|
||||
return f"知识库 {retriever_info['name']} 不存在"
|
||||
|
||||
db_meta = knowledge_base.global_databases_meta[db_id]
|
||||
mindmap_data = db_meta.get("mindmap")
|
||||
|
||||
if not mindmap_data:
|
||||
return f"知识库 {retriever_info['name']} 还没有生成思维导图。"
|
||||
|
||||
# 将思维导图数据转换为文本格式,便于AI理解
|
||||
def mindmap_to_text(node, level=0):
|
||||
"""递归将思维导图JSON转换为层级文本"""
|
||||
indent = " " * level
|
||||
text = f"{indent}- {node.get('content', '')}\n"
|
||||
for child in node.get("children", []):
|
||||
text += mindmap_to_text(child, level + 1)
|
||||
return text
|
||||
|
||||
mindmap_text = f"知识库 {retriever_info['name']} 的思维导图结构:\n\n"
|
||||
mindmap_text += mindmap_to_text(mindmap_data)
|
||||
|
||||
logger.debug(f"Successfully retrieved mindmap for {db_id}")
|
||||
return mindmap_text
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting mindmap for {db_id}: {e}")
|
||||
return f"获取思维导图失败: {str(e)}"
|
||||
|
||||
# 默认:检索知识库
|
||||
retriever = retriever_info["retriever"]
|
||||
try:
|
||||
logger.debug(f"Retrieving from database {db_id} with query: {query_text}")
|
||||
@ -144,8 +188,14 @@ def get_kb_based_tools() -> list:
|
||||
try:
|
||||
# 构建工具描述
|
||||
description = (
|
||||
f"使用 {retrieve_info['name']} 知识库进行检索。\n"
|
||||
f"下面是这个知识库的描述:\n{retrieve_info['description'] or '没有描述。'} "
|
||||
f"使用 {retrieve_info['name']} 知识库的多功能工具。\n"
|
||||
f"知识库描述:{retrieve_info['description'] or '没有描述。'}\n\n"
|
||||
f"支持的操作:\n"
|
||||
f"1. 'search' - 检索知识库内容:根据关键词查询相关文档片段\n"
|
||||
f"2. 'get_mindmap' - 获取思维导图:查看知识库的整体结构和文件分类\n\n"
|
||||
f"使用建议:\n"
|
||||
f"- 需要查询具体内容时,使用 operation='search'\n"
|
||||
f"- 想了解知识库结构、文件分类时,使用 operation='get_mindmap'"
|
||||
)
|
||||
|
||||
# 使用工厂函数创建检索器包装函数,避免闭包问题
|
||||
|
||||
@ -198,6 +198,11 @@ class ChromaKB(KnowledgeBase):
|
||||
|
||||
self._add_to_processing_queue(file_id)
|
||||
try:
|
||||
# 确保params中包含db_id(ZIP文件处理需要)
|
||||
if params is None:
|
||||
params = {}
|
||||
params["db_id"] = db_id
|
||||
|
||||
# 根据内容类型处理内容
|
||||
if content_type == "file":
|
||||
markdown_content = await process_file_to_markdown(item, params=params)
|
||||
|
||||
@ -237,6 +237,11 @@ class LightRagKB(KnowledgeBase):
|
||||
|
||||
self._add_to_processing_queue(file_id)
|
||||
try:
|
||||
# 确保params中包含db_id(ZIP文件处理需要)
|
||||
if params is None:
|
||||
params = {}
|
||||
params["db_id"] = db_id
|
||||
|
||||
# 根据内容类型处理内容
|
||||
if content_type == "file":
|
||||
markdown_content = await process_file_to_markdown(item, params=params)
|
||||
|
||||
@ -242,6 +242,11 @@ class MilvusKB(KnowledgeBase):
|
||||
self._add_to_processing_queue(file_id)
|
||||
|
||||
try:
|
||||
# 确保params中包含db_id(ZIP文件处理需要)
|
||||
if params is None:
|
||||
params = {}
|
||||
params["db_id"] = db_id
|
||||
|
||||
if content_type == "file":
|
||||
markdown_content = await process_file_to_markdown(item, params=params)
|
||||
else:
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from langchain_community.document_loaders import (
|
||||
@ -13,7 +15,9 @@ from langchain_community.document_loaders import (
|
||||
)
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
from src.utils import logger
|
||||
from src.knowledge.utils import calculate_content_hash
|
||||
from src.storage.minio import get_minio_client
|
||||
from src.utils import hashstr, logger
|
||||
|
||||
SUPPORTED_FILE_EXTENSIONS: tuple[str, ...] = (
|
||||
".txt",
|
||||
@ -33,6 +37,7 @@ SUPPORTED_FILE_EXTENSIONS: tuple[str, ...] = (
|
||||
".bmp",
|
||||
".tiff",
|
||||
".tif",
|
||||
".zip",
|
||||
)
|
||||
|
||||
|
||||
@ -265,10 +270,15 @@ async def process_file_to_markdown(file_path: str, params: dict | None = None) -
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
params: 处理参数
|
||||
params: 处理参数,对于ZIP文件需要包含 db_id
|
||||
|
||||
Returns:
|
||||
markdown格式内容
|
||||
|
||||
Note:
|
||||
对于ZIP文件,会在params中保存处理结果供调用方使用:
|
||||
- params['_zip_images_info']: 图片信息列表
|
||||
- params['_zip_content_hash']: 内容哈希值
|
||||
"""
|
||||
file_path_obj = Path(file_path)
|
||||
file_ext = file_path_obj.suffix.lower()
|
||||
@ -350,11 +360,196 @@ async def process_file_to_markdown(file_path: str, params: dict | None = None) -
|
||||
json_str = json.dumps(data, ensure_ascii=False, indent=2)
|
||||
return f"# {file_path_obj.name}\n\n```json\n{json_str}\n```"
|
||||
|
||||
elif file_ext == ".zip":
|
||||
if not params or "db_id" not in params:
|
||||
raise ValueError("ZIP文件处理需要在params中提供db_id参数")
|
||||
|
||||
result = await asyncio.to_thread(_process_zip_file, str(file_path_obj), params["db_id"])
|
||||
|
||||
# 将处理结果保存到params中供调用方使用
|
||||
params["_zip_images_info"] = result["images_info"]
|
||||
params["_zip_content_hash"] = result["content_hash"]
|
||||
|
||||
return result["markdown_content"]
|
||||
|
||||
else:
|
||||
# 尝试作为文本文件读取
|
||||
raise ValueError(f"Unsupported file type: {file_ext}")
|
||||
|
||||
|
||||
def _process_zip_file(zip_path: str, db_id: str) -> dict:
|
||||
"""
|
||||
处理ZIP文件,提取markdown内容和图片(内部函数)
|
||||
|
||||
Args:
|
||||
zip_path: ZIP文件路径
|
||||
db_id: 数据库ID
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"markdown_content": str, # markdown内容
|
||||
"content_hash": str, # 内容哈希值
|
||||
"images_info": list[dict] # 图片信息列表
|
||||
}
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: ZIP文件不存在
|
||||
ValueError: ZIP文件格式错误或内容不符合要求
|
||||
"""
|
||||
# 1. 安全检查
|
||||
if not os.path.exists(zip_path):
|
||||
raise FileNotFoundError(f"ZIP 文件不存在: {zip_path}")
|
||||
|
||||
# 2. 解压ZIP并提取内容
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
# 安全检查:防止路径遍历攻击
|
||||
for name in zf.namelist():
|
||||
if name.startswith("/") or name.startswith("\\"):
|
||||
raise ValueError(f"ZIP 包含不安全路径: {name}")
|
||||
if ".." in Path(name).parts:
|
||||
raise ValueError(f"ZIP 路径包含上级引用: {name}")
|
||||
|
||||
# 查找markdown文件
|
||||
md_files = [n for n in zf.namelist() if n.lower().endswith(".md")]
|
||||
if not md_files:
|
||||
raise ValueError("压缩包中未找到 .md 文件")
|
||||
|
||||
# 优先使用 full.md,否则使用第一个md文件
|
||||
md_file = next((n for n in md_files if Path(n).name == "full.md"), md_files[0])
|
||||
|
||||
# 读取markdown内容
|
||||
with zf.open(md_file) as f:
|
||||
markdown_content = f.read().decode("utf-8")
|
||||
|
||||
# 3. 处理图片
|
||||
images_info = []
|
||||
images_dir = _find_images_directory(zf, md_file)
|
||||
|
||||
if images_dir:
|
||||
images_info = _process_images(zf, images_dir, db_id, md_file)
|
||||
markdown_content = _replace_image_links(markdown_content, images_info)
|
||||
|
||||
# 4. 生成结果
|
||||
content_hash = calculate_content_hash(markdown_content.encode("utf-8"))
|
||||
|
||||
return {
|
||||
"markdown_content": markdown_content,
|
||||
"content_hash": content_hash,
|
||||
"images_info": images_info,
|
||||
}
|
||||
|
||||
|
||||
def _find_images_directory(zip_file: zipfile.ZipFile, md_file_path: str) -> str | None:
|
||||
"""查找images目录"""
|
||||
md_parent = Path(md_file_path).parent
|
||||
|
||||
# 候选目录
|
||||
candidates = []
|
||||
if str(md_parent) != ".":
|
||||
candidates.extend([str(md_parent / "images"), str(md_parent.parent / "images")])
|
||||
candidates.append("images")
|
||||
|
||||
# 查找存在的目录
|
||||
for cand in candidates:
|
||||
cand_clean = cand.rstrip("/")
|
||||
if any(n.startswith(cand_clean + "/") for n in zip_file.namelist()):
|
||||
return cand_clean
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _process_images(zip_file: zipfile.ZipFile, images_dir: str, db_id: str, md_file_path: str) -> list[dict]:
|
||||
"""处理图片:上传到MinIO并返回信息"""
|
||||
# 支持的图片格式
|
||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
||||
CONTENT_TYPE_MAP = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
images = []
|
||||
image_names = [n for n in zip_file.namelist() if n.startswith(images_dir + "/")]
|
||||
|
||||
# 上传图片到MinIO
|
||||
minio_client = get_minio_client()
|
||||
bucket_name = "kb-images"
|
||||
minio_client.ensure_bucket_exists(bucket_name)
|
||||
|
||||
file_id = hashstr(Path(md_file_path).name, length=16)
|
||||
|
||||
for img_name in image_names:
|
||||
suffix = Path(img_name).suffix.lower()
|
||||
if suffix not in SUPPORTED_EXTENSIONS:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 读取图片数据
|
||||
with zip_file.open(img_name) as f:
|
||||
data = f.read()
|
||||
|
||||
# 上传到MinIO
|
||||
object_name = f"{db_id}/{file_id}/images/{Path(img_name).name}"
|
||||
content_type = CONTENT_TYPE_MAP.get(suffix, "image/jpeg")
|
||||
|
||||
result = minio_client.upload_file(
|
||||
bucket_name=bucket_name,
|
||||
object_name=object_name,
|
||||
data=data,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
# 记录图片信息
|
||||
img_info = {"name": Path(img_name).name, "url": result.url, "path": f"images/{Path(img_name).name}"}
|
||||
images.append(img_info)
|
||||
|
||||
logger.debug(f"图片上传成功: {Path(img_name).name} -> {result.url}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"上传图片失败 {Path(img_name).name}: {e}")
|
||||
continue
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def _replace_image_links(markdown_content: str, images: list[dict]) -> str:
|
||||
"""替换markdown中的图片链接为MinIO URL"""
|
||||
if not images:
|
||||
return markdown_content
|
||||
|
||||
# 构建路径映射
|
||||
image_map = {}
|
||||
for img in images:
|
||||
path = img["path"]
|
||||
url = img["url"]
|
||||
image_map[path] = url
|
||||
image_map[f"/{path}"] = url
|
||||
image_map[img["name"]] = url
|
||||
|
||||
def replace_link(match):
|
||||
alt_text = match.group(1) or ""
|
||||
img_path = match.group(2)
|
||||
|
||||
# 尝试匹配各种路径格式
|
||||
for pattern, url in image_map.items():
|
||||
if img_path.endswith(pattern) or img_path == pattern:
|
||||
return f""
|
||||
|
||||
# 尝试文件名匹配
|
||||
filename = os.path.basename(img_path)
|
||||
if filename in image_map:
|
||||
return f""
|
||||
|
||||
return match.group(0)
|
||||
|
||||
# 使用正则表达式替换图片链接
|
||||
pattern = r"!\[([^\]]*)\]\(([^)]+)\)"
|
||||
return re.sub(pattern, replace_link, markdown_content)
|
||||
|
||||
|
||||
async def process_url_to_markdown(url: str, params: dict | None = None) -> str:
|
||||
"""
|
||||
将URL转换为markdown格式
|
||||
|
||||
@ -33,7 +33,7 @@ class MinIOClient:
|
||||
简化的 MinIO 客户端类
|
||||
"""
|
||||
|
||||
PUBLIC_READ_BUCKETS = {"generated-images", "avatar"}
|
||||
PUBLIC_READ_BUCKETS = {"generated-images", "avatar", "kb-images"}
|
||||
|
||||
def __init__(self):
|
||||
"""初始化 MinIO 客户端"""
|
||||
|
||||
@ -29,6 +29,8 @@
|
||||
"lucide-vue-next": "^0.542.0",
|
||||
"marked": "^16.2.1",
|
||||
"marked-highlight": "^2.2.2",
|
||||
"markmap-lib": "^0.18.12",
|
||||
"markmap-view": "^0.18.12",
|
||||
"md-editor-v3": "^5.8.4",
|
||||
"pinia": "^3.0.3",
|
||||
"sigma": "^3.0.2",
|
||||
|
||||
@ -9,6 +9,7 @@ export * from './knowledge_api' // 知识库管理API
|
||||
export * from './graph_api' // 图谱API
|
||||
export * from './agent_api' // 智能体API
|
||||
export * from './tasker' // 任务管理API
|
||||
export * from './mindmap_api' // 思维导图API
|
||||
|
||||
// 导出基础工具函数
|
||||
export { apiGet, apiPost, apiPut, apiDelete,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base'
|
||||
import { apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete, apiRequest } from './base'
|
||||
|
||||
/**
|
||||
* 知识库管理API模块
|
||||
@ -160,6 +160,27 @@ export const queryApi = {
|
||||
*/
|
||||
getKnowledgeBaseQueryParams: async (dbId) => {
|
||||
return apiAdminGet(`/api/knowledge/databases/${dbId}/query-params`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成知识库的测试问题
|
||||
* @param {string} dbId - 知识库ID
|
||||
* @param {number} count - 生成问题数量,默认10
|
||||
* @returns {Promise} - 生成的问题列表
|
||||
*/
|
||||
generateSampleQuestions: async (dbId, count = 10) => {
|
||||
return apiAdminPost(`/api/knowledge/databases/${dbId}/sample-questions`, {
|
||||
count
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取知识库的测试问题
|
||||
* @param {string} dbId - 知识库ID
|
||||
* @returns {Promise} - 问题列表
|
||||
*/
|
||||
getSampleQuestions: async (dbId) => {
|
||||
return apiAdminGet(`/api/knowledge/databases/${dbId}/sample-questions`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,6 +216,40 @@ export const fileApi = {
|
||||
*/
|
||||
getSupportedFileTypes: async () => {
|
||||
return apiAdminGet('/api/knowledge/files/supported-types')
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传文件夹(zip格式)
|
||||
* @param {File} file - zip文件
|
||||
* @param {string} dbId - 知识库ID
|
||||
* @returns {Promise} - 上传结果
|
||||
*/
|
||||
uploadFolder: async (file, dbId) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
// 使用 apiRequest 直接发送 FormData,但使用统一的错误处理
|
||||
return apiRequest(`/api/knowledge/files/upload-folder?db_id=${dbId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
// 不设置 Content-Type,让浏览器自动设置 boundary
|
||||
}, true, 'json') // 需要认证,期望JSON响应
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理文件夹(异步处理zip文件)
|
||||
* @param {Object} data - 处理参数
|
||||
* @param {string} data.file_path - 已上传的zip文件路径
|
||||
* @param {string} data.db_id - 知识库ID
|
||||
* @param {string} data.content_hash - 文件内容哈希
|
||||
* @returns {Promise} - 处理任务结果
|
||||
*/
|
||||
processFolder: async ({ file_path, db_id, content_hash }) => {
|
||||
return apiAdminPost('/api/knowledge/files/process-folder', {
|
||||
file_path,
|
||||
db_id,
|
||||
content_hash
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
66
web/src/apis/mindmap_api.js
Normal file
66
web/src/apis/mindmap_api.js
Normal file
@ -0,0 +1,66 @@
|
||||
import { apiAdminGet, apiAdminPost } from './base'
|
||||
|
||||
/**
|
||||
* 思维导图API模块
|
||||
* 提供思维导图相关的接口功能
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// === 知识库管理 ===
|
||||
// =============================================================================
|
||||
|
||||
export const mindmapApi = {
|
||||
/**
|
||||
* 获取所有知识库概览(用于选择)
|
||||
* @returns {Promise} - 知识库列表
|
||||
*/
|
||||
getDatabases: async () => {
|
||||
return apiAdminGet('/api/mindmap/databases')
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取指定知识库的文件列表
|
||||
* @param {string} dbId - 知识库ID
|
||||
* @returns {Promise} - 文件列表
|
||||
*/
|
||||
getDatabaseFiles: async (dbId) => {
|
||||
return apiAdminGet(`/api/mindmap/databases/${dbId}/files`)
|
||||
},
|
||||
|
||||
/**
|
||||
* AI生成思维导图
|
||||
* @param {string} dbId - 知识库ID
|
||||
* @param {Array<string>} fileIds - 选择的文件ID列表(为空则使用所有文件)
|
||||
* @param {string} userPrompt - 用户自定义提示词
|
||||
* @returns {Promise} - 思维导图数据
|
||||
*/
|
||||
generateMindmap: async (dbId, fileIds = [], userPrompt = '') => {
|
||||
return apiAdminPost('/api/mindmap/generate', {
|
||||
db_id: dbId,
|
||||
file_ids: fileIds,
|
||||
user_prompt: userPrompt
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取知识库的思维导图
|
||||
* @param {string} dbId - 知识库ID
|
||||
* @returns {Promise} - 思维导图数据
|
||||
*/
|
||||
getByDatabase: async (dbId) => {
|
||||
return apiAdminGet(`/api/mindmap/database/${dbId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 保存思维导图到知识库
|
||||
* @param {string} dbId - 知识库ID
|
||||
* @param {Object} mindmapData - 思维导图数据
|
||||
* @returns {Promise} - 保存结果
|
||||
*/
|
||||
saveToDatabase: async (dbId, mindmapData) => {
|
||||
return apiAdminPost(`/api/mindmap/database/${dbId}`, {
|
||||
mindmap: mindmapData
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,11 +95,16 @@
|
||||
<p class="ant-upload-hint">
|
||||
支持的文件类型:{{ uploadHint }}
|
||||
</p>
|
||||
<div class="zip-support-tip" v-if="hasZipFiles">
|
||||
📦 zip 包会自动提取 Markdown 文件和图片,图片链接将替换为可访问的 URL
|
||||
</div>
|
||||
</a-upload-dragger>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- URL 输入区域 -->
|
||||
<div class="url-input" v-else>
|
||||
<div class="url-input" v-if="uploadMode === 'url'">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="网页链接 (每行一个URL)">
|
||||
<a-textarea
|
||||
@ -197,14 +202,18 @@ const acceptedFileTypes = computed(() => {
|
||||
if (!supportedFileTypes.value.length) {
|
||||
return '';
|
||||
}
|
||||
return supportedFileTypes.value.join(',');
|
||||
const exts = new Set(supportedFileTypes.value);
|
||||
exts.add('.zip');
|
||||
return Array.from(exts).join(',');
|
||||
});
|
||||
|
||||
const uploadHint = computed(() => {
|
||||
if (!supportedFileTypes.value.length) {
|
||||
return '加载中...';
|
||||
}
|
||||
return supportedFileTypes.value.join(', ');
|
||||
const exts = new Set(supportedFileTypes.value);
|
||||
exts.add('.zip');
|
||||
return Array.from(exts).join(', ');
|
||||
});
|
||||
|
||||
const isSupportedExtension = (fileName) => {
|
||||
@ -219,7 +228,7 @@ const isSupportedExtension = (fileName) => {
|
||||
return false;
|
||||
}
|
||||
const ext = fileName.slice(lastDotIndex).toLowerCase();
|
||||
return supportedFileTypes.value.includes(ext);
|
||||
return supportedFileTypes.value.includes(ext) || ext === '.zip';
|
||||
};
|
||||
|
||||
const loadSupportedFileTypes = async () => {
|
||||
@ -270,6 +279,7 @@ const uploadModeOptions = computed(() => [
|
||||
// 文件列表
|
||||
const fileList = ref([]);
|
||||
|
||||
|
||||
// URL列表
|
||||
const urlList = ref('');
|
||||
|
||||
@ -354,6 +364,27 @@ const hasPdfOrImageFiles = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
// 计算属性:是否有ZIP文件
|
||||
const hasZipFiles = computed(() => {
|
||||
if (fileList.value.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return fileList.value.some(file => {
|
||||
if (file.status !== 'done') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filePath = file.response?.file_path || file.name;
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
|
||||
return ext === '.zip';
|
||||
});
|
||||
});
|
||||
|
||||
// 计算属性:OCR选项
|
||||
const enableOcrOptions = computed(() => [
|
||||
{
|
||||
@ -508,6 +539,8 @@ const handleFileUpload = (info) => {
|
||||
|
||||
const handleDrop = () => {};
|
||||
|
||||
// 已移除文件夹上传逻辑
|
||||
|
||||
const showChunkConfigModal = () => {
|
||||
tempChunkParams.value = {
|
||||
chunk_size: chunkParams.value.chunk_size,
|
||||
@ -553,6 +586,11 @@ const getAuthHeaders = () => {
|
||||
};
|
||||
|
||||
const chunkData = async () => {
|
||||
if (!databaseId.value) {
|
||||
message.error('请先选择知识库');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证OCR服务可用性
|
||||
if (!validateOcrService()) {
|
||||
return;
|
||||
@ -583,7 +621,15 @@ const chunkData = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
success = await store.addFiles({ items: validFiles, contentType: 'file', params: chunkParams.value });
|
||||
try {
|
||||
store.state.chunkLoading = true;
|
||||
success = await store.addFiles({ items: validFiles, contentType: 'file', params: chunkParams.value });
|
||||
} catch (error) {
|
||||
console.error('文件上传失败:', error);
|
||||
message.error('文件上传失败: ' + (error.message || '未知错误'));
|
||||
} finally {
|
||||
store.state.chunkLoading = false;
|
||||
}
|
||||
} else if (uploadMode.value === 'url') {
|
||||
const urls = urlList.value.split('\n')
|
||||
.map(url => url.trim())
|
||||
@ -594,7 +640,15 @@ const chunkData = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
success = await store.addFiles({ items: urls, contentType: 'url', params: chunkParams.value });
|
||||
try {
|
||||
store.state.chunkLoading = true;
|
||||
success = await store.addFiles({ items: urls, contentType: 'url', params: chunkParams.value });
|
||||
} catch (error) {
|
||||
console.error('URL上传失败:', error);
|
||||
message.error('URL上传失败: ' + (error.message || '未知错误'));
|
||||
} finally {
|
||||
store.state.chunkLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
@ -709,4 +763,18 @@ const chunkData = async () => {
|
||||
color: #d46b08;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.folder-upload-tip {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f0f7ff;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.zip-support-tip {
|
||||
font-size: 12px;
|
||||
color: var(--color-warning);
|
||||
}
|
||||
</style>
|
||||
|
||||
415
web/src/components/MindMapSection.vue
Normal file
415
web/src/components/MindMapSection.vue
Normal file
@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<div class="mindmap-section">
|
||||
<div class="section-header">
|
||||
<div class="header-left">
|
||||
<BrainCircuit :size="16" />
|
||||
<span>知识导图</span>
|
||||
<a-tag v-if="!loading && mindmapData" color="blue" size="small">
|
||||
已生成
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-content">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<a-spin size="small" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 生成中状态 -->
|
||||
<div v-else-if="generating" class="generating-state">
|
||||
<a-spin size="small" />
|
||||
<span>AI 正在生成思维导图...</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!mindmapData" class="empty-state">
|
||||
<Network :size="32" />
|
||||
<p>暂无思维导图</p>
|
||||
<a-button type="primary" size="small" @click="generateMindmap">
|
||||
<template #icon><Sparkles :size="14" /></template>
|
||||
生成思维导图
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 思维导图显示 -->
|
||||
<div v-else class="mindmap-container">
|
||||
<div class="mindmap-toolbar">
|
||||
<a-space :size="8">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="refreshMindmap"
|
||||
:loading="generating"
|
||||
title="重新生成"
|
||||
>
|
||||
<template #icon><RefreshCw :size="14" /></template>
|
||||
<span class="toolbar-text">重新生成</span>
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="fitView" title="适应视图">
|
||||
<template #icon><Maximize2 :size="14" /></template>
|
||||
<span class="toolbar-text">适应视图</span>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<div class="mindmap-svg-container">
|
||||
<svg ref="mindmapSvg" class="mindmap-svg"></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
BrainCircuit,
|
||||
RefreshCw,
|
||||
Network,
|
||||
Sparkles,
|
||||
Maximize2
|
||||
} from 'lucide-vue-next'
|
||||
import { mindmapApi } from '@/apis/mindmap_api'
|
||||
import { Markmap } from 'markmap-view'
|
||||
import { Transformer } from 'markmap-lib'
|
||||
|
||||
const props = defineProps({
|
||||
databaseId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 状态管理
|
||||
// ============================================================================
|
||||
|
||||
const loading = ref(false)
|
||||
const generating = ref(false)
|
||||
const mindmapData = ref(null)
|
||||
const mindmapSvg = ref(null)
|
||||
let markmapInstance = null
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 加载思维导图
|
||||
*/
|
||||
const loadMindmap = async () => {
|
||||
if (!props.databaseId) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await mindmapApi.getByDatabase(props.databaseId)
|
||||
|
||||
if (response.mindmap) {
|
||||
mindmapData.value = response.mindmap
|
||||
await nextTick()
|
||||
|
||||
// 延迟渲染,确保DOM完全更新
|
||||
setTimeout(() => {
|
||||
renderMindmap(response.mindmap)
|
||||
}, 100)
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果是404错误,说明还没有生成,静默处理
|
||||
if (error?.message?.includes('404') || error?.message?.includes('不存在') || error?.message?.includes('还没有生成')) {
|
||||
mindmapData.value = null
|
||||
} else {
|
||||
console.error('加载思维导图失败:', error)
|
||||
const errorMsg = error?.message || String(error)
|
||||
message.error('加载思维导图失败: ' + errorMsg)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成思维导图
|
||||
*/
|
||||
const generateMindmap = async () => {
|
||||
if (!props.databaseId) return
|
||||
|
||||
try {
|
||||
generating.value = true
|
||||
|
||||
const response = await mindmapApi.generateMindmap(
|
||||
props.databaseId,
|
||||
[], // 使用所有文件
|
||||
'' // 无自定义提示
|
||||
)
|
||||
|
||||
mindmapData.value = response.mindmap
|
||||
|
||||
// 等待DOM更新
|
||||
await nextTick()
|
||||
|
||||
// 再延迟一点,确保SVG元素完全渲染
|
||||
setTimeout(() => {
|
||||
renderMindmap(response.mindmap)
|
||||
message.success('思维导图生成成功!')
|
||||
}, 100)
|
||||
} catch (error) {
|
||||
console.error('生成思维导图失败:', error)
|
||||
const errorMsg = error?.message || String(error)
|
||||
message.error('生成失败: ' + errorMsg)
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新思维导图
|
||||
*/
|
||||
const refreshMindmap = async () => {
|
||||
await generateMindmap()
|
||||
}
|
||||
|
||||
/**
|
||||
* 将JSON转换为Markdown
|
||||
*/
|
||||
const jsonToMarkdown = (node, level = 0) => {
|
||||
if (!node || !node.content) return ''
|
||||
|
||||
const indent = '#'.repeat(level + 1)
|
||||
let markdown = `${indent} ${node.content}\n\n`
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
for (const child of node.children) {
|
||||
markdown += jsonToMarkdown(child, level + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return markdown
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染思维导图
|
||||
*/
|
||||
const renderMindmap = (data, retryCount = 0) => {
|
||||
if (!data) return
|
||||
|
||||
if (!mindmapSvg.value) {
|
||||
// 如果SVG引用还没准备好,最多重试3次
|
||||
if (retryCount < 3) {
|
||||
setTimeout(() => {
|
||||
renderMindmap(data, retryCount + 1)
|
||||
}, 100)
|
||||
return
|
||||
} else {
|
||||
console.error('无法获取SVG容器,渲染失败')
|
||||
message.error('渲染失败:无法找到SVG容器')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 清空之前的实例
|
||||
if (markmapInstance) {
|
||||
markmapInstance.destroy()
|
||||
}
|
||||
|
||||
// 将JSON转换为Markdown
|
||||
const markdown = jsonToMarkdown(data)
|
||||
|
||||
// 使用Transformer转换
|
||||
const transformer = new Transformer()
|
||||
const { root } = transformer.transform(markdown)
|
||||
|
||||
// 创建Markmap实例
|
||||
markmapInstance = Markmap.create(mindmapSvg.value, {
|
||||
duration: 300,
|
||||
maxWidth: 200,
|
||||
nodeMinHeight: 24,
|
||||
paddingX: 8,
|
||||
spacingVertical: 5,
|
||||
spacingHorizontal: 60
|
||||
})
|
||||
|
||||
markmapInstance.setData(root)
|
||||
markmapInstance.fit()
|
||||
|
||||
// 延迟再次适应,确保布局完全稳定
|
||||
setTimeout(() => {
|
||||
if (markmapInstance) {
|
||||
markmapInstance.fit()
|
||||
}
|
||||
}, 300)
|
||||
} catch (error) {
|
||||
console.error('渲染思维导图失败:', error)
|
||||
message.error('渲染失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 适应视图
|
||||
*/
|
||||
const fitView = () => {
|
||||
if (markmapInstance) {
|
||||
markmapInstance.fit()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暴露给父组件的方法
|
||||
*/
|
||||
defineExpose({
|
||||
refreshMindmap,
|
||||
generateMindmap
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// ============================================================================
|
||||
|
||||
// 监听数据库ID变化
|
||||
watch(() => props.databaseId, (newId) => {
|
||||
if (newId) {
|
||||
loadMindmap()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听容器大小变化,自动适应
|
||||
let resizeObserver = null
|
||||
|
||||
onMounted(() => {
|
||||
// 设置ResizeObserver监听容器大小变化
|
||||
nextTick(() => {
|
||||
if (mindmapSvg.value) {
|
||||
const container = mindmapSvg.value.parentElement
|
||||
if (container) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (markmapInstance) {
|
||||
markmapInstance.fit()
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
if (markmapInstance) {
|
||||
markmapInstance.destroy()
|
||||
}
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.mindmap-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
border-top: 1px solid var(--border-color);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 10px 16px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.section-content {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.generating-state,
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mindmap-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mindmap-toolbar {
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
.toolbar-text {
|
||||
margin-left: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-text) {
|
||||
padding: 4px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mindmap-svg-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.mindmap-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 150px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 确保父容器有高度
|
||||
:deep(.markmap) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -13,20 +13,48 @@
|
||||
@press-enter.prevent="onQuery"
|
||||
/>
|
||||
<div class="search-actions">
|
||||
<a-switch
|
||||
v-model:checked="showRawData"
|
||||
checked-children="格式化"
|
||||
un-checked-children="原始"
|
||||
/>
|
||||
<a-button
|
||||
@click="onQuery"
|
||||
:loading="searchLoading"
|
||||
class="search-button"
|
||||
type="primary"
|
||||
:disabled="!queryText.trim()"
|
||||
:icon=h(SearchOutlined)
|
||||
shape="circle"
|
||||
/>
|
||||
<div class="query-examples-compact">
|
||||
<span class="examples-label">示例:</span>
|
||||
<div class="examples-container">
|
||||
<!-- 加载中或生成中 -->
|
||||
<div v-if="loadingQuestions || generatingQuestions" class="loading-text">
|
||||
<a-spin size="small" />
|
||||
<span>{{ generatingQuestions ? 'AI生成中...' : '加载中...' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 示例轮播 -->
|
||||
<transition v-else-if="queryExamples.length > 0" name="fade" mode="out-in">
|
||||
<a-button
|
||||
type="text"
|
||||
:key="currentExampleIndex"
|
||||
@click="useQueryExample(queryExamples[currentExampleIndex])"
|
||||
size="small"
|
||||
class="example-btn"
|
||||
>
|
||||
{{ queryExamples[currentExampleIndex] }}
|
||||
</a-button>
|
||||
</transition>
|
||||
|
||||
<!-- 空状态 - 添加文件后会自动生成 -->
|
||||
<span v-else style="color: #999; font-size: 12px;">添加文件后自动生成</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
<a-switch
|
||||
v-model:checked="showRawData"
|
||||
checked-children="格式化"
|
||||
un-checked-children="原始"
|
||||
/>
|
||||
<a-button
|
||||
@click="onQuery"
|
||||
:loading="searchLoading"
|
||||
class="search-button"
|
||||
type="primary"
|
||||
:disabled="!queryText.trim()"
|
||||
:icon=h(SearchOutlined)
|
||||
shape="circle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -102,7 +130,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, h } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted, watch, h } from 'vue';
|
||||
import { useDatabaseStore } from '@/stores/database';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { queryApi } from '@/apis/knowledge_api';
|
||||
@ -123,7 +151,6 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const searchLoading = computed(() => store.state.searchLoading);
|
||||
const queryResult = ref('');
|
||||
const showRawData = ref(true);
|
||||
@ -131,6 +158,130 @@ const showRawData = ref(true);
|
||||
// 查询测试
|
||||
const queryText = ref('');
|
||||
|
||||
// 示例问题相关
|
||||
const queryExamples = ref([]);
|
||||
const currentExampleIndex = ref(0);
|
||||
const loadingQuestions = ref(false);
|
||||
const generatingQuestions = ref(false);
|
||||
|
||||
// 示例轮播相关
|
||||
let exampleCarouselInterval = null;
|
||||
|
||||
// 加载示例问题
|
||||
const loadSampleQuestions = async () => {
|
||||
if (!store.database?.db_id) return;
|
||||
|
||||
try {
|
||||
loadingQuestions.value = true;
|
||||
const data = await queryApi.getSampleQuestions(store.database.db_id);
|
||||
if (data.questions && data.questions.length > 0) {
|
||||
queryExamples.value = data.questions;
|
||||
} else {
|
||||
// 如果没有问题,清空列表
|
||||
queryExamples.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
// 404表示还没有生成问题,清空问题列表
|
||||
if (error.status === 404 || error?.message?.includes('404') || error?.message?.includes('还没有生成')) {
|
||||
queryExamples.value = [];
|
||||
} else {
|
||||
console.error('加载示例问题失败:', error);
|
||||
}
|
||||
} finally {
|
||||
loadingQuestions.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 清空问题列表
|
||||
const clearQuestions = () => {
|
||||
queryExamples.value = [];
|
||||
currentExampleIndex.value = 0;
|
||||
stopExampleCarousel();
|
||||
};
|
||||
|
||||
// 生成示例问题
|
||||
const generateSampleQuestions = async (silent = false) => {
|
||||
if (!store.database?.db_id) return;
|
||||
|
||||
try {
|
||||
generatingQuestions.value = true;
|
||||
const data = await queryApi.generateSampleQuestions(store.database.db_id, 10);
|
||||
if (data.questions && data.questions.length > 0) {
|
||||
queryExamples.value = data.questions;
|
||||
if (!silent) {
|
||||
message.success(`成功生成 ${data.questions.length} 个测试问题`);
|
||||
}
|
||||
// 开始轮播
|
||||
if (!exampleCarouselInterval) {
|
||||
startExampleCarousel();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成示例问题失败:', error);
|
||||
// 静默模式下不显示错误消息(自动生成时)
|
||||
if (!silent) {
|
||||
// 提取详细错误信息
|
||||
let errorMsg = '未知错误';
|
||||
if (error.response?.data?.detail) {
|
||||
errorMsg = error.response.data.detail;
|
||||
} else if (error.detail) {
|
||||
errorMsg = error.detail;
|
||||
} else if (error.message) {
|
||||
errorMsg = error.message;
|
||||
} else if (typeof error === 'string') {
|
||||
errorMsg = error;
|
||||
} else {
|
||||
errorMsg = JSON.stringify(error);
|
||||
}
|
||||
message.error('生成失败: ' + errorMsg);
|
||||
}
|
||||
} finally {
|
||||
generatingQuestions.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const useQueryExample = (example) => {
|
||||
queryText.value = example;
|
||||
onQuery();
|
||||
};
|
||||
|
||||
const startExampleCarousel = () => {
|
||||
if (exampleCarouselInterval) return;
|
||||
|
||||
exampleCarouselInterval = setInterval(() => {
|
||||
currentExampleIndex.value = (currentExampleIndex.value + 1) % queryExamples.value.length;
|
||||
}, 10000); // 每10秒切换一次
|
||||
};
|
||||
|
||||
const stopExampleCarousel = () => {
|
||||
if (exampleCarouselInterval) {
|
||||
clearInterval(exampleCarouselInterval);
|
||||
exampleCarouselInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听知识库ID变化,切换知识库时重新加载问题
|
||||
watch(
|
||||
() => store.database?.db_id,
|
||||
async (newDbId, oldDbId) => {
|
||||
// 如果知识库ID发生变化
|
||||
if (newDbId && newDbId !== oldDbId) {
|
||||
// 停止当前轮播
|
||||
stopExampleCarousel();
|
||||
// 清空当前问题列表
|
||||
queryExamples.value = [];
|
||||
currentExampleIndex.value = 0;
|
||||
// 重新加载新知识库的问题
|
||||
await loadSampleQuestions();
|
||||
// 如果有问题,启动轮播
|
||||
if (queryExamples.value.length > 0) {
|
||||
startExampleCarousel();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
|
||||
const onQuery = async () => {
|
||||
if (!queryText.value.trim()) {
|
||||
@ -156,10 +307,39 @@ const onQuery = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时加载查询参数
|
||||
onMounted(() => {
|
||||
// 组件挂载时启动示例轮播
|
||||
onMounted(async () => {
|
||||
// 加载查询参数
|
||||
store.loadQueryParams();
|
||||
|
||||
// 加载示例问题
|
||||
await loadSampleQuestions();
|
||||
|
||||
// 如果有示例问题,启动轮播
|
||||
if (queryExamples.value.length > 0) {
|
||||
startExampleCarousel();
|
||||
}
|
||||
// 不自动生成,只在创建知识库和添加文件时由 DataBaseInfoView 触发生成
|
||||
});
|
||||
|
||||
// 组件卸载时停止示例轮播
|
||||
onUnmounted(() => {
|
||||
// 停止示例轮播
|
||||
stopExampleCarousel();
|
||||
});
|
||||
|
||||
// 检查是否已有问题
|
||||
const hasQuestions = () => {
|
||||
return queryExamples.value.length > 0;
|
||||
};
|
||||
|
||||
// 暴露给父组件的方法和属性
|
||||
defineExpose({
|
||||
generateSampleQuestions,
|
||||
loadSampleQuestions,
|
||||
hasQuestions,
|
||||
clearQuestions,
|
||||
queryExamples
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -395,4 +575,50 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.query-examples-compact {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.examples-label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.examples-container {
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.example-btn {
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
height: auto;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -29,10 +29,18 @@
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="query" tab="检索测试">
|
||||
<QuerySection
|
||||
ref="querySectionRef"
|
||||
:visible="true"
|
||||
@toggle-visible="() => {}"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="mindmap" tab="知识导图">
|
||||
<MindMapSection
|
||||
v-if="databaseId"
|
||||
:database-id="databaseId"
|
||||
ref="mindmapSectionRef"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="config" tab="检索配置">
|
||||
<SearchConfigTab :database-id="databaseId" />
|
||||
</a-tab-pane>
|
||||
@ -53,6 +61,7 @@ import FileUploadModal from '@/components/FileUploadModal.vue';
|
||||
import KnowledgeGraphSection from '@/components/KnowledgeGraphSection.vue';
|
||||
import QuerySection from '@/components/QuerySection.vue';
|
||||
import SearchConfigTab from '@/components/SearchConfigTab.vue';
|
||||
import MindMapSection from '@/components/MindMapSection.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const store = useDatabaseStore();
|
||||
@ -69,6 +78,12 @@ const isGraphSupported = computed(() => {
|
||||
// Tab 切换逻辑 - 智能默认
|
||||
const activeTab = ref('query');
|
||||
|
||||
// 思维导图引用
|
||||
const mindmapSectionRef = ref(null);
|
||||
|
||||
// 查询区域引用
|
||||
const querySectionRef = ref(null);
|
||||
|
||||
|
||||
const resetGraphStats = () => {
|
||||
store.graphStats = {
|
||||
@ -122,6 +137,9 @@ const resizeHandle = ref(null);
|
||||
// 添加文件弹窗
|
||||
const addFilesModalVisible = ref(false);
|
||||
|
||||
// 标记是否是初次加载
|
||||
const isInitialLoad = ref(true);
|
||||
|
||||
// 显示添加文件弹窗
|
||||
const showAddFilesModal = () => {
|
||||
addFilesModalVisible.value = true;
|
||||
@ -134,7 +152,10 @@ const resetFileSelectionState = () => {
|
||||
store.state.fileDetailModalVisible = false;
|
||||
};
|
||||
|
||||
watch(() => route.params.database_id, async (newId) => {
|
||||
watch(() => route.params.database_id, async (newId, oldId) => {
|
||||
// 切换知识库时,标记为初次加载
|
||||
isInitialLoad.value = true;
|
||||
|
||||
store.databaseId = newId;
|
||||
resetFileSelectionState();
|
||||
resetGraphStats();
|
||||
@ -145,6 +166,84 @@ watch(() => route.params.database_id, async (newId) => {
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听文件列表变化,自动更新思维导图和生成示例问题
|
||||
const previousFileCount = ref(0);
|
||||
|
||||
watch(
|
||||
() => database.value?.files,
|
||||
(newFiles, oldFiles) => {
|
||||
if (!newFiles) return;
|
||||
|
||||
const newFileCount = Object.keys(newFiles).length;
|
||||
const oldFileCount = previousFileCount.value;
|
||||
|
||||
// 首次加载时,只更新计数,不触发任何操作
|
||||
if (isInitialLoad.value) {
|
||||
previousFileCount.value = newFileCount;
|
||||
isInitialLoad.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果文件数量发生变化(增加或减少),都重新生成问题和思维导图
|
||||
if (newFileCount !== oldFileCount) {
|
||||
const changeType = newFileCount > oldFileCount ? '增加' : '减少';
|
||||
console.log(`文件数量从 ${oldFileCount} ${changeType}到 ${newFileCount},准备重新生成问题和思维导图`);
|
||||
|
||||
// 只要有文件,就重新生成思维导图(无论增加还是减少)
|
||||
if (newFileCount > 0) {
|
||||
setTimeout(() => {
|
||||
if (mindmapSectionRef.value) {
|
||||
if (oldFileCount === 0) {
|
||||
// 首次添加文件,生成思维导图
|
||||
console.log('首次添加文件,生成思维导图');
|
||||
mindmapSectionRef.value.generateMindmap();
|
||||
} else {
|
||||
// 文件数量变化(增加或减少),重新生成思维导图
|
||||
console.log(`文件数量变化,重新生成思维导图`);
|
||||
mindmapSectionRef.value.refreshMindmap();
|
||||
}
|
||||
}
|
||||
}, 2000); // 等待2秒让后端处理完成
|
||||
} else {
|
||||
// 如果文件数量变为0,清空思维导图(如果需要的话)
|
||||
console.log('文件数量为0,思维导图将自动清空');
|
||||
}
|
||||
|
||||
// 只要有文件,就重新生成问题(无论之前是否有问题)
|
||||
if (newFileCount > 0) {
|
||||
setTimeout(async () => {
|
||||
console.log('文件数量变化,检查是否需要生成问题,querySectionRef:', querySectionRef.value);
|
||||
if (querySectionRef.value) {
|
||||
console.log('开始重新生成问题...');
|
||||
await querySectionRef.value.generateSampleQuestions(true);
|
||||
} else {
|
||||
console.warn('querySectionRef 未准备好,稍后重试');
|
||||
// 如果组件还没准备好,再等一会儿
|
||||
setTimeout(async () => {
|
||||
if (querySectionRef.value) {
|
||||
console.log('延迟后开始生成问题...');
|
||||
await querySectionRef.value.generateSampleQuestions(true);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}, 3000); // 等待3秒让后端处理完成
|
||||
} else {
|
||||
// 如果文件数量变为0,清空问题列表
|
||||
console.log('文件数量为0,清空问题列表');
|
||||
setTimeout(() => {
|
||||
if (querySectionRef.value) {
|
||||
// 清空问题列表
|
||||
querySectionRef.value.clearQuestions();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
previousFileCount.value = newFileCount;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 组件挂载时启动示例轮播
|
||||
onMounted(() => {
|
||||
store.databaseId = route.params.database_id;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user