Compare commits
4 Commits
e42a5525f6
...
31cf2bfc1c
| Author | SHA1 | Date |
|---|---|---|
|
|
31cf2bfc1c | |
|
|
e6c92551ea | |
|
|
0b0713fc4e | |
|
|
2d41a3ba2f |
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -31,3 +31,6 @@ logs/
|
|||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# AI
|
||||
.gemini-clipboard/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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=["知识库对话"])
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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="模型测试成功")
|
||||
|
|
@ -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"
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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="索引重建任务已启动")
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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}')>"
|
||||
|
|
@ -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}')>"
|
||||
)
|
||||
|
|
@ -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}')>"
|
||||
|
|
@ -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}')>"
|
||||
|
|
@ -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)} 维向量",
|
||||
}
|
||||
|
|
@ -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)} 维向量",
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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='知识库对话消息表';
|
||||
|
|
@ -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='文档向量表';
|
||||
|
|
@ -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='项目向量化任务表';
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
@ -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()
|
||||
|
|
@ -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. 前端将文档浏览页的页面级编排从页面入口中拆出,并统一认证存储边界
|
||||
|
||||
这两项改动都直接对应结构规范中的高优先级问题,属于低风险、可验证、后续可继续扩展的结构优化。
|
||||
|
|
@ -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. 执行基线
|
||||
|
||||
后续所有新增功能、重构与代码审计,均以本文档为默认判断依据:
|
||||
|
||||
- 先判断职责边界是否正确
|
||||
- 再判断依赖方向是否健康
|
||||
- 再判断是否需要拆分或合并
|
||||
- 最后才考虑目录美观、文件长短和风格一致性
|
||||
|
||||
当“看起来更模块化”和“真实可读、可改、可验证”发生冲突时,优先后者。
|
||||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目成员
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/* 收起状态下的图标放大 */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
buildTocItems,
|
||||
collectParentKeys,
|
||||
convertTreeToMenuItems,
|
||||
filterTreeByKeyword,
|
||||
findRootReadme,
|
||||
resolveRelativePath,
|
||||
} from '../Document/documentBrowserUtils'
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) // 延迟一小段时间,让提示先展示
|
||||
|
|
|
|||
Loading…
Reference in New Issue