diff --git a/.gitignore b/.gitignore index b7334b5..fe9abff 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,8 @@ logs/ *.tmp *.temp +# Local models +models + # AI .gemini-clipboard/ diff --git a/.memsearch/memory/2026-06-03.md b/.memsearch/memory/2026-06-03.md deleted file mode 100644 index 59098be..0000000 --- a/.memsearch/memory/2026-06-03.md +++ /dev/null @@ -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 - diff --git a/backend/app/api/v1/llm_model_configs.py b/backend/app/api/v1/llm_model_configs.py index f842ade..0e5de02 100644 --- a/backend/app/api/v1/llm_model_configs.py +++ b/backend/app/api/v1/llm_model_configs.py @@ -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="模型配置删除成功") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index 4f33205..0000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -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", -] diff --git a/backend/app/models/chat_session.py b/backend/app/models/chat_session.py deleted file mode 100644 index 714cb9e..0000000 --- a/backend/app/models/chat_session.py +++ /dev/null @@ -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"" - - -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"" diff --git a/backend/app/models/document.py b/backend/app/models/document.py deleted file mode 100644 index 76a1814..0000000 --- a/backend/app/models/document.py +++ /dev/null @@ -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"" diff --git a/backend/app/models/document_vector.py b/backend/app/models/document_vector.py deleted file mode 100644 index 557bb2a..0000000 --- a/backend/app/models/document_vector.py +++ /dev/null @@ -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"" - ) diff --git a/backend/app/models/git_repo.py b/backend/app/models/git_repo.py deleted file mode 100644 index ebf1ca4..0000000 --- a/backend/app/models/git_repo.py +++ /dev/null @@ -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"" diff --git a/backend/app/models/llm_model_config.py b/backend/app/models/llm_model_config.py deleted file mode 100644 index d86e639..0000000 --- a/backend/app/models/llm_model_config.py +++ /dev/null @@ -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"" diff --git a/backend/app/models/log.py b/backend/app/models/log.py deleted file mode 100644 index 428e26e..0000000 --- a/backend/app/models/log.py +++ /dev/null @@ -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"" diff --git a/backend/app/models/mcp_bot.py b/backend/app/models/mcp_bot.py deleted file mode 100644 index b7f4ef4..0000000 --- a/backend/app/models/mcp_bot.py +++ /dev/null @@ -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"" diff --git a/backend/app/models/menu.py b/backend/app/models/menu.py deleted file mode 100644 index 71eaed9..0000000 --- a/backend/app/models/menu.py +++ /dev/null @@ -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"" - - -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"" diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py deleted file mode 100644 index 3a9f20b..0000000 --- a/backend/app/models/notification.py +++ /dev/null @@ -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"" diff --git a/backend/app/models/project.py b/backend/app/models/project.py deleted file mode 100644 index e92ed94..0000000 --- a/backend/app/models/project.py +++ /dev/null @@ -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"" - - -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"" diff --git a/backend/app/models/project_vectorization_task.py b/backend/app/models/project_vectorization_task.py deleted file mode 100644 index 261ddb8..0000000 --- a/backend/app/models/project_vectorization_task.py +++ /dev/null @@ -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"" diff --git a/backend/app/models/role.py b/backend/app/models/role.py deleted file mode 100644 index 7fdbb58..0000000 --- a/backend/app/models/role.py +++ /dev/null @@ -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"" - - -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"" diff --git a/backend/app/models/share.py b/backend/app/models/share.py deleted file mode 100644 index 368ffd3..0000000 --- a/backend/app/models/share.py +++ /dev/null @@ -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"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py deleted file mode 100644 index fc3e75a..0000000 --- a/backend/app/models/user.py +++ /dev/null @@ -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"" diff --git a/backend/app/services/file_vector_sync_service.py b/backend/app/services/file_vector_sync_service.py new file mode 100644 index 0000000..548d6a6 --- /dev/null +++ b/backend/app/services/file_vector_sync_service.py @@ -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() diff --git a/backend/app/services/llm_provider_service.py b/backend/app/services/llm_provider_service.py index 18be359..9ce336d 100644 --- a/backend/app/services/llm_provider_service.py +++ b/backend/app/services/llm_provider_service.py @@ -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( diff --git a/backend/app/services/local_embedding_service.py b/backend/app/services/local_embedding_service.py new file mode 100644 index 0000000..b538d77 --- /dev/null +++ b/backend/app/services/local_embedding_service.py @@ -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) diff --git a/backend/app/services/project_file_service.py b/backend/app/services/project_file_service.py index 5e51184..8b6891c 100644 --- a/backend/app/services/project_file_service.py +++ b/backend/app/services/project_file_service.py @@ -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="不支持的操作类型") diff --git a/backend/app/services/rag_service.py b/backend/app/services/rag_service.py index 93e3938..f80a616 100644 --- a/backend/app/services/rag_service.py +++ b/backend/app/services/rag_service.py @@ -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: """构建上下文文本""" diff --git a/backend/app/services/zvec_service.py b/backend/app/services/zvec_service.py index 75e2640..0a2a476 100644 --- a/backend/app/services/zvec_service.py +++ b/backend/app/services/zvec_service.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index f2d3212..63a9295 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/scripts/init_database.sql b/backend/scripts/init_database.sql index 15a0deb..730153b 100644 --- a/backend/scripts/init_database.sql +++ b/backend/scripts/init_database.sql @@ -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 '是否默认', diff --git a/backend/scripts/migrate_llm_model_config_embedding_options.sql b/backend/scripts/migrate_llm_model_config_embedding_options.sql new file mode 100644 index 0000000..51b888d --- /dev/null +++ b/backend/scripts/migrate_llm_model_config_embedding_options.sql @@ -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`; diff --git a/backend/scripts/migrate_llm_model_config_type_config.sql b/backend/scripts/migrate_llm_model_config_type_config.sql new file mode 100644 index 0000000..400012c --- /dev/null +++ b/backend/scripts/migrate_llm_model_config_type_config.sql @@ -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`; diff --git a/backend/tests/test_chat_citations.py b/backend/tests/test_chat_citations.py index acde182..6e57dd9 100644 --- a/backend/tests/test_chat_citations.py +++ b/backend/tests/test_chat_citations.py @@ -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() diff --git a/backend/tests/test_model_and_vector_configuration.py b/backend/tests/test_model_and_vector_configuration.py new file mode 100644 index 0000000..68c3b56 --- /dev/null +++ b/backend/tests/test_model_and_vector_configuration.py @@ -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() diff --git a/frontend/src/components/ModernSidebar/ModernSidebar.css b/frontend/src/components/ModernSidebar/ModernSidebar.css index c6aa53d..064c751 100644 --- a/frontend/src/components/ModernSidebar/ModernSidebar.css +++ b/frontend/src/components/ModernSidebar/ModernSidebar.css @@ -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; diff --git a/frontend/src/components/ModernSidebar/ModernSidebar.jsx b/frontend/src/components/ModernSidebar/ModernSidebar.jsx index 8f0a9a1..3a0c589 100644 --- a/frontend/src/components/ModernSidebar/ModernSidebar.jsx +++ b/frontend/src/components/ModernSidebar/ModernSidebar.jsx @@ -113,9 +113,19 @@ const ModernSidebar = ({ )} {collapsed && ( -
- -
+ + + + )} + + {collapsed && ( + + + )} {/* 用户卡片 */} @@ -140,9 +150,9 @@ const ModernSidebar = ({ )} {!collapsed && ( -
+
+ )} diff --git a/frontend/src/pages/Chat/Chat.jsx b/frontend/src/pages/Chat/Chat.jsx index 298e77f..7e28c16 100644 --- a/frontend/src/pages/Chat/Chat.jsx +++ b/frontend/src/pages/Chat/Chat.jsx @@ -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') } diff --git a/frontend/src/pages/System/ModelConfigs.css b/frontend/src/pages/System/ModelConfigs.css index a2b8435..09f0c74 100644 --- a/frontend/src/pages/System/ModelConfigs.css +++ b/frontend/src/pages/System/ModelConfigs.css @@ -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; + } +} diff --git a/frontend/src/pages/System/ModelConfigs.jsx b/frontend/src/pages/System/ModelConfigs.jsx index 4c442ec..524f371 100644 --- a/frontend/src/pages/System/ModelConfigs.jsx +++ b/frontend/src/pages/System/ModelConfigs.jsx @@ -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) => ( - {record.model_name} + + {record.model_name} + {record.is_default && 默认} + {record.model_code} ), @@ -399,6 +453,12 @@ function ModelConfigs() { width: 110, render: (value) => (value ? {value} : 自动), }) + 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) => ( + {!record.is_default && ( + + )}