main
mula.liu 2026-07-24 15:37:22 +08:00
parent e6c92551ea
commit 31cf2bfc1c
35 changed files with 6206 additions and 1031 deletions

View File

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

View File

@ -25,7 +25,7 @@
- 修改MD文件时重新向量化
- 删除MD文件时清理向量数据
- PDF文件不触发向量化
**Status**: In Progress (核心服务完成,待数据库创建)
**Status**: Complete
## Stage 3: 知识库对话功能
**Goal**: 基于ZVec向量化的文档实现大模型知识库对话

View File

@ -1,26 +1,126 @@
"""
知识库对话相关 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
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 typing import List
import json
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.models.project import Project
from app.models.document_vector import DocumentVector
from app.schemas.response import success_response
from app.services.project_service import get_project_or_404, require_project_read_access
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):
"""创建对话会话请求"""
@ -35,6 +135,11 @@ class ChatMessageRequest(BaseModel):
message: str
class ChatSessionUpdateRequest(BaseModel):
"""更新对话会话请求"""
title: str
class ChatResponse(BaseModel):
"""对话响应"""
session_id: int
@ -42,6 +147,181 @@ class ChatResponse(BaseModel):
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,
@ -49,8 +329,17 @@ async def create_chat_session(
db: AsyncSession = Depends(get_db),
):
"""创建新的对话会话"""
project = await get_project_or_404(db, req.project_id)
_, user_role = await require_project_read_access(db, req.project_id, current_user)
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,
@ -64,29 +353,33 @@ async def create_chat_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: int,
project_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""列出项目的所有对话会话"""
await require_project_read_access(db, project_id, current_user)
stmt = select(ChatSession).where(
ChatSession.project_id == project_id,
ChatSession.user_id == current_user.id,
).order_by(ChatSession.created_at.desc())
"""列出当前用户的对话会话,可按知识库筛选"""
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=[{
"id": s.id,
"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(),
@ -118,12 +411,99 @@ async def get_session_messages(
msg_result = await db.execute(msg_stmt)
messages = msg_result.scalars().all()
return success_response(data=[{
"id": m.id,
"role": m.role,
"content": m.content,
"created_at": m.created_at.isoformat(),
} for m in messages])
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)
@ -133,6 +513,10 @@ async def send_chat_message(
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()
@ -148,55 +532,69 @@ async def send_chat_message(
query_message = ChatMessage(
session_id=req.session_id,
role="user",
content=req.message,
content=question,
)
db.add(query_message)
await db.flush()
try:
query_vector = await _get_query_vector(req.message)
retrieved_docs = await rag_service.retrieve_documents(
db,
session.project_id,
query_vector,
top_k=5,
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_stmt = select(ChatMessage).where(
ChatMessage.session_id == req.session_id,
).order_by(ChatMessage.created_at.asc())
msg_result = await db.execute(msg_stmt)
prev_messages = msg_result.scalars().all()
prev_messages = list(reversed(msg_result.scalars().all()))
conversation_history = [
{"role": m.role, "content": m.content}
for m in prev_messages
if m.id != query_message.id
]
retrieved_docs = await rag_service.retrieve_documents(
db,
session.project_id,
question,
top_k=5,
)
assistant_response = await rag_service.generate_response(
db,
req.message,
query_vector,
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": req.message,
"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:
@ -207,6 +605,189 @@ async def send_chat_message(
)
@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,
@ -232,14 +813,59 @@ async def delete_chat_session(
return success_response(message="对话会话已删除")
async def _get_query_vector(query: str) -> List[float]:
"""从查询文本生成向量"""
try:
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
@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()
vectorizer = TfidfVectorizer(max_features=768)
query_vector = vectorizer.fit_transform([query]).toarray()[0]
return query_vector.tolist()
except Exception:
return [0.0] * 768
if not row:
raise HTTPException(status_code=404, detail="消息不存在")
msg, session = row
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权删除该消息")
await db.delete(msg)
if session.message_count and session.message_count > 0:
session.message_count = max(0, session.message_count - 1)
await db.commit()
return success_response(message="消息已删除")
@router.put("/sessions/{session_id}", response_model=dict)
async def update_chat_session(
session_id: int,
req: ChatSessionUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新对话会话标题"""
stmt = select(ChatSession).where(ChatSession.id == session_id)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="对话会话不存在")
if session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权修改该对话")
title = (req.title or "").strip()
if not title:
raise HTTPException(status_code=400, detail="标题不能为空")
session.title = title
await db.commit()
return success_response(message="标题已更新", data={"session_id": session_id, "title": title})

View File

@ -4,6 +4,7 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from fastapi.responses import StreamingResponse, FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
import os
import zipfile
@ -18,6 +19,7 @@ from app.core.deps import get_current_user, get_user_from_token_or_query
from app.models.user import User
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,
@ -98,8 +100,6 @@ async def get_project_tree(
# 生成目录树
tree = storage_service.generate_tree(project_root)
<<<<<<< HEAD
=======
share_result = await db.execute(
select(ShareLink.file_path).where(
ShareLink.project_id == project_id,
@ -128,7 +128,6 @@ async def get_project_tree(
if member:
user_role = member.role
>>>>>>> origin/main
return success_response(data={
"tree": tree,
"user_role": user_role,

View File

@ -24,6 +24,7 @@ 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)
@ -31,8 +32,9 @@ class LLMModelConfigUpsertRequest(BaseModel):
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(2048, ge=1, le=32768)
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
@ -67,6 +69,7 @@ def serialize_model_config(config: LLMModelConfig, include_api_key: bool = False
"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,
@ -75,6 +78,7 @@ def serialize_model_config(config: LLMModelConfig, include_api_key: bool = False
"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),
@ -102,13 +106,22 @@ 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)
data["model_code"] = data.get("model_code") or LLMProviderService.build_model_code(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
@ -167,6 +180,7 @@ async def get_llm_model_configs(
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),
@ -184,6 +198,8 @@ async def get_llm_model_configs(
)
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)
@ -406,7 +422,13 @@ async def test_llm_model_config(
request_data: LLMModelConfigTestRequest,
current_user: User = Depends(get_current_user),
):
"""测试模型连接"""
"""测试模型连接按类型分流chat 走对话补全embedding 走向量接口)"""
payload = normalize_payload(request_data)
test_result = await LLMProviderService.test_model_connection(payload)
try:
if payload.get("model_type") == "embedding":
test_result = await LLMProviderService.test_embedding_connection(payload)
else:
test_result = await LLMProviderService.test_model_connection(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return success_response(data=test_result, message="模型测试成功")

View File

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

View File

@ -13,6 +13,7 @@ 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",
@ -32,4 +33,5 @@ __all__ = [
"LLMModelConfig",
"ChatSession",
"ChatMessage",
"ProjectVectorizationTask",
]

View File

@ -1,7 +1,7 @@
"""
文档向量化模型
"""
from sqlalchemy import Column, BigInteger, String, DateTime, Index, Text
from sqlalchemy import Column, BigInteger, Integer, String, DateTime, Index, Text
from sqlalchemy.sql import func
from app.core.database import Base
@ -14,8 +14,10 @@ class DocumentVector(Base):
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="文件相对路径")
content_hash = Column(String(64), comment="内容哈希值,用于判断文件是否变更")
zvec_id = Column(String(256), comment="ZVec返回的向量ID")
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="错误信息")
@ -24,7 +26,11 @@ class DocumentVector(Base):
__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}, file_path='{self.file_path}', status='{self.status}')>"
return (
f"<DocumentVector(id={self.id}, project_id={self.project_id}, "
f"file_path='{self.file_path}', chunk_index={self.chunk_index}, status='{self.status}')>"
)

View File

@ -15,6 +15,7 @@ class LLMModelConfig(Base):
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")
@ -22,8 +23,9 @@ class LLMModelConfig(Base):
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=2048, comment="最大输出 Token")
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="是否默认")

View File

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

View File

@ -2,13 +2,17 @@
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, Dict, List, Optional
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional
PROVIDER_CATALOG: List[Dict[str, str]] = [
@ -156,10 +160,6 @@ class LLMProviderService:
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(
@ -170,10 +170,6 @@ class LLMProviderService:
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
)
latency_ms = int((time.perf_counter() - started_at) * 1000)
@ -194,10 +190,6 @@ class LLMProviderService:
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(
@ -205,10 +197,6 @@ class LLMProviderService:
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
)
if protocol == "gemini":
@ -217,10 +205,6 @@ class LLMProviderService:
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
)
return cls._test_openai_compatible(
@ -229,10 +213,6 @@ class LLMProviderService:
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
)
@classmethod
@ -243,10 +223,6 @@ class LLMProviderService:
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 = {
@ -257,11 +233,7 @@ class LLMProviderService:
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,
"messages": cls._build_openai_test_messages(),
}
response = cls._request_json(url, headers, payload, timeout)
choices = response.get("choices") or []
@ -269,6 +241,8 @@ class LLMProviderService:
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)
@ -286,10 +260,6 @@ class LLMProviderService:
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 = {
@ -299,9 +269,7 @@ class LLMProviderService:
}
payload = {
"model": llm_model_name,
"max_tokens": min(max_tokens, 256),
"temperature": temperature,
"top_p": top_p,
"max_tokens": 32,
"messages": [
{
"role": "user",
@ -309,8 +277,6 @@ class LLMProviderService:
}
],
}
if system_prompt:
payload["system"] = system_prompt
response = cls._request_json(url, headers, payload, timeout)
content = response.get("content") or []
@ -330,10 +296,6 @@ class LLMProviderService:
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("/"))
@ -347,17 +309,8 @@ class LLMProviderService:
"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 []
@ -373,12 +326,8 @@ class LLMProviderService:
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
def _build_openai_test_messages() -> List[Dict[str, str]]:
return [{"role": "user", "content": "请只回复“连接测试成功”。"}]
@staticmethod
def _join_endpoint(base_url: str, suffix: str) -> str:
@ -387,6 +336,38 @@ class LLMProviderService:
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,
@ -402,8 +383,10 @@ class LLMProviderService:
method="POST",
)
ctx = cls._build_ssl_context()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
with urllib.request.urlopen(request, timeout=timeout, context=ctx) as response:
body = response.read().decode("utf-8")
if not body:
return {}
@ -439,3 +422,503 @@ class LLMProviderService:
return None
# ==================== 对话文本生成RAG 调用) ====================
@classmethod
async def generate_text(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, Any]],
) -> str:
"""根据多轮对话消息生成回复文本(供知识库 RAG 使用)"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 llm_model_name")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再调用")
normalized = cls._normalize_messages(messages)
if not normalized:
raise ValueError("缺少有效的对话消息")
protocol = provider_meta.get("protocol", "openai_compatible")
return await asyncio.to_thread(
cls._chat_completion,
protocol,
endpoint_url,
api_key,
llm_model_name,
int(timeout or 120),
float(temperature or 0.7),
float(top_p or 0.9),
int(max_tokens or 2048),
(system_prompt or "").strip(),
normalized,
)
@classmethod
async def generate_text_stream(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, Any]],
) -> AsyncIterator[str]:
"""流式生成回复文本。OpenAI-compatible 使用原生流,其它协议回退为分块输出。"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 llm_model_name")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再调用")
normalized = cls._normalize_messages(messages)
if not normalized:
raise ValueError("缺少有效的对话消息")
protocol = provider_meta.get("protocol", "openai_compatible")
if protocol != "openai_compatible":
text = await cls.generate_text(
provider,
endpoint_url,
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
normalized,
)
for index in range(0, len(text), 24):
yield text[index:index + 24]
return
result_queue: "queue.Queue[tuple[str, Any]]" = queue.Queue()
def worker():
try:
for chunk in cls._chat_openai_compatible_stream(
endpoint_url,
api_key,
llm_model_name,
int(timeout or 120),
float(temperature or 0.7),
float(top_p or 0.9),
int(max_tokens or 2048),
(system_prompt or "").strip(),
normalized,
):
result_queue.put(("chunk", chunk))
except Exception as exc: # pragma: no cover - surfaced through stream
result_queue.put(("error", exc))
finally:
result_queue.put(("done", None))
threading.Thread(target=worker, daemon=True).start()
while True:
kind, payload = await asyncio.to_thread(result_queue.get)
if kind == "chunk":
yield payload
elif kind == "error":
raise payload
else:
break
@staticmethod
def _normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, str]]:
normalized: List[Dict[str, str]] = []
for item in messages or []:
if not isinstance(item, dict):
continue
role = str(item.get("role") or "").strip().lower()
if role not in {"system", "user", "assistant"}:
continue
content = item.get("content", "")
if isinstance(content, list):
content = "".join(
part.get("text", "") if isinstance(part, dict) else str(part)
for part in content
)
content = str(content).strip()
if not content:
continue
normalized.append({"role": role, "content": content})
return normalized
@classmethod
def _chat_completion(
cls,
protocol: str,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
if protocol == "anthropic":
return cls._chat_anthropic(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
if protocol == "gemini":
return cls._chat_gemini(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
return cls._chat_openai_compatible(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
@classmethod
def _chat_openai_compatible(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
url = cls._join_endpoint(endpoint_url, "/chat/completions")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload_messages: List[Dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages)
payload = {
"model": llm_model_name,
"messages": payload_messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": False,
}
response = cls._request_json(url, headers, payload, timeout)
choices = response.get("choices") or []
if not choices:
raise ValueError("请求已发送,但未收到模型返回内容")
content = choices[0].get("message", {}).get("content", "")
if isinstance(content, list):
content = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in content
)
content = str(content).strip()
if not content:
raise ValueError("模型返回成功,但内容为空")
return content
@classmethod
def _chat_openai_compatible_stream(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> Iterator[str]:
url = cls._join_endpoint(endpoint_url, "/chat/completions")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload_messages: List[Dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages)
payload = {
"model": llm_model_name,
"messages": payload_messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": True,
}
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
ctx = cls._build_ssl_context()
try:
with urllib.request.urlopen(request, timeout=timeout, context=ctx) as response:
for raw_line in response:
line = raw_line.decode("utf-8", errors="ignore").strip()
if not line or not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
payload = json.loads(data)
except json.JSONDecodeError:
continue
for choice in payload.get("choices") or []:
delta = choice.get("delta") or {}
content = delta.get("content")
if isinstance(content, list):
content = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in content
)
if content:
yield str(content)
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="ignore")
message = cls._extract_error_message(error_body) or error_body[:300] or str(exc)
raise ValueError(f"模型调用失败HTTP {exc.code}{message}") from exc
except urllib.error.URLError as exc:
reason = exc.reason
if isinstance(reason, socket.timeout):
raise ValueError("模型调用超时,请检查网络或调大超时时间") from exc
raise ValueError(f"模型调用失败:{reason}") from exc
except socket.timeout as exc:
raise ValueError("模型调用超时,请检查网络或调大超时时间") from exc
@classmethod
def _chat_anthropic(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
url = cls._join_endpoint(endpoint_url, "/v1/messages")
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
payload = {
"model": llm_model_name,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"messages": [
{"role": m["role"], "content": [{"type": "text", "text": m["content"]}]}
for m in messages
if m["role"] in {"user", "assistant"}
],
}
if system_prompt:
payload["system"] = system_prompt
response = cls._request_json(url, headers, payload, timeout)
content = response.get("content") or []
texts = [
item.get("text", "")
for item in content
if isinstance(item, dict) and item.get("type") == "text"
]
preview = "".join(texts).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@classmethod
def _chat_gemini(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}"
encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/"))
url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}"
headers = {"Content-Type": "application/json"}
role_map = {"user": "user", "assistant": "model"}
contents = [
{"role": role_map[m["role"]], "parts": [{"text": m["content"]}]}
for m in messages
if m["role"] in role_map
]
payload = {
"contents": contents,
"generationConfig": {
"temperature": temperature,
"topP": top_p,
"maxOutputTokens": max_tokens,
},
}
if system_prompt:
payload["systemInstruction"] = {"parts": [{"text": system_prompt}]}
response = cls._request_json(url, headers, payload, timeout)
candidates = response.get("candidates") or []
if not candidates:
raise ValueError("请求已发送,但未收到模型返回内容")
parts = candidates[0].get("content", {}).get("parts", [])
preview = "".join(
part.get("text", "") for part in parts if isinstance(part, dict)
).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
# ==================== Embedding 向量生成与测试 ====================
@classmethod
async def generate_embedding(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
text: str,
timeout: int = 60,
dimension: Optional[int] = None,
) -> List[float]:
"""调用 OpenAI 兼容的 /embeddings 接口生成单条文本向量"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
text = (text or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 embedding 模型标识")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再调用")
if not text:
raise ValueError("待向量化文本为空")
vectors = await asyncio.to_thread(
cls._embeddings_request,
endpoint_url,
api_key,
llm_model_name,
[text[:8000]],
int(timeout or 60),
dimension,
)
if not vectors:
raise ValueError("Embedding 接口返回为空")
return vectors[0]
@classmethod
def _embeddings_request(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
inputs: List[str],
timeout: int,
dimension: Optional[int],
) -> List[List[float]]:
url = cls._join_endpoint(endpoint_url, "/embeddings")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload: Dict[str, Any] = {
"model": llm_model_name,
"input": inputs,
}
if dimension:
payload["dimensions"] = dimension
response = cls._request_json(url, headers, payload, timeout)
data = response.get("data") or []
if not data:
raise ValueError("Embedding 请求已发送,但未收到向量数据")
vectors: List[List[float]] = []
for item in data:
embedding = item.get("embedding") if isinstance(item, dict) else None
if embedding:
vectors.append([float(x) for x in embedding])
if not vectors:
raise ValueError("Embedding 返回结果中未解析到向量")
return vectors
@classmethod
async def test_embedding_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
"""测试 embedding 模型连通性,并返回实际向量维度"""
provider = payload.get("provider")
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (payload.get("llm_model_name") or "").strip()
api_key = (payload.get("api_key") or "").strip()
timeout = int(payload.get("llm_timeout") or 60)
dimension = payload.get("embedding_dimension")
started_at = time.perf_counter()
vector = await cls.generate_embedding(
provider,
endpoint_url,
api_key,
llm_model_name,
"连接测试",
timeout=timeout,
dimension=int(dimension) if dimension else None,
)
latency_ms = int((time.perf_counter() - started_at) * 1000)
return {
"provider": provider,
"endpoint_url": endpoint_url,
"llm_model_name": llm_model_name,
"latency_ms": latency_ms,
"dimension": len(vector),
"preview": f"成功生成 {len(vector)} 维向量",
}

View File

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

View File

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

View File

@ -1,14 +1,25 @@
"""
知识库 RAG 服务 - 向量检索 + 大模型生成
知识库 RAG 服务 - ZVec 向量检索 + 大模型生成
"""
from typing import List, Dict, Any, Optional
import logging
from typing import AsyncIterator, List, Dict, Any
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import json
from app.models.document_vector import DocumentVector
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:
@ -18,87 +29,177 @@ class RAGService:
async def retrieve_documents(
db: AsyncSession,
project_id: int,
query_vector: List[float],
query: str,
top_k: int = 5,
similarity_threshold: float = 0.3,
) -> List[Dict[str, Any]]:
"""向量检索相关文档"""
if not query_vector or len(query_vector) == 0:
"""通过 ZVec 向量检索相关文档,并读取文档内容"""
# 多取一些分块,再按文件去重,避免高频命中文件挤掉其他相关文档。
matched = await zvec_service.search_similar(db, project_id, query, top_k * 4)
if not matched:
return []
stmt = select(DocumentVector).where(
DocumentVector.project_id == project_id,
DocumentVector.vector.isnot(None),
)
from app.models.project import Project
stmt = select(Project).where(Project.id == project_id)
result = await db.execute(stmt)
all_vectors = result.scalars().all()
if not all_vectors:
project = result.scalar_one_or_none()
if not project:
return []
scored_docs = []
for doc in all_vectors:
# 读取文件内容做缓存,避免同一文件多个分块命中时重复读盘
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:
doc_vector = json.loads(doc.vector) if isinstance(doc.vector, str) else doc.vector
similarity = RAGService._cosine_similarity(query_vector, doc_vector)
if similarity >= similarity_threshold:
scored_docs.append({
"doc": doc,
"similarity": similarity,
"file_path": doc.file_path,
"content_preview": doc.content[:500] if doc.content else "",
})
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
scored_docs.sort(key=lambda x: x["similarity"], reverse=True)
return scored_docs[:top_k]
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 _cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
"""计算余弦相似度"""
if len(vec1) != len(vec2) or len(vec1) == 0:
return 0.0
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}"
dot_product = sum(a * b for a, b in zip(vec1, vec2))
magnitude1 = sum(a * a for a in vec1) ** 0.5
magnitude2 = sum(b * b for b in vec2) ** 0.5
@staticmethod
def _resolve_excerpt(content: str, chunk_text: str, chunk_index: int) -> str:
"""返回引用预览片段。
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
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
return dot_product / (magnitude1 * magnitude2)
@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,
query_vector: List[float],
project_id: int,
llm_config_id: int,
retrieved_docs: List[Dict[str, Any]],
conversation_history: List[Dict[str, 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)
system_prompt = f"""你是一个知识库助手。基于用户提供的知识库文档,回答用户的问题。
如果知识库中没有相关信息请明确说明
知识库文档内容:
{context_text}
请基于以上知识库内容用中文回答用户的问题"""
messages = list(conversation_history)
messages.append({"role": "user", "content": query})
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,
@ -115,6 +216,72 @@ class RAGService:
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:
"""构建上下文文本"""
@ -123,12 +290,26 @@ class RAGService:
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"文档 {i}: {doc_info['file_path']} (相似度: {doc_info['similarity']:.2%})\n"
f"{doc_info['content_preview']}..."
f"--- [{citation_id}] 文档: {doc_info['file_path']} ---\n{content}"
)
return "\n\n".join(context_parts)
return "\n\n".join(context_parts) if context_parts else "(未找到相关知识库内容)"
@staticmethod
def _build_citation_hint(retrieved_docs: List[Dict[str, Any]]) -> str:
"""构建引用提示"""
if not retrieved_docs:
return "未检索到文档时不要标注引用。"
return "\n".join(
f"[{doc.get('citation_id', index)}] {doc.get('file_name') or doc.get('file_path')}"
for index, doc in enumerate(retrieved_docs, 1)
)
rag_service = RAGService()

View File

@ -1,135 +1,407 @@
"""
ZVec 向量化服务 - 调用阿里云ZVec API进行文档向量化
ZVec 本地向量化服务 - 使用本地 zvec + 模型配置中的 embedding 模型
"""
import json
import os
import httpx
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.core.database import get_db
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 向量化服务"""
"""基于本地 zvec 库的向量存储和检索服务"""
ZVEC_API_ENDPOINT = os.getenv("ZVEC_API_ENDPOINT", "https://api.aliyun.com/zvec")
ZVEC_API_KEY = os.getenv("ZVEC_API_KEY", "")
ZVEC_MODEL = os.getenv("ZVEC_MODEL", "text-embedding-v3")
_collections: Dict[int, zvec.Collection] = {}
@classmethod
async def is_configured(cls) -> bool:
"""检查ZVec是否已配置"""
return bool(cls.ZVEC_API_KEY and cls.ZVEC_API_ENDPOINT)
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
async def vectorize_text(cls, text: str, doc_id: Optional[int] = None) -> Optional[List[float]]:
"""
调用ZVec API对文本进行向量化
def _open_collection(cls, project_id: int) -> Optional[zvec.Collection]:
"""打开已存在的 collection不存在则返回 None"""
if project_id in cls._collections:
return cls._collections[project_id]
Args:
text: 要向量化的文本内容
doc_id: 文档ID用于日志追踪
Returns:
向量列表或如果API调用失败则返回None
"""
if not await cls.is_configured():
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:
# 截断超长文本ZVec有输入限制
text = text[:8000]
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {cls.ZVEC_API_KEY}",
}
payload = {
"model": cls.ZVEC_MODEL,
"input": text,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{cls.ZVEC_API_ENDPOINT}/embeddings",
headers=headers,
json=payload,
)
response.raise_for_status()
data = response.json()
embeddings = data.get("data", [])
if embeddings:
return embeddings[0].get("embedding", [])
return None
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:
print(f"ZVec vectorization failed for doc_id={doc_id}: {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
async def store_vector(
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,
doc_id: Optional[int] = None,
) -> bool:
"""对 MD 文件分块向量化并存入 ZVec同时逐分块记录状态到 document_vector。
流程删除旧分块 分块 逐块生成 embedding写入 ZVec写记录
"""
向量化文本并存储到数据库
from app.core.config import settings
Args:
db: 数据库会话
project_id: 项目ID
file_path: 文件路径
content: 文件内容
doc_id: 文档元数据ID
content_hash = cls._content_hash(content)
Returns:
是否成功存储
"""
# 调用ZVec获取向量
embedding = await cls.vectorize_text(content, doc_id)
if not embedding:
return False
# 先清理该文件的所有旧分块(增量重建),保证不残留过期向量
await cls._purge_file_chunks(db, project_id, file_path)
try:
# 先删除旧的向量记录
await db.execute(
delete(DocumentVector).where(
(DocumentVector.project_id == project_id)
& (DocumentVector.file_path == file_path)
)
)
# 创建新的向量记录
vector_record = DocumentVector(
project_id=project_id,
file_path=file_path,
doc_id=doc_id,
embedding=json.dumps(embedding),
embedding_model=cls.ZVEC_MODEL,
chunk_count=1, # 单个文件作为一个chunk
)
db.add(vector_record)
chunks = cls._chunk_text(content, settings.CHUNK_SIZE, settings.CHUNK_OVERLAP)
if not chunks:
# 空文件:清理后直接提交,视为成功(无可向量化内容)
await db.commit()
return True
except Exception as exc:
print(f"Failed to store vector for {file_path}: {exc}")
await db.rollback()
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,
@ -137,30 +409,248 @@ class ZVecService:
project_id: int,
file_path: str,
) -> bool:
"""
删除文档的向量记录
Args:
db: 数据库会话
project_id: 项目ID
file_path: 文件路径
Returns:
是否成功删除
"""
"""从 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)
& (DocumentVector.file_path == file_path)
)
delete(DocumentVector).where(DocumentVector.project_id == project_id)
)
await db.commit()
return True
except Exception as exc:
print(f"Failed to delete vector for {file_path}: {exc}")
await db.rollback()
return False
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(
@ -170,71 +660,57 @@ class ZVecService:
query: str,
top_k: int = 5,
) -> List[Dict[str, Any]]:
"""
基于查询文本检索相似文档
Args:
db: 数据库会话
project_id: 项目ID
query: 查询文本
top_k: 返回最相似的文档数量
Returns:
相似文档列表按相似度排序
"""
# 获取查询文本的向量
query_embedding = await cls.vectorize_text(query)
"""基于查询文本检索相似文档"""
query_embedding = await cls.generate_embedding(db, query)
if not query_embedding:
return []
try:
# 获取项目中所有文档的向量
result = await db.execute(
select(DocumentVector).where(
DocumentVector.project_id == project_id
)
collection = cls._open_collection(project_id)
if collection is None:
return [] # 项目尚未向量化
results = collection.query(
zvec.VectorQuery("embedding", vector=query_embedding),
topk=top_k,
)
vectors = result.scalars().all()
if not vectors:
# 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 []
# 计算相似度(余弦相似度)
similarities = []
for vector_record in vectors:
try:
doc_embedding = json.loads(vector_record.embedding)
similarity = cls._cosine_similarity(query_embedding, doc_embedding)
similarities.append({
"file_path": vector_record.file_path,
"doc_id": vector_record.doc_id,
"similarity": similarity,
})
except json.JSONDecodeError:
continue
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()}
# 按相似度排序并返回top-k
similarities.sort(key=lambda x: x["similarity"], reverse=True)
return similarities[:top_k]
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:
print(f"Failed to search similar documents: {exc}")
logger.error(f"ZVec search failed: {exc}")
return []
@staticmethod
def _cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
"""计算两个向量的余弦相似度"""
if not vec1 or not vec2 or len(vec1) != len(vec2):
return 0.0
dot_product = sum(a * b for a, b in zip(vec1, vec2))
magnitude1 = sum(a * a for a in vec1) ** 0.5
magnitude2 = sum(b * b for b in vec2) ** 0.5
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
return dot_product / (magnitude1 * magnitude2)
@classmethod
def close_collection(cls, project_id: int) -> None:
"""关闭项目的 ZVec collection"""
if project_id in cls._collections:
del cls._collections[project_id]
zvec_service = ZVecService()

View File

@ -1,25 +1,33 @@
-- 创建知识库对话会话表
CREATE TABLE IF NOT EXISTS chat_session (
session_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '会话ID',
project_id BIGINT NOT NULL COMMENT '项目ID',
user_id BIGINT NOT NULL COMMENT '创建用户ID',
model_config_id BIGINT NOT NULL COMMENT '使用的LLM模型配置ID',
title VARCHAR(255) NOT NULL COMMENT '会话标题',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_project_user (project_id, user_id),
INDEX idx_created_at (created_at)
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 (
message_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '消息ID',
session_id BIGINT NOT NULL COMMENT '所属会话ID',
role VARCHAR(20) NOT NULL COMMENT '角色(user/assistant)',
content LONGTEXT NOT NULL COMMENT '消息内容',
retrieved_docs JSON COMMENT '检索到的相关文档(JSON数组)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
FOREIGN KEY (session_id) REFERENCES chat_session(session_id) ON DELETE CASCADE,
INDEX idx_session_id (session_id),
INDEX idx_created_at (created_at)
CREATE TABLE IF NOT EXISTS `chat_message` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID',
`session_id` BIGINT NOT NULL COMMENT '会话ID',
`role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)',
`content` TEXT NOT NULL COMMENT '消息内容',
`referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)',
`tokens_used` INT DEFAULT NULL COMMENT '消耗的token数',
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX `idx_session_id` (`session_id`),
FOREIGN KEY (`session_id`) REFERENCES `chat_session`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话消息表';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -86,6 +86,9 @@ function App() {
<Route path="/system/model-configs" element={<ModelConfigs />} />
<Route path="/system/logs" element={<SystemLogs />} />
<Route path="/chat" element={<Chat />} />
<Route path="/chat/new" element={<Chat />} />
<Route path="/knowledge" element={<Navigate to="/chat" replace />} />
<Route path="/knowledge/my" element={<Navigate to="/chat" replace />} />
</Route>
<Route path="/" element={<Navigate to="/projects" replace />} />

View File

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

View File

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

View File

@ -61,9 +61,11 @@ export function deleteLLMModelConfig(configId) {
}
export function testLLMModelConfig(data) {
const timeoutSeconds = Number(data?.llm_timeout) || 30
return request({
url: '/llm-model-configs/test',
method: 'post',
data,
timeout: Math.max(timeoutSeconds * 1000 + 5000, 30000),
})
}

View File

@ -62,7 +62,7 @@ const builtInMenuMetaMap = {
icon: 'TeamOutlined',
},
'knowledge:my': {
path: '/knowledge',
path: '/chat',
icon: 'ReadOutlined',
},
'system:users': {
@ -128,7 +128,7 @@ const resolveBuiltInMenuMeta = (item) => {
if (item.path === '/knowledge' || item.path === '/knowledge/my' || item.menu_name === '我的知识库') {
return {
path: '/knowledge',
path: '/chat',
icon: 'ReadOutlined',
}
}

View File

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

View File

@ -65,6 +65,22 @@ body {
border-radius: 2px;
}
/* 从知识库引用跳转定位到的段落,短暂强调后淡出 */
.search-highlight.cited-highlight-flash {
animation: cited-flash 2.4s ease-out;
}
@keyframes cited-flash {
0%, 30% {
background-color: #ff9800 !important;
box-shadow: 0 0 0 4px rgba(255, 152, 0, 0.35);
}
100% {
background-color: #ffd54f !important;
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0);
}
}
/* Dark mode overrides for highlight.js */
body.dark .hljs {
display: block;

View File

@ -1,109 +1,894 @@
/* Chat 页面样式 */
.chat-container {
display: flex;
height: 100vh;
.chat-page-shell {
height: calc(100vh - 96px);
min-height: 0;
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 12px;
}
.chat-sidebar {
width: 280px;
border-right: 1px solid #e8e8e8;
overflow-y: auto;
background: #fafafa;
padding: 16px;
.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-header {
padding: 16px;
border-bottom: 1px solid #e8e8e8;
.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;
justify-content: space-between;
align-items: center;
gap: 10px;
font-size: 16px;
line-height: 1;
cursor: pointer;
color: var(--text-color);
}
.chat-header-title {
font-size: 16px;
.chat-action-entry:hover {
color: #1677ff;
}
.chat-action-entry span {
font-size: 15px;
font-weight: 600;
}
.chat-content {
.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;
flex-direction: column;
background: #fff;
align-items: center;
justify-content: center;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
.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-message {
margin-bottom: 12px;
display: flex;
justify-content: flex-start;
.chat-session-item:hover {
border-color: #1677ff;
}
.chat-message.user {
justify-content: flex-end;
.chat-session-item.active {
border-color: #1677ff;
background: color-mix(in srgb, #1677ff 10%, var(--bg-color-secondary));
}
.chat-message-bubble {
max-width: 70%;
padding: 8px 12px;
border-radius: 6px;
word-break: break-word;
white-space: pre-wrap;
}
.chat-message-bubble.user {
background: #1890ff;
color: #fff;
}
.chat-message-bubble.assistant {
background: #f0f0f0;
color: #000;
}
.chat-input-area {
padding: 16px;
border-top: 1px solid #e8e8e8;
.chat-session-item-top {
display: flex;
gap: 8px;
}
.chat-session-item {
padding: 8px;
margin-bottom: 8px;
border-radius: 4px;
cursor: pointer;
border: 1px solid #d9d9d9;
transition: all 0.3s;
}
.chat-session-item:hover {
background: #f5f5f5;
}
.chat-session-item.active {
background: #e6f7ff;
border: 1px solid #1890ff;
.chat-session-title-area {
flex: 1;
min-width: 0;
}
.chat-session-title {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
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-session-date {
font-size: 11px;
color: #999;
margin-top: 4px;
.chat-composer-kb {
max-width: 280px;
}
.chat-project-picker {
width: 260px;
}
.chat-project-picker-select {
width: 100%;
}
.chat-composer-model,
.chat-composer-model-select {
width: 220px;
flex-shrink: 0;
}
.chat-composer-model {
max-width: 220px;
}
.chat-composer-model-select .ant-select-selector {
border-radius: 999px !important;
border-color: #f0f0f0 !important;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06) !important;
}
.chat-composer-model-select .ant-select-selection-placeholder,
.chat-composer-model-select .ant-select-selection-item {
display: inline-flex;
align-items: center;
}
.chat-send-button {
width: 40px !important;
height: 40px !important;
min-width: 40px !important;
border: 1px solid #bebebe !important;
background: #d1d1d1 !important;
color: #fff !important;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28);
font-size: 24px !important;
line-height: 1 !important;
transform: scale(1.08);
transform-origin: center;
}
.chat-send-button .anticon {
font-size: 22px;
}
.chat-send-button:not(:disabled):hover {
background: #4096ff !important;
border-color: #4096ff !important;
color: #fff !important;
}
.chat-send-button:not(:disabled) {
background: #1677ff !important;
border-color: #1677ff !important;
color: #fff !important;
}
.chat-send-button:disabled {
background: #d8d8d8 !important;
border-color: #c8c8c8 !important;
color: #fff !important;
opacity: 1;
}
.chat-search-bar {
width: 100%;
}
.chat-search-result-list {
margin-top: 16px;
max-height: 60vh;
overflow: auto;
}
.chat-search-result-item {
cursor: pointer;
}
.chat-search-result-snippet {
color: var(--text-color);
line-height: 1.7;
}
.chat-search-highlight {
padding: 0 2px;
border-radius: 3px;
background: #ffe58f;
color: #ad4e00;
}
body.dark .chat-search-highlight {
background: #614700;
color: #ffe58f;
}
.chat-search-result-time {
margin-top: 4px;
font-size: 12px;
color: var(--text-color-secondary);
}
@media (max-width: 1200px) {
.chat-page-shell {
grid-template-columns: 300px minmax(0, 1fr);
}
}
@media (max-width: 992px) {
.chat-page-shell {
grid-template-columns: 1fr;
height: auto;
}
.chat-left-panel {
max-height: 360px;
}
.chat-composer-actions {
align-items: stretch;
flex-direction: column;
}
.chat-composer-left,
.chat-composer-right {
width: 100%;
justify-content: space-between;
}
.chat-project-picker-select,
.chat-composer-model-select {
flex: 1;
min-width: 0;
}
.chat-send-button {
width: 48px !important;
height: 48px !important;
min-width: 48px !important;
font-size: 24px !important;
transform: scale(1.08);
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -58,3 +58,82 @@
flex: 1;
min-height: 0;
}
/* 竖向双 tab 模型管理 */
.model-config-tabs > .ant-tabs-nav {
min-width: 160px;
}
.model-config-tabs > .ant-tabs-nav .ant-tabs-tab {
padding: 12px 16px;
border-radius: 8px;
margin: 0 0 4px 0 !important;
}
.model-config-tabs > .ant-tabs-nav .ant-tabs-tab-active {
background: rgba(22, 119, 255, 0.08);
}
.model-config-tabs > .ant-tabs-nav .ant-tabs-tab-btn {
display: flex;
align-items: center;
gap: 8px;
}
.model-config-tabs > .ant-tabs-content-holder {
padding-left: 20px;
}
.model-config-tab-panel {
min-height: 600px;
}
.model-config-tab-desc {
margin: 0 0 16px 0;
color: var(--text-color-secondary);
font-size: 13px;
line-height: 1.6;
}
/* Max Tokens 选择:独立的圆角胶囊按钮组 */
.max-tokens-group {
display: flex;
gap: 12px;
}
.max-tokens-group .ant-radio-button-wrapper {
height: 36px;
min-width: 72px;
padding: 0 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px !important;
border: 1px solid transparent;
background: var(--bg-color-secondary);
color: var(--text-color);
font-weight: 500;
transition: all 0.15s ease;
}
/* 去掉 antd 默认的相邻按钮分隔线 */
.max-tokens-group .ant-radio-button-wrapper::before {
display: none !important;
}
.max-tokens-group .ant-radio-button-wrapper:hover {
color: var(--text-color);
background: color-mix(in srgb, var(--border-color) 35%, var(--bg-color-secondary));
}
.max-tokens-group .ant-radio-button-wrapper-checked {
background: #d9d9d9 !important;
border-color: #d9d9d9 !important;
color: rgba(0, 0, 0, 0.88) !important;
box-shadow: none !important;
}
.max-tokens-group .ant-radio-button-wrapper-checked:hover {
background: #cfcfcf !important;
border-color: #cfcfcf !important;
}

View File

@ -7,13 +7,18 @@ import {
InputNumber,
Modal,
Popconfirm,
Radio,
Select,
Slider,
Space,
Switch,
Tabs,
Tag,
} from 'antd'
import {
CloudServerOutlined,
CommentOutlined,
DeploymentUnitOutlined,
ExperimentOutlined,
PlusOutlined,
ReloadOutlined,
@ -39,7 +44,31 @@ import '@/pages/System/AdminPages.css'
const { Search, TextArea } = Input
function buildModelCode(provider, llmModelName) {
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 = []
@ -56,7 +85,8 @@ function buildModelCode(provider, llmModelName) {
}
const suffix = sanitized.join('').replace(/^_+|_+$/g, '') || 'model'
return `llm_${providerPart}_${suffix}`
const base = `llm_${providerPart}_${suffix}`
return modelType === 'embedding' ? `emb_${base}` : base
}
function buildModelName(providerMeta, provider, llmModelName) {
@ -75,6 +105,8 @@ function formatDateTime(value) {
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)
@ -96,6 +128,7 @@ function ModelConfigs() {
const providerValue = Form.useWatch('provider', form)
const llmModelNameValue = Form.useWatch('llm_model_name', form)
const isEmbedding = editingType === 'embedding'
useEffect(() => {
loadProviderCatalog()
@ -103,7 +136,7 @@ function ModelConfigs() {
useEffect(() => {
loadConfigs()
}, [page, pageSize, keyword, providerFilter, statusFilter])
}, [activeType, page, pageSize, keyword, providerFilter, statusFilter])
useEffect(() => {
if (!modalVisible || !providerValue) {
@ -134,7 +167,7 @@ function ModelConfigs() {
}
if (autoFillFlags.modelCode) {
const nextModelCode = buildModelCode(providerValue, llmModelNameValue)
const nextModelCode = buildModelCode(providerValue, llmModelNameValue, editingType)
if (nextModelCode !== currentValues.model_code) {
nextValues.model_code = nextModelCode
}
@ -144,7 +177,7 @@ function ModelConfigs() {
if (Object.keys(nextValues).length > 0) {
form.setFieldsValue(nextValues)
}
}, [modalVisible, providerValue, llmModelNameValue, providerCatalog, autoFillFlags, form])
}, [modalVisible, providerValue, llmModelNameValue, providerCatalog, autoFillFlags, editingType, form])
const loadProviderCatalog = async () => {
try {
@ -162,6 +195,7 @@ function ModelConfigs() {
const params = {
page,
page_size: pageSize,
model_type: activeType,
}
if (keyword) params.keyword = keyword
if (providerFilter) params.provider = providerFilter
@ -180,24 +214,34 @@ function ModelConfigs() {
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: 120,
llm_timeout: activeType === 'embedding' ? 60 : 120,
llm_temperature: 0.7,
llm_top_p: 0.9,
llm_max_tokens: 2048,
llm_max_tokens: DEFAULT_MAX_TOKENS,
embedding_dimension: undefined,
is_active: true,
is_default: false,
description: '',
llm_system_prompt: '',
model_name: '',
@ -212,13 +256,15 @@ function ModelConfigs() {
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),
modelCode: !detail.model_code || detail.model_code === buildModelCode(detail.provider, detail.llm_model_name, detailType),
})
form.setFieldsValue({
...detail,
@ -240,11 +286,12 @@ function ModelConfigs() {
const handleSubmit = async (values) => {
try {
setSubmitting(true)
const payload = { ...values, model_type: editingType }
if (editingConfigId) {
await updateLLMModelConfig(editingConfigId, values)
await updateLLMModelConfig(editingConfigId, payload)
Toast.success('模型配置更新成功')
} else {
await createLLMModelConfig(values)
await createLLMModelConfig(payload)
Toast.success('模型配置创建成功')
}
closeModal()
@ -277,232 +324,272 @@ function ModelConfigs() {
}
const showTestResult = (result) => {
Modal.info({
title: '模型测试成功',
width: 620,
content: (
<div className="model-config-test-result">
<div>提供方{getProviderMeta(result.provider)?.label || result.provider}</div>
<div>模型{result.llm_model_name}</div>
<div>延迟{result.latency_ms} ms</div>
<div>返回预览{result.preview || '模型已成功返回,但未提取到文本内容。'}</div>
</div>
),
})
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)
const res = await testLLMModelConfig({ ...values, model_type: editingType })
showTestResult(res.data)
} catch (error) {
if (error?.errorFields) {
return
}
Toast.error(error.response?.data?.detail || '模型测试失败')
//
// Toast
} finally {
setTesting(false)
}
}
const columns = [
{
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,
},
{
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: 'is_default',
key: 'is_default',
width: 90,
render: (value) => (
value ? <Tag color="gold">默认</Tag> : <Tag></Tag>
),
},
{
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)}
>
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"
danger
icon={<DeleteOutlined />}
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
>
删除
编辑
</Button>
</Popconfirm>
</Space>
),
},
]
<Popconfirm
title="确认删除该模型配置?"
description="删除后将无法恢复。"
okText="删除"
cancelText="取消"
onConfirm={() => handleDelete(record)}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
删除
</Button>
</Popconfirm>
</Space>
),
},
)
return (
<div className="admin-page">
<PageHeader
title="模型配置"
description="管理系统可用的大模型提供方、默认接口地址、参数模板与连通性测试。"
icon={<CloudServerOutlined />}
extra={(
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">
<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 icon={<ReloadOutlined />} onClick={loadConfigs}>
刷新
</Button>
</div>
</div>
<ListTable
rowKey="config_id"
className="model-config-table"
loading={loading}
columns={columns}
dataSource={configs}
scroll={{ x: 1280 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
showTotal: (value) => `${value}`,
}}
<Tabs
items={tabItems}
activeKey={activeType}
onChange={handleTabChange}
tabPosition="left"
className="model-config-tabs"
/>
</Card>
<Modal
title={editingConfigId ? '编辑模型配置' : '新增模型配置'}
title={`${editingConfigId ? '编辑' : '新增'}${MODEL_TYPE_META[editingType].label}`}
open={modalVisible}
width={860}
className="model-config-modal"
@ -537,6 +624,12 @@ function ModelConfigs() {
<div className="model-config-modal-hint">
<strong>自动生成规则</strong> 选择提供方后会自动带出默认 `base_url`填写模型标识后会自动生成模型名称模型编码
如果你手动改过这些字段后续就不会再被自动覆盖
{isEmbedding && (
<>
<br />
<strong>向量维度</strong> 留空时按模型实际返回维度自动建立向量库如填写需与模型输出维度一致更换不同维度的模型会重建该项目的向量库
</>
)}
</div>
<Form
@ -547,11 +640,14 @@ function ModelConfigs() {
llm_timeout: 120,
llm_temperature: 0.7,
llm_top_p: 0.9,
llm_max_tokens: 2048,
llm_max_tokens: DEFAULT_MAX_TOKENS,
is_active: true,
is_default: false,
}}
>
<Form.Item name="model_type" hidden>
<Input />
</Form.Item>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Form.Item
label="提供方"
@ -590,7 +686,13 @@ function ModelConfigs() {
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型标识或部署名' }]}
>
<Input placeholder="如 gpt-4.1-mini / qwen3.6-plus / claude-3-5-sonnet-latest" />
<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
@ -640,55 +742,86 @@ function ModelConfigs() {
/>
</Form.Item>
<Space style={{ width: '100%' }} size={16} align="start">
{isEmbedding ? (
<Form.Item
label="Temperature"
name="llm_temperature"
style={{ width: '33%' }}
rules={[{ required: true, message: '请输入 temperature' }]}
label="向量维度"
name="embedding_dimension"
extra="留空则按模型实际返回维度自动建立;填写需与模型输出一致。"
>
<InputNumber min={0} max={2} step={0.05} style={{ width: '100%' }} />
<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={{ width: '33%' }}
rules={[{ required: true, message: '请输入 top_p' }]}
>
<InputNumber min={0} max={1} step={0.05} style={{ width: '100%' }} />
</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"
style={{ width: '33%' }}
rules={[{ required: true, message: '请输入 max_tokens' }]}
>
<InputNumber min={1} max={32768} step={128} style={{ width: '100%' }} />
</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="llm_system_prompt">
<TextArea
rows={4}
placeholder="可选。对话与测试时会作为 system prompt 一并发送。"
/>
</Form.Item>
</>
)}
<Form.Item label="描述" name="description">
<TextArea rows={2} placeholder="可填写用途、场景、适用业务等说明" />
</Form.Item>
<Space size={24}>
<Form.Item label="启用状态" name="is_active" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
<Form.Item label="设为默认" name="is_default" valuePropName="checked">
<Switch checkedChildren="默认" unCheckedChildren="普通" />
</Form.Item>
</Space>
<Form.Item label="启用状态" name="is_active" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
</Form>
</Modal>
</div>