Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8253b86a0 | |||
| 37c0364e7e | |||
| 4bb50ae9c1 | |||
| 3f12e96ea0 | |||
| b8561e04c6 | |||
| abcced35b8 | |||
| 5d42a0573a | |||
| 38865b0f7d | |||
| 1ce689ad1e | |||
| 19b6cdcbd8 |
@@ -0,0 +1,37 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
# Source code - enforce LF line endings
|
||||||
|
*.py text eol=lf
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.tsx text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.jsx text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
|
*.scss text eol=lf
|
||||||
|
*.html text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.toml text eol=lf
|
||||||
|
*.sql text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.bat text eol=lf
|
||||||
|
*.conf text eol=lf
|
||||||
|
*.cfg text eol=lf
|
||||||
|
*.txt text eol=lf
|
||||||
|
*.env text eol=lf
|
||||||
|
|
||||||
|
# Binary files - don't touch
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
@@ -19,6 +19,7 @@ build/
|
|||||||
node_modules/
|
node_modules/
|
||||||
.next/
|
.next/
|
||||||
out/
|
out/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# 运行时数据
|
# 运行时数据
|
||||||
runtime/
|
runtime/
|
||||||
@@ -33,6 +34,7 @@ generated_images/
|
|||||||
# 数据目录中的运行时文件(保留源文件如 .tex)
|
# 数据目录中的运行时文件(保留源文件如 .tex)
|
||||||
data/database/
|
data/database/
|
||||||
data/knowledge_base/
|
data/knowledge_base/
|
||||||
|
data/images/
|
||||||
|
|
||||||
# LaTeX 中间文件
|
# LaTeX 中间文件
|
||||||
*.aux
|
*.aux
|
||||||
@@ -62,6 +64,7 @@ Desktop.ini
|
|||||||
# 其他
|
# 其他
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
*.tar.gz
|
||||||
.cache/
|
.cache/
|
||||||
*.coverage
|
*.coverage
|
||||||
.coverage
|
.coverage
|
||||||
|
|||||||
+53
-32
@@ -4,14 +4,17 @@ FastAPI应用入口
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 设置系统时区为北京时间
|
# 设置系统时区为北京时间
|
||||||
os.environ['TZ'] = 'Asia/Shanghai'
|
os.environ['TZ'] = 'Asia/Shanghai'
|
||||||
|
|
||||||
@@ -52,9 +55,9 @@ def startup_knowledge_base():
|
|||||||
try:
|
try:
|
||||||
from src.services.file_watcher_service import start_file_watcher
|
from src.services.file_watcher_service import start_file_watcher
|
||||||
start_file_watcher()
|
start_file_watcher()
|
||||||
print("知识库文件监控服务启动成功")
|
logger.info("知识库文件监控服务启动成功")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"启动知识库文件监控服务失败: {str(e)}")
|
logger.error(f"启动知识库文件监控服务失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
def seed_forum_categories():
|
def seed_forum_categories():
|
||||||
@@ -72,17 +75,17 @@ def seed_forum_categories():
|
|||||||
try:
|
try:
|
||||||
existing_count = db.query(ForumCategory).count()
|
existing_count = db.query(ForumCategory).count()
|
||||||
if existing_count > 0:
|
if existing_count > 0:
|
||||||
print(f"论坛分类已存在({existing_count} 个),跳过初始化")
|
logger.info(f"论坛分类已存在({existing_count} 个),跳过初始化")
|
||||||
return
|
return
|
||||||
|
|
||||||
for cat_data in DEFAULT_CATEGORIES:
|
for cat_data in DEFAULT_CATEGORIES:
|
||||||
category = ForumCategory(**cat_data)
|
category = ForumCategory(**cat_data)
|
||||||
db.add(category)
|
db.add(category)
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"已创建 {len(DEFAULT_CATEGORIES)} 个论坛分类")
|
logger.info(f"已创建 {len(DEFAULT_CATEGORIES)} 个论坛分类")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
print(f"初始化论坛分类失败: {e}")
|
logger.error(f"初始化论坛分类失败: {e}")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -94,10 +97,9 @@ async def startup_event():
|
|||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
print("=" * 50, file=sys.stderr)
|
logger.info("=" * 50)
|
||||||
print("应用启动事件开始", file=sys.stderr)
|
logger.info("应用启动事件开始")
|
||||||
print("=" * 50, file=sys.stderr)
|
logger.info("=" * 50)
|
||||||
sys.stderr.flush()
|
|
||||||
|
|
||||||
# 等待数据库可用(重试机制,处理 DNS 解析延迟)
|
# 等待数据库可用(重试机制,处理 DNS 解析延迟)
|
||||||
max_retries = 10
|
max_retries = 10
|
||||||
@@ -105,38 +107,42 @@ async def startup_event():
|
|||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
print(f"尝试连接数据库(尝试 {attempt + 1}/{max_retries})...", file=sys.stderr)
|
logger.info(f"尝试连接数据库(尝试 {attempt + 1}/{max_retries})...")
|
||||||
sys.stderr.flush()
|
|
||||||
# 尝试创建数据库表
|
# 尝试创建数据库表
|
||||||
create_tables()
|
create_tables()
|
||||||
print("数据库表创建成功", file=sys.stderr)
|
logger.info("数据库表创建成功")
|
||||||
sys.stderr.flush()
|
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
print(f"数据库连接失败(尝试 {attempt + 1}/{max_retries}): {e}", file=sys.stderr)
|
logger.warning(f"数据库连接失败(尝试 {attempt + 1}/{max_retries}): {e}")
|
||||||
print(f"等待 {retry_delay} 秒后重试...", file=sys.stderr)
|
|
||||||
sys.stderr.flush()
|
|
||||||
await asyncio.sleep(retry_delay)
|
await asyncio.sleep(retry_delay)
|
||||||
else:
|
else:
|
||||||
print(f"数据库连接失败,已达到最大重试次数: {e}", file=sys.stderr)
|
logger.error(f"数据库连接失败,已达到最大重试次数: {e}")
|
||||||
sys.stderr.flush()
|
|
||||||
# 不抛出异常,让应用继续启动,但数据库操作会失败
|
# 不抛出异常,让应用继续启动,但数据库操作会失败
|
||||||
|
|
||||||
try:
|
try:
|
||||||
startup_knowledge_base()
|
startup_knowledge_base()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"启动知识库服务失败: {e}", file=sys.stderr)
|
logger.error(f"启动知识库服务失败: {e}")
|
||||||
|
|
||||||
|
# 确保系统知识库与目录同步
|
||||||
|
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:
|
||||||
|
logger.error(f"同步系统知识库失败: {e}")
|
||||||
|
|
||||||
# 初始化论坛分类
|
# 初始化论坛分类
|
||||||
try:
|
try:
|
||||||
seed_forum_categories()
|
seed_forum_categories()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"初始化论坛分类失败: {e}", file=sys.stderr)
|
logger.error(f"初始化论坛分类失败: {e}")
|
||||||
sys.stderr.flush()
|
|
||||||
|
|
||||||
print("应用启动事件完成", file=sys.stderr)
|
logger.info("应用启动事件完成")
|
||||||
sys.stderr.flush()
|
|
||||||
|
|
||||||
# 应用关闭事件
|
# 应用关闭事件
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
@@ -145,9 +151,18 @@ async def shutdown_event():
|
|||||||
try:
|
try:
|
||||||
from src.services.file_watcher_service import stop_file_watcher
|
from src.services.file_watcher_service import stop_file_watcher
|
||||||
stop_file_watcher()
|
stop_file_watcher()
|
||||||
print("知识库文件监控服务已停止")
|
logger.info("知识库文件监控服务已停止")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"停止知识库文件监控服务失败: {str(e)}")
|
logger.error(f"停止知识库文件监控服务失败: {str(e)}")
|
||||||
|
|
||||||
|
# 全局异常处理器
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def generic_exception_handler(request: Request, exc: Exception):
|
||||||
|
logger.error(f"未处理的异常: {exc}", exc_info=True)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": "服务器内部错误,请稍后重试"}
|
||||||
|
)
|
||||||
|
|
||||||
# 注册路由
|
# 注册路由
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
@@ -174,6 +189,10 @@ from src.api import course_content, forum
|
|||||||
app.include_router(course_content.router)
|
app.include_router(course_content.router)
|
||||||
app.include_router(forum.router)
|
app.include_router(forum.router)
|
||||||
|
|
||||||
|
# 导入并注册后台管理API
|
||||||
|
from src.api import admin
|
||||||
|
app.include_router(admin.router)
|
||||||
|
|
||||||
# 静态文件服务
|
# 静态文件服务
|
||||||
if os.path.exists(settings.upload_dir):
|
if os.path.exists(settings.upload_dir):
|
||||||
app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
|
app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
|
||||||
@@ -182,6 +201,11 @@ if os.path.exists(settings.upload_dir):
|
|||||||
if os.path.exists(settings.generated_images_dir):
|
if os.path.exists(settings.generated_images_dir):
|
||||||
app.mount("/generated_images", StaticFiles(directory=settings.generated_images_dir), name="generated_images")
|
app.mount("/generated_images", StaticFiles(directory=settings.generated_images_dir), name="generated_images")
|
||||||
|
|
||||||
|
# 挂载PDF图片提取目录
|
||||||
|
IMAGES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "images")
|
||||||
|
os.makedirs(IMAGES_DIR, exist_ok=True)
|
||||||
|
app.mount("/images", StaticFiles(directory=IMAGES_DIR), name="images")
|
||||||
|
|
||||||
# 根路径
|
# 根路径
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
@@ -295,11 +319,8 @@ async def list_services():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
print("=" * 50, file=sys.stderr)
|
logger.info("启动 Uvicorn 服务器...")
|
||||||
print("启动 Uvicorn 服务器...", file=sys.stderr)
|
logger.info(f"Host: {settings.host}, Port: {settings.port}")
|
||||||
print(f"Host: {settings.host}, Port: {settings.port}", file=sys.stderr)
|
|
||||||
print("=" * 50, file=sys.stderr)
|
|
||||||
sys.stderr.flush()
|
|
||||||
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"main:app",
|
"main:app",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ dependencies = [
|
|||||||
"duckduckgo-search>=6.0.0",
|
"duckduckgo-search>=6.0.0",
|
||||||
"docx2txt>=0.9",
|
"docx2txt>=0.9",
|
||||||
"pypdf>=6.12.0",
|
"pypdf>=6.12.0",
|
||||||
|
"pymupdf>=1.27.2.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
"""
|
||||||
|
后台管理API
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func, desc
|
||||||
|
|
||||||
|
from ..core.database import get_db
|
||||||
|
from ..core.security import get_current_user
|
||||||
|
from ..models.user import User
|
||||||
|
from ..models.chat import ChatSession, ChatMessage
|
||||||
|
from ..models.document import Document, DocumentChunk
|
||||||
|
from ..models.knowledge_base import KnowledgeBase
|
||||||
|
from ..models.forum import ForumCategory, ForumPost, ForumReply
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["后台管理"])
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
) -> User:
|
||||||
|
"""验证当前用户是否为管理员"""
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user or not user.is_superuser:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="需要管理员权限"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 数据概览 =====
|
||||||
|
|
||||||
|
class AdminDashboardStats(BaseModel):
|
||||||
|
total_users: int
|
||||||
|
active_users_7d: int
|
||||||
|
total_sessions: int
|
||||||
|
total_messages: int
|
||||||
|
total_documents: int
|
||||||
|
total_knowledge_bases: int
|
||||||
|
total_forum_posts: int
|
||||||
|
total_forum_replies: int
|
||||||
|
total_generated_images: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdminTrendItem(BaseModel):
|
||||||
|
date: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard", response_model=AdminDashboardStats)
|
||||||
|
async def get_dashboard(
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取管理后台仪表盘数据"""
|
||||||
|
total_users = db.query(func.count(User.id)).scalar() or 0
|
||||||
|
|
||||||
|
# 7天内活跃用户(有消息的用户)
|
||||||
|
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||||
|
active_users_7d = db.query(func.count(func.distinct(ChatMessage.session_id))).join(
|
||||||
|
ChatSession
|
||||||
|
).filter(
|
||||||
|
ChatMessage.created_at >= week_ago
|
||||||
|
).scalar() or 0
|
||||||
|
# 更准确的计算:有消息的独立用户数
|
||||||
|
active_users_7d = db.query(func.count(func.distinct(ChatSession.user_id))).join(
|
||||||
|
ChatMessage
|
||||||
|
).filter(
|
||||||
|
ChatMessage.created_at >= week_ago
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
total_sessions = db.query(func.count(ChatSession.id)).scalar() or 0
|
||||||
|
total_messages = db.query(func.count(ChatMessage.id)).scalar() or 0
|
||||||
|
total_documents = db.query(func.count(Document.id)).scalar() or 0
|
||||||
|
total_knowledge_bases = db.query(func.count(KnowledgeBase.id)).scalar() or 0
|
||||||
|
total_forum_posts = db.query(func.count(ForumPost.id)).scalar() or 0
|
||||||
|
total_forum_replies = db.query(func.count(ForumReply.id)).scalar() or 0
|
||||||
|
|
||||||
|
# 生成图像数(从目录统计)
|
||||||
|
from pathlib import Path
|
||||||
|
from ..core.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
generated_images_dir = Path(settings.generated_images_dir)
|
||||||
|
total_generated_images = 0
|
||||||
|
if generated_images_dir.exists():
|
||||||
|
image_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.gif'}
|
||||||
|
total_generated_images = sum(
|
||||||
|
1 for f in generated_images_dir.iterdir()
|
||||||
|
if f.is_file() and f.suffix.lower() in image_extensions
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminDashboardStats(
|
||||||
|
total_users=total_users,
|
||||||
|
active_users_7d=active_users_7d,
|
||||||
|
total_sessions=total_sessions,
|
||||||
|
total_messages=total_messages,
|
||||||
|
total_documents=total_documents,
|
||||||
|
total_knowledge_bases=total_knowledge_bases,
|
||||||
|
total_forum_posts=total_forum_posts,
|
||||||
|
total_forum_replies=total_forum_replies,
|
||||||
|
total_generated_images=total_generated_images,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trends/users")
|
||||||
|
async def get_user_trends(
|
||||||
|
days: int = 30,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取用户注册趋势"""
|
||||||
|
start_date = datetime.utcnow() - timedelta(days=days)
|
||||||
|
results = db.query(
|
||||||
|
func.date(User.created_at).label("date"),
|
||||||
|
func.count(User.id).label("count")
|
||||||
|
).filter(
|
||||||
|
User.created_at >= start_date
|
||||||
|
).group_by(
|
||||||
|
func.date(User.created_at)
|
||||||
|
).order_by("date").all()
|
||||||
|
|
||||||
|
return [{"date": str(r.date), "count": r.count} for r in results]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trends/messages")
|
||||||
|
async def get_message_trends(
|
||||||
|
days: int = 30,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取消息趋势"""
|
||||||
|
start_date = datetime.utcnow() - timedelta(days=days)
|
||||||
|
results = db.query(
|
||||||
|
func.date(ChatMessage.created_at).label("date"),
|
||||||
|
func.count(ChatMessage.id).label("count")
|
||||||
|
).filter(
|
||||||
|
ChatMessage.created_at >= start_date
|
||||||
|
).group_by(
|
||||||
|
func.date(ChatMessage.created_at)
|
||||||
|
).order_by("date").all()
|
||||||
|
|
||||||
|
return [{"date": str(r.date), "count": r.count} for r in results]
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 用户管理 =====
|
||||||
|
|
||||||
|
class AdminUserItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
full_name: Optional[str]
|
||||||
|
is_active: bool
|
||||||
|
is_superuser: bool
|
||||||
|
created_at: Optional[str]
|
||||||
|
last_login: Optional[str]
|
||||||
|
session_count: int
|
||||||
|
message_count: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=List[AdminUserItem])
|
||||||
|
async def list_users(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取用户列表"""
|
||||||
|
users = db.query(User).order_by(desc(User.id)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for u in users:
|
||||||
|
session_count = db.query(func.count(ChatSession.id)).filter(
|
||||||
|
ChatSession.user_id == u.id
|
||||||
|
).scalar() or 0
|
||||||
|
message_count = db.query(func.count(ChatMessage.id)).join(
|
||||||
|
ChatSession
|
||||||
|
).filter(ChatSession.user_id == u.id).scalar() or 0
|
||||||
|
|
||||||
|
result.append(AdminUserItem(
|
||||||
|
id=u.id,
|
||||||
|
username=u.username,
|
||||||
|
email=u.email,
|
||||||
|
full_name=u.full_name,
|
||||||
|
is_active=u.is_active,
|
||||||
|
is_superuser=u.is_superuser,
|
||||||
|
created_at=u.created_at.isoformat() if u.created_at else None,
|
||||||
|
last_login=u.last_login.isoformat() if u.last_login else None,
|
||||||
|
session_count=session_count,
|
||||||
|
message_count=message_count,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/toggle-active")
|
||||||
|
async def toggle_user_active(
|
||||||
|
user_id: int,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""启用/禁用用户"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
if user.id == admin.id:
|
||||||
|
raise HTTPException(status_code=400, detail="不能禁用自己")
|
||||||
|
|
||||||
|
user.is_active = not user.is_active
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "is_active": user.is_active}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/toggle-admin")
|
||||||
|
async def toggle_user_admin(
|
||||||
|
user_id: int,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""设为/取消管理员"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
if user.id == admin.id:
|
||||||
|
raise HTTPException(status_code=400, detail="不能修改自己的管理员状态")
|
||||||
|
|
||||||
|
user.is_superuser = not user.is_superuser
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "is_superuser": user.is_superuser}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""删除用户"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
if user.id == admin.id:
|
||||||
|
raise HTTPException(status_code=400, detail="不能删除自己")
|
||||||
|
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 论坛管理 =====
|
||||||
|
|
||||||
|
class AdminForumCategory(BaseModel):
|
||||||
|
id: int
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
post_count: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AdminForumCategoryCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/forum/categories", response_model=List[AdminForumCategory])
|
||||||
|
async def list_forum_categories(
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取论坛分类列表(管理)"""
|
||||||
|
categories = db.query(ForumCategory).order_by(ForumCategory.id).all()
|
||||||
|
result = []
|
||||||
|
for c in categories:
|
||||||
|
post_count = db.query(func.count(ForumPost.id)).filter(
|
||||||
|
ForumPost.category_id == c.id
|
||||||
|
).scalar() or 0
|
||||||
|
result.append(AdminForumCategory(
|
||||||
|
id=c.id,
|
||||||
|
slug=c.slug,
|
||||||
|
name=c.name,
|
||||||
|
description=c.description,
|
||||||
|
post_count=post_count,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forum/categories", response_model=AdminForumCategory)
|
||||||
|
async def create_forum_category(
|
||||||
|
data: AdminForumCategoryCreate,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""创建论坛分类"""
|
||||||
|
existing = db.query(ForumCategory).filter(ForumCategory.slug == data.slug).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="slug 已存在")
|
||||||
|
category = ForumCategory(
|
||||||
|
slug=data.slug,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
)
|
||||||
|
db.add(category)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(category)
|
||||||
|
return AdminForumCategory(
|
||||||
|
id=category.id,
|
||||||
|
slug=category.slug,
|
||||||
|
name=category.name,
|
||||||
|
description=category.description,
|
||||||
|
post_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/forum/categories/{category_id}")
|
||||||
|
async def update_forum_category(
|
||||||
|
category_id: int,
|
||||||
|
data: AdminForumCategoryCreate,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""更新论坛分类"""
|
||||||
|
category = db.query(ForumCategory).filter(ForumCategory.id == category_id).first()
|
||||||
|
if not category:
|
||||||
|
raise HTTPException(status_code=404, detail="分类不存在")
|
||||||
|
category.name = data.name
|
||||||
|
category.slug = data.slug
|
||||||
|
category.description = data.description
|
||||||
|
db.commit()
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/forum/categories/{category_id}")
|
||||||
|
async def delete_forum_category(
|
||||||
|
category_id: int,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""删除论坛分类"""
|
||||||
|
category = db.query(ForumCategory).filter(ForumCategory.id == category_id).first()
|
||||||
|
if not category:
|
||||||
|
raise HTTPException(status_code=404, detail="分类不存在")
|
||||||
|
# 删除该分类下的所有帖子和回复
|
||||||
|
posts = db.query(ForumPost).filter(ForumPost.category_id == category_id).all()
|
||||||
|
for post in posts:
|
||||||
|
db.query(ForumReply).filter(ForumReply.post_id == post.id).delete()
|
||||||
|
db.query(ForumPost).filter(ForumPost.category_id == category_id).delete()
|
||||||
|
db.delete(category)
|
||||||
|
db.commit()
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AdminForumPost(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
author_name: str
|
||||||
|
category_name: str
|
||||||
|
reply_count: int
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/forum/posts", response_model=List[AdminForumPost])
|
||||||
|
async def list_forum_posts(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取所有帖子列表(管理)"""
|
||||||
|
posts = db.query(ForumPost).order_by(desc(ForumPost.created_at)).offset(skip).limit(limit).all()
|
||||||
|
result = []
|
||||||
|
for p in posts:
|
||||||
|
author = db.query(User).filter(User.id == p.user_id).first()
|
||||||
|
category = db.query(ForumCategory).filter(ForumCategory.id == p.category_id).first()
|
||||||
|
reply_count = db.query(func.count(ForumReply.id)).filter(
|
||||||
|
ForumReply.post_id == p.id
|
||||||
|
).scalar() or 0
|
||||||
|
result.append(AdminForumPost(
|
||||||
|
id=p.id,
|
||||||
|
title=p.title,
|
||||||
|
author_name=author.username if author else "未知",
|
||||||
|
category_name=category.name if category else "未知",
|
||||||
|
reply_count=reply_count,
|
||||||
|
created_at=p.created_at.isoformat() if p.created_at else "",
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/forum/posts/{post_id}")
|
||||||
|
async def delete_forum_post(
|
||||||
|
post_id: int,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""删除帖子"""
|
||||||
|
post = db.query(ForumPost).filter(ForumPost.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(status_code=404, detail="帖子不存在")
|
||||||
|
db.query(ForumReply).filter(ForumReply.post_id == post_id).delete()
|
||||||
|
db.delete(post)
|
||||||
|
db.commit()
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 知识库管理 =====
|
||||||
|
|
||||||
|
class AdminKnowledgeBase(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
owner_name: str
|
||||||
|
is_system: bool
|
||||||
|
document_count: int
|
||||||
|
chunk_count: int
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-bases", response_model=List[AdminKnowledgeBase])
|
||||||
|
async def list_knowledge_bases(
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取所有知识库"""
|
||||||
|
kbs = db.query(KnowledgeBase).order_by(desc(KnowledgeBase.id)).all()
|
||||||
|
result = []
|
||||||
|
for kb in kbs:
|
||||||
|
owner = db.query(User).filter(User.id == kb.user_id).first()
|
||||||
|
doc_count = db.query(func.count(Document.id)).filter(
|
||||||
|
Document.knowledge_base_id == kb.id
|
||||||
|
).scalar() or 0
|
||||||
|
chunk_count = db.query(func.count(DocumentChunk.id)).join(
|
||||||
|
Document
|
||||||
|
).filter(Document.knowledge_base_id == kb.id).scalar() or 0
|
||||||
|
result.append(AdminKnowledgeBase(
|
||||||
|
id=kb.id,
|
||||||
|
name=kb.name,
|
||||||
|
description=kb.description,
|
||||||
|
owner_name=owner.username if owner else "系统",
|
||||||
|
is_system=kb.is_system,
|
||||||
|
document_count=doc_count,
|
||||||
|
chunk_count=chunk_count,
|
||||||
|
created_at=kb.created_at.isoformat() if kb.created_at else "",
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/knowledge-bases/{kb_id}")
|
||||||
|
async def delete_knowledge_base(
|
||||||
|
kb_id: int,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""删除知识库"""
|
||||||
|
kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first()
|
||||||
|
if not kb:
|
||||||
|
raise HTTPException(status_code=404, detail="知识库不存在")
|
||||||
|
# 删除关联文档和chunks
|
||||||
|
docs = db.query(Document).filter(Document.knowledge_base_id == kb_id).all()
|
||||||
|
for doc in docs:
|
||||||
|
db.query(DocumentChunk).filter(DocumentChunk.document_id == doc.id).delete()
|
||||||
|
db.query(Document).filter(Document.knowledge_base_id == kb_id).delete()
|
||||||
|
db.delete(kb)
|
||||||
|
db.commit()
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 系统状态 =====
|
||||||
|
|
||||||
|
@router.get("/system/status")
|
||||||
|
async def get_system_status(
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取系统状态"""
|
||||||
|
from ..core.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# 数据库状态
|
||||||
|
db_ok = True
|
||||||
|
try:
|
||||||
|
db.execute(func.now())
|
||||||
|
except Exception:
|
||||||
|
db_ok = False
|
||||||
|
|
||||||
|
# 向量库状态
|
||||||
|
vector_store_ok = True
|
||||||
|
vector_count = 0
|
||||||
|
try:
|
||||||
|
from ..rag.vector_store import VectorStore
|
||||||
|
vs = VectorStore()
|
||||||
|
collection = vs.get_or_create_collection("knowledge_base")
|
||||||
|
vector_count = collection.count()
|
||||||
|
except Exception:
|
||||||
|
vector_store_ok = False
|
||||||
|
|
||||||
|
return {
|
||||||
|
"database": {"status": "ok" if db_ok else "error"},
|
||||||
|
"vector_store": {"status": "ok" if vector_store_ok else "error", "vector_count": vector_count},
|
||||||
|
"llm_model": settings.siliconflow_model,
|
||||||
|
"embedding_model": "text2vec-base-chinese",
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+29
-25
@@ -1,11 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
聊天对话API
|
聊天对话API
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any, Literal
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ from ..rag.chains import create_rag_chain
|
|||||||
from ..rag.conversation_chains import create_conversation_chain
|
from ..rag.conversation_chains import create_conversation_chain
|
||||||
from ..llm.siliconflow import get_llm_client
|
from ..llm.siliconflow import get_llm_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/chat", tags=["聊天"])
|
router = APIRouter(prefix="/chat", tags=["聊天"])
|
||||||
|
|
||||||
|
|
||||||
@@ -33,11 +37,11 @@ def get_user_id_by_username(db: Session, username: str) -> int:
|
|||||||
|
|
||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
"""聊天请求模型"""
|
"""聊天请求模型"""
|
||||||
message: str
|
message: str = Field(..., min_length=1, max_length=5000, description="用户消息")
|
||||||
session_id: Optional[int] = None
|
session_id: Optional[int] = None
|
||||||
mode: str = "normal" # normal, rag
|
mode: Literal["normal", "rag"] = "normal"
|
||||||
knowledge_base_ids: Optional[List[int]] = None
|
knowledge_base_ids: Optional[List[int]] = Field(None, description="RAG模式使用的知识库ID列表")
|
||||||
model: Optional[str] = None # 模型ID,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
model: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ChatResponse(BaseModel):
|
class ChatResponse(BaseModel):
|
||||||
@@ -105,24 +109,24 @@ async def send_message(
|
|||||||
new_title = request.message.strip()[:30] + "..." if len(request.message.strip()) > 30 else request.message.strip()
|
new_title = request.message.strip()[:30] + "..." if len(request.message.strip()) > 30 else request.message.strip()
|
||||||
session.title = new_title
|
session.title = new_title
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"[DEBUG-AUTO-TITLE] 自动更新会话标题: {new_title}")
|
logger.debug(f"自动更新会话标题: {new_title}")
|
||||||
|
|
||||||
# 根据模式运行不同的问答工作流
|
# 根据模式运行不同的问答工作流
|
||||||
if request.mode == "rag":
|
if request.mode == "rag":
|
||||||
print(f"[DEBUG-RAG] 非流式RAG模式")
|
logger.debug(f"非流式RAG模式")
|
||||||
print(f"[DEBUG-RAG] 接收到的knowledge_base_ids: {request.knowledge_base_ids}")
|
logger.debug(f"接收到的knowledge_base_ids: {request.knowledge_base_ids}")
|
||||||
print(f"[DEBUG-RAG] knowledge_base_ids类型: {type(request.knowledge_base_ids)}")
|
logger.debug(f"knowledge_base_ids类型: {type(request.knowledge_base_ids)}")
|
||||||
if request.knowledge_base_ids:
|
if request.knowledge_base_ids:
|
||||||
print(f"[DEBUG-RAG] 第一个ID: {request.knowledge_base_ids[0]}, 类型: {type(request.knowledge_base_ids[0])}")
|
logger.debug(f"第一个ID: {request.knowledge_base_ids[0]}, 类型: {type(request.knowledge_base_ids[0])}")
|
||||||
|
|
||||||
# 使用LangChain 1.0 RAG链
|
# 使用LangChain 1.0 RAG链
|
||||||
print(f"[DEBUG-RAG] 模型: {request.model}")
|
logger.debug(f"模型: {request.model}")
|
||||||
rag_chain = create_rag_chain(knowledge_base_ids=request.knowledge_base_ids, model=request.model)
|
rag_chain = create_rag_chain(knowledge_base_ids=request.knowledge_base_ids, model=request.model)
|
||||||
result = rag_chain.invoke(request.message)
|
result = rag_chain.invoke(request.message)
|
||||||
else:
|
else:
|
||||||
# 普通模式:使用LangChain 1.0对话链
|
# 普通模式:使用LangChain 1.0对话链
|
||||||
print(f"[DEBUG-CHAT] 普通模式 - 使用LangChain对话链")
|
logger.debug(f"普通模式 - 使用LangChain对话链")
|
||||||
print(f"[DEBUG-CHAT] 模型: {request.model}")
|
logger.debug(f"模型: {request.model}")
|
||||||
conversation_chain = create_conversation_chain(model=request.model)
|
conversation_chain = create_conversation_chain(model=request.model)
|
||||||
|
|
||||||
# 获取聊天历史
|
# 获取聊天历史
|
||||||
@@ -175,7 +179,7 @@ async def stream_message(
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# 打印请求参数调试信息
|
# 打印请求参数调试信息
|
||||||
print(f"[DEBUG-CHAT] 接收流式请求 - mode: {request.mode}, knowledge_base_ids: {request.knowledge_base_ids}, model: {request.model}, message: {request.message[:50]}...")
|
logger.debug(f"接收流式请求 - mode: {request.mode}, knowledge_base_ids: {request.knowledge_base_ids}, model: {request.model}, message: {request.message[:50]}...")
|
||||||
|
|
||||||
# 获取或创建会话
|
# 获取或创建会话
|
||||||
if request.session_id:
|
if request.session_id:
|
||||||
@@ -215,7 +219,7 @@ async def stream_message(
|
|||||||
new_title = request.message.strip()[:30] + "..." if len(request.message.strip()) > 30 else request.message.strip()
|
new_title = request.message.strip()[:30] + "..." if len(request.message.strip()) > 30 else request.message.strip()
|
||||||
session.title = new_title
|
session.title = new_title
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"[DEBUG-AUTO-TITLE] 自动更新会话标题: {new_title}")
|
logger.debug(f"自动更新会话标题: {new_title}")
|
||||||
|
|
||||||
# 获取聊天历史
|
# 获取聊天历史
|
||||||
chat_history = []
|
chat_history = []
|
||||||
@@ -231,18 +235,18 @@ async def stream_message(
|
|||||||
|
|
||||||
# 根据模式选择不同的处理方式
|
# 根据模式选择不同的处理方式
|
||||||
if request.mode == "rag":
|
if request.mode == "rag":
|
||||||
print(f"[DEBUG-RAG] 使用LangChain 1.0 RAG链")
|
logger.debug(f"使用LangChain 1.0 RAG链")
|
||||||
print(f"[DEBUG-RAG] 接收到的knowledge_base_ids: {request.knowledge_base_ids}")
|
logger.debug(f"接收到的knowledge_base_ids: {request.knowledge_base_ids}")
|
||||||
print(f"[DEBUG-RAG] knowledge_base_ids类型: {type(request.knowledge_base_ids)}")
|
logger.debug(f"knowledge_base_ids类型: {type(request.knowledge_base_ids)}")
|
||||||
print(f"[DEBUG-RAG] 模型: {request.model}")
|
logger.debug(f"模型: {request.model}")
|
||||||
if request.knowledge_base_ids:
|
if request.knowledge_base_ids:
|
||||||
print(f"[DEBUG-RAG] 第一个ID: {request.knowledge_base_ids[0]}, 类型: {type(request.knowledge_base_ids[0])}")
|
logger.debug(f"第一个ID: {request.knowledge_base_ids[0]}, 类型: {type(request.knowledge_base_ids[0])}")
|
||||||
|
|
||||||
# 创建RAG链
|
# 创建RAG链
|
||||||
rag_chain = create_rag_chain(
|
rag_chain = create_rag_chain(
|
||||||
knowledge_base_ids=request.knowledge_base_ids,
|
knowledge_base_ids=request.knowledge_base_ids,
|
||||||
search_type="similarity",
|
search_type="similarity",
|
||||||
k=5,
|
k=50,
|
||||||
model=request.model
|
model=request.model
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -288,8 +292,8 @@ async def stream_message(
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# 普通模式:使用LangChain 1.0对话链
|
# 普通模式:使用LangChain 1.0对话链
|
||||||
print(f"[DEBUG-CHAT] 普通模式 - 使用LangChain流式对话链")
|
logger.debug(f"普通模式 - 使用LangChain流式对话链")
|
||||||
print(f"[DEBUG-CHAT] 模型: {request.model}")
|
logger.debug(f"模型: {request.model}")
|
||||||
conversation_chain = create_conversation_chain(model=request.model)
|
conversation_chain = create_conversation_chain(model=request.model)
|
||||||
|
|
||||||
# 使用带思考过程的流式输出
|
# 使用带思考过程的流式输出
|
||||||
@@ -739,7 +743,7 @@ async def run_rag_workflow_with_context(question: str, session_id: int, db: Sess
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"RAG工作流执行失败: {str(e)}")
|
logger.error(f"RAG工作流执行失败: {str(e)}")
|
||||||
# 降级到基础问答
|
# 降级到基础问答
|
||||||
rag_chain = create_rag_chain(model=model)
|
rag_chain = create_rag_chain(model=model)
|
||||||
return rag_chain.invoke(question)
|
return rag_chain.invoke(question)
|
||||||
|
|||||||
+22
-15
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
文档管理API
|
文档管理API
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
@@ -15,6 +16,8 @@ from ..core.security import get_current_user
|
|||||||
from ..models.document import Document, DocumentChunk
|
from ..models.document import Document, DocumentChunk
|
||||||
from ..services.document_service import DocumentService
|
from ..services.document_service import DocumentService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/documents", tags=["文档管理"])
|
router = APIRouter(prefix="/documents", tags=["文档管理"])
|
||||||
|
|
||||||
|
|
||||||
@@ -186,32 +189,30 @@ async def delete_document(
|
|||||||
|
|
||||||
# 1. 先删除向量数据和文档块
|
# 1. 先删除向量数据和文档块
|
||||||
try:
|
try:
|
||||||
print(f"开始删除文档向量数据: {document.filename} (ID: {document.id})")
|
logger.info(f"开始删除文档向量数据: {document.filename} (ID: {document.id})")
|
||||||
document_service = DocumentService(db)
|
document_service = DocumentService(db)
|
||||||
vector_deleted = document_service.delete_document_chunks(document.id)
|
vector_deleted = document_service.delete_document_chunks(document.id)
|
||||||
if vector_deleted:
|
if vector_deleted:
|
||||||
print(f"成功删除文档向量数据: {document.filename}")
|
logger.info(f"成功删除文档向量数据: {document.filename}")
|
||||||
else:
|
else:
|
||||||
print(f"警告:删除文档向量数据失败: {document.filename}")
|
logger.warning(f"警告:删除文档向量数据失败: {document.filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"删除向量数据时发生错误: {str(e)}")
|
logger.error(f"删除向量数据时发生错误: {str(e)}", exc_info=True)
|
||||||
import traceback
|
|
||||||
print(f"详细错误信息: {traceback.format_exc()}")
|
|
||||||
|
|
||||||
# 2. 删除物理文件
|
# 2. 删除物理文件
|
||||||
try:
|
try:
|
||||||
if os.path.exists(document.file_path):
|
if os.path.exists(document.file_path):
|
||||||
os.remove(document.file_path)
|
os.remove(document.file_path)
|
||||||
print(f"成功删除物理文件: {document.file_path}")
|
logger.info(f"成功删除物理文件: {document.file_path}")
|
||||||
else:
|
else:
|
||||||
print(f"物理文件不存在: {document.file_path}")
|
logger.info(f"物理文件不存在: {document.file_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"删除物理文件时发生错误: {str(e)}")
|
logger.error(f"删除物理文件时发生错误: {str(e)}")
|
||||||
|
|
||||||
# 3. 删除数据库记录
|
# 3. 删除数据库记录
|
||||||
db.delete(document)
|
db.delete(document)
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"成功删除文档数据库记录: {document.filename}")
|
logger.info(f"成功删除文档数据库记录: {document.filename}")
|
||||||
|
|
||||||
return {"message": "文档删除成功"}
|
return {"message": "文档删除成功"}
|
||||||
|
|
||||||
@@ -227,10 +228,11 @@ async def delete_document(
|
|||||||
@router.post("/{document_id}/process")
|
@router.post("/{document_id}/process")
|
||||||
async def process_document(
|
async def process_document(
|
||||||
document_id: int,
|
document_id: int,
|
||||||
|
force: bool = False,
|
||||||
current_user: str = Depends(get_current_user),
|
current_user: str = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""处理文档(向量化)"""
|
"""处理文档(向量化),force=true 强制重新处理"""
|
||||||
try:
|
try:
|
||||||
# 获取用户ID
|
# 获取用户ID
|
||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
@@ -253,12 +255,17 @@ async def process_document(
|
|||||||
detail="文档不存在"
|
detail="文档不存在"
|
||||||
)
|
)
|
||||||
|
|
||||||
if document.is_processed:
|
if document.is_processed and not force:
|
||||||
return {"message": "文档已经处理过了"}
|
return {"message": "文档已经处理过了,使用 force=true 强制重新处理"}
|
||||||
|
|
||||||
# 处理文档
|
# 强制重新处理时,先删除已有的向量数据
|
||||||
|
if force and document.is_processed:
|
||||||
|
document_service = DocumentService(db)
|
||||||
|
document_service.delete_document_chunks(document.id)
|
||||||
|
|
||||||
|
# 处理文档(直接 await 异步方法)
|
||||||
document_service = DocumentService(db)
|
document_service = DocumentService(db)
|
||||||
success = await asyncio.to_thread(document_service.process_document, document.id)
|
success = await document_service.process_document(document.id)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return {"message": "文档处理成功"}
|
return {"message": "文档处理成功"}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
知识库CRUD API
|
知识库CRUD API
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
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
|
||||||
@@ -18,6 +19,8 @@ from ..models.document import Document
|
|||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
from ..services.document_service import DocumentService
|
from ..services.document_service import DocumentService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/knowledge-bases", tags=["知识库管理"])
|
router = APIRouter(prefix="/knowledge-bases", tags=["知识库管理"])
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +67,7 @@ async def get_knowledge_bases(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取用户的所有知识库"""
|
"""获取用户的所有知识库"""
|
||||||
print(f"DEBUG: get_knowledge_bases called for user: {current_user}")
|
logger.debug(f"get_knowledge_bases called for user: {current_user}")
|
||||||
try:
|
try:
|
||||||
# 获取用户ID
|
# 获取用户ID
|
||||||
user = db.query(User).filter(User.username == current_user).first()
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
@@ -118,7 +121,7 @@ async def create_knowledge_base(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""创建新知识库"""
|
"""创建新知识库"""
|
||||||
print(f"DEBUG: create_knowledge_base called for user: {current_user}, data: {data}")
|
logger.debug(f"create_knowledge_base called for user: {current_user}, data: {data}")
|
||||||
try:
|
try:
|
||||||
# 获取用户ID
|
# 获取用户ID
|
||||||
user = db.query(User).filter(User.username == current_user).first()
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
@@ -130,8 +133,7 @@ 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:
|
||||||
@@ -140,17 +142,28 @@ async def create_knowledge_base(
|
|||||||
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,
|
||||||
name=knowledge_base.name,
|
name=knowledge_base.name,
|
||||||
@@ -260,10 +273,13 @@ 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:
|
||||||
@@ -335,10 +351,13 @@ 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:
|
||||||
@@ -352,7 +371,7 @@ async def delete_knowledge_base(
|
|||||||
Document.knowledge_base_id == knowledge_base_id
|
Document.knowledge_base_id == knowledge_base_id
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
print(f"开始删除知识库 '{knowledge_base.name}',包含 {len(documents)} 个文档")
|
logger.info(f"开始删除知识库 '{knowledge_base.name}',包含 {len(documents)} 个文档")
|
||||||
|
|
||||||
# 2. 逐个删除文档的向量数据和物理文件
|
# 2. 逐个删除文档的向量数据和物理文件
|
||||||
document_service = DocumentService(db)
|
document_service = DocumentService(db)
|
||||||
@@ -364,33 +383,31 @@ async def delete_knowledge_base(
|
|||||||
# 删除向量数据
|
# 删除向量数据
|
||||||
vector_deleted = document_service.delete_document_chunks(document.id)
|
vector_deleted = document_service.delete_document_chunks(document.id)
|
||||||
if vector_deleted:
|
if vector_deleted:
|
||||||
print(f"成功删除文档向量数据: {document.filename}")
|
logger.info(f"成功删除文档向量数据: {document.filename}")
|
||||||
else:
|
else:
|
||||||
print(f"警告:删除文档向量数据失败: {document.filename}")
|
logger.warning(f"警告:删除文档向量数据失败: {document.filename}")
|
||||||
error_count += 1
|
error_count += 1
|
||||||
|
|
||||||
# 删除物理文件
|
# 删除物理文件
|
||||||
if os.path.exists(document.file_path):
|
if os.path.exists(document.file_path):
|
||||||
os.remove(document.file_path)
|
os.remove(document.file_path)
|
||||||
print(f"成功删除物理文件: {document.file_path}")
|
logger.info(f"成功删除物理文件: {document.file_path}")
|
||||||
else:
|
else:
|
||||||
print(f"物理文件不存在: {document.file_path}")
|
logger.info(f"物理文件不存在: {document.file_path}")
|
||||||
|
|
||||||
success_count += 1
|
success_count += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"删除文档 {document.filename} 的资源时出错: {str(e)}")
|
logger.error(f"删除文档 {document.filename} 的资源时出错: {str(e)}", exc_info=True)
|
||||||
import traceback
|
|
||||||
print(f"详细错误信息: {traceback.format_exc()}")
|
|
||||||
error_count += 1
|
error_count += 1
|
||||||
# 继续处理其他文档
|
# 继续处理其他文档
|
||||||
|
|
||||||
print(f"文档清理完成: 成功 {success_count} 个,失败 {error_count} 个")
|
logger.info(f"文档清理完成: 成功 {success_count} 个,失败 {error_count} 个")
|
||||||
|
|
||||||
# 3. 删除知识库(级联删除文档记录)
|
# 3. 删除知识库(级联删除文档记录)
|
||||||
db.delete(knowledge_base)
|
db.delete(knowledge_base)
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"成功删除知识库数据库记录: {knowledge_base.name}")
|
logger.info(f"成功删除知识库数据库记录: {knowledge_base.name}")
|
||||||
|
|
||||||
return {"message": "知识库删除成功"}
|
return {"message": "知识库删除成功"}
|
||||||
|
|
||||||
@@ -455,28 +472,38 @@ 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)
|
||||||
@@ -492,7 +519,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)
|
||||||
@@ -501,20 +529,18 @@ async def upload_document_to_knowledge_base(
|
|||||||
|
|
||||||
# 自动处理文档向量化
|
# 自动处理文档向量化
|
||||||
try:
|
try:
|
||||||
print(f"开始处理文档向量化: {document.filename} (ID: {document.id})")
|
logger.info(f"开始处理文档向量化: {document.filename} (ID: {document.id})")
|
||||||
document_service = DocumentService(db)
|
document_service = DocumentService(db)
|
||||||
success = await document_service.process_document(document.id)
|
success = await document_service.process_document(document.id)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
print(f"文档向量化处理成功: {document.filename}")
|
logger.info(f"文档向量化处理成功: {document.filename}")
|
||||||
message = "文档上传并处理成功"
|
message = "文档上传并处理成功"
|
||||||
else:
|
else:
|
||||||
print(f"文档向量化处理失败: {document.filename}")
|
logger.warning(f"文档向量化处理失败: {document.filename}")
|
||||||
message = "文档上传成功,但向量化处理失败"
|
message = "文档上传成功,但向量化处理失败"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"文档向量化处理异常: {document.filename}, 错误: {str(e)}")
|
logger.error(f"文档向量化处理异常: {document.filename}, 错误: {str(e)}", exc_info=True)
|
||||||
import traceback
|
|
||||||
print(f"详细错误信息: {traceback.format_exc()}")
|
|
||||||
message = "文档上传成功,但向量化处理失败"
|
message = "文档上传成功,但向量化处理失败"
|
||||||
|
|
||||||
return DocumentUploadResponse(
|
return DocumentUploadResponse(
|
||||||
@@ -549,10 +575,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:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from passlib.context import CryptContext
|
|||||||
from fastapi import HTTPException, status, Depends
|
from fastapi import HTTPException, status, Depends
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
|
|
||||||
@@ -82,3 +83,24 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s
|
|||||||
return username
|
return username
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_obj(current_user: str = Depends(get_current_user)):
|
||||||
|
"""获取当前用户的完整User对象
|
||||||
|
|
||||||
|
用法: current_user: User = Depends(get_current_user_obj)
|
||||||
|
替代: current_user: str = Depends(get_current_user) + 手动 db.query(User)
|
||||||
|
"""
|
||||||
|
from ..models.user import User
|
||||||
|
from .database import get_db
|
||||||
|
db = next(get_db())
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
LangGraph节点定义
|
LangGraph节点定义
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Dict, Any, List, TypedDict
|
from typing import Dict, Any, List, TypedDict
|
||||||
from langchain.schema import HumanMessage, AIMessage, SystemMessage
|
from langchain.schema import HumanMessage, AIMessage, SystemMessage
|
||||||
@@ -9,6 +10,8 @@ from ..llm.siliconflow import get_llm_client
|
|||||||
from ..rag.retrievers import KnowledgeBaseRetriever
|
from ..rag.retrievers import KnowledgeBaseRetriever
|
||||||
from ..rag.vector_store import get_vector_store
|
from ..rag.vector_store import get_vector_store
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_from_response(text: str) -> dict:
|
def _parse_json_from_response(text: str) -> dict:
|
||||||
"""从LLM响应中提取JSON,兼容markdown代码块包裹的情况"""
|
"""从LLM响应中提取JSON,兼容markdown代码块包裹的情况"""
|
||||||
@@ -73,7 +76,7 @@ def analyze_question_node(state: GraphState) -> GraphState:
|
|||||||
return new_state
|
return new_state
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"问题分析失败: {str(e)}")
|
logger.error(f"问题分析失败: {str(e)}")
|
||||||
new_state = state.copy()
|
new_state = state.copy()
|
||||||
new_state["metadata"] = {**state["metadata"], "analysis": {
|
new_state["metadata"] = {**state["metadata"], "analysis": {
|
||||||
"question_type": "通用",
|
"question_type": "通用",
|
||||||
@@ -138,9 +141,7 @@ def retrieve_knowledge_node(state: GraphState) -> GraphState:
|
|||||||
return new_state
|
return new_state
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"知识检索失败: {str(e)}")
|
logger.error(f"知识检索失败: {str(e)}", exc_info=True)
|
||||||
import traceback
|
|
||||||
print(traceback.format_exc())
|
|
||||||
new_state = state.copy()
|
new_state = state.copy()
|
||||||
new_state["retrieved_docs"] = []
|
new_state["retrieved_docs"] = []
|
||||||
new_state["sources"] = []
|
new_state["sources"] = []
|
||||||
@@ -186,7 +187,7 @@ def generate_answer_node(state: GraphState) -> GraphState:
|
|||||||
return new_state
|
return new_state
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"答案生成失败: {str(e)}")
|
logger.error(f"答案生成失败: {str(e)}")
|
||||||
new_state = state.copy()
|
new_state = state.copy()
|
||||||
new_state["answer"] = "抱歉,我无法生成合适的回答。请稍后重试。"
|
new_state["answer"] = "抱歉,我无法生成合适的回答。请稍后重试。"
|
||||||
return new_state
|
return new_state
|
||||||
@@ -208,5 +209,5 @@ def format_response_node(state: GraphState) -> GraphState:
|
|||||||
return new_state
|
return new_state
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"响应格式化失败: {str(e)}")
|
logger.error(f"响应格式化失败: {str(e)}")
|
||||||
return state
|
return state
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
问答LangGraph工作流
|
问答LangGraph工作流
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
from langgraph.graph import StateGraph, END
|
from langgraph.graph import StateGraph, END
|
||||||
from .nodes import GraphState, analyze_question_node, retrieve_knowledge_node, generate_answer_node, format_response_node
|
from .nodes import GraphState, analyze_question_node, retrieve_knowledge_node, generate_answer_node, format_response_node
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_qa_graph() -> StateGraph:
|
def create_qa_graph() -> StateGraph:
|
||||||
"""创建问答图"""
|
"""创建问答图"""
|
||||||
@@ -57,7 +60,7 @@ def run_qa_workflow(question: str, knowledge_base_ids: Optional[List[int]] = Non
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"问答工作流执行失败: {str(e)}")
|
logger.error(f"问答工作流执行失败: {str(e)}")
|
||||||
return {
|
return {
|
||||||
"answer": "抱歉,处理您的问题时出现了错误。请稍后重试。",
|
"answer": "抱歉,处理您的问题时出现了错误。请稍后重试。",
|
||||||
"sources": [],
|
"sources": [],
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
大模型API集成 — 支持 SiliconFlow 和 DeepSeek 官方
|
大模型API集成 — 支持 SiliconFlow 和 DeepSeek 官方 + 视觉模型
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import base64
|
||||||
from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple
|
from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple
|
||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
|
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
|
||||||
@@ -11,8 +13,21 @@ import openai
|
|||||||
|
|
||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# 图片描述提示词
|
||||||
|
IMAGE_DESCRIPTION_PROMPT = """你是一个国土空间规划专家。请详细描述这张PDF文档中的图片内容。
|
||||||
|
|
||||||
|
图片周围文字上下文(来自PDF页面):{context_text}
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 说明图片类型(地图/规划图/图表/流程图/示意图/照片等)
|
||||||
|
2. 描述图片中的关键信息、数据和空间关系
|
||||||
|
3. 提取图中所有文字标注
|
||||||
|
4. 描述控制在200-300字"""
|
||||||
|
|
||||||
# DeepSeek 官方模型 ID 前缀(用于自动路由)
|
# DeepSeek 官方模型 ID 前缀(用于自动路由)
|
||||||
DEEPSEEK_OFFICIAL_MODELS = {
|
DEEPSEEK_OFFICIAL_MODELS = {
|
||||||
"deepseek-chat",
|
"deepseek-chat",
|
||||||
@@ -44,7 +59,7 @@ class SiliconFlowLLM:
|
|||||||
api_key, base_url, resolved_model = _resolve_provider(raw_model)
|
api_key, base_url, resolved_model = _resolve_provider(raw_model)
|
||||||
|
|
||||||
self.model_name = resolved_model
|
self.model_name = resolved_model
|
||||||
print(f"[LLM] 模型: {resolved_model}, API: {base_url}")
|
logger.info(f"模型: {resolved_model}, API: {base_url}")
|
||||||
|
|
||||||
self.llm = ChatOpenAI(
|
self.llm = ChatOpenAI(
|
||||||
model=resolved_model,
|
model=resolved_model,
|
||||||
@@ -150,6 +165,61 @@ class SiliconFlowLLM:
|
|||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
async def describe_image(self, image_path: str, context_text: str = "") -> str:
|
||||||
|
"""使用VLM模型描述图片内容
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: 图片文件路径
|
||||||
|
context_text: 图片周围的PDF文本上下文
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
图片的文字描述
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 读取图片并编码为base64
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
image_data = base64.b64encode(f.read()).decode("utf-8")
|
||||||
|
|
||||||
|
# 检测图片格式
|
||||||
|
ext = os.path.splitext(image_path)[1].lower()
|
||||||
|
mime_map = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp"}
|
||||||
|
mime_type = mime_map.get(ext, "image/png")
|
||||||
|
|
||||||
|
prompt = IMAGE_DESCRIPTION_PROMPT.format(context_text=context_text[:600])
|
||||||
|
|
||||||
|
vision_model = "Qwen/Qwen3-VL-8B-Instruct"
|
||||||
|
api_key, base_url, _ = _resolve_provider(vision_model)
|
||||||
|
|
||||||
|
client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||||
|
|
||||||
|
max_retries = 3
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=vision_model,
|
||||||
|
messages=[{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_data}"}},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
max_tokens=600,
|
||||||
|
temperature=0.3,
|
||||||
|
timeout=90.0,
|
||||||
|
)
|
||||||
|
return response.choices[0].message.content or ""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"描述失败 attempt={attempt+1}: {e}")
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# 全局LLM实例(使用默认模型)
|
# 全局LLM实例(使用默认模型)
|
||||||
llm_client = SiliconFlowLLM()
|
llm_client = SiliconFlowLLM()
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
迁移孤立文档到默认知识库
|
迁移孤立文档到默认知识库
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
from ..models.knowledge_base import KnowledgeBase
|
from ..models.knowledge_base import KnowledgeBase
|
||||||
from ..models.document import Document
|
from ..models.document import Document
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def migrate_orphaned_documents():
|
def migrate_orphaned_documents():
|
||||||
"""将没有知识库的文档迁移到用户的默认知识库"""
|
"""将没有知识库的文档迁移到用户的默认知识库"""
|
||||||
@@ -40,12 +43,12 @@ def migrate_orphaned_documents():
|
|||||||
doc.knowledge_base_id = default_kb.id
|
doc.knowledge_base_id = default_kb.id
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"用户 {user.username} 的 {len(orphaned_docs)} 个文档已迁移到默认知识库")
|
logger.info(f"用户 {user.username} 的 {len(orphaned_docs)} 个文档已迁移到默认知识库")
|
||||||
|
|
||||||
print("孤立文档迁移完成")
|
logger.info("孤立文档迁移完成")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"迁移失败: {str(e)}")
|
logger.error(f"迁移失败: {str(e)}")
|
||||||
db.rollback()
|
db.rollback()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class ChatSession(Base):
|
|||||||
__tablename__ = "chat_sessions"
|
__tablename__ = "chat_sessions"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
title = Column(String(200), nullable=True)
|
title = Column(String(200), nullable=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -32,7 +32,7 @@ class ChatMessage(Base):
|
|||||||
__tablename__ = "chat_messages"
|
__tablename__ = "chat_messages"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
session_id = Column(Integer, ForeignKey("chat_sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("chat_sessions.id"), nullable=False, index=True)
|
||||||
role = Column(String(20), nullable=False) # user, assistant, system
|
role = Column(String(20), nullable=False) # user, assistant, system
|
||||||
content = Column(Text, nullable=False)
|
content = Column(Text, nullable=False)
|
||||||
message_metadata = Column(Text, nullable=True) # JSON格式的元数据
|
message_metadata = Column(Text, nullable=True) # JSON格式的元数据
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class Document(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # None表示系统文档
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # None表示系统文档
|
||||||
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=True) # 所属知识库
|
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=True, index=True)
|
||||||
filename = Column(String(255), nullable=False)
|
filename = Column(String(255), nullable=False)
|
||||||
original_filename = Column(String(255), nullable=False)
|
original_filename = Column(String(255), nullable=False)
|
||||||
file_path = Column(String(500), nullable=False)
|
file_path = Column(String(500), nullable=False)
|
||||||
@@ -47,7 +47,7 @@ class DocumentChunk(Base):
|
|||||||
__tablename__ = "document_chunks"
|
__tablename__ = "document_chunks"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
|
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False, index=True)
|
||||||
chunk_index = Column(Integer, nullable=False)
|
chunk_index = Column(Integer, nullable=False)
|
||||||
content = Column(Text, nullable=False)
|
content = Column(Text, nullable=False)
|
||||||
content_hash = Column(String(64), nullable=False) # 内容哈希
|
content_hash = Column(String(64), nullable=False) # 内容哈希
|
||||||
|
|||||||
+44
-31
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
RAG检索链(LangChain 1.0)
|
RAG检索链(LangChain 1.0)
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
|
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
|
||||||
from langchain_core.prompts import ChatPromptTemplate
|
from langchain_core.prompts import ChatPromptTemplate
|
||||||
@@ -11,6 +12,8 @@ from .retrievers import KnowledgeBaseRetriever
|
|||||||
from .vector_store import get_vector_store
|
from .vector_store import get_vector_store
|
||||||
from ..llm.siliconflow import get_llm_client
|
from ..llm.siliconflow import get_llm_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class RAGChain:
|
class RAGChain:
|
||||||
"""RAG问答链(LangChain 1.0标准API)"""
|
"""RAG问答链(LangChain 1.0标准API)"""
|
||||||
|
|
||||||
@@ -18,8 +21,8 @@ class RAGChain:
|
|||||||
self,
|
self,
|
||||||
knowledge_base_ids: Optional[List[int]] = None,
|
knowledge_base_ids: Optional[List[int]] = None,
|
||||||
search_type: str = "similarity",
|
search_type: str = "similarity",
|
||||||
k: int = 5,
|
k: int = 50,
|
||||||
score_threshold: float = 0.1,
|
score_threshold: float = 0.15,
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
):
|
):
|
||||||
"""初始化RAG链
|
"""初始化RAG链
|
||||||
@@ -31,7 +34,7 @@ class RAGChain:
|
|||||||
score_threshold: 相似度阈值
|
score_threshold: 相似度阈值
|
||||||
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||||
"""
|
"""
|
||||||
print(f"[DEBUG-RAGChain] 初始化,knowledge_base_ids: {knowledge_base_ids}, model: {model}")
|
logger.debug(f"初始化RAG链,knowledge_base_ids: {knowledge_base_ids}, model: {model}")
|
||||||
_sf_client = get_llm_client(model=model)
|
_sf_client = get_llm_client(model=model)
|
||||||
self.llm = _sf_client.llm
|
self.llm = _sf_client.llm
|
||||||
self.client = _sf_client
|
self.client = _sf_client
|
||||||
@@ -46,7 +49,7 @@ class RAGChain:
|
|||||||
search_kwargs={"k": k},
|
search_kwargs={"k": k},
|
||||||
score_threshold=score_threshold
|
score_threshold=score_threshold
|
||||||
)
|
)
|
||||||
print(f"[DEBUG-RAGChain] 检索器创建完成,knowledge_base_ids: {self.retriever.knowledge_base_ids}")
|
logger.debug(f"检索器创建完成,knowledge_base_ids: {self.retriever.knowledge_base_ids}")
|
||||||
|
|
||||||
# 创建Prompt
|
# 创建Prompt
|
||||||
self.prompt = create_rag_prompt()
|
self.prompt = create_rag_prompt()
|
||||||
@@ -58,8 +61,8 @@ class RAGChain:
|
|||||||
"""创建检索链(LangChain 1.0 Runnable API)"""
|
"""创建检索链(LangChain 1.0 Runnable API)"""
|
||||||
# 使用LangChain 1.0的runnable API构建RAG链
|
# 使用LangChain 1.0的runnable API构建RAG链
|
||||||
def format_docs(docs):
|
def format_docs(docs):
|
||||||
"""格式化文档"""
|
"""格式化文档(使用带编号的上下文格式)"""
|
||||||
return "\n\n".join(doc.page_content for doc in docs)
|
return self._format_docs_for_context(docs)
|
||||||
|
|
||||||
# 构建RAG链 - 只检索一次,返回完整结果
|
# 构建RAG链 - 只检索一次,返回完整结果
|
||||||
def rag_with_sources(input_data):
|
def rag_with_sources(input_data):
|
||||||
@@ -115,7 +118,7 @@ class RAGChain:
|
|||||||
"""流式调用(只流式输出答案)"""
|
"""流式调用(只流式输出答案)"""
|
||||||
# 先获取文档(这是唯一一次检索)
|
# 先获取文档(这是唯一一次检索)
|
||||||
docs = await self.retriever.ainvoke(question)
|
docs = await self.retriever.ainvoke(question)
|
||||||
context = "\n\n".join(doc.page_content for doc in docs)
|
context = self._format_docs_for_context(docs)
|
||||||
|
|
||||||
# 构建prompt
|
# 构建prompt
|
||||||
prompt_value = await self.prompt.ainvoke({"context": context, "question": question})
|
prompt_value = await self.prompt.ainvoke({"context": context, "question": question})
|
||||||
@@ -133,37 +136,34 @@ class RAGChain:
|
|||||||
"""流式调用(返回答案流和文档,包含思考过程)"""
|
"""流式调用(返回答案流和文档,包含思考过程)"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# 0. 思考阶段开始
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
yield {"type": "thinking", "stage": "understanding", "message": "正在理解问题..."}
|
|
||||||
|
|
||||||
# 1. 检索文档
|
# 1. 检索文档
|
||||||
yield {"type": "thinking", "stage": "retrieving", "message": "正在检索相关知识..."}
|
|
||||||
retrieval_start = time.time()
|
retrieval_start = time.time()
|
||||||
docs = await self.retriever.ainvoke(question)
|
docs = await self.retriever.ainvoke(question)
|
||||||
retrieval_time = time.time() - retrieval_start
|
retrieval_time = time.time() - retrieval_start
|
||||||
|
|
||||||
# 发送检索结果 — 包含文档标题和摘要
|
# 发送检索结果(只在有文档时)
|
||||||
doc_details = []
|
if docs:
|
||||||
for i, doc in enumerate(docs[:5]):
|
doc_details = []
|
||||||
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
for i, doc in enumerate(docs[:10]):
|
||||||
title = metadata.get("title", metadata.get("filename", f"文档 {i+1}"))
|
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
||||||
preview = doc.page_content[:100].replace('\n', ' ')
|
title = metadata.get("title", metadata.get("filename", f"文档 {i+1}"))
|
||||||
doc_details.append(f"**{title}**: {preview}...")
|
preview = doc.page_content[:100].replace('\n', ' ')
|
||||||
|
doc_details.append(f"**{title}**: {preview}...")
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "thinking",
|
"type": "thinking",
|
||||||
"stage": "retrieved",
|
"stage": "retrieved",
|
||||||
"message": f"检索到 {len(docs)} 篇相关文档",
|
"message": f"检索到 {len(docs)} 篇相关文档",
|
||||||
"doc_count": len(docs),
|
"doc_count": len(docs),
|
||||||
"time": round(retrieval_time, 2),
|
"time": round(retrieval_time, 2),
|
||||||
"details": doc_details
|
"details": doc_details
|
||||||
}
|
}
|
||||||
|
|
||||||
context = "\n\n".join(doc.page_content for doc in docs)
|
context = self._format_docs_for_context(docs)
|
||||||
|
|
||||||
# 2. 构建prompt
|
# 2. 构建prompt并流式生成
|
||||||
yield {"type": "thinking", "stage": "generating", "message": f"基于 {len(docs)} 篇文档生成回答..."}
|
|
||||||
prompt_value = await self.prompt.ainvoke({"context": context, "question": question})
|
prompt_value = await self.prompt.ainvoke({"context": context, "question": question})
|
||||||
messages = prompt_value.to_messages()
|
messages = prompt_value.to_messages()
|
||||||
|
|
||||||
@@ -193,20 +193,33 @@ class RAGChain:
|
|||||||
def _format_sources(self, documents: List) -> List[Dict]:
|
def _format_sources(self, documents: List) -> List[Dict]:
|
||||||
"""格式化来源信息"""
|
"""格式化来源信息"""
|
||||||
sources = []
|
sources = []
|
||||||
for doc in documents:
|
for i, doc in enumerate(documents, start=1):
|
||||||
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
||||||
sources.append({
|
sources.append({
|
||||||
|
"id": i,
|
||||||
"title": metadata.get("title", "未知标题"),
|
"title": metadata.get("title", "未知标题"),
|
||||||
"filename": metadata.get("filename", "未知文件"),
|
"filename": metadata.get("filename", "未知文件"),
|
||||||
"page": metadata.get("chunk_index", 0),
|
"page": metadata.get("chunk_index", 0),
|
||||||
"preview": doc.page_content[:200] + "..." if len(doc.page_content) > 200 else doc.page_content
|
"score": metadata.get("score"),
|
||||||
|
"preview": doc.page_content,
|
||||||
|
"source_type": metadata.get("source_type", "rag"),
|
||||||
|
"image_url": metadata.get("image_url"),
|
||||||
})
|
})
|
||||||
return sources
|
return sources
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_docs_for_context(docs) -> str:
|
||||||
|
"""将文档列表格式化为带编号的LLM上下文字符串"""
|
||||||
|
parts = []
|
||||||
|
for i, doc in enumerate(docs, start=1):
|
||||||
|
source_title = doc.metadata.get("title", doc.metadata.get("filename", "未知"))
|
||||||
|
parts.append(f"[来源{i}](来源:{source_title})\n{doc.page_content}")
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
def create_rag_chain(
|
def create_rag_chain(
|
||||||
knowledge_base_ids: Optional[List[int]] = None,
|
knowledge_base_ids: Optional[List[int]] = None,
|
||||||
search_type: str = "similarity",
|
search_type: str = "similarity",
|
||||||
k: int = 5,
|
k: int = 50,
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
) -> RAGChain:
|
) -> RAGChain:
|
||||||
"""创建RAG链实例
|
"""创建RAG链实例
|
||||||
|
|||||||
@@ -85,16 +85,8 @@ class ConversationChain:
|
|||||||
"""流式调用(包含思考过程,捕获推理模型的真实推理内容)"""
|
"""流式调用(包含思考过程,捕获推理模型的真实推理内容)"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# 思考阶段
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
yield {"type": "thinking", "stage": "understanding", "message": "正在理解问题..."}
|
|
||||||
|
|
||||||
# 准备历史
|
|
||||||
history_messages = self._format_history(chat_history or [])
|
history_messages = self._format_history(chat_history or [])
|
||||||
history_count = len([m for m in (chat_history or []) if m["role"] == "user"])
|
|
||||||
yield {"type": "thinking", "stage": "preparing", "message": f"加载对话上下文({history_count} 轮历史)..." if history_count > 0 else "准备生成回答..."}
|
|
||||||
|
|
||||||
yield {"type": "thinking", "stage": "generating", "message": "正在生成回答..."}
|
|
||||||
|
|
||||||
# 直接使用原始 OpenAI SDK 捕获推理内容
|
# 直接使用原始 OpenAI SDK 捕获推理内容
|
||||||
prompt_value = await self.prompt.ainvoke({
|
prompt_value = await self.prompt.ainvoke({
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
LangChain 1.0 文档加载器封装
|
LangChain 1.0 文档加载器封装 + PDF图片提取
|
||||||
"""
|
"""
|
||||||
from typing import List, Optional
|
import logging
|
||||||
|
from typing import List, Optional, Dict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import fitz # pymupdf
|
||||||
from langchain_community.document_loaders import (
|
from langchain_community.document_loaders import (
|
||||||
PyPDFLoader,
|
PyPDFLoader,
|
||||||
Docx2txtLoader,
|
Docx2txtLoader,
|
||||||
@@ -11,6 +13,62 @@ from langchain_community.document_loaders import (
|
|||||||
)
|
)
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PDFImageExtractor:
|
||||||
|
"""使用pymupdf从PDF中提取内嵌图片"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_images(file_path: str, output_dir: str) -> List[dict]:
|
||||||
|
"""提取PDF中所有图片,返回图片元数据列表"""
|
||||||
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
images = []
|
||||||
|
doc = fitz.open(file_path)
|
||||||
|
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page = doc[page_num]
|
||||||
|
# 获取页面文本作为图片上下文
|
||||||
|
page_text = page.get_text("text")
|
||||||
|
# 提取页面内嵌图片
|
||||||
|
image_list = page.get_images(full=True)
|
||||||
|
|
||||||
|
for img_idx, img_info in enumerate(image_list):
|
||||||
|
xref = img_info[0]
|
||||||
|
try:
|
||||||
|
base_image = doc.extract_image(xref)
|
||||||
|
image_bytes = base_image["image"]
|
||||||
|
ext = base_image["ext"]
|
||||||
|
|
||||||
|
# 过滤太小的图片(图标、装饰元素等)
|
||||||
|
if len(image_bytes) < 2048:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 图片周围文本(取该页文字前后各300字作为上下文)
|
||||||
|
context_start = max(0, page_text.find(
|
||||||
|
page_text[:len(page_text)//2]) if len(page_text) > 600
|
||||||
|
else 0)
|
||||||
|
context_text = page_text[context_start:context_start+600].strip()
|
||||||
|
|
||||||
|
filename = f"page{page_num+1}_img{img_idx+1}.{ext}"
|
||||||
|
output_path = Path(output_dir) / filename
|
||||||
|
output_path.write_bytes(image_bytes)
|
||||||
|
|
||||||
|
images.append({
|
||||||
|
"path": str(output_path),
|
||||||
|
"filename": filename,
|
||||||
|
"page": page_num + 1,
|
||||||
|
"context_text": context_text,
|
||||||
|
"size": len(image_bytes),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"提取图片失败 page={page_num+1} img={img_idx}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
class DocumentLoaderFactory:
|
class DocumentLoaderFactory:
|
||||||
"""文档加载器工厂"""
|
"""文档加载器工厂"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
嵌入模型管理
|
嵌入模型管理
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from typing import List
|
from typing import List
|
||||||
from sentence_transformers import SentenceTransformer
|
from sentence_transformers import SentenceTransformer
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ class EmbeddingModel:
|
|||||||
"""加载嵌入模型"""
|
"""加载嵌入模型"""
|
||||||
try:
|
try:
|
||||||
self.model = SentenceTransformer(self.model_name)
|
self.model = SentenceTransformer(self.model_name)
|
||||||
print(f"嵌入模型 {self.model_name} 加载成功")
|
logger.info(f"嵌入模型 {self.model_name} 加载成功")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"嵌入模型加载失败: {str(e)}")
|
raise Exception(f"嵌入模型加载失败: {str(e)}")
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,22 @@ from langchain_core.messages import SystemMessage, HumanMessage
|
|||||||
# RAG系统提示词
|
# RAG系统提示词
|
||||||
RAG_SYSTEM_PROMPT = """你是一个专业的国土空间规划知识问答助手。请基于以下上下文信息回答用户的问题。
|
RAG_SYSTEM_PROMPT = """你是一个专业的国土空间规划知识问答助手。请基于以下上下文信息回答用户的问题。
|
||||||
|
|
||||||
上下文信息:
|
上下文信息(每个来源已标注编号,如 [来源1]、[来源2]):
|
||||||
{context}
|
{context}
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
1. 回答要准确、专业、详细
|
1. 综合评估上下文信息的相关性,优先采用与问题最相关的内容,不强行引用不相关的来源
|
||||||
2. 如果上下文中没有相关信息,请诚实说明
|
2. 回答中必须使用 [来源N] 格式(N为上下文中的来源编号)标注所引用的具体来源,例如"根据[来源3],国土空间规划..."
|
||||||
3. 回答要结构清晰,逻辑性强
|
3. 如果上下文中包含[图片描述]内容且与问题相关,**必须**在回答中展示该图片。上下文中已有"图片URL: /images/..."字段,请直接复制该URL,使用Markdown语法引用:
|
||||||
4. 适当引用相关概念和术语
|

|
||||||
5. 回答长度控制在500-1000字之间
|
例如:上下文中某来源包含"图片URL: /images/1/page3_img1.png",则插入:
|
||||||
|

|
||||||
|
4. 回答要准确、专业、详细,结构清晰,逻辑性强
|
||||||
|
5. 如果上下文中没有相关信息,请诚实说明
|
||||||
|
6. 在回答末尾,列出所有实际引用的参考来源,格式为:
|
||||||
|
**参考来源:**
|
||||||
|
- [来源N] 文档标题
|
||||||
|
7. 回答长度控制在500-1000字之间
|
||||||
|
|
||||||
请基于上述上下文信息回答用户的问题。"""
|
请基于上述上下文信息回答用户的问题。"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
文档检索器
|
文档检索器
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from .vector_store import get_vector_store
|
from .vector_store import get_vector_store
|
||||||
from .embeddings import get_embedding_model
|
from .embeddings import get_embedding_model
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class DocumentRetriever:
|
class DocumentRetriever:
|
||||||
"""文档检索器"""
|
"""文档检索器"""
|
||||||
@@ -23,7 +26,7 @@ class DocumentRetriever:
|
|||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""检索相关文档"""
|
"""检索相关文档"""
|
||||||
try:
|
try:
|
||||||
print(f"[DEBUG-RETRIEVER] 开始检索 - query: {query[:50]}..., filter_metadata: {filter_metadata}")
|
logger.debug(f"开始检索 - query: {query[:50]}..., filter_metadata: {filter_metadata}")
|
||||||
|
|
||||||
# 执行向量搜索
|
# 执行向量搜索
|
||||||
results = self.vector_store.search(
|
results = self.vector_store.search(
|
||||||
@@ -31,19 +34,19 @@ class DocumentRetriever:
|
|||||||
n_results=top_k,
|
n_results=top_k,
|
||||||
filter_metadata=filter_metadata
|
filter_metadata=filter_metadata
|
||||||
)
|
)
|
||||||
print(f"[DEBUG-RETRIEVER] 向量搜索返回结果数量: {len(results)}")
|
logger.debug(f"向量搜索返回结果数量: {len(results)}")
|
||||||
|
|
||||||
# 过滤低分结果
|
# 过滤低分结果
|
||||||
filtered_results = [
|
filtered_results = [
|
||||||
result for result in results
|
result for result in results
|
||||||
if result.get("distance", 1.0) <= (1 - score_threshold)
|
if result.get("distance", 1.0) <= (1 - score_threshold)
|
||||||
]
|
]
|
||||||
print(f"[DEBUG-RETRIEVER] 过滤后结果数量: {len(filtered_results)}")
|
logger.debug(f"过滤后结果数量: {len(filtered_results)}")
|
||||||
|
|
||||||
return filtered_results
|
return filtered_results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"文档检索失败: {str(e)}")
|
logger.error(f"文档检索失败: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def retrieve_by_document_id(self, document_id: str) -> List[Dict[str, Any]]:
|
def retrieve_by_document_id(self, document_id: str) -> List[Dict[str, Any]]:
|
||||||
@@ -56,7 +59,7 @@ class DocumentRetriever:
|
|||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"按文档ID检索失败: {str(e)}")
|
logger.error(f"按文档ID检索失败: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_relevant_context(self, query: str, max_length: int = 2000) -> str:
|
def get_relevant_context(self, query: str, max_length: int = 2000) -> str:
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
自定义知识库检索器(LangChain 1.0)
|
自定义知识库检索器(LangChain 1.0)
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import math
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
from langchain_core.callbacks.manager import CallbackManagerForRetrieverRun
|
from langchain_core.callbacks.manager import CallbackManagerForRetrieverRun
|
||||||
|
from .score_utils import convert_distance_to_score
|
||||||
from langchain_core.retrievers import BaseRetriever
|
from langchain_core.retrievers import BaseRetriever
|
||||||
|
|
||||||
class KnowledgeBaseRetriever(BaseRetriever):
|
class KnowledgeBaseRetriever(BaseRetriever):
|
||||||
@@ -14,7 +17,7 @@ class KnowledgeBaseRetriever(BaseRetriever):
|
|||||||
knowledge_base_ids: Optional[List[int]] = None
|
knowledge_base_ids: Optional[List[int]] = None
|
||||||
search_type: str = "similarity"
|
search_type: str = "similarity"
|
||||||
search_kwargs: dict = {"k": 5}
|
search_kwargs: dict = {"k": 5}
|
||||||
score_threshold: float = 0.1
|
score_threshold: float = 0.15
|
||||||
|
|
||||||
def _get_relevant_documents(
|
def _get_relevant_documents(
|
||||||
self,
|
self,
|
||||||
@@ -23,16 +26,16 @@ class KnowledgeBaseRetriever(BaseRetriever):
|
|||||||
run_manager: CallbackManagerForRetrieverRun
|
run_manager: CallbackManagerForRetrieverRun
|
||||||
) -> List[Document]:
|
) -> List[Document]:
|
||||||
"""获取相关文档(LangChain 1.0标准接口)"""
|
"""获取相关文档(LangChain 1.0标准接口)"""
|
||||||
print(f"[DEBUG-Retriever] 查询: {query}")
|
logger.debug(f"查询: {query}")
|
||||||
print(f"[DEBUG-Retriever] knowledge_base_ids: {self.knowledge_base_ids}")
|
logger.debug(f"knowledge_base_ids: {self.knowledge_base_ids}")
|
||||||
|
|
||||||
# 构建知识库过滤条件
|
# 构建知识库过滤条件
|
||||||
filter_dict = None
|
filter_dict = None
|
||||||
if self.knowledge_base_ids:
|
if self.knowledge_base_ids:
|
||||||
filter_dict = {"knowledge_base_id": {"$in": self.knowledge_base_ids}}
|
filter_dict = {"knowledge_base_id": {"$in": self.knowledge_base_ids}}
|
||||||
print(f"[DEBUG-Retriever] 构建的过滤条件: {filter_dict}")
|
logger.debug(f"构建的过滤条件: {filter_dict}")
|
||||||
else:
|
else:
|
||||||
print(f"[DEBUG-Retriever] 没有知识库ID,不进行过滤")
|
logger.debug("没有知识库ID,不进行过滤")
|
||||||
|
|
||||||
# 执行搜索
|
# 执行搜索
|
||||||
if self.search_type == "similarity":
|
if self.search_type == "similarity":
|
||||||
@@ -41,21 +44,21 @@ class KnowledgeBaseRetriever(BaseRetriever):
|
|||||||
k=self.search_kwargs.get("k", 5),
|
k=self.search_kwargs.get("k", 5),
|
||||||
filter=filter_dict
|
filter=filter_dict
|
||||||
)
|
)
|
||||||
print(f"[DEBUG-Retriever] 搜索返回文档数量: {len(docs_and_scores)}")
|
logger.debug(f"搜索返回文档数量: {len(docs_and_scores)}")
|
||||||
|
|
||||||
# 打印每个文档的知识库ID
|
# 打印每个文档的知识库ID
|
||||||
for i, (doc, distance) in enumerate(docs_and_scores):
|
for i, (doc, distance) in enumerate(docs_and_scores):
|
||||||
kb_id = doc.metadata.get("knowledge_base_id", "未知")
|
kb_id = doc.metadata.get("knowledge_base_id", "未知")
|
||||||
print(f"[DEBUG-Retriever] 文档{i+1}: knowledge_base_id={kb_id}, distance={distance:.4f}")
|
logger.debug(f"文档{i+1}: knowledge_base_id={kb_id}, distance={distance:.4f}")
|
||||||
|
|
||||||
# 转换距离为分数并过滤
|
# 转换距离为分数并过滤
|
||||||
filtered_docs = []
|
filtered_docs = []
|
||||||
for doc, distance in docs_and_scores:
|
for doc, distance in docs_and_scores:
|
||||||
score = self._convert_distance_to_score(distance)
|
score = convert_distance_to_score(distance)
|
||||||
if score > self.score_threshold:
|
if score > self.score_threshold:
|
||||||
filtered_docs.append(doc)
|
filtered_docs.append(doc)
|
||||||
|
|
||||||
print(f"[DEBUG-Retriever] 过滤后文档数量: {len(filtered_docs)}")
|
logger.debug(f"过滤后文档数量: {len(filtered_docs)}")
|
||||||
return filtered_docs
|
return filtered_docs
|
||||||
|
|
||||||
elif self.search_type == "mmr":
|
elif self.search_type == "mmr":
|
||||||
@@ -67,16 +70,3 @@ class KnowledgeBaseRetriever(BaseRetriever):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _convert_distance_to_score(self, distance: float) -> float:
|
|
||||||
"""将ChromaDB的distance转换为0-1范围的相似度分数"""
|
|
||||||
# 内积距离(负值)
|
|
||||||
if distance < 0:
|
|
||||||
return (1 + distance) / 2
|
|
||||||
|
|
||||||
# 大距离使用对数缩放
|
|
||||||
if distance > 100:
|
|
||||||
return 1 / (1 + math.log(distance))
|
|
||||||
|
|
||||||
# 标准距离转换
|
|
||||||
return 1 / (1 + distance)
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
分数转换工具
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def convert_distance_to_score(distance: float) -> float:
|
||||||
|
"""将ChromaDB的distance转换为0-1范围的相似度分数"""
|
||||||
|
if distance < 0:
|
||||||
|
return (1 + distance) / 2
|
||||||
|
if distance > 100:
|
||||||
|
return 1 / (1 + math.log(distance))
|
||||||
|
return 1 / (1 + distance)
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
LangChain 1.0 向量存储封装
|
LangChain 1.0 向量存储封装
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
@@ -12,6 +13,8 @@ from langchain_core.documents import Document as LangChainDocument
|
|||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
from .embeddings import get_embedding_model
|
from .embeddings import get_embedding_model
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +43,7 @@ class VectorStore:
|
|||||||
self.vectorstore.add_documents(documents)
|
self.vectorstore.add_documents(documents)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"添加文档失败: {str(e)}")
|
logger.error(f"添加文档失败: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def as_retriever(self, **kwargs):
|
def as_retriever(self, **kwargs):
|
||||||
@@ -54,7 +57,7 @@ class VectorStore:
|
|||||||
filter: Optional[Dict] = None
|
filter: Optional[Dict] = None
|
||||||
):
|
):
|
||||||
"""相似度搜索(带分数)"""
|
"""相似度搜索(带分数)"""
|
||||||
print(f"[DEBUG-VectorStore] 查询参数 - k: {k}, filter: {filter}")
|
logger.debug(f"查询参数 - k: {k}, filter: {filter}")
|
||||||
|
|
||||||
result = self.vectorstore.similarity_search_with_score(
|
result = self.vectorstore.similarity_search_with_score(
|
||||||
query=query,
|
query=query,
|
||||||
@@ -62,9 +65,20 @@ class VectorStore:
|
|||||||
filter=filter
|
filter=filter
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"[DEBUG-VectorStore] 返回结果数量: {len(result)}")
|
logger.debug(f"返回结果数量: {len(result)}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def delete_by_document_id(self, document_id: int) -> bool:
|
||||||
|
"""删除指定文档的所有向量数据"""
|
||||||
|
try:
|
||||||
|
self.vectorstore._collection.delete(
|
||||||
|
where={"document_id": document_id}
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"删除向量数据失败: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
def max_marginal_relevance_search(
|
def max_marginal_relevance_search(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
学习分析服务
|
学习分析服务
|
||||||
提供用户学习数据的统计和分析功能
|
提供用户学习数据的统计和分析功能
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func, distinct, and_
|
from sqlalchemy import func, distinct, and_
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
from ..models.chat import ChatSession, ChatMessage
|
from ..models.chat import ChatSession, ChatMessage
|
||||||
from ..models.document import Document
|
from ..models.document import Document
|
||||||
@@ -65,7 +67,7 @@ class AnalyticsService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] 获取用户统计数据失败: {str(e)}")
|
logger.error(f"获取用户统计数据失败: {str(e)}")
|
||||||
raise Exception(f"获取统计数据失败: {str(e)}")
|
raise Exception(f"获取统计数据失败: {str(e)}")
|
||||||
|
|
||||||
def get_user_learning_trends(self, user_id: int, days: int = 30) -> List[Dict[str, Any]]:
|
def get_user_learning_trends(self, user_id: int, days: int = 30) -> List[Dict[str, Any]]:
|
||||||
@@ -107,7 +109,7 @@ class AnalyticsService:
|
|||||||
return trends
|
return trends
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] 获取学习趋势数据失败: {str(e)}")
|
logger.error(f"获取学习趋势数据失败: {str(e)}")
|
||||||
raise Exception(f"获取学习趋势失败: {str(e)}")
|
raise Exception(f"获取学习趋势失败: {str(e)}")
|
||||||
|
|
||||||
def get_popular_questions(self, user_id: int, limit: int = 10) -> List[Dict[str, Any]]:
|
def get_popular_questions(self, user_id: int, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
@@ -141,7 +143,7 @@ class AnalyticsService:
|
|||||||
return popular_questions
|
return popular_questions
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] 获取热门问题失败: {str(e)}")
|
logger.error(f"获取热门问题失败: {str(e)}")
|
||||||
raise Exception(f"获取热门问题失败: {str(e)}")
|
raise Exception(f"获取热门问题失败: {str(e)}")
|
||||||
|
|
||||||
def get_knowledge_coverage(self, user_id: int) -> List[Dict[str, Any]]:
|
def get_knowledge_coverage(self, user_id: int) -> List[Dict[str, Any]]:
|
||||||
@@ -160,7 +162,7 @@ class AnalyticsService:
|
|||||||
return coverage_data
|
return coverage_data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] 获取知识覆盖度失败: {str(e)}")
|
logger.error(f"获取知识覆盖度失败: {str(e)}")
|
||||||
raise Exception(f"获取知识覆盖度失败: {str(e)}")
|
raise Exception(f"获取知识覆盖度失败: {str(e)}")
|
||||||
|
|
||||||
def get_learning_report(self, user_id: int) -> Dict[str, Any]:
|
def get_learning_report(self, user_id: int) -> Dict[str, Any]:
|
||||||
@@ -211,5 +213,5 @@ class AnalyticsService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] 获取学习报告失败: {str(e)}")
|
logger.error(f"获取学习报告失败: {str(e)}")
|
||||||
raise Exception(f"获取学习报告失败: {str(e)}")
|
raise Exception(f"获取学习报告失败: {str(e)}")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
用户认证服务
|
用户认证服务
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -10,6 +11,8 @@ from ..models.user import User
|
|||||||
from ..core.security import get_password_hash, verify_password, create_access_token
|
from ..core.security import get_password_hash, verify_password, create_access_token
|
||||||
from ..core.config import get_settings
|
from ..core.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
@@ -58,7 +61,7 @@ class AuthService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
print(f"创建用户失败: {str(e)}")
|
logger.error(f"创建用户失败: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def authenticate_user(self, username: str, password: str) -> Optional[User]:
|
def authenticate_user(self, username: str, password: str) -> Optional[User]:
|
||||||
@@ -85,7 +88,7 @@ class AuthService:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"用户认证失败: {str(e)}")
|
logger.error(f"用户认证失败: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||||
@@ -117,7 +120,7 @@ class AuthService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
print(f"更新用户失败: {str(e)}")
|
logger.error(f"更新用户失败: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def deactivate_user(self, user_id: int) -> bool:
|
def deactivate_user(self, user_id: int) -> bool:
|
||||||
@@ -133,7 +136,7 @@ class AuthService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
print(f"停用用户失败: {str(e)}")
|
logger.error(f"停用用户失败: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
||||||
@@ -152,7 +155,7 @@ class AuthService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
print(f"修改密码失败: {str(e)}")
|
logger.error(f"修改密码失败: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def create_access_token_for_user(self, user: User) -> Dict[str, Any]:
|
def create_access_token_for_user(self, user: User) -> Dict[str, Any]:
|
||||||
@@ -193,7 +196,7 @@ class AuthService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"获取用户统计失败: {str(e)}")
|
logger.error(f"获取用户统计失败: {str(e)}")
|
||||||
return {
|
return {
|
||||||
"total_users": 0,
|
"total_users": 0,
|
||||||
"active_users": 0,
|
"active_users": 0,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
根据数据库中的行号范围从LaTeX文件动态读取内容
|
根据数据库中的行号范围从LaTeX文件动态读取内容
|
||||||
层级结构:书籍 -> Chapter -> Section -> Subsection(知识点)
|
层级结构:书籍 -> Chapter -> Section -> Subsection(知识点)
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -10,6 +11,8 @@ from sqlalchemy.orm import Session
|
|||||||
from ..models.book_structure import Chapter, Section, Subsection
|
from ..models.book_structure import Chapter, Section, Subsection
|
||||||
from .latex_parser import LaTeXParser
|
from .latex_parser import LaTeXParser
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BookContentService:
|
class BookContentService:
|
||||||
"""书籍内容服务"""
|
"""书籍内容服务"""
|
||||||
@@ -59,7 +62,7 @@ class BookContentService:
|
|||||||
)
|
)
|
||||||
return content
|
return content
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"读取章节内容失败: {e}")
|
logger.error(f"读取章节内容失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_section_content(self, db: Session, section_id: int) -> Optional[str]:
|
def get_section_content(self, db: Session, section_id: int) -> Optional[str]:
|
||||||
@@ -91,7 +94,7 @@ class BookContentService:
|
|||||||
)
|
)
|
||||||
return content
|
return content
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"读取节内容失败: {e}")
|
logger.error(f"读取节内容失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_subsection_content(self, db: Session, subsection_id: int) -> Optional[str]:
|
def get_subsection_content(self, db: Session, subsection_id: int) -> Optional[str]:
|
||||||
@@ -127,6 +130,6 @@ class BookContentService:
|
|||||||
)
|
)
|
||||||
return content
|
return content
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"读取小节内容失败: {e}")
|
logger.error(f"读取小节内容失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
文档处理服务(LangChain 1.0)
|
文档处理服务(LangChain 1.0 + 多模态图片处理)
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import hashlib
|
import asyncio
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from langchain_core.documents import Document as LangChainDocument
|
from langchain_core.documents import Document as LangChainDocument
|
||||||
|
|
||||||
from ..models.document import Document, DocumentChunk
|
from ..models.document import Document, DocumentChunk
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
from ..rag.vector_store import get_vector_store
|
from ..rag.vector_store import get_vector_store
|
||||||
from ..rag.document_loaders import DocumentLoaderFactory
|
from ..rag.document_loaders import DocumentLoaderFactory, PDFImageExtractor
|
||||||
from ..rag.text_splitters import get_text_splitter
|
from ..rag.text_splitters import get_text_splitter
|
||||||
|
from ..rag.score_utils import convert_distance_to_score
|
||||||
|
from ..llm.siliconflow import get_llm_client
|
||||||
|
|
||||||
|
IMAGES_DIR = Path(__file__).parent.parent.parent.parent / "data" / "images"
|
||||||
|
|
||||||
|
|
||||||
class DocumentService:
|
class DocumentService:
|
||||||
"""文档处理服务"""
|
"""文档处理服务"""
|
||||||
@@ -21,13 +29,13 @@ class DocumentService:
|
|||||||
self.vector_store = get_vector_store()
|
self.vector_store = get_vector_store()
|
||||||
|
|
||||||
async def process_document(self, document_id: int) -> bool:
|
async def process_document(self, document_id: int) -> bool:
|
||||||
"""处理文档(使用LangChain 1.0)"""
|
"""处理文档(使用LangChain 1.0 + 多模态图片处理)"""
|
||||||
try:
|
try:
|
||||||
document = self.db.query(Document).filter(Document.id == document_id).first()
|
document = self.db.query(Document).filter(Document.id == document_id).first()
|
||||||
if not document:
|
if not document:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 1. 使用LangChain加载文档
|
# 1. 使用LangChain加载文档(文本)
|
||||||
documents = DocumentLoaderFactory.load_document(
|
documents = DocumentLoaderFactory.load_document(
|
||||||
file_path=document.file_path,
|
file_path=document.file_path,
|
||||||
file_type=document.file_type,
|
file_type=document.file_type,
|
||||||
@@ -43,21 +51,108 @@ class DocumentService:
|
|||||||
text_splitter = get_text_splitter(chunk_size=1000, chunk_overlap=200)
|
text_splitter = get_text_splitter(chunk_size=1000, chunk_overlap=200)
|
||||||
splits = text_splitter.split_documents(documents)
|
splits = text_splitter.split_documents(documents)
|
||||||
|
|
||||||
# 3. 添加到向量存储
|
# 3. PDF图片提取和描述(仅PDF文件)
|
||||||
success = self.vector_store.add_documents(splits)
|
image_chunks = []
|
||||||
|
if document.file_type == ".pdf":
|
||||||
|
image_chunks = await self._process_pdf_images(
|
||||||
|
file_path=document.file_path,
|
||||||
|
document_id=document.id,
|
||||||
|
knowledge_base_id=document.knowledge_base_id,
|
||||||
|
title=document.title,
|
||||||
|
filename=document.filename
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 将文本块和图片描述合并添加到向量存储
|
||||||
|
all_splits = splits + image_chunks
|
||||||
|
success = self.vector_store.add_documents(all_splits)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
document.is_processed = True
|
document.is_processed = True
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
logger.info(f"[DocumentService] 文档 {document.filename} 处理完成: "
|
||||||
|
f"{len(splits)} 个文本块, {len(image_chunks)} 个图片描述块")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"处理文档失败: {str(e)}")
|
logger.error(f"处理文档失败: {str(e)}")
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def _process_pdf_images(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
document_id: int,
|
||||||
|
knowledge_base_id: int,
|
||||||
|
title: str,
|
||||||
|
filename: str
|
||||||
|
) -> List[LangChainDocument]:
|
||||||
|
"""提取PDF图片并用VLM生成描述"""
|
||||||
|
image_chunks = []
|
||||||
|
try:
|
||||||
|
# 创建图片输出目录
|
||||||
|
img_output_dir = IMAGES_DIR / str(document_id)
|
||||||
|
|
||||||
|
# 提取图片
|
||||||
|
images = PDFImageExtractor.extract_images(str(file_path), str(img_output_dir))
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
logger.info(f"[DocumentService] 未发现可提取的图片: {filename}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"[DocumentService] 提取到 {len(images)} 张图片, 开始VLM描述生成...")
|
||||||
|
|
||||||
|
# 并发调用VLM生成描述(限制并发度为5)
|
||||||
|
llm_client = get_llm_client()
|
||||||
|
semaphore = asyncio.Semaphore(5)
|
||||||
|
|
||||||
|
async def describe_single_image(idx, img):
|
||||||
|
async with semaphore:
|
||||||
|
try:
|
||||||
|
description = await llm_client.describe_image(
|
||||||
|
img["path"],
|
||||||
|
img.get("context_text", "")
|
||||||
|
)
|
||||||
|
if description:
|
||||||
|
rel_path = f"{document_id}/{img['filename']}"
|
||||||
|
image_url = f"/images/{rel_path}"
|
||||||
|
chunk_content = (
|
||||||
|
f"[图片描述 - 第{img['page']}页]\n"
|
||||||
|
f"图片URL: {image_url}\n"
|
||||||
|
f"图片内容:{description}"
|
||||||
|
)
|
||||||
|
chunk = LangChainDocument(
|
||||||
|
page_content=chunk_content,
|
||||||
|
metadata={
|
||||||
|
"document_id": document_id,
|
||||||
|
"knowledge_base_id": knowledge_base_id,
|
||||||
|
"title": title,
|
||||||
|
"filename": filename,
|
||||||
|
"source_type": "image",
|
||||||
|
"image_path": str(img["path"]),
|
||||||
|
"image_url": image_url,
|
||||||
|
"page": img["page"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(f"[DocumentService] 图片描述成功 {idx+1}/{len(images)}: {img['filename']}")
|
||||||
|
return chunk
|
||||||
|
else:
|
||||||
|
logger.warning(f"[DocumentService] 图片描述为空 {idx+1}/{len(images)}: {img['filename']}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DocumentService] 图片处理失败 {img['filename']}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
tasks = [describe_single_image(idx, img) for idx, img in enumerate(images)]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
image_chunks = [r for r in results if r is not None]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DocumentService] PDF图片处理失败: {e}")
|
||||||
|
|
||||||
|
return image_chunks
|
||||||
|
|
||||||
def search_documents(self, query: str, knowledge_base_ids: Optional[List[int]] = None, limit: int = 5) -> List[Dict[str, Any]]:
|
def search_documents(self, query: str, knowledge_base_ids: Optional[List[int]] = None, limit: int = 5) -> List[Dict[str, Any]]:
|
||||||
"""搜索文档(保留原有接口兼容性)"""
|
"""搜索文档(保留原有接口兼容性)"""
|
||||||
try:
|
try:
|
||||||
@@ -77,7 +172,7 @@ class DocumentService:
|
|||||||
search_results = []
|
search_results = []
|
||||||
for doc, distance in results:
|
for doc, distance in results:
|
||||||
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
||||||
score = self._convert_distance_to_score(distance)
|
score = convert_distance_to_score(distance)
|
||||||
|
|
||||||
search_results.append({
|
search_results.append({
|
||||||
"content": doc.page_content,
|
"content": doc.page_content,
|
||||||
@@ -89,24 +184,9 @@ class DocumentService:
|
|||||||
return search_results
|
return search_results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"搜索文档失败: {str(e)}")
|
logger.error(f"搜索文档失败: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _convert_distance_to_score(self, distance: float) -> float:
|
|
||||||
"""将ChromaDB的distance转换为0-1范围的相似度分数"""
|
|
||||||
import math
|
|
||||||
|
|
||||||
# 内积距离(负值)
|
|
||||||
if distance < 0:
|
|
||||||
return (1 + distance) / 2
|
|
||||||
|
|
||||||
# 大距离使用对数缩放
|
|
||||||
if distance > 100:
|
|
||||||
return 1 / (1 + math.log(distance))
|
|
||||||
|
|
||||||
# 标准距离转换
|
|
||||||
return 1 / (1 + distance)
|
|
||||||
|
|
||||||
def get_document_chunks(self, document_id: int) -> List[DocumentChunk]:
|
def get_document_chunks(self, document_id: int) -> List[DocumentChunk]:
|
||||||
"""获取文档的所有块"""
|
"""获取文档的所有块"""
|
||||||
return self.db.query(DocumentChunk).filter(
|
return self.db.query(DocumentChunk).filter(
|
||||||
@@ -114,14 +194,17 @@ class DocumentService:
|
|||||||
).order_by(DocumentChunk.chunk_index).all()
|
).order_by(DocumentChunk.chunk_index).all()
|
||||||
|
|
||||||
def delete_document_chunks(self, document_id: int) -> bool:
|
def delete_document_chunks(self, document_id: int) -> bool:
|
||||||
"""删除文档的所有块"""
|
"""删除文档的所有块(数据库 + 向量存储)"""
|
||||||
try:
|
try:
|
||||||
|
# 删除向量存储中的文档数据
|
||||||
|
self.vector_store.delete_by_document_id(document_id)
|
||||||
|
# 删除数据库中的chunk记录
|
||||||
self.db.query(DocumentChunk).filter(
|
self.db.query(DocumentChunk).filter(
|
||||||
DocumentChunk.document_id == document_id
|
DocumentChunk.document_id == document_id
|
||||||
).delete()
|
).delete()
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"删除文档块失败: {str(e)}")
|
logger.error(f"删除文档块失败: {str(e)}")
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
return False
|
return False
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Callable
|
from typing import Optional, Callable
|
||||||
@@ -13,6 +14,7 @@ from ..core.config import get_settings
|
|||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
from .knowledge_base_service import KnowledgeBaseService
|
from .knowledge_base_service import KnowledgeBaseService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
@@ -27,19 +29,19 @@ class KnowledgeBaseHandler(FileSystemEventHandler):
|
|||||||
def on_created(self, event):
|
def on_created(self, event):
|
||||||
"""处理文件创建事件"""
|
"""处理文件创建事件"""
|
||||||
if not event.is_directory and self._is_supported_file(event.src_path):
|
if not event.is_directory and self._is_supported_file(event.src_path):
|
||||||
print(f"检测到新文件: {event.src_path}")
|
logger.info(f"检测到新文件: {event.src_path}")
|
||||||
self._process_file_async(event.src_path, "created")
|
self._process_file_async(event.src_path, "created")
|
||||||
|
|
||||||
def on_modified(self, event):
|
def on_modified(self, event):
|
||||||
"""处理文件修改事件"""
|
"""处理文件修改事件"""
|
||||||
if not event.is_directory and self._is_supported_file(event.src_path):
|
if not event.is_directory and self._is_supported_file(event.src_path):
|
||||||
print(f"检测到文件修改: {event.src_path}")
|
logger.info(f"检测到文件修改: {event.src_path}")
|
||||||
self._process_file_async(event.src_path, "modified")
|
self._process_file_async(event.src_path, "modified")
|
||||||
|
|
||||||
def on_deleted(self, event):
|
def on_deleted(self, event):
|
||||||
"""处理文件删除事件"""
|
"""处理文件删除事件"""
|
||||||
if not event.is_directory and self._is_supported_file(event.src_path):
|
if not event.is_directory and self._is_supported_file(event.src_path):
|
||||||
print(f"检测到文件删除: {event.src_path}")
|
logger.info(f"检测到文件删除: {event.src_path}")
|
||||||
self._handle_file_deletion(event.src_path)
|
self._handle_file_deletion(event.src_path)
|
||||||
|
|
||||||
def _is_supported_file(self, file_path: str) -> bool:
|
def _is_supported_file(self, file_path: str) -> bool:
|
||||||
@@ -54,10 +56,10 @@ class KnowledgeBaseHandler(FileSystemEventHandler):
|
|||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
result = self.kb_service.process_file(file_path)
|
result = self.kb_service.process_file(file_path)
|
||||||
print(f"文件处理结果 ({event_type}): {result}")
|
logger.info(f"文件处理结果 ({event_type}): {result}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"处理文件失败: {file_path}, 错误: {str(e)}")
|
logger.error(f"处理文件失败: {file_path}, 错误: {str(e)}")
|
||||||
|
|
||||||
# 在后台线程中处理
|
# 在后台线程中处理
|
||||||
thread = threading.Thread(target=process)
|
thread = threading.Thread(target=process)
|
||||||
@@ -80,12 +82,12 @@ class KnowledgeBaseHandler(FileSystemEventHandler):
|
|||||||
|
|
||||||
if document:
|
if document:
|
||||||
result = self.kb_service.delete_document(document.id)
|
result = self.kb_service.delete_document(document.id)
|
||||||
print(f"文件删除处理结果: {result}")
|
logger.info(f"文件删除处理结果: {result}")
|
||||||
else:
|
else:
|
||||||
print(f"未找到对应的数据库记录: {file_path}")
|
logger.info(f"未找到对应的数据库记录: {file_path}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"处理文件删除失败: {file_path}, 错误: {str(e)}")
|
logger.error(f"处理文件删除失败: {file_path}, 错误: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
class FileWatcherService:
|
class FileWatcherService:
|
||||||
@@ -99,15 +101,15 @@ class FileWatcherService:
|
|||||||
def start(self):
|
def start(self):
|
||||||
"""启动文件监控"""
|
"""启动文件监控"""
|
||||||
if self.is_running:
|
if self.is_running:
|
||||||
print("文件监控服务已在运行")
|
logger.info("文件监控服务已在运行")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not settings.enable_file_watcher:
|
if not settings.enable_file_watcher:
|
||||||
print("文件监控服务已禁用")
|
logger.info("文件监控服务已禁用")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.knowledge_base_dir.exists():
|
if not self.knowledge_base_dir.exists():
|
||||||
print(f"知识库目录不存在: {self.knowledge_base_dir}")
|
logger.info(f"知识库目录不存在: {self.knowledge_base_dir}")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -130,15 +132,19 @@ class FileWatcherService:
|
|||||||
self.observer.start()
|
self.observer.start()
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
|
|
||||||
print(f"文件监控服务已启动,监控目录: {self.knowledge_base_dir}")
|
logger.info(f"文件监控服务已启动,监控目录: {self.knowledge_base_dir}")
|
||||||
|
|
||||||
|
# 确保子目录对应的系统知识库存在
|
||||||
|
logger.info("确保系统知识库与目录同步...")
|
||||||
|
kb_service.ensure_system_knowledge_bases()
|
||||||
|
|
||||||
# 执行初始扫描
|
# 执行初始扫描
|
||||||
print("执行初始知识库扫描...")
|
logger.info("执行初始知识库扫描...")
|
||||||
scan_result = kb_service.scan_directory()
|
scan_result = kb_service.scan_directory()
|
||||||
print(f"初始扫描结果: {scan_result}")
|
logger.info(f"初始扫描结果: {scan_result}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"启动文件监控服务失败: {str(e)}")
|
logger.error(f"启动文件监控服务失败: {str(e)}")
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
@@ -147,7 +153,7 @@ class FileWatcherService:
|
|||||||
self.observer.stop()
|
self.observer.stop()
|
||||||
self.observer.join()
|
self.observer.join()
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
print("文件监控服务已停止")
|
logger.info("文件监控服务已停止")
|
||||||
|
|
||||||
def is_active(self) -> bool:
|
def is_active(self) -> bool:
|
||||||
"""检查监控服务是否活跃"""
|
"""检查监控服务是否活跃"""
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
import httpx
|
import httpx
|
||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ImageGenerationService:
|
class ImageGenerationService:
|
||||||
"""统一的图像生成服务基类"""
|
"""统一的图像生成服务基类"""
|
||||||
@@ -21,9 +24,9 @@ class ImageGenerationService:
|
|||||||
|
|
||||||
async def call_api(self, model: str, payload: dict) -> dict:
|
async def call_api(self, model: str, payload: dict) -> dict:
|
||||||
"""调用硅基流动 API"""
|
"""调用硅基流动 API"""
|
||||||
print(f"[DEBUG] 调用SiliconFlow API: {self.base_url}")
|
logger.debug(f"调用SiliconFlow API: {self.base_url}")
|
||||||
print(f"[DEBUG] 模型: {model}")
|
logger.debug(f"模型: {model}")
|
||||||
print(f"[DEBUG] 请求参数: {payload}")
|
logger.debug(f"请求参数: {payload}")
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
@@ -37,9 +40,9 @@ class ImageGenerationService:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
result = response.json()
|
result = response.json()
|
||||||
print(f"[DEBUG] API响应状态: {response.status_code}")
|
logger.debug(f"API响应状态: {response.status_code}")
|
||||||
print(f"[DEBUG] API响应类型: {type(result)}")
|
logger.debug(f"API响应类型: {type(result)}")
|
||||||
print(f"[DEBUG] API响应键: {list(result.keys()) if isinstance(result, dict) else '非字典类型'}")
|
logger.debug(f"API响应键: {list(result.keys()) if isinstance(result, dict) else '非字典类型'}")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ class ImageGenerationService:
|
|||||||
# 移除可能的空白字符
|
# 移除可能的空白字符
|
||||||
base64_data = base64_data.strip()
|
base64_data = base64_data.strip()
|
||||||
|
|
||||||
print(f"[DEBUG] 开始解码base64数据,长度: {len(base64_data)}")
|
logger.debug(f"开始解码base64数据,长度: {len(base64_data)}")
|
||||||
|
|
||||||
# 解码 base64 数据
|
# 解码 base64 数据
|
||||||
image_bytes = base64.b64decode(base64_data)
|
image_bytes = base64.b64decode(base64_data)
|
||||||
@@ -62,7 +65,7 @@ class ImageGenerationService:
|
|||||||
if len(image_bytes) == 0:
|
if len(image_bytes) == 0:
|
||||||
raise Exception("解码后的图像数据为空")
|
raise Exception("解码后的图像数据为空")
|
||||||
|
|
||||||
print(f"[DEBUG] 图像数据大小: {len(image_bytes)} bytes")
|
logger.debug(f"图像数据大小: {len(image_bytes)} bytes")
|
||||||
|
|
||||||
# 确保目录存在
|
# 确保目录存在
|
||||||
image_dir = Path(settings.generated_images_dir)
|
image_dir = Path(settings.generated_images_dir)
|
||||||
@@ -73,13 +76,13 @@ class ImageGenerationService:
|
|||||||
with open(image_path, "wb") as f:
|
with open(image_path, "wb") as f:
|
||||||
f.write(image_bytes)
|
f.write(image_bytes)
|
||||||
|
|
||||||
print(f"[DEBUG] 图像已保存到: {image_path}")
|
logger.debug(f"图像已保存到: {image_path}")
|
||||||
|
|
||||||
return str(image_path)
|
return str(image_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] 保存图像失败: {str(e)}")
|
logger.error(f"保存图像失败: {str(e)}")
|
||||||
print(f"[ERROR] base64数据长度: {len(base64_data) if base64_data else 0}")
|
logger.error(f"base64数据长度: {len(base64_data) if base64_data else 0}")
|
||||||
print(f"[ERROR] base64数据前100字符: {base64_data[:100] if base64_data else 'None'}")
|
logger.error(f"base64数据前100字符: {base64_data[:100] if base64_data else 'None'}")
|
||||||
raise Exception(f"保存图像失败: {str(e)}")
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
def generate_image_id(self) -> str:
|
def generate_image_id(self) -> str:
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
from .image_generation_service import ImageGenerationService
|
from .image_generation_service import ImageGenerationService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ImageToImageService(ImageGenerationService):
|
class ImageToImageService(ImageGenerationService):
|
||||||
"""图生图服务"""
|
"""图生图服务"""
|
||||||
@@ -59,13 +62,13 @@ class ImageToImageService(ImageGenerationService):
|
|||||||
result = await self.call_api(self.MODEL, payload)
|
result = await self.call_api(self.MODEL, payload)
|
||||||
|
|
||||||
# 调试信息
|
# 调试信息
|
||||||
print(f"API响应结构: {type(result)}")
|
logger.debug(f"API响应结构: {type(result)}")
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
print(f"响应键: {list(result.keys())}")
|
logger.debug(f"响应键: {list(result.keys())}")
|
||||||
if "images" in result:
|
if "images" in result:
|
||||||
print(f"图像数量: {len(result['images'])}")
|
logger.debug(f"图像数量: {len(result['images'])}")
|
||||||
if result["images"]:
|
if result["images"]:
|
||||||
print(f"第一个图像键: {list(result['images'][0].keys()) if isinstance(result['images'][0], dict) else '非字典类型'}")
|
logger.debug(f"第一个图像键: {list(result['images'][0].keys()) if isinstance(result['images'][0], dict) else '非字典类型'}")
|
||||||
|
|
||||||
# 保存结果
|
# 保存结果
|
||||||
image_id = self.generate_image_id()
|
image_id = self.generate_image_id()
|
||||||
@@ -73,56 +76,56 @@ class ImageToImageService(ImageGenerationService):
|
|||||||
|
|
||||||
# 检查是否为字典类型
|
# 检查是否为字典类型
|
||||||
if isinstance(edited_image_data, dict):
|
if isinstance(edited_image_data, dict):
|
||||||
print(f"[DEBUG] 图像数据是字典,键: {list(edited_image_data.keys())}")
|
logger.debug(f"图像数据是字典,键: {list(edited_image_data.keys())}")
|
||||||
|
|
||||||
# 优先检查URL字段
|
# 优先检查URL字段
|
||||||
if "url" in edited_image_data:
|
if "url" in edited_image_data:
|
||||||
image_url = edited_image_data["url"]
|
image_url = edited_image_data["url"]
|
||||||
print(f"[DEBUG] 检测到URL字段: {image_url}")
|
logger.info(f"检测到URL字段: {image_url}")
|
||||||
try:
|
try:
|
||||||
image_path = await self.download_image_from_url(image_id, image_url)
|
image_path = await self.download_image_from_url(image_id, image_url)
|
||||||
print(f"[DEBUG] URL下载成功: {image_path}")
|
logger.info(f"URL下载成功: {image_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
logger.error(f"URL下载失败: {str(e)}")
|
||||||
raise Exception(f"下载图像失败: {str(e)}")
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
|
||||||
# 如果没有URL,尝试base64字段
|
# 如果没有URL,尝试base64字段
|
||||||
elif any(key in edited_image_data for key in ["b64_json", "b64", "data"]):
|
elif any(key in edited_image_data for key in ["b64_json", "b64", "data"]):
|
||||||
image_b64 = edited_image_data.get("b64_json") or edited_image_data.get("b64") or edited_image_data.get("data")
|
image_b64 = edited_image_data.get("b64_json") or edited_image_data.get("b64") or edited_image_data.get("data")
|
||||||
print(f"[DEBUG] 检测到base64字段,长度: {len(image_b64) if image_b64 else 0}")
|
logger.debug(f"检测到base64字段,长度: {len(image_b64) if image_b64 else 0}")
|
||||||
try:
|
try:
|
||||||
image_path = self.save_image(image_id, image_b64)
|
image_path = self.save_image(image_id, image_b64)
|
||||||
print(f"[DEBUG] base64保存成功: {image_path}")
|
logger.info(f"base64保存成功: {image_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
logger.error(f"base64保存失败: {str(e)}")
|
||||||
raise Exception(f"保存图像失败: {str(e)}")
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"[ERROR] 未找到有效的图像数据字段")
|
logger.error(f"未找到有效的图像数据字段")
|
||||||
raise Exception("API响应中未找到有效的图像数据")
|
raise Exception("API响应中未找到有效的图像数据")
|
||||||
|
|
||||||
# 如果直接是字符串,判断是URL还是base64
|
# 如果直接是字符串,判断是URL还是base64
|
||||||
elif isinstance(edited_image_data, str):
|
elif isinstance(edited_image_data, str):
|
||||||
print(f"[DEBUG] 图像数据是字符串,长度: {len(edited_image_data)}")
|
logger.debug(f"图像数据是字符串,长度: {len(edited_image_data)}")
|
||||||
if edited_image_data.startswith('http'):
|
if edited_image_data.startswith('http'):
|
||||||
print(f"[DEBUG] 检测到URL字符串: {edited_image_data}")
|
logger.info(f"检测到URL字符串: {edited_image_data}")
|
||||||
try:
|
try:
|
||||||
image_path = await self.download_image_from_url(image_id, edited_image_data)
|
image_path = await self.download_image_from_url(image_id, edited_image_data)
|
||||||
print(f"[DEBUG] URL下载成功: {image_path}")
|
logger.info(f"URL下载成功: {image_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
logger.error(f"URL下载失败: {str(e)}")
|
||||||
raise Exception(f"下载图像失败: {str(e)}")
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
else:
|
else:
|
||||||
print(f"[DEBUG] 检测到base64字符串")
|
logger.debug(f"检测到base64字符串")
|
||||||
try:
|
try:
|
||||||
image_path = self.save_image(image_id, edited_image_data)
|
image_path = self.save_image(image_id, edited_image_data)
|
||||||
print(f"[DEBUG] base64保存成功: {image_path}")
|
logger.info(f"base64保存成功: {image_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
logger.error(f"base64保存失败: {str(e)}")
|
||||||
raise Exception(f"保存图像失败: {str(e)}")
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"[ERROR] 未知的图像数据类型: {type(edited_image_data)}")
|
logger.error(f"未知的图像数据类型: {type(edited_image_data)}")
|
||||||
raise Exception(f"不支持的图像数据类型: {type(edited_image_data)}")
|
raise Exception(f"不支持的图像数据类型: {type(edited_image_data)}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -165,70 +168,70 @@ class ImageToImageService(ImageGenerationService):
|
|||||||
result = await self.call_api(self.MODEL, payload)
|
result = await self.call_api(self.MODEL, payload)
|
||||||
|
|
||||||
# 调试信息
|
# 调试信息
|
||||||
print(f"API响应结构: {type(result)}")
|
logger.debug(f"API响应结构: {type(result)}")
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
print(f"响应键: {list(result.keys())}")
|
logger.debug(f"响应键: {list(result.keys())}")
|
||||||
if "images" in result:
|
if "images" in result:
|
||||||
print(f"图像数量: {len(result['images'])}")
|
logger.debug(f"图像数量: {len(result['images'])}")
|
||||||
|
|
||||||
# 处理变体结果
|
# 处理变体结果
|
||||||
variations = []
|
variations = []
|
||||||
for i, img_data in enumerate(result["images"]):
|
for i, img_data in enumerate(result["images"]):
|
||||||
print(f"[DEBUG] 处理第 {i+1} 个变体")
|
logger.debug(f"处理第 {i+1} 个变体")
|
||||||
image_id = self.generate_image_id()
|
image_id = self.generate_image_id()
|
||||||
|
|
||||||
# 检查是否为字典类型
|
# 检查是否为字典类型
|
||||||
if isinstance(img_data, dict):
|
if isinstance(img_data, dict):
|
||||||
print(f"[DEBUG] 图像数据是字典,键: {list(img_data.keys())}")
|
logger.debug(f"图像数据是字典,键: {list(img_data.keys())}")
|
||||||
|
|
||||||
# 优先检查URL字段
|
# 优先检查URL字段
|
||||||
if "url" in img_data:
|
if "url" in img_data:
|
||||||
image_url = img_data["url"]
|
image_url = img_data["url"]
|
||||||
print(f"[DEBUG] 检测到URL字段: {image_url}")
|
logger.info(f"检测到URL字段: {image_url}")
|
||||||
try:
|
try:
|
||||||
image_path = await self.download_image_from_url(image_id, image_url)
|
image_path = await self.download_image_from_url(image_id, image_url)
|
||||||
print(f"[DEBUG] URL下载成功: {image_path}")
|
logger.info(f"URL下载成功: {image_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
logger.error(f"URL下载失败: {str(e)}")
|
||||||
raise Exception(f"下载图像失败: {str(e)}")
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
|
||||||
# 如果没有URL,尝试base64字段
|
# 如果没有URL,尝试base64字段
|
||||||
elif any(key in img_data for key in ["b64_json", "b64", "data"]):
|
elif any(key in img_data for key in ["b64_json", "b64", "data"]):
|
||||||
image_b64 = img_data.get("b64_json") or img_data.get("b64") or img_data.get("data")
|
image_b64 = img_data.get("b64_json") or img_data.get("b64") or img_data.get("data")
|
||||||
print(f"[DEBUG] 检测到base64字段,长度: {len(image_b64) if image_b64 else 0}")
|
logger.debug(f"检测到base64字段,长度: {len(image_b64) if image_b64 else 0}")
|
||||||
try:
|
try:
|
||||||
image_path = self.save_image(image_id, image_b64)
|
image_path = self.save_image(image_id, image_b64)
|
||||||
print(f"[DEBUG] base64保存成功: {image_path}")
|
logger.info(f"base64保存成功: {image_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
logger.error(f"base64保存失败: {str(e)}")
|
||||||
raise Exception(f"保存图像失败: {str(e)}")
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"[ERROR] 未找到有效的图像数据字段")
|
logger.error(f"未找到有效的图像数据字段")
|
||||||
raise Exception("API响应中未找到有效的图像数据")
|
raise Exception("API响应中未找到有效的图像数据")
|
||||||
|
|
||||||
# 如果直接是字符串,判断是URL还是base64
|
# 如果直接是字符串,判断是URL还是base64
|
||||||
elif isinstance(img_data, str):
|
elif isinstance(img_data, str):
|
||||||
print(f"[DEBUG] 图像数据是字符串,长度: {len(img_data)}")
|
logger.debug(f"图像数据是字符串,长度: {len(img_data)}")
|
||||||
if img_data.startswith('http'):
|
if img_data.startswith('http'):
|
||||||
print(f"[DEBUG] 检测到URL字符串: {img_data}")
|
logger.info(f"检测到URL字符串: {img_data}")
|
||||||
try:
|
try:
|
||||||
image_path = await self.download_image_from_url(image_id, img_data)
|
image_path = await self.download_image_from_url(image_id, img_data)
|
||||||
print(f"[DEBUG] URL下载成功: {image_path}")
|
logger.info(f"URL下载成功: {image_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
logger.error(f"URL下载失败: {str(e)}")
|
||||||
raise Exception(f"下载图像失败: {str(e)}")
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
else:
|
else:
|
||||||
print(f"[DEBUG] 检测到base64字符串")
|
logger.debug(f"检测到base64字符串")
|
||||||
try:
|
try:
|
||||||
image_path = self.save_image(image_id, img_data)
|
image_path = self.save_image(image_id, img_data)
|
||||||
print(f"[DEBUG] base64保存成功: {image_path}")
|
logger.info(f"base64保存成功: {image_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
logger.error(f"base64保存失败: {str(e)}")
|
||||||
raise Exception(f"保存图像失败: {str(e)}")
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"[ERROR] 未知的图像数据类型: {type(img_data)}")
|
logger.error(f"未知的图像数据类型: {type(img_data)}")
|
||||||
raise Exception(f"不支持的图像数据类型: {type(img_data)}")
|
raise Exception(f"不支持的图像数据类型: {type(img_data)}")
|
||||||
|
|
||||||
variations.append({
|
variations.append({
|
||||||
@@ -320,7 +323,7 @@ class ImageToImageService(ImageGenerationService):
|
|||||||
async def download_image_from_url(self, image_id: str, image_url: str) -> str:
|
async def download_image_from_url(self, image_id: str, image_url: str) -> str:
|
||||||
"""从URL下载图像"""
|
"""从URL下载图像"""
|
||||||
try:
|
try:
|
||||||
print(f"[DEBUG] 下载图像URL: {image_url}")
|
logger.debug(f"下载图像URL: {image_url}")
|
||||||
|
|
||||||
# 下载图像
|
# 下载图像
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
@@ -331,7 +334,7 @@ class ImageToImageService(ImageGenerationService):
|
|||||||
if len(image_bytes) == 0:
|
if len(image_bytes) == 0:
|
||||||
raise Exception("下载的图像数据为空")
|
raise Exception("下载的图像数据为空")
|
||||||
|
|
||||||
print(f"[DEBUG] 下载图像数据大小: {len(image_bytes)} bytes")
|
logger.debug(f"下载图像数据大小: {len(image_bytes)} bytes")
|
||||||
|
|
||||||
# 确保目录存在
|
# 确保目录存在
|
||||||
image_dir = Path(self.get_image_dir())
|
image_dir = Path(self.get_image_dir())
|
||||||
@@ -342,11 +345,11 @@ class ImageToImageService(ImageGenerationService):
|
|||||||
with open(image_path, "wb") as f:
|
with open(image_path, "wb") as f:
|
||||||
f.write(image_bytes)
|
f.write(image_bytes)
|
||||||
|
|
||||||
print(f"[DEBUG] 图像已保存到: {image_path}")
|
logger.debug(f"图像已保存到: {image_path}")
|
||||||
return str(image_path)
|
return str(image_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] 下载图像失败: {str(e)}")
|
logger.error(f"下载图像失败: {str(e)}")
|
||||||
raise Exception(f"下载图像失败: {str(e)}")
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
|
||||||
def get_image_dir(self) -> str:
|
def get_image_dir(self) -> str:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
知识库管理服务
|
知识库管理服务
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,9 +11,13 @@ 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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +39,9 @@ class KnowledgeBaseService:
|
|||||||
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,
|
||||||
@@ -70,6 +78,72 @@ class KnowledgeBaseService:
|
|||||||
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)
|
||||||
|
logger.info(f"升级为系统知识库: {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)
|
||||||
|
logger.info(f"自动创建系统知识库: {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:
|
||||||
@@ -106,11 +180,20 @@ class KnowledgeBaseService:
|
|||||||
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)}
|
||||||
@@ -169,12 +252,12 @@ class KnowledgeBaseService:
|
|||||||
loop.run_until_complete(self.document_service.process_document(document.id))
|
loop.run_until_complete(self.document_service.process_document(document.id))
|
||||||
loop.close()
|
loop.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"后台处理文档 {document.id} 失败: {e}")
|
logger.error(f"后台处理文档 {document.id} 失败: {e}")
|
||||||
|
|
||||||
thread = threading.Thread(target=process_in_background, daemon=True)
|
thread = threading.Thread(target=process_in_background, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"启动文档处理线程失败: {e}")
|
logger.error(f"启动文档处理线程失败: {e}")
|
||||||
|
|
||||||
return {"status": "new", "document_id": document.id, "message": "文档创建成功,正在处理中"}
|
return {"status": "new", "document_id": document.id, "message": "文档创建成功,正在处理中"}
|
||||||
|
|
||||||
@@ -207,12 +290,12 @@ class KnowledgeBaseService:
|
|||||||
loop.run_until_complete(self.document_service.process_document(document.id))
|
loop.run_until_complete(self.document_service.process_document(document.id))
|
||||||
loop.close()
|
loop.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"后台处理文档 {document.id} 失败: {e}")
|
logger.error(f"后台处理文档 {document.id} 失败: {e}")
|
||||||
|
|
||||||
thread = threading.Thread(target=process_in_background, daemon=True)
|
thread = threading.Thread(target=process_in_background, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"启动文档处理线程失败: {e}")
|
logger.error(f"启动文档处理线程失败: {e}")
|
||||||
|
|
||||||
return {"status": "updated", "document_id": document.id, "message": "文档更新成功,正在重新处理中"}
|
return {"status": "updated", "document_id": document.id, "message": "文档更新成功,正在重新处理中"}
|
||||||
|
|
||||||
@@ -402,7 +485,7 @@ class KnowledgeBaseService:
|
|||||||
all_files = [f for f in directory.rglob("*") if f.is_file() and self._is_supported_file(f)]
|
all_files = [f for f in directory.rglob("*") if f.is_file() and self._is_supported_file(f)]
|
||||||
total_files = len(all_files)
|
total_files = len(all_files)
|
||||||
|
|
||||||
print(f" 找到 {total_files} 个支持的文件,开始处理...")
|
logger.info(f"找到 {total_files} 个支持的文件,开始处理...")
|
||||||
|
|
||||||
for idx, file_path in enumerate(all_files, 1):
|
for idx, file_path in enumerate(all_files, 1):
|
||||||
try:
|
try:
|
||||||
@@ -426,7 +509,7 @@ class KnowledgeBaseService:
|
|||||||
# 显示进度(每10个文件或最后一个文件时显示)
|
# 显示进度(每10个文件或最后一个文件时显示)
|
||||||
if idx % 10 == 0 or idx == total_files:
|
if idx % 10 == 0 or idx == total_files:
|
||||||
percentage = (idx * 100) // total_files if total_files > 0 else 0
|
percentage = (idx * 100) // total_files if total_files > 0 else 0
|
||||||
print(f"\r 处理进度: {idx}/{total_files} ({percentage}%)", end="", flush=True)
|
logger.info(f"处理进度: {idx}/{total_files} ({percentage}%)")
|
||||||
|
|
||||||
if existing_doc:
|
if existing_doc:
|
||||||
# 检查是否需要更新
|
# 检查是否需要更新
|
||||||
@@ -469,6 +552,6 @@ class KnowledgeBaseService:
|
|||||||
"error": str(e)
|
"error": str(e)
|
||||||
})
|
})
|
||||||
|
|
||||||
print() # 换行
|
logger.info("文件处理完成")
|
||||||
results["success"] = len(results["errors"]) == 0
|
results["success"] = len(results["errors"]) == 0
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
支持 Kwai-Kolors/Kolors 和 Qwen/Qwen-Image 模型
|
支持 Kwai-Kolors/Kolors 和 Qwen/Qwen-Image 模型
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
import httpx
|
import httpx
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from .image_generation_service import ImageGenerationService
|
from .image_generation_service import ImageGenerationService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TextToImageService(ImageGenerationService):
|
class TextToImageService(ImageGenerationService):
|
||||||
"""文生图服务"""
|
"""文生图服务"""
|
||||||
@@ -73,72 +76,72 @@ class TextToImageService(ImageGenerationService):
|
|||||||
result = await self.call_api(self.MODELS[model], payload)
|
result = await self.call_api(self.MODELS[model], payload)
|
||||||
|
|
||||||
# 调试信息
|
# 调试信息
|
||||||
print(f"API响应结构: {type(result)}")
|
logger.debug(f"API响应结构: {type(result)}")
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
print(f"响应键: {list(result.keys())}")
|
logger.debug(f"响应键: {list(result.keys())}")
|
||||||
if "images" in result:
|
if "images" in result:
|
||||||
print(f"图像数量: {len(result['images'])}")
|
logger.debug(f"图像数量: {len(result['images'])}")
|
||||||
if result["images"]:
|
if result["images"]:
|
||||||
print(f"第一个图像键: {list(result['images'][0].keys()) if isinstance(result['images'][0], dict) else '非字典类型'}")
|
logger.debug(f"第一个图像键: {list(result['images'][0].keys()) if isinstance(result['images'][0], dict) else '非字典类型'}")
|
||||||
|
|
||||||
# 处理结果
|
# 处理结果
|
||||||
images = []
|
images = []
|
||||||
for i, img_data in enumerate(result.get("images", [])):
|
for i, img_data in enumerate(result.get("images", [])):
|
||||||
print(f"[DEBUG] 处理第 {i+1} 张图像")
|
logger.debug(f"处理第 {i+1} 张图像")
|
||||||
image_id = self.generate_image_id()
|
image_id = self.generate_image_id()
|
||||||
|
|
||||||
# 检查是否为字典类型
|
# 检查是否为字典类型
|
||||||
if isinstance(img_data, dict):
|
if isinstance(img_data, dict):
|
||||||
print(f"[DEBUG] 图像数据是字典,键: {list(img_data.keys())}")
|
logger.debug(f"图像数据是字典,键: {list(img_data.keys())}")
|
||||||
|
|
||||||
# 优先检查URL字段
|
# 优先检查URL字段
|
||||||
if "url" in img_data:
|
if "url" in img_data:
|
||||||
image_url = img_data["url"]
|
image_url = img_data["url"]
|
||||||
print(f"[DEBUG] 检测到URL字段: {image_url}")
|
logger.info(f"检测到URL字段: {image_url}")
|
||||||
try:
|
try:
|
||||||
image_path = await self.download_image_from_url(image_id, image_url)
|
image_path = await self.download_image_from_url(image_id, image_url)
|
||||||
print(f"[DEBUG] URL下载成功: {image_path}")
|
logger.info(f"URL下载成功: {image_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
logger.error(f"URL下载失败: {str(e)}")
|
||||||
raise Exception(f"下载图像失败: {str(e)}")
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
|
||||||
# 如果没有URL,尝试base64字段
|
# 如果没有URL,尝试base64字段
|
||||||
elif any(key in img_data for key in ["b64_json", "b64", "data"]):
|
elif any(key in img_data for key in ["b64_json", "b64", "data"]):
|
||||||
image_b64 = img_data.get("b64_json") or img_data.get("b64") or img_data.get("data")
|
image_b64 = img_data.get("b64_json") or img_data.get("b64") or img_data.get("data")
|
||||||
print(f"[DEBUG] 检测到base64字段,长度: {len(image_b64) if image_b64 else 0}")
|
logger.debug(f"检测到base64字段,长度: {len(image_b64) if image_b64 else 0}")
|
||||||
try:
|
try:
|
||||||
image_path = self.save_image(image_id, image_b64)
|
image_path = self.save_image(image_id, image_b64)
|
||||||
print(f"[DEBUG] base64保存成功: {image_path}")
|
logger.info(f"base64保存成功: {image_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
logger.error(f"base64保存失败: {str(e)}")
|
||||||
raise Exception(f"保存图像失败: {str(e)}")
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"[ERROR] 未找到有效的图像数据字段")
|
logger.error(f"未找到有效的图像数据字段")
|
||||||
raise Exception("API响应中未找到有效的图像数据")
|
raise Exception("API响应中未找到有效的图像数据")
|
||||||
|
|
||||||
# 如果直接是字符串,判断是URL还是base64
|
# 如果直接是字符串,判断是URL还是base64
|
||||||
elif isinstance(img_data, str):
|
elif isinstance(img_data, str):
|
||||||
print(f"[DEBUG] 图像数据是字符串,长度: {len(img_data)}")
|
logger.debug(f"图像数据是字符串,长度: {len(img_data)}")
|
||||||
if img_data.startswith('http'):
|
if img_data.startswith('http'):
|
||||||
print(f"[DEBUG] 检测到URL字符串: {img_data}")
|
logger.info(f"检测到URL字符串: {img_data}")
|
||||||
try:
|
try:
|
||||||
image_path = await self.download_image_from_url(image_id, img_data)
|
image_path = await self.download_image_from_url(image_id, img_data)
|
||||||
print(f"[DEBUG] URL下载成功: {image_path}")
|
logger.info(f"URL下载成功: {image_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
logger.error(f"URL下载失败: {str(e)}")
|
||||||
raise Exception(f"下载图像失败: {str(e)}")
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
else:
|
else:
|
||||||
print(f"[DEBUG] 检测到base64字符串")
|
logger.debug(f"检测到base64字符串")
|
||||||
try:
|
try:
|
||||||
image_path = self.save_image(image_id, img_data)
|
image_path = self.save_image(image_id, img_data)
|
||||||
print(f"[DEBUG] base64保存成功: {image_path}")
|
logger.info(f"base64保存成功: {image_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
logger.error(f"base64保存失败: {str(e)}")
|
||||||
raise Exception(f"保存图像失败: {str(e)}")
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"[ERROR] 未知的图像数据类型: {type(img_data)}")
|
logger.error(f"未知的图像数据类型: {type(img_data)}")
|
||||||
raise Exception(f"不支持的图像数据类型: {type(img_data)}")
|
raise Exception(f"不支持的图像数据类型: {type(img_data)}")
|
||||||
|
|
||||||
images.append({
|
images.append({
|
||||||
@@ -182,7 +185,7 @@ class TextToImageService(ImageGenerationService):
|
|||||||
async def download_image_from_url(self, image_id: str, image_url: str) -> str:
|
async def download_image_from_url(self, image_id: str, image_url: str) -> str:
|
||||||
"""从URL下载图像"""
|
"""从URL下载图像"""
|
||||||
try:
|
try:
|
||||||
print(f"[DEBUG] 下载图像URL: {image_url}")
|
logger.debug(f"下载图像URL: {image_url}")
|
||||||
|
|
||||||
# 下载图像
|
# 下载图像
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
@@ -193,7 +196,7 @@ class TextToImageService(ImageGenerationService):
|
|||||||
if len(image_bytes) == 0:
|
if len(image_bytes) == 0:
|
||||||
raise Exception("下载的图像数据为空")
|
raise Exception("下载的图像数据为空")
|
||||||
|
|
||||||
print(f"[DEBUG] 下载图像数据大小: {len(image_bytes)} bytes")
|
logger.debug(f"下载图像数据大小: {len(image_bytes)} bytes")
|
||||||
|
|
||||||
# 确保目录存在
|
# 确保目录存在
|
||||||
image_dir = Path(self.get_image_dir())
|
image_dir = Path(self.get_image_dir())
|
||||||
@@ -204,11 +207,11 @@ class TextToImageService(ImageGenerationService):
|
|||||||
with open(image_path, "wb") as f:
|
with open(image_path, "wb") as f:
|
||||||
f.write(image_bytes)
|
f.write(image_bytes)
|
||||||
|
|
||||||
print(f"[DEBUG] 图像已保存到: {image_path}")
|
logger.debug(f"图像已保存到: {image_path}")
|
||||||
return str(image_path)
|
return str(image_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] 下载图像失败: {str(e)}")
|
logger.error(f"下载图像失败: {str(e)}")
|
||||||
raise Exception(f"下载图像失败: {str(e)}")
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
|
||||||
def get_image_dir(self) -> str:
|
def get_image_dir(self) -> str:
|
||||||
|
|||||||
Generated
+19
-1
@@ -1,5 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 2
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.13'",
|
"python_full_version >= '3.13'",
|
||||||
@@ -443,6 +443,7 @@ dependencies = [
|
|||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "pydantic", extra = ["email"] },
|
{ name = "pydantic", extra = ["email"] },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "pymupdf" },
|
||||||
{ name = "pypdf" },
|
{ name = "pypdf" },
|
||||||
{ name = "pypdf2" },
|
{ name = "pypdf2" },
|
||||||
{ name = "python-docx" },
|
{ name = "python-docx" },
|
||||||
@@ -492,6 +493,7 @@ requires-dist = [
|
|||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.0" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.0" },
|
||||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.5.0" },
|
{ name = "pydantic", extras = ["email"], specifier = ">=2.5.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.1.0" },
|
{ name = "pydantic-settings", specifier = ">=2.1.0" },
|
||||||
|
{ name = "pymupdf", specifier = ">=1.27.2.3" },
|
||||||
{ name = "pypdf", specifier = ">=6.12.0" },
|
{ name = "pypdf", specifier = ">=6.12.0" },
|
||||||
{ name = "pypdf2", specifier = ">=3.0.0" },
|
{ name = "pypdf2", specifier = ">=3.0.0" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" },
|
||||||
@@ -2893,6 +2895,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pymupdf"
|
||||||
|
version = "1.27.2.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/22/32/708bedc9dde7b328d45abbc076091769d44f2f24ad151ad92d56a6ec142b/pymupdf-1.27.2.3.tar.gz", hash = "sha256:7a92faa25129e8bbec5e50eeb9214f187665428c31b05c4ef6e36c58c0b1c6d2", size = 85759618, upload-time = "2026-04-24T14:13:14.42Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/09/ddbdfa7ee91fbabd6f63d7d744884cbdfe3e7ff9b8604749fb38bddf5c5d/pymupdf-1.27.2.3-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc1bc3cae6e9e150b0dbb0a9221bdfd411d65f0db2fe359eaa22467d7cc2a05f", size = 24002636, upload-time = "2026-04-24T14:09:17.459Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/89/3f8edd6c4f50ca370e2a2f2a3011face36f3760728ffe76dffec91c0fca0/pymupdf-1.27.2.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:660d93cb6da5bbddf11d3982ae27745dd3a9902d9f24cdb69adab83962294b5a", size = 23278238, upload-time = "2026-04-24T14:09:32.882Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/26/b7e5a70eb83bd189f8b5df87ec442746b992f2f632662839b288170d357d/pymupdf-1.27.2.3-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1dd460a3ae4597a755f00a3bd9771f5ebf1531dc111f6a36bf05dd00a6b84425", size = 24333923, upload-time = "2026-04-24T14:09:47.341Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/a0/aa1ee2240f29481a04a827c313333b4ecd8a14d6ac3e15d3f41a30574781/pymupdf-1.27.2.3-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:857842b4888827bd6155a1131341b2822a7ebe9a8c15a975fd7d490d7a64a30c", size = 24963198, upload-time = "2026-04-24T14:10:07.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/49/4f742451f980840829fc00ba158bebb25d389c846d8f4f8c65936ee55de8/pymupdf-1.27.2.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:580983849c64a08d08344ca3d1580e87c01f046a8392421797bc850efd72a5b6", size = 25184609, upload-time = "2026-04-24T14:10:22.911Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/3f/3853d6608f394faf6eec2bd4e8ea9f6a00beea329b071abdb29f4164cc3d/pymupdf-1.27.2.3-cp310-abi3-win32.whl", hash = "sha256:a5c1088a87189891a4946ab314a14b7934ac4c5b6077f7e74ebee956f8906d0e", size = 18019286, upload-time = "2026-04-24T14:10:34.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/47/5fb10fe73f96b31253a41647c362ea9e0380920bddf16028414a051247fc/pymupdf-1.27.2.3-cp310-abi3-win_amd64.whl", hash = "sha256:d20f68ef15195e073071dbc4ae7455257c7889af7584e39df490c0a92728526e", size = 19249102, upload-time = "2026-04-24T14:10:46.72Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/a4/b9e91aac82293f9c954654c85581ee8212b5b05efadc534b581141241e6f/pymupdf-1.27.2.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:77691604c5d1d0233827139bbcdea61fd57879c84712b8e49b1f45520f7ab9c2", size = 25000393, upload-time = "2026-04-24T14:11:01.669Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pypdf"
|
name = "pypdf"
|
||||||
version = "6.12.0"
|
version = "6.12.0"
|
||||||
|
|||||||
+3
-3
@@ -6,7 +6,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: course_agent_db
|
POSTGRES_DB: course_agent_db
|
||||||
POSTGRES_USER: user
|
POSTGRES_USER: user
|
||||||
POSTGRES_PASSWORD: password
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
|
||||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- "8001:8001" # 前端应用
|
- "8001:8001" # 前端应用
|
||||||
environment:
|
environment:
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
DATABASE_URL: postgresql+psycopg://user:password@db:5432/course_agent_db
|
DATABASE_URL: postgresql+psycopg://user:${POSTGRES_PASSWORD:-password}@db:5432/course_agent_db
|
||||||
|
|
||||||
# JWT配置
|
# JWT配置
|
||||||
SECRET_KEY: ${SECRET_KEY:-your-super-secret-key-change-in-production}
|
SECRET_KEY: ${SECRET_KEY:-your-super-secret-key-change-in-production}
|
||||||
@@ -43,7 +43,7 @@ services:
|
|||||||
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
||||||
|
|
||||||
# 硅基流动API配置
|
# 硅基流动API配置
|
||||||
SILICONFLOW_API_KEY: ${SILICONFLOW_API_KEY:-sk-pvvtosiglncktlucwarxilvsypqcttqizgpcfdvodgcuaezn}
|
SILICONFLOW_API_KEY: ${SILICONFLOW_API_KEY:?请设置 SILICONFLOW_API_KEY 环境变量}
|
||||||
SILICONFLOW_BASE_URL: ${SILICONFLOW_BASE_URL:-https://api.siliconflow.cn/v1}
|
SILICONFLOW_BASE_URL: ${SILICONFLOW_BASE_URL:-https://api.siliconflow.cn/v1}
|
||||||
SILICONFLOW_MODEL: ${SILICONFLOW_MODEL:-Qwen/Qwen3-30B-A3B-Thinking-2507}
|
SILICONFLOW_MODEL: ${SILICONFLOW_MODEL:-Qwen/Qwen3-30B-A3B-Thinking-2507}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ const nextConfig = {
|
|||||||
source: '/generated_images/:path*',
|
source: '/generated_images/:path*',
|
||||||
destination: `${backendUrl}/generated_images/:path*`,
|
destination: `${backendUrl}/generated_images/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: '/images/:path*',
|
||||||
|
destination: `${backendUrl}/images/:path*`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
// 禁用静态生成,避免 SSR 时使用浏览器 API 的错误
|
// 禁用静态生成,避免 SSR 时使用浏览器 API 的错误
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.3",
|
||||||
|
|||||||
Generated
+50
@@ -131,6 +131,9 @@ importers:
|
|||||||
rehype-katex:
|
rehype-katex:
|
||||||
specifier: ^7.0.1
|
specifier: ^7.0.1
|
||||||
version: 7.0.1
|
version: 7.0.1
|
||||||
|
rehype-raw:
|
||||||
|
specifier: ^7.0.0
|
||||||
|
version: 7.0.0
|
||||||
remark-gfm:
|
remark-gfm:
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
@@ -1260,6 +1263,7 @@ packages:
|
|||||||
|
|
||||||
'@ungap/structured-clone@1.3.0':
|
'@ungap/structured-clone@1.3.0':
|
||||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||||
|
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||||
|
|
||||||
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||||
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
|
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
|
||||||
@@ -2099,9 +2103,15 @@ packages:
|
|||||||
hast-util-parse-selector@4.0.0:
|
hast-util-parse-selector@4.0.0:
|
||||||
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
|
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
|
||||||
|
|
||||||
|
hast-util-raw@9.1.0:
|
||||||
|
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
|
||||||
|
|
||||||
hast-util-to-jsx-runtime@2.3.6:
|
hast-util-to-jsx-runtime@2.3.6:
|
||||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||||
|
|
||||||
|
hast-util-to-parse5@8.0.1:
|
||||||
|
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
|
||||||
|
|
||||||
hast-util-to-text@4.0.2:
|
hast-util-to-text@4.0.2:
|
||||||
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
|
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
|
||||||
|
|
||||||
@@ -2127,6 +2137,9 @@ packages:
|
|||||||
html-url-attributes@3.0.1:
|
html-url-attributes@3.0.1:
|
||||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||||
|
|
||||||
|
html-void-elements@3.0.0:
|
||||||
|
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -3000,6 +3013,9 @@ packages:
|
|||||||
rehype-katex@7.0.1:
|
rehype-katex@7.0.1:
|
||||||
resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
|
resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
|
||||||
|
|
||||||
|
rehype-raw@7.0.0:
|
||||||
|
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
|
||||||
|
|
||||||
remark-gfm@4.0.1:
|
remark-gfm@4.0.1:
|
||||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||||
|
|
||||||
@@ -5450,6 +5466,22 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
|
|
||||||
|
hast-util-raw@9.1.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
'@types/unist': 3.0.3
|
||||||
|
'@ungap/structured-clone': 1.3.0
|
||||||
|
hast-util-from-parse5: 8.0.3
|
||||||
|
hast-util-to-parse5: 8.0.1
|
||||||
|
html-void-elements: 3.0.0
|
||||||
|
mdast-util-to-hast: 13.2.0
|
||||||
|
parse5: 7.3.0
|
||||||
|
unist-util-position: 5.0.0
|
||||||
|
unist-util-visit: 5.0.0
|
||||||
|
vfile: 6.0.3
|
||||||
|
web-namespaces: 2.0.1
|
||||||
|
zwitch: 2.0.4
|
||||||
|
|
||||||
hast-util-to-jsx-runtime@2.3.6:
|
hast-util-to-jsx-runtime@2.3.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@@ -5470,6 +5502,16 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
hast-util-to-parse5@8.0.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
comma-separated-tokens: 2.0.3
|
||||||
|
devlop: 1.1.0
|
||||||
|
property-information: 7.1.0
|
||||||
|
space-separated-tokens: 2.0.2
|
||||||
|
web-namespaces: 2.0.1
|
||||||
|
zwitch: 2.0.4
|
||||||
|
|
||||||
hast-util-to-text@4.0.2:
|
hast-util-to-text@4.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
@@ -5505,6 +5547,8 @@ snapshots:
|
|||||||
|
|
||||||
html-url-attributes@3.0.1: {}
|
html-url-attributes@3.0.1: {}
|
||||||
|
|
||||||
|
html-void-elements@3.0.0: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
@@ -6552,6 +6596,12 @@ snapshots:
|
|||||||
unist-util-visit-parents: 6.0.1
|
unist-util-visit-parents: 6.0.1
|
||||||
vfile: 6.0.3
|
vfile: 6.0.3
|
||||||
|
|
||||||
|
rehype-raw@7.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
hast-util-raw: 9.1.0
|
||||||
|
vfile: 6.0.3
|
||||||
|
|
||||||
remark-gfm@4.0.1:
|
remark-gfm@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function LoginPage() {
|
|||||||
href="/"
|
href="/"
|
||||||
className="flex items-center justify-center space-x-3 mb-6 group cursor-pointer transition-opacity hover:opacity-80"
|
className="flex items-center justify-center space-x-3 mb-6 group cursor-pointer transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-shadow">
|
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center shadow-md group-hover:shadow-lg transition-shadow">
|
||||||
<BookOpen className="w-7 h-7 text-white" />
|
<BookOpen className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold">
|
<span className="text-2xl font-bold">
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function RegisterPage() {
|
|||||||
href="/"
|
href="/"
|
||||||
className="flex items-center justify-center space-x-3 mb-6 group cursor-pointer transition-opacity hover:opacity-80"
|
className="flex items-center justify-center space-x-3 mb-6 group cursor-pointer transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-shadow">
|
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center shadow-md group-hover:shadow-lg transition-shadow">
|
||||||
<BookOpen className="w-7 h-7 text-white" />
|
<BookOpen className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold">
|
<span className="text-2xl font-bold">
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { BookOpen } from "lucide-react";
|
||||||
|
import { courseContentAPI } from "@/lib/api";
|
||||||
|
|
||||||
|
interface Subsection {
|
||||||
|
id: number;
|
||||||
|
subsection_number: number;
|
||||||
|
title: string;
|
||||||
|
display_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
id: number;
|
||||||
|
section_number: number;
|
||||||
|
title: string;
|
||||||
|
display_order: number;
|
||||||
|
subsections: Subsection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chapter {
|
||||||
|
id: number;
|
||||||
|
chapter_number: number;
|
||||||
|
title: string;
|
||||||
|
display_order: number;
|
||||||
|
sections: Section[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CourseAdminPage() {
|
||||||
|
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
courseContentAPI.getCourseContent()
|
||||||
|
.then((data) => setChapters(data.chapters || []))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = (key: string) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-muted-foreground text-sm py-12 text-center">加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSections = chapters.reduce((a, c) => a + c.sections.length, 0);
|
||||||
|
const totalSubsections = chapters.reduce(
|
||||||
|
(a, c) => a + c.sections.reduce((b, s) => b + s.subsections.length, 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BookOpen className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">课程内容管理</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="rounded-xl border p-4 text-center">
|
||||||
|
<div className="text-2xl font-semibold font-mono tabular-nums">{chapters.length}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">章</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border p-4 text-center">
|
||||||
|
<div className="text-2xl font-semibold font-mono tabular-nums">{totalSections}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">节</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border p-4 text-center">
|
||||||
|
<div className="text-2xl font-semibold font-mono tabular-nums">{totalSubsections}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">知识点</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chapters.length === 0 ? (
|
||||||
|
<div className="rounded-xl border py-12 text-center text-muted-foreground text-sm">
|
||||||
|
暂无课程内容数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{chapters.map((chapter) => {
|
||||||
|
const chKey = `ch-${chapter.id}`;
|
||||||
|
return (
|
||||||
|
<div key={chapter.id} className="rounded-xl border overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(chKey)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs font-mono text-muted-foreground w-8">
|
||||||
|
{chapter.chapter_number}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-sm">{chapter.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{chapter.sections.length} 节
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-muted-foreground transition-transform ${expanded.has(chKey) ? "rotate-90" : ""}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded.has(chKey) && (
|
||||||
|
<div className="border-t">
|
||||||
|
{chapter.sections.map((section) => {
|
||||||
|
const secKey = `sec-${section.id}`;
|
||||||
|
return (
|
||||||
|
<div key={section.id} className="border-b last:border-0">
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(secKey)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2.5 pl-10 hover:bg-muted/20 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs font-mono text-muted-foreground w-8">
|
||||||
|
{section.section_number}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">{section.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{section.subsections.length} 知识点
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className={`w-3.5 h-3.5 text-muted-foreground transition-transform ${expanded.has(secKey) ? "rotate-90" : ""}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded.has(secKey) && section.subsections.length > 0 && (
|
||||||
|
<div className="border-t bg-muted/10">
|
||||||
|
{section.subsections.map((sub) => (
|
||||||
|
<div
|
||||||
|
key={sub.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-2 pl-16 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-mono w-8">{sub.subsection_number}</span>
|
||||||
|
<span>{sub.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { adminAPI } from "@/lib/api";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { MessageSquare, Plus, Trash2, RefreshCw, Pencil } from "lucide-react";
|
||||||
|
|
||||||
|
interface AdminCategory {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
post_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminPost {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
author_name: string;
|
||||||
|
category_name: string;
|
||||||
|
reply_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForumAdminPage() {
|
||||||
|
const [tab, setTab] = useState<"categories" | "posts">("categories");
|
||||||
|
const [categories, setCategories] = useState<AdminCategory[]>([]);
|
||||||
|
const [posts, setPosts] = useState<AdminPost[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<{ type: "cat" | "post"; id: number } | null>(null);
|
||||||
|
|
||||||
|
// Category form
|
||||||
|
const [showCatForm, setShowCatForm] = useState(false);
|
||||||
|
const [editingCat, setEditingCat] = useState<AdminCategory | null>(null);
|
||||||
|
const [catForm, setCatForm] = useState({ name: "", slug: "", description: "" });
|
||||||
|
|
||||||
|
const loadCategories = () => {
|
||||||
|
adminAPI.listForumCategories()
|
||||||
|
.then(setCategories)
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPosts = () => {
|
||||||
|
adminAPI.listForumPosts()
|
||||||
|
.then(setPosts)
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([loadCategories(), loadPosts()]).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveCategory = async () => {
|
||||||
|
try {
|
||||||
|
if (editingCat) {
|
||||||
|
await adminAPI.updateForumCategory(editingCat.id, catForm);
|
||||||
|
} else {
|
||||||
|
await adminAPI.createForumCategory(catForm);
|
||||||
|
}
|
||||||
|
setShowCatForm(false);
|
||||||
|
setEditingCat(null);
|
||||||
|
setCatForm({ name: "", slug: "", description: "" });
|
||||||
|
loadCategories();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCategory = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await adminAPI.deleteForumCategory(id);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
loadCategories();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePost = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await adminAPI.deleteForumPost(id);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
loadPosts();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditCat = (cat: AdminCategory) => {
|
||||||
|
setEditingCat(cat);
|
||||||
|
setCatForm({ name: cat.name, slug: cat.slug, description: cat.description || "" });
|
||||||
|
setShowCatForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-muted-foreground text-sm py-12 text-center">加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MessageSquare className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">论坛管理</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("categories")}
|
||||||
|
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
|
||||||
|
tab === "categories" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
分类管理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("posts")}
|
||||||
|
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
|
||||||
|
tab === "posts" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
帖子管理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "categories" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!showCatForm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingCat(null); setCatForm({ name: "", slug: "", description: "" }); setShowCatForm(true); }}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
新增分类
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border p-4 space-y-3">
|
||||||
|
<h3 className="text-sm font-medium">{editingCat ? "编辑分类" : "新增分类"}</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">名称</label>
|
||||||
|
<input
|
||||||
|
value={catForm.name}
|
||||||
|
onChange={(e) => setCatForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
className="w-full mt-1 px-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Slug</label>
|
||||||
|
<input
|
||||||
|
value={catForm.slug}
|
||||||
|
onChange={(e) => setCatForm((f) => ({ ...f, slug: e.target.value }))}
|
||||||
|
className="w-full mt-1 px-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="text-xs text-muted-foreground">描述</label>
|
||||||
|
<input
|
||||||
|
value={catForm.description}
|
||||||
|
onChange={(e) => setCatForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
className="w-full mt-1 px-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleSaveCategory} className="px-4 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90">
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowCatForm(false); setEditingCat(null); }} className="px-4 py-1.5 text-sm border rounded-lg hover:bg-muted">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/30">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">名称</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Slug</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">描述</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">帖子数</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<tr key={cat.id} className="border-b last:border-0 hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-3 font-medium">{cat.name}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">{cat.slug}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs max-w-40 truncate">{cat.description || "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-center font-mono tabular-nums">{cat.post_count}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button onClick={() => startEditCat(cat)} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
{confirmDelete?.type === "cat" && confirmDelete.id === cat.id ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => handleDeleteCategory(cat.id)} className="text-xs px-1.5 py-0.5 bg-destructive text-destructive-foreground rounded">确认</button>
|
||||||
|
<button onClick={() => setConfirmDelete(null)} className="text-xs px-1.5 py-0.5 border rounded">取消</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setConfirmDelete({ type: "cat", id: cat.id })} className="text-red-500 hover:text-red-700">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "posts" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">共 {posts.length} 个帖子</span>
|
||||||
|
<button onClick={loadPosts} className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/30">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">标题</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">作者</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">分类</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">回复数</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">时间</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">暂无帖子</td></tr>
|
||||||
|
) : posts.map((p) => (
|
||||||
|
<tr key={p.id} className="border-b last:border-0 hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-3 font-medium max-w-64 truncate">{p.title}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{p.author_name}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs">{p.category_name}</td>
|
||||||
|
<td className="px-4 py-3 text-center font-mono tabular-nums">{p.reply_count}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs">
|
||||||
|
{format(new Date(p.created_at), "yyyy/M/d HH:mm")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{confirmDelete?.type === "post" && confirmDelete.id === p.id ? (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button onClick={() => handleDeletePost(p.id)} className="text-xs px-2 py-1 bg-red-600 text-white rounded">确认</button>
|
||||||
|
<button onClick={() => setConfirmDelete(null)} className="text-xs px-2 py-1 border rounded">取消</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setConfirmDelete({ type: "post", id: p.id })} className="text-red-500 hover:text-red-700">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { adminAPI } from "@/lib/api";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Database, Trash2, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
interface AdminKB {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
owner_name: string;
|
||||||
|
is_system: boolean;
|
||||||
|
document_count: number;
|
||||||
|
chunk_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KnowledgeAdminPage() {
|
||||||
|
const [kbs, setKbs] = useState<AdminKB[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setLoading(true);
|
||||||
|
adminAPI.listKnowledgeBases()
|
||||||
|
.then(setKbs)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const handleDelete = async (kbId: number) => {
|
||||||
|
try {
|
||||||
|
await adminAPI.deleteKnowledgeBase(kbId);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
setKbs((prev) => prev.filter((kb) => kb.id !== kbId));
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-muted-foreground text-sm py-12 text-center">加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Database className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">知识库管理</h2>
|
||||||
|
<span className="text-sm text-muted-foreground">共 {kbs.length} 个知识库</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kbs.length === 0 ? (
|
||||||
|
<div className="rounded-xl border py-12 text-center text-muted-foreground text-sm">
|
||||||
|
暂无知识库
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/30">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">名称</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">描述</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">所有者</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">类型</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">文档数</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">向量数</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">创建时间</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{kbs.map((kb) => (
|
||||||
|
<tr key={kb.id} className="border-b last:border-0 hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-3 font-medium">{kb.name}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs max-w-48 truncate">
|
||||||
|
{kb.description || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center text-muted-foreground">{kb.owner_name}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
kb.is_system
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{kb.is_system ? "系统" : "用户"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center font-mono tabular-nums">{kb.document_count}</td>
|
||||||
|
<td className="px-4 py-3 text-center font-mono tabular-nums">{kb.chunk_count}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs">
|
||||||
|
{format(new Date(kb.created_at), "yyyy/M/d HH:mm")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{confirmDelete === kb.id ? (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(kb.id)}
|
||||||
|
className="text-xs px-2 py-1 bg-destructive text-destructive-foreground rounded hover:bg-destructive/80"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(null)}
|
||||||
|
className="text-xs px-2 py-1 border rounded hover:bg-accent"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(kb.id)}
|
||||||
|
className="text-destructive hover:text-destructive/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
Database,
|
||||||
|
MessageSquare,
|
||||||
|
BookOpen,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
const ADMIN_NAV = [
|
||||||
|
{ href: "/admin", label: "数据概览", icon: LayoutDashboard, exact: true },
|
||||||
|
{ href: "/admin/users", label: "用户管理", icon: Users },
|
||||||
|
{ href: "/admin/knowledge", label: "知识库管理", icon: Database },
|
||||||
|
{ href: "/admin/forum", label: "论坛管理", icon: MessageSquare },
|
||||||
|
{ href: "/admin/course", label: "课程内容", icon: BookOpen },
|
||||||
|
{ href: "/admin/settings", label: "系统设置", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
if (!isAuthenticated || !user?.is_superuser) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<Shield className="w-16 h-16 mx-auto text-muted-foreground" />
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">需要管理员权限</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">请联系管理员获取访问权限</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="text-primary hover:underline text-sm"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[calc(100vh-4rem)]">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-56 shrink-0 border-r bg-background hidden md:block">
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-primary" />
|
||||||
|
后台管理
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<nav className="p-2 space-y-0.5">
|
||||||
|
{ADMIN_NAV.map((item) => {
|
||||||
|
const active = item.exact
|
||||||
|
? pathname === item.href
|
||||||
|
: pathname.startsWith(item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2.5 px-3 py-2 text-sm rounded-lg transition-colors
|
||||||
|
${active
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile nav */}
|
||||||
|
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-background border-t z-50">
|
||||||
|
<div className="flex items-center justify-around py-2">
|
||||||
|
{ADMIN_NAV.map((item) => {
|
||||||
|
const active = item.exact
|
||||||
|
? pathname === item.href
|
||||||
|
: pathname.startsWith(item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex flex-col items-center gap-0.5 px-2 py-1 text-[10px] ${
|
||||||
|
active ? "text-primary" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-auto pb-16 md:pb-0">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { adminAPI } from "@/lib/api";
|
||||||
|
import { Users, MessageSquare, FileText, TrendingUp, Image, Activity } from "lucide-react";
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
total_users: number;
|
||||||
|
active_users_7d: number;
|
||||||
|
total_sessions: number;
|
||||||
|
total_messages: number;
|
||||||
|
total_documents: number;
|
||||||
|
total_knowledge_bases: number;
|
||||||
|
total_forum_posts: number;
|
||||||
|
total_forum_replies: number;
|
||||||
|
total_generated_images: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendChart({ title, icon: Icon, data, variant }: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
data: { date: string; count: number }[];
|
||||||
|
variant?: "default" | "secondary";
|
||||||
|
}) {
|
||||||
|
const barClass = variant === "secondary" ? "bg-emerald-500/35" : "bg-primary/35";
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border p-5 space-y-3">
|
||||||
|
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Icon className="w-4 h-4" />{title}
|
||||||
|
</h3>
|
||||||
|
<div className="h-40 flex items-end gap-1">
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<div className="w-full text-center text-xs text-muted-foreground py-8">暂无数据</div>
|
||||||
|
) : data.map((d) => {
|
||||||
|
const max = Math.max(...data.map((x) => x.count), 1);
|
||||||
|
const h = Math.max((d.count / max) * 100, 2);
|
||||||
|
return (
|
||||||
|
<div key={d.date} className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<span className="text-[9px] text-muted-foreground tabular-nums">{d.count || ""}</span>
|
||||||
|
<div className={`w-full rounded-t ${barClass}`} style={{ height: `${h}%` }} title={`${d.date}: ${d.count}`} />
|
||||||
|
<span className="text-[9px] text-muted-foreground">{d.date.slice(5)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
const [userTrends, setUserTrends] = useState<{ date: string; count: number }[]>([]);
|
||||||
|
const [msgTrends, setMsgTrends] = useState<{ date: string; count: number }[]>([]);
|
||||||
|
const [systemStatus, setSystemStatus] = useState<{
|
||||||
|
database: { status: string };
|
||||||
|
vector_store: { status: string; vector_count: number };
|
||||||
|
llm_model: string;
|
||||||
|
embedding_model: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.allSettled([
|
||||||
|
adminAPI.getDashboard(),
|
||||||
|
adminAPI.getUserTrends(14),
|
||||||
|
adminAPI.getMessageTrends(14),
|
||||||
|
adminAPI.getSystemStatus(),
|
||||||
|
]).then(([dashboard, trendsU, trendsM, status]) => {
|
||||||
|
if (dashboard.status === "fulfilled") setStats(dashboard.value);
|
||||||
|
if (trendsU.status === "fulfilled") setUserTrends(trendsU.value);
|
||||||
|
if (trendsM.status === "fulfilled") setMsgTrends(trendsM.value);
|
||||||
|
if (status.status === "fulfilled") setSystemStatus(status.value);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="py-12 text-center text-muted-foreground text-sm">加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ label: "注册用户", value: stats?.total_users ?? 0, icon: Users, sub: `${stats?.active_users_7d ?? 0} 人近7天活跃` },
|
||||||
|
{ label: "对话会话", value: stats?.total_sessions ?? 0, icon: MessageSquare, sub: `${stats?.total_messages ?? 0} 条消息` },
|
||||||
|
{ label: "知识文档", value: stats?.total_documents ?? 0, icon: FileText, sub: `${stats?.total_knowledge_bases ?? 0} 个知识库` },
|
||||||
|
{ label: "论坛帖子", value: stats?.total_forum_posts ?? 0, icon: TrendingUp, sub: `${stats?.total_forum_replies ?? 0} 条回复` },
|
||||||
|
{ label: "生成图像", value: stats?.total_generated_images ?? 0, icon: Image, sub: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<div key={c.label} className="rounded-xl border p-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">{c.label}</span>
|
||||||
|
<c.icon className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold font-mono tabular-nums">{c.value.toLocaleString()}</div>
|
||||||
|
{c.sub && <div className="text-[11px] text-muted-foreground">{c.sub}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<TrendChart title="近14天用户注册" icon={Users} data={userTrends} variant="default" />
|
||||||
|
<TrendChart title="近14天消息数" icon={MessageSquare} data={msgTrends} variant="secondary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{systemStatus && (
|
||||||
|
<div className="rounded-xl border p-5 space-y-3">
|
||||||
|
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Activity className="w-4 h-4" />系统状态
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: "数据库", ok: systemStatus.database.status === "ok" },
|
||||||
|
{ label: "向量库", ok: systemStatus.vector_store.status === "ok", sub: `${systemStatus.vector_store.vector_count} 向量` },
|
||||||
|
{ label: "LLM 模型", ok: true, sub: systemStatus.llm_model },
|
||||||
|
{ label: "嵌入模型", ok: true, sub: systemStatus.embedding_model },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className={`w-2 h-2 rounded-full shrink-0 ${s.ok ? "bg-emerald-500" : "bg-destructive"}`} />
|
||||||
|
<span>{s.label}</span>
|
||||||
|
{s.sub && <span className="text-muted-foreground text-xs truncate">{s.sub}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { adminAPI } from "@/lib/api";
|
||||||
|
import { Settings, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
interface SystemStatus {
|
||||||
|
database: { status: string };
|
||||||
|
vector_store: { status: string; vector_count: number };
|
||||||
|
llm_model: string;
|
||||||
|
embedding_model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SystemSettingsPage() {
|
||||||
|
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setLoading(true);
|
||||||
|
adminAPI.getSystemStatus()
|
||||||
|
.then(setStatus)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-muted-foreground text-sm py-12 text-center">加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusItems = status ? [
|
||||||
|
{
|
||||||
|
label: "数据库",
|
||||||
|
ok: status.database.status === "ok",
|
||||||
|
detail: status.database.status === "ok" ? "运行正常" : "连接异常",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "向量存储",
|
||||||
|
ok: status.vector_store.status === "ok",
|
||||||
|
detail: `${status.vector_store.vector_count.toLocaleString()} 个向量`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "LLM 模型",
|
||||||
|
ok: true,
|
||||||
|
detail: status.llm_model,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "嵌入模型",
|
||||||
|
ok: true,
|
||||||
|
detail: status.embedding_model,
|
||||||
|
},
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Settings className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">系统设置</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
刷新状态
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System status */}
|
||||||
|
<div className="rounded-xl border">
|
||||||
|
<div className="px-5 py-3 border-b">
|
||||||
|
<h3 className="text-sm font-medium">系统状态</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{statusItems.map((s) => (
|
||||||
|
<div key={s.label} className="flex items-center justify-between px-5 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${s.ok ? "bg-emerald-500" : "bg-destructive"}`} />
|
||||||
|
<span className="text-sm">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">{s.detail}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Runtime info */}
|
||||||
|
<div className="rounded-xl border">
|
||||||
|
<div className="px-5 py-3 border-b">
|
||||||
|
<h3 className="text-sm font-medium">运行信息</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
|
<span className="text-sm">前端版本</span>
|
||||||
|
<span className="text-sm text-muted-foreground font-mono">1.0.0</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
|
<span className="text-sm">框架</span>
|
||||||
|
<span className="text-sm text-muted-foreground">Next.js 15 + FastAPI</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
|
<span className="text-sm">部署模式</span>
|
||||||
|
<span className="text-sm text-muted-foreground">Docker (Supervisor)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
|
<span className="text-sm">向量数据库</span>
|
||||||
|
<span className="text-sm text-muted-foreground">ChromaDB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger zone */}
|
||||||
|
<div className="rounded-xl border border-destructive/30">
|
||||||
|
<div className="px-5 py-3 border-b border-destructive/30">
|
||||||
|
<h3 className="text-sm font-medium text-destructive">危险操作</h3>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm">重建向量索引</div>
|
||||||
|
<div className="text-xs text-muted-foreground">重新处理所有知识库文档并建立向量索引</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-sm border border-destructive/30 text-destructive rounded-lg hover:bg-destructive/10 transition-colors"
|
||||||
|
onClick={() => alert("此功能需要通过后端命令行执行")}
|
||||||
|
>
|
||||||
|
重建索引
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm">清理缓存</div>
|
||||||
|
<div className="text-xs text-muted-foreground">清除系统缓存和临时文件</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-sm border border-destructive/30 text-destructive rounded-lg hover:bg-destructive/10 transition-colors"
|
||||||
|
onClick={() => alert("此功能需要通过后端命令行执行")}
|
||||||
|
>
|
||||||
|
清理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { adminAPI } from "@/lib/api";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Users, Search } from "lucide-react";
|
||||||
|
|
||||||
|
interface AdminUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
is_superuser: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
last_login: string | null;
|
||||||
|
session_count: number;
|
||||||
|
message_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const loadUsers = () => {
|
||||||
|
setLoading(true);
|
||||||
|
adminAPI.listUsers()
|
||||||
|
.then(setUsers)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadUsers(); }, []);
|
||||||
|
|
||||||
|
const handleToggleActive = async (userId: number) => {
|
||||||
|
try {
|
||||||
|
await adminAPI.toggleUserActive(userId);
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((u) => (u.id === userId ? { ...u, is_active: !u.is_active } : u))
|
||||||
|
);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAdmin = async (userId: number) => {
|
||||||
|
try {
|
||||||
|
await adminAPI.toggleUserAdmin(userId);
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((u) => (u.id === userId ? { ...u, is_superuser: !u.is_superuser } : u))
|
||||||
|
);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (userId: number) => {
|
||||||
|
try {
|
||||||
|
await adminAPI.deleteUser(userId);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
setUsers((prev) => prev.filter((u) => u.id !== userId));
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = users.filter(
|
||||||
|
(u) =>
|
||||||
|
u.username.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(u.full_name && u.full_name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-muted-foreground text-sm py-12 text-center">加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Users className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">用户管理</h2>
|
||||||
|
<span className="text-sm text-muted-foreground">共 {users.length} 个用户</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="搜索用户名、邮箱..."
|
||||||
|
className="pl-9 pr-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/30">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">用户</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">邮箱</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">状态</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">角色</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">对话 / 消息</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">注册时间</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
|
||||||
|
{search ? "无匹配用户" : "暂无用户"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{filtered.map((u) => (
|
||||||
|
<tr key={u.id} className="border-b last:border-0 hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium">{u.username}</div>
|
||||||
|
{u.full_name && <div className="text-xs text-muted-foreground">{u.full_name}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{u.email}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleActive(u.id)}
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
u.is_active
|
||||||
|
? "bg-emerald-100 text-emerald-700 hover:bg-emerald-200"
|
||||||
|
: "bg-red-100 text-red-700 hover:bg-red-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.is_active ? "正常" : "禁用"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleAdmin(u.id)}
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
u.is_superuser
|
||||||
|
? "bg-amber-100 text-amber-700 hover:bg-amber-200"
|
||||||
|
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.is_superuser ? "管理员" : "用户"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center font-mono tabular-nums text-muted-foreground">
|
||||||
|
{u.session_count} / {u.message_count}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs">
|
||||||
|
{u.created_at ? format(new Date(u.created_at), "yyyy/M/d HH:mm") : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{confirmDelete === u.id ? (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(u.id)}
|
||||||
|
className="text-xs px-2 py-1 bg-destructive text-destructive-foreground rounded hover:bg-destructive/80"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(null)}
|
||||||
|
className="text-xs px-2 py-1 border rounded hover:bg-accent"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(u.id)}
|
||||||
|
className="text-xs text-red-500 hover:text-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -188,7 +188,7 @@ export default function AnalyticsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<MessageSquare className="w-8 h-8 text-blue-600" />
|
<MessageSquare className="w-8 h-8 text-primary" />
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-muted-foreground">对话会话</p>
|
<p className="text-sm font-medium text-muted-foreground">对话会话</p>
|
||||||
<p className="text-2xl font-bold text-foreground">{statistics.total_sessions}</p>
|
<p className="text-2xl font-bold text-foreground">{statistics.total_sessions}</p>
|
||||||
@@ -200,7 +200,7 @@ export default function AnalyticsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<BookOpen className="w-8 h-8 text-green-600" />
|
<BookOpen className="w-8 h-8 text-emerald-500" />
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-muted-foreground">消息总数</p>
|
<p className="text-sm font-medium text-muted-foreground">消息总数</p>
|
||||||
<p className="text-2xl font-bold text-foreground">{statistics.total_messages}</p>
|
<p className="text-2xl font-bold text-foreground">{statistics.total_messages}</p>
|
||||||
@@ -212,7 +212,7 @@ export default function AnalyticsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<FileText className="w-8 h-8 text-purple-600" />
|
<FileText className="w-8 h-8 text-primary" />
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-muted-foreground">文档数量</p>
|
<p className="text-sm font-medium text-muted-foreground">文档数量</p>
|
||||||
<p className="text-2xl font-bold text-foreground">{statistics.total_documents}</p>
|
<p className="text-2xl font-bold text-foreground">{statistics.total_documents}</p>
|
||||||
@@ -224,7 +224,7 @@ export default function AnalyticsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Calendar className="w-8 h-8 text-orange-600" />
|
<Calendar className="w-8 h-8 text-muted-foreground" />
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-muted-foreground">活跃天数</p>
|
<p className="text-sm font-medium text-muted-foreground">活跃天数</p>
|
||||||
<p className="text-2xl font-bold text-foreground">{statistics.active_days}</p>
|
<p className="text-2xl font-bold text-foreground">{statistics.active_days}</p>
|
||||||
@@ -250,7 +250,7 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-muted rounded-full h-2">
|
<div className="w-full bg-muted rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||||
style={{ width: `${item.coverage}%` }}
|
style={{ width: `${item.coverage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,7 +278,7 @@ export default function AnalyticsPage() {
|
|||||||
<p className="text-sm font-medium text-foreground">{item.question}</p>
|
<p className="text-sm font-medium text-foreground">{item.question}</p>
|
||||||
<p className="text-xs text-muted-foreground">{item.category}</p>
|
<p className="text-xs text-muted-foreground">{item.category}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-blue-600">{item.count} 次</div>
|
<div className="text-sm font-medium text-primary">{item.count} 次</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -300,11 +300,11 @@ export default function AnalyticsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-lg font-medium text-foreground">总体进度</span>
|
<span className="text-lg font-medium text-foreground">总体进度</span>
|
||||||
<span className="text-2xl font-bold text-blue-600">{learningReport.learning_progress}%</span>
|
<span className="text-2xl font-bold text-primary">{learningReport.learning_progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-muted rounded-full h-4">
|
<div className="w-full bg-muted rounded-full h-4">
|
||||||
<div
|
<div
|
||||||
className="bg-gradient-to-r from-blue-500 to-purple-600 h-4 rounded-full transition-all duration-500"
|
className="bg-primary h-4 rounded-full transition-all duration-500"
|
||||||
style={{ width: `${learningReport.learning_progress}%` }}
|
style={{ width: `${learningReport.learning_progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,8 +334,8 @@ export default function AnalyticsPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{learningReport.recommendations.map((recommendation, index) => (
|
{learningReport.recommendations.map((recommendation, index) => (
|
||||||
<div key={index} className="flex items-start space-x-3 p-3 bg-blue-500/10 dark:bg-blue-500/20 rounded-lg">
|
<div key={index} className="flex items-start space-x-3 p-3 bg-primary/10 dark:bg-blue-500/20 rounded-lg">
|
||||||
<div className="w-2 h-2 bg-blue-600 rounded-full mt-2 flex-shrink-0" />
|
<div className="w-2 h-2 bg-primary rounded-full mt-2 flex-shrink-0" />
|
||||||
<p className="text-sm text-foreground">{recommendation}</p>
|
<p className="text-sm text-foreground">{recommendation}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -355,8 +355,8 @@ export default function AnalyticsPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{learningReport.knowledge_gaps.map((gap, index) => (
|
{learningReport.knowledge_gaps.map((gap, index) => (
|
||||||
<div key={index} className="flex items-start space-x-3 p-3 bg-orange-500/10 dark:bg-orange-500/20 rounded-lg">
|
<div key={index} className="flex items-start space-x-3 p-3 bg-muted dark:bg-orange-500/20 rounded-lg">
|
||||||
<div className="w-2 h-2 bg-orange-600 rounded-full mt-2 flex-shrink-0" />
|
<div className="w-2 h-2 bg-muted-foreground rounded-full mt-2 flex-shrink-0" />
|
||||||
<p className="text-sm text-foreground">{gap}</p>
|
<p className="text-sm text-foreground">{gap}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
|||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAuthenticated, user, isLoading: authLoading } = useAuthStore();
|
const { isAuthenticated, user, isLoading: authLoading } = useAuthStore();
|
||||||
const { loadSessions, sessions } = useChatStore();
|
const { loadSessions, sessions, selectSession, currentSession } = useChatStore();
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [initialLoading, setInitialLoading] = useState(true);
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
@@ -26,7 +26,14 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAuthenticated && !isInitialized) {
|
if (isAuthenticated && !isInitialized) {
|
||||||
loadSessions().then(() => setInitialLoading(false));
|
loadSessions().then(async () => {
|
||||||
|
setInitialLoading(false);
|
||||||
|
// Auto-select the most recent session, or do nothing (welcome page)
|
||||||
|
const store = useChatStore.getState();
|
||||||
|
if (!store.currentSession && store.sessions.length > 0) {
|
||||||
|
await store.selectSession(store.sessions[0].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, authLoading, isInitialized, router, loadSessions]);
|
}, [isAuthenticated, authLoading, isInitialized, router, loadSessions]);
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export default function CourseContentPage() {
|
|||||||
<>
|
<>
|
||||||
{/* 知识图谱视图 */}
|
{/* 知识图谱视图 */}
|
||||||
{viewMode === 'graph' && (
|
{viewMode === 'graph' && (
|
||||||
<div className="w-full h-[calc(100vh-250px)] min-h-[600px] lg:min-h-[700px] rounded-lg border border-border/40 overflow-hidden bg-slate-50">
|
<div className="w-full h-[calc(100vh-250px)] min-h-[600px] lg:min-h-[700px] rounded-lg border border-border overflow-hidden bg-muted">
|
||||||
<KnowledgeGraph bookStructure={bookStructure} />
|
<KnowledgeGraph bookStructure={bookStructure} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+121
-112
@@ -12,59 +12,24 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
User,
|
User,
|
||||||
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
import { forumAPI } from "@/lib/api";
|
import { forumAPI } from "@/lib/api";
|
||||||
import type { ForumCategory, ForumPostSummary } from "@/types";
|
import type { ForumCategory, ForumPostSummary } from "@/types";
|
||||||
|
import { format } from "date-fns";
|
||||||
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
|
||||||
default: <MessageSquare className="w-5 h-5" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CATEGORY_ORDER = ["公告", "通知", "学习", "讨论", "课程", "反馈", "使用"];
|
const CATEGORY_ORDER = ["公告", "通知", "学习", "讨论", "课程", "反馈", "使用"];
|
||||||
|
|
||||||
function getCategoryIcon(name: string) {
|
function getCategoryIcon(name: string) {
|
||||||
if (name.includes("公告") || name.includes("通知"))
|
if (name.includes("公告") || name.includes("通知"))
|
||||||
return <Megaphone className="w-5 h-5" />;
|
return <Megaphone className="w-4 h-4" />;
|
||||||
if (name.includes("学习") || name.includes("讨论") || name.includes("课程"))
|
if (name.includes("学习") || name.includes("讨论") || name.includes("课程"))
|
||||||
return <BookOpen className="w-5 h-5" />;
|
return <BookOpen className="w-4 h-4" />;
|
||||||
if (name.includes("反馈") || name.includes("使用"))
|
if (name.includes("反馈") || name.includes("使用"))
|
||||||
return <Lightbulb className="w-5 h-5" />;
|
return <Lightbulb className="w-4 h-4" />;
|
||||||
return CATEGORY_ICONS.default;
|
return <MessageSquare className="w-4 h-4" />;
|
||||||
}
|
|
||||||
|
|
||||||
function getCategoryGradient(name: string) {
|
|
||||||
if (name.includes("公告") || name.includes("通知"))
|
|
||||||
return "from-rose-500/10 to-rose-50";
|
|
||||||
if (name.includes("学习") || name.includes("讨论") || name.includes("课程"))
|
|
||||||
return "from-blue-500/10 to-blue-50";
|
|
||||||
if (name.includes("反馈") || name.includes("使用"))
|
|
||||||
return "from-amber-500/10 to-amber-50";
|
|
||||||
return "from-slate-500/10 to-slate-50";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCategoryAccent(name: string) {
|
|
||||||
if (name.includes("公告") || name.includes("通知")) return "text-rose-600";
|
|
||||||
if (name.includes("学习") || name.includes("讨论") || name.includes("课程")) return "text-blue-600";
|
|
||||||
if (name.includes("反馈") || name.includes("使用")) return "text-amber-600";
|
|
||||||
return "text-slate-600";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string) {
|
|
||||||
const date = new Date(
|
|
||||||
new Date(dateStr).getTime() + 8 * 60 * 60 * 1000
|
|
||||||
);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffMinutes < 1) return "刚刚";
|
|
||||||
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
|
|
||||||
if (diffHours < 24) return `${diffHours} 小时前`;
|
|
||||||
if (diffDays < 7) return `${diffDays} 天前`;
|
|
||||||
return date.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ForumHomePage() {
|
export default function ForumHomePage() {
|
||||||
@@ -74,17 +39,30 @@ export default function ForumHomePage() {
|
|||||||
>({});
|
>({});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCategories = async () => {
|
const loadCategories = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const data = await forumAPI.getCategories();
|
const data = await forumAPI.getCategories();
|
||||||
setCategories(data);
|
const sorted = [...data].sort((a, b) => {
|
||||||
|
const getOrder = (name: string) => {
|
||||||
|
for (let i = 0; i < CATEGORY_ORDER.length; i++) {
|
||||||
|
if (name.includes(CATEGORY_ORDER[i])) return i;
|
||||||
|
}
|
||||||
|
return CATEGORY_ORDER.length;
|
||||||
|
};
|
||||||
|
return getOrder(a.name) - getOrder(b.name);
|
||||||
|
});
|
||||||
|
setCategories(sorted);
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
setActiveTab(String(sorted[0].id));
|
||||||
|
}
|
||||||
const postsEntries = await Promise.all(
|
const postsEntries = await Promise.all(
|
||||||
data.map(async (category) => {
|
sorted.map(async (category) => {
|
||||||
try {
|
try {
|
||||||
const posts = await forumAPI.getPosts(category.id, 3);
|
const posts = await forumAPI.getPosts(category.id, 10);
|
||||||
return [category.id, posts] as const;
|
return [category.id, posts] as const;
|
||||||
} catch {
|
} catch {
|
||||||
return [category.id, []] as const;
|
return [category.id, []] as const;
|
||||||
@@ -105,80 +83,92 @@ export default function ForumHomePage() {
|
|||||||
loadCategories();
|
loadCategories();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
if (isLoading) {
|
||||||
<div className="min-h-screen bg-background">
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="min-h-screen bg-background">
|
||||||
{isLoading ? (
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="flex items-center justify-center gap-2 text-muted-foreground py-20">
|
<div className="flex items-center justify-center gap-2 text-muted-foreground py-20">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
正在加载社区...
|
正在加载社区...
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<p className="text-muted-foreground">{error}</p>
|
<p className="text-muted-foreground">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-5">
|
</div>
|
||||||
{[...categories]
|
);
|
||||||
.sort((a, b) => {
|
}
|
||||||
const getOrder = (name: string) => {
|
|
||||||
for (let i = 0; i < CATEGORY_ORDER.length; i++) {
|
|
||||||
if (name.includes(CATEGORY_ORDER[i])) return i;
|
|
||||||
}
|
|
||||||
return CATEGORY_ORDER.length;
|
|
||||||
};
|
|
||||||
return getOrder(a.name) - getOrder(b.name);
|
|
||||||
})
|
|
||||||
.map((category) => {
|
|
||||||
const posts = categoryPosts[category.id] || [];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={category.id}
|
|
||||||
className={`bg-gradient-to-r ${getCategoryGradient(
|
|
||||||
category.name
|
|
||||||
)} rounded-xl border border-border/40 overflow-hidden`}
|
|
||||||
>
|
|
||||||
{/* Category header */}
|
|
||||||
<div className="px-5 py-3.5 flex items-center justify-between border-b border-border/20">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={getCategoryAccent(category.name)}>
|
|
||||||
{getCategoryIcon(category.name)}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-semibold text-foreground">
|
|
||||||
{category.name}
|
|
||||||
</h2>
|
|
||||||
{category.description && (
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
|
||||||
{category.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm text-muted-foreground tabular-nums">
|
|
||||||
{category.post_count} 条讨论
|
|
||||||
</span>
|
|
||||||
<Link
|
|
||||||
href={`/forum/${category.id}`}
|
|
||||||
className="text-sm font-medium text-primary hover:underline flex items-center gap-0.5"
|
|
||||||
>
|
|
||||||
查看全部
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Posts list */}
|
return (
|
||||||
<div className="divide-y divide-border/20">
|
<div className="min-h-screen bg-background">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={category.id}
|
||||||
|
value={String(category.id)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{getCategoryIcon(category.name)}
|
||||||
|
{category.name}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{categories.map((category) => {
|
||||||
|
const posts = categoryPosts[category.id] || [];
|
||||||
|
return (
|
||||||
|
<TabsContent
|
||||||
|
key={category.id}
|
||||||
|
value={String(category.id)}
|
||||||
|
className="mt-6"
|
||||||
|
>
|
||||||
|
{/* Category description */}
|
||||||
|
{category.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{category.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New post button */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm text-muted-foreground tabular-nums">
|
||||||
|
{category.post_count} 条讨论
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={`/forum/${category.id}`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
发表讨论
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Posts list */}
|
||||||
|
<div className="rounded-xl border border-border overflow-hidden">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
{posts.length > 0 ? (
|
{posts.length > 0 ? (
|
||||||
posts.map((post) => (
|
posts.map((post) => (
|
||||||
<Link
|
<Link
|
||||||
key={post.id}
|
key={post.id}
|
||||||
href={`/forum/post/${post.id}`}
|
href={`/forum/post/${post.id}`}
|
||||||
className="flex items-start gap-3 px-5 py-3.5 hover:bg-white/40 transition-colors group"
|
className="flex items-start gap-3 px-5 py-3.5 hover:bg-accent transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="mt-0.5 w-8 h-8 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0">
|
<div className="mt-0.5 w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
|
||||||
<User className="w-4 h-4 text-muted-foreground" />
|
<User className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -189,7 +179,10 @@ export default function ForumHomePage() {
|
|||||||
<span>{post.author_name}</span>
|
<span>{post.author_name}</span>
|
||||||
<span className="inline-flex items-center gap-0.5">
|
<span className="inline-flex items-center gap-0.5">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{formatRelativeTime(post.created_at)}
|
{format(
|
||||||
|
new Date(post.created_at),
|
||||||
|
"yyyy/M/d HH:mm"
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-0.5">
|
<span className="inline-flex items-center gap-0.5">
|
||||||
<MessageCircle className="w-3 h-3" />
|
<MessageCircle className="w-3 h-3" />
|
||||||
@@ -201,16 +194,32 @@ export default function ForumHomePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
|
<div className="px-5 py-12 text-center">
|
||||||
暂无讨论,成为第一个发帖的人
|
<MessageSquare className="w-8 h-8 text-muted-foreground/40 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
暂无讨论,成为第一个发帖的人
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
{/* View all link */}
|
||||||
</div>
|
{posts.length > 0 && (
|
||||||
)}
|
<div className="flex justify-center mt-4">
|
||||||
|
<Link
|
||||||
|
href={`/forum/${category.id}`}
|
||||||
|
className="text-sm text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
查看全部 {category.post_count} 条讨论
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export default function KnowledgeBaseDetailPage() {
|
|||||||
返回
|
返回
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<BookOpen className="w-8 h-8 text-blue-600" />
|
<BookOpen className="w-8 h-8 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-2">{knowledgeBase.name}</h1>
|
<h1 className="text-2xl font-bold mb-2">{knowledgeBase.name}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
@@ -262,7 +262,7 @@ export default function KnowledgeBaseDetailPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700">
|
<Button variant="default">
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
上传文档
|
上传文档
|
||||||
</Button>
|
</Button>
|
||||||
@@ -302,17 +302,17 @@ export default function KnowledgeBaseDetailPage() {
|
|||||||
<div key={fileId} className="flex items-center justify-between p-2 border rounded-md">
|
<div key={fileId} className="flex items-center justify-between p-2 border rounded-md">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<FileText className="w-4 h-4 text-blue-600 flex-shrink-0" />
|
<FileText className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
<span className="text-sm truncate">{file.name}</span>
|
<span className="text-sm truncate">{file.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
({(file.size / 1024 / 1024).toFixed(2)} MB)
|
({(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-red-600 mt-1">{error}</div>
|
<div className="text-xs text-destructive mt-1">{error}</div>
|
||||||
)}
|
)}
|
||||||
{progress > 0 && progress < 100 && (
|
{progress > 0 && progress < 100 && (
|
||||||
<div className="w-full bg-gray-200 rounded-full h-1 mt-1">
|
<div className="w-full bg-muted rounded-full h-1 mt-1">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-600 h-1 rounded-full transition-all duration-300"
|
className="bg-blue-600 h-1 rounded-full transition-all duration-300"
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
@@ -320,7 +320,7 @@ export default function KnowledgeBaseDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{progress === 100 && !error && (
|
{progress === 100 && !error && (
|
||||||
<div className="text-xs text-green-600 mt-1">✓ 上传完成</div>
|
<div className="text-xs text-emerald-500 mt-1">✓ 上传完成</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -330,7 +330,7 @@ export default function KnowledgeBaseDetailPage() {
|
|||||||
setUploadFiles(prev => prev.filter((_, i) => i !== index));
|
setUploadFiles(prev => prev.filter((_, i) => i !== index));
|
||||||
}}
|
}}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className="text-red-600 hover:text-red-700"
|
className="text-destructive hover:text-destructive/80"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -398,7 +398,7 @@ export default function KnowledgeBaseDetailPage() {
|
|||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索文档..."
|
placeholder="搜索文档..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -450,14 +450,14 @@ export default function KnowledgeBaseDetailPage() {
|
|||||||
<TableRow key={doc.id}>
|
<TableRow key={doc.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{doc.is_processed ? (
|
{doc.is_processed ? (
|
||||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
<CheckCircle className="w-5 h-5 text-emerald-500" />
|
||||||
) : (
|
) : (
|
||||||
<Clock className="w-5 h-5 text-yellow-600" />
|
<Clock className="w-5 h-5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<FileText className="w-4 h-4 text-blue-600" />
|
<FileText className="w-4 h-4 text-primary" />
|
||||||
<span>{doc.title}</span>
|
<span>{doc.title}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -480,7 +480,7 @@ export default function KnowledgeBaseDetailPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDeleteDocument(doc.id)}
|
onClick={() => handleDeleteDocument(doc.id)}
|
||||||
className="text-red-600 hover:text-red-700"
|
className="text-destructive hover:text-destructive/80"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -142,7 +144,7 @@ export default function KnowledgePage() {
|
|||||||
{/* 创建知识库按钮 */}
|
{/* 创建知识库按钮 */}
|
||||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700">
|
<Button variant="default">
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
创建知识库
|
创建知识库
|
||||||
</Button>
|
</Button>
|
||||||
@@ -204,7 +206,7 @@ export default function KnowledgePage() {
|
|||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索知识库..."
|
placeholder="搜索知识库..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -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-primary" />
|
||||||
|
<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-primary/30">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<FolderOpen className="w-5 h-5 text-primary" />
|
||||||
|
<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-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</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-emerald-500" : "text-muted-foreground"}>
|
||||||
|
{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-primary" />
|
||||||
<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-primary" />
|
||||||
|
<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-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</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-emerald-500" : "text-muted-foreground"}>
|
||||||
|
{kb.document_count > 0 ? "已就绪" : "空知识库"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</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">
|
<div className="flex space-x-2 mt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => router.push(`/knowledge/${kb.id}`)}
|
onClick={() => router.push(`/knowledge/${kb.id}`)}
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4 mr-1" />
|
<Settings className="w-4 h-4 mr-1" />
|
||||||
管理
|
管理
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDeleteKnowledgeBase(kb.id)}
|
onClick={() => handleDeleteKnowledgeBase(kb.id)}
|
||||||
className="text-red-600 hover:text-red-700"
|
className="text-destructive hover:text-destructive/80"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,25 +7,18 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import MobileNav from "@/components/layout/mobile-nav";
|
import MobileNav from "@/components/layout/mobile-nav";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
User as UserIcon,
|
|
||||||
Mail,
|
|
||||||
Calendar,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Edit2,
|
Edit2,
|
||||||
Save,
|
Save,
|
||||||
X,
|
X,
|
||||||
MessageSquare,
|
User,
|
||||||
Database,
|
|
||||||
FileText
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { authAPI, analyticsAPI } from "@/lib/api";
|
import { authAPI } from "@/lib/api";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
const profileSchema = z.object({
|
const profileSchema = z.object({
|
||||||
@@ -43,12 +36,6 @@ export default function ProfilePage() {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
const [statistics, setStatistics] = useState<{
|
|
||||||
total_sessions: number;
|
|
||||||
total_messages: number;
|
|
||||||
total_documents: number;
|
|
||||||
active_days: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -69,10 +56,7 @@ export default function ProfilePage() {
|
|||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isAuthenticated) loadUserData();
|
||||||
if (isAuthenticated) {
|
|
||||||
loadUserData();
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, authLoading, router]);
|
}, [isAuthenticated, authLoading, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,24 +70,8 @@ export default function ProfilePage() {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// 加载用户信息
|
|
||||||
const userData = await authAPI.getCurrentUser();
|
const userData = await authAPI.getCurrentUser();
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
|
||||||
// 加载统计数据
|
|
||||||
try {
|
|
||||||
const stats = await analyticsAPI.getStatistics();
|
|
||||||
setStatistics({
|
|
||||||
total_sessions: stats.total_sessions || 0,
|
|
||||||
total_messages: stats.total_messages || 0,
|
|
||||||
total_documents: stats.total_documents || 0,
|
|
||||||
active_days: stats.active_days || 0,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("加载统计数据失败:", err);
|
|
||||||
// 统计数据加载失败不影响页面显示
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("加载用户信息失败:", err);
|
console.error("加载用户信息失败:", err);
|
||||||
setError("加载用户信息失败");
|
setError("加载用户信息失败");
|
||||||
@@ -117,17 +85,13 @@ export default function ProfilePage() {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
|
||||||
const updatedUser = await authAPI.updateUserInfo({
|
const updatedUser = await authAPI.updateUserInfo({
|
||||||
email: data.email,
|
email: data.email,
|
||||||
full_name: data.full_name || undefined,
|
full_name: data.full_name || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setUser(updatedUser);
|
setUser(updatedUser);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setSuccess("个人信息更新成功");
|
setSuccess("个人信息更新成功");
|
||||||
|
|
||||||
// 3秒后清除成功消息
|
|
||||||
setTimeout(() => setSuccess(null), 3000);
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("更新用户信息失败:", err);
|
console.error("更新用户信息失败:", err);
|
||||||
@@ -155,235 +119,119 @@ export default function ProfilePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated || !user) {
|
if (!isAuthenticated || !user) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initials = (user.full_name || user.username || "U").charAt(0).toUpperCase();
|
const initials = (user.full_name || user.username || "U").charAt(0).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-app pb-16 lg:pb-0">
|
<div className="min-h-screen bg-background pb-16 lg:pb-0">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* 错误提示 */}
|
|
||||||
|
{/* 状态提示 */}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive" className="mb-6">
|
<div className="mb-6 p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive text-sm">{error}</div>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 成功提示 */}
|
|
||||||
{success && (
|
{success && (
|
||||||
<Alert className="mb-6 border-green-500 bg-green-50 dark:bg-green-950">
|
<div className="mb-6 p-3 rounded-lg bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-400 text-sm flex items-center gap-2">
|
||||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
<CheckCircle className="w-4 h-4" />{success}
|
||||||
<AlertDescription className="text-green-800 dark:text-green-200">
|
</div>
|
||||||
{success}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="space-y-6">
|
||||||
{/* 左侧:用户头像和基本信息 */}
|
{/* 个人信息卡片 */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Card>
|
||||||
{/* 用户头像卡片 */}
|
<CardHeader>
|
||||||
<Card>
|
<div className="flex items-center justify-between">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle>头像</CardTitle>
|
<User className="w-5 h-5 text-primary" />
|
||||||
<CardDescription>您的账户头像</CardDescription>
|
<CardTitle>个人信息</CardTitle>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
{!isEditing && (
|
||||||
<div className="flex items-center space-x-6">
|
<button
|
||||||
<div className="w-24 h-24 bg-gradient-to-r from-blue-600 to-purple-600 rounded-full flex items-center justify-center text-white text-3xl font-bold shadow-lg">
|
onClick={() => setIsEditing(true)}
|
||||||
{initials}
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />编辑
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardDescription>管理您的账户基本信息</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* 头像 + 表单 */}
|
||||||
|
<div className="flex items-center gap-5 mb-6 pb-6 border-b">
|
||||||
|
<div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center text-primary-foreground text-xl font-bold flex-shrink-0">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-base font-medium">{user.full_name || user.username}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm text-muted-foreground">用户名</Label>
|
||||||
|
<Input value={user.username} disabled className="mt-1.5 bg-muted" />
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">用户名创建后无法修改</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<Label className="text-sm text-muted-foreground">注册时间</Label>
|
||||||
当前使用用户名首字母作为头像
|
<Input value={formatDate(user.created_at)} disabled className="mt-1.5 bg-muted" />
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
未来版本将支持上传自定义头像
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 基本信息卡片 */}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>基本信息</CardTitle>
|
<Label htmlFor="email" className="text-sm text-muted-foreground">邮箱</Label>
|
||||||
<CardDescription>您的账户基本信息</CardDescription>
|
|
||||||
</div>
|
|
||||||
{!isEditing && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
>
|
|
||||||
<Edit2 className="w-4 h-4 mr-2" />
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
{/* 用户名(只读) */}
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="username">用户名</Label>
|
|
||||||
<Input
|
|
||||||
id="username"
|
|
||||||
value={user.username}
|
|
||||||
disabled
|
|
||||||
className="bg-muted"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
用户名创建后无法修改
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 邮箱(可编辑) */}
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="email">邮箱</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
{...register("email")}
|
{...register("email")}
|
||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
className={errors.email ? "border-red-500" : ""}
|
className={`mt-1.5 ${errors.email ? "border-destructive" : ""}`}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && <p className="text-xs text-destructive mt-1">{errors.email.message}</p>}
|
||||||
<p className="text-xs text-red-500 mt-1">
|
|
||||||
{errors.email.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 真实姓名(可编辑) */}
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="full_name">真实姓名</Label>
|
<Label htmlFor="full_name" className="text-sm text-muted-foreground">真实姓名</Label>
|
||||||
<Input
|
<Input
|
||||||
id="full_name"
|
id="full_name"
|
||||||
{...register("full_name")}
|
{...register("full_name")}
|
||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
placeholder="请输入您的真实姓名(可选)"
|
placeholder="请输入您的真实姓名(可选)"
|
||||||
|
className="mt-1.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 注册时间(只读) */}
|
{isEditing && (
|
||||||
<div>
|
<div className="flex items-center gap-3 pt-4 border-t">
|
||||||
<Label htmlFor="created_at">注册时间</Label>
|
<button
|
||||||
<Input
|
type="submit"
|
||||||
id="created_at"
|
disabled={isSaving}
|
||||||
value={formatDate(user.created_at)}
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||||
disabled
|
>
|
||||||
className="bg-muted"
|
{isSaving ? <><Loader2 className="w-4 h-4 animate-spin" />保存中...</> : <><Save className="w-4 h-4" />保存</>}
|
||||||
/>
|
</button>
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
{/* 账户状态(只读) */}
|
onClick={handleCancel}
|
||||||
<div>
|
disabled={isSaving}
|
||||||
<Label htmlFor="is_active">账户状态</Label>
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted disabled:opacity-50"
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
>
|
||||||
<Input
|
<X className="w-4 h-4" />取消
|
||||||
id="is_active"
|
</button>
|
||||||
value={user.is_active ? "已激活" : "未激活"}
|
|
||||||
disabled
|
|
||||||
className="bg-muted"
|
|
||||||
/>
|
|
||||||
{user.is_active && (
|
|
||||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 编辑模式下的按钮 */}
|
|
||||||
{isEditing && (
|
|
||||||
<div className="flex items-center space-x-3 pt-4">
|
|
||||||
<Button type="submit" disabled={isSaving}>
|
|
||||||
{isSaving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
保存中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
保存
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4 mr-2" />
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧:学习统计 */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>学习统计</CardTitle>
|
|
||||||
<CardDescription>您的学习数据概览</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{statistics ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<MessageSquare className="w-5 h-5 text-blue-600" />
|
|
||||||
<span className="text-sm font-medium">总对话数</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-bold">{statistics.total_sessions}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<MessageSquare className="w-5 h-5 text-green-600" />
|
|
||||||
<span className="text-sm font-medium">总消息数</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-bold">{statistics.total_messages}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Database className="w-5 h-5 text-purple-600" />
|
|
||||||
<span className="text-sm font-medium">知识库</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-bold">-</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<FileText className="w-5 h-5 text-orange-600" />
|
|
||||||
<span className="text-sm font-medium">文档数</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-bold">{statistics.total_documents}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-muted-foreground" />
|
|
||||||
<p className="text-sm text-muted-foreground">加载统计数据中...</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</form>
|
||||||
</Card>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 移动端导航 */}
|
|
||||||
<MobileNav />
|
<MobileNav />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
// 禁用静态生成
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -10,15 +9,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import MobileNav from "@/components/layout/mobile-nav";
|
import MobileNav from "@/components/layout/mobile-nav";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
@@ -39,17 +35,8 @@ const passwordSchema = z.object({
|
|||||||
|
|
||||||
type PasswordForm = z.infer<typeof passwordSchema>;
|
type PasswordForm = z.infer<typeof passwordSchema>;
|
||||||
|
|
||||||
// 密码强度检查函数
|
function getPasswordStrength(password: string) {
|
||||||
function getPasswordStrength(password: string): {
|
if (!password) return { label: "", color: "", score: 0 };
|
||||||
strength: "weak" | "medium" | "strong";
|
|
||||||
label: string;
|
|
||||||
color: string;
|
|
||||||
score: number;
|
|
||||||
} {
|
|
||||||
if (!password) {
|
|
||||||
return { strength: "weak", label: "", color: "", score: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
let score = 0;
|
let score = 0;
|
||||||
if (password.length >= 6) score++;
|
if (password.length >= 6) score++;
|
||||||
if (password.length >= 8) score++;
|
if (password.length >= 8) score++;
|
||||||
@@ -58,13 +45,9 @@ function getPasswordStrength(password: string): {
|
|||||||
if (/[0-9]/.test(password)) score++;
|
if (/[0-9]/.test(password)) score++;
|
||||||
if (/[^a-zA-Z0-9]/.test(password)) score++;
|
if (/[^a-zA-Z0-9]/.test(password)) score++;
|
||||||
|
|
||||||
if (score <= 2) {
|
if (score <= 2) return { label: "弱", color: "text-destructive", score };
|
||||||
return { strength: "weak", label: "弱", color: "text-red-600", score };
|
if (score <= 4) return { label: "中", color: "text-amber-500", score };
|
||||||
} else if (score <= 4) {
|
return { label: "强", color: "text-emerald-500", score };
|
||||||
return { strength: "medium", label: "中", color: "text-yellow-600", score };
|
|
||||||
} else {
|
|
||||||
return { strength: "strong", label: "强", color: "text-green-600", score };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -95,16 +78,12 @@ export default function SettingsPage() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
|
||||||
await authAPI.changePassword({
|
await authAPI.changePassword({
|
||||||
old_password: data.old_password,
|
old_password: data.old_password,
|
||||||
new_password: data.new_password,
|
new_password: data.new_password,
|
||||||
});
|
});
|
||||||
|
|
||||||
setSuccess("密码修改成功");
|
setSuccess("密码修改成功");
|
||||||
reset();
|
reset();
|
||||||
|
|
||||||
// 3秒后清除成功消息
|
|
||||||
setTimeout(() => setSuccess(null), 3000);
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("修改密码失败:", err);
|
console.error("修改密码失败:", err);
|
||||||
@@ -128,179 +107,128 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-app pb-16 lg:pb-0">
|
<div className="min-h-screen bg-background pb-16 lg:pb-0">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* 错误提示 */}
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive" className="mb-6">
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 成功提示 */}
|
{/* 状态提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{success && (
|
{success && (
|
||||||
<Alert className="mb-6 border-green-500 bg-green-50 dark:bg-green-950">
|
<div className="mb-6 p-3 rounded-lg bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-400 text-sm flex items-center gap-2">
|
||||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
<AlertDescription className="text-green-800 dark:text-green-200">
|
{success}
|
||||||
{success}
|
</div>
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 账户安全 */}
|
{/* 账户安全 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-2">
|
||||||
<Shield className="w-5 h-5 text-blue-600" />
|
<Shield className="w-5 h-5 text-primary" />
|
||||||
<CardTitle>账户安全</CardTitle>
|
<CardTitle>账户安全</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>修改您的登录密码</CardDescription>
|
<CardDescription>修改您的登录密码</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
{/* 当前密码 */}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="old_password">当前密码</Label>
|
<Label htmlFor="old_password" className="text-sm text-muted-foreground">当前密码</Label>
|
||||||
<div className="relative">
|
<div className="relative mt-1.5">
|
||||||
<Input
|
<Input
|
||||||
id="old_password"
|
id="old_password"
|
||||||
type={showOldPassword ? "text" : "password"}
|
type={showOldPassword ? "text" : "password"}
|
||||||
{...register("old_password")}
|
{...register("old_password")}
|
||||||
className={errors.old_password ? "border-red-500" : ""}
|
className={errors.old_password ? "border-destructive" : ""}
|
||||||
placeholder="请输入当前密码"
|
placeholder="请输入当前密码"
|
||||||
/>
|
/>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
size="sm"
|
onClick={() => setShowOldPassword(!showOldPassword)}
|
||||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
>
|
||||||
onClick={() => setShowOldPassword(!showOldPassword)}
|
{showOldPassword ? <EyeOff className="w-4 h-4 text-muted-foreground" /> : <Eye className="w-4 h-4 text-muted-foreground" />}
|
||||||
>
|
</button>
|
||||||
{showOldPassword ? (
|
|
||||||
<EyeOff className="w-4 h-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{errors.old_password && (
|
|
||||||
<p className="text-xs text-red-500 mt-1">
|
|
||||||
{errors.old_password.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 新密码 */}
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="new_password">新密码</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="new_password"
|
|
||||||
type={showNewPassword ? "text" : "password"}
|
|
||||||
{...register("new_password")}
|
|
||||||
className={errors.new_password ? "border-red-500" : ""}
|
|
||||||
placeholder="请输入新密码(至少6个字符)"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
||||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
|
||||||
>
|
|
||||||
{showNewPassword ? (
|
|
||||||
<EyeOff className="w-4 h-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{newPassword && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="flex items-center space-x-2 mb-1">
|
|
||||||
<span className="text-xs text-muted-foreground">密码强度:</span>
|
|
||||||
<span className={`text-xs font-medium ${passwordStrength.color}`}>
|
|
||||||
{passwordStrength.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-muted rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all ${
|
|
||||||
passwordStrength.strength === "weak"
|
|
||||||
? "bg-red-500 w-1/3"
|
|
||||||
: passwordStrength.strength === "medium"
|
|
||||||
? "bg-yellow-500 w-2/3"
|
|
||||||
: "bg-green-500 w-full"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{errors.old_password && <p className="text-xs text-destructive mt-1">{errors.old_password.message}</p>}
|
||||||
{errors.new_password && (
|
</div>
|
||||||
<p className="text-xs text-red-500 mt-1">
|
|
||||||
{errors.new_password.message}
|
<div>
|
||||||
</p>
|
<Label htmlFor="new_password" className="text-sm text-muted-foreground">新密码</Label>
|
||||||
)}
|
<div className="relative mt-1.5">
|
||||||
|
<Input
|
||||||
|
id="new_password"
|
||||||
|
type={showNewPassword ? "text" : "password"}
|
||||||
|
{...register("new_password")}
|
||||||
|
className={errors.new_password ? "border-destructive" : ""}
|
||||||
|
placeholder="请输入新密码(至少6个字符)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
|
>
|
||||||
|
{showNewPassword ? <EyeOff className="w-4 h-4 text-muted-foreground" /> : <Eye className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{newPassword && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs text-muted-foreground">密码强度:</span>
|
||||||
|
<span className={`text-xs font-medium ${passwordStrength.color}`}>{passwordStrength.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
|
passwordStrength.score <= 2 ? "bg-destructive w-1/3"
|
||||||
|
: passwordStrength.score <= 4 ? "bg-amber-500 w-2/3"
|
||||||
|
: "bg-emerald-500 w-full"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errors.new_password && <p className="text-xs text-destructive mt-1">{errors.new_password.message}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 确认新密码 */}
|
<div className="max-w-md">
|
||||||
<div>
|
<Label htmlFor="confirm_password" className="text-sm text-muted-foreground">确认新密码</Label>
|
||||||
<Label htmlFor="confirm_password">确认新密码</Label>
|
<div className="relative mt-1.5">
|
||||||
<div className="relative">
|
|
||||||
<Input
|
<Input
|
||||||
id="confirm_password"
|
id="confirm_password"
|
||||||
type={showConfirmPassword ? "text" : "password"}
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
{...register("confirm_password")}
|
{...register("confirm_password")}
|
||||||
className={errors.confirm_password ? "border-red-500" : ""}
|
className={errors.confirm_password ? "border-destructive" : ""}
|
||||||
placeholder="请再次输入新密码"
|
placeholder="请再次输入新密码"
|
||||||
/>
|
/>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
size="sm"
|
|
||||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
||||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
>
|
>
|
||||||
{showConfirmPassword ? (
|
{showConfirmPassword ? <EyeOff className="w-4 h-4 text-muted-foreground" /> : <Eye className="w-4 h-4 text-muted-foreground" />}
|
||||||
<EyeOff className="w-4 h-4 text-muted-foreground" />
|
</button>
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{errors.confirm_password && (
|
{errors.confirm_password && <p className="text-xs text-destructive mt-1">{errors.confirm_password.message}</p>}
|
||||||
<p className="text-xs text-red-500 mt-1">
|
|
||||||
{errors.confirm_password.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 密码要求提示 */}
|
<div className="pt-4 border-t">
|
||||||
<div className="p-4 bg-muted/50 rounded-lg">
|
<button
|
||||||
<p className="text-sm font-medium mb-2">密码要求:</p>
|
type="submit"
|
||||||
<ul className="text-xs text-muted-foreground space-y-1">
|
disabled={isSubmitting}
|
||||||
<li>• 至少6个字符</li>
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||||
<li>• 建议包含大小写字母、数字和特殊字符</li>
|
>
|
||||||
<li>• 避免使用常用密码或个人信息</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 提交按钮 */}
|
|
||||||
<div className="pt-4">
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<><Loader2 className="w-4 h-4 animate-spin" />修改中...</>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
修改中...
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<><Key className="w-4 h-4" />修改密码</>
|
||||||
<Key className="w-4 h-4 mr-2" />
|
|
||||||
修改密码
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -309,43 +237,25 @@ export default function SettingsPage() {
|
|||||||
{/* 偏好设置 */}
|
{/* 偏好设置 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-2">
|
||||||
<Palette className="w-5 h-5 text-purple-600" />
|
<Palette className="w-5 h-5 text-primary" />
|
||||||
<CardTitle>偏好设置</CardTitle>
|
<CardTitle>偏好设置</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>自定义您的使用偏好</CardDescription>
|
<CardDescription>自定义您的使用体验</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="flex items-center justify-between py-3 border-b last:border-0">
|
||||||
{/* 主题设置 */}
|
<div>
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
<p className="text-sm font-medium">外观主题</p>
|
||||||
<div className="flex-1">
|
<p className="text-xs text-muted-foreground mt-0.5">切换浅色或深色模式</p>
|
||||||
<div className="flex items-center space-x-2 mb-1">
|
|
||||||
<Palette className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<Label className="text-base font-medium">主题</Label>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
切换浅色/深色主题模式
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 未来可添加其他偏好设置 */}
|
|
||||||
<div className="p-4 bg-muted/50 rounded-lg">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
更多偏好设置功能即将推出
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 移动端导航 */}
|
|
||||||
<MobileNav />
|
<MobileNav />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default function TextToImagePage() {
|
|||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="text-center mb-8"
|
className="text-center mb-8"
|
||||||
>
|
>
|
||||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-4">
|
<h1 className="text-4xl font-bold text-foreground mb-4">
|
||||||
文生图 - Text to Image
|
文生图 - Text to Image
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Link from "next/link";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { forumAPI } from "@/lib/api";
|
import { forumAPI } from "@/lib/api";
|
||||||
import type { ForumCategory, ForumPostSummary } from "@/types";
|
import type { ForumCategory, ForumPostSummary } from "@/types";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
@@ -19,22 +18,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string) {
|
import { format } from "date-fns";
|
||||||
const date = new Date(
|
|
||||||
new Date(dateStr).getTime() + 8 * 60 * 60 * 1000
|
|
||||||
);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffMinutes < 1) return "刚刚";
|
|
||||||
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
|
|
||||||
if (diffHours < 24) return `${diffHours} 小时前`;
|
|
||||||
if (diffDays < 7) return `${diffDays} 天前`;
|
|
||||||
return date.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ForumCategoryPage() {
|
export default function ForumCategoryPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -118,7 +102,7 @@ export default function ForumCategoryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Back nav */}
|
{/* Back nav */}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/forum")}
|
onClick={() => router.push("/forum")}
|
||||||
@@ -144,20 +128,19 @@ export default function ForumCategoryPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<Button
|
<button
|
||||||
onClick={() => setShowForm(!showForm)}
|
onClick={() => setShowForm(!showForm)}
|
||||||
size="sm"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
className="gap-1.5"
|
|
||||||
>
|
>
|
||||||
<PenLine className="w-3.5 h-3.5" />
|
<PenLine className="w-3.5 h-3.5" />
|
||||||
{showForm ? "收起" : "发帖"}
|
{showForm ? "收起" : "发帖"}
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New post form (collapsible) */}
|
{/* New post form (collapsible) */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="bg-muted/30 rounded-xl border border-border/40 p-5 mb-6 space-y-4">
|
<div className="bg-muted rounded-xl border border-border p-5 mb-6 space-y-4">
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
{submitError && (
|
{submitError && (
|
||||||
<p className="text-sm text-destructive">{submitError}</p>
|
<p className="text-sm text-destructive">{submitError}</p>
|
||||||
@@ -179,11 +162,10 @@ export default function ForumCategoryPage() {
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="gap-1.5"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-[#2563eb] text-white hover:bg-[#2563eb]/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
@@ -196,7 +178,7 @@ export default function ForumCategoryPage() {
|
|||||||
发布
|
发布
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,7 +186,7 @@ export default function ForumCategoryPage() {
|
|||||||
|
|
||||||
{/* Login prompt */}
|
{/* Login prompt */}
|
||||||
{!isAuthenticated && (
|
{!isAuthenticated && (
|
||||||
<div className="text-center py-4 mb-6 bg-muted/20 rounded-lg text-sm text-muted-foreground">
|
<div className="text-center py-4 mb-6 bg-muted rounded-lg text-sm text-muted-foreground">
|
||||||
需要登录后才能发帖。请先
|
需要登录后才能发帖。请先
|
||||||
<Link href="/login" className="text-primary hover:underline mx-1">
|
<Link href="/login" className="text-primary hover:underline mx-1">
|
||||||
登录
|
登录
|
||||||
@@ -235,14 +217,14 @@ export default function ForumCategoryPage() {
|
|||||||
<p className="text-muted-foreground">暂无帖子,来发第一个帖吧</p>
|
<p className="text-muted-foreground">暂无帖子,来发第一个帖吧</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border/40 rounded-xl border border-border/40 overflow-hidden">
|
<div className="divide-y divide-border/40 rounded-xl border border-border overflow-hidden">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<Link
|
<Link
|
||||||
key={post.id}
|
key={post.id}
|
||||||
href={`/forum/post/${post.id}`}
|
href={`/forum/post/${post.id}`}
|
||||||
className="flex items-start gap-3 px-5 py-4 hover:bg-muted/30 transition-colors group"
|
className="flex items-start gap-3 px-5 py-4 hover:bg-muted transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="mt-0.5 w-9 h-9 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0">
|
<div className="mt-0.5 w-9 h-9 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
|
||||||
<User className="w-4 h-4 text-muted-foreground" />
|
<User className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -253,7 +235,7 @@ export default function ForumCategoryPage() {
|
|||||||
<span>{post.author_name}</span>
|
<span>{post.author_name}</span>
|
||||||
<span className="inline-flex items-center gap-0.5">
|
<span className="inline-flex items-center gap-0.5">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{formatRelativeTime(post.created_at)}
|
{format(new Date(post.created_at), "yyyy/M/d HH:mm")}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-0.5">
|
<span className="inline-flex items-center gap-0.5">
|
||||||
<MessageCircle className="w-3 h-3" />
|
<MessageCircle className="w-3 h-3" />
|
||||||
|
|||||||
@@ -9,22 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Loader2, MessageCircle, Clock, User, Send, ArrowLeft } from "lucide-react";
|
import { Loader2, MessageCircle, Clock, User, Send, ArrowLeft } from "lucide-react";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string) {
|
import { format } from "date-fns";
|
||||||
const date = new Date(
|
|
||||||
new Date(dateStr).getTime() + 8 * 60 * 60 * 1000
|
|
||||||
);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffMinutes < 1) return "刚刚";
|
|
||||||
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
|
|
||||||
if (diffHours < 24) return `${diffHours} 小时前`;
|
|
||||||
if (diffDays < 7) return `${diffDays} 天前`;
|
|
||||||
return date.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ForumPostPage() {
|
export default function ForumPostPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -90,7 +75,8 @@ export default function ForumPostPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
{/* Back nav */}
|
{/* Back nav */}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
@@ -112,8 +98,8 @@ export default function ForumPostPage() {
|
|||||||
) : post ? (
|
) : post ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Post content */}
|
{/* Post content */}
|
||||||
<article className="rounded-xl border border-border/40 overflow-hidden">
|
<article className="rounded-xl border border-border overflow-hidden">
|
||||||
<div className="px-6 py-5 border-b border-border/20">
|
<div className="px-6 py-5 border-b border-border">
|
||||||
<h1
|
<h1
|
||||||
className="text-xl font-bold tracking-tight leading-snug"
|
className="text-xl font-bold tracking-tight leading-snug"
|
||||||
style={{ fontFamily: "var(--font-serif), Georgia, serif" }}
|
style={{ fontFamily: "var(--font-serif), Georgia, serif" }}
|
||||||
@@ -122,14 +108,14 @@ export default function ForumPostPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-3 mt-3 text-sm text-muted-foreground">
|
<div className="flex items-center gap-3 mt-3 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-6 h-6 rounded-full bg-muted/60 flex items-center justify-center">
|
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center">
|
||||||
<User className="w-3 h-3 text-muted-foreground" />
|
<User className="w-3 h-3 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<span>{post.author_name}</span>
|
<span>{post.author_name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="inline-flex items-center gap-0.5">
|
<span className="inline-flex items-center gap-0.5">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{formatRelativeTime(post.created_at)}
|
{format(new Date(post.created_at), "yyyy/M/d HH:mm")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +134,7 @@ export default function ForumPostPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{post.replies.length === 0 ? (
|
{post.replies.length === 0 ? (
|
||||||
<div className="text-center py-8 text-sm text-muted-foreground bg-muted/20 rounded-xl">
|
<div className="text-center py-8 text-sm text-muted-foreground bg-muted rounded-xl">
|
||||||
暂无回复,欢迎分享你的观点
|
暂无回复,欢迎分享你的观点
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -156,9 +142,9 @@ export default function ForumPostPage() {
|
|||||||
{post.replies.map((reply) => (
|
{post.replies.map((reply) => (
|
||||||
<div
|
<div
|
||||||
key={reply.id}
|
key={reply.id}
|
||||||
className="flex gap-3 px-5 py-4 rounded-xl border border-border/30 hover:border-border/60 transition-colors"
|
className="flex gap-3 px-5 py-4 rounded-xl border border-border hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0 mt-0.5">
|
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
<User className="w-3.5 h-3.5 text-muted-foreground" />
|
<User className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -167,7 +153,7 @@ export default function ForumPostPage() {
|
|||||||
{reply.author_name}
|
{reply.author_name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{formatRelativeTime(reply.created_at)}
|
{format(new Date(reply.created_at), "yyyy/M/d HH:mm")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
|
<p className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
|
||||||
@@ -181,7 +167,7 @@ export default function ForumPostPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Reply form */}
|
{/* Reply form */}
|
||||||
<div className="rounded-xl border border-border/40 p-5">
|
<div className="rounded-xl border border-border p-5">
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<form onSubmit={handleReply} className="space-y-3">
|
<form onSubmit={handleReply} className="space-y-3">
|
||||||
{submitError && (
|
{submitError && (
|
||||||
@@ -199,7 +185,7 @@ export default function ForumPostPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !replyContent.trim()}
|
disabled={isSubmitting || !replyContent.trim()}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg bg-[#2563eb] text-white hover:bg-[#2563eb]/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
@@ -236,6 +222,7 @@ export default function ForumPostPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -378,6 +378,39 @@ button {
|
|||||||
margin: 1.5em 0;
|
margin: 1.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 文内引用链接样式 */
|
||||||
|
.citation-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 0.125rem;
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
background-color: hsl(var(--primary) / 0.08);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
vertical-align: super;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.citation-link:hover {
|
||||||
|
background-color: hsl(var(--primary) / 0.18);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 来源卡片锚点滚动偏移(避免被固定header遮挡) */
|
||||||
|
.scroll-mt-20 {
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 来源卡片被引用点击时的高亮动画 */
|
||||||
|
@keyframes citation-highlight {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
|
||||||
|
50% { box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.35); }
|
||||||
|
}
|
||||||
|
.citation-highlight {
|
||||||
|
animation: citation-highlight 1.2s ease-in-out 2;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { knowledgeBaseAPI } from "@/lib/api";
|
|||||||
export default function ChatInterface() {
|
export default function ChatInterface() {
|
||||||
const [inputMessage, setInputMessage] = useState("");
|
const [inputMessage, setInputMessage] = useState("");
|
||||||
const [isComposing, setIsComposing] = useState(false);
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState("deepseek-chat");
|
const [selectedModel, setSelectedModel] = useState("deepseek-reasoner");
|
||||||
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<string[]>([]);
|
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<string[]>([]);
|
||||||
const [systemKnowledgeBases, setSystemKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
const [systemKnowledgeBases, setSystemKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||||
const [userKnowledgeBases, setUserKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
const [userKnowledgeBases, setUserKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||||
@@ -188,7 +188,7 @@ export default function ChatInterface() {
|
|||||||
"transition-all duration-150",
|
"transition-all duration-150",
|
||||||
isStreaming
|
isStreaming
|
||||||
? "bg-destructive hover:bg-destructive/90 text-white"
|
? "bg-destructive hover:bg-destructive/90 text-white"
|
||||||
: "bg-[#2563eb] hover:bg-[#1d4ed8] text-white disabled:bg-[#2563eb]/40 disabled:cursor-not-allowed"
|
: "bg-primary hover:bg-primary/90 text-primary-foreground disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChatMessage, ThinkingStep } from "@/types";
|
import { ChatMessage, ThinkingStep } from "@/types";
|
||||||
import { User, Bot, Copy, Edit, RotateCcw, ThumbsUp, ThumbsDown, Loader2, CheckCircle2, FileSearch, Brain, Sparkles } from "lucide-react";
|
import { User, Bot, Copy, Edit, RotateCcw, ThumbsUp, ThumbsDown, Loader2, Brain } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeRaw from "rehype-raw";
|
||||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
|
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
import { format } from "date-fns";
|
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,32 +22,37 @@ 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;
|
||||||
|
|
||||||
// 分离状态步骤和推理内容
|
// 只提取有实际内容的步骤:推理内容和检索文档
|
||||||
const statusSteps = thinking.filter(s => s.stage !== 'reasoning');
|
|
||||||
const reasoningSteps = thinking.filter(s => s.stage === 'reasoning');
|
const reasoningSteps = thinking.filter(s => s.stage === 'reasoning');
|
||||||
|
const retrievedStep = thinking.find(s => s.stage === 'retrieved');
|
||||||
const hasReasoning = reasoningSteps.length > 0;
|
const hasReasoning = reasoningSteps.length > 0;
|
||||||
|
const hasDocs = retrievedStep?.details && retrievedStep.details.length > 0;
|
||||||
|
|
||||||
|
// 没有推理内容也没有文档详情时不显示
|
||||||
|
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('');
|
||||||
|
|
||||||
// 汇总信息
|
// 流式时是否正在推理
|
||||||
const retrievedStep = statusSteps.find(s => s.stage === 'retrieved');
|
|
||||||
const totalTime = statusSteps.reduce((sum, s) => sum + (s.time || 0), 0);
|
|
||||||
|
|
||||||
// 流式时显示最后状态
|
|
||||||
const lastStatusStep = statusSteps[statusSteps.length - 1];
|
|
||||||
const isReasoningNow = isStreaming && thinking[thinking.length - 1]?.stage === 'reasoning';
|
const isReasoningNow = isStreaming && thinking[thinking.length - 1]?.stage === 'reasoning';
|
||||||
|
|
||||||
// 折叠标题
|
// 折叠标题
|
||||||
const collapsedTitle = isStreaming
|
const collapsedTitle = isStreaming
|
||||||
? isReasoningNow
|
? isReasoningNow ? '思考中...' : '检索中...'
|
||||||
? '深度思考中...'
|
|
||||||
: lastStatusStep?.message || '思考中...'
|
|
||||||
: hasReasoning
|
: hasReasoning
|
||||||
? `思考过程 (${reasoningText.length} 字)`
|
? `思考过程 (${reasoningText.length} 字)`
|
||||||
: `思考过程${totalTime > 0 ? ` (${totalTime.toFixed(1)}s)` : ''}`;
|
: retrievedStep
|
||||||
|
? `检索到 ${retrievedStep.doc_count} 篇相关文档`
|
||||||
|
: '思考过程';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-2.5">
|
<div className="mb-2.5">
|
||||||
@@ -62,37 +68,14 @@ const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[];
|
|||||||
</svg>
|
</svg>
|
||||||
<Brain className={cn("h-3 w-3", isReasoningNow && "animate-pulse text-primary")} />
|
<Brain className={cn("h-3 w-3", isReasoningNow && "animate-pulse text-primary")} />
|
||||||
<span>{collapsedTitle}</span>
|
<span>{collapsedTitle}</span>
|
||||||
{retrievedStep?.doc_count != null && (
|
|
||||||
<span className="opacity-60 ml-1">· {retrievedStep.doc_count} 篇文档</span>
|
|
||||||
)}
|
|
||||||
{totalTime > 0 && !isStreaming && (
|
|
||||||
<span className="opacity-50 ml-1">· {totalTime.toFixed(1)}s</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="mt-1.5 ml-4 space-y-2 text-xs border-l-2 border-border pl-3">
|
<div className="mt-1.5 ml-4 space-y-2 text-xs border-l-2 border-border pl-3">
|
||||||
{/* 状态步骤 */}
|
|
||||||
{statusSteps.map((step, index) => (
|
|
||||||
<div key={`s-${index}`} className="flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
{step.stage === 'retrieving' ? (
|
|
||||||
<FileSearch className="h-3 w-3" />
|
|
||||||
) : step.stage === 'retrieved' ? (
|
|
||||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
|
||||||
) : step.stage === 'generating' ? (
|
|
||||||
<Sparkles className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-current" />
|
|
||||||
)}
|
|
||||||
<span>{step.message}</span>
|
|
||||||
{step.time != null && <span className="opacity-50">{step.time.toFixed(1)}s</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 检索到的文档详情 */}
|
{/* 检索到的文档详情 */}
|
||||||
{retrievedStep?.details && retrievedStep.details.length > 0 && (
|
{hasDocs && (
|
||||||
<div className="mt-1 space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-muted-foreground font-medium">参考文档:</div>
|
<div className="text-muted-foreground font-medium">参考文档:</div>
|
||||||
{retrievedStep.details.map((detail: string, i: number) => (
|
{retrievedStep?.details?.map((detail: string, i: number) => (
|
||||||
<div key={i} className="text-muted-foreground/80 pl-2 border-l border-border/50">
|
<div key={i} className="text-muted-foreground/80 pl-2 border-l border-border/50">
|
||||||
{detail}
|
{detail}
|
||||||
</div>
|
</div>
|
||||||
@@ -103,9 +86,6 @@ const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[];
|
|||||||
{/* 推理内容 */}
|
{/* 推理内容 */}
|
||||||
{hasReasoning && (
|
{hasReasoning && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<div className="text-muted-foreground font-medium mb-1">
|
|
||||||
{isReasoningNow ? '推理进行中...' : '推理过程:'}
|
|
||||||
</div>
|
|
||||||
<div className="text-foreground/80 whitespace-pre-wrap leading-relaxed bg-muted/30 rounded-lg p-2.5 max-h-80 overflow-y-auto">
|
<div className="text-foreground/80 whitespace-pre-wrap leading-relaxed bg-muted/30 rounded-lg p-2.5 max-h-80 overflow-y-auto">
|
||||||
{reasoningText}
|
{reasoningText}
|
||||||
</div>
|
</div>
|
||||||
@@ -117,6 +97,13 @@ const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[];
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function preprocessCitations(content: string, messageId: string): string {
|
||||||
|
return content.replace(
|
||||||
|
/\[来源\s*(\d+)\]/g,
|
||||||
|
`<a class="citation-link" href="#source-${messageId}-$1" data-message-id="${messageId}" data-source-id="$1">[$1]</a>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MessageItem({ message, selectedModel }: MessageItemProps) {
|
export default function MessageItem({ message, selectedModel }: MessageItemProps) {
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const isAssistant = message.role === "assistant";
|
const isAssistant = message.role === "assistant";
|
||||||
@@ -211,7 +198,33 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
|
|||||||
)}>
|
)}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw]}
|
||||||
components={{
|
components={{
|
||||||
|
a: ({ href, children, className, ...props }: any) => {
|
||||||
|
if (className === 'citation-link') {
|
||||||
|
const sourceId = (props as any)['data-source-id'];
|
||||||
|
const messageId = (props as any)['data-message-id'];
|
||||||
|
const fullId = `source-${messageId}-${sourceId}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="citation-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = document.getElementById(fullId);
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
target.classList.add('citation-highlight');
|
||||||
|
setTimeout(() => target.classList.remove('citation-highlight'), 2500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={`跳转到来源 ${sourceId}`}
|
||||||
|
>
|
||||||
|
[{sourceId}]
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline" {...props}>{children}</a>;
|
||||||
|
},
|
||||||
code({ node, inline, className, children, ...props }: any) {
|
code({ node, inline, className, children, ...props }: any) {
|
||||||
const match = /language-(\w+)/.exec(className || "");
|
const match = /language-(\w+)/.exec(className || "");
|
||||||
return !inline && match ? (
|
return !inline && match ? (
|
||||||
@@ -239,15 +252,31 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
|
|||||||
td: ({ children }) => (
|
td: ({ children }) => (
|
||||||
<td className="border border-border px-2 py-1.5">{children}</td>
|
<td className="border border-border px-2 py-1.5">{children}</td>
|
||||||
),
|
),
|
||||||
|
img: ({ src, alt }: any) => (
|
||||||
|
<a href={src} target="_blank" rel="noopener noreferrer" className="block my-3 group">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt || "图片来源"}
|
||||||
|
loading="lazy"
|
||||||
|
className="max-w-full max-h-80 rounded-lg border border-border/50 cursor-pointer
|
||||||
|
hover:border-primary/40 transition-colors object-contain bg-muted/20"
|
||||||
|
/>
|
||||||
|
{alt && (
|
||||||
|
<span className="block text-[11px] text-muted-foreground/70 mt-1 text-center">
|
||||||
|
{alt}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{message.content}
|
{preprocessCitations(message.content, String(message.id))}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAssistant && message.metadata?.sources && message.metadata.sources.length > 0 && (
|
{isAssistant && message.metadata?.sources && message.metadata.sources.length > 0 && (
|
||||||
<div className="mt-2.5 pt-2.5 border-t border-border/30">
|
<div className="mt-2.5 pt-2.5 border-t border-border/30">
|
||||||
<SourceReferences sources={message.metadata.sources} maxSources={3} />
|
<SourceReferences sources={message.metadata.sources} answerContent={message.content} messageId={String(message.id)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +309,7 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
|
|||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-muted-foreground/50 mx-1">
|
<span className="text-[10px] text-muted-foreground/50 mx-1">
|
||||||
{message.created_at
|
{message.created_at
|
||||||
? format(new Date(new Date(message.created_at).getTime() + 8 * 60 * 60 * 1000), "HH:mm")
|
? format(new Date(message.created_at), "yyyy/M/d HH:mm")
|
||||||
: ""}
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuLabel,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ChevronDown, Cpu, Zap, Sparkles, Brain } from "lucide-react";
|
import { ChevronDown, Cpu, Sparkles, Brain } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface ModelOption {
|
export interface ModelOption {
|
||||||
@@ -29,55 +28,25 @@ interface ModelSelectorProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelGroups = [
|
const models: ModelOption[] = [
|
||||||
{
|
{
|
||||||
|
id: "deepseek-reasoner",
|
||||||
|
name: "DeepSeek-R1",
|
||||||
|
description: "深度推理模型,适合复杂分析任务",
|
||||||
provider: "deepseek-official",
|
provider: "deepseek-official",
|
||||||
label: "DeepSeek 官方",
|
providerLabel: "DeepSeek",
|
||||||
models: [
|
icon: Brain,
|
||||||
{
|
|
||||||
id: "deepseek-chat",
|
|
||||||
name: "DeepSeek-V3",
|
|
||||||
description: "DeepSeek 最新通用模型,速度快、能力强",
|
|
||||||
provider: "deepseek-official",
|
|
||||||
providerLabel: "DeepSeek",
|
|
||||||
icon: Sparkles,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "deepseek-reasoner",
|
|
||||||
name: "DeepSeek-R1",
|
|
||||||
description: "深度推理模型,适合复杂分析任务",
|
|
||||||
provider: "deepseek-official",
|
|
||||||
providerLabel: "DeepSeek",
|
|
||||||
icon: Brain,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "siliconflow",
|
id: "deepseek-chat",
|
||||||
label: "SiliconFlow(硅基流动)",
|
name: "DeepSeek-V3",
|
||||||
models: [
|
description: "通用模型,速度快、能力强",
|
||||||
{
|
provider: "deepseek-official",
|
||||||
id: "deepseek-ai/DeepSeek-V3",
|
providerLabel: "DeepSeek",
|
||||||
name: "DeepSeek-V3",
|
icon: Sparkles,
|
||||||
description: "通过硅基流动调用,稳定的推理能力",
|
|
||||||
provider: "siliconflow",
|
|
||||||
providerLabel: "硅基流动",
|
|
||||||
icon: Sparkles,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "Qwen/QwQ-32B",
|
|
||||||
name: "QwQ-32B",
|
|
||||||
description: "Qwen 推理模型,高效准确",
|
|
||||||
provider: "siliconflow",
|
|
||||||
providerLabel: "硅基流动",
|
|
||||||
icon: Zap,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const allModels = modelGroups.flatMap((g) => g.models);
|
|
||||||
|
|
||||||
export default function ModelSelector({
|
export default function ModelSelector({
|
||||||
selectedModel,
|
selectedModel,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
@@ -86,7 +55,7 @@ export default function ModelSelector({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const selectedModelData =
|
const selectedModelData =
|
||||||
allModels.find((model) => model.id === selectedModel) || allModels[0];
|
models.find((model) => model.id === selectedModel) || models[0];
|
||||||
const IconComponent = selectedModelData.icon || Cpu;
|
const IconComponent = selectedModelData.icon || Cpu;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -107,51 +76,44 @@ export default function ModelSelector({
|
|||||||
<ChevronDown className="w-3 h-3 ml-1 opacity-50" />
|
<ChevronDown className="w-3 h-3 ml-1 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-72">
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
选择AI模型
|
选择AI模型
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{modelGroups.map((group) => (
|
{models.map((model) => {
|
||||||
<div key={group.provider}>
|
const ModelIcon = model.icon || Cpu;
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground/70 font-normal px-2 pt-2">
|
const isSelected = model.id === selectedModel;
|
||||||
{group.label}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{group.models.map((model) => {
|
|
||||||
const ModelIcon = model.icon || Cpu;
|
|
||||||
const isSelected = model.id === selectedModel;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onModelChange(model.id);
|
onModelChange(model.id);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-start space-x-3 p-3 cursor-pointer",
|
"flex items-start space-x-3 p-3 cursor-pointer",
|
||||||
isSelected && "bg-muted/50"
|
isSelected && "bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ModelIcon className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-medium text-sm">{model.name}</span>
|
||||||
|
{isSelected && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
已选择
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
<ModelIcon className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
<div className="flex-1 min-w-0">
|
{model.description}
|
||||||
<div className="flex items-center space-x-2">
|
</p>
|
||||||
<span className="font-medium text-sm">{model.name}</span>
|
</div>
|
||||||
{isSelected && (
|
</DropdownMenuItem>
|
||||||
<Badge variant="secondary" className="text-xs">
|
);
|
||||||
已选择
|
})}
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{model.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
+103
-100
@@ -12,7 +12,15 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Download,
|
Download,
|
||||||
Search,
|
Search,
|
||||||
|
MoreVertical,
|
||||||
|
PanelLeftClose,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -109,106 +117,101 @@ export default function Sidebar({ onClose }: SidebarProps) {
|
|||||||
const groups = groupSessionsByDate(filteredSessions());
|
const groups = groupSessionsByDate(filteredSessions());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex flex-col h-full">
|
{/* Header */}
|
||||||
{/* Header */}
|
<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
|
||||||
<Button onClick={handleNewChat} size="sm" variant="ghost" className="h-7 w-7 p-0 hover:bg-primary/10 hover:text-primary">
|
onClick={onClose}
|
||||||
<Plus className="w-4 h-4" />
|
className="h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||||
</Button>
|
title="收起侧边栏"
|
||||||
</div>
|
|
||||||
<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" />
|
|
||||||
<Input
|
|
||||||
placeholder="搜索"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-8 h-8 text-sm bg-muted/30 border border-border/20 focus-visible:ring-1 focus-visible:border-primary/30"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Session list with date groups */}
|
|
||||||
<ScrollArea className="flex-1 px-2">
|
|
||||||
<div className="pb-4">
|
|
||||||
{groups.map(group => (
|
|
||||||
<div key={group.label} className="mb-3">
|
|
||||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground/70 uppercase tracking-wider">
|
|
||||||
{group.label}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{group.sessions.map(session => (
|
|
||||||
<div
|
|
||||||
key={session.id}
|
|
||||||
className="relative rounded-lg cursor-pointer transition-colors hover:bg-muted/50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: currentSession?.id === session.id ? 'var(--muted)' : undefined,
|
|
||||||
color: currentSession?.id === session.id ? 'var(--foreground)' : undefined
|
|
||||||
}}
|
|
||||||
onClick={() => handleSelectSession(session.id)}
|
|
||||||
>
|
|
||||||
{/* Title row */}
|
|
||||||
<div className="flex items-center gap-2 px-2.5 py-2 pr-28">
|
|
||||||
<MessageSquare className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
|
||||||
<span className="text-sm truncate">{session.title}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action buttons — absolute positioned, always visible */}
|
|
||||||
<div
|
|
||||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-1"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-gray-500 hover:text-gray-800 hover:bg-gray-200 transition-colors"
|
|
||||||
title="重命名"
|
|
||||||
onClick={() => { setEditingSession(session.id); setEditTitle(session.title); }}
|
|
||||||
>
|
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<ExportDialog sessionId={session.id} sessionTitle={session.title} onClose={() => {}}>
|
|
||||||
<button
|
|
||||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-gray-500 hover:text-gray-800 hover:bg-gray-200 transition-colors"
|
|
||||||
title="导出"
|
|
||||||
>
|
|
||||||
<Download className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</ExportDialog>
|
|
||||||
<button
|
|
||||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-gray-500 hover:text-red-600 hover:bg-red-50 transition-colors"
|
|
||||||
title="删除"
|
|
||||||
onClick={() => setDeleteSessionId(session.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{sessions.length === 0 && (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<MessageSquare className="w-8 h-8 text-muted-foreground/40 mx-auto mb-2" />
|
|
||||||
<p className="text-xs text-muted-foreground">开始新的对话</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{/* Footer — new chat button with distinct background */}
|
|
||||||
<div className="px-3 py-3 border-t border-border/30 bg-muted/20">
|
|
||||||
<Button
|
|
||||||
onClick={handleNewChat}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full h-9 text-sm justify-center gap-2 border-dashed border-border/50 hover:bg-primary/5 hover:text-primary hover:border-primary/30"
|
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<PanelLeftClose className="w-4 h-4" />
|
||||||
新对话
|
</button>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<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" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-8 h-8 text-sm bg-muted/30 border border-border/20 focus-visible:ring-1 focus-visible:border-primary/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session list */}
|
||||||
|
<ScrollArea className="flex-1 px-2">
|
||||||
|
<div className="pb-4 w-full">
|
||||||
|
{groups.map(group => (
|
||||||
|
<div key={group.label} className="mb-3">
|
||||||
|
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground/70 uppercase tracking-wider">
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{group.sessions.map(session => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={`group relative flex items-center gap-2 px-2.5 py-2 rounded-lg cursor-pointer transition-colors hover:bg-muted/50 overflow-hidden ${
|
||||||
|
currentSession?.id === session.id ? 'bg-muted' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectSession(session.id)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="text-sm truncate min-w-0 w-0 flex-1">{session.title}</span>
|
||||||
|
|
||||||
|
{/* Dropdown menu — visible on hover */}
|
||||||
|
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="h-6 w-6 inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-all">
|
||||||
|
<MoreVertical className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenuItem onSelect={() => { setEditingSession(session.id); setEditTitle(session.title); }}>
|
||||||
|
<Edit2 className="w-4 h-4 mr-2" />
|
||||||
|
重命名
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<ExportDialog sessionId={session.id} sessionTitle={session.title}>
|
||||||
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
导出
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</ExportDialog>
|
||||||
|
<DropdownMenuItem onSelect={() => setDeleteSessionId(session.id)} className="text-destructive">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
删除
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{sessions.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<MessageSquare className="w-8 h-8 text-muted-foreground/40 mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-muted-foreground">开始新的对话</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-3 py-3 border-t border-border/30 bg-muted/20">
|
||||||
|
<Button
|
||||||
|
onClick={handleNewChat}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-9 text-sm justify-center gap-2 border-dashed border-border/50 hover:bg-primary/5 hover:text-primary hover:border-primary/30"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
新对话
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rename dialog */}
|
{/* Rename dialog */}
|
||||||
@@ -222,7 +225,7 @@ export default function Sidebar({ onClose }: SidebarProps) {
|
|||||||
value={editTitle}
|
value={editTitle}
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
placeholder="对话名称"
|
placeholder="对话名称"
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' && editTitle.trim()) handleSaveRename(); }}
|
onKeyDown={(e) => { if (e.key === "Enter" && editTitle.trim()) handleSaveRename(); }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,6 +249,6 @@ export default function Sidebar({ onClose }: SidebarProps) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +1,304 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FileText, ExternalLink, Star, Database, Globe } from "lucide-react";
|
import { useState, useMemo } from "react";
|
||||||
|
import { Database, Globe, ChevronDown, ChevronRight, Star, Image } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface SourceReference {
|
interface SourceReference {
|
||||||
|
id?: number;
|
||||||
title: string;
|
title: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
score?: number;
|
score?: number;
|
||||||
preview: string;
|
preview: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
source_type?: "web" | "rag";
|
source_type?: "web" | "rag" | "image";
|
||||||
|
image_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SourceReferencesProps {
|
interface SourceReferencesProps {
|
||||||
sources: SourceReference[];
|
sources: SourceReference[];
|
||||||
maxSources?: number;
|
maxSources?: number;
|
||||||
|
answerContent?: string;
|
||||||
|
messageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SourceReferences({ sources, maxSources = 5 }: SourceReferencesProps) {
|
/** 从 answer 中提取长度 >= minLen 的不重叠片段,用于在来源原文中高亮匹配 */
|
||||||
|
function findMatchedSpans(
|
||||||
|
answer: string,
|
||||||
|
sourceText: string,
|
||||||
|
minLen = 15
|
||||||
|
): Array<{ start: number; end: number }> {
|
||||||
|
if (!answer || !sourceText) return [];
|
||||||
|
|
||||||
|
// 从 answer 中切分出所有有意义的中文/英文片段
|
||||||
|
const segments: string[] = [];
|
||||||
|
// 按句号、换行等断开
|
||||||
|
const sentences = answer.split(/[。,;!?\n、:()]/);
|
||||||
|
for (const s of sentences) {
|
||||||
|
const trimmed = s.trim();
|
||||||
|
if (trimmed.length >= minLen) {
|
||||||
|
segments.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 sourceText 中查找每个片段
|
||||||
|
const spans: Array<{ start: number; end: number }> = [];
|
||||||
|
for (const seg of segments) {
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < sourceText.length) {
|
||||||
|
const idx = sourceText.indexOf(seg, pos);
|
||||||
|
if (idx === -1) break;
|
||||||
|
const end = idx + seg.length;
|
||||||
|
// 检查是否与已有 span 重叠,有则合并
|
||||||
|
const overlapping = spans.find(
|
||||||
|
(s) => !(end <= s.start || idx >= s.end)
|
||||||
|
);
|
||||||
|
if (overlapping) {
|
||||||
|
overlapping.start = Math.min(overlapping.start, idx);
|
||||||
|
overlapping.end = Math.max(overlapping.end, end);
|
||||||
|
} else {
|
||||||
|
spans.push({ start: idx, end });
|
||||||
|
}
|
||||||
|
pos = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spans.sort((a, b) => a.start - b.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将匹配的 span 用 <mark> 包裹 */
|
||||||
|
function highlightText(
|
||||||
|
text: string,
|
||||||
|
spans: Array<{ start: number; end: number }>
|
||||||
|
): React.ReactNode {
|
||||||
|
if (!spans.length) return text;
|
||||||
|
|
||||||
|
// 合并重叠/相邻的 span
|
||||||
|
const merged: Array<{ start: number; end: number }> = [];
|
||||||
|
for (const span of spans) {
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (last && span.start <= last.end + 3) {
|
||||||
|
last.end = Math.max(last.end, span.end);
|
||||||
|
} else {
|
||||||
|
merged.push({ ...span });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let last = 0;
|
||||||
|
for (const span of merged) {
|
||||||
|
if (span.start > last) {
|
||||||
|
parts.push(text.slice(last, span.start));
|
||||||
|
}
|
||||||
|
parts.push(
|
||||||
|
<mark key={span.start} className="bg-yellow-200/70 dark:bg-yellow-500/30 rounded-sm px-0.5">
|
||||||
|
{text.slice(span.start, span.end)}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
last = span.end;
|
||||||
|
}
|
||||||
|
if (last < text.length) {
|
||||||
|
parts.push(text.slice(last));
|
||||||
|
}
|
||||||
|
return <>{parts}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceRow({
|
||||||
|
source,
|
||||||
|
answerContent,
|
||||||
|
messageId,
|
||||||
|
}: {
|
||||||
|
source: SourceReference;
|
||||||
|
answerContent?: string;
|
||||||
|
messageId?: string;
|
||||||
|
}) {
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
|
||||||
|
const matchedSpans = useMemo(() => {
|
||||||
|
if (!answerContent || !source.preview) return [];
|
||||||
|
return findMatchedSpans(answerContent, source.preview);
|
||||||
|
}, [answerContent, source.preview]);
|
||||||
|
|
||||||
|
const previewContent = useMemo(() => {
|
||||||
|
if (!matchedSpans.length) return source.preview;
|
||||||
|
return highlightText(source.preview, matchedSpans);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [showPreview, matchedSpans]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={source.id != null && messageId ? `source-${messageId}-${source.id}` : undefined}
|
||||||
|
className="scroll-mt-20"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-1.5 px-2 py-1 -mx-2 rounded text-left",
|
||||||
|
"hover:bg-muted/60 transition-colors group text-xs"
|
||||||
|
)}
|
||||||
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3 flex-shrink-0 text-muted-foreground/60 transition-transform",
|
||||||
|
showPreview && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{source.id != null && (
|
||||||
|
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] rounded-full bg-primary/10 text-primary text-[10px] font-bold flex-shrink-0">
|
||||||
|
{source.id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-medium truncate">{source.title}</span>
|
||||||
|
{source.filename && (
|
||||||
|
<span className="text-muted-foreground/70 truncate hidden sm:inline">
|
||||||
|
{source.filename.replace(/\.(pdf|docx?|txt|md)$/i, "")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{source.score != null && source.score > 0 && source.score < 1 && (
|
||||||
|
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground flex-shrink-0 ml-auto">
|
||||||
|
<Star className="h-2.5 w-2.5 text-yellow-500" />
|
||||||
|
{(source.score * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showPreview && (
|
||||||
|
<div className="ml-7 pl-3 pr-2 py-1.5 mb-0.5 border-l-2 border-primary/20 bg-muted/30 rounded-r text-xs text-muted-foreground leading-relaxed max-h-48 overflow-y-auto">
|
||||||
|
{previewContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SourceReferences({
|
||||||
|
sources,
|
||||||
|
maxSources = 20,
|
||||||
|
answerContent,
|
||||||
|
messageId,
|
||||||
|
}: SourceReferencesProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
if (!sources || sources.length === 0) {
|
if (!sources || sources.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const displaySources = sources.slice(0, maxSources);
|
const displaySources = sources.slice(0, maxSources);
|
||||||
const ragSources = displaySources.filter(s => s.source_type !== "web");
|
const showExpandButton = sources.length > maxSources;
|
||||||
const webSources = displaySources.filter(s => s.source_type === "web");
|
const visibleSources = expanded ? sources : displaySources;
|
||||||
|
|
||||||
return (
|
const ragSources = visibleSources.filter((s) => s.source_type !== "web" && s.source_type !== "image");
|
||||||
<div className="mt-4 space-y-3">
|
const imageSources = visibleSources.filter((s) => s.source_type === "image");
|
||||||
|
const webSources = visibleSources.filter((s) => s.source_type === "web");
|
||||||
|
|
||||||
|
const sourceSection = (
|
||||||
|
<div className="space-y-1">
|
||||||
{ragSources.length > 0 && (
|
{ragSources.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2 text-sm text-blue-600">
|
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-3 w-3 text-blue-500" />
|
||||||
<span>知识库来源 ({ragSources.length})</span>
|
<span>
|
||||||
|
知识库来源 ({ragSources.length}
|
||||||
|
{expanded && showExpandButton
|
||||||
|
? `/${sources.filter((s) => s.source_type !== "web").length}`
|
||||||
|
: ""}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="divide-y divide-border/30">
|
||||||
|
{ragSources.map((source, i) => (
|
||||||
|
<SourceRow
|
||||||
|
key={i}
|
||||||
|
source={source}
|
||||||
|
answerContent={answerContent}
|
||||||
|
messageId={messageId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
{imageSources.length > 0 && (
|
||||||
{ragSources.map((source, index) => (
|
<div className="space-y-1">
|
||||||
<Card key={index} className="border border-blue-200 bg-blue-50/30 hover:border-blue-300 transition-colors">
|
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
|
||||||
<CardHeader className="pb-2">
|
<Image className="h-3 w-3 text-purple-500" />
|
||||||
<div className="flex items-start justify-between">
|
<span>
|
||||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
图片来源 ({imageSources.length}
|
||||||
{source.title}
|
{expanded && showExpandButton
|
||||||
</CardTitle>
|
? `/${sources.filter((s) => s.source_type === "image").length}`
|
||||||
{source.score != null && source.score > 0 && source.score < 1 && (
|
: ""}
|
||||||
<div className="flex items-center gap-1 ml-2">
|
)
|
||||||
<Star className="h-3 w-3 text-yellow-500" />
|
</span>
|
||||||
<span className="text-xs text-gray-500">
|
</div>
|
||||||
{(source.score * 100).toFixed(1)}%
|
<div className="divide-y divide-border/30">
|
||||||
</span>
|
{imageSources.map((source, i) => (
|
||||||
</div>
|
<SourceRow
|
||||||
)}
|
key={i}
|
||||||
</div>
|
source={source}
|
||||||
<div className="text-xs text-gray-500">
|
answerContent={answerContent}
|
||||||
{source.filename}
|
messageId={messageId}
|
||||||
{source.page && ` • 第 ${source.page} 页`}
|
/>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<p className="text-xs text-gray-600 line-clamp-2">
|
|
||||||
{source.preview}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{webSources.length > 0 && (
|
{webSources.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-3 w-3 text-green-500" />
|
||||||
<span>网络来源 ({webSources.length})</span>
|
<span>
|
||||||
|
网络来源 ({webSources.length}
|
||||||
|
{expanded && showExpandButton
|
||||||
|
? `/${sources.filter((s) => s.source_type === "web").length}`
|
||||||
|
: ""}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="divide-y divide-border/30">
|
||||||
<div className="space-y-2">
|
{webSources.map((source, i) => (
|
||||||
{webSources.map((source, index) => (
|
<SourceRow
|
||||||
<Card key={index} className="border border-green-200 bg-green-50/30 hover:border-green-300 transition-colors">
|
key={i}
|
||||||
<CardHeader className="pb-2">
|
source={source}
|
||||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
answerContent={answerContent}
|
||||||
{source.title}
|
messageId={messageId}
|
||||||
</CardTitle>
|
/>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<p className="text-xs text-gray-600 line-clamp-2 mb-2">
|
|
||||||
{source.preview}
|
|
||||||
</p>
|
|
||||||
{source.url && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-6 text-xs"
|
|
||||||
onClick={() => window.open(source.url, '_blank')}
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3 mr-1" />
|
|
||||||
查看原文
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{sources.length > maxSources && (
|
return (
|
||||||
<div className="text-xs text-gray-500 text-center">
|
<div className="mt-3 space-y-2">
|
||||||
还有 {sources.length - maxSources} 个相关来源
|
{expanded ? (
|
||||||
|
<div className="max-h-96 overflow-y-auto pr-1">
|
||||||
|
{sourceSection}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
sourceSection
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showExpandButton && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-7 text-[11px] text-muted-foreground"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
{expanded
|
||||||
|
? `收起(共 ${sources.length} 个)`
|
||||||
|
: `展开全部 ${sources.length} 个来源`}
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"ml-1 h-3 w-3 transition-transform",
|
||||||
|
expanded && "rotate-180"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|||||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { BookOpen, LogOut, User as UserIcon, Settings, ChevronDown, MessageSquare, Database, Image, TrendingUp, GraduationCap, Users } from "lucide-react";
|
import { BookOpen, LogOut, User as UserIcon, Settings, ChevronDown, MessageSquare, Database, Image, TrendingUp, GraduationCap, Users, ShieldCheck } from "lucide-react";
|
||||||
import { User } from "@/types";
|
import { User } from "@/types";
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
@@ -20,7 +20,8 @@ const NAV_LINKS = [
|
|||||||
{ href: "/chat", label: "智能问答", icon: MessageSquare },
|
{ href: "/chat", label: "智能问答", icon: MessageSquare },
|
||||||
{ href: "/knowledge", label: "知识库", icon: Database },
|
{ href: "/knowledge", label: "知识库", icon: Database },
|
||||||
{ href: "/spatial", label: "空间设计", icon: Image },
|
{ href: "/spatial", label: "空间设计", icon: Image },
|
||||||
{ href: "/forum", label: "课程社区", icon: Users },
|
{ href: "/forum", label: "课程社区", icon: Users, requireAuth: true },
|
||||||
|
{ href: "/admin", label: "后台管理", icon: ShieldCheck, adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar({ isAuthenticated, user }: NavbarProps) {
|
export default function Navbar({ isAuthenticated, user }: NavbarProps) {
|
||||||
@@ -54,7 +55,7 @@ export default function Navbar({ isAuthenticated, user }: NavbarProps) {
|
|||||||
{/* 导航链接 — 带底部下划线指示 */}
|
{/* 导航链接 — 带底部下划线指示 */}
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<div className="hidden md:flex items-center h-full -mb-px">
|
<div className="hidden md:flex items-center h-full -mb-px">
|
||||||
{NAV_LINKS.map((item) => {
|
{NAV_LINKS.filter((item) => !item.adminOnly || user?.is_superuser).map((item) => {
|
||||||
const active = isActive(item.href);
|
const active = isActive(item.href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -79,28 +80,6 @@ export default function Navbar({ isAuthenticated, user }: NavbarProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 未登录时也显示课程社区链接 */}
|
|
||||||
{!isAuthenticated && (
|
|
||||||
<div className="hidden md:flex items-center h-full -mb-px">
|
|
||||||
<Link
|
|
||||||
href="/forum"
|
|
||||||
className={`
|
|
||||||
relative flex items-center gap-1.5 px-3 h-full text-sm transition-colors
|
|
||||||
${isActive("/forum")
|
|
||||||
? "text-primary font-medium"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Users className="w-4 h-4" />
|
|
||||||
<span>课程社区</span>
|
|
||||||
{isActive("/forum") && (
|
|
||||||
<span className="absolute bottom-0 left-3 right-3 h-0.5 bg-primary rounded-full" />
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
@@ -148,6 +127,14 @@ export default function Navbar({ isAuthenticated, user }: NavbarProps) {
|
|||||||
设置
|
设置
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{user.is_superuser && (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/admin" className="flex items-center cursor-pointer">
|
||||||
|
<ShieldCheck className="w-4 h-4 mr-2" />
|
||||||
|
后台管理
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={logout} className="text-destructive cursor-pointer">
|
<DropdownMenuItem onClick={logout} className="text-destructive cursor-pointer">
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
|||||||
@@ -15,15 +15,16 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Users
|
Users,
|
||||||
|
ShieldCheck
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: "course-content", name: "课程内容", icon: GraduationCap, href: "/course-content" },
|
{ id: "course-content", name: "课程内容", icon: GraduationCap, href: "/course-content", requireAuth: true },
|
||||||
{ id: "chat", name: "对话", icon: MessageSquare, href: "/chat" },
|
{ id: "chat", name: "对话", icon: MessageSquare, href: "/chat", requireAuth: true },
|
||||||
{ id: "knowledge", name: "知识库", icon: BookOpen, href: "/knowledge" },
|
{ id: "knowledge", name: "知识库", icon: BookOpen, href: "/knowledge", requireAuth: true },
|
||||||
{ id: "spatial", name: "空间出图", icon: Image, href: "/spatial" },
|
{ id: "spatial", name: "空间出图", icon: Image, href: "/spatial", requireAuth: true },
|
||||||
{ id: "forum", name: "社区", icon: Users, href: "/forum" },
|
{ id: "forum", name: "社区", icon: Users, href: "/forum", requireAuth: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MobileNav() {
|
export default function MobileNav() {
|
||||||
@@ -45,10 +46,11 @@ export default function MobileNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 移动端导航栏 */}
|
{/* Bottom navigation bar */}
|
||||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 mobile-safe-area z-50">
|
{user && (
|
||||||
|
<div className="lg:hidden fixed bottom-0 left-0 right-0 bg-background border-t border-border mobile-safe-area z-50">
|
||||||
<div className="flex items-center justify-around py-2">
|
<div className="flex items-center justify-around py-2">
|
||||||
{navItems.map((item) => {
|
{navItems.filter(item => !item.requireAuth || user).map((item) => {
|
||||||
const isActive = pathname === item.href;
|
const isActive = pathname === item.href;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -57,7 +59,7 @@ export default function MobileNav() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleNavClick(item.href)}
|
onClick={() => handleNavClick(item.href)}
|
||||||
className={`flex flex-col items-center space-y-1 px-3 py-2 ${
|
className={`flex flex-col items-center space-y-1 px-3 py-2 ${
|
||||||
isActive ? "text-white" : "text-gray-600"
|
isActive ? "" : "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon className="w-5 h-5" />
|
<item.icon className="w-5 h-5" />
|
||||||
@@ -67,52 +69,48 @@ export default function MobileNav() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 移动端菜单按钮 */}
|
{/* Menu toggle button */}
|
||||||
<div className="lg:hidden fixed top-4 right-4 z-50">
|
<div className="lg:hidden fixed top-4 right-4 z-50">
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="bg-white shadow-lg"
|
className="inline-flex items-center justify-center w-9 h-9 rounded-lg bg-background border border-border shadow-sm hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
{isOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
|
{isOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 移动端侧边菜单 */}
|
{/* Side menu overlay */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="lg:hidden fixed inset-0 z-40">
|
<div className="lg:hidden fixed inset-0 z-40">
|
||||||
{/* 遮罩 */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black bg-opacity-50"
|
className="absolute inset-0 bg-black/50"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute right-0 top-0 h-full w-80 max-w-[85vw] bg-background shadow-xl mobile-safe-area">
|
||||||
{/* 菜单内容 */}
|
|
||||||
<div className="absolute right-0 top-0 h-full w-80 max-w-[85vw] bg-white shadow-xl mobile-safe-area">
|
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* 用户信息 */}
|
{/* User info */}
|
||||||
<div className="p-6 border-b border-gray-200">
|
<div className="p-6 border-b border-border">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-muted rounded-full flex items-center justify-center">
|
||||||
<User className="w-6 h-6 text-gray-600" />
|
<User className="w-6 h-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-lg font-medium text-gray-900 truncate">
|
<p className="text-lg font-medium text-foreground truncate">
|
||||||
{user?.full_name || user?.username}
|
{user?.full_name || user?.username}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{user?.email}
|
{user?.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 导航菜单 */}
|
{/* Navigation */}
|
||||||
<div className="flex-1 p-6">
|
<div className="flex-1 p-6">
|
||||||
<nav className="space-y-2">
|
<nav className="space-y-2">
|
||||||
{navItems.map((item) => {
|
{navItems.filter(item => !item.requireAuth || user).map((item) => {
|
||||||
const isActive = pathname === item.href;
|
const isActive = pathname === item.href;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -129,8 +127,18 @@ export default function MobileNav() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部操作 */}
|
{/* Bottom actions */}
|
||||||
<div className="p-6 border-t border-gray-200 space-y-2">
|
<div className="p-6 border-t border-border space-y-2">
|
||||||
|
{user?.is_superuser && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleNavClick("/admin")}
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-5 h-5 mr-3" />
|
||||||
|
后台管理
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => handleNavClick("/analytics")}
|
onClick={() => handleNavClick("/analytics")}
|
||||||
@@ -150,7 +158,7 @@ export default function MobileNav() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50"
|
className="w-full justify-start text-destructive hover:bg-destructive/10"
|
||||||
>
|
>
|
||||||
<LogOut className="w-5 h-5 mr-3" />
|
<LogOut className="w-5 h-5 mr-3" />
|
||||||
退出登录
|
退出登录
|
||||||
@@ -163,9 +171,3 @@ export default function MobileNav() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+130
-1
@@ -12,7 +12,7 @@ import type {
|
|||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
// API基础配置
|
// API基础配置
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || (typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.host}/api` : "http://127.0.0.1:8000");
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || (typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8000` : "http://127.0.0.1:8000");
|
||||||
|
|
||||||
// 请求拦截器
|
// 请求拦截器
|
||||||
async function apiRequest<T>(
|
async function apiRequest<T>(
|
||||||
@@ -583,6 +583,9 @@ export const chatAPI = {
|
|||||||
} else if (data.type === "chunk") {
|
} else if (data.type === "chunk") {
|
||||||
console.log("[DEBUG-STREAM] 接收chunk:", data.content);
|
console.log("[DEBUG-STREAM] 接收chunk:", data.content);
|
||||||
onChunk?.(data.content);
|
onChunk?.(data.content);
|
||||||
|
} else if (data.type === "sources") {
|
||||||
|
console.log("[DEBUG-STREAM] 收到sources:", data.sources?.length, "个来源");
|
||||||
|
onChunk?.(JSON.stringify(data));
|
||||||
} else if (data.type === "done") {
|
} else if (data.type === "done") {
|
||||||
console.log("[DEBUG-STREAM] 流式完成, session_id:", data.session_id, "message_id:", data.message_id, "user_message_id:", data.user_message_id);
|
console.log("[DEBUG-STREAM] 流式完成, session_id:", data.session_id, "message_id:", data.message_id, "user_message_id:", data.user_message_id);
|
||||||
onComplete?.(data.session_id, data.message_id, data.user_message_id);
|
onComplete?.(data.session_id, data.message_id, data.user_message_id);
|
||||||
@@ -1067,3 +1070,129 @@ export const bookAPI = {
|
|||||||
return `${API_BASE_URL}/books/${bookId}/file`;
|
return `${API_BASE_URL}/books/${bookId}/file`;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== 后台管理 API =====
|
||||||
|
export const adminAPI = {
|
||||||
|
// 仪表盘
|
||||||
|
async getDashboard() {
|
||||||
|
return apiRequest<{
|
||||||
|
total_users: number;
|
||||||
|
active_users_7d: number;
|
||||||
|
total_sessions: number;
|
||||||
|
total_messages: number;
|
||||||
|
total_documents: number;
|
||||||
|
total_knowledge_bases: number;
|
||||||
|
total_forum_posts: number;
|
||||||
|
total_forum_replies: number;
|
||||||
|
total_generated_images: number;
|
||||||
|
}>("/admin/dashboard");
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUserTrends(days = 30) {
|
||||||
|
return apiRequest<{ date: string; count: number }[]>(`/admin/trends/users?days=${days}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMessageTrends(days = 30) {
|
||||||
|
return apiRequest<{ date: string; count: number }[]>(`/admin/trends/messages?days=${days}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 用户管理
|
||||||
|
async listUsers(skip = 0, limit = 50) {
|
||||||
|
return apiRequest<{
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
is_superuser: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
last_login: string | null;
|
||||||
|
session_count: number;
|
||||||
|
message_count: number;
|
||||||
|
}[]>(`/admin/users?skip=${skip}&limit=${limit}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleUserActive(userId: number) {
|
||||||
|
return apiRequest<{ success: boolean; is_active: boolean }>(`/admin/users/${userId}/toggle-active`, { method: "PUT" });
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleUserAdmin(userId: number) {
|
||||||
|
return apiRequest<{ success: boolean; is_superuser: boolean }>(`/admin/users/${userId}/toggle-admin`, { method: "PUT" });
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteUser(userId: number) {
|
||||||
|
return apiRequest<{ success: boolean }>(`/admin/users/${userId}`, { method: "DELETE" });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 论坛管理
|
||||||
|
async listForumCategories() {
|
||||||
|
return apiRequest<{
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
post_count: number;
|
||||||
|
}[]>("/admin/forum/categories");
|
||||||
|
},
|
||||||
|
|
||||||
|
async createForumCategory(data: { name: string; slug: string; description?: string }) {
|
||||||
|
return apiRequest("/admin/forum/categories", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateForumCategory(categoryId: number, data: { name: string; slug: string; description?: string }) {
|
||||||
|
return apiRequest(`/admin/forum/categories/${categoryId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteForumCategory(categoryId: number) {
|
||||||
|
return apiRequest<{ success: boolean }>(`/admin/forum/categories/${categoryId}`, { method: "DELETE" });
|
||||||
|
},
|
||||||
|
|
||||||
|
async listForumPosts(skip = 0, limit = 50) {
|
||||||
|
return apiRequest<{
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
author_name: string;
|
||||||
|
category_name: string;
|
||||||
|
reply_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}[]>(`/admin/forum/posts?skip=${skip}&limit=${limit}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteForumPost(postId: number) {
|
||||||
|
return apiRequest<{ success: boolean }>(`/admin/forum/posts/${postId}`, { method: "DELETE" });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 知识库管理
|
||||||
|
async listKnowledgeBases() {
|
||||||
|
return apiRequest<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
owner_name: string;
|
||||||
|
is_system: boolean;
|
||||||
|
document_count: number;
|
||||||
|
chunk_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}[]>("/admin/knowledge-bases");
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteKnowledgeBase(kbId: number) {
|
||||||
|
return apiRequest<{ success: boolean }>(`/admin/knowledge-bases/${kbId}`, { method: "DELETE" });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 系统状态
|
||||||
|
async getSystemStatus() {
|
||||||
|
return apiRequest<{
|
||||||
|
database: { status: string };
|
||||||
|
vector_store: { status: string; vector_count: number };
|
||||||
|
llm_model: string;
|
||||||
|
embedding_model: string;
|
||||||
|
}>("/admin/system/status");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
+24
-1
@@ -91,7 +91,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
|
|
||||||
// 选择会话
|
// 选择会话
|
||||||
selectSession: async (sessionId: number) => {
|
selectSession: async (sessionId: number) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ error: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = get().sessions.find(s => s.id === sessionId);
|
const session = get().sessions.find(s => s.id === sessionId);
|
||||||
@@ -309,6 +309,18 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
thinkingBuffer.push(step);
|
thinkingBuffer.push(step);
|
||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
return;
|
return;
|
||||||
|
} else if (data.type === 'sources') {
|
||||||
|
// 更新助手消息的元数据(sources)
|
||||||
|
set((state) => ({
|
||||||
|
messages: state.messages.map(msg => {
|
||||||
|
if (msg.id !== assistantMessage.id) return msg;
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
metadata: { ...(msg.metadata || {}), sources: data.sources },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
} else if (data.type === 'chunk') {
|
} else if (data.type === 'chunk') {
|
||||||
chunkCount++;
|
chunkCount++;
|
||||||
totalChars += data.content.length;
|
totalChars += data.content.length;
|
||||||
@@ -500,6 +512,17 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
regenThinkingBuffer.push(step);
|
regenThinkingBuffer.push(step);
|
||||||
regenSchedule();
|
regenSchedule();
|
||||||
return;
|
return;
|
||||||
|
} else if (data.type === 'sources') {
|
||||||
|
set((state) => ({
|
||||||
|
messages: state.messages.map(msg => {
|
||||||
|
if (msg.id !== assistantPlaceholder.id) return msg;
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
metadata: { ...(msg.metadata || {}), sources: data.sources },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
} else if (data.type === 'chunk') {
|
} else if (data.type === 'chunk') {
|
||||||
regenContentBuffer += data.content;
|
regenContentBuffer += data.content;
|
||||||
regenSchedule();
|
regenSchedule();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,13 +94,15 @@ export interface ChatMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SourceInfo {
|
export interface SourceInfo {
|
||||||
|
id?: number;
|
||||||
title: string;
|
title: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
score?: number;
|
score?: number;
|
||||||
preview: string;
|
preview: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
source_type?: "web" | "rag";
|
source_type?: "web" | "rag" | "image";
|
||||||
|
image_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResponse {
|
export interface ChatResponse {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user