From 4d609c0bca92f799aee0d47414d2ab051b98e836 Mon Sep 17 00:00:00 2001 From: ulleo Date: Thu, 5 Nov 2026 17:18:56 +0800 Subject: [PATCH] feat: add workspace model settings --- CONTEXT.md | 6 +- .../versions/073_workspace_model_settings.py | 19 + backend/apps/ai_model/model_factory.py | 8 +- backend/apps/chat/api/chat.py | 9 +- backend/apps/chat/task/llm.py | 29 +- backend/apps/system/api/aimodel.py | 54 +- backend/apps/system/api/assistant.py | 7 +- backend/apps/system/api/workspace.py | 20 + backend/apps/system/crud/aimodel_manage.py | 7 +- backend/apps/system/crud/workspace_model.py | 248 +++++++ backend/apps/system/models/system_model.py | 3 + .../apps/system/schemas/workspace_model.py | 50 ++ backend/locales/en.json | 6 + backend/locales/ko-KR.json | 6 + backend/locales/th-TH.json | 6 + backend/locales/zh-CN.json | 6 + backend/locales/zh-TW.json | 6 + backend/tests/test_workspace_model_policy.py | 656 ++++++++++++++++++ frontend/src/api/system.ts | 4 +- frontend/src/api/workspace.ts | 37 + frontend/src/i18n/en.json | 16 + frontend/src/i18n/ko-KR.json | 16 + frontend/src/i18n/th-TH.json | 16 + frontend/src/i18n/zh-CN.json | 16 + frontend/src/i18n/zh-TW.json | 16 + frontend/src/views/chat/ChatListContainer.vue | 6 +- frontend/src/views/chat/index.vue | 6 +- frontend/src/views/ds/Datasource.vue | 6 +- frontend/src/views/system/embedded/iframe.vue | 2 +- frontend/src/views/system/model/Model.vue | 9 +- .../AuthorizedWorkspaceDialogForModel.vue | 35 +- .../workspace/AuthorizedWorkspaceDraw.vue | 18 +- .../system/workspace/ModelImpactDialog.vue | 99 +++ .../system/workspace/ModelSettingsDialog.vue | 126 ++++ frontend/src/views/system/workspace/index.vue | 53 +- tests/browser/README.md | 12 + tests/browser/playwright.config.mjs | 5 + tests/browser/specs/workspace.model.spec.mjs | 160 +++++ 38 files changed, 1715 insertions(+), 89 deletions(-) create mode 100644 backend/alembic/versions/073_workspace_model_settings.py create mode 100644 backend/apps/system/crud/workspace_model.py create mode 100644 backend/apps/system/schemas/workspace_model.py create mode 100644 backend/tests/test_workspace_model_policy.py create mode 100644 frontend/src/views/system/workspace/ModelImpactDialog.vue create mode 100644 frontend/src/views/system/workspace/ModelSettingsDialog.vue create mode 100644 tests/browser/specs/workspace.model.spec.mjs diff --git a/CONTEXT.md b/CONTEXT.md index 3e4367e2f..6085dc5d2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -79,8 +79,10 @@ _Avoid_: Row Permission _Avoid_: 模型名称(仅显示用,与基础模型无强制关系) **Default Model / 默认模型**: -系统全局唯一的默认问答模型;工作空间不设各自的默认模型。 -_Avoid_: 工作空间默认模型(该概念不存在) +默认模型分为系统默认模型和工作空间默认模型。系统默认模型全局唯一;工作空间默认使用它并允许穿透空间授权,也可显式指定一个已授权模型。工作空间启用指定模型但 ID 留空时,禁止该空间所有问数入口的模型调用。助手选择默认模型时动态跟随空间配置;助手或 MCP 显式指定模型时必须验证空间授权。 + +**Workspace Model Policy / 工作空间模型策略**: +仅系统管理员配置,由 `sys_workspace.enable_custom_model` 和 `custom_model` 持久化。失效的指定 ID 保留,调用时报错、不回退;有效的显式指定模型可替代失效的空间默认模型。策略与授权在每次新操作时读取,正在执行的操作沿用启动时选定的模型。 ### 会话 diff --git a/backend/alembic/versions/073_workspace_model_settings.py b/backend/alembic/versions/073_workspace_model_settings.py new file mode 100644 index 000000000..9930cf118 --- /dev/null +++ b/backend/alembic/versions/073_workspace_model_settings.py @@ -0,0 +1,19 @@ +"""Add workspace model policy; existing and new workspaces keep system defaults.""" + +import sqlalchemy as sa +from alembic import op + +revision = "c71e75fd6a30" +down_revision = "f898322341a5" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("sys_workspace", sa.Column("enable_custom_model", sa.Boolean(), server_default=sa.false(), nullable=False, comment="是否指定工作空间模型")) + op.add_column("sys_workspace", sa.Column("custom_model", sa.String(255), nullable=True, comment="工作空间默认模型 ID;显式留空禁止模型调用")) + + +def downgrade(): + op.drop_column("sys_workspace", "custom_model") + op.drop_column("sys_workspace", "enable_custom_model") diff --git a/backend/apps/ai_model/model_factory.py b/backend/apps/ai_model/model_factory.py index 887005ce7..c064f4942 100644 --- a/backend/apps/ai_model/model_factory.py +++ b/backend/apps/ai_model/model_factory.py @@ -10,6 +10,8 @@ from apps.ai_model.openai.llm import BaseChatOpenAI from apps.system.models.system_model import AiModelDetail from common.core.db import engine +from common.error import SingleMessageError +from common.utils.locale import I18n from common.utils.crypto import sqlbot_decrypt from common.utils.utils import prepare_model_arg from langchain_community.llms import VLLMOpenAI @@ -145,12 +147,14 @@ def register_llm(cls, model_type: str, llm_class: Type[BaseLLM]): return config """ -async def get_default_config(custom_model_id: Optional[int] = None) -> LLMConfig: +async def get_default_config(custom_model_id: Optional[int] = None, *, trans=None) -> LLMConfig: with Session(engine) as session: db_model: AiModelDetail | None = None if custom_model_id: db_model = session.get(AiModelDetail, custom_model_id) - if not db_model: + if db_model is None: + raise SingleMessageError((trans or I18n()())('i18n_llm.model_unavailable')) + else: db_model = session.exec( select(AiModelDetail).where(AiModelDetail.default_model == True) ).first() diff --git a/backend/apps/chat/api/chat.py b/backend/apps/chat/api/chat.py index 028567a30..14194bae8 100644 --- a/backend/apps/chat/api/chat.py +++ b/backend/apps/chat/api/chat.py @@ -319,11 +319,11 @@ async def question_answer_inner(session: SessionDep, current_user: CurrentUser, elif command == QuickCommand.ANALYSIS: return await analysis_or_predict(session, current_user, rec_id, 'analysis', current_assistant, in_chat, - stream) + stream, custom_model=request_question.custom_model) elif command == QuickCommand.PREDICT_DATA: return await analysis_or_predict(session, current_user, rec_id, 'predict', current_assistant, in_chat, - stream) + stream, custom_model=request_question.custom_model) else: raise Exception(f'Unknown command: {command.value}') else: @@ -397,7 +397,8 @@ async def analysis_or_predict_question(session: SessionDep, current_user: Curren async def analysis_or_predict(session: SessionDep, current_user: CurrentUser, chat_record_id: int, action_type: str, - current_assistant: CurrentAssistant, in_chat: bool = True, stream: bool = True): + current_assistant: CurrentAssistant, in_chat: bool = True, stream: bool = True, + custom_model: str | int | None = None): try: if action_type != 'analysis' and action_type != 'predict': raise Exception(f"Type {action_type} Not Found") @@ -429,7 +430,7 @@ async def analysis_or_predict(session: SessionDep, current_user: CurrentUser, ch raise Exception( f"Chat record with id {chat_record_id} has not generated chart, do not support to analyze it") - request_question = ChatQuestion(chat_id=record.chat_id, question=record.question) + request_question = ChatQuestion(chat_id=record.chat_id, question=record.question, custom_model=custom_model) llm_service = await LLMService.create(session, current_user, request_question, current_assistant) llm_service.run_analysis_or_predict_task_async(session, action_type, record, in_chat, stream) diff --git a/backend/apps/chat/task/llm.py b/backend/apps/chat/task/llm.py index 2ff944001..fefc396a3 100644 --- a/backend/apps/chat/task/llm.py +++ b/backend/apps/chat/task/llm.py @@ -44,7 +44,7 @@ from apps.datasource.embedding.ds_embedding import get_ds_embedding from apps.datasource.models.datasource import CoreDatasource from apps.db.db import exec_sql, get_version, check_connection, get_sqlglot_dialect -from apps.system.crud.aimodel_manage import get_ai_model_list_by_workspace +from apps.system.crud.workspace_model import resolve_model_id from apps.system.crud.assistant import AssistantOutDs, AssistantOutDsFactory, get_assistant_ds from apps.system.crud.parameter_manage import get_groups from apps.system.crud.user import user_ws_list @@ -223,26 +223,13 @@ def __init__(self, session: Session, current_user: CurrentUser, chat_question: C @classmethod async def create(cls, *args, **kwargs): - specialized_model_id = None - _ai_model_list = [] - if args[3]: - if args[1]: - ws_id = args[1].oid - _ai_model_list = get_ai_model_list_by_workspace(args[0], ws_id) - if args[3].enable_custom_model: - if args[3].custom_model: - if any(str(model.id) == str(args[3].custom_model) for model in _ai_model_list): - specialized_model_id = args[3].custom_model - print("use custom model: id[" + specialized_model_id + "]") - # mcp call, custom model id - if args[2] and args[2].custom_model: - if args[1]: - ws_id = args[1].oid - _ai_model_list = get_ai_model_list_by_workspace(args[0], ws_id) - if any(str(model.id) == str(args[2].custom_model) for model in _ai_model_list): - specialized_model_id = args[2].custom_model - print("use custom model: id[" + specialized_model_id + "]") - config: LLMConfig = await get_default_config(specialized_model_id) + session, user, question, assistant = args[:4] + chat = session.get(Chat, question.chat_id) + if chat is None: + raise SingleMessageError(f"Chat with id {question.chat_id} not found") + trans = i18n(lang=user.language) + model_id = resolve_model_id(session, chat.oid, trans, assistant, question.custom_model) + config: LLMConfig = await get_default_config(model_id, trans=trans) # 构造函数可能同步调用宿主 API(type==1 高级应用动态数据源, # LLMService.__init__ -> AssistantOutDsFactory.get_instance -> requests.get), # 必须在工作线程执行,避免阻塞事件循环(Issue #1288) diff --git a/backend/apps/system/api/aimodel.py b/backend/apps/system/api/aimodel.py index 620adac7b..bf7bf1f46 100644 --- a/backend/apps/system/api/aimodel.py +++ b/backend/apps/system/api/aimodel.py @@ -8,10 +8,12 @@ from apps.ai_model.model_factory import LLMConfig, LLMFactory from apps.swagger.i18n import PLACEHOLDER_PREFIX from apps.system.crud.aimodel_manage import get_ai_model_list_by_workspace +from apps.system.crud.workspace_model import resolve_model_id, get_model_impacts, apply_model_impact_resolutions +from apps.system.schemas.workspace_model import ModelMappingUpdate, ModelImpactResolution, ModelImpact from apps.system.models.system_model import AiModelDetail, AiModelWorkspaceMapping, AiModelBrief from apps.system.schemas.ai_model_schema import AiModelConfigItem, AiModelCreator, AiModelEditor, AiModelGridItem from apps.system.schemas.permission import SqlbotPermission, require_permissions -from common.core.deps import SessionDep, Trans, CurrentUser +from common.core.deps import SessionDep, Trans, CurrentUser, CurrentAssistant from common.utils.crypto import sqlbot_decrypt from common.utils.time import get_timestamp from common.utils.utils import SQLBotLogUtil, prepare_model_arg @@ -52,12 +54,8 @@ async def generate(): @router.get("/default", include_in_schema=False) -async def check_default(session: SessionDep, trans: Trans): - db_model = session.exec( - select(AiModelDetail).where(AiModelDetail.default_model == True) - ).first() - if not db_model: - raise Exception(trans('i18n_llm.miss_default')) +async def check_default(session: SessionDep, trans: Trans, current_user: CurrentUser, current_assistant: CurrentAssistant): + resolve_model_id(session, current_user.oid, trans, current_assistant) @router.put("/default/{id}", summary=f"{PLACEHOLDER_PREFIX}system_model_default", @@ -198,15 +196,31 @@ async def update_model( async def delete_model( session: SessionDep, trans: Trans, - id: int = Path(description="ID") + current_user: CurrentUser, + id: int = Path(description="ID"), + resolutions: list[ModelImpactResolution] = Body(default=[]), ): - item = session.get(AiModelDetail, id) + item = session.exec(select(AiModelDetail).where(AiModelDetail.id == id).with_for_update()).first() + if item is None: + raise ValueError(trans('i18n_llm.model_unavailable')) if item.default_model: raise Exception(trans('i18n_llm.delete_default_error', key=item.name)) + apply_model_impact_resolutions(session, id, None, resolutions, trans, actor=current_user) + session.exec(delete(AiModelWorkspaceMapping).where(AiModelWorkspaceMapping.ai_model_id == id)) session.delete(item) session.commit() +@router.post("/{id}/workspace_impact", response_model=list[ModelImpact], summary=f"{PLACEHOLDER_PREFIX}system_model_workspace_impact") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def model_workspace_impact(session: SessionDep, trans: Trans, id: int, ws_ids: list[str] | None = Body(default=None)): + impacts = get_model_impacts(session, id, None if ws_ids is None else [int(value) for value in ws_ids]) + for impact in impacts: + if impact.workspace_name.startswith('i18n'): + impact.workspace_name = trans(impact.workspace_name) + return impacts + + @router.get("/{id}/ws_mapping", response_model=List[str], summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping", description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping") @require_permissions(permission=SqlbotPermission(role=['admin'])) @@ -232,20 +246,28 @@ async def get_model_ws_mapping_by_id( @router.put("/{id}/ws_mapping", response_model=List[str], summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_update", description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_update") @require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.AI_MODEL, resource_id_expr="id", extract_params=False)) async def update_model_ws_mapping_by_id( session: SessionDep, + trans: Trans, + current_user: CurrentUser, id: int = Path(description="ID"), - ws_ids: List[str] = Body(description="workspace id list"), + ws_ids: List[str] | ModelMappingUpdate = Body(description="workspace id list and impact resolutions"), ): + resolutions = ws_ids.resolutions if isinstance(ws_ids, ModelMappingUpdate) else [] + ws_ids = ws_ids.ws_ids if isinstance(ws_ids, ModelMappingUpdate) else ws_ids if ws_ids is None: ws_ids = [] # 提前去重 ws_ids = list({int(ws_id) for ws_id in ws_ids}) - db_model = session.get(AiModelDetail, id) + db_model = session.exec(select(AiModelDetail).where(AiModelDetail.id == id).with_for_update()).first() if not db_model: raise ValueError(f"AiModelDetail with id {id} not found") + existing_ids = session.exec(select(AiModelWorkspaceMapping.workspace_id).where(AiModelWorkspaceMapping.ai_model_id == id)).all() + removed_ids = list(set(existing_ids) - set(ws_ids)) + apply_model_impact_resolutions(session, id, removed_ids, resolutions, trans, actor=current_user) # 根据 ai_model_id 更新关联的 workspace_id 列表 # 1. 批量删除旧映射 session.execute( @@ -316,19 +338,25 @@ async def add_model_ws_mapping_by_id( summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_delete", description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_delete") @require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.AI_MODEL, resource_id_expr="id", extract_params=False)) async def delete_model_ws_mapping_by_id( session: SessionDep, + trans: Trans, + current_user: CurrentUser, id: int = Path(description="ID"), - ws_ids: List[str] = Body(description="workspace id list"), + ws_ids: List[str] | ModelMappingUpdate = Body(description="workspace id list and impact resolutions"), ): + resolutions = ws_ids.resolutions if isinstance(ws_ids, ModelMappingUpdate) else [] + ws_ids = ws_ids.ws_ids if isinstance(ws_ids, ModelMappingUpdate) else ws_ids if ws_ids is None: ws_ids = [] ws_ids = list({int(ws_id) for ws_id in ws_ids}) - db_model = session.get(AiModelDetail, id) + db_model = session.exec(select(AiModelDetail).where(AiModelDetail.id == id).with_for_update()).first() if not db_model: raise ValueError(f"AiModelDetail with id {id} not found") + apply_model_impact_resolutions(session, id, ws_ids, resolutions, trans, actor=current_user) # 只删除指定的映射 if ws_ids: session.execute( diff --git a/backend/apps/system/api/assistant.py b/backend/apps/system/api/assistant.py index 663426b55..0f974c000 100644 --- a/backend/apps/system/api/assistant.py +++ b/backend/apps/system/api/assistant.py @@ -15,6 +15,7 @@ from apps.swagger.i18n import PLACEHOLDER_PREFIX from apps.system.crud.assistant import AssistantOutDs, AssistantOutDsFactory, get_assistant_info from apps.system.crud.assistant_manage import dynamic_upgrade_cors, get_ws_assistant, save +from apps.system.crud.workspace_model import require_authorized_model from apps.system.models.system_model import AssistantModel from apps.system.schemas.auth import CacheName, CacheNamespace from apps.system.schemas.permission import SqlbotPermission, require_permissions @@ -336,8 +337,10 @@ async def query_advanced_application(session: SessionDep, current_user: CurrentU @router.post("", summary=f"{PLACEHOLDER_PREFIX}assistant_create_api", description=f"{PLACEHOLDER_PREFIX}assistant_create_api") @require_permissions(permission=SqlbotPermission(role=['ws_admin'])) @system_log(LogConfig(operation_type=OperationType.CREATE, module=OperationModules.APPLICATION, result_id_expr="id")) -async def add(request: Request, session: SessionDep, current_user: CurrentUser, creator: AssistantBase): +async def add(request: Request, session: SessionDep, current_user: CurrentUser, trans: Trans, creator: AssistantBase): oid = current_user.oid if creator.type != 4 else 1 + if creator.enable_custom_model: + require_authorized_model(session, oid, creator.custom_model, trans) return await save(request, session, creator, oid) @@ -354,6 +357,8 @@ async def update(request: Request, session: SessionDep, current_user: CurrentUse update_data.oid = db_model.oid # create_time 不在请求体中,model_validate 会填默认值 0,更新时保留原值 update_data.create_time = db_model.create_time + if update_data.enable_custom_model: + require_authorized_model(session, update_data.oid, update_data.custom_model, trans) db_model.sqlmodel_update(update_data) session.add(db_model) session.commit() diff --git a/backend/apps/system/api/workspace.py b/backend/apps/system/api/workspace.py index 0c909bb1c..447c1f0c9 100644 --- a/backend/apps/system/api/workspace.py +++ b/backend/apps/system/api/workspace.py @@ -4,6 +4,8 @@ from apps.swagger.i18n import PLACEHOLDER_PREFIX from apps.system.crud.user import clean_user_cache from apps.system.crud.workspace import reset_single_user_oid, reset_user_oid +from apps.system.crud.workspace_model import get_workspace_model_settings, save_workspace_model_settings +from apps.system.schemas.workspace_model import WorkspaceModelSettings, WorkspaceModelSettingsOut from apps.system.models.system_model import UserWsModel, WorkspaceBase, WorkspaceEditor, WorkspaceModel from apps.system.models.user import UserModel from apps.system.schemas.permission import SqlbotPermission, require_permissions @@ -217,6 +219,24 @@ async def update(session: SessionDep, editor: WorkspaceEditor): db_model.name = editor.name session.add(db_model) +@router.get("/{id}/model", response_model=WorkspaceModelSettingsOut, summary=f"{PLACEHOLDER_PREFIX}ws_model_settings") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def model_settings(session: SessionDep, trans: Trans, id: int): + workspace = session.get(WorkspaceModel, id) + if workspace is None: + raise HTTPException(status_code=404, detail=trans('i18n_llm.workspace_unavailable')) + return get_workspace_model_settings(session, workspace) + + +@router.put("/{id}/model", summary=f"{PLACEHOLDER_PREFIX}ws_model_settings") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def update_model_settings(session: SessionDep, current_user: CurrentUser, trans: Trans, id: int, editor: WorkspaceModelSettings): + workspace = session.exec(select(WorkspaceModel).where(WorkspaceModel.id == id).with_for_update()).first() + if workspace is None: + raise HTTPException(status_code=404, detail=trans('i18n_llm.workspace_unavailable')) + return save_workspace_model_settings(session, workspace, editor, trans, actor=current_user) + + @router.get("/{id}", response_model=WorkspaceModel, summary=f"{PLACEHOLDER_PREFIX}ws_query_api", description=f"{PLACEHOLDER_PREFIX}ws_query_api") @require_permissions(permission=SqlbotPermission(role=['admin'])) async def get_one(session: SessionDep, trans: Trans, id: int = Path(description=f"{PLACEHOLDER_PREFIX}oid")): diff --git a/backend/apps/system/crud/aimodel_manage.py b/backend/apps/system/crud/aimodel_manage.py index b0fabdbd1..fb73963b7 100644 --- a/backend/apps/system/crud/aimodel_manage.py +++ b/backend/apps/system/crud/aimodel_manage.py @@ -1,12 +1,13 @@ from sqlmodel import Session, select, or_ from apps.system.models.system_model import AiModelDetail, AiModelBrief, AiModelWorkspaceMapping -from common.core.db import engine -from common.utils.crypto import sqlbot_encrypt -from common.utils.utils import SQLBotLogUtil async def async_model_info(): + from common.core.db import engine + from common.utils.crypto import sqlbot_encrypt + from common.utils.utils import SQLBotLogUtil + with Session(engine) as session: model_list = session.exec(select(AiModelDetail)).all() any_model_change = False diff --git a/backend/apps/system/crud/workspace_model.py b/backend/apps/system/crud/workspace_model.py new file mode 100644 index 000000000..acf845c66 --- /dev/null +++ b/backend/apps/system/crud/workspace_model.py @@ -0,0 +1,248 @@ +"""Workspace model policy shared by page questions, assistants and MCP.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from sqlmodel import Session, select + +from apps.system.crud.aimodel_manage import get_ai_model_list_by_workspace +from apps.system.models.system_model import ( + AiModelDetail, + AiModelWorkspaceMapping, + AssistantModel, + WorkspaceModel, +) +from apps.system.schemas.workspace_model import ( + ModelImpact, + ModelImpactResolution, + WorkspaceModelSettings, + WorkspaceModelSettingsOut, +) +from common.audit.models.log_model import SystemLog, SystemLogsResource +from common.error import SingleMessageError +from common.utils.locale import I18nHelper + +if TYPE_CHECKING: + from apps.system.schemas.system_schema import AssistantHeader, UserInfoDTO + +ModelSettingsSnapshot = dict[str, bool | str | None] + + +def record_model_settings_change( + session: Session, + workspace: WorkspaceModel, + before: ModelSettingsSnapshot, + after: ModelSettingsSnapshot, + actor: UserInfoDTO | None, + trans: I18nHelper, +) -> None: + # Commit the audit entry with the configuration/mapping transaction. + if actor is not None: + log = SystemLog( + operation_type="update", + operation_status="success", + module="workspace", + operation_detail=trans("ws_model_settings"), + resource_id=str(workspace.id), + resource_name=workspace.name, + oid=workspace.id, + user_id=actor.id, + user_name=actor.name, + remark=json.dumps({"before": before, "after": after}), + ) + session.add(log) + session.flush() + session.add( + SystemLogsResource( + log_id=log.id, + resource_id=str(workspace.id), + resource_name=workspace.name, + module="workspace", + ) + ) + + +def require_authorized_model( + session: Session, workspace_id: int, model_id: str | int | None, trans: I18nHelper +) -> int: + try: + if isinstance(model_id, bool): + raise ValueError + value = int(str(model_id)) + if not 0 < value <= 2**63 - 1: + raise ValueError + except (ValueError, TypeError): + raise SingleMessageError(trans("i18n_llm.model_unavailable")) + model = session.exec( + select(AiModelDetail) + .join( + AiModelWorkspaceMapping, + AiModelWorkspaceMapping.ai_model_id == AiModelDetail.id, + ) + .where( + AiModelDetail.id == value, + AiModelWorkspaceMapping.workspace_id == workspace_id, + ) + ).first() + if model is None: + raise SingleMessageError(trans("i18n_llm.model_unavailable")) + return value + + +def resolve_model_id( + session: Session, + workspace_id: int, + trans: I18nHelper, + assistant: AssistantHeader | None = None, + custom_model: str | int | None = None, +) -> int: + workspace = session.get(WorkspaceModel, workspace_id) + if workspace is None: + raise SingleMessageError(trans("i18n_llm.workspace_unavailable")) + # An explicitly empty workspace default disables every model invocation, + # including assistants and MCP requests that supply their own model. + if workspace.enable_custom_model and not workspace.custom_model: + raise SingleMessageError(trans("i18n_llm.workspace_disabled")) + if custom_model is not None and custom_model != "": + return require_authorized_model(session, workspace_id, custom_model, trans) + if assistant and assistant.enable_custom_model: + return require_authorized_model( + session, workspace_id, assistant.custom_model, trans + ) + if workspace.enable_custom_model: + return require_authorized_model( + session, workspace_id, workspace.custom_model, trans + ) + model = session.exec( + select(AiModelDetail).where(AiModelDetail.default_model.is_(True)) + ).first() + if model is None: + raise SingleMessageError(trans("i18n_llm.miss_default")) + return model.id + + +def get_workspace_model_settings( + session: Session, workspace: WorkspaceModel +) -> WorkspaceModelSettingsOut: + models = get_ai_model_list_by_workspace(session, workspace.id, False) + return WorkspaceModelSettingsOut( + enable_custom_model=workspace.enable_custom_model, + custom_model=workspace.custom_model, + models=models, + model_valid=any(str(item.id) == workspace.custom_model for item in models), + ) + + +def save_workspace_model_settings( + session: Session, + workspace: WorkspaceModel, + settings: WorkspaceModelSettings, + trans: I18nHelper, + actor: UserInfoDTO | None = None, +) -> dict[str, ModelSettingsSnapshot]: + if settings.enable_custom_model and settings.custom_model: + require_authorized_model(session, workspace.id, settings.custom_model, trans) + before = WorkspaceModelSettings.model_validate( + workspace, from_attributes=True + ).model_dump() + workspace.enable_custom_model = settings.enable_custom_model + workspace.custom_model = ( + settings.custom_model if settings.enable_custom_model else None + ) + session.add(workspace) + changes: dict[str, ModelSettingsSnapshot] = { + "before": before, + "after": WorkspaceModelSettings.model_validate( + workspace, from_attributes=True + ).model_dump(), + } + record_model_settings_change( + session, workspace, changes["before"], changes["after"], actor, trans + ) + session.commit() + return changes + + +def get_model_impacts( + session: Session, + model_id: int, + ws_ids: list[int] | None = None, + *, + lock: bool = False, +) -> list[ModelImpact]: + statement = select(WorkspaceModel).order_by(WorkspaceModel.id) + if ws_ids is not None: + statement = statement.where(WorkspaceModel.id.in_(ws_ids)) + if lock: + statement = statement.with_for_update() + workspaces = session.exec(statement).all() + assistants = session.exec( + select(AssistantModel).where( + AssistantModel.enable_custom_model.is_(True), + AssistantModel.custom_model == str(model_id), + ) + ).all() + result = [] + for workspace in workspaces: + uses_default = workspace.enable_custom_model and workspace.custom_model == str( + model_id + ) + names = [item.name for item in assistants if item.oid == workspace.id] + if uses_default or names: + result.append( + ModelImpact( + workspace_id=str(workspace.id), + workspace_name=workspace.name, + uses_default=uses_default, + assistants=names, + models=[ + item + for item in get_ai_model_list_by_workspace( + session, workspace.id, False + ) + if item.id != model_id + ], + ) + ) + return result + + +def apply_model_impact_resolutions( + session: Session, + model_id: int, + ws_ids: list[int] | None, + resolutions: list[ModelImpactResolution], + trans: I18nHelper, + actor: UserInfoDTO | None = None, +) -> None: + # Recompute at mutation time; an earlier preview is never authorization. + impacts = get_model_impacts(session, model_id, ws_ids, lock=True) + resolution_map = {item.workspace_id: item for item in resolutions} + if len(resolution_map) != len(resolutions) or set(resolution_map) != { + int(item.workspace_id) for item in impacts + }: + raise SingleMessageError(trans("i18n_llm.impact_confirmation_required")) + changes = [] + # Validate the entire batch before changing any workspace or mapping. + for impact in impacts: + resolution = resolution_map[int(impact.workspace_id)] + if resolution.custom_model is not None: + replacement = require_authorized_model( + session, resolution.workspace_id, resolution.custom_model, trans + ) + if not impact.uses_default or replacement == model_id: + raise SingleMessageError(trans("i18n_llm.model_unavailable")) + changes.append((resolution.workspace_id, str(replacement))) + for workspace_id, replacement in changes: + workspace = session.get(WorkspaceModel, workspace_id) + before = WorkspaceModelSettings.model_validate( + workspace, from_attributes=True + ).model_dump() + workspace.custom_model = replacement + session.add(workspace) + after = WorkspaceModelSettings.model_validate( + workspace, from_attributes=True + ).model_dump() + record_model_settings_change(session, workspace, before, after, actor, trans) diff --git a/backend/apps/system/models/system_model.py b/backend/apps/system/models/system_model.py index 6b3602329..cf23e8d10 100644 --- a/backend/apps/system/models/system_model.py +++ b/backend/apps/system/models/system_model.py @@ -1,6 +1,7 @@ from typing import Optional from pydantic import field_serializer +from sqlalchemy import false from sqlmodel import BigInteger, Field, Text, SQLModel from common.core.models import SnowflakeBase @@ -53,6 +54,8 @@ class WorkspaceEditor(WorkspaceBase, BaseCreatorDTO): class WorkspaceModel(SnowflakeBase, WorkspaceBase, table=True): __tablename__ = "sys_workspace" create_time: int = Field(default=0, sa_type=BigInteger()) + enable_custom_model: bool = Field(default=False, nullable=False, sa_column_kwargs={"server_default": false()}) + custom_model: str | None = Field(default=None, max_length=255, nullable=True) class UserWsBaseModel(SQLModel): diff --git a/backend/apps/system/schemas/workspace_model.py b/backend/apps/system/schemas/workspace_model.py new file mode 100644 index 000000000..0276a40d0 --- /dev/null +++ b/backend/apps/system/schemas/workspace_model.py @@ -0,0 +1,50 @@ +from pydantic import BaseModel, Field, field_validator + +from apps.system.models.system_model import AiModelBrief + + +class WorkspaceModelSettings(BaseModel): + enable_custom_model: bool = False + custom_model: str | None = Field(default=None, max_length=255) + + @field_validator("custom_model", mode="before") + @classmethod + def normalize_model_id(cls, value: object) -> str | None: + if value is None or value == "": + return None + if isinstance(value, bool) or not isinstance(value, (int, str)): + raise ValueError("Invalid model ID") + value = str(value).strip() + if not value: + return None + if ( + not value.isascii() + or not value.isdecimal() + or not 0 < int(value) <= 2**63 - 1 + ): + raise ValueError("Invalid model ID") + return str(int(value)) + + +class WorkspaceModelSettingsOut(WorkspaceModelSettings): + models: list[AiModelBrief] + model_valid: bool + + +class ModelImpact(BaseModel): + workspace_id: str + workspace_name: str + uses_default: bool + assistants: list[str] + models: list[AiModelBrief] + + +class ModelImpactResolution(BaseModel): + workspace_id: int + # None explicitly acknowledges the unavailable default; retain its old ID. + custom_model: str | None = None + + +class ModelMappingUpdate(BaseModel): + ws_ids: list[str] + resolutions: list[ModelImpactResolution] = Field(default_factory=list) diff --git a/backend/locales/en.json b/backend/locales/en.json index 69e909866..4e4bdc3fb 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1,4 +1,6 @@ { + "ws_model_settings": "Workspace model settings", + "system_model_workspace_impact": "Model change impact", "i18n_default_workspace": "Default Workspace", "i18n_ds_name_exist": "Name already exists", "i18n_concat_admin": "Please contact the administrator!", @@ -47,6 +49,10 @@ "permission_resource_limit": "Access denied or resource not found!" }, "i18n_llm": { + "model_unavailable": "Model does not exist or is not authorized for this workspace. Contact an administrator.", + "workspace_unavailable": "Workspace is unavailable.", + "workspace_disabled": "Model calls are disabled for this workspace. Contact an administrator.", + "impact_confirmation_required": "Affected workspaces have changed. Confirm replacements or suspension of default calls again.", "validate_error": "Validation failed [{msg}]", "delete_default_error": "Cannot delete the default model [{key}]!", "miss_default": "Default large language model has not been configured" diff --git a/backend/locales/ko-KR.json b/backend/locales/ko-KR.json index 832c15ab9..195ece263 100644 --- a/backend/locales/ko-KR.json +++ b/backend/locales/ko-KR.json @@ -1,4 +1,6 @@ { + "ws_model_settings": "워크스페이스 모델 설정", + "system_model_workspace_impact": "모델 변경 영향", "i18n_default_workspace": "기본 작업 공간", "i18n_ds_name_exist": "이름이 이미 존재합니다", "i18n_concat_admin": "관리자에게 문의하세요!", @@ -47,6 +49,10 @@ "permission_resource_limit": "권한이 없거나 리소스를 찾을 수 없습니다!" }, "i18n_llm": { + "model_unavailable": "모델이 없거나 이 워크스페이스에 승인되지 않았습니다. 관리자에게 문의하세요.", + "workspace_unavailable": "워크스페이스를 사용할 수 없습니다.", + "workspace_disabled": "이 워크스페이스의 모델 호출이 차단되었습니다. 관리자에게 문의하세요.", + "impact_confirmation_required": "영향을 받는 워크스페이스가 변경되었습니다. 대체 모델 또는 기본 호출 중단을 다시 확인하세요.", "validate_error": "검증 실패 [{msg}]", "delete_default_error": "기본 모델 [{key}]을(를) 삭제할 수 없습니다!", "miss_default": "기본 대형 언어 모델이 구성되지 않았습니다" diff --git a/backend/locales/th-TH.json b/backend/locales/th-TH.json index 9aa8b1049..aedf260ca 100644 --- a/backend/locales/th-TH.json +++ b/backend/locales/th-TH.json @@ -1,4 +1,6 @@ { + "ws_model_settings": "การตั้งค่าโมเดลเวิร์กสเปซ", + "system_model_workspace_impact": "ผลกระทบจากการเปลี่ยนโมเดล", "i18n_default_workspace": "พื้นที่ทำงานเริ่มต้น", "i18n_ds_name_exist": "ชื่อนี้มีอยู่แล้ว", "i18n_concat_admin": "โปรดติดต่อผู้ดูแลระบบ!", @@ -47,6 +49,10 @@ "permission_resource_limit": "ไม่มีสิทธิ์ดำเนินการ หรือไม่พบทรัพยากร!" }, "i18n_llm": { + "model_unavailable": "ไม่มีโมเดลหรือโมเดลไม่ได้รับอนุญาตในเวิร์กสเปซนี้ โปรดติดต่อผู้ดูแลระบบ", + "workspace_unavailable": "ไม่สามารถใช้เวิร์กสเปซนี้ได้", + "workspace_disabled": "เวิร์กสเปซนี้ปิดการเรียกโมเดล โปรดติดต่อผู้ดูแลระบบ", + "impact_confirmation_required": "เวิร์กสเปซที่ได้รับผลกระทบเปลี่ยนแปลง โปรดยืนยันโมเดลใหม่หรือระงับการเรียกแบบเริ่มต้นอีกครั้ง", "validate_error": "ตรวจสอบไม่สำเร็จ [{msg}]", "delete_default_error": "ไม่สามารถลบโมเดลเริ่มต้นได้ [{key}]!", "miss_default": "ยังไม่ได้กำหนดค่าโมเดลภาษาขนาดใหญ่เริ่มต้น" diff --git a/backend/locales/zh-CN.json b/backend/locales/zh-CN.json index c99c11209..2ca53c75c 100644 --- a/backend/locales/zh-CN.json +++ b/backend/locales/zh-CN.json @@ -1,4 +1,6 @@ { + "ws_model_settings": "工作空间模型设置", + "system_model_workspace_impact": "模型变更影响", "i18n_default_workspace": "默认工作空间", "i18n_ds_name_exist": "名称已存在", "i18n_concat_admin": "请联系管理员!", @@ -47,6 +49,10 @@ "permission_resource_limit": "没有操作权限或资源不存在!" }, "i18n_llm": { + "model_unavailable": "模型不存在或未授权给当前工作空间,请联系管理员重新配置", + "workspace_unavailable": "当前工作空间不可用", + "workspace_disabled": "当前工作空间已禁止模型调用,请联系管理员", + "impact_confirmation_required": "受影响的工作空间已变化,请重新确认替代模型或暂停默认调用", "validate_error": "校验失败[{msg}]", "delete_default_error": "无法删除默认模型[{key}]!", "miss_default": "尚未配置默认大语言模型" diff --git a/backend/locales/zh-TW.json b/backend/locales/zh-TW.json index 315e6f495..f8e722b33 100644 --- a/backend/locales/zh-TW.json +++ b/backend/locales/zh-TW.json @@ -1,4 +1,6 @@ { + "ws_model_settings": "工作空間模型設定", + "system_model_workspace_impact": "模型變更影響", "i18n_default_workspace": "默認工作空間", "i18n_ds_name_exist": "名稱已存在", "i18n_concat_admin": "請聯繫管理員!", @@ -47,6 +49,10 @@ "permission_resource_limit": "沒有操作權限或資源不存在!" }, "i18n_llm": { + "model_unavailable": "模型不存在或未授權給目前工作空間,請聯絡管理員重新設定", + "workspace_unavailable": "目前工作空間無法使用", + "workspace_disabled": "目前工作空間已禁止模型呼叫,請聯絡管理員", + "impact_confirmation_required": "受影響的工作空間已變更,請重新確認替代模型或暫停預設呼叫", "validate_error": "校驗失敗[{msg}]", "delete_default_error": "無法刪除默認模型[{key}]!", "miss_default": "尚未配置默認大語言模型" diff --git a/backend/tests/test_workspace_model_policy.py b/backend/tests/test_workspace_model_policy.py new file mode 100644 index 000000000..f47c214da --- /dev/null +++ b/backend/tests/test_workspace_model_policy.py @@ -0,0 +1,656 @@ +"""Offline policy/API regressions with SQLite and a loopback OpenAI endpoint. + +Real routers, permission decorators, SQLModel queries, model selection, config +loading and provider calls are exercised. Identity, audit DB, unrelated chat +construction and chat parameters are isolated; no external LLM is contacted. +""" + +import asyncio +import importlib.util +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace + +import pytest +import sqlbot_xpack # noqa: F401 -- initialize the released wheel before app routers +from alembic.migration import MigrationContext +from alembic.operations import Operations +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient +from sqlalchemy import MetaData, inspect, text +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, create_engine, select + +from apps.ai_model.model_factory import LLMFactory, get_default_config +from apps.chat.api import chat as chat_api +from apps.chat.models.chat_model import Chat, ChatQuestion, ChatRecord +from apps.chat.task import llm +from apps.mcp import mcp +from apps.system.api import aimodel, workspace +from apps.system.crud.workspace_model import resolve_model_id +from apps.system.models.system_model import ( + AiModelDetail, + AiModelWorkspaceMapping, + AssistantModel, + WorkspaceModel, +) +from apps.system.schemas.permission import RequestContextMiddleware +from common.audit.models.log_model import SystemLog, SystemLogsResource +from common.audit.schemas.request_context import RequestContextMiddlewareCommon +from common.core.db import get_session +from common.error import SingleMessageError +from common.utils.locale import I18n + +TRANS = I18n()(lang="en") + + +@pytest.fixture +def environment(monkeypatch): + calls = [] + + class Provider(BaseHTTPRequestHandler): + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + calls.append(body["model"]) + data = json.dumps( + { + "id": "local-test", + "object": "chat.completion", + "created": 1, + "model": body["model"], + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "ok"}, + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, *_args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Provider) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + database = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + for model in ( + AiModelDetail, + AiModelWorkspaceMapping, + WorkspaceModel, + AssistantModel, + Chat, + ChatRecord, + ): + model.__table__.create(database) + # Migration 055 allows nullable audit fields; its SQLModel annotations are + # stricter. Match the production migration without changing global metadata. + metadata = MetaData() + for model in (SystemLog, SystemLogsResource): + table = model.__table__.to_metadata(metadata) + for column in table.columns: + if not column.primary_key: + column.nullable = True + table.create(database) + with Session(database) as session: + session.add_all( + [WorkspaceModel(id=1, name="A"), WorkspaceModel(id=2, name="B")] + ) + for model_id in (10, 20, 30): + session.add( + AiModelDetail( + id=model_id, + name=f"model-{model_id}", + supplier=15, + model_type=0, + base_model=f"base-{model_id}", + default_model=model_id == 10, + api_domain=f"http://127.0.0.1:{server.server_port}/v1", + api_key="local-test", + config="[]", + ) + ) + session.add_all( + [ + AiModelWorkspaceMapping(ai_model_id=20, workspace_id=1), + AiModelWorkspaceMapping(ai_model_id=30, workspace_id=1), + ] + ) + session.add_all( + [ + Chat(id=101, oid=1, create_by=1, engine_type="PostgreSQL"), + Chat(id=102, oid=2, create_by=1, engine_type="PostgreSQL"), + ] + ) + session.commit() + monkeypatch.setattr("apps.ai_model.model_factory.engine", database) + monkeypatch.setattr("common.audit.schemas.logger_decorator.engine", database) + # The audit name union includes unrelated xpack tables; only resource-name + # lookup is isolated. The decorator and log persistence remain real. + monkeypatch.setattr( + "common.audit.schemas.logger_decorator.get_resource_name_by_id_and_module", + lambda *_args: [], + ) + monkeypatch.setattr("apps.system.api.workspace.clean_user_cache", _empty_async) + monkeypatch.setattr(llm, "get_groups", _empty_async) + + def construct(self, _session, user, question, _assistant, *_args, **kwargs): + self.config = kwargs["config"] + self.current_user = user + self.chat_question = question + self.llm = LLMFactory.create_llm(self.config).llm + + monkeypatch.setattr(llm.LLMService, "__init__", construct) + monkeypatch.setattr("apps.system.schemas.permission.engine", database) + monkeypatch.setattr(llm.LLMService, "init_record", lambda *_args, **_kwargs: None) + + def run(self, **kwargs): + self.test_stream = kwargs.get("stream", True) + assert self.llm.invoke("hello").content == "ok" + + def result(self): + data = {"success": True, "model_id": self.config.model_id} + yield "data:" + json.dumps(data) + "\n\n" if self.test_stream else data + + monkeypatch.setattr(llm.LLMService, "run_task_async", run) + monkeypatch.setattr(llm.LLMService, "await_result", result) + monkeypatch.setattr(llm.LLMService, "run_recommend_questions_task_async", run) + monkeypatch.setattr( + llm.LLMService, + "run_analysis_or_predict_task_async", + lambda self, _session, _action, _record, _in_chat, stream: run( + self, stream=stream + ), + ) + monkeypatch.setattr( + mcp, "get_user", lambda *_args: SimpleNamespace(id=1, oid=2, language="en") + ) + + async def workspaces(*_args): + return [SimpleNamespace(id=1), SimpleNamespace(id=2)] + + monkeypatch.setattr(mcp, "user_ws_options", workspaces) + app = FastAPI() + app.include_router(workspace.router) + app.include_router(aimodel.router) + app.include_router(chat_api.router) + app.include_router(mcp.router) + app.add_middleware(RequestContextMiddleware) + app.add_middleware(RequestContextMiddlewareCommon) + + @app.middleware("http") + async def identity(request, call_next): + role = request.headers.get("test-role", "admin") + request.state.assistant = None + if role != "anonymous": + request.state.current_user = SimpleNamespace( + id=1, + oid=1, + name="test", + language="en", + isAdmin=role == "admin", + weight=1 if role == "workspace-admin" else 0, + ) + return await call_next(request) + + @app.exception_handler(Exception) + async def error_response(_request, exc): + return JSONResponse(status_code=400, content={"error": str(exc)}) + + def session_dependency(): + with Session(database) as session: + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + + app.dependency_overrides[get_session] = session_dependency + with TestClient(app, raise_server_exceptions=False) as client: + yield SimpleNamespace(database=database, client=client, calls=calls) + database.dispose() + server.shutdown() + server.server_close() + thread.join() + + +async def _empty_async(*_args, **_kwargs): + return [] + + +def save(env, enabled, model_id, workspace_id=1): + return env.client.put( + f"/system/workspace/{workspace_id}/model", + json={"enable_custom_model": enabled, "custom_model": model_id}, + ) + + +def call(env, custom_model=None, assistant=None, chat_id=101): + with Session(env.database) as session: + service = asyncio.run( + llm.LLMService.create( + session, + SimpleNamespace(id=1, oid=2, language="en"), + ChatQuestion( + chat_id=chat_id, question="hello", custom_model=custom_model + ), + assistant, + ) + ) + assert service.llm.invoke("hello").content == "ok" + return service.config.model_id + + +def test_default_compatibility_and_workspace_isolation(environment): + env = environment + assert call(env) == 10 # not authorized to workspace A + assert save(env, True, "20").status_code == 200 + assert call(env) == 20 + assert call(env, chat_id=102) == 10 + assert env.calls == ["base-10", "base-20", "base-10"] + assert save(env, False, None).status_code == 200 + assert call(env) == 10 + + +@pytest.mark.parametrize( + "kind", ["page", "assistant-default", "assistant-custom", "mcp"] +) +def test_explicit_empty_workspace_disables_every_entry(environment, kind): + env = environment + assert save(env, True, None).status_code == 200 + assistant = ( + SimpleNamespace( + enable_custom_model=kind == "assistant-custom", custom_model="20" + ) + if kind.startswith("assistant") + else None + ) + with pytest.raises(SingleMessageError, match="disabled"): + call(env, custom_model="30" if kind == "mcp" else None, assistant=assistant) + assert env.calls == [] + + +def test_assistant_inherits_without_rewriting_custom_settings(environment): + env = environment + default = SimpleNamespace(enable_custom_model=False, custom_model=None) + explicit = SimpleNamespace(enable_custom_model=True, custom_model="30") + assert save(env, True, "20").status_code == 200 + assert call(env, assistant=default) == 20 + assert call(env, assistant=explicit) == 30 + assert save(env, False, None).status_code == 200 + assert call(env, assistant=default) == 10 + assert explicit.custom_model == "30" + + +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("model_id", ["10", "999", "-1", "bad", 999]) +def test_explicit_unauthorized_or_missing_models_never_fallback( + environment, enabled, model_id +): + env = environment + assert save(env, enabled, "20" if enabled else None).status_code == 200 + for assistant in ( + None, + SimpleNamespace(enable_custom_model=True, custom_model=model_id), + ): + with pytest.raises(SingleMessageError, match="not authorized"): + call( + env, + custom_model=model_id if assistant is None else None, + assistant=assistant, + ) + assert env.calls == [] + + +def test_invalid_default_preserves_reference_and_allows_valid_explicit_override( + environment, +): + env = environment + assert save(env, True, "20").status_code == 200 + with Session(env.database) as session: + session.delete(session.get(AiModelDetail, 20)) + session.commit() + with pytest.raises(SingleMessageError): + call(env) + assert call(env, custom_model="30") == 30 + assert ( + call( + env, assistant=SimpleNamespace(enable_custom_model=True, custom_model="30") + ) + == 30 + ) + assert env.client.get("/system/workspace/1/model").json()["custom_model"] == "20" + with pytest.raises(SingleMessageError): + asyncio.run(get_default_config(20, trans=TRANS)) + assert env.calls == ["base-30", "base-30"] + + +@pytest.mark.parametrize("role", ["workspace-admin", "user", "anonymous"]) +def test_only_system_admin_can_read_or_change_settings_or_impacts(environment, role): + env = environment + headers = {"test-role": role} + assert ( + env.client.get("/system/workspace/1/model", headers=headers).status_code >= 400 + ) + assert ( + env.client.put( + "/system/workspace/1/model", + json={"enable_custom_model": True, "custom_model": None}, + headers=headers, + ).status_code + >= 400 + ) + assert ( + env.client.post( + "/system/aimodel/20/workspace_impact", json=None, headers=headers + ).status_code + >= 400 + ) + assert ( + env.client.get("/system/workspace/1/model").json()["enable_custom_model"] + is False + ) + + +def test_settings_validate_authorization_preserve_rename_and_audit(environment): + env = environment + assert save(env, True, "10").status_code >= 400 + assert save(env, True, "20").status_code == 200 + assert ( + env.client.put( + "/system/workspace", json={"id": 1, "name": "renamed"} + ).status_code + == 200 + ) + result = env.client.get("/system/workspace/1/model").json() + assert result["custom_model"] == "20" + assert [item["id"] for item in result["models"]] == ["20", "30"] + with Session(env.database) as session: + logs = session.exec(select(SystemLog).where(SystemLog.resource_id == "1")).all() + audit = next(json.loads(item.remark) for item in logs if item.remark) + assert audit == { + "before": {"enable_custom_model": False, "custom_model": None}, + "after": {"enable_custom_model": True, "custom_model": "20"}, + } + + +def test_precheck_uses_workspace_model_without_system_default(environment): + env = environment + assert save(env, True, "20").status_code == 200 + with Session(env.database) as session: + session.delete(session.get(AiModelDetail, 10)) + session.commit() + assert env.client.get("/system/aimodel/default").status_code == 200 + assert call(env) == 20 + assert save(env, True, None).status_code == 200 + assert env.client.get("/system/aimodel/default").status_code >= 400 + + +@pytest.mark.parametrize("operation", ["delete", "remove", "replace-mapping"]) +def test_impact_confirmation_replacement_and_atomic_rejection(environment, operation): + env = environment + assert save(env, True, "20").status_code == 200 + preview = env.client.post("/system/aimodel/20/workspace_impact", json=["1"]) + assert preview.status_code == 200 + assert preview.json()[0]["workspace_id"] == "1" + assert [item["id"] for item in preview.json()[0]["models"]] == ["30"] + + def mutate(resolutions): + if operation == "delete": + return env.client.request("DELETE", "/system/aimodel/20", json=resolutions) + return env.client.request( + "PUT" if operation == "replace-mapping" else "DELETE", + "/system/aimodel/20/ws_mapping", + json={ + "ws_ids": [] if operation == "replace-mapping" else ["1"], + "resolutions": resolutions, + }, + ) + + assert mutate([]).status_code >= 400 + assert mutate([{"workspace_id": "1", "custom_model": "10"}]).status_code >= 400 + with Session(env.database) as session: + assert session.get(WorkspaceModel, 1).custom_model == "20" + assert session.get(AiModelDetail, 20) is not None + assert ( + session.exec( + select(AiModelWorkspaceMapping).where( + AiModelWorkspaceMapping.ai_model_id == 20 + ) + ).first() + is not None + ) + assert mutate([{"workspace_id": "1", "custom_model": "30"}]).status_code == 200 + assert call(env) == 30 + + +def test_confirmed_revoke_retains_id_and_refuses_stale_preview(environment): + env = environment + assert save(env, True, "20").status_code == 200 + assert save(env, True, "20", workspace_id=2).status_code >= 400 + response = env.client.request( + "DELETE", + "/system/aimodel/20/ws_mapping", + json={ + "ws_ids": ["1"], + "resolutions": [{"workspace_id": "1", "custom_model": None}], + }, + ) + assert response.status_code == 200 + result = env.client.get("/system/workspace/1/model").json() + assert result["custom_model"] == "20" and result["model_valid"] is False + with pytest.raises(SingleMessageError): + call(env) + assert call(env, custom_model="30") == 30 + # Old clients cannot bypass the impact check by sending the legacy array. + assert ( + env.client.put("/system/aimodel/30/ws_mapping", json=["1"]).status_code == 200 + ) + + +def test_new_requests_see_changes_but_started_operation_keeps_its_model(environment): + env = environment + assert save(env, True, "20").status_code == 200 + with Session(env.database) as session: + service = asyncio.run( + llm.LLMService.create( + session, + SimpleNamespace(id=1, oid=1, language="en"), + ChatQuestion(chat_id=101, question="hello"), + None, + ) + ) + assert save(env, True, None).status_code == 200 + assert service.llm.invoke("hello").content == "ok" + assert env.calls == ["base-20"] + with pytest.raises(SingleMessageError): + call(env) + + +def test_model_list_and_resolution_use_same_explicit_authorization(environment): + env = environment + with Session(env.database) as session: + assert resolve_model_id(session, 1, TRANS) == 10 + with pytest.raises(SingleMessageError): + resolve_model_id(session, 1, TRANS, custom_model=10) + session.add(AiModelWorkspaceMapping(ai_model_id=10, workspace_id=1)) + session.commit() + assert resolve_model_id(session, 1, TRANS, custom_model=10) == 10 + + +def test_migration_preserves_old_rows_and_server_defaults_and_can_downgrade(): + migration_path = ( + Path(__file__).resolve().parents[1] + / "alembic/versions/073_workspace_model_settings.py" + ) + spec = importlib.util.spec_from_file_location( + "workspace_model_migration", migration_path + ) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + database = create_engine("sqlite://") + with database.begin() as connection: + connection.execute( + text( + "CREATE TABLE sys_workspace (id BIGINT PRIMARY KEY, name VARCHAR(255))" + ) + ) + connection.execute( + text("INSERT INTO sys_workspace (id, name) VALUES (1, 'existing')") + ) + migration.op = Operations(MigrationContext.configure(connection)) + migration.upgrade() + connection.execute( + text("INSERT INTO sys_workspace (id, name) VALUES (2, 'new')") + ) + assert connection.execute( + text( + "SELECT enable_custom_model, custom_model FROM sys_workspace ORDER BY id" + ) + ).all() == [(0, None), (0, None)] + migration.downgrade() + assert [ + item["name"] for item in inspect(connection).get_columns("sys_workspace") + ] == ["id", "name"] + assert ( + connection.execute(text("SELECT COUNT(*) FROM sys_workspace")).scalar() == 2 + ) + database.dispose() + + +def test_batch_revoke_requires_fresh_complete_confirmation(environment): + env = environment + assert save(env, True, "20").status_code == 200 + # A second workspace starts using this default after the first preview. + assert ( + env.client.post("/system/aimodel/20/ws_mapping", json=["2"]).status_code == 200 + ) + assert save(env, True, "20", workspace_id=2).status_code == 200 + request = { + "ws_ids": ["1", "2"], + "resolutions": [{"workspace_id": "1", "custom_model": "30"}], + } + assert ( + env.client.request( + "DELETE", "/system/aimodel/20/ws_mapping", json=request + ).status_code + >= 400 + ) + request["resolutions"].append({"workspace_id": "2", "custom_model": "30"}) + # Replacement 30 is not authorized to B: all workspaces/mappings stay intact. + assert ( + env.client.request( + "DELETE", "/system/aimodel/20/ws_mapping", json=request + ).status_code + >= 400 + ) + with Session(env.database) as session: + assert session.get(WorkspaceModel, 1).custom_model == "20" + assert session.get(WorkspaceModel, 2).custom_model == "20" + assert ( + len( + session.exec( + select(AiModelWorkspaceMapping).where( + AiModelWorkspaceMapping.ai_model_id == 20 + ) + ).all() + ) + == 2 + ) + + +def test_page_and_mcp_http_routes_use_policy_and_reject_without_provider_requests( + environment, +): + env = environment + assert save(env, True, "20").status_code == 200 + response = env.client.post( + "/chat/question", json={"chat_id": 101, "question": "hello"} + ) + assert response.status_code == 200 and '"model_id": 20' in response.text + payload = { + "token": "test-identity", + "chat_id": 101, + "question": "hello", + "stream": False, + "lang": "en", + "custom_model": "30", + } + response = env.client.post("/mcp/mcp_question", json=payload) + assert response.status_code == 200 and response.json()["model_id"] == 30 + payload["custom_model"] = "10" + response = env.client.post("/mcp/mcp_question", json=payload) + assert ( + response.status_code == 500 and "not authorized" in response.json()["message"] + ) + assert env.calls == ["base-20", "base-30"] + assert save(env, True, None).status_code == 200 + payload["custom_model"] = "30" + response = env.client.post("/mcp/mcp_question", json=payload) + assert response.status_code == 500 and "disabled" in response.json()["message"] + response = env.client.post( + "/chat/question", json={"chat_id": 101, "question": "hello"} + ) + assert '"type":"error"' in response.text and "disabled" in response.text + assert env.calls == ["base-20", "base-30"] + + +@pytest.mark.parametrize("entry", ["analysis", "predict", "recommend", "regenerate"]) +def test_followup_http_operations_revalidate_policy(environment, entry): + env = environment + with Session(env.database) as session: + session.add( + ChatRecord( + id=201, + chat_id=101, + create_by=1, + question="hello", + first_chat=False, + ai_modal_id=10, + chart="{}", + ) + ) + session.commit() + assert save(env, True, "20").status_code == 200 + + def request(): + if entry == "recommend": + return env.client.post("/chat/recommend_questions/201") + if entry == "regenerate": + return env.client.post( + "/mcp/mcp_question", + json={ + "token": "test-identity", + "chat_id": 101, + "question": "/regenerate", + "stream": False, + "lang": "en", + "custom_model": "30", + }, + ) + return env.client.post(f"/chat/record/201/{entry}") + + response = request() + assert response.status_code == 200 + assert env.calls == ["base-30" if entry == "regenerate" else "base-20"] + assert save(env, True, None).status_code == 200 + response = request() + assert "disabled" in response.text + assert len(env.calls) == 1 diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index 4d56fecb1..cd461fe54 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -1,4 +1,5 @@ import { request } from '@/utils/request' +import type { ModelImpactResolution } from './workspace' export const modelApi = { queryAll: (keyword?: string) => @@ -23,7 +24,8 @@ export const modelApi = { } return request.put('/system/aimodel', param) }, - delete: (id: number) => request.delete(`/system/aimodel/${id}`), + delete: (id: string | number, resolutions: ModelImpactResolution[] = []) => + request.delete(`/system/aimodel/${id}`, { data: resolutions }), query: (id: number) => request.get(`/system/aimodel/${id}`), setDefault: (id: number) => request.put(`/system/aimodel/default/${id}`), check: (data: any) => request.fetchStream('/system/aimodel/status', data), diff --git a/frontend/src/api/workspace.ts b/frontend/src/api/workspace.ts index 46637678c..9d092c72e 100644 --- a/frontend/src/api/workspace.ts +++ b/frontend/src/api/workspace.ts @@ -24,3 +24,40 @@ export const workspaceModelMappingAdd = (aiModelId: any, data: any) => request.post(`/system/aimodel/${aiModelId}/ws_mapping`, data) export const workspaceModelMappingDelete = (aiModelId: any, data: any) => request.delete(`/system/aimodel/${aiModelId}/ws_mapping`, { data }) + +export interface WorkspaceModelOption { + id: string + name: string + default_model: boolean + supplier: number +} + +export interface WorkspaceModelSettings { + enable_custom_model: boolean + custom_model: string | null +} + +export interface WorkspaceModelSettingsResult extends WorkspaceModelSettings { + models: WorkspaceModelOption[] + model_valid: boolean +} + +export interface ModelImpact { + workspace_id: string + workspace_name: string + uses_default: boolean + assistants: string[] + models: WorkspaceModelOption[] +} + +export interface ModelImpactResolution { + workspace_id: string + custom_model: string | null +} + +export const workspaceModelSettings = (id: string): Promise => + request.get(`/system/workspace/${id}/model`) +export const workspaceModelSettingsUpdate = (id: string, data: WorkspaceModelSettings) => + request.put(`/system/workspace/${id}/model`, data) +export const modelWorkspaceImpact = (id: string, wsIds?: string[]): Promise => + request.post(`/system/aimodel/${id}/workspace_impact`, wsIds ?? null) diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index c9ffcf5e3..783c6873f 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -1,4 +1,20 @@ { + "workspace_model": { + "title": "Model management", + "system": "Use system default model", + "custom": "Specify model", + "default": "Workspace default model", + "select": "Select an authorized model, or leave empty", + "invalid": "Model deleted or authorization revoked. Select another model or clear it.", + "empty_hint": "Leaving this empty disables model calls for this workspace, including assistants and MCP.", + "system_hint": "Use the system default model regardless of workspace authorization. Assistant defaults follow this setting.", + "impact_title": "Affected workspaces", + "impact_hint": "Choose a replacement workspace default, or keep the unavailable configuration. Calls without another valid authorized model will stop, with no fallback.", + "resolve": "Select a replacement or confirm suspension", + "pause": "Keep unavailable default and suspend default calls", + "assistant_impact": "Assistants {names} still specify this model. Their calls will fail until their model settings are updated.", + "assistant_default": "Follow workspace default model" + }, "menu": { "add_interface_credentials": "Please add interface credentials", "Data Q&A": "Data Q&A", diff --git a/frontend/src/i18n/ko-KR.json b/frontend/src/i18n/ko-KR.json index e00ef3b87..2aaff3fc8 100644 --- a/frontend/src/i18n/ko-KR.json +++ b/frontend/src/i18n/ko-KR.json @@ -1,4 +1,20 @@ { + "workspace_model": { + "title": "모델 관리", + "system": "시스템 기본 모델 사용", + "custom": "모델 지정", + "default": "워크스페이스 기본 모델", + "select": "승인된 모델 선택 또는 비워 두기", + "invalid": "모델이 삭제되었거나 승인이 취소되었습니다. 다시 선택하거나 비워 주세요.", + "empty_hint": "비워 두면 어시스턴트와 MCP를 포함한 이 워크스페이스의 모든 모델 호출이 차단됩니다.", + "system_hint": "워크스페이스 승인과 관계없이 시스템 기본 모델을 사용합니다. 어시스턴트 기본 모델도 이 설정을 따릅니다.", + "impact_title": "영향을 받는 워크스페이스", + "impact_hint": "다른 기본 모델을 선택하거나 사용할 수 없는 설정을 유지하세요. 유효한 승인 모델을 지정하지 않은 호출은 대체 없이 중단됩니다.", + "resolve": "대체 모델 선택 또는 중단 확인", + "pause": "사용 불가 기본 모델 유지 및 기본 호출 중단", + "assistant_impact": "어시스턴트 {names}은(는) 이 모델을 계속 지정합니다. 어시스턴트 모델 설정을 변경하기 전까지 호출이 실패합니다.", + "assistant_default": "워크스페이스 기본 모델 따르기" + }, "menu": { "add_interface_credentials": "인터페이스 자격 증명을 추가하십시오", "Data Q&A": "스마트 데이터 조회", diff --git a/frontend/src/i18n/th-TH.json b/frontend/src/i18n/th-TH.json index 3743b4c28..39a699c1e 100644 --- a/frontend/src/i18n/th-TH.json +++ b/frontend/src/i18n/th-TH.json @@ -1,4 +1,20 @@ { + "workspace_model": { + "title": "จัดการโมเดล", + "system": "ใช้โมเดลเริ่มต้นของระบบ", + "custom": "ระบุโมเดล", + "default": "โมเดลเริ่มต้นของเวิร์กสเปซ", + "select": "เลือกโมเดลที่ได้รับอนุญาต หรือเว้นว่าง", + "invalid": "โมเดลถูกลบหรือถูกเพิกถอนสิทธิ์ โปรดเลือกใหม่หรือล้างค่า", + "empty_hint": "หากเว้นว่าง ระบบจะปิดการเรียกโมเดลทั้งหมดในเวิร์กสเปซนี้ รวมถึงผู้ช่วยและ MCP", + "system_hint": "ใช้โมเดลเริ่มต้นของระบบโดยไม่ขึ้นกับสิทธิ์ของเวิร์กสเปซ โมเดลเริ่มต้นของผู้ช่วยจะใช้การตั้งค่านี้ด้วย", + "impact_title": "เวิร์กสเปซที่ได้รับผลกระทบ", + "impact_hint": "เลือกโมเดลเริ่มต้นใหม่ หรือยืนยันเก็บการตั้งค่าที่ใช้ไม่ได้ การเรียกที่ไม่ได้ระบุโมเดลอื่นที่มีสิทธิ์จะหยุดโดยไม่มีการใช้โมเดลสำรอง", + "resolve": "เลือกโมเดลใหม่หรือยืนยันระงับ", + "pause": "เก็บค่าเริ่มต้นที่ใช้ไม่ได้และระงับการเรียกแบบเริ่มต้น", + "assistant_impact": "ผู้ช่วย {names} ยังคงระบุโมเดลนี้ การเรียกจะล้มเหลวจนกว่าจะเปลี่ยนการตั้งค่าโมเดลของผู้ช่วย", + "assistant_default": "ใช้โมเดลเริ่มต้นของเวิร์กสเปซ" + }, "menu": { "add_interface_credentials": "โปรดเพิ่มข้อมูลรับรอง API", "Data Q&A": "สอบถามข้อมูลอัจฉริยะ", diff --git a/frontend/src/i18n/zh-CN.json b/frontend/src/i18n/zh-CN.json index 8aff119d9..403ef517b 100644 --- a/frontend/src/i18n/zh-CN.json +++ b/frontend/src/i18n/zh-CN.json @@ -1,4 +1,20 @@ { + "workspace_model": { + "title": "模型管理", + "system": "使用系统默认模型", + "custom": "指定模型", + "default": "空间默认模型", + "select": "选择已授权模型,可留空", + "invalid": "模型已删除或取消授权,请重新选择或清空", + "empty_hint": "留空将禁止该空间所有问数入口的模型调用,包括助手和 MCP。", + "system_hint": "使用系统默认模型,允许穿透工作空间授权;助手默认模型同步跟随。", + "impact_title": "受影响的工作空间", + "impact_hint": "更换空间默认模型,或确认保留失效配置。未指定其他有效授权模型的调用将暂停,不会回退。", + "resolve": "请选择替代模型或确认暂停", + "pause": "确认默认模型失效并暂停默认调用", + "assistant_impact": "助手 {names} 仍指定此模型,后续调用将报错,需另行修改助手配置。", + "assistant_default": "跟随工作空间默认模型" + }, "menu": { "add_interface_credentials": "请添加接口凭证", "Data Q&A": "智能问数", diff --git a/frontend/src/i18n/zh-TW.json b/frontend/src/i18n/zh-TW.json index 41b93e23a..565305c1b 100644 --- a/frontend/src/i18n/zh-TW.json +++ b/frontend/src/i18n/zh-TW.json @@ -1,4 +1,20 @@ { + "workspace_model": { + "title": "模型管理", + "system": "使用系統預設模型", + "custom": "指定模型", + "default": "空間預設模型", + "select": "選擇已授權模型,可留空", + "invalid": "模型已刪除或取消授權,請重新選擇或清空", + "empty_hint": "留空將禁止該空間所有問數入口的模型呼叫,包括助手和 MCP。", + "system_hint": "使用系統預設模型,允許穿透工作空間授權;助手預設模型同步跟隨。", + "impact_title": "受影響的工作空間", + "impact_hint": "更換空間預設模型,或確認保留失效設定。未指定其他有效授權模型的呼叫將暫停,不會回退。", + "resolve": "請選擇替代模型或確認暫停", + "pause": "確認預設模型失效並暫停預設呼叫", + "assistant_impact": "助手 {names} 仍指定此模型,後續呼叫將報錯,需另行修改助手設定。", + "assistant_default": "跟隨工作空間預設模型" + }, "menu": { "add_interface_credentials": "請新增介面憑證", "Data Q&A": "智慧問數", diff --git a/frontend/src/views/chat/ChatListContainer.vue b/frontend/src/views/chat/ChatListContainer.vue index f39bc300c..ad85250a8 100644 --- a/frontend/src/views/chat/ChatListContainer.vue +++ b/frontend/src/views/chat/ChatListContainer.vue @@ -124,10 +124,10 @@ const createNewChat = async () => { await chatApi.checkLLMModel() } catch (error: any) { console.error(error) - let errorMsg = t('model.default_miss') + const errorMsg = + typeof error.response?.data === 'string' ? error.response.data : t('model.default_miss') let confirm_text = t('datasource.got_it') if (userStore.isAdmin) { - errorMsg = t('model.default_miss_admin') confirm_text = t('model.to_config') } ElMessageBox.confirm(t('qa.ask_failed'), { @@ -141,7 +141,7 @@ const createNewChat = async () => { showClose: false, callback: (val: string) => { if (userStore.isAdmin && val === 'confirm') { - router.push('/system/model') + router.push('/system/workspace') } }, }) diff --git a/frontend/src/views/chat/index.vue b/frontend/src/views/chat/index.vue index b054e2759..d34d2d18b 100644 --- a/frontend/src/views/chat/index.vue +++ b/frontend/src/views/chat/index.vue @@ -650,10 +650,10 @@ const createNewChat = async () => { await chatApi.checkLLMModel() } catch (error: any) { console.error(error) - let errorMsg = t('model.default_miss') + const errorMsg = + typeof error.response?.data === 'string' ? error.response.data : t('model.default_miss') let confirm_text = t('datasource.got_it') if (userStore.isAdmin) { - errorMsg = t('model.default_miss_admin') confirm_text = t('model.to_config') } ElMessageBox.confirm(t('qa.ask_failed'), { @@ -667,7 +667,7 @@ const createNewChat = async () => { showClose: false, callback: (val: string) => { if (userStore.isAdmin && val === 'confirm') { - router.push('/system/model') + router.push('/system/workspace') } }, }) diff --git a/frontend/src/views/ds/Datasource.vue b/frontend/src/views/ds/Datasource.vue index aba06f681..cebc977eb 100644 --- a/frontend/src/views/ds/Datasource.vue +++ b/frontend/src/views/ds/Datasource.vue @@ -90,10 +90,10 @@ const handleQuestion = async (id: string) => { await chatApi.checkLLMModel() } catch (error: any) { console.error(error) - let errorMsg = t('model.default_miss') + const errorMsg = + typeof error.response?.data === 'string' ? error.response.data : t('model.default_miss') let confirm_text = t('datasource.got_it') if (userStore.isAdmin) { - errorMsg = t('model.default_miss_admin') confirm_text = t('model.to_config') } ElMessageBox.confirm(t('qa.ask_failed'), { @@ -107,7 +107,7 @@ const handleQuestion = async (id: string) => { showClose: false, callback: (val: string) => { if (userStore.isAdmin && val === 'confirm') { - router.push('/system/model') + router.push('/system/workspace') } }, }) diff --git a/frontend/src/views/system/embedded/iframe.vue b/frontend/src/views/system/embedded/iframe.vue index 2191f11df..b31555421 100644 --- a/frontend/src/views/system/embedded/iframe.vue +++ b/frontend/src/views/system/embedded/iframe.vue @@ -832,7 +832,7 @@ const saveHandler = () => { - {{ t('embedded.defaultModel') }} + {{ t('workspace_model.assistant_default') }} {{ t('embedded.customModel') }} diff --git a/frontend/src/views/system/model/Model.vue b/frontend/src/views/system/model/Model.vue index 47da0cd49..00c964d64 100644 --- a/frontend/src/views/system/model/Model.vue +++ b/frontend/src/views/system/model/Model.vue @@ -18,6 +18,7 @@ import { get_supplier } from '@/entity/supplier' import { highlightKeyword } from '@/utils/xss' import AuthorizedWorkspaceDialogForModel from '@/views/system/workspace/AuthorizedWorkspaceDialogForModel.vue' import AuthorizedWorkspaceDraw from '@/views/system/workspace/AuthorizedWorkspaceDraw.vue' +import ModelImpactDialog from '@/views/system/workspace/ModelImpactDialog.vue' interface Model { ws_mapping_count: number | undefined @@ -42,6 +43,7 @@ const activeType = ref('') const modelFormRef = ref() const cardRefs = ref([]) const showCardError = ref(false) // if you don`t want card mask error, just change this to false +const modelImpactDialog = ref>() reactive({ form: { id: '', @@ -238,9 +240,11 @@ const deleteHandler = (item: any) => { cancelButtonText: t('common.cancel'), customClass: 'confirm-no_icon', autofocus: false, - callback: (value: string) => { + callback: async (value: string) => { if (value === 'confirm') { - modelApi.delete(item.id).then(() => { + const resolutions = await modelImpactDialog.value!.confirm(String(item.id)) + if (resolutions === null) return + modelApi.delete(item.id, resolutions).then(() => { ElMessage({ type: 'success', message: t('dashboard.delete_success'), @@ -486,6 +490,7 @@ const submit = (item: any) => { + diff --git a/frontend/src/views/system/workspace/ModelSettingsDialog.vue b/frontend/src/views/system/workspace/ModelSettingsDialog.vue new file mode 100644 index 000000000..93eadea8a --- /dev/null +++ b/frontend/src/views/system/workspace/ModelSettingsDialog.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/frontend/src/views/system/workspace/index.vue b/frontend/src/views/system/workspace/index.vue index 303c888e6..9c44e1cb1 100644 --- a/frontend/src/views/system/workspace/index.vue +++ b/frontend/src/views/system/workspace/index.vue @@ -16,6 +16,7 @@ import EmptyBackground from '@/views/dashboard/common/EmptyBackground.vue' import icon_form_outlined from '@/assets/svg/icon_moments-categories_outlined.svg' import assigned from '@/assets/svg/icon_assigned_outlined.svg' import AuthorizedWorkspaceDialog from './AuthorizedWorkspaceDialog.vue' +import ModelSettingsDialog from './ModelSettingsDialog.vue' import rename from '@/assets/svg/icon_rename_outlined.svg' import icon_member from '@/assets/svg/icon_member.svg' import SuccessFilled from '@/assets/svg/gou_icon.svg' @@ -37,6 +38,7 @@ const keywordsMember = ref('') const oldKeywordsMember = ref('') const workspaceFormRef = ref() const authorizedWorkspaceDialog = ref() +const modelSettingsDialog = ref>() const tableListWithSearch = computed(() => { if (!keyword.value) return tableList.value return tableList.value.filter((ele) => @@ -407,25 +409,30 @@ const handleCurrentChange = (val: number) => { {{ pageInfo.total }}
- - - {{ $t('workspace.add_member') }} + + {{ t('workspace_model.title') }} - - - +
+ + + {{ $t('workspace.add_member') }} + + + + +
{ }}
+