diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index ae84cd0bba..a458c52d86 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -4736,7 +4736,7 @@ }, } -CONFIG_METADATA_3["plugin_group"]["metadata"]["btw"] = { +CONFIG_METADATA_3["ai_group"]["metadata"]["btw"] = { "description": "BTW 双循环", "type": "object", "items": { @@ -4769,6 +4769,7 @@ "description": "工作循环 Computer Use 运行时", "type": "string", "options": ["inherit", "none", "local", "sandbox"], + "labels": ["继承", "无", "本地", "沙箱"], "hint": "inherit 沿用当前 Computer Use 配置。对话循环始终禁用电脑和文件工具;工作循环仍须满足已有角色、路径和沙箱授权规则。", "condition": {"btw.enabled": True}, }, diff --git a/dashboard/src/components/config/AiConfigPanel.vue b/dashboard/src/components/config/AiConfigPanel.vue index 612b3f9f02..a962e03979 100644 --- a/dashboard/src/components/config/AiConfigPanel.vue +++ b/dashboard/src/components/config/AiConfigPanel.vue @@ -368,6 +368,7 @@ const localTabGroups = computed(() => { 'websearch', 'agent_computer_use', 'proactive_capability', + 'btw', ] .filter((key) => props.metadata?.[key]) .map((key) => ({ diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index 9a01822cf6..fd606fb3a1 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -63,6 +63,26 @@ @update:model-value="emitUpdate" /> + + + @@ -323,6 +343,8 @@ import ProviderSelector from './ProviderSelector.vue'; import PersonaSelector from './PersonaSelector.vue'; import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'; import PluginSetSelector from './PluginSetSelector.vue'; +import PluginLoopSelector from './PluginLoopSelector.vue'; +import CapabilityLoopSelector from './CapabilityLoopSelector.vue'; import T2ITemplateEditor from './T2ITemplateEditor.vue'; import DashboardTotpManager from './DashboardTotpManager.vue'; import { computed, ref } from 'vue'; 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 d3c058df1b..a4b9c662f1 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -409,6 +409,58 @@ } } }, + "btw": { + "description": "BTW dual loops", + "btw": { + "enabled": { + "description": "Enable BTW dual loops", + "hint": "Experimental and disabled by default. Ordinary admitted AI requests use the conversation entry over the existing Agent." + }, + "conversation_loop": { + "provider_id": { + "description": "Conversation loop model", + "hint": "Leave empty to keep the current session model selection. When set, this model takes priority." + } + }, + "work_loop": { + "enabled": { + "description": "Enable work loop", + "hint": "Disabled by default. Allow explicit work execution." + }, + "provider_id": { + "description": "Work loop model", + "hint": "Leave empty to keep the current session model selection. When set, this model takes priority." + }, + "computer_use_runtime": { + "description": "Work loop Computer Use runtime", + "hint": "inherit uses the existing Computer Use setting. The conversation loop has no computer or file tools. Work still follows existing role, path, and sandbox authorization rules.", + "labels": ["Inherit", "None", "Local", "Sandbox"] + }, + "max_concurrent": { + "description": "Concurrent work execution", + "hint": "Active execution limit, default 2. This is not a waiting-queue length limit." + } + }, + "work_session": { + "max_age_seconds": { + "description": "Terminal work retention (seconds)", + "hint": "Keep completed, failed or cancelled records for 3600 seconds by default." + } + }, + "plugin_routes": { + "description": "Plugin tool loop assignments", + "hint": "Plugin LLM tools default to the work loop; explicitly assign an enabled plugin to the conversation loop or both when needed." + }, + "mcp_routes": { + "description": "MCP server loop assignments", + "hint": "MCP tools default to the work loop; explicitly assign an enabled server to the conversation loop or both when needed." + }, + "skill_routes": { + "description": "Skill loop assignments", + "hint": "Skills default to both loops; explicitly restrict an enabled Skill to one loop when needed. Workspace Skills remain work-only with the local runtime." + } + } + }, "truncate_and_compress": { "hint": "How AstrBot manages working memory.", "description": "Context Management Strategy", @@ -1202,57 +1254,6 @@ "description": "Available Plugins", "hint": "All non-disabled plugins are enabled by default. If a plugin is disabled on the plugins page, selections here will not take effect." } - }, - "btw": { - "description": "BTW dual loops", - "btw": { - "enabled": { - "description": "Enable BTW dual loops", - "hint": "Experimental and disabled by default. Ordinary admitted AI requests use the conversation entry over the existing Agent." - }, - "work_loop": { - "enabled": { - "description": "Enable work loop", - "hint": "Disabled by default. Allow explicit work execution." - }, - "max_concurrent": { - "description": "Concurrent work execution", - "hint": "Active execution limit, default 2. This is not a waiting-queue length limit." - }, - "provider_id": { - "description": "Work loop model", - "hint": "Leave empty to keep the current session model selection. When set, this model takes priority." - }, - "computer_use_runtime": { - "description": "Work loop Computer Use runtime", - "hint": "inherit uses the existing Computer Use setting. The conversation loop has no computer or file tools. Work still follows existing role, path, and sandbox authorization rules." - } - }, - "work_session": { - "max_age_seconds": { - "description": "Terminal work retention (seconds)", - "hint": "Keep completed, failed or cancelled records for 3600 seconds by default." - } - }, - "conversation_loop": { - "provider_id": { - "description": "Conversation loop model", - "hint": "Leave empty to keep the current session model selection. When set, this model takes priority." - } - }, - "plugin_routes": { - "description": "Plugin tool loop assignments", - "hint": "Plugin LLM tools default to the work loop; explicitly assign an enabled plugin to the conversation loop or both when needed." - }, - "mcp_routes": { - "description": "MCP server loop assignments", - "hint": "MCP tools default to the work loop; explicitly assign an enabled server to the conversation loop or both when needed." - }, - "skill_routes": { - "description": "Skill loop assignments", - "hint": "Skills default to both loops; explicitly restrict an enabled Skill to one loop when needed. Workspace Skills remain work-only with the local runtime." - } - } } }, "ext_group": { 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 7668b1dc35..4edb877fe8 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -403,6 +403,58 @@ } } }, + "btw": { + "description": "BTW 双循环", + "btw": { + "enabled": { + "description": "启用 BTW 双循环", + "hint": "实验功能,默认关闭。普通且已通过准入的 AI 请求经对话入口使用现有 Agent。" + }, + "conversation_loop": { + "provider_id": { + "description": "对话循环模型", + "hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。" + } + }, + "work_loop": { + "enabled": { + "description": "启用工作循环", + "hint": "默认关闭;允许显式工作请求使用工作执行器。" + }, + "provider_id": { + "description": "工作循环模型", + "hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。" + }, + "computer_use_runtime": { + "description": "工作循环 Computer Use 运行时", + "hint": "inherit 沿用当前 Computer Use 配置。对话循环始终禁用电脑和文件工具;工作循环仍须满足已有角色、路径和沙箱授权规则。", + "labels": ["继承", "无", "本地", "沙箱"] + }, + "max_concurrent": { + "description": "工作任务执行并发", + "hint": "同时执行的工作任务数,默认 2。此值不是等待队列的长度限制。" + } + }, + "work_session": { + "max_age_seconds": { + "description": "终态工作会话保留秒数", + "hint": "已完成、失败或取消的工作会话保留时间,默认 3600 秒。" + } + }, + "plugin_routes": { + "description": "插件工具循环分配", + "hint": "插件 LLM 工具默认仅在工作循环可用;可为每个已启用插件显式改为对话循环或两者。" + }, + "mcp_routes": { + "description": "MCP 服务器循环分配", + "hint": "MCP 工具默认仅在工作循环可用;可为每个已启用服务器显式改为对话循环或两者。" + }, + "skill_routes": { + "description": "Skills 循环分配", + "hint": "Skill 默认注入两个循环;可为每个已启用 Skill 显式限制到单一循环。 工作区 Skill 仅在本地工作循环可用。" + } + } + }, "truncate_and_compress": { "hint": "AstrBot 如何管理工作记忆。", "description": "上下文管理策略", @@ -1196,57 +1248,6 @@ "description": "可用插件", "hint": "默认启用全部未被禁用的插件。若插件在插件页面被禁用,则此处的选择不会生效。" } - }, - "btw": { - "description": "BTW 双循环", - "btw": { - "enabled": { - "description": "启用 BTW 双循环", - "hint": "实验功能,默认关闭。普通且已通过准入的 AI 请求经对话入口使用现有 Agent。" - }, - "work_loop": { - "enabled": { - "description": "启用工作循环", - "hint": "默认关闭;允许显式工作请求使用工作执行器。" - }, - "max_concurrent": { - "description": "工作任务执行并发", - "hint": "同时执行的工作任务数,默认 2。此值不是等待队列的长度限制。" - }, - "provider_id": { - "description": "工作循环模型", - "hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。" - }, - "computer_use_runtime": { - "description": "工作循环 Computer Use 运行时", - "hint": "inherit 沿用当前 Computer Use 配置。对话循环始终禁用电脑和文件工具;工作循环仍须满足已有角色、路径和沙箱授权规则。" - } - }, - "work_session": { - "max_age_seconds": { - "description": "终态工作会话保留秒数", - "hint": "已完成、失败或取消的工作会话保留时间,默认 3600 秒。" - } - }, - "conversation_loop": { - "provider_id": { - "description": "对话循环模型", - "hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。" - } - }, - "plugin_routes": { - "description": "插件工具循环分配", - "hint": "插件 LLM 工具默认仅在工作循环可用;可为每个已启用插件显式改为对话循环或两者。" - }, - "mcp_routes": { - "description": "MCP 服务器循环分配", - "hint": "MCP 工具默认仅在工作循环可用;可为每个已启用服务器显式改为对话循环或两者。" - }, - "skill_routes": { - "description": "Skills 循环分配", - "hint": "Skill 默认注入两个循环;可为每个已启用 Skill 显式限制到单一循环。 工作区 Skill 仅在本地工作循环可用。" - } - } } }, "ext_group": { diff --git a/dashboard/tests/aiConfigCapabilities.vitest.ts b/dashboard/tests/aiConfigCapabilities.vitest.ts new file mode 100644 index 0000000000..c035bdfab3 --- /dev/null +++ b/dashboard/tests/aiConfigCapabilities.vitest.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; +import AiConfigPanel from '@/components/config/AiConfigPanel.vue'; +import { initI18n } from '@/i18n/composables'; +import { mountWithVuetify } from './utils/mountWithVuetify'; + +vi.mock('@/utils/monacoLoader', () => ({})); + +vi.mock('@guolao/vue-monaco-editor', () => ({ + VueMonacoEditor: { + name: 'VueMonacoEditor', + template: '
', + }, +})); + +function buildMetadata(includeBtw: boolean) { + const metadata: Record = { + agent_runner: { items: {} }, + ai: { items: {} }, + persona: { items: {} }, + knowledgebase: { description: 'Knowledge Base', items: {} }, + websearch: { description: 'Web Search', items: {} }, + agent_computer_use: { description: 'Computer Use', items: {} }, + proactive_capability: { description: 'Proactive Agent', items: {} }, + others: { items: {} }, + }; + if (includeBtw) { + metadata.btw = { + description: 'BTW dual loops', + type: 'object', + items: { + 'btw.enabled': { description: 'Enable BTW dual loops', type: 'bool' }, + }, + }; + } + return metadata; +} + +function mountAiPanel(metadata: Record) { + return mountWithVuetify(AiConfigPanel, { + props: { + metadata, + configData: { + provider_settings: { enable: true }, + agent_runner: { runner_type: 'local' }, + btw: { enabled: false }, + }, + }, + global: { + stubs: { + AstrBotConfigV4: { + props: ['metadataKey'], + template: '
{{ metadataKey }}
', + }, + }, + }, + }); +} + +describe('AI capabilities panel', () => { + it('lists the BTW dual-loop group after the other capabilities', async () => { + await initI18n('en-US'); + const wrapper = mountAiPanel(buildMetadata(true)); + + const capabilitiesTab = wrapper.findAll('.ai-config-tabs__item')[2]; + 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', + 'agent_computer_use', + 'proactive_capability', + ]); + }); +}); diff --git a/dashboard/tests/configItemRendererLoopRoutes.vitest.ts b/dashboard/tests/configItemRendererLoopRoutes.vitest.ts new file mode 100644 index 0000000000..e95d3f62e8 --- /dev/null +++ b/dashboard/tests/configItemRendererLoopRoutes.vitest.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; +import ConfigItemRenderer from '@/components/shared/ConfigItemRenderer.vue'; +import { mountWithVuetify } from './utils/mountWithVuetify'; + +vi.mock('@/utils/monacoLoader', () => ({})); + +vi.mock('@guolao/vue-monaco-editor', () => ({ + VueMonacoEditor: { + name: 'VueMonacoEditor', + template: '
', + }, +})); + +function mountRenderer(special: string) { + return mountWithVuetify(ConfigItemRenderer, { + props: { + modelValue: [], + itemMeta: { type: 'list', _special: special }, + }, + global: { + stubs: { + PluginLoopSelector: { + template: '
', + }, + CapabilityLoopSelector: { + props: ['kind'], + template: '
{{ kind }}
', + }, + ListConfigItem: { + template: '
', + }, + }, + }, + }); +} + +describe('ConfigItemRenderer loop-route specials', () => { + it('renders PluginLoopSelector for plugin loop assignments', () => { + const wrapper = mountRenderer('select_plugin_loop_routes'); + expect(wrapper.find('.plugin-loop-stub').exists()).toBe(true); + expect(wrapper.find('.list-config-stub').exists()).toBe(false); + wrapper.unmount(); + }); + + it('renders CapabilityLoopSelector for MCP and Skill loop assignments', () => { + const mcp = mountRenderer('select_mcp_loop_routes'); + expect(mcp.find('.capability-loop-stub').text()).toBe('mcp'); + expect(mcp.find('.list-config-stub').exists()).toBe(false); + mcp.unmount(); + + const skill = mountRenderer('select_skill_loop_routes'); + expect(skill.find('.capability-loop-stub').text()).toBe('skill'); + expect(skill.find('.list-config-stub').exists()).toBe(false); + skill.unmount(); + }); +}); diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index 322cbc6075..f62b00a28b 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 → 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, **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. 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/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index ee51f63c47..4ed6fa9834 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 后,可通过 **配置文件 → BTW 双循环 → 插件工具循环分配** 为每个已启用的非系统插件选择对话循环、工作循环或两者。未分配的插件默认仅工作循环可用;选择两者会保存显式覆盖,重新选择工作循环会移除覆盖。关闭 BTW 后保留普通工具可用性。 +在配置档中启用 BTW 后,可通过 **配置文件 → AI 配置 → 能力 → BTW 双循环 → 插件工具循环分配** 为每个已启用的非系统插件选择对话循环、工作循环或两者。未分配的插件默认仅工作循环可用;选择两者会保存显式覆盖,重新选择工作循环会移除覆盖。关闭 BTW 后保留普通工具可用性。 主 Agent 与其子 Agent handoff 应用相同分配,并继续遵守 Persona、配置档与授权限制。循环分配不会授予工具执行权限。插件事件处理器和显式命令保留原有执行路径;此设置不会把整个插件转换为后台任务。 diff --git a/tests/unit/test_config_metadata_i18n.py b/tests/unit/test_config_metadata_i18n.py index bd26ca5194..700074e41c 100644 --- a/tests/unit/test_config_metadata_i18n.py +++ b/tests/unit/test_config_metadata_i18n.py @@ -1,7 +1,5 @@ """Ensure Dashboard config-metadata i18n keys cover runtime metadata.""" -from __future__ import annotations - import json from pathlib import Path from typing import Any @@ -137,23 +135,32 @@ def test_config_metadata_locale_trees_match() -> None: def test_every_btw_profile_field_reaches_dashboard_controls() -> None: converted = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3) - items = converted["plugin_group"]["metadata"]["btw"]["items"] + items = converted["ai_group"]["metadata"]["btw"]["items"] expected_fields = {f"btw.{field}" for field in _flatten(DEFAULT_CONFIG["btw"])} assert set(items) == expected_fields def test_btw_controls_survive_dashboard_metadata_conversion() -> None: converted = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3) - section = converted["plugin_group"]["metadata"]["btw"] - assert section["description"] == "plugin_group.btw.description" + section = converted["ai_group"]["metadata"]["btw"] + assert section["description"] == "ai_group.btw.description" enabled = section["items"]["btw.enabled"] assert enabled["type"] == "bool" - assert enabled["description"] == "plugin_group.btw.btw.enabled.description" - assert enabled["hint"] == "plugin_group.btw.btw.enabled.hint" + assert enabled["description"] == "ai_group.btw.btw.enabled.description" + assert enabled["hint"] == "ai_group.btw.btw.enabled.hint" + runtime = section["items"]["btw.work_loop.computer_use_runtime"] + assert runtime["options"] == ["inherit", "none", "local", "sandbox"] + assert runtime["labels"] == "ai_group.btw.btw.work_loop.computer_use_runtime.labels" for locale in LOCALES: catalog = _load_locale(locale) assert catalog[enabled["description"]] assert catalog[enabled["hint"]] + assert len(catalog[runtime["labels"]]) == len(runtime["options"]) + + +def test_btw_section_stays_out_of_the_plugin_group() -> None: + converted = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3) + assert set(converted["plugin_group"]["metadata"]) == {"plugin"} def test_config_metadata_docs_paths_are_relative_and_preserved() -> None: