Merge pull request #31 from iamyaojie/feat/per-bot-agent-preset

feat: 按机器人选择 Agent Preset
This commit is contained in:
xiemanR 2026-08-21 23:28:59 +08:00 • committed by GitHub
commit 8ab4b9e7de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 2227 additions and 740 deletions

View file

@ -92,7 +92,7 @@ After installation, follow the built-in instructions on each channel page to sca
| Default behavior | Description |
| --- | --- |
| Bot workspace | Each bot stores its workspace independently. New bots start with the Host's current working directory, which can later be changed from the bot card. |
| Agent Preset | New Sessions inherit Harness's `agent-presets.default` unless the channel explicitly overrides it. Later changes do not affect existing Sessions. |
| Agent Preset | Each bot can choose an Agent Preset on its settings card. When none is chosen, new Sessions follow the Host's `agent-presets.default`. A channel-level `config.agentPreset` is only the default for later new bots on that channel. Changing the preset affects only later new Sessions and never clears existing ones. |
Each Telegram bot has its own access-mode control on its bot card. Existing and newly connected bots both default to **Compatible mode**: DMs receive replies, while group messages require a mention of or reply to the bot. Restrictions apply only after explicitly switching that bot to **Safe mode (private-chat allowlist)**. Safe mode ignores every group message and admits only numeric User IDs in that bot's allowlist. Enter one ID per line. Switching back to Compatible mode retains the allowlist without enforcing it, so it is available when Safe mode is enabled again. An empty allowlist in Safe mode rejects all inbound messages for that bot.
@ -147,9 +147,10 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
- **Image understanding**: all nine built-in channels can send JPEG, PNG, WebP, and GIF files sent as images to Harness, with an optional text description. Each image is limited to 5 MB, and all images in one message are limited to 20 MB in total.
- **Switch workspaces from a bot card**: every bot card on the settings page shows its current Harness workspace. Enter an existing absolute directory path directly or open the directory picker. Switching clears only that bot's old chat mappings; it never deletes, empties, or archives old Sessions. Replies already in progress may finish, while later messages use the new workspace.
- **Choose an Agent Preset from a bot card**: every bot card can select one of the Host's existing Agent Presets, or follow the Host default. The change applies only to that bot and only to later new Sessions; existing Sessions and replies already in progress are left unchanged.
- **Check the connection and send a test message**: when a bot is online, clicking **Check connection** verifies the platform connection and sends a “DeepSeek Harness connection test succeeded” message to the bot's most recently remembered direct conversation; WhatsApp uses the account's self-chat. The test neither creates a Harness Session nor invokes the model. The bot must have received at least one direct message before it has a remembered test target; otherwise the page reports that no test conversation is available yet.
- **Retry a connection or remove an integration**: when a bot is offline, its card action changes to **Retry connection**. Use **Remove integration** when the bot is no longer needed. Each action affects only the selected bot and leaves other bots and channels unchanged.
- **Manage multiple bots independently**: a channel can have multiple connected bots. Credentials, connection state, workspace, and chat-to-Session mappings are kept separately for every bot, so card actions do not affect sibling bots.
- **Manage multiple bots independently**: a channel can have multiple connected bots. Credentials, connection state, workspace, Agent Preset, and chat-to-Session mappings are kept separately for every bot, so card actions do not affect sibling bots.
- **Streaming replies and progress**: the plugin uses each platform's available capabilities to show thinking state, tool progress, and incremental answers. Platforms without a native streaming API complete replies through message edits, card updates, or a final message.
## Design

View file

@ -95,7 +95,7 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
| 默认行为 | 说明 |
| --- | --- |
| 机器人工作区 | 每个机器人独立保存工作区。新机器人默认使用 Host 当时的工作目录;之后可在机器人卡片中修改。 |
| Agent Preset | 新会话默认继承 Harness 的 `agent-presets.default`;渠道显式配置优先,已有会话不受后续修改影响。 |
| Agent Preset | 每个机器人可在设置页卡片中选择 Agent Preset。未选择时跟随 Host 的 `agent-presets.default`;渠道级 `config.agentPreset` 只作为该渠道之后新接入机器人的默认值。切换只影响之后新建的会话,不会清空已有会话。 |
每个 Telegram 机器人都可以在自己的卡片中切换访问模式。旧机器人和新接入机器人均默认使用**兼容模式**:私聊直接响应,群聊仅在提及机器人或回复机器人消息时响应。只有主动切换到**安全模式(私聊白名单)**后,机器人才会忽略全部群聊,并只接受该机器人白名单中的数字 User ID。白名单每行一个 ID、按机器人独立保存;切回兼容模式时会保留但不使用,再切回安全模式即可继续使用。安全模式的空白名单会拒绝该机器人的所有入站消息。
@ -150,9 +150,10 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
- **图片识别**:九个内置渠道都可以把 JPEG、PNG、WebP,以及以图片文件方式发送的 GIF 交给 Harness;图片可以附带文字说明。单张图片上限为 5 MB,单条消息中的图片总大小上限为 20 MB。
- **在机器人卡片切换工作区**:设置页中的每张机器人卡片都会显示当前 Harness 工作区。可以直接填写已有目录的绝对路径,也可以打开目录选择器。切换只清除该机器人的旧聊天映射,不会删除、清空或归档旧 Session;已经开始的回复可以继续完成,后续消息使用新工作区。
- **在机器人卡片选择 Agent Preset**:设置页中的每张机器人卡片都可以选择 Host 已有的 Agent Preset,或跟随 Host 默认。切换只作用于该机器人,并且只影响之后新建的会话;已有会话和正在生成的回复不受影响。
- **检查连接并发送测试消息**:机器人在线时,点击卡片上的「检查连接」会检查平台连接,并向该机器人最近记录的私聊发送一条“DeepSeek Harness 连接测试成功”消息;WhatsApp 会发送到账号自聊。测试消息不会创建 Harness Session,也不会调用模型。机器人必须至少收到过一条私聊才能记住测试目标,否则页面会提示尚无可用的测试会话。
- **重试连接和移除接入**:机器人离线时,卡片上的操作会变为「重试连接」;不再使用时可以点击「移除接入」。这些操作都只作用于所选机器人,不影响其他机器人或渠道。
- **多机器人独立管理**:同一渠道可以接入多个机器人。每个机器人分别保存凭据、连接状态、工作区和聊天会话映射,卡片上的工作区、连接检查、重试和移除操作互不影响。
- **多机器人独立管理**:同一渠道可以接入多个机器人。每个机器人分别保存凭据、连接状态、工作区、Agent Preset 和聊天会话映射,卡片上的工作区、Preset、连接检查、重试和移除操作互不影响。
- **流式回复和进度提示**:插件会按各平台能力显示正在思考、工具执行和逐步生成的回答;不支持原生流式接口的平台会通过编辑消息、卡片更新或最终消息完成回复。
## 设计

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,101 @@
import * as React from 'react';
import { h } from './i18n.js';
export const SET_AGENT_PRESET_ENDPOINT = 'bot.preset.set';
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
export const EMPTY_AGENT_PRESET_CATALOG = Object.freeze({
defaultId: '',
items: Object.freeze([]),
});
export const AgentPresetCatalogContext = React.createContext(EMPTY_AGENT_PRESET_CATALOG);
export function normalizeAgentPresetId(value) {
if (typeof value !== 'string') return '';
const id = value.trim();
return PRESET_ID.test(id) ? id : '';
}
export function normalizeAgentPresetCatalog(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { defaultId: '', items: [] };
}
const items = [];
const seen = new Set();
for (const entry of Array.isArray(value.items) ? value.items : []) {
const id = typeof entry === 'string'
? normalizeAgentPresetId(entry)
: normalizeAgentPresetId(entry?.id);
if (!id || seen.has(id)) continue;
seen.add(id);
const label = typeof entry?.label === 'string' && entry.label.trim()
? entry.label.trim().slice(0, 128)
: typeof entry?.name === 'string' && entry.name.trim()
? entry.name.trim().slice(0, 128)
: id;
items.push({ id, label });
}
return {
defaultId: normalizeAgentPresetId(value.defaultId),
items,
};
}
export function AgentPresetEditor({ agentPreset = '', disabled = false, onSave }) {
const catalog = React.useContext(AgentPresetCatalogContext) ?? EMPTY_AGENT_PRESET_CATALOG;
const current = normalizeAgentPresetId(agentPreset);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState(null);
const items = [];
const seen = new Set();
for (const item of Array.isArray(catalog.items) ? catalog.items : []) {
if (!item?.id || seen.has(item.id)) continue;
seen.add(item.id);
items.push(item);
}
if (current && !seen.has(current)) {
items.push({ id: current, label: current });
}
const inheritLabel = '跟随 Host 默认';
const change = async (event) => {
const next = event.target.value;
if (next === current || saving || disabled) return;
setSaving(true);
setError(null);
try {
await onSave?.(next || null);
} catch (cause) {
setError(cause?.message ?? 'Agent Preset 修改失败,请重试。');
} finally {
setSaving(false);
}
};
return h('div', { className: 'dim-preset' },
h('div', { className: 'dim-presetHeader' },
h('span', null, 'Agent Preset'),
saving ? h('span', { className: 'dim-presetStatus' }, '保存中…') : null),
React.createElement('select', {
className: 'dim-presetSelect',
value: current,
disabled: disabled || saving,
'aria-label': 'Agent Preset',
onChange: (event) => { void change(event); },
},
h('option', { value: '' }, inheritLabel),
...items.map((item) => h(
'option',
{ key: item.id, value: item.id },
item.label && item.label !== item.id ? `${item.label}(${item.id})` : item.id,
)),
),
h('small', { className: 'dim-presetHelp' }, '只影响之后新建的会话。'),
error ? h('p', { className: 'dim-presetError', role: 'alert' }, error) : null,
);
}

View file

@ -1,3 +1,5 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
export const DINGTALK_RPC_CHANNEL = '/dingtalk';
export const DINGTALK_ENDPOINTS = Object.freeze({
@ -9,6 +11,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -157,6 +160,7 @@ function normalizeBot(value) {
connected,
configured: value.configured !== false,
workspace: optionalString(value.workspace, 4_096) ?? '',
agentPreset: normalizeAgentPresetId(value.agentPreset),
bot: {
name: optionalString(bot.name, 100) ?? '钉钉机器人',
clientIdMasked: optionalString(bot.clientIdMasked, 140) ?? '已安全保存',
@ -200,6 +204,7 @@ export function normalizeSnapshot(value) {
},
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
testMessage: normalizeTestMessage(source.testMessage),
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog),
};
}

View file

@ -3,6 +3,11 @@ import * as React from 'react';
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import {
DINGTALK_ENDPOINTS,
@ -213,6 +218,7 @@ export function AccountCard({
removing,
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -241,6 +247,11 @@ export function AccountCard({
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h(AgentPresetEditor, {
agentPreset: account.agentPreset,
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
feedback ? h('div', {
@ -272,6 +283,7 @@ function AccountList(props) {
removing: props.removeTarget === account.botId,
onReconnect: () => props.onReconnect(account),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(account, agentPreset),
onRequestRemove: () => props.onRequestRemove(account),
onConfirmRemove: () => props.onConfirmRemove(account),
onCancelRemove: props.onCancelRemove,
@ -283,6 +295,7 @@ const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 });
export function DingtalkSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({
phase: 'loading', bots: [], totals: EMPTY_TOTALS, revision: 0, error: null,
agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
});
const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false);
@ -389,6 +402,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
discardStaleFeedback(snapshot);
if (restoreProvisioning && snapshot.provisioning) {
@ -504,6 +518,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
discardStaleFeedback(snapshot);
}
@ -638,6 +653,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
discardStaleFeedback(snapshot);
}
@ -700,6 +716,33 @@ export function DingtalkSettingsTab({ rpcCall }) {
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
discardStaleFeedback(snapshot);
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBotBusy(account.botId, null);
}
}, [discardStaleFeedback, invoke, loadStatus, setBotBusy, workspaceFence]);
const saveAgentPreset = React.useCallback(async (account, agentPreset) => {
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, 'preset');
try {
const snapshot = normalizeSnapshot(await invoke(
DINGTALK_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset },
));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({
phase: 'ready',
bots: snapshot.bots,
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
discardStaleFeedback(snapshot);
}
@ -762,7 +805,9 @@ export function DingtalkSettingsTab({ rpcCall }) {
})
: null;
return h('section', { className: 'ddt-page dim-channelPage', 'aria-label': '钉钉设置' },
return h(AgentPresetCatalogContext.Provider, {
value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
}, h('section', { className: 'ddt-page dim-channelPage', 'aria-label': '钉钉设置' },
h(Heading, {
totals: model.totals,
adding: Boolean(provision),
@ -798,11 +843,12 @@ export function DingtalkSettingsTab({ rpcCall }) {
removeTarget,
onReconnect: (account) => void reconnect(account),
onWorkspaceSave: saveWorkspace,
onAgentPresetSave: saveAgentPreset,
onRequestRemove: (account) => setRemoveTarget(account.botId),
onConfirmRemove: (account) => void remove(account),
onCancelRemove: () => setRemoveTarget(null),
})
: null));
: null)));
}
export function apply(ctx) {

View file

@ -6,6 +6,8 @@
* must never be returned by any endpoint on this channel.
*/
import { normalizeAgentPresetCatalog, normalizeAgentPresetId } from "../../agent-preset.js";
export const FEISHU_RPC_CHANNEL = "/feishu";
export const FEISHU_ENDPOINTS = Object.freeze({
@ -19,6 +21,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
disconnectBot: "bot.disconnect",
deleteBot: "bot.delete",
setWorkspace: "bot.workspace.set",
setAgentPreset: "bot.preset.set",
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
testConnection: "connection.test",
disconnect: "connection.disconnect",
@ -182,6 +185,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
connected,
configured: value.configured !== false,
workspace: optionalString(value.workspace)?.slice(0, 4_096) ?? "",
agentPreset: normalizeAgentPresetId(value.agentPreset),
bot: normalizeBot(value.bot),
health: normalizeHealth(value.health, connected),
error: normalizeError(value.error),
@ -236,6 +240,7 @@ export function normalizeBotsSnapshot(value) {
? normalizeProvisioning(value.provisioning)
: undefined,
error: normalizeError(value.error),
agentPresetCatalog: normalizeAgentPresetCatalog(value.agentPresetCatalog),
};
}

View file

@ -16,6 +16,11 @@ import {
} from "./api.js";
import { useAnimationFrameScheduler } from "../../lifecycle.js";
import { WorkspaceEditor } from "../../workspace-editor.js";
import {
AgentPresetCatalogContext,
AgentPresetEditor,
EMPTY_AGENT_PRESET_CATALOG,
} from "../../agent-preset.js";
import { useWorkspaceSnapshotFence } from "../../workspace-snapshot-fence.js";
import { installFeishuStyles } from "./styles.js";
@ -418,6 +423,7 @@ export function BotCard({
onReconnect,
onRepairCallback,
onWorkspaceSave,
onAgentPresetSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -468,6 +474,11 @@ export function BotCard({
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h(AgentPresetEditor, {
agentPreset: connection.agentPreset,
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h("div", { className: "bxf-connectedFooter dim-cardFooter" },
summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
summary) : null,
@ -524,6 +535,7 @@ function BotList(props) {
onReconnect: () => props.onReconnect(bot),
onRepairCallback: () => props.onRepairCallback(bot),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(bot, agentPreset),
onRequestRemove: () => props.onRequestRemove(bot),
onConfirmRemove: () => props.onConfirmRemove(bot),
onCancelRemove: props.onCancelRemove,
@ -575,6 +587,7 @@ export function mergeFeishuSnapshotState(
provisioning,
pageError: null,
statusError: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? current.agentPresetCatalog,
};
}
@ -587,6 +600,7 @@ export function FeishuSettingsTab({ rpcCall }) {
provisioning: null,
pageError: null,
statusError: null,
agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
});
const [pageBusy, setPageBusy] = React.useState(false);
const [provisionBusy, setProvisionBusy] = React.useState(false);
@ -1067,6 +1081,26 @@ export function FeishuSettingsTab({ rpcCall }) {
}
}, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
const saveAgentPreset = React.useCallback(async (connection, agentPreset) => {
const { botId } = connection;
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(botId, "preset");
setBotError(botId, null);
try {
const snapshot = normalizeBotsSnapshot(await invoke(
FEISHU_ENDPOINTS.setAgentPreset,
{ botId, agentPreset },
));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
mergeSnapshot(snapshot);
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBotBusy(botId, null);
}
}, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
const requestRemove = React.useCallback((connection) => {
setRemoveTargetId(connection.botId);
}, []);
@ -1173,7 +1207,9 @@ export function FeishuSettingsTab({ rpcCall }) {
else removeButtonRefs.current.delete(botId);
}, []);
return h("section", { className: "bxf-page dim-channelPage", "aria-label": "飞书机器人设置" },
return h(AgentPresetCatalogContext.Provider, {
value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
}, h("section", { className: "bxf-page dim-channelPage", "aria-label": "飞书机器人设置" },
h(Heading, {
totals: model.totals,
onAdd: () => void startProvisioning(),
@ -1217,6 +1253,7 @@ export function FeishuSettingsTab({ rpcCall }) {
onReconnect: (bot) => void reconnectOneBot(bot),
onRepairCallback: repairCallback,
onWorkspaceSave: saveWorkspace,
onAgentPresetSave: saveAgentPreset,
onRequestRemove: requestRemove,
onConfirmRemove: (bot) => void confirmRemove(bot),
onCancelRemove: cancelRemove,
@ -1225,7 +1262,7 @@ export function FeishuSettingsTab({ rpcCall }) {
})
: null,
),
);
));
}
export function apply(ctx) {

View file

@ -1,3 +1,5 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
export const QQ_RPC_CHANNEL = '/qq';
export const QQ_ENDPOINTS = Object.freeze({
@ -9,6 +11,7 @@ export const QQ_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@ -82,6 +85,7 @@ function normalizeBot(value) {
connected,
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
bot: {
name: text(value.bot?.name, 'QQ机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),
@ -117,6 +121,7 @@ export function normalizeSnapshot(value) {
bots,
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog),
...(testMessage ? { testMessage } : {}),
};
}

View file

@ -4,6 +4,11 @@ import { QqLogoGlyph } from '../../channel-logos.js';
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import {
@ -156,6 +161,7 @@ export function AccountCard({
removing,
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -180,6 +186,11 @@ export function AccountCard({
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h(AgentPresetEditor, {
agentPreset: account.agentPreset,
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
feedback ? h('div', {
@ -196,7 +207,10 @@ export function AccountCard({
}
export function QqSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({ phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null });
const [model, setModel] = React.useState({
phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null,
agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
});
const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [busyByBot, setBusyByBot] = React.useState({});
@ -233,7 +247,10 @@ export function QqSettingsTab({ rpcCall }) {
const snapshot = normalizeSnapshot(await invoke(QQ_ENDPOINTS.status, {}, signal));
if (!mounted.current || signal?.aborted
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
if (restore && snapshot.provisioning) setProvision({
...snapshot.provisioning,
durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()),
@ -297,7 +314,10 @@ export function QqSettingsTab({ rpcCall }) {
));
if (!mounted.current) return;
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
}
setCredentialOpen(false);
} catch (error) {
@ -357,7 +377,10 @@ export function QqSettingsTab({ rpcCall }) {
try {
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
}
return snapshot;
} finally {
@ -424,6 +447,12 @@ export function QqSettingsTab({ rpcCall }) {
QQ_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
),
onAgentPresetSave: (agentPreset) => botAction(
account,
'preset',
QQ_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {
@ -447,7 +476,9 @@ export function QqSettingsTab({ rpcCall }) {
})
: null;
return h('section', { className: 'ddt-page dqq-page dim-channelPage', 'aria-label': 'QQ 设置' },
return h(AgentPresetCatalogContext.Provider, {
value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
}, h('section', { className: 'ddt-page dqq-page dim-channelPage', 'aria-label': 'QQ 设置' },
h(Heading, {
totals: model.totals,
adding: Boolean(provision),
@ -465,7 +496,7 @@ export function QqSettingsTab({ rpcCall }) {
provisionView,
model.bots.length === 0 && !provision && !credentialOpen
? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
botList));
botList)));
}
export function apply(ctx) {

View file

@ -1,3 +1,5 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
function isRecord(value) {
@ -25,6 +27,7 @@ export const TOKEN_BOT_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
export function createTokenChannelApi(channel, connectionSummary, {
@ -52,6 +55,7 @@ export function createTokenChannelApi(channel, connectionSummary, {
connected,
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
bot: {
name: text(value.bot?.name, `${channel}机器人`, 100),
username: text(value.bot?.username, '', 100),
@ -82,6 +86,7 @@ export function createTokenChannelApi(channel, connectionSummary, {
revision: Number.isSafeInteger(source.revision) ? source.revision : 0,
bots,
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog),
};
};

View file

@ -4,6 +4,11 @@ import { CredentialActionIcon, CredentialBindingPanel } from '../../credential-b
import { h } from '../../i18n.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
const Button = React.forwardRef(function Button(
@ -63,7 +68,7 @@ export function createTokenChannelSettings(definition) {
accountSettingsEndpoint = null,
} = definition;
function AccountCard({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onAccountSettingsSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
function AccountCard({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onAgentPresetSave, onAccountSettingsSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
const state = busy === 'reconnect' ? 'connecting' : account.state;
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
@ -90,6 +95,11 @@ export function createTokenChannelSettings(definition) {
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h(AgentPresetEditor, {
agentPreset: account.agentPreset,
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
AccountSettings ? h(AccountSettings, {
account,
busy: Boolean(busy),
@ -122,6 +132,7 @@ export function createTokenChannelSettings(definition) {
function SettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({
phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null,
agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
});
const [credentialOpen, setCredentialOpen] = React.useState(false);
const [credentialError, setCredentialError] = React.useState(null);
@ -156,7 +167,10 @@ export function createTokenChannelSettings(definition) {
const snapshot = api.normalizeSnapshot(await invoke(endpoints.status, {}, signal));
if (!mounted.current || signal?.aborted
|| !workspaceFence.canCommitStatus(workspaceVersion)) return;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
} catch (error) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
&& workspaceFence.canCommitStatus(workspaceVersion)) {
@ -199,7 +213,10 @@ export function createTokenChannelSettings(definition) {
));
if (!mounted.current) return;
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
}
setCredentialOpen(false);
} catch (error) {
@ -218,7 +235,10 @@ export function createTokenChannelSettings(definition) {
const value = await invoke(endpoint, payload);
const snapshot = api.normalizeSnapshot(value);
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
}
if (mounted.current && operation === 'reconnect') {
setTestNoticeByBot((current) => ({
@ -267,6 +287,12 @@ export function createTokenChannelSettings(definition) {
endpoints.setWorkspace,
{ botId: account.botId, workspace },
),
onAgentPresetSave: (agentPreset) => botAction(
account,
'preset',
endpoints.setAgentPreset,
{ botId: account.botId, agentPreset },
),
onAccountSettingsSave: AccountSettings && accountSettingsEndpoint
? (payload) => botAction(
account,
@ -287,7 +313,9 @@ export function createTokenChannelSettings(definition) {
})))))
: null;
return h('section', {
return h(AgentPresetCatalogContext.Provider, {
value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
}, h('section', {
className: `ddt-page ${pageClass} dim-channelPage`,
'aria-label': `${channel} 设置`,
},
@ -353,7 +381,7 @@ export function createTokenChannelSettings(definition) {
'aria-hidden': 'true',
}, h(LogoGlyph, { size: 64 }))))
: null,
botList));
botList)));
}
return { SettingsTab, AccountCard };

View file

@ -1,3 +1,5 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
export const WECOM_RPC_CHANNEL = '/wecom';
export const WECOM_ENDPOINTS = Object.freeze({
@ -9,6 +11,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@ -91,6 +94,7 @@ function normalizeBot(value) {
connected,
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
bot: {
name: text(value.bot?.name, '企业微信机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),
@ -116,6 +120,7 @@ export function normalizeSnapshot(value) {
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
testMessage: normalizeTestMessage(source.testMessage),
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog),
};
}

View file

@ -4,6 +4,11 @@ import { WecomLogoGlyph } from '../../channel-logos.js';
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import {
@ -155,6 +160,7 @@ export function AccountCard({
removing,
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -179,6 +185,11 @@ export function AccountCard({
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h(AgentPresetEditor, {
agentPreset: account.agentPreset,
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
feedback ? h('div', {
@ -195,7 +206,10 @@ export function AccountCard({
}
export function WecomSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({ phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null });
const [model, setModel] = React.useState({
phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null,
agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
});
const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [busyByBot, setBusyByBot] = React.useState({});
@ -253,7 +267,10 @@ export function WecomSettingsTab({ rpcCall }) {
const snapshot = normalizeSnapshot(await invoke(WECOM_ENDPOINTS.status, {}, signal));
if (!mounted.current || signal?.aborted
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
if (restore && snapshot.provisioning) setProvision({
...snapshot.provisioning,
durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()),
@ -317,7 +334,10 @@ export function WecomSettingsTab({ rpcCall }) {
));
if (!mounted.current) return;
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
}
setCredentialOpen(false);
} catch (error) {
@ -377,7 +397,10 @@ export function WecomSettingsTab({ rpcCall }) {
try {
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
}
return snapshot;
} finally {
@ -455,6 +478,12 @@ export function WecomSettingsTab({ rpcCall }) {
WECOM_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
),
onAgentPresetSave: (agentPreset) => botAction(
account,
'preset',
WECOM_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {
@ -478,7 +507,9 @@ export function WecomSettingsTab({ rpcCall }) {
})
: null;
return h('section', { className: 'ddt-page dwecom-page dim-channelPage', 'aria-label': '企业微信设置' },
return h(AgentPresetCatalogContext.Provider, {
value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
}, h('section', { className: 'ddt-page dwecom-page dim-channelPage', 'aria-label': '企业微信设置' },
h(Heading, {
totals: model.totals,
adding: Boolean(provision),
@ -497,7 +528,7 @@ export function WecomSettingsTab({ rpcCall }) {
provisionView,
model.bots.length === 0 && !provision && !credentialOpen
? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
botList));
botList)));
}
export function apply(ctx) {

View file

@ -1,3 +1,5 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
export const WEIXIN_RPC_CHANNEL = '/weixin';
export const WEIXIN_ENDPOINTS = Object.freeze({
status: 'connection.status',
@ -8,6 +10,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -124,6 +127,7 @@ function normalizeBot(value) {
connected,
configured: value.configured === true,
workspace: string(value.workspace).slice(0, 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
bot: {
name: string(value.bot.name, '微信机器人'),
accountIdMasked: string(value.bot.accountIdMasked, '已安全保存'),
@ -163,6 +167,7 @@ export function normalizeSnapshot(value) {
},
provisioning: value.provisioning ? normalizeProvisioning(value.provisioning) : null,
testMessage: normalizeTestMessage(value.testMessage),
agentPresetCatalog: normalizeAgentPresetCatalog(value.agentPresetCatalog),
};
}

View file

@ -16,6 +16,11 @@ import {
} from './api.js';
import { createPollScheduler, useAnimationFrameScheduler } from '../../lifecycle.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { installWeixinStyles } from './styles.js';
@ -202,6 +207,7 @@ export function AccountCard({
removing,
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -228,6 +234,11 @@ export function AccountCard({
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h(AgentPresetEditor, {
agentPreset: account.agentPreset,
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h('div', { className: 'dxw-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
account.lastMessageError ? h('div', {
@ -264,6 +275,7 @@ function AccountList(props) {
removing: props.removeTarget === account.botId,
onReconnect: () => props.onReconnect(account),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(account, agentPreset),
onRequestRemove: () => props.onRequestRemove(account),
onConfirmRemove: () => props.onConfirmRemove(account),
onCancelRemove: props.onCancelRemove,
@ -289,6 +301,7 @@ export function mergeWeixinProvisioningSnapshot(
export function WeixinSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({
phase: 'loading', bots: [], totals: EMPTY_TOTALS, revision: 0, error: null,
agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
});
const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false);
@ -331,6 +344,7 @@ export function WeixinSettingsTab({ rpcCall }) {
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
revision: snapshot.revision, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
if (snapshot.provisioning) {
setProvision((current) => mergeWeixinProvisioningSnapshot(
@ -519,7 +533,10 @@ export function WeixinSettingsTab({ rpcCall }) {
{ botId: account.botId, sendTest: true },
));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
setModel((current) => ({
...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision,
agentPresetCatalog: snapshot.agentPresetCatalog ?? current.agentPresetCatalog,
}));
}
const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
let feedback;
@ -563,6 +580,29 @@ export function WeixinSettingsTab({ rpcCall }) {
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
revision: snapshot.revision, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBotBusy(account.botId, null);
}
}, [invoke, loadStatus, setBotBusy, workspaceFence]);
const saveAgentPreset = React.useCallback(async (account, agentPreset) => {
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, 'preset');
try {
const snapshot = normalizeSnapshot(await invoke(
WEIXIN_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset },
));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
revision: snapshot.revision, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
}
} finally {
@ -581,7 +621,10 @@ export function WeixinSettingsTab({ rpcCall }) {
confirm: true,
}));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
setModel((current) => ({
...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision,
agentPresetCatalog: snapshot.agentPresetCatalog ?? current.agentPresetCatalog,
}));
}
setRemoveTarget(null);
announce('微信账号及本机凭据已移除。');
@ -621,7 +664,9 @@ export function WeixinSettingsTab({ rpcCall }) {
});
}
return h('section', { className: 'dxw-page dim-channelPage', 'aria-label': '微信设置' },
return h(AgentPresetCatalogContext.Provider, {
value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
}, h('section', { className: 'dxw-page dim-channelPage', 'aria-label': '微信设置' },
h(Heading, {
totals: model.totals,
adding: Boolean(provision),
@ -654,12 +699,13 @@ export function WeixinSettingsTab({ rpcCall }) {
removeTarget,
onReconnect: (account) => void reconnect(account),
onWorkspaceSave: saveWorkspace,
onAgentPresetSave: saveAgentPreset,
onRequestRemove: (account) => setRemoveTarget(account.botId),
onConfirmRemove: (account) => void remove(account),
onCancelRemove: () => setRemoveTarget(null),
})
: null),
);
));
}
export function apply(ctx) {

View file

@ -1,3 +1,5 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
export const WHATSAPP_ENDPOINTS = Object.freeze({
@ -8,6 +10,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
const PROVISION_STATES = new Set(['starting', 'pending', 'connecting', 'connected', 'failed', 'cancelled']);
@ -82,6 +85,7 @@ function normalizeBot(value) {
connected,
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
bot: {
name: text(value.bot?.name, 'WhatsApp机器人', 100),
idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140),
@ -109,6 +113,7 @@ export function normalizeSnapshot(value) {
bots,
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog),
};
}

View file

@ -4,6 +4,11 @@ import { WhatsappLogoGlyph } from '../../channel-logos.js';
import { QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import {
@ -173,6 +178,7 @@ export function WhatsappAccountCard({
removing,
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -205,6 +211,11 @@ export function WhatsappAccountCard({
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h(AgentPresetEditor, {
agentPreset: account.agentPreset,
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
testNotice ? h('div', {
@ -229,6 +240,7 @@ export function WhatsappAccountCard({
export function WhatsappSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({
phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null,
agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
});
const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false);
@ -264,7 +276,10 @@ export function WhatsappSettingsTab({ rpcCall }) {
const snapshot = normalizeSnapshot(await invoke(WHATSAPP_ENDPOINTS.status, {}, signal));
if (!mounted.current || signal?.aborted
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
if (restore && snapshot.provisioning) setProvision({
...snapshot.provisioning,
durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()),
@ -392,7 +407,10 @@ export function WhatsappSettingsTab({ rpcCall }) {
const value = await invoke(endpoint, payload);
const snapshot = normalizeSnapshot(value);
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
});
if (operation === 'reconnect') {
setTestNoticeByBot((current) => ({
...current,
@ -441,6 +459,12 @@ export function WhatsappSettingsTab({ rpcCall }) {
WHATSAPP_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
),
onAgentPresetSave: (agentPreset) => botAction(
account,
'preset',
WHATSAPP_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {
@ -453,7 +477,9 @@ export function WhatsappSettingsTab({ rpcCall }) {
})))))
: null;
return h('section', {
return h(AgentPresetCatalogContext.Provider, {
value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
}, h('section', {
className: 'ddt-page dwa-page dim-channelPage',
'aria-label': 'WhatsApp 设置',
},
@ -490,5 +516,5 @@ export function WhatsappSettingsTab({ rpcCall }) {
: model.bots.length === 0
? h(EmptyView, { busy, onStart: () => void startProvisioning(false) })
: null,
botList));
botList)));
}

View file

@ -117,6 +117,12 @@ const EN = Object.freeze({
'工作区路径不存在。': 'The workspace path does not exist.',
'工作区路径必须指向一个目录。': 'The workspace path must point to a directory.',
'找不到要修改的机器人。': 'The bot could not be found.',
'Agent Preset': 'Agent Preset',
'跟随 Host 默认': 'Follow the Host default',
'只影响之后新建的会话。': 'Only later new sessions use this preset.',
'Agent Preset 修改失败,请重试。': 'Could not update the Agent Preset. Try again.',
'请选择 Agent Preset。': 'Choose an Agent Preset.',
'Agent Preset 无效。': 'The Agent Preset is invalid.',
'尚未检查': 'Not checked yet',
'刚刚': 'Just now',
'检查连接': 'Check connection',

View file

@ -142,6 +142,14 @@ const CSS = String.raw`
.dim-panel .dim-workspaceEdit { grid-column: 2; grid-row: 1; padding: 0; border: 0; color: #1677ff; background: transparent; font: inherit; font-weight: 560; white-space: nowrap; cursor: pointer; }
.dim-panel .dim-workspaceEdit:disabled { cursor: not-allowed; opacity: .55; }
.dim-panel .dim-workspacePath { min-width: 0; max-width: 100%; grid-column: 1 / -1; grid-row: 2; display: block; overflow-x: auto; overflow-y: hidden; color: var(--dsw-alias-label-primary, #1f2329); font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; }
.dim-panel .dim-preset { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; column-gap: 10px; row-gap: 4px; margin-top: 6px; padding: 6px 10px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
.dim-panel .dim-presetHeader { display: contents; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; }
.dim-panel .dim-presetHeader > span { grid-column: 1; grid-row: 1; white-space: nowrap; }
.dim-panel .dim-presetStatus { grid-column: 2; grid-row: 1; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; white-space: nowrap; }
.dim-panel .dim-presetSelect { min-width: 0; max-width: 100%; grid-column: 1 / -1; grid-row: 2; height: 30px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; }
.dim-panel .dim-presetSelect:disabled { cursor: not-allowed; opacity: .55; }
.dim-panel .dim-presetHelp { grid-column: 1 / -1; grid-row: 3; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 1.4; }
.dim-panel .dim-presetError { grid-column: 1 / -1; grid-row: 4; margin: 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.4; }
.dim-directoryPickerBackdrop { --dim-blue: var(--dsw-alias-state-business-primary, #3370ff); --dim-blue-soft: color-mix(in srgb, var(--dim-blue) 9%, transparent); position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: rgb(15 17 21 / 42%); backdrop-filter: blur(3px); }
.dim-directoryPickerBackdrop, .dim-directoryPickerBackdrop *, .dim-directoryPickerBackdrop *::before, .dim-directoryPickerBackdrop *::after { box-sizing: border-box; }
.dim-directoryPicker { width: min(720px, 100%); height: min(620px, calc(100vh - 48px)); min-height: 420px; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 18px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 24px 72px rgb(15 17 21 / 24%); }

View file

@ -14,6 +14,7 @@ import {
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
import { createConnectionSupervisor } from './connection-supervisor.mjs';
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
@ -135,7 +136,11 @@ export async function createProductionController(ctx, config = {}, internals = {
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
controller,
harness,

View file

@ -1,6 +1,7 @@
import QRCode from 'qrcode';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
import {
connectionTestTargetUnavailable,
publicConnectionTestResult,
@ -16,6 +17,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
approveSender: 'bot.sender.approve',
revokeSender: 'bot.sender.revoke',
});
@ -88,6 +90,10 @@ function payloadFailure(endpoint, payload) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
if (endpoint === DINGTALK_ENDPOINTS.setAgentPreset) {
return validAgentPresetPayload(payload)
? null : '请选择 Agent Preset。';
}
if (endpoint === DINGTALK_ENDPOINTS.approveSender) {
return exactKeys(payload, ['botId', 'requestId', 'confirm'])
&& validId(payload.botId)
@ -245,6 +251,12 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
await controller.updateWorkspace(payload.botId, payload.workspace),
cachedEncode,
);
} else if (endpoint === DINGTALK_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await publicStatus(
await controller.updateAgentPreset(payload.botId, payload.agentPreset),
cachedEncode,
);
} else if (endpoint === DINGTALK_ENDPOINTS.approveSender) {
value = await publicStatus(
await controller.approveSender(payload.botId, payload.requestId),

View file

@ -20,6 +20,7 @@ import {
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
function harnessOrigin(webServer, configured) {
if (configured !== undefined) return new URL(configured);
@ -166,6 +167,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor: stateForBotId,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({

View file

@ -2,6 +2,7 @@ import QRCode from 'qrcode';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import { validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
import {
FEISHU_ENDPOINTS,
FEISHU_RPC_CHANNEL,
@ -378,6 +379,10 @@ function validPayload(endpoint, payload) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
if (endpoint === FEISHU_ENDPOINTS.setAgentPreset) {
return validAgentPresetPayload(payload)
? null : '请选择 Agent Preset。';
}
return 'Unknown Feishu endpoint.';
}
@ -606,6 +611,12 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
await controller.updateWorkspace(payload.botId, payload.workspace),
{ encodeQr: cachedEncodeQr },
);
} else if (endpoint === FEISHU_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await toPublicFeishuStatus(
await controller.updateAgentPreset(payload.botId, payload.agentPreset),
{ encodeQr: cachedEncodeQr },
);
} else {
if (typeof controller.deleteBot !== 'function') throw new Error('Multi-bot delete is unavailable');
value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr });

View file

@ -14,6 +14,7 @@ import {
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
import { createConnectionSupervisor } from './connection-supervisor.mjs';
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
@ -125,7 +126,11 @@ export async function createProductionController(ctx, config = {}, internals = {
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
controller,
harness,

View file

@ -5,6 +5,7 @@ import {
} from '../../../../src/channels/shared/connection-test.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
export const QQ_RPC_CHANNEL = '/qq';
export const QQ_ENDPOINTS = Object.freeze({
@ -16,6 +17,7 @@ export const QQ_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
export const QQ_RPC_ENDPOINTS = Object.freeze(Object.values(QQ_ENDPOINTS));
@ -70,6 +72,10 @@ function payloadFailure(endpoint, payload) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
if (endpoint === QQ_ENDPOINTS.setAgentPreset) {
return validAgentPresetPayload(payload)
? null : '请选择 Agent Preset。';
}
return 'Unknown QQ endpoint.';
}
@ -165,6 +171,12 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
await controller.updateWorkspace(payload.botId, payload.workspace),
cachedEncode,
);
} else if (endpoint === QQ_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await publicStatus(
await controller.updateAgentPreset(payload.botId, payload.agentPreset),
cachedEncode,
);
} else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
}

View file

@ -0,0 +1,21 @@
import { AGENT_PRESET_ID } from '../../../../src/channels/shared/agent-preset.mjs';
export const SET_AGENT_PRESET_ENDPOINT = 'bot.preset.set';
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
export function validAgentPresetPayload(payload) {
return isRecord(payload)
&& Object.keys(payload).every((key) => ['botId', 'agentPreset'].includes(key))
&& typeof payload.botId === 'string'
&& /^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)
&& (payload.agentPreset === null
|| (typeof payload.agentPreset === 'string' && AGENT_PRESET_ID.test(payload.agentPreset)));
}
export function publicAgentPresetError(error) {
if (!['agent-preset-invalid', 'workspace-bot-not-found'].includes(error?.code)) return null;
return { code: error.code, message: error.message };
}

View file

@ -11,6 +11,7 @@ import {
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
export function harnessOrigin(webServer, configured) {
if (configured !== undefined) return new URL(configured);
@ -127,7 +128,11 @@ export async function createTokenProductionController(ctx, config, internals, de
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
channel,
controller,

View file

@ -5,6 +5,10 @@ import {
SET_WORKSPACE_ENDPOINT,
validWorkspacePayload,
} from './workspace-rpc.mjs';
import {
SET_AGENT_PRESET_ENDPOINT,
validAgentPresetPayload,
} from './agent-preset-rpc.mjs';
export const TOKEN_BOT_ENDPOINTS = Object.freeze({
status: 'connection.status',
@ -12,6 +16,7 @@ export const TOKEN_BOT_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
const ENDPOINTS = Object.freeze(Object.values(TOKEN_BOT_ENDPOINTS));
@ -57,6 +62,10 @@ function payloadFailure(endpoint, payload) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
if (endpoint === TOKEN_BOT_ENDPOINTS.setAgentPreset) {
return validAgentPresetPayload(payload)
? null : '请选择 Agent Preset。';
}
return 'Unknown bot endpoint.';
}
@ -132,6 +141,9 @@ export function createTokenBotRpcHandler(controller, { channel }) {
} else if (endpoint === TOKEN_BOT_ENDPOINTS.setWorkspace) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await controller.updateWorkspace(payload.botId, payload.workspace);
} else if (endpoint === TOKEN_BOT_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await controller.updateAgentPreset(payload.botId, payload.agentPreset);
} else {
value = await controller.deleteBot(payload.botId);
}

View file

@ -20,6 +20,7 @@ export function publicWorkspaceError(error) {
'workspace-not-found',
'workspace-not-directory',
'workspace-bot-not-found',
'agent-preset-invalid',
].includes(error?.code)) return null;
return { code: error.code, message: error.message };
}

View file

@ -12,6 +12,7 @@ import {
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
import { harnessOrigin, pluginPaths } from '../shared/production.mjs';
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
@ -105,7 +106,11 @@ export async function createProductionController(ctx, config = {}, internals = {
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
channel: 'slack',
controller,

View file

@ -5,6 +5,10 @@ import {
SET_WORKSPACE_ENDPOINT,
validWorkspacePayload,
} from '../shared/workspace-rpc.mjs';
import {
SET_AGENT_PRESET_ENDPOINT,
validAgentPresetPayload,
} from '../shared/agent-preset-rpc.mjs';
export const SLACK_RPC_CHANNEL = '/slack';
export const SLACK_ENDPOINTS = Object.freeze({
@ -13,6 +17,7 @@ export const SLACK_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
export const SLACK_RPC_ENDPOINTS = Object.freeze(Object.values(SLACK_ENDPOINTS));
@ -66,6 +71,10 @@ function payloadFailure(endpoint, payload) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
if (endpoint === SLACK_ENDPOINTS.setAgentPreset) {
return validAgentPresetPayload(payload)
? null : '请选择 Agent Preset。';
}
return 'Unknown Slack endpoint.';
}
@ -145,6 +154,10 @@ export function createSlackRpcHandler(controller) {
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
value = await controller.updateWorkspace(payload.botId, payload.workspace);
}
else if (endpoint === SLACK_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await controller.updateAgentPreset(payload.botId, payload.agentPreset);
}
else value = await controller.deleteBot(payload.botId);
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }

View file

@ -14,6 +14,7 @@ import {
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
import { createConnectionSupervisor } from './connection-supervisor.mjs';
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
@ -129,7 +130,11 @@ export async function createProductionController(ctx, config = {}, internals = {
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
controller,
harness,

View file

@ -1,6 +1,7 @@
import QRCode from 'qrcode';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
import {
connectionTestTargetUnavailable,
publicConnectionTestResult,
@ -16,6 +17,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
export const WECOM_RPC_ENDPOINTS = Object.freeze(Object.values(WECOM_ENDPOINTS));
@ -70,6 +72,10 @@ function payloadFailure(endpoint, payload) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
if (endpoint === WECOM_ENDPOINTS.setAgentPreset) {
return validAgentPresetPayload(payload)
? null : '请选择 Agent Preset。';
}
return 'Unknown Enterprise WeChat endpoint.';
}
@ -166,6 +172,12 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {})
await controller.updateWorkspace(payload.botId, payload.workspace),
cachedEncode,
);
} else if (endpoint === WECOM_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await publicStatus(
await controller.updateAgentPreset(payload.botId, payload.agentPreset),
cachedEncode,
);
} else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
}

View file

@ -14,6 +14,7 @@ import {
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
import { createConnectionSupervisor } from './connection-supervisor.mjs';
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
@ -129,7 +130,11 @@ export async function createProductionController(ctx, config = {}, internals = {
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
controller,
harness,

View file

@ -5,6 +5,10 @@ import {
SET_WORKSPACE_ENDPOINT,
validWorkspacePayload,
} from '../shared/workspace-rpc.mjs';
import {
SET_AGENT_PRESET_ENDPOINT,
validAgentPresetPayload,
} from '../shared/agent-preset-rpc.mjs';
import {
connectionTestTargetUnavailable,
publicConnectionTestResult,
@ -20,6 +24,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
export const WEIXIN_RPC_ENDPOINTS = Object.freeze(Object.values(WEIXIN_ENDPOINTS));
@ -74,6 +79,10 @@ function payloadFailure(endpoint, payload) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
if (endpoint === WEIXIN_ENDPOINTS.setAgentPreset) {
return validAgentPresetPayload(payload)
? null : '请选择 Agent Preset。';
}
return 'Unknown Weixin endpoint.';
}
@ -198,6 +207,12 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
await controller.updateWorkspace(payload.botId, payload.workspace),
cachedEncode,
);
} else if (endpoint === WEIXIN_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await publicStatus(
await controller.updateAgentPreset(payload.botId, payload.agentPreset),
cachedEncode,
);
} else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
}

View file

@ -14,6 +14,7 @@ import {
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
@ -136,7 +137,11 @@ export async function createProductionController(ctx, config = {}, internals = {
}
},
});
const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
channel: 'whatsapp',
controller,

View file

@ -3,6 +3,7 @@ import QRCode from 'qrcode';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
export const WHATSAPP_ENDPOINTS = Object.freeze({
@ -13,6 +14,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
});
export const WHATSAPP_RPC_ENDPOINTS = Object.freeze(Object.values(WHATSAPP_ENDPOINTS));
@ -52,6 +54,10 @@ function payloadFailure(endpoint, payload) {
return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。';
}
if (endpoint === WHATSAPP_ENDPOINTS.setAgentPreset) {
return validAgentPresetPayload(payload)
? null : '请选择 Agent Preset。';
}
return 'Unknown WhatsApp endpoint.';
}
@ -154,6 +160,12 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
await controller.updateWorkspace(payload.botId, payload.workspace),
cachedEncode,
);
} else if (endpoint === WHATSAPP_ENDPOINTS.setAgentPreset) {
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
value = await publicStatus(
await controller.updateAgentPreset(payload.botId, payload.agentPreset),
cachedEncode,
);
} else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
}

View file

@ -0,0 +1,74 @@
/** Matches DeepSeek Harness agent-preset directory ids. */
export const AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
export const EMPTY_AGENT_PRESET_CATALOG = Object.freeze({
defaultId: '',
items: Object.freeze([]),
});
export function normalizeAgentPresetId(value) {
if (value == null) return null;
if (typeof value !== 'string') return null;
const id = value.trim();
return AGENT_PRESET_ID.test(id) ? id : null;
}
export function validateAgentPresetId(value) {
if (value == null || value === '') return null;
const id = normalizeAgentPresetId(value);
if (!id) {
const error = new Error('Agent Preset 无效。');
error.code = 'agent-preset-invalid';
throw error;
}
return id;
}
function catalogItem(value) {
if (typeof value === 'string') {
const id = normalizeAgentPresetId(value);
return id ? { id, label: id } : null;
}
if (!value || typeof value !== 'object') return null;
const id = normalizeAgentPresetId(value.id);
if (!id) return null;
const label = typeof value.name === 'string' && value.name.trim()
? value.name.trim().slice(0, 128)
: typeof value.label === 'string' && value.label.trim()
? value.label.trim().slice(0, 128)
: id;
return { id, label };
}
export function normalizeAgentPresetCatalog(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { defaultId: '', items: [] };
}
const items = [];
const seen = new Set();
for (const entry of Array.isArray(value.items) ? value.items : []) {
const item = catalogItem(entry);
if (!item || seen.has(item.id)) continue;
seen.add(item.id);
items.push(item);
}
return {
defaultId: normalizeAgentPresetId(value.defaultId) ?? '',
items,
};
}
export async function listAgentPresetCatalog(ctx) {
const service = typeof ctx?.get === 'function' ? ctx.get('agentPresets') : ctx?.agentPresets;
if (!service || typeof service.list !== 'function') return { defaultId: '', items: [] };
let listed = [];
try {
listed = await service.list();
} catch {
return { defaultId: '', items: [] };
}
return normalizeAgentPresetCatalog({
defaultId: typeof service.defaultId === 'string' ? service.defaultId : '',
items: Array.isArray(listed) ? listed : [],
});
}

View file

@ -9,6 +9,7 @@ import {
} from 'node:fs/promises';
import { dirname, isAbsolute, resolve } from 'node:path';
import { validateAgentPresetId } from './agent-preset.mjs';
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
@ -49,7 +50,22 @@ function normalizeDocument(value) {
|| typeof workspace !== 'string' || !isAbsolute(workspace)) return null;
workspaces[botId] = resolve(workspace);
}
return { version: 1, workspaces };
let agentPresets = {};
if (value.agentPresets !== undefined) {
if (!value.agentPresets || typeof value.agentPresets !== 'object'
|| Array.isArray(value.agentPresets)) return null;
for (const [botId, agentPreset] of Object.entries(value.agentPresets)) {
if (!/^[A-Za-z0-9_-]{1,128}$/.test(botId)) return null;
try {
const normalized = validateAgentPresetId(agentPreset);
if (!normalized) return null;
agentPresets[botId] = normalized;
} catch {
return null;
}
}
}
return { version: 1, workspaces, agentPresets };
}
export async function validateWorkspacePath(value) {
@ -79,6 +95,7 @@ export class BotWorkspaceStore {
#path;
#defaultWorkspace;
#workspaces = {};
#agentPresets = {};
#generations = new Map();
#nextGeneration = 1;
#incarnations = new Map();
@ -100,9 +117,11 @@ export class BotWorkspaceStore {
const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
if (!normalized) throw new Error('dsh-im workspace config is invalid');
this.#workspaces = normalized.workspaces;
this.#agentPresets = normalized.agentPresets;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#workspaces = {};
this.#agentPresets = {};
}
this.#generations.clear();
this.#nextGeneration = 1;
@ -130,6 +149,10 @@ export class BotWorkspaceStore {
return this.#workspaces[botIdOf(botId)] ?? this.#defaultWorkspace;
}
agentPresetFor(botId) {
return this.#agentPresets[botIdOf(botId)] ?? null;
}
generationFor(botId) {
return this.#generations.get(botIdOf(botId)) ?? null;
}
@ -207,6 +230,37 @@ export class BotWorkspaceStore {
});
}
async setAgentPreset(botId, value, { incarnation } = {}) {
const id = botIdOf(botId);
if (!this.has(id)
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const agentPreset = validateAgentPresetId(value);
return this.#enqueue(id, async () => {
if (!this.has(id)
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const previous = this.#agentPresets[id] ?? null;
if (previous === agentPreset) return agentPreset;
if (agentPreset) this.#agentPresets[id] = agentPreset;
else delete this.#agentPresets[id];
try {
await this.#persist();
} catch (error) {
if (previous) this.#agentPresets[id] = previous;
else delete this.#agentPresets[id];
throw error;
}
return agentPreset;
});
}
async bindWorkspaceSession(botId, value, {
conversationKey,
sessionId,
@ -353,7 +407,11 @@ export class BotWorkspaceStore {
async reconcile(activeBotIds) {
const active = new Set([...activeBotIds].map(botIdOf));
const candidates = new Set([...Object.keys(this.#workspaces), ...this.#dirtyRemovals]);
const candidates = new Set([
...Object.keys(this.#workspaces),
...Object.keys(this.#agentPresets),
...this.#dirtyRemovals,
]);
for (const botId of candidates) {
if (!active.has(botId)) await this.remove(botId);
}
@ -364,7 +422,11 @@ export class BotWorkspaceStore {
return {
...status,
bots: status.bots.map((bot) => bot?.botId
? { ...bot, workspace: this.workspaceFor(bot.botId) }
? {
...bot,
workspace: this.workspaceFor(bot.botId),
agentPreset: this.agentPresetFor(bot.botId),
}
: bot),
};
}
@ -392,8 +454,10 @@ export class BotWorkspaceStore {
async #retireCurrentIncarnation(id) {
const hadWorkspace = Object.hasOwn(this.#workspaces, id);
const needsCleanup = hadWorkspace || this.#dirtyRemovals.has(id);
const hadPreset = Object.hasOwn(this.#agentPresets, id);
const needsCleanup = hadWorkspace || hadPreset || this.#dirtyRemovals.has(id);
delete this.#workspaces[id];
delete this.#agentPresets[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
if (!needsCleanup) return {
@ -425,6 +489,9 @@ export class BotWorkspaceStore {
async #persist() {
const document = { version: 1, workspaces: this.#workspaces };
if (Object.keys(this.#agentPresets).length > 0) {
document.agentPresets = this.#agentPresets;
}
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
const temporary = `${this.#path}.tmp`;
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
@ -436,7 +503,8 @@ export class BotWorkspaceStore {
}
async #persistCurrentDocument() {
if (Object.keys(this.#workspaces).length > 0) {
if (Object.keys(this.#workspaces).length > 0
|| Object.keys(this.#agentPresets).length > 0) {
await this.#persist();
return;
}
@ -450,10 +518,18 @@ export class BotWorkspaceStore {
}
}
function decorateResult(workspaces, result) {
function decorateResult(workspaces, result, catalog) {
const decorate = (value) => {
const decorated = workspaces.decorateStatus(value);
if (!catalog || !decorated || typeof decorated !== 'object') return decorated;
const agentPresetCatalog = typeof catalog === 'function' ? catalog() : catalog;
return agentPresetCatalog
? { ...decorated, agentPresetCatalog }
: decorated;
};
return result && typeof result.then === 'function'
? result.then((value) => workspaces.decorateStatus(value))
: workspaces.decorateStatus(result);
? result.then(decorate)
: decorate(result);
}
function targetStatus(controller) {
@ -608,9 +684,11 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
throw error;
}
const generation = workspaces.generationFor(botId);
const agentPreset = workspaces.agentPresetFor(botId);
const sessionId = await target.createSession({
...options,
workspace: workspaces.workspaceFor(botId),
...(agentPreset == null ? {} : { agentPreset }),
});
sessionGenerations.set(sessionId, generation);
return sessionId;
@ -765,7 +843,7 @@ export function createBotScopedHarness(harness, options) {
return createBotWorkspaceScope(harness, options).harness;
}
export function createWorkspaceAwareController(controller, { workspaces, stateFor }) {
export function createWorkspaceAwareController(controller, { workspaces, stateFor, agentPresetCatalog } = {}) {
if (!controller || !workspaces || typeof stateFor !== 'function') {
throw new TypeError('controller, workspaces, and stateFor are required');
}
@ -778,6 +856,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
if (transitions.get(botId) === current) transitions.delete(botId);
});
};
const decorate = (value) => decorateResult(workspaces, value, agentPresetCatalog);
const updateWorkspace = (botId, workspace) => {
// Capture at API invocation, before even waiting for an older outer
// transition. A queued request still belongs to the incarnation that the
@ -795,7 +874,20 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
clearSessions: () => state.clearSessions(),
incarnation,
});
return workspaces.decorateStatus(await controller.status());
return decorate(await controller.status());
});
};
const updateAgentPreset = (botId, agentPreset) => {
const incarnation = workspaces.incarnationFor(botId);
return withBotTransition(botId, async () => {
const snapshot = await controller.status();
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
await workspaces.setAgentPreset(botId, agentPreset, { incarnation });
return decorate(await controller.status());
});
};
const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
@ -822,7 +914,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
try {
const result = await invokeDelete();
await workspaces.finishRemoval(removal);
return workspaces.decorateStatus(result);
return decorate(result);
} catch (error) {
const after = await targetStatus(controller).catch(() => null);
const knownAbsent = Array.isArray(after?.bots)
@ -836,6 +928,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
return new Proxy(controller, {
get(target, property) {
if (property === 'updateWorkspace') return updateWorkspace;
if (property === 'updateAgentPreset') return updateAgentPreset;
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') return value;
if (property === 'deleteBot') {
@ -848,11 +941,11 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
return async (...args) => {
const before = await target.status();
const botId = before?.bots?.[0]?.botId;
if (!botId) return decorateResult(workspaces, value.apply(target, args));
if (!botId) return decorate(value.apply(target, args));
return deleteWithWorkspace(botId, () => value.apply(target, args));
};
}
return (...args) => decorateResult(workspaces, value.apply(target, args));
return (...args) => decorate(value.apply(target, args));
},
});
}

View file

@ -592,11 +592,13 @@ export class HarnessClient {
}
async createSession(options = {}) {
await this.ensureRunning(options);
const workspaceId = await this.workspaceId(options);
const { agentPreset: requestedPreset, ...rpcOptions } = options;
await this.ensureRunning(rpcOptions);
const workspaceId = await this.workspaceId(rpcOptions);
const payload = { workspaceId };
if (this.#agentPreset !== undefined) payload.agentPreset = this.#agentPreset;
const created = await this.rpc('session.create', payload, 30_000, options);
const agentPreset = requestedPreset !== undefined ? requestedPreset : this.#agentPreset;
if (agentPreset != null) payload.agentPreset = agentPreset;
const created = await this.rpc('session.create', payload, 30_000, rpcOptions);
return created.sessionId;
}

View file

@ -24,6 +24,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: 'bot.preset.set',
});
});

View file

@ -456,3 +456,59 @@ test('unmount does not cancel a Host provisioning task that already started', as
);
assert.equal(clock.frames.size, 0);
});
test('DingTalk settings save an Agent Preset through bot.preset.set', async (t) => {
const clock = createBrowserClock();
t.after(() => clock.restore());
const bot = {
botId: 'dt_test',
connected: true,
state: 'connected',
workspace: '/workspace/current',
bot: { name: '钉钉机器人', clientIdMasked: 'ding••••test' },
health: { status: 'healthy', summary: '连接正常', lastCheckedAt: Date.now() },
};
const catalog = {
defaultId: 'default',
items: [
{ id: 'coding', label: 'Coding' },
{ id: 'default', label: 'Default' },
],
};
const calls = [];
const rpcCall = async (endpoint, payload) => {
calls.push({ endpoint, payload });
if (endpoint === DINGTALK_ENDPOINTS.status) {
return ok(snapshot({
state: 'connected',
bots: [bot],
agentPresetCatalog: catalog,
}));
}
if (endpoint === DINGTALK_ENDPOINTS.setAgentPreset) {
return ok(snapshot({
state: 'connected',
bots: [{ ...bot, agentPreset: payload.agentPreset }],
agentPresetCatalog: catalog,
}));
}
throw new Error(`unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
await flushMicrotasks();
});
await act(async () => {
renderer.root.findByProps({ className: 'dim-presetSelect' })
.props.onChange({ target: { value: 'coding' } });
await flushMicrotasks();
});
assert.deepEqual(
calls.find((call) => call.endpoint === DINGTALK_ENDPOINTS.setAgentPreset)?.payload,
{ botId: 'dt_test', agentPreset: 'coding' },
);
act(() => renderer.unmount());
});

View file

@ -41,6 +41,7 @@ test('QQ bot cards match the shared two-metric card treatment', () => {
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(markup, />消息通道<[^]*>最近检查</);
assert.match(markup, />检查连接<[^]*>移除接入</);
assert.match(markup, /class="dim-presetSelect"/);
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|QQ WebSocket 长连接运行正常/);
const offlineMarkup = renderToStaticMarkup(React.createElement(AccountCard, {

View file

@ -70,6 +70,7 @@ test('Enterprise WeChat bot cards match the shared two-metric card treatment', (
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(markup, />消息通道<[^]*>最近检查</);
assert.match(markup, />检查连接<[^]*>移除接入</);
assert.match(markup, /class="dim-presetSelect"/);
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|企业微信 WebSocket 长连接运行正常/);
});

View file

@ -73,6 +73,28 @@ test('HarnessClient lets the Host resolve an omitted agent preset and forwards a
assert.deepEqual(await createPayload({ agentPreset: null }), { workspaceId: 'workspace-one' });
});
test('HarnessClient forwards a per-session agent preset override', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
agentPreset: 'router-standard',
});
let payload;
client.ensureRunning = async () => true;
client.workspaceId = async () => 'workspace-one';
client.rpc = async (method, value) => {
assert.equal(method, 'session.create');
payload = value;
return { sessionId: 'session-one' };
};
assert.equal(await client.createSession({ agentPreset: 'marketing-jeep' }), 'session-one');
assert.deepEqual(payload, {
workspaceId: 'workspace-one',
agentPreset: 'marketing-jeep',
});
});
test('HarnessClient lists only absolute workspace paths', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',

View file

@ -69,6 +69,7 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
assert.match(markup, /WhatsApp Web/);
assert.match(markup, /检查连接/);
assert.match(markup, /移除接入/);
assert.match(markup, /class="dim-presetSelect"/);
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
});

View file

@ -237,6 +237,7 @@ test('Feishu bot cards place the application identifier under the bot name', asy
assert.doesNotMatch(markup, /custom-bot-avatar/);
assert.equal((markup.match(/class="bxf-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(markup, />消息通道<[^]*>最近检查</);
assert.match(markup, /class="dim-presetSelect"/);
assert.doesNotMatch(markup, />应用标识<|>飞书机器人</);
assert.match(styles, /\.bxf-statusGrid \{[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/);
});
@ -445,6 +446,7 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => {
assert.match(accountMarkup, /class="dxw-avatar dim-botAvatar"[^]*data-im-channel-logo="weixin"/);
assert.match(accountMarkup, /class="dxw-health dim-botHealth"/);
assert.match(accountMarkup, /class="dxw-accountFooter dim-cardFooter"/);
assert.match(accountMarkup, /class="dim-presetSelect"/);
assert.doesNotMatch(accountMarkup, /dim-cardSummary|微信消息长轮询运行正常/);
assert.equal((accountMarkup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
assert.equal((accountMarkup.match(/class="dxw-metric dim-botMetric"/g) ?? []).length, 2);
@ -534,6 +536,13 @@ test('bot cards keep the full workspace path on its own single line', async () =
assert.match(styles, /\.dim-panel \.dim-workspaceEdit \{[^}]*grid-column: 2;[^}]*grid-row: 1;[^}]*white-space: nowrap;/);
});
test('bot cards keep the Agent Preset selector below the workspace path', async () => {
const styles = await readFile(STYLES_URL, 'utf8');
assert.match(styles, /\.dim-panel \.dim-preset \{[^}]*grid-template-columns: minmax\(0, 1fr\) max-content;[^}]*margin-top: 6px;[^}]*padding: 6px 10px;/);
assert.match(styles, /\.dim-panel \.dim-presetSelect \{[^}]*grid-column: 1 \/ -1;[^}]*grid-row: 2;/);
});
test('the bundled DingTalk channel has no local sender approval workflow', async () => {
const [source, bundle] = await Promise.all([
readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'),

View file

@ -3,6 +3,10 @@ import test from 'node:test';
import * as React from 'react';
import TestRenderer from 'react-test-renderer';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
} from '../plugin-src/client/agent-preset.js';
import {
WorkspaceDirectoryPickerContext,
WorkspaceEditor,
@ -663,3 +667,109 @@ test('an older reconnect snapshot cannot resurrect a bot deleted by a newer muta
assert.equal(renderer.root.findAllByProps({ 'data-bot-id': 'discord_first' }).length, 0);
await act(async () => { renderer.unmount(); });
});
const PRESET_CATALOG = {
defaultId: 'default',
items: [
{ id: 'coding', label: 'Coding' },
{ id: 'default', label: 'Default' },
],
};
function optionValues(select) {
return select.children.map((option) => option.props.value);
}
test('AgentPresetEditor lists Host presets and keeps the catalog label for the current id', () => {
const renderer = create(React.createElement(
AgentPresetCatalogContext.Provider,
{ value: PRESET_CATALOG },
React.createElement(AgentPresetEditor, {
agentPreset: 'coding',
onSave() {},
}),
));
const select = renderer.root.findByProps({ className: 'dim-presetSelect' });
assert.equal(select.props.value, 'coding');
assert.deepEqual(optionValues(select), ['', 'coding', 'default']);
assert.equal(textOf(select.children[0]), '跟随 Host 默认');
assert.equal(textOf(select.children[1]), 'Coding(coding)');
renderer.unmount();
});
test('AgentPresetEditor saves a selected preset and can follow the Host default', async () => {
const saved = [];
function Harness() {
const [agentPreset, setAgentPreset] = React.useState('');
return React.createElement(AgentPresetEditor, {
agentPreset,
onSave(value) {
saved.push(value);
setAgentPreset(value ?? '');
},
});
}
const renderer = create(React.createElement(
AgentPresetCatalogContext.Provider,
{ value: PRESET_CATALOG },
React.createElement(Harness),
));
const select = renderer.root.findByProps({ className: 'dim-presetSelect' });
await act(async () => {
select.props.onChange({ target: { value: 'coding' } });
await flushMicrotasks();
});
await act(async () => {
renderer.root.findByProps({ className: 'dim-presetSelect' })
.props.onChange({ target: { value: '' } });
await flushMicrotasks();
});
assert.deepEqual(saved, ['coding', null]);
renderer.unmount();
});
test('Discord settings save an Agent Preset through bot.preset.set', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
t.after(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const snapshot = {
...discordSnapshot('/workspace/current'),
agentPresetCatalog: PRESET_CATALOG,
};
const calls = [];
const rpcCall = async (endpoint, payload) => {
calls.push({ endpoint, payload });
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
if (endpoint === 'bot.preset.set') {
return {
ok: true,
value: {
...snapshot,
bots: [{ ...snapshot.bots[0], agentPreset: payload.agentPreset }],
},
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const card = renderer.root.findByProps({ 'data-bot-id': 'discord_test' });
await act(async () => {
card.findByProps({ className: 'dim-presetSelect' })
.props.onChange({ target: { value: 'coding' } });
await flushMicrotasks();
});
assert.deepEqual(calls.find((call) => call.endpoint === 'bot.preset.set')?.payload, {
botId: 'discord_test',
agentPreset: 'coding',
});
await act(async () => { renderer.unmount(); });
});

View file

@ -1286,3 +1286,137 @@ test('workspace RPC validates payloads and returns the updated public status', a
assert.equal(missing.error.code, 'workspace-not-found');
assert.match(missing.error.message, /不存在/);
});
test('BotWorkspaceStore persists per-bot agent presets without changing workspaces', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([store.ensure('bot_one'), store.ensure('bot_two')]);
assert.equal(store.agentPresetFor('bot_one'), null);
await store.setAgentPreset('bot_one', 'marketing-jeep');
assert.equal(store.agentPresetFor('bot_one'), 'marketing-jeep');
assert.equal(store.agentPresetFor('bot_two'), null);
assert.equal(store.workspaceFor('bot_one'), defaultWorkspace);
await store.setWorkspace('bot_one', alternateWorkspace);
assert.equal(store.agentPresetFor('bot_one'), 'marketing-jeep');
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
version: 1,
workspaces: { bot_one: alternateWorkspace, bot_two: defaultWorkspace },
agentPresets: { bot_one: 'marketing-jeep' },
});
await store.setAgentPreset('bot_one', null);
assert.equal(store.agentPresetFor('bot_one'), null);
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
version: 1,
workspaces: { bot_one: alternateWorkspace, bot_two: defaultWorkspace },
});
await writeFile(path, `${JSON.stringify({
version: 1,
workspaces: { bot_one: defaultWorkspace },
agentPresets: { bot_one: 'standard-claude' },
}, null, 2)}\n`);
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace: tmpdir() }).load();
assert.equal(reloaded.agentPresetFor('bot_one'), 'standard-claude');
});
test('BotWorkspaceStore rejects invalid agent preset ids and missing bots', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await store.ensure('bot_one');
await assert.rejects(store.setAgentPreset('bot_one', 'Standard'), { code: 'agent-preset-invalid' });
await assert.rejects(store.setAgentPreset('bot_missing', 'standard'), { code: 'workspace-bot-not-found' });
assert.equal(store.agentPresetFor('bot_one'), null);
});
test('changing a bot agent preset does not clear sessions', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await store.ensure('bot_one');
let clears = 0;
const generation = store.generationFor('bot_one');
await store.setAgentPreset('bot_one', 'marketing-jeep', {
clearSessions: async () => { clears += 1; },
});
assert.equal(clears, 0);
assert.equal(store.generationFor('bot_one'), generation);
});
test('bot-scoped Harness creates sessions with the selected agent preset', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_one'), workspaces.ensure('bot_two')]);
await workspaces.setAgentPreset('bot_one', 'marketing-jeep');
const calls = [];
const harness = {
async createSession(options) { calls.push(options); return `session-${calls.length}`; },
async ensureRunning() { return true; },
};
const state = { async clearSessions() {} };
const one = createBotScopedHarness(harness, { botId: 'bot_one', workspaces, state });
const two = createBotScopedHarness(harness, { botId: 'bot_two', workspaces, state });
await one.createSession();
await two.createSession();
assert.equal(calls[0].workspace, defaultWorkspace);
assert.equal(calls[0].agentPreset, 'marketing-jeep');
assert.equal(calls[1].workspace, defaultWorkspace);
assert.equal(Object.hasOwn(calls[1], 'agentPreset'), false);
});
test('workspace RPC can set a bot agent preset without switching workspace', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_one');
await workspaces.setWorkspace('bot_one', alternateWorkspace);
const catalog = {
defaultId: 'standard',
items: [
{ id: 'standard', label: 'Standard' },
{ id: 'marketing-jeep', label: '营销吉普' },
],
};
const base = {
status() { return { bots: [{ botId: 'bot_one', connected: true }] }; },
bindCredentials() { return this.status(); },
reconnectBot() { return this.status(); },
deleteBot() { return { bots: [] }; },
};
const controller = createWorkspaceAwareController(base, {
workspaces,
stateFor: async () => ({ async clearSessions() {} }),
agentPresetCatalog: catalog,
});
const handler = createTokenBotRpcHandler(controller, { channel: 'Telegram' });
const generation = workspaces.generationFor('bot_one');
const listed = await handler(TOKEN_BOT_ENDPOINTS.status, {});
assert.equal(listed.ok, true);
assert.deepEqual(listed.value.agentPresetCatalog, catalog);
assert.equal(listed.value.bots[0].agentPreset, null);
assert.equal(listed.value.bots[0].workspace, alternateWorkspace);
const success = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: 'marketing-jeep',
});
assert.equal(success.ok, true);
assert.equal(success.value.bots[0].agentPreset, 'marketing-jeep');
assert.equal(success.value.bots[0].workspace, alternateWorkspace);
assert.equal(workspaces.generationFor('bot_one'), generation);
const cleared = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: null,
});
assert.equal(cleared.ok, true);
assert.equal(cleared.value.bots[0].agentPreset, null);
const invalid = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: 'Not Valid',
});
assert.equal(invalid.ok, false);
assert.equal(invalid.error.code, 'bad-request');
});