Compare commits
17 Commits
ecd5c7ff31
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6395ee3b49 | |||
| bbf6b921af | |||
| a6987ff996 | |||
| 3250e87b30 | |||
| 5f768e5c83 | |||
| 01a1c55de8 | |||
| 1a3730c33c | |||
| 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/
|
||||
.next/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
|
||||
# 运行时数据
|
||||
runtime/
|
||||
@@ -33,6 +34,7 @@ generated_images/
|
||||
# 数据目录中的运行时文件(保留源文件如 .tex)
|
||||
data/database/
|
||||
data/knowledge_base/
|
||||
data/images/
|
||||
|
||||
# LaTeX 中间文件
|
||||
*.aux
|
||||
@@ -62,7 +64,13 @@ Desktop.ini
|
||||
# 其他
|
||||
*.tmp
|
||||
*.temp
|
||||
*.tar.gz
|
||||
.cache/
|
||||
*.coverage
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
officefile
|
||||
data/1法律
|
||||
data/data_backup
|
||||
|
||||
|
||||
@@ -1,291 +1,121 @@
|
||||
# 国土空间规划课程智能体 - Docker单容器版本
|
||||
# 国土空间规划课程智能体
|
||||
|
||||
一个基于大模型的智能问答系统,专门为国土空间规划课程设计。本版本将所有服务整合到单个Docker容器中,简化部署和管理。
|
||||
基于大模型和 RAG 的国土空间规划课程智能问答系统,支持多模态文档理解、知识图谱、AI 绘图和论坛交流。
|
||||
|
||||
## 🎯 项目特色
|
||||
## 功能概览
|
||||
|
||||
- **单容器架构**: 前端和后端服务整合在一个容器中,使用supervisor管理进程
|
||||
- **AI驱动**: 集成硅基流动大模型和LangGraph工作流
|
||||
- **RAG增强**: 基于Chroma向量数据库的智能检索
|
||||
- **响应式设计**: 完美适配手机、平板、桌面设备
|
||||
- **现代化技术栈**: Next.js 15 + React 19 + FastAPI + Python 3.12
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| **智能问答** | RAG 检索增强生成,支持流式输出、多模型切换、思考过程展示 |
|
||||
| **多模态 RAG** | PDF 图片自动提取 + VLM 描述生成,问答结果中直接展示图片 |
|
||||
| **知识库管理** | 上传 PDF/DOCX/TXT/MD 文档,自动分块、向量化入库 |
|
||||
| **课程内容** | 知识图谱可视化,教材章节结构浏览 |
|
||||
| **AI 绘图** | 文生图(Stable Diffusion)、图生图,SiliconFlow API 驱动 |
|
||||
| **论坛** | 分类讨论区,支持发帖、回复 |
|
||||
| **用户系统** | JWT 认证,管理员/普通用户角色分离 |
|
||||
|
||||
## 🏗️ 技术架构
|
||||
|
||||
### 单容器架构
|
||||
## 技术架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Docker Container │
|
||||
│ ┌─────────────┐ ┌──────────────┐ │
|
||||
│ │ Backend │ │ Frontend │ │
|
||||
│ │ FastAPI │ │ Next.js │ │
|
||||
│ │ :8000 │ │ :8001 │ │
|
||||
│ └─────────────┘ └──────────────┘ │
|
||||
│ │ │ │
|
||||
│ └──────┬─────────┘ │
|
||||
│ Supervisor │
|
||||
└─────────────────────────────────────┘
|
||||
用户浏览器
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ Database │
|
||||
└──────────────────┘
|
||||
Next.js 15 (:8001) ←── Turbopack 构建
|
||||
│ /api/* → 反向代理
|
||||
│ /images/* → 反向代理
|
||||
▼
|
||||
FastAPI (:8000)
|
||||
├── LangGraph 工作流 (analyze → retrieve → generate)
|
||||
├── RAG 管线
|
||||
│ ├── text2vec-base-chinese (嵌入)
|
||||
│ ├── ChromaDB (向量存储)
|
||||
│ ├── pymupdf (PDF 图片提取)
|
||||
│ └── Qwen3-VL-8B (图片描述生成)
|
||||
├── LLM 路由
|
||||
│ ├── SiliconFlow (Qwen/DeepSeek 系列)
|
||||
│ └── DeepSeek 官方 API
|
||||
└── SQLAlchemy ORM
|
||||
├── SQLite (本地开发)
|
||||
└── PostgreSQL (Docker 部署)
|
||||
```
|
||||
|
||||
### 技术栈
|
||||
## 技术栈
|
||||
|
||||
**后端**:
|
||||
- FastAPI + Uvicorn
|
||||
- LangChain + LangGraph
|
||||
- 硅基流动API (Qwen3-30B)
|
||||
- Chroma向量数据库
|
||||
- PostgreSQL
|
||||
**后端**: Python 3.12 / FastAPI / LangChain / LangGraph / ChromaDB / SQLAlchemy / pymupdf
|
||||
|
||||
**前端**:
|
||||
- Next.js 15 (App Router)
|
||||
- React 19
|
||||
- TypeScript
|
||||
- TailwindCSS + shadcn/ui
|
||||
**前端**: Next.js 15 / React 19 / TypeScript / TailwindCSS / shadcn/ui / Zustand
|
||||
|
||||
## 📁 项目结构
|
||||
**部署**: Docker + Supervisor (单容器) / PostgreSQL
|
||||
|
||||
```
|
||||
course_Agent/
|
||||
├── backend/ # Python后端代码
|
||||
│ ├── src/ # 源代码
|
||||
│ ├── main.py # 应用入口
|
||||
│ ├── pyproject.toml # 依赖配置
|
||||
│ └── migrations/ # 数据库迁移
|
||||
├── web/ # Next.js前端代码
|
||||
│ ├── src/ # 源代码
|
||||
│ ├── public/ # 静态资源
|
||||
│ └── package.json # 依赖配置
|
||||
├── docker/ # Docker相关文件
|
||||
│ ├── supervisord.conf # Supervisor配置
|
||||
│ └── start.sh # 启动脚本
|
||||
├── data/ # 数据目录(挂载卷)
|
||||
├── vector_store/ # 向量数据库(挂载卷)
|
||||
├── uploads/ # 上传文件(挂载卷)
|
||||
├── logs/ # 日志文件(挂载卷)
|
||||
├── generated_images/ # 生成的图像(挂载卷)
|
||||
├── Dockerfile # Docker构建文件
|
||||
├── docker-compose.yml # Docker Compose配置
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.0+
|
||||
|
||||
### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
cd course_Agent
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
复制环境变量模板:
|
||||
|
||||
```bash
|
||||
cp env.example .env
|
||||
```
|
||||
|
||||
编辑 `.env` 文件,配置必要的参数(API密钥、数据库密码等)。
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
# 构建并启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 查看服务状态
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### 4. 访问应用
|
||||
|
||||
- **前端应用**: http://localhost:8001
|
||||
- **后端API**: http://localhost:8000
|
||||
- **API文档**: http://localhost:8000/docs
|
||||
|
||||
### 5. 停止服务
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## 🔧 服务管理
|
||||
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
# 查看所有服务日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 查看特定服务日志
|
||||
docker-compose logs -f app
|
||||
docker-compose logs -f db
|
||||
```
|
||||
|
||||
### 重启服务
|
||||
|
||||
```bash
|
||||
# 重启所有服务
|
||||
docker-compose restart
|
||||
|
||||
# 重启特定服务
|
||||
docker-compose restart app
|
||||
```
|
||||
|
||||
### 进入容器
|
||||
|
||||
```bash
|
||||
# 进入应用容器
|
||||
docker-compose exec app bash
|
||||
|
||||
# 在容器内查看进程状态
|
||||
supervisorctl status
|
||||
```
|
||||
|
||||
### 数据库操作
|
||||
|
||||
```bash
|
||||
# 进入数据库容器
|
||||
docker-compose exec db psql -U user -d course_agent_db
|
||||
|
||||
# 备份数据库
|
||||
docker-compose exec db pg_dump -U user course_agent_db > backup.sql
|
||||
|
||||
# 恢复数据库
|
||||
docker-compose exec -T db psql -U user course_agent_db < backup.sql
|
||||
```
|
||||
|
||||
## 📊 数据持久化
|
||||
|
||||
以下目录通过Docker volumes挂载,数据会持久化到宿主机:
|
||||
|
||||
- `./data` → `/app/data` - 知识库数据
|
||||
- `./vector_store` → `/app/vector_store` - 向量数据库
|
||||
- `./uploads` → `/app/uploads` - 用户上传文件
|
||||
- `./logs` → `/app/logs` - 日志文件
|
||||
- `./generated_images` → `/app/generated_images` - 生成的图像
|
||||
|
||||
## 🔍 故障排除
|
||||
|
||||
### 服务无法启动
|
||||
|
||||
1. 检查端口是否被占用:
|
||||
```bash
|
||||
netstat -ano | findstr :8000
|
||||
netstat -ano | findstr :8001
|
||||
```
|
||||
|
||||
2. 查看容器日志:
|
||||
```bash
|
||||
docker-compose logs app
|
||||
```
|
||||
|
||||
3. 检查环境变量配置:
|
||||
```bash
|
||||
docker-compose exec app env | grep DATABASE_URL
|
||||
```
|
||||
|
||||
### 数据库连接失败
|
||||
|
||||
1. 确保数据库服务已启动:
|
||||
```bash
|
||||
docker-compose ps db
|
||||
```
|
||||
|
||||
2. 检查数据库连接字符串:
|
||||
```bash
|
||||
docker-compose exec app env | grep DATABASE_URL
|
||||
```
|
||||
|
||||
### 前端无法访问后端
|
||||
|
||||
1. 检查后端服务是否运行:
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
2. 检查CORS配置:
|
||||
```bash
|
||||
docker-compose exec app env | grep ALLOWED_ORIGINS
|
||||
```
|
||||
|
||||
## 🛠️ 开发指南
|
||||
## 快速开始
|
||||
|
||||
### 本地开发
|
||||
|
||||
如果需要本地开发(不使用Docker):
|
||||
|
||||
1. **启动后端**:
|
||||
**后端** (终端 1):
|
||||
```bash
|
||||
cd backend
|
||||
uv sync
|
||||
uv run main.py
|
||||
uv run main.py # http://localhost:8000
|
||||
```
|
||||
|
||||
2. **启动前端** (新终端):
|
||||
**前端** (终端 2):
|
||||
```bash
|
||||
cd web
|
||||
pnpm install
|
||||
pnpm dev
|
||||
pnpm dev # http://localhost:8001
|
||||
```
|
||||
|
||||
### 修改代码后重建镜像
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
# 重新构建镜像
|
||||
docker-compose build app
|
||||
|
||||
# 重启服务
|
||||
docker-compose up -d app
|
||||
cp env.example .env # 编辑 .env,配置 SILICONFLOW_API_KEY 等
|
||||
docker-compose up -d # http://localhost:8001
|
||||
```
|
||||
|
||||
## 📝 环境变量说明
|
||||
## 项目结构
|
||||
|
||||
主要环境变量(完整列表见 `env.example`):
|
||||
```
|
||||
course_agent_od/
|
||||
├── backend/
|
||||
│ ├── main.py # 应用入口
|
||||
│ ├── pyproject.toml # 依赖 (uv)
|
||||
│ └── src/
|
||||
│ ├── api/ # FastAPI 路由
|
||||
│ ├── core/ # 配置、数据库、安全
|
||||
│ ├── rag/ # RAG 管线
|
||||
│ │ ├── chains.py # RAGChain
|
||||
│ │ ├── retrievers.py # KnowledgeBaseRetriever
|
||||
│ │ ├── document_loaders.py # PDFImageExtractor
|
||||
│ │ ├── vector_store.py # ChromaDB 封装
|
||||
│ │ └── prompts.py # 提示词模板
|
||||
│ ├── llm/ # LLM 客户端 + VLM
|
||||
│ ├── graph/ # LangGraph 工作流
|
||||
│ ├── models/ # SQLAlchemy ORM
|
||||
│ └── services/ # 业务逻辑
|
||||
├── web/
|
||||
│ ├── next.config.js # 反向代理配置
|
||||
│ └── src/
|
||||
│ ├── app/(main)/ # 页面路由
|
||||
│ ├── components/chat/ # 聊天组件
|
||||
│ ├── store/ # Zustand 状态管理
|
||||
│ └── lib/api.ts # API 客户端
|
||||
├── data/ # 运行时数据 (.gitignore)
|
||||
├── docker/ # Docker + Supervisor 配置
|
||||
├── Dockerfile
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
- `DATABASE_URL`: 数据库连接字符串
|
||||
- `SILICONFLOW_API_KEY`: 硅基流动API密钥
|
||||
- `SECRET_KEY`: JWT密钥
|
||||
- `VECTOR_STORE_PATH`: 向量数据库路径
|
||||
- `KNOWLEDGE_BASE_DIR`: 知识库目录
|
||||
## 环境变量
|
||||
|
||||
## 🔐 安全建议
|
||||
关键变量见 `env.example`:
|
||||
|
||||
1. **生产环境**:
|
||||
- 修改所有默认密码和密钥
|
||||
- 使用强密码
|
||||
- 配置HTTPS(通过Nginx反向代理)
|
||||
- 限制数据库访问
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `SILICONFLOW_API_KEY` | SiliconFlow LLM/VLM API 密钥 (必需) |
|
||||
| `DATABASE_URL` | SQLite 或 PostgreSQL 连接串 |
|
||||
| `SECRET_KEY` | JWT 签名密钥 |
|
||||
| `POSTGRES_PASSWORD` | PostgreSQL 密码 (Docker) |
|
||||
|
||||
2. **数据备份**:
|
||||
- 定期备份PostgreSQL数据库
|
||||
- 备份向量数据库和知识库文件
|
||||
|
||||
## 📄 许可证
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如有问题或建议,请联系开发团队。
|
||||
|
||||
---
|
||||
|
||||
**版本**: v1.0.0 (单容器版本)
|
||||
**最后更新**: 2025年1月
|
||||
|
||||
|
||||
|
||||
+53
-32
@@ -4,14 +4,17 @@ FastAPI应用入口
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
import uvicorn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 设置系统时区为北京时间
|
||||
os.environ['TZ'] = 'Asia/Shanghai'
|
||||
|
||||
@@ -52,9 +55,9 @@ def startup_knowledge_base():
|
||||
try:
|
||||
from src.services.file_watcher_service import start_file_watcher
|
||||
start_file_watcher()
|
||||
print("知识库文件监控服务启动成功")
|
||||
logger.info("知识库文件监控服务启动成功")
|
||||
except Exception as e:
|
||||
print(f"启动知识库文件监控服务失败: {str(e)}")
|
||||
logger.error(f"启动知识库文件监控服务失败: {str(e)}")
|
||||
|
||||
|
||||
def seed_forum_categories():
|
||||
@@ -72,17 +75,17 @@ def seed_forum_categories():
|
||||
try:
|
||||
existing_count = db.query(ForumCategory).count()
|
||||
if existing_count > 0:
|
||||
print(f"论坛分类已存在({existing_count} 个),跳过初始化")
|
||||
logger.info(f"论坛分类已存在({existing_count} 个),跳过初始化")
|
||||
return
|
||||
|
||||
for cat_data in DEFAULT_CATEGORIES:
|
||||
category = ForumCategory(**cat_data)
|
||||
db.add(category)
|
||||
db.commit()
|
||||
print(f"已创建 {len(DEFAULT_CATEGORIES)} 个论坛分类")
|
||||
logger.info(f"已创建 {len(DEFAULT_CATEGORIES)} 个论坛分类")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"初始化论坛分类失败: {e}")
|
||||
logger.error(f"初始化论坛分类失败: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -94,10 +97,9 @@ async def startup_event():
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
print("=" * 50, file=sys.stderr)
|
||||
print("应用启动事件开始", file=sys.stderr)
|
||||
print("=" * 50, file=sys.stderr)
|
||||
sys.stderr.flush()
|
||||
logger.info("=" * 50)
|
||||
logger.info("应用启动事件开始")
|
||||
logger.info("=" * 50)
|
||||
|
||||
# 等待数据库可用(重试机制,处理 DNS 解析延迟)
|
||||
max_retries = 10
|
||||
@@ -105,38 +107,42 @@ async def startup_event():
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
print(f"尝试连接数据库(尝试 {attempt + 1}/{max_retries})...", file=sys.stderr)
|
||||
sys.stderr.flush()
|
||||
logger.info(f"尝试连接数据库(尝试 {attempt + 1}/{max_retries})...")
|
||||
# 尝试创建数据库表
|
||||
create_tables()
|
||||
print("数据库表创建成功", file=sys.stderr)
|
||||
sys.stderr.flush()
|
||||
logger.info("数据库表创建成功")
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
print(f"数据库连接失败(尝试 {attempt + 1}/{max_retries}): {e}", file=sys.stderr)
|
||||
print(f"等待 {retry_delay} 秒后重试...", file=sys.stderr)
|
||||
sys.stderr.flush()
|
||||
logger.warning(f"数据库连接失败(尝试 {attempt + 1}/{max_retries}): {e}")
|
||||
await asyncio.sleep(retry_delay)
|
||||
else:
|
||||
print(f"数据库连接失败,已达到最大重试次数: {e}", file=sys.stderr)
|
||||
sys.stderr.flush()
|
||||
logger.error(f"数据库连接失败,已达到最大重试次数: {e}")
|
||||
# 不抛出异常,让应用继续启动,但数据库操作会失败
|
||||
|
||||
try:
|
||||
startup_knowledge_base()
|
||||
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:
|
||||
seed_forum_categories()
|
||||
except Exception as e:
|
||||
print(f"初始化论坛分类失败: {e}", file=sys.stderr)
|
||||
sys.stderr.flush()
|
||||
logger.error(f"初始化论坛分类失败: {e}")
|
||||
|
||||
print("应用启动事件完成", file=sys.stderr)
|
||||
sys.stderr.flush()
|
||||
logger.info("应用启动事件完成")
|
||||
|
||||
# 应用关闭事件
|
||||
@app.on_event("shutdown")
|
||||
@@ -145,9 +151,18 @@ async def shutdown_event():
|
||||
try:
|
||||
from src.services.file_watcher_service import stop_file_watcher
|
||||
stop_file_watcher()
|
||||
print("知识库文件监控服务已停止")
|
||||
logger.info("知识库文件监控服务已停止")
|
||||
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)
|
||||
@@ -174,6 +189,10 @@ from src.api import course_content, forum
|
||||
app.include_router(course_content.router)
|
||||
app.include_router(forum.router)
|
||||
|
||||
# 导入并注册后台管理API
|
||||
from src.api import admin
|
||||
app.include_router(admin.router)
|
||||
|
||||
# 静态文件服务
|
||||
if os.path.exists(settings.upload_dir):
|
||||
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):
|
||||
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("/")
|
||||
async def root():
|
||||
@@ -295,11 +319,8 @@ async def list_services():
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print("=" * 50, file=sys.stderr)
|
||||
print("启动 Uvicorn 服务器...", file=sys.stderr)
|
||||
print(f"Host: {settings.host}, Port: {settings.port}", file=sys.stderr)
|
||||
print("=" * 50, file=sys.stderr)
|
||||
sys.stderr.flush()
|
||||
logger.info("启动 Uvicorn 服务器...")
|
||||
logger.info(f"Host: {settings.host}, Port: {settings.port}")
|
||||
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
|
||||
@@ -42,6 +42,9 @@ dependencies = [
|
||||
"duckduckgo-search>=6.0.0",
|
||||
"docx2txt>=0.9",
|
||||
"pypdf>=6.12.0",
|
||||
"pymupdf>=1.27.2.3",
|
||||
"pdf2image>=1.17.0",
|
||||
"opencv-python-headless>=4.13.0.92",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -60,6 +63,10 @@ packages = ["src"]
|
||||
[tool.uv]
|
||||
required-version = ">=0.6.15"
|
||||
|
||||
[[tool.uv.index]]
|
||||
url = "https://mirrors.aliyun.com/pypi/simple/"
|
||||
default = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
indent-width = 4
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""测试 Heron 版面检测 + 裁剪 + VLM 图片描述流程"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from PIL import Image
|
||||
from src.rag.document_loaders import PDFImageExtractor
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent # course-agent-od/
|
||||
TEST_PDF = (
|
||||
PROJECT_ROOT
|
||||
/ "data" / "uploads" / "testuser" / "knowledge_bases" / "植物知识图谱"
|
||||
/ "ab810d4a-b030-45c0-94d2-bd6e2a23053b.pdf"
|
||||
)
|
||||
OUTPUT_DIR = PROJECT_ROOT / "data" / "images" / "_test_pdf_extract"
|
||||
|
||||
|
||||
def test_extract(max_pages: int | None = None):
|
||||
"""阶段1:Heron 版面检测 + 裁剪"""
|
||||
if not TEST_PDF.exists():
|
||||
logger.error(f"测试PDF不存在: {TEST_PDF}")
|
||||
sys.exit(1)
|
||||
|
||||
# 清理旧输出
|
||||
if OUTPUT_DIR.exists():
|
||||
for f in OUTPUT_DIR.iterdir():
|
||||
f.unlink()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[阶段1] Heron 版面检测 + 裁剪测试")
|
||||
print(f" PDF: {TEST_PDF.name}")
|
||||
print(f" 输出: {OUTPUT_DIR}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
t0 = time.time()
|
||||
images = PDFImageExtractor.extract_images(
|
||||
str(TEST_PDF), str(OUTPUT_DIR),
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
if not images:
|
||||
print(" ❌ 未提取到任何图片")
|
||||
return []
|
||||
|
||||
if max_pages:
|
||||
images = [img for img in images if img["page"] <= max_pages]
|
||||
|
||||
# 按页分组统计
|
||||
from collections import defaultdict
|
||||
by_page = defaultdict(list)
|
||||
for img in images:
|
||||
by_page[img["page"]].append(img)
|
||||
|
||||
total_size = 0
|
||||
fig_count = 0
|
||||
full_count = 0
|
||||
for page_num in sorted(by_page.keys()):
|
||||
page_imgs = by_page[page_num]
|
||||
for img in page_imgs:
|
||||
path = Path(img["path"])
|
||||
with Image.open(path) as pil:
|
||||
w, h = pil.size
|
||||
size_kb = img["size"] / 1024
|
||||
total_size += img["size"]
|
||||
is_full = "_full." in img["filename"]
|
||||
tag = "FULL" if is_full else "FIG"
|
||||
if is_full:
|
||||
full_count += 1
|
||||
else:
|
||||
fig_count += 1
|
||||
print(
|
||||
f" page {img['page']:>3d} [{tag}] {img['filename']:<25s} "
|
||||
f"{w}x{h} {size_kb:>7.1f} KB"
|
||||
)
|
||||
|
||||
print(
|
||||
f"\n 汇总: {len(images)} 张 ({fig_count} 裁剪 + {full_count} 整页兜底), "
|
||||
f"{total_size/1024/1024:.1f} MB, 耗时 {elapsed:.1f}s\n"
|
||||
)
|
||||
return images
|
||||
|
||||
|
||||
async def test_vlm(images: list[dict], max_images: int = 3):
|
||||
"""阶段2:VLM图片描述"""
|
||||
from src.llm.siliconflow import get_llm_client
|
||||
|
||||
# 优先选裁剪图
|
||||
fig_images = [img for img in images if "_full." not in img["filename"]]
|
||||
targets = (fig_images or images)[:max_images]
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[阶段2] VLM图片描述测试 ({len(targets)} 张)")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
client = get_llm_client()
|
||||
sem = asyncio.Semaphore(3)
|
||||
|
||||
async def describe_one(idx: int, img: dict):
|
||||
async with sem:
|
||||
t0 = time.time()
|
||||
desc = await client.describe_image(img["path"], img.get("context_text", ""))
|
||||
elapsed = time.time() - t0
|
||||
return idx, desc, elapsed
|
||||
|
||||
tasks = [describe_one(i, img) for i, img in enumerate(targets)]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for r in results:
|
||||
if isinstance(r, Exception):
|
||||
print(f" ❌ 失败: {r}\n")
|
||||
continue
|
||||
idx, desc, elapsed = r
|
||||
img = targets[idx]
|
||||
print(f" --- page {img['page']} ({img['filename']}) {elapsed:.1f}s ---")
|
||||
print(f" {desc}\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="测试PDF图片提取")
|
||||
parser.add_argument("--vlm", action="store_true", help="启用VLM图片描述")
|
||||
parser.add_argument("--max-pages", type=int, default=None, help="限制提取页数")
|
||||
parser.add_argument("--max-vlm", type=int, default=3, help="VLM描述最大图片数")
|
||||
args = parser.parse_args()
|
||||
|
||||
images = test_extract(max_pages=args.max_pages)
|
||||
|
||||
if args.vlm and images:
|
||||
asyncio.run(test_vlm(images, max_images=args.max_vlm))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
full_name: Optional[str]
|
||||
is_active: bool
|
||||
is_superuser: bool = False
|
||||
created_at: str
|
||||
|
||||
|
||||
@@ -81,6 +82,7 @@ async def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
is_superuser=user.is_superuser,
|
||||
created_at=user.created_at.isoformat()
|
||||
)
|
||||
|
||||
@@ -127,6 +129,7 @@ async def get_current_user_info(current_user: str = Depends(get_current_user), d
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
is_superuser=user.is_superuser,
|
||||
created_at=user.created_at.isoformat()
|
||||
)
|
||||
|
||||
@@ -172,6 +175,7 @@ async def update_user_info(
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
is_superuser=user.is_superuser,
|
||||
created_at=user.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
+29
-25
@@ -1,11 +1,13 @@
|
||||
"""
|
||||
聊天对话API
|
||||
"""
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any, Literal
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
@@ -17,6 +19,8 @@ from ..rag.chains import create_rag_chain
|
||||
from ..rag.conversation_chains import create_conversation_chain
|
||||
from ..llm.siliconflow import get_llm_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["聊天"])
|
||||
|
||||
|
||||
@@ -33,11 +37,11 @@ def get_user_id_by_username(db: Session, username: str) -> int:
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
"""聊天请求模型"""
|
||||
message: str
|
||||
message: str = Field(..., min_length=1, max_length=5000, description="用户消息")
|
||||
session_id: Optional[int] = None
|
||||
mode: str = "normal" # normal, rag
|
||||
knowledge_base_ids: Optional[List[int]] = None
|
||||
model: Optional[str] = None # 模型ID,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||
mode: Literal["normal", "rag"] = "normal"
|
||||
knowledge_base_ids: Optional[List[int]] = Field(None, description="RAG模式使用的知识库ID列表")
|
||||
model: Optional[str] = None
|
||||
|
||||
|
||||
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()
|
||||
session.title = new_title
|
||||
db.commit()
|
||||
print(f"[DEBUG-AUTO-TITLE] 自动更新会话标题: {new_title}")
|
||||
logger.debug(f"自动更新会话标题: {new_title}")
|
||||
|
||||
# 根据模式运行不同的问答工作流
|
||||
if request.mode == "rag":
|
||||
print(f"[DEBUG-RAG] 非流式RAG模式")
|
||||
print(f"[DEBUG-RAG] 接收到的knowledge_base_ids: {request.knowledge_base_ids}")
|
||||
print(f"[DEBUG-RAG] knowledge_base_ids类型: {type(request.knowledge_base_ids)}")
|
||||
logger.debug(f"非流式RAG模式")
|
||||
logger.debug(f"接收到的knowledge_base_ids: {request.knowledge_base_ids}")
|
||||
logger.debug(f"knowledge_base_ids类型: {type(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链
|
||||
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)
|
||||
result = rag_chain.invoke(request.message)
|
||||
else:
|
||||
# 普通模式:使用LangChain 1.0对话链
|
||||
print(f"[DEBUG-CHAT] 普通模式 - 使用LangChain对话链")
|
||||
print(f"[DEBUG-CHAT] 模型: {request.model}")
|
||||
logger.debug(f"普通模式 - 使用LangChain对话链")
|
||||
logger.debug(f"模型: {request.model}")
|
||||
conversation_chain = create_conversation_chain(model=request.model)
|
||||
|
||||
# 获取聊天历史
|
||||
@@ -175,7 +179,7 @@ async def stream_message(
|
||||
db = SessionLocal()
|
||||
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:
|
||||
@@ -215,7 +219,7 @@ async def stream_message(
|
||||
new_title = request.message.strip()[:30] + "..." if len(request.message.strip()) > 30 else request.message.strip()
|
||||
session.title = new_title
|
||||
db.commit()
|
||||
print(f"[DEBUG-AUTO-TITLE] 自动更新会话标题: {new_title}")
|
||||
logger.debug(f"自动更新会话标题: {new_title}")
|
||||
|
||||
# 获取聊天历史
|
||||
chat_history = []
|
||||
@@ -231,18 +235,18 @@ async def stream_message(
|
||||
|
||||
# 根据模式选择不同的处理方式
|
||||
if request.mode == "rag":
|
||||
print(f"[DEBUG-RAG] 使用LangChain 1.0 RAG链")
|
||||
print(f"[DEBUG-RAG] 接收到的knowledge_base_ids: {request.knowledge_base_ids}")
|
||||
print(f"[DEBUG-RAG] knowledge_base_ids类型: {type(request.knowledge_base_ids)}")
|
||||
print(f"[DEBUG-RAG] 模型: {request.model}")
|
||||
logger.debug(f"使用LangChain 1.0 RAG链")
|
||||
logger.debug(f"接收到的knowledge_base_ids: {request.knowledge_base_ids}")
|
||||
logger.debug(f"knowledge_base_ids类型: {type(request.knowledge_base_ids)}")
|
||||
logger.debug(f"模型: {request.model}")
|
||||
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_chain = create_rag_chain(
|
||||
knowledge_base_ids=request.knowledge_base_ids,
|
||||
search_type="similarity",
|
||||
k=5,
|
||||
k=50,
|
||||
model=request.model
|
||||
)
|
||||
|
||||
@@ -288,8 +292,8 @@ async def stream_message(
|
||||
|
||||
else:
|
||||
# 普通模式:使用LangChain 1.0对话链
|
||||
print(f"[DEBUG-CHAT] 普通模式 - 使用LangChain流式对话链")
|
||||
print(f"[DEBUG-CHAT] 模型: {request.model}")
|
||||
logger.debug(f"普通模式 - 使用LangChain流式对话链")
|
||||
logger.debug(f"模型: {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
|
||||
|
||||
except Exception as e:
|
||||
print(f"RAG工作流执行失败: {str(e)}")
|
||||
logger.error(f"RAG工作流执行失败: {str(e)}")
|
||||
# 降级到基础问答
|
||||
rag_chain = create_rag_chain(model=model)
|
||||
return rag_chain.invoke(question)
|
||||
|
||||
+61
-122
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
文档管理API
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
@@ -15,6 +16,8 @@ from ..core.security import get_current_user
|
||||
from ..models.document import Document, DocumentChunk
|
||||
from ..services.document_service import DocumentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["文档管理"])
|
||||
|
||||
|
||||
@@ -45,6 +48,18 @@ class DocumentStats(BaseModel):
|
||||
file_types: dict
|
||||
|
||||
|
||||
def _check_document_access(document: Document, user) -> None:
|
||||
"""检查用户是否有权访问该文档(自己的文档,或系统知识库且为admin)"""
|
||||
if document.user_id == user.id:
|
||||
return
|
||||
if document.knowledge_base and document.knowledge_base.is_system and user.is_superuser:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文档不存在"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=DocumentUploadResponse, deprecated=True)
|
||||
async def upload_document(
|
||||
file: UploadFile = File(...),
|
||||
@@ -69,29 +84,20 @@ async def get_documents(
|
||||
):
|
||||
"""获取文档列表"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
from ..models.user import User
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
# 获取用户的文档
|
||||
documents = db.query(Document).filter(
|
||||
Document.user_id == user.id
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
return [
|
||||
DocumentResponse(
|
||||
id=doc.id,
|
||||
filename=doc.filename,
|
||||
title=doc.title,
|
||||
file_size=doc.file_size,
|
||||
file_type=doc.file_type,
|
||||
is_processed=doc.is_processed,
|
||||
is_public=doc.is_public,
|
||||
id=doc.id, filename=doc.filename, title=doc.title,
|
||||
file_size=doc.file_size, file_type=doc.file_type,
|
||||
is_processed=doc.is_processed, is_public=doc.is_public,
|
||||
created_at=doc.created_at.isoformat()
|
||||
)
|
||||
for doc in documents
|
||||
@@ -100,10 +106,7 @@ async def get_documents(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取文档列表失败: {str(e)}"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取文档列表失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{document_id}", response_model=DocumentResponse)
|
||||
@@ -114,45 +117,27 @@ async def get_document(
|
||||
):
|
||||
"""获取单个文档信息"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
from ..models.user import User
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取文档
|
||||
document = db.query(Document).filter(
|
||||
Document.id == document_id,
|
||||
Document.user_id == user.id
|
||||
).first()
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
document = db.query(Document).filter(Document.id == document_id).first()
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文档不存在"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
|
||||
_check_document_access(document, user)
|
||||
|
||||
return DocumentResponse(
|
||||
id=document.id,
|
||||
filename=document.filename,
|
||||
title=document.title,
|
||||
file_size=document.file_size,
|
||||
file_type=document.file_type,
|
||||
is_processed=document.is_processed,
|
||||
is_public=document.is_public,
|
||||
id=document.id, filename=document.filename, title=document.title,
|
||||
file_size=document.file_size, file_type=document.file_type,
|
||||
is_processed=document.is_processed, is_public=document.is_public,
|
||||
created_at=document.created_at.isoformat()
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取文档信息失败: {str(e)}"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取文档信息失败: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/{document_id}")
|
||||
@@ -163,118 +148,89 @@ async def delete_document(
|
||||
):
|
||||
"""删除文档"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
from ..models.user import User
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取文档
|
||||
document = db.query(Document).filter(
|
||||
Document.id == document_id,
|
||||
Document.user_id == user.id
|
||||
).first()
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
document = db.query(Document).filter(Document.id == document_id).first()
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文档不存在"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
|
||||
_check_document_access(document, user)
|
||||
|
||||
# 1. 先删除向量数据和文档块
|
||||
# 1. 删除向量数据和文档块
|
||||
try:
|
||||
print(f"开始删除文档向量数据: {document.filename} (ID: {document.id})")
|
||||
logger.info(f"开始删除文档向量数据: {document.filename} (ID={document.id})")
|
||||
document_service = DocumentService(db)
|
||||
vector_deleted = document_service.delete_document_chunks(document.id)
|
||||
vector_deleted = document_service.delete_document_chunks(document.id, knowledge_base_id=document.knowledge_base_id)
|
||||
if vector_deleted:
|
||||
print(f"成功删除文档向量数据: {document.filename}")
|
||||
logger.info(f"成功删除文档向量数据: {document.filename}")
|
||||
else:
|
||||
print(f"警告:删除文档向量数据失败: {document.filename}")
|
||||
logger.warning(f"删除文档向量数据失败: {document.filename}")
|
||||
except Exception as e:
|
||||
print(f"删除向量数据时发生错误: {str(e)}")
|
||||
import traceback
|
||||
print(f"详细错误信息: {traceback.format_exc()}")
|
||||
logger.error(f"删除向量数据时发生错误: {str(e)}", exc_info=True)
|
||||
|
||||
# 2. 删除物理文件
|
||||
try:
|
||||
if os.path.exists(document.file_path):
|
||||
os.remove(document.file_path)
|
||||
print(f"成功删除物理文件: {document.file_path}")
|
||||
logger.info(f"成功删除物理文件: {document.file_path}")
|
||||
else:
|
||||
print(f"物理文件不存在: {document.file_path}")
|
||||
logger.info(f"物理文件不存在: {document.file_path}")
|
||||
except Exception as e:
|
||||
print(f"删除物理文件时发生错误: {str(e)}")
|
||||
logger.error(f"删除物理文件时发生错误: {str(e)}")
|
||||
|
||||
# 3. 删除数据库记录
|
||||
db.delete(document)
|
||||
db.commit()
|
||||
print(f"成功删除文档数据库记录: {document.filename}")
|
||||
logger.info(f"成功删除文档数据库记录: {document.filename}")
|
||||
|
||||
return {"message": "文档删除成功"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"删除文档失败: {str(e)}"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"删除文档失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{document_id}/process")
|
||||
async def process_document(
|
||||
document_id: int,
|
||||
force: bool = False,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""处理文档(向量化)"""
|
||||
"""处理文档(向量化),force=true 强制重新处理"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
from ..models.user import User
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取文档
|
||||
document = db.query(Document).filter(
|
||||
Document.id == document_id,
|
||||
Document.user_id == user.id
|
||||
).first()
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
document = db.query(Document).filter(Document.id == document_id).first()
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文档不存在"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
|
||||
_check_document_access(document, user)
|
||||
|
||||
if document.is_processed:
|
||||
return {"message": "文档已经处理过了"}
|
||||
if document.is_processed and not force:
|
||||
return {"message": "文档已经处理过了,使用 force=true 强制重新处理"}
|
||||
|
||||
# 处理文档
|
||||
if force and document.is_processed:
|
||||
document_service = DocumentService(db)
|
||||
success = await asyncio.to_thread(document_service.process_document, document.id)
|
||||
document_service.delete_document_chunks(document.id, knowledge_base_id=document.knowledge_base_id)
|
||||
|
||||
document_service = DocumentService(db)
|
||||
success = await document_service.process_document(document.id)
|
||||
|
||||
if success:
|
||||
return {"message": "文档处理成功"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="文档处理失败"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="文档处理失败")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"处理文档失败: {str(e)}"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"处理文档失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats/overview", response_model=DocumentStats)
|
||||
@@ -284,27 +240,19 @@ async def get_document_stats(
|
||||
):
|
||||
"""获取文档统计信息"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
from ..models.user import User
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
# 统计信息
|
||||
total_documents = db.query(Document).filter(Document.user_id == user.id).count()
|
||||
processed_documents = db.query(Document).filter(
|
||||
Document.user_id == user.id,
|
||||
Document.is_processed == True
|
||||
Document.user_id == user.id, Document.is_processed == True
|
||||
).count()
|
||||
|
||||
# 计算总大小
|
||||
documents = db.query(Document).filter(Document.user_id == user.id).all()
|
||||
total_size = sum(doc.file_size for doc in documents)
|
||||
|
||||
# 文件类型统计
|
||||
file_types = {}
|
||||
for doc in documents:
|
||||
file_type = doc.file_type
|
||||
@@ -320,13 +268,4 @@ async def get_document_stats(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取统计信息失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取统计信息失败: {str(e)}")
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""
|
||||
知识库CRUD API
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import or_, and_
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.database import get_db
|
||||
@@ -18,6 +21,8 @@ from ..models.document import Document
|
||||
from ..models.user import User
|
||||
from ..services.document_service import DocumentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/knowledge-bases", tags=["知识库管理"])
|
||||
|
||||
|
||||
@@ -64,7 +69,7 @@ async def get_knowledge_bases(
|
||||
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:
|
||||
# 获取用户ID
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
@@ -118,7 +123,7 @@ async def create_knowledge_base(
|
||||
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:
|
||||
# 获取用户ID
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
@@ -128,7 +133,16 @@ async def create_knowledge_base(
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 检查知识库名称是否已存在
|
||||
# 管理员创建的知识库标记为系统知识库
|
||||
is_system = user.is_superuser
|
||||
|
||||
# 检查知识库名称是否在同域内已存在(系统知识库与用户知识库互不冲突)
|
||||
if is_system:
|
||||
existing_kb = db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.name == data.name,
|
||||
KnowledgeBase.is_system == True
|
||||
).first()
|
||||
else:
|
||||
existing_kb = db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.name == data.name,
|
||||
KnowledgeBase.user_id == user.id
|
||||
@@ -144,13 +158,21 @@ async def create_knowledge_base(
|
||||
knowledge_base = KnowledgeBase(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
user_id=user.id
|
||||
user_id=user.id,
|
||||
is_system=is_system
|
||||
)
|
||||
|
||||
db.add(knowledge_base)
|
||||
db.commit()
|
||||
db.refresh(knowledge_base)
|
||||
|
||||
# 在对应目录下创建文件夹
|
||||
if is_system:
|
||||
kb_dir = Path(settings.knowledge_base_dir) / data.name
|
||||
else:
|
||||
kb_dir = Path(settings.upload_dir) / user.username / "knowledge_bases" / data.name
|
||||
kb_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return KnowledgeBaseResponse(
|
||||
id=knowledge_base.id,
|
||||
name=knowledge_base.name,
|
||||
@@ -260,10 +282,13 @@ async def update_knowledge_base(
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取知识库
|
||||
# 获取知识库(用户自己的,或admin操作系统知识库)
|
||||
knowledge_base = db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.id == knowledge_base_id,
|
||||
KnowledgeBase.user_id == user.id
|
||||
or_(
|
||||
KnowledgeBase.user_id == user.id,
|
||||
and_(KnowledgeBase.is_system == True, user.is_superuser == True)
|
||||
)
|
||||
).first()
|
||||
|
||||
if not knowledge_base:
|
||||
@@ -335,10 +360,13 @@ async def delete_knowledge_base(
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取知识库
|
||||
# 获取知识库(用户自己的,或admin操作系统知识库)
|
||||
knowledge_base = db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.id == knowledge_base_id,
|
||||
KnowledgeBase.user_id == user.id
|
||||
or_(
|
||||
KnowledgeBase.user_id == user.id,
|
||||
and_(KnowledgeBase.is_system == True, user.is_superuser == True)
|
||||
)
|
||||
).first()
|
||||
|
||||
if not knowledge_base:
|
||||
@@ -352,7 +380,7 @@ async def delete_knowledge_base(
|
||||
Document.knowledge_base_id == knowledge_base_id
|
||||
).all()
|
||||
|
||||
print(f"开始删除知识库 '{knowledge_base.name}',包含 {len(documents)} 个文档")
|
||||
logger.info(f"开始删除知识库 '{knowledge_base.name}',包含 {len(documents)} 个文档")
|
||||
|
||||
# 2. 逐个删除文档的向量数据和物理文件
|
||||
document_service = DocumentService(db)
|
||||
@@ -362,35 +390,49 @@ async def delete_knowledge_base(
|
||||
for document in documents:
|
||||
try:
|
||||
# 删除向量数据
|
||||
vector_deleted = document_service.delete_document_chunks(document.id)
|
||||
vector_deleted = document_service.delete_document_chunks(document.id, knowledge_base_id=document.knowledge_base_id)
|
||||
if vector_deleted:
|
||||
print(f"成功删除文档向量数据: {document.filename}")
|
||||
logger.info(f"成功删除文档向量数据: {document.filename}")
|
||||
else:
|
||||
print(f"警告:删除文档向量数据失败: {document.filename}")
|
||||
logger.warning(f"警告:删除文档向量数据失败: {document.filename}")
|
||||
error_count += 1
|
||||
|
||||
# 删除物理文件
|
||||
if os.path.exists(document.file_path):
|
||||
os.remove(document.file_path)
|
||||
print(f"成功删除物理文件: {document.file_path}")
|
||||
logger.info(f"成功删除物理文件: {document.file_path}")
|
||||
else:
|
||||
print(f"物理文件不存在: {document.file_path}")
|
||||
logger.info(f"物理文件不存在: {document.file_path}")
|
||||
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"删除文档 {document.filename} 的资源时出错: {str(e)}")
|
||||
import traceback
|
||||
print(f"详细错误信息: {traceback.format_exc()}")
|
||||
logger.error(f"删除文档 {document.filename} 的资源时出错: {str(e)}", exc_info=True)
|
||||
error_count += 1
|
||||
# 继续处理其他文档
|
||||
|
||||
print(f"文档清理完成: 成功 {success_count} 个,失败 {error_count} 个")
|
||||
logger.info(f"文档清理完成: 成功 {success_count} 个,失败 {error_count} 个")
|
||||
|
||||
# 清理知识库目录
|
||||
if knowledge_base.is_system:
|
||||
kb_dir = Path(settings.knowledge_base_dir) / knowledge_base.name
|
||||
else:
|
||||
kb_dir = Path(settings.upload_dir) / user.username / "knowledge_bases" / knowledge_base.name
|
||||
if kb_dir.exists():
|
||||
shutil.rmtree(kb_dir)
|
||||
logger.info(f"已删除知识库目录: {kb_dir}")
|
||||
|
||||
# 清理知识库对应的图片目录
|
||||
from ..services.document_service import IMAGES_DIR
|
||||
kb_img_dir = IMAGES_DIR / str(knowledge_base.id)
|
||||
if kb_img_dir.exists():
|
||||
shutil.rmtree(kb_img_dir)
|
||||
logger.info(f"已删除知识库图片目录: {kb_img_dir}")
|
||||
|
||||
# 3. 删除知识库(级联删除文档记录)
|
||||
db.delete(knowledge_base)
|
||||
db.commit()
|
||||
print(f"成功删除知识库数据库记录: {knowledge_base.name}")
|
||||
logger.info(f"成功删除知识库数据库记录: {knowledge_base.name}")
|
||||
|
||||
return {"message": "知识库删除成功"}
|
||||
|
||||
@@ -455,14 +497,16 @@ async def upload_document_to_knowledge_base(
|
||||
detail="知识库不存在"
|
||||
)
|
||||
|
||||
# 检查是否为系统知识库,系统知识库不允许任何用户上传文档
|
||||
# 检查知识库权限
|
||||
if knowledge_base.is_system:
|
||||
# 系统知识库:仅管理员可上传
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="系统知识库不允许上传文档,请使用您自己的知识库"
|
||||
detail="系统知识库仅管理员可上传文档"
|
||||
)
|
||||
|
||||
# 验证知识库属于当前用户(非系统知识库必须属于用户)
|
||||
else:
|
||||
# 用户知识库:必须属于当前用户
|
||||
if knowledge_base.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -473,10 +517,18 @@ async def upload_document_to_knowledge_base(
|
||||
file_id = str(uuid.uuid4())
|
||||
filename = f"{file_id}{file_extension}"
|
||||
|
||||
# 保存文件
|
||||
upload_dir = Path(settings.upload_dir)
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = upload_dir / filename
|
||||
# 根据知识库类型选择保存路径
|
||||
if knowledge_base.is_system:
|
||||
# 系统知识库:保存到 knowledge_base_dir/{kb_name}/
|
||||
save_dir = Path(settings.knowledge_base_dir) / knowledge_base.name
|
||||
source_type = "knowledge_base"
|
||||
else:
|
||||
# 用户知识库:保存到 uploads/{username}/knowledge_bases/{kb_name}/
|
||||
save_dir = Path(settings.upload_dir) / user.username / "knowledge_bases" / knowledge_base.name
|
||||
source_type = "upload"
|
||||
|
||||
save_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = save_dir / filename
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
@@ -492,7 +544,8 @@ async def upload_document_to_knowledge_base(
|
||||
file_type=file_extension,
|
||||
title=title or Path(file.filename).stem,
|
||||
description=description,
|
||||
is_processed=False
|
||||
is_processed=False,
|
||||
source_type=source_type
|
||||
)
|
||||
|
||||
db.add(document)
|
||||
@@ -501,20 +554,18 @@ async def upload_document_to_knowledge_base(
|
||||
|
||||
# 自动处理文档向量化
|
||||
try:
|
||||
print(f"开始处理文档向量化: {document.filename} (ID: {document.id})")
|
||||
logger.info(f"开始处理文档向量化: {document.filename} (ID: {document.id})")
|
||||
document_service = DocumentService(db)
|
||||
success = await document_service.process_document(document.id)
|
||||
|
||||
if success:
|
||||
print(f"文档向量化处理成功: {document.filename}")
|
||||
logger.info(f"文档向量化处理成功: {document.filename}")
|
||||
message = "文档上传并处理成功"
|
||||
else:
|
||||
print(f"文档向量化处理失败: {document.filename}")
|
||||
logger.warning(f"文档向量化处理失败: {document.filename}")
|
||||
message = "文档上传成功,但向量化处理失败"
|
||||
except Exception as e:
|
||||
print(f"文档向量化处理异常: {document.filename}, 错误: {str(e)}")
|
||||
import traceback
|
||||
print(f"详细错误信息: {traceback.format_exc()}")
|
||||
logger.error(f"文档向量化处理异常: {document.filename}, 错误: {str(e)}", exc_info=True)
|
||||
message = "文档上传成功,但向量化处理失败"
|
||||
|
||||
return DocumentUploadResponse(
|
||||
@@ -549,10 +600,13 @@ async def get_knowledge_base_documents(
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 验证知识库存在且属于用户
|
||||
# 验证知识库存在(用户自己的,或系统知识库)
|
||||
knowledge_base = db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.id == knowledge_base_id,
|
||||
KnowledgeBase.user_id == user.id
|
||||
or_(
|
||||
KnowledgeBase.user_id == user.id,
|
||||
KnowledgeBase.is_system == True
|
||||
)
|
||||
).first()
|
||||
|
||||
if not knowledge_base:
|
||||
@@ -711,6 +765,52 @@ async def reindex_document(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/download")
|
||||
async def download_document(
|
||||
document_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(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="用户不存在")
|
||||
|
||||
document = db.query(Document).filter(Document.id == document_id).first()
|
||||
if not document:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
|
||||
|
||||
# 权限检查:系统知识库所有人可访问,用户知识库仅限所有者
|
||||
if not document.knowledge_base:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在")
|
||||
if not document.knowledge_base.is_system and document.knowledge_base.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此文档")
|
||||
|
||||
if not os.path.exists(document.file_path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在")
|
||||
|
||||
import mimetypes
|
||||
mime_type, _ = mimetypes.guess_type(document.original_filename or document.file_path)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
return FileResponse(
|
||||
document.file_path,
|
||||
filename=document.original_filename,
|
||||
media_type=mime_type,
|
||||
content_disposition_type="inline"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"下载文档失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/documents/{document_id}", response_model=ReindexResponse)
|
||||
async def delete_knowledge_base_document(
|
||||
document_id: int,
|
||||
|
||||
@@ -128,6 +128,10 @@ class Settings(BaseSettings):
|
||||
# 全局配置实例
|
||||
settings = Settings()
|
||||
|
||||
# 将 HF_ENDPOINT 写入环境变量,供 sentence_transformers/huggingface_hub 使用
|
||||
if settings.hf_endpoint and not os.environ.get("HF_ENDPOINT"):
|
||||
os.environ["HF_ENDPOINT"] = settings.hf_endpoint
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""获取配置实例"""
|
||||
|
||||
@@ -8,6 +8,7 @@ from passlib.context import CryptContext
|
||||
from fastapi import HTTPException, status, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
@@ -82,3 +83,24 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s
|
||||
return username
|
||||
except JWTError:
|
||||
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节点定义
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, Any, List, TypedDict
|
||||
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.vector_store import get_vector_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_json_from_response(text: str) -> dict:
|
||||
"""从LLM响应中提取JSON,兼容markdown代码块包裹的情况"""
|
||||
@@ -73,7 +76,7 @@ def analyze_question_node(state: GraphState) -> GraphState:
|
||||
return new_state
|
||||
|
||||
except Exception as e:
|
||||
print(f"问题分析失败: {str(e)}")
|
||||
logger.error(f"问题分析失败: {str(e)}")
|
||||
new_state = state.copy()
|
||||
new_state["metadata"] = {**state["metadata"], "analysis": {
|
||||
"question_type": "通用",
|
||||
@@ -138,9 +141,7 @@ def retrieve_knowledge_node(state: GraphState) -> GraphState:
|
||||
return new_state
|
||||
|
||||
except Exception as e:
|
||||
print(f"知识检索失败: {str(e)}")
|
||||
import traceback
|
||||
print(traceback.format_exc())
|
||||
logger.error(f"知识检索失败: {str(e)}", exc_info=True)
|
||||
new_state = state.copy()
|
||||
new_state["retrieved_docs"] = []
|
||||
new_state["sources"] = []
|
||||
@@ -186,7 +187,7 @@ def generate_answer_node(state: GraphState) -> GraphState:
|
||||
return new_state
|
||||
|
||||
except Exception as e:
|
||||
print(f"答案生成失败: {str(e)}")
|
||||
logger.error(f"答案生成失败: {str(e)}")
|
||||
new_state = state.copy()
|
||||
new_state["answer"] = "抱歉,我无法生成合适的回答。请稍后重试。"
|
||||
return new_state
|
||||
@@ -208,5 +209,5 @@ def format_response_node(state: GraphState) -> GraphState:
|
||||
return new_state
|
||||
|
||||
except Exception as e:
|
||||
print(f"响应格式化失败: {str(e)}")
|
||||
logger.error(f"响应格式化失败: {str(e)}")
|
||||
return state
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""
|
||||
问答LangGraph工作流
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from langgraph.graph import StateGraph, END
|
||||
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:
|
||||
"""创建问答图"""
|
||||
@@ -57,7 +60,7 @@ def run_qa_workflow(question: str, knowledge_base_ids: Optional[List[int]] = Non
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"问答工作流执行失败: {str(e)}")
|
||||
logger.error(f"问答工作流执行失败: {str(e)}")
|
||||
return {
|
||||
"answer": "抱歉,处理您的问题时出现了错误。请稍后重试。",
|
||||
"sources": [],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""
|
||||
大模型API集成 — 支持 SiliconFlow 和 DeepSeek 官方
|
||||
大模型API集成 — 支持 SiliconFlow 和 DeepSeek 官方 + 视觉模型
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import base64
|
||||
from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
|
||||
@@ -11,8 +13,21 @@ import openai
|
||||
|
||||
from ..core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# 图片描述提示词
|
||||
IMAGE_DESCRIPTION_PROMPT = """你是一个国土空间规划专家。请详细描述这张PDF文档中的图片内容。
|
||||
|
||||
图片周围文字上下文(来自PDF页面):{context_text}
|
||||
|
||||
要求:
|
||||
1. 说明图片类型(地图/规划图/图表/流程图/示意图/照片等)
|
||||
2. 描述图片中的关键信息、数据和空间关系
|
||||
3. 提取图中所有文字标注
|
||||
4. 描述控制在200-300字"""
|
||||
|
||||
# DeepSeek 官方模型 ID 前缀(用于自动路由)
|
||||
DEEPSEEK_OFFICIAL_MODELS = {
|
||||
"deepseek-chat",
|
||||
@@ -44,7 +59,7 @@ class SiliconFlowLLM:
|
||||
api_key, base_url, resolved_model = _resolve_provider(raw_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(
|
||||
model=resolved_model,
|
||||
@@ -150,6 +165,71 @@ class SiliconFlowLLM:
|
||||
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,不支持的格式先转为PNG
|
||||
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"}
|
||||
|
||||
if ext not in mime_map:
|
||||
from PIL import Image
|
||||
import io
|
||||
img = Image.open(image_path)
|
||||
if img.mode in ("CMYK", "P"):
|
||||
img = img.convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
image_data = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
mime_type = "image/png"
|
||||
else:
|
||||
with open(image_path, "rb") as f:
|
||||
image_data = base64.b64encode(f.read()).decode("utf-8")
|
||||
mime_type = mime_map[ext]
|
||||
|
||||
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_client = SiliconFlowLLM()
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""
|
||||
迁移孤立文档到默认知识库
|
||||
"""
|
||||
import logging
|
||||
from sqlalchemy.orm import Session
|
||||
from ..core.database import get_db
|
||||
from ..models.user import User
|
||||
from ..models.knowledge_base import KnowledgeBase
|
||||
from ..models.document import Document
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate_orphaned_documents():
|
||||
"""将没有知识库的文档迁移到用户的默认知识库"""
|
||||
@@ -40,12 +43,12 @@ def migrate_orphaned_documents():
|
||||
doc.knowledge_base_id = default_kb.id
|
||||
|
||||
db.commit()
|
||||
print(f"用户 {user.username} 的 {len(orphaned_docs)} 个文档已迁移到默认知识库")
|
||||
logger.info(f"用户 {user.username} 的 {len(orphaned_docs)} 个文档已迁移到默认知识库")
|
||||
|
||||
print("孤立文档迁移完成")
|
||||
logger.info("孤立文档迁移完成")
|
||||
|
||||
except Exception as e:
|
||||
print(f"迁移失败: {str(e)}")
|
||||
logger.error(f"迁移失败: {str(e)}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -13,7 +13,7 @@ class ChatSession(Base):
|
||||
__tablename__ = "chat_sessions"
|
||||
|
||||
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)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -32,7 +32,7 @@ class ChatMessage(Base):
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
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
|
||||
content = Column(Text, nullable=False)
|
||||
message_metadata = Column(Text, nullable=True) # JSON格式的元数据
|
||||
|
||||
@@ -14,7 +14,7 @@ class Document(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
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)
|
||||
original_filename = Column(String(255), nullable=False)
|
||||
file_path = Column(String(500), nullable=False)
|
||||
@@ -47,7 +47,7 @@ class DocumentChunk(Base):
|
||||
__tablename__ = "document_chunks"
|
||||
|
||||
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)
|
||||
content = Column(Text, nullable=False)
|
||||
content_hash = Column(String(64), nullable=False) # 内容哈希
|
||||
|
||||
+31
-18
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
RAG检索链(LangChain 1.0)
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
@@ -11,6 +12,8 @@ from .retrievers import KnowledgeBaseRetriever
|
||||
from .vector_store import get_vector_store
|
||||
from ..llm.siliconflow import get_llm_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RAGChain:
|
||||
"""RAG问答链(LangChain 1.0标准API)"""
|
||||
|
||||
@@ -18,8 +21,8 @@ class RAGChain:
|
||||
self,
|
||||
knowledge_base_ids: Optional[List[int]] = None,
|
||||
search_type: str = "similarity",
|
||||
k: int = 5,
|
||||
score_threshold: float = 0.1,
|
||||
k: int = 50,
|
||||
score_threshold: float = 0.15,
|
||||
model: Optional[str] = None
|
||||
):
|
||||
"""初始化RAG链
|
||||
@@ -31,7 +34,7 @@ class RAGChain:
|
||||
score_threshold: 相似度阈值
|
||||
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)
|
||||
self.llm = _sf_client.llm
|
||||
self.client = _sf_client
|
||||
@@ -46,7 +49,7 @@ class RAGChain:
|
||||
search_kwargs={"k": k},
|
||||
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
|
||||
self.prompt = create_rag_prompt()
|
||||
@@ -58,8 +61,8 @@ class RAGChain:
|
||||
"""创建检索链(LangChain 1.0 Runnable API)"""
|
||||
# 使用LangChain 1.0的runnable API构建RAG链
|
||||
def format_docs(docs):
|
||||
"""格式化文档"""
|
||||
return "\n\n".join(doc.page_content for doc in docs)
|
||||
"""格式化文档(使用带编号的上下文格式)"""
|
||||
return self._format_docs_for_context(docs)
|
||||
|
||||
# 构建RAG链 - 只检索一次,返回完整结果
|
||||
def rag_with_sources(input_data):
|
||||
@@ -115,7 +118,7 @@ class RAGChain:
|
||||
"""流式调用(只流式输出答案)"""
|
||||
# 先获取文档(这是唯一一次检索)
|
||||
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_value = await self.prompt.ainvoke({"context": context, "question": question})
|
||||
@@ -133,19 +136,17 @@ class RAGChain:
|
||||
"""流式调用(返回答案流和文档,包含思考过程)"""
|
||||
import time
|
||||
|
||||
# 0. 思考阶段开始
|
||||
start_time = time.time()
|
||||
yield {"type": "thinking", "stage": "understanding", "message": "正在理解问题..."}
|
||||
|
||||
# 1. 检索文档
|
||||
yield {"type": "thinking", "stage": "retrieving", "message": "正在检索相关知识..."}
|
||||
retrieval_start = time.time()
|
||||
docs = await self.retriever.ainvoke(question)
|
||||
retrieval_time = time.time() - retrieval_start
|
||||
|
||||
# 发送检索结果 — 包含文档标题和摘要
|
||||
# 发送检索结果(只在有文档时)
|
||||
if docs:
|
||||
doc_details = []
|
||||
for i, doc in enumerate(docs[:5]):
|
||||
for i, doc in enumerate(docs[:10]):
|
||||
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
||||
title = metadata.get("title", metadata.get("filename", f"文档 {i+1}"))
|
||||
preview = doc.page_content[:100].replace('\n', ' ')
|
||||
@@ -160,10 +161,9 @@ class RAGChain:
|
||||
"details": doc_details
|
||||
}
|
||||
|
||||
context = "\n\n".join(doc.page_content for doc in docs)
|
||||
context = self._format_docs_for_context(docs)
|
||||
|
||||
# 2. 构建prompt
|
||||
yield {"type": "thinking", "stage": "generating", "message": f"基于 {len(docs)} 篇文档生成回答..."}
|
||||
# 2. 构建prompt并流式生成
|
||||
prompt_value = await self.prompt.ainvoke({"context": context, "question": question})
|
||||
messages = prompt_value.to_messages()
|
||||
|
||||
@@ -193,20 +193,33 @@ class RAGChain:
|
||||
def _format_sources(self, documents: List) -> List[Dict]:
|
||||
"""格式化来源信息"""
|
||||
sources = []
|
||||
for doc in documents:
|
||||
for i, doc in enumerate(documents, start=1):
|
||||
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
||||
sources.append({
|
||||
"id": i,
|
||||
"title": metadata.get("title", "未知标题"),
|
||||
"filename": metadata.get("filename", "未知文件"),
|
||||
"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
|
||||
|
||||
@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(
|
||||
knowledge_base_ids: Optional[List[int]] = None,
|
||||
search_type: str = "similarity",
|
||||
k: int = 5,
|
||||
k: int = 50,
|
||||
model: Optional[str] = None
|
||||
) -> RAGChain:
|
||||
"""创建RAG链实例
|
||||
|
||||
@@ -85,16 +85,8 @@ class ConversationChain:
|
||||
"""流式调用(包含思考过程,捕获推理模型的真实推理内容)"""
|
||||
import time
|
||||
|
||||
# 思考阶段
|
||||
start_time = time.time()
|
||||
yield {"type": "thinking", "stage": "understanding", "message": "正在理解问题..."}
|
||||
|
||||
# 准备历史
|
||||
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 捕获推理内容
|
||||
prompt_value = await self.prompt.ainvoke({
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""
|
||||
LangChain 1.0 文档加载器封装
|
||||
LangChain 1.0 文档加载器封装 + PDF图片提取(Heron 版面检测 + 裁剪)
|
||||
"""
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
from typing import List, Optional, Dict
|
||||
from pathlib import Path
|
||||
import fitz # pymupdf — 仅用于提取页面文本
|
||||
from PIL import Image
|
||||
from pdf2image import convert_from_path
|
||||
from langchain_community.document_loaders import (
|
||||
PyPDFLoader,
|
||||
Docx2txtLoader,
|
||||
@@ -11,6 +15,263 @@ from langchain_community.document_loaders import (
|
||||
)
|
||||
from langchain_core.documents import Document
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PDFImageExtractor:
|
||||
"""使用 pdf2image 渲染页面 + Heron RT-DETR 检测 Picture 区域 + 裁剪"""
|
||||
|
||||
# 渲染参数
|
||||
DEFAULT_DPI = 250
|
||||
DEFAULT_TARGET_WIDTH = 2500
|
||||
JPEG_QUALITY = 90
|
||||
CROP_PADDING = 10
|
||||
|
||||
# Heron 检测参数(参考 ZDTL config)
|
||||
HERON_MODEL = "docling-project/docling-layout-heron"
|
||||
PICTURE_THRESHOLD = 0.35
|
||||
NMS_IOU = 0.3
|
||||
MAX_AREA_RATIO = 0.45
|
||||
MIN_SIZE_W = 100
|
||||
MIN_SIZE_H = 80
|
||||
|
||||
@staticmethod
|
||||
def _nms(boxes: list[dict], iou_threshold: float) -> list[dict]:
|
||||
"""IoU-based Non-Maximum Suppression"""
|
||||
if not boxes:
|
||||
return boxes
|
||||
|
||||
import torch
|
||||
|
||||
bboxes = torch.tensor([b["bbox"] for b in boxes], dtype=torch.float32)
|
||||
scores = torch.tensor([b["score"] for b in boxes])
|
||||
|
||||
x1 = bboxes[:, 0]
|
||||
y1 = bboxes[:, 1]
|
||||
x2 = bboxes[:, 2]
|
||||
y2 = bboxes[:, 3]
|
||||
areas = (x2 - x1) * (y2 - y1)
|
||||
|
||||
_, order = scores.sort(descending=True)
|
||||
keep = []
|
||||
while order.numel() > 0:
|
||||
if order.numel() == 1:
|
||||
keep.append(order.item())
|
||||
break
|
||||
i = order[0].item()
|
||||
keep.append(i)
|
||||
|
||||
xx1 = torch.max(x1[i], x1[order[1:]])
|
||||
yy1 = torch.max(y1[i], y1[order[1:]])
|
||||
xx2 = torch.min(x2[i], x2[order[1:]])
|
||||
yy2 = torch.min(y2[i], y2[order[1:]])
|
||||
|
||||
inter = (xx2 - xx1).clamp(min=0) * (yy2 - yy1).clamp(min=0)
|
||||
union = areas[i] + areas[order[1:]] - inter
|
||||
iou = inter / union
|
||||
mask = iou <= iou_threshold
|
||||
order = order[1:][mask]
|
||||
|
||||
return [boxes[i] for i in keep]
|
||||
|
||||
@staticmethod
|
||||
def _detect_pictures(
|
||||
pil_images: list,
|
||||
model_name: str = None,
|
||||
picture_threshold: float = None,
|
||||
nms_iou: float = None,
|
||||
max_area_ratio: float = None,
|
||||
min_size_w: int = None,
|
||||
min_size_h: int = None,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""用 Heron RT-DETR 检测每页中的 Picture 区域
|
||||
|
||||
Returns:
|
||||
{page_index: [{bbox: [x1,y1,x2,y2], score: float}, ...]}
|
||||
"""
|
||||
import torch
|
||||
from transformers import RTDetrV2ForObjectDetection, RTDetrImageProcessor
|
||||
|
||||
model_name = model_name or PDFImageExtractor.HERON_MODEL
|
||||
picture_threshold = picture_threshold or PDFImageExtractor.PICTURE_THRESHOLD
|
||||
nms_iou = nms_iou or PDFImageExtractor.NMS_IOU
|
||||
max_area_ratio = max_area_ratio or PDFImageExtractor.MAX_AREA_RATIO
|
||||
min_size_w = min_size_w or PDFImageExtractor.MIN_SIZE_W
|
||||
min_size_h = min_size_h or PDFImageExtractor.MIN_SIZE_H
|
||||
|
||||
logger.info(f"[ImageExtractor] 加载 Heron 模型: {model_name}")
|
||||
processor = RTDetrImageProcessor.from_pretrained(
|
||||
model_name, local_files_only=True
|
||||
)
|
||||
model = RTDetrV2ForObjectDetection.from_pretrained(
|
||||
model_name, local_files_only=True
|
||||
)
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
model.to(device)
|
||||
model.eval()
|
||||
logger.info(f"[ImageExtractor] 模型加载完成, device={device}")
|
||||
|
||||
results: dict[int, list[dict]] = {}
|
||||
|
||||
for idx, pil_img in enumerate(pil_images):
|
||||
img = pil_img.convert("RGB")
|
||||
W, H = img.size
|
||||
inputs = processor(images=img, return_tensors="pt").to(device)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = model(**inputs)
|
||||
|
||||
detections = processor.post_process_object_detection(
|
||||
outputs, threshold=0.1, target_sizes=[(H, W)]
|
||||
)[0]
|
||||
|
||||
pictures = []
|
||||
for score, label, box in zip(
|
||||
detections["scores"], detections["labels"], detections["boxes"]
|
||||
):
|
||||
s = score.item()
|
||||
l = label.item()
|
||||
x1, y1, x2, y2 = box.tolist()
|
||||
area = (x2 - x1) * (y2 - y1) / (W * H)
|
||||
w, h = x2 - x1, y2 - y1
|
||||
|
||||
if area > max_area_ratio or w < min_size_w or h < min_size_h:
|
||||
continue
|
||||
|
||||
cls = model.config.id2label.get(l, str(l))
|
||||
if cls == "picture" and s >= picture_threshold:
|
||||
pictures.append({
|
||||
"bbox": [round(v, 1) for v in [x1, y1, x2, y2]],
|
||||
"score": round(s, 3),
|
||||
})
|
||||
|
||||
# NMS + 按位置排序
|
||||
pictures = PDFImageExtractor._nms(pictures, nms_iou)
|
||||
pictures.sort(key=lambda b: (b["bbox"][1] // 200, b["bbox"][0]))
|
||||
results[idx] = pictures
|
||||
|
||||
logger.info(
|
||||
f"[ImageExtractor] page {idx + 1}: "
|
||||
f"{len(pictures)} pictures detected"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def extract_images(
|
||||
file_path: str,
|
||||
output_dir: str,
|
||||
dpi: int = None,
|
||||
target_width: int = None,
|
||||
) -> List[dict]:
|
||||
"""将PDF每页渲染为图片,用 Heron 检测并裁剪出图表区域
|
||||
|
||||
若某页无检测到 Picture,则保存整页图作为兜底。
|
||||
|
||||
Args:
|
||||
file_path: PDF文件路径
|
||||
output_dir: 图片输出目录
|
||||
dpi: 渲染DPI(默认250)
|
||||
target_width: 缩放目标宽度像素(默认2500)
|
||||
|
||||
Returns:
|
||||
图片元数据列表,每个dict包含 path, filename, page, context_text, size
|
||||
"""
|
||||
dpi = dpi or PDFImageExtractor.DEFAULT_DPI
|
||||
target_width = target_width or PDFImageExtractor.DEFAULT_TARGET_WIDTH
|
||||
quality = PDFImageExtractor.JPEG_QUALITY
|
||||
padding = PDFImageExtractor.CROP_PADDING
|
||||
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Phase 1: pymupdf 提取页面文本
|
||||
page_texts: list[str] = []
|
||||
try:
|
||||
doc = fitz.open(file_path)
|
||||
for page_num in range(len(doc)):
|
||||
page_texts.append(doc[page_num].get_text("text"))
|
||||
doc.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"[ImageExtractor] pymupdf文本提取失败: {e}")
|
||||
|
||||
# Phase 2: pdf2image 渲染页面
|
||||
try:
|
||||
pil_images = convert_from_path(file_path, dpi=dpi, fmt="jpeg")
|
||||
except Exception as e:
|
||||
logger.error(f"[ImageExtractor] pdf2image渲染失败: {e}")
|
||||
return []
|
||||
|
||||
# Phase 3: 缩放页面图
|
||||
scaled_images = []
|
||||
for pil_img in pil_images:
|
||||
w, h = pil_img.size
|
||||
if w > target_width:
|
||||
ratio = target_width / w
|
||||
new_h = int(h * ratio)
|
||||
pil_img = pil_img.resize(
|
||||
(target_width, new_h), Image.Resampling.LANCZOS
|
||||
)
|
||||
scaled_images.append(pil_img)
|
||||
|
||||
# Phase 4: Heron 检测 Picture 区域
|
||||
try:
|
||||
detections = PDFImageExtractor._detect_pictures(scaled_images)
|
||||
except Exception as e:
|
||||
logger.error(f"[ImageExtractor] Heron检测失败: {e}")
|
||||
return []
|
||||
|
||||
# Phase 5: 裁剪 + 保存
|
||||
images = []
|
||||
for page_idx, pil_img in enumerate(scaled_images):
|
||||
page_num = page_idx + 1
|
||||
W, H = pil_img.size
|
||||
context_text = ""
|
||||
if page_idx < len(page_texts):
|
||||
context_text = page_texts[page_idx][:600].strip()
|
||||
|
||||
pics = detections.get(page_idx, [])
|
||||
|
||||
if pics:
|
||||
# 裁剪检测到的 Picture 区域
|
||||
for fig_idx, pic in enumerate(pics):
|
||||
try:
|
||||
x1, y1, x2, y2 = pic["bbox"]
|
||||
x1 = max(0, int(x1) - padding)
|
||||
y1 = max(0, int(y1) - padding)
|
||||
x2 = min(W, int(x2) + padding)
|
||||
y2 = min(H, int(y2) + padding)
|
||||
|
||||
crop = pil_img.crop((x1, y1, x2, y2))
|
||||
filename = f"page{page_num}_fig{fig_idx + 1}.jpg"
|
||||
output_path = Path(output_dir) / filename
|
||||
crop.save(str(output_path), format="JPEG", quality=quality)
|
||||
|
||||
images.append({
|
||||
"path": str(output_path),
|
||||
"filename": filename,
|
||||
"page": page_num,
|
||||
"context_text": context_text,
|
||||
"size": output_path.stat().st_size,
|
||||
})
|
||||
logger.info(
|
||||
f"[ImageExtractor] 裁剪 page={page_num} "
|
||||
f"fig={fig_idx + 1} bbox={pic['bbox']} "
|
||||
f"size={images[-1]['size']}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[ImageExtractor] 裁剪失败 page={page_num} "
|
||||
f"fig={fig_idx + 1}: {e}"
|
||||
)
|
||||
else:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"[ImageExtractor] 完成: {len(images)} 张图片 from {file_path}"
|
||||
)
|
||||
return images
|
||||
|
||||
|
||||
class DocumentLoaderFactory:
|
||||
"""文档加载器工厂"""
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""
|
||||
嵌入模型管理
|
||||
"""
|
||||
import logging
|
||||
from typing import List
|
||||
from sentence_transformers import SentenceTransformer
|
||||
import numpy as np
|
||||
|
||||
from ..core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@@ -23,7 +26,7 @@ class EmbeddingModel:
|
||||
"""加载嵌入模型"""
|
||||
try:
|
||||
self.model = SentenceTransformer(self.model_name)
|
||||
print(f"嵌入模型 {self.model_name} 加载成功")
|
||||
logger.info(f"嵌入模型 {self.model_name} 加载成功")
|
||||
except Exception as e:
|
||||
raise Exception(f"嵌入模型加载失败: {str(e)}")
|
||||
|
||||
|
||||
@@ -7,15 +7,22 @@ from langchain_core.messages import SystemMessage, HumanMessage
|
||||
# RAG系统提示词
|
||||
RAG_SYSTEM_PROMPT = """你是一个专业的国土空间规划知识问答助手。请基于以下上下文信息回答用户的问题。
|
||||
|
||||
上下文信息:
|
||||
上下文信息(每个来源已标注编号,如 [来源1]、[来源2]):
|
||||
{context}
|
||||
|
||||
要求:
|
||||
1. 回答要准确、专业、详细
|
||||
2. 如果上下文中没有相关信息,请诚实说明
|
||||
3. 回答要结构清晰,逻辑性强
|
||||
4. 适当引用相关概念和术语
|
||||
5. 回答长度控制在500-1000字之间
|
||||
1. 综合评估上下文信息的相关性,优先采用与问题最相关的内容,不强行引用不相关的来源
|
||||
2. 回答中必须使用 [来源N] 格式(N为上下文中的来源编号)标注所引用的具体来源,例如"根据[来源3],国土空间规划..."
|
||||
3. 如果上下文中包含[图片描述]内容且与问题相关,**必须**在回答中展示该图片。上下文中已有"图片URL: /images/..."字段,请直接复制该URL,使用Markdown语法引用:
|
||||

|
||||
例如:上下文中某来源包含"图片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 .vector_store import get_vector_store
|
||||
from .embeddings import get_embedding_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DocumentRetriever:
|
||||
"""文档检索器"""
|
||||
@@ -23,7 +26,7 @@ class DocumentRetriever:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""检索相关文档"""
|
||||
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(
|
||||
@@ -31,19 +34,19 @@ class DocumentRetriever:
|
||||
n_results=top_k,
|
||||
filter_metadata=filter_metadata
|
||||
)
|
||||
print(f"[DEBUG-RETRIEVER] 向量搜索返回结果数量: {len(results)}")
|
||||
logger.debug(f"向量搜索返回结果数量: {len(results)}")
|
||||
|
||||
# 过滤低分结果
|
||||
filtered_results = [
|
||||
result for result in results
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
print(f"文档检索失败: {str(e)}")
|
||||
logger.error(f"文档检索失败: {str(e)}")
|
||||
return []
|
||||
|
||||
def retrieve_by_document_id(self, document_id: str) -> List[Dict[str, Any]]:
|
||||
@@ -56,7 +59,7 @@ class DocumentRetriever:
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"按文档ID检索失败: {str(e)}")
|
||||
logger.error(f"按文档ID检索失败: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_relevant_context(self, query: str, max_length: int = 2000) -> str:
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""
|
||||
自定义知识库检索器(LangChain 1.0)
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
import math
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.callbacks.manager import CallbackManagerForRetrieverRun
|
||||
from .score_utils import convert_distance_to_score
|
||||
from langchain_core.retrievers import BaseRetriever
|
||||
|
||||
class KnowledgeBaseRetriever(BaseRetriever):
|
||||
@@ -14,7 +17,7 @@ class KnowledgeBaseRetriever(BaseRetriever):
|
||||
knowledge_base_ids: Optional[List[int]] = None
|
||||
search_type: str = "similarity"
|
||||
search_kwargs: dict = {"k": 5}
|
||||
score_threshold: float = 0.1
|
||||
score_threshold: float = 0.15
|
||||
|
||||
def _get_relevant_documents(
|
||||
self,
|
||||
@@ -23,16 +26,16 @@ class KnowledgeBaseRetriever(BaseRetriever):
|
||||
run_manager: CallbackManagerForRetrieverRun
|
||||
) -> List[Document]:
|
||||
"""获取相关文档(LangChain 1.0标准接口)"""
|
||||
print(f"[DEBUG-Retriever] 查询: {query}")
|
||||
print(f"[DEBUG-Retriever] knowledge_base_ids: {self.knowledge_base_ids}")
|
||||
logger.debug(f"查询: {query}")
|
||||
logger.debug(f"knowledge_base_ids: {self.knowledge_base_ids}")
|
||||
|
||||
# 构建知识库过滤条件
|
||||
filter_dict = None
|
||||
if 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:
|
||||
print(f"[DEBUG-Retriever] 没有知识库ID,不进行过滤")
|
||||
logger.debug("没有知识库ID,不进行过滤")
|
||||
|
||||
# 执行搜索
|
||||
if self.search_type == "similarity":
|
||||
@@ -41,21 +44,21 @@ class KnowledgeBaseRetriever(BaseRetriever):
|
||||
k=self.search_kwargs.get("k", 5),
|
||||
filter=filter_dict
|
||||
)
|
||||
print(f"[DEBUG-Retriever] 搜索返回文档数量: {len(docs_and_scores)}")
|
||||
logger.debug(f"搜索返回文档数量: {len(docs_and_scores)}")
|
||||
|
||||
# 打印每个文档的知识库ID
|
||||
for i, (doc, distance) in enumerate(docs_and_scores):
|
||||
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 = []
|
||||
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:
|
||||
filtered_docs.append(doc)
|
||||
|
||||
print(f"[DEBUG-Retriever] 过滤后文档数量: {len(filtered_docs)}")
|
||||
logger.debug(f"过滤后文档数量: {len(filtered_docs)}")
|
||||
return filtered_docs
|
||||
|
||||
elif self.search_type == "mmr":
|
||||
@@ -67,16 +70,3 @@ class KnowledgeBaseRetriever(BaseRetriever):
|
||||
)
|
||||
|
||||
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 向量存储封装
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import hashlib
|
||||
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 .embeddings import get_embedding_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@@ -40,7 +43,7 @@ class VectorStore:
|
||||
self.vectorstore.add_documents(documents)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"添加文档失败: {str(e)}")
|
||||
logger.error(f"添加文档失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def as_retriever(self, **kwargs):
|
||||
@@ -54,7 +57,7 @@ class VectorStore:
|
||||
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(
|
||||
query=query,
|
||||
@@ -62,9 +65,20 @@ class VectorStore:
|
||||
filter=filter
|
||||
)
|
||||
|
||||
print(f"[DEBUG-VectorStore] 返回结果数量: {len(result)}")
|
||||
logger.debug(f"返回结果数量: {len(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(
|
||||
self,
|
||||
query: str,
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
学习分析服务
|
||||
提供用户学习数据的统计和分析功能
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, distinct, and_
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from ..models.user import User
|
||||
from ..models.chat import ChatSession, ChatMessage
|
||||
from ..models.document import Document
|
||||
@@ -65,7 +67,7 @@ class AnalyticsService:
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 获取用户统计数据失败: {str(e)}")
|
||||
logger.error(f"获取用户统计数据失败: {str(e)}")
|
||||
raise Exception(f"获取统计数据失败: {str(e)}")
|
||||
|
||||
def get_user_learning_trends(self, user_id: int, days: int = 30) -> List[Dict[str, Any]]:
|
||||
@@ -107,7 +109,7 @@ class AnalyticsService:
|
||||
return trends
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 获取学习趋势数据失败: {str(e)}")
|
||||
logger.error(f"获取学习趋势数据失败: {str(e)}")
|
||||
raise Exception(f"获取学习趋势失败: {str(e)}")
|
||||
|
||||
def get_popular_questions(self, user_id: int, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
@@ -141,7 +143,7 @@ class AnalyticsService:
|
||||
return popular_questions
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 获取热门问题失败: {str(e)}")
|
||||
logger.error(f"获取热门问题失败: {str(e)}")
|
||||
raise Exception(f"获取热门问题失败: {str(e)}")
|
||||
|
||||
def get_knowledge_coverage(self, user_id: int) -> List[Dict[str, Any]]:
|
||||
@@ -160,7 +162,7 @@ class AnalyticsService:
|
||||
return coverage_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 获取知识覆盖度失败: {str(e)}")
|
||||
logger.error(f"获取知识覆盖度失败: {str(e)}")
|
||||
raise Exception(f"获取知识覆盖度失败: {str(e)}")
|
||||
|
||||
def get_learning_report(self, user_id: int) -> Dict[str, Any]:
|
||||
@@ -211,5 +213,5 @@ class AnalyticsService:
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 获取学习报告失败: {str(e)}")
|
||||
logger.error(f"获取学习报告失败: {str(e)}")
|
||||
raise Exception(f"获取学习报告失败: {str(e)}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
用户认证服务
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
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.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@@ -58,7 +61,7 @@ class AuthService:
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
print(f"创建用户失败: {str(e)}")
|
||||
logger.error(f"创建用户失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def authenticate_user(self, username: str, password: str) -> Optional[User]:
|
||||
@@ -85,7 +88,7 @@ class AuthService:
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
print(f"用户认证失败: {str(e)}")
|
||||
logger.error(f"用户认证失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
@@ -117,7 +120,7 @@ class AuthService:
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
print(f"更新用户失败: {str(e)}")
|
||||
logger.error(f"更新用户失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def deactivate_user(self, user_id: int) -> bool:
|
||||
@@ -133,7 +136,7 @@ class AuthService:
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
print(f"停用用户失败: {str(e)}")
|
||||
logger.error(f"停用用户失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
||||
@@ -152,7 +155,7 @@ class AuthService:
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
print(f"修改密码失败: {str(e)}")
|
||||
logger.error(f"修改密码失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def create_access_token_for_user(self, user: User) -> Dict[str, Any]:
|
||||
@@ -193,7 +196,7 @@ class AuthService:
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取用户统计失败: {str(e)}")
|
||||
logger.error(f"获取用户统计失败: {str(e)}")
|
||||
return {
|
||||
"total_users": 0,
|
||||
"active_users": 0,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
根据数据库中的行号范围从LaTeX文件动态读取内容
|
||||
层级结构:书籍 -> Chapter -> Section -> Subsection(知识点)
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -10,6 +11,8 @@ from sqlalchemy.orm import Session
|
||||
from ..models.book_structure import Chapter, Section, Subsection
|
||||
from .latex_parser import LaTeXParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookContentService:
|
||||
"""书籍内容服务"""
|
||||
@@ -59,7 +62,7 @@ class BookContentService:
|
||||
)
|
||||
return content
|
||||
except Exception as e:
|
||||
print(f"读取章节内容失败: {e}")
|
||||
logger.error(f"读取章节内容失败: {e}")
|
||||
return None
|
||||
|
||||
def get_section_content(self, db: Session, section_id: int) -> Optional[str]:
|
||||
@@ -91,7 +94,7 @@ class BookContentService:
|
||||
)
|
||||
return content
|
||||
except Exception as e:
|
||||
print(f"读取节内容失败: {e}")
|
||||
logger.error(f"读取节内容失败: {e}")
|
||||
return None
|
||||
|
||||
def get_subsection_content(self, db: Session, subsection_id: int) -> Optional[str]:
|
||||
@@ -127,6 +130,6 @@ class BookContentService:
|
||||
)
|
||||
return content
|
||||
except Exception as e:
|
||||
print(f"读取小节内容失败: {e}")
|
||||
logger.error(f"读取小节内容失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
"""
|
||||
文档处理服务(LangChain 1.0)
|
||||
文档处理服务(LangChain 1.0 + 多模态图片处理)
|
||||
"""
|
||||
import os
|
||||
import hashlib
|
||||
import shutil
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from langchain_core.documents import Document as LangChainDocument
|
||||
|
||||
from ..models.document import Document, DocumentChunk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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.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:
|
||||
"""文档处理服务"""
|
||||
@@ -21,13 +30,13 @@ class DocumentService:
|
||||
self.vector_store = get_vector_store()
|
||||
|
||||
async def process_document(self, document_id: int) -> bool:
|
||||
"""处理文档(使用LangChain 1.0)"""
|
||||
"""处理文档(使用LangChain 1.0 + 多模态图片处理)"""
|
||||
try:
|
||||
document = self.db.query(Document).filter(Document.id == document_id).first()
|
||||
if not document:
|
||||
return False
|
||||
|
||||
# 1. 使用LangChain加载文档
|
||||
# 1. 使用LangChain加载文档(文本)
|
||||
documents = DocumentLoaderFactory.load_document(
|
||||
file_path=document.file_path,
|
||||
file_type=document.file_type,
|
||||
@@ -43,21 +52,108 @@ class DocumentService:
|
||||
text_splitter = get_text_splitter(chunk_size=1000, chunk_overlap=200)
|
||||
splits = text_splitter.split_documents(documents)
|
||||
|
||||
# 3. 添加到向量存储
|
||||
success = self.vector_store.add_documents(splits)
|
||||
# 3. PDF图片提取和描述(仅PDF文件)
|
||||
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:
|
||||
document.is_processed = True
|
||||
self.db.commit()
|
||||
logger.info(f"[DocumentService] 文档 {document.filename} 处理完成: "
|
||||
f"{len(splits)} 个文本块, {len(image_chunks)} 个图片描述块")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"处理文档失败: {str(e)}")
|
||||
logger.error(f"处理文档失败: {str(e)}")
|
||||
self.db.rollback()
|
||||
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:
|
||||
# 创建图片输出目录: images/{knowledge_base_id}/{document_id}/
|
||||
img_output_dir = IMAGES_DIR / str(knowledge_base_id) / 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"{knowledge_base_id}/{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]]:
|
||||
"""搜索文档(保留原有接口兼容性)"""
|
||||
try:
|
||||
@@ -77,7 +173,7 @@ class DocumentService:
|
||||
search_results = []
|
||||
for doc, distance in results:
|
||||
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
||||
score = self._convert_distance_to_score(distance)
|
||||
score = convert_distance_to_score(distance)
|
||||
|
||||
search_results.append({
|
||||
"content": doc.page_content,
|
||||
@@ -89,39 +185,46 @@ class DocumentService:
|
||||
return search_results
|
||||
|
||||
except Exception as e:
|
||||
print(f"搜索文档失败: {str(e)}")
|
||||
logger.error(f"搜索文档失败: {str(e)}")
|
||||
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]:
|
||||
"""获取文档的所有块"""
|
||||
return self.db.query(DocumentChunk).filter(
|
||||
DocumentChunk.document_id == document_id
|
||||
).order_by(DocumentChunk.chunk_index).all()
|
||||
|
||||
def delete_document_chunks(self, document_id: int) -> bool:
|
||||
"""删除文档的所有块"""
|
||||
def delete_document_chunks(self, document_id: int, knowledge_base_id: int = None) -> bool:
|
||||
"""删除文档的所有块(数据库 + 向量存储 + 图片目录)"""
|
||||
try:
|
||||
# 删除向量存储中的文档数据
|
||||
self.vector_store.delete_by_document_id(document_id)
|
||||
# 删除数据库中的chunk记录
|
||||
self.db.query(DocumentChunk).filter(
|
||||
DocumentChunk.document_id == document_id
|
||||
).delete()
|
||||
self.db.commit()
|
||||
|
||||
# 清理提取的图片目录: images/{kb_id}/{doc_id}/
|
||||
if knowledge_base_id:
|
||||
img_dir = IMAGES_DIR / str(knowledge_base_id) / str(document_id)
|
||||
else:
|
||||
# 兼容旧数据:尝试查找
|
||||
for kb_subdir in IMAGES_DIR.iterdir():
|
||||
if not kb_subdir.is_dir():
|
||||
continue
|
||||
candidate = kb_subdir / str(document_id)
|
||||
if candidate.exists():
|
||||
img_dir = candidate
|
||||
break
|
||||
else:
|
||||
img_dir = None
|
||||
if img_dir and img_dir.exists():
|
||||
shutil.rmtree(img_dir)
|
||||
logger.info(f"已清理图片目录: {img_dir}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"删除文档块失败: {str(e)}")
|
||||
logger.error(f"删除文档块失败: {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
@@ -13,6 +14,7 @@ from ..core.config import get_settings
|
||||
from ..core.database import get_db
|
||||
from .knowledge_base_service import KnowledgeBaseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@@ -27,19 +29,19 @@ class KnowledgeBaseHandler(FileSystemEventHandler):
|
||||
def on_created(self, event):
|
||||
"""处理文件创建事件"""
|
||||
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")
|
||||
|
||||
def on_modified(self, event):
|
||||
"""处理文件修改事件"""
|
||||
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")
|
||||
|
||||
def on_deleted(self, event):
|
||||
"""处理文件删除事件"""
|
||||
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)
|
||||
|
||||
def _is_supported_file(self, file_path: str) -> bool:
|
||||
@@ -54,10 +56,10 @@ class KnowledgeBaseHandler(FileSystemEventHandler):
|
||||
time.sleep(1)
|
||||
|
||||
result = self.kb_service.process_file(file_path)
|
||||
print(f"文件处理结果 ({event_type}): {result}")
|
||||
logger.info(f"文件处理结果 ({event_type}): {result}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"处理文件失败: {file_path}, 错误: {str(e)}")
|
||||
logger.error(f"处理文件失败: {file_path}, 错误: {str(e)}")
|
||||
|
||||
# 在后台线程中处理
|
||||
thread = threading.Thread(target=process)
|
||||
@@ -80,12 +82,12 @@ class KnowledgeBaseHandler(FileSystemEventHandler):
|
||||
|
||||
if document:
|
||||
result = self.kb_service.delete_document(document.id)
|
||||
print(f"文件删除处理结果: {result}")
|
||||
logger.info(f"文件删除处理结果: {result}")
|
||||
else:
|
||||
print(f"未找到对应的数据库记录: {file_path}")
|
||||
logger.info(f"未找到对应的数据库记录: {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"处理文件删除失败: {file_path}, 错误: {str(e)}")
|
||||
logger.error(f"处理文件删除失败: {file_path}, 错误: {str(e)}")
|
||||
|
||||
|
||||
class FileWatcherService:
|
||||
@@ -99,15 +101,15 @@ class FileWatcherService:
|
||||
def start(self):
|
||||
"""启动文件监控"""
|
||||
if self.is_running:
|
||||
print("文件监控服务已在运行")
|
||||
logger.info("文件监控服务已在运行")
|
||||
return
|
||||
|
||||
if not settings.enable_file_watcher:
|
||||
print("文件监控服务已禁用")
|
||||
logger.info("文件监控服务已禁用")
|
||||
return
|
||||
|
||||
if not self.knowledge_base_dir.exists():
|
||||
print(f"知识库目录不存在: {self.knowledge_base_dir}")
|
||||
logger.info(f"知识库目录不存在: {self.knowledge_base_dir}")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -130,15 +132,19 @@ class FileWatcherService:
|
||||
self.observer.start()
|
||||
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()
|
||||
print(f"初始扫描结果: {scan_result}")
|
||||
logger.info(f"初始扫描结果: {scan_result}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"启动文件监控服务失败: {str(e)}")
|
||||
logger.error(f"启动文件监控服务失败: {str(e)}")
|
||||
self.is_running = False
|
||||
|
||||
def stop(self):
|
||||
@@ -147,7 +153,7 @@ class FileWatcherService:
|
||||
self.observer.stop()
|
||||
self.observer.join()
|
||||
self.is_running = False
|
||||
print("文件监控服务已停止")
|
||||
logger.info("文件监控服务已停止")
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""检查监控服务是否活跃"""
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
import httpx
|
||||
from src.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImageGenerationService:
|
||||
"""统一的图像生成服务基类"""
|
||||
@@ -21,9 +24,9 @@ class ImageGenerationService:
|
||||
|
||||
async def call_api(self, model: str, payload: dict) -> dict:
|
||||
"""调用硅基流动 API"""
|
||||
print(f"[DEBUG] 调用SiliconFlow API: {self.base_url}")
|
||||
print(f"[DEBUG] 模型: {model}")
|
||||
print(f"[DEBUG] 请求参数: {payload}")
|
||||
logger.debug(f"调用SiliconFlow API: {self.base_url}")
|
||||
logger.debug(f"模型: {model}")
|
||||
logger.debug(f"请求参数: {payload}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
@@ -37,9 +40,9 @@ class ImageGenerationService:
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] API响应状态: {response.status_code}")
|
||||
print(f"[DEBUG] API响应类型: {type(result)}")
|
||||
print(f"[DEBUG] API响应键: {list(result.keys()) if isinstance(result, dict) else '非字典类型'}")
|
||||
logger.debug(f"API响应状态: {response.status_code}")
|
||||
logger.debug(f"API响应类型: {type(result)}")
|
||||
logger.debug(f"API响应键: {list(result.keys()) if isinstance(result, dict) else '非字典类型'}")
|
||||
|
||||
return result
|
||||
|
||||
@@ -53,7 +56,7 @@ class ImageGenerationService:
|
||||
# 移除可能的空白字符
|
||||
base64_data = base64_data.strip()
|
||||
|
||||
print(f"[DEBUG] 开始解码base64数据,长度: {len(base64_data)}")
|
||||
logger.debug(f"开始解码base64数据,长度: {len(base64_data)}")
|
||||
|
||||
# 解码 base64 数据
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
@@ -62,7 +65,7 @@ class ImageGenerationService:
|
||||
if len(image_bytes) == 0:
|
||||
raise Exception("解码后的图像数据为空")
|
||||
|
||||
print(f"[DEBUG] 图像数据大小: {len(image_bytes)} bytes")
|
||||
logger.debug(f"图像数据大小: {len(image_bytes)} bytes")
|
||||
|
||||
# 确保目录存在
|
||||
image_dir = Path(settings.generated_images_dir)
|
||||
@@ -73,13 +76,13 @@ class ImageGenerationService:
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
print(f"[DEBUG] 图像已保存到: {image_path}")
|
||||
logger.debug(f"图像已保存到: {image_path}")
|
||||
|
||||
return str(image_path)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 保存图像失败: {str(e)}")
|
||||
print(f"[ERROR] 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"保存图像失败: {str(e)}")
|
||||
logger.error(f"base64数据长度: {len(base64_data) if base64_data else 0}")
|
||||
logger.error(f"base64数据前100字符: {base64_data[:100] if base64_data else 'None'}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
def generate_image_id(self) -> str:
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import httpx
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from fastapi import UploadFile
|
||||
from .image_generation_service import ImageGenerationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImageToImageService(ImageGenerationService):
|
||||
"""图生图服务"""
|
||||
@@ -59,13 +62,13 @@ class ImageToImageService(ImageGenerationService):
|
||||
result = await self.call_api(self.MODEL, payload)
|
||||
|
||||
# 调试信息
|
||||
print(f"API响应结构: {type(result)}")
|
||||
logger.debug(f"API响应结构: {type(result)}")
|
||||
if isinstance(result, dict):
|
||||
print(f"响应键: {list(result.keys())}")
|
||||
logger.debug(f"响应键: {list(result.keys())}")
|
||||
if "images" in result:
|
||||
print(f"图像数量: {len(result['images'])}")
|
||||
logger.debug(f"图像数量: {len(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()
|
||||
@@ -73,56 +76,56 @@ class ImageToImageService(ImageGenerationService):
|
||||
|
||||
# 检查是否为字典类型
|
||||
if isinstance(edited_image_data, dict):
|
||||
print(f"[DEBUG] 图像数据是字典,键: {list(edited_image_data.keys())}")
|
||||
logger.debug(f"图像数据是字典,键: {list(edited_image_data.keys())}")
|
||||
|
||||
# 优先检查URL字段
|
||||
if "url" in edited_image_data:
|
||||
image_url = edited_image_data["url"]
|
||||
print(f"[DEBUG] 检测到URL字段: {image_url}")
|
||||
logger.info(f"检测到URL字段: {image_url}")
|
||||
try:
|
||||
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:
|
||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||
logger.error(f"URL下载失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
|
||||
# 如果没有URL,尝试base64字段
|
||||
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")
|
||||
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:
|
||||
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:
|
||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||
logger.error(f"base64保存失败: {str(e)}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
else:
|
||||
print(f"[ERROR] 未找到有效的图像数据字段")
|
||||
logger.error(f"未找到有效的图像数据字段")
|
||||
raise Exception("API响应中未找到有效的图像数据")
|
||||
|
||||
# 如果直接是字符串,判断是URL还是base64
|
||||
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'):
|
||||
print(f"[DEBUG] 检测到URL字符串: {edited_image_data}")
|
||||
logger.info(f"检测到URL字符串: {edited_image_data}")
|
||||
try:
|
||||
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:
|
||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||
logger.error(f"URL下载失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
else:
|
||||
print(f"[DEBUG] 检测到base64字符串")
|
||||
logger.debug(f"检测到base64字符串")
|
||||
try:
|
||||
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:
|
||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||
logger.error(f"base64保存失败: {str(e)}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
else:
|
||||
print(f"[ERROR] 未知的图像数据类型: {type(edited_image_data)}")
|
||||
logger.error(f"未知的图像数据类型: {type(edited_image_data)}")
|
||||
raise Exception(f"不支持的图像数据类型: {type(edited_image_data)}")
|
||||
|
||||
return {
|
||||
@@ -165,70 +168,70 @@ class ImageToImageService(ImageGenerationService):
|
||||
result = await self.call_api(self.MODEL, payload)
|
||||
|
||||
# 调试信息
|
||||
print(f"API响应结构: {type(result)}")
|
||||
logger.debug(f"API响应结构: {type(result)}")
|
||||
if isinstance(result, dict):
|
||||
print(f"响应键: {list(result.keys())}")
|
||||
logger.debug(f"响应键: {list(result.keys())}")
|
||||
if "images" in result:
|
||||
print(f"图像数量: {len(result['images'])}")
|
||||
logger.debug(f"图像数量: {len(result['images'])}")
|
||||
|
||||
# 处理变体结果
|
||||
variations = []
|
||||
for i, img_data in enumerate(result["images"]):
|
||||
print(f"[DEBUG] 处理第 {i+1} 个变体")
|
||||
logger.debug(f"处理第 {i+1} 个变体")
|
||||
image_id = self.generate_image_id()
|
||||
|
||||
# 检查是否为字典类型
|
||||
if isinstance(img_data, dict):
|
||||
print(f"[DEBUG] 图像数据是字典,键: {list(img_data.keys())}")
|
||||
logger.debug(f"图像数据是字典,键: {list(img_data.keys())}")
|
||||
|
||||
# 优先检查URL字段
|
||||
if "url" in img_data:
|
||||
image_url = img_data["url"]
|
||||
print(f"[DEBUG] 检测到URL字段: {image_url}")
|
||||
logger.info(f"检测到URL字段: {image_url}")
|
||||
try:
|
||||
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:
|
||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||
logger.error(f"URL下载失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
|
||||
# 如果没有URL,尝试base64字段
|
||||
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")
|
||||
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:
|
||||
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:
|
||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||
logger.error(f"base64保存失败: {str(e)}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
else:
|
||||
print(f"[ERROR] 未找到有效的图像数据字段")
|
||||
logger.error(f"未找到有效的图像数据字段")
|
||||
raise Exception("API响应中未找到有效的图像数据")
|
||||
|
||||
# 如果直接是字符串,判断是URL还是base64
|
||||
elif isinstance(img_data, str):
|
||||
print(f"[DEBUG] 图像数据是字符串,长度: {len(img_data)}")
|
||||
logger.debug(f"图像数据是字符串,长度: {len(img_data)}")
|
||||
if img_data.startswith('http'):
|
||||
print(f"[DEBUG] 检测到URL字符串: {img_data}")
|
||||
logger.info(f"检测到URL字符串: {img_data}")
|
||||
try:
|
||||
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:
|
||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||
logger.error(f"URL下载失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
else:
|
||||
print(f"[DEBUG] 检测到base64字符串")
|
||||
logger.debug(f"检测到base64字符串")
|
||||
try:
|
||||
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:
|
||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||
logger.error(f"base64保存失败: {str(e)}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
else:
|
||||
print(f"[ERROR] 未知的图像数据类型: {type(img_data)}")
|
||||
logger.error(f"未知的图像数据类型: {type(img_data)}")
|
||||
raise Exception(f"不支持的图像数据类型: {type(img_data)}")
|
||||
|
||||
variations.append({
|
||||
@@ -320,7 +323,7 @@ class ImageToImageService(ImageGenerationService):
|
||||
async def download_image_from_url(self, image_id: str, image_url: str) -> str:
|
||||
"""从URL下载图像"""
|
||||
try:
|
||||
print(f"[DEBUG] 下载图像URL: {image_url}")
|
||||
logger.debug(f"下载图像URL: {image_url}")
|
||||
|
||||
# 下载图像
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
@@ -331,7 +334,7 @@ class ImageToImageService(ImageGenerationService):
|
||||
if len(image_bytes) == 0:
|
||||
raise Exception("下载的图像数据为空")
|
||||
|
||||
print(f"[DEBUG] 下载图像数据大小: {len(image_bytes)} bytes")
|
||||
logger.debug(f"下载图像数据大小: {len(image_bytes)} bytes")
|
||||
|
||||
# 确保目录存在
|
||||
image_dir = Path(self.get_image_dir())
|
||||
@@ -342,11 +345,11 @@ class ImageToImageService(ImageGenerationService):
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
print(f"[DEBUG] 图像已保存到: {image_path}")
|
||||
logger.debug(f"图像已保存到: {image_path}")
|
||||
return str(image_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 下载图像失败: {str(e)}")
|
||||
logger.error(f"下载图像失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
|
||||
def get_image_dir(self) -> str:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
知识库管理服务
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
@@ -10,9 +11,13 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from ..models.document import Document, DocumentChunk
|
||||
from ..models.knowledge_base import KnowledgeBase
|
||||
from ..models.user import User
|
||||
from ..core.config import get_settings
|
||||
from .document_service import DocumentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@@ -34,6 +39,9 @@ class KnowledgeBaseService:
|
||||
if not directory.exists():
|
||||
return {"success": False, "message": f"目录不存在: {directory}"}
|
||||
|
||||
# 先确保每个子目录都有对应的系统知识库
|
||||
self.ensure_system_knowledge_bases()
|
||||
|
||||
results = {
|
||||
"scanned_files": 0,
|
||||
"new_files": 0,
|
||||
@@ -70,6 +78,72 @@ class KnowledgeBaseService:
|
||||
results["success"] = len(results["errors"]) == 0
|
||||
return results
|
||||
|
||||
def ensure_system_knowledge_bases(self):
|
||||
"""确保 knowledge_base_dir 下每个子目录都有对应的系统知识库"""
|
||||
if not self.knowledge_base_dir.exists():
|
||||
return
|
||||
|
||||
for subdir in self.knowledge_base_dir.iterdir():
|
||||
if subdir.is_dir():
|
||||
self._get_or_create_system_kb(subdir.name)
|
||||
|
||||
def _get_or_create_system_kb(self, name: str) -> int:
|
||||
"""按名称查找或创建系统知识库,返回 knowledge_base_id"""
|
||||
kb = self.db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.name == name,
|
||||
KnowledgeBase.is_system == True
|
||||
).first()
|
||||
|
||||
if kb:
|
||||
return kb.id
|
||||
|
||||
# 检查是否已有同名非系统KB,如有则升级为系统KB
|
||||
existing = self.db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.name == name,
|
||||
KnowledgeBase.is_system == False
|
||||
).first()
|
||||
if existing:
|
||||
existing.is_system = True
|
||||
admin = self.db.query(User).filter(User.is_superuser == True).first()
|
||||
if admin:
|
||||
existing.user_id = admin.id
|
||||
self.db.commit()
|
||||
self.db.refresh(existing)
|
||||
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]:
|
||||
"""处理单个文件(检查、提取、入库)"""
|
||||
try:
|
||||
@@ -106,11 +180,20 @@ class KnowledgeBaseService:
|
||||
existing_doc.file_hash == file_hash):
|
||||
return {"status": "skipped", "message": "文件未修改"}
|
||||
|
||||
# 如果文档没有关联知识库,尝试关联
|
||||
if existing_doc.knowledge_base_id is None:
|
||||
kb_id = self._resolve_knowledge_base(file_path)
|
||||
if kb_id:
|
||||
existing_doc.knowledge_base_id = kb_id
|
||||
self.db.commit()
|
||||
|
||||
# 更新现有文档
|
||||
return self._update_document(existing_doc, file_path, file_size, last_modified, file_hash)
|
||||
else:
|
||||
# 解析知识库 ID
|
||||
kb_id = self._resolve_knowledge_base(file_path)
|
||||
# 创建新文档
|
||||
return self._create_document(file_path, file_size, last_modified, file_hash)
|
||||
return self._create_document(file_path, file_size, last_modified, file_hash, knowledge_base_id=kb_id)
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
@@ -169,12 +252,12 @@ class KnowledgeBaseService:
|
||||
loop.run_until_complete(self.document_service.process_document(document.id))
|
||||
loop.close()
|
||||
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.start()
|
||||
except Exception as e:
|
||||
print(f"启动文档处理线程失败: {e}")
|
||||
logger.error(f"启动文档处理线程失败: {e}")
|
||||
|
||||
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.close()
|
||||
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.start()
|
||||
except Exception as e:
|
||||
print(f"启动文档处理线程失败: {e}")
|
||||
logger.error(f"启动文档处理线程失败: {e}")
|
||||
|
||||
return {"status": "updated", "document_id": document.id, "message": "文档更新成功,正在重新处理中"}
|
||||
|
||||
@@ -315,15 +398,34 @@ class KnowledgeBaseService:
|
||||
if not document:
|
||||
return {"success": False, "message": "文档不存在"}
|
||||
|
||||
# 删除向量数据
|
||||
try:
|
||||
from ..rag.vector_store import get_vector_store
|
||||
vector_store = get_vector_store()
|
||||
vector_store.delete_by_document_id(document_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"删除向量数据失败: {e}")
|
||||
|
||||
# 删除文档块
|
||||
self.db.query(DocumentChunk).filter(DocumentChunk.document_id == document_id).delete()
|
||||
|
||||
# 删除图片目录
|
||||
try:
|
||||
from .document_service import IMAGES_DIR
|
||||
kb_id = document.knowledge_base_id
|
||||
img_dir = IMAGES_DIR / str(kb_id) / str(document_id)
|
||||
if img_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(img_dir)
|
||||
logger.info(f"已清理图片目录: {img_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清理图片目录失败: {e}")
|
||||
|
||||
# 删除文档记录
|
||||
self.db.delete(document)
|
||||
self.db.commit()
|
||||
|
||||
return {"success": True, "message": "文档删除成功"}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
return {"success": False, "message": str(e)}
|
||||
@@ -402,7 +504,7 @@ class KnowledgeBaseService:
|
||||
all_files = [f for f in directory.rglob("*") if f.is_file() and self._is_supported_file(f)]
|
||||
total_files = len(all_files)
|
||||
|
||||
print(f" 找到 {total_files} 个支持的文件,开始处理...")
|
||||
logger.info(f"找到 {total_files} 个支持的文件,开始处理...")
|
||||
|
||||
for idx, file_path in enumerate(all_files, 1):
|
||||
try:
|
||||
@@ -426,7 +528,7 @@ class KnowledgeBaseService:
|
||||
# 显示进度(每10个文件或最后一个文件时显示)
|
||||
if idx % 10 == 0 or idx == total_files:
|
||||
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:
|
||||
# 检查是否需要更新
|
||||
@@ -469,6 +571,6 @@ class KnowledgeBaseService:
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
print() # 换行
|
||||
logger.info("文件处理完成")
|
||||
results["success"] = len(results["errors"]) == 0
|
||||
return results
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
支持 Kwai-Kolors/Kolors 和 Qwen/Qwen-Image 模型
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
import httpx
|
||||
from typing import List, Dict, Any
|
||||
from pathlib import Path
|
||||
from .image_generation_service import ImageGenerationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TextToImageService(ImageGenerationService):
|
||||
"""文生图服务"""
|
||||
@@ -73,72 +76,72 @@ class TextToImageService(ImageGenerationService):
|
||||
result = await self.call_api(self.MODELS[model], payload)
|
||||
|
||||
# 调试信息
|
||||
print(f"API响应结构: {type(result)}")
|
||||
logger.debug(f"API响应结构: {type(result)}")
|
||||
if isinstance(result, dict):
|
||||
print(f"响应键: {list(result.keys())}")
|
||||
logger.debug(f"响应键: {list(result.keys())}")
|
||||
if "images" in result:
|
||||
print(f"图像数量: {len(result['images'])}")
|
||||
logger.debug(f"图像数量: {len(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 = []
|
||||
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()
|
||||
|
||||
# 检查是否为字典类型
|
||||
if isinstance(img_data, dict):
|
||||
print(f"[DEBUG] 图像数据是字典,键: {list(img_data.keys())}")
|
||||
logger.debug(f"图像数据是字典,键: {list(img_data.keys())}")
|
||||
|
||||
# 优先检查URL字段
|
||||
if "url" in img_data:
|
||||
image_url = img_data["url"]
|
||||
print(f"[DEBUG] 检测到URL字段: {image_url}")
|
||||
logger.info(f"检测到URL字段: {image_url}")
|
||||
try:
|
||||
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:
|
||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||
logger.error(f"URL下载失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
|
||||
# 如果没有URL,尝试base64字段
|
||||
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")
|
||||
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:
|
||||
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:
|
||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||
logger.error(f"base64保存失败: {str(e)}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
else:
|
||||
print(f"[ERROR] 未找到有效的图像数据字段")
|
||||
logger.error(f"未找到有效的图像数据字段")
|
||||
raise Exception("API响应中未找到有效的图像数据")
|
||||
|
||||
# 如果直接是字符串,判断是URL还是base64
|
||||
elif isinstance(img_data, str):
|
||||
print(f"[DEBUG] 图像数据是字符串,长度: {len(img_data)}")
|
||||
logger.debug(f"图像数据是字符串,长度: {len(img_data)}")
|
||||
if img_data.startswith('http'):
|
||||
print(f"[DEBUG] 检测到URL字符串: {img_data}")
|
||||
logger.info(f"检测到URL字符串: {img_data}")
|
||||
try:
|
||||
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:
|
||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||
logger.error(f"URL下载失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
else:
|
||||
print(f"[DEBUG] 检测到base64字符串")
|
||||
logger.debug(f"检测到base64字符串")
|
||||
try:
|
||||
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:
|
||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||
logger.error(f"base64保存失败: {str(e)}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
else:
|
||||
print(f"[ERROR] 未知的图像数据类型: {type(img_data)}")
|
||||
logger.error(f"未知的图像数据类型: {type(img_data)}")
|
||||
raise Exception(f"不支持的图像数据类型: {type(img_data)}")
|
||||
|
||||
images.append({
|
||||
@@ -182,7 +185,7 @@ class TextToImageService(ImageGenerationService):
|
||||
async def download_image_from_url(self, image_id: str, image_url: str) -> str:
|
||||
"""从URL下载图像"""
|
||||
try:
|
||||
print(f"[DEBUG] 下载图像URL: {image_url}")
|
||||
logger.debug(f"下载图像URL: {image_url}")
|
||||
|
||||
# 下载图像
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
@@ -193,7 +196,7 @@ class TextToImageService(ImageGenerationService):
|
||||
if len(image_bytes) == 0:
|
||||
raise Exception("下载的图像数据为空")
|
||||
|
||||
print(f"[DEBUG] 下载图像数据大小: {len(image_bytes)} bytes")
|
||||
logger.debug(f"下载图像数据大小: {len(image_bytes)} bytes")
|
||||
|
||||
# 确保目录存在
|
||||
image_dir = Path(self.get_image_dir())
|
||||
@@ -204,11 +207,11 @@ class TextToImageService(ImageGenerationService):
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
print(f"[DEBUG] 图像已保存到: {image_path}")
|
||||
logger.debug(f"图像已保存到: {image_path}")
|
||||
return str(image_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 下载图像失败: {str(e)}")
|
||||
logger.error(f"下载图像失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
|
||||
def get_image_dir(self) -> str:
|
||||
|
||||
Generated
+2617
-2565
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -6,7 +6,7 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: course_agent_db
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- "8001:8001" # 前端应用
|
||||
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配置
|
||||
SECRET_KEY: ${SECRET_KEY:-your-super-secret-key-change-in-production}
|
||||
@@ -43,7 +43,7 @@ services:
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
||||
|
||||
# 硅基流动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_MODEL: ${SILICONFLOW_MODEL:-Qwen/Qwen3-30B-A3B-Thinking-2507}
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ const nextConfig = {
|
||||
source: '/generated_images/:path*',
|
||||
destination: `${backendUrl}/generated_images/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/images/:path*',
|
||||
destination: `${backendUrl}/images/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
// 禁用静态生成,避免 SSR 时使用浏览器 API 的错误
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sonner": "^2.0.3",
|
||||
|
||||
Generated
+50
@@ -131,6 +131,9 @@ importers:
|
||||
rehype-katex:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1
|
||||
rehype-raw:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -1260,6 +1263,7 @@ packages:
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
|
||||
@@ -2099,9 +2103,15 @@ packages:
|
||||
hast-util-parse-selector@4.0.0:
|
||||
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
|
||||
|
||||
hast-util-raw@9.1.0:
|
||||
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
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:
|
||||
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
|
||||
|
||||
@@ -2127,6 +2137,9 @@ packages:
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -3000,6 +3013,9 @@ packages:
|
||||
rehype-katex@7.0.1:
|
||||
resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
|
||||
|
||||
rehype-raw@7.0.0:
|
||||
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||
|
||||
@@ -5450,6 +5466,22 @@ snapshots:
|
||||
dependencies:
|
||||
'@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:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -5470,6 +5502,16 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -5505,6 +5547,8 @@ snapshots:
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
@@ -6552,6 +6596,12 @@ snapshots:
|
||||
unist-util-visit-parents: 6.0.1
|
||||
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:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function LoginPage() {
|
||||
href="/"
|
||||
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" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold">
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function RegisterPage() {
|
||||
href="/"
|
||||
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" />
|
||||
</div>
|
||||
<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>
|
||||
<CardContent className="p-6">
|
||||
<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">
|
||||
<p className="text-sm font-medium text-muted-foreground">对话会话</p>
|
||||
<p className="text-2xl font-bold text-foreground">{statistics.total_sessions}</p>
|
||||
@@ -200,7 +200,7 @@ export default function AnalyticsPage() {
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<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">
|
||||
<p className="text-sm font-medium text-muted-foreground">消息总数</p>
|
||||
<p className="text-2xl font-bold text-foreground">{statistics.total_messages}</p>
|
||||
@@ -212,7 +212,7 @@ export default function AnalyticsPage() {
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<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">
|
||||
<p className="text-sm font-medium text-muted-foreground">文档数量</p>
|
||||
<p className="text-2xl font-bold text-foreground">{statistics.total_documents}</p>
|
||||
@@ -224,7 +224,7 @@ export default function AnalyticsPage() {
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<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">
|
||||
<p className="text-sm font-medium text-muted-foreground">活跃天数</p>
|
||||
<p className="text-2xl font-bold text-foreground">{statistics.active_days}</p>
|
||||
@@ -250,7 +250,7 @@ export default function AnalyticsPage() {
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<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}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -278,7 +278,7 @@ export default function AnalyticsPage() {
|
||||
<p className="text-sm font-medium text-foreground">{item.question}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.category}</p>
|
||||
</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>
|
||||
@@ -300,11 +300,11 @@ export default function AnalyticsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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 className="w-full bg-muted rounded-full h-4">
|
||||
<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}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -334,8 +334,8 @@ export default function AnalyticsPage() {
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{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 className="w-2 h-2 bg-blue-600 rounded-full mt-2 flex-shrink-0" />
|
||||
<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-primary rounded-full mt-2 flex-shrink-0" />
|
||||
<p className="text-sm text-foreground">{recommendation}</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -355,8 +355,8 @@ export default function AnalyticsPage() {
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{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 className="w-2 h-2 bg-orange-600 rounded-full mt-2 flex-shrink-0" />
|
||||
<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-muted-foreground rounded-full mt-2 flex-shrink-0" />
|
||||
<p className="text-sm text-foreground">{gap}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
export default function ChatPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, user, isLoading: authLoading } = useAuthStore();
|
||||
const { loadSessions, sessions } = useChatStore();
|
||||
const { loadSessions, sessions, selectSession, currentSession } = useChatStore();
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
@@ -26,7 +26,14 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}, [isAuthenticated, authLoading, isInitialized, router, loadSessions]);
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function CourseContentPage() {
|
||||
<>
|
||||
{/* 知识图谱视图 */}
|
||||
{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} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,59 +12,24 @@ import {
|
||||
ChevronRight,
|
||||
Clock,
|
||||
User,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import { forumAPI } from "@/lib/api";
|
||||
import type { ForumCategory, ForumPostSummary } from "@/types";
|
||||
|
||||
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||
default: <MessageSquare className="w-5 h-5" />,
|
||||
};
|
||||
import { format } from "date-fns";
|
||||
|
||||
const CATEGORY_ORDER = ["公告", "通知", "学习", "讨论", "课程", "反馈", "使用"];
|
||||
|
||||
function getCategoryIcon(name: string) {
|
||||
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("课程"))
|
||||
return <BookOpen className="w-5 h-5" />;
|
||||
return <BookOpen className="w-4 h-4" />;
|
||||
if (name.includes("反馈") || name.includes("使用"))
|
||||
return <Lightbulb className="w-5 h-5" />;
|
||||
return CATEGORY_ICONS.default;
|
||||
}
|
||||
|
||||
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" });
|
||||
return <Lightbulb className="w-4 h-4" />;
|
||||
return <MessageSquare className="w-4 h-4" />;
|
||||
}
|
||||
|
||||
export default function ForumHomePage() {
|
||||
@@ -74,17 +39,30 @@ export default function ForumHomePage() {
|
||||
>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
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(
|
||||
data.map(async (category) => {
|
||||
sorted.map(async (category) => {
|
||||
try {
|
||||
const posts = await forumAPI.getPosts(category.id, 3);
|
||||
const posts = await forumAPI.getPosts(category.id, 10);
|
||||
return [category.id, posts] as const;
|
||||
} catch {
|
||||
return [category.id, []] as const;
|
||||
@@ -105,80 +83,92 @@ export default function ForumHomePage() {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
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">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground py-20">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
正在加载社区...
|
||||
</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">
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{[...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;
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return CATEGORY_ORDER.length;
|
||||
};
|
||||
return getOrder(a.name) - getOrder(b.name);
|
||||
})
|
||||
.map((category) => {
|
||||
|
||||
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">
|
||||
<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 (
|
||||
<div
|
||||
<TabsContent
|
||||
key={category.id}
|
||||
className={`bg-gradient-to-r ${getCategoryGradient(
|
||||
category.name
|
||||
)} rounded-xl border border-border/40 overflow-hidden`}
|
||||
value={String(category.id)}
|
||||
className="mt-6"
|
||||
>
|
||||
{/* 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 */}
|
||||
{category.description && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{category.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
{/* 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="text-sm font-medium text-primary hover:underline flex items-center gap-0.5"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
查看全部
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
发表讨论
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Posts list */}
|
||||
<div className="divide-y divide-border/20">
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<div className="divide-y divide-border">
|
||||
{posts.length > 0 ? (
|
||||
posts.map((post) => (
|
||||
<Link
|
||||
key={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" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -189,7 +179,10 @@ export default function ForumHomePage() {
|
||||
<span>{post.author_name}</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(post.created_at)}
|
||||
{format(
|
||||
new Date(post.created_at),
|
||||
"yyyy/M/d HH:mm"
|
||||
)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<MessageCircle className="w-3 h-3" />
|
||||
@@ -201,16 +194,32 @@ export default function ForumHomePage() {
|
||||
</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>
|
||||
|
||||
{/* View all link */}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
Settings
|
||||
} from "lucide-react";
|
||||
import { formatFileSize, formatDate } from "@/lib/utils";
|
||||
import { knowledgeBaseAPI } from "@/lib/api";
|
||||
import { knowledgeBaseAPI, resolveImageUrl } from "@/lib/api";
|
||||
import { KnowledgeBaseDetail, Document } from "@/types";
|
||||
|
||||
export default function KnowledgeBaseDetailPage() {
|
||||
@@ -155,11 +155,28 @@ export default function KnowledgeBaseDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDocument = (doc: Document) => {
|
||||
// Use the file_path from the document to construct the download URL
|
||||
// Since backend serves static files from /uploads, we can use the file_path directly
|
||||
const fileUrl = `/api/uploads/${doc.filename}`;
|
||||
window.open(fileUrl, '_blank');
|
||||
const handleViewDocument = async (doc: Document) => {
|
||||
try {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const res = await fetch(resolveImageUrl(`/knowledge-bases/documents/${doc.id}/download`), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error("获取文档失败");
|
||||
const blob = await res.blob();
|
||||
|
||||
const disposition = res.headers.get("Content-Disposition");
|
||||
let filename = `${doc.title}${doc.file_type}`;
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename\*?=(?:UTF-8'')?(.+)/i);
|
||||
if (match) filename = decodeURIComponent(match[1].replace(/["']/g, ""));
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
} catch {
|
||||
window.open(resolveImageUrl(`/knowledge-bases/documents/${doc.id}/download`), '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredDocuments = knowledgeBase?.documents.filter(doc =>
|
||||
@@ -209,7 +226,7 @@ export default function KnowledgeBaseDetailPage() {
|
||||
返回
|
||||
</Button>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold mb-2">{knowledgeBase.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
@@ -262,7 +279,7 @@ export default function KnowledgeBaseDetailPage() {
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
上传文档
|
||||
</Button>
|
||||
@@ -302,17 +319,17 @@ export default function KnowledgeBaseDetailPage() {
|
||||
<div key={fileId} className="flex items-center justify-between p-2 border rounded-md">
|
||||
<div className="flex-1 min-w-0">
|
||||
<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-xs text-muted-foreground">
|
||||
({(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
</div>
|
||||
{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 && (
|
||||
<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
|
||||
className="bg-blue-600 h-1 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
@@ -320,7 +337,7 @@ export default function KnowledgeBaseDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
{progress === 100 && !error && (
|
||||
<div className="text-xs text-green-600 mt-1">✓ 上传完成</div>
|
||||
<div className="text-xs text-emerald-500 mt-1">✓ 上传完成</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
@@ -330,7 +347,7 @@ export default function KnowledgeBaseDetailPage() {
|
||||
setUploadFiles(prev => prev.filter((_, i) => i !== index));
|
||||
}}
|
||||
disabled={isUploading}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -398,7 +415,7 @@ export default function KnowledgeBaseDetailPage() {
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<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
|
||||
placeholder="搜索文档..."
|
||||
value={searchQuery}
|
||||
@@ -450,14 +467,14 @@ export default function KnowledgeBaseDetailPage() {
|
||||
<TableRow key={doc.id}>
|
||||
<TableCell>
|
||||
{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 className="font-medium">
|
||||
<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>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -480,7 +497,7 @@ export default function KnowledgeBaseDetailPage() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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" />
|
||||
</Button>
|
||||
|
||||
@@ -30,7 +30,7 @@ import { KnowledgeBase } from "@/types";
|
||||
|
||||
export default function KnowledgePage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||
const { isAuthenticated, isLoading: authLoading, user } = useAuthStore();
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -58,9 +58,7 @@ export default function KnowledgePage() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const bases = await knowledgeBaseAPI.getKnowledgeBases();
|
||||
// 过滤掉系统知识库,只显示用户创建的知识库
|
||||
const userBases = bases.filter(kb => !kb.is_system);
|
||||
setKnowledgeBases(userBases);
|
||||
setKnowledgeBases(bases);
|
||||
} catch (err) {
|
||||
console.error("加载知识库失败:", err);
|
||||
setError("加载知识库失败");
|
||||
@@ -114,6 +112,10 @@ export default function KnowledgePage() {
|
||||
(kb.description && kb.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
const isAdmin = user?.is_superuser === true;
|
||||
const systemKBs = isAdmin ? filteredKnowledgeBases.filter(kb => kb.is_system) : [];
|
||||
const userKBs = filteredKnowledgeBases.filter(kb => !kb.is_system);
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -142,7 +144,7 @@ export default function KnowledgePage() {
|
||||
{/* 创建知识库按钮 */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<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" />
|
||||
创建知识库
|
||||
</Button>
|
||||
@@ -204,7 +206,7 @@ export default function KnowledgePage() {
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<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
|
||||
placeholder="搜索知识库..."
|
||||
value={searchQuery}
|
||||
@@ -213,13 +215,80 @@ export default function KnowledgePage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {filteredKnowledgeBases.length} 个知识库
|
||||
共 {userKBs.length} 个知识库{systemKBs.length > 0 ? `,${systemKBs.length} 个系统知识库` : ""}
|
||||
</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">
|
||||
<CardContent className="text-center py-12">
|
||||
<BookOpen className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
@@ -238,13 +307,22 @@ export default function KnowledgePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
userKBs.length > 0 && (
|
||||
<div>
|
||||
{systemKBs.length > 0 && (
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<BookOpen className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">我的知识库</h2>
|
||||
<span className="text-sm text-muted-foreground">({userKBs.length})</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredKnowledgeBases.map((kb) => (
|
||||
{userKBs.map((kb) => (
|
||||
<Card key={kb.id} className="hover:shadow-lg transition-shadow backdrop-blur-sm bg-card/80 border-border/50">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<BookOpen className="w-5 h-5 text-blue-600" />
|
||||
<BookOpen className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">{kb.name}</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
@@ -254,9 +332,9 @@ export default function KnowledgePage() {
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{kb.document_count > 0 ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,7 +351,7 @@ export default function KnowledgePage() {
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>状态</span>
|
||||
<span className={kb.document_count > 0 ? "text-green-600" : "text-gray-500"}>
|
||||
<span className={kb.document_count > 0 ? "text-emerald-500" : "text-muted-foreground"}>
|
||||
{kb.document_count > 0 ? "已就绪" : "空知识库"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -293,7 +371,7 @@ export default function KnowledgePage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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" />
|
||||
</Button>
|
||||
@@ -302,6 +380,8 @@ export default function KnowledgePage() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,25 +7,18 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import MobileNav from "@/components/layout/mobile-nav";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Loader2,
|
||||
User as UserIcon,
|
||||
Mail,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Edit2,
|
||||
Save,
|
||||
X,
|
||||
MessageSquare,
|
||||
Database,
|
||||
FileText
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { authAPI, analyticsAPI } from "@/lib/api";
|
||||
import { authAPI } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
const profileSchema = z.object({
|
||||
@@ -43,12 +36,6 @@ export default function ProfilePage() {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = 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 {
|
||||
register,
|
||||
@@ -69,10 +56,7 @@ export default function ProfilePage() {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
loadUserData();
|
||||
}
|
||||
if (isAuthenticated) loadUserData();
|
||||
}, [isAuthenticated, authLoading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,24 +70,8 @@ export default function ProfilePage() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 加载用户信息
|
||||
const userData = await authAPI.getCurrentUser();
|
||||
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) {
|
||||
console.error("加载用户信息失败:", err);
|
||||
setError("加载用户信息失败");
|
||||
@@ -117,17 +85,13 @@ export default function ProfilePage() {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
const updatedUser = await authAPI.updateUserInfo({
|
||||
email: data.email,
|
||||
full_name: data.full_name || undefined,
|
||||
});
|
||||
|
||||
setUser(updatedUser);
|
||||
setIsEditing(false);
|
||||
setSuccess("个人信息更新成功");
|
||||
|
||||
// 3秒后清除成功消息
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err: any) {
|
||||
console.error("更新用户信息失败:", err);
|
||||
@@ -155,235 +119,119 @@ export default function ProfilePage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return null;
|
||||
}
|
||||
if (!isAuthenticated || !user) return null;
|
||||
|
||||
const initials = (user.full_name || user.username || "U").charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app pb-16 lg:pb-0">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 错误提示 */}
|
||||
<div className="min-h-screen bg-background pb-16 lg:pb-0">
|
||||
<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>
|
||||
<div className="mb-6 p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* 成功提示 */}
|
||||
{success && (
|
||||
<Alert className="mb-6 border-green-500 bg-green-50 dark:bg-green-950">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<AlertDescription className="text-green-800 dark:text-green-200">
|
||||
{success}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<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" />{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 左侧:用户头像和基本信息 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* 用户头像卡片 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>头像</CardTitle>
|
||||
<CardDescription>您的账户头像</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center space-x-6">
|
||||
<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">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
当前使用用户名首字母作为头像
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
未来版本将支持上传自定义头像
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 基本信息卡片 */}
|
||||
<div className="space-y-6">
|
||||
{/* 个人信息卡片 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>基本信息</CardTitle>
|
||||
<CardDescription>您的账户基本信息</CardDescription>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
<CardTitle>个人信息</CardTitle>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
<Edit2 className="w-3.5 h-3.5" />编辑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>管理您的账户基本信息</CardDescription>
|
||||
</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 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 htmlFor="email">邮箱</Label>
|
||||
<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>
|
||||
<Label className="text-sm text-muted-foreground">注册时间</Label>
|
||||
<Input value={formatDate(user.created_at)} disabled className="mt-1.5 bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-sm text-muted-foreground">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register("email")}
|
||||
disabled={!isEditing}
|
||||
className={errors.email ? "border-red-500" : ""}
|
||||
className={`mt-1.5 ${errors.email ? "border-destructive" : ""}`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
{errors.email && <p className="text-xs text-destructive mt-1">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* 真实姓名(可编辑) */}
|
||||
<div>
|
||||
<Label htmlFor="full_name">真实姓名</Label>
|
||||
<Label htmlFor="full_name" className="text-sm text-muted-foreground">真实姓名</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
{...register("full_name")}
|
||||
disabled={!isEditing}
|
||||
placeholder="请输入您的真实姓名(可选)"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 注册时间(只读) */}
|
||||
<div>
|
||||
<Label htmlFor="created_at">注册时间</Label>
|
||||
<Input
|
||||
id="created_at"
|
||||
value={formatDate(user.created_at)}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 账户状态(只读) */}
|
||||
<div>
|
||||
<Label htmlFor="is_active">账户状态</Label>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Input
|
||||
id="is_active"
|
||||
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
|
||||
<div className="flex items-center gap-3 pt-4 border-t">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
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"
|
||||
>
|
||||
{isSaving ? <><Loader2 className="w-4 h-4 animate-spin" />保存中...</> : <><Save className="w-4 h-4" />保存</>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
<X className="w-4 h-4" />取消
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端导航 */}
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
// 禁用静态生成
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -10,15 +9,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import MobileNav from "@/components/layout/mobile-nav";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import {
|
||||
Loader2,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle,
|
||||
@@ -39,17 +35,8 @@ const passwordSchema = z.object({
|
||||
|
||||
type PasswordForm = z.infer<typeof passwordSchema>;
|
||||
|
||||
// 密码强度检查函数
|
||||
function getPasswordStrength(password: string): {
|
||||
strength: "weak" | "medium" | "strong";
|
||||
label: string;
|
||||
color: string;
|
||||
score: number;
|
||||
} {
|
||||
if (!password) {
|
||||
return { strength: "weak", label: "", color: "", score: 0 };
|
||||
}
|
||||
|
||||
function getPasswordStrength(password: string) {
|
||||
if (!password) return { label: "", color: "", score: 0 };
|
||||
let score = 0;
|
||||
if (password.length >= 6) score++;
|
||||
if (password.length >= 8) score++;
|
||||
@@ -58,13 +45,9 @@ function getPasswordStrength(password: string): {
|
||||
if (/[0-9]/.test(password)) score++;
|
||||
if (/[^a-zA-Z0-9]/.test(password)) score++;
|
||||
|
||||
if (score <= 2) {
|
||||
return { strength: "weak", label: "弱", color: "text-red-600", score };
|
||||
} else if (score <= 4) {
|
||||
return { strength: "medium", label: "中", color: "text-yellow-600", score };
|
||||
} else {
|
||||
return { strength: "strong", label: "强", color: "text-green-600", score };
|
||||
}
|
||||
if (score <= 2) return { label: "弱", color: "text-destructive", score };
|
||||
if (score <= 4) return { label: "中", color: "text-amber-500", score };
|
||||
return { label: "强", color: "text-emerald-500", score };
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -95,16 +78,12 @@ export default function SettingsPage() {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
await authAPI.changePassword({
|
||||
old_password: data.old_password,
|
||||
new_password: data.new_password,
|
||||
});
|
||||
|
||||
setSuccess("密码修改成功");
|
||||
reset();
|
||||
|
||||
// 3秒后清除成功消息
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err: any) {
|
||||
console.error("修改密码失败:", err);
|
||||
@@ -128,179 +107,128 @@ export default function SettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app pb-16 lg:pb-0">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="min-h-screen bg-background pb-16 lg:pb-0">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
{/* 成功提示 */}
|
||||
{/* 状态提示 */}
|
||||
{error && (
|
||||
<div className="mb-6 p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<Alert className="mb-6 border-green-500 bg-green-50 dark:bg-green-950">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<AlertDescription className="text-green-800 dark:text-green-200">
|
||||
<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" />
|
||||
{success}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 账户安全 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Shield className="w-5 h-5 text-blue-600" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
<CardTitle>账户安全</CardTitle>
|
||||
</div>
|
||||
<CardDescription>修改您的登录密码</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* 当前密码 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label htmlFor="old_password">当前密码</Label>
|
||||
<div className="relative">
|
||||
<Label htmlFor="old_password" className="text-sm text-muted-foreground">当前密码</Label>
|
||||
<div className="relative mt-1.5">
|
||||
<Input
|
||||
id="old_password"
|
||||
type={showOldPassword ? "text" : "password"}
|
||||
{...register("old_password")}
|
||||
className={errors.old_password ? "border-red-500" : ""}
|
||||
className={errors.old_password ? "border-destructive" : ""}
|
||||
placeholder="请输入当前密码"
|
||||
/>
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
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>
|
||||
)}
|
||||
{errors.old_password && <p className="text-xs text-destructive mt-1">{errors.old_password.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* 新密码 */}
|
||||
<div>
|
||||
<Label htmlFor="new_password">新密码</Label>
|
||||
<div className="relative">
|
||||
<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-red-500" : ""}
|
||||
className={errors.new_password ? "border-destructive" : ""}
|
||||
placeholder="请输入新密码(至少6个字符)"
|
||||
/>
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
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>
|
||||
{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">
|
||||
<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>
|
||||
<span className={`text-xs font-medium ${passwordStrength.color}`}>{passwordStrength.label}</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
<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"
|
||||
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-red-500 mt-1">
|
||||
{errors.new_password.message}
|
||||
</p>
|
||||
)}
|
||||
{errors.new_password && <p className="text-xs text-destructive mt-1">{errors.new_password.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 确认新密码 */}
|
||||
<div>
|
||||
<Label htmlFor="confirm_password">确认新密码</Label>
|
||||
<div className="relative">
|
||||
<div className="max-w-md">
|
||||
<Label htmlFor="confirm_password" className="text-sm text-muted-foreground">确认新密码</Label>
|
||||
<div className="relative mt-1.5">
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
{...register("confirm_password")}
|
||||
className={errors.confirm_password ? "border-red-500" : ""}
|
||||
className={errors.confirm_password ? "border-destructive" : ""}
|
||||
placeholder="请再次输入新密码"
|
||||
/>
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
{showConfirmPassword ? <EyeOff className="w-4 h-4 text-muted-foreground" /> : <Eye className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirm_password && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.confirm_password.message}
|
||||
</p>
|
||||
)}
|
||||
{errors.confirm_password && <p className="text-xs text-destructive mt-1">{errors.confirm_password.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* 密码要求提示 */}
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-sm font-medium mb-2">密码要求:</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>• 至少6个字符</li>
|
||||
<li>• 建议包含大小写字母、数字和特殊字符</li>
|
||||
<li>• 避免使用常用密码或个人信息</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="pt-4">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<div className="pt-4 border-t">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
修改中...
|
||||
</>
|
||||
<><Loader2 className="w-4 h-4 animate-spin" />修改中...</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
修改密码
|
||||
</>
|
||||
<><Key className="w-4 h-4" />修改密码</>
|
||||
)}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -309,43 +237,25 @@ export default function SettingsPage() {
|
||||
{/* 偏好设置 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Palette className="w-5 h-5 text-purple-600" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-5 h-5 text-primary" />
|
||||
<CardTitle>偏好设置</CardTitle>
|
||||
</div>
|
||||
<CardDescription>自定义您的使用偏好</CardDescription>
|
||||
<CardDescription>自定义您的使用体验</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 主题设置 */}
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
<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 className="flex items-center justify-between py-3 border-b last:border-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium">外观主题</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">切换浅色或深色模式</p>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* 未来可添加其他偏好设置 */}
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
更多偏好设置功能即将推出
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端导航 */}
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Loader2, Download, Copy, Upload, Edit3, Wand2, Expand, Palette, Image as ImageIcon, X } from "lucide-react";
|
||||
import { imageAPI } from "@/lib/api";
|
||||
import { imageAPI, resolveImageUrl } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface EditResult {
|
||||
@@ -312,14 +312,22 @@ export default function ImageToImagePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (imageUrl: string, imageId: string) => {
|
||||
const handleDownload = async (imageUrl: string, imageId: string) => {
|
||||
try {
|
||||
const res = await fetch(imageUrl);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = imageUrl;
|
||||
link.href = url;
|
||||
link.download = `edited-image-${imageId}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("图像下载成功");
|
||||
} catch {
|
||||
toast.error("图像下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -595,7 +603,7 @@ export default function ImageToImagePage() {
|
||||
<div className="space-y-4">
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={editResult.url}
|
||||
src={resolveImageUrl(editResult.url)}
|
||||
alt="Edited image"
|
||||
className="w-full h-64 object-cover rounded-lg"
|
||||
/>
|
||||
@@ -604,7 +612,7 @@ export default function ImageToImagePage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleDownload(editResult.url, editResult.id)}
|
||||
onClick={() => handleDownload(resolveImageUrl(editResult.url), editResult.id)}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -648,7 +656,7 @@ export default function ImageToImagePage() {
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={variation.url}
|
||||
src={resolveImageUrl(variation.url)}
|
||||
alt={`Variation ${index + 1}`}
|
||||
className="w-full h-48 object-cover rounded-lg"
|
||||
/>
|
||||
@@ -657,7 +665,7 @@ export default function ImageToImagePage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleDownload(variation.url, variation.id)}
|
||||
onClick={() => handleDownload(resolveImageUrl(variation.url), variation.id)}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
X
|
||||
} from "lucide-react";
|
||||
import LoadingSpinner from "@/components/ui/loading-spinner";
|
||||
import { imageAPI } from "@/lib/api";
|
||||
import { imageAPI, resolveImageUrl } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
@@ -350,14 +350,22 @@ export default function SpatialPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (imageUrl: string, imageId: string) => {
|
||||
const handleDownload = async (imageUrl: string, imageId: string) => {
|
||||
try {
|
||||
const res = await fetch(imageUrl);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = imageUrl;
|
||||
link.href = url;
|
||||
link.download = `image-${imageId}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("图像下载成功");
|
||||
} catch {
|
||||
toast.error("图像下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
@@ -580,7 +588,7 @@ export default function SpatialPage() {
|
||||
<Card className="overflow-hidden">
|
||||
<div className="aspect-square relative bg-muted">
|
||||
<img
|
||||
src={image.url}
|
||||
src={resolveImageUrl(image.url)}
|
||||
alt={`Generated image ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
@@ -599,7 +607,7 @@ export default function SpatialPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleDownload(image.url, image.id)}
|
||||
onClick={() => handleDownload(resolveImageUrl(image.url), image.id)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载图片
|
||||
@@ -852,7 +860,7 @@ export default function SpatialPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="relative group bg-muted rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={editResult.url}
|
||||
src={resolveImageUrl(editResult.url)}
|
||||
alt="Edited image"
|
||||
className="w-full h-64 object-contain"
|
||||
loading="lazy"
|
||||
@@ -871,7 +879,7 @@ export default function SpatialPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleDownload(editResult.url, editResult.id)}
|
||||
onClick={() => handleDownload(resolveImageUrl(editResult.url), editResult.id)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载图片
|
||||
@@ -907,7 +915,7 @@ export default function SpatialPage() {
|
||||
<Card className="overflow-hidden">
|
||||
<div className="relative bg-muted">
|
||||
<img
|
||||
src={variation.url}
|
||||
src={resolveImageUrl(variation.url)}
|
||||
alt={`Variation ${index + 1}`}
|
||||
className="w-full h-48 object-cover"
|
||||
loading="lazy"
|
||||
@@ -922,7 +930,7 @@ export default function SpatialPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleDownload(variation.url, variation.id)}
|
||||
onClick={() => handleDownload(resolveImageUrl(variation.url), variation.id)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载图片
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Loader2, Download, Copy, RefreshCw, Sparkles, Image as ImageIcon } from "lucide-react";
|
||||
import { imageAPI } from "@/lib/api";
|
||||
import { imageAPI, resolveImageUrl } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface GeneratedImage {
|
||||
@@ -95,14 +95,22 @@ export default function TextToImagePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (imageUrl: string, imageId: string) => {
|
||||
const handleDownload = async (imageUrl: string, imageId: string) => {
|
||||
try {
|
||||
const res = await fetch(imageUrl);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = imageUrl;
|
||||
link.href = url;
|
||||
link.download = `generated-image-${imageId}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("图像下载成功");
|
||||
} catch {
|
||||
toast.error("图像下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyPrompt = (prompt: string) => {
|
||||
@@ -120,7 +128,7 @@ export default function TextToImagePage() {
|
||||
transition={{ duration: 0.5 }}
|
||||
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
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
@@ -316,7 +324,7 @@ export default function TextToImagePage() {
|
||||
<Card className="overflow-hidden">
|
||||
<div className="aspect-square relative">
|
||||
<img
|
||||
src={image.url}
|
||||
src={resolveImageUrl(image.url)}
|
||||
alt={`Generated image ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -326,7 +334,7 @@ export default function TextToImagePage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleDownload(image.url, image.id)}
|
||||
onClick={() => handleDownload(resolveImageUrl(image.url), image.id)}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
@@ -5,7 +5,6 @@ import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { forumAPI } from "@/lib/api";
|
||||
import type { ForumCategory, ForumPostSummary } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@@ -19,22 +18,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
|
||||
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" });
|
||||
}
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function ForumCategoryPage() {
|
||||
const params = useParams();
|
||||
@@ -118,7 +102,7 @@ export default function ForumCategoryPage() {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<button
|
||||
onClick={() => router.push("/forum")}
|
||||
@@ -144,20 +128,19 @@ export default function ForumCategoryPage() {
|
||||
)}
|
||||
</div>
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
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"
|
||||
>
|
||||
<PenLine className="w-3.5 h-3.5" />
|
||||
{showForm ? "收起" : "发帖"}
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New post form (collapsible) */}
|
||||
{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">
|
||||
{submitError && (
|
||||
<p className="text-sm text-destructive">{submitError}</p>
|
||||
@@ -179,11 +162,10 @@ export default function ForumCategoryPage() {
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
<button
|
||||
type="submit"
|
||||
size="sm"
|
||||
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 ? (
|
||||
<>
|
||||
@@ -196,7 +178,7 @@ export default function ForumCategoryPage() {
|
||||
发布
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -204,7 +186,7 @@ export default function ForumCategoryPage() {
|
||||
|
||||
{/* Login prompt */}
|
||||
{!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">
|
||||
登录
|
||||
@@ -235,14 +217,14 @@ export default function ForumCategoryPage() {
|
||||
<p className="text-muted-foreground">暂无帖子,来发第一个帖吧</p>
|
||||
</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) => (
|
||||
<Link
|
||||
key={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" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -253,7 +235,7 @@ export default function ForumCategoryPage() {
|
||||
<span>{post.author_name}</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(post.created_at)}
|
||||
{format(new Date(post.created_at), "yyyy/M/d HH:mm")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<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 { useAuthStore } from "@/store/auth";
|
||||
|
||||
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" });
|
||||
}
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function ForumPostPage() {
|
||||
const params = useParams();
|
||||
@@ -90,7 +75,8 @@ export default function ForumPostPage() {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
@@ -112,8 +98,8 @@ export default function ForumPostPage() {
|
||||
) : post ? (
|
||||
<div className="space-y-6">
|
||||
{/* Post content */}
|
||||
<article className="rounded-xl border border-border/40 overflow-hidden">
|
||||
<div className="px-6 py-5 border-b border-border/20">
|
||||
<article className="rounded-xl border border-border overflow-hidden">
|
||||
<div className="px-6 py-5 border-b border-border">
|
||||
<h1
|
||||
className="text-xl font-bold tracking-tight leading-snug"
|
||||
style={{ fontFamily: "var(--font-serif), Georgia, serif" }}
|
||||
@@ -122,14 +108,14 @@ export default function ForumPostPage() {
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mt-3 text-sm text-muted-foreground">
|
||||
<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" />
|
||||
</div>
|
||||
<span>{post.author_name}</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(post.created_at)}
|
||||
{format(new Date(post.created_at), "yyyy/M/d HH:mm")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,7 +134,7 @@ export default function ForumPostPage() {
|
||||
</div>
|
||||
|
||||
{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>
|
||||
) : (
|
||||
@@ -156,9 +142,9 @@ export default function ForumPostPage() {
|
||||
{post.replies.map((reply) => (
|
||||
<div
|
||||
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" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -167,7 +153,7 @@ export default function ForumPostPage() {
|
||||
{reply.author_name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(reply.created_at)}
|
||||
{format(new Date(reply.created_at), "yyyy/M/d HH:mm")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
|
||||
@@ -181,7 +167,7 @@ export default function ForumPostPage() {
|
||||
</section>
|
||||
|
||||
{/* Reply form */}
|
||||
<div className="rounded-xl border border-border/40 p-5">
|
||||
<div className="rounded-xl border border-border p-5">
|
||||
{isAuthenticated ? (
|
||||
<form onSubmit={handleReply} className="space-y-3">
|
||||
{submitError && (
|
||||
@@ -199,7 +185,7 @@ export default function ForumPostPage() {
|
||||
<button
|
||||
type="submit"
|
||||
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 ? (
|
||||
<>
|
||||
@@ -238,5 +224,6 @@ export default function ForumPostPage() {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,6 +378,39 @@ button {
|
||||
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) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2563eb"/>
|
||||
<text x="16" y="22" font-family="Arial,sans-serif" font-size="18" font-weight="bold" fill="white" text-anchor="middle">国</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 255 B |
@@ -15,7 +15,7 @@ import { knowledgeBaseAPI } from "@/lib/api";
|
||||
export default function ChatInterface() {
|
||||
const [inputMessage, setInputMessage] = useState("");
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState("deepseek-chat");
|
||||
const [selectedModel, setSelectedModel] = useState("deepseek-reasoner");
|
||||
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<string[]>([]);
|
||||
const [systemKnowledgeBases, setSystemKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [userKnowledgeBases, setUserKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
@@ -188,7 +188,7 @@ export default function ChatInterface() {
|
||||
"transition-all duration-150",
|
||||
isStreaming
|
||||
? "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 ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Download, FileText, FileJson, File } from "lucide-react";
|
||||
import { Download, FileText, FileJson } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -61,12 +61,6 @@ export default function ExportDialog({ sessionId, sessionTitle, children, onClos
|
||||
description: "可读性好的文本格式,适合分享",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
value: "pdf",
|
||||
label: "PDF 格式",
|
||||
description: "适合打印和正式文档",
|
||||
icon: File,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"use client";
|
||||
|
||||
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 ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { format } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useChatStore } from "@/store/chat";
|
||||
import SourceReferences from "./source-references";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface MessageItemProps {
|
||||
@@ -21,32 +22,37 @@ interface MessageItemProps {
|
||||
|
||||
const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[]; isStreaming?: boolean }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!thinking || thinking.length === 0) return null;
|
||||
|
||||
// 分离状态步骤和推理内容
|
||||
const statusSteps = 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 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 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 collapsedTitle = isStreaming
|
||||
? isReasoningNow
|
||||
? '深度思考中...'
|
||||
: lastStatusStep?.message || '思考中...'
|
||||
? isReasoningNow ? '思考中...' : '检索中...'
|
||||
: hasReasoning
|
||||
? `思考过程 (${reasoningText.length} 字)`
|
||||
: `思考过程${totalTime > 0 ? ` (${totalTime.toFixed(1)}s)` : ''}`;
|
||||
: retrievedStep
|
||||
? `检索到 ${retrievedStep.doc_count} 篇相关文档`
|
||||
: '思考过程';
|
||||
|
||||
return (
|
||||
<div className="mb-2.5">
|
||||
@@ -62,37 +68,14 @@ const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[];
|
||||
</svg>
|
||||
<Brain className={cn("h-3 w-3", isReasoningNow && "animate-pulse text-primary")} />
|
||||
<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>
|
||||
{expanded && (
|
||||
<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 && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{hasDocs && (
|
||||
<div className="space-y-1">
|
||||
<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">
|
||||
{detail}
|
||||
</div>
|
||||
@@ -103,9 +86,6 @@ const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[];
|
||||
{/* 推理内容 */}
|
||||
{hasReasoning && (
|
||||
<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">
|
||||
{reasoningText}
|
||||
</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) {
|
||||
const isUser = message.role === "user";
|
||||
const isAssistant = message.role === "assistant";
|
||||
@@ -126,7 +113,18 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
} else {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = message.content;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
toast.success("已复制");
|
||||
} catch {
|
||||
toast.error("复制失败");
|
||||
@@ -211,7 +209,33 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
|
||||
)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
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) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
@@ -239,15 +263,35 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
|
||||
td: ({ children }) => (
|
||||
<td className="border border-border px-2 py-1.5">{children}</td>
|
||||
),
|
||||
img: ({ src, alt }: any) => {
|
||||
const resolvedSrc = src && src.startsWith("/")
|
||||
? `${process.env.NEXT_PUBLIC_API_URL || `${window.location.protocol}//${window.location.hostname}:8002`}${src}`
|
||||
: src;
|
||||
return (
|
||||
<a href={resolvedSrc} target="_blank" rel="noopener noreferrer" className="block my-3 group">
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{isAssistant && message.metadata?.sources && message.metadata.sources.length > 0 && (
|
||||
<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>
|
||||
@@ -280,7 +324,7 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground/50 mx-1">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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";
|
||||
|
||||
export interface ModelOption {
|
||||
@@ -29,19 +28,7 @@ interface ModelSelectorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const modelGroups = [
|
||||
{
|
||||
provider: "deepseek-official",
|
||||
label: "DeepSeek 官方",
|
||||
models: [
|
||||
{
|
||||
id: "deepseek-chat",
|
||||
name: "DeepSeek-V3",
|
||||
description: "DeepSeek 最新通用模型,速度快、能力强",
|
||||
provider: "deepseek-official",
|
||||
providerLabel: "DeepSeek",
|
||||
icon: Sparkles,
|
||||
},
|
||||
const models: ModelOption[] = [
|
||||
{
|
||||
id: "deepseek-reasoner",
|
||||
name: "DeepSeek-R1",
|
||||
@@ -50,34 +37,16 @@ const modelGroups = [
|
||||
providerLabel: "DeepSeek",
|
||||
icon: Brain,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "siliconflow",
|
||||
label: "SiliconFlow(硅基流动)",
|
||||
models: [
|
||||
{
|
||||
id: "deepseek-ai/DeepSeek-V3",
|
||||
id: "deepseek-chat",
|
||||
name: "DeepSeek-V3",
|
||||
description: "通过硅基流动调用,稳定的推理能力",
|
||||
provider: "siliconflow",
|
||||
providerLabel: "硅基流动",
|
||||
description: "通用模型,速度快、能力强",
|
||||
provider: "deepseek-official",
|
||||
providerLabel: "DeepSeek",
|
||||
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({
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
@@ -86,7 +55,7 @@ export default function ModelSelector({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const selectedModelData =
|
||||
allModels.find((model) => model.id === selectedModel) || allModels[0];
|
||||
models.find((model) => model.id === selectedModel) || models[0];
|
||||
const IconComponent = selectedModelData.icon || Cpu;
|
||||
|
||||
return (
|
||||
@@ -107,17 +76,12 @@ export default function ModelSelector({
|
||||
<ChevronDown className="w-3 h-3 ml-1 opacity-50" />
|
||||
</Button>
|
||||
</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">
|
||||
选择AI模型
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{modelGroups.map((group) => (
|
||||
<div key={group.provider}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground/70 font-normal px-2 pt-2">
|
||||
{group.label}
|
||||
</DropdownMenuLabel>
|
||||
{group.models.map((model) => {
|
||||
{models.map((model) => {
|
||||
const ModelIcon = model.icon || Cpu;
|
||||
const isSelected = model.id === selectedModel;
|
||||
|
||||
@@ -150,8 +114,6 @@ export default function ModelSelector({
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,15 @@ import {
|
||||
Trash2,
|
||||
Download,
|
||||
Search,
|
||||
MoreVertical,
|
||||
PanelLeftClose,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -109,15 +117,18 @@ export default function Sidebar({ onClose }: SidebarProps) {
|
||||
const groups = groupSessionsByDate(filteredSessions());
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<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">
|
||||
<span className="text-sm font-semibold text-foreground">对话</span>
|
||||
<Button onClick={handleNewChat} size="sm" variant="ghost" className="h-7 w-7 p-0 hover:bg-primary/10 hover:text-primary">
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
title="收起侧边栏"
|
||||
>
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
@@ -130,9 +141,9 @@ export default function Sidebar({ onClose }: SidebarProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session list with date groups */}
|
||||
{/* Session list */}
|
||||
<ScrollArea className="flex-1 px-2">
|
||||
<div className="pb-4">
|
||||
<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">
|
||||
@@ -142,46 +153,39 @@ export default function Sidebar({ onClose }: SidebarProps) {
|
||||
{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
|
||||
}}
|
||||
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)}
|
||||
>
|
||||
{/* 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>
|
||||
<span className="text-sm truncate min-w-0 w-0 flex-1">{session.title}</span>
|
||||
|
||||
{/* 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" />
|
||||
{/* 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>
|
||||
<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>
|
||||
<DropdownMenuItem onSelect={() => setDeleteSessionId(session.id)} className="text-destructive">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -198,7 +202,7 @@ export default function Sidebar({ onClose }: SidebarProps) {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer — new chat button with distinct background */}
|
||||
{/* Footer */}
|
||||
<div className="px-3 py-3 border-t border-border/30 bg-muted/20">
|
||||
<Button
|
||||
onClick={handleNewChat}
|
||||
@@ -209,7 +213,6 @@ export default function Sidebar({ onClose }: SidebarProps) {
|
||||
新对话
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rename dialog */}
|
||||
<Dialog open={!!editingSession} onOpenChange={() => { setEditingSession(null); setEditTitle(""); }}>
|
||||
@@ -222,7 +225,7 @@ export default function Sidebar({ onClose }: SidebarProps) {
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="对话名称"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && editTitle.trim()) handleSaveRename(); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && editTitle.trim()) handleSaveRename(); }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -246,6 +249,6 @@ export default function Sidebar({ onClose }: SidebarProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,116 +1,304 @@
|
||||
"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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SourceReference {
|
||||
id?: number;
|
||||
title: string;
|
||||
filename?: string;
|
||||
page?: number;
|
||||
score?: number;
|
||||
preview: string;
|
||||
url?: string;
|
||||
source_type?: "web" | "rag";
|
||||
source_type?: "web" | "rag" | "image";
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
interface SourceReferencesProps {
|
||||
sources: SourceReference[];
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displaySources = sources.slice(0, maxSources);
|
||||
const ragSources = displaySources.filter(s => s.source_type !== "web");
|
||||
const webSources = displaySources.filter(s => s.source_type === "web");
|
||||
const showExpandButton = sources.length > maxSources;
|
||||
const visibleSources = expanded ? sources : displaySources;
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
const ragSources = visibleSources.filter((s) => s.source_type !== "web" && s.source_type !== "image");
|
||||
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 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600">
|
||||
<Database className="h-4 w-4" />
|
||||
<span>知识库来源 ({ragSources.length})</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{ragSources.map((source, index) => (
|
||||
<Card key={index} className="border border-blue-200 bg-blue-50/30 hover:border-blue-300 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
||||
{source.title}
|
||||
</CardTitle>
|
||||
{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 className="text-xs text-gray-500">
|
||||
{(source.score * 100).toFixed(1)}%
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
|
||||
<Database className="h-3 w-3 text-blue-500" />
|
||||
<span>
|
||||
知识库来源 ({ragSources.length}
|
||||
{expanded && showExpandButton
|
||||
? `/${sources.filter((s) => s.source_type !== "web").length}`
|
||||
: ""}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/30">
|
||||
{ragSources.map((source, i) => (
|
||||
<SourceRow
|
||||
key={i}
|
||||
source={source}
|
||||
answerContent={answerContent}
|
||||
messageId={messageId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageSources.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
|
||||
<Image className="h-3 w-3 text-purple-500" />
|
||||
<span>
|
||||
图片来源 ({imageSources.length}
|
||||
{expanded && showExpandButton
|
||||
? `/${sources.filter((s) => s.source_type === "image").length}`
|
||||
: ""}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{source.filename}
|
||||
{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 className="divide-y divide-border/30">
|
||||
{imageSources.map((source, i) => (
|
||||
<SourceRow
|
||||
key={i}
|
||||
source={source}
|
||||
answerContent={answerContent}
|
||||
messageId={messageId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSources.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>网络来源 ({webSources.length})</span>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
|
||||
<Globe className="h-3 w-3 text-green-500" />
|
||||
<span>
|
||||
网络来源 ({webSources.length}
|
||||
{expanded && showExpandButton
|
||||
? `/${sources.filter((s) => s.source_type === "web").length}`
|
||||
: ""}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{webSources.map((source, index) => (
|
||||
<Card key={index} className="border border-green-200 bg-green-50/30 hover:border-green-300 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
||||
{source.title}
|
||||
</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 className="divide-y divide-border/30">
|
||||
{webSources.map((source, i) => (
|
||||
<SourceRow
|
||||
key={i}
|
||||
source={source}
|
||||
answerContent={answerContent}
|
||||
messageId={messageId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sources.length > maxSources && (
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
还有 {sources.length - maxSources} 个相关来源
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
{expanded ? (
|
||||
<div className="max-h-96 overflow-y-auto pr-1">
|
||||
{sourceSection}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
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";
|
||||
|
||||
interface NavbarProps {
|
||||
@@ -20,7 +20,8 @@ const NAV_LINKS = [
|
||||
{ href: "/chat", label: "智能问答", icon: MessageSquare },
|
||||
{ href: "/knowledge", label: "知识库", icon: Database },
|
||||
{ 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) {
|
||||
@@ -54,7 +55,7 @@ export default function Navbar({ isAuthenticated, user }: NavbarProps) {
|
||||
{/* 导航链接 — 带底部下划线指示 */}
|
||||
{isAuthenticated && (
|
||||
<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);
|
||||
return (
|
||||
<Link
|
||||
@@ -79,28 +80,6 @@ export default function Navbar({ isAuthenticated, user }: NavbarProps) {
|
||||
</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">
|
||||
<ThemeToggle />
|
||||
|
||||
@@ -148,6 +127,14 @@ export default function Navbar({ isAuthenticated, user }: NavbarProps) {
|
||||
设置
|
||||
</Link>
|
||||
</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 />
|
||||
<DropdownMenuItem onClick={logout} className="text-destructive cursor-pointer">
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
|
||||
@@ -15,15 +15,16 @@ import {
|
||||
LogOut,
|
||||
GraduationCap,
|
||||
TrendingUp,
|
||||
Users
|
||||
Users,
|
||||
ShieldCheck
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ id: "course-content", name: "课程内容", icon: GraduationCap, href: "/course-content" },
|
||||
{ id: "chat", name: "对话", icon: MessageSquare, href: "/chat" },
|
||||
{ id: "knowledge", name: "知识库", icon: BookOpen, href: "/knowledge" },
|
||||
{ id: "spatial", name: "空间出图", icon: Image, href: "/spatial" },
|
||||
{ id: "forum", name: "社区", icon: Users, href: "/forum" },
|
||||
{ id: "course-content", name: "课程内容", icon: GraduationCap, href: "/course-content", requireAuth: true },
|
||||
{ id: "chat", name: "对话", icon: MessageSquare, href: "/chat", requireAuth: true },
|
||||
{ id: "knowledge", name: "知识库", icon: BookOpen, href: "/knowledge", requireAuth: true },
|
||||
{ id: "spatial", name: "空间出图", icon: Image, href: "/spatial", requireAuth: true },
|
||||
{ id: "forum", name: "社区", icon: Users, href: "/forum", requireAuth: true },
|
||||
];
|
||||
|
||||
export default function MobileNav() {
|
||||
@@ -45,10 +46,11 @@ export default function MobileNav() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 移动端导航栏 */}
|
||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 mobile-safe-area z-50">
|
||||
{/* Bottom navigation bar */}
|
||||
{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">
|
||||
{navItems.map((item) => {
|
||||
{navItems.filter(item => !item.requireAuth || user).map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Button
|
||||
@@ -57,7 +59,7 @@ export default function MobileNav() {
|
||||
size="sm"
|
||||
onClick={() => handleNavClick(item.href)}
|
||||
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" />
|
||||
@@ -67,52 +69,48 @@ export default function MobileNav() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 移动端菜单按钮 */}
|
||||
{/* Menu toggle button */}
|
||||
<div className="lg:hidden fixed top-4 right-4 z-50">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
<button
|
||||
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" />}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 移动端侧边菜单 */}
|
||||
{/* Side menu overlay */}
|
||||
{isOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-40">
|
||||
{/* 遮罩 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black bg-opacity-50"
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 菜单内容 */}
|
||||
<div className="absolute right-0 top-0 h-full w-80 max-w-[85vw] bg-white shadow-xl mobile-safe-area">
|
||||
<div className="absolute right-0 top-0 h-full w-80 max-w-[85vw] bg-background shadow-xl mobile-safe-area">
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 用户信息 */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
{/* User info */}
|
||||
<div className="p-6 border-b border-border">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<User className="w-6 h-6 text-gray-600" />
|
||||
<div className="w-12 h-12 bg-muted rounded-full flex items-center justify-center">
|
||||
<User className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<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}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 p-6">
|
||||
<nav className="space-y-2">
|
||||
{navItems.map((item) => {
|
||||
{navItems.filter(item => !item.requireAuth || user).map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Button
|
||||
@@ -129,8 +127,18 @@ export default function MobileNav() {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="p-6 border-t border-gray-200 space-y-2">
|
||||
{/* Bottom actions */}
|
||||
<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
|
||||
variant="ghost"
|
||||
onClick={() => handleNavClick("/analytics")}
|
||||
@@ -150,7 +158,7 @@ export default function MobileNav() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
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" />
|
||||
退出登录
|
||||
@@ -163,9 +171,3 @@ export default function MobileNav() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+137
-1
@@ -12,7 +12,14 @@ import type {
|
||||
} from "@/types";
|
||||
|
||||
// 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}:8002` : "http://127.0.0.1:8002");
|
||||
|
||||
// 将后端返回的相对路径图片URL转为完整的后端地址
|
||||
export function resolveImageUrl(url: string): string {
|
||||
if (!url) return url;
|
||||
if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("data:")) return url;
|
||||
return `${API_BASE_URL}${url.startsWith("/") ? "" : "/"}${url}`;
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
async function apiRequest<T>(
|
||||
@@ -583,6 +590,9 @@ export const chatAPI = {
|
||||
} else if (data.type === "chunk") {
|
||||
console.log("[DEBUG-STREAM] 接收chunk:", 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") {
|
||||
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);
|
||||
@@ -1067,3 +1077,129 @@ export const bookAPI = {
|
||||
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");
|
||||
},
|
||||
};
|
||||
|
||||
+33
-1
@@ -91,7 +91,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
|
||||
// 选择会话
|
||||
selectSession: async (sessionId: number) => {
|
||||
set({ isLoading: true, error: null });
|
||||
set({ error: null });
|
||||
|
||||
try {
|
||||
const session = get().sessions.find(s => s.id === sessionId);
|
||||
@@ -309,6 +309,18 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
thinkingBuffer.push(step);
|
||||
scheduleFlush();
|
||||
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') {
|
||||
chunkCount++;
|
||||
totalChars += data.content.length;
|
||||
@@ -327,6 +339,15 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
scheduleFlush();
|
||||
},
|
||||
(_sessionId: number, messageId?: number, userMessageId?: number) => {
|
||||
// 刷出缓冲区中剩余的内容
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
if (contentBuffer) {
|
||||
flushBuffer();
|
||||
}
|
||||
|
||||
if (messageId) {
|
||||
set((state) => ({
|
||||
messages: state.messages.map(msg => {
|
||||
@@ -500,6 +521,17 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
regenThinkingBuffer.push(step);
|
||||
regenSchedule();
|
||||
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') {
|
||||
regenContentBuffer += data.content;
|
||||
regenSchedule();
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface User {
|
||||
email: string;
|
||||
full_name?: string;
|
||||
is_active: boolean;
|
||||
is_superuser?: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -93,13 +94,15 @@ export interface ChatMessage {
|
||||
}
|
||||
|
||||
export interface SourceInfo {
|
||||
id?: number;
|
||||
title: string;
|
||||
filename?: string;
|
||||
page?: number;
|
||||
score?: number;
|
||||
preview: string;
|
||||
url?: string;
|
||||
source_type?: "web" | "rag";
|
||||
source_type?: "web" | "rag" | "image";
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
|
||||
Reference in New Issue
Block a user