From 5d47f18b73cf524c11844fa0e7c692ff187b7033 Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Fri, 18 Sep 2026 01:13:59 +0800 Subject: [PATCH 1/6] feat(dashboard): give BTW its own pages under More Features --- astrbot/core/config/default.py | 11 + .../src/components/config/AiConfigPanel.vue | 4 +- .../components/shared/CodingAgentsEditor.vue | 893 ++++++++++++++++++ .../components/shared/ConfigItemRenderer.vue | 7 + .../i18n/locales/en-US/core/navigation.json | 3 +- .../en-US/features/config-metadata.json | 4 + .../i18n/locales/en-US/features/config.json | 127 +++ .../i18n/locales/zh-CN/core/navigation.json | 3 +- .../zh-CN/features/config-metadata.json | 4 + .../i18n/locales/zh-CN/features/config.json | 127 +++ .../full/vertical-sidebar/sidebarItem.ts | 5 + dashboard/src/router/MainRoutes.ts | 5 + dashboard/src/views/BtwPage.vue | 320 +++++++ .../tests/aiConfigCapabilities.vitest.ts | 20 +- dashboard/tests/btwPage.vitest.ts | 140 +++ dashboard/tests/codingAgentsEditor.vitest.ts | 433 +++++++++ .../configItemRendererLoopRoutes.vitest.ts | 12 + 17 files changed, 2099 insertions(+), 19 deletions(-) create mode 100644 dashboard/src/components/shared/CodingAgentsEditor.vue create mode 100644 dashboard/src/views/BtwPage.vue create mode 100644 dashboard/tests/btwPage.vitest.ts create mode 100644 dashboard/tests/codingAgentsEditor.vitest.ts diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index de9b9da317..bef7f5296d 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -227,6 +227,7 @@ def get_local_permission_defaults(system: str | None = None) -> dict: "read_only": True, "report_via_conversation": True, "workspace_root": "", + "coding_agents": [], }, "work_session": {"max_age_seconds": 3600}, "plugin_routes": [], @@ -4865,6 +4866,16 @@ def get_local_permission_defaults(system: str | None = None) -> dict: "hint": "每个委派任务在此目录下拥有独立文件夹。留空时使用数据目录下的 btw/workspaces。", "condition": {"btw.work_loop.enabled": True}, }, + "btw.work_loop.coding_agents": { + "description": "第三方编码代理", + "type": "list", + "hint": "可委派写入任务的本地 CLI 代理(如 Claude Code、Codex)。每项包含 id、type、command、权限模式与 provider 预设;provider 会以该 CLI 原生配置层的形式生效,不改动用户全局配置。权限模式与沙箱是该 CLI 自己执行的策略,不是操作系统级隔离。委派会启动本地进程并写入文件,因此还要求工作循环的 Computer Use 运行时为 local(sandbox 下委派等于绕过沙箱),并通过 tool.local_exec 与 tool.file_write 的授权。Claude Code 以非交互方式(-p)运行,没有终端可以回答权限询问:acceptEdits 只自动放行编辑,需要跑 shell 命令的任务会一直等到超时,这类任务要显式选择 bypassPermissions。", + "_special": "select_coding_agents", + # The editor is a list of cards holding a nested preset list; half + # of a row is not enough to lay one out. + "full_width": True, + "condition": {"btw.work_loop.enabled": True}, + }, "btw.work_session.max_age_seconds": { "description": "终态工作会话保留秒数", "type": "int", diff --git a/dashboard/src/components/config/AiConfigPanel.vue b/dashboard/src/components/config/AiConfigPanel.vue index a962e03979..6afce36f4f 100644 --- a/dashboard/src/components/config/AiConfigPanel.vue +++ b/dashboard/src/components/config/AiConfigPanel.vue @@ -368,7 +368,9 @@ const localTabGroups = computed(() => { 'websearch', 'agent_computer_use', 'proactive_capability', - 'btw', + // `btw` lives on its own page now (More Features > BTW Dual Loop); the + // work loop is a separate execution path with its own boundary, and it + // was easy to lose among the model and runner options here. ] .filter((key) => props.metadata?.[key]) .map((key) => ({ diff --git a/dashboard/src/components/shared/CodingAgentsEditor.vue b/dashboard/src/components/shared/CodingAgentsEditor.vue new file mode 100644 index 0000000000..88e9137116 --- /dev/null +++ b/dashboard/src/components/shared/CodingAgentsEditor.vue @@ -0,0 +1,893 @@ + + + + + + diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index 9688e0e0c1..22a840fe7e 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -83,6 +83,12 @@ @update:model-value="emitUpdate" /> + @@ -352,6 +358,7 @@ import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'; import PluginSetSelector from './PluginSetSelector.vue'; import PluginLoopSelector from './PluginLoopSelector.vue'; import CapabilityLoopSelector from './CapabilityLoopSelector.vue'; +import CodingAgentsEditor from './CodingAgentsEditor.vue'; import T2ITemplateEditor from './T2ITemplateEditor.vue'; import DashboardTotpManager from './DashboardTotpManager.vue'; import LocalPermissionMatrix from './LocalPermissionMatrix.vue'; diff --git a/dashboard/src/i18n/locales/en-US/core/navigation.json b/dashboard/src/i18n/locales/en-US/core/navigation.json index 7a25db9b07..356ff77548 100644 --- a/dashboard/src/i18n/locales/en-US/core/navigation.json +++ b/dashboard/src/i18n/locales/en-US/core/navigation.json @@ -49,5 +49,6 @@ "selectVersion": "Select Version", "current": "Current" }, - "pluginWebui": "Plugin Pages" + "pluginWebui": "Plugin Pages", + "btw": "BTW Dual Loop" } diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index bf0986e7ea..a85ff49e74 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -493,6 +493,10 @@ "workspace_root": { "description": "Coding-agent task folder root", "hint": "Each delegated task gets its own folder under this path. Empty uses btw/workspaces inside the data directory." + }, + "coding_agents": { + "description": "Third-party coding agents", + "hint": "Local CLI agents that can be handed write tasks (for example Claude Code or Codex). Each entry carries an id, type, command, permission mode, and provider presets. A provider's endpoint and model take effect as that CLI's own config layer; its key is passed only in the child's environment, is never written to disk, and never rewrites the user's global configuration. A delegation starts a local process and writes files, so the work loop's Computer Use runtime must also be local or sandbox, and `tool.local_exec` and `tool.file_write` must both be authorized." } }, "work_session": { diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index 8eb08f1147..f25c010197 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -218,5 +218,132 @@ "emptyMcp": "There are no enabled MCP servers.", "skillHint": "Skills default to both loops. Workspace Skills remain available only to the work loop. Workspace Skills remain work-only with the local runtime.", "emptySkill": "There are no enabled Skills." + }, + "codingAgentsEditor": { + "hint": "Each entry names a local CLI agent the work loop hands writes to. Only enabled agents are used, and only while the work loop's Computer Use runtime is local. The permission mode and the sandbox are policies the CLI itself enforces, not operating-system isolation, so bypassPermissions and danger-full-access have to be chosen deliberately.", + "empty": "No coding agent yet. Add one to let the work loop delegate writes.", + "newAgent": "New agent", + "addAgent": "Add agent", + "removeAgent": "Remove agent", + "moveUp": "Move up", + "moveDown": "Move down", + "enabled": "Enabled", + "disabledWarning": "A disabled agent is not offered to the work loop.", + "agentId": "ID", + "agentIdHint": "A unique name for this agent. It names the generated CLI config files, so prefer letters, digits, dots, dashes and underscores.", + "missingIdWarning": "An agent without an ID is ignored when the configuration is read.", + "duplicateIdWarning": "Another agent already uses this ID; the later one is ignored when the configuration is read.", + "agentName": "Display name", + "agentType": "Type", + "command": "Command", + "commandHint": "The executable to run. Empty uses the command shown as the placeholder.", + "missingCommandWarning": "A custom agent without a command is ignored when the configuration is read.", + "model": "Model", + "modelHint": "Empty uses the active preset's model.", + "permissionMode": "Permission mode", + "permissionModeHint": "Passed to Claude Code as --permission-mode. A delegated run is non-interactive, so it approves edits only: a task that runs shell commands waits for a confirmation nothing can give. Choose bypassPermissions deliberately when the task needs them.", + "riskyPermissionWarning": "bypassPermissions lets the CLI run without asking. Only use it where the task folder can be discarded.", + "sandbox": "Sandbox", + "sandboxHint": "Passed to Codex as --sandbox.", + "riskySandboxWarning": "danger-full-access removes the Codex sandbox. Only use it where the task folder can be discarded.", + "projectDir": "Extra writable directory", + "projectDirHint": "An optional absolute path added to the run. Empty keeps writes inside the task folder.", + "extraArgs": "Extra arguments", + "extraArgsHint": "Passed before the flags AstrBot manages, so a managed flag always wins.", + "addArgument": "Add argument", + "env": "Environment", + "envHint": "Added to the child process only. One NAME=value per entry.", + "addEnv": "Add variable", + "timeoutSeconds": "Timeout (seconds)", + "maxOutputChars": "Output limit (characters)", + "providers": "Provider presets", + "providersHint": "The active preset's endpoint and model take effect as this CLI's own config layer. Its key is passed only in the child's environment, never written to disk.", + "providersCustomHint": "A custom CLI does not read a generated config layer; give it credentials through Environment instead.", + "noProviders": "No preset. The CLI falls back to the account you already signed in with.", + "activeProvider": "Active preset", + "activeProviderHint": "The preset handed to this CLI.", + "addProvider": "Add preset", + "removeProvider": "Remove preset", + "providerId": "ID", + "providerName": "Name", + "providerBaseUrl": "Base URL", + "providerModel": "Model", + "providerApiKey": "API key", + "providerApiKeyHint": "Passed to the child process for the length of the run and never written to the configuration.", + "providerWireApi": "Wire API", + "providerWireApiHint": "Codex only. \"responses\" is the default; \"chat\" selects the chat completions API." + }, + "btwPage": { + "title": "BTW Dual Loop", + "subtitle": "The conversation loop answers and the work loop carries out longer tasks. The work loop reads and plans: anything that has to write is handed to a coding agent running in a task folder of its own.", + "appliesTo": "Configuration", + "appliesToHint": "Which configuration profile these settings are saved to.", + "loadError": "Could not load the BTW settings.", + "retry": "Retry", + "metadataMissing": "This build does not describe the BTW settings, so there is nothing to edit here.", + "actions": "BTW settings actions", + "save": "Save", + "saveSuccess": "BTW settings saved", + "saveError": "Could not save the BTW settings", + "twoFactorRejected": "That code was not accepted. Enter the current one.", + "unsavedTitle": "Unsaved BTW settings", + "unsavedMessage": "Leaving now discards them. The work loop's boundary is among what would be lost." + }, + "cliConfigPage": { + "title": "CLI Provider Switching", + "subtitle": "Keep a list of providers for Claude Code and Codex, and switch a CLI to one of them. Switching writes that provider into the CLI's own configuration, so the CLI uses it everywhere -- including the sessions you start by hand.", + "untouchedHint": "A delegated coding task is not affected: it layers its own configuration over these files instead of reading them, so switching a provider here never changes how a task runs.", + "loadError": "Could not read the CLI configuration.", + "retry": "Retry", + "claudeCode": "Claude Code", + "codex": "Codex", + "managed": "Written by AstrBot", + "credentialStored": "A key is stored", + "fileExists": "The file exists.", + "fileAbsent": "The file does not exist yet and will be created on the first switch.", + "noProviders": "No provider yet. Add one to be able to switch this CLI.", + "noBaseUrl": "no endpoint", + "current": "In use", + "enable": "Switch", + "edit": "Edit", + "duplicate": "Duplicate", + "delete": "Delete", + "restore": "Take back", + "addProvider": "Add provider", + "editProvider": "Edit provider", + "providerId": "ID", + "providerIdHint": "A unique name for this provider. It names the stored credential, so prefer letters, digits, dots, dashes and underscores.", + "providerName": "Display name", + "baseUrl": "Endpoint", + "baseUrlHintClaude": "For example https://api.example.com -- written as ANTHROPIC_BASE_URL.", + "baseUrlHintCodex": "For example https://api.example.com/v1 -- written as the Codex provider's base_url.", + "apiKey": "API key", + "apiKeyHint": "Stored in the CLI's own configuration file on this host, readable only by you. Leave empty when editing to keep the stored key.", + "model": "Model", + "modelHint": "Optional. Written as the CLI's default model.", + "note": "Note", + "keyPresent": "Key stored", + "keyAbsent": "No key stored", + "cancel": "Cancel", + "confirm": "Save", + "save": "Save list", + "saved": "Provider list saved", + "saveError": "Could not save the provider list", + "idRequired": "A provider needs an ID.", + "endpointOrKeyRequired": "A provider needs an endpoint or an API key.", + "idTaken": "Another provider already uses this ID.", + "switchTitle": "Replace the CLI's own configuration?", + "switchMessage": "This rewrites the file the CLI reads on this host, including the stored key. Your previous file is kept once as a backup, and “Take back” restores it.", + "deleteTitle": "Remove this provider?", + "deleteMessage": "It is removed from the list. If the CLI is currently using it, the file it wrote is left as it is until you switch or take it back.", + "restoreTitle": "Take AstrBot's configuration back out?", + "restoreMessage": "The file is restored from the backup taken before the first switch, if there is one.", + "switched": "Switched. The CLI now uses this provider.", + "switchError": "Could not switch the CLI's provider.", + "restored": "Restored.", + "restoreError": "Could not restore the CLI configuration.", + "unsavedTitle": "Unsaved provider list", + "unsavedMessage": "Leaving now discards the changes to the list.", + "actions": "Provider list actions" } } diff --git a/dashboard/src/i18n/locales/zh-CN/core/navigation.json b/dashboard/src/i18n/locales/zh-CN/core/navigation.json index ea698b56b3..f0a95d0b0a 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/navigation.json +++ b/dashboard/src/i18n/locales/zh-CN/core/navigation.json @@ -49,5 +49,6 @@ "selectVersion": "选择版本", "current": "当前" }, - "pluginWebui": "插件页面" + "pluginWebui": "插件页面", + "btw": "BTW 双循环" } diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 6948b03280..b615123ceb 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -487,6 +487,10 @@ "workspace_root": { "description": "编码代理任务目录根路径", "hint": "每个委派任务在此目录下拥有独立文件夹。留空时使用数据目录下的 btw/workspaces。" + }, + "coding_agents": { + "description": "第三方编码代理", + "hint": "可委派写入任务的本地 CLI 代理(如 Claude Code、Codex)。每项包含 id、type、command、权限模式与 provider 预设;provider 的端点与模型会以该 CLI 原生配置层的形式生效,密钥只在运行时经环境变量传入、不落盘,也不改动用户全局配置。委派会启动本地进程并写入文件,因此还要求工作循环的 Computer Use 运行时为 local 或 sandbox,并通过 tool.local_exec 与 tool.file_write 的授权。" } }, "work_session": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index 332efe9e67..41d180be7a 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -218,5 +218,132 @@ "emptyMcp": "当前没有已启用的 MCP 服务器。", "skillHint": "Skill 默认注入两个循环;工作区 Skill 仍仅在工作循环中可用。 工作区 Skill 仅在本地工作循环可用。", "emptySkill": "当前没有已启用的 Skill。" + }, + "codingAgentsEditor": { + "hint": "每一项声明一个可被工作循环委派写入的本地 CLI 代理。只有已启用、且工作循环的 Computer Use 运行时为 local 时才会使用该代理。权限模式与沙箱由该 CLI 自己执行,不是操作系统级隔离,因此 bypassPermissions 与 danger-full-access 必须显式选择。", + "empty": "暂无编码代理。添加一个后工作循环才能委派写入。", + "newAgent": "新代理", + "addAgent": "添加代理", + "removeAgent": "删除代理", + "moveUp": "上移", + "moveDown": "下移", + "enabled": "已启用", + "disabledWarning": "未启用的代理不会交给工作循环使用。", + "agentId": "ID", + "agentIdHint": "该代理的唯一名称。它会作为生成配置文件的文件名,建议只用字母、数字、点、短横线和下划线。", + "missingIdWarning": "没有 ID 的代理在读取配置时会被忽略。", + "duplicateIdWarning": "已有代理使用该 ID;读取配置时后者会被忽略。", + "agentName": "显示名称", + "agentType": "类型", + "command": "命令", + "commandHint": "要运行的可执行文件。留空时使用占位符中显示的默认命令。", + "missingCommandWarning": "自定义代理缺少命令时,读取配置会被忽略。", + "model": "模型", + "modelHint": "留空时使用当前预设的模型。", + "permissionMode": "权限模式", + "permissionModeHint": "以 --permission-mode 传给 Claude Code。委派是非交互运行,只自动放行编辑:需要跑 shell 命令的任务会一直等一个没人能给的确认。确实需要时请显式选择 bypassPermissions。", + "riskyPermissionWarning": "bypassPermissions 会让该 CLI 不再询问直接执行。仅建议在任务目录可随时丢弃时使用。", + "sandbox": "沙箱", + "sandboxHint": "以 --sandbox 传给 Codex。", + "riskySandboxWarning": "danger-full-access 会取消 Codex 沙箱。仅建议在任务目录可随时丢弃时使用。", + "projectDir": "额外可写目录", + "projectDirHint": "可选,绝对路径,会加入本次运行的可写范围。留空表示只在任务目录内写入。", + "extraArgs": "额外参数", + "extraArgsHint": "排在 AstrBot 托管的参数之前,因此托管参数始终优先。", + "addArgument": "添加参数", + "env": "环境变量", + "envHint": "仅注入子进程。每一条写成 名称=值。", + "addEnv": "添加变量", + "timeoutSeconds": "超时(秒)", + "maxOutputChars": "输出上限(字符)", + "providers": "Provider 预设", + "providersHint": "当前预设的端点与模型会以该 CLI 自己的配置层生效。密钥只在运行时经子进程环境传入,不写入配置文件。", + "providersCustomHint": "自定义 CLI 不会读取生成的配置层,请在环境变量中给它凭据。", + "noProviders": "没有预设。该 CLI 会回退到你已经登录的账号。", + "activeProvider": "当前预设", + "activeProviderHint": "交给该 CLI 使用的预设。", + "addProvider": "添加预设", + "removeProvider": "删除预设", + "providerId": "ID", + "providerName": "名称", + "providerBaseUrl": "Base URL", + "providerModel": "模型", + "providerApiKey": "API Key", + "providerApiKeyHint": "仅在本次运行期间传给子进程,不会写入配置。", + "providerWireApi": "Wire API", + "providerWireApiHint": "仅 Codex 使用。默认 \"responses\",\"chat\" 表示走 chat completions 接口。" + }, + "btwPage": { + "title": "BTW 双循环", + "subtitle": "对话循环负责应答,工作循环负责较长的任务。工作循环只读取与规划:需要写盘的步骤交给在独立任务目录中运行的编码代理。", + "appliesTo": "配置档", + "appliesToHint": "这些设置保存到哪一个配置档。", + "loadError": "无法加载 BTW 设置。", + "retry": "重试", + "metadataMissing": "当前构建没有描述 BTW 设置,这里没有可编辑的内容。", + "actions": "BTW 设置操作", + "save": "保存", + "saveSuccess": "BTW 设置已保存", + "saveError": "BTW 设置保存失败", + "twoFactorRejected": "验证码未被接受,请输入当前验证码。", + "unsavedTitle": "BTW 设置尚未保存", + "unsavedMessage": "现在离开会丢弃这些改动,其中包含工作循环的边界设置。" + }, + "cliConfigPage": { + "title": "CLI 供应商切换", + "subtitle": "为 Claude Code 与 Codex 各维护一份 provider 列表,把某个 CLI 切换到其中之一。切换会把该 provider 写进这个 CLI 自己的配置文件,让它在所有场合生效——包括你手动启动的会话。", + "untouchedHint": "委派任务不受影响:它使用独立的配置层,不读取这些文件,因此这里切换 provider 不会改变任务的运行方式。", + "loadError": "无法读取 CLI 配置。", + "retry": "重试", + "claudeCode": "Claude Code", + "codex": "Codex", + "managed": "由 AstrBot 写入", + "credentialStored": "已存有密钥", + "fileExists": "文件已存在。", + "fileAbsent": "文件尚不存在,首次切换时会创建。", + "noProviders": "尚无 provider。添加一个后即可切换该 CLI。", + "noBaseUrl": "无端点", + "current": "使用中", + "enable": "启用", + "edit": "编辑", + "duplicate": "复制", + "delete": "删除", + "restore": "取回", + "addProvider": "添加 provider", + "editProvider": "编辑 provider", + "providerId": "ID", + "providerIdHint": "该 provider 的唯一名称。它会作为所存密钥的变量名,建议只用字母、数字、点、短横线和下划线。", + "providerName": "显示名称", + "baseUrl": "端点地址", + "baseUrlHintClaude": "例如 https://api.example.com —— 写入 ANTHROPIC_BASE_URL。", + "baseUrlHintCodex": "例如 https://api.example.com/v1 —— 写入 Codex provider 的 base_url。", + "apiKey": "API Key", + "apiKeyHint": "保存在本机该 CLI 自己的配置文件里,仅本机用户可读。编辑时留空表示保留已存的密钥。", + "model": "模型", + "modelHint": "可选。会写成该 CLI 的默认模型。", + "note": "备注", + "keyPresent": "已存密钥", + "keyAbsent": "无密钥", + "cancel": "取消", + "confirm": "保存", + "save": "保存列表", + "saved": "provider 列表已保存", + "saveError": "provider 列表保存失败", + "idRequired": "provider 需要一个 ID。", + "endpointOrKeyRequired": "provider 至少需要端点或 API Key 之一。", + "idTaken": "已有其他 provider 使用该 ID。", + "switchTitle": "要覆盖该 CLI 自己的配置吗?", + "switchMessage": "这会改写该 CLI 在本机读取的配置文件,包括所存的密钥。原文件会在首次切换前备份一次,「取回」可以还原。", + "deleteTitle": "要删除这个 provider 吗?", + "deleteMessage": "它只会从列表中移除。如果该 CLI 正在使用它,已写入的文件会保持原样,直到你切换或取回。", + "restoreTitle": "要取回 AstrBot 的配置吗?", + "restoreMessage": "如果存在首次切换前的备份,会用它还原该文件。", + "switched": "已切换,该 CLI 现在使用这个 provider。", + "switchError": "切换 CLI 的 provider 失败。", + "restored": "已还原。", + "restoreError": "还原 CLI 配置失败。", + "unsavedTitle": "provider 列表尚未保存", + "unsavedMessage": "现在离开会丢弃列表的改动。", + "actions": "provider 列表操作" } } diff --git a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts index 0a25ce5a85..378aaa94b3 100644 --- a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts +++ b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts @@ -112,6 +112,11 @@ const sidebarItem: menu[] = [ icon: 'mdi-vector-link', to: '/subagent', }, + { + title: 'core.navigation.btw', + icon: 'mdi-swap-horizontal', + to: '/btw', + }, { title: 'core.navigation.data', icon: 'mdi-database', diff --git a/dashboard/src/router/MainRoutes.ts b/dashboard/src/router/MainRoutes.ts index 208dc0e7db..b603f9c7f2 100644 --- a/dashboard/src/router/MainRoutes.ts +++ b/dashboard/src/router/MainRoutes.ts @@ -114,6 +114,11 @@ const MainRoutes = { path: '/subagent', component: () => import('@/views/SubAgentPage.vue'), }, + { + name: 'BtwSettings', + path: '/btw', + component: () => import('@/views/BtwPage.vue'), + }, { name: 'CronJobs', path: '/cron', diff --git a/dashboard/src/views/BtwPage.vue b/dashboard/src/views/BtwPage.vue new file mode 100644 index 0000000000..347212aa92 --- /dev/null +++ b/dashboard/src/views/BtwPage.vue @@ -0,0 +1,320 @@ + + + + + + diff --git a/dashboard/tests/aiConfigCapabilities.vitest.ts b/dashboard/tests/aiConfigCapabilities.vitest.ts index c035bdfab3..0c4f544a91 100644 --- a/dashboard/tests/aiConfigCapabilities.vitest.ts +++ b/dashboard/tests/aiConfigCapabilities.vitest.ts @@ -57,7 +57,10 @@ function mountAiPanel(metadata: Record) { } describe('AI capabilities panel', () => { - it('lists the BTW dual-loop group after the other capabilities', async () => { + it('lists only the capability groups that stay in this panel', async () => { + // BTW is deliberately absent: it moved to its own page under More Features, + // because the work loop is a separate execution path with its own boundary + // and was easy to lose among the model and agent-runner options here. await initI18n('en-US'); const wrapper = mountAiPanel(buildMetadata(true)); @@ -65,21 +68,6 @@ describe('AI capabilities panel', () => { expect(capabilitiesTab?.text()).toBe('Capabilities'); await capabilitiesTab?.trigger('click'); - expect(wrapper.findAll('.v4-group').map((group) => group.text())).toEqual([ - 'knowledgebase', - 'websearch', - 'agent_computer_use', - 'proactive_capability', - 'btw', - ]); - }); - - it('omits the BTW group when the panel metadata does not carry it', async () => { - await initI18n('en-US'); - const wrapper = mountAiPanel(buildMetadata(false)); - - await wrapper.findAll('.ai-config-tabs__item')[2]?.trigger('click'); - expect(wrapper.findAll('.v4-group').map((group) => group.text())).toEqual([ 'knowledgebase', 'websearch', diff --git a/dashboard/tests/btwPage.vitest.ts b/dashboard/tests/btwPage.vitest.ts new file mode 100644 index 0000000000..1481b25504 --- /dev/null +++ b/dashboard/tests/btwPage.vitest.ts @@ -0,0 +1,140 @@ +import { flushPromises } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import BtwPage from '@/views/BtwPage.vue'; +import { mountWithVuetify } from './utils/mountWithVuetify'; + +const testState = vi.hoisted(() => ({ + listMock: vi.fn(), + getProfileMock: vi.fn(), + updateProfileMock: vi.fn(), + stepUpMock: vi.fn(), +})); + +vi.mock('@/utils/monacoLoader', () => ({})); + +vi.mock('@guolao/vue-monaco-editor', () => ({ + VueMonacoEditor: { + name: 'VueMonacoEditor', + template: '
', + }, +})); + +vi.mock('@/api/v1', () => ({ + configProfileApi: { + list: testState.listMock, + get: testState.getProfileMock, + update: testState.updateProfileMock, + }, +})); + +vi.mock('@/api/v1/authorization', () => ({ + STEP_UP_TTL_SECONDS: 300, + authorizationApi: { + stepUp: testState.stepUpMock, + webChatStepUp: vi.fn(), + }, +})); + +/** The metadata shape the config endpoints return for the BTW section. */ +const METADATA = { + ai_group: { + metadata: { + btw: { + description: 'BTW 双循环', + type: 'object', + items: { + 'btw.enabled': { + description: 'Enable BTW', + type: 'bool', + hint: 'Experimental.', + }, + }, + }, + }, + }, +}; + +const CONFIG = { btw: { enabled: false } }; + +function mountPage() { + return mountWithVuetify(BtwPage); +} + +describe('BtwPage', () => { + beforeEach(() => { + testState.listMock.mockResolvedValue({ + data: { status: 'ok', data: { info_list: [] } }, + }); + testState.getProfileMock.mockResolvedValue({ + data: { status: 'ok', data: { config: CONFIG, metadata: METADATA } }, + }); + testState.updateProfileMock.mockResolvedValue({ + data: { status: 'ok', message: 'saved' }, + }); + }); + + it('loads through the profile endpoint and renders the BTW settings', async () => { + // Not the system-config endpoint: that one serves the system metadata + // tree alone, so the AI section holding `btw` would be missing and the + // page would report nothing to edit. + const wrapper = mountPage(); + await flushPromises(); + + expect(testState.getProfileMock).toHaveBeenCalledWith('default'); + expect(wrapper.text()).toContain('BTW'); + // The group renders through the shared config renderer, so the settings + // themselves are the ones the config file page used to show. + expect(wrapper.find('.config-item-renderer, .v-input').exists()).toBe(true); + wrapper.unmount(); + }); + + it('reports a load failure instead of rendering an empty form', async () => { + testState.getProfileMock.mockRejectedValue(new Error('nope')); + + const wrapper = mountPage(); + await flushPromises(); + + expect(wrapper.text()).toContain('Could not load the BTW settings'); + wrapper.unmount(); + }); + + it('saves the edited value back to the system profile', async () => { + const wrapper = mountPage(); + await flushPromises(); + + await wrapper.find('.btw-page__save').trigger('click'); + await flushPromises(); + + expect(testState.updateProfileMock).toHaveBeenCalledWith( + 'default', + CONFIG, + expect.objectContaining({ headers: {} }), + ); + wrapper.unmount(); + }); + + it('offers the named profiles alongside the default one', async () => { + testState.listMock.mockResolvedValue({ + data: { + status: 'ok', + data: { + info_list: [ + { id: 'default', name: 'Default' }, + { id: 'work', name: 'Work profile' }, + ], + }, + }, + }); + + const wrapper = mountPage(); + await flushPromises(); + + const select = wrapper.findComponent('.btw-page__scope-select'); + expect(select.exists()).toBe(true); + const values = (select.props('items') as { value: string }[]).map( + (item) => item.value, + ); + expect(values).toEqual(['default', 'work']); + wrapper.unmount(); + }); +}); diff --git a/dashboard/tests/codingAgentsEditor.vitest.ts b/dashboard/tests/codingAgentsEditor.vitest.ts new file mode 100644 index 0000000000..7ac504aecc --- /dev/null +++ b/dashboard/tests/codingAgentsEditor.vitest.ts @@ -0,0 +1,433 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import CodingAgentsEditor from '@/components/shared/CodingAgentsEditor.vue'; +import { mountWithVuetify } from './utils/mountWithVuetify'; + +/** The projected shape the editor emits, for one agent with no presets. */ +const claudeAgent = { + id: 'claude_code', + name: 'Claude Code', + type: 'claude_code', + enabled: true, + command: 'claude', + model: '', + permission_mode: 'acceptEdits', + sandbox: 'workspace-write', + project_dir: '', + extra_args: [], + env: {}, + timeout_seconds: 1800, + max_output_chars: 20000, + active_provider: 'official', + providers: [ + { + id: 'official', + name: 'official', + base_url: '', + api_key: '', + model: '', + wire_api: 'responses', + }, + ], +}; + +function mountEditor(modelValue: unknown) { + return mountWithVuetify(CodingAgentsEditor, { + props: { modelValue }, + }); +} + +/** The component behind one classed control, so its value can be driven. */ +function control(wrapper: ReturnType, selector: string) { + const found = wrapper.findComponent(selector); + expect(found.exists(), `${selector} should be rendered`).toBe(true); + return found; +} + +function lastEmitted(wrapper: ReturnType) { + const emitted = wrapper.emitted('update:modelValue'); + expect(emitted).toBeTruthy(); + const value = emitted!.at(-1)![0] as Record[]; + return value; +} + +describe('CodingAgentsEditor', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('allows class attrs without fragment warnings', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const wrapper = mountWithVuetify(CodingAgentsEditor, { + attrs: { class: 'config-field' }, + props: { modelValue: [claudeAgent] }, + }); + + expect(wrapper.classes()).toContain('config-field'); + expect( + warnSpy.mock.calls.some((args) => + String(args[0]).includes('Extraneous non-props attributes'), + ), + ).toBe(false); + wrapper.unmount(); + }); + + it.each([[null], ['oops'], [undefined], [[null, 7]]])( + 'renders the empty state for the malformed value %s', + (modelValue) => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const wrapper = mountEditor(modelValue); + + expect(wrapper.find('.coding-agents-editor__empty').exists()).toBe(true); + expect(wrapper.find('.coding-agents-editor__agent').exists()).toBe(false); + expect(errorSpy).not.toHaveBeenCalled(); + expect( + warnSpy.mock.calls.some((args) => + String(args[0]).includes('Extraneous non-props attributes'), + ), + ).toBe(false); + wrapper.unmount(); + }, + ); + + it('shows the fields each agent type actually uses', () => { + const wrapper = mountEditor([ + { ...claudeAgent }, + { ...claudeAgent, id: 'codex', type: 'codex' }, + { ...claudeAgent, id: 'other', type: 'custom', command: 'other-cli' }, + ]); + + const agents = wrapper.findAll('.coding-agents-editor__agent'); + expect(agents).toHaveLength(3); + + expect( + agents[0].find('.coding-agents-editor__permission-mode').exists(), + ).toBe(true); + expect(agents[0].find('.coding-agents-editor__sandbox').exists()).toBe( + false, + ); + expect( + agents[1].find('.coding-agents-editor__permission-mode').exists(), + ).toBe(false); + expect(agents[1].find('.coding-agents-editor__sandbox').exists()).toBe( + true, + ); + expect( + agents[2].find('.coding-agents-editor__permission-mode').exists(), + ).toBe(false); + expect(agents[2].find('.coding-agents-editor__sandbox').exists()).toBe( + false, + ); + + for (const agent of agents) { + expect(agent.find('.coding-agents-editor__command').exists()).toBe(true); + expect(agent.find('.coding-agents-editor__extra-args').exists()).toBe( + true, + ); + expect(agent.find('.coding-agents-editor__env').exists()).toBe(true); + expect(agent.find('.coding-agents-editor__providers').exists()).toBe( + true, + ); + } + wrapper.unmount(); + }); + + it('emits the whole normalized list once, and only after an edit', async () => { + const wrapper = mountEditor([claudeAgent]); + expect(wrapper.emitted('update:modelValue')).toBeFalsy(); + + control(wrapper, '.coding-agents-editor__name').vm.$emit( + 'update:modelValue', + 'My Claude', + ); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:modelValue')).toHaveLength(1); + expect(lastEmitted(wrapper)).toEqual([ + { ...claudeAgent, name: 'My Claude' }, + ]); + wrapper.unmount(); + }); + + it('adds an agent with a unique id, a default command and disabled', async () => { + const wrapper = mountEditor([claudeAgent]); + + await wrapper.find('.coding-agents-editor__add-agent').trigger('click'); + + const emitted = lastEmitted(wrapper); + expect(emitted).toHaveLength(2); + expect(emitted[1]).toEqual({ + id: 'agent', + name: '', + type: 'claude_code', + enabled: false, + command: 'claude', + model: '', + permission_mode: 'acceptEdits', + sandbox: 'workspace-write', + project_dir: '', + extra_args: [], + env: {}, + timeout_seconds: 1800, + max_output_chars: 20000, + active_provider: '', + providers: [], + }); + wrapper.unmount(); + }); + + it('does not reuse an id that is already taken', async () => { + const wrapper = mountEditor([{ ...claudeAgent, id: 'agent' }]); + + await wrapper.find('.coding-agents-editor__add-agent').trigger('click'); + + expect(lastEmitted(wrapper)[1]).toMatchObject({ id: 'agent-2' }); + wrapper.unmount(); + }); + + it('removes and reorders agents, and bounds the move buttons', async () => { + const second = { ...claudeAgent, id: 'second', name: 'Second' }; + const wrapper = mountEditor([claudeAgent, second]); + + const cards = wrapper.findAll('.coding-agents-editor__agent'); + expect( + cards[0] + .find('.coding-agents-editor__move-up') + .classes() + .some((name) => name.includes('disabled')), + ).toBe(true); + expect( + cards[1] + .find('.coding-agents-editor__move-down') + .classes() + .some((name) => name.includes('disabled')), + ).toBe(true); + + await cards[0].find('.coding-agents-editor__move-down').trigger('click'); + const reordered = lastEmitted(wrapper); + expect(reordered.map((entry) => entry.id)).toEqual([ + 'second', + 'claude_code', + ]); + + await wrapper.setProps({ modelValue: reordered }); + await wrapper + .findAll('.coding-agents-editor__agent')[0] + .find('.coding-agents-editor__remove-agent') + .trigger('click'); + expect(lastEmitted(wrapper).map((entry) => entry.id)).toEqual([ + 'claude_code', + ]); + wrapper.unmount(); + }); + + it('activates a new preset only when none was active', async () => { + const unset = { ...claudeAgent, active_provider: '', providers: [] }; + const wrapper = mountEditor([unset]); + + await wrapper.find('.coding-agents-editor__add-provider').trigger('click'); + + const added = lastEmitted(wrapper)[0]; + expect(added.providers).toEqual([ + { + id: 'provider', + name: 'provider', + base_url: '', + api_key: '', + model: '', + wire_api: 'responses', + }, + ]); + expect(added.active_provider).toBe('provider'); + wrapper.unmount(); + + const configured = mountEditor([{ ...claudeAgent }]); + await configured + .find('.coding-agents-editor__add-provider') + .trigger('click'); + expect(lastEmitted(configured)[0].active_provider).toBe('official'); + configured.unmount(); + }); + + it('repoints the active preset when the active one is removed', async () => { + const wrapper = mountEditor([ + { + ...claudeAgent, + active_provider: 'official', + providers: [...claudeAgent.providers, { id: 'gw', name: 'gw' }], + }, + ]); + + await wrapper + .find('.coding-agents-editor__remove-provider') + .trigger('click'); + + const entry = lastEmitted(wrapper)[0]; + expect((entry.providers as { id: string }[]).map((p) => p.id)).toEqual([ + 'gw', + ]); + expect(entry.active_provider).toBe('gw'); + wrapper.unmount(); + }); + + it('round-trips extra arguments as a list', async () => { + const wrapper = mountEditor([ + { ...claudeAgent, extra_args: ['--verbose'] }, + ]); + + const list = wrapper + .find('.coding-agents-editor__extra-args') + .findComponent({ name: 'ListConfigItem' }); + expect(list.exists()).toBe(true); + list.vm.$emit('update:modelValue', ['--verbose', '--debug']); + await wrapper.vm.$nextTick(); + + expect(lastEmitted(wrapper)[0].extra_args).toEqual([ + '--verbose', + '--debug', + ]); + wrapper.unmount(); + }); + + it('round-trips environment variables as NAME=value lines', async () => { + const wrapper = mountEditor([ + { ...claudeAgent, env: { ANTHROPIC_MODEL: 'opus' } }, + ]); + + const list = wrapper + .find('.coding-agents-editor__env') + .findComponent({ name: 'ListConfigItem' }); + expect(list.exists()).toBe(true); + expect(list.props('modelValue')).toEqual(['ANTHROPIC_MODEL=opus']); + + list.vm.$emit('update:modelValue', [ + 'A=1', + 'B=2=3', + 'no-equals', + '=orphan', + ]); + await wrapper.vm.$nextTick(); + + // A value keeps its own `=`; a line with no name names nothing. + expect(lastEmitted(wrapper)[0].env).toEqual({ + A: '1', + B: '2=3', + 'no-equals': '', + }); + wrapper.unmount(); + }); + + it('projects a malformed entry the way the backend would read it', async () => { + const wrapper = mountEditor([ + { + id: 42, + type: 'nope', + enabled: 'yes', + permission_mode: 'bogus', + sandbox: null, + extra_args: 'oops', + env: ['oops'], + timeout_seconds: 'x', + max_output_chars: 5, + providers: [null, { id: '' }, { id: 'p' }], + }, + ]); + + control(wrapper, '.coding-agents-editor__type').vm.$emit( + 'update:modelValue', + 'codex', + ); + await wrapper.vm.$nextTick(); + + expect(lastEmitted(wrapper)).toEqual([ + { + id: '', + name: '', + type: 'codex', + enabled: false, + command: 'codex', + model: '', + permission_mode: 'acceptEdits', + sandbox: 'workspace-write', + project_dir: '', + extra_args: [], + env: {}, + timeout_seconds: 1800, + max_output_chars: 20000, + active_provider: 'p', + providers: [ + { + id: 'p', + name: 'p', + base_url: '', + api_key: '', + model: '', + wire_api: 'responses', + }, + ], + }, + ]); + wrapper.unmount(); + }); + + it('marks an unusable entry without dropping it', async () => { + const wrapper = mountEditor([ + { id: '', type: 'custom', command: '', enabled: false }, + ]); + + expect(wrapper.find('.coding-agents-editor__agent').exists()).toBe(true); + const warnings = wrapper.find('.coding-agents-editor__warning').text(); + expect(warnings).toContain('ignored when the configuration is read'); + expect(warnings.length).toBeGreaterThan(0); + expect(wrapper.emitted('update:modelValue')).toBeFalsy(); + wrapper.unmount(); + }); + + it('warns about the permission choices that leave the boundary open', () => { + const wrapper = mountEditor([ + { ...claudeAgent, permission_mode: 'bypassPermissions' }, + { + ...claudeAgent, + id: 'codex', + type: 'codex', + sandbox: 'danger-full-access', + }, + ]); + + const cards = wrapper.findAll('.coding-agents-editor__agent'); + expect(cards[0].find('.coding-agents-editor__warning').text()).toContain( + 'bypassPermissions', + ); + expect(cards[1].find('.coding-agents-editor__warning').text()).toContain( + 'danger-full-access', + ); + wrapper.unmount(); + }); + + it('follows the type default command without overwriting a typed one', async () => { + const wrapper = mountEditor([{ ...claudeAgent }]); + const type = control(wrapper, '.coding-agents-editor__type'); + + type.vm.$emit('update:modelValue', 'codex'); + await wrapper.vm.$nextTick(); + expect(lastEmitted(wrapper)[0]).toMatchObject({ + type: 'codex', + command: 'codex', + }); + wrapper.unmount(); + + const typed = mountEditor([{ ...claudeAgent, command: 'my-claude' }]); + control(typed, '.coding-agents-editor__type').vm.$emit( + 'update:modelValue', + 'codex', + ); + await typed.vm.$nextTick(); + expect(lastEmitted(typed)[0]).toMatchObject({ + type: 'codex', + command: 'my-claude', + }); + typed.unmount(); + }); +}); diff --git a/dashboard/tests/configItemRendererLoopRoutes.vitest.ts b/dashboard/tests/configItemRendererLoopRoutes.vitest.ts index e95d3f62e8..20f6070e33 100644 --- a/dashboard/tests/configItemRendererLoopRoutes.vitest.ts +++ b/dashboard/tests/configItemRendererLoopRoutes.vitest.ts @@ -26,6 +26,9 @@ function mountRenderer(special: string) { props: ['kind'], template: '
{{ kind }}
', }, + CodingAgentsEditor: { + template: '
', + }, ListConfigItem: { template: '
', }, @@ -53,4 +56,13 @@ describe('ConfigItemRenderer loop-route specials', () => { expect(skill.find('.list-config-stub').exists()).toBe(false); skill.unmount(); }); + + it('renders CodingAgentsEditor for a coding-agent profile', () => { + // The regression guard: a `_special` with no branch falls through to the + // string-list editor, which cannot hold the objects this value is made of. + const wrapper = mountRenderer('select_coding_agents'); + expect(wrapper.find('.coding-agents-stub').exists()).toBe(true); + expect(wrapper.find('.list-config-stub').exists()).toBe(false); + wrapper.unmount(); + }); }); From cd002209f004104fab3e13a591eff1087317f3a6 Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Fri, 18 Sep 2026 16:30:14 +0800 Subject: [PATCH 2/6] fix(dashboard): match list entries by id when restoring stored secrets `_restore_redacted_sensitive_config` put a redacted secret back by list position, so a list of objects carrying an `api_key` handed the wrong one back the moment the list moved. Reordering two coding agents, or deleting the first, left the remaining entry holding its neighbour's key, and a copied entry -- whose id the profile has never seen -- kept the `__ASTRBOT_REDACTED__` marker itself, which the CLI switcher would then write into the CLI's own configuration as if it were a credential. Entries that carry an `id` are now matched by it, which is the reading that survives a reorder. An entry the stored list does not name -- a new one, or one the client renamed, which it has no way to say -- falls back to its own position, but only while that stored item is not already claimed by a name, so a fallback can never hand the same secret out twice. A marker with nothing to restore from is blanked rather than stored: it is what a response says instead of a secret, never a secret itself. `sensitive_config_changed` reads the same lists the same way, so a reordered list is no longer reported as a change to a credential -- it was, which made saving one demand `provider.credentials.write`. The editor keys a card on its agent rather than on its slot, so the reveal state of a preset follows the preset instead of the position, and renaming the active preset keeps it active rather than silently falling back to the first one. The wire API select is offered only for Codex, which is the only profile that writes it. AI-Generated: true Generated-At: 2026-09-18T08:29:00Z --- astrbot/dashboard/services/config_service.py | 137 +++++++++++++++--- .../components/shared/CodingAgentsEditor.vue | 51 +++++-- dashboard/tests/codingAgentsEditor.vitest.ts | 49 +++++++ tests/unit/test_dashboard_config_service.py | 126 ++++++++++++++++ 4 files changed, 332 insertions(+), 31 deletions(-) diff --git a/astrbot/dashboard/services/config_service.py b/astrbot/dashboard/services/config_service.py index 0c12f398b5..c8f3927263 100644 --- a/astrbot/dashboard/services/config_service.py +++ b/astrbot/dashboard/services/config_service.py @@ -141,6 +141,98 @@ def _is_sensitive_config_key(key: str | None) -> bool: return normalized.endswith(SENSITIVE_CONFIG_SUFFIXES) +def _item_identity(item: Any) -> str | None: + """Return the ``id`` that names a list item, when it carries one.""" + if not isinstance(item, dict): + return None + identity = item.get("id") + return identity if isinstance(identity, str) and identity else None + + +def _items_by_identity(items: list) -> dict[str, int]: + """Map each item's ``id`` to its position, or ``{}`` when that does not fit. + + A list of objects is read by identity only when every item carries a unique, + non-empty ``id``. Anything else -- a list of scalars, a repeated or missing + id -- leaves position as the only reading the two lists share. + """ + by_id: dict[str, int] = {} + for index, item in enumerate(items): + identity = _item_identity(item) + if identity is None or identity in by_id: + return {} + by_id[identity] = index + return by_id + + +def _stored_twins(posted: list, current: list) -> list[Any]: + """Return the stored item each posted item is the same entry as, if any. + + An entry that carries an ``id`` is matched by it, so that moving, removing + or copying one cannot hand it a neighbour's stored value; that is the + failure this exists to prevent, and it is also what makes the match survive + a reordering. An entry the stored list does not name -- a new one, or one + the client renamed, which it has no way to say -- falls back to its own + position, but only while that stored item is not already claimed by a name, + so a fallback can never hand the same stored value out twice. + """ + current_by_id = _items_by_identity(current) + if not current_by_id: + return [ + current[index] if index < len(current) else None + for index in range(len(posted)) + ] + + twins: list[Any] = [None] * len(posted) + matched: set[int] = set() + claimed: set[int] = set() + for index, item in enumerate(posted): + position = current_by_id.get(_item_identity(item) or "") + if position is None: + continue + twins[index] = current[position] + matched.add(index) + claimed.add(position) + for index in range(len(posted)): + if index in matched or index >= len(current) or index in claimed: + continue + twins[index] = current[index] + return twins + + +def _blank_placeholders(value: Any, *, key_name: str | None) -> Any: + """Replace every secret marker that has no stored value to stand for. + + A response writes ``REDACTED_SECRET_PLACEHOLDER`` where a secret is stored, + so the marker is never itself a secret. A request that carries it for + something the profile does not have -- a copied entry, a hand-built one -- + has nothing to resolve it back to, and keeping it would write the marker + into the configuration as if it were a credential. + """ + if isinstance(value, dict): + blanked = { + key: _blank_placeholders(item, key_name=key) for key, item in value.items() + } + return blanked if blanked != value else value + + if isinstance(value, list): + if key_name and _is_sensitive_config_key(key_name): + blanked = [ + "" if item == REDACTED_SECRET_PLACEHOLDER else item for item in value + ] + return blanked if blanked != value else value + return [_blank_placeholders(item, key_name=key_name) for item in value] + + if ( + key_name + and _is_sensitive_config_key(key_name) + and value == REDACTED_SECRET_PLACEHOLDER + ): + return "" + + return value + + def _redact_sensitive_config(value: Any, *, key_name: str | None = None) -> Any: if isinstance(value, dict): return { @@ -172,6 +264,7 @@ def _restore_redacted_sensitive_config( if isinstance(posted_value, dict) and isinstance(current_value, dict): for key, item in posted_value.items(): if key not in current_value: + posted_value[key] = _blank_placeholders(item, key_name=key) continue posted_value[key] = _restore_redacted_sensitive_config( item, @@ -184,22 +277,22 @@ def _restore_redacted_sensitive_config( if key_name and _is_sensitive_config_key(key_name): restored_items = [] for idx, item in enumerate(posted_value): - if ( - item == REDACTED_SECRET_PLACEHOLDER - and idx < len(current_value) - and isinstance(current_value[idx], str) - ): - restored_items.append(current_value[idx]) - else: + if item != REDACTED_SECRET_PLACEHOLDER: restored_items.append(item) + continue + stored = current_value[idx] if idx < len(current_value) else None + restored_items.append(stored if isinstance(stored, str) else "") return restored_items + twins = _stored_twins(posted_value, current_value) for idx, item in enumerate(posted_value): - if idx >= len(current_value): - break + counterpart = twins[idx] + if counterpart is None: + posted_value[idx] = _blank_placeholders(item, key_name=key_name) + continue posted_value[idx] = _restore_redacted_sensitive_config( item, - current_value[idx], + counterpart, key_name=key_name, ) return posted_value @@ -209,7 +302,7 @@ def _restore_redacted_sensitive_config( and _is_sensitive_config_key(key_name) and posted_value == REDACTED_SECRET_PLACEHOLDER ): - return current_value + return current_value if current_value != REDACTED_SECRET_PLACEHOLDER else "" return posted_value @@ -269,9 +362,10 @@ def changed( return False if isinstance(posted, list): current_list = current if isinstance(current, list) else [] + twins = _stored_twins(posted, current_list) if any( changed( - current_list[index] if index < len(current_list) else None, + twins[index], value, key_name=key_name, path=path, @@ -279,14 +373,19 @@ def changed( for index, value in enumerate(posted) ): return True - return bool( - missing_is_change - and key_name - and _is_sensitive_config_key(key_name) - and any( - item not in (None, "", [], {}) - for item in current_list[len(posted) :] + if not ( + missing_is_change and key_name and _is_sensitive_config_key(key_name) + ): + return False + if _items_by_identity(current_list): + posted_identities = {_item_identity(value) for value in posted} + return any( + _item_identity(item) not in posted_identities + for item in current_list + if item not in (None, "", [], {}) ) + return any( + item not in (None, "", [], {}) for item in current_list[len(posted) :] ) return False diff --git a/dashboard/src/components/shared/CodingAgentsEditor.vue b/dashboard/src/components/shared/CodingAgentsEditor.vue index 88e9137116..3dd8615b78 100644 --- a/dashboard/src/components/shared/CodingAgentsEditor.vue +++ b/dashboard/src/components/shared/CodingAgentsEditor.vue @@ -41,7 +41,7 @@ @@ -344,7 +344,7 @@ @@ -399,7 +399,7 @@ class="coding-agents-editor__provider-api-key" :model-value="provider.api_key" :type=" - revealed.has(providerKey(index, providerIndex)) + revealed.has(providerKey(agentKeys[index], provider.id)) ? 'text' : 'password' " @@ -409,12 +409,12 @@ density="compact" variant="outlined" :append-inner-icon=" - revealed.has(providerKey(index, providerIndex)) + revealed.has(providerKey(agentKeys[index], provider.id)) ? 'mdi-eye-off-outline' : 'mdi-eye-outline' " @click:append-inner=" - toggleRevealed(providerKey(index, providerIndex)) + toggleRevealed(providerKey(agentKeys[index], provider.id)) " @update:model-value=" patchProvider(index, providerIndex, { @@ -438,7 +438,7 @@ " /> - + (); + for (const item of items) { + seen.set(item.id, (seen.get(item.id) ?? 0) + 1); + } + return items.map((item, index) => + item.id && seen.get(item.id) === 1 ? item.id : `#${index}`, + ); +} + +const agentKeys = computed(() => entryKeys(entries.value)); + function patchAgent(index: number, patch: Partial) { commit( entries.value.map((entry, i) => @@ -699,13 +720,19 @@ function patchProvider( patch: Partial, ) { const agent = entries.value[agentIndex]; + const previousId = agent.providers[providerIndex]?.id ?? ''; const providers = agent.providers.map((provider, i) => i === providerIndex ? { ...provider, ...patch } : provider, ); - patchAgent(agentIndex, { - providers, - active_provider: repointActive(providers, agent.active_provider), - }); + // Renaming the preset that was active keeps it active: it is the same + // preset under another name, and falling back to the first one instead would + // silently move the run to a provider the operator did not choose. + const renamed = patch.id === undefined ? '' : text(patch.id).trim(); + const active = + renamed && agent.active_provider === previousId + ? renamed + : repointActive(providers, agent.active_provider); + patchAgent(agentIndex, { providers, active_provider: active }); } /** Keep `active_provider` naming a preset that still exists, as the backend does. */ @@ -824,8 +851,8 @@ function commitNumber( patchAgent(index, { [field]: countOr(raw, fallback, minimum) }); } -function providerKey(agentIndex: number, providerIndex: number): string { - return `${agentIndex}:${providerIndex}`; +function providerKey(agentKey: string, providerId: string): string { + return `${agentKey}:${providerId}`; } function toggleRevealed(key: string) { diff --git a/dashboard/tests/codingAgentsEditor.vitest.ts b/dashboard/tests/codingAgentsEditor.vitest.ts index 7ac504aecc..1fcee6914a 100644 --- a/dashboard/tests/codingAgentsEditor.vitest.ts +++ b/dashboard/tests/codingAgentsEditor.vitest.ts @@ -121,6 +121,17 @@ describe('CodingAgentsEditor', () => { false, ); + // The wire API is a Codex field: only `_codex_profile` writes it. + expect( + agents[0].find('.coding-agents-editor__provider-wire-api').exists(), + ).toBe(false); + expect( + agents[1].find('.coding-agents-editor__provider-wire-api').exists(), + ).toBe(true); + expect( + agents[2].find('.coding-agents-editor__provider-wire-api').exists(), + ).toBe(false); + for (const agent of agents) { expect(agent.find('.coding-agents-editor__command').exists()).toBe(true); expect(agent.find('.coding-agents-editor__extra-args').exists()).toBe( @@ -272,6 +283,44 @@ describe('CodingAgentsEditor', () => { wrapper.unmount(); }); + it('keeps a renamed preset active instead of falling back to the first', async () => { + const wrapper = mountEditor([ + { + ...claudeAgent, + // Active, and not first: renaming it must not repoint to `official`. + active_provider: 'gw', + providers: [ + { + id: 'gw', + name: 'gw', + base_url: '', + api_key: '', + model: '', + wire_api: 'responses', + }, + { + id: 'official', + name: 'official', + base_url: '', + api_key: '', + model: '', + wire_api: 'responses', + }, + ], + }, + ]); + + control(wrapper, '.coding-agents-editor__provider-id').vm.$emit( + 'update:modelValue', + 'gateway', + ); + await wrapper.vm.$nextTick(); + + const entry = lastEmitted(wrapper)[0]; + expect(entry.active_provider).toBe('gateway'); + wrapper.unmount(); + }); + it('round-trips extra arguments as a list', async () => { const wrapper = mountEditor([ { ...claudeAgent, extra_args: ['--verbose'] }, diff --git a/tests/unit/test_dashboard_config_service.py b/tests/unit/test_dashboard_config_service.py index 1e61c14de3..031a031890 100644 --- a/tests/unit/test_dashboard_config_service.py +++ b/tests/unit/test_dashboard_config_service.py @@ -121,6 +121,132 @@ def test_sensitive_config_changed_ignores_redacted_placeholders() -> None: ) +def test_restore_matches_list_entries_by_id_not_position() -> None: + current = { + "coding_agents": [ + {"id": "a", "providers": [{"id": "p1", "api_key": "key-a"}]}, + {"id": "b", "providers": [{"id": "p2", "api_key": "key-b"}]}, + ] + } + posted = copy.deepcopy(current) + posted["coding_agents"].reverse() + for agent in posted["coding_agents"]: + for provider in agent["providers"]: + provider["api_key"] = config_service.REDACTED_SECRET_PLACEHOLDER + + restored = config_service._restore_redacted_sensitive_config(posted, current) + + moved = restored["coding_agents"] + assert [agent["id"] for agent in moved] == ["b", "a"] + assert [agent["providers"][0]["api_key"] for agent in moved] == [ + "key-b", + "key-a", + ] + + +def test_sensitive_config_changed_matches_list_entries_by_id() -> None: + current = { + "providers": [ + {"id": "a", "api_key": "key-a"}, + {"id": "b", "api_key": "key-b"}, + ] + } + posted = { + "providers": [ + {"id": "b", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + {"id": "a", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + ] + } + + assert not config_service.sensitive_config_changed( + current, posted, missing_is_change=False + ) + + posted["providers"][0]["api_key"] = "replacement" + assert config_service.sensitive_config_changed( + current, posted, missing_is_change=False + ) + + +def test_restore_blanks_a_placeholder_with_nothing_to_restore_from() -> None: + current = {"providers": [{"id": "p1", "api_key": "key-a"}]} + posted = { + "providers": [ + {"id": "p1", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + {"id": "p1-copy", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + ] + } + + restored = config_service._restore_redacted_sensitive_config(posted, current) + + assert restored["providers"][0]["api_key"] == "key-a" + assert restored["providers"][1]["api_key"] == "" + + +def test_restore_follows_an_entry_the_client_renamed() -> None: + current = {"providers": [{"id": "p1", "api_key": "key-a"}]} + posted = { + "providers": [ + {"id": "p1-renamed", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER} + ] + } + + restored = config_service._restore_redacted_sensitive_config(posted, current) + + assert restored["providers"][0]["api_key"] == "key-a" + + +def test_restore_never_hands_one_stored_key_to_two_entries() -> None: + current = { + "providers": [ + {"id": "a", "api_key": "key-a"}, + {"id": "b", "api_key": "key-b"}, + ] + } + posted = { + "providers": [ + {"id": "b", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + {"id": "new", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + ] + } + + restored = config_service._restore_redacted_sensitive_config(posted, current) + + assert [item["api_key"] for item in restored["providers"]] == ["key-b", ""] + + +def test_restore_keeps_position_when_ids_do_not_name_the_entries() -> None: + current = {"items": [{"name": "x", "api_key": "key-a"}]} + posted = { + "items": [{"name": "x", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}] + } + + restored = config_service._restore_redacted_sensitive_config(posted, current) + + assert restored["items"][0]["api_key"] == "key-a" + + repeated = { + "items": [ + {"id": "same", "api_key": "key-a"}, + {"id": "same", "api_key": "key-b"}, + ] + } + repeated_posted = { + "items": [ + {"id": "same", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + {"id": "same", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + ] + } + restored_repeated = config_service._restore_redacted_sensitive_config( + repeated_posted, repeated + ) + + assert [item["api_key"] for item in restored_repeated["items"]] == [ + "key-a", + "key-b", + ] + + def test_profile_and_system_config_responses_redact_secrets() -> None: current = { "dashboard": {"jwt_secret": "jwt-secret"}, From 9939f45e92acff94966d3718c5f39fc75e5ddb72 Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Fri, 18 Sep 2026 16:30:20 +0800 Subject: [PATCH 3/6] fix(dashboard): keep unsaved BTW edits across a profile switch The profile select reloaded the moment it changed, so an edit still in hand was replaced and the form remounted around it -- for a page whose unsaved state is the work loop's boundary, that is the one thing not to lose quietly. The config page already answers this with a save / discard / stay prompt, so the BTW page uses the same one rather than a second wording of the same question. The select is bound one way on purpose: it stays on the profile being edited until the page decides to move, so staying needs no undo of a move that should not have happened. The select also carried both a persistent hint and `hide-details`. In Vuetify 3 those cancel out and the hint is never rendered, so the hint is now the only one of the two left. AI-Generated: true Generated-At: 2026-09-18T08:29:30Z --- dashboard/src/views/BtwPage.vue | 97 ++++++++++++++++--- dashboard/tests/btwPage.vitest.ts | 154 ++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 14 deletions(-) diff --git a/dashboard/src/views/BtwPage.vue b/dashboard/src/views/BtwPage.vue index 347212aa92..59ec64d98e 100644 --- a/dashboard/src/views/BtwPage.vue +++ b/dashboard/src/views/BtwPage.vue @@ -37,7 +37,7 @@ @@ -105,6 +106,7 @@ import ConfigDocsLink from '@/components/shared/ConfigDocsLink.vue'; import DashboardStepUpDialog from '@/components/shared/DashboardStepUpDialog.vue'; import DashboardTwoFactorDialog from '@/components/shared/DashboardTwoFactorDialog.vue'; import FloatingActionStack from '@/components/ui/FloatingActionStack.vue'; +import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue'; import { useDashboardStepUp } from '@/composables/useDashboardStepUp'; import { useModuleI18n } from '@/i18n/composables'; import { runConfigMutationWithStepUp, stepUpHeaders } from '@/utils/stepUp'; @@ -115,8 +117,19 @@ defineOptions({ name: 'BtwPage' }); /** The id the backend uses for the profile that is the running configuration. */ const SYSTEM_SCOPE = 'default'; +interface UnsavedChangesDialogExposed { + open: (options: { + title: string; + message: string; + confirmHint: string; + cancelHint: string; + closeHint: string; + }) => Promise; +} + const { tm } = useModuleI18n('features/config'); const confirmDialog = useConfirmDialog(); +const unsavedChangesDialog = ref(null); const { dialogOpen: stepUpOpen, @@ -219,7 +232,20 @@ const hasUnsavedChanges = computed( ); async function save(twoFactorCode = '') { - if (saving.value) return; + await saveProfile(scope.value, twoFactorCode); +} + +/** + * Save the settings currently in hand into ``target``. + * + * Separate from ``save`` because switching profiles has to write the profile + * the operator is leaving, which is no longer the one the select shows. + * + * Returns: + * Whether the profile now holds what the page was showing. + */ +async function saveProfile(target: string, twoFactorCode = '') { + if (saving.value) return false; saving.value = true; const headers: Record = {}; if (twoFactorCode) headers['X-2FA-Code'] = twoFactorCode; @@ -233,18 +259,14 @@ async function save(twoFactorCode = '') { validateStatus: (status: number) => (status >= 200 && status < 300) || status === 401, }; - return configProfileApi.update( - scope.value, - configData.value, - requestConfig, - ); + return configProfileApi.update(target, configData.value, requestConfig); }, - scope.value, + target, requestStepUp, ); if (!response) { saving.value = false; - return; + return false; } const payload = asRecord(response.data?.data); @@ -254,7 +276,7 @@ async function save(twoFactorCode = '') { : ''; twoFactorOpen.value = true; saving.value = false; - return; + return false; } if (response.data?.status === 'ok') { @@ -262,11 +284,13 @@ async function save(twoFactorCode = '') { twoFactorError.value = ''; savedSnapshot.value = snapshot(configData.value); showSnack(response.data?.message || tm('btwPage.saveSuccess'), 'success'); - } else { - showSnack(response.data?.message || tm('btwPage.saveError'), 'error'); + return true; } + showSnack(response.data?.message || tm('btwPage.saveError'), 'error'); + return false; } catch { showSnack(tm('btwPage.saveError'), 'error'); + return false; } finally { saving.value = false; } @@ -277,6 +301,51 @@ function confirmTwoFactor(code: string) { void save(code); } +function createUnsavedChangesDialogOptions(message: string) { + return { + title: tm('unsavedChangesWarning.dialogTitle'), + message, + confirmHint: `${tm('unsavedChangesWarning.options.saveAndSwitch')}:${tm('unsavedChangesWarning.options.confirm')}`, + cancelHint: `${tm('unsavedChangesWarning.options.discardAndSwitch')}:${tm('unsavedChangesWarning.options.cancel')}`, + closeHint: `${tm('unsavedChangesWarning.options.closeCard')}:"x"`, + }; +} + +async function openUnsavedChangesDialog(message: string) { + return ( + (await unsavedChangesDialog.value?.open( + createUnsavedChangesDialogOptions(message), + )) ?? false + ); +} + +/** + * Point the page at another profile, without losing edits made in this one. + * + * The select is bound one way on purpose: it stays on the profile being + * edited until this decides to move, so declining the prompt needs no undo. + */ +async function onScopeChange(next: unknown) { + const target = typeof next === 'string' ? next : ''; + if (!target || target === scope.value) return; + + if (!hasUnsavedChanges.value) { + scope.value = target; + await loadConfig(); + return; + } + + const previous = scope.value; + const saveAndSwitch = await openUnsavedChangesDialog( + tm('unsavedChangesWarning.switchConfig'), + ); + if (saveAndSwitch === 'close') return; + if (saveAndSwitch && !(await saveProfile(previous))) return; + + scope.value = target; + await loadConfig(); +} + onBeforeRouteLeave(async () => { if (!hasUnsavedChanges.value) return true; // Staying is the safe answer: what is unsaved is the work loop's boundary. diff --git a/dashboard/tests/btwPage.vitest.ts b/dashboard/tests/btwPage.vitest.ts index 1481b25504..3f94ba5356 100644 --- a/dashboard/tests/btwPage.vitest.ts +++ b/dashboard/tests/btwPage.vitest.ts @@ -27,6 +27,33 @@ vi.mock('@/api/v1', () => ({ }, })); +/** + * The save/discard/stay prompt, under test control. + * + * It is stubbed rather than driven through the DOM because the real one renders + * inside a teleported dialog, and what matters here is which way the page goes + * for each answer. + */ +const dialogControl = vi.hoisted(() => ({ + answer: false as boolean | 'close', + asked: false, +})); + +vi.mock('@/components/config/UnsavedChangesConfirmDialog.vue', () => ({ + default: { + name: 'UnsavedChangesConfirmDialog', + setup(_props: unknown, { expose }: { expose: (api: object) => void }) { + expose({ + open: () => { + dialogControl.asked = true; + return Promise.resolve(dialogControl.answer); + }, + }); + return () => null; + }, + }, +})); + vi.mock('@/api/v1/authorization', () => ({ STEP_UP_TTL_SECONDS: 300, authorizationApi: { @@ -62,6 +89,12 @@ function mountPage() { describe('BtwPage', () => { beforeEach(() => { + dialogControl.answer = false; + dialogControl.asked = false; + CONFIG.btw.enabled = false; + testState.listMock.mockReset(); + testState.getProfileMock.mockReset(); + testState.updateProfileMock.mockReset(); testState.listMock.mockResolvedValue({ data: { status: 'ok', data: { info_list: [] } }, }); @@ -137,4 +170,125 @@ describe('BtwPage', () => { expect(values).toEqual(['default', 'work']); wrapper.unmount(); }); + + it('switches profiles without asking when there is nothing unsaved', async () => { + testState.listMock.mockResolvedValue({ + data: { + status: 'ok', + data: { info_list: [{ id: 'work', name: 'Work' }] }, + }, + }); + + const wrapper = mountPage(); + await flushPromises(); + await switchScope(wrapper, 'work'); + + expect(dialogControl.asked).toBe(false); + expect(testState.getProfileMock).toHaveBeenLastCalledWith('work'); + wrapper.unmount(); + }); + + it('stays on the profile being edited when the prompt is closed', async () => { + testState.listMock.mockResolvedValue({ + data: { + status: 'ok', + data: { info_list: [{ id: 'work', name: 'Work' }] }, + }, + }); + dialogControl.answer = 'close'; + + const wrapper = mountPage(); + await flushPromises(); + CONFIG.btw.enabled = true; + await switchScope(wrapper, 'work'); + + expect(dialogControl.asked).toBe(true); + // The select is bound one way, so closing the prompt leaves it where it + // was rather than needing the page to undo the move v-model would have made. + expect(scopeValue(wrapper)).toBe('default'); + expect(testState.updateProfileMock).not.toHaveBeenCalled(); + expect(testState.getProfileMock).toHaveBeenCalledTimes(1); + wrapper.unmount(); + }); + + it('switches and discards when the prompt is cancelled', async () => { + // The dialog spells this out: its cancel button is "discard and switch". + // The safer outcome, staying, is closing the prompt. + testState.listMock.mockResolvedValue({ + data: { + status: 'ok', + data: { info_list: [{ id: 'work', name: 'Work' }] }, + }, + }); + dialogControl.answer = false; + + const wrapper = mountPage(); + await flushPromises(); + CONFIG.btw.enabled = true; + await switchScope(wrapper, 'work'); + + expect(testState.updateProfileMock).not.toHaveBeenCalled(); + expect(testState.getProfileMock).toHaveBeenLastCalledWith('work'); + wrapper.unmount(); + }); + + it('saves the profile it is leaving before switching away from it', async () => { + testState.listMock.mockResolvedValue({ + data: { + status: 'ok', + data: { info_list: [{ id: 'work', name: 'Work' }] }, + }, + }); + dialogControl.answer = true; + + const wrapper = mountPage(); + await flushPromises(); + CONFIG.btw.enabled = true; + await switchScope(wrapper, 'work'); + + expect(testState.updateProfileMock).toHaveBeenCalledWith( + 'default', + CONFIG, + expect.objectContaining({ headers: {} }), + ); + expect(testState.getProfileMock).toHaveBeenLastCalledWith('work'); + wrapper.unmount(); + }); + + it('stays put when the profile it is leaving cannot be saved', async () => { + testState.listMock.mockResolvedValue({ + data: { + status: 'ok', + data: { info_list: [{ id: 'work', name: 'Work' }] }, + }, + }); + testState.updateProfileMock.mockResolvedValue({ + data: { status: 'error', message: 'no' }, + }); + dialogControl.answer = true; + + const wrapper = mountPage(); + await flushPromises(); + CONFIG.btw.enabled = true; + await switchScope(wrapper, 'work'); + + expect(scopeValue(wrapper)).toBe('default'); + expect(testState.getProfileMock).toHaveBeenCalledTimes(1); + wrapper.unmount(); + }); }); + +/** Move the profile select the way Vuetify would, without the DOM behind it. */ +async function switchScope( + wrapper: ReturnType, + value: string, +) { + await wrapper + .findComponent('.btw-page__scope-select') + .vm.$emit('update:model-value', value); + await flushPromises(); +} + +function scopeValue(wrapper: ReturnType) { + return wrapper.findComponent('.btw-page__scope-select').props('modelValue'); +} From dcc2651573354e02ea69a58875664d36b82d1ba6 Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Fri, 18 Sep 2026 16:30:28 +0800 Subject: [PATCH 4/6] docs: move the BTW entry points the page move left behind The BTW settings left the AI panel on the configuration page for a page of their own, and the developer guide still told operators to open **Config -> AI -> Capabilities -> BTW dual loops** for the per-plugin loop assignments. That path no longer exists; the setting is on the BTW page now. The user guide gains the entry pair the navigation table is for, so an operator following an older sentence lands somewhere, and a short paragraph saying what the page holds -- it is the same group the AI panel used to render, loaded and saved per profile. AI-Generated: true Generated-At: 2026-09-18T08:30:00Z --- docs/en/dev/astrbot-config.md | 2 +- docs/en/use/webui.md | 3 +++ docs/zh/dev/astrbot-config.md | 2 +- docs/zh/use/webui.md | 3 +++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index f77f7c07cd..93c1a9957b 100644 --- a/docs/en/dev/astrbot-config.md +++ b/docs/en/dev/astrbot-config.md @@ -209,7 +209,7 @@ The conversation loop can read the work loop's history: its request context carr ## BTW plugin tool assignments -When BTW is enabled in a configuration profile, **Config → AI → Capabilities → BTW dual loops → Plugin tool loop assignments** assigns each enabled non-system plugin's LLM tools to conversation, work, or both loops. An unassigned plugin defaults to work. Selecting both saves an explicit override; selecting work again removes it. Disabling BTW preserves normal tool availability. +When BTW is enabled in a configuration profile, **More Features → BTW Dual Loop → Plugin tool loop assignments** assigns each enabled non-system plugin's LLM tools to conversation, work, or both loops. An unassigned plugin defaults to work. Selecting both saves an explicit override; selecting work again removes it. Disabling BTW preserves normal tool availability. The main Agent and its subagent handoffs apply the same assignment, together with existing Persona, profile, and authorization restrictions. An assignment never grants permission to execute a tool. Plugin event handlers and explicit commands keep their existing execution path; this setting does not turn an entire plugin into a background task. diff --git a/docs/en/use/webui.md b/docs/en/use/webui.md index cdffdcf892..318069a31a 100644 --- a/docs/en/use/webui.md +++ b/docs/en/use/webui.md @@ -19,9 +19,12 @@ These entries match the default WebUI sidebar. If you customized the sidebar, ch | Config → System Config | Settings → General, Appearance, Network, or Security | | Command Management | Plugins → Manage behavior → Commands | | Standalone MCP / Skills entries | Plugins → MCP / Skills | +| Config → AI → Capabilities → BTW dual loops | More Features → BTW Dual Loop (`/btw`) | Legacy log, trace, conversation, and statistics URLs still redirect to the matching tabs. Agent runners are now saved with each profile instead of being created as a provider; see [Agent Runner](./agent-runner.md). +The BTW dual-loop settings moved out of the AI panel on the configuration page to **More Features → BTW Dual Loop** (`/btw`). The page loads and saves whichever profile it is pointed at, and holds what it held before: both loops and their models, the work loop's read-only boundary, the Computer Use runtime, the task folder root and result reporting, and the coding-agent list. + ## Accessing the Admin Panel After starting AstrBot, the local machine can open the admin panel at `http://localhost:6185`. diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index 3e57a0573b..e69d752d46 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -211,7 +211,7 @@ API Key 属于敏感配置。不要把真实 `cmd_config.json`、截图、日志 ## BTW 插件工具循环分配 -在配置档中启用 BTW 后,可通过 **配置文件 → AI 配置 → 能力 → BTW 双循环 → 插件工具循环分配** 为每个已启用的非系统插件选择对话循环、工作循环或两者。未分配的插件默认仅工作循环可用;选择两者会保存显式覆盖,重新选择工作循环会移除覆盖。关闭 BTW 后保留普通工具可用性。 +在配置档中启用 BTW 后,可通过 **更多功能 → BTW 双循环 → 插件工具循环分配** 为每个已启用的非系统插件选择对话循环、工作循环或两者。未分配的插件默认仅工作循环可用;选择两者会保存显式覆盖,重新选择工作循环会移除覆盖。关闭 BTW 后保留普通工具可用性。 主 Agent 与其子 Agent handoff 应用相同分配,并继续遵守 Persona、配置档与授权限制。循环分配不会授予工具执行权限。插件事件处理器和显式命令保留原有执行路径;此设置不会把整个插件转换为后台任务。 diff --git a/docs/zh/use/webui.md b/docs/zh/use/webui.md index 86e9a29a65..ed3abe1099 100644 --- a/docs/zh/use/webui.md +++ b/docs/zh/use/webui.md @@ -19,9 +19,12 @@ AstrBot 管理面板具有管理插件、查看日志、可视化配置、查看 | 配置文件 → 系统配置 | 左下角设置 → 常规、外观、网络或安全 | | 指令管理 | 插件 → 管理行为 → 指令 | | 独立 MCP / 技能入口 | 插件 → MCP / 技能 | +| 配置文件 → AI 配置 → 能力 → BTW 双循环 | 更多功能 → BTW 双循环(`/btw`) | 旧日志、追踪、对话和统计页面地址仍会跳转到对应的新标签页。Agent 执行器现在随配置文件保存,不再通过新增模型提供商创建;操作步骤见 [Agent 执行器](./agent-runner.md)。 +BTW 双循环的设置从配置文件页的 AI 面板移到了 `更多功能 → BTW 双循环`(`/btw`)。页面按配置文件加载和保存,内容不变:两个循环的开关与模型、工作循环的只读边界、Computer Use 运行时、委派任务的目录与结果汇报,以及编码代理列表。 + ## 管理面板的访问 当启动 AstrBot 之后,本机可以通过 `http://localhost:6185` 访问管理面板。 From 3153eafd2121394304503863686b22985372c16e Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Fri, 18 Sep 2026 16:30:29 +0800 Subject: [PATCH 5/6] fix(i18n): describe the coding-agent settings as they behave Two claims were wrong about what configuring a coding agent does. The Computer Use runtime has to be `local`, not "local or sandbox": a delegated run is a process started on the host, and `sandbox` is explicitly rejected there, so an operator who followed the hint and chose sandbox would add agents and watch them never run. The Chinese hint in `default.py` already said so; this is the English one catching up, in the copy the Dashboard actually renders. The second claim was that a provider key "is never written to disk". It is: the key is stored in the AstrBot profile like any other provider credential. What it is not written into is the CLI's own configuration layer, which the run generates and the key only travels in the child's environment -- a narrower and more useful thing to say. `cliConfigPage` goes with them. This branch ships no `/cli` route and no consumer of those keys; they arrived with the split and only lock in unused copy against the i18n check. AI-Generated: true Generated-At: 2026-09-18T08:30:30Z --- .../en-US/features/config-metadata.json | 2 +- .../i18n/locales/en-US/features/config.json | 61 +------------------ .../zh-CN/features/config-metadata.json | 2 +- .../i18n/locales/zh-CN/features/config.json | 61 +------------------ 4 files changed, 6 insertions(+), 120 deletions(-) diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index a85ff49e74..a94ac21dff 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -496,7 +496,7 @@ }, "coding_agents": { "description": "Third-party coding agents", - "hint": "Local CLI agents that can be handed write tasks (for example Claude Code or Codex). Each entry carries an id, type, command, permission mode, and provider presets. A provider's endpoint and model take effect as that CLI's own config layer; its key is passed only in the child's environment, is never written to disk, and never rewrites the user's global configuration. A delegation starts a local process and writes files, so the work loop's Computer Use runtime must also be local or sandbox, and `tool.local_exec` and `tool.file_write` must both be authorized." + "hint": "Local CLI agents that can be handed write tasks (for example Claude Code or Codex). Each entry carries an id, type, command, permission mode, and provider presets. A provider's endpoint and model take effect as that CLI's own config layer, and its key is passed to the child process for the length of the run rather than written into that layer. The key itself is stored in this AstrBot profile, like any other provider credential. A delegation starts a local process and writes files, so the work loop's Computer Use runtime must be `local`: `sandbox` is not enough, because the process is started on the host rather than inside the sandbox. `tool.local_exec` and `tool.file_write` must both be authorized." } }, "work_session": { diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index f25c010197..a5f687df30 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -257,7 +257,7 @@ "timeoutSeconds": "Timeout (seconds)", "maxOutputChars": "Output limit (characters)", "providers": "Provider presets", - "providersHint": "The active preset's endpoint and model take effect as this CLI's own config layer. Its key is passed only in the child's environment, never written to disk.", + "providersHint": "The active preset's endpoint and model take effect as this CLI's own config layer. Its key is passed to the child process for the length of the run rather than written into that layer; the key itself is stored in this AstrBot profile.", "providersCustomHint": "A custom CLI does not read a generated config layer; give it credentials through Environment instead.", "noProviders": "No preset. The CLI falls back to the account you already signed in with.", "activeProvider": "Active preset", @@ -269,7 +269,7 @@ "providerBaseUrl": "Base URL", "providerModel": "Model", "providerApiKey": "API key", - "providerApiKeyHint": "Passed to the child process for the length of the run and never written to the configuration.", + "providerApiKeyHint": "Passed to the child process for the length of the run. It is stored in this AstrBot profile, and is not written into the CLI's own configuration.", "providerWireApi": "Wire API", "providerWireApiHint": "Codex only. \"responses\" is the default; \"chat\" selects the chat completions API." }, @@ -288,62 +288,5 @@ "twoFactorRejected": "That code was not accepted. Enter the current one.", "unsavedTitle": "Unsaved BTW settings", "unsavedMessage": "Leaving now discards them. The work loop's boundary is among what would be lost." - }, - "cliConfigPage": { - "title": "CLI Provider Switching", - "subtitle": "Keep a list of providers for Claude Code and Codex, and switch a CLI to one of them. Switching writes that provider into the CLI's own configuration, so the CLI uses it everywhere -- including the sessions you start by hand.", - "untouchedHint": "A delegated coding task is not affected: it layers its own configuration over these files instead of reading them, so switching a provider here never changes how a task runs.", - "loadError": "Could not read the CLI configuration.", - "retry": "Retry", - "claudeCode": "Claude Code", - "codex": "Codex", - "managed": "Written by AstrBot", - "credentialStored": "A key is stored", - "fileExists": "The file exists.", - "fileAbsent": "The file does not exist yet and will be created on the first switch.", - "noProviders": "No provider yet. Add one to be able to switch this CLI.", - "noBaseUrl": "no endpoint", - "current": "In use", - "enable": "Switch", - "edit": "Edit", - "duplicate": "Duplicate", - "delete": "Delete", - "restore": "Take back", - "addProvider": "Add provider", - "editProvider": "Edit provider", - "providerId": "ID", - "providerIdHint": "A unique name for this provider. It names the stored credential, so prefer letters, digits, dots, dashes and underscores.", - "providerName": "Display name", - "baseUrl": "Endpoint", - "baseUrlHintClaude": "For example https://api.example.com -- written as ANTHROPIC_BASE_URL.", - "baseUrlHintCodex": "For example https://api.example.com/v1 -- written as the Codex provider's base_url.", - "apiKey": "API key", - "apiKeyHint": "Stored in the CLI's own configuration file on this host, readable only by you. Leave empty when editing to keep the stored key.", - "model": "Model", - "modelHint": "Optional. Written as the CLI's default model.", - "note": "Note", - "keyPresent": "Key stored", - "keyAbsent": "No key stored", - "cancel": "Cancel", - "confirm": "Save", - "save": "Save list", - "saved": "Provider list saved", - "saveError": "Could not save the provider list", - "idRequired": "A provider needs an ID.", - "endpointOrKeyRequired": "A provider needs an endpoint or an API key.", - "idTaken": "Another provider already uses this ID.", - "switchTitle": "Replace the CLI's own configuration?", - "switchMessage": "This rewrites the file the CLI reads on this host, including the stored key. Your previous file is kept once as a backup, and “Take back” restores it.", - "deleteTitle": "Remove this provider?", - "deleteMessage": "It is removed from the list. If the CLI is currently using it, the file it wrote is left as it is until you switch or take it back.", - "restoreTitle": "Take AstrBot's configuration back out?", - "restoreMessage": "The file is restored from the backup taken before the first switch, if there is one.", - "switched": "Switched. The CLI now uses this provider.", - "switchError": "Could not switch the CLI's provider.", - "restored": "Restored.", - "restoreError": "Could not restore the CLI configuration.", - "unsavedTitle": "Unsaved provider list", - "unsavedMessage": "Leaving now discards the changes to the list.", - "actions": "Provider list actions" } } diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index b615123ceb..7974368ae8 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -490,7 +490,7 @@ }, "coding_agents": { "description": "第三方编码代理", - "hint": "可委派写入任务的本地 CLI 代理(如 Claude Code、Codex)。每项包含 id、type、command、权限模式与 provider 预设;provider 的端点与模型会以该 CLI 原生配置层的形式生效,密钥只在运行时经环境变量传入、不落盘,也不改动用户全局配置。委派会启动本地进程并写入文件,因此还要求工作循环的 Computer Use 运行时为 local 或 sandbox,并通过 tool.local_exec 与 tool.file_write 的授权。" + "hint": "可委派写入任务的本地 CLI 代理(如 Claude Code、Codex)。每项包含 id、type、command、权限模式与 provider 预设;provider 的端点与模型会以该 CLI 原生配置层的形式生效,密钥只在运行时传给子进程,不会写进该配置层。密钥本身与其他 provider 凭据一样保存在本配置档中。委派会启动本地进程并写入文件,因此工作循环的 Computer Use 运行时必须是 local:sandbox 不够,因为进程由本机直接拉起,并不在沙箱内。同时需要 tool.local_exec 与 tool.file_write 的授权。" } }, "work_session": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index 41d180be7a..9fc87784b3 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -257,7 +257,7 @@ "timeoutSeconds": "超时(秒)", "maxOutputChars": "输出上限(字符)", "providers": "Provider 预设", - "providersHint": "当前预设的端点与模型会以该 CLI 自己的配置层生效。密钥只在运行时经子进程环境传入,不写入配置文件。", + "providersHint": "当前预设的端点与模型会以该 CLI 自己的配置层生效。密钥只在本次运行期间传给子进程,不会写进该配置层;密钥本身保存在本配置档中。", "providersCustomHint": "自定义 CLI 不会读取生成的配置层,请在环境变量中给它凭据。", "noProviders": "没有预设。该 CLI 会回退到你已经登录的账号。", "activeProvider": "当前预设", @@ -269,7 +269,7 @@ "providerBaseUrl": "Base URL", "providerModel": "模型", "providerApiKey": "API Key", - "providerApiKeyHint": "仅在本次运行期间传给子进程,不会写入配置。", + "providerApiKeyHint": "仅在本次运行期间传给子进程。密钥保存在本配置档中,不会写进该 CLI 自己的配置。", "providerWireApi": "Wire API", "providerWireApiHint": "仅 Codex 使用。默认 \"responses\",\"chat\" 表示走 chat completions 接口。" }, @@ -288,62 +288,5 @@ "twoFactorRejected": "验证码未被接受,请输入当前验证码。", "unsavedTitle": "BTW 设置尚未保存", "unsavedMessage": "现在离开会丢弃这些改动,其中包含工作循环的边界设置。" - }, - "cliConfigPage": { - "title": "CLI 供应商切换", - "subtitle": "为 Claude Code 与 Codex 各维护一份 provider 列表,把某个 CLI 切换到其中之一。切换会把该 provider 写进这个 CLI 自己的配置文件,让它在所有场合生效——包括你手动启动的会话。", - "untouchedHint": "委派任务不受影响:它使用独立的配置层,不读取这些文件,因此这里切换 provider 不会改变任务的运行方式。", - "loadError": "无法读取 CLI 配置。", - "retry": "重试", - "claudeCode": "Claude Code", - "codex": "Codex", - "managed": "由 AstrBot 写入", - "credentialStored": "已存有密钥", - "fileExists": "文件已存在。", - "fileAbsent": "文件尚不存在,首次切换时会创建。", - "noProviders": "尚无 provider。添加一个后即可切换该 CLI。", - "noBaseUrl": "无端点", - "current": "使用中", - "enable": "启用", - "edit": "编辑", - "duplicate": "复制", - "delete": "删除", - "restore": "取回", - "addProvider": "添加 provider", - "editProvider": "编辑 provider", - "providerId": "ID", - "providerIdHint": "该 provider 的唯一名称。它会作为所存密钥的变量名,建议只用字母、数字、点、短横线和下划线。", - "providerName": "显示名称", - "baseUrl": "端点地址", - "baseUrlHintClaude": "例如 https://api.example.com —— 写入 ANTHROPIC_BASE_URL。", - "baseUrlHintCodex": "例如 https://api.example.com/v1 —— 写入 Codex provider 的 base_url。", - "apiKey": "API Key", - "apiKeyHint": "保存在本机该 CLI 自己的配置文件里,仅本机用户可读。编辑时留空表示保留已存的密钥。", - "model": "模型", - "modelHint": "可选。会写成该 CLI 的默认模型。", - "note": "备注", - "keyPresent": "已存密钥", - "keyAbsent": "无密钥", - "cancel": "取消", - "confirm": "保存", - "save": "保存列表", - "saved": "provider 列表已保存", - "saveError": "provider 列表保存失败", - "idRequired": "provider 需要一个 ID。", - "endpointOrKeyRequired": "provider 至少需要端点或 API Key 之一。", - "idTaken": "已有其他 provider 使用该 ID。", - "switchTitle": "要覆盖该 CLI 自己的配置吗?", - "switchMessage": "这会改写该 CLI 在本机读取的配置文件,包括所存的密钥。原文件会在首次切换前备份一次,「取回」可以还原。", - "deleteTitle": "要删除这个 provider 吗?", - "deleteMessage": "它只会从列表中移除。如果该 CLI 正在使用它,已写入的文件会保持原样,直到你切换或取回。", - "restoreTitle": "要取回 AstrBot 的配置吗?", - "restoreMessage": "如果存在首次切换前的备份,会用它还原该文件。", - "switched": "已切换,该 CLI 现在使用这个 provider。", - "switchError": "切换 CLI 的 provider 失败。", - "restored": "已还原。", - "restoreError": "还原 CLI 配置失败。", - "unsavedTitle": "provider 列表尚未保存", - "unsavedMessage": "现在离开会丢弃列表的改动。", - "actions": "provider 列表操作" } } From 41d4a9ec5ed148622ef15f3ca17c2edc520592d3 Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Fri, 18 Sep 2026 17:30:33 +0800 Subject: [PATCH 6/6] style(docs): let Prettier pad the WebUI navigation table Adding a row widened the first column, and the table was re-padded by hand instead of by the formatter, so every pipe in it stopped lining up: `markdownlint-cli2` reports MD060 on the row and `prettier --check` rewrites the whole table. AI-Generated: true Generated-At: 2026-09-18T09:24:00Z --- docs/en/use/webui.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/en/use/webui.md b/docs/en/use/webui.md index 318069a31a..f65ababc04 100644 --- a/docs/en/use/webui.md +++ b/docs/en/use/webui.md @@ -8,18 +8,18 @@ The AstrBot admin panel features plugin management, log viewing, visual configur These entries match the default WebUI sidebar. If you customized the sidebar, check or restore the default layout under `Settings → Appearance` in the lower-left corner. -| Previous entry point | Current entry point | -| --------------------------------------- | ----------------------------------------------------------------------------------- | -| Providers → Add Provider → Agent Runner | Config → Select a profile → AI → `…` next to the AI heading → Change execution mode | -| Data / Dashboard | More → Data → Statistics | -| Conversation Management / Conversations | More → Data → Conversations | -| Logs / Console | More → Data → Logs | -| Trace | More → Data → Trace | -| Config → Normal Config | Config → Select a profile | -| Config → System Config | Settings → General, Appearance, Network, or Security | -| Command Management | Plugins → Manage behavior → Commands | -| Standalone MCP / Skills entries | Plugins → MCP / Skills | -| Config → AI → Capabilities → BTW dual loops | More Features → BTW Dual Loop (`/btw`) | +| Previous entry point | Current entry point | +| ------------------------------------------- | ----------------------------------------------------------------------------------- | +| Providers → Add Provider → Agent Runner | Config → Select a profile → AI → `…` next to the AI heading → Change execution mode | +| Data / Dashboard | More → Data → Statistics | +| Conversation Management / Conversations | More → Data → Conversations | +| Logs / Console | More → Data → Logs | +| Trace | More → Data → Trace | +| Config → Normal Config | Config → Select a profile | +| Config → System Config | Settings → General, Appearance, Network, or Security | +| Command Management | Plugins → Manage behavior → Commands | +| Standalone MCP / Skills entries | Plugins → MCP / Skills | +| Config → AI → Capabilities → BTW dual loops | More Features → BTW Dual Loop (`/btw`) | Legacy log, trace, conversation, and statistics URLs still redirect to the matching tabs. Agent runners are now saved with each profile instead of being created as a provider; see [Agent Runner](./agent-runner.md).