重构 zip 格式 markdown 入库以及其他风格优化 (#336)

* 更新日志


* refactor(docs): 重构 zip 格式的支持逻辑

---------

Co-authored-by: lihuan-coder <1120083712@qq.com>
This commit is contained in:
Wenjie Zhang 2025-11-15 12:18:31 +08:00 committed by GitHub
parent 0d20cf66db
commit 14bcb29d5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1849 additions and 40 deletions

View File

@ -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

View File

@ -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)

View File

@ -12,11 +12,13 @@
- 集成 LangFuse (观望) 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
- 集成 neo4j mcp (或者自己构建工具)
- 工具组件重构以支持 todofiles 等渲染。
- 文档解析部分的 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)
### 修复
- 修复重排序模型实际未生效的问题

View File

@ -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/*

View File

@ -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则使用默认路径

View 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)}")

View File

@ -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'"
)
# 使用工厂函数创建检索器包装函数,避免闭包问题

View File

@ -198,6 +198,11 @@ class ChromaKB(KnowledgeBase):
self._add_to_processing_queue(file_id)
try:
# 确保params中包含db_idZIP文件处理需要
if params is None:
params = {}
params["db_id"] = db_id
# 根据内容类型处理内容
if content_type == "file":
markdown_content = await process_file_to_markdown(item, params=params)

View File

@ -237,6 +237,11 @@ class LightRagKB(KnowledgeBase):
self._add_to_processing_queue(file_id)
try:
# 确保params中包含db_idZIP文件处理需要
if params is None:
params = {}
params["db_id"] = db_id
# 根据内容类型处理内容
if content_type == "file":
markdown_content = await process_file_to_markdown(item, params=params)

View File

@ -242,6 +242,11 @@ class MilvusKB(KnowledgeBase):
self._add_to_processing_queue(file_id)
try:
# 确保params中包含db_idZIP文件处理需要
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:

View File

@ -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"![{alt_text}]({url})"
# 尝试文件名匹配
filename = os.path.basename(img_path)
if filename in image_map:
return f"![{alt_text}]({image_map[filename]})"
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格式

View File

@ -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 客户端"""

View File

@ -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",

View File

@ -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,

View File

@ -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
})
}
}

View 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
})
}
}

View File

@ -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>

View 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) {
// SVG3
if (retryCount < 3) {
setTimeout(() => {
renderMindmap(data, retryCount + 1)
}, 100)
return
} else {
console.error('无法获取SVG容器渲染失败')
message.error('渲染失败无法找到SVG容器')
return
}
}
try {
//
if (markmapInstance) {
markmapInstance.destroy()
}
// JSONMarkdown
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>

View File

@ -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>

View File

@ -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;