v0.9.8
parent
e6c92551ea
commit
31cf2bfc1c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
- 修改MD文件时重新向量化
|
||||
- 删除MD文件时清理向量数据
|
||||
- PDF文件不触发向量化
|
||||
**Status**: In Progress (核心服务完成,待数据库创建)
|
||||
**Status**: Complete
|
||||
|
||||
## Stage 3: 知识库对话功能
|
||||
**Goal**: 基于ZVec向量化的文档,实现大模型知识库对话
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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="模型测试成功")
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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}')>"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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="是否默认")
|
||||
|
|
|
|||
|
|
@ -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}')>"
|
||||
|
|
@ -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)} 维向量",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)} 维向量",
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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='知识库对话消息表';
|
||||
|
|
|
|||
|
|
@ -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='文档向量表';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
CREATE TABLE IF NOT EXISTS `project_vectorization_task` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '任务ID',
|
||||
`task_id` VARCHAR(64) NOT NULL COMMENT '任务唯一标识',
|
||||
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '触发用户ID',
|
||||
`task_type` VARCHAR(32) NOT NULL COMMENT '任务类型:incremental/full',
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态:pending/running/success/failed',
|
||||
`total` INT NOT NULL DEFAULT 0 COMMENT '文件总数',
|
||||
`processed` INT NOT NULL DEFAULT 0 COMMENT '处理成功数',
|
||||
`skipped` INT NOT NULL DEFAULT 0 COMMENT '跳过数',
|
||||
`failed` INT NOT NULL DEFAULT 0 COMMENT '失败数',
|
||||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||||
`started_at` DATETIME DEFAULT NULL COMMENT '开始时间',
|
||||
`finished_at` DATETIME DEFAULT NULL COMMENT '完成时间',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY `uk_vector_task_id` (`task_id`),
|
||||
INDEX `idx_vector_task_project_status` (`project_id`, `status`),
|
||||
INDEX `idx_vector_task_user_id` (`user_id`),
|
||||
INDEX `idx_vector_task_created_at` (`created_at`),
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目向量化任务表';
|
||||
|
|
@ -202,6 +202,108 @@ CREATE TABLE IF NOT EXISTS `share_links` (
|
|||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表';
|
||||
|
||||
-- 12. LLM 模型配置表
|
||||
CREATE TABLE IF NOT EXISTS `llm_model_config` (
|
||||
`config_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID',
|
||||
`model_code` VARCHAR(128) NOT NULL COMMENT '模型编码',
|
||||
`model_name` VARCHAR(255) NOT NULL COMMENT '模型名称',
|
||||
`model_type` VARCHAR(32) NOT NULL DEFAULT 'chat' COMMENT '模型类型: chat/embedding',
|
||||
`provider` VARCHAR(64) DEFAULT NULL COMMENT '模型提供方',
|
||||
`endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '接口地址',
|
||||
`api_key` VARCHAR(512) DEFAULT NULL COMMENT 'API Key',
|
||||
`llm_model_name` VARCHAR(128) NOT NULL COMMENT '模型名称/部署名',
|
||||
`llm_timeout` INT NOT NULL DEFAULT 120 COMMENT '超时时间(秒)',
|
||||
`llm_temperature` DECIMAL(5,2) NOT NULL DEFAULT 0.70 COMMENT '温度',
|
||||
`llm_top_p` DECIMAL(5,2) NOT NULL DEFAULT 0.90 COMMENT 'Top P',
|
||||
`llm_max_tokens` INT NOT NULL DEFAULT 2048 COMMENT '最大输出 Token',
|
||||
`llm_system_prompt` TEXT DEFAULT NULL COMMENT '系统提示词',
|
||||
`embedding_dimension` INT DEFAULT NULL COMMENT '向量维度(仅 embedding 类型)',
|
||||
`description` VARCHAR(500) DEFAULT NULL COMMENT '描述',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY `uk_model_code` (`model_code`),
|
||||
INDEX `idx_model_code` (`model_code`),
|
||||
INDEX `idx_model_type` (`model_type`),
|
||||
INDEX `idx_is_active` (`is_active`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='LLM 模型配置表';
|
||||
|
||||
-- 13. 文档向量表
|
||||
CREATE TABLE IF NOT EXISTS `document_vector` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '向量ID',
|
||||
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||
`file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径',
|
||||
`content_hash` VARCHAR(64) DEFAULT NULL COMMENT '内容哈希值,用于判断文件是否变更',
|
||||
`zvec_id` VARCHAR(256) DEFAULT NULL COMMENT 'ZVec返回的向量ID',
|
||||
`zvec_response` TEXT DEFAULT NULL COMMENT 'ZVec完整响应JSON',
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'success' COMMENT '向量化状态:success/failed/pending',
|
||||
`error_message` VARCHAR(500) DEFAULT NULL COMMENT '错误信息',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX `idx_project_file` (`project_id`, `file_path`),
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文档向量表';
|
||||
|
||||
-- 14. 知识库对话会话表
|
||||
CREATE TABLE IF NOT EXISTS `chat_session` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '会话ID',
|
||||
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||
`llm_config_id` BIGINT NOT NULL COMMENT 'LLM配置ID',
|
||||
`title` VARCHAR(255) NOT NULL COMMENT '会话标题',
|
||||
`description` TEXT DEFAULT NULL COMMENT '会话描述',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否激活',
|
||||
`message_count` INT NOT NULL DEFAULT 0 COMMENT '消息数',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX `idx_project_id` (`project_id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_project_user` (`project_id`, `user_id`),
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`llm_config_id`) REFERENCES `llm_model_config`(`config_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库对话会话表';
|
||||
|
||||
-- 15. 知识库对话消息表
|
||||
CREATE TABLE IF NOT EXISTS `chat_message` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID',
|
||||
`session_id` BIGINT NOT NULL COMMENT '会话ID',
|
||||
`role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)',
|
||||
`content` TEXT NOT NULL COMMENT '消息内容',
|
||||
`referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)',
|
||||
`tokens_used` INT DEFAULT NULL COMMENT '消耗的token数',
|
||||
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
INDEX `idx_session_id` (`session_id`),
|
||||
FOREIGN KEY (`session_id`) REFERENCES `chat_session`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库对话消息表';
|
||||
|
||||
-- 16. 项目向量化任务表
|
||||
CREATE TABLE IF NOT EXISTS `project_vectorization_task` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '任务ID',
|
||||
`task_id` VARCHAR(64) NOT NULL COMMENT '任务唯一标识',
|
||||
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '触发用户ID',
|
||||
`task_type` VARCHAR(32) NOT NULL COMMENT '任务类型:incremental/full',
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态:pending/running/success/failed',
|
||||
`total` INT NOT NULL DEFAULT 0 COMMENT '文件总数',
|
||||
`processed` INT NOT NULL DEFAULT 0 COMMENT '处理成功数',
|
||||
`skipped` INT NOT NULL DEFAULT 0 COMMENT '跳过数',
|
||||
`failed` INT NOT NULL DEFAULT 0 COMMENT '失败数',
|
||||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||||
`started_at` DATETIME DEFAULT NULL COMMENT '开始时间',
|
||||
`finished_at` DATETIME DEFAULT NULL COMMENT '完成时间',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY `uk_vector_task_id` (`task_id`),
|
||||
INDEX `idx_vector_task_project_status` (`project_id`, `status`),
|
||||
INDEX `idx_vector_task_user_id` (`user_id`),
|
||||
INDEX `idx_vector_task_created_at` (`created_at`),
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目向量化任务表';
|
||||
|
||||
-- 插入初始角色数据
|
||||
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
|
||||
('超级管理员', 'super_admin', '拥有系统所有权限', 1),
|
||||
|
|
@ -216,7 +318,7 @@ INSERT INTO `system_menus` (`id`, `parent_id`, `menu_name`, `menu_code`, `menu_t
|
|||
(4, 1, '编辑项目', 'project:edit', 3, NULL, NULL, 3, 'project:edit'),
|
||||
(5, 1, '删除项目', 'project:delete', 3, NULL, NULL, 4, 'project:delete'),
|
||||
(10, 0, '知识库管理', 'knowledge', 1, NULL, 'FileTextOutlined', 2, NULL),
|
||||
(11, 10, '我的知识库', 'knowledge:view', 3, '/knowledges', NULL, 1, 'knowledge:view'),
|
||||
(11, 10, '我的知识库', 'knowledge:view', 2, '/chat', NULL, 1, 'knowledge:view'),
|
||||
(12, 10, '编辑知识库', 'knowledge:edit', 3, NULL, NULL, 2, 'knowledge:edit'),
|
||||
(13, 10, '删除知识库', 'knowledge:delete', 3, NULL, NULL, 3, 'knowledge:delete'),
|
||||
(20, 0, '系统管理', 'system', 1, '/system', 'SettingOutlined', 3, NULL),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
-- 迁移脚本:为 document_vector 表增加分块(chunk)支持
|
||||
-- 背景:RAG 由「整篇文档单向量」升级为「按分块向量化」,
|
||||
-- 一个文件可对应多条 chunk 记录。
|
||||
--
|
||||
-- 用法(已有数据库升级):
|
||||
-- mysql -u <user> -p <db_name> < migrate_document_vector_add_chunks.sql
|
||||
--
|
||||
-- 注意:升级后建议对所有项目执行一次「全量重建」向量化,
|
||||
-- 以将旧的整篇文档向量替换为分块向量(旧记录 chunk_index 默认为 0)。
|
||||
|
||||
ALTER TABLE `document_vector`
|
||||
ADD COLUMN `chunk_index` INT NOT NULL DEFAULT 0
|
||||
COMMENT '分块序号(0起),同一文件可有多个分块' AFTER `file_path`,
|
||||
ADD COLUMN `chunk_text` TEXT DEFAULT NULL
|
||||
COMMENT '分块首段文本,作为点击引用时的定位锚点' AFTER `chunk_index`;
|
||||
|
||||
-- 新增按 (project_id, file_path, chunk_index) 的复合索引,加速按分块查询/删除
|
||||
ALTER TABLE `document_vector`
|
||||
ADD INDEX `idx_project_file_chunk` (`project_id`, `file_path`, `chunk_index`);
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import unittest
|
||||
|
||||
from app.api.v1.chat import _canonicalize_message_citations, _compact_cited_refs
|
||||
from app.services.rag_service import RAGService
|
||||
|
||||
|
||||
class ChatCitationTest(unittest.TestCase):
|
||||
def test_duplicate_file_citations_are_renumbered_once(self):
|
||||
content, refs = _canonicalize_message_citations(
|
||||
"泰坦属于土星的卫星[2][4][5]。",
|
||||
[
|
||||
{"citation_id": 2, "file_path": "内容导航.md", "excerpt": "片段一"},
|
||||
{"citation_id": 4, "file_path": "内容导航.md", "excerpt": "片段二"},
|
||||
{"citation_id": 5, "file_path": "内容导航.md", "excerpt": "片段三"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(content, "泰坦属于土星的卫星[1]。")
|
||||
self.assertEqual(len(refs), 1)
|
||||
self.assertEqual(refs[0]["citation_id"], 1)
|
||||
self.assertEqual(refs[0]["excerpt"], "片段一\n\n片段二\n\n片段三")
|
||||
|
||||
def test_overlapping_blocks_are_not_duplicated(self):
|
||||
self.assertEqual(
|
||||
RAGService._merge_text_blocks("泰坦属于土星", "泰坦属于土星"),
|
||||
"泰坦属于土星",
|
||||
)
|
||||
|
||||
def test_single_used_reference_is_compacted_to_one(self):
|
||||
content, refs = _compact_cited_refs(
|
||||
"泰坦是土星的卫星[3],土卫二也属于土星[3]。",
|
||||
[
|
||||
{"citation_id": 1, "file_path": "其他一.md"},
|
||||
{"citation_id": 2, "file_path": "其他二.md"},
|
||||
{"citation_id": 3, "file_path": "内容导航.md"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(content, "泰坦是土星的卫星[1],土卫二也属于土星[1]。")
|
||||
self.assertEqual(refs, [{
|
||||
"citation_id": 1,
|
||||
"file_path": "内容导航.md",
|
||||
"anchor_text": "",
|
||||
"excerpt": "",
|
||||
}])
|
||||
|
||||
def test_used_references_follow_first_appearance_order(self):
|
||||
content, refs = _compact_cited_refs(
|
||||
"先使用第三份[3],再使用第一份[1]。",
|
||||
[
|
||||
{"citation_id": 1, "file_path": "第一份.md"},
|
||||
{"citation_id": 2, "file_path": "第二份.md"},
|
||||
{"citation_id": 3, "file_path": "第三份.md"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(content, "先使用第三份[1],再使用第一份[2]。")
|
||||
self.assertEqual(
|
||||
[(ref["citation_id"], ref["file_path"]) for ref in refs],
|
||||
[(1, "第三份.md"), (2, "第一份.md")],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination } from 'antd'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, Progress, Alert, List, Spin } from 'antd'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined } from '@ant-design/icons'
|
||||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
|
||||
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
|
||||
import { getUserList } from '@/api/users'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
import ListActionBar from '@/components/ListActionBar/ListActionBar'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import useProjectKnowledge from './useProjectKnowledge'
|
||||
import './ProjectList.css'
|
||||
|
||||
function ProjectList({ type = 'my' }) {
|
||||
|
|
@ -38,6 +39,17 @@ function ProjectList({ type = 'my' }) {
|
|||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const pageSize = 8
|
||||
|
||||
const {
|
||||
kbModalVisible,
|
||||
progress,
|
||||
currentTask,
|
||||
loadingProgress,
|
||||
vectorizing,
|
||||
openKnowledgeModal,
|
||||
closeKnowledgeModal,
|
||||
runVectorize,
|
||||
} = useProjectKnowledge()
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
setCurrentPage(1)
|
||||
|
|
@ -242,6 +254,13 @@ function ProjectList({ type = 'my' }) {
|
|||
fetchGitRepos(project.id)
|
||||
}
|
||||
|
||||
// 打开知识库(向量化进度)弹窗
|
||||
const handleKnowledge = (e, project) => {
|
||||
e.stopPropagation()
|
||||
setCurrentProject(project)
|
||||
openKnowledgeModal(project)
|
||||
}
|
||||
|
||||
// 加载Git仓库列表
|
||||
const fetchGitRepos = async (projectId) => {
|
||||
setLoadingRepos(true)
|
||||
|
|
@ -605,6 +624,7 @@ function ProjectList({ type = 'my' }) {
|
|||
actions={type === 'my' ? [
|
||||
<SettingOutlined key="settings" onClick={(e) => handleEdit(e, project)} />,
|
||||
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
|
||||
<DatabaseOutlined key="kb" onClick={(e) => handleKnowledge(e, project)} />,
|
||||
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
|
||||
] : [
|
||||
<EyeOutlined key="view" />,
|
||||
|
|
@ -1136,6 +1156,119 @@ function ProjectList({ type = 'my' }) {
|
|||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 知识库向量化弹窗 */}
|
||||
<Modal
|
||||
title={`知识库 - ${currentProject?.name || ''}`}
|
||||
open={kbModalVisible}
|
||||
onCancel={closeKnowledgeModal}
|
||||
width={640}
|
||||
footer={[
|
||||
<Button key="close" onClick={closeKnowledgeModal}>关闭</Button>,
|
||||
<Button
|
||||
key="incremental"
|
||||
type="primary"
|
||||
loading={vectorizing}
|
||||
disabled={vectorizing || !progress?.embedding_ready}
|
||||
onClick={() => runVectorize(currentProject?.id, { force: false })}
|
||||
>
|
||||
增量向量化
|
||||
</Button>,
|
||||
<Button
|
||||
key="full"
|
||||
danger
|
||||
loading={vectorizing}
|
||||
disabled={vectorizing || !progress?.embedding_ready}
|
||||
onClick={() => runVectorize(currentProject?.id, { force: true })}
|
||||
>
|
||||
全量向量化
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{loadingProgress && !progress ? (
|
||||
<div style={{ textAlign: 'center', padding: '32px 0', color: '#999' }}>
|
||||
加载向量化进度...
|
||||
</div>
|
||||
) : !progress ? (
|
||||
<Empty description="暂无数据" />
|
||||
) : (
|
||||
<div>
|
||||
{!progress.embedding_ready && (
|
||||
<div style={{ marginBottom: 16, padding: '10px 14px', borderRadius: 8, background: '#fff7e6', border: '1px solid #ffe7ba', color: '#d46b08' }}>
|
||||
尚未配置可用的向量模型(Embedding),请先到「模型配置 - 向量模型」中添加并启用,再进行向量化。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentTask && (
|
||||
<Alert
|
||||
style={{ marginBottom: 16 }}
|
||||
type={currentTask.status === 'failed' ? 'error' : currentTask.status === 'success' ? 'success' : 'info'}
|
||||
showIcon
|
||||
message={
|
||||
`最近任务:${currentTask.task_type === 'full' ? '全量向量化' : '增量向量化'} · ` +
|
||||
`${currentTask.status === 'pending' ? '等待执行' : currentTask.status === 'running' ? '后台执行中' : currentTask.status === 'success' ? '已完成' : '执行失败'}`
|
||||
}
|
||||
description={
|
||||
currentTask.status === 'failed'
|
||||
? (currentTask.error_message || '任务执行失败')
|
||||
: `处理 ${currentTask.processed || 0} 个,跳过 ${currentTask.skipped || 0} 个,失败 ${currentTask.failed || 0} 个`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Progress
|
||||
percent={progress.percent}
|
||||
status={progress.failed > 0 ? 'exception' : (progress.pending > 0 ? 'active' : 'success')}
|
||||
/>
|
||||
|
||||
<Row gutter={16} style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Col span={6}>
|
||||
<div style={{ fontSize: 22, fontWeight: 600 }}>{progress.total}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>MD 文件总数</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#52c41a' }}>{progress.success}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>已向量化</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#faad14' }}>{progress.pending}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>待处理</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#ff4d4f' }}>{progress.failed}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>失败</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{progress.failed_items?.length > 0 && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600, color: '#ff4d4f' }}>失败明细</div>
|
||||
<div style={{ maxHeight: 160, overflow: 'auto' }}>
|
||||
{progress.failed_items.map((item) => (
|
||||
<div key={item.file_path} style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0', fontSize: 13 }}>
|
||||
<div style={{ wordBreak: 'break-all' }}>{item.file_path}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>{item.error}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress.pending_items?.length > 0 && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600, color: '#faad14' }}>待处理文件</div>
|
||||
<div style={{ maxHeight: 140, overflow: 'auto' }}>
|
||||
{progress.pending_items.map((path) => (
|
||||
<div key={path} style={{ padding: '4px 0', fontSize: 13, wordBreak: 'break-all', color: '#666' }}>
|
||||
{path}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue