From 14bcb29d5b07fa17ca724cf03b20504c31ab9585 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sat, 15 Nov 2025 12:18:31 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=20zip=20=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=20markdown=20=E5=85=A5=E5=BA=93=E4=BB=A5=E5=8F=8A=E5=85=B6?= =?UTF-8?q?=E4=BB=96=E9=A3=8E=E6=A0=BC=E4=BC=98=E5=8C=96=20(#336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 更新日志 * refactor(docs): 重构 zip 格式的支持逻辑 --------- Co-authored-by: lihuan-coder <1120083712@qq.com> --- LICENSE | 2 +- docs/latest/advanced/document-processing.md | 20 + docs/latest/changelog/roadmap.md | 5 + server/routers/__init__.py | 2 + server/routers/knowledge_router.py | 201 +++++++++- server/routers/mindmap_router.py | 401 +++++++++++++++++++ src/agents/common/tools.py | 58 ++- src/knowledge/implementations/chroma.py | 5 + src/knowledge/implementations/lightrag.py | 5 + src/knowledge/implementations/milvus.py | 5 + src/knowledge/indexing.py | 199 +++++++++- src/storage/minio/client.py | 2 +- web/package.json | 2 + web/src/apis/index.js | 1 + web/src/apis/knowledge_api.js | 57 ++- web/src/apis/mindmap_api.js | 66 ++++ web/src/components/FileUploadModal.vue | 80 +++- web/src/components/MindMapSection.vue | 415 ++++++++++++++++++++ web/src/components/QuerySection.vue | 262 +++++++++++- web/src/views/DataBaseInfoView.vue | 101 ++++- 20 files changed, 1849 insertions(+), 40 deletions(-) create mode 100644 server/routers/mindmap_router.py create mode 100644 web/src/apis/mindmap_api.js create mode 100644 web/src/components/MindMapSection.vue diff --git a/LICENSE b/LICENSE index b5064ccb..ba26789d 100644 --- a/LICENSE +++ b/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 diff --git a/docs/latest/advanced/document-processing.md b/docs/latest/advanced/document-processing.md index 5f8c2307..2bf27eb2 100644 --- a/docs/latest/advanced/document-processing.md +++ b/docs/latest/advanced/document-processing.md @@ -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) diff --git a/docs/latest/changelog/roadmap.md b/docs/latest/changelog/roadmap.md index 06acc03c..3b305cf3 100644 --- a/docs/latest/changelog/roadmap.md +++ b/docs/latest/changelog/roadmap.md @@ -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)) ### 修复 - 修复重排序模型实际未生效的问题 diff --git a/server/routers/__init__.py b/server/routers/__init__.py index 987853ae..9698550e 100644 --- a/server/routers/__init__.py +++ b/server/routers/__init__.py @@ -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/* diff --git a/server/routers/knowledge_router.py b/server/routers/knowledge_router.py index fc46d2e3..2cbe2b5a 100644 --- a/server/routers/knowledge_router.py +++ b/server/routers/knowledge_router.py @@ -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则使用默认路径 diff --git a/server/routers/mindmap_router.py b/server/routers/mindmap_router.py new file mode 100644 index 00000000..78c8e9fe --- /dev/null +++ b/server/routers/mindmap_router.py @@ -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)}") diff --git a/src/agents/common/tools.py b/src/agents/common/tools.py index 79bfb52b..05bd0b4a 100644 --- a/src/agents/common/tools.py +++ b/src/agents/common/tools.py @@ -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'" ) # 使用工厂函数创建检索器包装函数,避免闭包问题 diff --git a/src/knowledge/implementations/chroma.py b/src/knowledge/implementations/chroma.py index 7350510e..8f9078ee 100644 --- a/src/knowledge/implementations/chroma.py +++ b/src/knowledge/implementations/chroma.py @@ -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) diff --git a/src/knowledge/implementations/lightrag.py b/src/knowledge/implementations/lightrag.py index 313a2a4d..fb4d8e09 100644 --- a/src/knowledge/implementations/lightrag.py +++ b/src/knowledge/implementations/lightrag.py @@ -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) diff --git a/src/knowledge/implementations/milvus.py b/src/knowledge/implementations/milvus.py index cae06236..549f73ce 100644 --- a/src/knowledge/implementations/milvus.py +++ b/src/knowledge/implementations/milvus.py @@ -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: diff --git a/src/knowledge/indexing.py b/src/knowledge/indexing.py index 309b39a5..7e792d22 100644 --- a/src/knowledge/indexing.py +++ b/src/knowledge/indexing.py @@ -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格式 diff --git a/src/storage/minio/client.py b/src/storage/minio/client.py index fbc221c6..2496a555 100644 --- a/src/storage/minio/client.py +++ b/src/storage/minio/client.py @@ -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 客户端""" diff --git a/web/package.json b/web/package.json index b5c343b7..128aad20 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/apis/index.js b/web/src/apis/index.js index 259ec55d..da9eaa8e 100644 --- a/web/src/apis/index.js +++ b/web/src/apis/index.js @@ -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, diff --git a/web/src/apis/knowledge_api.js b/web/src/apis/knowledge_api.js index 787c5fdc..828c99ad 100644 --- a/web/src/apis/knowledge_api.js +++ b/web/src/apis/knowledge_api.js @@ -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 + }) } } diff --git a/web/src/apis/mindmap_api.js b/web/src/apis/mindmap_api.js new file mode 100644 index 00000000..4615485c --- /dev/null +++ b/web/src/apis/mindmap_api.js @@ -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} 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 + }) + } +} + diff --git a/web/src/components/FileUploadModal.vue b/web/src/components/FileUploadModal.vue index 61b43a8b..b5c79d64 100644 --- a/web/src/components/FileUploadModal.vue +++ b/web/src/components/FileUploadModal.vue @@ -95,11 +95,16 @@

支持的文件类型:{{ uploadHint }}

+
+ 📦 zip 包会自动提取 Markdown 文件和图片,图片链接将替换为可访问的 URL +
+ + -
+
{ 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); +} diff --git a/web/src/components/MindMapSection.vue b/web/src/components/MindMapSection.vue new file mode 100644 index 00000000..56e7ecda --- /dev/null +++ b/web/src/components/MindMapSection.vue @@ -0,0 +1,415 @@ + + + + + + diff --git a/web/src/components/QuerySection.vue b/web/src/components/QuerySection.vue index f317ab25..aaeaf4b8 100644 --- a/web/src/components/QuerySection.vue +++ b/web/src/components/QuerySection.vue @@ -13,20 +13,48 @@ @press-enter.prevent="onQuery" />
- - +
+ 示例: +
+ +
+ + {{ generatingQuestions ? 'AI生成中...' : '加载中...' }} +
+ + + + + {{ queryExamples[currentExampleIndex] }} + + + + + 添加文件后自动生成 +
+
+
+ + +
@@ -102,7 +130,7 @@ @@ -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; +} + diff --git a/web/src/views/DataBaseInfoView.vue b/web/src/views/DataBaseInfoView.vue index 55887bb7..957ccd14 100644 --- a/web/src/views/DataBaseInfoView.vue +++ b/web/src/views/DataBaseInfoView.vue @@ -29,10 +29,18 @@ + + + @@ -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;