main
mula.liu 2026-08-03 13:59:52 +08:00
parent 31cf2bfc1c
commit 06d58be444
35 changed files with 977 additions and 673 deletions

3
.gitignore vendored
View File

@ -32,5 +32,8 @@ logs/
*.tmp
*.temp
# Local models
models
# AI
.gemini-clipboard/

View File

@ -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

View File

@ -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="模型配置删除成功")

View File

@ -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",
]

View File

@ -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}')>"

View File

@ -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}')>"

View File

@ -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}')>"
)

View File

@ -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}')>"

View File

@ -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}')>"

View File

@ -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}')>"

View File

@ -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}')>"

View File

@ -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="父菜单ID0表示根菜单")
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})>"

View File

@ -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}')>"

View File

@ -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}')>"

View File

@ -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}')>"

View File

@ -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})>"

View File

@ -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}')>"

View File

@ -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}')>"

View File

@ -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()

View File

@ -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(

View File

@ -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)

View File

@ -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="不支持的操作类型")

View File

@ -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:
"""构建上下文文本"""

View File

@ -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

View File

@ -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

View File

@ -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 '是否默认',

View File

@ -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`;

View File

@ -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`;

View File

@ -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()

View File

@ -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()

View File

@ -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;

View File

@ -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>

View File

@ -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')
}

View File

@ -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;
}
}

View File

@ -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"