feat: system knowledge base management with admin/user role separation

- Auto-create system KBs from data/knowledge_base/ subdirectories on startup
- Upgrade existing user KBs to system KBs when matching directory found
- Admin can upload to system KBs (saved to knowledge_base dir)
- Regular users see only their own KBs on knowledge page
- Fix API permission checks for system KBs (list, detail, documents, update, delete)
- Add is_superuser to UserResponse and frontend User type
- Fix sidebar session menu visibility for long titles

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 16:42:28 +08:00
parent 1ce689ad1e
commit 38865b0f7d
10 changed files with 344 additions and 129 deletions
+11
View File
@@ -128,6 +128,17 @@ async def startup_event():
except Exception as e: except Exception as e:
print(f"启动知识库服务失败: {e}", file=sys.stderr) 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: try:
seed_forum_categories() seed_forum_categories()
+4
View File
@@ -53,6 +53,7 @@ class UserResponse(BaseModel):
email: str email: str
full_name: Optional[str] full_name: Optional[str]
is_active: bool is_active: bool
is_superuser: bool = False
created_at: str created_at: str
@@ -81,6 +82,7 @@ async def register(user_data: UserCreate, db: Session = Depends(get_db)):
email=user.email, email=user.email,
full_name=user.full_name, full_name=user.full_name,
is_active=user.is_active, is_active=user.is_active,
is_superuser=user.is_superuser,
created_at=user.created_at.isoformat() 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, email=user.email,
full_name=user.full_name, full_name=user.full_name,
is_active=user.is_active, is_active=user.is_active,
is_superuser=user.is_superuser,
created_at=user.created_at.isoformat() created_at=user.created_at.isoformat()
) )
@@ -172,6 +175,7 @@ async def update_user_info(
email=user.email, email=user.email,
full_name=user.full_name, full_name=user.full_name,
is_active=user.is_active, is_active=user.is_active,
is_superuser=user.is_superuser,
created_at=user.created_at.isoformat() created_at=user.created_at.isoformat()
) )
+68 -38
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_ from sqlalchemy import or_, and_
from pydantic import BaseModel from pydantic import BaseModel
from ..core.database import get_db from ..core.database import get_db
@@ -130,26 +130,36 @@ async def create_knowledge_base(
# 检查知识库名称是否已存在 # 检查知识库名称是否已存在
existing_kb = db.query(KnowledgeBase).filter( existing_kb = db.query(KnowledgeBase).filter(
KnowledgeBase.name == data.name, KnowledgeBase.name == data.name
KnowledgeBase.user_id == user.id
).first() ).first()
if existing_kb: if existing_kb:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="知识库名称已存在" detail="知识库名称已存在"
) )
# 管理员创建的知识库标记为系统知识库
is_system = user.is_superuser
# 创建知识库 # 创建知识库
knowledge_base = KnowledgeBase( knowledge_base = KnowledgeBase(
name=data.name, name=data.name,
description=data.description, description=data.description,
user_id=user.id user_id=user.id,
is_system=is_system
) )
db.add(knowledge_base) db.add(knowledge_base)
db.commit() db.commit()
db.refresh(knowledge_base) 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( return KnowledgeBaseResponse(
id=knowledge_base.id, id=knowledge_base.id,
@@ -260,18 +270,21 @@ async def update_knowledge_base(
detail="用户不存在" detail="用户不存在"
) )
# 获取知识库 # 获取知识库(用户自己的,或admin操作系统知识库)
knowledge_base = db.query(KnowledgeBase).filter( knowledge_base = db.query(KnowledgeBase).filter(
KnowledgeBase.id == knowledge_base_id, 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() ).first()
if not knowledge_base: if not knowledge_base:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="知识库不存在" detail="知识库不存在"
) )
# 更新字段 # 更新字段
if data.name is not None: if data.name is not None:
# 检查新名称是否已存在 # 检查新名称是否已存在
@@ -335,18 +348,21 @@ async def delete_knowledge_base(
detail="用户不存在" detail="用户不存在"
) )
# 获取知识库 # 获取知识库(用户自己的,或admin操作系统知识库)
knowledge_base = db.query(KnowledgeBase).filter( knowledge_base = db.query(KnowledgeBase).filter(
KnowledgeBase.id == knowledge_base_id, 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() ).first()
if not knowledge_base: if not knowledge_base:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="知识库不存在" detail="知识库不存在"
) )
# 1. 获取知识库下的所有文档 # 1. 获取知识库下的所有文档
documents = db.query(Document).filter( documents = db.query(Document).filter(
Document.knowledge_base_id == knowledge_base_id Document.knowledge_base_id == knowledge_base_id
@@ -455,32 +471,42 @@ async def upload_document_to_knowledge_base(
detail="知识库不存在" detail="知识库不存在"
) )
# 检查是否为系统知识库,系统知识库不允许任何用户上传文档 # 检查知识库权限
if knowledge_base.is_system: if knowledge_base.is_system:
raise HTTPException( # 系统知识库:仅管理员可上传
status_code=status.HTTP_403_FORBIDDEN, if not user.is_superuser:
detail="系统知识库不允许上传文档,请使用您自己的知识库" raise HTTPException(
) status_code=status.HTTP_403_FORBIDDEN,
detail="系统知识库仅管理员可上传文档"
# 验证知识库属于当前用户(非系统知识库必须属于用户) )
if knowledge_base.user_id != user.id: else:
raise HTTPException( # 用户知识库:必须属于当前用户
status_code=status.HTTP_403_FORBIDDEN, if knowledge_base.user_id != user.id:
detail="您没有权限向此知识库上传文档" raise HTTPException(
) status_code=status.HTTP_403_FORBIDDEN,
detail="您没有权限向此知识库上传文档"
)
# 生成唯一文件名 # 生成唯一文件名
file_id = str(uuid.uuid4()) file_id = str(uuid.uuid4())
filename = f"{file_id}{file_extension}" filename = f"{file_id}{file_extension}"
# 保存文件 # 根据知识库类型选择保存路径
upload_dir = Path(settings.upload_dir) if knowledge_base.is_system:
upload_dir.mkdir(parents=True, exist_ok=True) # 系统知识库:保存到 knowledge_base_dir/{kb_name}/
file_path = upload_dir / filename 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: with open(file_path, "wb") as f:
f.write(content) f.write(content)
# 创建文档记录 # 创建文档记录
document = Document( document = Document(
user_id=user.id, user_id=user.id,
@@ -492,7 +518,8 @@ async def upload_document_to_knowledge_base(
file_type=file_extension, file_type=file_extension,
title=title or Path(file.filename).stem, title=title or Path(file.filename).stem,
description=description, description=description,
is_processed=False is_processed=False,
source_type=source_type
) )
db.add(document) db.add(document)
@@ -549,10 +576,13 @@ async def get_knowledge_base_documents(
detail="用户不存在" detail="用户不存在"
) )
# 验证知识库存在且属于用户 # 验证知识库存在(用户自己的,或系统知识库)
knowledge_base = db.query(KnowledgeBase).filter( knowledge_base = db.query(KnowledgeBase).filter(
KnowledgeBase.id == knowledge_base_id, KnowledgeBase.id == knowledge_base_id,
KnowledgeBase.user_id == user.id or_(
KnowledgeBase.user_id == user.id,
KnowledgeBase.is_system == True
)
).first() ).first()
if not knowledge_base: if not knowledge_base:
@@ -132,6 +132,10 @@ class FileWatcherService:
print(f"文件监控服务已启动,监控目录: {self.knowledge_base_dir}") print(f"文件监控服务已启动,监控目录: {self.knowledge_base_dir}")
# 确保子目录对应的系统知识库存在
print("确保系统知识库与目录同步...")
kb_service.ensure_system_knowledge_bases()
# 执行初始扫描 # 执行初始扫描
print("执行初始知识库扫描...") print("执行初始知识库扫描...")
scan_result = kb_service.scan_directory() scan_result = kb_service.scan_directory()
+90 -10
View File
@@ -10,6 +10,8 @@ from sqlalchemy.orm import Session
from sqlalchemy import and_ from sqlalchemy import and_
from ..models.document import Document, DocumentChunk from ..models.document import Document, DocumentChunk
from ..models.knowledge_base import KnowledgeBase
from ..models.user import User
from ..core.config import get_settings from ..core.config import get_settings
from .document_service import DocumentService from .document_service import DocumentService
@@ -30,10 +32,13 @@ class KnowledgeBaseService:
directory = self.knowledge_base_dir directory = self.knowledge_base_dir
else: else:
directory = Path(directory) directory = Path(directory)
if not directory.exists(): if not directory.exists():
return {"success": False, "message": f"目录不存在: {directory}"} return {"success": False, "message": f"目录不存在: {directory}"}
# 先确保每个子目录都有对应的系统知识库
self.ensure_system_knowledge_bases()
results = { results = {
"scanned_files": 0, "scanned_files": 0,
"new_files": 0, "new_files": 0,
@@ -41,14 +46,14 @@ class KnowledgeBaseService:
"skipped_files": 0, "skipped_files": 0,
"errors": [] "errors": []
} }
# 递归扫描目录 # 递归扫描目录
for file_path in directory.rglob("*"): for file_path in directory.rglob("*"):
if file_path.is_file() and self._is_supported_file(file_path): if file_path.is_file() and self._is_supported_file(file_path):
try: try:
result = self.process_file(str(file_path)) result = self.process_file(str(file_path))
results["scanned_files"] += 1 results["scanned_files"] += 1
if result["status"] == "new": if result["status"] == "new":
results["new_files"] += 1 results["new_files"] += 1
elif result["status"] == "updated": elif result["status"] == "updated":
@@ -60,16 +65,82 @@ class KnowledgeBaseService:
"file": str(file_path), "file": str(file_path),
"error": result["error"] "error": result["error"]
}) })
except Exception as e: except Exception as e:
results["errors"].append({ results["errors"].append({
"file": str(file_path), "file": str(file_path),
"error": str(e) "error": str(e)
}) })
results["success"] = len(results["errors"]) == 0 results["success"] = len(results["errors"]) == 0
return results 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]: def process_file(self, file_path: str) -> Dict[str, Any]:
"""处理单个文件(检查、提取、入库)""" """处理单个文件(检查、提取、入库)"""
try: try:
@@ -101,16 +172,25 @@ class KnowledgeBaseService:
if existing_doc: if existing_doc:
# 检查是否需要更新 # 检查是否需要更新
if (existing_doc.last_modified and if (existing_doc.last_modified and
existing_doc.last_modified >= last_modified and existing_doc.last_modified >= last_modified and
existing_doc.file_hash == file_hash): existing_doc.file_hash == file_hash):
return {"status": "skipped", "message": "文件未修改"} 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) return self._update_document(existing_doc, file_path, file_size, last_modified, file_hash)
else: 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: except Exception as e:
return {"status": "error", "error": str(e)} return {"status": "error", "error": str(e)}
+150 -70
View File
@@ -30,7 +30,7 @@ import { KnowledgeBase } from "@/types";
export default function KnowledgePage() { export default function KnowledgePage() {
const router = useRouter(); const router = useRouter();
const { isAuthenticated, isLoading: authLoading } = useAuthStore(); const { isAuthenticated, isLoading: authLoading, user } = useAuthStore();
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]); const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -58,9 +58,7 @@ export default function KnowledgePage() {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const bases = await knowledgeBaseAPI.getKnowledgeBases(); const bases = await knowledgeBaseAPI.getKnowledgeBases();
// 过滤掉系统知识库,只显示用户创建的知识库 setKnowledgeBases(bases);
const userBases = bases.filter(kb => !kb.is_system);
setKnowledgeBases(userBases);
} catch (err) { } catch (err) {
console.error("加载知识库失败:", err); console.error("加载知识库失败:", err);
setError("加载知识库失败"); setError("加载知识库失败");
@@ -114,6 +112,10 @@ export default function KnowledgePage() {
(kb.description && kb.description.toLowerCase().includes(searchQuery.toLowerCase())) (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) { if (authLoading || isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
@@ -213,13 +215,80 @@ export default function KnowledgePage() {
/> />
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{filteredKnowledgeBases.length} {userKBs.length} {systemKBs.length > 0 ? `${systemKBs.length} 个系统知识库` : ""}
</div> </div>
</div> </div>
</div> </div>
{/* 知识库列表 */} {/* 系统知识库 */}
{filteredKnowledgeBases.length === 0 ? ( {systemKBs.length > 0 && (
<div className="mb-8">
<div className="flex items-center space-x-2 mb-4">
<FolderOpen className="w-5 h-5 text-amber-600" />
<h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-muted-foreground">({systemKBs.length})</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{systemKBs.map((kb) => (
<Card key={kb.id} className="hover:shadow-lg transition-shadow backdrop-blur-sm bg-card/80 border-amber-500/30">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-2">
<FolderOpen className="w-5 h-5 text-amber-600" />
<div>
<CardTitle className="text-lg">{kb.name}</CardTitle>
<CardDescription className="text-sm">
{kb.description || "系统知识库"}
</CardDescription>
</div>
</div>
<div className="flex items-center space-x-1">
{kb.document_count > 0 ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<Clock className="w-4 h-4 text-gray-400" />
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between text-sm text-muted-foreground">
<span></span>
<span>{kb.document_count} </span>
</div>
<div className="flex justify-between text-sm text-muted-foreground">
<span></span>
<span>{formatDate(kb.created_at)}</span>
</div>
<div className="flex justify-between text-sm">
<span></span>
<span className={kb.document_count > 0 ? "text-green-600" : "text-gray-500"}>
{kb.document_count > 0 ? "已就绪" : "空知识库"}
</span>
</div>
</div>
<div className="flex space-x-2 mt-4">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => router.push(`/knowledge/${kb.id}`)}
>
<Settings className="w-4 h-4 mr-1" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* 用户知识库 */}
{userKBs.length === 0 && systemKBs.length === 0 ? (
<Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl"> <Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
<CardContent className="text-center py-12"> <CardContent className="text-center py-12">
<BookOpen className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> <BookOpen className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
@@ -238,70 +307,81 @@ export default function KnowledgePage() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> userKBs.length > 0 && (
{filteredKnowledgeBases.map((kb) => ( <div>
<Card key={kb.id} className="hover:shadow-lg transition-shadow backdrop-blur-sm bg-card/80 border-border/50"> {systemKBs.length > 0 && (
<CardHeader> <div className="flex items-center space-x-2 mb-4">
<div className="flex items-start justify-between"> <BookOpen className="w-5 h-5 text-blue-600" />
<div className="flex items-center space-x-2"> <h2 className="text-lg font-semibold"></h2>
<BookOpen className="w-5 h-5 text-blue-600" /> <span className="text-sm text-muted-foreground">({userKBs.length})</span>
<div> </div>
<CardTitle className="text-lg">{kb.name}</CardTitle> )}
<CardDescription className="text-sm"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{kb.description || "暂无描述"} {userKBs.map((kb) => (
</CardDescription> <Card key={kb.id} className="hover:shadow-lg transition-shadow backdrop-blur-sm bg-card/80 border-border/50">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-2">
<BookOpen className="w-5 h-5 text-blue-600" />
<div>
<CardTitle className="text-lg">{kb.name}</CardTitle>
<CardDescription className="text-sm">
{kb.description || "暂无描述"}
</CardDescription>
</div>
</div>
<div className="flex items-center space-x-1">
{kb.document_count > 0 ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<Clock className="w-4 h-4 text-gray-400" />
)}
</div>
</div> </div>
</div> </CardHeader>
<div className="flex items-center space-x-1"> <CardContent>
{kb.document_count > 0 ? ( <div className="space-y-2">
<CheckCircle className="w-4 h-4 text-green-600" /> <div className="flex justify-between text-sm text-muted-foreground">
) : ( <span></span>
<Clock className="w-4 h-4 text-gray-400" /> <span>{kb.document_count} </span>
)} </div>
</div> <div className="flex justify-between text-sm text-muted-foreground">
</div> <span></span>
</CardHeader> <span>{formatDate(kb.created_at)}</span>
<CardContent> </div>
<div className="space-y-2"> <div className="flex justify-between text-sm">
<div className="flex justify-between text-sm text-muted-foreground"> <span></span>
<span></span> <span className={kb.document_count > 0 ? "text-green-600" : "text-gray-500"}>
<span>{kb.document_count} </span> {kb.document_count > 0 ? "已就绪" : "空知识库"}
</div> </span>
<div className="flex justify-between text-sm text-muted-foreground"> </div>
<span></span> </div>
<span>{formatDate(kb.created_at)}</span>
</div> <div className="flex space-x-2 mt-4">
<div className="flex justify-between text-sm"> <Button
<span></span> variant="outline"
<span className={kb.document_count > 0 ? "text-green-600" : "text-gray-500"}> size="sm"
{kb.document_count > 0 ? "已就绪" : "空知识库"} className="flex-1"
</span> onClick={() => router.push(`/knowledge/${kb.id}`)}
</div> >
</div> <Settings className="w-4 h-4 mr-1" />
<div className="flex space-x-2 mt-4"> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="flex-1" onClick={() => handleDeleteKnowledgeBase(kb.id)}
onClick={() => router.push(`/knowledge/${kb.id}`)} className="text-red-600 hover:text-red-700"
> >
<Settings className="w-4 h-4 mr-1" /> <Trash2 className="w-4 h-4" />
</Button>
</Button> </div>
<Button </CardContent>
variant="outline" </Card>
size="sm" ))}
onClick={() => handleDeleteKnowledgeBase(kb.id)} </div>
className="text-red-600 hover:text-red-700" </div>
> )
<Trash2 className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)} )}
</div> </div>
+1 -7
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; 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 { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -61,12 +61,6 @@ export default function ExportDialog({ sessionId, sessionTitle, children, onClos
description: "可读性好的文本格式,适合分享", description: "可读性好的文本格式,适合分享",
icon: FileText, icon: FileText,
}, },
{
value: "pdf",
label: "PDF 格式",
description: "适合打印和正式文档",
icon: File,
},
]; ];
return ( return (
+7 -1
View File
@@ -11,7 +11,7 @@ import { format } from "date-fns";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useChatStore } from "@/store/chat"; import { useChatStore } from "@/store/chat";
import SourceReferences from "./source-references"; import SourceReferences from "./source-references";
import { useState } from "react"; import { useState, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
interface MessageItemProps { interface MessageItemProps {
@@ -21,6 +21,7 @@ interface MessageItemProps {
const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[]; isStreaming?: boolean }) => { const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[]; isStreaming?: boolean }) => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
if (!thinking || thinking.length === 0) return null; if (!thinking || thinking.length === 0) return null;
// 只提取有实际内容的步骤:推理内容和检索文档 // 只提取有实际内容的步骤:推理内容和检索文档
@@ -32,6 +33,11 @@ const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[];
// 没有推理内容也没有文档详情时不显示 // 没有推理内容也没有文档详情时不显示
if (!hasReasoning && !hasDocs && !isStreaming) return null; if (!hasReasoning && !hasDocs && !isStreaming) return null;
// 流式生成时自动展开,完成后自动收起
useEffect(() => {
setExpanded(!!isStreaming);
}, [isStreaming]);
// 合并推理文本 // 合并推理文本
const reasoningText = reasoningSteps.map(s => s.message).join(''); const reasoningText = reasoningSteps.map(s => s.message).join('');
+8 -3
View File
@@ -13,6 +13,7 @@ import {
Download, Download,
Search, Search,
MoreVertical, MoreVertical,
PanelLeftClose,
} from "lucide-react"; } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
@@ -121,9 +122,13 @@ export default function Sidebar({ onClose }: SidebarProps) {
<div className="px-3 pt-4 pb-3 space-y-3 border-b border-border/30"> <div className="px-3 pt-4 pb-3 space-y-3 border-b border-border/30">
<div className="flex items-center justify-between px-1"> <div className="flex items-center justify-between px-1">
<span className="text-sm font-semibold text-foreground"></span> <span className="text-sm font-semibold text-foreground"></span>
<Button onClick={handleNewChat} size="sm" variant="ghost" className="h-7 w-7 p-0 hover:bg-primary/10 hover:text-primary"> <button
<Plus className="w-4 h-4" /> onClick={onClose}
</Button> className="h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
title="收起侧边栏"
>
<PanelLeftClose className="w-4 h-4" />
</button>
</div> </div>
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
+1
View File
@@ -5,6 +5,7 @@ export interface User {
email: string; email: string;
full_name?: string; full_name?: string;
is_active: boolean; is_active: boolean;
is_superuser?: boolean;
created_at: string; created_at: string;
} }