Compare commits

...

17 Commits

Author SHA1 Message Date
pengxiao 6395ee3b49 feat: replace pymupdf image extraction with pdf2image + Heron layout detection
PDF image extraction now renders pages with pdf2image, detects figure
regions using docling-layout-heron (RT-DETRv2), and crops only the
detected pictures. Removes full-page fallback for text-only pages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 22:38:31 +08:00
pengxiao bbf6b921af fix: adapt frontend image paths for backend port 8002 and use blob download
Backend port changed from 8000 to 8002 due to port conflict (frpc on Windows).
- Add resolveImageUrl() to convert relative image URLs to full backend URLs
- Fix all spatial page image src and download handlers to use backend address
- Fix chat message image rendering to use correct port
- Switch download to blob-based approach to support cross-origin file saving

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 20:52:48 +08:00
pengxiao a6987ff996 修正删除文档不能同时删除图片的bug 2026-06-02 19:31:28 +08:00
pengxiao 3250e87b30 fix: knowledge base name collision, orphan dirs, and document download
- Name uniqueness check scoped to user domain (system KBs no longer block user KBs)
- Delete KB now cleans up its directory with shutil.rmtree
- User KB uploads saved to knowledge_bases/{kb_name}/ subdirectory
- New /documents/{id}/download endpoint with auth-aware FileResponse
- Frontend uses fetch+token instead of direct window.open for downloads

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:30:23 +08:00
pengxiao 5f768e5c83 chore: stop tracking tsconfig.tsbuildinfo
Already covered by *.tsbuildinfo in .gitignore but was tracked before
the rule was added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 10:00:13 +08:00
pengxiao 01a1c55de8 fix: resolve image 404 and add favicon
Markdown image src with relative paths (/images/...) now resolves to
backend API URL instead of frontend port. Added SVG favicon to eliminate
404 on /favicon.ico.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:57:26 +08:00
pengxiao 1a3730c33c docs: rewrite README to reflect current architecture and features
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:20:57 +08:00
pengxiao b8253b86a0 feat: global exception handler, concurrent VLM, and docker security
- Add global FastAPI exception handler for unhandled errors
- Add get_current_user_obj() dependency for cleaner auth patterns
- Switch VLM image description from serial to asyncio.gather concurrency
- Extract score conversion to shared score_utils module
- Docker: use env vars for passwords, remove hardcoded API key default
- Add .gitattributes and update .gitignore for tar.gz and tsbuildinfo

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:53:18 +08:00
pengxiao 37c0364e7e refactor: replace print() with structured logging across backend
Replace all print()/stderr logging with Python logging module using
logger = logging.getLogger(__name__) pattern for consistent log levels
and formatting. Extract score conversion to shared score_utils module.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:52:44 +08:00
pengxiao 4bb50ae9c1 feat: multimodal RAG with PDF image extraction and display
Extract images from PDFs using pymupdf, generate descriptions via
Qwen3-VL-8B, store in ChromaDB alongside text chunks, and render
images in chat answers. Includes image proxy rewrite, force re-process
endpoint, and VLM API timeout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:14:40 +08:00
pengxiao 3f12e96ea0 fix: use hostname:8000 as API fallback for remote deployments
Replace hardcoded localhost:8000 with window.location.hostname:8000 so the
frontend auto-detects the correct backend address when deployed on a remote
server instead of always pointing to localhost.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:22:46 +08:00
pengxiao b8561e04c6 fix: source list scroll, citation targeting, and page UI polish
- Replace ScrollArea with native overflow-y-auto for reliable mouse wheel scrolling
- Scope source DOM IDs by message ID to fix cross-round citation jumps
- Improve profile, settings, forum, knowledge page layouts
- Update mobile navigation and chat interface

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:05:08 +08:00
pengxiao abcced35b8 feat: RAG inline citations, source highlighting, and admin panel
- Increase RAG retrieval from 5 to 50 docs with relevance threshold (0.15)
- LLM inline citations with [来源N] format and reference list
- Clickable citation links scroll to source cards with highlight animation
- Source previews with full chunk text and answer-matched highlighting
- Message-scoped source IDs to fix cross-round citation targeting
- Admin panel pages (knowledge, users, forum, course, settings)
- Add rehype-raw dependency for HTML-in-markdown rendering

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:51:28 +08:00
pengxiao 5d42a0573a refactor: improve forum, profile and settings page UI
Streamline profile and settings pages, refine forum listing and post pages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:45:40 +08:00
pengxiao 38865b0f7d feat: system knowledge base management with admin/user role separation
- Auto-create system KBs from data/knowledge_base/ subdirectories on startup
- Upgrade existing user KBs to system KBs when matching directory found
- Admin can upload to system KBs (saved to knowledge_base dir)
- Regular users see only their own KBs on knowledge page
- Fix API permission checks for system KBs (list, detail, documents, update, delete)
- Add is_superuser to UserResponse and frontend User type
- Fix sidebar session menu visibility for long titles

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:42:28 +08:00
pengxiao 1ce689ad1e fix: sidebar menu visibility, model selector cleanup, time format
- Sidebar: restore DropdownMenu for session actions, fix long titles
  pushing button off-screen (add w-0 flex-1, w-full on content div)
- Model selector: remove SiliconFlow models, DeepSeek only
  (deepseek-reasoner default, deepseek-chat alternative)
- Message timestamp: change from HH:mm to yyyy/M/d HH:mm format
- Default model changed to deepseek-reasoner

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:10:45 +08:00
pengxiao 19b6cdcbd8 refactor: simplify thinking process UI and reduce noise
- Remove redundant status steps (understanding, preparing, generating)
  from SSE stream — only emit retrieved docs and reasoning content
- ThinkingProcess: only show when there's actual reasoning or docs
- Collapse header shows concise state: thinking count or doc count
- Clean up unused icon imports

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:26:30 +08:00
77 changed files with 7278 additions and 4528 deletions
+37
View File
@@ -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
+8
View File
@@ -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
+85 -255
View File
@@ -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月
+54 -33
View File
@@ -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()
print("应用启动事件完成", file=sys.stderr)
sys.stderr.flush()
logger.error(f"初始化论坛分类失败: {e}")
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",
+7
View File
@@ -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
+140
View File
@@ -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):
"""阶段1Heron 版面检测 + 裁剪"""
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):
"""阶段2VLM图片描述"""
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()
+511
View File
@@ -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",
}
+4
View File
@@ -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()
)
+31 -27
View File
@@ -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)
+83 -144
View File
@@ -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,41 +84,29 @@ 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
]
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="文档不存在"
)
# 1. 先删除向量数据和文档块
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
_check_document_access(document, user)
# 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="文档不存在"
)
if document.is_processed:
return {"message": "文档已经处理过了"}
# 处理文档
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
_check_document_access(document, user)
if document.is_processed and not force:
return {"message": "文档已经处理过了,使用 force=true 强制重新处理"}
if force and document.is_processed:
document_service = DocumentService(db)
document_service.delete_document_chunks(document.id, knowledge_base_id=document.knowledge_base_id)
document_service = DocumentService(db)
success = await asyncio.to_thread(document_service.process_document, document.id)
success = await document_service.process_document(document.id)
if success:
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,49 +240,32 @@ 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
file_types[file_type] = file_types.get(file_type, 0) + 1
return DocumentStats(
total_documents=total_documents,
processed_documents=processed_documents,
total_size=total_size,
file_types=file_types
)
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)}")
+162 -62
View File
@@ -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,28 +133,45 @@ async def create_knowledge_base(
detail="用户不存在"
)
# 检查知识库名称是否已存在
existing_kb = db.query(KnowledgeBase).filter(
KnowledgeBase.name == data.name,
KnowledgeBase.user_id == user.id
).first()
# 管理员创建的知识库标记为系统知识库
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
).first()
if existing_kb:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="知识库名称已存在"
)
# 创建知识库
knowledge_base = KnowledgeBase(
name=data.name,
description=data.description,
user_id=user.id
user_id=user.id,
is_system=is_system
)
db.add(knowledge_base)
db.commit()
db.refresh(knowledge_base)
# 在对应目录下创建文件夹
if is_system:
kb_dir = Path(settings.knowledge_base_dir) / data.name
else:
kb_dir = Path(settings.upload_dir) / user.username / "knowledge_bases" / data.name
kb_dir.mkdir(parents=True, exist_ok=True)
return KnowledgeBaseResponse(
id=knowledge_base.id,
@@ -260,18 +282,21 @@ async def update_knowledge_base(
detail="用户不存在"
)
# 获取知识库
# 获取知识库(用户自己的,或admin操作系统知识库)
knowledge_base = db.query(KnowledgeBase).filter(
KnowledgeBase.id == knowledge_base_id,
KnowledgeBase.user_id == user.id
or_(
KnowledgeBase.user_id == user.id,
and_(KnowledgeBase.is_system == True, user.is_superuser == True)
)
).first()
if not knowledge_base:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="知识库不存在"
)
# 更新字段
if data.name is not None:
# 检查新名称是否已存在
@@ -335,24 +360,27 @@ async def delete_knowledge_base(
detail="用户不存在"
)
# 获取知识库
# 获取知识库(用户自己的,或admin操作系统知识库)
knowledge_base = db.query(KnowledgeBase).filter(
KnowledgeBase.id == knowledge_base_id,
KnowledgeBase.user_id == user.id
or_(
KnowledgeBase.user_id == user.id,
and_(KnowledgeBase.is_system == True, user.is_superuser == True)
)
).first()
if not knowledge_base:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="知识库不存在"
)
# 1. 获取知识库下的所有文档
documents = db.query(Document).filter(
Document.knowledge_base_id == knowledge_base_id
).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,32 +497,42 @@ async def upload_document_to_knowledge_base(
detail="知识库不存在"
)
# 检查是否为系统知识库,系统知识库不允许任何用户上传文档
# 检查知识库权限
if knowledge_base.is_system:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="系统知识库不允许上传文档,请使用您自己的知识库"
)
# 验证知识库属于当前用户(非系统知识库必须属于用户)
if knowledge_base.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="您没有权限向此知识库上传文档"
)
# 系统知识库:仅管理员可上传
if not user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="系统知识库仅管理员可上传文档"
)
else:
# 用户知识库:必须属于当前用户
if knowledge_base.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="您没有权限向此知识库上传文档"
)
# 生成唯一文件名
file_id = str(uuid.uuid4())
filename = f"{file_id}{file_extension}"
# 保存文件
upload_dir = Path(settings.upload_dir)
upload_dir.mkdir(parents=True, exist_ok=True)
file_path = upload_dir / filename
# 根据知识库类型选择保存路径
if knowledge_base.is_system:
# 系统知识库:保存到 knowledge_base_dir/{kb_name}/
save_dir = Path(settings.knowledge_base_dir) / knowledge_base.name
source_type = "knowledge_base"
else:
# 用户知识库:保存到 uploads/{username}/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)
# 创建文档记录
document = Document(
user_id=user.id,
@@ -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,
+4
View File
@@ -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:
"""获取配置实例"""
+22
View File
@@ -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()
+7 -6
View File
@@ -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
+4 -1
View File
@@ -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": [],
+82 -2
View File
@@ -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()
+2 -2
View File
@@ -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格式的元数据
+2 -2
View File
@@ -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) # 内容哈希
+44 -31
View File
@@ -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,37 +136,34 @@ 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
# 发送检索结果 — 包含文档标题和摘要
doc_details = []
for i, doc in enumerate(docs[:5]):
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', ' ')
doc_details.append(f"**{title}**: {preview}...")
# 发送检索结果(只在有文档时)
if docs:
doc_details = []
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', ' ')
doc_details.append(f"**{title}**: {preview}...")
yield {
"type": "thinking",
"stage": "retrieved",
"message": f"检索到 {len(docs)} 篇相关文档",
"doc_count": len(docs),
"time": round(retrieval_time, 2),
"details": doc_details
}
yield {
"type": "thinking",
"stage": "retrieved",
"message": f"检索到 {len(docs)} 篇相关文档",
"doc_count": len(docs),
"time": round(retrieval_time, 2),
"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链实例
-8
View File
@@ -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({
+269 -8
View File
@@ -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,9 +15,266 @@ 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:
"""文档加载器工厂"""
@staticmethod
def get_loader(file_path: str, file_type: str):
"""根据文件类型获取对应的加载器"""
@@ -23,21 +284,21 @@ class DocumentLoaderFactory:
".txt": TextLoader,
".md": UnstructuredMarkdownLoader,
}
loader_class = loaders.get(file_type)
if not loader_class:
raise ValueError(f"Unsupported file type: {file_type}")
return loader_class(file_path)
@staticmethod
def load_document(file_path: str, file_type: str, metadata: Optional[dict] = None) -> List[Document]:
"""加载文档并添加元数据"""
loader = DocumentLoaderFactory.get_loader(file_path, file_type)
documents = loader.load()
if metadata:
for doc in documents:
doc.metadata.update(metadata)
return documents
+4 -1
View File
@@ -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)}")
+13 -6
View File
@@ -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)
例如:上下文中某来源包含"图片URL: /images/1/page3_img1.png",则插入:
![该图为XX规划图](/images/1/page3_img1.png)
4. 回答要准确、专业、详细,结构清晰,逻辑性强
5. 如果上下文中没有相关信息,请诚实说明
6. 在回答末尾,列出所有实际引用的参考来源,格式为:
**参考来源:**
- [来源N] 文档标题
7. 回答长度控制在500-1000字之间
请基于上述上下文信息回答用户的问题。"""
+8 -5
View File
@@ -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:
+13 -23
View File
@@ -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)
+13
View File
@@ -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)
+17 -3
View File
@@ -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,
+8 -6
View File
@@ -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)}")
+9 -6
View File
@@ -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,
+6 -3
View File
@@ -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
+139 -36
View File
@@ -1,33 +1,42 @@
"""
文档处理服务(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:
"""文档处理服务"""
def __init__(self, db: Session):
self.db = db
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,
@@ -38,25 +47,112 @@ class DocumentService:
"filename": document.filename
}
)
# 2. 使用中文优化的文本分割器
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]]:
"""搜索文档(保留原有接口兼容性)"""
@@ -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
+22 -16
View File
@@ -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:
+61 -58
View File
@@ -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,70 +62,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'])}")
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()
edited_image_data = result["images"][0]
# 检查是否为字典类型
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:
+127 -25
View File
@@ -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()
@@ -30,10 +35,13 @@ class KnowledgeBaseService:
directory = self.knowledge_base_dir
else:
directory = Path(directory)
if not directory.exists():
return {"success": False, "message": f"目录不存在: {directory}"}
# 先确保每个子目录都有对应的系统知识库
self.ensure_system_knowledge_bases()
results = {
"scanned_files": 0,
"new_files": 0,
@@ -41,14 +49,14 @@ class KnowledgeBaseService:
"skipped_files": 0,
"errors": []
}
# 递归扫描目录
for file_path in directory.rglob("*"):
if file_path.is_file() and self._is_supported_file(file_path):
try:
result = self.process_file(str(file_path))
results["scanned_files"] += 1
if result["status"] == "new":
results["new_files"] += 1
elif result["status"] == "updated":
@@ -60,16 +68,82 @@ class KnowledgeBaseService:
"file": str(file_path),
"error": result["error"]
})
except Exception as e:
results["errors"].append({
"file": str(file_path),
"error": str(e)
})
results["success"] = len(results["errors"]) == 0
return results
def ensure_system_knowledge_bases(self):
"""确保 knowledge_base_dir 下每个子目录都有对应的系统知识库"""
if not self.knowledge_base_dir.exists():
return
for subdir in self.knowledge_base_dir.iterdir():
if subdir.is_dir():
self._get_or_create_system_kb(subdir.name)
def _get_or_create_system_kb(self, name: str) -> int:
"""按名称查找或创建系统知识库,返回 knowledge_base_id"""
kb = self.db.query(KnowledgeBase).filter(
KnowledgeBase.name == name,
KnowledgeBase.is_system == True
).first()
if kb:
return kb.id
# 检查是否已有同名非系统KB,如有则升级为系统KB
existing = self.db.query(KnowledgeBase).filter(
KnowledgeBase.name == name,
KnowledgeBase.is_system == False
).first()
if existing:
existing.is_system = True
admin = self.db.query(User).filter(User.is_superuser == True).first()
if admin:
existing.user_id = admin.id
self.db.commit()
self.db.refresh(existing)
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:
@@ -101,16 +175,25 @@ class KnowledgeBaseService:
if existing_doc:
# 检查是否需要更新
if (existing_doc.last_modified and
if (existing_doc.last_modified and
existing_doc.last_modified >= last_modified and
existing_doc.file_hash == file_hash):
return {"status": "skipped", "message": "文件未修改"}
# 如果文档没有关联知识库,尝试关联
if existing_doc.knowledge_base_id is None:
kb_id = self._resolve_knowledge_base(file_path)
if kb_id:
existing_doc.knowledge_base_id = kb_id
self.db.commit()
# 更新现有文档
return self._update_document(existing_doc, file_path, file_size, last_modified, file_hash)
else:
# 解析知识库 ID
kb_id = self._resolve_knowledge_base(file_path)
# 创建新文档
return self._create_document(file_path, file_size, last_modified, file_hash)
return self._create_document(file_path, file_size, last_modified, file_hash, knowledge_base_id=kb_id)
except Exception as e:
return {"status": "error", "error": str(e)}
@@ -169,13 +252,13 @@ 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": "文档创建成功,正在处理中"}
except Exception as e:
@@ -207,13 +290,13 @@ 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": "文档更新成功,正在重新处理中"}
except Exception as e:
@@ -311,19 +394,38 @@ class KnowledgeBaseService:
Document.source_type == "knowledge_base"
)
).first()
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": "文档删除成功"}
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
+33 -30
View File
@@ -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:
+2617 -2565
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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}
+4
View File
@@ -32,6 +32,10 @@ const nextConfig = {
source: '/generated_images/:path*',
destination: `${backendUrl}/generated_images/:path*`,
},
{
source: '/images/:path*',
destination: `${backendUrl}/images/:path*`,
},
];
},
// 禁用静态生成,避免 SSR 时使用浏览器 API 的错误
+1
View File
@@ -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",
+50
View File
@@ -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
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
+166
View File
@@ -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>
);
}
+268
View File
@@ -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>
);
}
+139
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}
+132
View File
@@ -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>
);
}
+148
View File
@@ -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>
);
}
+185
View File
@@ -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>
);
}
+12 -12
View File
@@ -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>
))}
+9 -2
View File
@@ -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]);
+1 -1
View File
@@ -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>
)}
+121 -112
View File
@@ -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();
}, []);
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 ? (
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">
<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;
}
return CATEGORY_ORDER.length;
};
return getOrder(a.name) - getOrder(b.name);
})
.map((category) => {
const posts = categoryPosts[category.id] || [];
return (
<div
key={category.id}
className={`bg-gradient-to-r ${getCategoryGradient(
category.name
)} rounded-xl border border-border/40 overflow-hidden`}
>
{/* Category header */}
<div className="px-5 py-3.5 flex items-center justify-between border-b border-border/20">
<div className="flex items-center gap-3">
<span className={getCategoryAccent(category.name)}>
{getCategoryIcon(category.name)}
</span>
<div>
<h2 className="text-base font-semibold text-foreground">
{category.name}
</h2>
{category.description && (
<p className="text-sm text-muted-foreground mt-0.5">
{category.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground tabular-nums">
{category.post_count}
</span>
<Link
href={`/forum/${category.id}`}
className="text-sm font-medium text-primary hover:underline flex items-center gap-0.5"
>
<ChevronRight className="w-4 h-4" />
</Link>
</div>
</div>
</div>
</div>
);
}
{/* Posts list */}
<div className="divide-y divide-border/20">
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 (
<TabsContent
key={category.id}
value={String(category.id)}
className="mt-6"
>
{/* Category description */}
{category.description && (
<p className="text-sm text-muted-foreground mb-4">
{category.description}
</p>
)}
{/* New post button */}
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-muted-foreground tabular-nums">
{category.post_count}
</span>
<Link
href={`/forum/${category.id}`}
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
<Plus className="w-3.5 h-3.5" />
</Link>
</div>
{/* Posts list */}
<div className="rounded-xl border border-border overflow-hidden">
<div className="divide-y divide-border">
{posts.length > 0 ? (
posts.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>
);
})}
</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>
);
})}
</Tabs>
</div>
</div>
);
+35 -18
View File
@@ -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>
+152 -72
View File
@@ -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,70 +307,81 @@ export default function KnowledgePage() {
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredKnowledgeBases.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" />
<div>
<CardTitle className="text-lg">{kb.name}</CardTitle>
<CardDescription className="text-sm">
{kb.description || "暂无描述"}
</CardDescription>
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">
{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-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>
</div>
<div className="flex items-center space-x-1">
{kb.document_count > 0 ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<Clock className="w-4 h-4 text-gray-400" />
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between text-sm text-muted-foreground">
<span></span>
<span>{kb.document_count} </span>
</div>
<div className="flex justify-between text-sm text-muted-foreground">
<span></span>
<span>{formatDate(kb.created_at)}</span>
</div>
<div className="flex justify-between text-sm">
<span></span>
<span className={kb.document_count > 0 ? "text-green-600" : "text-gray-500"}>
{kb.document_count > 0 ? "已就绪" : "空知识库"}
</span>
</div>
</div>
<div className="flex space-x-2 mt-4">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => router.push(`/knowledge/${kb.id}`)}
>
<Settings className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteKnowledgeBase(kb.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</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>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteKnowledgeBase(kb.id)}
className="text-destructive hover:text-destructive/80"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
)}
</div>
+83 -235
View File
@@ -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 {
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 className="space-y-6">
{/* 个人信息卡片 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="w-5 h-5 text-primary" />
<CardTitle></CardTitle>
</div>
{!isEditing && (
<button
onClick={() => setIsEditing(true)}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{/* 头像 + 表单 */}
<div className="flex items-center gap-5 mb-6 pb-6 border-b">
<div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center text-primary-foreground text-xl font-bold flex-shrink-0">
{initials}
</div>
<div className="flex-1">
<p className="text-base font-medium">{user.full_name || user.username}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label className="text-sm text-muted-foreground"></Label>
<Input value={user.username} disabled className="mt-1.5 bg-muted" />
<p className="text-xs text-muted-foreground mt-1"></p>
</div>
<div className="flex-1">
<p className="text-sm text-muted-foreground mb-2">
使
</p>
<p className="text-xs text-muted-foreground">
</p>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<Input value={formatDate(user.created_at)} disabled className="mt-1.5 bg-muted" />
</div>
</div>
</CardContent>
</Card>
{/* 基本信息卡片 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
{!isEditing && (
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(true)}
>
<Edit2 className="w-4 h-4 mr-2" />
</Button>
)}
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* 用户名(只读) */}
<div>
<Label htmlFor="username"></Label>
<Input
id="username"
value={user.username}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
{/* 邮箱(可编辑) */}
<div>
<Label htmlFor="email"></Label>
<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>
{/* 注册时间(只读) */}
<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
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSaving}
>
<X className="w-4 h-4 mr-2" />
</Button>
</div>
)}
</form>
</CardContent>
</Card>
</div>
{/* 右侧:学习统计 */}
<div className="lg:col-span-1">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{statistics ? (
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div className="flex items-center space-x-3">
<MessageSquare className="w-5 h-5 text-blue-600" />
<span className="text-sm font-medium"></span>
</div>
<span className="text-lg font-bold">{statistics.total_sessions}</span>
</div>
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div className="flex items-center space-x-3">
<MessageSquare className="w-5 h-5 text-green-600" />
<span className="text-sm font-medium"></span>
</div>
<span className="text-lg font-bold">{statistics.total_messages}</span>
</div>
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div className="flex items-center space-x-3">
<Database className="w-5 h-5 text-purple-600" />
<span className="text-sm font-medium"></span>
</div>
<span className="text-lg font-bold">-</span>
</div>
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div className="flex items-center space-x-3">
<FileText className="w-5 h-5 text-orange-600" />
<span className="text-sm font-medium"></span>
</div>
<span className="text-lg font-bold">{statistics.total_documents}</span>
</div>
</div>
) : (
<div className="text-center py-8">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">...</p>
{isEditing && (
<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"
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" />
</button>
</div>
)}
</CardContent>
</Card>
</div>
</form>
</CardContent>
</Card>
</div>
</div>
{/* 移动端导航 */}
<MobileNav />
</div>
);
}
+105 -195
View File
@@ -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 {
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">
{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="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>
<Label htmlFor="old_password"></Label>
<div className="relative">
<Input
id="old_password"
type={showOldPassword ? "text" : "password"}
{...register("old_password")}
className={errors.old_password ? "border-red-500" : ""}
placeholder="请输入当前密码"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowOldPassword(!showOldPassword)}
>
{showOldPassword ? (
<EyeOff className="w-4 h-4 text-muted-foreground" />
) : (
<Eye className="w-4 h-4 text-muted-foreground" />
)}
</Button>
</div>
{errors.old_password && (
<p className="text-xs text-red-500 mt-1">
{errors.old_password.message}
</p>
)}
</div>
{/* 新密码 */}
<div>
<Label htmlFor="new_password"></Label>
<div className="relative">
<Input
id="new_password"
type={showNewPassword ? "text" : "password"}
{...register("new_password")}
className={errors.new_password ? "border-red-500" : ""}
placeholder="请输入新密码(至少6个字符)"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowNewPassword(!showNewPassword)}
>
{showNewPassword ? (
<EyeOff className="w-4 h-4 text-muted-foreground" />
) : (
<Eye className="w-4 h-4 text-muted-foreground" />
)}
</Button>
</div>
{newPassword && (
<div className="mt-2">
<div className="flex items-center space-x-2 mb-1">
<span className="text-xs text-muted-foreground"></span>
<span className={`text-xs font-medium ${passwordStrength.color}`}>
{passwordStrength.label}
</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
passwordStrength.strength === "weak"
? "bg-red-500 w-1/3"
: passwordStrength.strength === "medium"
? "bg-yellow-500 w-2/3"
: "bg-green-500 w-full"
}`}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<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-destructive" : ""}
placeholder="请输入当前密码"
/>
<button
type="button"
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>
</div>
)}
{errors.new_password && (
<p className="text-xs text-red-500 mt-1">
{errors.new_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" className="text-sm text-muted-foreground"></Label>
<div className="relative mt-1.5">
<Input
id="new_password"
type={showNewPassword ? "text" : "password"}
{...register("new_password")}
className={errors.new_password ? "border-destructive" : ""}
placeholder="请输入新密码(至少6个字符)"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2"
onClick={() => setShowNewPassword(!showNewPassword)}
>
{showNewPassword ? <EyeOff className="w-4 h-4 text-muted-foreground" /> : <Eye className="w-4 h-4 text-muted-foreground" />}
</button>
</div>
{newPassword && (
<div className="mt-2">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground"></span>
<span className={`text-xs font-medium ${passwordStrength.color}`}>{passwordStrength.label}</span>
</div>
<div className="w-full bg-muted rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all ${
passwordStrength.score <= 2 ? "bg-destructive w-1/3"
: passwordStrength.score <= 4 ? "bg-amber-500 w-2/3"
: "bg-emerald-500 w-full"
}`}
/>
</div>
</div>
)}
{errors.new_password && <p className="text-xs text-destructive mt-1">{errors.new_password.message}</p>}
</div>
</div>
{/* 确认新密码 */}
<div>
<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>
<ThemeToggle />
</div>
{/* 未来可添加其他偏好设置 */}
<div className="p-4 bg-muted/50 rounded-lg">
<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>
</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 link = document.createElement("a");
link.href = imageUrl;
link.download = `edited-image-${imageId}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success("图像下载成功");
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 = 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>
+23 -15
View File
@@ -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 link = document.createElement("a");
link.href = imageUrl;
link.download = `image-${imageId}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success("图像下载成功");
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 = 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 link = document.createElement("a");
link.href = imageUrl;
link.download = `generated-image-${imageId}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success("图像下载成功");
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 = 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>
+14 -32
View File
@@ -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" />
+14 -27
View File
@@ -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 ? (
<>
@@ -236,6 +222,7 @@ export default function ForumPostPage() {
</div>
</div>
) : null}
</div>
</div>
</div>
);
+33
View File
@@ -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;
+4
View File
@@ -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

+2 -2
View File
@@ -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
View File
@@ -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 (
+91 -47
View File
@@ -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 {
await navigator.clipboard.writeText(message.content);
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>
+46 -84
View File
@@ -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,55 +28,25 @@ interface ModelSelectorProps {
className?: string;
}
const modelGroups = [
const models: ModelOption[] = [
{
id: "deepseek-reasoner",
name: "DeepSeek-R1",
description: "深度推理模型,适合复杂分析任务",
provider: "deepseek-official",
label: "DeepSeek 官方",
models: [
{
id: "deepseek-chat",
name: "DeepSeek-V3",
description: "DeepSeek 最新通用模型,速度快、能力强",
provider: "deepseek-official",
providerLabel: "DeepSeek",
icon: Sparkles,
},
{
id: "deepseek-reasoner",
name: "DeepSeek-R1",
description: "深度推理模型,适合复杂分析任务",
provider: "deepseek-official",
providerLabel: "DeepSeek",
icon: Brain,
},
],
providerLabel: "DeepSeek",
icon: Brain,
},
{
provider: "siliconflow",
label: "SiliconFlow(硅基流动)",
models: [
{
id: "deepseek-ai/DeepSeek-V3",
name: "DeepSeek-V3",
description: "通过硅基流动调用,稳定的推理能力",
provider: "siliconflow",
providerLabel: "硅基流动",
icon: Sparkles,
},
{
id: "Qwen/QwQ-32B",
name: "QwQ-32B",
description: "Qwen 推理模型,高效准确",
provider: "siliconflow",
providerLabel: "硅基流动",
icon: Zap,
},
],
id: "deepseek-chat",
name: "DeepSeek-V3",
description: "通用模型,速度快、能力强",
provider: "deepseek-official",
providerLabel: "DeepSeek",
icon: Sparkles,
},
];
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,51 +76,44 @@ 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) => {
const ModelIcon = model.icon || Cpu;
const isSelected = model.id === selectedModel;
{models.map((model) => {
const ModelIcon = model.icon || Cpu;
const isSelected = model.id === selectedModel;
return (
<DropdownMenuItem
key={model.id}
onClick={() => {
onModelChange(model.id);
setIsOpen(false);
}}
className={cn(
"flex items-start space-x-3 p-3 cursor-pointer",
isSelected && "bg-muted/50"
return (
<DropdownMenuItem
key={model.id}
onClick={() => {
onModelChange(model.id);
setIsOpen(false);
}}
className={cn(
"flex items-start space-x-3 p-3 cursor-pointer",
isSelected && "bg-muted/50"
)}
>
<ModelIcon className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<span className="font-medium text-sm">{model.name}</span>
{isSelected && (
<Badge variant="secondary" className="text-xs">
</Badge>
)}
>
<ModelIcon className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<span className="font-medium text-sm">{model.name}</span>
{isSelected && (
<Badge variant="secondary" className="text-xs">
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{model.description}
</p>
</div>
</DropdownMenuItem>
);
})}
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{model.description}
</p>
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
+103 -100
View File
@@ -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,106 +117,101 @@ 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>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="搜索"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-sm bg-muted/30 border border-border/20 focus-visible:ring-1 focus-visible:border-primary/30"
/>
</div>
</div>
{/* Session list with date groups */}
<ScrollArea className="flex-1 px-2">
<div className="pb-4">
{groups.map(group => (
<div key={group.label} className="mb-3">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground/70 uppercase tracking-wider">
{group.label}
</div>
<div className="space-y-0.5">
{group.sessions.map(session => (
<div
key={session.id}
className="relative rounded-lg cursor-pointer transition-colors hover:bg-muted/50"
style={{
backgroundColor: currentSession?.id === session.id ? 'var(--muted)' : undefined,
color: currentSession?.id === session.id ? 'var(--foreground)' : undefined
}}
onClick={() => handleSelectSession(session.id)}
>
{/* Title row */}
<div className="flex items-center gap-2 px-2.5 py-2 pr-28">
<MessageSquare className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-sm truncate">{session.title}</span>
</div>
{/* Action buttons — absolute positioned, always visible */}
<div
className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<button
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-gray-500 hover:text-gray-800 hover:bg-gray-200 transition-colors"
title="重命名"
onClick={() => { setEditingSession(session.id); setEditTitle(session.title); }}
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<ExportDialog sessionId={session.id} sessionTitle={session.title} onClose={() => {}}>
<button
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-gray-500 hover:text-gray-800 hover:bg-gray-200 transition-colors"
title="导出"
>
<Download className="w-3.5 h-3.5" />
</button>
</ExportDialog>
<button
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-gray-500 hover:text-red-600 hover:bg-red-50 transition-colors"
title="删除"
onClick={() => setDeleteSessionId(session.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
))}
{sessions.length === 0 && (
<div className="text-center py-12">
<MessageSquare className="w-8 h-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-xs text-muted-foreground"></p>
</div>
)}
</div>
</ScrollArea>
{/* Footer — new chat button with distinct background */}
<div className="px-3 py-3 border-t border-border/30 bg-muted/20">
<Button
onClick={handleNewChat}
variant="outline"
className="w-full h-9 text-sm justify-center gap-2 border-dashed border-border/50 hover:bg-primary/5 hover:text-primary hover:border-primary/30"
<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={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="收起侧边栏"
>
<Plus className="w-4 h-4" />
</Button>
<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" />
<Input
placeholder="搜索"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-sm bg-muted/30 border border-border/20 focus-visible:ring-1 focus-visible:border-primary/30"
/>
</div>
</div>
{/* Session list */}
<ScrollArea className="flex-1 px-2">
<div className="pb-4 w-full">
{groups.map(group => (
<div key={group.label} className="mb-3">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground/70 uppercase tracking-wider">
{group.label}
</div>
<div className="space-y-0.5">
{group.sessions.map(session => (
<div
key={session.id}
className={`group relative flex items-center gap-2 px-2.5 py-2 rounded-lg cursor-pointer transition-colors hover:bg-muted/50 overflow-hidden ${
currentSession?.id === session.id ? 'bg-muted' : ''
}`}
onClick={() => handleSelectSession(session.id)}
>
<MessageSquare className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-sm truncate min-w-0 w-0 flex-1">{session.title}</span>
{/* Dropdown menu — visible on hover */}
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-6 w-6 inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-all">
<MoreVertical className="w-3.5 h-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onSelect={() => { setEditingSession(session.id); setEditTitle(session.title); }}>
<Edit2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<ExportDialog sessionId={session.id} sessionTitle={session.title}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Download className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</ExportDialog>
<DropdownMenuItem onSelect={() => setDeleteSessionId(session.id)} className="text-destructive">
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</div>
))}
{sessions.length === 0 && (
<div className="text-center py-12">
<MessageSquare className="w-8 h-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-xs text-muted-foreground"></p>
</div>
)}
</div>
</ScrollArea>
{/* Footer */}
<div className="px-3 py-3 border-t border-border/30 bg-muted/20">
<Button
onClick={handleNewChat}
variant="outline"
className="w-full h-9 text-sm justify-center gap-2 border-dashed border-border/50 hover:bg-primary/5 hover:text-primary hover:border-primary/30"
>
<Plus className="w-4 h-4" />
</Button>
</div>
{/* Rename dialog */}
@@ -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>
);
}
+278 -90
View File
@@ -1,117 +1,305 @@
"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) {
if (!sources || sources.length === 0) {
return null;
/** 从 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);
}
}
const displaySources = sources.slice(0, maxSources);
const ragSources = displaySources.filter(s => s.source_type !== "web");
const webSources = displaySources.filter(s => s.source_type === "web");
// 在 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 className="mt-4 space-y-3">
{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)}%
</span>
</div>
)}
</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>
</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>
<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>
</div>
)}
{sources.length > maxSources && (
<div className="text-xs text-gray-500 text-center">
{sources.length - maxSources}
<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 showExpandButton = sources.length > maxSources;
const visibleSources = expanded ? sources : displaySources;
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-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="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-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="divide-y divide-border/30">
{webSources.map((source, i) => (
<SourceRow
key={i}
source={source}
answerContent={answerContent}
messageId={messageId}
/>
))}
</div>
</div>
)}
</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>
);
}
+12 -25
View File
@@ -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" />
+41 -39
View File
@@ -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
View File
@@ -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");
},
};
+34 -2
View File
@@ -91,8 +91,8 @@ 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);
if (!session) {
@@ -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();
+4 -1
View File
@@ -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 {