v0.9.8
parent
e6c92551ea
commit
31cf2bfc1c
|
|
@ -13,6 +13,11 @@ REDIS_PASSWORD=redis_password_change_me
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_DB=8
|
REDIS_DB=8
|
||||||
|
|
||||||
|
# ==================== RAG配置 ====================
|
||||||
|
# 文档分块大小(字符数)与相邻分块的重叠字符数
|
||||||
|
CHUNK_SIZE=800
|
||||||
|
CHUNK_OVERLAP=150
|
||||||
|
|
||||||
# ==================== 应用配置 ====================
|
# ==================== 应用配置 ====================
|
||||||
# JWT 密钥(请务必修改为随机字符串)
|
# JWT 密钥(请务必修改为随机字符串)
|
||||||
SECRET_KEY=your-secret-key-change-me-in-production-use-openssl-rand-hex-32
|
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_PASSWORD=admin@123
|
||||||
ADMIN_EMAIL=admin@unisspace.com
|
ADMIN_EMAIL=admin@unisspace.com
|
||||||
ADMIN_NICKNAME=系统管理员
|
ADMIN_NICKNAME=系统管理员
|
||||||
|
# 开发环境跳过 SSL 证书验证(生产环境请勿开启)
|
||||||
|
DISABLE_SSL_VERIFY=true
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
- 修改MD文件时重新向量化
|
- 修改MD文件时重新向量化
|
||||||
- 删除MD文件时清理向量数据
|
- 删除MD文件时清理向量数据
|
||||||
- PDF文件不触发向量化
|
- PDF文件不触发向量化
|
||||||
**Status**: In Progress (核心服务完成,待数据库创建)
|
**Status**: Complete
|
||||||
|
|
||||||
## Stage 3: 知识库对话功能
|
## Stage 3: 知识库对话功能
|
||||||
**Goal**: 基于ZVec向量化的文档,实现大模型知识库对话
|
**Goal**: 基于ZVec向量化的文档,实现大模型知识库对话
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,126 @@
|
||||||
"""
|
"""
|
||||||
知识库对话相关 API
|
知识库对话相关 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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import select, delete
|
||||||
from typing import List
|
|
||||||
import json
|
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_user
|
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.user import User
|
||||||
from app.models.chat_session import ChatSession, ChatMessage
|
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.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.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
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
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):
|
class ChatCreateRequest(BaseModel):
|
||||||
"""创建对话会话请求"""
|
"""创建对话会话请求"""
|
||||||
|
|
@ -35,6 +135,11 @@ class ChatMessageRequest(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSessionUpdateRequest(BaseModel):
|
||||||
|
"""更新对话会话请求"""
|
||||||
|
title: str
|
||||||
|
|
||||||
|
|
||||||
class ChatResponse(BaseModel):
|
class ChatResponse(BaseModel):
|
||||||
"""对话响应"""
|
"""对话响应"""
|
||||||
session_id: int
|
session_id: int
|
||||||
|
|
@ -42,6 +147,181 @@ class ChatResponse(BaseModel):
|
||||||
assistant_message: str
|
assistant_message: str
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_refs(value):
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_refs(refs):
|
||||||
|
"""将存储的引用归一化为 [{citation_id, file_path, anchor_text, excerpt}] 形式。
|
||||||
|
|
||||||
|
兼容旧格式(纯文件路径数组)与新格式(对象数组)。
|
||||||
|
"""
|
||||||
|
normalized = []
|
||||||
|
for idx, ref in enumerate(refs or [], 1):
|
||||||
|
if isinstance(ref, dict):
|
||||||
|
file_path = ref.get("file_path")
|
||||||
|
citation_id = ref.get("citation_id", idx)
|
||||||
|
anchor_text = ref.get("anchor_text") or ""
|
||||||
|
excerpt = ref.get("excerpt") or ""
|
||||||
|
else:
|
||||||
|
file_path = ref
|
||||||
|
citation_id = idx
|
||||||
|
anchor_text = ""
|
||||||
|
excerpt = ""
|
||||||
|
if file_path:
|
||||||
|
normalized.append({
|
||||||
|
"citation_id": citation_id,
|
||||||
|
"file_path": file_path,
|
||||||
|
"anchor_text": anchor_text,
|
||||||
|
"excerpt": excerpt,
|
||||||
|
})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reference_items(refs, project_id=None):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"citation_id": ref["citation_id"],
|
||||||
|
"file_path": ref["file_path"],
|
||||||
|
"file_name": ref["file_path"].rsplit("/", 1)[-1],
|
||||||
|
"anchor_text": ref.get("anchor_text") or "",
|
||||||
|
"excerpt": ref.get("excerpt") or "",
|
||||||
|
"project_id": project_id,
|
||||||
|
}
|
||||||
|
for ref in _normalize_refs(refs)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _canonicalize_message_citations(content: str, refs):
|
||||||
|
"""按文件合并引用并将正文引用重新编号,兼容历史重复分块数据。"""
|
||||||
|
normalized = _normalize_refs(refs)
|
||||||
|
if not normalized:
|
||||||
|
return content, []
|
||||||
|
|
||||||
|
canonical_refs = []
|
||||||
|
file_to_id = {}
|
||||||
|
old_to_new = {}
|
||||||
|
for ref in normalized:
|
||||||
|
file_path = ref["file_path"]
|
||||||
|
old_id = int(ref["citation_id"])
|
||||||
|
new_id = file_to_id.get(file_path)
|
||||||
|
if new_id is None:
|
||||||
|
new_id = len(canonical_refs) + 1
|
||||||
|
file_to_id[file_path] = new_id
|
||||||
|
canonical_refs.append({**ref, "citation_id": new_id})
|
||||||
|
else:
|
||||||
|
canonical_ref = canonical_refs[new_id - 1]
|
||||||
|
current_excerpt = canonical_ref.get("excerpt", "").strip()
|
||||||
|
incoming_excerpt = ref.get("excerpt", "").strip()
|
||||||
|
if incoming_excerpt and incoming_excerpt not in current_excerpt:
|
||||||
|
canonical_ref["excerpt"] = (
|
||||||
|
f"{current_excerpt}\n\n{incoming_excerpt}"
|
||||||
|
if current_excerpt else incoming_excerpt
|
||||||
|
)
|
||||||
|
old_to_new[old_id] = new_id
|
||||||
|
|
||||||
|
def replace_citation(match):
|
||||||
|
old_id = int(match.group(1))
|
||||||
|
return f"[{old_to_new.get(old_id, old_id)}]"
|
||||||
|
|
||||||
|
normalized_content = _CITATION_PATTERN.sub(replace_citation, content or "")
|
||||||
|
normalized_content = re.sub(r"\[(\d+)\](?:\s*\[\1\])+", r"[\1]", normalized_content)
|
||||||
|
return normalized_content, canonical_refs
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_event(event: str, data) -> str:
|
||||||
|
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
_CITATION_PATTERN = re.compile(r"\[(\d+)\]")
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_cited_refs(answer: str, retrieved_docs):
|
||||||
|
"""仅保留实际使用的文档,并按正文首次引用顺序连续编号。"""
|
||||||
|
docs_by_id = {
|
||||||
|
int(doc.get("citation_id", index)): doc
|
||||||
|
for index, doc in enumerate(retrieved_docs, 1)
|
||||||
|
}
|
||||||
|
old_to_new = {}
|
||||||
|
file_to_new = {}
|
||||||
|
refs = []
|
||||||
|
|
||||||
|
for match in _CITATION_PATTERN.finditer(answer or ""):
|
||||||
|
old_id = int(match.group(1))
|
||||||
|
doc = docs_by_id.get(old_id)
|
||||||
|
if doc is None or old_id in old_to_new:
|
||||||
|
continue
|
||||||
|
file_path = doc.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_id = file_to_new.get(file_path)
|
||||||
|
if new_id is None:
|
||||||
|
new_id = len(refs) + 1
|
||||||
|
file_to_new[file_path] = new_id
|
||||||
|
refs.append({
|
||||||
|
"citation_id": new_id,
|
||||||
|
"file_path": file_path,
|
||||||
|
"anchor_text": doc.get("anchor_text") or "",
|
||||||
|
"excerpt": doc.get("excerpt") or doc.get("content") or doc.get("anchor_text") or "",
|
||||||
|
})
|
||||||
|
old_to_new[old_id] = new_id
|
||||||
|
|
||||||
|
def replace_citation(match):
|
||||||
|
old_id = int(match.group(1))
|
||||||
|
return f"[{old_to_new.get(old_id, old_id)}]"
|
||||||
|
|
||||||
|
normalized_answer = _CITATION_PATTERN.sub(replace_citation, answer or "")
|
||||||
|
normalized_answer = re.sub(
|
||||||
|
r"\[(\d+)\](?:\s*\[\1\])+", r"[\1]", normalized_answer
|
||||||
|
)
|
||||||
|
return normalized_answer, refs
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_session_title(db, llm_config_id: int, question: str, answer: str) -> Optional[str]:
|
||||||
|
"""根据首轮问答,用 LLM 生成简洁的会话标题。失败时返回 None。"""
|
||||||
|
stmt = select(LLMModelConfig).where(LLMModelConfig.config_id == llm_config_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
llm_config = result.scalar_one_or_none()
|
||||||
|
if not llm_config:
|
||||||
|
return None
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"你是一个会话标题生成器。请根据用户的问题和助手的回答,"
|
||||||
|
"用一句不超过 16 个字的中文短语概括对话主题,作为标题。"
|
||||||
|
"只输出标题本身,不要引号、标点结尾或任何解释。"
|
||||||
|
)
|
||||||
|
user_content = f"用户问题:{question}\n\n助手回答:{(answer or '')[:500]}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
title = await LLMProviderService.generate_text(
|
||||||
|
provider=llm_config.provider,
|
||||||
|
endpoint_url=llm_config.endpoint_url,
|
||||||
|
api_key=llm_config.api_key,
|
||||||
|
llm_model_name=llm_config.llm_model_name,
|
||||||
|
timeout=min(int(llm_config.llm_timeout or 60), 60),
|
||||||
|
temperature=0.3,
|
||||||
|
top_p=float(llm_config.llm_top_p or 0.9),
|
||||||
|
max_tokens=32,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
messages=[{"role": "user", "content": user_content}],
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - 标题生成失败不应影响主流程
|
||||||
|
logger.warning("生成会话标题失败: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
title = (title or "").strip().strip('"').strip("「」").strip()
|
||||||
|
title = title.splitlines()[0].strip() if title else ""
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
return title[:60]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sessions", response_model=dict)
|
@router.post("/sessions", response_model=dict)
|
||||||
async def create_chat_session(
|
async def create_chat_session(
|
||||||
req: ChatCreateRequest,
|
req: ChatCreateRequest,
|
||||||
|
|
@ -49,8 +329,17 @@ async def create_chat_session(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""创建新的对话会话"""
|
"""创建新的对话会话"""
|
||||||
project = await get_project_or_404(db, req.project_id)
|
await get_project_or_404(db, req.project_id)
|
||||||
_, user_role = await require_project_read_access(db, req.project_id, current_user)
|
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(
|
session = ChatSession(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
|
@ -64,29 +353,33 @@ async def create_chat_session(
|
||||||
|
|
||||||
return success_response(data={
|
return success_response(data={
|
||||||
"session_id": session.id,
|
"session_id": session.id,
|
||||||
|
"project_id": session.project_id,
|
||||||
|
"llm_config_id": session.llm_config_id,
|
||||||
"title": session.title,
|
"title": session.title,
|
||||||
"created_at": session.created_at.isoformat(),
|
"created_at": session.created_at.isoformat(),
|
||||||
|
"updated_at": session.updated_at.isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sessions", response_model=dict)
|
@router.get("/sessions", response_model=dict)
|
||||||
async def list_chat_sessions(
|
async def list_chat_sessions(
|
||||||
project_id: int,
|
project_id: Optional[int] = None,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""列出项目的所有对话会话"""
|
"""列出当前用户的对话会话,可按知识库筛选"""
|
||||||
await require_project_read_access(db, project_id, current_user)
|
stmt = select(ChatSession).where(ChatSession.user_id == current_user.id)
|
||||||
|
if project_id is not None:
|
||||||
stmt = select(ChatSession).where(
|
await require_project_read_access(db, project_id, current_user)
|
||||||
ChatSession.project_id == project_id,
|
stmt = stmt.where(ChatSession.project_id == project_id)
|
||||||
ChatSession.user_id == current_user.id,
|
stmt = stmt.order_by(ChatSession.updated_at.desc(), ChatSession.created_at.desc())
|
||||||
).order_by(ChatSession.created_at.desc())
|
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
sessions = result.scalars().all()
|
sessions = result.scalars().all()
|
||||||
|
|
||||||
return success_response(data=[{
|
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,
|
"title": s.title,
|
||||||
"created_at": s.created_at.isoformat(),
|
"created_at": s.created_at.isoformat(),
|
||||||
"updated_at": s.updated_at.isoformat(),
|
"updated_at": s.updated_at.isoformat(),
|
||||||
|
|
@ -118,12 +411,99 @@ async def get_session_messages(
|
||||||
msg_result = await db.execute(msg_stmt)
|
msg_result = await db.execute(msg_stmt)
|
||||||
messages = msg_result.scalars().all()
|
messages = msg_result.scalars().all()
|
||||||
|
|
||||||
return success_response(data=[{
|
data = []
|
||||||
"id": m.id,
|
for message in messages:
|
||||||
"role": m.role,
|
stored_refs = _parse_refs(message.referenced_files)
|
||||||
"content": m.content,
|
content = message.content
|
||||||
"created_at": m.created_at.isoformat(),
|
refs = stored_refs
|
||||||
} for m in messages])
|
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)
|
@router.post("/send", response_model=dict)
|
||||||
|
|
@ -133,6 +513,10 @@ async def send_chat_message(
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
stmt = select(ChatSession).where(ChatSession.id == req.session_id)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
session = result.scalar_one_or_none()
|
session = result.scalar_one_or_none()
|
||||||
|
|
@ -148,55 +532,69 @@ async def send_chat_message(
|
||||||
query_message = ChatMessage(
|
query_message = ChatMessage(
|
||||||
session_id=req.session_id,
|
session_id=req.session_id,
|
||||||
role="user",
|
role="user",
|
||||||
content=req.message,
|
content=question,
|
||||||
)
|
)
|
||||||
db.add(query_message)
|
db.add(query_message)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
query_vector = await _get_query_vector(req.message)
|
msg_stmt = (
|
||||||
|
select(ChatMessage)
|
||||||
retrieved_docs = await rag_service.retrieve_documents(
|
.where(
|
||||||
db,
|
ChatMessage.session_id == req.session_id,
|
||||||
session.project_id,
|
ChatMessage.id != query_message.id,
|
||||||
query_vector,
|
)
|
||||||
top_k=5,
|
.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)
|
msg_result = await db.execute(msg_stmt)
|
||||||
prev_messages = msg_result.scalars().all()
|
prev_messages = list(reversed(msg_result.scalars().all()))
|
||||||
|
|
||||||
conversation_history = [
|
conversation_history = [
|
||||||
{"role": m.role, "content": m.content}
|
{"role": m.role, "content": m.content}
|
||||||
for m in prev_messages
|
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(
|
assistant_response = await rag_service.generate_response(
|
||||||
db,
|
db,
|
||||||
req.message,
|
question,
|
||||||
query_vector,
|
|
||||||
session.project_id,
|
session.project_id,
|
||||||
session.llm_config_id,
|
session.llm_config_id,
|
||||||
retrieved_docs,
|
retrieved_docs,
|
||||||
conversation_history,
|
conversation_history,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 仅保留实际引用的文档,并把引用编号压缩为从 1 开始的连续序列。
|
||||||
|
assistant_response, cited_refs = _compact_cited_refs(
|
||||||
|
assistant_response, retrieved_docs
|
||||||
|
)
|
||||||
|
|
||||||
assistant_message = ChatMessage(
|
assistant_message = ChatMessage(
|
||||||
session_id=req.session_id,
|
session_id=req.session_id,
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content=assistant_response,
|
content=assistant_response,
|
||||||
|
referenced_files=json.dumps(cited_refs, ensure_ascii=False) if cited_refs else None,
|
||||||
)
|
)
|
||||||
db.add(assistant_message)
|
db.add(assistant_message)
|
||||||
|
|
||||||
|
# 累加会话消息计数(用户 + 助手 共 2 条)
|
||||||
|
session.message_count = (session.message_count or 0) + 2
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return success_response(data={
|
return success_response(data={
|
||||||
"session_id": req.session_id,
|
"session_id": req.session_id,
|
||||||
"user_message": req.message,
|
"user_message": question,
|
||||||
"assistant_message": assistant_response,
|
"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:
|
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)
|
@router.delete("/sessions/{session_id}", response_model=dict)
|
||||||
async def delete_chat_session(
|
async def delete_chat_session(
|
||||||
session_id: int,
|
session_id: int,
|
||||||
|
|
@ -232,14 +813,59 @@ async def delete_chat_session(
|
||||||
return success_response(message="对话会话已删除")
|
return success_response(message="对话会话已删除")
|
||||||
|
|
||||||
|
|
||||||
async def _get_query_vector(query: str) -> List[float]:
|
@router.delete("/messages/{message_id}", response_model=dict)
|
||||||
"""从查询文本生成向量"""
|
async def delete_chat_message(
|
||||||
try:
|
message_id: int,
|
||||||
import numpy as np
|
current_user: User = Depends(get_current_user),
|
||||||
from sklearn.feature_extraction.text import TfidfVectorizer
|
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)
|
if not row:
|
||||||
query_vector = vectorizer.fit_transform([query]).toarray()[0]
|
raise HTTPException(status_code=404, detail="消息不存在")
|
||||||
return query_vector.tolist()
|
|
||||||
except Exception:
|
msg, session = row
|
||||||
return [0.0] * 768
|
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 import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
||||||
from fastapi.responses import StreamingResponse, FileResponse
|
from fastapi.responses import StreamingResponse, FileResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
from typing import List
|
from typing import List
|
||||||
import os
|
import os
|
||||||
import zipfile
|
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.user import User
|
||||||
from app.models.log import OperationLog
|
from app.models.log import OperationLog
|
||||||
from app.models.share import ShareLink
|
from app.models.share import ShareLink
|
||||||
|
from app.models.project import Project, ProjectMember
|
||||||
from app.schemas.file import (
|
from app.schemas.file import (
|
||||||
FileTreeNode,
|
FileTreeNode,
|
||||||
FileSaveRequest,
|
FileSaveRequest,
|
||||||
|
|
@ -98,8 +100,6 @@ async def get_project_tree(
|
||||||
# 生成目录树
|
# 生成目录树
|
||||||
tree = storage_service.generate_tree(project_root)
|
tree = storage_service.generate_tree(project_root)
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
=======
|
|
||||||
share_result = await db.execute(
|
share_result = await db.execute(
|
||||||
select(ShareLink.file_path).where(
|
select(ShareLink.file_path).where(
|
||||||
ShareLink.project_id == project_id,
|
ShareLink.project_id == project_id,
|
||||||
|
|
@ -128,7 +128,6 @@ async def get_project_tree(
|
||||||
if member:
|
if member:
|
||||||
user_role = member.role
|
user_role = member.role
|
||||||
|
|
||||||
>>>>>>> origin/main
|
|
||||||
return success_response(data={
|
return success_response(data={
|
||||||
"tree": tree,
|
"tree": tree,
|
||||||
"user_role": user_role,
|
"user_role": user_role,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class LLMModelConfigUpsertRequest(BaseModel):
|
||||||
|
|
||||||
model_code: Optional[str] = None
|
model_code: Optional[str] = None
|
||||||
model_name: 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)
|
provider: str = Field(..., min_length=1, max_length=64)
|
||||||
endpoint_url: Optional[str] = Field(None, max_length=512)
|
endpoint_url: Optional[str] = Field(None, max_length=512)
|
||||||
api_key: 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_timeout: int = Field(120, ge=5, le=600)
|
||||||
llm_temperature: float = Field(0.70, ge=0, le=2)
|
llm_temperature: float = Field(0.70, ge=0, le=2)
|
||||||
llm_top_p: float = Field(0.90, ge=0, le=1)
|
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
|
llm_system_prompt: Optional[str] = None
|
||||||
|
embedding_dimension: Optional[int] = Field(None, ge=1, le=8192)
|
||||||
description: Optional[str] = Field(None, max_length=500)
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
is_default: bool = False
|
is_default: bool = False
|
||||||
|
|
@ -67,6 +69,7 @@ def serialize_model_config(config: LLMModelConfig, include_api_key: bool = False
|
||||||
"config_id": config.config_id,
|
"config_id": config.config_id,
|
||||||
"model_code": config.model_code,
|
"model_code": config.model_code,
|
||||||
"model_name": config.model_name,
|
"model_name": config.model_name,
|
||||||
|
"model_type": config.model_type or "chat",
|
||||||
"provider": config.provider,
|
"provider": config.provider,
|
||||||
"endpoint_url": config.endpoint_url,
|
"endpoint_url": config.endpoint_url,
|
||||||
"llm_model_name": config.llm_model_name,
|
"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_top_p": float(config.llm_top_p or 0),
|
||||||
"llm_max_tokens": config.llm_max_tokens,
|
"llm_max_tokens": config.llm_max_tokens,
|
||||||
"llm_system_prompt": config.llm_system_prompt,
|
"llm_system_prompt": config.llm_system_prompt,
|
||||||
|
"embedding_dimension": config.embedding_dimension,
|
||||||
"description": config.description,
|
"description": config.description,
|
||||||
"is_active": bool(config.is_active),
|
"is_active": bool(config.is_active),
|
||||||
"is_default": bool(config.is_default),
|
"is_default": bool(config.is_default),
|
||||||
|
|
@ -102,13 +106,22 @@ def normalize_payload(payload: LLMModelConfigUpsertRequest) -> dict:
|
||||||
data = payload.model_dump()
|
data = payload.model_dump()
|
||||||
provider = data["provider"]
|
provider = data["provider"]
|
||||||
llm_model_name = data["llm_model_name"]
|
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_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)
|
data["endpoint_url"] = data.get("endpoint_url") or LLMProviderService.get_default_endpoint_url(provider)
|
||||||
if data["is_default"]:
|
if data["is_default"]:
|
||||||
data["is_active"] = True
|
data["is_active"] = True
|
||||||
data["llm_temperature"] = Decimal(str(data["llm_temperature"]))
|
data["llm_temperature"] = Decimal(str(data["llm_temperature"]))
|
||||||
data["llm_top_p"] = Decimal(str(data["llm_top_p"]))
|
data["llm_top_p"] = Decimal(str(data["llm_top_p"]))
|
||||||
|
# embedding 不需要对话采样参数,但列非空,保留默认值即可
|
||||||
|
if model_type == "embedding":
|
||||||
|
data["llm_system_prompt"] = None
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -167,6 +180,7 @@ async def get_llm_model_configs(
|
||||||
page_size: int = Query(10, ge=1, le=100),
|
page_size: int = Query(10, ge=1, le=100),
|
||||||
keyword: Optional[str] = Query(None, description="搜索关键词(模型名称、编码、模型名)"),
|
keyword: Optional[str] = Query(None, description="搜索关键词(模型名称、编码、模型名)"),
|
||||||
provider: 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="启用状态筛选"),
|
is_active: Optional[bool] = Query(None, description="启用状态筛选"),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
|
@ -184,6 +198,8 @@ async def get_llm_model_configs(
|
||||||
)
|
)
|
||||||
if provider:
|
if provider:
|
||||||
conditions.append(LLMModelConfig.provider == provider)
|
conditions.append(LLMModelConfig.provider == provider)
|
||||||
|
if model_type:
|
||||||
|
conditions.append(LLMModelConfig.model_type == model_type)
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
conditions.append(LLMModelConfig.is_active == is_active)
|
conditions.append(LLMModelConfig.is_active == is_active)
|
||||||
|
|
||||||
|
|
@ -406,7 +422,13 @@ async def test_llm_model_config(
|
||||||
request_data: LLMModelConfigTestRequest,
|
request_data: LLMModelConfigTestRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""测试模型连接"""
|
"""测试模型连接(按类型分流:chat 走对话补全,embedding 走向量接口)"""
|
||||||
payload = normalize_payload(request_data)
|
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="模型测试成功")
|
return success_response(data=test_result, message="模型测试成功")
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,10 @@ class Settings(BaseSettings):
|
||||||
# 头像上传配置
|
# 头像上传配置
|
||||||
AVATAR_MAX_SIZE: int = 1 * 1024 * 1024 # 1MB
|
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"]
|
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.mcp_bot import MCPBot
|
||||||
from app.models.llm_model_config import LLMModelConfig
|
from app.models.llm_model_config import LLMModelConfig
|
||||||
from app.models.chat_session import ChatSession, ChatMessage
|
from app.models.chat_session import ChatSession, ChatMessage
|
||||||
|
from app.models.project_vectorization_task import ProjectVectorizationTask
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
|
|
@ -32,4 +33,5 @@ __all__ = [
|
||||||
"LLMModelConfig",
|
"LLMModelConfig",
|
||||||
"ChatSession",
|
"ChatSession",
|
||||||
"ChatMessage",
|
"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 sqlalchemy.sql import func
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
@ -14,8 +14,10 @@ class DocumentVector(Base):
|
||||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="向量ID")
|
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="向量ID")
|
||||||
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
||||||
file_path = Column(String(500), nullable=False, comment="文件相对路径")
|
file_path = Column(String(500), nullable=False, comment="文件相对路径")
|
||||||
content_hash = Column(String(64), comment="内容哈希值,用于判断文件是否变更")
|
chunk_index = Column(Integer, nullable=False, default=0, comment="分块序号(0起),同一文件可有多个分块")
|
||||||
zvec_id = Column(String(256), comment="ZVec返回的向量ID")
|
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")
|
zvec_response = Column(Text, comment="ZVec完整响应JSON")
|
||||||
status = Column(String(32), nullable=False, default="success", comment="向量化状态:success/failed/pending")
|
status = Column(String(32), nullable=False, default="success", comment="向量化状态:success/failed/pending")
|
||||||
error_message = Column(String(500), comment="错误信息")
|
error_message = Column(String(500), comment="错误信息")
|
||||||
|
|
@ -24,7 +26,11 @@ class DocumentVector(Base):
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_project_file", "project_id", "file_path"),
|
Index("idx_project_file", "project_id", "file_path"),
|
||||||
|
Index("idx_project_file_chunk", "project_id", "file_path", "chunk_index"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
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")
|
config_id = Column(BigInteger, primary_key=True, autoincrement=True, comment="配置ID")
|
||||||
model_code = Column(String(128), nullable=False, unique=True, index=True, comment="模型编码")
|
model_code = Column(String(128), nullable=False, unique=True, index=True, comment="模型编码")
|
||||||
model_name = Column(String(255), nullable=False, 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="模型提供方")
|
provider = Column(String(64), comment="模型提供方")
|
||||||
endpoint_url = Column(String(512), comment="接口地址")
|
endpoint_url = Column(String(512), comment="接口地址")
|
||||||
api_key = Column(String(512), comment="API Key")
|
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_timeout = Column(Integer, nullable=False, default=120, comment="超时时间(秒)")
|
||||||
llm_temperature = Column(Numeric(5, 2), nullable=False, default=0.70, 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_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="系统提示词")
|
llm_system_prompt = Column(Text, comment="系统提示词")
|
||||||
|
embedding_dimension = Column(Integer, nullable=True, default=None, comment="向量维度(仅 embedding 类型)")
|
||||||
description = Column(String(500), comment="描述")
|
description = Column(String(500), comment="描述")
|
||||||
is_active = Column(Boolean, nullable=False, default=True, index=True, comment="是否启用")
|
is_active = Column(Boolean, nullable=False, default=True, index=True, comment="是否启用")
|
||||||
is_default = Column(Boolean, nullable=False, default=False, 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 提供方测试服务
|
LLM 提供方测试服务
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import ssl
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
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]] = [
|
PROVIDER_CATALOG: List[Dict[str, str]] = [
|
||||||
|
|
@ -156,10 +160,6 @@ class LLMProviderService:
|
||||||
raise ValueError("缺少 API Key,请填写后再测试")
|
raise ValueError("缺少 API Key,请填写后再测试")
|
||||||
|
|
||||||
timeout = int(payload.get("llm_timeout") or 120)
|
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()
|
started_at = time.perf_counter()
|
||||||
preview = await asyncio.to_thread(
|
preview = await asyncio.to_thread(
|
||||||
|
|
@ -170,10 +170,6 @@ class LLMProviderService:
|
||||||
api_key,
|
api_key,
|
||||||
llm_model_name,
|
llm_model_name,
|
||||||
timeout,
|
timeout,
|
||||||
temperature,
|
|
||||||
top_p,
|
|
||||||
max_tokens,
|
|
||||||
system_prompt,
|
|
||||||
)
|
)
|
||||||
latency_ms = int((time.perf_counter() - started_at) * 1000)
|
latency_ms = int((time.perf_counter() - started_at) * 1000)
|
||||||
|
|
||||||
|
|
@ -194,10 +190,6 @@ class LLMProviderService:
|
||||||
api_key: str,
|
api_key: str,
|
||||||
llm_model_name: str,
|
llm_model_name: str,
|
||||||
timeout: int,
|
timeout: int,
|
||||||
temperature: float,
|
|
||||||
top_p: float,
|
|
||||||
max_tokens: int,
|
|
||||||
system_prompt: str,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
if protocol == "anthropic":
|
if protocol == "anthropic":
|
||||||
return cls._test_anthropic(
|
return cls._test_anthropic(
|
||||||
|
|
@ -205,10 +197,6 @@ class LLMProviderService:
|
||||||
api_key,
|
api_key,
|
||||||
llm_model_name,
|
llm_model_name,
|
||||||
timeout,
|
timeout,
|
||||||
temperature,
|
|
||||||
top_p,
|
|
||||||
max_tokens,
|
|
||||||
system_prompt,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if protocol == "gemini":
|
if protocol == "gemini":
|
||||||
|
|
@ -217,10 +205,6 @@ class LLMProviderService:
|
||||||
api_key,
|
api_key,
|
||||||
llm_model_name,
|
llm_model_name,
|
||||||
timeout,
|
timeout,
|
||||||
temperature,
|
|
||||||
top_p,
|
|
||||||
max_tokens,
|
|
||||||
system_prompt,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls._test_openai_compatible(
|
return cls._test_openai_compatible(
|
||||||
|
|
@ -229,10 +213,6 @@ class LLMProviderService:
|
||||||
api_key,
|
api_key,
|
||||||
llm_model_name,
|
llm_model_name,
|
||||||
timeout,
|
timeout,
|
||||||
temperature,
|
|
||||||
top_p,
|
|
||||||
max_tokens,
|
|
||||||
system_prompt,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -243,10 +223,6 @@ class LLMProviderService:
|
||||||
api_key: str,
|
api_key: str,
|
||||||
llm_model_name: str,
|
llm_model_name: str,
|
||||||
timeout: int,
|
timeout: int,
|
||||||
temperature: float,
|
|
||||||
top_p: float,
|
|
||||||
max_tokens: int,
|
|
||||||
system_prompt: str,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
url = cls._join_endpoint(endpoint_url, "/chat/completions")
|
url = cls._join_endpoint(endpoint_url, "/chat/completions")
|
||||||
headers = {
|
headers = {
|
||||||
|
|
@ -257,11 +233,7 @@ class LLMProviderService:
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": llm_model_name,
|
"model": llm_model_name,
|
||||||
"messages": cls._build_openai_messages(system_prompt),
|
"messages": cls._build_openai_test_messages(),
|
||||||
"temperature": temperature,
|
|
||||||
"top_p": top_p,
|
|
||||||
"max_tokens": min(max_tokens, 256),
|
|
||||||
"stream": False,
|
|
||||||
}
|
}
|
||||||
response = cls._request_json(url, headers, payload, timeout)
|
response = cls._request_json(url, headers, payload, timeout)
|
||||||
choices = response.get("choices") or []
|
choices = response.get("choices") or []
|
||||||
|
|
@ -269,6 +241,8 @@ class LLMProviderService:
|
||||||
raise ValueError("测试请求已发送,但未收到模型返回内容")
|
raise ValueError("测试请求已发送,但未收到模型返回内容")
|
||||||
|
|
||||||
content = choices[0].get("message", {}).get("content", "")
|
content = choices[0].get("message", {}).get("content", "")
|
||||||
|
if not content:
|
||||||
|
content = choices[0].get("text", "")
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
content = "".join(
|
content = "".join(
|
||||||
item.get("text", "") if isinstance(item, dict) else str(item)
|
item.get("text", "") if isinstance(item, dict) else str(item)
|
||||||
|
|
@ -286,10 +260,6 @@ class LLMProviderService:
|
||||||
api_key: str,
|
api_key: str,
|
||||||
llm_model_name: str,
|
llm_model_name: str,
|
||||||
timeout: int,
|
timeout: int,
|
||||||
temperature: float,
|
|
||||||
top_p: float,
|
|
||||||
max_tokens: int,
|
|
||||||
system_prompt: str,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
url = cls._join_endpoint(endpoint_url, "/v1/messages")
|
url = cls._join_endpoint(endpoint_url, "/v1/messages")
|
||||||
headers = {
|
headers = {
|
||||||
|
|
@ -299,9 +269,7 @@ class LLMProviderService:
|
||||||
}
|
}
|
||||||
payload = {
|
payload = {
|
||||||
"model": llm_model_name,
|
"model": llm_model_name,
|
||||||
"max_tokens": min(max_tokens, 256),
|
"max_tokens": 32,
|
||||||
"temperature": temperature,
|
|
||||||
"top_p": top_p,
|
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
|
|
@ -309,8 +277,6 @@ class LLMProviderService:
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
if system_prompt:
|
|
||||||
payload["system"] = system_prompt
|
|
||||||
|
|
||||||
response = cls._request_json(url, headers, payload, timeout)
|
response = cls._request_json(url, headers, payload, timeout)
|
||||||
content = response.get("content") or []
|
content = response.get("content") or []
|
||||||
|
|
@ -330,10 +296,6 @@ class LLMProviderService:
|
||||||
api_key: str,
|
api_key: str,
|
||||||
llm_model_name: str,
|
llm_model_name: str,
|
||||||
timeout: int,
|
timeout: int,
|
||||||
temperature: float,
|
|
||||||
top_p: float,
|
|
||||||
max_tokens: int,
|
|
||||||
system_prompt: str,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}"
|
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("/"))
|
encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/"))
|
||||||
|
|
@ -347,17 +309,8 @@ class LLMProviderService:
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"parts": [{"text": "请只回复“连接测试成功”。"}],
|
"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)
|
response = cls._request_json(url, headers, payload, timeout)
|
||||||
candidates = response.get("candidates") or []
|
candidates = response.get("candidates") or []
|
||||||
|
|
@ -373,12 +326,8 @@ class LLMProviderService:
|
||||||
return preview
|
return preview
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_openai_messages(system_prompt: str) -> List[Dict[str, str]]:
|
def _build_openai_test_messages() -> List[Dict[str, str]]:
|
||||||
messages: List[Dict[str, str]] = []
|
return [{"role": "user", "content": "请只回复“连接测试成功”。"}]
|
||||||
if system_prompt:
|
|
||||||
messages.append({"role": "system", "content": system_prompt})
|
|
||||||
messages.append({"role": "user", "content": "请只回复“连接测试成功”。"})
|
|
||||||
return messages
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _join_endpoint(base_url: str, suffix: str) -> str:
|
def _join_endpoint(base_url: str, suffix: str) -> str:
|
||||||
|
|
@ -387,6 +336,38 @@ class LLMProviderService:
|
||||||
return normalized
|
return normalized
|
||||||
return f"{normalized}{suffix}"
|
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
|
@classmethod
|
||||||
def _request_json(
|
def _request_json(
|
||||||
cls,
|
cls,
|
||||||
|
|
@ -402,8 +383,10 @@ class LLMProviderService:
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ctx = cls._build_ssl_context()
|
||||||
|
|
||||||
try:
|
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")
|
body = response.read().decode("utf-8")
|
||||||
if not body:
|
if not body:
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -439,3 +422,503 @@ class LLMProviderService:
|
||||||
|
|
||||||
return None
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
import json
|
|
||||||
|
|
||||||
from app.models.document_vector import DocumentVector
|
|
||||||
from app.models.llm_model_config import LLMModelConfig
|
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.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:
|
class RAGService:
|
||||||
|
|
@ -18,87 +29,177 @@ class RAGService:
|
||||||
async def retrieve_documents(
|
async def retrieve_documents(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
project_id: int,
|
project_id: int,
|
||||||
query_vector: List[float],
|
query: str,
|
||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
similarity_threshold: float = 0.3,
|
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""向量检索相关文档"""
|
"""通过 ZVec 向量检索相关文档,并读取文档内容"""
|
||||||
if not query_vector or len(query_vector) == 0:
|
# 多取一些分块,再按文件去重,避免高频命中文件挤掉其他相关文档。
|
||||||
|
matched = await zvec_service.search_similar(db, project_id, query, top_k * 4)
|
||||||
|
|
||||||
|
if not matched:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
stmt = select(DocumentVector).where(
|
from app.models.project import Project
|
||||||
DocumentVector.project_id == project_id,
|
stmt = select(Project).where(Project.id == project_id)
|
||||||
DocumentVector.vector.isnot(None),
|
|
||||||
)
|
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
all_vectors = result.scalars().all()
|
project = result.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
if not all_vectors:
|
|
||||||
return []
|
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:
|
try:
|
||||||
doc_vector = json.loads(doc.vector) if isinstance(doc.vector, str) else doc.vector
|
content = await _read_content(file_path)
|
||||||
similarity = RAGService._cosine_similarity(query_vector, doc_vector)
|
excerpt = RAGService._resolve_excerpt(
|
||||||
if similarity >= similarity_threshold:
|
content=content,
|
||||||
scored_docs.append({
|
chunk_text=chunk_text,
|
||||||
"doc": doc,
|
chunk_index=item.get("chunk_index", 0),
|
||||||
"similarity": similarity,
|
)
|
||||||
"file_path": doc.file_path,
|
snippet = RAGService._extract_chunk_context(content, chunk_text, excerpt)
|
||||||
"content_preview": doc.content[:500] if doc.content else "",
|
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:
|
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
|
continue
|
||||||
|
|
||||||
scored_docs.sort(key=lambda x: x["similarity"], reverse=True)
|
existing["score"] = max(existing.get("score", 0.0), doc.get("score", 0.0))
|
||||||
return scored_docs[:top_k]
|
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
|
@staticmethod
|
||||||
def _cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
|
def _merge_text_blocks(existing: str, incoming: str) -> str:
|
||||||
"""计算余弦相似度"""
|
"""合并同一文件的命中内容,避免重复分块生成多个引用。"""
|
||||||
if len(vec1) != len(vec2) or len(vec1) == 0:
|
current = (existing or "").strip()
|
||||||
return 0.0
|
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))
|
@staticmethod
|
||||||
magnitude1 = sum(a * a for a in vec1) ** 0.5
|
def _resolve_excerpt(content: str, chunk_text: str, chunk_index: int) -> str:
|
||||||
magnitude2 = sum(b * b for b in vec2) ** 0.5
|
"""返回引用预览片段。
|
||||||
|
|
||||||
if magnitude1 == 0 or magnitude2 == 0:
|
document_vector.chunk_text 是向量命中的分块文本,应直接作为引用预览。
|
||||||
return 0.0
|
仅在 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
|
@staticmethod
|
||||||
async def generate_response(
|
async def generate_response(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
query: str,
|
query: str,
|
||||||
query_vector: List[float],
|
|
||||||
project_id: int,
|
project_id: int,
|
||||||
llm_config_id: int,
|
llm_config_id: int,
|
||||||
retrieved_docs: List[Dict[str, Any]],
|
retrieved_docs: List[Dict[str, Any]],
|
||||||
conversation_history: List[Dict[str, str]],
|
conversation_history: List[Dict[str, str]],
|
||||||
) -> str:
|
) -> str:
|
||||||
"""基于检索文档生成对话回复"""
|
"""基于检索文档生成对话回复"""
|
||||||
stmt = select(LLMModelConfig).where(LLMModelConfig.config_id == llm_config_id)
|
llm_config, system_prompt, messages = await RAGService._prepare_generation(
|
||||||
result = await db.execute(stmt)
|
db, query, llm_config_id, retrieved_docs, conversation_history
|
||||||
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})
|
|
||||||
|
|
||||||
response_text = await LLMProviderService.generate_text(
|
response_text = await LLMProviderService.generate_text(
|
||||||
provider=llm_config.provider,
|
provider=llm_config.provider,
|
||||||
|
|
@ -115,6 +216,72 @@ class RAGService:
|
||||||
|
|
||||||
return response_text
|
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
|
@staticmethod
|
||||||
def _build_context(retrieved_docs: List[Dict[str, Any]]) -> str:
|
def _build_context(retrieved_docs: List[Dict[str, Any]]) -> str:
|
||||||
"""构建上下文文本"""
|
"""构建上下文文本"""
|
||||||
|
|
@ -123,12 +290,26 @@ class RAGService:
|
||||||
|
|
||||||
context_parts = []
|
context_parts = []
|
||||||
for i, doc_info in enumerate(retrieved_docs, 1):
|
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(
|
context_parts.append(
|
||||||
f"文档 {i}: {doc_info['file_path']} (相似度: {doc_info['similarity']:.2%})\n"
|
f"--- [{citation_id}] 文档: {doc_info['file_path']} ---\n{content}"
|
||||||
f"{doc_info['content_preview']}..."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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()
|
rag_service = RAGService()
|
||||||
|
|
|
||||||
|
|
@ -1,135 +1,407 @@
|
||||||
"""
|
"""
|
||||||
ZVec 向量化服务 - 调用阿里云ZVec API进行文档向量化
|
ZVec 本地向量化服务 - 使用本地 zvec 库 + 模型配置中的 embedding 模型
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import httpx
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
|
import zvec
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
|
from app.models.llm_model_config import LLMModelConfig
|
||||||
from app.models.document_vector import DocumentVector
|
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:
|
class ZVecService:
|
||||||
"""ZVec 向量化服务"""
|
"""基于本地 zvec 库的向量存储和检索服务"""
|
||||||
|
|
||||||
ZVEC_API_ENDPOINT = os.getenv("ZVEC_API_ENDPOINT", "https://api.aliyun.com/zvec")
|
_collections: Dict[int, zvec.Collection] = {}
|
||||||
ZVEC_API_KEY = os.getenv("ZVEC_API_KEY", "")
|
|
||||||
ZVEC_MODEL = os.getenv("ZVEC_MODEL", "text-embedding-v3")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def is_configured(cls) -> bool:
|
def _get_collection_path(cls, project_id: int) -> str:
|
||||||
"""检查ZVec是否已配置"""
|
"""返回项目 collection 路径。
|
||||||
return bool(cls.ZVEC_API_KEY and cls.ZVEC_API_ENDPOINT)
|
|
||||||
|
只确保父目录存在;叶子目录由 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
|
@classmethod
|
||||||
async def vectorize_text(cls, text: str, doc_id: Optional[int] = None) -> Optional[List[float]]:
|
def _open_collection(cls, project_id: int) -> Optional[zvec.Collection]:
|
||||||
"""
|
"""打开已存在的 collection;不存在则返回 None"""
|
||||||
调用ZVec API对文本进行向量化
|
if project_id in cls._collections:
|
||||||
|
return cls._collections[project_id]
|
||||||
|
|
||||||
Args:
|
collection_path = cls._get_collection_path(project_id)
|
||||||
text: 要向量化的文本内容
|
try:
|
||||||
doc_id: 文档ID(用于日志追踪)
|
collection = zvec.open(path=collection_path)
|
||||||
|
except Exception:
|
||||||
Returns:
|
|
||||||
向量列表,或如果API调用失败则返回None
|
|
||||||
"""
|
|
||||||
if not await cls.is_configured():
|
|
||||||
return None
|
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():
|
if not text or not text.strip():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
config = await cls.get_embedding_config(db)
|
||||||
|
if not config:
|
||||||
|
logger.warning("No embedding model configured, skipping vectorization")
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 截断超长文本(ZVec有输入限制)
|
return await LLMProviderService.generate_embedding(
|
||||||
text = text[:8000]
|
provider=config.provider,
|
||||||
|
endpoint_url=config.endpoint_url,
|
||||||
headers = {
|
api_key=config.api_key,
|
||||||
"Content-Type": "application/json",
|
llm_model_name=config.llm_model_name,
|
||||||
"Authorization": f"Bearer {cls.ZVEC_API_KEY}",
|
text=text,
|
||||||
}
|
timeout=config.llm_timeout or 60,
|
||||||
|
dimension=config.embedding_dimension,
|
||||||
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
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"ZVec vectorization failed for doc_id={doc_id}: {exc}")
|
logger.error(f"Embedding generation failed: {exc}")
|
||||||
return None
|
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
|
@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,
|
cls,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
project_id: int,
|
project_id: int,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
content: str,
|
content: str,
|
||||||
doc_id: Optional[int] = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
"""对 MD 文件分块向量化并存入 ZVec,同时逐分块记录状态到 document_vector。
|
||||||
|
|
||||||
|
流程:删除旧分块 → 分块 → 逐块生成 embedding、写入 ZVec、写记录。
|
||||||
"""
|
"""
|
||||||
向量化文本并存储到数据库
|
from app.core.config import settings
|
||||||
|
|
||||||
Args:
|
content_hash = cls._content_hash(content)
|
||||||
db: 数据库会话
|
|
||||||
project_id: 项目ID
|
|
||||||
file_path: 文件路径
|
|
||||||
content: 文件内容
|
|
||||||
doc_id: 文档元数据ID
|
|
||||||
|
|
||||||
Returns:
|
# 先清理该文件的所有旧分块(增量重建),保证不残留过期向量
|
||||||
是否成功存储
|
await cls._purge_file_chunks(db, project_id, file_path)
|
||||||
"""
|
|
||||||
# 调用ZVec获取向量
|
|
||||||
embedding = await cls.vectorize_text(content, doc_id)
|
|
||||||
if not embedding:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
chunks = cls._chunk_text(content, settings.CHUNK_SIZE, settings.CHUNK_OVERLAP)
|
||||||
# 先删除旧的向量记录
|
if not chunks:
|
||||||
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)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as exc:
|
success_count = 0
|
||||||
print(f"Failed to store vector for {file_path}: {exc}")
|
last_error: Optional[str] = None
|
||||||
await db.rollback()
|
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
|
return False
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def delete_vector(
|
async def delete_vector(
|
||||||
cls,
|
cls,
|
||||||
|
|
@ -137,30 +409,248 @@ class ZVecService:
|
||||||
project_id: int,
|
project_id: int,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""从 ZVec 删除文档的所有分块向量,并清除 document_vector 记录"""
|
||||||
删除文档的向量记录
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: 数据库会话
|
|
||||||
project_id: 项目ID
|
|
||||||
file_path: 文件路径
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否成功删除
|
|
||||||
"""
|
|
||||||
try:
|
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(
|
await db.execute(
|
||||||
delete(DocumentVector).where(
|
delete(DocumentVector).where(DocumentVector.project_id == project_id)
|
||||||
(DocumentVector.project_id == project_id)
|
|
||||||
& (DocumentVector.file_path == file_path)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return True
|
records = {}
|
||||||
except Exception as exc:
|
else:
|
||||||
print(f"Failed to delete vector for {file_path}: {exc}")
|
for stale_path in set(records) - md_file_set:
|
||||||
await db.rollback()
|
await cls.delete_vector(db, project_id, stale_path)
|
||||||
return False
|
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
|
@classmethod
|
||||||
async def search_similar(
|
async def search_similar(
|
||||||
|
|
@ -170,71 +660,57 @@ class ZVecService:
|
||||||
query: str,
|
query: str,
|
||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""基于查询文本检索相似文档"""
|
||||||
基于查询文本检索相似文档
|
query_embedding = await cls.generate_embedding(db, query)
|
||||||
|
|
||||||
Args:
|
|
||||||
db: 数据库会话
|
|
||||||
project_id: 项目ID
|
|
||||||
query: 查询文本
|
|
||||||
top_k: 返回最相似的文档数量
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
相似文档列表,按相似度排序
|
|
||||||
"""
|
|
||||||
# 获取查询文本的向量
|
|
||||||
query_embedding = await cls.vectorize_text(query)
|
|
||||||
if not query_embedding:
|
if not query_embedding:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 获取项目中所有文档的向量
|
collection = cls._open_collection(project_id)
|
||||||
result = await db.execute(
|
if collection is None:
|
||||||
select(DocumentVector).where(
|
return [] # 项目尚未向量化
|
||||||
DocumentVector.project_id == project_id
|
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 []
|
return []
|
||||||
|
|
||||||
# 计算相似度(余弦相似度)
|
db_result = await db.execute(
|
||||||
similarities = []
|
select(DocumentVector).where(
|
||||||
for vector_record in vectors:
|
DocumentVector.project_id == project_id,
|
||||||
try:
|
DocumentVector.zvec_id.in_(zvec_ids),
|
||||||
doc_embedding = json.loads(vector_record.embedding)
|
)
|
||||||
similarity = cls._cosine_similarity(query_embedding, doc_embedding)
|
)
|
||||||
similarities.append({
|
record_by_id = {r.zvec_id: r for r in db_result.scalars().all()}
|
||||||
"file_path": vector_record.file_path,
|
|
||||||
"doc_id": vector_record.doc_id,
|
|
||||||
"similarity": similarity,
|
|
||||||
})
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 按相似度排序并返回top-k
|
matched_docs = []
|
||||||
similarities.sort(key=lambda x: x["similarity"], reverse=True)
|
for result in results:
|
||||||
return similarities[:top_k]
|
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:
|
except Exception as exc:
|
||||||
print(f"Failed to search similar documents: {exc}")
|
logger.error(f"ZVec search failed: {exc}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def _cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
|
def close_collection(cls, project_id: int) -> None:
|
||||||
"""计算两个向量的余弦相似度"""
|
"""关闭项目的 ZVec collection"""
|
||||||
if not vec1 or not vec2 or len(vec1) != len(vec2):
|
if project_id in cls._collections:
|
||||||
return 0.0
|
del cls._collections[project_id]
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
zvec_service = ZVecService()
|
zvec_service = ZVecService()
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,33 @@
|
||||||
-- 创建知识库对话会话表
|
-- 创建知识库对话会话表
|
||||||
CREATE TABLE IF NOT EXISTS chat_session (
|
CREATE TABLE IF NOT EXISTS `chat_session` (
|
||||||
session_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '会话ID',
|
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '会话ID',
|
||||||
project_id BIGINT NOT NULL COMMENT '项目ID',
|
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||||
user_id BIGINT NOT NULL COMMENT '创建用户ID',
|
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||||
model_config_id BIGINT NOT NULL COMMENT '使用的LLM模型配置ID',
|
`llm_config_id` BIGINT NOT NULL COMMENT 'LLM配置ID',
|
||||||
title VARCHAR(255) NOT NULL COMMENT '会话标题',
|
`title` VARCHAR(255) NOT NULL COMMENT '会话标题',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
`description` TEXT DEFAULT NULL COMMENT '会话描述',
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否激活',
|
||||||
INDEX idx_project_user (project_id, user_id),
|
`message_count` INT NOT NULL DEFAULT 0 COMMENT '消息数',
|
||||||
INDEX idx_created_at (created_at)
|
`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='知识库对话会话表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话会话表';
|
||||||
|
|
||||||
-- 创建知识库对话消息表
|
-- 创建知识库对话消息表
|
||||||
CREATE TABLE IF NOT EXISTS chat_message (
|
CREATE TABLE IF NOT EXISTS `chat_message` (
|
||||||
message_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '消息ID',
|
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID',
|
||||||
session_id BIGINT NOT NULL COMMENT '所属会话ID',
|
`session_id` BIGINT NOT NULL COMMENT '会话ID',
|
||||||
role VARCHAR(20) NOT NULL COMMENT '角色(user/assistant)',
|
`role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)',
|
||||||
content LONGTEXT NOT NULL COMMENT '消息内容',
|
`content` TEXT NOT NULL COMMENT '消息内容',
|
||||||
retrieved_docs JSON COMMENT '检索到的相关文档(JSON数组)',
|
`referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
`tokens_used` INT DEFAULT NULL COMMENT '消耗的token数',
|
||||||
FOREIGN KEY (session_id) REFERENCES chat_session(session_id) ON DELETE CASCADE,
|
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除',
|
||||||
INDEX idx_session_id (session_id),
|
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
INDEX idx_created_at (created_at)
|
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='知识库对话消息表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话消息表';
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
CREATE TABLE IF NOT EXISTS document_vector (
|
CREATE TABLE IF NOT EXISTS `document_vector` (
|
||||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '向量ID',
|
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '向量ID',
|
||||||
project_id BIGINT NOT NULL COMMENT '项目ID',
|
`project_id` BIGINT NOT NULL COMMENT '项目ID',
|
||||||
file_path VARCHAR(500) NOT NULL COMMENT '文件相对路径',
|
`file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径',
|
||||||
content_hash VARCHAR(64) COMMENT '内容哈希',
|
`chunk_index` INT NOT NULL DEFAULT 0 COMMENT '分块序号(0起),同一文件可有多个分块',
|
||||||
vector JSON COMMENT '向量数据(JSON数组)',
|
`chunk_text` TEXT DEFAULT NULL COMMENT '分块首段文本,作为点击引用时的定位锚点',
|
||||||
metadata JSON COMMENT '向量元数据',
|
`content_hash` VARCHAR(64) DEFAULT NULL COMMENT '整个文件内容哈希值,用于判断文件是否变更',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
`zvec_id` VARCHAR(256) DEFAULT NULL COMMENT 'ZVec返回的向量ID(每个分块独立)',
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
`zvec_response` TEXT DEFAULT NULL COMMENT 'ZVec完整响应JSON',
|
||||||
INDEX idx_project_file (project_id, file_path),
|
`status` VARCHAR(32) NOT NULL DEFAULT 'success' COMMENT '向量化状态:success/failed/pending',
|
||||||
INDEX idx_created_at (created_at)
|
`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='文档向量表';
|
) 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
|
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表';
|
) 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
|
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
|
||||||
('超级管理员', 'super_admin', '拥有系统所有权限', 1),
|
('超级管理员', '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'),
|
(4, 1, '编辑项目', 'project:edit', 3, NULL, NULL, 3, 'project:edit'),
|
||||||
(5, 1, '删除项目', 'project:delete', 3, NULL, NULL, 4, 'project:delete'),
|
(5, 1, '删除项目', 'project:delete', 3, NULL, NULL, 4, 'project:delete'),
|
||||||
(10, 0, '知识库管理', 'knowledge', 1, NULL, 'FileTextOutlined', 2, NULL),
|
(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'),
|
(12, 10, '编辑知识库', 'knowledge:edit', 3, NULL, NULL, 2, 'knowledge:edit'),
|
||||||
(13, 10, '删除知识库', 'knowledge:delete', 3, NULL, NULL, 3, 'knowledge:delete'),
|
(13, 10, '删除知识库', 'knowledge:delete', 3, NULL, NULL, 3, 'knowledge:delete'),
|
||||||
(20, 0, '系统管理', 'system', 1, '/system', 'SettingOutlined', 3, NULL),
|
(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/model-configs" element={<ModelConfigs />} />
|
||||||
<Route path="/system/logs" element={<SystemLogs />} />
|
<Route path="/system/logs" element={<SystemLogs />} />
|
||||||
<Route path="/chat" element={<Chat />} />
|
<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>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/projects" replace />} />
|
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,119 @@
|
||||||
import request from '@/utils/request'
|
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', {
|
return request.post('/chat/sessions', {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
llm_config_id: llmConfigId,
|
llm_config_id: llmConfigId,
|
||||||
|
title,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getChatSessions = (projectId) => {
|
export const getChatSessions = (projectId = null) => {
|
||||||
return request.get(`/chat/sessions?project_id=${projectId}`)
|
return request.get('/chat/sessions', {
|
||||||
|
params: projectId ? { project_id: projectId } : undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getChatMessages = (sessionId, limit = 50, offset = 0) => {
|
export const getChatMessages = (sessionId) => {
|
||||||
return request.get(`/chat/messages?session_id=${sessionId}&limit=${limit}&offset=${offset}`)
|
return request.get(`/chat/sessions/${sessionId}/messages`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendChatMessage = (sessionId, userMessage) => {
|
export const sendChatMessage = (sessionId, message) => {
|
||||||
return request.post('/chat/messages', {
|
return request.post('/chat/send', {
|
||||||
session_id: sessionId,
|
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) => {
|
export const deleteChatSession = (sessionId) => {
|
||||||
return request.delete(`/chat/sessions/${sessionId}`)
|
return request.delete(`/chat/sessions/${sessionId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const deleteChatMessage = (messageId) => {
|
||||||
|
return request.delete(`/chat/messages/${messageId}`)
|
||||||
|
}
|
||||||
|
|
||||||
export const updateChatSessionTitle = (sessionId, title) => {
|
export const updateChatSessionTitle = (sessionId, title) => {
|
||||||
return request.put(`/chat/sessions/${sessionId}`, {
|
return request.put(`/chat/sessions/${sessionId}`, {
|
||||||
title,
|
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) {
|
export function testLLMModelConfig(data) {
|
||||||
|
const timeoutSeconds = Number(data?.llm_timeout) || 30
|
||||||
return request({
|
return request({
|
||||||
url: '/llm-model-configs/test',
|
url: '/llm-model-configs/test',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data,
|
data,
|
||||||
|
timeout: Math.max(timeoutSeconds * 1000 + 5000, 30000),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ const builtInMenuMetaMap = {
|
||||||
icon: 'TeamOutlined',
|
icon: 'TeamOutlined',
|
||||||
},
|
},
|
||||||
'knowledge:my': {
|
'knowledge:my': {
|
||||||
path: '/knowledge',
|
path: '/chat',
|
||||||
icon: 'ReadOutlined',
|
icon: 'ReadOutlined',
|
||||||
},
|
},
|
||||||
'system:users': {
|
'system:users': {
|
||||||
|
|
@ -128,7 +128,7 @@ const resolveBuiltInMenuMeta = (item) => {
|
||||||
|
|
||||||
if (item.path === '/knowledge' || item.path === '/knowledge/my' || item.menu_name === '我的知识库') {
|
if (item.path === '/knowledge' || item.path === '/knowledge/my' || item.menu_name === '我的知识库') {
|
||||||
return {
|
return {
|
||||||
path: '/knowledge',
|
path: '/chat',
|
||||||
icon: 'ReadOutlined',
|
icon: 'ReadOutlined',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
|
||||||
import { Layout } from 'antd'
|
import { Layout } from 'antd'
|
||||||
import AppSider from './AppSider'
|
import AppSider from './AppSider'
|
||||||
import AppHeader from './AppHeader'
|
import AppHeader from './AppHeader'
|
||||||
|
|
@ -7,18 +6,8 @@ import './MainLayout.css'
|
||||||
|
|
||||||
const { Content } = Layout
|
const { Content } = Layout
|
||||||
|
|
||||||
// 进入项目文档页/编辑页时自动折叠全局侧边栏,给文档内容腾出空间
|
|
||||||
const COLLAPSE_PATTERNS = [/^\/projects\/[^/]+\/docs/, /^\/projects\/[^/]+\/editor/]
|
|
||||||
|
|
||||||
function MainLayout({ children }) {
|
function MainLayout({ children }) {
|
||||||
const location = useLocation()
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (COLLAPSE_PATTERNS.some((pattern) => pattern.test(location.pathname))) {
|
|
||||||
setCollapsed(true)
|
|
||||||
}
|
|
||||||
}, [location.pathname])
|
|
||||||
|
|
||||||
const toggleCollapsed = () => {
|
const toggleCollapsed = () => {
|
||||||
setCollapsed(!collapsed)
|
setCollapsed(!collapsed)
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,22 @@ body {
|
||||||
border-radius: 2px;
|
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 */
|
/* Dark mode overrides for highlight.js */
|
||||||
body.dark .hljs {
|
body.dark .hljs {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,894 @@
|
||||||
/* Chat 页面样式 */
|
.chat-page-shell {
|
||||||
|
height: calc(100vh - 96px);
|
||||||
.chat-container {
|
min-height: 0;
|
||||||
display: flex;
|
display: grid;
|
||||||
height: 100vh;
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar {
|
.chat-left-panel,
|
||||||
width: 280px;
|
.chat-main-panel {
|
||||||
border-right: 1px solid #e8e8e8;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
border: 1px solid var(--border-color);
|
||||||
background: #fafafa;
|
border-radius: 8px;
|
||||||
padding: 16px;
|
background: var(--card-bg);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header {
|
.chat-left-panel {
|
||||||
padding: 16px;
|
display: flex;
|
||||||
border-bottom: 1px solid #e8e8e8;
|
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;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header-title {
|
.chat-action-entry:hover {
|
||||||
font-size: 16px;
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-action-entry span {
|
||||||
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-content {
|
.chat-history-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-loading,
|
||||||
|
.chat-empty-message {
|
||||||
|
min-height: 180px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
background: #fff;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-session-item {
|
||||||
flex: 1;
|
padding: 9px 10px;
|
||||||
overflow-y: auto;
|
margin-bottom: 4px;
|
||||||
padding: 16px;
|
border: 1px solid var(--border-color);
|
||||||
display: flex;
|
border-radius: 8px;
|
||||||
flex-direction: column;
|
background: var(--bg-color-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message {
|
.chat-session-item:hover {
|
||||||
margin-bottom: 12px;
|
border-color: #1677ff;
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message.user {
|
.chat-session-item.active {
|
||||||
justify-content: flex-end;
|
border-color: #1677ff;
|
||||||
|
background: color-mix(in srgb, #1677ff 10%, var(--bg-color-secondary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-bubble {
|
.chat-session-item-top {
|
||||||
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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-session-item {
|
.chat-session-title-area {
|
||||||
padding: 8px;
|
flex: 1;
|
||||||
margin-bottom: 8px;
|
min-width: 0;
|
||||||
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 {
|
.chat-session-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
font-size: 13px;
|
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;
|
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;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-session-date {
|
.chat-composer-kb {
|
||||||
font-size: 11px;
|
max-width: 280px;
|
||||||
color: #999;
|
}
|
||||||
margin-top: 4px;
|
|
||||||
|
.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])
|
}, [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 handleMenuClick = ({ key }) => {
|
||||||
const node = findNodeByKey(fileTree, key)
|
const node = findNodeByKey(fileTree, key)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { 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 } from '@ant-design/icons'
|
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 { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
|
||||||
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
|
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
|
||||||
import { getUserList } from '@/api/users'
|
import { getUserList } from '@/api/users'
|
||||||
import { searchDocuments } from '@/api/search'
|
import { searchDocuments } from '@/api/search'
|
||||||
import ListActionBar from '@/components/ListActionBar/ListActionBar'
|
import ListActionBar from '@/components/ListActionBar/ListActionBar'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
|
import useProjectKnowledge from './useProjectKnowledge'
|
||||||
import './ProjectList.css'
|
import './ProjectList.css'
|
||||||
|
|
||||||
function ProjectList({ type = 'my' }) {
|
function ProjectList({ type = 'my' }) {
|
||||||
|
|
@ -38,6 +39,17 @@ function ProjectList({ type = 'my' }) {
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const pageSize = 8
|
const pageSize = 8
|
||||||
|
|
||||||
|
const {
|
||||||
|
kbModalVisible,
|
||||||
|
progress,
|
||||||
|
currentTask,
|
||||||
|
loadingProgress,
|
||||||
|
vectorizing,
|
||||||
|
openKnowledgeModal,
|
||||||
|
closeKnowledgeModal,
|
||||||
|
runVectorize,
|
||||||
|
} = useProjectKnowledge()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProjects()
|
fetchProjects()
|
||||||
setCurrentPage(1)
|
setCurrentPage(1)
|
||||||
|
|
@ -242,6 +254,13 @@ function ProjectList({ type = 'my' }) {
|
||||||
fetchGitRepos(project.id)
|
fetchGitRepos(project.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打开知识库(向量化进度)弹窗
|
||||||
|
const handleKnowledge = (e, project) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setCurrentProject(project)
|
||||||
|
openKnowledgeModal(project)
|
||||||
|
}
|
||||||
|
|
||||||
// 加载Git仓库列表
|
// 加载Git仓库列表
|
||||||
const fetchGitRepos = async (projectId) => {
|
const fetchGitRepos = async (projectId) => {
|
||||||
setLoadingRepos(true)
|
setLoadingRepos(true)
|
||||||
|
|
@ -605,6 +624,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
actions={type === 'my' ? [
|
actions={type === 'my' ? [
|
||||||
<SettingOutlined key="settings" onClick={(e) => handleEdit(e, project)} />,
|
<SettingOutlined key="settings" onClick={(e) => handleEdit(e, project)} />,
|
||||||
<GithubOutlined key="git" onClick={(e) => handleGitSettings(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)} />,
|
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
|
||||||
] : [
|
] : [
|
||||||
<EyeOutlined key="view" />,
|
<EyeOutlined key="view" />,
|
||||||
|
|
@ -1136,6 +1156,119 @@ function ProjectList({ type = 'my' }) {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</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>
|
</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;
|
flex: 1;
|
||||||
min-height: 0;
|
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,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
|
Radio,
|
||||||
Select,
|
Select,
|
||||||
|
Slider,
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
|
CommentOutlined,
|
||||||
|
DeploymentUnitOutlined,
|
||||||
ExperimentOutlined,
|
ExperimentOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
|
@ -39,7 +44,31 @@ import '@/pages/System/AdminPages.css'
|
||||||
|
|
||||||
const { Search, TextArea } = Input
|
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 providerPart = (provider || 'custom').trim().toLowerCase()
|
||||||
const modelPart = (llmModelName || '').trim().toLowerCase()
|
const modelPart = (llmModelName || '').trim().toLowerCase()
|
||||||
const sanitized = []
|
const sanitized = []
|
||||||
|
|
@ -56,7 +85,8 @@ function buildModelCode(provider, llmModelName) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const suffix = sanitized.join('').replace(/^_+|_+$/g, '') || 'model'
|
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) {
|
function buildModelName(providerMeta, provider, llmModelName) {
|
||||||
|
|
@ -75,6 +105,8 @@ function formatDateTime(value) {
|
||||||
|
|
||||||
function ModelConfigs() {
|
function ModelConfigs() {
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
const [activeType, setActiveType] = useState('chat')
|
||||||
|
const [editingType, setEditingType] = useState('chat')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
@ -96,6 +128,7 @@ function ModelConfigs() {
|
||||||
|
|
||||||
const providerValue = Form.useWatch('provider', form)
|
const providerValue = Form.useWatch('provider', form)
|
||||||
const llmModelNameValue = Form.useWatch('llm_model_name', form)
|
const llmModelNameValue = Form.useWatch('llm_model_name', form)
|
||||||
|
const isEmbedding = editingType === 'embedding'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProviderCatalog()
|
loadProviderCatalog()
|
||||||
|
|
@ -103,7 +136,7 @@ function ModelConfigs() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfigs()
|
loadConfigs()
|
||||||
}, [page, pageSize, keyword, providerFilter, statusFilter])
|
}, [activeType, page, pageSize, keyword, providerFilter, statusFilter])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modalVisible || !providerValue) {
|
if (!modalVisible || !providerValue) {
|
||||||
|
|
@ -134,7 +167,7 @@ function ModelConfigs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autoFillFlags.modelCode) {
|
if (autoFillFlags.modelCode) {
|
||||||
const nextModelCode = buildModelCode(providerValue, llmModelNameValue)
|
const nextModelCode = buildModelCode(providerValue, llmModelNameValue, editingType)
|
||||||
if (nextModelCode !== currentValues.model_code) {
|
if (nextModelCode !== currentValues.model_code) {
|
||||||
nextValues.model_code = nextModelCode
|
nextValues.model_code = nextModelCode
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +177,7 @@ function ModelConfigs() {
|
||||||
if (Object.keys(nextValues).length > 0) {
|
if (Object.keys(nextValues).length > 0) {
|
||||||
form.setFieldsValue(nextValues)
|
form.setFieldsValue(nextValues)
|
||||||
}
|
}
|
||||||
}, [modalVisible, providerValue, llmModelNameValue, providerCatalog, autoFillFlags, form])
|
}, [modalVisible, providerValue, llmModelNameValue, providerCatalog, autoFillFlags, editingType, form])
|
||||||
|
|
||||||
const loadProviderCatalog = async () => {
|
const loadProviderCatalog = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -162,6 +195,7 @@ function ModelConfigs() {
|
||||||
const params = {
|
const params = {
|
||||||
page,
|
page,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
|
model_type: activeType,
|
||||||
}
|
}
|
||||||
if (keyword) params.keyword = keyword
|
if (keyword) params.keyword = keyword
|
||||||
if (providerFilter) params.provider = providerFilter
|
if (providerFilter) params.provider = providerFilter
|
||||||
|
|
@ -180,24 +214,34 @@ function ModelConfigs() {
|
||||||
|
|
||||||
const getProviderMeta = (provider) => providerCatalog.find((item) => item.value === provider)
|
const getProviderMeta = (provider) => providerCatalog.find((item) => item.value === provider)
|
||||||
|
|
||||||
|
const handleTabChange = (key) => {
|
||||||
|
setActiveType(key)
|
||||||
|
setPage(1)
|
||||||
|
setKeyword('')
|
||||||
|
setProviderFilter(undefined)
|
||||||
|
setStatusFilter(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
const defaultProvider = providerCatalog[0]?.value || 'openai'
|
const defaultProvider = providerCatalog[0]?.value || 'openai'
|
||||||
const defaultEndpointUrl = getProviderMeta(defaultProvider)?.default_endpoint_url || ''
|
const defaultEndpointUrl = getProviderMeta(defaultProvider)?.default_endpoint_url || ''
|
||||||
setEditingConfigId(null)
|
setEditingConfigId(null)
|
||||||
|
setEditingType(activeType)
|
||||||
setAutoFillFlags({
|
setAutoFillFlags({
|
||||||
endpointUrl: true,
|
endpointUrl: true,
|
||||||
modelName: true,
|
modelName: true,
|
||||||
modelCode: true,
|
modelCode: true,
|
||||||
})
|
})
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
|
model_type: activeType,
|
||||||
provider: defaultProvider,
|
provider: defaultProvider,
|
||||||
endpoint_url: defaultEndpointUrl,
|
endpoint_url: defaultEndpointUrl,
|
||||||
llm_timeout: 120,
|
llm_timeout: activeType === 'embedding' ? 60 : 120,
|
||||||
llm_temperature: 0.7,
|
llm_temperature: 0.7,
|
||||||
llm_top_p: 0.9,
|
llm_top_p: 0.9,
|
||||||
llm_max_tokens: 2048,
|
llm_max_tokens: DEFAULT_MAX_TOKENS,
|
||||||
|
embedding_dimension: undefined,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_default: false,
|
|
||||||
description: '',
|
description: '',
|
||||||
llm_system_prompt: '',
|
llm_system_prompt: '',
|
||||||
model_name: '',
|
model_name: '',
|
||||||
|
|
@ -212,13 +256,15 @@ function ModelConfigs() {
|
||||||
try {
|
try {
|
||||||
const res = await getLLMModelConfigDetail(record.config_id)
|
const res = await getLLMModelConfigDetail(record.config_id)
|
||||||
const detail = res.data
|
const detail = res.data
|
||||||
|
const detailType = detail.model_type || 'chat'
|
||||||
const providerMeta = getProviderMeta(detail.provider)
|
const providerMeta = getProviderMeta(detail.provider)
|
||||||
|
|
||||||
setEditingConfigId(record.config_id)
|
setEditingConfigId(record.config_id)
|
||||||
|
setEditingType(detailType)
|
||||||
setAutoFillFlags({
|
setAutoFillFlags({
|
||||||
endpointUrl: !detail.endpoint_url || detail.endpoint_url === (providerMeta?.default_endpoint_url || ''),
|
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),
|
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({
|
form.setFieldsValue({
|
||||||
...detail,
|
...detail,
|
||||||
|
|
@ -240,11 +286,12 @@ function ModelConfigs() {
|
||||||
const handleSubmit = async (values) => {
|
const handleSubmit = async (values) => {
|
||||||
try {
|
try {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
|
const payload = { ...values, model_type: editingType }
|
||||||
if (editingConfigId) {
|
if (editingConfigId) {
|
||||||
await updateLLMModelConfig(editingConfigId, values)
|
await updateLLMModelConfig(editingConfigId, payload)
|
||||||
Toast.success('模型配置更新成功')
|
Toast.success('模型配置更新成功')
|
||||||
} else {
|
} else {
|
||||||
await createLLMModelConfig(values)
|
await createLLMModelConfig(payload)
|
||||||
Toast.success('模型配置创建成功')
|
Toast.success('模型配置创建成功')
|
||||||
}
|
}
|
||||||
closeModal()
|
closeModal()
|
||||||
|
|
@ -277,232 +324,272 @@ function ModelConfigs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const showTestResult = (result) => {
|
const showTestResult = (result) => {
|
||||||
Modal.info({
|
const providerLabel = getProviderMeta(result.provider)?.label || result.provider
|
||||||
title: '模型测试成功',
|
const lines = [
|
||||||
width: 620,
|
`提供方:${providerLabel}`,
|
||||||
content: (
|
`模型:${result.llm_model_name}`,
|
||||||
<div className="model-config-test-result">
|
`延迟:${result.latency_ms} ms`,
|
||||||
<div>提供方:{getProviderMeta(result.provider)?.label || result.provider}</div>
|
]
|
||||||
<div>模型:{result.llm_model_name}</div>
|
if (result.dimension) {
|
||||||
<div>延迟:{result.latency_ms} ms</div>
|
lines.push(`向量维度:${result.dimension}`)
|
||||||
<div>返回预览:{result.preview || '模型已成功返回,但未提取到文本内容。'}</div>
|
}
|
||||||
</div>
|
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 () => {
|
const handleFormTest = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
setTesting(true)
|
setTesting(true)
|
||||||
const res = await testLLMModelConfig(values)
|
const res = await testLLMModelConfig({ ...values, model_type: editingType })
|
||||||
showTestResult(res.data)
|
showTestResult(res.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.errorFields) {
|
// 表单校验未通过:不弹提示,仅高亮表单项
|
||||||
return
|
// 接口失败:已由全局请求拦截器统一弹出错误 Toast,这里不重复提示
|
||||||
}
|
|
||||||
Toast.error(error.response?.data?.detail || '模型测试失败')
|
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false)
|
setTesting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const getColumns = (type) => {
|
||||||
{
|
const baseColumns = [
|
||||||
title: '模型名称',
|
{
|
||||||
dataIndex: 'model_name',
|
title: '模型名称',
|
||||||
key: 'model_name',
|
dataIndex: 'model_name',
|
||||||
width: 240,
|
key: 'model_name',
|
||||||
render: (_, record) => (
|
width: 240,
|
||||||
<Space direction="vertical" size={2}>
|
render: (_, record) => (
|
||||||
<span>{record.model_name}</span>
|
<Space direction="vertical" size={2}>
|
||||||
<span className="model-config-code">{record.model_code}</span>
|
<span>{record.model_name}</span>
|
||||||
</Space>
|
<span className="model-config-code">{record.model_code}</span>
|
||||||
),
|
</Space>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
title: '提供方',
|
{
|
||||||
dataIndex: 'provider',
|
title: '提供方',
|
||||||
key: 'provider',
|
dataIndex: 'provider',
|
||||||
width: 140,
|
key: 'provider',
|
||||||
render: (provider) => (
|
width: 140,
|
||||||
<Tag color="blue">{getProviderMeta(provider)?.label || provider || '-'}</Tag>
|
render: (provider) => (
|
||||||
),
|
<Tag color="blue">{getProviderMeta(provider)?.label || provider || '-'}</Tag>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
title: '模型标识',
|
{
|
||||||
dataIndex: 'llm_model_name',
|
title: '模型标识',
|
||||||
key: 'llm_model_name',
|
dataIndex: 'llm_model_name',
|
||||||
width: 180,
|
key: 'llm_model_name',
|
||||||
},
|
width: 180,
|
||||||
{
|
},
|
||||||
title: 'Base URL',
|
]
|
||||||
dataIndex: 'endpoint_url',
|
|
||||||
key: 'endpoint_url',
|
if (type === 'embedding') {
|
||||||
ellipsis: true,
|
baseColumns.push({
|
||||||
render: (value) => value || '-',
|
title: '向量维度',
|
||||||
},
|
dataIndex: 'embedding_dimension',
|
||||||
{
|
key: 'embedding_dimension',
|
||||||
title: '状态',
|
width: 110,
|
||||||
dataIndex: 'is_active',
|
render: (value) => (value ? <Tag color="purple">{value}</Tag> : <Tag>自动</Tag>),
|
||||||
key: 'is_active',
|
})
|
||||||
width: 100,
|
}
|
||||||
render: (value, record) => (
|
|
||||||
<Switch
|
baseColumns.push(
|
||||||
checked={value}
|
{
|
||||||
checkedChildren="启用"
|
title: 'Base URL',
|
||||||
unCheckedChildren="停用"
|
dataIndex: 'endpoint_url',
|
||||||
onChange={(checked) => handleStatusChange(record, checked)}
|
key: 'endpoint_url',
|
||||||
/>
|
ellipsis: true,
|
||||||
),
|
render: (value) => value || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '默认',
|
title: '状态',
|
||||||
dataIndex: 'is_default',
|
dataIndex: 'is_active',
|
||||||
key: 'is_default',
|
key: 'is_active',
|
||||||
width: 90,
|
width: 100,
|
||||||
render: (value) => (
|
render: (value, record) => (
|
||||||
value ? <Tag color="gold">默认</Tag> : <Tag>否</Tag>
|
<Switch
|
||||||
),
|
checked={value}
|
||||||
},
|
checkedChildren="启用"
|
||||||
{
|
unCheckedChildren="停用"
|
||||||
title: '更新时间',
|
onChange={(checked) => handleStatusChange(record, checked)}
|
||||||
dataIndex: 'updated_at',
|
/>
|
||||||
key: 'updated_at',
|
),
|
||||||
width: 180,
|
},
|
||||||
render: (value) => formatDateTime(value),
|
{
|
||||||
},
|
title: '更新时间',
|
||||||
{
|
dataIndex: 'updated_at',
|
||||||
title: '操作',
|
key: 'updated_at',
|
||||||
key: 'action',
|
width: 180,
|
||||||
fixed: 'right',
|
render: (value) => formatDateTime(value),
|
||||||
width: 170,
|
},
|
||||||
render: (_, record) => (
|
{
|
||||||
<Space size="small" className="model-config-actions">
|
title: '操作',
|
||||||
<Button
|
key: 'action',
|
||||||
type="link"
|
fixed: 'right',
|
||||||
size="small"
|
width: 170,
|
||||||
icon={<EditOutlined />}
|
render: (_, record) => (
|
||||||
onClick={() => openEditModal(record)}
|
<Space size="small" className="model-config-actions">
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Popconfirm
|
|
||||||
title="确认删除该模型配置?"
|
|
||||||
description="删除后将无法恢复。"
|
|
||||||
okText="删除"
|
|
||||||
cancelText="取消"
|
|
||||||
onConfirm={() => handleDelete(record)}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
danger
|
icon={<EditOutlined />}
|
||||||
icon={<DeleteOutlined />}
|
onClick={() => openEditModal(record)}
|
||||||
>
|
>
|
||||||
删除
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
<Popconfirm
|
||||||
</Space>
|
title="确认删除该模型配置?"
|
||||||
),
|
description="删除后将无法恢复。"
|
||||||
},
|
okText="删除"
|
||||||
]
|
cancelText="取消"
|
||||||
|
onConfirm={() => handleDelete(record)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return baseColumns
|
||||||
<div className="admin-page">
|
}
|
||||||
<PageHeader
|
|
||||||
title="模型配置"
|
const renderListPanel = (type) => (
|
||||||
description="管理系统可用的大模型提供方、默认接口地址、参数模板与连通性测试。"
|
<>
|
||||||
icon={<CloudServerOutlined />}
|
<div className="admin-toolbar">
|
||||||
extra={(
|
<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
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={openCreateModal}
|
onClick={openCreateModal}
|
||||||
>
|
>
|
||||||
新增模型
|
{MODEL_TYPE_META[type].addText}
|
||||||
</Button>
|
</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">
|
<Card className="admin-card model-config-card">
|
||||||
<div className="admin-toolbar">
|
<Tabs
|
||||||
<div className="admin-toolbar-left">
|
items={tabItems}
|
||||||
<Search
|
activeKey={activeType}
|
||||||
allowClear
|
onChange={handleTabChange}
|
||||||
placeholder="搜索模型名称、编码或模型标识"
|
tabPosition="left"
|
||||||
value={keyword}
|
className="model-config-tabs"
|
||||||
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} 条`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editingConfigId ? '编辑模型配置' : '新增模型配置'}
|
title={`${editingConfigId ? '编辑' : '新增'}${MODEL_TYPE_META[editingType].label}`}
|
||||||
open={modalVisible}
|
open={modalVisible}
|
||||||
width={860}
|
width={860}
|
||||||
className="model-config-modal"
|
className="model-config-modal"
|
||||||
|
|
@ -537,6 +624,12 @@ function ModelConfigs() {
|
||||||
<div className="model-config-modal-hint">
|
<div className="model-config-modal-hint">
|
||||||
<strong>自动生成规则:</strong> 选择提供方后会自动带出默认 `base_url`;填写模型标识后会自动生成“模型名称”和“模型编码”。
|
<strong>自动生成规则:</strong> 选择提供方后会自动带出默认 `base_url`;填写模型标识后会自动生成“模型名称”和“模型编码”。
|
||||||
如果你手动改过这些字段,后续就不会再被自动覆盖。
|
如果你手动改过这些字段,后续就不会再被自动覆盖。
|
||||||
|
{isEmbedding && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<strong>向量维度:</strong> 留空时按模型实际返回维度自动建立向量库;如填写,需与模型输出维度一致。更换不同维度的模型会重建该项目的向量库。
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
|
|
@ -547,11 +640,14 @@ function ModelConfigs() {
|
||||||
llm_timeout: 120,
|
llm_timeout: 120,
|
||||||
llm_temperature: 0.7,
|
llm_temperature: 0.7,
|
||||||
llm_top_p: 0.9,
|
llm_top_p: 0.9,
|
||||||
llm_max_tokens: 2048,
|
llm_max_tokens: DEFAULT_MAX_TOKENS,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_default: false,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Form.Item name="model_type" hidden>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="提供方"
|
label="提供方"
|
||||||
|
|
@ -590,7 +686,13 @@ function ModelConfigs() {
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
rules={[{ required: true, message: '请输入模型标识或部署名' }]}
|
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>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|
@ -640,55 +742,86 @@ function ModelConfigs() {
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Space style={{ width: '100%' }} size={16} align="start">
|
{isEmbedding ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Temperature"
|
label="向量维度"
|
||||||
name="llm_temperature"
|
name="embedding_dimension"
|
||||||
style={{ width: '33%' }}
|
extra="留空则按模型实际返回维度自动建立;填写需与模型输出一致。"
|
||||||
rules={[{ required: true, message: '请输入 temperature' }]}
|
|
||||||
>
|
>
|
||||||
<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>
|
</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
|
<Form.Item
|
||||||
label="Top P"
|
label="Top P"
|
||||||
name="llm_top_p"
|
name="llm_top_p"
|
||||||
style={{ width: '33%' }}
|
style={{ flex: 1 }}
|
||||||
rules={[{ required: true, message: '请输入 top_p' }]}
|
tooltip="核采样阈值,控制候选词的累计概率范围"
|
||||||
>
|
rules={[{ required: true, message: '请设置 top_p' }]}
|
||||||
<InputNumber min={0} max={1} step={0.05} style={{ width: '100%' }} />
|
>
|
||||||
</Form.Item>
|
<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
|
<Form.Item
|
||||||
label="Max Tokens"
|
label="Max Tokens"
|
||||||
name="llm_max_tokens"
|
name="llm_max_tokens"
|
||||||
style={{ width: '33%' }}
|
tooltip="单次回复的最大输出长度"
|
||||||
rules={[{ required: true, message: '请输入 max_tokens' }]}
|
rules={[{ required: true, message: '请选择 max_tokens' }]}
|
||||||
>
|
>
|
||||||
<InputNumber min={1} max={32768} step={128} style={{ width: '100%' }} />
|
<Radio.Group
|
||||||
</Form.Item>
|
className="max-tokens-group"
|
||||||
</Space>
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
options={MAX_TOKENS_OPTIONS}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="系统提示词" name="llm_system_prompt">
|
<Form.Item label="系统提示词" name="llm_system_prompt">
|
||||||
<TextArea
|
<TextArea
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="可选。测试模型时会作为 system prompt 一并发送。"
|
placeholder="可选。对话与测试时会作为 system prompt 一并发送。"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item label="描述" name="description">
|
<Form.Item label="描述" name="description">
|
||||||
<TextArea rows={2} placeholder="可填写用途、场景、适用业务等说明" />
|
<TextArea rows={2} placeholder="可填写用途、场景、适用业务等说明" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Space size={24}>
|
<Form.Item label="启用状态" name="is_active" valuePropName="checked">
|
||||||
<Form.Item label="启用状态" name="is_active" valuePropName="checked">
|
<Switch checkedChildren="启用" unCheckedChildren="停用" />
|
||||||
<Switch checkedChildren="启用" unCheckedChildren="停用" />
|
</Form.Item>
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="设为默认" name="is_default" valuePropName="checked">
|
|
||||||
<Switch checkedChildren="默认" unCheckedChildren="普通" />
|
|
||||||
</Form.Item>
|
|
||||||
</Space>
|
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue