diff --git a/backend/main.py b/backend/main.py index fce6903..d7c7184 100644 --- a/backend/main.py +++ b/backend/main.py @@ -128,6 +128,17 @@ async def startup_event(): except Exception as e: print(f"启动知识库服务失败: {e}", file=sys.stderr) + # 确保系统知识库与目录同步 + try: + from src.core.database import get_db + from src.services.knowledge_base_service import KnowledgeBaseService + db = next(get_db()) + kb_service = KnowledgeBaseService(db) + kb_service.ensure_system_knowledge_bases() + db.close() + except Exception as e: + print(f"同步系统知识库失败: {e}", file=sys.stderr) + # 初始化论坛分类 try: seed_forum_categories() diff --git a/backend/src/api/auth.py b/backend/src/api/auth.py index 2876440..c5cdd40 100644 --- a/backend/src/api/auth.py +++ b/backend/src/api/auth.py @@ -53,6 +53,7 @@ class UserResponse(BaseModel): email: str full_name: Optional[str] is_active: bool + is_superuser: bool = False created_at: str @@ -81,6 +82,7 @@ async def register(user_data: UserCreate, db: Session = Depends(get_db)): email=user.email, full_name=user.full_name, is_active=user.is_active, + is_superuser=user.is_superuser, created_at=user.created_at.isoformat() ) @@ -127,6 +129,7 @@ async def get_current_user_info(current_user: str = Depends(get_current_user), d email=user.email, full_name=user.full_name, is_active=user.is_active, + is_superuser=user.is_superuser, created_at=user.created_at.isoformat() ) @@ -172,6 +175,7 @@ async def update_user_info( email=user.email, full_name=user.full_name, is_active=user.is_active, + is_superuser=user.is_superuser, created_at=user.created_at.isoformat() ) diff --git a/backend/src/api/knowledge_base.py b/backend/src/api/knowledge_base.py index 86eef1f..ecb1895 100644 --- a/backend/src/api/knowledge_base.py +++ b/backend/src/api/knowledge_base.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import List, Optional, Dict, Any from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from sqlalchemy.orm import Session -from sqlalchemy import or_ +from sqlalchemy import or_, and_ from pydantic import BaseModel from ..core.database import get_db @@ -130,26 +130,36 @@ async def create_knowledge_base( # 检查知识库名称是否已存在 existing_kb = db.query(KnowledgeBase).filter( - KnowledgeBase.name == data.name, - KnowledgeBase.user_id == user.id + KnowledgeBase.name == data.name ).first() - + if existing_kb: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="知识库名称已存在" ) - + + # 管理员创建的知识库标记为系统知识库 + is_system = user.is_superuser + # 创建知识库 knowledge_base = KnowledgeBase( name=data.name, description=data.description, - user_id=user.id + user_id=user.id, + is_system=is_system ) - + db.add(knowledge_base) db.commit() db.refresh(knowledge_base) + + # 在对应目录下创建文件夹 + if is_system: + kb_dir = Path(settings.knowledge_base_dir) / data.name + else: + kb_dir = Path(settings.upload_dir) / user.username / "knowledge_bases" / data.name + kb_dir.mkdir(parents=True, exist_ok=True) return KnowledgeBaseResponse( id=knowledge_base.id, @@ -260,18 +270,21 @@ async def update_knowledge_base( detail="用户不存在" ) - # 获取知识库 + # 获取知识库(用户自己的,或admin操作系统知识库) knowledge_base = db.query(KnowledgeBase).filter( KnowledgeBase.id == knowledge_base_id, - KnowledgeBase.user_id == user.id + or_( + KnowledgeBase.user_id == user.id, + and_(KnowledgeBase.is_system == True, user.is_superuser == True) + ) ).first() - + if not knowledge_base: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在" ) - + # 更新字段 if data.name is not None: # 检查新名称是否已存在 @@ -335,18 +348,21 @@ async def delete_knowledge_base( detail="用户不存在" ) - # 获取知识库 + # 获取知识库(用户自己的,或admin操作系统知识库) knowledge_base = db.query(KnowledgeBase).filter( KnowledgeBase.id == knowledge_base_id, - KnowledgeBase.user_id == user.id + or_( + KnowledgeBase.user_id == user.id, + and_(KnowledgeBase.is_system == True, user.is_superuser == True) + ) ).first() - + if not knowledge_base: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在" ) - + # 1. 获取知识库下的所有文档 documents = db.query(Document).filter( Document.knowledge_base_id == knowledge_base_id @@ -455,32 +471,42 @@ async def upload_document_to_knowledge_base( detail="知识库不存在" ) - # 检查是否为系统知识库,系统知识库不允许任何用户上传文档 + # 检查知识库权限 if knowledge_base.is_system: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="系统知识库不允许上传文档,请使用您自己的知识库" - ) - - # 验证知识库属于当前用户(非系统知识库必须属于用户) - if knowledge_base.user_id != user.id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="您没有权限向此知识库上传文档" - ) - + # 系统知识库:仅管理员可上传 + if not user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="系统知识库仅管理员可上传文档" + ) + else: + # 用户知识库:必须属于当前用户 + if knowledge_base.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="您没有权限向此知识库上传文档" + ) + # 生成唯一文件名 file_id = str(uuid.uuid4()) filename = f"{file_id}{file_extension}" - - # 保存文件 - upload_dir = Path(settings.upload_dir) - upload_dir.mkdir(parents=True, exist_ok=True) - file_path = upload_dir / filename - + + # 根据知识库类型选择保存路径 + if knowledge_base.is_system: + # 系统知识库:保存到 knowledge_base_dir/{kb_name}/ + save_dir = Path(settings.knowledge_base_dir) / knowledge_base.name + source_type = "knowledge_base" + else: + # 用户知识库:保存到 uploads/{username}/ + save_dir = Path(settings.upload_dir) / user.username + source_type = "upload" + + save_dir.mkdir(parents=True, exist_ok=True) + file_path = save_dir / filename + with open(file_path, "wb") as f: f.write(content) - + # 创建文档记录 document = Document( user_id=user.id, @@ -492,7 +518,8 @@ async def upload_document_to_knowledge_base( file_type=file_extension, title=title or Path(file.filename).stem, description=description, - is_processed=False + is_processed=False, + source_type=source_type ) db.add(document) @@ -549,10 +576,13 @@ async def get_knowledge_base_documents( detail="用户不存在" ) - # 验证知识库存在且属于用户 + # 验证知识库存在(用户自己的,或系统知识库) knowledge_base = db.query(KnowledgeBase).filter( KnowledgeBase.id == knowledge_base_id, - KnowledgeBase.user_id == user.id + or_( + KnowledgeBase.user_id == user.id, + KnowledgeBase.is_system == True + ) ).first() if not knowledge_base: diff --git a/backend/src/services/file_watcher_service.py b/backend/src/services/file_watcher_service.py index 1dd32b8..8f5c300 100644 --- a/backend/src/services/file_watcher_service.py +++ b/backend/src/services/file_watcher_service.py @@ -132,6 +132,10 @@ class FileWatcherService: print(f"文件监控服务已启动,监控目录: {self.knowledge_base_dir}") + # 确保子目录对应的系统知识库存在 + print("确保系统知识库与目录同步...") + kb_service.ensure_system_knowledge_bases() + # 执行初始扫描 print("执行初始知识库扫描...") scan_result = kb_service.scan_directory() diff --git a/backend/src/services/knowledge_base_service.py b/backend/src/services/knowledge_base_service.py index 0c6bb06..b4adc08 100644 --- a/backend/src/services/knowledge_base_service.py +++ b/backend/src/services/knowledge_base_service.py @@ -10,6 +10,8 @@ from sqlalchemy.orm import Session from sqlalchemy import and_ from ..models.document import Document, DocumentChunk +from ..models.knowledge_base import KnowledgeBase +from ..models.user import User from ..core.config import get_settings from .document_service import DocumentService @@ -30,10 +32,13 @@ class KnowledgeBaseService: directory = self.knowledge_base_dir else: directory = Path(directory) - + if not directory.exists(): return {"success": False, "message": f"目录不存在: {directory}"} - + + # 先确保每个子目录都有对应的系统知识库 + self.ensure_system_knowledge_bases() + results = { "scanned_files": 0, "new_files": 0, @@ -41,14 +46,14 @@ class KnowledgeBaseService: "skipped_files": 0, "errors": [] } - + # 递归扫描目录 for file_path in directory.rglob("*"): if file_path.is_file() and self._is_supported_file(file_path): try: result = self.process_file(str(file_path)) results["scanned_files"] += 1 - + if result["status"] == "new": results["new_files"] += 1 elif result["status"] == "updated": @@ -60,16 +65,82 @@ class KnowledgeBaseService: "file": str(file_path), "error": result["error"] }) - + except Exception as e: results["errors"].append({ "file": str(file_path), "error": str(e) }) - + results["success"] = len(results["errors"]) == 0 return results - + + def ensure_system_knowledge_bases(self): + """确保 knowledge_base_dir 下每个子目录都有对应的系统知识库""" + if not self.knowledge_base_dir.exists(): + return + + for subdir in self.knowledge_base_dir.iterdir(): + if subdir.is_dir(): + self._get_or_create_system_kb(subdir.name) + + def _get_or_create_system_kb(self, name: str) -> int: + """按名称查找或创建系统知识库,返回 knowledge_base_id""" + kb = self.db.query(KnowledgeBase).filter( + KnowledgeBase.name == name, + KnowledgeBase.is_system == True + ).first() + + if kb: + return kb.id + + # 检查是否已有同名非系统KB,如有则升级为系统KB + existing = self.db.query(KnowledgeBase).filter( + KnowledgeBase.name == name, + KnowledgeBase.is_system == False + ).first() + if existing: + existing.is_system = True + admin = self.db.query(User).filter(User.is_superuser == True).first() + if admin: + existing.user_id = admin.id + self.db.commit() + self.db.refresh(existing) + print(f"[KB] 升级为系统知识库: {name} (id={existing.id})") + return existing.id + + # 找到 admin 用户(或任意 superuser)作为 owner + admin = self.db.query(User).filter(User.is_superuser == True).first() + if not admin: + admin = self.db.query(User).first() + + kb = KnowledgeBase( + name=name, + description=f"系统知识库:{name}", + user_id=admin.id if admin else 1, + is_system=True + ) + self.db.add(kb) + self.db.commit() + self.db.refresh(kb) + print(f"[KB] 自动创建系统知识库: {name} (id={kb.id})") + return kb.id + + def _resolve_knowledge_base(self, file_path: Path) -> Optional[int]: + """从文件路径解析对应的系统知识库 ID + + data/knowledge_base/国土空间规划文献/paper.pdf → 知识库 "国土空间规划文献" + """ + try: + relative = file_path.relative_to(self.knowledge_base_dir) + parts = relative.parts + if len(parts) >= 2: + subdir_name = parts[0] + return self._get_or_create_system_kb(subdir_name) + except ValueError: + pass + return None + def process_file(self, file_path: str) -> Dict[str, Any]: """处理单个文件(检查、提取、入库)""" try: @@ -101,16 +172,25 @@ class KnowledgeBaseService: if existing_doc: # 检查是否需要更新 - if (existing_doc.last_modified and + if (existing_doc.last_modified and existing_doc.last_modified >= last_modified and existing_doc.file_hash == file_hash): return {"status": "skipped", "message": "文件未修改"} - + + # 如果文档没有关联知识库,尝试关联 + if existing_doc.knowledge_base_id is None: + kb_id = self._resolve_knowledge_base(file_path) + if kb_id: + existing_doc.knowledge_base_id = kb_id + self.db.commit() + # 更新现有文档 return self._update_document(existing_doc, file_path, file_size, last_modified, file_hash) else: + # 解析知识库 ID + kb_id = self._resolve_knowledge_base(file_path) # 创建新文档 - return self._create_document(file_path, file_size, last_modified, file_hash) + return self._create_document(file_path, file_size, last_modified, file_hash, knowledge_base_id=kb_id) except Exception as e: return {"status": "error", "error": str(e)} diff --git a/web/src/app/(main)/knowledge/page.tsx b/web/src/app/(main)/knowledge/page.tsx index 7a9e16d..60a0045 100644 --- a/web/src/app/(main)/knowledge/page.tsx +++ b/web/src/app/(main)/knowledge/page.tsx @@ -30,7 +30,7 @@ import { KnowledgeBase } from "@/types"; export default function KnowledgePage() { const router = useRouter(); - const { isAuthenticated, isLoading: authLoading } = useAuthStore(); + const { isAuthenticated, isLoading: authLoading, user } = useAuthStore(); const [knowledgeBases, setKnowledgeBases] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); @@ -58,9 +58,7 @@ export default function KnowledgePage() { setIsLoading(true); setError(null); const bases = await knowledgeBaseAPI.getKnowledgeBases(); - // 过滤掉系统知识库,只显示用户创建的知识库 - const userBases = bases.filter(kb => !kb.is_system); - setKnowledgeBases(userBases); + setKnowledgeBases(bases); } catch (err) { console.error("加载知识库失败:", err); setError("加载知识库失败"); @@ -114,6 +112,10 @@ export default function KnowledgePage() { (kb.description && kb.description.toLowerCase().includes(searchQuery.toLowerCase())) ); + const isAdmin = user?.is_superuser === true; + const systemKBs = isAdmin ? filteredKnowledgeBases.filter(kb => kb.is_system) : []; + const userKBs = filteredKnowledgeBases.filter(kb => !kb.is_system); + if (authLoading || isLoading) { return (
@@ -213,13 +215,80 @@ export default function KnowledgePage() { />
- 共 {filteredKnowledgeBases.length} 个知识库 + 共 {userKBs.length} 个知识库{systemKBs.length > 0 ? `,${systemKBs.length} 个系统知识库` : ""}
- {/* 知识库列表 */} - {filteredKnowledgeBases.length === 0 ? ( + {/* 系统知识库 */} + {systemKBs.length > 0 && ( +
+
+ +

系统知识库

+ ({systemKBs.length}) +
+
+ {systemKBs.map((kb) => ( + + +
+
+ +
+ {kb.name} + + {kb.description || "系统知识库"} + +
+
+
+ {kb.document_count > 0 ? ( + + ) : ( + + )} +
+
+
+ +
+
+ 文档数量 + {kb.document_count} 个文档 +
+
+ 创建时间 + {formatDate(kb.created_at)} +
+
+ 状态 + 0 ? "text-green-600" : "text-gray-500"}> + {kb.document_count > 0 ? "已就绪" : "空知识库"} + +
+
+ +
+ +
+
+
+ ))} +
+
+ )} + + {/* 用户知识库 */} + {userKBs.length === 0 && systemKBs.length === 0 ? ( @@ -238,70 +307,81 @@ export default function KnowledgePage() { ) : ( -
- {filteredKnowledgeBases.map((kb) => ( - - -
-
- -
- {kb.name} - - {kb.description || "暂无描述"} - + userKBs.length > 0 && ( +
+ {systemKBs.length > 0 && ( +
+ +

我的知识库

+ ({userKBs.length}) +
+ )} +
+ {userKBs.map((kb) => ( + + +
+
+ +
+ {kb.name} + + {kb.description || "暂无描述"} + +
+
+
+ {kb.document_count > 0 ? ( + + ) : ( + + )} +
-
-
- {kb.document_count > 0 ? ( - - ) : ( - - )} -
-
- - -
-
- 文档数量 - {kb.document_count} 个文档 -
-
- 创建时间 - {formatDate(kb.created_at)} -
-
- 状态 - 0 ? "text-green-600" : "text-gray-500"}> - {kb.document_count > 0 ? "已就绪" : "空知识库"} - -
-
- -
- - -
-
- - ))} -
+ + +
+
+ 文档数量 + {kb.document_count} 个文档 +
+
+ 创建时间 + {formatDate(kb.created_at)} +
+
+ 状态 + 0 ? "text-green-600" : "text-gray-500"}> + {kb.document_count > 0 ? "已就绪" : "空知识库"} + +
+
+ +
+ + +
+
+ + ))} +
+
+ ) )}
diff --git a/web/src/components/chat/export-dialog.tsx b/web/src/components/chat/export-dialog.tsx index 3e25c57..d312d05 100644 --- a/web/src/components/chat/export-dialog.tsx +++ b/web/src/components/chat/export-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Download, FileText, FileJson, File } from "lucide-react"; +import { Download, FileText, FileJson } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -61,12 +61,6 @@ export default function ExportDialog({ sessionId, sessionTitle, children, onClos description: "可读性好的文本格式,适合分享", icon: FileText, }, - { - value: "pdf", - label: "PDF 格式", - description: "适合打印和正式文档", - icon: File, - }, ]; return ( diff --git a/web/src/components/chat/message-item.tsx b/web/src/components/chat/message-item.tsx index fb48cb5..8200eed 100644 --- a/web/src/components/chat/message-item.tsx +++ b/web/src/components/chat/message-item.tsx @@ -11,7 +11,7 @@ import { format } from "date-fns"; import { Button } from "@/components/ui/button"; import { useChatStore } from "@/store/chat"; import SourceReferences from "./source-references"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { toast } from "sonner"; interface MessageItemProps { @@ -21,6 +21,7 @@ interface MessageItemProps { const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[]; isStreaming?: boolean }) => { const [expanded, setExpanded] = useState(false); + if (!thinking || thinking.length === 0) return null; // 只提取有实际内容的步骤:推理内容和检索文档 @@ -32,6 +33,11 @@ const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[]; // 没有推理内容也没有文档详情时不显示 if (!hasReasoning && !hasDocs && !isStreaming) return null; + // 流式生成时自动展开,完成后自动收起 + useEffect(() => { + setExpanded(!!isStreaming); + }, [isStreaming]); + // 合并推理文本 const reasoningText = reasoningSteps.map(s => s.message).join(''); diff --git a/web/src/components/chat/sidebar.tsx b/web/src/components/chat/sidebar.tsx index 9fcdc6f..4e15ede 100644 --- a/web/src/components/chat/sidebar.tsx +++ b/web/src/components/chat/sidebar.tsx @@ -13,6 +13,7 @@ import { Download, Search, MoreVertical, + PanelLeftClose, } from "lucide-react"; import { DropdownMenu, @@ -121,9 +122,13 @@ export default function Sidebar({ onClose }: SidebarProps) {
对话 - +
diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 2eb9707..1269fa5 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -5,6 +5,7 @@ export interface User { email: string; full_name?: string; is_active: boolean; + is_superuser?: boolean; created_at: string; }