v0.9.9
parent
31cf2bfc1c
commit
06d58be444
|
|
@ -32,5 +32,8 @@ logs/
|
|||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Local models
|
||||
models
|
||||
|
||||
# AI
|
||||
.gemini-clipboard/
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
|
||||
## Session 14:55
|
||||
|
||||
|
||||
## Session 14:59
|
||||
|
||||
|
||||
## Session 16:54
|
||||
|
||||
|
||||
## Session 17:15
|
||||
|
||||
|
||||
## Session 17:16
|
||||
|
||||
|
||||
## Session 17:20
|
||||
|
||||
|
||||
## Session 17:54
|
||||
|
||||
|
||||
## Session 17:54
|
||||
|
||||
|
|
@ -1,23 +1,34 @@
|
|||
"""
|
||||
LLM 模型配置 API
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.llm_model_config import LLMModelConfig
|
||||
from app.models.user import User
|
||||
from app.schemas.response import success_response
|
||||
from app.services.llm_provider_service import LLMProviderService
|
||||
from app.services.local_embedding_service import LocalEmbeddingService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TYPE_SPECIFIC_FIELDS = {
|
||||
"llm_temperature",
|
||||
"llm_top_p",
|
||||
"llm_max_tokens",
|
||||
"llm_system_prompt",
|
||||
"embedding_dimension",
|
||||
"chunk_size",
|
||||
"chunk_overlap",
|
||||
}
|
||||
|
||||
|
||||
class LLMModelConfigUpsertRequest(BaseModel):
|
||||
"""模型配置新增/编辑请求"""
|
||||
|
|
@ -35,6 +46,8 @@ class LLMModelConfigUpsertRequest(BaseModel):
|
|||
llm_max_tokens: int = Field(8192, ge=1, le=32768)
|
||||
llm_system_prompt: Optional[str] = None
|
||||
embedding_dimension: Optional[int] = Field(None, ge=1, le=8192)
|
||||
chunk_size: int = Field(settings.CHUNK_SIZE, ge=100, le=10000)
|
||||
chunk_overlap: int = Field(settings.CHUNK_OVERLAP, ge=0, le=5000)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
is_active: bool = True
|
||||
is_default: bool = False
|
||||
|
|
@ -57,6 +70,12 @@ class LLMModelConfigUpsertRequest(BaseModel):
|
|||
return value or None
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_embedding_options(self):
|
||||
if self.model_type == "embedding" and self.chunk_overlap >= self.chunk_size:
|
||||
raise ValueError("分块重叠字符数必须小于分块字符数")
|
||||
return self
|
||||
|
||||
|
||||
class LLMModelConfigTestRequest(LLMModelConfigUpsertRequest):
|
||||
"""模型测试请求"""
|
||||
|
|
@ -79,6 +98,8 @@ def serialize_model_config(config: LLMModelConfig, include_api_key: bool = False
|
|||
"llm_max_tokens": config.llm_max_tokens,
|
||||
"llm_system_prompt": config.llm_system_prompt,
|
||||
"embedding_dimension": config.embedding_dimension,
|
||||
"chunk_size": config.chunk_size,
|
||||
"chunk_overlap": config.chunk_overlap,
|
||||
"description": config.description,
|
||||
"is_active": bool(config.is_active),
|
||||
"is_default": bool(config.is_default),
|
||||
|
|
@ -117,21 +138,50 @@ def normalize_payload(payload: LLMModelConfigUpsertRequest) -> dict:
|
|||
data["endpoint_url"] = data.get("endpoint_url") or LLMProviderService.get_default_endpoint_url(provider)
|
||||
if data["is_default"]:
|
||||
data["is_active"] = True
|
||||
data["llm_temperature"] = Decimal(str(data["llm_temperature"]))
|
||||
data["llm_top_p"] = Decimal(str(data["llm_top_p"]))
|
||||
# embedding 不需要对话采样参数,但列非空,保留默认值即可
|
||||
if model_type == "embedding":
|
||||
data["llm_system_prompt"] = None
|
||||
return data
|
||||
|
||||
|
||||
async def ensure_default_config(db: AsyncSession, preferred_config_id: Optional[int] = None):
|
||||
"""确保始终存在一个默认启用模型"""
|
||||
def to_storage_payload(payload: dict) -> dict:
|
||||
"""将接口扁平字段归并为按模型类型区分的 JSON 参数。"""
|
||||
data = dict(payload)
|
||||
if data["model_type"] == "embedding":
|
||||
type_config = {
|
||||
"chunk_size": data["chunk_size"],
|
||||
"chunk_overlap": data["chunk_overlap"],
|
||||
}
|
||||
if data.get("embedding_dimension") is not None:
|
||||
type_config["dimension"] = data["embedding_dimension"]
|
||||
else:
|
||||
type_config = {
|
||||
"temperature": data["llm_temperature"],
|
||||
"top_p": data["llm_top_p"],
|
||||
"max_tokens": data["llm_max_tokens"],
|
||||
}
|
||||
if data.get("llm_system_prompt"):
|
||||
type_config["system_prompt"] = data["llm_system_prompt"]
|
||||
|
||||
for field in TYPE_SPECIFIC_FIELDS:
|
||||
data.pop(field, None)
|
||||
data["type_config"] = type_config
|
||||
return data
|
||||
|
||||
|
||||
async def ensure_default_config(
|
||||
db: AsyncSession,
|
||||
model_type: str,
|
||||
preferred_config_id: Optional[int] = None,
|
||||
):
|
||||
"""确保指定模型类型始终存在一个默认启用配置。"""
|
||||
default_result = await db.execute(
|
||||
select(LLMModelConfig.config_id).where(
|
||||
select(LLMModelConfig.config_id)
|
||||
.where(
|
||||
LLMModelConfig.model_type == model_type,
|
||||
LLMModelConfig.is_default == True,
|
||||
LLMModelConfig.is_active == True,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if default_result.scalar_one_or_none():
|
||||
return
|
||||
|
|
@ -141,6 +191,7 @@ async def ensure_default_config(db: AsyncSession, preferred_config_id: Optional[
|
|||
candidate_result = await db.execute(
|
||||
select(LLMModelConfig.config_id).where(
|
||||
LLMModelConfig.config_id == preferred_config_id,
|
||||
LLMModelConfig.model_type == model_type,
|
||||
LLMModelConfig.is_active == True,
|
||||
)
|
||||
)
|
||||
|
|
@ -149,7 +200,10 @@ async def ensure_default_config(db: AsyncSession, preferred_config_id: Optional[
|
|||
if candidate_id is None:
|
||||
fallback_result = await db.execute(
|
||||
select(LLMModelConfig.config_id)
|
||||
.where(LLMModelConfig.is_active == True)
|
||||
.where(
|
||||
LLMModelConfig.model_type == model_type,
|
||||
LLMModelConfig.is_active == True,
|
||||
)
|
||||
.order_by(LLMModelConfig.updated_at.desc(), LLMModelConfig.config_id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
|
@ -158,7 +212,11 @@ async def ensure_default_config(db: AsyncSession, preferred_config_id: Optional[
|
|||
if candidate_id is None:
|
||||
return
|
||||
|
||||
await db.execute(update(LLMModelConfig).values(is_default=False))
|
||||
await db.execute(
|
||||
update(LLMModelConfig)
|
||||
.where(LLMModelConfig.model_type == model_type)
|
||||
.values(is_default=False)
|
||||
)
|
||||
await db.execute(
|
||||
update(LLMModelConfig)
|
||||
.where(LLMModelConfig.config_id == candidate_id)
|
||||
|
|
@ -171,7 +229,13 @@ async def get_provider_catalog(
|
|||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取模型提供方目录"""
|
||||
return success_response(data=LLMProviderService.get_provider_catalog())
|
||||
catalog = [dict(item) for item in LLMProviderService.get_provider_catalog()]
|
||||
for item in catalog:
|
||||
item["default_chunk_size"] = settings.CHUNK_SIZE
|
||||
item["default_chunk_overlap"] = settings.CHUNK_OVERLAP
|
||||
if item["value"] == "local":
|
||||
item["models"] = LocalEmbeddingService.list_available_models()
|
||||
return success_response(data=catalog)
|
||||
|
||||
|
||||
@router.get("/", response_model=dict)
|
||||
|
|
@ -255,7 +319,7 @@ async def create_llm_model_config(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建模型配置"""
|
||||
payload = normalize_payload(request_data)
|
||||
payload = to_storage_payload(normalize_payload(request_data))
|
||||
|
||||
existing_code_result = await db.execute(
|
||||
select(LLMModelConfig).where(LLMModelConfig.model_code == payload["model_code"])
|
||||
|
|
@ -270,11 +334,18 @@ async def create_llm_model_config(
|
|||
if payload["is_default"]:
|
||||
await db.execute(
|
||||
update(LLMModelConfig)
|
||||
.where(LLMModelConfig.config_id != new_config.config_id)
|
||||
.where(
|
||||
LLMModelConfig.model_type == new_config.model_type,
|
||||
LLMModelConfig.config_id != new_config.config_id,
|
||||
)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
await ensure_default_config(db, preferred_config_id=new_config.config_id)
|
||||
await ensure_default_config(
|
||||
db,
|
||||
new_config.model_type,
|
||||
preferred_config_id=new_config.config_id,
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(new_config)
|
||||
|
||||
|
|
@ -299,7 +370,8 @@ async def update_llm_model_config(
|
|||
if not config:
|
||||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||||
|
||||
payload = normalize_payload(request_data)
|
||||
previous_model_type = config.model_type or "chat"
|
||||
payload = to_storage_payload(normalize_payload(request_data))
|
||||
existing_code_result = await db.execute(
|
||||
select(LLMModelConfig).where(
|
||||
LLMModelConfig.model_code == payload["model_code"],
|
||||
|
|
@ -317,11 +389,20 @@ async def update_llm_model_config(
|
|||
if config.is_default:
|
||||
await db.execute(
|
||||
update(LLMModelConfig)
|
||||
.where(LLMModelConfig.config_id != config.config_id)
|
||||
.where(
|
||||
LLMModelConfig.model_type == config.model_type,
|
||||
LLMModelConfig.config_id != config.config_id,
|
||||
)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
await ensure_default_config(db, preferred_config_id=config.config_id)
|
||||
await ensure_default_config(
|
||||
db,
|
||||
config.model_type,
|
||||
preferred_config_id=config.config_id,
|
||||
)
|
||||
if previous_model_type != config.model_type:
|
||||
await ensure_default_config(db, previous_model_type)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
|
||||
|
|
@ -351,7 +432,7 @@ async def update_llm_model_config_status(
|
|||
config.is_default = False
|
||||
|
||||
await db.flush()
|
||||
await ensure_default_config(db)
|
||||
await ensure_default_config(db, config.model_type)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
|
||||
|
|
@ -380,14 +461,20 @@ async def set_default_llm_model_config(
|
|||
await db.flush()
|
||||
await db.execute(
|
||||
update(LLMModelConfig)
|
||||
.where(LLMModelConfig.config_id != config.config_id)
|
||||
.where(
|
||||
LLMModelConfig.model_type == config.model_type,
|
||||
LLMModelConfig.config_id != config.config_id,
|
||||
)
|
||||
.values(is_default=False)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
|
||||
return success_response(
|
||||
data=serialize_model_config(config),
|
||||
data={
|
||||
**serialize_model_config(config),
|
||||
"requires_revectorization": config.model_type == "embedding",
|
||||
},
|
||||
message="默认模型切换成功",
|
||||
)
|
||||
|
||||
|
|
@ -407,11 +494,12 @@ async def delete_llm_model_config(
|
|||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||||
|
||||
was_default = bool(config.is_default)
|
||||
model_type = config.model_type or "chat"
|
||||
await db.delete(config)
|
||||
await db.flush()
|
||||
|
||||
if was_default:
|
||||
await ensure_default_config(db)
|
||||
await ensure_default_config(db, model_type)
|
||||
|
||||
await db.commit()
|
||||
return success_response(message="模型配置删除成功")
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
"""
|
||||
导出所有数据库模型
|
||||
"""
|
||||
from app.core.database import Base
|
||||
from app.models.user import User
|
||||
from app.models.role import Role, UserRole
|
||||
from app.models.menu import SystemMenu, RoleMenu
|
||||
from app.models.project import Project, ProjectMember, ProjectMemberRole
|
||||
from app.models.document import DocumentMeta
|
||||
from app.models.document_vector import DocumentVector
|
||||
from app.models.share import ShareLink
|
||||
from app.models.log import OperationLog
|
||||
from app.models.mcp_bot import MCPBot
|
||||
from app.models.llm_model_config import LLMModelConfig
|
||||
from app.models.chat_session import ChatSession, ChatMessage
|
||||
from app.models.project_vectorization_task import ProjectVectorizationTask
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"User",
|
||||
"Role",
|
||||
"UserRole",
|
||||
"SystemMenu",
|
||||
"RoleMenu",
|
||||
"Project",
|
||||
"ProjectMember",
|
||||
"ProjectMemberRole",
|
||||
"DocumentMeta",
|
||||
"DocumentVector",
|
||||
"ShareLink",
|
||||
"OperationLog",
|
||||
"MCPBot",
|
||||
"LLMModelConfig",
|
||||
"ChatSession",
|
||||
"ChatMessage",
|
||||
"ProjectVectorizationTask",
|
||||
]
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
"""
|
||||
知识库对话会话模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, Text, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ChatSession(Base):
|
||||
"""对话会话表"""
|
||||
|
||||
__tablename__ = "chat_session"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="会话ID")
|
||||
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
||||
user_id = Column(BigInteger, nullable=False, index=True, comment="用户ID")
|
||||
llm_config_id = Column(BigInteger, nullable=False, comment="LLM配置ID")
|
||||
title = Column(String(255), nullable=False, comment="会话标题")
|
||||
description = Column(Text, comment="会话描述")
|
||||
is_active = Column(Boolean, nullable=False, default=True, comment="是否激活")
|
||||
message_count = Column(Integer, nullable=False, default=0, comment="消息数")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ChatSession(id={self.id}, project_id={self.project_id}, user_id={self.user_id})>"
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
"""对话消息表"""
|
||||
|
||||
__tablename__ = "chat_message"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="消息ID")
|
||||
session_id = Column(BigInteger, nullable=False, index=True, comment="会话ID")
|
||||
role = Column(String(32), nullable=False, comment="角色(user/assistant)")
|
||||
content = Column(Text, nullable=False, comment="消息内容")
|
||||
referenced_files = Column(Text, comment="参考文件(JSON数组)")
|
||||
tokens_used = Column(Integer, comment="消耗的token数")
|
||||
is_deleted = Column(Boolean, nullable=False, default=False, comment="是否已删除")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ChatMessage(id={self.id}, session_id={self.session_id}, role='{self.role}')>"
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
"""
|
||||
文档元数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class DocumentMeta(Base):
|
||||
"""文档元数据表模型"""
|
||||
|
||||
__tablename__ = "document_meta"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="元数据ID")
|
||||
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
||||
file_path = Column(String(500), nullable=False, comment="文件相对路径")
|
||||
title = Column(String(200), comment="文档标题")
|
||||
tags = Column(String(500), comment="标签(JSON数组)")
|
||||
author_id = Column(BigInteger, index=True, comment="作者ID")
|
||||
word_count = Column(Integer, default=0, comment="字数统计")
|
||||
view_count = Column(Integer, default=0, comment="浏览次数")
|
||||
last_editor_id = Column(BigInteger, comment="最后编辑者ID")
|
||||
last_edited_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="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DocumentMeta(id={self.id}, project_id={self.project_id}, file_path='{self.file_path}')>"
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
"""
|
||||
文档向量化模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, Integer, String, DateTime, Index, Text
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class DocumentVector(Base):
|
||||
"""文档向量表模型"""
|
||||
|
||||
__tablename__ = "document_vector"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="向量ID")
|
||||
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
||||
file_path = Column(String(500), nullable=False, comment="文件相对路径")
|
||||
chunk_index = Column(Integer, nullable=False, default=0, comment="分块序号(0起),同一文件可有多个分块")
|
||||
chunk_text = Column(Text, comment="分块首段文本,作为点击引用时的定位锚点")
|
||||
content_hash = Column(String(64), comment="整个文件内容哈希值,用于判断文件是否变更")
|
||||
zvec_id = Column(String(256), comment="ZVec返回的向量ID(每个分块独立)")
|
||||
zvec_response = Column(Text, comment="ZVec完整响应JSON")
|
||||
status = Column(String(32), nullable=False, default="success", comment="向量化状态:success/failed/pending")
|
||||
error_message = Column(String(500), comment="错误信息")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_project_file", "project_id", "file_path"),
|
||||
Index("idx_project_file_chunk", "project_id", "file_path", "chunk_index"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<DocumentVector(id={self.id}, project_id={self.project_id}, "
|
||||
f"file_path='{self.file_path}', chunk_index={self.chunk_index}, status='{self.status}')>"
|
||||
)
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
"""
|
||||
项目Git仓库模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, SmallInteger, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ProjectGitRepo(Base):
|
||||
"""项目Git仓库表模型"""
|
||||
|
||||
__tablename__ = "project_git_repos"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="ID")
|
||||
project_id = Column(BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True, comment="项目ID")
|
||||
name = Column(String(50), nullable=False, comment="仓库别名")
|
||||
repo_url = Column(String(255), nullable=False, comment="Git仓库地址")
|
||||
branch = Column(String(50), default="main", comment="Git分支")
|
||||
username = Column(String(100), comment="Git用户名")
|
||||
token = Column(String(255), comment="Git访问令牌/密码")
|
||||
is_default = Column(SmallInteger, default=0, comment="是否默认仓库")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
# 关系
|
||||
# project = relationship("Project", back_populates="git_repos")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProjectGitRepo(id={self.id}, name='{self.name}', repo_url='{self.repo_url}')>"
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
"""
|
||||
LLM 模型配置模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, Text, Numeric, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class LLMModelConfig(Base):
|
||||
"""大模型配置表模型"""
|
||||
|
||||
__tablename__ = "llm_model_config"
|
||||
|
||||
config_id = Column(BigInteger, primary_key=True, autoincrement=True, comment="配置ID")
|
||||
model_code = Column(String(128), nullable=False, unique=True, index=True, comment="模型编码")
|
||||
model_name = Column(String(255), nullable=False, comment="模型名称")
|
||||
model_type = Column(String(32), nullable=False, default="chat", index=True, comment="模型类型: chat/embedding")
|
||||
provider = Column(String(64), comment="模型提供方")
|
||||
endpoint_url = Column(String(512), comment="接口地址")
|
||||
api_key = Column(String(512), comment="API Key")
|
||||
llm_model_name = Column(String(128), nullable=False, comment="模型名称/部署名")
|
||||
llm_timeout = Column(Integer, nullable=False, default=120, comment="超时时间(秒)")
|
||||
llm_temperature = Column(Numeric(5, 2), nullable=False, default=0.70, comment="温度")
|
||||
llm_top_p = Column(Numeric(5, 2), nullable=False, default=0.90, comment="Top P")
|
||||
llm_max_tokens = Column(Integer, nullable=False, default=8192, comment="最大输出 Token")
|
||||
llm_system_prompt = Column(Text, comment="系统提示词")
|
||||
embedding_dimension = Column(Integer, nullable=True, default=None, comment="向量维度(仅 embedding 类型)")
|
||||
description = Column(String(500), comment="描述")
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True, comment="是否启用")
|
||||
is_default = Column(Boolean, nullable=False, default=False, comment="是否默认")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LLMModelConfig(config_id={self.config_id}, model_code='{self.model_code}')>"
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
"""
|
||||
操作日志模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, SmallInteger, Text
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class OperationLog(Base):
|
||||
"""操作日志表模型"""
|
||||
|
||||
__tablename__ = "operation_logs"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="日志ID")
|
||||
user_id = Column(BigInteger, index=True, comment="操作用户ID")
|
||||
username = Column(String(50), comment="用户名")
|
||||
operation_type = Column(String(50), nullable=False, comment="操作类型")
|
||||
resource_type = Column(String(50), nullable=False, index=True, comment="资源类型")
|
||||
resource_id = Column(BigInteger, index=True, comment="资源ID")
|
||||
detail = Column(Text, comment="操作详情(JSON)")
|
||||
ip_address = Column(String(50), comment="IP地址")
|
||||
user_agent = Column(String(500), comment="用户代理")
|
||||
status = Column(SmallInteger, default=1, comment="状态:0-失败 1-成功")
|
||||
error_message = Column(Text, comment="错误信息")
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True, comment="操作时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OperationLog(id={self.id}, operation_type='{self.operation_type}')>"
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
"""
|
||||
MCP bot credential model.
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, DateTime, SmallInteger
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class MCPBot(Base):
|
||||
"""Stores MCP access credentials mapped to a NexDocs user."""
|
||||
|
||||
__tablename__ = "mcp_bots"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="Bot credential ID")
|
||||
user_id = Column(BigInteger, nullable=False, unique=True, index=True, comment="Owner user ID")
|
||||
bot_id = Column(String(64), nullable=False, unique=True, index=True, comment="External MCP bot id")
|
||||
bot_secret = Column(String(255), nullable=False, comment="External MCP bot secret")
|
||||
status = Column(SmallInteger, default=1, index=True, comment="Status: 0-disabled 1-enabled")
|
||||
last_used_at = Column(DateTime, comment="Last successful MCP access time")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="Created at")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="Updated at")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MCPBot(user_id={self.user_id}, bot_id='{self.bot_id}')>"
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
"""
|
||||
菜单模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, SmallInteger
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SystemMenu(Base):
|
||||
"""系统菜单表模型"""
|
||||
|
||||
__tablename__ = "system_menus"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="菜单ID")
|
||||
parent_id = Column(BigInteger, default=0, comment="父菜单ID(0表示根菜单)")
|
||||
menu_name = Column(String(50), nullable=False, comment="菜单名称")
|
||||
menu_code = Column(String(50), nullable=False, unique=True, index=True, comment="菜单编码")
|
||||
menu_type = Column(SmallInteger, nullable=False, comment="菜单类型:1-目录 2-菜单 3-按钮/权限点")
|
||||
path = Column(String(255), comment="路由路径")
|
||||
component = Column(String(255), comment="组件路径")
|
||||
icon = Column(String(100), comment="图标")
|
||||
sort_order = Column(Integer, default=0, comment="排序号")
|
||||
visible = Column(SmallInteger, default=1, comment="是否可见:0-隐藏 1-显示")
|
||||
status = Column(SmallInteger, default=1, index=True, comment="状态:0-禁用 1-启用")
|
||||
permission = Column(String(100), comment="权限字符串")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemMenu(id={self.id}, menu_name='{self.menu_name}')>"
|
||||
|
||||
|
||||
class RoleMenu(Base):
|
||||
"""角色菜单授权表模型"""
|
||||
|
||||
__tablename__ = "role_menus"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="关联ID")
|
||||
role_id = Column(BigInteger, nullable=False, index=True, comment="角色ID")
|
||||
menu_id = Column(BigInteger, nullable=False, index=True, comment="菜单ID")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RoleMenu(role_id={self.role_id}, menu_id={self.menu_id})>"
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
"""
|
||||
通知模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, DateTime, SmallInteger, Text, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
"""用户通知表模型"""
|
||||
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="通知ID")
|
||||
user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="接收用户ID")
|
||||
type = Column(String(20), default="info", comment="类型:info, success, warning, error")
|
||||
category = Column(String(50), default="system", comment="分类:system, project, collaboration")
|
||||
title = Column(String(200), nullable=False, comment="标题")
|
||||
content = Column(Text, comment="内容")
|
||||
link = Column(String(255), comment="跳转链接")
|
||||
is_read = Column(SmallInteger, default=0, comment="是否已读:0-未读 1-已读")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
read_at = Column(DateTime, comment="阅读时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Notification(id={self.id}, user_id={self.user_id}, title='{self.title}')>"
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
"""
|
||||
项目模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, SmallInteger, Enum
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class ProjectMemberRole(str, enum.Enum):
|
||||
"""项目成员角色枚举"""
|
||||
ADMIN = "admin"
|
||||
EDITOR = "editor"
|
||||
VIEWER = "viewer"
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""项目表模型"""
|
||||
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="项目ID")
|
||||
name = Column(String(100), nullable=False, index=True, comment="项目名称")
|
||||
description = Column(String(500), comment="项目描述")
|
||||
storage_key = Column(String(36), nullable=False, unique=True, comment="磁盘存储UUID")
|
||||
owner_id = Column(BigInteger, nullable=False, index=True, comment="项目所有者ID")
|
||||
is_public = Column(SmallInteger, default=0, comment="是否公开:0-私有 1-公开")
|
||||
is_template = Column(SmallInteger, default=0, comment="是否模板项目:0-否 1-是")
|
||||
status = Column(SmallInteger, default=1, index=True, comment="状态:0-归档 1-活跃")
|
||||
cover_image = Column(String(255), comment="封面图")
|
||||
sort_order = Column(Integer, default=0, comment="排序号")
|
||||
visit_count = Column(Integer, default=0, comment="访问次数")
|
||||
access_pass = Column(String(100), comment="访问密码(用于分享链接)")
|
||||
created_at = Column(DateTime, server_default=func.now(), index=True, comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Project(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
"""项目成员表模型"""
|
||||
|
||||
__tablename__ = "project_members"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="成员ID")
|
||||
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
||||
user_id = Column(BigInteger, nullable=False, index=True, comment="用户ID")
|
||||
role = Column(
|
||||
String(20),
|
||||
default="viewer",
|
||||
index=True,
|
||||
comment="项目角色: admin/editor/viewer"
|
||||
)
|
||||
invited_by = Column(BigInteger, comment="邀请人ID")
|
||||
joined_at = Column(DateTime, server_default=func.now(), comment="加入时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProjectMember(project_id={self.project_id}, user_id={self.user_id}, role='{self.role}')>"
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
"""
|
||||
项目向量化任务模型
|
||||
"""
|
||||
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}')>"
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
"""
|
||||
角色模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, DateTime, SmallInteger
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""角色表模型"""
|
||||
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="角色ID")
|
||||
role_name = Column(String(50), nullable=False, unique=True, comment="角色名称")
|
||||
role_code = Column(String(50), nullable=False, unique=True, index=True, comment="角色编码")
|
||||
description = Column(String(255), comment="角色描述")
|
||||
status = Column(SmallInteger, default=1, index=True, comment="状态:0-禁用 1-启用")
|
||||
is_system = Column(SmallInteger, default=0, comment="是否系统角色:0-否 1-是")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role(id={self.id}, role_code='{self.role_code}')>"
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""用户角色关联表模型"""
|
||||
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="关联ID")
|
||||
user_id = Column(BigInteger, nullable=False, index=True, comment="用户ID")
|
||||
role_id = Column(BigInteger, nullable=False, index=True, comment="角色ID")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRole(user_id={self.user_id}, role_id={self.role_id})>"
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
"""
|
||||
分享链接模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, DateTime, SmallInteger
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ShareLink(Base):
|
||||
"""项目分享/文件分享链接"""
|
||||
|
||||
__tablename__ = "share_links"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="分享ID")
|
||||
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
||||
share_type = Column(String(20), nullable=False, index=True, comment="分享类型: project/file")
|
||||
share_code = Column(String(64), nullable=False, unique=True, index=True, comment="公开分享码")
|
||||
file_path = Column(String(500), comment="文件路径,仅文件分享使用")
|
||||
access_pass = Column(String(100), comment="访问密码")
|
||||
created_by = Column(BigInteger, index=True, comment="创建人ID")
|
||||
status = Column(SmallInteger, default=1, index=True, comment="状态:0-禁用 1-启用")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ShareLink(id={self.id}, type='{self.share_type}', code='{self.share_code}')>"
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
"""
|
||||
用户模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, SmallInteger
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户表模型"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="用户ID")
|
||||
username = Column(String(50), nullable=False, unique=True, index=True, comment="用户名")
|
||||
password_hash = Column(String(255), nullable=False, comment="密码哈希")
|
||||
nickname = Column(String(50), comment="昵称")
|
||||
email = Column(String(100), index=True, comment="邮箱")
|
||||
phone = Column(String(20), comment="手机号")
|
||||
avatar = Column(String(255), comment="头像URL")
|
||||
status = Column(SmallInteger, default=1, index=True, comment="状态:0-禁用 1-启用")
|
||||
is_superuser = Column(SmallInteger, default=0, comment="是否超级管理员:0-否 1-是")
|
||||
last_login_at = Column(DateTime, comment="最后登录时间")
|
||||
last_login_ip = Column(String(50), comment="最后登录IP")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}')>"
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"""项目文件变更后的后台向量索引同步。"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Set
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.services.zvec_service import zvec_service
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileVectorSyncService:
|
||||
"""使用独立数据库会话串行同步单个项目的文件向量。"""
|
||||
|
||||
def __init__(self):
|
||||
self._project_locks = {}
|
||||
self._tasks: Set[asyncio.Task] = set()
|
||||
|
||||
def revectorize(self, project_id: int, file_path: str) -> None:
|
||||
self._schedule(project_id, "revectorize", file_path)
|
||||
|
||||
def delete(self, project_id: int, file_path: str) -> None:
|
||||
self._schedule(project_id, "delete", file_path)
|
||||
|
||||
def move(self, project_id: int, old_path: str, new_path: str) -> None:
|
||||
self._schedule(project_id, "move", old_path, new_path)
|
||||
|
||||
def _schedule(
|
||||
self,
|
||||
project_id: int,
|
||||
operation: str,
|
||||
file_path: str,
|
||||
new_path: str = "",
|
||||
) -> None:
|
||||
task = asyncio.create_task(
|
||||
self._run(project_id, operation, file_path, new_path)
|
||||
)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
project_id: int,
|
||||
operation: str,
|
||||
file_path: str,
|
||||
new_path: str,
|
||||
) -> None:
|
||||
lock = self._project_locks.setdefault(project_id, asyncio.Lock())
|
||||
async with lock:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
if operation == "delete":
|
||||
await zvec_service.delete_vector(db, project_id, file_path)
|
||||
elif operation == "move":
|
||||
await zvec_service.delete_vector(db, project_id, file_path)
|
||||
await zvec_service.revectorize_path(db, project_id, new_path)
|
||||
else:
|
||||
await zvec_service.revectorize_path(db, project_id, file_path)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception(
|
||||
"File vector sync failed: project=%s operation=%s path=%s",
|
||||
project_id,
|
||||
operation,
|
||||
file_path,
|
||||
)
|
||||
|
||||
|
||||
file_vector_sync_service = FileVectorSyncService()
|
||||
|
|
@ -15,7 +15,7 @@ import urllib.request
|
|||
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional
|
||||
|
||||
|
||||
PROVIDER_CATALOG: List[Dict[str, str]] = [
|
||||
PROVIDER_CATALOG: List[Dict[str, Any]] = [
|
||||
{
|
||||
"value": "openai",
|
||||
"label": "OpenAI",
|
||||
|
|
@ -82,6 +82,13 @@ PROVIDER_CATALOG: List[Dict[str, str]] = [
|
|||
"default_endpoint_url": "http://localhost:11434/v1",
|
||||
"protocol": "openai_compatible",
|
||||
},
|
||||
{
|
||||
"value": "local",
|
||||
"label": "本地模型",
|
||||
"default_endpoint_url": "",
|
||||
"protocol": "local_embedding",
|
||||
"model_types": ["embedding"],
|
||||
},
|
||||
{
|
||||
"value": "ark",
|
||||
"label": "火山方舟",
|
||||
|
|
@ -103,7 +110,7 @@ class LLMProviderService:
|
|||
"""LLM 提供方测试服务"""
|
||||
|
||||
@staticmethod
|
||||
def get_provider_catalog() -> List[Dict[str, str]]:
|
||||
def get_provider_catalog() -> List[Dict[str, Any]]:
|
||||
return PROVIDER_CATALOG
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -826,13 +833,25 @@ class LLMProviderService:
|
|||
timeout: int = 60,
|
||||
dimension: Optional[int] = None,
|
||||
) -> List[float]:
|
||||
"""调用 OpenAI 兼容的 /embeddings 接口生成单条文本向量"""
|
||||
"""使用本地模型或 OpenAI 兼容接口生成单条文本向量。"""
|
||||
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 provider == "local":
|
||||
from app.services.local_embedding_service import LocalEmbeddingService
|
||||
|
||||
if not text:
|
||||
raise ValueError("待向量化文本为空")
|
||||
vector = await LocalEmbeddingService.generate_embedding(llm_model_name, text)
|
||||
if dimension and len(vector) != int(dimension):
|
||||
raise ValueError(
|
||||
f"模型实际输出 {len(vector)} 维,与配置的 {dimension} 维不一致"
|
||||
)
|
||||
return vector
|
||||
|
||||
if not endpoint_url:
|
||||
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
|
||||
if not llm_model_name:
|
||||
|
|
@ -853,7 +872,12 @@ class LLMProviderService:
|
|||
)
|
||||
if not vectors:
|
||||
raise ValueError("Embedding 接口返回为空")
|
||||
return vectors[0]
|
||||
vector = vectors[0]
|
||||
if dimension and len(vector) != int(dimension):
|
||||
raise ValueError(
|
||||
f"模型实际输出 {len(vector)} 维,与配置的 {dimension} 维不一致"
|
||||
)
|
||||
return vector
|
||||
|
||||
@classmethod
|
||||
def _embeddings_request(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
"""本地 Sentence Transformers 模型加载与推理。"""
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class LocalEmbeddingService:
|
||||
"""从 backend/models 安全加载本地向量模型,并复用已加载实例。"""
|
||||
|
||||
MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
|
||||
_models: Dict[str, Any] = {}
|
||||
_load_lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def list_available_models(cls) -> List[Dict[str, Any]]:
|
||||
models_root = cls.MODELS_DIR.resolve()
|
||||
if not models_root.is_dir():
|
||||
return []
|
||||
|
||||
models = []
|
||||
for model_path in sorted(models_root.iterdir(), key=lambda item: item.name.lower()):
|
||||
if not model_path.is_dir() or model_path.name.startswith("."):
|
||||
continue
|
||||
dimension = None
|
||||
pooling_config = model_path / "1_Pooling" / "config.json"
|
||||
try:
|
||||
with pooling_config.open("r", encoding="utf-8") as file:
|
||||
dimension = json.load(file).get("word_embedding_dimension")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
pass
|
||||
ready = any(
|
||||
(model_path / filename).is_file()
|
||||
for filename in ("model.safetensors", "pytorch_model.bin")
|
||||
)
|
||||
models.append({
|
||||
"name": model_path.name,
|
||||
"dimension": dimension,
|
||||
"ready": ready,
|
||||
})
|
||||
return models
|
||||
|
||||
@classmethod
|
||||
def resolve_model_path(cls, model_name: str) -> Path:
|
||||
name = (model_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("缺少本地模型目录名称")
|
||||
|
||||
models_root = cls.MODELS_DIR.resolve()
|
||||
requested = Path(name)
|
||||
candidate = requested.resolve() if requested.is_absolute() else (models_root / requested).resolve()
|
||||
if not candidate.is_relative_to(models_root):
|
||||
raise ValueError("本地模型必须位于 backend/models 目录中")
|
||||
if not candidate.is_dir():
|
||||
raise ValueError(f"本地模型目录不存在:{candidate.name}")
|
||||
return candidate
|
||||
|
||||
@classmethod
|
||||
def _get_model(cls, model_path: Path):
|
||||
cache_key = str(model_path)
|
||||
with cls._load_lock:
|
||||
model = cls._models.get(cache_key)
|
||||
if model is not None:
|
||||
return model
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
except ImportError as exc:
|
||||
raise ValueError(
|
||||
"本地向量模型依赖未安装,请执行 pip install -r requirements.txt"
|
||||
) from exc
|
||||
model = SentenceTransformer(cache_key, local_files_only=True)
|
||||
cls._models[cache_key] = model
|
||||
return model
|
||||
|
||||
@classmethod
|
||||
def _encode(cls, model_path: Path, text: str) -> List[float]:
|
||||
model = cls._get_model(model_path)
|
||||
vector = model.encode(
|
||||
text,
|
||||
normalize_embeddings=True,
|
||||
convert_to_numpy=True,
|
||||
show_progress_bar=False,
|
||||
)
|
||||
return [float(value) for value in vector.tolist()]
|
||||
|
||||
@classmethod
|
||||
async def generate_embedding(cls, model_name: str, text: str) -> List[float]:
|
||||
model_path = cls.resolve_model_path(model_name)
|
||||
return await asyncio.to_thread(cls._encode, model_path, text)
|
||||
|
|
@ -2,8 +2,7 @@
|
|||
项目文件业务服务
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -15,7 +14,7 @@ from app.services.log_service import log_service
|
|||
from app.services.notification_service import notification_service
|
||||
from app.services.search_service import search_service
|
||||
from app.services.storage import storage_service
|
||||
from app.services.zvec_service import zvec_service
|
||||
from app.services.file_vector_sync_service import file_vector_sync_service
|
||||
|
||||
|
||||
class ProjectFileService:
|
||||
|
|
@ -63,6 +62,34 @@ class ProjectFileService:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _markdown_paths(file_path: Path, relative_path: str) -> List[str]:
|
||||
if file_path.is_file():
|
||||
return [relative_path] if relative_path.endswith(".md") else []
|
||||
if not file_path.is_dir():
|
||||
return []
|
||||
return [
|
||||
(Path(relative_path) / child.relative_to(file_path)).as_posix()
|
||||
for child in file_path.rglob("*.md")
|
||||
if child.is_file()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _markdown_moves(
|
||||
cls,
|
||||
file_path: Path,
|
||||
old_path: str,
|
||||
new_path: str,
|
||||
) -> List[Tuple[str, str]]:
|
||||
if file_path.is_file():
|
||||
if old_path.endswith(".md") or new_path.endswith(".md"):
|
||||
return [(old_path, new_path)]
|
||||
return []
|
||||
return [
|
||||
(path, (Path(new_path) / Path(path).relative_to(old_path)).as_posix())
|
||||
for path in cls._markdown_paths(file_path, old_path)
|
||||
]
|
||||
|
||||
async def save_file(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
|
@ -102,10 +129,9 @@ class ProjectFileService:
|
|||
category="project",
|
||||
)
|
||||
|
||||
if path.endswith(".md"):
|
||||
asyncio.create_task(zvec_service.vectorize_markdown(db, project_id, path, content))
|
||||
|
||||
await db.commit()
|
||||
if path.endswith(".md"):
|
||||
file_vector_sync_service.revectorize(project_id, path)
|
||||
return "文件保存成功" if source == "http" else "文件更新成功"
|
||||
|
||||
async def operate_file(
|
||||
|
|
@ -125,11 +151,10 @@ class ProjectFileService:
|
|||
current_path = storage_service.get_secure_path(project.storage_key, path)
|
||||
|
||||
if action == "delete":
|
||||
markdown_paths = self._markdown_paths(current_path, path)
|
||||
await storage_service.delete_file(current_path)
|
||||
await self._remove_markdown_index(project_id, path)
|
||||
|
||||
if path.endswith(".md"):
|
||||
await zvec_service.delete_vectors(db, project_id, path)
|
||||
for markdown_path in markdown_paths:
|
||||
await self._remove_markdown_index(project_id, markdown_path)
|
||||
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
|
|
@ -153,6 +178,8 @@ class ProjectFileService:
|
|||
category="project",
|
||||
)
|
||||
await db.commit()
|
||||
for markdown_path in markdown_paths:
|
||||
file_vector_sync_service.delete(project_id, markdown_path)
|
||||
return "删除成功" if source == "http" else "文件删除成功"
|
||||
|
||||
if action in {"rename", "move"}:
|
||||
|
|
@ -161,8 +188,25 @@ class ProjectFileService:
|
|||
raise HTTPException(status_code=400, detail=detail)
|
||||
|
||||
destination_path = storage_service.get_secure_path(project.storage_key, new_path)
|
||||
markdown_moves = self._markdown_moves(current_path, path, new_path)
|
||||
await storage_service.rename_file(current_path, destination_path)
|
||||
await self._sync_markdown_index_for_move(project_id, path, new_path, destination_path)
|
||||
for old_markdown_path, new_markdown_path in markdown_moves:
|
||||
new_file_path = storage_service.get_secure_path(
|
||||
project.storage_key,
|
||||
new_markdown_path,
|
||||
)
|
||||
if old_markdown_path.endswith(".md") and new_markdown_path.endswith(".md"):
|
||||
await self._sync_markdown_index_for_move(
|
||||
project_id,
|
||||
old_markdown_path,
|
||||
new_markdown_path,
|
||||
new_file_path,
|
||||
)
|
||||
elif old_markdown_path.endswith(".md"):
|
||||
await self._remove_markdown_index(project_id, old_markdown_path)
|
||||
elif new_markdown_path.endswith(".md"):
|
||||
content = await storage_service.read_file(new_file_path)
|
||||
await self._update_markdown_index(project_id, new_markdown_path, content)
|
||||
|
||||
operation_type = (
|
||||
OperationType.RENAME_FILE if action == "rename" else OperationType.MOVE_FILE
|
||||
|
|
@ -194,10 +238,18 @@ class ProjectFileService:
|
|||
category="project",
|
||||
)
|
||||
|
||||
if path.endswith(".md") and new_path.endswith(".md"):
|
||||
asyncio.create_task(zvec_service.sync_vector_move(db, project_id, path, new_path))
|
||||
|
||||
await db.commit()
|
||||
for old_markdown_path, new_markdown_path in markdown_moves:
|
||||
if old_markdown_path.endswith(".md") and new_markdown_path.endswith(".md"):
|
||||
file_vector_sync_service.move(
|
||||
project_id,
|
||||
old_markdown_path,
|
||||
new_markdown_path,
|
||||
)
|
||||
elif old_markdown_path.endswith(".md"):
|
||||
file_vector_sync_service.delete(project_id, old_markdown_path)
|
||||
elif new_markdown_path.endswith(".md"):
|
||||
file_vector_sync_service.revectorize(project_id, new_markdown_path)
|
||||
return success_message if source == "http" else mcp_message
|
||||
|
||||
if action == "create_dir":
|
||||
|
|
@ -258,10 +310,9 @@ class ProjectFileService:
|
|||
category="project",
|
||||
)
|
||||
|
||||
if path.endswith(".md"):
|
||||
asyncio.create_task(zvec_service.vectorize_markdown(db, project_id, path, file_content))
|
||||
|
||||
await db.commit()
|
||||
if path.endswith(".md"):
|
||||
file_vector_sync_service.revectorize(project_id, path)
|
||||
return "文件创建成功"
|
||||
|
||||
raise HTTPException(status_code=400, detail="不支持的操作类型")
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ logger = logging.getLogger(__name__)
|
|||
# 分块后每次只注入命中段落及其上下文,而非整篇文档,从根本上避免长文档被截断。
|
||||
CHUNK_CONTEXT_WINDOW = 1200
|
||||
MAX_CHUNKS_PER_DOCUMENT = 3
|
||||
HISTORY_USER_QUESTION_LIMIT = 3
|
||||
HISTORY_USER_QUESTION_MAX_CHARS = 300
|
||||
|
||||
|
||||
class RAGService:
|
||||
|
|
@ -261,6 +263,13 @@ class RAGService:
|
|||
|
||||
context_text = RAGService._build_context(retrieved_docs)
|
||||
citation_hint = RAGService._build_citation_hint(retrieved_docs)
|
||||
history_hint = RAGService._build_history_hint(conversation_history)
|
||||
history_section = (
|
||||
"\n\n先前用户问题(仅用于理解当前问题中的指代,不是待回答任务):\n"
|
||||
f"{history_hint}"
|
||||
if history_hint
|
||||
else ""
|
||||
)
|
||||
system_prompt = f"""你是一个知识库助手。基于用户提供的知识库文档,回答用户的问题。
|
||||
|
||||
如果知识库中没有相关信息,请明确说明。
|
||||
|
|
@ -269,19 +278,35 @@ class RAGService:
|
|||
{context_text}
|
||||
|
||||
引用规则:
|
||||
{citation_hint}
|
||||
{citation_hint}{history_section}
|
||||
|
||||
回答要求:
|
||||
1. 仅基于知识库内容回答,不要编造。
|
||||
2. 如果使用了某条知识,请在对应句子后标注引用编号,例如 [1] 或 [1][3]。
|
||||
3. 如果没有找到依据,请直接说明未检索到相关内容。
|
||||
4. 只回答消息列表中最后一个用户问题;先前用户问题仅用于理解指代。
|
||||
5. 禁止复述、总结或继续回答先前问题,也不要重复先前助手的答案。
|
||||
|
||||
请基于以上知识库内容,用中文回答用户的问题。"""
|
||||
|
||||
messages = list(conversation_history)
|
||||
messages.append({"role": "user", "content": query})
|
||||
# 历史助手答案不能进入模型消息,否则部分兼容模型会在新回答中复述它。
|
||||
messages = [{"role": "user", "content": query}]
|
||||
return llm_config, system_prompt, messages
|
||||
|
||||
@staticmethod
|
||||
def _build_history_hint(conversation_history: List[Dict[str, str]]) -> str:
|
||||
"""仅保留最近用户问题作为指代提示,不注入历史助手答案。"""
|
||||
questions = []
|
||||
for message in conversation_history or []:
|
||||
if message.get("role") != "user":
|
||||
continue
|
||||
content = str(message.get("content") or "").strip()
|
||||
if content:
|
||||
questions.append(content[:HISTORY_USER_QUESTION_MAX_CHARS])
|
||||
|
||||
recent_questions = questions[-HISTORY_USER_QUESTION_LIMIT:]
|
||||
return "\n".join(f"- {question}" for question in recent_questions)
|
||||
|
||||
@staticmethod
|
||||
def _build_context(retrieved_docs: List[Dict[str, Any]]) -> str:
|
||||
"""构建上下文文本"""
|
||||
|
|
|
|||
|
|
@ -150,13 +150,16 @@ class ZVecService:
|
|||
|
||||
@classmethod
|
||||
async def generate_embedding(
|
||||
cls, db: AsyncSession, text: str
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
text: str,
|
||||
config: Optional[LLMModelConfig] = None,
|
||||
) -> Optional[List[float]]:
|
||||
"""调用配置的 embedding 模型生成向量"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
|
||||
config = await cls.get_embedding_config(db)
|
||||
config = config or await cls.get_embedding_config(db)
|
||||
if not config:
|
||||
logger.warning("No embedding model configured, skipping vectorization")
|
||||
return None
|
||||
|
|
@ -343,14 +346,20 @@ class ZVecService:
|
|||
|
||||
流程:删除旧分块 → 分块 → 逐块生成 embedding、写入 ZVec、写记录。
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
content_hash = cls._content_hash(content)
|
||||
config = await cls.get_embedding_config(db)
|
||||
if not config:
|
||||
logger.warning("No default embedding model configured for %s", file_path)
|
||||
return False
|
||||
|
||||
# 先清理该文件的所有旧分块(增量重建),保证不残留过期向量
|
||||
await cls._purge_file_chunks(db, project_id, file_path)
|
||||
|
||||
chunks = cls._chunk_text(content, settings.CHUNK_SIZE, settings.CHUNK_OVERLAP)
|
||||
chunks = cls._chunk_text(
|
||||
content,
|
||||
config.chunk_size,
|
||||
config.chunk_overlap,
|
||||
)
|
||||
if not chunks:
|
||||
# 空文件:清理后直接提交,视为成功(无可向量化内容)
|
||||
await db.commit()
|
||||
|
|
@ -359,7 +368,7 @@ class ZVecService:
|
|||
success_count = 0
|
||||
last_error: Optional[str] = None
|
||||
for chunk in chunks:
|
||||
embedding = await cls.generate_embedding(db, chunk["text"])
|
||||
embedding = await cls.generate_embedding(db, chunk["text"], config=config)
|
||||
if not embedding:
|
||||
last_error = "未配置可用的 embedding 模型或向量生成失败"
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ watchfiles==1.1.1
|
|||
websockets==15.0.1
|
||||
Whoosh==2.7.4
|
||||
markdown==3.5.2
|
||||
sentence-transformers>=3.0,<6
|
||||
weasyprint==61.2
|
||||
pydyf<0.11.0
|
||||
mcp==1.26.0
|
||||
|
|
|
|||
|
|
@ -213,11 +213,7 @@ CREATE TABLE IF NOT EXISTS `llm_model_config` (
|
|||
`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 类型)',
|
||||
`type_config` JSON NOT NULL COMMENT '模型类型差异参数',
|
||||
`description` VARCHAR(500) DEFAULT NULL COMMENT '描述',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
-- 为已有数据库增加向量模型的分块参数。
|
||||
-- 执行前请确认当前数据库已包含 llm_model_config 表。
|
||||
|
||||
ALTER TABLE `llm_model_config`
|
||||
ADD COLUMN `chunk_size` INT NOT NULL DEFAULT 800
|
||||
COMMENT '文档分块字符数(仅 embedding 类型)' AFTER `embedding_dimension`,
|
||||
ADD COLUMN `chunk_overlap` INT NOT NULL DEFAULT 150
|
||||
COMMENT '相邻分块重叠字符数(仅 embedding 类型)' AFTER `chunk_size`;
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
-- 将对话模型与向量模型的差异字段归并到 type_config JSON。
|
||||
-- 适用于 MySQL 8.0;执行前应已完成 embedding_options 迁移。
|
||||
|
||||
ALTER TABLE `llm_model_config`
|
||||
ADD COLUMN `type_config` JSON NULL
|
||||
COMMENT '模型类型差异参数' AFTER `llm_timeout`;
|
||||
|
||||
UPDATE `llm_model_config`
|
||||
SET `type_config` = CASE
|
||||
WHEN `model_type` = 'embedding' THEN
|
||||
CASE
|
||||
WHEN `embedding_dimension` IS NULL THEN JSON_OBJECT(
|
||||
'chunk_size', `chunk_size`,
|
||||
'chunk_overlap', `chunk_overlap`
|
||||
)
|
||||
ELSE JSON_OBJECT(
|
||||
'dimension', `embedding_dimension`,
|
||||
'chunk_size', `chunk_size`,
|
||||
'chunk_overlap', `chunk_overlap`
|
||||
)
|
||||
END
|
||||
ELSE
|
||||
CASE
|
||||
WHEN `llm_system_prompt` IS NULL OR `llm_system_prompt` = '' THEN JSON_OBJECT(
|
||||
'temperature', CAST(`llm_temperature` AS DOUBLE),
|
||||
'top_p', CAST(`llm_top_p` AS DOUBLE),
|
||||
'max_tokens', `llm_max_tokens`
|
||||
)
|
||||
ELSE JSON_OBJECT(
|
||||
'temperature', CAST(`llm_temperature` AS DOUBLE),
|
||||
'top_p', CAST(`llm_top_p` AS DOUBLE),
|
||||
'max_tokens', `llm_max_tokens`,
|
||||
'system_prompt', `llm_system_prompt`
|
||||
)
|
||||
END
|
||||
END;
|
||||
|
||||
ALTER TABLE `llm_model_config`
|
||||
MODIFY COLUMN `type_config` JSON NOT NULL COMMENT '模型类型差异参数',
|
||||
DROP COLUMN `llm_temperature`,
|
||||
DROP COLUMN `llm_top_p`,
|
||||
DROP COLUMN `llm_max_tokens`,
|
||||
DROP COLUMN `llm_system_prompt`,
|
||||
DROP COLUMN `embedding_dimension`,
|
||||
DROP COLUMN `chunk_size`,
|
||||
DROP COLUMN `chunk_overlap`;
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.api.v1.chat import _canonicalize_message_citations, _compact_cited_refs
|
||||
from app.services.rag_service import RAGService
|
||||
|
|
@ -61,5 +63,33 @@ class ChatCitationTest(unittest.TestCase):
|
|||
)
|
||||
|
||||
|
||||
class RAGConversationContextTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_previous_assistant_answer_is_not_sent_to_model(self):
|
||||
db = AsyncMock()
|
||||
db.execute.return_value = SimpleNamespace(
|
||||
scalar_one_or_none=lambda: SimpleNamespace(config_id=1)
|
||||
)
|
||||
history = [
|
||||
{"role": "user", "content": "包含了哪几次阿波罗计划?"},
|
||||
{"role": "assistant", "content": "上一轮完整答案不应再次发送给模型。"},
|
||||
]
|
||||
|
||||
_, system_prompt, messages = await RAGService._prepare_generation(
|
||||
db,
|
||||
"项目中的文档包含哪些土星的卫星?",
|
||||
1,
|
||||
[],
|
||||
history,
|
||||
)
|
||||
|
||||
self.assertEqual(messages, [{
|
||||
"role": "user",
|
||||
"content": "项目中的文档包含哪些土星的卫星?",
|
||||
}])
|
||||
self.assertIn("包含了哪几次阿波罗计划?", system_prompt)
|
||||
self.assertNotIn("上一轮完整答案不应再次发送给模型。", system_prompt)
|
||||
self.assertIn("禁止复述、总结或继续回答先前问题", system_prompt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.api.v1.llm_model_configs import (
|
||||
LLMModelConfigUpsertRequest,
|
||||
ensure_default_config,
|
||||
normalize_payload,
|
||||
to_storage_payload,
|
||||
)
|
||||
from app.models.llm_model_config import LLMModelConfig
|
||||
from app.services.file_vector_sync_service import FileVectorSyncService
|
||||
from app.services.llm_provider_service import LLMProviderService
|
||||
from app.services.local_embedding_service import LocalEmbeddingService
|
||||
from app.services.project_file_service import ProjectFileService
|
||||
from app.services.zvec_service import ZVecService
|
||||
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class _RecordingDB:
|
||||
def __init__(self, values):
|
||||
self.values = iter(values)
|
||||
self.statements = []
|
||||
|
||||
async def execute(self, statement):
|
||||
self.statements.append(statement)
|
||||
return _ScalarResult(next(self.values))
|
||||
|
||||
|
||||
class ModelConfigurationTest(unittest.IsolatedAsyncioTestCase):
|
||||
def test_chat_type_config_contains_only_chat_specific_fields(self):
|
||||
request = LLMModelConfigUpsertRequest(
|
||||
model_type="chat",
|
||||
provider="openai",
|
||||
llm_model_name="gpt-test",
|
||||
llm_temperature=0.4,
|
||||
llm_top_p=0.8,
|
||||
llm_max_tokens=2048,
|
||||
llm_system_prompt="测试提示词",
|
||||
)
|
||||
|
||||
payload = to_storage_payload(normalize_payload(request))
|
||||
|
||||
self.assertEqual(
|
||||
payload["type_config"],
|
||||
{
|
||||
"temperature": 0.4,
|
||||
"top_p": 0.8,
|
||||
"max_tokens": 2048,
|
||||
"system_prompt": "测试提示词",
|
||||
},
|
||||
)
|
||||
self.assertNotIn("chunk_size", payload["type_config"])
|
||||
self.assertNotIn("llm_temperature", payload)
|
||||
|
||||
def test_embedding_type_config_contains_only_embedding_specific_fields(self):
|
||||
request = LLMModelConfigUpsertRequest(
|
||||
model_type="embedding",
|
||||
provider="local",
|
||||
llm_model_name="m3e-small",
|
||||
embedding_dimension=512,
|
||||
chunk_size=600,
|
||||
chunk_overlap=100,
|
||||
)
|
||||
|
||||
payload = to_storage_payload(normalize_payload(request))
|
||||
config = LLMModelConfig(
|
||||
model_type="embedding",
|
||||
type_config=payload["type_config"],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
payload["type_config"],
|
||||
{"dimension": 512, "chunk_size": 600, "chunk_overlap": 100},
|
||||
)
|
||||
self.assertNotIn("temperature", payload["type_config"])
|
||||
self.assertEqual(config.embedding_dimension, 512)
|
||||
self.assertEqual(config.chunk_size, 600)
|
||||
self.assertEqual(config.chunk_overlap, 100)
|
||||
|
||||
def test_chunk_overlap_must_be_smaller_than_chunk_size(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
LLMModelConfigUpsertRequest(
|
||||
model_type="embedding",
|
||||
provider="local",
|
||||
llm_model_name="m3e-small",
|
||||
chunk_size=500,
|
||||
chunk_overlap=500,
|
||||
)
|
||||
|
||||
async def test_default_update_is_scoped_to_model_type(self):
|
||||
db = _RecordingDB([None, 7, None, None])
|
||||
|
||||
await ensure_default_config(db, "embedding", preferred_config_id=7)
|
||||
|
||||
update_statement = str(db.statements[2])
|
||||
self.assertIn("llm_model_config.model_type", update_statement)
|
||||
self.assertIn("is_default", update_statement)
|
||||
|
||||
async def test_local_embedding_dimension_is_validated(self):
|
||||
with patch.object(
|
||||
LocalEmbeddingService,
|
||||
"generate_embedding",
|
||||
new=AsyncMock(return_value=[0.1] * 384),
|
||||
):
|
||||
vector = await LLMProviderService.generate_embedding(
|
||||
provider="local",
|
||||
endpoint_url="",
|
||||
api_key="",
|
||||
llm_model_name="paraphrase-multilingual-MiniLM-L12-v2",
|
||||
text="测试文本",
|
||||
dimension=384,
|
||||
)
|
||||
self.assertEqual(len(vector), 384)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "实际输出 384 维"):
|
||||
await LLMProviderService.generate_embedding(
|
||||
provider="local",
|
||||
endpoint_url="",
|
||||
api_key="",
|
||||
llm_model_name="paraphrase-multilingual-MiniLM-L12-v2",
|
||||
text="测试文本",
|
||||
dimension=768,
|
||||
)
|
||||
|
||||
def test_local_model_path_cannot_escape_models_directory(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
models_dir = Path(temp_dir) / "models"
|
||||
models_dir.mkdir()
|
||||
(models_dir / "valid-model").mkdir()
|
||||
with patch.object(LocalEmbeddingService, "MODELS_DIR", models_dir):
|
||||
self.assertEqual(
|
||||
LocalEmbeddingService.resolve_model_path("valid-model"),
|
||||
(models_dir / "valid-model").resolve(),
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "必须位于"):
|
||||
LocalEmbeddingService.resolve_model_path("../outside")
|
||||
|
||||
def test_local_model_catalog_reports_dimension_and_readiness(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
models_dir = Path(temp_dir) / "models"
|
||||
model_dir = models_dir / "local-model"
|
||||
pooling_dir = model_dir / "1_Pooling"
|
||||
pooling_dir.mkdir(parents=True)
|
||||
(pooling_dir / "config.json").write_text(
|
||||
'{"word_embedding_dimension": 384}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(model_dir / "model.safetensors").touch()
|
||||
|
||||
with patch.object(LocalEmbeddingService, "MODELS_DIR", models_dir):
|
||||
self.assertEqual(
|
||||
LocalEmbeddingService.list_available_models(),
|
||||
[{"name": "local-model", "dimension": 384, "ready": True}],
|
||||
)
|
||||
|
||||
def test_embedding_config_controls_chunking(self):
|
||||
chunks = ZVecService._chunk_text("abcdefghij", chunk_size=6, overlap=2)
|
||||
self.assertEqual([item["text"] for item in chunks], ["abcdef", "efghij", "ij"])
|
||||
|
||||
|
||||
class FileVectorSyncTest(unittest.IsolatedAsyncioTestCase):
|
||||
def test_directory_markdown_paths_and_moves(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "docs"
|
||||
(root / "nested").mkdir(parents=True)
|
||||
(root / "a.md").write_text("a", encoding="utf-8")
|
||||
(root / "nested" / "b.md").write_text("b", encoding="utf-8")
|
||||
(root / "ignored.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
self.assertEqual(
|
||||
sorted(ProjectFileService._markdown_paths(root, "docs")),
|
||||
["docs/a.md", "docs/nested/b.md"],
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(ProjectFileService._markdown_moves(root, "docs", "archive")),
|
||||
[
|
||||
("docs/a.md", "archive/a.md"),
|
||||
("docs/nested/b.md", "archive/nested/b.md"),
|
||||
],
|
||||
)
|
||||
|
||||
async def test_background_sync_uses_its_own_database_session(self):
|
||||
db = AsyncMock()
|
||||
|
||||
class SessionContext:
|
||||
async def __aenter__(self):
|
||||
return db
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
service = FileVectorSyncService()
|
||||
with (
|
||||
patch(
|
||||
"app.services.file_vector_sync_service.AsyncSessionLocal",
|
||||
return_value=SessionContext(),
|
||||
),
|
||||
patch.object(
|
||||
ZVecService,
|
||||
"revectorize_path",
|
||||
new=AsyncMock(return_value=True),
|
||||
) as revectorize,
|
||||
):
|
||||
await service._run(3, "revectorize", "guide.md", "")
|
||||
|
||||
revectorize.assert_awaited_once_with(db, 3, "guide.md")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -169,6 +169,28 @@
|
|||
padding-left: 0;
|
||||
}
|
||||
|
||||
.footer-icon-button {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.footer-icon-button:hover {
|
||||
color: var(--text-color);
|
||||
background: var(--item-hover-bg);
|
||||
}
|
||||
|
||||
.footer-icon-button.logout:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* User Card */
|
||||
.user-card {
|
||||
background-color: var(--bg-color-secondary); /* Light gray background */
|
||||
|
|
@ -216,6 +238,8 @@
|
|||
}
|
||||
|
||||
.logout-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
|
|
|
|||
|
|
@ -113,9 +113,19 @@ const ModernSidebar = ({
|
|||
</div>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="footer-link collapsed">
|
||||
<QuestionCircleOutlined />
|
||||
</div>
|
||||
<Tooltip title="帮助支持" placement="right">
|
||||
<button type="button" className="footer-icon-button">
|
||||
<QuestionCircleOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{collapsed && (
|
||||
<Tooltip title="退出登录" placement="right">
|
||||
<button type="button" className="footer-icon-button logout" onClick={onLogout}>
|
||||
<LogoutOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 用户卡片 */}
|
||||
|
|
@ -140,9 +150,9 @@ const ModernSidebar = ({
|
|||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="logout-btn" onClick={onLogout} title="退出登录">
|
||||
<button type="button" className="logout-btn" onClick={onLogout} title="退出登录">
|
||||
<LogoutOutlined />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -124,20 +124,15 @@ function stripMarkdown(text) {
|
|||
.trim()
|
||||
}
|
||||
|
||||
function buildDocumentPreviewUrl(projectId, filePath, anchorText = '') {
|
||||
function buildDocumentPreviewUrl(projectId, filePath) {
|
||||
if (!projectId || !filePath) return ''
|
||||
const base = `/projects/${projectId}/docs?file=${encodeURIComponent(filePath)}`
|
||||
// 携带锚点文本作为 keyword,文档页据此高亮并滚动到被引用的段落
|
||||
const anchor = (anchorText || '').trim()
|
||||
return anchor ? `${base}&keyword=${encodeURIComponent(anchor)}` : base
|
||||
return `/projects/${projectId}/docs?file=${encodeURIComponent(filePath)}`
|
||||
}
|
||||
|
||||
function openDocument(ref, projectId) {
|
||||
const anchorText = ref?.anchor_text || getReferenceExcerpt(ref)
|
||||
const previewUrl = buildDocumentPreviewUrl(
|
||||
ref?.project_id || projectId,
|
||||
ref?.file_path,
|
||||
anchorText
|
||||
ref?.file_path
|
||||
)
|
||||
if (previewUrl) window.open(previewUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
|
@ -288,7 +283,10 @@ function Chat() {
|
|||
const loadModels = async () => {
|
||||
try {
|
||||
const res = await getLLMModelConfigs({ page: 1, page_size: 100, model_type: 'chat', is_active: true })
|
||||
setModels(res.data || [])
|
||||
const nextModels = res.data || []
|
||||
setModels(nextModels)
|
||||
const defaultModel = nextModels.find((item) => item.is_default) || nextModels[0]
|
||||
setNewModelId((current) => current || defaultModel?.config_id)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
|
@ -370,7 +368,7 @@ function Chat() {
|
|||
const handleStartNew = () => {
|
||||
setNewQuestion('')
|
||||
setNewProjectId(undefined)
|
||||
setNewModelId(undefined)
|
||||
setNewModelId(models.find((item) => item.is_default)?.config_id || models[0]?.config_id)
|
||||
navigate('/chat/new')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@
|
|||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.embedding-options-row > .ant-space-item {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.model-config-tab-panel {
|
||||
min-height: 600px;
|
||||
}
|
||||
|
|
@ -137,3 +142,32 @@
|
|||
background: #cfcfcf !important;
|
||||
border-color: #cfcfcf !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.model-config-tabs > .ant-tabs-nav {
|
||||
min-width: 0;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.model-config-tabs > .ant-tabs-content-holder {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.model-config-tab-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.model-config-card .ant-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.model-config-tabs .admin-toolbar-left,
|
||||
.model-config-tabs .admin-toolbar-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.model-config-tabs .admin-toolbar-left > *,
|
||||
.model-config-tabs .admin-toolbar-right > * {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Grid,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
|
|
@ -24,6 +25,7 @@ import {
|
|||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
StarOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
|
|
@ -32,6 +34,7 @@ import {
|
|||
getLLMModelConfigDetail,
|
||||
getLLMModelConfigs,
|
||||
getLLMProviderCatalog,
|
||||
setDefaultLLMModelConfig,
|
||||
testLLMModelConfig,
|
||||
updateLLMModelConfig,
|
||||
updateLLMModelConfigStatus,
|
||||
|
|
@ -105,6 +108,7 @@ function formatDateTime(value) {
|
|||
|
||||
function ModelConfigs() {
|
||||
const [form] = Form.useForm()
|
||||
const screens = Grid.useBreakpoint()
|
||||
const [activeType, setActiveType] = useState('chat')
|
||||
const [editingType, setEditingType] = useState('chat')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -125,10 +129,16 @@ function ModelConfigs() {
|
|||
modelName: true,
|
||||
modelCode: true,
|
||||
})
|
||||
const loadRequestIdRef = useRef(0)
|
||||
|
||||
const providerValue = Form.useWatch('provider', form)
|
||||
const llmModelNameValue = Form.useWatch('llm_model_name', form)
|
||||
const isEmbedding = editingType === 'embedding'
|
||||
const isLocalProvider = providerValue === 'local'
|
||||
const localModels = providerCatalog.find((item) => item.value === 'local')?.models || []
|
||||
const availableProviders = providerCatalog.filter((item) => (
|
||||
!item.model_types || item.model_types.includes(editingType)
|
||||
))
|
||||
|
||||
useEffect(() => {
|
||||
loadProviderCatalog()
|
||||
|
|
@ -190,6 +200,7 @@ function ModelConfigs() {
|
|||
}
|
||||
|
||||
const loadConfigs = async () => {
|
||||
const requestId = ++loadRequestIdRef.current
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = {
|
||||
|
|
@ -202,13 +213,16 @@ function ModelConfigs() {
|
|||
if (statusFilter !== undefined) params.is_active = statusFilter
|
||||
|
||||
const res = await getLLMModelConfigs(params)
|
||||
if (requestId !== loadRequestIdRef.current) return
|
||||
setConfigs(res.data || [])
|
||||
setTotal(res.total || 0)
|
||||
} catch (error) {
|
||||
console.error('Load llm model configs error:', error)
|
||||
Toast.error('加载模型配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (requestId === loadRequestIdRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,8 +237,15 @@ function ModelConfigs() {
|
|||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
const defaultProvider = providerCatalog[0]?.value || 'openai'
|
||||
const defaultEndpointUrl = getProviderMeta(defaultProvider)?.default_endpoint_url || ''
|
||||
const compatibleProviders = providerCatalog.filter((item) => (
|
||||
!item.model_types || item.model_types.includes(activeType)
|
||||
))
|
||||
const defaultProvider = activeType === 'embedding'
|
||||
? (compatibleProviders.find((item) => item.value === 'local')?.value || compatibleProviders[0]?.value)
|
||||
: compatibleProviders[0]?.value
|
||||
const resolvedProvider = defaultProvider || 'openai'
|
||||
const defaultEndpointUrl = getProviderMeta(resolvedProvider)?.default_endpoint_url || ''
|
||||
const providerDefaults = getProviderMeta(resolvedProvider)
|
||||
setEditingConfigId(null)
|
||||
setEditingType(activeType)
|
||||
setAutoFillFlags({
|
||||
|
|
@ -234,14 +255,17 @@ function ModelConfigs() {
|
|||
})
|
||||
form.setFieldsValue({
|
||||
model_type: activeType,
|
||||
provider: defaultProvider,
|
||||
provider: resolvedProvider,
|
||||
endpoint_url: defaultEndpointUrl,
|
||||
llm_timeout: activeType === 'embedding' ? 60 : 120,
|
||||
llm_temperature: 0.7,
|
||||
llm_top_p: 0.9,
|
||||
llm_max_tokens: DEFAULT_MAX_TOKENS,
|
||||
embedding_dimension: undefined,
|
||||
chunk_size: providerDefaults?.default_chunk_size || 800,
|
||||
chunk_overlap: providerDefaults?.default_chunk_overlap ?? 150,
|
||||
is_active: true,
|
||||
is_default: false,
|
||||
description: '',
|
||||
llm_system_prompt: '',
|
||||
model_name: '',
|
||||
|
|
@ -323,6 +347,33 @@ function ModelConfigs() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleSetDefault = (record) => {
|
||||
const isVectorModel = record.model_type === 'embedding'
|
||||
Modal.confirm({
|
||||
title: isVectorModel ? '切换默认向量模型?' : '切换默认对话模型?',
|
||||
content: isVectorModel
|
||||
? '新文件和后续检索将使用该模型。已有项目向量由旧模型生成,请在切换后对相关项目执行一次全量向量化。'
|
||||
: '新建知识库问答将默认选中该模型,已有会话不会改变。',
|
||||
okText: '设为默认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await setDefaultLLMModelConfig(record.config_id)
|
||||
setConfigs((current) => current
|
||||
.map((item) => ({
|
||||
...item,
|
||||
is_default: item.config_id === record.config_id,
|
||||
}))
|
||||
.sort((left, right) => Number(right.is_default) - Number(left.is_default)))
|
||||
Toast.success(isVectorModel ? '默认向量模型已切换,请重建已有项目向量' : '默认对话模型已切换')
|
||||
await loadConfigs()
|
||||
} catch (error) {
|
||||
Toast.error(error.response?.data?.detail || '切换默认模型失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const showTestResult = (result) => {
|
||||
const providerLabel = getProviderMeta(result.provider)?.label || result.provider
|
||||
const lines = [
|
||||
|
|
@ -369,7 +420,10 @@ function ModelConfigs() {
|
|||
width: 240,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>{record.model_name}</span>
|
||||
<Space size={6} wrap>
|
||||
<span>{record.model_name}</span>
|
||||
{record.is_default && <Tag color="green">默认</Tag>}
|
||||
</Space>
|
||||
<span className="model-config-code">{record.model_code}</span>
|
||||
</Space>
|
||||
),
|
||||
|
|
@ -399,6 +453,12 @@ function ModelConfigs() {
|
|||
width: 110,
|
||||
render: (value) => (value ? <Tag color="purple">{value}</Tag> : <Tag>自动</Tag>),
|
||||
})
|
||||
baseColumns.push({
|
||||
title: '分块 / 重叠',
|
||||
key: 'chunk_options',
|
||||
width: 130,
|
||||
render: (_, record) => `${record.chunk_size} / ${record.chunk_overlap}`,
|
||||
})
|
||||
}
|
||||
|
||||
baseColumns.push(
|
||||
|
|
@ -433,10 +493,20 @@ function ModelConfigs() {
|
|||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 170,
|
||||
fixed: screens.md ? 'right' : undefined,
|
||||
width: screens.md ? 260 : 220,
|
||||
render: (_, record) => (
|
||||
<Space size="small" className="model-config-actions">
|
||||
{!record.is_default && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<StarOutlined />}
|
||||
onClick={() => handleSetDefault(record)}
|
||||
>
|
||||
设为默认
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
|
|
@ -477,7 +547,7 @@ function ModelConfigs() {
|
|||
allowClear
|
||||
placeholder="搜索模型名称、编码或模型标识"
|
||||
value={keyword}
|
||||
style={{ width: 320 }}
|
||||
style={{ width: 320, maxWidth: '100%' }}
|
||||
onChange={(event) => {
|
||||
setPage(1)
|
||||
setKeyword(event.target.value)
|
||||
|
|
@ -583,7 +653,7 @@ function ModelConfigs() {
|
|||
items={tabItems}
|
||||
activeKey={activeType}
|
||||
onChange={handleTabChange}
|
||||
tabPosition="left"
|
||||
tabPosition={screens.md ? 'left' : 'top'}
|
||||
className="model-config-tabs"
|
||||
/>
|
||||
</Card>
|
||||
|
|
@ -622,12 +692,12 @@ function ModelConfigs() {
|
|||
)}
|
||||
>
|
||||
<div className="model-config-modal-hint">
|
||||
<strong>自动生成规则:</strong> 选择提供方后会自动带出默认 `base_url`;填写模型标识后会自动生成“模型名称”和“模型编码”。
|
||||
<strong>自动生成规则:</strong> 选择远程提供方后会自动带出默认 `base_url`;填写模型标识后会自动生成“模型名称”和“模型编码”。
|
||||
如果你手动改过这些字段,后续就不会再被自动覆盖。
|
||||
{isEmbedding && (
|
||||
<>
|
||||
<br />
|
||||
<strong>向量维度:</strong> 留空时按模型实际返回维度自动建立向量库;如填写,需与模型输出维度一致。更换不同维度的模型会重建该项目的向量库。
|
||||
<strong>索引参数:</strong> 向量维度必须与模型输出一致;分块大小与重叠会影响检索粒度,修改后应对已有项目执行全量向量化。
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -647,6 +717,9 @@ function ModelConfigs() {
|
|||
<Form.Item name="model_type" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_default" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
||||
<Form.Item
|
||||
|
|
@ -658,41 +731,62 @@ function ModelConfigs() {
|
|||
showSearch
|
||||
placeholder="请选择模型提供方"
|
||||
optionFilterProp="label"
|
||||
options={providerCatalog.map((item) => ({
|
||||
options={availableProviders.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Base URL"
|
||||
name="endpoint_url"
|
||||
rules={[{ required: true, message: '请输入 base_url' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="选择提供方后自动带出,也可以手动覆盖"
|
||||
onChange={() => {
|
||||
setAutoFillFlags((current) => ({ ...current, endpointUrl: false }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{!isLocalProvider && (
|
||||
<Form.Item
|
||||
label="Base URL"
|
||||
name="endpoint_url"
|
||||
rules={[{ required: true, message: '请输入 base_url' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="选择提供方后自动带出,也可以手动覆盖"
|
||||
onChange={() => {
|
||||
setAutoFillFlags((current) => ({ ...current, endpointUrl: false }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Space style={{ width: '100%' }} size={16} align="start">
|
||||
<Space style={{ width: '100%' }} size={16} align="start" wrap>
|
||||
<Form.Item
|
||||
label="模型标识"
|
||||
name="llm_model_name"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入模型标识或部署名' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder={
|
||||
isEmbedding
|
||||
? '如 text-embedding-3-small / text-embedding-v4 / bge-large-zh'
|
||||
: '如 gpt-4.1-mini / qwen3.6-plus / claude-3-5-sonnet-latest'
|
||||
}
|
||||
/>
|
||||
{isLocalProvider ? (
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择 backend/models 中的模型"
|
||||
options={localModels.map((item) => ({
|
||||
value: item.name,
|
||||
label: `${item.name}${item.dimension ? ` (${item.dimension} 维)` : ''}${item.ready ? '' : ' - 权重未就绪'}`,
|
||||
disabled: !item.ready,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
const selected = localModels.find((item) => item.name === value)
|
||||
if (selected?.dimension) {
|
||||
form.setFieldValue('embedding_dimension', selected.dimension)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={
|
||||
isEmbedding
|
||||
? '如 text-embedding-3-small / text-embedding-v4'
|
||||
: '如 gpt-4.1-mini / qwen3.6-plus / claude-3-5-sonnet-latest'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
|
|
@ -705,7 +799,7 @@ function ModelConfigs() {
|
|||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space style={{ width: '100%' }} size={16} align="start">
|
||||
<Space style={{ width: '100%' }} size={16} align="start" wrap>
|
||||
<Form.Item
|
||||
label="模型名称"
|
||||
name="model_name"
|
||||
|
|
@ -735,30 +829,60 @@ function ModelConfigs() {
|
|||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item label="API Key" name="api_key">
|
||||
<Input.Password
|
||||
placeholder="支持留空后稍后补齐;测试非 Ollama 模型时建议填写"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{isEmbedding ? (
|
||||
<Form.Item
|
||||
label="向量维度"
|
||||
name="embedding_dimension"
|
||||
extra="留空则按模型实际返回维度自动建立;填写需与模型输出一致。"
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={8192}
|
||||
step={1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="如 1536 / 1024 / 768,可留空自动识别"
|
||||
{!isLocalProvider && (
|
||||
<Form.Item label="API Key" name="api_key">
|
||||
<Input.Password
|
||||
placeholder="支持留空后稍后补齐;测试非 Ollama 模型时建议填写"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{isEmbedding ? (
|
||||
<Space className="embedding-options-row" style={{ width: '100%' }} size={16} align="start" wrap>
|
||||
<Form.Item
|
||||
label="向量维度"
|
||||
name="embedding_dimension"
|
||||
style={{ flex: 1 }}
|
||||
extra="填写后会校验模型实际输出维度。"
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={8192}
|
||||
step={1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="如 384 / 768 / 1536"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="分块字符数"
|
||||
name="chunk_size"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入分块字符数' }]}
|
||||
>
|
||||
<InputNumber min={100} max={10000} step={50} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="重叠字符数"
|
||||
name="chunk_overlap"
|
||||
style={{ flex: 1 }}
|
||||
dependencies={['chunk_size']}
|
||||
rules={[
|
||||
{ required: true, message: '请输入重叠字符数' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (value < getFieldValue('chunk_size')) return Promise.resolve()
|
||||
return Promise.reject(new Error('必须小于分块字符数'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<InputNumber min={0} max={5000} step={25} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
) : (
|
||||
<>
|
||||
<Space style={{ width: '100%' }} size={24} align="start">
|
||||
<Space style={{ width: '100%' }} size={24} align="start" wrap>
|
||||
<Form.Item
|
||||
label="Temperature"
|
||||
name="llm_temperature"
|
||||
|
|
|
|||
Loading…
Reference in New Issue