Compare commits

...

4 Commits

Author SHA1 Message Date
mula.liu 31cf2bfc1c v0.9.8 2026-07-24 15:37:22 +08:00
mula.liu e6c92551ea Stage 2 & 3: Remove Graphy, integrate ZVec vectorization, and add knowledge base chat
## Changes

### Stage 2: ZVec Integration
- Created `zvec_service.py` for MD file vectorization with Aliyun ZVec
- Added `document_vector.py` model for storing embeddings
- Integrated ZVec into file operations (create/update/delete MD files)
- Added `create_document_vector_table.sql` migration
- Background vectorization on file save, removal on file delete

### Stage 3: Knowledge Base Chat
- Created `chat_session.py` model for chat sessions and messages
- Implemented `rag_service.py` for RAG (vector retrieval + LLM generation)
- Added `chat.py` API endpoints (create sessions, send messages, list history)
- Created `Chat.jsx` frontend component with project/model selection
- Added `chat.js` API client
- Chat route integrated into App.jsx

### Cleanup
- Removed all graphy-related files (graphy/, graphy.py, graphy_task.py, etc.)
- Removed BUILD_GRAPH from enums (no longer needed)

### Database
- Created migration script for chat tables (ChatSession, ChatMessage)
- Created migration script for document vectors

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-23 21:58:16 +08:00
mula.liu 0b0713fc4e Merge remote main with local model config features
Merge origin/main which includes:
- New file sharing system (shares module)
- Large markdown viewer improvements
- Preview page refactoring
- FloatingToc component

Preserved from local:
- LLM model configuration support (llm_model_configs)

This merge integrates remote improvements while keeping the local
model configuration functionality for the next phase.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-23 21:52:09 +08:00
mula.liu 2d41a3ba2f v0.9.7 2026-04-09 01:03:57 +08:00
90 changed files with 12105 additions and 1048 deletions

17
.dockerignore 100644
View File

@ -0,0 +1,17 @@
.git
.DS_Store
.env
.env.local
storage
backup
frontend/node_modules
frontend/dist
backend/venv
backend/__pycache__
backend/logs
graphy/.venv
graphy/build
graphy/dist
graphy/*.egg-info
**/__pycache__
**/.pytest_cache

View File

@ -13,6 +13,11 @@ REDIS_PASSWORD=redis_password_change_me
REDIS_PORT=6379
REDIS_DB=8
# ==================== RAG配置 ====================
# 文档分块大小(字符数)与相邻分块的重叠字符数
CHUNK_SIZE=800
CHUNK_OVERLAP=150
# ==================== 应用配置 ====================
# JWT 密钥(请务必修改为随机字符串)
SECRET_KEY=your-secret-key-change-me-in-production-use-openssl-rand-hex-32
@ -44,3 +49,5 @@ ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin@123
ADMIN_EMAIL=admin@unisspace.com
ADMIN_NICKNAME=系统管理员
# 开发环境跳过 SSL 证书验证(生产环境请勿开启)
DISABLE_SSL_VERIFY=true

3
.gitignore vendored
View File

@ -31,3 +31,6 @@ logs/
# Temporary files
*.tmp
*.temp
# AI
.gemini-clipboard/

View File

@ -0,0 +1,58 @@
# 三阶段实现计划:远端合并 + ZVec集成 + 知识库对话
## Stage 1: 远端更新合并 & 模型配置集成
**Goal**: 合并远端的新增功能(项目管理、文件管理、分享等),保留本地的模型配置功能
**Success Criteria**:
- 无冲突合并远端代码
- 本地LLMModelConfig功能完全保留
- 所有数据库迁移脚本就位
- 前后端依赖关系正确
**Tests**:
- 数据库初始化成功
- 项目CRUD操作正常
- 模型配置API正常工作
**Status**: Complete
## Stage 2: 删除Graphy + 集成ZVec向量化
**Goal**: 移除graphy组件实现ZVec自动向量化MD文件操作时
**Success Criteria**:
- ✅ 删除所有graphy相关文件和导入
- ✅ 创建ZVec集成服务
- ✅ 在文件操作(创建/修改/删除时自动调用ZVec仅限MD
- ✅ 向量化结果存储到数据库
**Tests**:
- 创建MD文件时触发向量化
- 修改MD文件时重新向量化
- 删除MD文件时清理向量数据
- PDF文件不触发向量化
**Status**: Complete
## Stage 3: 知识库对话功能
**Goal**: 基于ZVec向量化的文档实现大模型知识库对话
**Success Criteria**:
- ✅ 创建知识库对话页面/组件
- ✅ 支持项目选择
- ✅ 支持LLM模型选择使用Stage 1的模型配置
- ✅ RAG检索+大模型生成对话
- ✅ 对话历史记录
**Tests**:
- 能成功创建对话会话
- 向量检索返回相关文档
- 大模型生成回复正常
- 对话历史保存正确
**Status**: Complete
---
## 关键技术决策
1. **ZVec集成方式**通过项目文件监听或API触发取决于项目架构
2. **向量存储**在DocumentMeta模型中新增embedding字段或创建单独的向量表
3. **RAG实现**:使用向量相似度检索 + LLMProviderService进行生成
4. **前端交互**Chat页面侧边栏项目/模型选择,消息列表
## 已有基础
- ✅ LLMProviderService多协议支持
- ✅ LLMModelConfig模型和API
- ✅ 项目管理系统
- ✅ 文件管理系统
- ✅ 前端框架React+Ant Design

View File

@ -2,7 +2,7 @@
API v1 路由汇总
"""
from fastapi import APIRouter
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications, shares
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications, shares, llm_model_configs, chat
api_router = APIRouter()
@ -21,3 +21,5 @@ api_router.include_router(users.router, prefix="/users", tags=["用户管理"])
api_router.include_router(roles.router, prefix="/roles", tags=["角色管理"])
api_router.include_router(search.router, prefix="/search", tags=["文档搜索"])
api_router.include_router(logs.router, prefix="/logs", tags=["系统日志"])
api_router.include_router(llm_model_configs.router, prefix="/llm-model-configs", tags=["LLM 模型配置"])
api_router.include_router(chat.router, prefix="/chat", tags=["知识库对话"])

View File

@ -0,0 +1,871 @@
"""
知识库对话相关 API
"""
import asyncio
import json
import logging
import re
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.llm_model_config import LLMModelConfig
from app.models.project import Project
from app.models.user import User
from app.models.chat_session import ChatSession, ChatMessage
from app.schemas.response import success_response
from app.services.project_service import (
get_project_or_404,
require_project_read_access,
require_project_write_access,
)
from app.services.rag_service import rag_service
from app.services.zvec_service import zvec_service
from app.services.llm_provider_service import LLMProviderService
from app.services.project_vectorization_task_service import project_vectorization_task_service
from pydantic import BaseModel
router = APIRouter()
logger = logging.getLogger(__name__)
CHAT_HISTORY_MESSAGE_LIMIT = 20
class VectorizeRequest(BaseModel):
"""批量向量化请求"""
force: bool = False # False=增量(跳过未变更文件), True=全量重建
@router.get("/projects/{project_id}/vectorize/progress", response_model=dict)
async def get_vectorize_progress(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查询项目向量化进度"""
project = await get_project_or_404(db, project_id)
await require_project_read_access(db, project_id, current_user)
progress = await zvec_service.get_progress(db, project_id, project.storage_key)
return success_response(data=progress)
@router.post("/projects/{project_id}/vectorize", response_model=dict)
async def vectorize_project(
project_id: int,
req: VectorizeRequest,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建项目文件夹向量化后台任务(增量或全量)"""
await get_project_or_404(db, project_id)
await require_project_write_access(db, project_id, current_user)
if await zvec_service.get_embedding_config(db) is None:
raise HTTPException(
status_code=400,
detail="尚未配置可用的向量模型,请先在「模型配置 - 向量模型」中添加并启用。",
)
running_task = await project_vectorization_task_service.get_running_task(db, project_id)
if running_task:
task = running_task
else:
task = await project_vectorization_task_service.create_task(
db,
project_id,
current_user.id,
force=req.force,
)
background_tasks.add_task(project_vectorization_task_service.run_task, task.task_id)
return success_response(
data=project_vectorization_task_service.serialize_task(task),
message="向量化任务已提交",
)
@router.get("/projects/{project_id}/vectorize/tasks/latest", response_model=dict)
async def get_latest_vectorize_task(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查询项目最近一次向量化任务"""
await require_project_read_access(db, project_id, current_user)
task = await project_vectorization_task_service.get_latest_task(db, project_id)
return success_response(
data=project_vectorization_task_service.serialize_task(task) if task else None
)
@router.get("/projects/{project_id}/vectorize/tasks/{task_id}", response_model=dict)
async def get_vectorize_task(
project_id: int,
task_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查询项目向量化任务状态"""
await require_project_read_access(db, project_id, current_user)
task = await project_vectorization_task_service.get_task(db, project_id, task_id)
if not task:
raise HTTPException(status_code=404, detail="向量化任务不存在")
return success_response(data=project_vectorization_task_service.serialize_task(task))
class ChatCreateRequest(BaseModel):
"""创建对话会话请求"""
project_id: int
llm_config_id: int
title: str = "新对话"
class ChatMessageRequest(BaseModel):
"""发送聊天消息请求"""
session_id: int
message: str
class ChatSessionUpdateRequest(BaseModel):
"""更新对话会话请求"""
title: str
class ChatResponse(BaseModel):
"""对话响应"""
session_id: int
user_message: str
assistant_message: str
def _parse_refs(value):
if not value:
return []
try:
return json.loads(value)
except (ValueError, TypeError):
return []
def _normalize_refs(refs):
"""将存储的引用归一化为 [{citation_id, file_path, anchor_text, excerpt}] 形式。
兼容旧格式纯文件路径数组与新格式对象数组
"""
normalized = []
for idx, ref in enumerate(refs or [], 1):
if isinstance(ref, dict):
file_path = ref.get("file_path")
citation_id = ref.get("citation_id", idx)
anchor_text = ref.get("anchor_text") or ""
excerpt = ref.get("excerpt") or ""
else:
file_path = ref
citation_id = idx
anchor_text = ""
excerpt = ""
if file_path:
normalized.append({
"citation_id": citation_id,
"file_path": file_path,
"anchor_text": anchor_text,
"excerpt": excerpt,
})
return normalized
def _build_reference_items(refs, project_id=None):
return [
{
"citation_id": ref["citation_id"],
"file_path": ref["file_path"],
"file_name": ref["file_path"].rsplit("/", 1)[-1],
"anchor_text": ref.get("anchor_text") or "",
"excerpt": ref.get("excerpt") or "",
"project_id": project_id,
}
for ref in _normalize_refs(refs)
]
def _canonicalize_message_citations(content: str, refs):
"""按文件合并引用并将正文引用重新编号,兼容历史重复分块数据。"""
normalized = _normalize_refs(refs)
if not normalized:
return content, []
canonical_refs = []
file_to_id = {}
old_to_new = {}
for ref in normalized:
file_path = ref["file_path"]
old_id = int(ref["citation_id"])
new_id = file_to_id.get(file_path)
if new_id is None:
new_id = len(canonical_refs) + 1
file_to_id[file_path] = new_id
canonical_refs.append({**ref, "citation_id": new_id})
else:
canonical_ref = canonical_refs[new_id - 1]
current_excerpt = canonical_ref.get("excerpt", "").strip()
incoming_excerpt = ref.get("excerpt", "").strip()
if incoming_excerpt and incoming_excerpt not in current_excerpt:
canonical_ref["excerpt"] = (
f"{current_excerpt}\n\n{incoming_excerpt}"
if current_excerpt else incoming_excerpt
)
old_to_new[old_id] = new_id
def replace_citation(match):
old_id = int(match.group(1))
return f"[{old_to_new.get(old_id, old_id)}]"
normalized_content = _CITATION_PATTERN.sub(replace_citation, content or "")
normalized_content = re.sub(r"\[(\d+)\](?:\s*\[\1\])+", r"[\1]", normalized_content)
return normalized_content, canonical_refs
def _stream_event(event: str, data) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
_CITATION_PATTERN = re.compile(r"\[(\d+)\]")
def _compact_cited_refs(answer: str, retrieved_docs):
"""仅保留实际使用的文档,并按正文首次引用顺序连续编号。"""
docs_by_id = {
int(doc.get("citation_id", index)): doc
for index, doc in enumerate(retrieved_docs, 1)
}
old_to_new = {}
file_to_new = {}
refs = []
for match in _CITATION_PATTERN.finditer(answer or ""):
old_id = int(match.group(1))
doc = docs_by_id.get(old_id)
if doc is None or old_id in old_to_new:
continue
file_path = doc.get("file_path")
if not file_path:
continue
new_id = file_to_new.get(file_path)
if new_id is None:
new_id = len(refs) + 1
file_to_new[file_path] = new_id
refs.append({
"citation_id": new_id,
"file_path": file_path,
"anchor_text": doc.get("anchor_text") or "",
"excerpt": doc.get("excerpt") or doc.get("content") or doc.get("anchor_text") or "",
})
old_to_new[old_id] = new_id
def replace_citation(match):
old_id = int(match.group(1))
return f"[{old_to_new.get(old_id, old_id)}]"
normalized_answer = _CITATION_PATTERN.sub(replace_citation, answer or "")
normalized_answer = re.sub(
r"\[(\d+)\](?:\s*\[\1\])+", r"[\1]", normalized_answer
)
return normalized_answer, refs
async def _generate_session_title(db, llm_config_id: int, question: str, answer: str) -> Optional[str]:
"""根据首轮问答,用 LLM 生成简洁的会话标题。失败时返回 None。"""
stmt = select(LLMModelConfig).where(LLMModelConfig.config_id == llm_config_id)
result = await db.execute(stmt)
llm_config = result.scalar_one_or_none()
if not llm_config:
return None
system_prompt = (
"你是一个会话标题生成器。请根据用户的问题和助手的回答,"
"用一句不超过 16 个字的中文短语概括对话主题,作为标题。"
"只输出标题本身,不要引号、标点结尾或任何解释。"
)
user_content = f"用户问题:{question}\n\n助手回答:{(answer or '')[:500]}"
try:
title = await LLMProviderService.generate_text(
provider=llm_config.provider,
endpoint_url=llm_config.endpoint_url,
api_key=llm_config.api_key,
llm_model_name=llm_config.llm_model_name,
timeout=min(int(llm_config.llm_timeout or 60), 60),
temperature=0.3,
top_p=float(llm_config.llm_top_p or 0.9),
max_tokens=32,
system_prompt=system_prompt,
messages=[{"role": "user", "content": user_content}],
)
except Exception as exc: # noqa: BLE001 - 标题生成失败不应影响主流程
logger.warning("生成会话标题失败: %s", exc)
return None
title = (title or "").strip().strip('"').strip("「」").strip()
title = title.splitlines()[0].strip() if title else ""
if not title:
return None
return title[:60]
@router.post("/sessions", response_model=dict)
async def create_chat_session(
req: ChatCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建新的对话会话"""
await get_project_or_404(db, req.project_id)
await require_project_read_access(db, req.project_id, current_user)
model_result = await db.execute(
select(LLMModelConfig).where(
LLMModelConfig.config_id == req.llm_config_id,
LLMModelConfig.model_type == "chat",
LLMModelConfig.is_active == True,
)
)
if model_result.scalar_one_or_none() is None:
raise HTTPException(status_code=400, detail="请选择有效的对话模型")
session = ChatSession(
user_id=current_user.id,
project_id=req.project_id,
llm_config_id=req.llm_config_id,
title=req.title,
)
db.add(session)
await db.commit()
await db.refresh(session)
return success_response(data={
"session_id": session.id,
"project_id": session.project_id,
"llm_config_id": session.llm_config_id,
"title": session.title,
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
})
@router.get("/sessions", response_model=dict)
async def list_chat_sessions(
project_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""列出当前用户的对话会话,可按知识库筛选"""
stmt = select(ChatSession).where(ChatSession.user_id == current_user.id)
if project_id is not None:
await require_project_read_access(db, project_id, current_user)
stmt = stmt.where(ChatSession.project_id == project_id)
stmt = stmt.order_by(ChatSession.updated_at.desc(), ChatSession.created_at.desc())
result = await db.execute(stmt)
sessions = result.scalars().all()
return success_response(data=[{
"session_id": s.id,
"project_id": s.project_id,
"llm_config_id": s.llm_config_id,
"title": s.title,
"created_at": s.created_at.isoformat(),
"updated_at": s.updated_at.isoformat(),
} for s in sessions])
@router.get("/sessions/{session_id}/messages", response_model=dict)
async def get_session_messages(
session_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取对话会话的所有消息"""
stmt = select(ChatSession).where(ChatSession.id == session_id)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="对话会话不存在")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权访问该对话")
await require_project_read_access(db, session.project_id, current_user)
msg_stmt = select(ChatMessage).where(
ChatMessage.session_id == session_id
).order_by(ChatMessage.created_at.asc())
msg_result = await db.execute(msg_stmt)
messages = msg_result.scalars().all()
data = []
for message in messages:
stored_refs = _parse_refs(message.referenced_files)
content = message.content
refs = stored_refs
if message.role == "assistant":
content, refs = _canonicalize_message_citations(content, stored_refs)
data.append({
"id": message.id,
"role": message.role,
"content": content,
"referenced_files": refs,
"references": _build_reference_items(refs, session.project_id) if message.role == "assistant" else [],
"created_at": message.created_at.isoformat(),
})
return success_response(data=data)
@router.get("/search", response_model=dict)
async def search_chat_messages(
keyword: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""搜索聊天消息内容"""
keyword = (keyword or "").strip()
if not keyword:
return success_response(data=[])
escaped_keyword = (
keyword.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
stmt = (
select(
ChatMessage.id,
ChatMessage.session_id,
ChatMessage.role,
ChatMessage.content,
ChatMessage.created_at,
ChatSession.title,
ChatSession.project_id,
ChatSession.llm_config_id,
Project.name.label("project_name"),
LLMModelConfig.model_name.label("model_name"),
)
.join(ChatSession, ChatMessage.session_id == ChatSession.id)
.join(Project, Project.id == ChatSession.project_id)
.join(LLMModelConfig, LLMModelConfig.config_id == ChatSession.llm_config_id)
.where(
ChatSession.user_id == current_user.id,
ChatMessage.content.ilike(f"%{escaped_keyword}%", escape="\\"),
)
.order_by(ChatMessage.created_at.desc())
.limit(50)
)
result = await db.execute(stmt)
rows = result.all()
def _build_snippet(content: str, term: str) -> str:
text = content or ""
if not text:
return ""
lowered = text.lower()
needle = term.lower()
idx = lowered.find(needle)
if idx < 0:
return text[:120]
start = max(0, idx - 24)
end = min(len(text), idx + len(term) + 48)
prefix = "..." if start > 0 else ""
suffix = "..." if end < len(text) else ""
return f"{prefix}{text[start:end].strip()}{suffix}"
return success_response(data=[
{
"result_id": row.id,
"session_id": row.session_id,
"message_id": row.id,
"role": row.role,
"content": row.content,
"snippet": _build_snippet(row.content, keyword),
"session_title": row.title,
"project_id": row.project_id,
"project_name": row.project_name,
"llm_config_id": row.llm_config_id,
"model_name": row.model_name,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
for row in rows
])
@router.post("/send", response_model=dict)
async def send_chat_message(
req: ChatMessageRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""发送对话消息并获取回复"""
question = (req.message or "").strip()
if not question:
raise HTTPException(status_code=400, detail="消息内容不能为空")
stmt = select(ChatSession).where(ChatSession.id == req.session_id)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="对话会话不存在")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权访问该对话")
await require_project_read_access(db, session.project_id, current_user)
query_message = ChatMessage(
session_id=req.session_id,
role="user",
content=question,
)
db.add(query_message)
await db.flush()
try:
msg_stmt = (
select(ChatMessage)
.where(
ChatMessage.session_id == req.session_id,
ChatMessage.id != query_message.id,
)
.order_by(ChatMessage.created_at.desc())
.limit(CHAT_HISTORY_MESSAGE_LIMIT)
)
msg_result = await db.execute(msg_stmt)
prev_messages = list(reversed(msg_result.scalars().all()))
conversation_history = [
{"role": m.role, "content": m.content}
for m in prev_messages
]
retrieved_docs = await rag_service.retrieve_documents(
db,
session.project_id,
question,
top_k=5,
)
assistant_response = await rag_service.generate_response(
db,
question,
session.project_id,
session.llm_config_id,
retrieved_docs,
conversation_history,
)
# 仅保留实际引用的文档,并把引用编号压缩为从 1 开始的连续序列。
assistant_response, cited_refs = _compact_cited_refs(
assistant_response, retrieved_docs
)
assistant_message = ChatMessage(
session_id=req.session_id,
role="assistant",
content=assistant_response,
referenced_files=json.dumps(cited_refs, ensure_ascii=False) if cited_refs else None,
)
db.add(assistant_message)
# 累加会话消息计数(用户 + 助手 共 2 条)
session.message_count = (session.message_count or 0) + 2
await db.commit()
return success_response(data={
"session_id": req.session_id,
"user_message": question,
"assistant_message": assistant_response,
"referenced_files": [ref["file_path"] for ref in cited_refs],
"references": _build_reference_items(cited_refs, session.project_id),
})
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=500,
detail=f"生成对话回复失败: {str(e)}"
)
@router.post("/send/stream", response_model=dict)
async def send_chat_message_stream(
req: ChatMessageRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""发送对话消息并流式返回回复"""
question = (req.message or "").strip()
if not question:
raise HTTPException(status_code=400, detail="消息内容不能为空")
stmt = select(ChatSession).where(ChatSession.id == req.session_id)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="对话会话不存在")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权访问该对话")
await require_project_read_access(db, session.project_id, current_user)
# 先读取历史消息(不含本轮提问),用于多轮上下文与判断是否首轮
msg_stmt = (
select(ChatMessage)
.where(ChatMessage.session_id == req.session_id)
.order_by(ChatMessage.created_at.desc())
.limit(CHAT_HISTORY_MESSAGE_LIMIT)
)
msg_result = await db.execute(msg_stmt)
prev_messages = list(reversed(msg_result.scalars().all()))
conversation_history = [
{"role": m.role, "content": m.content}
for m in prev_messages
]
retrieved_docs = await rag_service.retrieve_documents(
db,
session.project_id,
question,
top_k=5,
)
# 检索成功后立即持久化用户提问,并同时创建一条空的助手消息占位行。
# 二者都即时入库:这样即便流式输出中途刷新页面,刷新后也能看到提问,
# 以及助手已经生成的部分内容(边生成边回写到该占位行)。
query_message = ChatMessage(
session_id=req.session_id,
role="user",
content=question,
)
assistant_message = ChatMessage(
session_id=req.session_id,
role="assistant",
content="",
)
db.add_all([query_message, assistant_message])
session.message_count = (session.message_count or 0) + 2
await db.commit()
user_message_id = query_message.id
assistant_message_id = assistant_message.id
# 是否首轮对话(生成会话标题用)
is_first_exchange = len(conversation_history) == 0
llm_config_id = session.llm_config_id
project_id = session.project_id
# 每累计这么多字符就回写一次占位行,平衡「刷新可见性」与「写库频率」
FLUSH_EVERY_CHARS = 120
async def _persist_assistant(parts, *, completed):
"""把已生成内容回写到占位的助手消息行。
completed=True 时附带引用解析与标题生成False 表示流式中途的增量回写
"""
assistant_response = "".join(parts)
cited_refs = []
if completed:
assistant_response, cited_refs = _compact_cited_refs(
assistant_response, retrieved_docs
)
references = _build_reference_items(cited_refs, project_id)
msg = await db.get(ChatMessage, assistant_message_id)
if msg is not None:
msg.content = assistant_response
if completed:
msg.referenced_files = (
json.dumps(cited_refs, ensure_ascii=False) if cited_refs else None
)
new_title = None
if completed and is_first_exchange:
new_title = await _generate_session_title(
db, llm_config_id, question, assistant_response
)
# LLM 生成失败时回退为截断后的问题,确保侧边栏拿到有意义的标题
if not new_title:
fallback = (question or "").strip().splitlines()[0] if question else ""
new_title = (fallback[:24] + "...") if len(fallback) > 24 else fallback
if new_title:
sess = await db.get(ChatSession, req.session_id)
if sess is not None:
sess.title = new_title
await db.commit()
return assistant_response, references, new_title
async def event_generator():
assistant_response_parts = []
chars_since_flush = 0
finished = False
try:
# 先回传两条消息的真实 id使前端无需刷新即可获得删除入口等能力
yield _stream_event("ids", {
"session_id": req.session_id,
"user_message_id": user_message_id,
"assistant_message_id": assistant_message_id,
})
async for chunk in rag_service.generate_response_stream(
db,
question,
project_id,
llm_config_id,
retrieved_docs,
conversation_history,
):
assistant_response_parts.append(chunk)
chars_since_flush += len(chunk)
yield _stream_event("chunk", {"content": chunk})
# 边生成边落库:达到阈值就把当前进度回写到占位行
if chars_since_flush >= FLUSH_EVERY_CHARS:
chars_since_flush = 0
await _persist_assistant(assistant_response_parts, completed=False)
assistant_response, references, new_title = await _persist_assistant(
assistant_response_parts, completed=True
)
finished = True
yield _stream_event("references", references or [])
if new_title:
yield _stream_event("title", {
"session_id": req.session_id,
"title": new_title,
})
yield _stream_event("done", {
"session_id": req.session_id,
"user_message_id": user_message_id,
"assistant_message_id": assistant_message_id,
"content": assistant_response,
"references": references or [],
"title": new_title,
})
except (asyncio.CancelledError, GeneratorExit):
# 客户端中途断开(如刷新页面):尽力把已生成的部分回写到占位行。
# 由于提问与占位行已入库,刷新后至少能看到提问与已生成内容。
if not finished:
if not assistant_response_parts:
assistant_response_parts.append("回答生成已中断,请重新提问。")
try:
await db.rollback()
await _persist_assistant(assistant_response_parts, completed=False)
except Exception as save_exc: # noqa: BLE001
logger.warning("保存中断的助手回复失败: %s", save_exc)
raise
except Exception as exc:
await db.rollback()
if not assistant_response_parts:
assistant_response_parts.append("回答生成失败,请稍后重试。")
try:
await _persist_assistant(assistant_response_parts, completed=False)
except Exception as save_exc: # noqa: BLE001
logger.warning("保存失败的助手回复状态失败: %s", save_exc)
yield _stream_event("error", {"detail": f"生成对话回复失败: {str(exc)}"})
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.delete("/sessions/{session_id}", response_model=dict)
async def delete_chat_session(
session_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""删除对话会话"""
stmt = select(ChatSession).where(ChatSession.id == session_id)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="对话会话不存在")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权删除该对话")
await db.delete(session)
msg_stmt = delete(ChatMessage).where(ChatMessage.session_id == session_id)
await db.execute(msg_stmt)
await db.commit()
return success_response(message="对话会话已删除")
@router.delete("/messages/{message_id}", response_model=dict)
async def delete_chat_message(
message_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""删除单条对话消息"""
stmt = (
select(ChatMessage, ChatSession)
.join(ChatSession, ChatMessage.session_id == ChatSession.id)
.where(ChatMessage.id == message_id)
)
result = await db.execute(stmt)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="消息不存在")
msg, session = row
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权删除该消息")
await db.delete(msg)
if session.message_count and session.message_count > 0:
session.message_count = max(0, session.message_count - 1)
await db.commit()
return success_response(message="消息已删除")
@router.put("/sessions/{session_id}", response_model=dict)
async def update_chat_session(
session_id: int,
req: ChatSessionUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新对话会话标题"""
stmt = select(ChatSession).where(ChatSession.id == session_id)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="对话会话不存在")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权修改该对话")
title = (req.title or "").strip()
if not title:
raise HTTPException(status_code=400, detail="标题不能为空")
session.title = title
await db.commit()
return success_response(message="标题已更新", data={"session_id": session_id, "title": title})

View File

@ -12,13 +12,14 @@ import io
import mimetypes
import json
from pathlib import Path
from urllib.parse import quote
from app.core.database import get_db
from app.core.deps import get_current_user, get_user_from_token_or_query
from app.models.user import User
from app.models.project import Project, ProjectMember
from app.models.log import OperationLog
from app.models.share import ShareLink
from app.models.project import Project, ProjectMember
from app.schemas.file import (
FileTreeNode,
FileSaveRequest,
@ -26,10 +27,14 @@ from app.schemas.file import (
FileUploadResponse,
)
from app.schemas.response import success_response
from app.services.project_file_service import project_file_service
from app.services.storage import storage_service
from app.services.log_service import log_service
from app.services.notification_service import notification_service
from app.services.search_service import search_service
from app.services.project_service import (
get_project_or_404,
require_project_read_access,
require_project_write_access,
)
from app.core.enums import OperationType
router = APIRouter()
@ -80,8 +85,6 @@ def annotate_shared_files(tree: List[FileTreeNode], shared_paths: set[str]) -> N
node.is_shared = bool(node.isLeaf and node.key in shared_paths)
if node.children:
annotate_shared_files(node.children, shared_paths)
@router.get("/{project_id}/tree", response_model=dict)
async def get_project_tree(
project_id: int,
@ -89,7 +92,7 @@ async def get_project_tree(
db: AsyncSession = Depends(get_db)
):
"""获取项目目录树"""
project = await check_project_access(project_id, current_user, db)
project, user_role = await require_project_read_access(db, project_id, current_user)
# 获取项目根目录
project_root = storage_service.get_secure_path(project.storage_key)
@ -141,7 +144,7 @@ async def get_file_content(
db: AsyncSession = Depends(get_db)
):
"""获取文件内容"""
project = await check_project_access(project_id, current_user, db)
project, _ = await require_project_read_access(db, project_id, current_user)
# 获取文件路径
file_path = storage_service.get_secure_path(project.storage_key, path)
@ -161,43 +164,17 @@ async def save_file(
db: AsyncSession = Depends(get_db)
):
"""保存文件内容"""
project = await check_project_access(project_id, current_user, db, require_write=True)
# 获取文件路径
file_path = storage_service.get_secure_path(project.storage_key, file_data.path)
# 写入文件内容
await storage_service.write_file(file_path, file_data.content)
# 更新搜索索引 (仅限 Markdown)
if file_data.path.endswith('.md'):
file_title = Path(file_data.path).stem
await search_service.update_doc(project_id, file_data.path, file_title, file_data.content)
# 记录操作日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.SAVE_FILE,
project_id=project_id,
file_path=file_data.path,
user=current_user,
detail={"content_length": len(file_data.content)},
project, _ = await require_project_write_access(db, project_id, current_user)
message = await project_file_service.save_file(
db,
project_id,
project,
file_data.path,
file_data.content,
current_user,
request=request,
)
# 发送通知给其他成员
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title=f"项目文档更新",
content=f"项目 [{project.name}] 中的文档 [{file_data.path}] 已被 {current_user.nickname or current_user.username} 更新。",
link=f"/projects/{project_id}/docs?file={file_data.path}",
category="project"
)
await db.commit()
return success_response(message="文件保存成功")
return success_response(message=message)
@router.post("/{project_id}/file/operate", response_model=dict)
@ -209,185 +186,19 @@ async def operate_file(
db: AsyncSession = Depends(get_db)
):
"""文件操作(重命名、删除、创建目录、创建文件、移动)"""
project = await check_project_access(project_id, current_user, db, require_write=True)
# 获取当前路径
current_path = storage_service.get_secure_path(project.storage_key, operation.path)
if operation.action == "delete":
# 删除文件或文件夹
await storage_service.delete_file(current_path)
# 删除索引
if operation.path.endswith('.md'):
await search_service.remove_doc(project_id, operation.path)
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.DELETE_FILE,
project_id=project_id,
file_path=operation.path,
user=current_user,
request=request,
)
# 发送通知
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title=f"项目文档删除",
content=f"项目 [{project.name}] 中的文档/目录 [{operation.path}] 已被 {current_user.nickname or current_user.username} 删除。",
category="project"
)
await db.commit()
return success_response(message="删除成功")
elif operation.action == "rename":
# 重命名
if not operation.new_path:
raise HTTPException(status_code=400, detail="缺少新路径参数")
new_path = storage_service.get_secure_path(project.storage_key, operation.new_path)
await storage_service.rename_file(current_path, new_path)
# 更新索引 (删除旧的,添加新的 - 如果内容未变也需要重新读取内容吗?
# 优化Whoosh 更新需要内容。我们可以尝试读取文件内容。
# 如果是目录重命名,比较复杂,暂时忽略目录重命名的递归索引更新,或者后续实现重建索引功能)
if operation.path.endswith('.md') and operation.new_path.endswith('.md'):
# 简单处理:读取新文件内容并更新索引
try:
content = await storage_service.read_file(new_path)
file_title = Path(operation.new_path).stem
await search_service.remove_doc(project_id, operation.path)
await search_service.update_doc(project_id, operation.new_path, file_title, content)
except Exception as e:
# 忽略索引更新错误
pass
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.RENAME_FILE,
project_id=project_id,
file_path=operation.path,
user=current_user,
detail={"new_path": operation.new_path},
request=request,
)
# 发送通知
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title=f"项目文档重命名",
content=f"项目 [{project.name}] 中的文档 [{operation.path}] 已被重命名为 [{operation.new_path}]。",
category="project"
)
await db.commit()
return success_response(message="重命名成功")
elif operation.action == "move":
# 移动文件或文件夹
if not operation.new_path:
raise HTTPException(status_code=400, detail="缺少目标路径参数")
new_path = storage_service.get_secure_path(project.storage_key, operation.new_path)
await storage_service.rename_file(current_path, new_path)
# 更新索引
if operation.path.endswith('.md') and operation.new_path.endswith('.md'):
try:
content = await storage_service.read_file(new_path)
file_title = Path(operation.new_path).stem
await search_service.remove_doc(project_id, operation.path)
await search_service.update_doc(project_id, operation.new_path, file_title, content)
except Exception:
pass
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.MOVE_FILE,
project_id=project_id,
file_path=operation.path,
user=current_user,
detail={"new_path": operation.new_path},
request=request,
)
# 发送通知
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title=f"项目文档移动",
content=f"项目 [{project.name}] 中的文档 [{operation.path}] 已移动到 [{operation.new_path}]。",
category="project"
)
await db.commit()
return success_response(message="移动成功")
elif operation.action == "create_dir":
# 创建目录
await storage_service.create_directory(current_path)
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.CREATE_DIR,
project_id=project_id,
file_path=operation.path,
user=current_user,
request=request,
)
# 发送通知
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title=f"创建新目录",
content=f"{current_user.nickname or current_user.username} 在项目 [{project.name}] 中创建了新目录 [{operation.path}]。",
category="project"
)
await db.commit()
return success_response(message="目录创建成功")
elif operation.action == "create_file":
# 创建文件
content = operation.content or ""
await storage_service.write_file(current_path, content)
# 更新索引
if operation.path.endswith('.md'):
file_title = Path(operation.path).stem
await search_service.update_doc(project_id, operation.path, file_title, content)
# 记录日志
await log_service.log_file_operation(
db=db,
operation_type=OperationType.CREATE_FILE,
project_id=project_id,
file_path=operation.path,
user=current_user,
request=request,
)
# 发送通知
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title=f"创建新文档",
content=f"{current_user.nickname or current_user.username} 在项目 [{project.name}] 中创建了新文档 [{operation.path}]。",
link=f"/projects/{project_id}/docs?file={operation.path}",
category="project"
)
await db.commit()
return success_response(message="文件创建成功")
else:
raise HTTPException(status_code=400, detail="不支持的操作类型")
project, _ = await require_project_write_access(db, project_id, current_user)
message = await project_file_service.operate_file(
db,
project_id,
project,
operation.action,
operation.path,
current_user,
new_path=operation.new_path,
content=operation.content,
request=request,
)
return success_response(message=message)
@router.post("/{project_id}/upload", response_model=dict)
@ -400,7 +211,7 @@ async def upload_file(
db: AsyncSession = Depends(get_db)
):
"""上传文件(图片/附件)"""
project = await check_project_access(project_id, current_user, db, require_write=True)
project, _ = await require_project_write_access(db, project_id, current_user)
# 上传文件
file_info = await storage_service.upload_file(
@ -441,7 +252,7 @@ async def upload_document(
"""
上传文档文件PDF等到项目目录
"""
project = await check_project_access(project_id, current_user, db, require_write=True)
project, _ = await require_project_write_access(db, project_id, current_user)
# 只允许PDF文件
allowed_extensions = [".pdf"]
@ -486,7 +297,7 @@ async def get_document_file(
import re
import aiofiles
project = await check_project_access(project_id, current_user, db)
project, _ = await require_project_read_access(db, project_id, current_user)
# 获取文件路径
file_path = storage_service.get_secure_path(project.storage_key, path)
@ -557,12 +368,7 @@ async def get_asset_file(
db: AsyncSession = Depends(get_db)
):
"""获取资源文件(公开访问,支持分享)"""
# 验证项目是否存在
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
project = await get_project_or_404(db, project_id)
# 获取文件路径
asset_path = f"_assets/{subfolder}/{filename}"
@ -594,7 +400,7 @@ async def import_documents(
db: AsyncSession = Depends(get_db)
):
"""批量导入Markdown文档"""
project = await check_project_access(project_id, current_user, db, require_write=True)
project, _ = await require_project_write_access(db, project_id, current_user)
# 验证所有文件都是.md格式
for file in files:
@ -659,12 +465,12 @@ async def export_directory(
db: AsyncSession = Depends(get_db)
):
"""导出目录为ZIP包"""
project = await check_project_access(project_id, current_user, db)
project, _ = await require_project_read_access(db, project_id, current_user)
# 获取目标目录路径
source_dir = storage_service.get_secure_path(project.storage_key, directory_path)
if not source_dir.exists():
if not source_dir.exists() or not source_dir.is_dir():
raise HTTPException(status_code=404, detail="目录不存在")
# 创建ZIP文件在内存中
@ -684,7 +490,13 @@ async def export_directory(
zip_buffer.seek(0)
# 生成ZIP文件名
zip_filename = f"{project.name}_{directory_path.replace('/', '_') if directory_path else 'root'}.zip"
safe_project_name = project.name.replace("/", "_").replace("\\", "_")
zip_filename = (
f"{safe_project_name}.zip"
if not directory_path
else f"{safe_project_name}_{directory_path.replace('/', '_')}.zip"
)
encoded_zip_filename = quote(zip_filename)
# 记录日志
await log_service.log_file_operation(
@ -704,7 +516,7 @@ async def export_directory(
zip_buffer,
media_type="application/zip",
headers={
"Content-Disposition": f"attachment; filename={zip_filename}"
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_zip_filename}"
}
)
@ -719,10 +531,9 @@ async def export_pdf(
):
"""已登录用户导出 Markdown 为 PDF"""
import re
from urllib.parse import quote
from app.services.pdf_service import pdf_service
project = await check_project_access(project_id, current_user, db)
project, _ = await require_project_read_access(db, project_id, current_user)
file_path = storage_service.get_secure_path(project.storage_key, path)
if not file_path.exists() or not file_path.is_file():
@ -735,7 +546,10 @@ async def export_pdf(
content = re.sub(r'/api/v1/files/\d+/assets/', '_assets/', content)
project_root = storage_service.get_secure_path(project.storage_key)
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
try:
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
encoded_filename = quote(filename)
return StreamingResponse(

View File

@ -4,49 +4,20 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func
from typing import List
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.project import Project, ProjectMember
from app.models.git_repo import ProjectGitRepo
from app.schemas.git_repo import GitRepoCreate, GitRepoUpdate, GitRepoResponse
from app.schemas.response import success_response
from app.services.log_service import log_service
from app.services.project_service import require_project_read_access, require_project_roles
from app.core.enums import OperationType
router = APIRouter()
async def check_project_permission(db: AsyncSession, project_id: int, user_id: int, required_roles: list = None):
"""检查项目权限"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 如果是所有者,直接通过
if project.owner_id == user_id:
return project
# 如果指定了角色要求
if required_roles:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
ProjectMember.role.in_(required_roles)
)
)
if not member_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="无权执行此操作")
return project
@router.get("/projects/{project_id}/git-repos", response_model=dict)
async def get_project_git_repos(
project_id: int,
@ -54,10 +25,7 @@ async def get_project_git_repos(
db: AsyncSession = Depends(get_db)
):
"""获取项目的Git仓库列表"""
# 检查权限(查看权限即可)
# 这里稍微放宽一点只要能访问项目就能看Git配置
# 为了安全,还是限制为成员
await check_project_permission(db, project_id, current_user.id, ['admin', 'editor', 'viewer'])
await require_project_read_access(db, project_id, current_user)
result = await db.execute(
select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id).order_by(ProjectGitRepo.created_at)
@ -83,7 +51,12 @@ async def create_git_repo(
db: AsyncSession = Depends(get_db)
):
"""添加Git仓库"""
await check_project_permission(db, project_id, current_user.id, ['admin', 'editor'])
await require_project_roles(
db,
project_id,
current_user,
allowed_roles=["admin", "editor"],
)
# 如果是设为默认,先取消其他默认
if repo_in.is_default:
@ -126,7 +99,12 @@ async def update_git_repo(
db: AsyncSession = Depends(get_db)
):
"""更新Git仓库"""
await check_project_permission(db, project_id, current_user.id, ['admin', 'editor'])
await require_project_roles(
db,
project_id,
current_user,
allowed_roles=["admin", "editor"],
)
result = await db.execute(select(ProjectGitRepo).where(ProjectGitRepo.id == repo_id, ProjectGitRepo.project_id == project_id))
repo = result.scalar_one_or_none()
@ -160,7 +138,12 @@ async def delete_git_repo(
db: AsyncSession = Depends(get_db)
):
"""删除Git仓库"""
await check_project_permission(db, project_id, current_user.id, ['admin', 'editor'])
await require_project_roles(
db,
project_id,
current_user,
allowed_roles=["admin", "editor"],
)
result = await db.execute(select(ProjectGitRepo).where(ProjectGitRepo.id == repo_id, ProjectGitRepo.project_id == project_id))
repo = result.scalar_one_or_none()

View File

@ -0,0 +1,434 @@
"""
LLM 模型配置 API
"""
from decimal import Decimal
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.llm_model_config import LLMModelConfig
from app.models.user import User
from app.schemas.response import success_response
from app.services.llm_provider_service import LLMProviderService
router = APIRouter()
class LLMModelConfigUpsertRequest(BaseModel):
"""模型配置新增/编辑请求"""
model_code: Optional[str] = None
model_name: Optional[str] = None
model_type: str = Field("chat", pattern="^(chat|embedding)$")
provider: str = Field(..., min_length=1, max_length=64)
endpoint_url: Optional[str] = Field(None, max_length=512)
api_key: Optional[str] = Field(None, max_length=512)
llm_model_name: str = Field(..., min_length=1, max_length=128)
llm_timeout: int = Field(120, ge=5, le=600)
llm_temperature: float = Field(0.70, ge=0, le=2)
llm_top_p: float = Field(0.90, ge=0, le=1)
llm_max_tokens: int = Field(8192, ge=1, le=32768)
llm_system_prompt: Optional[str] = None
embedding_dimension: Optional[int] = Field(None, ge=1, le=8192)
description: Optional[str] = Field(None, max_length=500)
is_active: bool = True
is_default: bool = False
@field_validator(
"model_code",
"model_name",
"provider",
"endpoint_url",
"api_key",
"llm_model_name",
"llm_system_prompt",
"description",
mode="before",
)
@classmethod
def strip_string_fields(cls, value):
if isinstance(value, str):
value = value.strip()
return value or None
return value
class LLMModelConfigTestRequest(LLMModelConfigUpsertRequest):
"""模型测试请求"""
def serialize_model_config(config: LLMModelConfig, include_api_key: bool = False) -> dict:
"""序列化模型配置"""
api_key = config.api_key or ""
data = {
"config_id": config.config_id,
"model_code": config.model_code,
"model_name": config.model_name,
"model_type": config.model_type or "chat",
"provider": config.provider,
"endpoint_url": config.endpoint_url,
"llm_model_name": config.llm_model_name,
"llm_timeout": config.llm_timeout,
"llm_temperature": float(config.llm_temperature or 0),
"llm_top_p": float(config.llm_top_p or 0),
"llm_max_tokens": config.llm_max_tokens,
"llm_system_prompt": config.llm_system_prompt,
"embedding_dimension": config.embedding_dimension,
"description": config.description,
"is_active": bool(config.is_active),
"is_default": bool(config.is_default),
"has_api_key": bool(api_key),
"api_key_masked": mask_api_key(api_key),
"created_at": config.created_at.isoformat() if config.created_at else None,
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
}
if include_api_key:
data["api_key"] = api_key
return data
def mask_api_key(api_key: str) -> str:
"""脱敏 API Key"""
if not api_key:
return ""
if len(api_key) <= 8:
return "*" * len(api_key)
return f"{api_key[:4]}{'*' * (len(api_key) - 8)}{api_key[-4:]}"
def normalize_payload(payload: LLMModelConfigUpsertRequest) -> dict:
"""补齐自动生成字段"""
data = payload.model_dump()
provider = data["provider"]
llm_model_name = data["llm_model_name"]
model_type = data.get("model_type") or "chat"
data["model_type"] = model_type
data["model_name"] = data.get("model_name") or LLMProviderService.build_model_name(provider, llm_model_name)
base_code = data.get("model_code") or LLMProviderService.build_model_code(provider, llm_model_name)
# embedding 类型加前缀,避免与同名对话模型编码冲突
if model_type == "embedding" and not base_code.startswith("emb_"):
base_code = f"emb_{base_code}"
data["model_code"] = base_code
data["endpoint_url"] = data.get("endpoint_url") or LLMProviderService.get_default_endpoint_url(provider)
if data["is_default"]:
data["is_active"] = True
data["llm_temperature"] = Decimal(str(data["llm_temperature"]))
data["llm_top_p"] = Decimal(str(data["llm_top_p"]))
# embedding 不需要对话采样参数,但列非空,保留默认值即可
if model_type == "embedding":
data["llm_system_prompt"] = None
return data
async def ensure_default_config(db: AsyncSession, preferred_config_id: Optional[int] = None):
"""确保始终存在一个默认启用模型"""
default_result = await db.execute(
select(LLMModelConfig.config_id).where(
LLMModelConfig.is_default == True,
LLMModelConfig.is_active == True,
)
)
if default_result.scalar_one_or_none():
return
candidate_id = None
if preferred_config_id:
candidate_result = await db.execute(
select(LLMModelConfig.config_id).where(
LLMModelConfig.config_id == preferred_config_id,
LLMModelConfig.is_active == True,
)
)
candidate_id = candidate_result.scalar_one_or_none()
if candidate_id is None:
fallback_result = await db.execute(
select(LLMModelConfig.config_id)
.where(LLMModelConfig.is_active == True)
.order_by(LLMModelConfig.updated_at.desc(), LLMModelConfig.config_id.desc())
.limit(1)
)
candidate_id = fallback_result.scalar_one_or_none()
if candidate_id is None:
return
await db.execute(update(LLMModelConfig).values(is_default=False))
await db.execute(
update(LLMModelConfig)
.where(LLMModelConfig.config_id == candidate_id)
.values(is_default=True)
)
@router.get("/providers", response_model=dict)
async def get_provider_catalog(
current_user: User = Depends(get_current_user),
):
"""获取模型提供方目录"""
return success_response(data=LLMProviderService.get_provider_catalog())
@router.get("/", response_model=dict)
async def get_llm_model_configs(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
keyword: Optional[str] = Query(None, description="搜索关键词(模型名称、编码、模型名)"),
provider: Optional[str] = Query(None, description="提供方筛选"),
model_type: Optional[str] = Query(None, description="模型类型筛选: chat/embedding"),
is_active: Optional[bool] = Query(None, description="启用状态筛选"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取模型配置列表"""
conditions = []
if keyword:
conditions.append(
or_(
LLMModelConfig.model_name.like(f"%{keyword}%"),
LLMModelConfig.model_code.like(f"%{keyword}%"),
LLMModelConfig.llm_model_name.like(f"%{keyword}%"),
)
)
if provider:
conditions.append(LLMModelConfig.provider == provider)
if model_type:
conditions.append(LLMModelConfig.model_type == model_type)
if is_active is not None:
conditions.append(LLMModelConfig.is_active == is_active)
count_query = select(func.count(LLMModelConfig.config_id))
if conditions:
count_query = count_query.where(*conditions)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
query = select(LLMModelConfig).order_by(
LLMModelConfig.is_default.desc(),
LLMModelConfig.updated_at.desc(),
LLMModelConfig.config_id.desc(),
)
if conditions:
query = query.where(*conditions)
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
configs = result.scalars().all()
return {
"code": 200,
"message": "success",
"data": [serialize_model_config(item) for item in configs],
"total": total,
"page": page,
"page_size": page_size,
}
@router.get("/{config_id}", response_model=dict)
async def get_llm_model_config_detail(
config_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取模型配置详情"""
result = await db.execute(
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="模型配置不存在")
return success_response(data=serialize_model_config(config, include_api_key=True))
@router.post("/", response_model=dict)
async def create_llm_model_config(
request_data: LLMModelConfigUpsertRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建模型配置"""
payload = normalize_payload(request_data)
existing_code_result = await db.execute(
select(LLMModelConfig).where(LLMModelConfig.model_code == payload["model_code"])
)
if existing_code_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="模型编码已存在")
new_config = LLMModelConfig(**payload)
db.add(new_config)
await db.flush()
if payload["is_default"]:
await db.execute(
update(LLMModelConfig)
.where(LLMModelConfig.config_id != new_config.config_id)
.values(is_default=False)
)
await ensure_default_config(db, preferred_config_id=new_config.config_id)
await db.commit()
await db.refresh(new_config)
return success_response(
data=serialize_model_config(new_config),
message="模型配置创建成功",
)
@router.put("/{config_id}", response_model=dict)
async def update_llm_model_config(
config_id: int,
request_data: LLMModelConfigUpsertRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新模型配置"""
result = await db.execute(
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="模型配置不存在")
payload = normalize_payload(request_data)
existing_code_result = await db.execute(
select(LLMModelConfig).where(
LLMModelConfig.model_code == payload["model_code"],
LLMModelConfig.config_id != config_id,
)
)
if existing_code_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="模型编码已被其他配置使用")
for key, value in payload.items():
setattr(config, key, value)
await db.flush()
if config.is_default:
await db.execute(
update(LLMModelConfig)
.where(LLMModelConfig.config_id != config.config_id)
.values(is_default=False)
)
await ensure_default_config(db, preferred_config_id=config.config_id)
await db.commit()
await db.refresh(config)
return success_response(
data=serialize_model_config(config),
message="模型配置更新成功",
)
@router.put("/{config_id}/status", response_model=dict)
async def update_llm_model_config_status(
config_id: int,
is_active: bool = Query(..., description="是否启用"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新模型配置启用状态"""
result = await db.execute(
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="模型配置不存在")
config.is_active = is_active
if not is_active and config.is_default:
config.is_default = False
await db.flush()
await ensure_default_config(db)
await db.commit()
await db.refresh(config)
return success_response(
data=serialize_model_config(config),
message="模型状态更新成功",
)
@router.put("/{config_id}/default", response_model=dict)
async def set_default_llm_model_config(
config_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""设为默认模型配置"""
result = await db.execute(
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="模型配置不存在")
config.is_active = True
config.is_default = True
await db.flush()
await db.execute(
update(LLMModelConfig)
.where(LLMModelConfig.config_id != config.config_id)
.values(is_default=False)
)
await db.commit()
await db.refresh(config)
return success_response(
data=serialize_model_config(config),
message="默认模型切换成功",
)
@router.delete("/{config_id}", response_model=dict)
async def delete_llm_model_config(
config_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""删除模型配置"""
result = await db.execute(
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="模型配置不存在")
was_default = bool(config.is_default)
await db.delete(config)
await db.flush()
if was_default:
await ensure_default_config(db)
await db.commit()
return success_response(message="模型配置删除成功")
@router.post("/test", response_model=dict)
async def test_llm_model_config(
request_data: LLMModelConfigTestRequest,
current_user: User = Depends(get_current_user),
):
"""测试模型连接按类型分流chat 走对话补全embedding 走向量接口)"""
payload = normalize_payload(request_data)
try:
if payload.get("model_type") == "embedding":
test_result = await LLMProviderService.test_embedding_connection(payload)
else:
test_result = await LLMProviderService.test_model_connection(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data=test_result, message="模型测试成功")

View File

@ -14,46 +14,21 @@ from app.core.database import get_db
from app.core.deps import get_current_user_optional, security_optional
from app.core.security import decode_access_token
from app.core.redis_client import TokenCache
from app.models.project import Project, ProjectMember
from app.models.user import User
from app.schemas.response import success_response
from app.services.project_service import (
get_project_or_404,
require_project_read_access,
)
from app.services.storage import storage_service
from app.services.pdf_service import pdf_service
router = APIRouter()
async def check_preview_access(
project: Project,
current_user: Optional[User],
db: AsyncSession
):
"""检查预览访问权限"""
# 公开项目:任何人都可以访问
if project.is_public == 1:
return True
# 私密项目:必须是项目成员
if not current_user:
raise HTTPException(status_code=401, detail="私密项目需要登录才能访问")
# 检查是否是项目所有者
if project.owner_id == current_user.id:
return True
# 检查是否是项目成员
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project.id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=403, detail="无权访问该私密项目")
return True
def verify_project_password(project, provided_password: Optional[str]) -> None:
"""校验预览密码。"""
if project.access_pass and project.access_pass != provided_password:
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
@router.get("/{project_id}/info", response_model=dict)
@ -63,15 +38,14 @@ async def get_preview_info(
db: AsyncSession = Depends(get_db)
):
"""获取预览项目基本信息"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查访问权限
await check_preview_access(project, current_user, db)
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
unauthenticated_detail="私密项目需要登录才能访问",
forbidden_detail="无权访问该私密项目",
)
# 返回基本信息
info = {
@ -93,15 +67,14 @@ async def verify_access_password(
db: AsyncSession = Depends(get_db)
):
"""验证访问密码"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查访问权限
await check_preview_access(project, current_user, db)
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
unauthenticated_detail="私密项目需要登录才能访问",
forbidden_detail="无权访问该私密项目",
)
# 验证密码
if not project.access_pass:
@ -121,20 +94,15 @@ async def get_preview_tree(
db: AsyncSession = Depends(get_db)
):
"""获取预览项目的文档树"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查访问权限
await check_preview_access(project, current_user, db)
# 如果设置了密码,需要验证
if project.access_pass:
if not password or project.access_pass != password:
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
unauthenticated_detail="私密项目需要登录才能访问",
forbidden_detail="无权访问该私密项目",
)
verify_project_password(project, password)
# 获取文档树
project_path = storage_service.get_secure_path(project.storage_key)
@ -152,20 +120,15 @@ async def get_preview_file(
db: AsyncSession = Depends(get_db)
):
"""获取预览项目的文件内容"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查访问权限
await check_preview_access(project, current_user, db)
# 如果设置了密码,需要验证
if project.access_pass:
if not password or project.access_pass != password:
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
unauthenticated_detail="私密项目需要登录才能访问",
forbidden_detail="无权访问该私密项目",
)
verify_project_password(project, password)
# 获取文件内容
file_path = storage_service.get_secure_path(project.storage_key, path)
@ -208,21 +171,18 @@ async def get_preview_document(
except Exception:
pass # 忽略token验证失败继续作为未登录用户
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查访问权限
await check_preview_access(project, current_user, db)
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
unauthenticated_detail="私密项目需要登录才能访问",
forbidden_detail="无权访问该私密项目",
)
# 如果设置了密码需要验证优先使用header其次使用query参数
provided_password = password or access_pass
if project.access_pass:
if not provided_password or project.access_pass != provided_password:
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
verify_project_password(project, provided_password)
# 获取文件
file_path = storage_service.get_secure_path(project.storage_key, path)
@ -245,6 +205,8 @@ async def export_preview_pdf(
db: AsyncSession = Depends(get_db)
):
"""导出预览项目的文档为 PDF"""
from app.services.pdf_service import pdf_service
# 获取当前用户支持header或query参数
current_user = None
token_str = None
@ -268,21 +230,18 @@ async def export_preview_pdf(
except Exception:
pass
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查访问权限
await check_preview_access(project, current_user, db)
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
unauthenticated_detail="私密项目需要登录才能访问",
forbidden_detail="无权访问该私密项目",
)
# 验证密码
provided_password = password or access_pass
if project.access_pass:
if not provided_password or project.access_pass != provided_password:
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
verify_project_password(project, provided_password)
# 获取文件
file_path = storage_service.get_secure_path(project.storage_key, path)
@ -300,7 +259,10 @@ async def export_preview_pdf(
# 生成 PDF 字节流,传入项目根目录作为 base_url
project_root = storage_service.get_secure_path(project.storage_key)
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
try:
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
# 中文文件名需要 RFC 5987 编码
from urllib.parse import quote
@ -314,4 +276,3 @@ async def export_preview_pdf(
"Content-Type": "application/pdf"
}
)

View File

@ -12,6 +12,7 @@ from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.project import Project, ProjectMember
from app.services.project_service import require_project_read_access
from app.services.search_service import search_service
from app.services.storage import storage_service
from app.schemas.response import success_response
@ -37,24 +38,12 @@ async def search_documents(
allowed_project_ids = []
if project_id:
# 检查指定项目的访问权限
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查权限
if project.owner_id != current_user.id and project.is_public != 1:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
if not member_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="无权访问该项目")
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
)
allowed_project_ids.append(str(project_id))
else:
# 获取所有可访问的项目
@ -267,4 +256,4 @@ async def rebuild_index(
background_tasks.add_task(rebuild_index_task, db)
return success_response(message="索引重建任务已启动")
return success_response(message="索引重建任务已启动")

View File

@ -69,6 +69,10 @@ class Settings(BaseSettings):
# 头像上传配置
AVATAR_MAX_SIZE: int = 1 * 1024 * 1024 # 1MB
# RAG 分块配置
CHUNK_SIZE: int = 800 # 单个文档分块的字符数
CHUNK_OVERLAP: int = 150 # 相邻分块之间的重叠字符数
# 跨域配置
CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"]

View File

@ -21,14 +21,14 @@ from app.models.mcp_bot import MCPBot
from app.models.project import Project, ProjectMember
from app.models.user import User
from app.schemas.project import ProjectResponse
from app.services.notification_service import notification_service
from app.services.search_service import search_service
from app.services.project_file_service import project_file_service
from app.services.storage import storage_service
from app.services.log_service import log_service
from app.api.v1.projects import get_document_count
from app.api.v1.files import check_project_access
from app.services.project_service import (
count_project_documents,
require_project_read_access,
require_project_write_access,
)
from app.core.config import settings
from app.core.enums import OperationType
from app.mcp.context import MCPRequestContext, current_mcp_request
@ -59,7 +59,8 @@ async def _get_current_user(db) -> User:
async def _get_project_with_write_access(project_id: int, current_user: User, db):
return await check_project_access(project_id, current_user, db, require_write=True)
project, _ = await require_project_write_access(db, project_id, current_user)
return project
def _ensure_file_exists(file_path: Path, path: str) -> None:
@ -73,17 +74,6 @@ def _ensure_file_not_exists(file_path: Path, path: str) -> None:
if file_path.exists():
raise HTTPException(status_code=400, detail=f"文件已存在: {path}")
async def _update_markdown_index(project_id: int, path: str, content: str) -> None:
if path.endswith(".md"):
await search_service.update_doc(project_id, path, Path(path).stem, content)
async def _remove_markdown_index(project_id: int, path: str) -> None:
if path.endswith(".md"):
await search_service.remove_doc(project_id, path)
if mcp is not None:
@mcp.tool(name="list_created_projects", description="Get projects created by the authenticated user.")
async def list_created_projects(keyword: str = "", limit: int = 100) -> List[Dict[str, Any]]:
@ -98,7 +88,7 @@ if mcp is not None:
keyword_lower = keyword.strip().lower()
for project in projects:
project_dict = ProjectResponse.from_orm(project).dict()
project_dict["doc_count"] = get_document_count(project.storage_key)
project_dict["doc_count"] = count_project_documents(project.storage_key)
if keyword_lower:
haystack = f"{project.name} {project.description or ''}".lower()
if keyword_lower not in haystack:
@ -112,22 +102,10 @@ if mcp is not None:
async def get_project_tree(project_id: int) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await check_project_access(project_id, current_user, db)
project, user_role = await require_project_read_access(db, project_id, current_user)
project_root = storage_service.get_secure_path(project.storage_key)
tree = storage_service.generate_tree(project_root)
user_role = "owner"
if project.owner_id != current_user.id:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id,
)
)
member = member_result.scalar_one_or_none()
if member:
user_role = member.role
return {
"tree": [item.model_dump() for item in tree],
"user_role": user_role,
@ -140,7 +118,7 @@ if mcp is not None:
async def get_file(project_id: int, path: str) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await check_project_access(project_id, current_user, db)
project, _ = await require_project_read_access(db, project_id, current_user)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_exists(file_path, path)
content = await storage_service.read_file(file_path)
@ -154,30 +132,15 @@ if mcp is not None:
project = await _get_project_with_write_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_not_exists(file_path, path)
await storage_service.write_file(file_path, content)
await _update_markdown_index(project_id, path, content)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.CREATE_FILE,
project_id=project_id,
file_path=path,
user=current_user,
detail={"content_length": len(content), "source": "mcp"},
request=None,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title="项目文档创建",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {current_user.nickname or current_user.username} 通过 MCP 创建。"
),
link=f"/projects/{project_id}/docs?file={path}",
category="project",
await project_file_service.operate_file(
db,
project_id,
project,
"create_file",
path,
current_user,
content=content,
source="mcp",
)
return {
@ -194,30 +157,14 @@ if mcp is not None:
project = await _get_project_with_write_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_exists(file_path, path)
await storage_service.write_file(file_path, content)
await _update_markdown_index(project_id, path, content)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.SAVE_FILE,
project_id=project_id,
file_path=path,
user=current_user,
detail={"content_length": len(content), "source": "mcp"},
request=None,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title="项目文档更新",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {current_user.nickname or current_user.username} 通过 MCP 更新。"
),
link=f"/projects/{project_id}/docs?file={path}",
category="project",
await project_file_service.save_file(
db,
project_id,
project,
path,
content,
current_user,
source="mcp",
)
return {
@ -234,29 +181,14 @@ if mcp is not None:
project = await _get_project_with_write_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_exists(file_path, path)
await storage_service.delete_file(file_path)
await _remove_markdown_index(project_id, path)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.DELETE_FILE,
project_id=project_id,
file_path=path,
user=current_user,
detail={"source": "mcp"},
request=None,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title="项目文档删除",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {current_user.nickname or current_user.username} 通过 MCP 删除。"
),
category="project",
await project_file_service.operate_file(
db,
project_id,
project,
"delete",
path,
current_user,
source="mcp",
)
return {

View File

@ -7,9 +7,13 @@ from app.models.role import Role, UserRole
from app.models.menu import SystemMenu, RoleMenu
from app.models.project import Project, ProjectMember, ProjectMemberRole
from app.models.document import DocumentMeta
from app.models.document_vector import DocumentVector
from app.models.share import ShareLink
from app.models.log import OperationLog
from app.models.mcp_bot import MCPBot
from app.models.llm_model_config import LLMModelConfig
from app.models.chat_session import ChatSession, ChatMessage
from app.models.project_vectorization_task import ProjectVectorizationTask
__all__ = [
"Base",
@ -22,7 +26,12 @@ __all__ = [
"ProjectMember",
"ProjectMemberRole",
"DocumentMeta",
"DocumentVector",
"ShareLink",
"OperationLog",
"MCPBot",
"LLMModelConfig",
"ChatSession",
"ChatMessage",
"ProjectVectorizationTask",
]

View File

@ -0,0 +1,44 @@
"""
知识库对话会话模型
"""
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, Text, Boolean
from sqlalchemy.sql import func
from app.core.database import Base
class ChatSession(Base):
"""对话会话表"""
__tablename__ = "chat_session"
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="会话ID")
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
user_id = Column(BigInteger, nullable=False, index=True, comment="用户ID")
llm_config_id = Column(BigInteger, nullable=False, comment="LLM配置ID")
title = Column(String(255), nullable=False, comment="会话标题")
description = Column(Text, comment="会话描述")
is_active = Column(Boolean, nullable=False, default=True, comment="是否激活")
message_count = Column(Integer, nullable=False, default=0, comment="消息数")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<ChatSession(id={self.id}, project_id={self.project_id}, user_id={self.user_id})>"
class ChatMessage(Base):
"""对话消息表"""
__tablename__ = "chat_message"
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="消息ID")
session_id = Column(BigInteger, nullable=False, index=True, comment="会话ID")
role = Column(String(32), nullable=False, comment="角色(user/assistant)")
content = Column(Text, nullable=False, comment="消息内容")
referenced_files = Column(Text, comment="参考文件(JSON数组)")
tokens_used = Column(Integer, comment="消耗的token数")
is_deleted = Column(Boolean, nullable=False, default=False, comment="是否已删除")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
def __repr__(self):
return f"<ChatMessage(id={self.id}, session_id={self.session_id}, role='{self.role}')>"

View File

@ -0,0 +1,36 @@
"""
文档向量化模型
"""
from sqlalchemy import Column, BigInteger, Integer, String, DateTime, Index, Text
from sqlalchemy.sql import func
from app.core.database import Base
class DocumentVector(Base):
"""文档向量表模型"""
__tablename__ = "document_vector"
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="向量ID")
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
file_path = Column(String(500), nullable=False, comment="文件相对路径")
chunk_index = Column(Integer, nullable=False, default=0, comment="分块序号0起同一文件可有多个分块")
chunk_text = Column(Text, comment="分块首段文本,作为点击引用时的定位锚点")
content_hash = Column(String(64), comment="整个文件内容哈希值,用于判断文件是否变更")
zvec_id = Column(String(256), comment="ZVec返回的向量ID每个分块独立")
zvec_response = Column(Text, comment="ZVec完整响应JSON")
status = Column(String(32), nullable=False, default="success", comment="向量化状态success/failed/pending")
error_message = Column(String(500), comment="错误信息")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
__table_args__ = (
Index("idx_project_file", "project_id", "file_path"),
Index("idx_project_file_chunk", "project_id", "file_path", "chunk_index"),
)
def __repr__(self):
return (
f"<DocumentVector(id={self.id}, project_id={self.project_id}, "
f"file_path='{self.file_path}', chunk_index={self.chunk_index}, status='{self.status}')>"
)

View File

@ -0,0 +1,36 @@
"""
LLM 模型配置模型
"""
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, Text, Numeric, Boolean
from sqlalchemy.sql import func
from app.core.database import Base
class LLMModelConfig(Base):
"""大模型配置表模型"""
__tablename__ = "llm_model_config"
config_id = Column(BigInteger, primary_key=True, autoincrement=True, comment="配置ID")
model_code = Column(String(128), nullable=False, unique=True, index=True, comment="模型编码")
model_name = Column(String(255), nullable=False, comment="模型名称")
model_type = Column(String(32), nullable=False, default="chat", index=True, comment="模型类型: chat/embedding")
provider = Column(String(64), comment="模型提供方")
endpoint_url = Column(String(512), comment="接口地址")
api_key = Column(String(512), comment="API Key")
llm_model_name = Column(String(128), nullable=False, comment="模型名称/部署名")
llm_timeout = Column(Integer, nullable=False, default=120, comment="超时时间(秒)")
llm_temperature = Column(Numeric(5, 2), nullable=False, default=0.70, comment="温度")
llm_top_p = Column(Numeric(5, 2), nullable=False, default=0.90, comment="Top P")
llm_max_tokens = Column(Integer, nullable=False, default=8192, comment="最大输出 Token")
llm_system_prompt = Column(Text, comment="系统提示词")
embedding_dimension = Column(Integer, nullable=True, default=None, comment="向量维度(仅 embedding 类型)")
description = Column(String(500), comment="描述")
is_active = Column(Boolean, nullable=False, default=True, index=True, comment="是否启用")
is_default = Column(Boolean, nullable=False, default=False, comment="是否默认")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<LLMModelConfig(config_id={self.config_id}, model_code='{self.model_code}')>"

View File

@ -0,0 +1,37 @@
"""
项目向量化任务模型
"""
from sqlalchemy import BigInteger, Column, DateTime, Index, Integer, String, Text
from sqlalchemy.sql import func
from app.core.database import Base
class ProjectVectorizationTask(Base):
"""项目向量化后台任务表"""
__tablename__ = "project_vectorization_task"
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="任务ID")
task_id = Column(String(64), nullable=False, unique=True, index=True, comment="任务唯一标识")
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
user_id = Column(BigInteger, nullable=False, index=True, comment="触发用户ID")
task_type = Column(String(32), nullable=False, comment="任务类型incremental/full")
status = Column(String(32), nullable=False, default="pending", index=True, comment="任务状态pending/running/success/failed")
total = Column(Integer, nullable=False, default=0, comment="文件总数")
processed = Column(Integer, nullable=False, default=0, comment="处理成功数")
skipped = Column(Integer, nullable=False, default=0, comment="跳过数")
failed = Column(Integer, nullable=False, default=0, comment="失败数")
error_message = Column(Text, comment="错误信息")
started_at = Column(DateTime, comment="开始时间")
finished_at = Column(DateTime, comment="完成时间")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
__table_args__ = (
Index("idx_vector_task_project_status", "project_id", "status"),
Index("idx_vector_task_created_at", "created_at"),
)
def __repr__(self):
return f"<ProjectVectorizationTask(task_id='{self.task_id}', project_id={self.project_id}, status='{self.status}')>"

View File

@ -0,0 +1,924 @@
"""
LLM 提供方测试服务
"""
import asyncio
import os
import queue
import ssl
import json
import socket
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional
PROVIDER_CATALOG: List[Dict[str, str]] = [
{
"value": "openai",
"label": "OpenAI",
"default_endpoint_url": "https://api.openai.com/v1",
"protocol": "openai_compatible",
},
{
"value": "deepseek",
"label": "DeepSeek",
"default_endpoint_url": "https://api.deepseek.com/v1",
"protocol": "openai_compatible",
},
{
"value": "anthropic",
"label": "Anthropic",
"default_endpoint_url": "https://api.anthropic.com",
"protocol": "anthropic",
},
{
"value": "gemini",
"label": "Google Gemini",
"default_endpoint_url": "https://generativelanguage.googleapis.com/v1beta",
"protocol": "gemini",
},
{
"value": "dashscope",
"label": "阿里百炼",
"default_endpoint_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"protocol": "openai_compatible",
},
{
"value": "zhipu",
"label": "智谱 AI",
"default_endpoint_url": "https://open.bigmodel.cn/api/paas/v4",
"protocol": "openai_compatible",
},
{
"value": "moonshot",
"label": "Moonshot AI",
"default_endpoint_url": "https://api.moonshot.cn/v1",
"protocol": "openai_compatible",
},
{
"value": "groq",
"label": "Groq",
"default_endpoint_url": "https://api.groq.com/openai/v1",
"protocol": "openai_compatible",
},
{
"value": "openrouter",
"label": "OpenRouter",
"default_endpoint_url": "https://openrouter.ai/api/v1",
"protocol": "openai_compatible",
},
{
"value": "siliconflow",
"label": "SiliconFlow",
"default_endpoint_url": "https://api.siliconflow.cn/v1",
"protocol": "openai_compatible",
},
{
"value": "ollama",
"label": "Ollama",
"default_endpoint_url": "http://localhost:11434/v1",
"protocol": "openai_compatible",
},
{
"value": "ark",
"label": "火山方舟",
"default_endpoint_url": "https://ark.cn-beijing.volces.com/api/v3",
"protocol": "openai_compatible",
},
{
"value": "custom",
"label": "自定义兼容接口",
"default_endpoint_url": "",
"protocol": "openai_compatible",
},
]
PROVIDER_MAP = {item["value"]: item for item in PROVIDER_CATALOG}
class LLMProviderService:
"""LLM 提供方测试服务"""
@staticmethod
def get_provider_catalog() -> List[Dict[str, str]]:
return PROVIDER_CATALOG
@staticmethod
def get_provider_label(provider: Optional[str]) -> str:
if not provider:
return "自定义模型"
return PROVIDER_MAP.get(provider, {}).get("label", provider)
@staticmethod
def get_default_endpoint_url(provider: Optional[str]) -> str:
if not provider:
return ""
return PROVIDER_MAP.get(provider, {}).get("default_endpoint_url", "")
@classmethod
def build_model_name(cls, provider: Optional[str], llm_model_name: str) -> str:
model_name = (llm_model_name or "").strip()
label = cls.get_provider_label(provider)
if not model_name:
return label
return f"{label} {model_name}"
@staticmethod
def build_model_code(provider: Optional[str], llm_model_name: str) -> str:
provider_part = (provider or "custom").strip().lower()
model_part = (llm_model_name or "").strip().lower()
sanitized = []
previous_is_separator = False
for char in model_part:
if char.isalnum():
sanitized.append(char)
previous_is_separator = False
else:
if not previous_is_separator:
sanitized.append("_")
previous_is_separator = True
model_slug = "".join(sanitized).strip("_") or "model"
return f"llm_{provider_part}_{model_slug}"
@classmethod
async def test_model_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
provider = payload.get("provider")
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (payload.get("llm_model_name") or "").strip()
api_key = (payload.get("api_key") or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 llm_model_name")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再测试")
timeout = int(payload.get("llm_timeout") or 120)
started_at = time.perf_counter()
preview = await asyncio.to_thread(
cls._send_test_request,
provider_meta.get("protocol", "openai_compatible"),
provider,
endpoint_url,
api_key,
llm_model_name,
timeout,
)
latency_ms = int((time.perf_counter() - started_at) * 1000)
return {
"provider": provider,
"endpoint_url": endpoint_url,
"llm_model_name": llm_model_name,
"latency_ms": latency_ms,
"preview": preview[:200],
}
@classmethod
def _send_test_request(
cls,
protocol: str,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
) -> str:
if protocol == "anthropic":
return cls._test_anthropic(
endpoint_url,
api_key,
llm_model_name,
timeout,
)
if protocol == "gemini":
return cls._test_gemini(
endpoint_url,
api_key,
llm_model_name,
timeout,
)
return cls._test_openai_compatible(
provider,
endpoint_url,
api_key,
llm_model_name,
timeout,
)
@classmethod
def _test_openai_compatible(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
) -> str:
url = cls._join_endpoint(endpoint_url, "/chat/completions")
headers = {
"Content-Type": "application/json",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": llm_model_name,
"messages": cls._build_openai_test_messages(),
}
response = cls._request_json(url, headers, payload, timeout)
choices = response.get("choices") or []
if not choices:
raise ValueError("测试请求已发送,但未收到模型返回内容")
content = choices[0].get("message", {}).get("content", "")
if not content:
content = choices[0].get("text", "")
if isinstance(content, list):
content = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in content
)
content = str(content).strip()
if not content:
raise ValueError("模型返回成功,但内容为空")
return content
@classmethod
def _test_anthropic(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
) -> str:
url = cls._join_endpoint(endpoint_url, "/v1/messages")
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
payload = {
"model": llm_model_name,
"max_tokens": 32,
"messages": [
{
"role": "user",
"content": "请只回复“连接测试成功”。",
}
],
}
response = cls._request_json(url, headers, payload, timeout)
content = response.get("content") or []
texts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
texts.append(item.get("text", ""))
preview = "".join(texts).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@classmethod
def _test_gemini(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
) -> str:
model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}"
encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/"))
url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}"
headers = {
"Content-Type": "application/json",
}
payload = {
"contents": [
{
"role": "user",
"parts": [{"text": "请只回复“连接测试成功”。"}],
}
]
}
response = cls._request_json(url, headers, payload, timeout)
candidates = response.get("candidates") or []
if not candidates:
raise ValueError("测试请求已发送,但未收到模型返回内容")
parts = candidates[0].get("content", {}).get("parts", [])
preview = "".join(
part.get("text", "") for part in parts if isinstance(part, dict)
).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@staticmethod
def _build_openai_test_messages() -> List[Dict[str, str]]:
return [{"role": "user", "content": "请只回复“连接测试成功”。"}]
@staticmethod
def _join_endpoint(base_url: str, suffix: str) -> str:
normalized = base_url.rstrip("/")
if normalized.endswith(suffix):
return normalized
return f"{normalized}{suffix}"
@staticmethod
def _build_ssl_context() -> ssl.SSLContext:
"""构建 SSL 上下文。
默认用 certifi 提供的 CA 证书包解决部分系统 macOS
找不到本地根证书导致的 CERTIFICATE_VERIFY_FAILED
设置 DISABLE_SSL_VERIFY=1 可临时关闭校验仅用于自签名/调试
"""
if os.getenv("DISABLE_SSL_VERIFY", "").lower() in ("1", "true", "yes"):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
# 优先使用显式配置的 CA 文件,其次 certifi最后系统默认
ca_bundle = (
os.getenv("LLM_SSL_CA_FILE")
or os.getenv("SSL_CERT_FILE")
or os.getenv("REQUESTS_CA_BUNDLE")
)
if ca_bundle:
try:
return ssl.create_default_context(cafile=ca_bundle)
except (FileNotFoundError, OSError, ssl.SSLError):
pass
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except ImportError:
return ssl.create_default_context()
@classmethod
def _request_json(
cls,
url: str,
headers: Dict[str, str],
payload: Dict[str, Any],
timeout: int,
) -> Dict[str, Any]:
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
ctx = cls._build_ssl_context()
try:
with urllib.request.urlopen(request, timeout=timeout, context=ctx) as response:
body = response.read().decode("utf-8")
if not body:
return {}
return json.loads(body)
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="ignore")
message = cls._extract_error_message(error_body) or error_body[:300] or str(exc)
raise ValueError(f"模型测试失败HTTP {exc.code}{message}") from exc
except urllib.error.URLError as exc:
reason = exc.reason
if isinstance(reason, socket.timeout):
raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc
raise ValueError(f"模型测试失败:{reason}") from exc
except socket.timeout as exc:
raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc
except json.JSONDecodeError as exc:
raise ValueError("模型服务返回了无法解析的响应,请检查接口地址是否正确") from exc
@staticmethod
def _extract_error_message(error_body: str) -> Optional[str]:
if not error_body:
return None
try:
payload = json.loads(error_body)
except json.JSONDecodeError:
return error_body.strip()
if isinstance(payload, dict):
if isinstance(payload.get("error"), dict):
return payload["error"].get("message") or payload["error"].get("type")
return payload.get("message") or payload.get("detail")
return None
# ==================== 对话文本生成RAG 调用) ====================
@classmethod
async def generate_text(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, Any]],
) -> str:
"""根据多轮对话消息生成回复文本(供知识库 RAG 使用)"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 llm_model_name")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再调用")
normalized = cls._normalize_messages(messages)
if not normalized:
raise ValueError("缺少有效的对话消息")
protocol = provider_meta.get("protocol", "openai_compatible")
return await asyncio.to_thread(
cls._chat_completion,
protocol,
endpoint_url,
api_key,
llm_model_name,
int(timeout or 120),
float(temperature or 0.7),
float(top_p or 0.9),
int(max_tokens or 2048),
(system_prompt or "").strip(),
normalized,
)
@classmethod
async def generate_text_stream(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, Any]],
) -> AsyncIterator[str]:
"""流式生成回复文本。OpenAI-compatible 使用原生流,其它协议回退为分块输出。"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 llm_model_name")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再调用")
normalized = cls._normalize_messages(messages)
if not normalized:
raise ValueError("缺少有效的对话消息")
protocol = provider_meta.get("protocol", "openai_compatible")
if protocol != "openai_compatible":
text = await cls.generate_text(
provider,
endpoint_url,
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
normalized,
)
for index in range(0, len(text), 24):
yield text[index:index + 24]
return
result_queue: "queue.Queue[tuple[str, Any]]" = queue.Queue()
def worker():
try:
for chunk in cls._chat_openai_compatible_stream(
endpoint_url,
api_key,
llm_model_name,
int(timeout or 120),
float(temperature or 0.7),
float(top_p or 0.9),
int(max_tokens or 2048),
(system_prompt or "").strip(),
normalized,
):
result_queue.put(("chunk", chunk))
except Exception as exc: # pragma: no cover - surfaced through stream
result_queue.put(("error", exc))
finally:
result_queue.put(("done", None))
threading.Thread(target=worker, daemon=True).start()
while True:
kind, payload = await asyncio.to_thread(result_queue.get)
if kind == "chunk":
yield payload
elif kind == "error":
raise payload
else:
break
@staticmethod
def _normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, str]]:
normalized: List[Dict[str, str]] = []
for item in messages or []:
if not isinstance(item, dict):
continue
role = str(item.get("role") or "").strip().lower()
if role not in {"system", "user", "assistant"}:
continue
content = item.get("content", "")
if isinstance(content, list):
content = "".join(
part.get("text", "") if isinstance(part, dict) else str(part)
for part in content
)
content = str(content).strip()
if not content:
continue
normalized.append({"role": role, "content": content})
return normalized
@classmethod
def _chat_completion(
cls,
protocol: str,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
if protocol == "anthropic":
return cls._chat_anthropic(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
if protocol == "gemini":
return cls._chat_gemini(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
return cls._chat_openai_compatible(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
@classmethod
def _chat_openai_compatible(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
url = cls._join_endpoint(endpoint_url, "/chat/completions")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload_messages: List[Dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages)
payload = {
"model": llm_model_name,
"messages": payload_messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": False,
}
response = cls._request_json(url, headers, payload, timeout)
choices = response.get("choices") or []
if not choices:
raise ValueError("请求已发送,但未收到模型返回内容")
content = choices[0].get("message", {}).get("content", "")
if isinstance(content, list):
content = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in content
)
content = str(content).strip()
if not content:
raise ValueError("模型返回成功,但内容为空")
return content
@classmethod
def _chat_openai_compatible_stream(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> Iterator[str]:
url = cls._join_endpoint(endpoint_url, "/chat/completions")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload_messages: List[Dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages)
payload = {
"model": llm_model_name,
"messages": payload_messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": True,
}
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
ctx = cls._build_ssl_context()
try:
with urllib.request.urlopen(request, timeout=timeout, context=ctx) as response:
for raw_line in response:
line = raw_line.decode("utf-8", errors="ignore").strip()
if not line or not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
payload = json.loads(data)
except json.JSONDecodeError:
continue
for choice in payload.get("choices") or []:
delta = choice.get("delta") or {}
content = delta.get("content")
if isinstance(content, list):
content = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in content
)
if content:
yield str(content)
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="ignore")
message = cls._extract_error_message(error_body) or error_body[:300] or str(exc)
raise ValueError(f"模型调用失败HTTP {exc.code}{message}") from exc
except urllib.error.URLError as exc:
reason = exc.reason
if isinstance(reason, socket.timeout):
raise ValueError("模型调用超时,请检查网络或调大超时时间") from exc
raise ValueError(f"模型调用失败:{reason}") from exc
except socket.timeout as exc:
raise ValueError("模型调用超时,请检查网络或调大超时时间") from exc
@classmethod
def _chat_anthropic(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
url = cls._join_endpoint(endpoint_url, "/v1/messages")
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
payload = {
"model": llm_model_name,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"messages": [
{"role": m["role"], "content": [{"type": "text", "text": m["content"]}]}
for m in messages
if m["role"] in {"user", "assistant"}
],
}
if system_prompt:
payload["system"] = system_prompt
response = cls._request_json(url, headers, payload, timeout)
content = response.get("content") or []
texts = [
item.get("text", "")
for item in content
if isinstance(item, dict) and item.get("type") == "text"
]
preview = "".join(texts).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@classmethod
def _chat_gemini(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}"
encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/"))
url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}"
headers = {"Content-Type": "application/json"}
role_map = {"user": "user", "assistant": "model"}
contents = [
{"role": role_map[m["role"]], "parts": [{"text": m["content"]}]}
for m in messages
if m["role"] in role_map
]
payload = {
"contents": contents,
"generationConfig": {
"temperature": temperature,
"topP": top_p,
"maxOutputTokens": max_tokens,
},
}
if system_prompt:
payload["systemInstruction"] = {"parts": [{"text": system_prompt}]}
response = cls._request_json(url, headers, payload, timeout)
candidates = response.get("candidates") or []
if not candidates:
raise ValueError("请求已发送,但未收到模型返回内容")
parts = candidates[0].get("content", {}).get("parts", [])
preview = "".join(
part.get("text", "") for part in parts if isinstance(part, dict)
).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
# ==================== Embedding 向量生成与测试 ====================
@classmethod
async def generate_embedding(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
text: str,
timeout: int = 60,
dimension: Optional[int] = None,
) -> List[float]:
"""调用 OpenAI 兼容的 /embeddings 接口生成单条文本向量"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
text = (text or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 embedding 模型标识")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再调用")
if not text:
raise ValueError("待向量化文本为空")
vectors = await asyncio.to_thread(
cls._embeddings_request,
endpoint_url,
api_key,
llm_model_name,
[text[:8000]],
int(timeout or 60),
dimension,
)
if not vectors:
raise ValueError("Embedding 接口返回为空")
return vectors[0]
@classmethod
def _embeddings_request(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
inputs: List[str],
timeout: int,
dimension: Optional[int],
) -> List[List[float]]:
url = cls._join_endpoint(endpoint_url, "/embeddings")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload: Dict[str, Any] = {
"model": llm_model_name,
"input": inputs,
}
if dimension:
payload["dimensions"] = dimension
response = cls._request_json(url, headers, payload, timeout)
data = response.get("data") or []
if not data:
raise ValueError("Embedding 请求已发送,但未收到向量数据")
vectors: List[List[float]] = []
for item in data:
embedding = item.get("embedding") if isinstance(item, dict) else None
if embedding:
vectors.append([float(x) for x in embedding])
if not vectors:
raise ValueError("Embedding 返回结果中未解析到向量")
return vectors
@classmethod
async def test_embedding_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
"""测试 embedding 模型连通性,并返回实际向量维度"""
provider = payload.get("provider")
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (payload.get("llm_model_name") or "").strip()
api_key = (payload.get("api_key") or "").strip()
timeout = int(payload.get("llm_timeout") or 60)
dimension = payload.get("embedding_dimension")
started_at = time.perf_counter()
vector = await cls.generate_embedding(
provider,
endpoint_url,
api_key,
llm_model_name,
"连接测试",
timeout=timeout,
dimension=int(dimension) if dimension else None,
)
latency_ms = int((time.perf_counter() - started_at) * 1000)
return {
"provider": provider,
"endpoint_url": endpoint_url,
"llm_model_name": llm_model_name,
"latency_ms": latency_ms,
"dimension": len(vector),
"preview": f"成功生成 {len(vector)} 维向量",
}

View File

@ -0,0 +1,794 @@
"""
LLM 提供方测试服务
"""
import asyncio
import json
import socket
import time
import urllib.error
import urllib.parse
import urllib.request
import ssl
from typing import Any, Dict, List, Optional
PROVIDER_CATALOG: List[Dict[str, str]] = [
{
"value": "openai",
"label": "OpenAI",
"default_endpoint_url": "https://api.openai.com/v1",
"protocol": "openai_compatible",
},
{
"value": "deepseek",
"label": "DeepSeek",
"default_endpoint_url": "https://api.deepseek.com/v1",
"protocol": "openai_compatible",
},
{
"value": "anthropic",
"label": "Anthropic",
"default_endpoint_url": "https://api.anthropic.com",
"protocol": "anthropic",
},
{
"value": "gemini",
"label": "Google Gemini",
"default_endpoint_url": "https://generativelanguage.googleapis.com/v1beta",
"protocol": "gemini",
},
{
"value": "dashscope",
"label": "阿里百炼",
"default_endpoint_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"protocol": "openai_compatible",
},
{
"value": "zhipu",
"label": "智谱 AI",
"default_endpoint_url": "https://open.bigmodel.cn/api/paas/v4",
"protocol": "openai_compatible",
},
{
"value": "moonshot",
"label": "Moonshot AI",
"default_endpoint_url": "https://api.moonshot.cn/v1",
"protocol": "openai_compatible",
},
{
"value": "groq",
"label": "Groq",
"default_endpoint_url": "https://api.groq.com/openai/v1",
"protocol": "openai_compatible",
},
{
"value": "openrouter",
"label": "OpenRouter",
"default_endpoint_url": "https://openrouter.ai/api/v1",
"protocol": "openai_compatible",
},
{
"value": "siliconflow",
"label": "SiliconFlow",
"default_endpoint_url": "https://api.siliconflow.cn/v1",
"protocol": "openai_compatible",
},
{
"value": "ollama",
"label": "Ollama",
"default_endpoint_url": "http://localhost:11434/v1",
"protocol": "openai_compatible",
},
{
"value": "ark",
"label": "火山方舟",
"default_endpoint_url": "https://ark.cn-beijing.volces.com/api/v3",
"protocol": "openai_compatible",
},
{
"value": "custom",
"label": "自定义兼容接口",
"default_endpoint_url": "",
"protocol": "openai_compatible",
},
]
PROVIDER_MAP = {item["value"]: item for item in PROVIDER_CATALOG}
class LLMProviderService:
"""LLM 提供方测试服务"""
@staticmethod
def get_provider_catalog() -> List[Dict[str, str]]:
return PROVIDER_CATALOG
@staticmethod
def get_provider_label(provider: Optional[str]) -> str:
if not provider:
return "自定义模型"
return PROVIDER_MAP.get(provider, {}).get("label", provider)
@staticmethod
def get_default_endpoint_url(provider: Optional[str]) -> str:
if not provider:
return ""
return PROVIDER_MAP.get(provider, {}).get("default_endpoint_url", "")
@classmethod
def build_model_name(cls, provider: Optional[str], llm_model_name: str) -> str:
model_name = (llm_model_name or "").strip()
label = cls.get_provider_label(provider)
if not model_name:
return label
return f"{label} {model_name}"
@staticmethod
def build_model_code(provider: Optional[str], llm_model_name: str) -> str:
provider_part = (provider or "custom").strip().lower()
model_part = (llm_model_name or "").strip().lower()
sanitized = []
previous_is_separator = False
for char in model_part:
if char.isalnum():
sanitized.append(char)
previous_is_separator = False
else:
if not previous_is_separator:
sanitized.append("_")
previous_is_separator = True
model_slug = "".join(sanitized).strip("_") or "model"
return f"llm_{provider_part}_{model_slug}"
@classmethod
async def test_model_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
provider = payload.get("provider")
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (payload.get("llm_model_name") or "").strip()
api_key = (payload.get("api_key") or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 llm_model_name")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再测试")
timeout = int(payload.get("llm_timeout") or 120)
temperature = float(payload.get("llm_temperature") or 0.7)
top_p = float(payload.get("llm_top_p") or 0.9)
max_tokens = int(payload.get("llm_max_tokens") or 2048)
system_prompt = (payload.get("llm_system_prompt") or "").strip()
started_at = time.perf_counter()
preview = await asyncio.to_thread(
cls._send_test_request,
provider_meta.get("protocol", "openai_compatible"),
provider,
endpoint_url,
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
)
latency_ms = int((time.perf_counter() - started_at) * 1000)
return {
"provider": provider,
"endpoint_url": endpoint_url,
"llm_model_name": llm_model_name,
"latency_ms": latency_ms,
"preview": preview[:200],
}
@classmethod
def _send_test_request(
cls,
protocol: str,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
) -> str:
if protocol == "anthropic":
return cls._test_anthropic(
endpoint_url,
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
)
if protocol == "gemini":
return cls._test_gemini(
endpoint_url,
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
)
return cls._test_openai_compatible(
provider,
endpoint_url,
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
)
@classmethod
def _test_openai_compatible(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
) -> str:
url = cls._join_endpoint(endpoint_url, "/chat/completions")
headers = {
"Content-Type": "application/json",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": llm_model_name,
"messages": cls._build_openai_messages(system_prompt),
"temperature": temperature,
"top_p": top_p,
"max_tokens": min(max_tokens, 256),
"stream": False,
}
response = cls._request_json(url, headers, payload, timeout)
choices = response.get("choices") or []
if not choices:
raise ValueError("测试请求已发送,但未收到模型返回内容")
content = choices[0].get("message", {}).get("content", "")
if isinstance(content, list):
content = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in content
)
content = str(content).strip()
if not content:
raise ValueError("模型返回成功,但内容为空")
return content
@classmethod
def _test_anthropic(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
) -> str:
url = cls._join_endpoint(endpoint_url, "/v1/messages")
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
payload = {
"model": llm_model_name,
"max_tokens": min(max_tokens, 256),
"temperature": temperature,
"top_p": top_p,
"messages": [
{
"role": "user",
"content": "请只回复“连接测试成功”。",
}
],
}
if system_prompt:
payload["system"] = system_prompt
response = cls._request_json(url, headers, payload, timeout)
content = response.get("content") or []
texts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
texts.append(item.get("text", ""))
preview = "".join(texts).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@classmethod
def _test_gemini(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
) -> str:
model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}"
encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/"))
url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}"
headers = {
"Content-Type": "application/json",
}
payload = {
"contents": [
{
"role": "user",
"parts": [{"text": "请只回复“连接测试成功”。"}],
}
],
"generationConfig": {
"temperature": temperature,
"topP": top_p,
"maxOutputTokens": min(max_tokens, 256),
},
}
if system_prompt:
payload["systemInstruction"] = {
"parts": [{"text": system_prompt}],
}
response = cls._request_json(url, headers, payload, timeout)
candidates = response.get("candidates") or []
if not candidates:
raise ValueError("测试请求已发送,但未收到模型返回内容")
parts = candidates[0].get("content", {}).get("parts", [])
preview = "".join(
part.get("text", "") for part in parts if isinstance(part, dict)
).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@staticmethod
def _build_openai_messages(system_prompt: str) -> List[Dict[str, str]]:
messages: List[Dict[str, str]] = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": "请只回复“连接测试成功”。"})
return messages
@staticmethod
def _join_endpoint(base_url: str, suffix: str) -> str:
normalized = base_url.rstrip("/")
if normalized.endswith(suffix):
return normalized
return f"{normalized}{suffix}"
@classmethod
def _request_json(
cls,
url: str,
headers: Dict[str, str],
payload: Dict[str, Any],
timeout: int,
) -> Dict[str, Any]:
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
ctx = None
if os.getenv("DISABLE_SSL_VERIFY", "").lower() in ("1", "true", "yes"):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(request, timeout=timeout, context=ctx) as response:
body = response.read().decode("utf-8")
if not body:
return {}
return json.loads(body)
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="ignore")
message = cls._extract_error_message(error_body) or error_body[:300] or str(exc)
raise ValueError(f"模型测试失败HTTP {exc.code}{message}") from exc
except urllib.error.URLError as exc:
reason = exc.reason
if isinstance(reason, socket.timeout):
raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc
raise ValueError(f"模型测试失败:{reason}") from exc
except socket.timeout as exc:
raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc
except json.JSONDecodeError as exc:
raise ValueError("模型服务返回了无法解析的响应,请检查接口地址是否正确") from exc
@staticmethod
def _extract_error_message(error_body: str) -> Optional[str]:
if not error_body:
return None
try:
payload = json.loads(error_body)
except json.JSONDecodeError:
return error_body.strip()
if isinstance(payload, dict):
if isinstance(payload.get("error"), dict):
return payload["error"].get("message") or payload["error"].get("type")
return payload.get("message") or payload.get("detail")
return None
# ==================== 对话文本生成RAG 调用) ====================
@classmethod
async def generate_text(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, Any]],
) -> str:
"""根据多轮对话消息生成回复文本(供知识库 RAG 使用)"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 llm_model_name")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再调用")
normalized = cls._normalize_messages(messages)
if not normalized:
raise ValueError("缺少有效的对话消息")
protocol = provider_meta.get("protocol", "openai_compatible")
return await asyncio.to_thread(
cls._chat_completion,
protocol,
endpoint_url,
api_key,
llm_model_name,
int(timeout or 120),
float(temperature or 0.7),
float(top_p or 0.9),
int(max_tokens or 2048),
(system_prompt or "").strip(),
normalized,
)
@staticmethod
def _normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, str]]:
normalized: List[Dict[str, str]] = []
for item in messages or []:
if not isinstance(item, dict):
continue
role = str(item.get("role") or "").strip().lower()
if role not in {"system", "user", "assistant"}:
continue
content = item.get("content", "")
if isinstance(content, list):
content = "".join(
part.get("text", "") if isinstance(part, dict) else str(part)
for part in content
)
content = str(content).strip()
if not content:
continue
normalized.append({"role": role, "content": content})
return normalized
@classmethod
def _chat_completion(
cls,
protocol: str,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
if protocol == "anthropic":
return cls._chat_anthropic(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
if protocol == "gemini":
return cls._chat_gemini(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
return cls._chat_openai_compatible(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
@classmethod
def _chat_openai_compatible(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
url = cls._join_endpoint(endpoint_url, "/chat/completions")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload_messages: List[Dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages)
payload = {
"model": llm_model_name,
"messages": payload_messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": False,
}
response = cls._request_json(url, headers, payload, timeout)
choices = response.get("choices") or []
if not choices:
raise ValueError("请求已发送,但未收到模型返回内容")
content = choices[0].get("message", {}).get("content", "")
if isinstance(content, list):
content = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in content
)
content = str(content).strip()
if not content:
raise ValueError("模型返回成功,但内容为空")
return content
@classmethod
def _chat_anthropic(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
url = cls._join_endpoint(endpoint_url, "/v1/messages")
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
payload = {
"model": llm_model_name,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"messages": [
{"role": m["role"], "content": [{"type": "text", "text": m["content"]}]}
for m in messages
if m["role"] in {"user", "assistant"}
],
}
if system_prompt:
payload["system"] = system_prompt
response = cls._request_json(url, headers, payload, timeout)
content = response.get("content") or []
texts = [
item.get("text", "")
for item in content
if isinstance(item, dict) and item.get("type") == "text"
]
preview = "".join(texts).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@classmethod
def _chat_gemini(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}"
encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/"))
url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}"
headers = {"Content-Type": "application/json"}
role_map = {"user": "user", "assistant": "model"}
contents = [
{"role": role_map[m["role"]], "parts": [{"text": m["content"]}]}
for m in messages
if m["role"] in role_map
]
payload = {
"contents": contents,
"generationConfig": {
"temperature": temperature,
"topP": top_p,
"maxOutputTokens": max_tokens,
},
}
if system_prompt:
payload["systemInstruction"] = {"parts": [{"text": system_prompt}]}
response = cls._request_json(url, headers, payload, timeout)
candidates = response.get("candidates") or []
if not candidates:
raise ValueError("请求已发送,但未收到模型返回内容")
parts = candidates[0].get("content", {}).get("parts", [])
preview = "".join(
part.get("text", "") for part in parts if isinstance(part, dict)
).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
# ==================== Embedding 向量生成与测试 ====================
@classmethod
async def generate_embedding(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
text: str,
timeout: int = 60,
dimension: Optional[int] = None,
) -> List[float]:
"""调用 OpenAI 兼容的 /embeddings 接口生成单条文本向量"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
text = (text or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 embedding 模型标识")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再调用")
if not text:
raise ValueError("待向量化文本为空")
vectors = await asyncio.to_thread(
cls._embeddings_request,
endpoint_url,
api_key,
llm_model_name,
[text[:8000]],
int(timeout or 60),
dimension,
)
if not vectors:
raise ValueError("Embedding 接口返回为空")
return vectors[0]
@classmethod
def _embeddings_request(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
inputs: List[str],
timeout: int,
dimension: Optional[int],
) -> List[List[float]]:
url = cls._join_endpoint(endpoint_url, "/embeddings")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload: Dict[str, Any] = {
"model": llm_model_name,
"input": inputs,
}
if dimension:
payload["dimensions"] = dimension
response = cls._request_json(url, headers, payload, timeout)
data = response.get("data") or []
if not data:
raise ValueError("Embedding 请求已发送,但未收到向量数据")
vectors: List[List[float]] = []
for item in data:
embedding = item.get("embedding") if isinstance(item, dict) else None
if embedding:
vectors.append([float(x) for x in embedding])
if not vectors:
raise ValueError("Embedding 返回结果中未解析到向量")
return vectors
@classmethod
async def test_embedding_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
"""测试 embedding 模型连通性,并返回实际向量维度"""
provider = payload.get("provider")
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (payload.get("llm_model_name") or "").strip()
api_key = (payload.get("api_key") or "").strip()
timeout = int(payload.get("llm_timeout") or 60)
dimension = payload.get("embedding_dimension")
started_at = time.perf_counter()
vector = await cls.generate_embedding(
provider,
endpoint_url,
api_key,
llm_model_name,
"连接测试",
timeout=timeout,
dimension=int(dimension) if dimension else None,
)
latency_ms = int((time.perf_counter() - started_at) * 1000)
return {
"provider": provider,
"endpoint_url": endpoint_url,
"llm_model_name": llm_model_name,
"latency_ms": latency_ms,
"dimension": len(vector),
"preview": f"成功生成 {len(vector)} 维向量",
}

View File

@ -1,14 +1,59 @@
"""
PDF 生成服务 - 基于 WeasyPrint + 系统字体
"""
import markdown
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
import io
import os
import platform
from pathlib import Path
class PDFService:
def __init__(self):
self.markdown = None
self.html_class = None
self.css_class = None
self.font_config = None
def _configure_macos_library_path(self):
"""为 macOS 上的 Homebrew 动态库补充兜底搜索路径。"""
if platform.system() != "Darwin":
return
candidate_paths = [
Path("/opt/homebrew/lib"),
Path("/usr/local/lib"),
]
existing_paths = [
path for path in os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "").split(":") if path
]
for candidate in candidate_paths:
candidate_str = str(candidate)
if candidate.exists() and candidate_str not in existing_paths:
existing_paths.append(candidate_str)
if existing_paths:
os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = ":".join(existing_paths)
def _ensure_dependencies(self):
"""按需加载 PDF 依赖,避免系统库缺失时影响整个服务启动。"""
if self.markdown and self.html_class and self.css_class and self.font_config:
return
try:
self._configure_macos_library_path()
import markdown
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
except Exception as exc:
raise RuntimeError(
"PDF 导出依赖不可用,请先安装 backend/requirements.txt 中的 Python 依赖,"
"并为 WeasyPrint 安装系统库(如 glib、pango、cairo"
) from exc
self.markdown = markdown
self.html_class = HTML
self.css_class = CSS
self.font_config = FontConfiguration()
def get_css(self):
@ -82,7 +127,9 @@ class PDFService:
async def md_to_pdf(self, md_content: str, title: str = "Document", base_url: str = None) -> io.BytesIO:
"""将 Markdown 转换为 PDF 字节流"""
html_content = markdown.markdown(
self._ensure_dependencies()
html_content = self.markdown.markdown(
md_content,
extensions=['extra', 'codehilite', 'toc', 'tables']
)
@ -101,8 +148,8 @@ class PDFService:
"""
pdf_buffer = io.BytesIO()
css = CSS(string=self.get_css(), font_config=self.font_config)
HTML(string=full_html, base_url=base_url).write_pdf(
css = self.css_class(string=self.get_css(), font_config=self.font_config)
self.html_class(string=full_html, base_url=base_url).write_pdf(
pdf_buffer,
stylesheets=[css],
font_config=self.font_config

View File

@ -0,0 +1,202 @@
"""
项目导出业务服务
"""
import asyncio
import threading
import time
import uuid
import zipfile
from pathlib import Path
from fastapi import HTTPException
from app.services.storage import storage_service
class ProjectExportService:
"""管理项目导出任务及其文件打包流程。"""
def __init__(self, ttl_seconds: int = 3600):
self.ttl_seconds = ttl_seconds
self._tasks: dict[str, dict] = {}
self._tasks_lock = threading.Lock()
@staticmethod
def _build_zip_filename(project_name: str) -> str:
safe_project_name = project_name.replace("/", "_").replace("\\", "_")
return f"{safe_project_name}.zip"
@staticmethod
def _serialize_task(task: dict) -> dict:
total_files = task.get("total_files", 0) or 0
processed_files = task.get("processed_files", 0) or 0
progress = task.get("progress")
if progress is None:
progress = int(processed_files * 100 / total_files) if total_files else 0
return {
"task_id": task["task_id"],
"project_id": task["project_id"],
"status": task["status"],
"message": task.get("message", ""),
"progress": progress,
"processed_files": processed_files,
"total_files": total_files,
"file_count": total_files,
"zip_filename": task["zip_filename"],
"error": task.get("error"),
"created_at": task.get("created_at"),
"completed_at": task.get("completed_at"),
}
def _remove_task(self, task_id: str) -> None:
with self._tasks_lock:
task = self._tasks.pop(task_id, None)
if not task:
return
file_path = task.get("file_path")
if file_path:
try:
Path(file_path).unlink(missing_ok=True)
except Exception:
pass
def cleanup_expired_tasks(self) -> None:
now = time.time()
expired_task_ids = []
with self._tasks_lock:
for task_id, task in self._tasks.items():
created_at = task.get("created_at", now)
completed_at = task.get("completed_at")
if completed_at and now - completed_at > self.ttl_seconds:
expired_task_ids.append(task_id)
elif task.get("status") == "failed" and now - created_at > self.ttl_seconds:
expired_task_ids.append(task_id)
for task_id in expired_task_ids:
self._remove_task(task_id)
def _update_task(self, task_id: str, **fields) -> None:
with self._tasks_lock:
task = self._tasks.get(task_id)
if task:
task.update(fields)
def get_task_or_404(self, task_id: str) -> dict:
self.cleanup_expired_tasks()
with self._tasks_lock:
task = self._tasks.get(task_id)
if not task:
raise HTTPException(status_code=404, detail="导出任务不存在或已过期")
return task
def get_owned_task_or_404(self, project_id: int, task_id: str, user_id: int) -> dict:
task = self.get_task_or_404(task_id)
if task["project_id"] != project_id or task["user_id"] != user_id:
raise HTTPException(status_code=404, detail="导出任务不存在")
return task
def cleanup_task(self, task_id: str) -> None:
self._remove_task(task_id)
def serialize_task(self, task: dict) -> dict:
return self._serialize_task(task)
def _run_export_task(self, task_id: str, source_dir: Path) -> None:
try:
self._update_task(
task_id,
status="scanning",
progress=0,
message="正在统计导出文件...",
processed_files=0,
total_files=0,
)
files = [file_path for file_path in source_dir.rglob("*") if file_path.is_file()]
total_files = len(files)
storage_service.temp_root.mkdir(parents=True, exist_ok=True)
zip_path = storage_service.temp_root / f"{task_id}.zip"
self._update_task(
task_id,
status="zipping",
progress=0 if total_files else 100,
message="正在打包项目文件...",
total_files=total_files,
file_path=str(zip_path),
)
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
for index, file_path in enumerate(files, start=1):
arcname = file_path.relative_to(source_dir)
zip_file.write(file_path, arcname)
progress = int(index * 100 / total_files) if total_files else 100
self._update_task(
task_id,
processed_files=index,
progress=progress,
)
self._update_task(
task_id,
status="completed",
progress=100,
message="项目导出已完成",
completed_at=time.time(),
)
except Exception as exc:
self._update_task(
task_id,
status="failed",
message="项目导出失败",
error=str(exc),
completed_at=time.time(),
)
task = self.get_task_or_404(task_id)
file_path = task.get("file_path")
if file_path:
try:
Path(file_path).unlink(missing_ok=True)
except Exception:
pass
self._update_task(task_id, file_path=None)
async def start_export(self, project_id: int, user_id: int, project_name: str, source_dir: Path) -> dict:
if not source_dir.exists() or not source_dir.is_dir():
raise HTTPException(status_code=404, detail="项目目录不存在")
self.cleanup_expired_tasks()
task_id = uuid.uuid4().hex
task = {
"task_id": task_id,
"project_id": project_id,
"user_id": user_id,
"status": "pending",
"message": "导出任务已创建",
"progress": 0,
"processed_files": 0,
"total_files": 0,
"zip_filename": self._build_zip_filename(project_name),
"file_path": None,
"error": None,
"created_at": time.time(),
"completed_at": None,
}
with self._tasks_lock:
self._tasks[task_id] = task
asyncio.create_task(asyncio.to_thread(self._run_export_task, task_id, source_dir))
return self._serialize_task(task)
project_export_service = ProjectExportService()

View File

@ -0,0 +1,270 @@
"""
项目文件业务服务
"""
from pathlib import Path
from typing import Optional
import asyncio
from fastapi import HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.enums import OperationType
from app.models.project import Project
from app.models.user import User
from app.services.log_service import log_service
from app.services.notification_service import notification_service
from app.services.search_service import search_service
from app.services.storage import storage_service
from app.services.zvec_service import zvec_service
class ProjectFileService:
"""收口项目文件写入、副作用联动和通知流程。"""
@staticmethod
def _actor_name(user: User) -> str:
return user.nickname or user.username
@staticmethod
def _source_suffix(source: str) -> str:
return " 通过 MCP" if source == "mcp" else ""
@staticmethod
def _detail_with_source(detail: Optional[dict], source: str) -> Optional[dict]:
merged = dict(detail or {})
if source != "http":
merged["source"] = source
return merged or None
@staticmethod
async def _update_markdown_index(project_id: int, path: str, content: str) -> None:
if path.endswith(".md"):
await search_service.update_doc(project_id, path, Path(path).stem, content)
@staticmethod
async def _remove_markdown_index(project_id: int, path: str) -> None:
if path.endswith(".md"):
await search_service.remove_doc(project_id, path)
@staticmethod
async def _sync_markdown_index_for_move(
project_id: int,
old_path: str,
new_path: str,
new_file_path: Path,
) -> None:
if not old_path.endswith(".md") or not new_path.endswith(".md"):
return
try:
content = await storage_service.read_file(new_file_path)
await search_service.remove_doc(project_id, old_path)
await search_service.update_doc(project_id, new_path, Path(new_path).stem, content)
except Exception:
pass
async def save_file(
self,
db: AsyncSession,
project_id: int,
project: Project,
path: str,
content: str,
user: User,
*,
request: Optional[Request] = None,
source: str = "http",
) -> str:
file_path = storage_service.get_secure_path(project.storage_key, path)
await storage_service.write_file(file_path, content)
await self._update_markdown_index(project_id, path, content)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.SAVE_FILE,
project_id=project_id,
file_path=path,
user=user,
detail=self._detail_with_source({"content_length": len(content)}, source),
request=request,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=user.id,
title="项目文档更新",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {self._actor_name(user)}{self._source_suffix(source)} 更新。"
),
link=f"/projects/{project_id}/docs?file={path}",
category="project",
)
if path.endswith(".md"):
asyncio.create_task(zvec_service.vectorize_markdown(db, project_id, path, content))
await db.commit()
return "文件保存成功" if source == "http" else "文件更新成功"
async def operate_file(
self,
db: AsyncSession,
project_id: int,
project: Project,
action: str,
path: str,
user: User,
*,
new_path: Optional[str] = None,
content: Optional[str] = None,
request: Optional[Request] = None,
source: str = "http",
) -> str:
current_path = storage_service.get_secure_path(project.storage_key, path)
if action == "delete":
await storage_service.delete_file(current_path)
await self._remove_markdown_index(project_id, path)
if path.endswith(".md"):
await zvec_service.delete_vectors(db, project_id, path)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.DELETE_FILE,
project_id=project_id,
file_path=path,
user=user,
detail=self._detail_with_source(None, source),
request=request,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=user.id,
title="项目文档删除",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {self._actor_name(user)}{self._source_suffix(source)} 删除。"
),
category="project",
)
await db.commit()
return "删除成功" if source == "http" else "文件删除成功"
if action in {"rename", "move"}:
if not new_path:
detail = "缺少新路径参数" if action == "rename" else "缺少目标路径参数"
raise HTTPException(status_code=400, detail=detail)
destination_path = storage_service.get_secure_path(project.storage_key, new_path)
await storage_service.rename_file(current_path, destination_path)
await self._sync_markdown_index_for_move(project_id, path, new_path, destination_path)
operation_type = (
OperationType.RENAME_FILE if action == "rename" else OperationType.MOVE_FILE
)
notification_title = "项目文档重命名" if action == "rename" else "项目文档移动"
notification_action = "重命名为" if action == "rename" else "移动到"
success_message = "重命名成功" if action == "rename" else "移动成功"
mcp_message = "文件重命名成功" if action == "rename" else "文件移动成功"
await log_service.log_file_operation(
db=db,
operation_type=operation_type,
project_id=project_id,
file_path=path,
user=user,
detail=self._detail_with_source({"new_path": new_path}, source),
request=request,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=user.id,
title=notification_title,
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {self._actor_name(user)}{self._source_suffix(source)} {notification_action} [{new_path}]。"
),
category="project",
)
if path.endswith(".md") and new_path.endswith(".md"):
asyncio.create_task(zvec_service.sync_vector_move(db, project_id, path, new_path))
await db.commit()
return success_message if source == "http" else mcp_message
if action == "create_dir":
await storage_service.create_directory(current_path)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.CREATE_DIR,
project_id=project_id,
file_path=path,
user=user,
detail=self._detail_with_source(None, source),
request=request,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=user.id,
title="创建新目录",
content=(
f"{self._actor_name(user)}{self._source_suffix(source)} "
f"在项目 [{project.name}] 中创建了新目录 [{path}]。"
),
category="project",
)
await db.commit()
return "目录创建成功"
if action == "create_file":
file_content = content or ""
await storage_service.write_file(current_path, file_content)
await self._update_markdown_index(project_id, path, file_content)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.CREATE_FILE,
project_id=project_id,
file_path=path,
user=user,
detail=self._detail_with_source(
{"content_length": len(file_content)},
source,
),
request=request,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=user.id,
title="创建新文档",
content=(
f"{self._actor_name(user)}{self._source_suffix(source)} "
f"在项目 [{project.name}] 中创建了新文档 [{path}]。"
),
link=f"/projects/{project_id}/docs?file={path}",
category="project",
)
if path.endswith(".md"):
asyncio.create_task(zvec_service.vectorize_markdown(db, project_id, path, file_content))
await db.commit()
return "文件创建成功"
raise HTTPException(status_code=400, detail="不支持的操作类型")
project_file_service = ProjectFileService()

View File

@ -0,0 +1,151 @@
"""
项目域相关服务
"""
from typing import Optional, Sequence
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.project import Project, ProjectMember, ProjectMemberRole
from app.models.user import User
from app.schemas.project import ProjectResponse
from app.services.storage import storage_service
OWNER_ROLE = "owner"
PUBLIC_ROLE = "public"
async def get_project_or_404(db: AsyncSession, project_id: int) -> Project:
"""获取项目,不存在时抛出 404。"""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
return project
async def get_project_member(
db: AsyncSession,
project_id: int,
user_id: int,
) -> Optional[ProjectMember]:
"""查询项目成员记录。"""
result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def get_project_role(
db: AsyncSession,
project: Project,
current_user: Optional[User],
) -> Optional[str]:
"""解析用户在项目中的角色。"""
if not current_user:
return None
if project.owner_id == current_user.id:
return OWNER_ROLE
member = await get_project_member(db, project.id, current_user.id)
return member.role if member else None
async def require_project_read_access(
db: AsyncSession,
project_id: int,
current_user: Optional[User],
*,
allow_public: bool = False,
unauthenticated_detail: str = "请先登录",
forbidden_detail: str = "无权访问该项目",
) -> tuple[Project, str]:
"""校验项目读取权限。"""
project = await get_project_or_404(db, project_id)
role = await get_project_role(db, project, current_user)
if role:
return project, role
if allow_public and project.is_public == 1:
return project, PUBLIC_ROLE
if not current_user:
raise HTTPException(status_code=401, detail=unauthenticated_detail)
raise HTTPException(status_code=403, detail=forbidden_detail)
async def require_project_roles(
db: AsyncSession,
project_id: int,
current_user: Optional[User],
*,
allowed_roles: Sequence[str],
allow_public: bool = False,
unauthenticated_detail: str = "请先登录",
forbidden_detail: str = "无权执行此操作",
) -> tuple[Project, str]:
"""校验项目角色权限。owner 始终视为通过。"""
project, role = await require_project_read_access(
db,
project_id,
current_user,
allow_public=allow_public,
unauthenticated_detail=unauthenticated_detail,
forbidden_detail=forbidden_detail,
)
if role in {OWNER_ROLE, *allowed_roles}:
return project, role
raise HTTPException(status_code=403, detail=forbidden_detail)
async def require_project_write_access(
db: AsyncSession,
project_id: int,
current_user: User,
) -> tuple[Project, str]:
"""校验项目写权限。"""
return await require_project_roles(
db,
project_id,
current_user,
allowed_roles=[
ProjectMemberRole.ADMIN.value,
ProjectMemberRole.EDITOR.value,
],
forbidden_detail="无写入权限",
)
def count_project_documents(storage_key: str) -> int:
"""统计项目中可见文档数量。"""
try:
project_path = storage_service.get_secure_path(storage_key)
if not project_path.exists():
return 0
md_count = len(list(project_path.rglob("*.md")))
pdf_count = len(list(project_path.rglob("*.pdf")))
assets_dir = project_path / "_assets"
assets_md = len(list(assets_dir.rglob("*.md"))) if assets_dir.exists() else 0
assets_pdf = len(list(assets_dir.rglob("*.pdf"))) if assets_dir.exists() else 0
return md_count + pdf_count - assets_md - assets_pdf
except Exception:
return 0
def serialize_project(project: Project, **extra_fields) -> dict:
"""序列化项目,并补充文档统计。"""
project_data = ProjectResponse.from_orm(project).dict()
project_data["doc_count"] = count_project_documents(project.storage_key)
project_data.update(extra_fields)
return project_data

View File

@ -0,0 +1,161 @@
"""
项目向量化后台任务服务
"""
import logging
import uuid
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import func
from app.core.database import AsyncSessionLocal
from app.models.project import Project
from app.models.project_vectorization_task import ProjectVectorizationTask
from app.services.zvec_service import zvec_service
logger = logging.getLogger(__name__)
class ProjectVectorizationTaskService:
"""管理项目文件夹向量化任务的创建、执行和查询。"""
RUNNING_STATUSES = ("pending", "running")
@staticmethod
def serialize_task(task: ProjectVectorizationTask) -> dict:
return {
"task_id": task.task_id,
"project_id": task.project_id,
"task_type": task.task_type,
"status": task.status,
"total": task.total,
"processed": task.processed,
"skipped": task.skipped,
"failed": task.failed,
"error_message": task.error_message,
"created_at": task.created_at.isoformat() if task.created_at else None,
"started_at": task.started_at.isoformat() if task.started_at else None,
"finished_at": task.finished_at.isoformat() if task.finished_at else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
}
async def create_task(
self,
db: AsyncSession,
project_id: int,
user_id: int,
*,
force: bool,
) -> ProjectVectorizationTask:
task = ProjectVectorizationTask(
task_id=uuid.uuid4().hex,
project_id=project_id,
user_id=user_id,
task_type="full" if force else "incremental",
status="pending",
)
db.add(task)
await db.commit()
await db.refresh(task)
return task
async def get_running_task(
self,
db: AsyncSession,
project_id: int,
) -> Optional[ProjectVectorizationTask]:
result = await db.execute(
select(ProjectVectorizationTask)
.where(
ProjectVectorizationTask.project_id == project_id,
ProjectVectorizationTask.status.in_(self.RUNNING_STATUSES),
)
.order_by(ProjectVectorizationTask.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def get_task(
self,
db: AsyncSession,
project_id: int,
task_id: str,
) -> Optional[ProjectVectorizationTask]:
result = await db.execute(
select(ProjectVectorizationTask).where(
ProjectVectorizationTask.project_id == project_id,
ProjectVectorizationTask.task_id == task_id,
)
)
return result.scalar_one_or_none()
async def get_latest_task(
self,
db: AsyncSession,
project_id: int,
) -> Optional[ProjectVectorizationTask]:
result = await db.execute(
select(ProjectVectorizationTask)
.where(ProjectVectorizationTask.project_id == project_id)
.order_by(ProjectVectorizationTask.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def run_task(self, task_id: str) -> None:
async with AsyncSessionLocal() as db:
task = None
try:
result = await db.execute(
select(ProjectVectorizationTask).where(
ProjectVectorizationTask.task_id == task_id
)
)
task = result.scalar_one_or_none()
if not task:
return
project_result = await db.execute(
select(Project).where(Project.id == task.project_id)
)
project = project_result.scalar_one_or_none()
if not project:
task.status = "failed"
task.error_message = "项目不存在"
task.finished_at = func.now()
await db.commit()
return
task.status = "running"
task.started_at = func.now()
await db.commit()
result_data = await zvec_service.vectorize_project(
db,
task.project_id,
project.storage_key,
force=task.task_type == "full",
)
task.status = "success" if result_data.get("failed", 0) == 0 else "failed"
task.total = result_data.get("total", 0)
task.processed = result_data.get("processed", 0)
task.skipped = result_data.get("skipped", 0)
task.failed = result_data.get("failed", 0)
task.error_message = (
f"{task.failed} 个文件向量化失败" if task.failed else None
)
task.finished_at = func.now()
await db.commit()
except Exception as exc:
logger.exception("Project vectorization task failed: %s", task_id)
await db.rollback()
if task is not None:
task.status = "failed"
task.error_message = str(exc)[:2000]
task.finished_at = func.now()
await db.commit()
project_vectorization_task_service = ProjectVectorizationTaskService()

View File

@ -0,0 +1,315 @@
"""
知识库 RAG 服务 - ZVec 向量检索 + 大模型生成
"""
import logging
from typing import AsyncIterator, List, Dict, Any
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.llm_model_config import LLMModelConfig
from app.core.config import settings
from app.services.llm_provider_service import LLMProviderService
from app.services.zvec_service import zvec_service
from app.services.storage import storage_service
logger = logging.getLogger(__name__)
# 单个命中分块注入上下文时,围绕命中位置向前后各扩展的字符数。
# 分块后每次只注入命中段落及其上下文,而非整篇文档,从根本上避免长文档被截断。
CHUNK_CONTEXT_WINDOW = 1200
MAX_CHUNKS_PER_DOCUMENT = 3
class RAGService:
"""RAG 知识库检索和生成服务"""
@staticmethod
async def retrieve_documents(
db: AsyncSession,
project_id: int,
query: str,
top_k: int = 5,
) -> List[Dict[str, Any]]:
"""通过 ZVec 向量检索相关文档,并读取文档内容"""
# 多取一些分块,再按文件去重,避免高频命中文件挤掉其他相关文档。
matched = await zvec_service.search_similar(db, project_id, query, top_k * 4)
if not matched:
return []
from app.models.project import Project
stmt = select(Project).where(Project.id == project_id)
result = await db.execute(stmt)
project = result.scalar_one_or_none()
if not project:
return []
# 读取文件内容做缓存,避免同一文件多个分块命中时重复读盘
file_content_cache: Dict[str, str] = {}
async def _read_content(path: str) -> str:
if path in file_content_cache:
return file_content_cache[path]
full_path = storage_service.get_secure_path(project.storage_key, path)
text = await storage_service.read_file(full_path)
file_content_cache[path] = text
return text
docs_by_path: Dict[str, Dict[str, Any]] = {}
for item in matched:
file_path = item["file_path"]
chunk_text = item.get("chunk_text") or ""
try:
content = await _read_content(file_path)
excerpt = RAGService._resolve_excerpt(
content=content,
chunk_text=chunk_text,
chunk_index=item.get("chunk_index", 0),
)
snippet = RAGService._extract_chunk_context(content, chunk_text, excerpt)
doc = {
"file_path": file_path,
"file_name": Path(file_path).name,
"chunk_index": item.get("chunk_index", 0),
"anchor_text": RAGService._build_anchor(excerpt or chunk_text),
"score": item.get("score", 0.0),
"excerpt": excerpt,
"content": snippet,
"_merged_chunks": 1,
}
except Exception:
doc = {
"file_path": file_path,
"file_name": Path(file_path).name,
"chunk_index": item.get("chunk_index", 0),
"anchor_text": RAGService._build_anchor(chunk_text),
"score": item.get("score", 0.0),
"excerpt": "",
"content": "",
"_merged_chunks": 1,
}
existing = docs_by_path.get(file_path)
if existing is None:
docs_by_path[file_path] = doc
continue
if existing["_merged_chunks"] >= MAX_CHUNKS_PER_DOCUMENT:
continue
existing["score"] = max(existing.get("score", 0.0), doc.get("score", 0.0))
existing["excerpt"] = RAGService._merge_text_blocks(
existing.get("excerpt", ""), doc.get("excerpt", "")
)
existing["content"] = RAGService._merge_text_blocks(
existing.get("content", ""), doc.get("content", "")
)
existing["_merged_chunks"] += 1
docs_with_content = list(docs_by_path.values())
docs_with_content = docs_with_content[:top_k]
for citation_id, doc in enumerate(docs_with_content, 1):
doc.pop("_merged_chunks", None)
doc["citation_id"] = citation_id
return docs_with_content
@staticmethod
def _merge_text_blocks(existing: str, incoming: str) -> str:
"""合并同一文件的命中内容,避免重复分块生成多个引用。"""
current = (existing or "").strip()
candidate = (incoming or "").strip()
if not candidate or candidate in current:
return current
if not current or current in candidate:
return candidate
return f"{current}\n\n{candidate}"
@staticmethod
def _resolve_excerpt(content: str, chunk_text: str, chunk_index: int) -> str:
"""返回引用预览片段。
document_vector.chunk_text 是向量命中的分块文本应直接作为引用预览
仅在 chunk_text 为空时才按 chunk_index 从原文反推作为兜底
"""
text = (chunk_text or "").strip()
if text:
return text
return RAGService._extract_chunk_by_index(
content,
chunk_index,
settings.CHUNK_SIZE,
settings.CHUNK_OVERLAP,
) or text
@staticmethod
def _build_anchor(text: str, max_chars: int = 120) -> str:
for raw_line in (text or "").splitlines():
line = raw_line.strip()
if line:
return line[:max_chars]
return (text or "").strip()[:max_chars]
@staticmethod
def _extract_chunk_by_index(
content: str,
chunk_index: int,
chunk_size: int,
overlap: int,
) -> str:
"""按向量化时的分块参数,从原文切出命中的原始分块。"""
text = content or ""
if not text:
return ""
chunk_size = max(1, int(chunk_size or 1))
overlap = max(0, min(int(overlap or 0), chunk_size - 1))
step = chunk_size - overlap
start = max(0, int(chunk_index or 0)) * step
if start >= len(text):
return ""
return text[start:start + chunk_size]
@staticmethod
def _extract_chunk_context(content: str, anchor: str, fallback: str = "") -> str:
"""围绕命中锚点截取上下文窗口。
用锚点在原文中定位命中位置向前后各扩展 CHUNK_CONTEXT_WINDOW 字符
避免注入整篇长文档定位失败时回退为命中的原始分块
"""
text = content or ""
if not text:
return ""
pos = text.find(anchor) if anchor else -1
if pos < 0:
return fallback or text[:CHUNK_CONTEXT_WINDOW * 2]
start = max(0, pos - CHUNK_CONTEXT_WINDOW)
end = min(len(text), pos + len(anchor) + CHUNK_CONTEXT_WINDOW)
return text[start:end]
@staticmethod
async def generate_response(
db: AsyncSession,
query: str,
project_id: int,
llm_config_id: int,
retrieved_docs: List[Dict[str, Any]],
conversation_history: List[Dict[str, str]],
) -> str:
"""基于检索文档生成对话回复"""
llm_config, system_prompt, messages = await RAGService._prepare_generation(
db, query, llm_config_id, retrieved_docs, conversation_history
)
response_text = await LLMProviderService.generate_text(
provider=llm_config.provider,
endpoint_url=llm_config.endpoint_url,
api_key=llm_config.api_key,
llm_model_name=llm_config.llm_model_name,
timeout=llm_config.llm_timeout,
temperature=float(llm_config.llm_temperature),
top_p=float(llm_config.llm_top_p),
max_tokens=llm_config.llm_max_tokens,
system_prompt=system_prompt,
messages=messages,
)
return response_text
@staticmethod
async def generate_response_stream(
db: AsyncSession,
query: str,
project_id: int,
llm_config_id: int,
retrieved_docs: List[Dict[str, Any]],
conversation_history: List[Dict[str, str]],
) -> AsyncIterator[str]:
"""基于检索文档流式生成对话回复"""
llm_config, system_prompt, messages = await RAGService._prepare_generation(
db, query, llm_config_id, retrieved_docs, conversation_history
)
async for chunk in LLMProviderService.generate_text_stream(
provider=llm_config.provider,
endpoint_url=llm_config.endpoint_url,
api_key=llm_config.api_key,
llm_model_name=llm_config.llm_model_name,
timeout=llm_config.llm_timeout,
temperature=float(llm_config.llm_temperature),
top_p=float(llm_config.llm_top_p),
max_tokens=llm_config.llm_max_tokens,
system_prompt=system_prompt,
messages=messages,
):
yield chunk
@staticmethod
async def _prepare_generation(
db: AsyncSession,
query: str,
llm_config_id: int,
retrieved_docs: List[Dict[str, Any]],
conversation_history: List[Dict[str, str]],
):
stmt = select(LLMModelConfig).where(LLMModelConfig.config_id == llm_config_id)
result = await db.execute(stmt)
llm_config = result.scalar_one_or_none()
if not llm_config:
raise ValueError(f"LLM配置不存在: {llm_config_id}")
context_text = RAGService._build_context(retrieved_docs)
citation_hint = RAGService._build_citation_hint(retrieved_docs)
system_prompt = f"""你是一个知识库助手。基于用户提供的知识库文档,回答用户的问题。
如果知识库中没有相关信息请明确说明
知识库文档内容:
{context_text}
引用规则:
{citation_hint}
回答要求:
1. 仅基于知识库内容回答不要编造
2. 如果使用了某条知识请在对应句子后标注引用编号例如 [1] [1][3]
3. 如果没有找到依据请直接说明未检索到相关内容
请基于以上知识库内容用中文回答用户的问题"""
messages = list(conversation_history)
messages.append({"role": "user", "content": query})
return llm_config, system_prompt, messages
@staticmethod
def _build_context(retrieved_docs: List[Dict[str, Any]]) -> str:
"""构建上下文文本"""
if not retrieved_docs:
return "(未找到相关知识库内容)"
context_parts = []
for i, doc_info in enumerate(retrieved_docs, 1):
content = doc_info.get("content", "").strip()
if not content:
continue
citation_id = doc_info.get("citation_id", i)
context_parts.append(
f"--- [{citation_id}] 文档: {doc_info['file_path']} ---\n{content}"
)
return "\n\n".join(context_parts) if context_parts else "(未找到相关知识库内容)"
@staticmethod
def _build_citation_hint(retrieved_docs: List[Dict[str, Any]]) -> str:
"""构建引用提示"""
if not retrieved_docs:
return "未检索到文档时不要标注引用。"
return "\n".join(
f"[{doc.get('citation_id', index)}] {doc.get('file_name') or doc.get('file_path')}"
for index, doc in enumerate(retrieved_docs, 1)
)
rag_service = RAGService()

View File

@ -0,0 +1,716 @@
"""
ZVec 本地向量化服务 - 使用本地 zvec + 模型配置中的 embedding 模型
"""
import os
import hashlib
import logging
from pathlib import Path
from typing import Dict, List, Optional, Any
import zvec
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from app.models.llm_model_config import LLMModelConfig
from app.models.document_vector import DocumentVector
from app.services.llm_provider_service import LLMProviderService
logger = logging.getLogger(__name__)
def _resolve_zvec_data_dir() -> str:
"""解析 ZVec 向量库根目录。
优先用 ZVEC_DATA_DIR 环境变量否则放到 storage 存储区下的
vector_index 目录 search_index 并列便于统一备份/管理
"""
env_dir = os.getenv("ZVEC_DATA_DIR")
if env_dir:
return env_dir
from app.core.config import settings
storage_root = Path(settings.STORAGE_ROOT)
if not storage_root.is_absolute():
backend_dir = Path(__file__).parent.parent.parent
storage_root = (backend_dir / storage_root).resolve()
return str(storage_root / "vector_index")
ZVEC_DATA_DIR = _resolve_zvec_data_dir()
EMBEDDING_DIMENSION = int(os.getenv("ZVEC_EMBEDDING_DIM", "1536"))
class ZVecService:
"""基于本地 zvec 库的向量存储和检索服务"""
_collections: Dict[int, zvec.Collection] = {}
@classmethod
def _get_collection_path(cls, project_id: int) -> str:
"""返回项目 collection 路径。
只确保父目录存在叶子目录由 zvec.create_and_open 自行创建
预先创建叶子目录会让 create_and_open "path exists"
"""
path = Path(ZVEC_DATA_DIR) / str(project_id)
path.parent.mkdir(parents=True, exist_ok=True)
return str(path)
@classmethod
def _open_collection(cls, project_id: int) -> Optional[zvec.Collection]:
"""打开已存在的 collection不存在则返回 None"""
if project_id in cls._collections:
return cls._collections[project_id]
collection_path = cls._get_collection_path(project_id)
try:
collection = zvec.open(path=collection_path)
except Exception:
return None
cls._collections[project_id] = collection
return collection
@classmethod
def _get_or_create_collection(cls, project_id: int, dimension: int) -> zvec.Collection:
"""按指定维度获取或创建 collection。
ZVec collection 维度创建后固定若已存在的维度与当前 embedding
模型不一致则重建该项目的 collection旧向量作废需重新向量化
"""
collection = cls._open_collection(project_id)
if collection is not None:
existing_dim = cls._collection_dimension(collection)
if existing_dim is not None and existing_dim != dimension:
logger.warning(
"Project %s collection dim %s != model dim %s, rebuilding",
project_id, existing_dim, dimension,
)
cls._drop_collection(project_id)
collection = None
else:
return collection
collection_path = cls._get_collection_path(project_id)
# ZVec 的 create_and_open 要求目标路径不存在,清掉可能残留的空/损坏目录,保证幂等
leaf = Path(ZVEC_DATA_DIR) / str(project_id)
if leaf.exists():
import shutil
shutil.rmtree(leaf, ignore_errors=True)
schema = zvec.CollectionSchema(
name=f"project_{project_id}",
vectors=zvec.VectorSchema(
"embedding", zvec.DataType.VECTOR_FP32, dimension
),
)
collection = zvec.create_and_open(path=collection_path, schema=schema)
cls._collections[project_id] = collection
return collection
@staticmethod
def _collection_dimension(collection: zvec.Collection) -> Optional[int]:
"""尽力读取 collection 的向量维度,读取失败返回 None"""
try:
schema = collection.schema
vectors = getattr(schema, "vectors", None)
if vectors is None:
return None
vector_schema = vectors[0] if isinstance(vectors, (list, tuple)) else vectors
return getattr(vector_schema, "dimension", None)
except Exception:
return None
@classmethod
def _drop_collection(cls, project_id: int) -> None:
"""删除项目 collection 的本地数据并清理缓存"""
cls._collections.pop(project_id, None)
import shutil
path = Path(ZVEC_DATA_DIR) / str(project_id)
if path.exists():
shutil.rmtree(path, ignore_errors=True)
@classmethod
async def get_embedding_config(cls, db: AsyncSession) -> Optional[LLMModelConfig]:
"""获取系统配置的 embedding 模型(按 model_type 精确查询)"""
stmt = (
select(LLMModelConfig)
.where(
LLMModelConfig.is_active == True,
LLMModelConfig.model_type == "embedding",
)
.order_by(
LLMModelConfig.is_default.desc(),
LLMModelConfig.updated_at.desc(),
LLMModelConfig.config_id.desc(),
)
)
result = await db.execute(stmt)
return result.scalars().first()
@classmethod
async def generate_embedding(
cls, db: AsyncSession, text: str
) -> Optional[List[float]]:
"""调用配置的 embedding 模型生成向量"""
if not text or not text.strip():
return None
config = await cls.get_embedding_config(db)
if not config:
logger.warning("No embedding model configured, skipping vectorization")
return None
try:
return await LLMProviderService.generate_embedding(
provider=config.provider,
endpoint_url=config.endpoint_url,
api_key=config.api_key,
llm_model_name=config.llm_model_name,
text=text,
timeout=config.llm_timeout or 60,
dimension=config.embedding_dimension,
)
except Exception as exc:
logger.error(f"Embedding generation failed: {exc}")
return None
@staticmethod
def _doc_id(file_path: str, chunk_index: int = 0) -> str:
"""生成 ZVec 合法的 doc id。
ZVec doc id 不允许中文等非 ASCII 字符故对文件路径做 MD5 哈希
分块后每个 chunk 需独立 doc id故追加 chunk 序号
file_path 的反查通过 document_vector 表的 zvec_id 字段完成
"""
base = hashlib.md5(file_path.encode("utf-8")).hexdigest()
return f"{base}#{chunk_index}"
@staticmethod
def _content_hash(content: str) -> str:
return hashlib.sha256((content or "").encode("utf-8")).hexdigest()
@staticmethod
def _make_anchor(text: str, max_chars: int = 120) -> str:
"""从 chunk 文本中提取定位锚点:首个非空文本行、去除常见 markdown 标记。
锚点供旧引用定位兼容使用故取纯文本片段
"""
import re
for raw_line in (text or "").splitlines():
line = raw_line.strip()
if not line:
continue
# 去除标题井号、列表符号、引用符号等行首标记
line = re.sub(r"^\s*#{1,6}\s+", "", line)
line = re.sub(r"^\s*[-*+]\s+", "", line)
line = re.sub(r"^\s*>\s+", "", line)
line = re.sub(r"^\s*\d+\.\s+", "", line)
# 去除行内强调/代码标记
line = re.sub(r"[*_`~]", "", line)
line = line.strip()
if line:
return line[:max_chars]
return (text or "").strip()[:max_chars]
@classmethod
def _chunk_text(
cls,
content: str,
chunk_size: int,
overlap: int,
) -> List[Dict[str, Any]]:
"""按字符滑动窗口分块。
返回 [{index, text, anchor}]相邻分块重叠 overlap 个字符
以避免语义在分块边界被切断空内容返回空列表
"""
text = content or ""
if not text.strip():
return []
chunk_size = max(1, int(chunk_size))
overlap = max(0, min(int(overlap), chunk_size - 1))
step = chunk_size - overlap
chunks: List[Dict[str, Any]] = []
start = 0
length = len(text)
index = 0
while start < length:
piece = text[start:start + chunk_size]
if piece.strip():
chunks.append({
"index": index,
"text": piece,
"anchor": cls._make_anchor(piece),
})
index += 1
start += step
return chunks
@classmethod
async def _upsert_vector_record(
cls,
db: AsyncSession,
project_id: int,
file_path: str,
*,
status: str,
chunk_index: int = 0,
chunk_text: Optional[str] = None,
content_hash: Optional[str] = None,
zvec_id: Optional[str] = None,
error_message: Optional[str] = None,
) -> None:
"""写入/更新 document_vector 表中某个分块的向量化状态。
(project_id, file_path, chunk_index) 定位记录失败状态通常写
chunk_index=0 的一条即可成功状态则逐分块写入
"""
stmt = select(DocumentVector).where(
DocumentVector.project_id == project_id,
DocumentVector.file_path == file_path,
DocumentVector.chunk_index == chunk_index,
)
result = await db.execute(stmt)
record = result.scalar_one_or_none()
if record is None:
record = DocumentVector(
project_id=project_id,
file_path=file_path,
chunk_index=chunk_index,
chunk_text=chunk_text,
status=status,
content_hash=content_hash,
zvec_id=zvec_id,
error_message=(error_message or "")[:500] or None,
)
db.add(record)
else:
record.status = status
if chunk_text is not None:
record.chunk_text = chunk_text
if content_hash is not None:
record.content_hash = content_hash
if zvec_id is not None:
record.zvec_id = zvec_id
record.error_message = (error_message or "")[:500] or None
@classmethod
async def _purge_file_chunks(
cls,
db: AsyncSession,
project_id: int,
file_path: str,
) -> None:
"""删除某文件在 ZVec 与 document_vector 表中的所有分块记录(不 commit"""
# 先查出该文件所有分块的 zvec_id用于从 ZVec 集合中删除
result = await db.execute(
select(DocumentVector.zvec_id).where(
DocumentVector.project_id == project_id,
DocumentVector.file_path == file_path,
)
)
zvec_ids = [z for (z,) in result.all() if z]
if zvec_ids:
try:
collection = cls._open_collection(project_id)
if collection is not None:
collection.delete(zvec_ids)
except Exception as exc:
logger.error(f"ZVec delete chunks failed for {file_path}: {exc}")
await db.execute(
delete(DocumentVector).where(
DocumentVector.project_id == project_id,
DocumentVector.file_path == file_path,
)
)
@classmethod
async def vectorize_markdown(
cls,
db: AsyncSession,
project_id: int,
file_path: str,
content: str,
) -> bool:
"""对 MD 文件分块向量化并存入 ZVec同时逐分块记录状态到 document_vector。
流程删除旧分块 分块 逐块生成 embedding写入 ZVec写记录
"""
from app.core.config import settings
content_hash = cls._content_hash(content)
# 先清理该文件的所有旧分块(增量重建),保证不残留过期向量
await cls._purge_file_chunks(db, project_id, file_path)
chunks = cls._chunk_text(content, settings.CHUNK_SIZE, settings.CHUNK_OVERLAP)
if not chunks:
# 空文件:清理后直接提交,视为成功(无可向量化内容)
await db.commit()
return True
success_count = 0
last_error: Optional[str] = None
for chunk in chunks:
embedding = await cls.generate_embedding(db, chunk["text"])
if not embedding:
last_error = "未配置可用的 embedding 模型或向量生成失败"
continue
doc_id = cls._doc_id(file_path, chunk["index"])
try:
collection = cls._get_or_create_collection(project_id, len(embedding))
collection.insert([
zvec.Doc(id=doc_id, vectors={"embedding": embedding})
])
except Exception as exc:
logger.error(
f"ZVec insert failed for {file_path} chunk {chunk['index']}: {exc}"
)
last_error = str(exc)
continue
await cls._upsert_vector_record(
db, project_id, file_path,
status="success",
chunk_index=chunk["index"],
chunk_text=chunk["text"],
content_hash=content_hash,
zvec_id=doc_id,
)
success_count += 1
if success_count == 0:
# 全部分块失败:写一条 failed 记录chunk_index=0便于进度展示
await cls._upsert_vector_record(
db, project_id, file_path,
status="failed",
chunk_index=0,
content_hash=content_hash,
error_message=last_error or "向量化失败",
)
await db.commit()
return False
await db.commit()
return True
@classmethod
async def delete_vector(
cls,
db: AsyncSession,
project_id: int,
file_path: str,
) -> bool:
"""从 ZVec 删除文档的所有分块向量,并清除 document_vector 记录"""
try:
await cls._purge_file_chunks(db, project_id, file_path)
await db.commit()
except Exception as exc:
logger.error(f"Delete document_vector record failed for {file_path}: {exc}")
await db.rollback()
return True
# 兼容 project_file_service 中的调用别名
@classmethod
async def delete_vectors(
cls,
db: AsyncSession,
project_id: int,
file_path: str,
) -> bool:
"""delete_vector 的别名(兼容文件服务调用)"""
return await cls.delete_vector(db, project_id, file_path)
@classmethod
async def sync_vector_move(
cls,
db: AsyncSession,
project_id: int,
old_path: str,
new_path: str,
content: Optional[str] = None,
) -> bool:
"""文件移动/重命名时同步向量。
content 为空时会尝试读取新路径文件内容再向量化
"""
await cls.delete_vector(db, project_id, old_path)
if content is None:
return await cls.revectorize_path(db, project_id, new_path)
return await cls.vectorize_markdown(db, project_id, new_path, content)
@classmethod
async def update_vector_path(
cls,
db: AsyncSession,
project_id: int,
old_path: str,
new_path: str,
content: str,
) -> bool:
"""文件重命名/移动时更新向量(删除旧的,插入新的)"""
await cls.delete_vector(db, project_id, old_path)
return await cls.vectorize_markdown(db, project_id, new_path, content)
@classmethod
async def revectorize_path(
cls,
db: AsyncSession,
project_id: int,
file_path: str,
) -> bool:
"""读取项目中指定文件内容并重新向量化"""
content = await cls._read_project_file(db, project_id, file_path)
if content is None:
return False
return await cls.vectorize_markdown(db, project_id, file_path, content)
@staticmethod
async def _read_project_file(
db: AsyncSession,
project_id: int,
file_path: str,
) -> Optional[str]:
"""读取项目内某个相对路径文件的文本内容"""
from app.models.project import Project
from app.services.storage import storage_service
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
return None
try:
full_path = storage_service.get_secure_path(project.storage_key, file_path)
return await storage_service.read_file(full_path)
except Exception as exc:
logger.error(f"Read project file failed {file_path}: {exc}")
return None
@classmethod
def _list_markdown_files(cls, storage_key: str) -> List[str]:
"""列出项目下所有 MD 文件的相对路径(排除 _assets 资源目录)"""
from app.services.storage import storage_service
root = storage_service.get_secure_path(storage_key)
if not root.exists():
return []
files: List[str] = []
for md_path in root.rglob("*.md"):
try:
rel = md_path.relative_to(root).as_posix()
except ValueError:
continue
# 跳过 _assets 资源目录
if rel.startswith("_assets/") or "/_assets/" in rel:
continue
# 跳过 . 开头的隐藏文件或位于隐藏目录下的文件(如 .git/.obsidian 等)
if any(part.startswith(".") for part in rel.split("/")):
continue
files.append(rel)
return files
@staticmethod
def _aggregate_file_records(
rows: List[DocumentVector],
) -> Dict[str, Dict[str, Any]]:
"""将多分块记录按文件聚合为文件级状态。
返回 {file_path: {status, content_hash, error_message}}
任一分块 failed 则该文件视为 failed否则若有 success success
"""
by_file: Dict[str, Dict[str, Any]] = {}
for r in rows:
agg = by_file.get(r.file_path)
if agg is None:
agg = {"status": r.status, "content_hash": r.content_hash, "error_message": r.error_message}
by_file[r.file_path] = agg
else:
# failed 优先级最高,用于暴露问题
if r.status == "failed":
agg["status"] = "failed"
agg["error_message"] = r.error_message or agg.get("error_message")
elif agg["status"] != "failed" and r.status == "success":
agg["status"] = "success"
if agg.get("content_hash") is None and r.content_hash:
agg["content_hash"] = r.content_hash
return by_file
@classmethod
async def get_progress(
cls,
db: AsyncSession,
project_id: int,
storage_key: str,
) -> Dict[str, Any]:
"""统计项目向量化进度:已成功 / 失败 / 待处理 / 总 MD 文件数"""
md_files = cls._list_markdown_files(storage_key)
total = len(md_files)
result = await db.execute(
select(DocumentVector).where(DocumentVector.project_id == project_id)
)
records = cls._aggregate_file_records(list(result.scalars().all()))
success = 0
failed_items: List[Dict[str, str]] = []
pending_items: List[str] = []
for path in md_files:
record = records.get(path)
if record and record["status"] == "success":
success += 1
elif record and record["status"] == "failed":
failed_items.append({
"file_path": path,
"error": record.get("error_message") or "向量化失败",
})
else:
pending_items.append(path)
percent = int(success / total * 100) if total else 0
return {
"total": total,
"success": success,
"failed": len(failed_items),
"pending": len(pending_items),
"percent": percent,
"failed_items": failed_items[:50],
"pending_items": pending_items[:50],
"embedding_ready": await cls.get_embedding_config(db) is not None,
}
@classmethod
async def vectorize_project(
cls,
db: AsyncSession,
project_id: int,
storage_key: str,
*,
force: bool = False,
) -> Dict[str, Any]:
"""批量向量化项目下所有 MD 文件。
force=False 时跳过内容未变化且已成功的文件增量
force=True 时全部重新向量化全量
"""
md_files = cls._list_markdown_files(storage_key)
md_file_set = set(md_files)
result = await db.execute(
select(DocumentVector).where(DocumentVector.project_id == project_id)
)
records = cls._aggregate_file_records(list(result.scalars().all()))
if force:
cls._drop_collection(project_id)
await db.execute(
delete(DocumentVector).where(DocumentVector.project_id == project_id)
)
await db.commit()
records = {}
else:
for stale_path in set(records) - md_file_set:
await cls.delete_vector(db, project_id, stale_path)
records.pop(stale_path, None)
processed = 0
skipped = 0
failed = 0
for path in md_files:
content = await cls._read_project_file(db, project_id, path)
if content is None:
failed += 1
continue
if not force:
record = records.get(path)
if (
record
and record["status"] == "success"
and record.get("content_hash") == cls._content_hash(content)
):
skipped += 1
continue
ok = await cls.vectorize_markdown(db, project_id, path, content)
if ok:
processed += 1
else:
failed += 1
return {
"total": len(md_files),
"processed": processed,
"skipped": skipped,
"failed": failed,
}
@classmethod
async def search_similar(
cls,
db: AsyncSession,
project_id: int,
query: str,
top_k: int = 5,
) -> List[Dict[str, Any]]:
"""基于查询文本检索相似文档"""
query_embedding = await cls.generate_embedding(db, query)
if not query_embedding:
return []
try:
collection = cls._open_collection(project_id)
if collection is None:
return [] # 项目尚未向量化
results = collection.query(
zvec.VectorQuery("embedding", vector=query_embedding),
topk=top_k,
)
# doc id 是「路径哈希#分块序号」,通过 document_vector 表的 zvec_id
# 反查真实 file_path、chunk_index 及分块文本 chunk_text
zvec_ids = [r.id for r in results]
if not zvec_ids:
return []
db_result = await db.execute(
select(DocumentVector).where(
DocumentVector.project_id == project_id,
DocumentVector.zvec_id.in_(zvec_ids),
)
)
record_by_id = {r.zvec_id: r for r in db_result.scalars().all()}
matched_docs = []
for result in results:
record = record_by_id.get(result.id)
if not record:
continue
matched_docs.append({
"file_path": record.file_path,
"chunk_index": record.chunk_index,
"chunk_text": record.chunk_text or "",
"score": result.score if hasattr(result, "score") else 0.0,
})
return matched_docs
except Exception as exc:
logger.error(f"ZVec search failed: {exc}")
return []
@classmethod
def close_collection(cls, project_id: int) -> None:
"""关闭项目的 ZVec collection"""
if project_id in cls._collections:
del cls._collections[project_id]
zvec_service = ZVecService()

View File

@ -0,0 +1,33 @@
-- 创建知识库对话会话表
CREATE TABLE IF NOT EXISTS `chat_session` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '会话ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`llm_config_id` BIGINT NOT NULL COMMENT 'LLM配置ID',
`title` VARCHAR(255) NOT NULL COMMENT '会话标题',
`description` TEXT DEFAULT NULL COMMENT '会话描述',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否激活',
`message_count` INT NOT NULL DEFAULT 0 COMMENT '消息数',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_project_id` (`project_id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_project_user` (`project_id`, `user_id`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`llm_config_id`) REFERENCES `llm_model_config`(`config_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话会话表';
-- 创建知识库对话消息表
CREATE TABLE IF NOT EXISTS `chat_message` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID',
`session_id` BIGINT NOT NULL COMMENT '会话ID',
`role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)',
`content` TEXT NOT NULL COMMENT '消息内容',
`referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)',
`tokens_used` INT DEFAULT NULL COMMENT '消耗的token数',
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX `idx_session_id` (`session_id`),
FOREIGN KEY (`session_id`) REFERENCES `chat_session`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话消息表';

View File

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS `document_vector` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '向量ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径',
`chunk_index` INT NOT NULL DEFAULT 0 COMMENT '分块序号0起同一文件可有多个分块',
`chunk_text` TEXT DEFAULT NULL COMMENT '分块首段文本,作为点击引用时的定位锚点',
`content_hash` VARCHAR(64) DEFAULT NULL COMMENT '整个文件内容哈希值,用于判断文件是否变更',
`zvec_id` VARCHAR(256) DEFAULT NULL COMMENT 'ZVec返回的向量ID每个分块独立',
`zvec_response` TEXT DEFAULT NULL COMMENT 'ZVec完整响应JSON',
`status` VARCHAR(32) NOT NULL DEFAULT 'success' COMMENT '向量化状态success/failed/pending',
`error_message` VARCHAR(500) DEFAULT NULL COMMENT '错误信息',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_project_file` (`project_id`, `file_path`),
INDEX `idx_project_file_chunk` (`project_id`, `file_path`, `chunk_index`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文档向量表';

View File

@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS `project_vectorization_task` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '任务ID',
`task_id` VARCHAR(64) NOT NULL COMMENT '任务唯一标识',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`user_id` BIGINT NOT NULL COMMENT '触发用户ID',
`task_type` VARCHAR(32) NOT NULL COMMENT '任务类型incremental/full',
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态pending/running/success/failed',
`total` INT NOT NULL DEFAULT 0 COMMENT '文件总数',
`processed` INT NOT NULL DEFAULT 0 COMMENT '处理成功数',
`skipped` INT NOT NULL DEFAULT 0 COMMENT '跳过数',
`failed` INT NOT NULL DEFAULT 0 COMMENT '失败数',
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
`started_at` DATETIME DEFAULT NULL COMMENT '开始时间',
`finished_at` DATETIME DEFAULT NULL COMMENT '完成时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_vector_task_id` (`task_id`),
INDEX `idx_vector_task_project_status` (`project_id`, `status`),
INDEX `idx_vector_task_user_id` (`user_id`),
INDEX `idx_vector_task_created_at` (`created_at`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目向量化任务表';

View File

@ -202,6 +202,108 @@ CREATE TABLE IF NOT EXISTS `share_links` (
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表';
-- 12. LLM 模型配置表
CREATE TABLE IF NOT EXISTS `llm_model_config` (
`config_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID',
`model_code` VARCHAR(128) NOT NULL COMMENT '模型编码',
`model_name` VARCHAR(255) NOT NULL COMMENT '模型名称',
`model_type` VARCHAR(32) NOT NULL DEFAULT 'chat' COMMENT '模型类型: chat/embedding',
`provider` VARCHAR(64) DEFAULT NULL COMMENT '模型提供方',
`endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '接口地址',
`api_key` VARCHAR(512) DEFAULT NULL COMMENT 'API Key',
`llm_model_name` VARCHAR(128) NOT NULL COMMENT '模型名称/部署名',
`llm_timeout` INT NOT NULL DEFAULT 120 COMMENT '超时时间(秒)',
`llm_temperature` DECIMAL(5,2) NOT NULL DEFAULT 0.70 COMMENT '温度',
`llm_top_p` DECIMAL(5,2) NOT NULL DEFAULT 0.90 COMMENT 'Top P',
`llm_max_tokens` INT NOT NULL DEFAULT 2048 COMMENT '最大输出 Token',
`llm_system_prompt` TEXT DEFAULT NULL COMMENT '系统提示词',
`embedding_dimension` INT DEFAULT NULL COMMENT '向量维度(仅 embedding 类型)',
`description` VARCHAR(500) DEFAULT NULL COMMENT '描述',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_model_code` (`model_code`),
INDEX `idx_model_code` (`model_code`),
INDEX `idx_model_type` (`model_type`),
INDEX `idx_is_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='LLM 模型配置表';
-- 13. 文档向量表
CREATE TABLE IF NOT EXISTS `document_vector` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '向量ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径',
`content_hash` VARCHAR(64) DEFAULT NULL COMMENT '内容哈希值,用于判断文件是否变更',
`zvec_id` VARCHAR(256) DEFAULT NULL COMMENT 'ZVec返回的向量ID',
`zvec_response` TEXT DEFAULT NULL COMMENT 'ZVec完整响应JSON',
`status` VARCHAR(32) NOT NULL DEFAULT 'success' COMMENT '向量化状态success/failed/pending',
`error_message` VARCHAR(500) DEFAULT NULL COMMENT '错误信息',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_project_file` (`project_id`, `file_path`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文档向量表';
-- 14. 知识库对话会话表
CREATE TABLE IF NOT EXISTS `chat_session` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '会话ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`llm_config_id` BIGINT NOT NULL COMMENT 'LLM配置ID',
`title` VARCHAR(255) NOT NULL COMMENT '会话标题',
`description` TEXT DEFAULT NULL COMMENT '会话描述',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否激活',
`message_count` INT NOT NULL DEFAULT 0 COMMENT '消息数',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_project_id` (`project_id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_project_user` (`project_id`, `user_id`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`llm_config_id`) REFERENCES `llm_model_config`(`config_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库对话会话表';
-- 15. 知识库对话消息表
CREATE TABLE IF NOT EXISTS `chat_message` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID',
`session_id` BIGINT NOT NULL COMMENT '会话ID',
`role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)',
`content` TEXT NOT NULL COMMENT '消息内容',
`referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)',
`tokens_used` INT DEFAULT NULL COMMENT '消耗的token数',
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX `idx_session_id` (`session_id`),
FOREIGN KEY (`session_id`) REFERENCES `chat_session`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库对话消息表';
-- 16. 项目向量化任务表
CREATE TABLE IF NOT EXISTS `project_vectorization_task` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '任务ID',
`task_id` VARCHAR(64) NOT NULL COMMENT '任务唯一标识',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`user_id` BIGINT NOT NULL COMMENT '触发用户ID',
`task_type` VARCHAR(32) NOT NULL COMMENT '任务类型incremental/full',
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态pending/running/success/failed',
`total` INT NOT NULL DEFAULT 0 COMMENT '文件总数',
`processed` INT NOT NULL DEFAULT 0 COMMENT '处理成功数',
`skipped` INT NOT NULL DEFAULT 0 COMMENT '跳过数',
`failed` INT NOT NULL DEFAULT 0 COMMENT '失败数',
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
`started_at` DATETIME DEFAULT NULL COMMENT '开始时间',
`finished_at` DATETIME DEFAULT NULL COMMENT '完成时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_vector_task_id` (`task_id`),
INDEX `idx_vector_task_project_status` (`project_id`, `status`),
INDEX `idx_vector_task_user_id` (`user_id`),
INDEX `idx_vector_task_created_at` (`created_at`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目向量化任务表';
-- 插入初始角色数据
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
('超级管理员', 'super_admin', '拥有系统所有权限', 1),
@ -216,7 +318,7 @@ INSERT INTO `system_menus` (`id`, `parent_id`, `menu_name`, `menu_code`, `menu_t
(4, 1, '编辑项目', 'project:edit', 3, NULL, NULL, 3, 'project:edit'),
(5, 1, '删除项目', 'project:delete', 3, NULL, NULL, 4, 'project:delete'),
(10, 0, '知识库管理', 'knowledge', 1, NULL, 'FileTextOutlined', 2, NULL),
(11, 10, '我的知识库', 'knowledge:view', 3, '/knowledges', NULL, 1, 'knowledge:view'),
(11, 10, '我的知识库', 'knowledge:view', 2, '/chat', NULL, 1, 'knowledge:view'),
(12, 10, '编辑知识库', 'knowledge:edit', 3, NULL, NULL, 2, 'knowledge:edit'),
(13, 10, '删除知识库', 'knowledge:delete', 3, NULL, NULL, 3, 'knowledge:delete'),
(20, 0, '系统管理', 'system', 1, '/system', 'SettingOutlined', 3, NULL),

View File

@ -0,0 +1,19 @@
-- 迁移脚本:为 document_vector 表增加分块chunk支持
-- 背景RAG 由「整篇文档单向量」升级为「按分块向量化」,
-- 一个文件可对应多条 chunk 记录。
--
-- 用法(已有数据库升级):
-- mysql -u <user> -p <db_name> < migrate_document_vector_add_chunks.sql
--
-- 注意:升级后建议对所有项目执行一次「全量重建」向量化,
-- 以将旧的整篇文档向量替换为分块向量(旧记录 chunk_index 默认为 0
ALTER TABLE `document_vector`
ADD COLUMN `chunk_index` INT NOT NULL DEFAULT 0
COMMENT '分块序号0起同一文件可有多个分块' AFTER `file_path`,
ADD COLUMN `chunk_text` TEXT DEFAULT NULL
COMMENT '分块首段文本,作为点击引用时的定位锚点' AFTER `chunk_index`;
-- 新增按 (project_id, file_path, chunk_index) 的复合索引,加速按分块查询/删除
ALTER TABLE `document_vector`
ADD INDEX `idx_project_file_chunk` (`project_id`, `file_path`, `chunk_index`);

View File

@ -0,0 +1,65 @@
import unittest
from app.api.v1.chat import _canonicalize_message_citations, _compact_cited_refs
from app.services.rag_service import RAGService
class ChatCitationTest(unittest.TestCase):
def test_duplicate_file_citations_are_renumbered_once(self):
content, refs = _canonicalize_message_citations(
"泰坦属于土星的卫星[2][4][5]。",
[
{"citation_id": 2, "file_path": "内容导航.md", "excerpt": "片段一"},
{"citation_id": 4, "file_path": "内容导航.md", "excerpt": "片段二"},
{"citation_id": 5, "file_path": "内容导航.md", "excerpt": "片段三"},
],
)
self.assertEqual(content, "泰坦属于土星的卫星[1]。")
self.assertEqual(len(refs), 1)
self.assertEqual(refs[0]["citation_id"], 1)
self.assertEqual(refs[0]["excerpt"], "片段一\n\n片段二\n\n片段三")
def test_overlapping_blocks_are_not_duplicated(self):
self.assertEqual(
RAGService._merge_text_blocks("泰坦属于土星", "泰坦属于土星"),
"泰坦属于土星",
)
def test_single_used_reference_is_compacted_to_one(self):
content, refs = _compact_cited_refs(
"泰坦是土星的卫星[3],土卫二也属于土星[3]。",
[
{"citation_id": 1, "file_path": "其他一.md"},
{"citation_id": 2, "file_path": "其他二.md"},
{"citation_id": 3, "file_path": "内容导航.md"},
],
)
self.assertEqual(content, "泰坦是土星的卫星[1],土卫二也属于土星[1]。")
self.assertEqual(refs, [{
"citation_id": 1,
"file_path": "内容导航.md",
"anchor_text": "",
"excerpt": "",
}])
def test_used_references_follow_first_appearance_order(self):
content, refs = _compact_cited_refs(
"先使用第三份[3],再使用第一份[1]。",
[
{"citation_id": 1, "file_path": "第一份.md"},
{"citation_id": 2, "file_path": "第二份.md"},
{"citation_id": 3, "file_path": "第三份.md"},
],
)
self.assertEqual(content, "先使用第三份[1],再使用第一份[2]。")
self.assertEqual(
[(ref["citation_id"], ref["file_path"]) for ref in refs],
[(1, "第三份.md"), (2, "第一份.md")],
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,220 @@
# 代码结构审计与优化记录2026-04-08
本文依据 [`docs/code-structure-standards.md`](./code-structure-standards.md) 对当前仓库进行前后端结构审计,并记录本轮已落地的优化项。
## 1. 审计范围
- 后端:`backend/app/api/v1`、`backend/app/services`
- 前端:`frontend/src/pages`、`frontend/src/utils`、`frontend/src/components`
## 2. 主要结构问题
### 2.1 后端 Router 过厚
以下问题在多个 router 中重复出现:
- 项目存在性校验
- 读权限/写权限/成员角色判断
- 项目文档数量统计
- 公开项目与私密项目访问分支
受影响较明显的文件:
- `backend/app/api/v1/projects.py`
- `backend/app/api/v1/files.py`
- `backend/app/api/v1/preview.py`
- `backend/app/api/v1/search.py`
- `backend/app/api/v1/git_repos.py`
这类问题违反了“Router 只做协议转换”和“副作用与规则应收口”的要求,导致:
- 权限规则难以统一演进
- 同一类修改影响多个入口
- 新增接口时容易复制旧逻辑继续膨胀
### 2.2 前端文档页承担过多页面级编排
`frontend/src/pages/Document/DocumentPage.jsx` 在审计前同时承担了:
- 文件树加载
- URL deep link 同步
- 搜索与节点展开
- Markdown 文件加载
- PDF URL 组装
- Markdown 内链解析
- TOC 生成
- 页面展示渲染
这已经明显超过“页面入口必须薄”的建议范围,属于页面入口和页面编排层混杂。
### 2.3 前端认证存储边界分散
`access_token` 与登录态清理逻辑分散在:
- `request.js`
- `ProtectedRoute.jsx`
- `userStore.js`
- 多个页面文件
这违背了“前端基础设施应收口”的要求,也增加了未来切换 token 策略时的修改面。
## 3. 本轮已落地优化
### 3.1 后端新增项目域服务
新增:
- `backend/app/services/project_service.py`
收口能力:
- 项目存在性查询
- 项目成员查询
- 项目角色解析
- 项目读权限校验
- 项目角色权限校验
- 项目写权限校验
- 项目文档数量统计
- 项目序列化补充 `doc_count`
### 3.2 后端 Router 瘦身
已切换到项目域服务的文件:
- `backend/app/api/v1/projects.py`
- `backend/app/api/v1/files.py`
- `backend/app/api/v1/preview.py`
- `backend/app/api/v1/search.py`
- `backend/app/api/v1/git_repos.py`
效果:
- 权限判断不再散落在每个 handler 内
- `projects.py` 的列表序列化不再自己统计文档数
- `preview.py` 的公开/私密访问逻辑集中复用
- `files.py` 的读写权限边界更明确
### 3.3 前端抽离文档页编排层
新增:
- `frontend/src/pages/Document/useDocumentBrowser.js`
- `frontend/src/pages/Document/documentBrowserUtils.jsx`
抽离后的职责:
- 文件树加载与状态维护
- URL 参数驱动的文档打开
- Markdown/PDF 切换
- 搜索与节点展开
- TOC 生成
- Markdown 内链解析
`frontend/src/pages/Document/DocumentPage.jsx` 现在主要保留:
- 页面布局
- Git 操作 UI
- 分享设置 UI
- Markdown 渲染输出
### 3.4 前端认证存储收口
新增:
- `frontend/src/utils/authStorage.js`
已接入:
- `frontend/src/utils/request.js`
- `frontend/src/components/ProtectedRoute.jsx`
- `frontend/src/stores/userStore.js`
- `frontend/src/pages/Preview/PreviewPage.jsx`
- `frontend/src/pages/Document/DocumentPage.jsx`
### 3.5 继续下沉编辑页与浏览器副作用
新增:
- `frontend/src/pages/Document/useDocumentEditorWorkspace.js`
- `frontend/src/utils/browserIO.js`
- `frontend/src/pages/ProjectList/useProjectExport.js`
- `frontend/src/pages/ProjectList/useProjectShare.js`
- `frontend/src/pages/ProjectList/useProjectCollaboration.js`
- `frontend/src/pages/ProjectList/useProjectGitRepos.js`
本轮继续优化后:
- `DocumentEditor.jsx` 中的树加载、URL 同步、文件打开、刷新流程已下沉到工作区 hook
- 项目导出、文件名解析、复制链接等浏览器能力已从页面中抽出为通用工具
- `ProjectList.jsx``DocumentPage.jsx` 不再自己维护复制降级实现
- `ProjectList.jsx` 中的导出轮询、分享设置、成员协作、Git 仓库管理已各自收口为稳定子流程
### 3.6 补齐后端文件用例与预览页编排层
新增:
- `backend/app/services/project_export_service.py`
- `backend/app/services/project_file_service.py`
- `frontend/src/pages/Preview/usePreviewBrowser.js`
- `frontend/src/pages/Preview/previewBrowserUtils.js`
本轮继续优化后:
- `projects.py` 中的导出任务状态管理、过期清理与 ZIP 打包流程已下沉到独立导出服务
- `files.py` 的保存、创建、删除、重命名、移动流程已通过项目文件服务统一编排
- `backend/app/mcp/server.py` 不再复制文件写入后的索引、日志、通知逻辑,改为复用同一文件用例服务
- `PreviewPage.jsx` 中的密码校验、目录树加载、文档切换、搜索、TOC 与 PDF/Markdown 模式切换已下沉到页面级 hook
## 4. 验证结果
已执行:
1. `python3 -m py_compile backend/app/services/project_service.py backend/app/api/v1/projects.py backend/app/api/v1/files.py backend/app/api/v1/preview.py backend/app/api/v1/search.py backend/app/api/v1/git_repos.py`
2. `npm run build`(在 `frontend/` 下)
结果:
- 后端语法校验通过
- 前端生产构建通过
- 仍存在 Vite 默认的大包告警,属于性能优化项,不影响本轮结构改造正确性
## 5. 剩余建议
以下问题已在审计中确认,但未在本轮一并处理,以控制风险:
### 5.1 前端仍有超大页面待继续下沉
重点文件:
- `frontend/src/pages/Document/DocumentEditor.jsx`
- `frontend/src/pages/ProjectList/ProjectList.jsx`
- `frontend/src/pages/Preview/PreviewPage.jsx` 已完成一轮下沉,但页面内仍保留部分响应式布局与渲染分支,可后续继续压薄
建议方向:
- 将上传/导出/重命名/移动等流程拆为页面级 controller 或 hook
- 将下载、文件名解析、Blob 导出等浏览器副作用进一步沉到可复用基础模块
### 5.2 前端构建产物体积偏大
构建结果中仍存在明显的大 chunk 警告,建议后续评估:
- 文档页与编辑页按路由拆包
- PDF/Markdown 编辑器相关库延迟加载
- 搜索与预览相关能力按场景拆分
### 5.3 后端入口层仍有剩余热点
虽然项目导出和文件编排已下沉,但以下热点仍值得继续优化:
- `backend/app/api/v1/projects.py` 仍包含较多成员管理与分享配置流程,可继续按主题拆出更明确的 use case
- `backend/app/api/v1/files.py` 仍承载上传、导入、导出等多类文件主题,后续可继续按“编辑文件 / 传输文件 / 读取文档”拆分
## 6. 结论
本轮优化重点完成了两件事:
1. 后端将重复的项目访问规则从 router 中收口到项目域服务
2. 前端将文档浏览页的页面级编排从页面入口中拆出,并统一认证存储边界
这两项改动都直接对应结构规范中的高优先级问题,属于低风险、可验证、后续可继续扩展的结构优化。

View File

@ -0,0 +1,499 @@
# 通用代码结构设计规范(强制执行)
本文档定义项目在长期演进中应遵守的结构边界、拆分原则与审计标准。
目标不是机械追求“小文件”“多目录”或“某种固定架构”,而是让任意语言、任意框架、任意部署形态下的代码都满足以下要求:
- 入口清晰
- 依赖方向稳定
- 职责边界明确
- 修改影响面可控
- 新人可顺序读懂
本文档适用于:
- 前端应用
- 后端服务
- CLI / 脚本 / Worker
- SDK / Library
- 单仓或多仓项目
本文档自落地起作为后续开发与重构的默认结构基线。
---
## 1. 核心原则
### 1.1 先划清职责,再决定目录
- 先区分“入口层 / 业务编排层 / 领域规则层 / 基础设施层 / 共享基础层”,再决定是否拆目录、拆文件、拆包。
- 目录结构是职责设计的结果,不是先验答案。
- 同一个团队可以采用不同目录形态,但不能模糊职责边界。
### 1.2 领域内聚优先于机械拆分
- 第一判断标准是“是否仍然属于同一业务主题”,不是“文件还能不能再拆小”。
- 同一主题内的读取、写入、校验、少量派生逻辑,可以保留在同一模块中。
- 如果拆分只会制造更多跳转、隐藏真实依赖、降低顺序可读性,就不应继续拆。
### 1.3 装配层必须薄
- 启动入口、路由入口、页面入口、命令入口都只负责装配。
- 装配层可以做依赖注入、参数收集、状态接线、组件拼装、调用编排入口。
- 装配层不应承载复杂业务规则、数据库细节、文件系统细节、网络细节或长流程状态机。
### 1.4 副作用必须收口
- 数据库访问、文件读写、网络调用、缓存、定时器、浏览器存储、进程环境依赖等,都属于副作用。
- 副作用应集中在可识别的边界模块中不应在页面、视图、路由、DTO、纯工具里四处散落。
- 任何需要 mock、替换、复用或测试隔离的外部依赖都应有明确归属。
### 1.5 依赖方向必须单向
- 默认依赖方向应从外向内:入口层 -> 业务编排层 -> 领域规则层 -> 基础设施实现。
- 共享基础层可以被多个层使用,但不能反向依赖业务实现。
- 低层不能反向引用高层具体实现来“图省事”。
### 1.6 文件大小不是目标,跨职责才是风险
- 行数只作为预警信号,不作为强制拆分指标。
- 真正需要拆分的信号包括:
- 一个模块服务多个业务主题
- 一个模块同时承担输入解析、业务决策、数据访问和展示
- 一个改动常常需要在同一文件中切换多种关注点
- 一个模块需要为不同调用方维持多套语义
### 1.7 重构优先低风险搬运
- 结构重构优先做“职责收口、边界清理、命名校正、依赖下沉/上提”。
- 默认不要在同一轮改动里同时进行:
- 大规模结构调整
- 新功能开发
- 行为修复
- 如果确需并行,必须以最小范围控制风险,并显式验证关键路径。
### 1.8 命名必须体现主题
- 文件、目录、模块、类型、服务名都应直接表达责任。
- 禁止使用模糊命名掩盖职责,例如:
- `misc`
- `helpers2`
- `commonThing`
- `temp_service`
- `manager_new`
---
## 2. 通用分层模型
以下是跨语言可复用的职责模型。项目不要求逐字使用这些目录名,但必须能映射到这些边界。
### 2.1 入口层 / 接口层
典型形态:
- 前端的 `App`、路由入口、页面入口
- 后端的 router / controller / handler
- CLI 的 command / main
- Worker 的 job handler / consumer entry
职责:
- 接收输入
- 做基础参数解析与协议适配
- 调用业务编排层
- 返回结果或渲染输出
禁止:
- 写复杂业务规则
- 直接拼装 SQL / ORM 流程
- 直接进行大段文件系统读写
- 直接进行复杂网络编排
- 在入口层内维护长生命周期状态机
### 2.2 业务编排层 / 用例层
典型形态:
- service
- use case
- action
- controller hook
- page model
- workflow
职责:
- 表达一个明确业务流程
- 协调多个依赖
- 承载事务边界、步骤顺序、状态推进
- 组织权限判断、前置校验、错误分支
要求:
- 一个模块只负责一个业务域或一个稳定子流程
- 可以依赖基础设施接口,但不应把具体协议细节暴露给上层
- 可以包含少量私有 helper但 helper 仅服务当前主题
### 2.3 领域规则层
典型形态:
- domain service
- policy
- rule
- validator
- entity behavior
- pure business helpers
职责:
- 承载稳定的业务规则和领域语义
- 保持尽量纯净、可测试、与外部协议解耦
- 统一业务概念、状态转换、派生计算
要求:
- 不直接访问数据库、网络、文件、浏览器环境
- 不依赖 UI、HTTP、CLI、消息队列等入口协议
### 2.4 基础设施层 / 数据访问层
典型形态:
- repository
- gateway
- API client
- storage adapter
- cache adapter
- filesystem adapter
- persistence implementation
职责:
- 封装外部系统细节
- 处理数据库、缓存、HTTP、对象存储、消息队列、浏览器存储、操作系统能力
- 提供可复用的边界接口
要求:
- 只解决“怎么接外部系统”,不承担业务决策
- 协议转换、序列化、连接管理、重试策略等应在此层收口
### 2.5 共享基础层
典型形态:
- constants
- shared types
- date / string / number helpers
- 通用 UI 基础组件
- 通用错误定义
要求:
- 必须是真正跨域、稳定、低语义耦合的内容
- 不允许把业务逻辑伪装成“common / shared / utils”
- 一旦某模块开始依赖特定业务名词,它就不再是共享基础层
---
## 3. 目录与模块组织规范
### 3.1 允许的组织方式
项目可以采用以下任一方式:
- 按领域优先组织:`<domain>/<entry|application|infra|shared>`
- 按层优先组织:`entry/ application/ domain/ infra/`
- 混合组织:顶层按领域,领域内再分层
- Monorepo 组织:`apps/ packages/ services/ workers/`
允许多种组织方式并存,但必须满足:
- 同一仓库内的同类代码遵循一致的判断逻辑
- 每个模块都能被映射到明确职责层
- 依赖方向清晰、稳定、可审计
### 3.2 推荐的判断方式
当你不确定某段代码应该放哪里时,按顺序判断:
1. 它是在接收输入、渲染输出、还是拼装启动吗
2. 它是在表达一个完整业务流程吗
3. 它是在表达不依赖外部协议的业务规则吗
4. 它是在接数据库、文件、网络、缓存、浏览器或系统能力吗
5. 它真的是跨域共享能力吗
### 3.3 一个模块只能有一个主语义
- 一个模块可以有多个函数,但只能服务一个主职责。
- 如果一个文件既是“页面”又是“API 聚合器”又是“缓存控制器”又是“视图组件”,就已经越界。
- 如果一个 router 文件开始长时间停留在 SQL、文件读写、事务与状态轮询细节上也已经越界。
### 3.4 兼容层与过渡层
- 允许存在短期兼容层、导出层、适配层。
- 兼容层必须被明确标记为过渡用途。
- 禁止长期把新逻辑继续堆回兼容层。
---
## 4. 前端通用规范
本节适用于 Web、桌面端、移动端和前端壳应用不绑定 React / Vue / Svelte 等具体框架。
### 4.1 页面/路由入口必须薄
- 页面文件默认负责页面装配、布局组织、边界兜底。
- 页面可持有少量与页面展示强绑定的状态。
- 当页面开始同时承担以下两项及以上时,应拆出页面级编排模块:
- 多个接口请求
- 轮询或定时器
- 上传/下载流程
- 多个 Drawer / Modal / Sheet 子流程
- 复杂权限判断
- 大量数据清洗与派生
### 4.2 视图组件默认无副作用
- 纯视图组件只接收整理好的 props。
- 纯视图组件默认不直接请求接口、不直接碰浏览器存储、不直接起轮询。
- 如果一个组件必须自带数据流程,它应被明确视为“功能组件”或“场景组件”,而不是伪装成通用组件。
### 4.3 页面级业务流程应集中编排
- 页面相关的请求、轮询、草稿保存、上传进度、权限行为、状态联动,应尽量集中在页面编排层。
- 页面编排层可以是 hook、store、controller、presenter 或 view-model不限定技术名词。
- 不要求为了形式而一律抽 hook只有在页面入口已经承担过多流程时才拆。
### 4.4 前端基础设施应收口
- API client
- 本地存储
- Session / token 持久化
- 浏览器标题、副作用事件、定时器策略
- 配置读取
以上能力应收口在可识别模块中,不应被页面随机复制。
### 4.5 复用原则
- 提炼稳定复用模式,不提炼偶然重复。
- 三处以上重复,优先评估抽取。
- 如果抽取后的接口比原地代码更难理解,不应抽取。
- 不允许制造“只有一个页面使用、但包装层很多”的伪复用。
---
## 5. 后端通用规范
本节适用于 HTTP 服务、RPC 服务、任务处理器和后台作业,不绑定 FastAPI / Spring / NestJS / Gin 等框架。
### 5.1 启动入口必须只做装配
- `main`
- app factory
- bootstrap
- container
这些入口只负责:
- 创建应用实例
- 注册路由/处理器
- 初始化中间件
- 装配依赖
- 生命周期绑定
不应承担:
- 业务规则
- SQL 或 ORM 编排
- 文件系统细节
- 长流程任务控制
### 5.2 Router / Controller / Handler 只做协议转换
允许:
- 接收请求参数
- 基础校验
- 调用用例层
- 将领域错误映射为接口错误
不允许:
- 在 handler 内直接堆大量 SQL
- 一边处理权限,一边处理事务,一边操作文件,一边组装响应模型
- 在 handler 内实现长流程状态机
### 5.3 Service / Use Case 以业务域组织
- 一个 service 文件只负责一个业务域或一个稳定子主题。
- 同域内的查询、写入、校验、少量派生逻辑可以在一起。
- 如果一个 service 同时承担多个主题,应优先拆主题而不是拆技术动作。
### 5.4 数据访问与外部适配要收口
- 数据库查询
- ORM 组装
- 缓存细节
- 第三方 HTTP 调用
- 文件上传下载
- 对象存储
- 队列/任务系统
这些细节应尽量沉到 repository / gateway / adapter / infra 中。
### 5.5 Schema / DTO / Contract 必须纯净
- DTO 只用于定义契约,不应携带数据库、文件系统、网络调用或业务副作用。
- 契约字段演进必须可追踪。
- 避免让数据库模型、接口模型、领域模型长期混成一种结构。
---
## 6. CLI / 脚本 / Worker 规范
- 命令入口只负责解析参数、准备依赖、调用用例。
- 脚本如果会长期保留,必须从“一次性脚本”升级为可读的结构化模块。
- Worker handler 只负责接收消息、提取 payload、调用业务流程、回写状态。
- 重试、幂等、死信、超时策略等运行时策略应有单独归属,不应散落在业务逻辑内部。
---
## 7. 拆分与合并准则
### 7.1 何时应拆分
满足任一项即可考虑拆分:
- 同一模块出现多个业务主题
- 同一模块同时依赖多种外部系统
- 同一模块同时承担输入解析、业务编排、持久化和展示
- 多人修改时经常产生冲突
- 阅读一个改动需要频繁跨越无关上下文
- 相同规则被复制到多个入口
### 7.2 何时不应拆分
- 仍是单一主题
- 代码虽长但顺序可读
- 继续拆只会制造纯转发层
- 继续拆会让调用链更深、定位更慢
- 抽出后接口语义比原代码更含糊
### 7.3 合并也是一种优化
- 如果多个模块只是在相互转发、没有独立语义,应考虑合并。
- 如果拆分之后需要同时打开 4 到 6 个文件才能理解一个简单流程,通常已经过度拆分。
---
## 8. 命名与依赖规则
### 8.1 命名规则
- 名称应表达业务主题或技术边界,不表达情绪和历史包袱。
- 优先使用“对象 + 语义”命名,而不是“抽象 + 序号”命名。
- 避免模糊后缀:
- `handler2`
- `newService`
- `commonUtils`
- `tempPage`
### 8.2 依赖规则
- 上层可以依赖下层抽象,不应依赖下层杂乱细节。
- 共享层不能反向依赖业务层。
- 领域模块之间若需协作,应通过明确用例、接口或边界对象完成,而不是互相穿透内部实现。
---
## 9. 测试与验证要求
### 9.1 结构改动后的默认验证
- 前端结构改动后,至少执行构建或类型校验。
- 后端结构改动后,至少执行语法校验、启动校验或最小测试集。
- 如果改动涉及契约、权限、状态流转、持久化边界,应追加针对性验证。
### 9.2 测试优先级
- 先保关键业务路径
- 再保跨层边界
- 再保复杂状态流转
- 最后补充纯工具覆盖
### 9.3 文档同步要求
以下情况必须同步设计文档或架构说明:
- 新增一层明确职责边界
- 新增一个稳定领域模块模板
- 改变入口层、用例层、基础设施层的责任划分
- 引入新的运行时或新的跨项目复用规范
---
## 10. 评审与审计清单
做结构评审时,默认检查以下问题:
### 10.1 入口层
- 入口是否足够薄
- 是否混入业务规则
- 是否混入外部系统细节
### 10.2 业务编排层
- 是否以业务主题组织
- 是否承担了过多无关流程
- 是否存在纯转发服务
### 10.3 领域规则层
- 是否仍保持纯净
- 是否被框架、HTTP、数据库协议污染
### 10.4 基础设施层
- 副作用是否收口
- 是否把业务规则偷偷塞回 adapter / utils / core
### 10.5 共享层
- 是否真的跨域复用
- 是否把业务逻辑伪装成 common / shared / utils
### 10.6 演进风险
- 新增功能是否沿着既有边界落位
- 兼容层是否在持续变厚
- 是否出现单文件多职责继续膨胀
---
## 11. 禁止事项
- 禁止为了图省事把新逻辑堆回入口层
- 禁止为了“文件更短”制造无语义的纯包装层
- 禁止在 `utils` / `common` / `shared` 中隐藏领域逻辑
- 禁止让页面、路由、handler 直接承载大量持久化或文件系统细节
- 禁止把 schema / DTO / config 当成业务逻辑容器
- 禁止一次改动里同时重写结构、协议、UI 和业务行为,且没有明确验证策略
---
## 12. 执行基线
后续所有新增功能、重构与代码审计,均以本文档为默认判断依据:
- 先判断职责边界是否正确
- 再判断依赖方向是否健康
- 再判断是否需要拆分或合并
- 最后才考虑目录美观、文件长短和风格一致性
当“看起来更模块化”和“真实可读、可改、可验证”发生冲突时,优先后者。

View File

@ -18,7 +18,9 @@ import ProfilePage from '@/pages/Profile/ProfilePage'
import Permissions from '@/pages/System/Permissions'
import Users from '@/pages/System/Users'
import Roles from '@/pages/System/Roles'
import ModelConfigs from '@/pages/System/ModelConfigs'
import SystemLogs from '@/pages/SystemLogs/SystemLogs'
import Chat from '@/pages/Chat/Chat'
import NotificationList from '@/pages/Notifications/NotificationList'
import ProtectedRoute from '@/components/ProtectedRoute'
import MainLayout from '@/components/MainLayout/MainLayout'
@ -81,7 +83,12 @@ function App() {
<Route path="/system/permissions" element={<Permissions />} />
<Route path="/system/users" element={<Users />} />
<Route path="/system/roles" element={<Roles />} />
<Route path="/system/model-configs" element={<ModelConfigs />} />
<Route path="/system/logs" element={<SystemLogs />} />
<Route path="/chat" element={<Chat />} />
<Route path="/chat/new" element={<Chat />} />
<Route path="/knowledge" element={<Navigate to="/chat" replace />} />
<Route path="/knowledge/my" element={<Navigate to="/chat" replace />} />
</Route>
<Route path="/" element={<Navigate to="/projects" replace />} />

View File

@ -0,0 +1,119 @@
import request from '@/utils/request'
import { getAccessToken } from '@/utils/authStorage'
export const createChatSession = (projectId, llmConfigId, title = '新对话') => {
return request.post('/chat/sessions', {
project_id: projectId,
llm_config_id: llmConfigId,
title,
})
}
export const getChatSessions = (projectId = null) => {
return request.get('/chat/sessions', {
params: projectId ? { project_id: projectId } : undefined,
})
}
export const getChatMessages = (sessionId) => {
return request.get(`/chat/sessions/${sessionId}/messages`)
}
export const sendChatMessage = (sessionId, message) => {
return request.post('/chat/send', {
session_id: sessionId,
message,
}, {
timeout: 180000,
})
}
export const sendChatMessageStream = async (sessionId, message, handlers = {}) => {
const token = getAccessToken()
const response = await fetch('/api/v1/chat/send/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
session_id: sessionId,
message,
}),
})
if (!response.ok || !response.body) {
let detail = '发送消息失败'
try {
const payload = await response.json()
detail = payload?.detail || payload?.message || detail
} catch {
// 响应不是 JSON 时保留通用错误信息
}
throw new Error(detail)
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
let completed = false
const dispatchEvent = (block) => {
const lines = block.split('\n')
const eventLine = lines.find((line) => line.startsWith('event:'))
const dataLine = lines.find((line) => line.startsWith('data:'))
if (!eventLine || !dataLine) return
const event = eventLine.slice(6).trim()
const dataText = dataLine.slice(5).trim()
const data = dataText ? JSON.parse(dataText) : null
if (event === 'ids') handlers.onIds?.(data)
if (event === 'references') handlers.onReferences?.(data || [])
if (event === 'chunk') handlers.onChunk?.(data?.content || '')
if (event === 'title') handlers.onTitle?.(data)
if (event === 'done') {
completed = true
handlers.onDone?.(data)
}
if (event === 'error') {
throw new Error(data?.detail || '发送消息失败')
}
}
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const blocks = buffer.split('\n\n')
buffer = blocks.pop() || ''
for (const block of blocks) {
dispatchEvent(block)
}
}
if (buffer.trim()) {
dispatchEvent(buffer)
}
if (!completed) {
throw new Error('回答生成连接意外中断,请稍后重试')
}
}
export const deleteChatSession = (sessionId) => {
return request.delete(`/chat/sessions/${sessionId}`)
}
export const deleteChatMessage = (messageId) => {
return request.delete(`/chat/messages/${messageId}`)
}
export const updateChatSessionTitle = (sessionId, title) => {
return request.put(`/chat/sessions/${sessionId}`, {
title,
})
}
export const searchChatMessages = (keyword) => {
return request.get('/chat/search', {
params: { keyword },
})
}

View File

@ -0,0 +1,21 @@
import request from '@/utils/request'
// 查询项目向量化进度
export const getVectorizeProgress = (projectId) => {
return request.get(`/chat/projects/${projectId}/vectorize/progress`)
}
// 触发项目批量向量化force=false 增量force=true 全量重建)
export const vectorizeProject = (projectId, force = false) => {
return request.post(`/chat/projects/${projectId}/vectorize`, { force })
}
// 查询最近一次向量化任务
export const getLatestVectorizeTask = (projectId) => {
return request.get(`/chat/projects/${projectId}/vectorize/tasks/latest`)
}
// 查询指定向量化任务
export const getVectorizeTask = (projectId, taskId) => {
return request.get(`/chat/projects/${projectId}/vectorize/tasks/${taskId}`)
}

View File

@ -0,0 +1,71 @@
import request from '@/utils/request'
export function getLLMProviderCatalog() {
return request({
url: '/llm-model-configs/providers',
method: 'get',
})
}
export function getLLMModelConfigs(params) {
return request({
url: '/llm-model-configs/',
method: 'get',
params,
})
}
export function getLLMModelConfigDetail(configId) {
return request({
url: `/llm-model-configs/${configId}`,
method: 'get',
})
}
export function createLLMModelConfig(data) {
return request({
url: '/llm-model-configs/',
method: 'post',
data,
})
}
export function updateLLMModelConfig(configId, data) {
return request({
url: `/llm-model-configs/${configId}`,
method: 'put',
data,
})
}
export function updateLLMModelConfigStatus(configId, isActive) {
return request({
url: `/llm-model-configs/${configId}/status`,
method: 'put',
params: { is_active: isActive },
})
}
export function setDefaultLLMModelConfig(configId) {
return request({
url: `/llm-model-configs/${configId}/default`,
method: 'put',
})
}
export function deleteLLMModelConfig(configId) {
return request({
url: `/llm-model-configs/${configId}`,
method: 'delete',
})
}
export function testLLMModelConfig(data) {
const timeoutSeconds = Number(data?.llm_timeout) || 30
return request({
url: '/llm-model-configs/test',
method: 'post',
data,
timeout: Math.max(timeoutSeconds * 1000 + 5000, 30000),
})
}

View File

@ -86,6 +86,38 @@ export function transferProject(projectId, newOwnerId) {
})
}
/**
* 启动项目导出任务
*/
export function startProjectExport(projectId) {
return request({
url: `/projects/${projectId}/export`,
method: 'post',
})
}
/**
* 获取项目导出任务状态
*/
export function getProjectExportStatus(projectId, taskId) {
return request({
url: `/projects/${projectId}/export/${taskId}`,
method: 'get',
})
}
/**
* 下载项目导出 ZIP
*/
export function downloadProjectExport(projectId, taskId) {
return request({
url: `/projects/${projectId}/export/${taskId}/download`,
method: 'get',
responseType: 'blob',
timeout: 0,
})
}
/**
* 获取项目成员
*/

View File

@ -1,6 +1,6 @@
/* 帮助面板样式 */
.action-help-panel .ant-drawer-header {
border-bottom: 2px solid #f0f0f0;
border-bottom: 2px solid var(--border-color);
}
.help-panel-title {
@ -160,7 +160,7 @@
display: inline-block;
padding: 6px 12px;
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
border: 1px solid #d9d9d9;
border: 1px solid var(--border-color-strong);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
font-size: 12px;
@ -179,7 +179,7 @@
.help-action-item {
padding: 12px;
background: white;
border: 1px solid #f0f0f0;
border: 1px solid var(--border-color);
border-radius: 8px;
transition: all 0.3s ease;
cursor: pointer;
@ -213,7 +213,7 @@
.help-action-item-shortcut {
padding: 2px 6px;
background: #f0f0f0;
border: 1px solid #d9d9d9;
border: 1px solid var(--border-color-strong);
border-radius: 4px;
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;

View File

@ -24,7 +24,7 @@
/* 主题样式 */
.bottom-hint-bar-light {
background: #ffffff;
border-top: 1px solid #f0f0f0;
border-top: 1px solid var(--border-color);
}
.bottom-hint-bar-dark {
@ -168,7 +168,7 @@
.bottom-hint-bar-light .shortcut-kbd {
background: #f0f0f0;
border-color: #d9d9d9;
border-color: var(--border-color-strong);
color: rgba(0, 0, 0, 0.88);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
}
@ -194,7 +194,7 @@
.bottom-hint-bar-light .hint-bar-close {
background: #f0f0f0;
border-color: #d9d9d9;
border-color: var(--border-color-strong);
color: rgba(0, 0, 0, 0.45);
}

View File

@ -34,7 +34,7 @@
/* 引导弹窗样式 */
.button-guide-modal .ant-modal-header {
padding: 20px 24px;
border-bottom: 2px solid #f0f0f0;
border-bottom: 2px solid var(--border-color);
}
.button-guide-modal .ant-modal-body {
@ -152,7 +152,7 @@
padding: 16px;
background: white;
border-radius: 8px;
border: 1px solid #f0f0f0;
border: 1px solid var(--border-color);
}
.guide-footer-item {
@ -170,7 +170,7 @@
display: inline-block;
padding: 4px 10px;
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
border: 1px solid #d9d9d9;
border: 1px solid var(--border-color-strong);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
font-size: 11px;

View File

@ -81,7 +81,7 @@
/* 引导弹窗样式 */
.button-guide-modal .ant-modal-header {
padding: 20px 24px;
border-bottom: 2px solid #f0f0f0;
border-bottom: 2px solid var(--border-color);
}
.button-guide-modal .ant-modal-body {
@ -199,7 +199,7 @@
padding: 16px;
background: white;
border-radius: 8px;
border: 1px solid #f0f0f0;
border: 1px solid var(--border-color);
}
.guide-footer-item {
@ -217,7 +217,7 @@
display: inline-block;
padding: 4px 10px;
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
border: 1px solid #d9d9d9;
border: 1px solid var(--border-color-strong);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
font-size: 11px;

View File

@ -50,7 +50,7 @@
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid var(--border-color);
}
.hover-card-title-wrapper {
@ -144,7 +144,7 @@
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
border-top: 1px solid var(--border-color);
}
.footer-label {
@ -156,7 +156,7 @@
display: inline-block;
padding: 4px 10px;
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
border: 1px solid #d9d9d9;
border: 1px solid var(--border-color-strong);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
font-size: 11px;

View File

@ -12,7 +12,7 @@
align-items: center;
padding: 16px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
@ -90,7 +90,7 @@
}
.detail-drawer-tabs :global(.ant-tabs-nav::before) {
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid var(--border-color);
}
.detail-drawer-tabs :global(.ant-tabs-tab) {

View File

@ -42,7 +42,7 @@
align-items: center;
padding: 16px 20px;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
border-bottom: 1px solid #e8e8e8;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
user-select: none;
transition: background 0.2s ease;

View File

@ -8,7 +8,7 @@
.info-panel > :global(.ant-row) {
padding: 24px;
background: #ffffff;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid var(--border-color);
}
.info-panel-item {
@ -16,7 +16,7 @@
flex-direction: column;
gap: 5px;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid var(--border-color);
transition: all 0.2s ease;
position: relative;
}
@ -75,7 +75,7 @@
.info-panel-actions {
padding: 24px 32px;
background: linear-gradient(to bottom, #fafafa 0%, #f5f5f5 100%);
border-top: 2px solid #e8e8e8;
border-top: 2px solid var(--border-color);
position: relative;
}
@ -93,4 +93,3 @@

View File

@ -1,8 +1,8 @@
.app-sider {
height: 100%;
overflow: auto;
background: #fafafa;
border-right: 1px solid #f0f0f0;
background: var(--bg-color-secondary);
border-right: 1px solid var(--border-color);
transition: all 0.2s;
}
@ -23,7 +23,7 @@
.sider-menu {
border-right: none;
padding-top: 8px;
background: #fafafa;
background: var(--bg-color-secondary);
}
/* 收起状态下的图标放大 */

View File

@ -5,6 +5,7 @@ import {
DesktopOutlined,
GlobalOutlined,
CloudServerOutlined,
FileSearchOutlined,
UserOutlined,
AppstoreOutlined,
SettingOutlined,
@ -29,6 +30,7 @@ const iconMap = {
DesktopOutlined: <DesktopOutlined />,
GlobalOutlined: <GlobalOutlined />,
CloudServerOutlined: <CloudServerOutlined />,
FileSearchOutlined: <FileSearchOutlined />,
UserOutlined: <UserOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
SettingOutlined: <SettingOutlined />,
@ -42,6 +44,133 @@ const iconMap = {
BookOutlined: <BookOutlined />,
}
const builtInMenuMetaMap = {
dashboard: {
path: '/dashboard',
icon: 'DashboardOutlined',
},
desktop: {
path: '/desktop',
icon: 'DesktopOutlined',
},
'projects:my': {
path: '/projects/my',
icon: 'FolderOutlined',
},
'projects:share': {
path: '/projects/share',
icon: 'TeamOutlined',
},
'knowledge:my': {
path: '/chat',
icon: 'ReadOutlined',
},
'system:users': {
path: '/system/users',
icon: 'UserOutlined',
},
'system:roles': {
path: '/system/roles',
icon: 'TeamOutlined',
},
'system:permissions': {
path: '/system/permissions',
icon: 'SafetyOutlined',
},
'system:model-configs': {
path: '/system/model-configs',
icon: 'CloudServerOutlined',
},
'system:logs': {
path: '/system/logs',
icon: 'FileSearchOutlined',
},
}
const resolveBuiltInMenuMeta = (item) => {
const directMeta = builtInMenuMetaMap[item.menu_code]
if (directMeta) {
return directMeta
}
if (item.path === '/dashboard' || item.menu_name === '管理面板') {
return {
path: '/dashboard',
icon: 'DashboardOutlined',
}
}
if (item.path === '/desktop' || item.menu_name === '个人桌面') {
return {
path: '/desktop',
icon: 'DesktopOutlined',
}
}
if (
item.path === '/projects' ||
item.path === '/projects/my' ||
item.menu_name === '我的项目' ||
item.menu_name === '项目空间'
) {
return {
path: '/projects/my',
icon: 'FolderOutlined',
}
}
if (item.path === '/projects/share' || item.menu_name === '参与项目') {
return {
path: '/projects/share',
icon: 'TeamOutlined',
}
}
if (item.path === '/knowledge' || item.path === '/knowledge/my' || item.menu_name === '我的知识库') {
return {
path: '/chat',
icon: 'ReadOutlined',
}
}
if (item.path === '/system/users' || item.menu_name === '用户管理') {
return {
path: '/system/users',
icon: 'UserOutlined',
}
}
if (item.path === '/system/roles' || item.menu_name === '角色管理') {
return {
path: '/system/roles',
icon: 'TeamOutlined',
}
}
if (item.path === '/system/permissions' || item.menu_name === '权限管理') {
return {
path: '/system/permissions',
icon: 'SafetyOutlined',
}
}
if (item.path === '/system/model-configs' || item.menu_name === '模型配置') {
return {
path: '/system/model-configs',
icon: 'CloudServerOutlined',
}
}
if (item.path === '/system/logs' || item.menu_name === '系统日志') {
return {
path: '/system/logs',
icon: 'FileSearchOutlined',
}
}
return null
}
function AppSider({ collapsed, onToggle }) {
const navigate = useNavigate()
const location = useLocation()
@ -83,12 +212,15 @@ function AppSider({ collapsed, onToggle }) {
if (validChildren.length > 0) {
//
const groupItems = validChildren.map(child => {
const icon = typeof child.icon === 'string' ? (iconMap[child.icon] || <AppstoreOutlined />) : child.icon
const normalizedChild = normalizeMenuItem(child)
const icon = typeof normalizedChild.icon === 'string'
? (iconMap[normalizedChild.icon] || <AppstoreOutlined />)
: normalizedChild.icon
return {
key: child.menu_code,
label: child.menu_name,
key: normalizedChild.menu_code,
label: normalizedChild.menu_name,
icon: icon,
path: child.path
path: normalizedChild.path
}
})
@ -98,12 +230,15 @@ function AppSider({ collapsed, onToggle }) {
})
} else {
//
const icon = typeof item.icon === 'string' ? (iconMap[item.icon] || <AppstoreOutlined />) : item.icon
const normalizedItem = normalizeMenuItem(item)
const icon = typeof normalizedItem.icon === 'string'
? (iconMap[normalizedItem.icon] || <AppstoreOutlined />)
: normalizedItem.icon
defaultGroup.items.push({
key: item.menu_code,
label: item.menu_name,
key: normalizedItem.menu_code,
label: normalizedItem.menu_name,
icon: icon,
path: item.path
path: normalizedItem.path
})
}
})
@ -116,6 +251,20 @@ function AppSider({ collapsed, onToggle }) {
setMenuGroups(groups)
}
const normalizeMenuItem = (item) => {
const builtInMeta = resolveBuiltInMenuMeta(item)
if (!builtInMeta) {
return item
}
return {
...item,
icon: builtInMeta.icon || item.icon,
path: builtInMeta.path || item.path,
}
}
const handleNavigate = (key, item) => {
if (item.path) {
navigate(item.path)

View File

@ -1,5 +1,4 @@
import { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useState } from 'react'
import { Layout } from 'antd'
import AppSider from './AppSider'
import AppHeader from './AppHeader'
@ -7,18 +6,8 @@ import './MainLayout.css'
const { Content } = Layout
// /
const COLLAPSE_PATTERNS = [/^\/projects\/[^/]+\/docs/, /^\/projects\/[^/]+\/editor/]
function MainLayout({ children }) {
const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
if (COLLAPSE_PATTERNS.some((pattern) => pattern.test(location.pathname))) {
setCollapsed(true)
}
}, [location.pathname])
const [collapsed, setCollapsed] = useState(true)
const toggleCollapsed = () => {
setCollapsed(!collapsed)

View File

@ -14,7 +14,7 @@
align-items: center;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}

View File

@ -1,8 +1,9 @@
import { Navigate, useLocation } from 'react-router-dom'
import { getAccessToken } from '@/utils/authStorage'
function ProtectedRoute({ children }) {
const location = useLocation()
const token = localStorage.getItem('access_token')
const token = getAccessToken()
if (!token) {
const returnTo = encodeURIComponent(`${location.pathname}${location.search}${location.hash}`)

View File

@ -25,7 +25,7 @@
align-items: center;
padding: 16px 20px;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
border-bottom: 1px solid #e8e8e8;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
user-select: none;
transition: background 0.2s ease;

View File

@ -1,15 +1,15 @@
/* 统计卡片 */
.stat-card {
padding: 16px;
background: #ffffff;
background: var(--card-bg);
border-radius: 8px;
border: 1px solid #f0f0f0;
border: 1px solid var(--border-color);
transition: all 0.3s ease;
}
.stat-card:hover {
border-color: #d9d9d9;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: var(--border-color-strong);
box-shadow: var(--panel-shadow);
}
/* 一列布局(默认) */
@ -51,7 +51,7 @@
.stat-card-title {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
color: var(--text-color-secondary);
font-weight: 500;
}
@ -79,7 +79,7 @@
font-size: 14px;
font-weight: 400;
margin-left: 4px;
color: rgba(0, 0, 0, 0.45);
color: var(--text-color-secondary);
}
/* 趋势指示器 */
@ -95,12 +95,12 @@
.stat-card-trend.trend-up {
color: #52c41a;
background: #f6ffed;
background: rgba(82, 196, 26, 0.12);
}
.stat-card-trend.trend-down {
color: #ff4d4f;
background: #fff1f0;
background: rgba(255, 77, 79, 0.12);
}
.stat-card-trend svg {

View File

@ -11,7 +11,7 @@
padding: 12px;
background: #f5f7fa;
border-radius: 6px;
border: 1px dashed #d9d9d9;
border: 1px dashed var(--border-color-strong);
}
.tree-filter-tag {

View File

@ -3,7 +3,8 @@
--bg-color-secondary: #fafafa;
--text-color: #000000e0;
--text-color-secondary: #00000073;
--border-color: #f0f0f0;
--border-color: #d9dde3;
--border-color-strong: #c4cad3;
--header-bg: #fff;
--sider-bg: #fff;
--item-hover-bg: #f5f5f5;
@ -11,12 +12,13 @@
/* Markdown & Editor Specific */
--code-bg: #f6f8fa;
--table-border-color: #dfe2e5;
--table-border-color: #cfd6de;
--table-header-bg: #f6f8fa;
--blockquote-border-color: #dfe2e5;
--blockquote-border-color: #cfd6de;
--blockquote-text-color: #6a737d;
--toolbar-bg: #fafafa;
--link-color: #1677ff;
--panel-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
}
body.dark {
@ -24,7 +26,8 @@ body.dark {
--bg-color-secondary: #1f1f1f;
--text-color: #ffffffd9;
--text-color-secondary: #ffffff73;
--border-color: #303030;
--border-color: #454545;
--border-color-strong: #5a5a5a;
--header-bg: #141414;
--sider-bg: #141414;
--item-hover-bg: #1f1f1f;
@ -32,12 +35,13 @@ body.dark {
/* Markdown & Editor Specific Dark */
--code-bg: #2d2d2d;
--table-border-color: #303030;
--table-border-color: #4d4d4d;
--table-header-bg: #1f1f1f;
--blockquote-border-color: #303030;
--blockquote-border-color: #4d4d4d;
--blockquote-text-color: #8b949e;
--toolbar-bg: #1f1f1f;
--link-color: #177ddc;
--panel-shadow: 0 10px 24px rgba(0, 0, 0, 0.22);
}
body {
@ -45,6 +49,14 @@ body {
color: var(--text-color);
}
.document-workspace-frame {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 18px;
box-shadow: var(--panel-shadow);
overflow: hidden;
}
.search-highlight {
background-color: #ffd54f !important;
color: black !important;
@ -53,6 +65,22 @@ body {
border-radius: 2px;
}
/* 从知识库引用跳转定位到的段落,短暂强调后淡出 */
.search-highlight.cited-highlight-flash {
animation: cited-flash 2.4s ease-out;
}
@keyframes cited-flash {
0%, 30% {
background-color: #ff9800 !important;
box-shadow: 0 0 0 4px rgba(255, 152, 0, 0.35);
}
100% {
background-color: #ffd54f !important;
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0);
}
}
/* Dark mode overrides for highlight.js */
body.dark .hljs {
display: block;

View File

@ -0,0 +1,894 @@
.chat-page-shell {
height: calc(100vh - 96px);
min-height: 0;
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 12px;
}
.chat-left-panel,
.chat-main-panel {
min-height: 0;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--card-bg);
overflow: hidden;
}
.chat-left-panel {
display: flex;
flex-direction: column;
}
.chat-left-actions {
padding: 14px 16px 12px;
display: grid;
gap: 10px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.chat-action-entry {
width: 100%;
border: 0;
background: transparent;
padding: 10px 6px;
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
line-height: 1;
cursor: pointer;
color: var(--text-color);
}
.chat-action-entry:hover {
color: #1677ff;
}
.chat-action-entry span {
font-size: 15px;
font-weight: 600;
}
.chat-history-panel {
flex: 1;
min-height: 0;
overflow: auto;
padding: 10px 10px 12px;
}
.chat-panel-loading,
.chat-empty-message {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
}
.chat-session-item {
padding: 9px 10px;
margin-bottom: 4px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-color-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.chat-session-item:hover {
border-color: #1677ff;
}
.chat-session-item.active {
border-color: #1677ff;
background: color-mix(in srgb, #1677ff 10%, var(--bg-color-secondary));
}
.chat-session-item-top {
display: flex;
gap: 8px;
}
.chat-session-title-area {
flex: 1;
min-width: 0;
}
.chat-session-title {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1.45;
font-weight: 600;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-session-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.chat-session-more {
width: 24px;
height: 24px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-color-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
flex-shrink: 0;
}
.chat-session-item:hover .chat-session-more,
.chat-session-item.active .chat-session-more {
opacity: 1;
}
.chat-session-more:hover {
background: color-mix(in srgb, var(--border-color) 45%, transparent);
color: var(--text-color);
}
.chat-session-meta {
margin-top: 5px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.chat-session-meta-text {
margin: 0;
font-size: 11px;
line-height: 18px;
}
.chat-session-group {
margin-bottom: 14px;
}
.chat-session-group-title {
padding: 4px 4px 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-color-secondary);
}
.chat-main-panel {
min-width: 0;
}
.chat-shell,
.chat-start-shell {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat-start-shell {
align-items: center;
justify-content: center;
padding: 24px;
}
.chat-start-card {
width: min(740px, 100%);
padding: 30px 28px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-color-secondary);
display: flex;
flex-direction: column;
gap: 14px;
align-items: center;
text-align: center;
}
.chat-start-title {
font-size: 32px;
font-weight: 600;
color: var(--text-color);
}
.chat-start-desc {
color: var(--text-color-secondary);
line-height: 1.6;
}
.chat-header {
padding: 16px 20px 14px;
border-bottom: 1px solid var(--border-color);
background: var(--card-bg);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.chat-header-title {
font-size: 18px;
font-weight: 600;
}
.chat-header-subtitle {
margin-top: 4px;
display: flex;
flex-wrap: wrap;
gap: 12px;
color: var(--text-color-secondary);
font-size: 12px;
}
.chat-message-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 18px 20px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.chat-message-row {
display: flex;
align-items: flex-end;
gap: 10px;
}
.chat-message-row.user {
justify-content: flex-end;
}
.chat-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
}
.chat-avatar.user {
background: #1677ff;
}
.chat-avatar.assistant {
background: color-mix(in srgb, #1677ff 10%, var(--card-bg));
color: #1677ff;
}
.chat-message-bubble {
max-width: 100%;
width: fit-content;
padding: 14px 16px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-color);
word-break: break-word;
}
.chat-message-bubble.user {
background: #1677ff;
color: #fff;
border-color: #1677ff;
}
.chat-markdown {
font-size: 14px;
line-height: 1.75;
}
.chat-markdown > :first-child {
margin-top: 0;
}
.chat-markdown > :last-child {
margin-bottom: 0;
}
.chat-markdown h1,
.chat-markdown h2,
.chat-markdown h3,
.chat-markdown h4 {
margin: 16px 0 8px;
font-weight: 600;
line-height: 1.4;
}
.chat-markdown h1 { font-size: 1.5em; }
.chat-markdown h2 { font-size: 1.3em; }
.chat-markdown h3 { font-size: 1.15em; }
.chat-markdown h4 { font-size: 1em; }
.chat-markdown p {
margin: 8px 0;
}
.chat-markdown ul,
.chat-markdown ol {
margin: 8px 0;
padding-left: 24px;
}
.chat-markdown li {
margin: 4px 0;
}
.chat-markdown li > p {
margin: 0;
}
.chat-markdown a {
color: #1677ff;
text-decoration: none;
}
.chat-markdown a:hover {
text-decoration: underline;
}
.chat-markdown blockquote {
margin: 10px 0;
padding: 4px 14px;
border-left: 3px solid var(--border-color);
color: var(--text-color-secondary);
background: color-mix(in srgb, var(--border-color) 16%, transparent);
border-radius: 0 6px 6px 0;
}
.chat-markdown code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.88em;
padding: 2px 6px;
border-radius: 4px;
background: color-mix(in srgb, var(--border-color) 32%, transparent);
}
.chat-markdown pre {
margin: 12px 0;
padding: 14px 16px;
border-radius: 8px;
overflow: auto;
background: #f6f8fa;
border: 1px solid var(--border-color);
}
.chat-markdown pre code {
padding: 0;
background: transparent;
font-size: 0.85em;
line-height: 1.6;
}
.chat-markdown table {
width: 100%;
margin: 12px 0;
border-collapse: collapse;
font-size: 0.92em;
}
.chat-markdown th,
.chat-markdown td {
padding: 8px 12px;
border: 1px solid var(--border-color);
text-align: left;
}
.chat-markdown th {
background: color-mix(in srgb, var(--border-color) 22%, transparent);
font-weight: 600;
}
.chat-markdown tr:nth-child(even) td {
background: color-mix(in srgb, var(--border-color) 10%, transparent);
}
.chat-markdown img {
max-width: 100%;
border-radius: 6px;
}
.chat-markdown hr {
margin: 16px 0;
border: 0;
border-top: 1px solid var(--border-color);
}
.chat-citation {
font-size: 0.72em;
line-height: 0;
vertical-align: super;
color: #1677ff;
font-weight: 600;
margin: 0 1px;
cursor: pointer;
}
.chat-citation:hover {
text-decoration: underline;
}
.chat-markdown.streaming > :last-child::after {
content: '';
display: inline-block;
width: 7px;
height: 1.05em;
margin-left: 2px;
vertical-align: text-bottom;
background: #1677ff;
border-radius: 1px;
animation: chat-caret-blink 1s steps(2, start) infinite;
}
@keyframes chat-caret-blink {
to {
visibility: hidden;
}
}
.chat-thinking {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-color-secondary);
font-size: 14px;
line-height: 1.75;
}
.chat-thinking-text {
animation: chat-thinking-pulse 1.6s ease-in-out infinite;
}
@keyframes chat-thinking-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
.chat-thinking-dots {
display: inline-flex;
align-items: center;
gap: 4px;
}
.chat-thinking-dots i {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
display: inline-block;
animation: chat-thinking-bounce 1.2s ease-in-out infinite;
}
.chat-thinking-dots i:nth-child(2) {
animation-delay: 0.18s;
}
.chat-thinking-dots i:nth-child(3) {
animation-delay: 0.36s;
}
@keyframes chat-thinking-bounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.4;
}
40% {
transform: translateY(-4px);
opacity: 1;
}
}
.chat-message-column {
display: flex;
flex-direction: column;
gap: 4px;
max-width: min(760px, 78%);
min-width: 0;
}
.chat-message-row.user .chat-message-column {
align-items: flex-end;
}
.chat-message-actions {
display: flex;
align-items: center;
gap: 4px;
height: 24px;
opacity: 0;
transition: opacity 0.15s ease;
}
.chat-message-row:hover .chat-message-actions {
opacity: 1;
}
.chat-message-action {
width: 26px;
height: 24px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-color-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
transition: all 0.15s ease;
}
.chat-message-action:hover {
background: color-mix(in srgb, var(--border-color) 45%, transparent);
color: var(--text-color);
}
.chat-message-action.danger:hover {
background: color-mix(in srgb, #ff4d4f 14%, transparent);
color: #ff4d4f;
}
.chat-plain-text {
white-space: pre-wrap;
line-height: 1.7;
}
.chat-references {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--border-color);
}
.chat-references-label {
display: block;
margin-bottom: 8px;
font-size: 12px;
}
.chat-reference-link {
cursor: pointer;
}
.chat-reference-link:hover {
opacity: 0.82;
}
.chat-message-error {
color: #cf1322;
line-height: 1.7;
}
.chat-message-error-inline {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed color-mix(in srgb, #ff4d4f 35%, transparent);
font-size: 13px;
}
.chat-citation-preview {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 460px;
}
.chat-citation-preview-file {
display: block;
word-break: break-all;
}
.chat-citation-preview-content {
max-height: 260px;
overflow: auto;
word-break: break-word;
line-height: 1.65;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: color-mix(in srgb, var(--border-color) 10%, transparent);
}
.chat-citation-preview-content > :first-child {
margin-top: 0;
}
.chat-citation-preview-content > :last-child {
margin-bottom: 0;
}
.chat-citation-preview-content pre {
overflow: auto;
padding: 8px;
border-radius: 6px;
background: var(--code-bg);
}
.chat-citation-preview-content code {
font-size: 0.92em;
}
.chat-citation-popover .ant-popover-inner {
max-width: min(520px, calc(100vw - 40px));
}
.chat-new-page-card {
width: min(1180px, 92%);
display: flex;
flex-direction: column;
gap: 28px;
align-items: stretch;
}
.chat-new-page-card .chat-start-title {
font-size: 34px;
text-align: center;
}
.chat-composer-shell {
padding: 12px 12px 8px;
border: 1px solid #9bbcff;
border-radius: 8px;
background: var(--card-bg);
box-shadow: 0 18px 40px rgba(64, 111, 255, 0.08);
display: flex;
flex-direction: column;
gap: 24px;
}
.chat-shell .chat-composer-shell {
margin: 0 24px 24px;
}
.chat-new-composer {
min-height: 150px;
padding-top: 10px;
padding-bottom: 10px;
}
.chat-composer-input {
border: 0;
box-shadow: none !important;
resize: none;
padding: 0;
font-size: 16px;
line-height: 1.7;
background: transparent;
flex: none;
}
.chat-composer-input:focus {
border: 0;
box-shadow: none;
}
.chat-composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
}
.chat-composer-left,
.chat-composer-right {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
flex-wrap: nowrap;
}
.chat-composer-left {
flex: 1;
}
.chat-composer-right {
flex-shrink: 0;
}
.chat-composer-plus {
width: 44px;
height: 44px;
min-width: 44px;
border: 1px solid #f0f0f0;
border-radius: 50%;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
color: var(--text-color-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 24px;
cursor: pointer;
flex-shrink: 0;
}
.chat-composer-plus:hover {
color: var(--text-color);
border-color: #e6e6e6;
}
.chat-composer-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 8px 18px;
border: 1px solid #f0f0f0;
border-radius: 999px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
color: var(--text-color);
line-height: 1.2;
white-space: nowrap;
}
.chat-composer-pill-icon {
color: var(--text-color-secondary);
flex-shrink: 0;
}
.chat-composer-pill-text {
overflow: hidden;
text-overflow: ellipsis;
}
.chat-composer-kb {
max-width: 280px;
}
.chat-project-picker {
width: 260px;
}
.chat-project-picker-select {
width: 100%;
}
.chat-composer-model,
.chat-composer-model-select {
width: 220px;
flex-shrink: 0;
}
.chat-composer-model {
max-width: 220px;
}
.chat-composer-model-select .ant-select-selector {
border-radius: 999px !important;
border-color: #f0f0f0 !important;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06) !important;
}
.chat-composer-model-select .ant-select-selection-placeholder,
.chat-composer-model-select .ant-select-selection-item {
display: inline-flex;
align-items: center;
}
.chat-send-button {
width: 40px !important;
height: 40px !important;
min-width: 40px !important;
border: 1px solid #bebebe !important;
background: #d1d1d1 !important;
color: #fff !important;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28);
font-size: 24px !important;
line-height: 1 !important;
transform: scale(1.08);
transform-origin: center;
}
.chat-send-button .anticon {
font-size: 22px;
}
.chat-send-button:not(:disabled):hover {
background: #4096ff !important;
border-color: #4096ff !important;
color: #fff !important;
}
.chat-send-button:not(:disabled) {
background: #1677ff !important;
border-color: #1677ff !important;
color: #fff !important;
}
.chat-send-button:disabled {
background: #d8d8d8 !important;
border-color: #c8c8c8 !important;
color: #fff !important;
opacity: 1;
}
.chat-search-bar {
width: 100%;
}
.chat-search-result-list {
margin-top: 16px;
max-height: 60vh;
overflow: auto;
}
.chat-search-result-item {
cursor: pointer;
}
.chat-search-result-snippet {
color: var(--text-color);
line-height: 1.7;
}
.chat-search-highlight {
padding: 0 2px;
border-radius: 3px;
background: #ffe58f;
color: #ad4e00;
}
body.dark .chat-search-highlight {
background: #614700;
color: #ffe58f;
}
.chat-search-result-time {
margin-top: 4px;
font-size: 12px;
color: var(--text-color-secondary);
}
@media (max-width: 1200px) {
.chat-page-shell {
grid-template-columns: 300px minmax(0, 1fr);
}
}
@media (max-width: 992px) {
.chat-page-shell {
grid-template-columns: 1fr;
height: auto;
}
.chat-left-panel {
max-height: 360px;
}
.chat-composer-actions {
align-items: stretch;
flex-direction: column;
}
.chat-composer-left,
.chat-composer-right {
width: 100%;
justify-content: space-between;
}
.chat-project-picker-select,
.chat-composer-model-select {
flex: 1;
min-width: 0;
}
.chat-send-button {
width: 48px !important;
height: 48px !important;
min-width: 48px !important;
font-size: 24px !important;
transform: scale(1.08);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,12 @@
import { useState, useEffect } from 'react'
import { Card, Row, Col, Statistic, Table, Spin, Button, Tooltip, message } from 'antd'
import { UserOutlined, ProjectOutlined, FileTextOutlined, SyncOutlined } from '@ant-design/icons'
import { Card, Table, Spin, Button, message } from 'antd'
import { UserOutlined, ProjectOutlined, FileTextOutlined, SyncOutlined, DashboardOutlined } from '@ant-design/icons'
import { getDashboardStats } from '@/api/dashboard'
import { rebuildIndex } from '@/api/search'
import PageHeader from '@/components/PageHeader/PageHeader'
import StatCard from '@/components/StatCard/StatCard'
import Toast from '@/components/Toast/Toast'
import '@/pages/System/AdminPages.css'
function Dashboard() {
const [loading, setLoading] = useState(true)
@ -103,82 +106,40 @@ function Dashboard() {
}
return (
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600 }}>管理员仪表盘</h1>
<div className="admin-page">
<PageHeader
title="管理面板"
description="汇总查看用户、项目与文档数据,并执行搜索索引维护操作。"
icon={<DashboardOutlined />}
extra={(
<Button
type="primary"
icon={<SyncOutlined spin={rebuilding} />}
onClick={handleRebuildIndex}
loading={rebuilding}
>
重建索引
</Button>
)}
/>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col span={8}>
<Card>
<Statistic
title="用户总数"
value={stats.user_count}
prefix={<UserOutlined />}
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="项目总数"
value={stats.project_count}
prefix={<ProjectOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>文档总数</span>
<Tooltip title="重建全文搜索索引(扫描所有文档)">
<Button
type="link"
size="small"
style={{ padding: 0, height: 'auto' }}
icon={<SyncOutlined spin={rebuilding} />}
onClick={handleRebuildIndex}
disabled={rebuilding}
>
重建索引
</Button>
</Tooltip>
</div>
}
value={stats.document_count}
prefix={<FileTextOutlined />}
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
</Row>
<div className="admin-stats">
<StatCard title="用户总数" value={stats.user_count} icon={<UserOutlined />} color="green" />
<StatCard title="项目总数" value={stats.project_count} icon={<ProjectOutlined />} color="blue" />
<StatCard title="文档总数" value={stats.document_count} icon={<FileTextOutlined />} color="red" />
</div>
{/* 最近用户 */}
<Card title="最近创建的用户" style={{ marginBottom: '24px' }}>
<Table
columns={userColumns}
dataSource={recentUsers}
rowKey="id"
pagination={false}
/>
<div className="admin-stack">
<Card title="最近创建的用户" className="admin-card">
<Table columns={userColumns} dataSource={recentUsers} rowKey="id" pagination={false} />
</Card>
{/* 最近项目 */}
<Card title="最近创建的项目" style={{ marginBottom: '24px' }}>
<Table
columns={projectColumns}
dataSource={recentProjects}
rowKey="id"
pagination={false}
/>
<Card title="最近创建的项目" className="admin-card">
<Table columns={projectColumns} dataSource={recentProjects} rowKey="id" pagination={false} />
</Card>
</div>
</div>
)
}
export default Dashboard
export default Dashboard

View File

@ -1,11 +1,5 @@
.desktop-page {
padding: 24px;
}
.page-title {
margin-bottom: 24px;
font-size: 24px;
font-weight: 600;
.desktop-grid {
align-items: stretch;
}
/* 日历卡片 */
@ -83,13 +77,12 @@
}
.activity-item-disabled:hover {
background-color: #f5f5f5;
background-color: var(--bg-color-secondary);
padding-left: 12px;
padding-right: 12px;
border-radius: 4px;
}
/* 暗色模式适配 */
body.dark .activity-item-clickable:hover {
background-color: rgba(24, 144, 255, 0.15);
}
@ -106,6 +99,6 @@ body.dark .activity-item-disabled:hover {
@media (max-width: 992px) {
.calendar-card,
.activity-card {
margin-bottom: 24px;
min-height: auto;
}
}

View File

@ -1,11 +1,13 @@
import { useState, useEffect } from 'react'
import { Card, Row, Col, Calendar, List, Badge, Empty, Typography, Spin } from 'antd'
import { FileTextOutlined, ClockCircleOutlined } from '@ant-design/icons'
import { Card, Calendar, List, Badge, Empty, Typography, Spin } from 'antd'
import { DesktopOutlined, FileTextOutlined, ClockCircleOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { getDocumentActivityDates, getDocumentActivity } from '@/api/dashboard'
import PageHeader from '@/components/PageHeader/PageHeader'
import Toast from '@/components/Toast/Toast'
import dayjs from 'dayjs'
import './Desktop.css'
import '@/pages/System/AdminPages.css'
const { Text } = Typography
@ -111,99 +113,97 @@ function Desktop() {
}
return (
<div className="desktop-page">
<h1 className="page-title">个人桌面</h1>
<div className="admin-page">
<PageHeader
title="个人桌面"
description="查看文档活动日历与每天的编辑轨迹,快速回到最近处理过的内容。"
icon={<DesktopOutlined />}
/>
<Row gutter={24}>
{/* 左侧日历 */}
<Col xs={24} lg={12}>
<Card className="calendar-card">
<Calendar
fullscreen={false}
value={selectedDate}
onSelect={onSelect}
onPanelChange={onPanelChange}
fullCellRender={dateCellRender}
/>
</Card>
</Col>
<div className="admin-grid-2 desktop-grid">
<Card className="admin-card calendar-card">
<Calendar
fullscreen={false}
value={selectedDate}
onSelect={onSelect}
onPanelChange={onPanelChange}
fullCellRender={dateCellRender}
/>
</Card>
{/* 右侧活动列表 */}
<Col xs={24} lg={12}>
<Card
className="activity-card"
title={
<div>
<FileTextOutlined style={{ marginRight: 8 }} />
{selectedDate.format('YYYY年MM月DD日')} 的文档活动
</div>
}
>
<Spin spinning={loading}>
{activityLogs.length > 0 ? (
<List
dataSource={activityLogs}
renderItem={(item) => (
<List.Item
key={item.id}
onClick={() => handleDocumentClick(item)}
className={item.file_exists ? 'activity-item-clickable' : 'activity-item-disabled'}
style={{ cursor: item.file_exists ? 'pointer' : 'default' }}
>
<List.Item.Meta
avatar={
<ClockCircleOutlined
style={{
fontSize: 20,
color: item.file_exists ? '#1890ff' : '#d9d9d9'
}}
/>
}
title={
<div>
<Text strong style={{ color: item.file_exists ? undefined : '#999' }}>
{item.project_name}
<Card
className="admin-card activity-card"
title={(
<div>
<FileTextOutlined style={{ marginRight: 8 }} />
{selectedDate.format('YYYY年MM月DD日')} 的文档活动
</div>
)}
>
<Spin spinning={loading}>
{activityLogs.length > 0 ? (
<List
dataSource={activityLogs}
renderItem={(item) => (
<List.Item
key={item.id}
onClick={() => handleDocumentClick(item)}
className={item.file_exists ? 'activity-item-clickable' : 'activity-item-disabled'}
style={{ cursor: item.file_exists ? 'pointer' : 'default' }}
>
<List.Item.Meta
avatar={(
<ClockCircleOutlined
style={{
fontSize: 20,
color: item.file_exists ? 'var(--link-color)' : 'var(--border-color-strong)'
}}
/>
)}
title={(
<div>
<Text strong style={{ color: item.file_exists ? undefined : 'var(--text-color-secondary)' }}>
{item.project_name}
</Text>
<Text
type="secondary"
style={{ marginLeft: 8, color: item.file_exists ? undefined : 'var(--text-color-secondary)' }}
>
{item.operation_type}
</Text>
{!item.file_exists && (
<Text type="danger" style={{ marginLeft: 8, fontSize: 12 }}>
(已失效)
</Text>
<Text
type="secondary"
style={{ marginLeft: 8, color: item.file_exists ? undefined : '#bbb' }}
>
{item.operation_type}
</Text>
{!item.file_exists && (
<Text type="danger" style={{ marginLeft: 8, fontSize: 12 }}>
(已失效)
</Text>
)}
</div>
}
description={
<div>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">文件</Text>
<Text code style={{ color: item.file_exists ? undefined : '#999' }}>
{item.file_path}
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(item.created_at).toLocaleTimeString('zh-CN')}
)}
</div>
)}
description={(
<div>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">文件</Text>
<Text code style={{ color: item.file_exists ? undefined : 'var(--text-color-secondary)' }}>
{item.file_path}
</Text>
</div>
}
/>
</List.Item>
)}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该日期暂无文档活动记录"
/>
)}
</Spin>
</Card>
</Col>
</Row>
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(item.created_at).toLocaleTimeString('zh-CN')}
</Text>
</div>
)}
/>
</List.Item>
)}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该日期暂无文档活动记录"
/>
)}
</Spin>
</Card>
</div>
</div>
)
}

View File

@ -4,6 +4,9 @@
height: calc(100vh - 64px);
/* width: calc(100% + 32px); */
display: flex;
margin: -16px;
padding: 8px;
box-sizing: border-box;
background: var(--bg-color);
}
@ -464,3 +467,9 @@
min-width: 0;
max-width: none;
}
@media (max-width: 768px) {
.document-editor-page {
padding: 8px;
}
}

View File

@ -1,5 +1,8 @@
.project-docs-page {
height: calc(100vh - 64px);
margin: -16px;
padding: 8px;
box-sizing: border-box;
overflow: hidden;
background: var(--bg-color);
}
@ -395,3 +398,9 @@
.markdown-body li {
margin-bottom: 4px;
}
@media (max-width: 768px) {
.project-docs-page {
padding: 8px;
}
}

View File

@ -454,6 +454,30 @@ function DocumentPage() {
}
}, [markdownContent, isLargeMarkdown])
// URL keyword
useEffect(() => {
if (loading || !searchKeyword || !markdownContent) return
if (viewMode !== 'markdown') return
let canceled = false
const timer = window.setTimeout(() => {
if (canceled) return
const container = contentRef.current
const target = container?.querySelector('.search-highlight')
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
//
target.classList.add('cited-highlight-flash')
window.setTimeout(() => target.classList.remove('cited-highlight-flash'), 2400)
}
}, 260)
return () => {
canceled = true
window.clearTimeout(timer)
}
}, [loading, markdownContent, searchKeyword, viewMode])
//
const handleMenuClick = ({ key }) => {
const node = findNodeByKey(fileTree, key)

View File

@ -0,0 +1,145 @@
import { FileTextOutlined, FolderOutlined, FilePdfOutlined } from '@ant-design/icons'
import GithubSlugger from 'github-slugger'
export function findRootReadme(nodes) {
return nodes.find((node) => node.title === 'README.md' && node.isLeaf) || null
}
export function findNodeByKey(nodes, key) {
for (const node of nodes) {
if (node.key === key) {
return node
}
if (node.children?.length) {
const found = findNodeByKey(node.children, key)
if (found) {
return found
}
}
}
return null
}
export function collectParentKeys(path) {
const parts = path.split('/')
const parentKeys = []
let currentPath = ''
for (let index = 0; index < parts.length - 1; index += 1) {
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index]
parentKeys.push(currentPath)
}
return parentKeys
}
export function resolveRelativePath(currentPath, relativePath) {
const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/'))
const dirParts = currentDir ? currentDir.split('/') : []
relativePath.split('/').forEach((part) => {
if (part === '..') {
dirParts.pop()
return
}
if (part !== '.' && part !== '') {
dirParts.push(part)
}
})
return dirParts.join('/')
}
export function buildTocItems(markdownContent) {
if (!markdownContent) {
return []
}
const slugger = new GithubSlugger()
const headings = []
markdownContent.split('\n').forEach((line) => {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (!match) {
return
}
const title = match[2]
const key = slugger.slug(title)
headings.push({
key: `#${key}`,
href: `#${key}`,
title,
level: match[1].length,
})
})
return headings
}
export function filterTreeByKeyword(nodes, keyword, matchedFilePaths) {
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) {
return nodes
}
const loop = (items) => {
const result = []
items.forEach((node) => {
const titleMatch = node.title.toLowerCase().includes(normalizedKeyword)
const contentMatch = matchedFilePaths.has(node.key)
if (node.children?.length) {
const children = loop(node.children)
if (children.length > 0 || titleMatch) {
result.push({ ...node, children })
}
return
}
if (titleMatch || contentMatch) {
result.push(node)
}
})
return result
}
return loop(nodes)
}
export function convertTreeToMenuItems(nodes) {
return nodes
.map((node) => {
if (!node.isLeaf) {
return {
key: node.key,
label: node.title,
icon: <FolderOutlined />,
children: node.children ? convertTreeToMenuItems(node.children) : [],
}
}
if (node.title?.endsWith('.md')) {
return {
key: node.key,
label: node.title.replace('.md', ''),
icon: <FileTextOutlined />,
}
}
if (node.title?.endsWith('.pdf')) {
return {
key: node.key,
label: node.title,
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
}
}
return null
})
.filter(Boolean)
}

View File

@ -0,0 +1,255 @@
import { useEffect, useMemo, useState } from 'react'
import { getDocumentUrl, getFileContent, getProjectTree } from '@/api/file'
import { searchDocuments } from '@/api/search'
import { buildAuthorizedUrl } from '@/utils/authStorage'
import {
buildTocItems,
collectParentKeys,
convertTreeToMenuItems,
filterTreeByKeyword,
findNodeByKey,
findRootReadme,
resolveRelativePath,
} from './documentBrowserUtils'
function useDocumentBrowser({ projectId, searchParams, setSearchParams, contentRef }) {
const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('')
const [selectedNodeKey, setSelectedNodeKey] = useState('')
const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([])
const [tocItems, setTocItems] = useState([])
const [userRole, setUserRole] = useState('viewer')
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown')
const [projectName, setProjectName] = useState('')
const [searchKeyword, setSearchKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false)
const updateFileParam = (filePath) => {
const nextParams = new URLSearchParams(searchParams)
if (filePath) {
nextParams.set('file', filePath)
} else {
nextParams.delete('file')
}
setSearchParams(nextParams, { replace: true })
}
const loadMarkdown = async (filePath) => {
setLoading(true)
setTocItems([])
try {
const res = await getFileContent(projectId, filePath)
setMarkdownContent(res.data?.content || '')
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
} catch (error) {
console.error('Load markdown error:', error)
setMarkdownContent('# 文档加载失败\n\n无法加载该文档请稍后重试。')
} finally {
setLoading(false)
}
}
const openFile = async (filePath, options = {}) => {
const { syncUrl = true } = options
setSelectedFile(filePath)
setSelectedNodeKey(filePath)
setOpenKeys((prev) => [...new Set([...prev, ...collectParentKeys(filePath)])])
if (syncUrl) {
updateFileParam(filePath)
}
if (filePath.toLowerCase().endsWith('.pdf')) {
setPdfUrl(buildAuthorizedUrl(getDocumentUrl(projectId, filePath)))
setPdfFilename(filePath.split('/').pop())
setViewMode('pdf')
return
}
setPdfUrl('')
setPdfFilename('')
setViewMode('markdown')
await loadMarkdown(filePath)
}
const loadFileTree = async () => {
try {
const res = await getProjectTree(projectId)
const data = res.data || {}
setFileTree(data.tree || data || [])
setUserRole(data.user_role || 'viewer')
setProjectName(data.project_name || '')
} catch (error) {
console.error('Load file tree error:', error)
}
}
const handleSearch = async (value) => {
setSearchKeyword(value)
if (!value.trim()) {
setMatchedFilePaths(new Set())
return
}
setIsSearching(true)
try {
const res = await searchDocuments(value, projectId)
const paths = new Set(res.data.map((item) => item.file_path))
setMatchedFilePaths(paths)
const expandedKeys = new Set(openKeys)
res.data.forEach((item) => {
collectParentKeys(item.file_path).forEach((parentKey) => expandedKeys.add(parentKey))
})
setOpenKeys(Array.from(expandedKeys))
} catch (error) {
console.error('Search error:', error)
} finally {
setIsSearching(false)
}
}
const handleMenuClick = ({ key }) => {
openFile(key)
}
const refreshCurrentView = async () => {
await loadFileTree()
if (!selectedFile) {
return
}
if (selectedFile.toLowerCase().endsWith('.pdf')) {
const baseUrl = getDocumentUrl(projectId, selectedFile)
const separator = baseUrl.includes('?') ? '&' : '?'
setPdfUrl(buildAuthorizedUrl(`${baseUrl}${separator}_=${Date.now()}`))
return
}
await loadMarkdown(selectedFile)
}
const handleMarkdownLink = (event, href) => {
if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
return
}
if (href.startsWith('#')) {
return
}
const isMarkdownFile = href.endsWith('.md')
const isPdfFile = href.toLowerCase().endsWith('.pdf')
if (!isMarkdownFile && !isPdfFile) {
return
}
event.preventDefault()
let decodedHref = href
try {
decodedHref = decodeURIComponent(href)
} catch (error) {
console.warn('Decode markdown href failed:', error)
}
const targetPath = decodedHref.startsWith('.') || decodedHref.startsWith('..')
? resolveRelativePath(selectedFile, decodedHref)
: (decodedHref.startsWith('/') ? decodedHref.substring(1) : decodedHref)
openFile(targetPath)
}
useEffect(() => {
loadFileTree()
}, [projectId])
useEffect(() => {
setTocItems(buildTocItems(markdownContent))
}, [markdownContent])
useEffect(() => {
if (fileTree.length === 0) {
return
}
const fileParam = searchParams.get('file')
const keywordParam = searchParams.get('keyword')
if (keywordParam && keywordParam !== searchKeyword) {
handleSearch(keywordParam)
}
if (fileParam) {
if (fileParam !== selectedFile) {
openFile(fileParam, { syncUrl: false })
}
return
}
if (!selectedFile) {
const readmeNode = findRootReadme(fileTree)
if (readmeNode) {
openFile(readmeNode.key)
}
}
}, [fileTree, searchParams])
const filteredTreeData = useMemo(
() => filterTreeByKeyword(fileTree, searchKeyword, matchedFilePaths),
[fileTree, searchKeyword, matchedFilePaths]
)
const menuItems = useMemo(
() => convertTreeToMenuItems(filteredTreeData),
[filteredTreeData]
)
const selectedNode = useMemo(
() => findNodeByKey(fileTree, selectedNodeKey),
[fileTree, selectedNodeKey]
)
return {
fileTree,
filteredTreeData,
menuItems,
selectedFile,
selectedNode,
selectedNodeKey,
markdownContent,
loading,
openKeys,
projectName,
projectId,
userRole,
pdfFilename,
pdfUrl,
viewMode,
searchKeyword,
isSearching,
tocItems,
setOpenKeys,
setSearchKeyword,
loadFileTree,
loadMarkdown,
refreshCurrentView,
handleMenuClick,
handleMarkdownLink,
handleSearch,
}
}
export default useDocumentBrowser

View File

@ -0,0 +1,168 @@
import { useEffect, useState } from 'react'
import { getFileContent, getProjectTree } from '@/api/file'
import Toast from '@/components/Toast/Toast'
import { collectParentKeys, findNodeByKey } from './documentBrowserUtils'
function useDocumentEditorWorkspace({ projectId, searchParams, setSearchParams }) {
const [treeData, setTreeData] = useState([])
const [selectedFile, setSelectedFile] = useState(null)
const [selectedNode, setSelectedNode] = useState(null)
const [fileContent, setFileContent] = useState('')
const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([])
const [selectedMenuKey, setSelectedMenuKey] = useState(null)
const [isPdfSelected, setIsPdfSelected] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [projectName, setProjectName] = useState('')
const [userRole, setUserRole] = useState('viewer')
const updateFileParam = (filePath) => {
const nextParams = new URLSearchParams(searchParams)
if (filePath) {
nextParams.set('file', filePath)
} else {
nextParams.delete('file')
}
setSearchParams(nextParams, { replace: true })
}
const fetchTree = async () => {
try {
const res = await getProjectTree(projectId)
const data = res.data || {}
setTreeData(data.tree || data || [])
setProjectName(data.project_name || '')
setUserRole(data.user_role || 'viewer')
} catch (error) {
Toast.error('加载失败', '加载文件树失败')
}
}
const clearSelection = () => {
setSelectedFile(null)
setSelectedNode(null)
setSelectedMenuKey(null)
setFileContent('')
setIsPdfSelected(false)
updateFileParam(null)
}
const loadEditableFile = async (filePath) => {
setLoading(true)
try {
const res = await getFileContent(projectId, filePath)
setSelectedFile(filePath)
setFileContent(res.data.content)
} catch (error) {
Toast.error('加载失败', '加载文件失败')
} finally {
setLoading(false)
}
}
const openNodeByKey = async (key, node = null, syncUrl = false) => {
const targetNode = node || findNodeByKey(treeData, key)
if (!targetNode) {
return
}
setSelectedNode(targetNode)
setSelectedMenuKey(key)
if (!targetNode.isLeaf) {
if (syncUrl) {
updateFileParam(null)
}
return
}
if (syncUrl) {
updateFileParam(key)
}
setOpenKeys((prev) => Array.from(new Set([...prev, ...collectParentKeys(key)])))
if (key.toLowerCase().endsWith('.pdf')) {
setSelectedFile(key)
setIsPdfSelected(true)
setFileContent('')
return
}
setIsPdfSelected(false)
await loadEditableFile(key)
}
const handleMenuClick = async ({ key }) => {
const node = findNodeByKey(treeData, key)
if (!node) {
return
}
await openNodeByKey(key, node, true)
}
const refreshCurrentDocument = async () => {
setRefreshing(true)
try {
await fetchTree()
if (selectedFile && !isPdfSelected) {
await loadEditableFile(selectedFile)
}
} catch (error) {
console.error('Refresh document editor error:', error)
Toast.error('刷新失败', '请稍后重试')
} finally {
setRefreshing(false)
}
}
useEffect(() => {
fetchTree()
}, [projectId])
useEffect(() => {
if (treeData.length === 0) {
return
}
const fileParam = searchParams.get('file')
if (!fileParam || fileParam === selectedFile) {
return
}
const targetNode = findNodeByKey(treeData, fileParam)
if (!targetNode) {
return
}
openNodeByKey(fileParam, targetNode, false)
}, [treeData, searchParams])
return {
treeData,
selectedFile,
selectedNode,
fileContent,
loading,
openKeys,
selectedMenuKey,
isPdfSelected,
refreshing,
projectName,
userRole,
setOpenKeys,
setSelectedNode,
setSelectedFile,
setSelectedMenuKey,
setFileContent,
fetchTree,
clearSelection,
loadEditableFile,
openNodeByKey,
handleMenuClick,
refreshCurrentDocument,
}
}
export default useDocumentEditorWorkspace

View File

@ -153,14 +153,14 @@
border-radius: 8px;
padding: 12px 16px;
background: #fafafa;
border: 1px solid #f0f0f0;
border: 1px solid var(--border-color);
transition: all 0.3s;
}
.login-form-container .ant-input-affix-wrapper:hover,
.login-form-container .ant-input:hover {
background: #fff;
border-color: #d9d9d9;
border-color: var(--border-color-strong);
}
.login-form-container .ant-input-affix-wrapper-focused,

View File

@ -0,0 +1,8 @@
export {
buildTocItems,
collectParentKeys,
convertTreeToMenuItems,
filterTreeByKeyword,
findRootReadme,
resolveRelativePath,
} from '../Document/documentBrowserUtils'

View File

@ -0,0 +1,290 @@
import { useEffect, useMemo, useState } from 'react'
import Toast from '@/components/Toast/Toast'
import {
exportPDF,
getPreviewDocumentUrl,
getPreviewFile,
getPreviewInfo,
getPreviewTree,
verifyAccessPassword,
} from '@/api/share'
import { searchDocuments } from '@/api/search'
import { buildAuthorizedUrl } from '@/utils/authStorage'
import {
buildTocItems,
collectParentKeys,
convertTreeToMenuItems,
filterTreeByKeyword,
findRootReadme,
resolveRelativePath,
} from './previewBrowserUtils'
function usePreviewBrowser({ projectId, searchParams, contentRef }) {
const [projectInfo, setProjectInfo] = useState(null)
const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('')
const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([])
const [tocItems, setTocItems] = useState([])
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const [password, setPassword] = useState('')
const [accessPassword, setAccessPassword] = useState(null)
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown')
const [searchKeyword, setSearchKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false)
const buildPreviewUrl = (url) => buildAuthorizedUrl(url, { access_pass: accessPassword })
const loadMarkdown = async (filePath, pwd = null) => {
setLoading(true)
setTocItems([])
try {
const res = await getPreviewFile(projectId, filePath, pwd || accessPassword)
setMarkdownContent(res.data?.content || '')
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
} catch (error) {
console.error('Load markdown error:', error)
if (error.response?.status === 403) {
Toast.error('访问密码错误或已过期')
setPasswordModalVisible(true)
} else {
Toast.error('加载失败', '文档加载失败,请稍后重试')
setMarkdownContent('')
}
} finally {
setLoading(false)
}
}
const openFile = async (filePath, pwd = null) => {
setSelectedFile(filePath)
setOpenKeys((prev) => [...new Set([...prev, ...collectParentKeys(filePath)])])
if (filePath.toLowerCase().endsWith('.pdf')) {
setPdfUrl(buildPreviewUrl(getPreviewDocumentUrl(projectId, filePath)))
setPdfFilename(filePath.split('/').pop())
setViewMode('pdf')
return
}
setViewMode('markdown')
await loadMarkdown(filePath, pwd)
}
const loadFileTree = async (pwd = null) => {
try {
const res = await getPreviewTree(projectId, pwd || accessPassword)
setFileTree(res.data || [])
} catch (error) {
console.error('Load file tree error:', error)
if (error.response?.status === 403) {
Toast.error('访问密码错误或已过期')
setPasswordModalVisible(true)
}
}
}
const loadProjectInfo = async () => {
try {
const res = await getPreviewInfo(projectId)
const info = res.data
setProjectInfo(info)
if (info.has_password) {
setPasswordModalVisible(true)
return
}
loadFileTree()
} catch (error) {
console.error('Load project info error:', error)
Toast.error('加载失败', '项目不存在或已被删除')
}
}
const handleVerifyPassword = async () => {
if (!password.trim()) {
Toast.warning('提示', '请输入访问密码')
return
}
try {
await verifyAccessPassword(projectId, password)
setAccessPassword(password)
setPasswordModalVisible(false)
loadFileTree(password)
Toast.success('验证成功')
} catch (error) {
Toast.error('访问密码错误')
}
}
const handleSearch = async (value) => {
setSearchKeyword(value)
if (!value.trim()) {
setMatchedFilePaths(new Set())
return
}
setIsSearching(true)
try {
const res = await searchDocuments(value, projectId)
const paths = new Set(res.data.map((item) => item.file_path))
setMatchedFilePaths(paths)
const expandedKeys = new Set(openKeys)
res.data.forEach((item) => {
collectParentKeys(item.file_path).forEach((parentKey) => expandedKeys.add(parentKey))
})
setOpenKeys(Array.from(expandedKeys))
} catch (error) {
console.error('Search error:', error)
Toast.error('搜索失败', '请稍后重试')
} finally {
setIsSearching(false)
}
}
const handleMenuClick = ({ key }) => {
openFile(key)
}
const handleMarkdownLink = (event, href) => {
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) {
return
}
const isMarkdownFile = href.endsWith('.md')
const isPdfFile = href.toLowerCase().endsWith('.pdf')
if (!isMarkdownFile && !isPdfFile) {
return
}
event.preventDefault()
let decodedHref = href
try {
decodedHref = decodeURIComponent(href)
} catch (error) {
console.warn('Decode markdown href failed:', error)
}
const targetPath = decodedHref.startsWith('.') || decodedHref.startsWith('..')
? resolveRelativePath(selectedFile, decodedHref)
: (decodedHref.startsWith('/') ? decodedHref.substring(1) : decodedHref)
setOpenKeys((prev) => [...new Set([...prev, ...collectParentKeys(targetPath)])])
handleMenuClick({ key: targetPath })
}
const handleContentClick = (event) => {
const target = event.target.closest('a')
if (!target) {
return
}
const href = target.getAttribute('href')
if (href) {
handleMarkdownLink(event, href)
}
}
const handleExportPDF = () => {
if (viewMode === 'pdf') {
const link = document.createElement('a')
link.href = pdfUrl
link.download = pdfFilename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
return
}
window.open(buildPreviewUrl(exportPDF(projectId, selectedFile)), '_blank')
}
useEffect(() => {
loadProjectInfo()
}, [projectId])
useEffect(() => {
setTocItems(buildTocItems(markdownContent))
}, [markdownContent])
useEffect(() => {
if (fileTree.length === 0) {
return
}
if (projectInfo?.has_password && !accessPassword) {
return
}
const fileParam = searchParams.get('file')
const keywordParam = searchParams.get('keyword')
if (keywordParam && keywordParam !== searchKeyword) {
handleSearch(keywordParam)
}
if (fileParam) {
if (fileParam !== selectedFile) {
openFile(fileParam, accessPassword)
}
return
}
if (!selectedFile) {
const readmeNode = findRootReadme(fileTree)
if (readmeNode) {
openFile(readmeNode.key, accessPassword)
}
}
}, [searchParams, fileTree, accessPassword, projectInfo])
const filteredTreeData = useMemo(
() => filterTreeByKeyword(fileTree, searchKeyword, matchedFilePaths),
[fileTree, searchKeyword, matchedFilePaths]
)
const menuItems = useMemo(
() => convertTreeToMenuItems(filteredTreeData),
[filteredTreeData]
)
return {
projectInfo,
filteredTreeData,
menuItems,
selectedFile,
markdownContent,
loading,
openKeys,
tocItems,
passwordModalVisible,
password,
pdfUrl,
pdfFilename,
viewMode,
searchKeyword,
isSearching,
setOpenKeys,
setPassword,
setSearchKeyword,
setPasswordModalVisible,
handleContentClick,
handleExportPDF,
handleMenuClick,
handleSearch,
handleVerifyPassword,
}
}
export default usePreviewBrowser

View File

@ -1,19 +1,23 @@
.project-list-container {
padding: 24px;
}
.project-list-header {
.project-list-card .ant-card-body {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-direction: column;
gap: 16px;
}
.project-list-header h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--text-color);
.project-results-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.project-results-summary {
color: var(--text-color-secondary);
font-size: 14px;
}
.project-search-file-meta {
font-size: 12px;
color: var(--text-color-secondary);
}
.project-card {
@ -28,7 +32,7 @@
.project-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: var(--panel-shadow);
}
.project-card-public-badge {
@ -76,6 +80,16 @@
color: var(--text-color-secondary);
}
.project-empty-state {
padding: 12px 0;
}
.project-list-pagination {
display: flex;
justify-content: center;
margin-top: 8px;
}
/* 圆点分页指示器样式 */
.dot-pagination.ant-pagination {
display: flex;

View File

@ -1,13 +1,14 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination } from 'antd'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined } from '@ant-design/icons'
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, Progress, Alert, List, Spin } from 'antd'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined } from '@ant-design/icons'
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
import { getUserList } from '@/api/users'
import { searchDocuments } from '@/api/search'
import ListActionBar from '@/components/ListActionBar/ListActionBar'
import Toast from '@/components/Toast/Toast'
import useProjectKnowledge from './useProjectKnowledge'
import './ProjectList.css'
function ProjectList({ type = 'my' }) {
@ -38,6 +39,17 @@ function ProjectList({ type = 'my' }) {
const [currentPage, setCurrentPage] = useState(1)
const pageSize = 8
const {
kbModalVisible,
progress,
currentTask,
loadingProgress,
vectorizing,
openKnowledgeModal,
closeKnowledgeModal,
runVectorize,
} = useProjectKnowledge()
useEffect(() => {
fetchProjects()
setCurrentPage(1)
@ -242,6 +254,13 @@ function ProjectList({ type = 'my' }) {
fetchGitRepos(project.id)
}
//
const handleKnowledge = (e, project) => {
e.stopPropagation()
setCurrentProject(project)
openKnowledgeModal(project)
}
// Git
const fetchGitRepos = async (projectId) => {
setLoadingRepos(true)
@ -605,6 +624,7 @@ function ProjectList({ type = 'my' }) {
actions={type === 'my' ? [
<SettingOutlined key="settings" onClick={(e) => handleEdit(e, project)} />,
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
<DatabaseOutlined key="kb" onClick={(e) => handleKnowledge(e, project)} />,
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
] : [
<EyeOutlined key="view" />,
@ -1136,6 +1156,119 @@ function ProjectList({ type = 'my' }) {
</Form.Item>
</Form>
</Modal>
{/* 知识库向量化弹窗 */}
<Modal
title={`知识库 - ${currentProject?.name || ''}`}
open={kbModalVisible}
onCancel={closeKnowledgeModal}
width={640}
footer={[
<Button key="close" onClick={closeKnowledgeModal}>关闭</Button>,
<Button
key="incremental"
type="primary"
loading={vectorizing}
disabled={vectorizing || !progress?.embedding_ready}
onClick={() => runVectorize(currentProject?.id, { force: false })}
>
增量向量化
</Button>,
<Button
key="full"
danger
loading={vectorizing}
disabled={vectorizing || !progress?.embedding_ready}
onClick={() => runVectorize(currentProject?.id, { force: true })}
>
全量向量化
</Button>,
]}
>
{loadingProgress && !progress ? (
<div style={{ textAlign: 'center', padding: '32px 0', color: '#999' }}>
加载向量化进度...
</div>
) : !progress ? (
<Empty description="暂无数据" />
) : (
<div>
{!progress.embedding_ready && (
<div style={{ marginBottom: 16, padding: '10px 14px', borderRadius: 8, background: '#fff7e6', border: '1px solid #ffe7ba', color: '#d46b08' }}>
尚未配置可用的向量模型Embedding请先到模型配置 - 向量模型中添加并启用再进行向量化
</div>
)}
{currentTask && (
<Alert
style={{ marginBottom: 16 }}
type={currentTask.status === 'failed' ? 'error' : currentTask.status === 'success' ? 'success' : 'info'}
showIcon
message={
`最近任务:${currentTask.task_type === 'full' ? '全量向量化' : '增量向量化'} · ` +
`${currentTask.status === 'pending' ? '等待执行' : currentTask.status === 'running' ? '后台执行中' : currentTask.status === 'success' ? '已完成' : '执行失败'}`
}
description={
currentTask.status === 'failed'
? (currentTask.error_message || '任务执行失败')
: `处理 ${currentTask.processed || 0} 个,跳过 ${currentTask.skipped || 0} 个,失败 ${currentTask.failed || 0}`
}
/>
)}
<Progress
percent={progress.percent}
status={progress.failed > 0 ? 'exception' : (progress.pending > 0 ? 'active' : 'success')}
/>
<Row gutter={16} style={{ marginTop: 16, textAlign: 'center' }}>
<Col span={6}>
<div style={{ fontSize: 22, fontWeight: 600 }}>{progress.total}</div>
<div style={{ color: '#999', fontSize: 12 }}>MD 文件总数</div>
</Col>
<Col span={6}>
<div style={{ fontSize: 22, fontWeight: 600, color: '#52c41a' }}>{progress.success}</div>
<div style={{ color: '#999', fontSize: 12 }}>已向量化</div>
</Col>
<Col span={6}>
<div style={{ fontSize: 22, fontWeight: 600, color: '#faad14' }}>{progress.pending}</div>
<div style={{ color: '#999', fontSize: 12 }}>待处理</div>
</Col>
<Col span={6}>
<div style={{ fontSize: 22, fontWeight: 600, color: '#ff4d4f' }}>{progress.failed}</div>
<div style={{ color: '#999', fontSize: 12 }}>失败</div>
</Col>
</Row>
{progress.failed_items?.length > 0 && (
<div style={{ marginTop: 20 }}>
<div style={{ marginBottom: 8, fontWeight: 600, color: '#ff4d4f' }}>失败明细</div>
<div style={{ maxHeight: 160, overflow: 'auto' }}>
{progress.failed_items.map((item) => (
<div key={item.file_path} style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0', fontSize: 13 }}>
<div style={{ wordBreak: 'break-all' }}>{item.file_path}</div>
<div style={{ color: '#999', fontSize: 12 }}>{item.error}</div>
</div>
))}
</div>
</div>
)}
{progress.pending_items?.length > 0 && (
<div style={{ marginTop: 20 }}>
<div style={{ marginBottom: 8, fontWeight: 600, color: '#faad14' }}>待处理文件</div>
<div style={{ maxHeight: 140, overflow: 'auto' }}>
{progress.pending_items.map((path) => (
<div key={path} style={{ padding: '4px 0', fontSize: 13, wordBreak: 'break-all', color: '#666' }}>
{path}
</div>
))}
</div>
</div>
)}
</div>
)}
</Modal>
</div>
)
}

View File

@ -0,0 +1,126 @@
import { useState } from 'react'
import { message, Modal } from 'antd'
import { addProjectMember, getProjectMembers, removeProjectMember, transferProject } from '@/api/project'
import { getUserList } from '@/api/users'
function useProjectCollaboration() {
const [membersModalVisible, setMembersModalVisible] = useState(false)
const [members, setMembers] = useState([])
const [users, setUsers] = useState([])
const [loadingMembers, setLoadingMembers] = useState(false)
const [transferModalVisible, setTransferModalVisible] = useState(false)
const loadTransferCandidates = async (project) => {
setLoadingMembers(true)
setTransferModalVisible(true)
try {
const res = await getUserList({ page: 1, page_size: 100, status: 1 })
const allUsers = res.data || []
setUsers(allUsers.filter((user) => user.id !== project.owner_id))
} catch (error) {
message.error('加载用户列表失败')
} finally {
setLoadingMembers(false)
}
}
const submitTransfer = async ({ projectId, newOwnerId, onCompleted }) => {
Modal.confirm({
title: '确认转移',
content: '确定要将项目所有权转移给该用户吗?转移后您将变为管理员,无法再删除项目或转移所有权。',
okText: '确认转移',
okType: 'danger',
onOk: async () => {
try {
await transferProject(projectId, newOwnerId)
message.success('项目所有权已转移')
setTransferModalVisible(false)
onCompleted?.()
} catch (error) {
console.error('Transfer error:', error)
message.error(`转移失败: ${error.response?.data?.detail || error.message}`)
}
},
})
}
const closeTransferModal = () => {
setTransferModalVisible(false)
}
const openMembersModal = async (project) => {
setMembersModalVisible(true)
setLoadingMembers(true)
try {
const [membersRes, usersRes] = await Promise.all([
getProjectMembers(project.id),
getUserList({ page: 1, page_size: 100, status: 1, role_id: 3 }),
])
setMembers(membersRes.data || [])
setUsers(Array.isArray(usersRes.data) ? usersRes.data : [])
} catch (error) {
console.error('Get members error:', error)
console.error('Error details:', error.response)
message.error(`获取数据失败: ${error.response?.data?.detail || error.message}`)
} finally {
setLoadingMembers(false)
}
}
const closeMembersModal = () => {
setMembersModalVisible(false)
setMembers([])
setUsers([])
}
const addMember = async (projectId, values) => {
try {
await addProjectMember(projectId, values)
message.success('成员添加成功')
const res = await getProjectMembers(projectId)
setMembers(res.data || [])
} catch (error) {
console.error('Add member error:', error)
const errorMsg = error.response?.data?.detail || error.message || '添加成员失败'
message.error(errorMsg)
}
}
const removeMember = async (projectId, userId) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个成员吗?',
onOk: async () => {
try {
await removeProjectMember(projectId, userId)
message.success('成员删除成功')
const res = await getProjectMembers(projectId)
setMembers(res.data || [])
} catch (error) {
console.error('Remove member error:', error)
const errorMsg = error.response?.data?.detail || error.message || '删除成员失败'
message.error(errorMsg)
}
},
})
}
return {
membersModalVisible,
members,
users,
loadingMembers,
transferModalVisible,
loadTransferCandidates,
submitTransfer,
closeTransferModal,
openMembersModal,
closeMembersModal,
addMember,
removeMember,
}
}
export default useProjectCollaboration

View File

@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from 'react'
import { downloadProjectExport, getProjectExportStatus, startProjectExport } from '@/api/project'
import Toast from '@/components/Toast/Toast'
import { extractFilenameFromDisposition, triggerBlobDownload } from '@/utils/browserIO'
function useProjectExport() {
const [exportModalVisible, setExportModalVisible] = useState(false)
const [exportingProject, setExportingProject] = useState(false)
const [exportTask, setExportTask] = useState(null)
const exportPollingRef = useRef(false)
useEffect(() => {
return () => {
exportPollingRef.current = false
}
}, [])
const resetExportState = () => {
setExportModalVisible(false)
setExportTask(null)
}
const closeExportModal = () => {
if (exportingProject) {
return
}
resetExportState()
}
const extractBlobErrorMessage = async (error, fallback) => {
const blob = error?.response?.data
if (!(blob instanceof Blob)) {
return error?.response?.data?.detail || error?.message || fallback
}
try {
const text = await blob.text()
const data = JSON.parse(text)
return data?.detail || data?.message || fallback
} catch (parseError) {
console.error('Parse export error blob failed:', parseError)
return error?.message || fallback
}
}
const exportProject = async (project) => {
if (!project?.id) {
return
}
try {
setExportModalVisible(true)
setExportingProject(true)
setExportTask({
status: 'pending',
progress: 0,
processed_files: 0,
total_files: 0,
message: '正在创建导出任务...',
zip_filename: `${project.name}.zip`,
})
const startRes = await startProjectExport(project.id)
const task = startRes.data
setExportTask(task)
exportPollingRef.current = true
while (exportPollingRef.current) {
const statusRes = await getProjectExportStatus(project.id, task.task_id)
const nextTask = statusRes.data
setExportTask(nextTask)
if (nextTask.status === 'completed') {
const downloadRes = await downloadProjectExport(project.id, task.task_id)
const contentDisposition = downloadRes.headers['content-disposition']
const fallbackName = nextTask.zip_filename || `${project.name}.zip`
const filename = extractFilenameFromDisposition(contentDisposition, fallbackName)
triggerBlobDownload(downloadRes.data, filename)
Toast.success('导出成功', '项目压缩包已开始下载')
break
}
if (nextTask.status === 'failed') {
throw new Error(nextTask.error || nextTask.message || '项目导出失败')
}
await new Promise((resolve) => setTimeout(resolve, 800))
}
resetExportState()
} catch (error) {
console.error('Export project error:', error)
const errorMessage = await extractBlobErrorMessage(error, '项目导出失败')
setExportTask((prev) => ({
...prev,
status: 'failed',
message: errorMessage,
error: errorMessage,
}))
Toast.error('导出失败', errorMessage)
} finally {
exportPollingRef.current = false
setExportingProject(false)
}
}
return {
exportModalVisible,
exportingProject,
exportTask,
closeExportModal,
exportProject,
}
}
export default useProjectExport

View File

@ -0,0 +1,116 @@
import { useState } from 'react'
import { message, Modal } from 'antd'
import { createGitRepo, deleteGitRepo, getGitRepos, updateGitRepo } from '@/api/project'
function useProjectGitRepos() {
const [gitRepos, setGitRepos] = useState([])
const [loadingRepos, setLoadingRepos] = useState(false)
const [gitModalVisible, setGitModalVisible] = useState(false)
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
const [editingRepo, setEditingRepo] = useState(null)
const fetchGitRepos = async (projectId) => {
setLoadingRepos(true)
try {
const res = await getGitRepos(projectId)
setGitRepos(res.data || [])
} catch (error) {
console.error('Fetch git repos error:', error)
message.error('加载Git仓库失败')
} finally {
setLoadingRepos(false)
}
}
const openGitSettings = async (projectId) => {
setGitModalVisible(true)
await fetchGitRepos(projectId)
}
const closeGitSettings = () => {
setGitModalVisible(false)
setGitRepos([])
}
const openAddRepoModal = ({ repoForm, gitRepos }) => {
setEditingRepo(null)
repoForm.resetFields()
if (gitRepos.length === 0) {
repoForm.setFieldsValue({ is_default: 1 })
}
setGitRepoModalVisible(true)
}
const openEditRepoModal = ({ repo, repoForm }) => {
setEditingRepo(repo)
repoForm.setFieldsValue({
...repo,
is_default: repo.is_default === 1,
})
setGitRepoModalVisible(true)
}
const closeGitRepoModal = ({ repoForm }) => {
setGitRepoModalVisible(false)
repoForm.resetFields()
}
const deleteRepo = async ({ projectId, repoId }) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个Git仓库配置吗',
onOk: async () => {
try {
await deleteGitRepo(projectId, repoId)
message.success('删除成功')
await fetchGitRepos(projectId)
} catch (error) {
console.error('Delete repo error:', error)
message.error('删除失败')
}
},
})
}
const saveRepo = async ({ projectId, values }) => {
try {
const data = {
...values,
is_default: values.is_default ? 1 : 0,
}
if (editingRepo) {
await updateGitRepo(projectId, editingRepo.id, data)
message.success('更新成功')
} else {
await createGitRepo(projectId, data)
message.success('添加成功')
}
await fetchGitRepos(projectId)
return true
} catch (error) {
console.error('Save repo error:', error)
message.error(editingRepo ? '更新失败' : '添加失败')
return false
}
}
return {
gitRepos,
loadingRepos,
gitModalVisible,
gitRepoModalVisible,
editingRepo,
fetchGitRepos,
openGitSettings,
closeGitSettings,
openAddRepoModal,
openEditRepoModal,
closeGitRepoModal,
deleteRepo,
saveRepo,
}
}
export default useProjectGitRepos

View File

@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react'
import { message } from 'antd'
import { getLatestVectorizeTask, getVectorizeProgress, getVectorizeTask, vectorizeProject } from '@/api/knowledgeBase'
function useProjectKnowledge() {
const [kbModalVisible, setKbModalVisible] = useState(false)
const [kbProject, setKbProject] = useState(null)
const [progress, setProgress] = useState(null)
const [currentTask, setCurrentTask] = useState(null)
const [loadingProgress, setLoadingProgress] = useState(false)
const [vectorizing, setVectorizing] = useState(false)
const pollTimer = useRef(null)
const clearPoll = () => {
if (pollTimer.current) {
clearInterval(pollTimer.current)
pollTimer.current = null
}
}
useEffect(() => () => clearPoll(), [])
const fetchProgress = async (projectId, { silent = false } = {}) => {
if (!silent) setLoadingProgress(true)
try {
const res = await getVectorizeProgress(projectId)
setProgress(res.data || null)
return res.data
} catch (error) {
console.error('Fetch vectorize progress error:', error)
if (!silent) message.error('加载向量化进度失败')
return null
} finally {
if (!silent) setLoadingProgress(false)
}
}
const syncLatestTask = async (projectId) => {
try {
const res = await getLatestVectorizeTask(projectId)
const task = res.data || null
setCurrentTask(task)
setVectorizing(['pending', 'running'].includes(task?.status))
return task
} catch (error) {
console.error('Fetch vectorize task error:', error)
return null
}
}
const pollTask = (projectId, taskId) => {
clearPoll()
pollTimer.current = setInterval(async () => {
try {
const [taskRes] = await Promise.all([
getVectorizeTask(projectId, taskId),
fetchProgress(projectId, { silent: true }),
])
const task = taskRes.data || null
setCurrentTask(task)
if (!['pending', 'running'].includes(task?.status)) {
clearPoll()
setVectorizing(false)
await fetchProgress(projectId, { silent: true })
if (task?.status === 'success') {
message.success(
`向量化完成:处理 ${task.processed || 0}` +
`${task.skipped ? `,跳过 ${task.skipped}` : ''}` +
`${task.failed ? `,失败 ${task.failed}` : ''}`
)
} else if (task?.status === 'failed') {
message.error(task.error_message || '向量化任务失败')
}
}
} catch (error) {
console.error('Poll vectorize task error:', error)
clearPoll()
setVectorizing(false)
}
}, 2000)
}
const openKnowledgeModal = async (project) => {
setKbProject(project)
setKbModalVisible(true)
setProgress(null)
setCurrentTask(null)
await fetchProgress(project.id)
const task = await syncLatestTask(project.id)
if (['pending', 'running'].includes(task?.status)) {
pollTask(project.id, task.task_id)
}
}
const closeKnowledgeModal = () => {
clearPoll()
setKbModalVisible(false)
setKbProject(null)
setProgress(null)
setCurrentTask(null)
setVectorizing(false)
}
const runVectorize = async (projectId, { force = false } = {}) => {
if (!projectId) return
setVectorizing(true)
try {
const res = await vectorizeProject(projectId, force)
const task = res.data || null
setCurrentTask(task)
message.success('向量化任务已提交,正在后台执行')
await fetchProgress(projectId)
if (task?.task_id) {
pollTask(projectId, task.task_id)
}
} catch (error) {
console.error('Vectorize project error:', error)
const detail = error.response?.data?.detail || '向量化失败'
message.error(detail)
await fetchProgress(projectId, { silent: true })
} finally {
if (!pollTimer.current) {
setVectorizing(false)
}
}
}
return {
kbModalVisible,
kbProject,
progress,
currentTask,
loadingProgress,
vectorizing,
openKnowledgeModal,
closeKnowledgeModal,
refreshProgress: fetchProgress,
runVectorize,
}
}
export default useProjectKnowledge

View File

@ -0,0 +1,100 @@
import { useState } from 'react'
import { message } from 'antd'
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
import Toast from '@/components/Toast/Toast'
import { copyText } from '@/utils/browserIO'
function useProjectShare({ type }) {
const [shareModalVisible, setShareModalVisible] = useState(false)
const [shareInfo, setShareInfo] = useState(null)
const [hasPassword, setHasPassword] = useState(false)
const [password, setPassword] = useState('')
const openShareModal = async (project) => {
try {
const res = await getProjectShareInfo(project.id)
setShareInfo(res.data)
setHasPassword(res.data.has_password)
setPassword('')
setShareModalVisible(true)
} catch (error) {
console.error('Get share info error:', error)
message.error('获取分享信息失败')
}
}
const closeShareModal = () => {
setShareModalVisible(false)
}
const refreshShareInfo = async (projectId) => {
const res = await getProjectShareInfo(projectId)
setShareInfo(res.data)
return res.data
}
const copyShareLink = async () => {
if (!shareInfo) {
return
}
try {
await copyText(`${window.location.origin}${shareInfo.share_url}`)
Toast.success('复制成功', '分享链接已复制到剪贴板')
} catch (error) {
console.error('Failed to copy:', error)
Toast.error('复制失败', '无法访问剪贴板')
}
}
const togglePassword = async (projectId, checked) => {
if (!checked) {
try {
await updateShareSettings(projectId, { access_pass: null })
setHasPassword(false)
setPassword('')
message.success('已取消访问密码')
await refreshShareInfo(projectId)
} catch (error) {
console.error('Update settings error:', error)
message.error('操作失败')
}
return
}
setHasPassword(true)
}
const savePassword = async (projectId) => {
if (!password.trim()) {
message.warning('请输入访问密码')
return
}
try {
await updateShareSettings(projectId, { access_pass: password })
message.success('访问密码已设置')
await refreshShareInfo(projectId)
setHasPassword(true)
} catch (error) {
console.error('Save password error:', error)
message.error('设置密码失败')
}
}
return {
shareModalVisible,
shareInfo,
hasPassword,
password,
setPassword,
openShareModal,
closeShareModal,
copyShareLink,
togglePassword,
savePassword,
showPasswordControls: type === 'my',
}
}
export default useProjectShare

View File

@ -0,0 +1,107 @@
.admin-page {
display: flex;
flex-direction: column;
gap: 20px;
}
.admin-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.admin-stats.admin-stats-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.admin-card {
border-radius: 12px;
}
.admin-card .ant-card-head {
border-bottom-color: var(--border-color);
}
.admin-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.admin-toolbar-left,
.admin-toolbar-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.admin-grid-2 {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
}
.admin-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-hint {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--border-color);
background: var(--bg-color-secondary);
color: var(--text-color-secondary);
line-height: 1.6;
}
.admin-hint strong {
color: var(--text-color);
}
.admin-inline-code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
}
.admin-detail-cell {
max-width: 320px;
}
.admin-detail-cell > div {
font-size: 12px;
line-height: 1.6;
word-break: break-word;
}
.admin-modal .ant-modal-content {
max-height: 80vh;
display: flex;
flex-direction: column;
}
.admin-modal .ant-modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
@media (max-width: 1200px) {
.admin-stats.admin-stats-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 992px) {
.admin-stats,
.admin-stats.admin-stats-4,
.admin-grid-2 {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,139 @@
.model-config-card .ant-card-body {
display: flex;
flex-direction: column;
gap: 0;
}
.model-config-table :global(.ant-table-cell) {
vertical-align: top;
}
.model-config-table.list-table-container {
height: auto;
min-height: 626px;
}
.model-config-actions {
flex-wrap: nowrap;
white-space: nowrap;
}
.model-config-actions :global(.ant-btn) {
padding-inline: 0;
}
.model-config-code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
}
.model-config-modal-hint {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--border-color);
background: linear-gradient(180deg, rgba(22, 119, 255, 0.06) 0%, rgba(22, 119, 255, 0.02) 100%);
color: var(--text-color-secondary);
line-height: 1.6;
}
.model-config-modal-hint strong {
color: var(--text-color);
}
.model-config-test-result {
line-height: 1.8;
color: var(--text-color);
white-space: pre-wrap;
word-break: break-word;
}
.model-config-modal .ant-modal-content {
max-height: 80vh;
display: flex;
flex-direction: column;
}
.model-config-modal .ant-modal-body {
flex: 1;
min-height: 0;
}
/* 竖向双 tab 模型管理 */
.model-config-tabs > .ant-tabs-nav {
min-width: 160px;
}
.model-config-tabs > .ant-tabs-nav .ant-tabs-tab {
padding: 12px 16px;
border-radius: 8px;
margin: 0 0 4px 0 !important;
}
.model-config-tabs > .ant-tabs-nav .ant-tabs-tab-active {
background: rgba(22, 119, 255, 0.08);
}
.model-config-tabs > .ant-tabs-nav .ant-tabs-tab-btn {
display: flex;
align-items: center;
gap: 8px;
}
.model-config-tabs > .ant-tabs-content-holder {
padding-left: 20px;
}
.model-config-tab-panel {
min-height: 600px;
}
.model-config-tab-desc {
margin: 0 0 16px 0;
color: var(--text-color-secondary);
font-size: 13px;
line-height: 1.6;
}
/* Max Tokens 选择:独立的圆角胶囊按钮组 */
.max-tokens-group {
display: flex;
gap: 12px;
}
.max-tokens-group .ant-radio-button-wrapper {
height: 36px;
min-width: 72px;
padding: 0 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px !important;
border: 1px solid transparent;
background: var(--bg-color-secondary);
color: var(--text-color);
font-weight: 500;
transition: all 0.15s ease;
}
/* 去掉 antd 默认的相邻按钮分隔线 */
.max-tokens-group .ant-radio-button-wrapper::before {
display: none !important;
}
.max-tokens-group .ant-radio-button-wrapper:hover {
color: var(--text-color);
background: color-mix(in srgb, var(--border-color) 35%, var(--bg-color-secondary));
}
.max-tokens-group .ant-radio-button-wrapper-checked {
background: #d9d9d9 !important;
border-color: #d9d9d9 !important;
color: rgba(0, 0, 0, 0.88) !important;
box-shadow: none !important;
}
.max-tokens-group .ant-radio-button-wrapper-checked:hover {
background: #cfcfcf !important;
border-color: #cfcfcf !important;
}

View File

@ -0,0 +1,831 @@
import { useEffect, useState } from 'react'
import {
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Radio,
Select,
Slider,
Space,
Switch,
Tabs,
Tag,
} from 'antd'
import {
CloudServerOutlined,
CommentOutlined,
DeploymentUnitOutlined,
ExperimentOutlined,
PlusOutlined,
ReloadOutlined,
DeleteOutlined,
EditOutlined,
} from '@ant-design/icons'
import {
createLLMModelConfig,
deleteLLMModelConfig,
getLLMModelConfigDetail,
getLLMModelConfigs,
getLLMProviderCatalog,
testLLMModelConfig,
updateLLMModelConfig,
updateLLMModelConfigStatus,
} from '@/api/llmModelConfigs'
import ListTable from '@/components/ListTable/ListTable'
import PageHeader from '@/components/PageHeader/PageHeader'
import Toast from '@/components/Toast/Toast'
import './ModelConfigs.css'
import '@/pages/System/AdminPages.css'
const { Search, TextArea } = Input
const MAX_TOKENS_OPTIONS = [
{ label: '4K', value: 4096 },
{ label: '8K', value: 8192 },
{ label: '16K', value: 16384 },
{ label: '32K', value: 32768 },
]
const DEFAULT_MAX_TOKENS = 8192
const MODEL_TYPE_META = {
chat: {
label: '对话模型',
icon: <CommentOutlined />,
description: '用于知识库对话、问答生成的大语言模型。',
addText: '新增对话模型',
},
embedding: {
label: '向量模型',
icon: <DeploymentUnitOutlined />,
description: '用于文档向量化ZVec与语义检索的 Embedding 模型。',
addText: '新增向量模型',
},
}
function buildModelCode(provider, llmModelName, modelType) {
const providerPart = (provider || 'custom').trim().toLowerCase()
const modelPart = (llmModelName || '').trim().toLowerCase()
const sanitized = []
let previousSeparator = false
for (const char of modelPart) {
if (/[a-z0-9]/.test(char)) {
sanitized.push(char)
previousSeparator = false
} else if (!previousSeparator) {
sanitized.push('_')
previousSeparator = true
}
}
const suffix = sanitized.join('').replace(/^_+|_+$/g, '') || 'model'
const base = `llm_${providerPart}_${suffix}`
return modelType === 'embedding' ? `emb_${base}` : base
}
function buildModelName(providerMeta, provider, llmModelName) {
const label = providerMeta?.label || provider || '自定义模型'
const modelPart = (llmModelName || '').trim()
if (!modelPart) {
return label
}
return `${label} ${modelPart}`
}
function formatDateTime(value) {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN')
}
function ModelConfigs() {
const [form] = Form.useForm()
const [activeType, setActiveType] = useState('chat')
const [editingType, setEditingType] = useState('chat')
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [testing, setTesting] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [providerFilter, setProviderFilter] = useState(undefined)
const [statusFilter, setStatusFilter] = useState(undefined)
const [configs, setConfigs] = useState([])
const [providerCatalog, setProviderCatalog] = useState([])
const [modalVisible, setModalVisible] = useState(false)
const [editingConfigId, setEditingConfigId] = useState(null)
const [autoFillFlags, setAutoFillFlags] = useState({
endpointUrl: true,
modelName: true,
modelCode: true,
})
const providerValue = Form.useWatch('provider', form)
const llmModelNameValue = Form.useWatch('llm_model_name', form)
const isEmbedding = editingType === 'embedding'
useEffect(() => {
loadProviderCatalog()
}, [])
useEffect(() => {
loadConfigs()
}, [activeType, page, pageSize, keyword, providerFilter, statusFilter])
useEffect(() => {
if (!modalVisible || !providerValue) {
return
}
const providerMeta = providerCatalog.find((item) => item.value === providerValue)
if (!providerMeta) {
return
}
const currentValues = form.getFieldsValue()
const nextValues = {}
if (autoFillFlags.endpointUrl) {
const nextEndpointUrl = providerMeta.default_endpoint_url || ''
if (nextEndpointUrl !== currentValues.endpoint_url) {
nextValues.endpoint_url = nextEndpointUrl
}
}
if (llmModelNameValue) {
if (autoFillFlags.modelName) {
const nextModelName = buildModelName(providerMeta, providerValue, llmModelNameValue)
if (nextModelName !== currentValues.model_name) {
nextValues.model_name = nextModelName
}
}
if (autoFillFlags.modelCode) {
const nextModelCode = buildModelCode(providerValue, llmModelNameValue, editingType)
if (nextModelCode !== currentValues.model_code) {
nextValues.model_code = nextModelCode
}
}
}
if (Object.keys(nextValues).length > 0) {
form.setFieldsValue(nextValues)
}
}, [modalVisible, providerValue, llmModelNameValue, providerCatalog, autoFillFlags, editingType, form])
const loadProviderCatalog = async () => {
try {
const res = await getLLMProviderCatalog()
setProviderCatalog(res.data || [])
} catch (error) {
console.error('Load LLM provider catalog error:', error)
Toast.error('加载提供方列表失败')
}
}
const loadConfigs = async () => {
try {
setLoading(true)
const params = {
page,
page_size: pageSize,
model_type: activeType,
}
if (keyword) params.keyword = keyword
if (providerFilter) params.provider = providerFilter
if (statusFilter !== undefined) params.is_active = statusFilter
const res = await getLLMModelConfigs(params)
setConfigs(res.data || [])
setTotal(res.total || 0)
} catch (error) {
console.error('Load llm model configs error:', error)
Toast.error('加载模型配置失败')
} finally {
setLoading(false)
}
}
const getProviderMeta = (provider) => providerCatalog.find((item) => item.value === provider)
const handleTabChange = (key) => {
setActiveType(key)
setPage(1)
setKeyword('')
setProviderFilter(undefined)
setStatusFilter(undefined)
}
const openCreateModal = () => {
const defaultProvider = providerCatalog[0]?.value || 'openai'
const defaultEndpointUrl = getProviderMeta(defaultProvider)?.default_endpoint_url || ''
setEditingConfigId(null)
setEditingType(activeType)
setAutoFillFlags({
endpointUrl: true,
modelName: true,
modelCode: true,
})
form.setFieldsValue({
model_type: activeType,
provider: defaultProvider,
endpoint_url: defaultEndpointUrl,
llm_timeout: activeType === 'embedding' ? 60 : 120,
llm_temperature: 0.7,
llm_top_p: 0.9,
llm_max_tokens: DEFAULT_MAX_TOKENS,
embedding_dimension: undefined,
is_active: true,
description: '',
llm_system_prompt: '',
model_name: '',
model_code: '',
llm_model_name: '',
api_key: '',
})
setModalVisible(true)
}
const openEditModal = async (record) => {
try {
const res = await getLLMModelConfigDetail(record.config_id)
const detail = res.data
const detailType = detail.model_type || 'chat'
const providerMeta = getProviderMeta(detail.provider)
setEditingConfigId(record.config_id)
setEditingType(detailType)
setAutoFillFlags({
endpointUrl: !detail.endpoint_url || detail.endpoint_url === (providerMeta?.default_endpoint_url || ''),
modelName: !detail.model_name || detail.model_name === buildModelName(providerMeta, detail.provider, detail.llm_model_name),
modelCode: !detail.model_code || detail.model_code === buildModelCode(detail.provider, detail.llm_model_name, detailType),
})
form.setFieldsValue({
...detail,
api_key: detail.api_key || '',
})
setModalVisible(true)
} catch (error) {
console.error('Load llm model config detail error:', error)
Toast.error('加载模型配置详情失败')
}
}
const closeModal = () => {
setModalVisible(false)
setEditingConfigId(null)
form.resetFields()
}
const handleSubmit = async (values) => {
try {
setSubmitting(true)
const payload = { ...values, model_type: editingType }
if (editingConfigId) {
await updateLLMModelConfig(editingConfigId, payload)
Toast.success('模型配置更新成功')
} else {
await createLLMModelConfig(payload)
Toast.success('模型配置创建成功')
}
closeModal()
loadConfigs()
} catch (error) {
Toast.error(error.response?.data?.detail || '保存模型配置失败')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (record) => {
try {
await deleteLLMModelConfig(record.config_id)
Toast.success('模型配置删除成功')
loadConfigs()
} catch (error) {
Toast.error(error.response?.data?.detail || '删除模型配置失败')
}
}
const handleStatusChange = async (record, checked) => {
try {
await updateLLMModelConfigStatus(record.config_id, checked)
Toast.success(checked ? '模型已启用' : '模型已停用')
loadConfigs()
} catch (error) {
Toast.error(error.response?.data?.detail || '更新状态失败')
}
}
const showTestResult = (result) => {
const providerLabel = getProviderMeta(result.provider)?.label || result.provider
const lines = [
`提供方:${providerLabel}`,
`模型:${result.llm_model_name}`,
`延迟:${result.latency_ms} ms`,
]
if (result.dimension) {
lines.push(`向量维度:${result.dimension}`)
}
if (result.preview) {
lines.push(`返回预览:${result.preview}`)
}
const description = (
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{lines.map((line, index) => (
<div key={index}>{line}</div>
))}
</div>
)
Toast.success('模型测试成功', description, 5)
}
const handleFormTest = async () => {
try {
const values = await form.validateFields()
setTesting(true)
const res = await testLLMModelConfig({ ...values, model_type: editingType })
showTestResult(res.data)
} catch (error) {
//
// Toast
} finally {
setTesting(false)
}
}
const getColumns = (type) => {
const baseColumns = [
{
title: '模型名称',
dataIndex: 'model_name',
key: 'model_name',
width: 240,
render: (_, record) => (
<Space direction="vertical" size={2}>
<span>{record.model_name}</span>
<span className="model-config-code">{record.model_code}</span>
</Space>
),
},
{
title: '提供方',
dataIndex: 'provider',
key: 'provider',
width: 140,
render: (provider) => (
<Tag color="blue">{getProviderMeta(provider)?.label || provider || '-'}</Tag>
),
},
{
title: '模型标识',
dataIndex: 'llm_model_name',
key: 'llm_model_name',
width: 180,
},
]
if (type === 'embedding') {
baseColumns.push({
title: '向量维度',
dataIndex: 'embedding_dimension',
key: 'embedding_dimension',
width: 110,
render: (value) => (value ? <Tag color="purple">{value}</Tag> : <Tag>自动</Tag>),
})
}
baseColumns.push(
{
title: 'Base URL',
dataIndex: 'endpoint_url',
key: 'endpoint_url',
ellipsis: true,
render: (value) => value || '-',
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 100,
render: (value, record) => (
<Switch
checked={value}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => handleStatusChange(record, checked)}
/>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 180,
render: (value) => formatDateTime(value),
},
{
title: '操作',
key: 'action',
fixed: 'right',
width: 170,
render: (_, record) => (
<Space size="small" className="model-config-actions">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
>
编辑
</Button>
<Popconfirm
title="确认删除该模型配置?"
description="删除后将无法恢复。"
okText="删除"
cancelText="取消"
onConfirm={() => handleDelete(record)}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
删除
</Button>
</Popconfirm>
</Space>
),
},
)
return baseColumns
}
const renderListPanel = (type) => (
<>
<div className="admin-toolbar">
<div className="admin-toolbar-left">
<Search
allowClear
placeholder="搜索模型名称、编码或模型标识"
value={keyword}
style={{ width: 320 }}
onChange={(event) => {
setPage(1)
setKeyword(event.target.value)
}}
onSearch={(value) => {
setPage(1)
setKeyword(value)
}}
/>
<Select
allowClear
placeholder="筛选提供方"
style={{ width: 180 }}
value={providerFilter}
options={providerCatalog.map((item) => ({
label: item.label,
value: item.value,
}))}
onChange={(value) => {
setPage(1)
setProviderFilter(value)
}}
/>
<Select
allowClear
placeholder="筛选状态"
style={{ width: 140 }}
value={statusFilter}
options={[
{ label: '启用', value: true },
{ label: '停用', value: false },
]}
onChange={(value) => {
setPage(1)
setStatusFilter(value)
}}
/>
</div>
<div className="admin-toolbar-right">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openCreateModal}
>
{MODEL_TYPE_META[type].addText}
</Button>
<Button icon={<ReloadOutlined />} onClick={loadConfigs}>
刷新
</Button>
</div>
</div>
<ListTable
rowKey="config_id"
className="model-config-table"
loading={loading}
columns={getColumns(type)}
dataSource={configs}
scroll={{ x: 1280 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
showTotal: (value) => `${value}`,
}}
/>
</>
)
const tabItems = Object.entries(MODEL_TYPE_META).map(([type, meta]) => ({
key: type,
label: (
<span>
{meta.icon}
{meta.label}
</span>
),
children: (
<div className="model-config-tab-panel">
<p className="model-config-tab-desc">{meta.description}</p>
{activeType === type ? renderListPanel(type) : null}
</div>
),
}))
return (
<div className="admin-page">
<PageHeader
title="模型配置"
description="分类管理对话模型与向量模型的提供方、接口地址、参数模板与连通性测试。"
icon={<CloudServerOutlined />}
/>
<Card className="admin-card model-config-card">
<Tabs
items={tabItems}
activeKey={activeType}
onChange={handleTabChange}
tabPosition="left"
className="model-config-tabs"
/>
</Card>
<Modal
title={`${editingConfigId ? '编辑' : '新增'}${MODEL_TYPE_META[editingType].label}`}
open={modalVisible}
width={860}
className="model-config-modal"
style={{ maxWidth: 'calc(100vw - 32px)' }}
destroyOnClose
onCancel={closeModal}
onOk={() => form.submit()}
confirmLoading={submitting}
okText={editingConfigId ? '保存修改' : '创建'}
cancelText="取消"
styles={{
body: {
maxHeight: 'calc(80vh - 180px)',
overflowY: 'auto',
paddingRight: 8,
},
}}
footer={(_, { OkBtn, CancelBtn }) => (
<>
<CancelBtn />
<Button
icon={<ExperimentOutlined />}
loading={testing}
onClick={handleFormTest}
>
测试模型
</Button>
<OkBtn />
</>
)}
>
<div className="model-config-modal-hint">
<strong>自动生成规则</strong> 选择提供方后会自动带出默认 `base_url`填写模型标识后会自动生成模型名称模型编码
如果你手动改过这些字段后续就不会再被自动覆盖
{isEmbedding && (
<>
<br />
<strong>向量维度</strong> 留空时按模型实际返回维度自动建立向量库如填写需与模型输出维度一致更换不同维度的模型会重建该项目的向量库
</>
)}
</div>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
llm_timeout: 120,
llm_temperature: 0.7,
llm_top_p: 0.9,
llm_max_tokens: DEFAULT_MAX_TOKENS,
is_active: true,
}}
>
<Form.Item name="model_type" hidden>
<Input />
</Form.Item>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Form.Item
label="提供方"
name="provider"
rules={[{ required: true, message: '请选择模型提供方' }]}
>
<Select
showSearch
placeholder="请选择模型提供方"
optionFilterProp="label"
options={providerCatalog.map((item) => ({
label: item.label,
value: item.value,
}))}
/>
</Form.Item>
<Form.Item
label="Base URL"
name="endpoint_url"
rules={[{ required: true, message: '请输入 base_url' }]}
>
<Input
placeholder="选择提供方后自动带出,也可以手动覆盖"
onChange={() => {
setAutoFillFlags((current) => ({ ...current, endpointUrl: false }))
}}
/>
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size={16} align="start">
<Form.Item
label="模型标识"
name="llm_model_name"
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型标识或部署名' }]}
>
<Input
placeholder={
isEmbedding
? '如 text-embedding-3-small / text-embedding-v4 / bge-large-zh'
: '如 gpt-4.1-mini / qwen3.6-plus / claude-3-5-sonnet-latest'
}
/>
</Form.Item>
<Form.Item
label="请求超时(秒)"
name="llm_timeout"
style={{ width: 180 }}
rules={[{ required: true, message: '请输入超时时间' }]}
>
<InputNumber min={5} max={600} style={{ width: '100%' }} />
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size={16} align="start">
<Form.Item
label="模型名称"
name="model_name"
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型名称' }]}
>
<Input
placeholder="会根据提供方和模型标识自动生成"
onChange={() => {
setAutoFillFlags((current) => ({ ...current, modelName: false }))
}}
/>
</Form.Item>
<Form.Item
label="模型编码"
name="model_code"
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型编码' }]}
>
<Input
placeholder="会自动生成,支持手动调整"
onChange={() => {
setAutoFillFlags((current) => ({ ...current, modelCode: false }))
}}
/>
</Form.Item>
</Space>
<Form.Item label="API Key" name="api_key">
<Input.Password
placeholder="支持留空后稍后补齐;测试非 Ollama 模型时建议填写"
autoComplete="new-password"
/>
</Form.Item>
{isEmbedding ? (
<Form.Item
label="向量维度"
name="embedding_dimension"
extra="留空则按模型实际返回维度自动建立;填写需与模型输出一致。"
>
<InputNumber
min={1}
max={8192}
step={1}
style={{ width: '100%' }}
placeholder="如 1536 / 1024 / 768可留空自动识别"
/>
</Form.Item>
) : (
<>
<Space style={{ width: '100%' }} size={24} align="start">
<Form.Item
label="Temperature"
name="llm_temperature"
style={{ flex: 1 }}
tooltip="数值越高回答越发散,越低越稳定保守"
rules={[{ required: true, message: '请设置 temperature' }]}
>
<Slider
min={0}
max={2}
step={0.05}
marks={{ 0: '0', 0.7: '0.7', 1: '1', 2: '2' }}
tooltip={{ open: undefined }}
/>
</Form.Item>
<Form.Item
label="Top P"
name="llm_top_p"
style={{ flex: 1 }}
tooltip="核采样阈值,控制候选词的累计概率范围"
rules={[{ required: true, message: '请设置 top_p' }]}
>
<Slider
min={0}
max={1}
step={0.05}
marks={{ 0: '0', 0.5: '0.5', 0.9: '0.9', 1: '1' }}
tooltip={{ open: undefined }}
/>
</Form.Item>
</Space>
<Form.Item
label="Max Tokens"
name="llm_max_tokens"
tooltip="单次回复的最大输出长度"
rules={[{ required: true, message: '请选择 max_tokens' }]}
>
<Radio.Group
className="max-tokens-group"
optionType="button"
buttonStyle="solid"
options={MAX_TOKENS_OPTIONS}
/>
</Form.Item>
<Form.Item label="系统提示词" name="llm_system_prompt">
<TextArea
rows={4}
placeholder="可选。对话与测试时会作为 system prompt 一并发送。"
/>
</Form.Item>
</>
)}
<Form.Item label="描述" name="description">
<TextArea rows={2} placeholder="可填写用途、场景、适用业务等说明" />
</Form.Item>
<Form.Item label="启用状态" name="is_active" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default ModelConfigs

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { Tree, Button, Card, Row, Col, Tag, Space, message } from 'antd'
import { SafetyOutlined, SaveOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
import { Tree, Button, Card, Tag, Space } from 'antd'
import { SafetyOutlined, SaveOutlined, CheckCircleOutlined, CloseCircleOutlined, AppstoreOutlined } from '@ant-design/icons'
import {
getAllRoles,
getMenuTree,
@ -8,7 +8,10 @@ import {
updateRolePermissions,
} from '@/api/rolePermissions'
import ListTable from '@/components/ListTable/ListTable'
import PageHeader from '@/components/PageHeader/PageHeader'
import StatCard from '@/components/StatCard/StatCard'
import Toast from '@/components/Toast/Toast'
import '@/pages/System/AdminPages.css'
function Permissions() {
const [loading, setLoading] = useState(false)
@ -151,85 +154,84 @@ function Permissions() {
},
]
const editableRoles = roles.filter((role) => role.is_system !== 1).length
const flattenTreeCount = (nodes) => nodes.reduce((count, node) => {
const childrenCount = node.children ? flattenTreeCount(node.children) : 0
return count + 1 + childrenCount
}, 0)
const menuCount = flattenTreeCount(menuTree)
return (
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600, color: 'var(--text-color)' }}>
<SafetyOutlined style={{ marginRight: '8px' }} />
角色权限管理
</h1>
<div className="admin-page">
<PageHeader
title="权限管理"
description="为角色配置菜单和功能权限,系统角色默认只读。"
icon={<SafetyOutlined />}
/>
<Row gutter={16}>
{/* 左侧:功能权限树 */}
<Col span={12}>
<Card
title="功能权限树"
extra={
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
disabled={!selectedRole}
>
保存权限
</Button>
}
>
{selectedRole ? (
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-color-secondary)', borderRadius: 4 }}>
<Space>
<span style={{ fontWeight: 500 }}>当前角色</span>
<Tag color="blue">{selectedRole.role_name}</Tag>
{selectedRole.is_system === 1 && <Tag color="orange">系统角色只读</Tag>}
</Space>
</div>
) : (
<div
style={{
marginBottom: 16,
padding: '12px',
background: 'var(--bg-color-secondary)',
borderRadius: 4,
color: 'var(--text-color-secondary)',
border: '1px solid var(--border-color)',
}}
>
请从右侧选择一个角色来查看和编辑权限
</div>
)}
<Tree
checkable
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
checkedKeys={checkedKeys}
onCheck={handleCheck}
treeData={menuTree}
disabled={!selectedRole || selectedRole.is_system === 1}
style={{ minHeight: 400 }}
/>
</Card>
</Col>
{/* 右侧:角色列表 */}
<Col span={12}>
<Card title="角色列表">
<ListTable
columns={columns}
dataSource={roles}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ y: 600 }}
onRowClick={handleRoleClick}
selectedRow={selectedRole}
onSelectionChange={() => {}}
/>
</Card>
</Col>
</Row>
<div className="admin-stats">
<StatCard title="角色总数" value={roles.length} icon={<SafetyOutlined />} color="blue" />
<StatCard title="可编辑角色" value={editableRoles} icon={<CheckCircleOutlined />} color="green" />
<StatCard title="权限节点数" value={menuCount} icon={<AppstoreOutlined />} color="orange" />
</div>
<div className="admin-grid-2">
<Card
title="功能权限树"
className="admin-card"
extra={(
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
disabled={!selectedRole}
>
保存权限
</Button>
)}
>
{selectedRole ? (
<div className="admin-hint">
<Space>
<span><strong>当前角色</strong></span>
<Tag color="blue">{selectedRole.role_name}</Tag>
{selectedRole.is_system === 1 && <Tag color="orange">系统角色只读</Tag>}
</Space>
</div>
) : (
<div className="admin-hint">
请先从右侧角色列表中选择一个角色再查看或编辑权限
</div>
)}
<Tree
checkable
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
checkedKeys={checkedKeys}
onCheck={handleCheck}
treeData={menuTree}
disabled={!selectedRole || selectedRole.is_system === 1}
style={{ minHeight: 480 }}
/>
</Card>
<Card title="角色列表" className="admin-card">
<ListTable
columns={columns}
dataSource={roles}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ y: 600 }}
onRowClick={handleRoleClick}
selectedRow={selectedRole}
onSelectionChange={() => {}}
/>
</Card>
</div>
</div>
)
}

View File

@ -9,8 +9,6 @@ import {
Space,
Popconfirm,
Card,
Row,
Col,
} from 'antd'
import {
PlusOutlined,
@ -19,7 +17,8 @@ import {
TeamOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
SearchOutlined,
SafetyOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import {
getRoleList,
@ -29,7 +28,12 @@ import {
getRoleUsers,
} from '@/api/roles'
import ListTable from '@/components/ListTable/ListTable'
import PageHeader from '@/components/PageHeader/PageHeader'
import StatCard from '@/components/StatCard/StatCard'
import Toast from '@/components/Toast/Toast'
import '@/pages/System/AdminPages.css'
const { Search } = Input
function Roles() {
const [loading, setLoading] = useState(false)
@ -312,70 +316,94 @@ function Roles() {
},
]
const enabledRoles = roles.filter((role) => role.status === 1).length
const systemRoles = roles.filter((role) => role.is_system === 1).length
return (
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600, color: 'var(--text-color)' }}>角色管理</h1>
<div className="admin-page">
<PageHeader
title="角色管理"
description="维护系统角色、角色编码与用户归属关系。"
icon={<TeamOutlined />}
extra={(
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
新增角色
</Button>
)}
/>
{/* 搜索和操作栏 */}
<Card style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={8}>
<Input
placeholder="搜索角色名称、编码"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
allowClear
/>
</Col>
<Col span={6}>
<Select
placeholder="筛选状态"
style={{ width: '100%' }}
value={statusFilter}
onChange={setStatusFilter}
allowClear
>
<Select.Option value={1}>启用</Select.Option>
<Select.Option value={0}>禁用</Select.Option>
</Select>
</Col>
<Col span={10} style={{ textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
新增角色
</Button>
</Col>
</Row>
</Card>
<div className="admin-stats">
<StatCard title="角色总数" value={total} icon={<SafetyOutlined />} color="blue" />
<StatCard title="当前页启用" value={enabledRoles} icon={<CheckCircleOutlined />} color="green" />
<StatCard title="系统角色数" value={systemRoles} icon={<TeamOutlined />} color="orange" />
</div>
{/* 角色列表 */}
<Card>
<ListTable
columns={columns}
dataSource={roles}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPage(page)
setPageSize(pageSize)
},
}}
onSelectionChange={() => {}}
/>
</Card>
<Card className="admin-card">
<div className="admin-toolbar">
<div className="admin-toolbar-left">
<Search
allowClear
placeholder="搜索角色名称、编码"
value={keyword}
style={{ width: 320 }}
onChange={(event) => {
setPage(1)
setKeyword(event.target.value)
}}
onSearch={(value) => {
setPage(1)
setKeyword(value)
}}
/>
<Select
allowClear
placeholder="筛选状态"
style={{ width: 140 }}
value={statusFilter}
onChange={(value) => {
setPage(1)
setStatusFilter(value ?? null)
}}
options={[
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
]}
/>
</div>
<div className="admin-toolbar-right">
<Button icon={<ReloadOutlined />} onClick={loadRoles}>
刷新
</Button>
</div>
</div>
<ListTable
columns={columns}
dataSource={roles}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showTotal: (value) => `${value}`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
onSelectionChange={() => {}}
/>
</Card>
{/* 创建角色对话框 */}
<Modal
title="新增角色"
open={createModalVisible}
className="admin-modal"
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
onCancel={() => {
setCreateModalVisible(false)
createForm.resetFields()
@ -416,6 +444,8 @@ function Roles() {
<Modal
title="编辑角色"
open={editModalVisible}
className="admin-modal"
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
onCancel={() => {
setEditModalVisible(false)
editForm.resetFields()
@ -456,6 +486,7 @@ function Roles() {
<Modal
title={`角色用户列表 - ${currentRole?.role_name || ''}`}
open={usersModalVisible}
className="admin-modal"
onCancel={() => {
setUsersModalVisible(false)
setRoleUsers([])
@ -463,6 +494,7 @@ function Roles() {
}}
footer={null}
width={1000}
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
>
<ListTable
columns={userColumns}
@ -483,8 +515,7 @@ function Roles() {
onSelectionChange={() => {}}
/>
</Modal>
</div>
</div>
)
}

View File

@ -10,8 +10,6 @@ import {
Popconfirm,
Switch,
Card,
Row,
Col,
} from 'antd'
import {
PlusOutlined,
@ -19,9 +17,10 @@ import {
DeleteOutlined,
KeyOutlined,
TeamOutlined,
SearchOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
UserOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import {
getUserList,
@ -34,7 +33,12 @@ import {
} from '@/api/users'
import { getAllRoles } from '@/api/rolePermissions'
import ListTable from '@/components/ListTable/ListTable'
import PageHeader from '@/components/PageHeader/PageHeader'
import StatCard from '@/components/StatCard/StatCard'
import Toast from '@/components/Toast/Toast'
import '@/pages/System/AdminPages.css'
const { Search } = Input
function Users() {
const [loading, setLoading] = useState(false)
@ -57,9 +61,12 @@ function Users() {
useEffect(() => {
loadUsers()
loadRoles()
}, [page, pageSize, keyword, statusFilter])
useEffect(() => {
loadRoles()
}, [])
const loadUsers = async () => {
try {
setLoading(true)
@ -297,70 +304,93 @@ function Users() {
},
]
const activeUsers = users.filter((user) => user.status === 1).length
return (
<div style={{ padding: '24px' }}>
<h1 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: 600, color: 'var(--text-color)' }}>用户管理</h1>
<div className="admin-page">
<PageHeader
title="用户管理"
description="统一管理系统用户、账号状态、角色分配与密码重置。"
icon={<UserOutlined />}
extra={(
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
新增用户
</Button>
)}
/>
{/* 搜索和操作栏 */}
<Card style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={8}>
<Input
placeholder="搜索用户名、昵称、邮箱"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
allowClear
/>
</Col>
<Col span={6}>
<Select
placeholder="筛选状态"
style={{ width: '100%' }}
value={statusFilter}
onChange={setStatusFilter}
allowClear
>
<Select.Option value={1}>启用</Select.Option>
<Select.Option value={0}>停用</Select.Option>
</Select>
</Col>
<Col span={10} style={{ textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
新增用户
</Button>
</Col>
</Row>
</Card>
<div className="admin-stats">
<StatCard title="用户总数" value={total} icon={<UserOutlined />} color="blue" />
<StatCard title="当前页启用" value={activeUsers} icon={<CheckCircleOutlined />} color="green" />
<StatCard title="可分配角色数" value={roles.length} icon={<TeamOutlined />} color="orange" />
</div>
{/* 用户列表 */}
<Card>
<ListTable
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
scroll={{ x: 1300 }}
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPage(page)
setPageSize(pageSize)
},
}}
onSelectionChange={() => {}}
/>
</Card>
<Card className="admin-card">
<div className="admin-toolbar">
<div className="admin-toolbar-left">
<Search
allowClear
placeholder="搜索用户名、昵称、邮箱"
value={keyword}
style={{ width: 320 }}
onChange={(event) => {
setPage(1)
setKeyword(event.target.value)
}}
onSearch={(value) => {
setPage(1)
setKeyword(value)
}}
/>
<Select
allowClear
placeholder="筛选状态"
style={{ width: 140 }}
value={statusFilter}
onChange={(value) => {
setPage(1)
setStatusFilter(value ?? null)
}}
options={[
{ label: '启用', value: 1 },
{ label: '停用', value: 0 },
]}
/>
</div>
<div className="admin-toolbar-right">
<Button icon={<ReloadOutlined />} onClick={loadUsers}>
刷新
</Button>
</div>
</div>
<ListTable
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
scroll={{ x: 1300 }}
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showTotal: (value) => `${value}`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
onSelectionChange={() => {}}
/>
</Card>
{/* 创建用户对话框 */}
<Modal
title="新增用户"
open={createModalVisible}
className="admin-modal"
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
onCancel={() => {
setCreateModalVisible(false)
createForm.resetFields()
@ -412,6 +442,8 @@ function Users() {
<Modal
title="编辑用户"
open={editModalVisible}
className="admin-modal"
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
onCancel={() => {
setEditModalVisible(false)
editForm.resetFields()
@ -439,6 +471,8 @@ function Users() {
<Modal
title="分配角色"
open={rolesModalVisible}
className="admin-modal"
styles={{ body: { maxHeight: 'calc(80vh - 180px)', overflowY: 'auto' } }}
onCancel={() => {
setRolesModalVisible(false)
rolesForm.resetFields()
@ -457,8 +491,7 @@ function Users() {
</Form.Item>
</Form>
</Modal>
</div>
</div>
)
}

View File

@ -1,9 +1,3 @@
.system-logs-container {
padding: 24px;
}
.system-logs-container h2 {
margin-bottom: 24px;
font-size: 24px;
font-weight: 600;
min-width: 0;
}

View File

@ -1,10 +1,13 @@
import { useState, useEffect } from 'react'
import { Table, Card, Select, DatePicker, Space, Button, Tag, Statistic, Row, Col, Input, message } from 'antd'
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { Table, Card, Select, DatePicker, Space, Button, Tag, Input, message } from 'antd'
import { ReloadOutlined, SearchOutlined, FileSearchOutlined, CheckCircleOutlined, FolderOutlined, FileTextOutlined } from '@ant-design/icons'
import { getOperationLogs, getLogStats } from '@/api/logs'
import { getUserList } from '@/api/users'
import PageHeader from '@/components/PageHeader/PageHeader'
import StatCard from '@/components/StatCard/StatCard'
import dayjs from 'dayjs'
import './SystemLogs.css'
import '@/pages/System/AdminPages.css'
const { RangePicker } = DatePicker
const { Option } = Select
@ -88,7 +91,7 @@ function SystemLogs() {
fetchUsers()
}, [])
const fetchLogs = async (page = 1, pageSize = 20) => {
const fetchLogs = async (page = 1, pageSize = 20, nextFilters = filters) => {
setLoading(true)
try {
const params = {
@ -97,23 +100,23 @@ function SystemLogs() {
}
//
if (filters.operation_type) {
params.operation_type = filters.operation_type
if (nextFilters.operation_type) {
params.operation_type = nextFilters.operation_type
}
if (filters.resource_type) {
params.resource_type = filters.resource_type
if (nextFilters.resource_type) {
params.resource_type = nextFilters.resource_type
}
if (filters.user_id) {
params.user_id = filters.user_id
if (nextFilters.user_id) {
params.user_id = nextFilters.user_id
}
if (filters.project_id) {
params.project_id = filters.project_id
if (nextFilters.project_id) {
params.project_id = nextFilters.project_id
}
//
if (filters.dateRange && filters.dateRange.length === 2) {
params.start_date = filters.dateRange[0].format('YYYY-MM-DD')
params.end_date = filters.dateRange[1].format('YYYY-MM-DD')
if (nextFilters.dateRange && nextFilters.dateRange.length === 2) {
params.start_date = nextFilters.dateRange[0].format('YYYY-MM-DD')
params.end_date = nextFilters.dateRange[1].format('YYYY-MM-DD')
}
const res = await getOperationLogs(params)
@ -143,10 +146,8 @@ function SystemLogs() {
const fetchUsers = async () => {
try {
const res = await getUserList({ page: 1, page_size: 100, status: 1 })
console.log('Fetch users response:', res)
// : { code: 200, message: "success", data: [...], total, page, page_size }
const usersData = Array.isArray(res.data) ? res.data : []
console.log('Users data:', usersData)
setUsers(usersData)
} catch (error) {
console.error('Fetch users error:', error)
@ -165,20 +166,19 @@ function SystemLogs() {
delete cleanFilters.project_id
}
setFilters(cleanFilters)
fetchLogs(1, pagination.pageSize)
fetchLogs(1, pagination.pageSize, cleanFilters)
}
const handleReset = () => {
setFilters({
const resetFilters = {
operation_type: undefined,
resource_type: undefined,
user_id: undefined,
project_id: undefined,
dateRange: null,
})
setTimeout(() => {
fetchLogs(1, pagination.pageSize)
}, 0)
}
setFilters(resetFilters)
fetchLogs(1, pagination.pageSize, resetFilters)
}
const columns = [
@ -228,9 +228,9 @@ function SystemLogs() {
try {
const parsed = JSON.parse(detail)
return (
<div style={{ maxWidth: 300 }}>
<div className="admin-detail-cell">
{Object.entries(parsed).map(([key, value]) => (
<div key={key} style={{ fontSize: 12 }}>
<div key={key}>
<strong>{key}:</strong> {JSON.stringify(value)}
</div>
))}
@ -267,45 +267,38 @@ function SystemLogs() {
},
]
return (
<div className="system-logs-container">
<h2>系统日志</h2>
const projectOperationCount = (
(stats?.operation_stats?.create_project || 0) +
(stats?.operation_stats?.update_project || 0) +
(stats?.operation_stats?.delete_project || 0)
)
const fileOperationCount = (
(stats?.operation_stats?.create_file || 0) +
(stats?.operation_stats?.save_file || 0) +
(stats?.operation_stats?.delete_file || 0)
)
return (
<div className="admin-page system-logs-container">
<PageHeader
title="系统日志"
description="按用户、资源、时间维度筛选系统操作日志与行为统计。"
icon={<FileSearchOutlined />}
/>
{/* 统计信息 */}
{stats && (
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic title="总日志数" value={stats.total_count} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="今日操作" value={stats.today_count} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="项目操作"
value={stats.operation_stats?.create_project + stats.operation_stats?.update_project + stats.operation_stats?.delete_project || 0}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="文件操作"
value={stats.operation_stats?.create_file + stats.operation_stats?.save_file + stats.operation_stats?.delete_file || 0}
/>
</Card>
</Col>
</Row>
<div className="admin-stats admin-stats-4">
<StatCard title="总日志数" value={stats.total_count} icon={<FileSearchOutlined />} color="blue" />
<StatCard title="今日操作" value={stats.today_count} icon={<CheckCircleOutlined />} color="green" />
<StatCard title="项目操作" value={projectOperationCount} icon={<FolderOutlined />} color="orange" />
<StatCard title="文件操作" value={fileOperationCount} icon={<FileTextOutlined />} color="red" />
</div>
)}
{/* 筛选条件 */}
<Card style={{ marginBottom: 16 }}>
<Space wrap size="middle">
<Card className="admin-card">
<div className="admin-toolbar">
<div className="admin-toolbar-left">
<Select
placeholder="操作类型"
style={{ width: 160 }}
@ -366,11 +359,9 @@ function SystemLogs() {
<Button icon={<ReloadOutlined />} onClick={handleReset}>
重置
</Button>
</Space>
</Card>
</div>
</div>
{/* 日志列表 */}
<Card>
<Table
columns={columns}
dataSource={logs}

View File

@ -3,6 +3,7 @@
*/
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { clearAuthStorage } from '@/utils/authStorage'
const useUserStore = create(
persist(
@ -14,8 +15,7 @@ const useUserStore = create(
setToken: (token) => set({ token }),
logout: () => {
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
clearAuthStorage()
set({ user: null, token: null })
},

View File

@ -0,0 +1,34 @@
const ACCESS_TOKEN_KEY = 'access_token'
const USER_INFO_KEY = 'user_info'
export function getAccessToken() {
return localStorage.getItem(ACCESS_TOKEN_KEY)
}
export function clearAuthStorage() {
localStorage.removeItem(ACCESS_TOKEN_KEY)
localStorage.removeItem(USER_INFO_KEY)
}
export function buildAuthorizedUrl(url, extraParams = {}) {
const params = new URLSearchParams()
const token = getAccessToken()
if (token) {
params.set('token', token)
}
Object.entries(extraParams).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.set(key, value)
}
})
const query = params.toString()
if (!query) {
return url
}
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}${query}`
}

View File

@ -0,0 +1,56 @@
export async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return
}
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
textArea.style.top = '0'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
if (!successful) {
throw new Error('Copy command failed')
}
}
export function extractFilenameFromDisposition(contentDisposition, fallback) {
if (!contentDisposition) {
return fallback
}
const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch (error) {
console.warn('Decode filename* failed:', error)
}
}
const basicMatch = /filename="?([^";]+)"?/i.exec(contentDisposition)
if (basicMatch?.[1]) {
return basicMatch[1]
}
return fallback
}
export function triggerBlobDownload(blob, filename) {
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}

View File

@ -3,6 +3,7 @@
*/
import axios from 'axios'
import Toast from '@/components/Toast/Toast'
import { clearAuthStorage, getAccessToken } from '@/utils/authStorage'
let isHandlingUnauthorized = false
@ -24,8 +25,7 @@ const request = axios.create({
// 请求拦截器
request.interceptors.request.use(
(config) => {
// 从 localStorage 获取 token
const token = localStorage.getItem('access_token')
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
@ -54,8 +54,7 @@ request.interceptors.response.use(
isHandlingUnauthorized = true
Toast.muteErrors(2500)
Toast.error('认证失败', res.message || '未登录或登录已过期', 3, { force: true })
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
clearAuthStorage()
setTimeout(() => {
redirectToLoginWithReturnTo()
}, 600)
@ -83,8 +82,7 @@ request.interceptors.response.use(
isHandlingUnauthorized = true
Toast.muteErrors(2500)
Toast.error('认证失败', data?.detail || '未登录或登录已过期', 3, { force: true })
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
clearAuthStorage()
setTimeout(() => {
redirectToLoginWithReturnTo()
}, 600) // 延迟一小段时间,让提示先展示