Răsfoiți Sursa

支持 Bot 独立模型设置并在所有会话下一轮统一应用

Samien 1 lună în urmă
părinte
comite
287ed83233

+ 21 - 0
docs/verification/2026-09-20-bot-model-settings.md

@@ -0,0 +1,21 @@
+# Bot 模型设置验证
+
+设置由 Bot 持有,`provider` 与 `model` 成对为空表示继承。`reasoningEffort`、`fastMode` 分别允许继承;`none` 表示关闭思考,速度保留 `normal / fast / auto / cold`。资料保存使用 `expectedRevision` 防止覆盖其他端的新版本;旧客户端没有提交 `modelSettings` 时保留原值。
+
+Web/桌面端在 Bot 个人资料新增“模型设置”,iOS 和 Android 在资料编辑器中提供相同入口。保存不改变 Profile,也不打断当前轮次。各个会话、群聊、队列及定时任务共用的执行入口在下一轮获取 Bot 快照,检查模型、思考和速度的会话配置回执;运行检查记录保存资料版本、有效值及应用结果。
+
+断线和重启恢复只追踪原轮次。恢复继承会覆盖已有会话的旧设置。原生模型费用确认绑定 Provider 与模型;长上下文切换产生的额外确认保存在 Bot 中,回到资料保存流程确认后才能重试。
+
+## 验证结果
+
+- 工具桥 Python 测试:33 项通过。涵盖模型目录、四种速度、关闭思考、Provider 路由、能力未知、配置错误、凭据隔离及安装兼容。
+- 本机已安装 Hermes 只读检查:真实模型目录读取和默认配置解析成功,没有修改 Profile 或安装插件。
+- Web 与服务端类型检查通过。
+- 新增服务端、运行链路与 Vue 用例通过:同一 Bot 的旧单聊和两个群聊、同 Profile 的 Bot 隔离、正在回复时保存、排队下一轮、恢复继承、恢复不重发、原生配置失败不提交、费用确认、版本冲突、草稿保护、保存期间收到新版本、旧桥禁用编辑、切换基础 Profile 后重新加载。
+- 相关完整测试套件有已存在的失败:`workspace.test.ts` 两项上下文百分比断言、`workspaceRoutes.test.ts` 一项已停用远端来源断言、`botProfileDialog.test.ts` 一项旧资料字段断言。已在修改前的 HEAD 副本中复现同样 4 项失败。
+- iOS:编译、76 项 WorkspaceChat 单元测试及 1 项资料页 UI 测试通过。验证全部速度的协议和快照、明确空值、确认目标与版本、`agent.changed`、保存及重入回显、未保存草稿关闭保护。既有 250 ms 延迟用例曾出现计时失败,最终复跑通过。
+- Android:编译、core:model/core:network 测试通过;2 项模拟器 UI 测试通过,覆盖保存和回显、未保存草稿保护、另一端保存后的草稿保留、禁用旧版本保存和重新加载。
+
+## 发布
+
+工具桥版本为 1.3.0,模型协议版本为 1。先更新工具桥和服务端,在空闲时重启 Hermes Dashboard,再更新客户端。本次没有部署或重启实际服务。管理员继续负责模型服务和密钥,Bot 编辑权限只控制该 Bot 的选择。

+ 6 - 0
integrations/hermes-bots-bridge/README.md

@@ -45,6 +45,8 @@ Runner 包内可直接运行同目录的 `install-hermes-bridge.py`。安装器
 前缀:`/api/plugins/yaoyao-bot-bridge`。
 
 - `GET /capabilities?profile=...`:检查对应 Profile 及托管能力。
+- `GET /model-options?profile=...`:读取 Profile 的模型目录、默认值、思考能力与速度能力;只返回公开配置,不返回凭据。
+- `POST /model-settings/resolve`:接收 `profile` 与 `settings`,通过 Hermes 原生解析器校验完整组合,返回版本 1 的有效配置和模型确认提示;不写 Profile 配置。
 - `POST /bind`:绑定真实 Hermes 会话、Profile 和当前轮次;最多 30 分钟,支持同轮续租。
 - `POST /unbind`:只撤销匹配的会话与轮次。
 - `POST /computer-file`:当前运行轮次的文件传输;验证 HTTP 身份、会话、轮次、Profile、文件保护规则,最多 25 MiB。请求不能选择其他 Profile。
@@ -83,3 +85,7 @@ CLI 增加 `--check` 可只读检查默认及命名 Profile;`--enable` 明确
 1.2.3 将技能复盘与记忆隔离分开:`skill_learning: true` 表示插件允许受限的原生技能复盘。不会强制每次对话创建技能,也不会另建发布流程。
 
 1.2.4 修复非默认 Profile 在工具桥绑定、工具目录刷新及子代理继承时误读默认 Profile 工具目录的问题。目录重建使用绑定会话的 Profile,完成或失败后恢复调用方上下文。更新后需重启 Hermes Dashboard 服务,再发起新一轮对话。
+
+1.3.0 增加 Bot 个人资料的模型设置协议(`model_settings_version: 1`)。模型与 Provider 成对选择,思考等级和 `normal / fast / auto / cold` 速度可分别继承。Web 服务在每轮提交前解析配置,并通过原生 `config.set` 按模型、思考、速度的顺序应用到当前 Bot 会话;保存资料不修改 Profile 默认值,也不打断正在运行的回复。恢复中的原轮次只继续追踪,不重新设置或提交。模型目录未声明具体等级时返回 `reasoningKnown: false`;快速能力同时检查模型和 Provider 路由。
+
+发布时先更新工具桥与 Web 服务,在空闲时重启 Hermes Dashboard,再发布 Web/桌面端、iOS 和 Android 客户端。旧桥不能编辑新增设置,会明确提示升级;未设置 `modelSettings` 的旧 Bot 继续按原有继承路径运行。原生会话配置回执不兼容、模型无效或应用失败会停止本轮,不使用旧配置继续运行。

+ 2 - 2
integrations/hermes-bots-bridge/bridge_runtime.py

@@ -28,7 +28,7 @@ from pathlib import Path
 from typing import Any
 
 VERSION = 1
-PLUGIN_VERSION = "1.2.4"
+PLUGIN_VERSION = "1.3.0"
 def _plugin_fingerprint():
     root = Path(__file__).parent
     digest = hashlib.sha256()
@@ -355,7 +355,7 @@ def capabilities(profile: str = "default") -> dict:
             return {"version": VERSION, "ready": True, "in_process": True, "profile": profile, "native_tools": True,
                     "computer_runtime_version": 2, "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT,
                     "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True,
-                    "isolated_context_files": True, "skill_learning": True}
+                    "isolated_context_files": True, "skill_learning": True, "model_settings_version": 1}
         finally:
             reset_hermes_home_override(context)
     except Exception as exc:

+ 30 - 0
integrations/hermes-bots-bridge/dashboard/plugin_api.py

@@ -17,6 +17,36 @@ _bootstrap = importlib.util.module_from_spec(_spec)
 _spec.loader.exec_module(_bootstrap)
 bridge = _bootstrap.runtime()
 router = APIRouter()
+_model_spec = importlib.util.spec_from_file_location("_yaoyao_bot_model_settings", Path(__file__).parents[1] / "model_settings.py")
+_models = importlib.util.module_from_spec(_model_spec)
+_model_spec.loader.exec_module(_models)
+
+
+@router.get("/model-options")
+async def model_options(request: Request, profile: str = "default"):
+    try:
+        bridge.request_identity(request)
+        return await asyncio.to_thread(_models.options, profile)
+    except bridge.BridgeError as exc:
+        return _error(exc)
+    except _models.ModelSettingsError as exc:
+        return JSONResponse(status_code=400, content={"detail": str(exc), "code": "invalid_model_settings"})
+    except Exception:
+        return JSONResponse(status_code=503, content={"detail": "模型工具桥与 Hermes 不兼容或模型服务暂不可用,请检查并重试", "code": "model_settings_unavailable"})
+
+
+@router.post("/model-settings/resolve")
+async def resolve_model_settings(request: Request):
+    try:
+        bridge.request_identity(request)
+        body = await _body(request)
+        return await asyncio.to_thread(_models.resolve, body.get("profile"), body.get("settings"))
+    except bridge.BridgeError as exc:
+        return _error(exc)
+    except _models.ModelSettingsError as exc:
+        return JSONResponse(status_code=400, content={"detail": str(exc), "code": "invalid_model_settings"})
+    except Exception:
+        return JSONResponse(status_code=503, content={"detail": "无法解析 Bot 模型配置,请检查工具桥与模型服务", "code": "model_settings_unavailable"})
 
 
 def _error(exc):

+ 129 - 0
integrations/hermes-bots-bridge/model_settings.py

@@ -0,0 +1,129 @@
+"""Read-only Bot model catalogue and runtime resolution. Never writes Profile config."""
+from contextlib import contextmanager
+import re
+
+
+class ModelSettingsError(Exception):
+    pass
+
+
+@contextmanager
+def profile_scope(profile):
+    from hermes_cli.profiles import get_profile_dir, profile_exists
+    from hermes_constants import set_hermes_home_override, reset_hermes_home_override
+    if not isinstance(profile, str) or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", profile) or not profile_exists(profile):
+        raise ModelSettingsError("基础机器人不存在")
+    token = set_hermes_home_override(get_profile_dir(profile))
+    try:
+        yield
+    finally:
+        reset_hermes_home_override(token)
+
+
+def defaults(ctx, config):
+    agent = config.get("agent") or {}
+    effort = agent.get("reasoning_effort", "medium")
+    tier = agent.get("service_tier") or "normal"
+    return {"provider": ctx.current_provider, "model": ctx.current_model,
+            "reasoningEffort": "none" if effort is False else str(effort or "medium"),
+            "fastMode": {"priority": "fast"}.get(tier, tier)}
+
+
+def route_url(ctx, provider):
+    if provider == ctx.current_provider:
+        return ctx.current_base_url
+    entry = (ctx.user_providers or {}).get(provider)
+    if isinstance(entry, dict):
+        return str(entry.get("base_url") or "")
+    for entry in ctx.custom_providers or []:
+        if provider in (entry.get("name"), entry.get("id")):
+            return str(entry.get("base_url") or "")
+    return ""
+
+
+def capability(provider, model, base_url="", hint=None):
+    from hermes_constants import VALID_REASONING_EFFORTS
+    from hermes_cli.models import resolve_fast_mode_overrides
+    hint = hint or {}
+    known = hint.get("reasoning") is False
+    reasoning = hint.get("reasoning", True)
+    try:
+        from agent.models_dev import get_model_capabilities
+        meta = get_model_capabilities(provider, model)
+        if meta is not None:
+            known = not meta.supports_reasoning or known
+            reasoning = bool(meta.supports_reasoning) and reasoning
+    except Exception:
+        pass
+    efforts = (["none"] if hint.get("can_disable_reasoning", True) else []) + list(VALID_REASONING_EFFORTS) if reasoning else []
+    if provider == "copilot":
+        from hermes_cli.models import github_model_reasoning_efforts
+        declared = github_model_reasoning_efforts(model)
+        if declared:
+            efforts, known = list(declared), True
+    fast = resolve_fast_mode_overrides(model, provider=provider, base_url=base_url) is not None
+    return {"provider": provider, "model": model, "name": model,
+            "reasoningEfforts": efforts, "reasoningKnown": known,
+            "fastModes": ["normal", "fast", "auto", "cold"] if fast else ["normal"]}
+
+
+def options(profile):
+    from hermes_cli.config import load_config
+    from hermes_cli.inventory import load_picker_context, build_model_options_payload
+    with profile_scope(profile):
+        ctx = load_picker_context()
+        payload = build_model_options_payload(ctx, explicit_only=True)
+        rows = []
+        for provider in payload.get("providers", []):
+            slug = provider["slug"]
+            for model in provider.get("models", []):
+                if isinstance(model, str):
+                    rows.append(capability(slug, model, route_url(ctx, slug), (provider.get("capabilities") or {}).get(model)))
+        return {"version": 1, "defaults": defaults(ctx, load_config()), "models": rows}
+
+
+def resolve(profile, settings):
+    from hermes_cli.config import load_config
+    from hermes_cli.inventory import load_picker_context, _reasoning_catalog_reader
+    from hermes_cli.model_switch import switch_model
+    from hermes_cli.model_selection_guards import combined_selection_warning
+    from hermes_constants import parse_reasoning_effort
+    if settings is None:
+        settings = {}
+    if not isinstance(settings, dict) or set(settings) - {"provider", "model", "reasoningEffort", "fastMode"}:
+        raise ModelSettingsError("模型配置格式无效")
+    with profile_scope(profile):
+        ctx, config = load_picker_context(), load_config()
+        value = defaults(ctx, config)
+        value.update({key: item for key, item in settings.items() if item is not None})
+        if bool(settings.get("provider")) != bool(settings.get("model")):
+            raise ModelSettingsError("模型与 Provider 必须一起选择")
+        for key in ("model", "provider"):
+            item = value[key]
+            if not isinstance(item, str) or not item or len(item) > 512 or re.search(r"[\s\"'\\]", item) or item.startswith("-"):
+                raise ModelSettingsError("基础机器人尚未配置有效的模型与 Provider")
+        model_config = config.get("model") or {}
+        current_key = model_config.get("api_key", "") if isinstance(model_config, dict) else ""
+        result = switch_model(raw_input=value["model"], current_provider=ctx.current_provider,
+                              current_model=ctx.current_model, current_base_url=ctx.current_base_url,
+                              current_api_key=current_key, is_global=False, explicit_provider=value["provider"],
+                              user_providers=ctx.user_providers, custom_providers=ctx.custom_providers)
+        if not result.success:
+            # Provider errors may include secrets/URLs; keep this boundary deliberately generic.
+            raise ModelSettingsError("所选模型或 Provider 当前不可用,请检查基础机器人的模型服务")
+        value.update(provider=result.target_provider, model=result.new_model)
+        hint = {}
+        reader = _reasoning_catalog_reader(result.target_provider)
+        detail = reader(result.new_model) if reader else None
+        if detail:
+            hint = {"reasoning": detail.get("supports_reasoning", True), "can_disable_reasoning": not detail.get("mandatory")}
+        caps = capability(value["provider"], value["model"], result.base_url or route_url(ctx, value["provider"]), hint)
+        if not isinstance(value["reasoningEffort"], str) or parse_reasoning_effort(value["reasoningEffort"]) is None:
+            raise ModelSettingsError("思考等级无效")
+        if settings.get("reasoningEffort") is not None and value["reasoningEffort"] not in caps["reasoningEfforts"]:
+            raise ModelSettingsError("该模型不支持所选思考等级,请重新选择或继承基础机器人")
+        if value["fastMode"] not in caps["fastModes"]:
+            raise ModelSettingsError("该模型或 Provider 不支持所选速度,请重新选择普通速度")
+        warning = combined_selection_warning(value["model"], provider=value["provider"],
+                                             base_url=result.base_url, api_key=result.api_key, model_info=result.model_info)
+        return {"version": 1, "effective": value, "confirmationMessage": warning.message if warning else None}

+ 1 - 1
integrations/hermes-bots-bridge/plugin.yaml

@@ -1,4 +1,4 @@
 name: yaoyao-bot-bridge
-version: "1.2.5"
+version: "1.3.0"
 description: "夭夭 Bot 每轮授权的电脑、浏览器、手机、团队与应用工具桥"
 author: "Samien"

+ 86 - 0
integrations/hermes-bots-bridge/tests/test_model_settings.py

@@ -0,0 +1,86 @@
+import importlib.util
+import json
+from contextlib import nullcontext
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+import unittest
+
+spec = importlib.util.spec_from_file_location('bot_model_settings_test', Path(__file__).parents[1] / 'model_settings.py')
+models = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(models)
+
+
+class BotModelSettingsTests(unittest.TestCase):
+    def setUp(self):
+        self.config = {"agent": {"reasoning_effort": "medium", "service_tier": "auto"}, "model": {"api_key": "fixture-secret"}}
+        self.ctx = SimpleNamespace(current_provider='openai', current_model='model-a', current_base_url='https://api.openai.com/v1', user_providers={}, custom_providers=[])
+        self.switch = Mock(side_effect=lambda **kw: SimpleNamespace(success=True, target_provider=kw['explicit_provider'], new_model=kw['raw_input'], base_url=self.ctx.current_base_url, api_key='fixture-secret', model_info=None))
+        self.warning = Mock(return_value=None)
+        self.meta = Mock(return_value=SimpleNamespace(supports_reasoning=True))
+        self.fast = Mock(side_effect=lambda model, **kw: {'service_tier': 'priority'} if kw['base_url'].startswith('https://api.openai.com/') else None)
+        self.reader = Mock(return_value=None)
+        modules = {
+            'hermes_constants': SimpleNamespace(VALID_REASONING_EFFORTS=['low','medium','high','ultra'], parse_reasoning_effort=lambda value: {} if value in ['none','low','medium','high','ultra'] else None),
+            'hermes_cli.config': SimpleNamespace(load_config=lambda: self.config),
+            'hermes_cli.inventory': SimpleNamespace(load_picker_context=lambda: self.ctx, _reasoning_catalog_reader=lambda _: self.reader,
+                build_model_options_payload=lambda *a, **kw: {'providers': [{'slug':'openai','models':['model-a'], 'capabilities': {'model-a': {'reasoning': True}}}]}),
+            'hermes_cli.models': SimpleNamespace(resolve_fast_mode_overrides=self.fast, github_model_reasoning_efforts=lambda _: ['low','high']),
+            'hermes_cli.model_switch': SimpleNamespace(switch_model=self.switch),
+            'hermes_cli.model_selection_guards': SimpleNamespace(combined_selection_warning=self.warning),
+            'agent.models_dev': SimpleNamespace(get_model_capabilities=self.meta),
+        }
+        self.scoped = patch.object(models, 'profile_scope', side_effect=lambda _: nullcontext())
+        self.modules = patch.dict('sys.modules', modules)
+        self.scoped.start(); self.modules.start()
+        self.addCleanup(self.scoped.stop); self.addCleanup(self.modules.stop)
+
+    def test_catalogue_preserves_defaults_modes_and_does_not_disclose_credentials(self):
+        value = models.options('default')
+        self.assertEqual(value['defaults']['fastMode'], 'auto')
+        self.assertEqual(value['models'][0]['fastModes'], ['normal','fast','auto','cold'])
+        self.assertNotIn('fixture-secret', json.dumps(value))
+
+    def test_inheritance_and_explicit_none_are_distinct_and_resolution_never_writes(self):
+        value = models.resolve('default', {'provider': None, 'model': None, 'reasoningEffort': 'none', 'fastMode': 'cold'})
+        self.assertEqual(value['effective'], {'provider':'openai','model':'model-a','reasoningEffort':'none','fastMode':'cold'})
+        self.assertFalse(self.switch.call_args.kwargs['is_global'])
+        self.assertEqual(self.switch.call_args.kwargs['current_api_key'], 'fixture-secret')
+        self.assertNotIn('fixture-secret', json.dumps(value))
+        self.config['agent'].update(reasoning_effort=False, service_tier='normal')
+        self.assertEqual(models.resolve('default', None)['effective']['reasoningEffort'], 'none')
+
+    def test_proxy_route_rejects_all_accelerated_modes(self):
+        self.ctx.current_base_url = 'https://proxy.invalid/v1'
+        for speed in ['fast','auto','cold']:
+            with self.subTest(speed=speed), self.assertRaises(models.ModelSettingsError):
+                models.resolve('default', {'fastMode': speed})
+        self.assertEqual(models.resolve('default', {'fastMode':'normal'})['effective']['fastMode'], 'normal')
+
+    def test_copilot_and_mandatory_reasoning_restrict_levels(self):
+        with self.assertRaises(models.ModelSettingsError):
+            models.resolve('default', {'provider':'copilot','model':'model-a','reasoningEffort':'ultra'})
+        self.reader.return_value = {'supports_reasoning':True,'mandatory':True}
+        with self.assertRaises(models.ModelSettingsError):
+            models.resolve('default', {'reasoningEffort':'none'})
+
+    def test_unknown_capabilities_remain_unknown(self):
+        self.meta.return_value = None
+        value = models.options('default')['models'][0]
+        self.assertFalse(value['reasoningKnown'])
+        self.assertIn('ultra', value['reasoningEfforts'])
+
+    def test_invalid_pair_and_switch_failure_stop_resolution_without_secrets(self):
+        for settings in [{'model':'model-b'}, {'provider':'openai','model':'bad --global'}, {'fastMode':'turbo'}, {'config':{}}]:
+            with self.subTest(settings=settings), self.assertRaises(models.ModelSettingsError): models.resolve('default',settings)
+        self.switch.side_effect = None
+        self.switch.return_value = SimpleNamespace(success=False, error_message='fixture-secret')
+        with self.assertRaises(models.ModelSettingsError) as exc: models.resolve('default',None)
+        self.assertNotIn('fixture-secret', str(exc.exception))
+
+    def test_confirmation_is_returned_without_applying_any_change(self):
+        self.warning.return_value = SimpleNamespace(message='模型费用较高')
+        self.assertEqual(models.resolve('default',{})['confirmationMessage'], '模型费用较高')
+
+
+if __name__ == '__main__': unittest.main()

+ 99 - 0
src/client/components/workspace/BotModelSettingsPanel.vue

@@ -0,0 +1,99 @@
+<script setup lang="ts">
+import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
+import { apiRequest } from '@/api/client'
+import type { WorkspaceAgent } from '@shared/workspace'
+import { botFastLabels, botReasoningLabels, inheritedBotModelSettings, type BotModelOptions, type BotModelSettings } from '@shared/botModelSettings'
+const props = defineProps<{ agent: WorkspaceAgent }>()
+const emit = defineEmits<{ saved: []; dirtyChange: [dirty: boolean]; busyChange: [busy: boolean] }>()
+const options = ref<BotModelOptions>(), draft = ref(inheritedBotModelSettings()), baseline = ref('')
+const loading = ref(false), saving = ref(false), error = ref(''), notice = ref(''), changedElsewhere = ref(false)
+let generation = 0
+const dirty = computed(() => !!baseline.value && JSON.stringify(draft.value) !== baseline.value)
+watch(dirty, value => emit('dirtyChange', value), { immediate: true })
+watch(saving, value => emit('busyChange', value), { flush: 'sync' })
+const key = (provider: string, model: string) => JSON.stringify([provider, model])
+const modelKey = computed({ get: () => draft.value.model && draft.value.provider ? key(draft.value.provider, draft.value.model) : '', set: (value: string) => {
+  const pair = value ? JSON.parse(value) as [string, string] : [null, null]
+  draft.value = { ...draft.value, provider: pair[0]!, model: pair[1]! }
+} })
+const selected = computed(() => options.value?.models.find(item => item.provider === (draft.value.provider ?? options.value?.defaults.provider) && item.model === (draft.value.model ?? options.value?.defaults.model)))
+const invalid = computed(() => draft.value.model && !selected.value ? '所选模型已不可用,请重新选择或继承基础机器人。'
+  : draft.value.reasoningEffort && !selected.value?.reasoningEfforts.includes(draft.value.reasoningEffort) ? '该模型不支持所选思考等级,请重新选择或继承。'
+  : draft.value.fastMode && !selected.value?.fastModes.includes(draft.value.fastMode) ? '该模型不支持所选速度,请重新选择或继承。' : '')
+async function load() {
+  const current = ++generation
+  loading.value = true; options.value = undefined; error.value = ''; notice.value = ''
+  try {
+    const value = await apiRequest<BotModelOptions>(`/api/app/agents/${props.agent.id}/model-options`)
+    if (current !== generation) return
+    if (value.version !== 1) throw new Error('请升级模型工具桥后重试。')
+    options.value = value; draft.value = { ...inheritedBotModelSettings(), ...value.settings }
+    baseline.value = JSON.stringify(draft.value); changedElsewhere.value = false
+  } catch (cause) { if (current === generation) error.value = cause instanceof Error ? cause.message : '模型设置加载失败' }
+  finally { if (current === generation) loading.value = false }
+}
+async function save() {
+  if (!options.value || invalid.value || saving.value) return
+  saving.value = true; error.value = ''; notice.value = ''
+  const settings: BotModelSettings = { ...draft.value }
+  const body = { modelSettings: { ...settings }, expectedRevision: options.value.revision }
+  type Result = { agent?: WorkspaceAgent; confirmationRequired?: boolean; confirmationMessage?: string; confirmationTarget?: string }
+  try {
+    let result = await apiRequest<Result>(`/api/app/agents/${props.agent.id}`, { method: 'PATCH', body })
+    if (result.confirmationRequired) {
+      if (!window.confirm(result.confirmationMessage || '确认使用此模型?')) return
+      if (!result.confirmationTarget) throw new Error('模型确认响应无效,请重新加载。')
+      result = await apiRequest<Result>(`/api/app/agents/${props.agent.id}`, { method: 'PATCH', body: { ...body, confirmedModel: result.confirmationTarget } })
+    }
+    if (!result.agent) throw new Error('模型设置尚未保存,请重新加载后重试。')
+    options.value.revision = result.agent.revision; options.value.settings = result.agent.modelSettings ?? null
+    baseline.value = JSON.stringify(settings); changedElsewhere.value = false
+    notice.value = '已保存,从下一轮开始使用。'; emit('saved')
+  } catch (cause) { error.value = cause instanceof Error ? cause.message : '模型设置保存失败' }
+  finally { saving.value = false }
+}
+watch(() => [props.agent.revision, saving.value] as const, ([revision]) => {
+  if (!options.value || saving.value || revision <= options.value.revision) return
+  if (dirty.value) changedElsewhere.value = true
+  else void load()
+})
+onMounted(load)
+function reload() { if (!dirty.value || window.confirm('放弃未保存的模型设置并重新加载?')) void load() }
+onBeforeUnmount(() => { generation++; emit('dirtyChange', false); emit('busyChange', false) })
+</script>
+<template>
+  <section class="bot-model-settings" aria-label="Bot 模型设置">
+    <p>保存后,该 Bot 在所有会话和群聊中从下一轮开始使用。</p>
+    <p v-if="loading" role="status">正在读取模型设置…</p>
+    <p v-if="error" class="error" role="alert">{{ error }}</p>
+    <p v-if="notice" role="status">{{ notice }}</p>
+    <p v-if="changedElsewhere" role="status">其他端已更新资料。当前草稿已保留,请重新加载后保存。</p>
+    <form v-if="options" @submit.prevent="save">
+      <fieldset :disabled="loading || saving">
+        <label>模型与 Provider<select v-model="modelKey" aria-label="Bot 模型">
+          <option value="">继承基础机器人 · {{ options.defaults.provider }} / {{ options.defaults.model }}</option>
+          <option v-if="modelKey && !selected" :value="modelKey">{{ draft.provider }} / {{ draft.model }}(不可用)</option>
+          <option v-for="item in options.models" :key="key(item.provider,item.model)" :value="key(item.provider,item.model)">{{ item.provider }} / {{ item.name }}</option>
+        </select></label>
+        <label>思考等级<select v-model="draft.reasoningEffort" aria-label="Bot 思考等级">
+          <option :value="null">继承基础机器人 · {{ botReasoningLabels[options.defaults.reasoningEffort] || options.defaults.reasoningEffort }}</option>
+          <option v-if="draft.reasoningEffort && !selected?.reasoningEfforts.includes(draft.reasoningEffort)" :value="draft.reasoningEffort">{{ draft.reasoningEffort }}(不可用)</option>
+          <option v-for="effort in selected?.reasoningEfforts || []" :key="effort" :value="effort">{{ botReasoningLabels[effort] || effort }}</option>
+        </select></label>
+        <small v-if="selected && !selected.reasoningKnown">该模型未声明完整思考能力,等级是否有效由模型服务决定。</small>
+        <label>速度<select v-model="draft.fastMode" aria-label="Bot 速度">
+          <option :value="null">继承基础机器人 · {{ botFastLabels[options.defaults.fastMode] }}</option>
+          <option v-if="draft.fastMode && !selected?.fastModes.includes(draft.fastMode)" :value="draft.fastMode">{{ botFastLabels[draft.fastMode] }}(不可用)</option>
+          <option v-for="speed in selected?.fastModes || ['normal']" :key="speed" :value="speed">{{ botFastLabels[speed] }}</option>
+        </select></label>
+        <small>快速模式可能产生额外费用;每轮开始加速和首次回复加速使用 Hermes 配置的加速时长。</small>
+        <p v-if="invalid" class="error" role="alert">{{ invalid }}</p>
+        <button type="submit" :disabled="!!invalid || changedElsewhere">{{ saving ? '正在保存…' : '保存模型设置' }}</button>
+      </fieldset>
+    </form>
+    <button type="button" :disabled="loading || saving" @click="reload">{{ options ? '重新加载' : '重试' }}</button>
+  </section>
+</template>
+<style scoped>
+.bot-model-settings{display:grid;gap:14px;max-width:650px}p{margin:0;color:var(--text-secondary);line-height:1.6;font-size:14px}fieldset{border:0;margin:0;padding:0;display:grid;gap:16px}label{display:grid;gap:8px;font-weight:550;font-size:14px}select{min-height:44px;max-width:100%;width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;color:var(--text-primary);background:var(--surface)}small{color:var(--text-secondary);line-height:1.6}.error{color:var(--danger,#c33)}button{min-height:44px;padding:10px 16px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);cursor:pointer;justify-self:start}button:disabled{opacity:.5;cursor:default}button:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+</style>

+ 15 - 8
src/client/components/workspace/BotProfileDialog.vue

@@ -5,17 +5,18 @@ import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
 import ModelServicesPanel from '@/components/app/ModelServicesPanel.vue'
+import BotModelSettingsPanel from './BotModelSettingsPanel.vue'
 import WorkspaceKnowledgePanel from './WorkspaceKnowledgePanel.vue'
 import {apiRequest} from '@/api/client'
 import type {WorkspaceAgent, WorkspaceAgentUsage, WorkspaceApprovalPolicy, WorkspaceConversation, WorkspaceSource} from '@shared/workspace'
 import type {Profile} from '@shared/types'
 
 interface Draft {name:string;avatar:string;instructions:string;description?:string;job?:string;antiJobs?:string[];voice?:''|'concise'|'casual'|'rigorous'|'custom';voiceCustom?:string;actBias?:''|'ask_first'|'ask_key_then_act'|'act_now';source:string;canManageTeam:boolean;canCollaborate:boolean;memoryEnabled:boolean;approvalPolicy?:WorkspaceApprovalPolicy}
-type Page='overview'|'identity'|'rules'|'usage'|'models'|'memory'|'permissions'
+type Page='overview'|'identity'|'rules'|'usage'|'modelSettings'|'models'|'memory'|'permissions'
 const props=defineProps<{agent:WorkspaceAgent;draft:Draft;sources:WorkspaceSource[];profiles:Profile[];agents:WorkspaceAgent[];conversations:WorkspaceConversation[];conversationId?:string;isAdmin?:boolean;knowledgeEnabled?:boolean;busy?:boolean;error?:string}>()
 const emit=defineEmits<{close:[];save:[draft:Draft & {approvalPolicy:WorkspaceApprovalPolicy}];changed:[]}>()
 const draft=reactive({...props.draft,approvalPolicy:props.draft.approvalPolicy??'ask'})
-const page=ref<Page>('overview'),query=ref(''),localError=ref(''),modelDirty=ref(false),memoryDirty=ref(false)
+const page=ref<Page>('overview'),query=ref(''),localError=ref(''),modelDirty=ref(false),modelSaving=ref(false),memoryDirty=ref(false)
 const usage=ref<WorkspaceAgentUsage|null>(null),usageLoading=ref(false),usageError=ref('')
 const formatTokens=(value:number)=>value.toLocaleString('zh-CN')
 onMounted(async()=>{
@@ -51,6 +52,7 @@ const allPages=computed(()=>[
   {id:'identity' as const,label:'身份与头像',icon:'users' as const},
   {id:'rules' as const,label:'角色与规则',icon:'files' as const},
   {id:'usage' as const,label:'Token 用量',icon:'history' as const},
+  {id:'modelSettings' as const,label:'模型设置',icon:'model' as const},
   ...(props.isAdmin?[{id:'models' as const,label:'模型与 Provider',icon:'model' as const}]:[]),
   ...(props.knowledgeEnabled&&!readOnly.value?[{id:'memory' as const,label:'记忆',icon:'brain' as const}]:[]),
   {id:'permissions' as const,label:'权限',icon:'settings' as const},
@@ -59,14 +61,14 @@ const pages=computed(()=>allPages.value.filter(item=>item.label.toLocaleLowerCas
 const activeTitle=computed(()=>allPages.value.find(item=>item.id===page.value)?.label??'概览')
 const environment=computed(()=>'服务端 Hermes · 设备按全局设置开放')
 function selectPage(next:Page){
-  if(props.busy||next===page.value)return
-  if(modelDirty.value&&!window.confirm('放弃当前模型服务未保存的更改?'))return
+  if(props.busy||modelSaving.value||next===page.value)return
+  if(modelDirty.value&&!window.confirm('放弃当前模型设置未保存的更改?'))return
   if(memoryDirty.value&&!window.confirm('放弃当前记忆未保存的更改?'))return
   modelDirty.value=false;memoryDirty.value=false;localError.value='';page.value=next
 }
-function beforeClose(){return !props.busy&&(!(dirty.value||modelDirty.value||memoryDirty.value)||window.confirm('放弃尚未保存的资料更改?'))}
+function beforeClose(){return !props.busy&&!modelSaving.value&&(!(dirty.value||modelDirty.value||memoryDirty.value)||window.confirm('放弃尚未保存的资料更改?'))}
 function submit(){
-  if(props.busy||modelDirty.value||memoryDirty.value)return
+  if(props.busy||modelSaving.value||modelDirty.value||memoryDirty.value)return
   if(!draft.name.trim()){page.value='identity';localError.value='请输入机器人名称';return}
   localError.value='';emit('save',{...snapshot(),name:draft.name.trim()})
 }
@@ -78,7 +80,7 @@ function submit(){
       <aside class="bot-profile__sidebar" aria-label="资料分类">
         <label class="profile-search"><AppIcon name="search" :size="16"/><input v-model="query" type="search" aria-label="搜索资料设置" placeholder="搜索设置"/></label>
         <div class="profile-source"><small>基础机器人</small><div class="profile-source__picker"><AgentAvatar :name="sourceLabel" :avatar="sourceAvatar" :size="30"/><span class="profile-source__identity"><strong>{{sourceLabel}}</strong><small>{{sourceProfile}}</small></span><AppIcon name="chevron-down" :size="14"/>
-          <select v-model="draft.source" aria-label="基础机器人" :disabled="busy||readOnly||modelDirty"><option v-if="!currentSource" :value="draft.source">{{sourceLabel}} · {{sourceProfile}}</option><option v-for="source in sources" :key="sourceKey(source)" :value="sourceKey(source)">{{sourceTitle(source)}} · {{source.profile}}{{source.nodeId==='local'?'':' · 远端'}}</option></select></div>
+          <select v-model="draft.source" aria-label="基础机器人" :disabled="busy||readOnly||modelDirty||modelSaving"><option v-if="!currentSource" :value="draft.source">{{sourceLabel}} · {{sourceProfile}}</option><option v-for="source in sources" :key="sourceKey(source)" :value="sourceKey(source)">{{sourceTitle(source)}} · {{source.profile}}{{source.nodeId==='local'?'':' · 远端'}}</option></select></div>
         </div>
         <nav aria-label="编辑资料分类"><button v-for="item in pages" :key="item.id" type="button" :aria-current="page===item.id?'page':undefined" :disabled="busy" @click="selectPage(item.id)"><AppIcon :name="item.icon" :size="18"/><span>{{item.label}}</span></button><p v-if="!pages.length">没有匹配的设置</p></nav>
       </aside>
@@ -126,6 +128,11 @@ function submit(){
             </section>
             <p class="profile-note">用量在每次运行结束时从模型会话统计(输入 + 输出 Token)。从本版本起开始累计;远程 Bot 或个别模型不回报用量时无法统计。</p>
           </template>
+          <template v-else-if="page==='modelSettings'">
+            <p v-if="readOnly" class="profile-note">该 Bot 的模型设置由远端管理。</p>
+            <p v-else-if="sourceChanged" class="profile-note">请先保存基础机器人的变更,再修改模型设置。</p>
+            <BotModelSettingsPanel v-else :agent="agent" @dirty-change="modelDirty=$event" @busy-change="modelSaving=$event" @saved="emit('changed')" />
+          </template>
           <template v-else-if="page==='models' && isAdmin">
             <p v-if="readOnly||agent.nodeId!=='local'" class="profile-note">模型与 Provider 由远端执行节点管理,请在对应节点配置。</p>
             <p v-else-if="sourceChanged" class="profile-note">请先保存基础机器人的变更,再配置对应的模型与 Provider。</p>
@@ -141,7 +148,7 @@ function submit(){
             <section v-if="knowledgeEnabled" class="profile-card profile-permissions"><h4>记忆</h4><label><input v-model="draft.memoryEnabled" type="checkbox" :disabled="busy||readOnly"/><span>自动记录长期事实<small>保存到当前 Bot 的独立记忆。组队、建 Bot 和看见同伴默认可用,不需要单独开启。</small></span></label></section>
           </template>
         </div>
-        <footer class="bot-profile__footer"><span v-if="modelDirty||memoryDirty">请先保存或取消当前编辑</span><span v-else-if="dirty">有未保存的更改</span><button type="button" :disabled="busy" @click="beforeClose()&&emit('close')">关闭</button><button type="button" class="profile-save" :disabled="busy||!dirty||modelDirty||memoryDirty" @click="submit">{{busy?'正在保存…':'保存更改'}}</button></footer>
+        <footer class="bot-profile__footer"><span v-if="modelDirty||memoryDirty">请先保存或取消当前编辑</span><span v-else-if="dirty">有未保存的更改</span><button type="button" :disabled="busy||modelSaving" @click="beforeClose()&&emit('close')">关闭</button><button type="button" class="profile-save" :disabled="busy||modelSaving||!dirty||modelDirty||memoryDirty" @click="submit">{{busy?'正在保存…':'保存更改'}}</button></footer>
       </main>
     </div>
   </StandaloneDialog>

+ 55 - 0
src/server/botModelSettings.ts

@@ -0,0 +1,55 @@
+import { z } from 'zod'
+import type { BotModelSettings, ResolvedBotModelSettings } from '../shared/botModelSettings.js'
+import type { GatewayTarget } from './workspaceGateway.js'
+import { HttpError } from './errors.js'
+
+const prefix = '/api/plugins/yaoyao-bot-bridge'
+const id = z.string().min(1).max(512).refine(value => !/[\s"'\\\u0000-\u001f]/.test(value) && !value.startsWith('-'))
+const effort = z.enum(['none','minimal','low','medium','high','xhigh','max','ultra'])
+const speed = z.enum(['normal','fast','auto','cold'])
+const effective = z.object({ provider: id, model: id, reasoningEffort: effort, fastMode: speed })
+const resolved = z.object({ version: z.literal(1), effective, confirmationMessage: z.string().max(10000).nullable() })
+const catalogue = z.object({ version: z.literal(1), defaults: effective, models: z.array(z.object({
+  provider: id, model: id, name: z.string(), reasoningEfforts: z.array(effort), reasoningKnown: z.boolean(), fastModes: z.array(speed),
+})) })
+
+async function request(target: GatewayTarget, profile: string, settings?: BotModelSettings | null) {
+  const response = await target.session.request(prefix + (settings === undefined ? '/model-options' : '/model-settings/resolve'), settings === undefined
+    ? { search: new URLSearchParams({ profile }), cache: 'reload' }
+    : { method: 'POST', body: { profile, settings }, cache: 'reload' })
+  let body: any
+  try { body = JSON.parse(response.body.toString()) } catch { /* fail with the versioned boundary below */ }
+  if (response.status === 404 || response.status === 405) throw new HttpError(409, '请升级夭夭工具桥并重启 Hermes,当前版本尚不支持 Bot 模型设置。', 'model_settings_upgrade_required')
+  if (response.status !== 200) throw new HttpError(response.status === 400 ? 400 : 503, typeof body?.detail === 'string' ? body.detail : 'Bot 模型服务暂不可用,请重试。', 'model_settings_unavailable')
+  return body
+}
+export async function botModelOptions(target: GatewayTarget, profile: string) {
+  const parsed = catalogue.safeParse(await request(target, profile))
+  if (!parsed.success) throw new HttpError(409, '工具桥模型目录格式不兼容,请升级工具桥。', 'model_settings_upgrade_required')
+  return parsed.data
+}
+export async function resolveBotModelSettings(target: GatewayTarget, profile: string, settings: BotModelSettings | null) {
+  const parsed = resolved.safeParse(await request(target, profile, settings))
+  if (!parsed.success) throw new HttpError(409, '工具桥模型配置格式不兼容,请升级工具桥。', 'model_settings_upgrade_required')
+  return parsed.data
+}
+export const botModelTarget = (value: Pick<ResolvedBotModelSettings, 'provider' | 'model'>) => JSON.stringify([value.provider, value.model])
+export class BotModelConfirmationError extends HttpError {
+  constructor(readonly target: string, readonly confirmationMessage: string) {
+    super(409, '此模型需要确认,请打开 Bot 个人资料重新保存模型设置。', 'model_confirmation_required')
+  }
+}
+
+/** Always applied to an idle session; acknowledgements precede prompt submission. */
+export async function applyBotModelSettings(
+  rpc: (method: string, params: Record<string, unknown>) => Promise<any>, sessionId: string,
+  value: ResolvedBotModelSettings, confirmed: boolean,
+  current?: { model?: string; provider?: string },
+) {
+  for (const [key, input] of [['model', `${value.model} --provider ${value.provider} --session`], ['reasoning', value.reasoningEffort], ['fast', value.fastMode]]) {
+    if (key === 'model' && current?.model === value.model && current?.provider === value.provider) continue
+    const result = await rpc('config.set', { session_id: sessionId, key, value: input, scope: 'session', ...(key === 'model' && confirmed ? { confirm_expensive_model: true } : {}) })
+    if (result?.confirm_required) throw new BotModelConfirmationError(botModelTarget(value), String(result.confirm_message || result.warning || '此会话切换模型可能增加费用,请确认继续。').slice(0, 10000))
+    if (result?.deferred || (key === 'model' && result?.scope !== 'session') || result?.value !== (key === 'model' ? value.model : input)) throw new HttpError(502, 'Hermes 未确认 Bot 会话模型设置,本轮未发送,请检查模型服务后重试。', 'model_settings_not_applied')
+  }
+}

+ 37 - 2
src/server/workspaceRoutes.ts

@@ -9,6 +9,7 @@ import type Koa from 'koa'
 import { createReadStream, statSync } from 'node:fs'
 import { z } from 'zod'
 import { WorkspaceStore, agentInput, agentPatch, parse } from './workspaceStore.js'
+import { botModelOptions, resolveBotModelSettings, botModelTarget } from './botModelSettings.js'
 import { fetchGrokBotPage, parseGrokBotPage, fetchAvatarDataUrl, grokBotDraft, GrokBotImportError } from '../shared/grokBotImport.js'
 import { WorkspaceRuntime, sendInput } from './workspaceRuntime.js'
 import { WorkspaceNodes, type WorkspaceNode } from './workspaceGateway.js'
@@ -76,6 +77,7 @@ export function workspaceRouter(
       features: [
         ...KNOWLEDGE_FEATURES,
         'agents',
+        'botModelSettings',
         'conversations',
         'conversationTasks',
         'editableGroups',
@@ -137,12 +139,39 @@ export function workspaceRouter(
     }
     ctx.status = 201
   })
+  router.get('/api/app/agents/:id/model-options', async ctx => {
+    const user = owner(ctx), agent = store.require<WorkspaceAgent>(user, 'agent', ctx.params.id)
+    nodes.requireSource(user, agent)
+    const options = await botModelOptions(nodes.target(user, agent.nodeId), agent.profile)
+    nodes.requireSource(user, agent)
+    if (store.require<WorkspaceAgent>(user, 'agent', agent.id).revision !== agent.revision) throw new HttpError(409, 'Bot 资料已更新,请重新加载。', 'agent_revision_conflict')
+    ctx.set('Cache-Control', 'no-store')
+    ctx.body = { ...options, settings: agent.modelSettings ?? null, revision: agent.revision }
+  })
   router.patch('/api/app/agents/:id', async (ctx) => {
-    const user = owner(ctx), agent = store.require<WorkspaceAgent>(user, 'agent', ctx.params.id), input = parse(agentPatch,body(ctx))
+    const raw = body(ctx), { confirmedModel, ...patch } = raw
+    if (confirmedModel !== undefined && (typeof confirmedModel !== 'string' || confirmedModel.length > 1100)) throw new HttpError(400, '模型确认无效', 'invalid_model_confirmation')
+    const user = owner(ctx), agent = store.require<WorkspaceAgent>(user, 'agent', ctx.params.id), input = parse(agentPatch,patch)
     const authorization = auth.pushAuthorizationVersion(user)
     const updated:WorkspaceAgent={...agent,...input,job:input.job??undefined,antiJobs:input.antiJobs??undefined,voice:input.voice??undefined,voiceCustom:input.voiceCustom??undefined,actBias:input.actBias??undefined}
     const sourceChanged=updated.nodeId!==agent.nodeId||updated.profile!==agent.profile
     nodes.requireSource(user,updated)
+    let confirmation: string | undefined
+    // Source-only edits are saved first, so clients can load the new Profile's
+    // catalogue. Keep the selection intact; the next round also validates it.
+    if (input.modelSettings !== undefined) {
+      if (input.expectedRevision !== agent.revision) throw new HttpError(409, 'Bot 资料已更新,请重新加载后保存模型设置。', 'agent_revision_conflict')
+      input.expectedRevision = agent.revision
+      const resolution = await resolveBotModelSettings(nodes.target(user, updated.nodeId), updated.profile, updated.modelSettings ?? null)
+      const target = botModelTarget(resolution.effective)
+      const pending = agent.modelSettingsPendingConfirmation
+      const confirmationMessage = [resolution.confirmationMessage, pending?.target === target ? pending.message : null].filter(Boolean).join('\n\n')
+      if (confirmationMessage && confirmedModel !== target && agent.modelSettingsConfirmation !== target) {
+        ctx.body = { confirmationRequired: true, confirmationMessage, confirmationTarget: target }
+        return
+      }
+      confirmation = confirmedModel === target || agent.modelSettingsConfirmation === target ? target : ''
+    }
     if(sourceChanged){
       const sources=await nodes.sources(user)
       if(!sources.sources.some(s=>s.nodeId===updated.nodeId&&s.profile===updated.profile))throw new HttpError(409,'基础机器人当前不可用','source_unavailable')
@@ -156,7 +185,13 @@ export function workspaceRouter(
         if (direct) store.changeConversationLifecycle(user, direct.id, 'archive')
       }
       nodes.requireSource(user,{...store.require<WorkspaceAgent>(user,'agent',ctx.params.id),...input})
-      return store.updateAgent(user, ctx.params.id, input)
+      const saved = store.updateAgent(user, ctx.params.id, input)
+      if (confirmation !== undefined) {
+        saved.modelSettingsConfirmation = confirmation || undefined
+        saved.modelSettingsPendingConfirmation = undefined
+        store.put(user, 'agent', saved.id, saved)
+      }
+      return saved
     })) }
   })
   router.delete('/api/app/agents/:id', async (ctx) => {

+ 23 - 0
src/server/workspaceRuntime.ts

@@ -20,6 +20,7 @@ import type {
 } from '../shared/workspace.js'
 import { personaSection } from '../shared/workspace.js'
 import { readHostTools } from './hostToolSettings.js'
+import { resolveBotModelSettings, applyBotModelSettings, botModelTarget, BotModelConfirmationError } from './botModelSettings.js'
 function globalComputers(home: string) {
   try { return readHostTools(home) }
   catch { return { denyServerTools: false, approvalPolicy: 'ask' as const, scriptMachine: false, serverComputer: false, vm: false, cloud: false } }
@@ -704,6 +705,28 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       } else {
         if (opened.running) throw new HttpError(409, '上游会话仍在运行', 'session_busy')
         this.nodes.requireSource(owner, agent)
+        if (agent.modelSettings !== undefined) {
+          const entry = { method: 'bot.model-settings', agentId: agent.id, runId: run.runId, taskId: run.conversationTaskId }
+          const snapshot = { revision: agent.revision, settings: agent.modelSettings }
+          this.inspector?.record(owner, c.id, { ...entry, direction: 'request', data: snapshot })
+          try {
+            const resolution = await resolveBotModelSettings(target, agent.profile, agent.modelSettings)
+            const confirmed = agent.modelSettingsConfirmation === botModelTarget(resolution.effective)
+            if (resolution.confirmationMessage && !confirmed) throw new HttpError(409, '此模型需要确认,请打开 Bot 个人资料保存模型设置。', 'model_confirmation_required')
+            await applyBotModelSettings((method, params) => gateway.rpc(method, params), runtimeId, resolution.effective, confirmed, opened.info)
+            this.inspector?.record(owner, c.id, { ...entry, direction: 'response', data: { ...snapshot, applied: true, effective: resolution.effective } })
+          } catch (error) {
+            // Context-dependent native warnings cannot be known from the model
+            // catalogue. Bring them back to the profile's existing save flow.
+            if (error instanceof BotModelConfirmationError) {
+              const latest = this.store.require<Agent>(owner, 'agent', agent.id)
+              if (latest.revision === agent.revision) this.store.put(owner, 'agent', latest.id, { ...latest,
+                modelSettingsPendingConfirmation: { target: error.target, message: error.confirmationMessage } })
+            }
+            this.inspector?.record(owner, c.id, { ...entry, direction: 'response', data: { ...snapshot, applied: false, error: error instanceof Error ? error.message : '模型设置应用失败' } })
+            throw error
+          }
+        }
         const team=botCapabilities.tools&&!agent.temporaryGoalId&&!agent.remoteAgentId&&!this.store.require<Run>(owner,'run',run.runId).assignmentId
         const cloud=!!this.cloud?.selected(owner,agent)
         const plugins=!!this.plugins?.selected(owner,agent)

+ 10 - 1
src/server/workspaceStore.ts

@@ -44,6 +44,12 @@ const personaInput = {
   voiceCustom: z.string().max(200).optional(),
   actBias: z.enum(['ask_first', 'ask_key_then_act', 'act_now']).optional(),
 }
+const modelIdentifier = z.string().trim().min(1).max(512).refine(value => !/[\s"'\\\u0000-\u001f]/.test(value) && !value.startsWith('-'), '模型或 Provider 标识无效')
+export const botModelSettingsInput = z.object({
+  provider: modelIdentifier.nullable(), model: modelIdentifier.nullable(),
+  reasoningEffort: z.enum(['none','minimal','low','medium','high','xhigh','max','ultra']).nullable(),
+  fastMode: z.enum(['normal','fast','auto','cold']).nullable(),
+}).strict().refine(value => (value.provider === null) === (value.model === null), '模型与 Provider 必须一起选择')
 export const agentInput = z
   .object({
     name,
@@ -68,6 +74,8 @@ export const agentInput = z
   .strict()
 export const agentPatch = z
   .object({
+    modelSettings: botModelSettingsInput.nullable().optional(),
+    expectedRevision: z.number().int().positive().optional(),
     name: name.optional(),
     avatar: avatar.optional(),
     instructions: z.string().max(24_000).optional(),
@@ -464,7 +472,7 @@ export class WorkspaceStore {
   updateAgent(owner: string, id: string, input: unknown): Agent {
     // Persona fields use null from clients to clear; normalizing with an
     // always-present key keeps the clear semantics when spreading over the record.
-    const { job, antiJobs, voice, voiceCustom, actBias, ...rest } = parse(agentPatch, input)
+    const { job, antiJobs, voice, voiceCustom, actBias, expectedRevision, ...rest } = parse(agentPatch, input)
     const patch: typeof rest & Partial<Pick<Agent, 'job' | 'antiJobs' | 'voice' | 'voiceCustom' | 'actBias'>> = rest
     // Only materialize keys the request actually carried; null clears the field.
     if (job !== undefined) patch.job = job ?? undefined
@@ -481,6 +489,7 @@ export class WorkspaceStore {
     if (patch.computer === 'browser') patch.computer = 'off'
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
+      if (expectedRevision !== undefined && agent.revision !== expectedRevision) throw new HttpError(409, 'Bot 资料已在其他地方更新,请重新加载后保存。', 'agent_revision_conflict')
       // Parallel-environment agents keep their set; a raw computer write only
       // toggles the VM leg (local-vm enable/disable and Compose bindings).
       if(patch.computer!==undefined&&agent.envs){

+ 31 - 0
src/shared/botModelSettings.ts

@@ -0,0 +1,31 @@
+export type BotFastMode = 'normal' | 'fast' | 'auto' | 'cold'
+export interface BotModelSettings {
+  provider: string | null
+  model: string | null
+  reasoningEffort: string | null
+  fastMode: BotFastMode | null
+}
+export interface ResolvedBotModelSettings {
+  provider: string
+  model: string
+  reasoningEffort: string
+  fastMode: BotFastMode
+}
+export interface BotModelOption {
+  provider: string
+  model: string
+  name: string
+  reasoningEfforts: string[]
+  reasoningKnown: boolean
+  fastModes: BotFastMode[]
+}
+export interface BotModelOptions {
+  version: 1
+  defaults: ResolvedBotModelSettings
+  models: BotModelOption[]
+  settings: BotModelSettings | null
+  revision: number
+}
+export const inheritedBotModelSettings = (): BotModelSettings => ({ provider: null, model: null, reasoningEffort: null, fastMode: null })
+export const botReasoningLabels: Record<string, string> = { none: '关闭', minimal: '最小', low: '低', medium: '中', high: '高', xhigh: '极高', max: '最大', ultra: 'Ultra' }
+export const botFastLabels: Record<BotFastMode, string> = { normal: '普通', fast: '快速', auto: '每轮开始加速', cold: '首次回复加速' }

+ 5 - 0
src/shared/workspace.ts

@@ -3,6 +3,11 @@ export type WorkspaceApprovalPolicy = 'ask' | 'allow' | 'deny'
 export type WorkspaceVoice='concise'|'casual'|'rigorous'|'custom'
 export type WorkspaceActBias='ask_first'|'ask_key_then_act'|'act_now'
 export interface WorkspaceAgent {
+  modelSettings?: import('./botModelSettings.js').BotModelSettings | null
+  /** Server-owned acknowledgement, bound to the resolved provider/model. */
+  modelSettingsConfirmation?: string
+  /** Server-owned native warning discovered while preparing an idle session. */
+  modelSettingsPendingConfirmation?: { target: string; message: string }
   /** 一句话职责:这个 Bot 只做的一件事。 */
   job?: string
   /** 反任务清单:任何情况下都不做的事。 */

+ 68 - 0
tests/client/botModelSettingsPanel.test.ts

@@ -0,0 +1,68 @@
+import { mount, flushPromises } from '@vue/test-utils'
+import { beforeEach, afterEach, expect, it, vi } from 'vitest'
+import BotModelSettingsPanel from '@/components/workspace/BotModelSettingsPanel.vue'
+const api = vi.hoisted(() => vi.fn())
+vi.mock('@/api/client', () => ({ apiRequest: api }))
+const defaults = {provider:'openai',model:'model-a',reasoningEffort:'medium',fastMode:'normal'}
+const agent = {id:'bot-a',profile:'default',name:'甲',avatar:'',instructions:'',nodeId:'local',archived:false,revision:1,createdAt:1,updatedAt:1}
+const catalogue = () => ({version:1,defaults,settings:null,revision:1,models:[
+  {provider:'openai',model:'model-a',name:'A',reasoningEfforts:['none','high'],reasoningKnown:false,fastModes:['normal','fast','auto','cold']},
+  {provider:'other',model:'model-b',name:'B',reasoningEfforts:[],reasoningKnown:true,fastModes:['normal']},
+]})
+beforeEach(() => { api.mockReset(); api.mockResolvedValue(catalogue()) })
+afterEach(() => vi.restoreAllMocks())
+it('loads the Bot catalogue, preserves full speed modes, and saves with a revision', async () => {
+  const view = mount(BotModelSettingsPanel,{props:{agent}}); await flushPromises()
+  await view.get('[aria-label="Bot 速度"]').setValue('cold')
+  api.mockResolvedValueOnce({agent:{...agent,revision:2,modelSettings:{provider:null,model:null,reasoningEffort:null,fastMode:'cold'}}})
+  await view.get('form').trigger('submit'); await flushPromises()
+  expect(api).toHaveBeenLastCalledWith('/api/app/agents/bot-a',{method:'PATCH',body:{modelSettings:{provider:null,model:null,reasoningEffort:null,fastMode:'cold'},expectedRevision:1}})
+  expect(view.text()).toContain('已保存'); view.unmount()
+})
+it('does not silently reset incompatible settings after changing model', async () => {
+  const view = mount(BotModelSettingsPanel,{props:{agent}}); await flushPromises()
+  await view.get('[aria-label="Bot 思考等级"]').setValue('high')
+  await view.get('[aria-label="Bot 模型"]').setValue('["other","model-b"]')
+  expect(view.text()).toContain('不支持所选思考等级')
+  expect(view.get<HTMLButtonElement>('button[type="submit"]').element.disabled).toBe(true)
+  view.unmount()
+})
+it('keeps dirty drafts when another client saves and disables stale submission', async () => {
+  const view = mount(BotModelSettingsPanel,{props:{agent}}); await flushPromises()
+  await view.get('[aria-label="Bot 速度"]').setValue('auto')
+  await view.setProps({agent:{...agent,revision:2}})
+  expect(view.get<HTMLSelectElement>('[aria-label="Bot 速度"]').element.value).toBe('auto')
+  expect(view.text()).toContain('当前草稿已保留')
+  expect(api).toHaveBeenCalledTimes(1); view.unmount()
+})
+it('confirms the exact model in the profile before saving', async () => {
+  vi.spyOn(window,'confirm').mockReturnValue(true)
+  const view = mount(BotModelSettingsPanel,{props:{agent}}); await flushPromises()
+  api.mockResolvedValueOnce({confirmationRequired:true,confirmationMessage:'费用较高',confirmationTarget:'["openai","model-a"]'})
+    .mockResolvedValueOnce({agent:{...agent,revision:2}})
+  await view.get('form').trigger('submit'); await flushPromises()
+  expect(window.confirm).toHaveBeenCalledWith('费用较高')
+  expect(api.mock.lastCall?.[1].body.confirmedModel).toBe('["openai","model-a"]'); view.unmount()
+})
+it('refreshes a newer cross-client update that arrives while its own save is in flight', async () => {
+  const view=mount(BotModelSettingsPanel,{props:{agent}});await flushPromises()
+  await view.get('[aria-label="Bot 速度"]').setValue('cold')
+  let finish:(value:any)=>void=()=>{}
+  api.mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}))
+    .mockResolvedValueOnce({...catalogue(),revision:3,settings:{provider:null,model:null,reasoningEffort:null,fastMode:'auto'}})
+  await view.get('form').trigger('submit')
+  expect(view.emitted('busyChange')?.at(-1)).toEqual([true])
+  await view.setProps({agent:{...agent,revision:3}})
+  finish({agent:{...agent,revision:2}});await flushPromises()
+  expect(view.emitted('busyChange')?.at(-1)).toEqual([false])
+  expect(api).toHaveBeenLastCalledWith('/api/app/agents/bot-a/model-options')
+  expect(view.get<HTMLSelectElement>('[aria-label="Bot 速度"]').element.value).toBe('auto');view.unmount()
+})
+it('disables editing when a reload discovers an unavailable bridge and allows retry',async()=>{
+  const view=mount(BotModelSettingsPanel,{props:{agent}});await flushPromises()
+  api.mockRejectedValueOnce(new Error('请升级工具桥'))
+  await view.get('button[type="button"]').trigger('click');await flushPromises()
+  expect(view.find('form').exists()).toBe(false);expect(view.text()).toContain('请升级工具桥')
+  await view.get('button[type="button"]').trigger('click');await flushPromises()
+  expect(view.find('form').exists()).toBe(true);view.unmount()
+})

+ 57 - 0
tests/server/botModelSettings.test.ts

@@ -0,0 +1,57 @@
+// @vitest-environment node
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { WorkspaceStore, botModelSettingsInput } from '../../src/server/workspaceStore'
+import { applyBotModelSettings, botModelOptions, resolveBotModelSettings } from '../../src/server/botModelSettings'
+import type { GatewayTarget } from '../../src/server/workspaceGateway'
+import type { WorkspaceAgent } from '../../src/shared/workspace'
+import { inheritedBotModelSettings } from '../../src/shared/botModelSettings'
+
+let home: string, store: WorkspaceStore
+const settings = { provider: 'openai', model: 'model-b', reasoningEffort: 'high', fastMode: 'auto' as const }
+beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'bot-model-')); store = new WorkspaceStore(home) })
+afterEach(() => { store.close(); rmSync(home, { recursive: true, force: true }) })
+it('persists per-Bot settings, preserves old-client patches, and rejects stale edits atomically', () => {
+  const a = store.createAgent('owner', { name: '甲', profile: 'default' }), b = store.createAgent('owner', { name: '乙', profile: 'default' })
+  const updated = store.updateAgent('owner', a.id, { modelSettings: settings, expectedRevision: a.revision })
+  store.updateAgent('owner', a.id, { name: '新名字' })
+  expect(store.require<WorkspaceAgent>('owner','agent',a.id).modelSettings).toEqual(settings)
+  expect(store.require<WorkspaceAgent>('owner','agent',b.id).modelSettings).toBeUndefined()
+  expect(() => store.updateAgent('owner', a.id, { modelSettings: null, expectedRevision: updated.revision })).toThrow('已在其他地方更新')
+  const current = store.require<WorkspaceAgent>('owner','agent',a.id)
+  store.updateAgent('owner', a.id, { modelSettings: null, expectedRevision: current.revision })
+  store.close(); store = new WorkspaceStore(home)
+  expect(store.require<WorkspaceAgent>('owner','agent',a.id).modelSettings).toBeNull()
+})
+it('distinguishes inherited values, explicit no reasoning, and all four speed modes', () => {
+  for (const fastMode of ['normal','fast','auto','cold']) expect(botModelSettingsInput.parse({ ...inheritedBotModelSettings(), reasoningEffort:'none', fastMode }).fastMode).toBe(fastMode)
+  for (const invalid of [{...settings,model:null},{...settings,fastMode:true},{...settings,model:'model --global'}]) expect(botModelSettingsInput.safeParse(invalid).success).toBe(false)
+})
+it('applies and checks model, reasoning, speed in order using session scope only', async () => {
+  const rpc = vi.fn(async (_method, params) => ({ value: params.key === 'model' ? 'model-b' : params.value, scope:'session' }))
+  await applyBotModelSettings(rpc, 'session-a', settings, false)
+  expect(rpc.mock.calls.map(([,p]) => p.key)).toEqual(['model','reasoning','fast'])
+  for (const [method, params] of rpc.mock.calls) { expect(method).toBe('config.set'); expect(params).toMatchObject({ session_id:'session-a',scope:'session' }) }
+  expect(rpc.mock.calls[0]![1].value).toBe('model-b --provider openai --session')
+})
+it.each([{ value:'wrong',scope:'session' }, { value:'model-b',scope:'global' }, { value:'model-b',deferred:true }, { confirm_required:true }])('never submits downstream changes without an immediate acknowledgement: %j', async response => {
+  const rpc = vi.fn().mockResolvedValue(response)
+  await expect(applyBotModelSettings(rpc,'session-a',settings,false)).rejects.toThrow()
+  expect(rpc).toHaveBeenCalledTimes(1)
+})
+it('avoids rebuilding an unchanged model and still replaces old reasoning and speed pins', async () => {
+  const rpc = vi.fn(async (_method, params) => ({value:params.value}))
+  await applyBotModelSettings(rpc,'session-a',{...settings,reasoningEffort:'none',fastMode:'normal'},false,settings)
+  expect(rpc.mock.calls.map(([,p])=>[p.key,p.value])).toEqual([['reasoning','none'],['fast','normal']])
+})
+it('rejects old bridges and malformed catalogues and resolves inherited values through the bridge', async () => {
+  const request = vi.fn().mockResolvedValue({status:404,body:Buffer.from('{}')})
+  const target = {session:{request}} as unknown as GatewayTarget
+  await expect(botModelOptions(target,'default')).rejects.toMatchObject({code:'model_settings_upgrade_required'})
+  request.mockResolvedValue({status:200,body:Buffer.from(JSON.stringify({version:1,effective:settings,confirmationMessage:null}))})
+  expect((await resolveBotModelSettings(target,'default',null)).effective).toEqual(settings)
+  expect(request.mock.lastCall).toEqual(['/api/plugins/yaoyao-bot-bridge/model-settings/resolve',{method:'POST',body:{profile:'default',settings:null},cache:'reload'}])
+  await expect(botModelOptions(target,'default')).rejects.toMatchObject({code:'model_settings_upgrade_required'})
+})

+ 76 - 0
tests/server/workspace.test.ts

@@ -33,6 +33,7 @@ let requests: Array<{ method: string; params: Record<string, any> }>,
 let nodes: WorkspaceNodes
 let configuredCwds: Map<string, string>
 let rejectPrompt: string | undefined
+let rejectedModelConfig: Record<string, unknown> | undefined
 let rejectedInterrupts: number
 let recoveryHistory: Array<{ role: string; content: string; tool_calls?: unknown }>
 let storedByRuntime: Map<string, string>, recoveryByStored: Map<string, Array<{ role: string; content: string }>>
@@ -47,6 +48,7 @@ beforeEach(async () => {
   recoveryHistory = []
   storedByRuntime = new Map(); recoveryByStored = new Map()
   rejectPrompt = undefined
+  rejectedModelConfig = undefined
   rejectedInterrupts = 0
   server = new WebSocketServer({ port: 0, host: '127.0.0.1' })
   await new Promise<void>((resolve) => server.once('listening', resolve))
@@ -93,6 +95,7 @@ beforeEach(async () => {
         respond({ session_id: runtimeId, stored_session_id: storedId, running: false, info: { profile_name: frame.params.profile } })
       }
       else if (frame.method === 'session.cwd.set') respond({ cwd: frame.params.cwd })
+      else if (frame.method === 'config.set') respond(rejectedModelConfig ?? { value: frame.params.key === 'model' ? frame.params.value.split(' --provider ')[0] : frame.params.value, scope:'session' })
       else if (frame.method === 'prompt.submit') {
         if (rejectPrompt) { socket.send(JSON.stringify({id:frame.id,error:{code:4006,message:rejectPrompt}})); return }
         respond({ status: 'streaming' })
@@ -528,8 +531,10 @@ describe('Web-owned workspace', () => {
     expect(store.get<{ percent: number }>(owner, 'context', c.id)?.percent).toBe(40)
   })
   it('resumes a persisted uncertain run by inspecting history, without resending the prompt', async () => {
+    modelBridge()
     const a = agent('恢复测试'),
       c = direct(a.id)
+    store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' } })
     reply = (socket, p) => {
       recoveryHistory = [
         { role: 'user', content: p.text },
@@ -542,10 +547,14 @@ describe('Web-owned workspace', () => {
       expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('uncertain'),
     )
     runtime.close()
+    const configured = requests.filter(r => r.method === 'config.set').length
+    expect(configured).toBe(3)
+    store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-b', reasoningEffort: 'none', fastMode: 'cold' } })
     runtime = new WorkspaceRuntime(store, nodes, uploads)
     await runtime.reconcile(owner, run.id)
     await finished(run.id)
     expect(requests.filter((r) => r.method === 'prompt.submit')).toHaveLength(1)
+    expect(requests.filter(r => r.method === 'config.set')).toHaveLength(configured)
     expect(store.messages(owner, c.id).map((m) => m.content)).toEqual(['修改一次', '已经执行完成'])
   })
   it('routes approvals and clarifications to the owning run and ignores optional push failures', async () => {
@@ -1725,3 +1734,70 @@ it('offers a direct host-to-host copy through the real Bot tool bridge without p
   expect(publish).not.toHaveBeenCalled();expect(store.list(owner,'file')).toEqual([])
  }finally{timers.forEach(clearInterval);desktop.close()}
 })
+
+function modelBridge() {
+  const target = nodes.target(owner, 'local'), original = target.session.request.bind(target.session)
+  target.session.request = async (path, options) => {
+    if (path.endsWith('/model-settings/resolve')) {
+      const settings = (options?.body as any)?.settings ?? {}
+      return { status:200, headers:new Headers(), body:Buffer.from(JSON.stringify({version:1,confirmationMessage:null,effective:{provider:settings.provider??'base',model:settings.model??'base-model',reasoningEffort:settings.reasoningEffort??'medium',fastMode:settings.fastMode??'normal'}})) }
+    }
+    return original(path, options)
+  }
+}
+it.each([{value:'wrong'}, {confirm_required:true,confirm_message:'切换长上下文将增加费用'}])('stops before submission on a failed native model acknowledgement and keeps its confirmation in the profile: %j',async rejection=>{
+  modelBridge(); rejectedModelConfig=rejection
+  const a=agent('配置失败'),c=direct(a.id)
+  store.updateAgent(owner,a.id,{modelSettings:{provider:'openai',model:'model-a',reasoningEffort:'high',fastMode:'fast'}})
+  const record=vi.fn(); runtime.inspector={record} as any
+  const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'不能用旧模型执行'})
+  await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
+  expect(requests.some(r=>r.method==='prompt.submit')).toBe(false)
+  expect(record.mock.calls.some(([, , e])=>e.method==='bot.model-settings'&&e.data.applied===false&&e.data.revision===2)).toBe(true)
+  if('confirm_required' in rejection) expect(store.require<any>(owner,'agent',a.id).modelSettingsPendingConfirmation).toEqual({target:'["openai","model-a"]',message:rejection.confirm_message})
+})
+it('updates the same Bot across an existing direct chat and two groups without changing its Profile peers', async () => {
+  modelBridge()
+  const a=agent('模型甲'), b=agent('模型乙'), c=direct(a.id)
+  const groups=[1,2].map(n=>store.createGroup(owner,{name:`模型群${n}`,memberIds:[a.id,b.id],administratorId:a.id}))
+  const settings={provider:'openai',model:'model-a',reasoningEffort:'high',fastMode:'fast'}
+  store.updateAgent(owner,a.id,{modelSettings:settings})
+  await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'建立已有会话'}).id)
+  const before=requests.length
+  store.updateAgent(owner,a.id,{modelSettings:{...settings,model:'model-b',reasoningEffort:'none',fastMode:'cold'}})
+  for(const chat of [c,...groups]) await finished(runtime.send(owner,chat.id,{requestId:randomUUID(),content:'@模型甲 继续'}).id)
+  const updates=requests.slice(before).filter(r=>r.method==='config.set')
+  expect(updates.map(r=>[r.params.key,r.params.value])).toEqual(Array.from({length:3},()=>[
+    ['model','model-b --provider openai --session'],['reasoning','none'],['fast','cold'],
+  ]).flat())
+  expect(requests.slice(before).some(r=>r.method==='session.resume')).toBe(true)
+  expect(store.require<any>(owner,'agent',b.id).modelSettings).toBeUndefined()
+  store.updateAgent(owner,a.id,{modelSettings:null})
+  const reset=requests.length
+  await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'恢复继承'}).id)
+  expect(requests.slice(reset).filter(r=>r.method==='config.set').map(r=>r.params.value)).toEqual(['base-model --provider base --session','medium','normal'])
+})
+it('keeps a running reply on its snapshot and applies new settings to the queued reply', async () => {
+  modelBridge()
+  const a=agent('切换成员'), c=direct(a.id)
+  store.updateAgent(owner,a.id,{modelSettings:{provider:'openai',model:'model-a',reasoningEffort:'low',fastMode:'normal'}})
+  let release:()=>void=()=>{}
+  reply=(socket,p)=>{release=()=>socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'完成',status:'complete'}}}))}
+  const first=runtime.send(owner,c.id,{requestId:randomUUID(),content:'第一轮'})
+  await vi.waitFor(()=>expect(requests.some(r=>r.method==='prompt.submit')).toBe(true))
+  const before=requests.length
+  store.updateAgent(owner,a.id,{modelSettings:{provider:'openai',model:'model-b',reasoningEffort:'ultra',fastMode:'auto'}})
+  const queued=runtime.send(owner,c.id,{requestId:randomUUID(),content:'排队的新轮'})
+  expect(requests.length).toBe(before)
+  reply=(socket,p)=>socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'完成',status:'complete'}}}))
+  release(); await finished(first.id); await finished(queued.id)
+  expect(requests.slice(before).filter(r=>r.method==='config.set').map(r=>r.params.value)).toEqual(['model-b --provider openai --session','ultra','auto'])
+  expect(requests.filter(r=>r.method==='session.interrupt')).toHaveLength(0)
+})
+it('does not send a prompt when the model bridge cannot resolve the saved setting', async () => {
+  const a=agent('失败成员'),c=direct(a.id)
+  store.updateAgent(owner,a.id,{modelSettings:{provider:'openai',model:'gone',reasoningEffort:'high',fastMode:'fast'}})
+  const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'不能回退'})
+  await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
+  expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(0)
+})

+ 77 - 0
tests/server/workspaceRoutes.test.ts

@@ -611,3 +611,80 @@ it('aggregates per-agent daily token usage for today, month and lifetime', async
   expect(usage.daily.map((d: any) => d.date)).toEqual([today, older])
   await req('get', `/api/app/agents/${randomUUID()}/usage`).expect(404)
 })
+
+function stubBotModels(warning: string | null = null, duringResolve?: () => void) {
+  const original=runtime.upstreamSession.request.bind(runtime.upstreamSession)
+  return vi.spyOn(runtime.upstreamSession,'request').mockImplementation(async (path, options) => {
+    const defaults={provider:'openai',model:'model-a',reasoningEffort:'medium',fastMode:'normal'}
+    if(path.endsWith('/model-options'))return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({version:1,defaults,models:[{provider:'openai',model:'model-a',name:'A',reasoningEfforts:['none','medium','high'],reasoningKnown:true,fastModes:['normal','fast','auto','cold'],api_key:'must-not-leak'}]}))}
+    if(path.endsWith('/model-settings/resolve')){
+      duringResolve?.()
+      const settings=(options?.body as any)?.settings??{}
+      return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({version:1,effective:{...defaults,...Object.fromEntries(Object.entries(settings).filter(([,v])=>v!==null))},confirmationMessage:warning}))}
+    }
+    return original(path,options)
+  })
+}
+it('exposes owned Bot model capabilities and saves settings without altering its peers or Profile', async () => {
+  stubBotModels()
+  const a=runtime.workspace.createAgent('first',{name:'模型甲',profile:'default'}),b=runtime.workspace.createAgent('first',{name:'模型乙',profile:'default'})
+  const path=`/api/app/agents/${a.id}`
+  const options=(await req('get',`${path}/model-options`).expect(200)).body
+  expect(options).toMatchObject({revision:1,settings:null,defaults:{model:'model-a'}})
+  expect(JSON.stringify(options)).not.toContain('must-not-leak')
+  await req('get',`${path}/model-options`,'second').expect(404)
+  const modelSettings={provider:'openai',model:'model-a',reasoningEffort:'none',fastMode:'auto'}
+  const saved=(await req('patch',path).send({modelSettings,expectedRevision:1}).expect(200)).body.agent
+  expect(saved.modelSettings).toEqual(modelSettings)
+  expect(runtime.workspace.require<any>('first','agent',b.id).modelSettings).toBeUndefined()
+  await req('patch',path).send({name:'改名'}).expect(200)
+  expect(runtime.workspace.require<any>('first','agent',a.id).modelSettings).toEqual(modelSettings)
+  await req('patch',path).send({modelSettings:null,expectedRevision:1}).expect(409)
+  expect(upstream.some(p=>p.includes('/model/set'))).toBe(false)
+})
+it('requires model confirmation before saving and binds it to the resolved selection', async () => {
+  stubBotModels('费用较高,请确认')
+  const a=runtime.workspace.createAgent('first',{name:'确认模型',profile:'default'}),path=`/api/app/agents/${a.id}`
+  const body={expectedRevision:1,modelSettings:{provider:'openai',model:'model-a',reasoningEffort:null,fastMode:null}}
+  const confirmation=(await req('patch',path).send(body).expect(200)).body
+  expect(confirmation.confirmationRequired).toBe(true)
+  expect(runtime.workspace.require<any>('first','agent',a.id).revision).toBe(1)
+  const result=(await req('patch',path).send({...body,confirmedModel:confirmation.confirmationTarget}).expect(200)).body.agent
+  expect(result.modelSettingsConfirmation).toBe('["openai","model-a"]')
+  const changed=(await req('patch',path).send({...body,expectedRevision:2,modelSettings:{...body.modelSettings,model:'model-b'},confirmedModel:confirmation.confirmationTarget}).expect(200)).body
+  expect(changed.confirmationTarget).toBe('["openai","model-b"]')
+  expect(runtime.workspace.require<any>('first','agent',a.id).revision).toBe(2)
+})
+it('detects a concurrent edit after asynchronous model validation without overwriting the new record', async () => {
+  const a=runtime.workspace.createAgent('first',{name:'并发编辑',profile:'default'})
+  stubBotModels(null,()=>runtime.workspace.updateAgent('first',a.id,{name:'另一端的新名称'}))
+  await req('patch',`/api/app/agents/${a.id}`).send({expectedRevision:1,modelSettings:null}).expect(409)
+  expect(runtime.workspace.require<any>('first','agent',a.id)).toMatchObject({name:'另一端的新名称',revision:2})
+  expect(runtime.workspace.require<any>('first','agent',a.id).modelSettings).toBeUndefined()
+})
+it('preserves ordinary Bot editing when the installed bridge predates model settings', async () => {
+  const a=runtime.workspace.createAgent('first',{name:'旧工具桥',profile:'default'}),path=`/api/app/agents/${a.id}`
+  expect((await req('get',`${path}/model-options`).expect(409)).body.code).toBe('model_settings_upgrade_required')
+  await req('patch',path).send({instructions:'新规则'}).expect(200)
+  await req('patch',path).send({modelSettings:{provider:'openai',model:null,reasoningEffort:null,fastMode:null},expectedRevision:2}).expect(400)
+})
+it('confirms a session-dependent native warning in the profile save flow',async()=>{
+  stubBotModels()
+  const a=runtime.workspace.createAgent('first',{name:'长上下文切换',profile:'default'}),path=`/api/app/agents/${a.id}`
+  runtime.workspace.put('first','agent',a.id,{...a,modelSettingsPendingConfirmation:{target:'["openai","model-a"]',message:'切换长上下文将增加费用'}})
+  const body={expectedRevision:1,modelSettings:null}
+  const warning=(await req('patch',path).send(body).expect(200)).body
+  expect(warning.confirmationMessage).toContain('长上下文')
+  const saved=(await req('patch',path).send({...body,confirmedModel:warning.confirmationTarget}).expect(200)).body.agent
+  expect(saved.modelSettingsConfirmation).toBe('["openai","model-a"]')
+  expect(saved.modelSettingsPendingConfirmation).toBeUndefined()
+})
+it('saves a new base Profile before reloading and validating its model catalogue',async()=>{
+  const a=runtime.workspace.createAgent('first',{name:'换来源',profile:'other'}),path=`/api/app/agents/${a.id}`
+  runtime.workspace.updateAgent('first',a.id,{modelSettings:{provider:'old-provider',model:'old-model',reasoningEffort:'high',fastMode:'cold'}})
+  // Even an old bridge must allow source-only edits, preserving the draft values.
+  const saved=(await req('patch',path).send({profile:'default'}).expect(200)).body.agent
+  expect(saved.profile).toBe('default');expect(saved.modelSettings.model).toBe('old-model')
+  stubBotModels()
+  expect((await req('get',`${path}/model-options`).expect(200)).body).toMatchObject({revision:saved.revision,settings:saved.modelSettings})
+})