mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
Keep IM tool approvals in chat and add WhatsApp default model.
Claim in-flight IM turns before ownership flips active so approvals no longer stick only in DSH Web, auto-recover stale session models once, and let WhatsApp bots pick a shared default model for new sessions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2723e94541
commit
74c58bdcde
21 changed files with 1388 additions and 395 deletions
|
|
@ -1,4 +1,9 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import {
|
||||
normalizeDefaultModelSelection,
|
||||
normalizeHostModelCatalog,
|
||||
SET_DEFAULT_MODEL_ENDPOINT,
|
||||
} from '../../default-model.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
import { normalizeAccessGrant } from '../../../../src/channels/shared/access-grant.mjs';
|
||||
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
|
||||
|
|
@ -21,6 +26,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
|
|||
setGroupSessionScope: 'bot.group-session-scope.set',
|
||||
setWorkspace: 'bot.workspace.set',
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setDefaultModel: SET_DEFAULT_MODEL_ENDPOINT,
|
||||
setContextEnhancement: 'bot.context-enhancement.set',
|
||||
resolveChannelPeer: 'bot.session.channel-peer',
|
||||
});
|
||||
|
|
@ -98,6 +104,7 @@ function normalizeBot(value) {
|
|||
state: connected ? 'connected' : state,
|
||||
workspace: text(value.workspace, '', 4_096),
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
defaultModel: normalizeDefaultModelSelection(value.defaultModel),
|
||||
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
|
||||
...(Object.hasOwn(value, 'accessPolicy')
|
||||
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
|
||||
|
|
@ -137,6 +144,7 @@ export function normalizeSnapshot(value) {
|
|||
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
|
||||
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
|
||||
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog),
|
||||
modelCatalog: normalizeHostModelCatalog(source.modelCatalog),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ import {
|
|||
AgentPresetEditor,
|
||||
EMPTY_AGENT_PRESET_CATALOG,
|
||||
} from '../../agent-preset.js';
|
||||
import {
|
||||
DefaultModelEditor,
|
||||
EMPTY_MODEL_CATALOG,
|
||||
ModelCatalogContext,
|
||||
} from '../../default-model.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
|
|
@ -186,6 +191,7 @@ export function WhatsappAccountCard({
|
|||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
onAgentPresetSave,
|
||||
onDefaultModelSave,
|
||||
onContextEnhancementSave,
|
||||
onRequestRemove,
|
||||
onConfirmRemove,
|
||||
|
|
@ -233,6 +239,11 @@ export function WhatsappAccountCard({
|
|||
disabled: Boolean(busy),
|
||||
onSave: onAgentPresetSave,
|
||||
}),
|
||||
h(DefaultModelEditor, {
|
||||
defaultModel: account.defaultModel,
|
||||
disabled: Boolean(busy),
|
||||
onSave: onDefaultModelSave,
|
||||
}),
|
||||
h(ContextEnhancementEditor, {
|
||||
config: account.contextEnhancement,
|
||||
disabled: Boolean(busy),
|
||||
|
|
@ -268,6 +279,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
|
|||
const [model, setModel] = React.useState({
|
||||
phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null,
|
||||
agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
|
||||
modelCatalog: EMPTY_MODEL_CATALOG,
|
||||
});
|
||||
const [provision, setProvision] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
|
@ -306,6 +318,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
|
|||
setModel({
|
||||
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
|
||||
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
|
||||
modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
|
||||
});
|
||||
if (restore && snapshot.provisioning) setProvision({
|
||||
...snapshot.provisioning,
|
||||
|
|
@ -437,6 +450,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
|
|||
setModel({
|
||||
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
|
||||
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
|
||||
modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
|
||||
});
|
||||
if (operation === 'reconnect') {
|
||||
setTestNoticeByBot((current) => ({
|
||||
|
|
@ -495,6 +509,12 @@ export function WhatsappSettingsTab({ rpcCall }) {
|
|||
WHATSAPP_ENDPOINTS.setAgentPreset,
|
||||
{ botId: account.botId, agentPreset },
|
||||
),
|
||||
onDefaultModelSave: (defaultModel) => botAction(
|
||||
account,
|
||||
'model',
|
||||
WHATSAPP_ENDPOINTS.setDefaultModel,
|
||||
{ botId: account.botId, defaultModel },
|
||||
),
|
||||
onContextEnhancementSave: (config) => botAction(
|
||||
account,
|
||||
'context-enhancement',
|
||||
|
|
@ -515,6 +535,8 @@ export function WhatsappSettingsTab({ rpcCall }) {
|
|||
|
||||
return h(AgentPresetCatalogContext.Provider, {
|
||||
value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
|
||||
}, h(ModelCatalogContext.Provider, {
|
||||
value: model.modelCatalog ?? EMPTY_MODEL_CATALOG,
|
||||
}, h('section', {
|
||||
className: 'ddt-page dwa-page dim-channelPage',
|
||||
'aria-label': 'WhatsApp 设置',
|
||||
|
|
@ -552,5 +574,5 @@ export function WhatsappSettingsTab({ rpcCall }) {
|
|||
: model.bots.length === 0
|
||||
? h(EmptyView, { busy, onStart: () => void startProvisioning(false) })
|
||||
: null,
|
||||
botList)));
|
||||
botList))));
|
||||
}
|
||||
|
|
|
|||
105
plugin-src/client/default-model.js
Normal file
105
plugin-src/client/default-model.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
defaultModelKey,
|
||||
normalizeDefaultModel,
|
||||
normalizeModelCatalog,
|
||||
} from '../../src/channels/shared/default-model.mjs';
|
||||
import { h } from './i18n.js';
|
||||
|
||||
export const SET_DEFAULT_MODEL_ENDPOINT = 'bot.model.set';
|
||||
|
||||
export const EMPTY_MODEL_CATALOG = Object.freeze({
|
||||
groups: Object.freeze([]),
|
||||
failures: Object.freeze([]),
|
||||
items: Object.freeze([]),
|
||||
});
|
||||
|
||||
export const ModelCatalogContext = React.createContext(EMPTY_MODEL_CATALOG);
|
||||
|
||||
export function normalizeDefaultModelSelection(value) {
|
||||
return normalizeDefaultModel(value);
|
||||
}
|
||||
|
||||
export function normalizeHostModelCatalog(value) {
|
||||
return normalizeModelCatalog(value);
|
||||
}
|
||||
|
||||
export function DefaultModelEditor({ defaultModel = null, disabled = false, onSave }) {
|
||||
const catalog = React.useContext(ModelCatalogContext) ?? EMPTY_MODEL_CATALOG;
|
||||
const helpId = React.useId();
|
||||
const current = normalizeDefaultModel(defaultModel);
|
||||
const currentKey = defaultModelKey(current);
|
||||
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?.key || seen.has(item.key)) continue;
|
||||
seen.add(item.key);
|
||||
items.push(item);
|
||||
}
|
||||
const currentUnavailable = Boolean(currentKey && !seen.has(currentKey));
|
||||
if (currentUnavailable && current) {
|
||||
items.push({
|
||||
key: currentKey,
|
||||
provider: current.provider,
|
||||
model: current.model,
|
||||
label: currentKey,
|
||||
unavailable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const change = async (event) => {
|
||||
const nextKey = event.target.value;
|
||||
if (nextKey === currentKey || saving || disabled) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave?.(nextKey ? normalizeDefaultModel(nextKey) : null);
|
||||
} catch (cause) {
|
||||
setError(cause?.message ?? '默认模型修改失败,请重试。');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return h('div', { className: 'dim-preset' },
|
||||
h('div', { className: 'dim-presetHeader' },
|
||||
h('span', { className: 'dim-presetTitle' },
|
||||
h('span', null, '默认模型'),
|
||||
h('span', { className: 'dim-presetHelp' },
|
||||
h('button', {
|
||||
type: 'button',
|
||||
className: 'dim-presetHelpButton',
|
||||
'aria-label': '查看默认模型说明',
|
||||
'aria-describedby': helpId,
|
||||
}, h('span', { 'aria-hidden': 'true' }, '?')),
|
||||
h('span', {
|
||||
id: helpId,
|
||||
className: 'dim-presetTooltip',
|
||||
role: 'tooltip',
|
||||
}, '为该 WhatsApp 机器人的新建会话指定模型,避免每人各自 /model。已有会话需先 /new 再发消息才会生效。'))),
|
||||
saving ? h('span', { className: 'dim-presetStatus' }, '保存中…') : null),
|
||||
React.createElement('select', {
|
||||
className: 'dim-presetSelect',
|
||||
value: currentKey,
|
||||
disabled: disabled || saving,
|
||||
'aria-label': '默认模型',
|
||||
onChange: (event) => { void change(event); },
|
||||
},
|
||||
h('option', { value: '' }, '跟随 Host 默认'),
|
||||
...items.map((item) => h(
|
||||
'option',
|
||||
{ key: item.key, value: item.key },
|
||||
item.unavailable ? [item.key, '(已不可用)'] : item.label,
|
||||
)),
|
||||
),
|
||||
error || currentUnavailable ? h(
|
||||
'p',
|
||||
{ className: 'dim-presetError', role: error ? 'alert' : 'status' },
|
||||
error ?? '当前默认模型已不可用,请选择其他模型或跟随 Host 默认。',
|
||||
) : null,
|
||||
);
|
||||
}
|
||||
27
plugin-src/host/channels/shared/default-model-rpc.mjs
Normal file
27
plugin-src/host/channels/shared/default-model-rpc.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { normalizeDefaultModel } from '../../../../src/channels/shared/default-model.mjs';
|
||||
|
||||
export const SET_DEFAULT_MODEL_ENDPOINT = 'bot.model.set';
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function validDefaultModelPayload(payload) {
|
||||
if (!isRecord(payload)
|
||||
|| !Object.keys(payload).every((key) => ['botId', 'defaultModel'].includes(key))
|
||||
|| typeof payload.botId !== 'string'
|
||||
|| !/^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)) {
|
||||
return false;
|
||||
}
|
||||
if (payload.defaultModel === null) return true;
|
||||
return Boolean(normalizeDefaultModel(payload.defaultModel));
|
||||
}
|
||||
|
||||
export function publicDefaultModelError(error) {
|
||||
if (![
|
||||
'default-model-invalid',
|
||||
'default-model-unavailable',
|
||||
'workspace-bot-not-found',
|
||||
].includes(error?.code)) return null;
|
||||
return { code: error.code, message: error.message };
|
||||
}
|
||||
|
|
@ -22,6 +22,8 @@ export function publicWorkspaceError(error) {
|
|||
'workspace-bot-not-found',
|
||||
'agent-preset-invalid',
|
||||
'agent-preset-unavailable',
|
||||
'default-model-invalid',
|
||||
'default-model-unavailable',
|
||||
'context-enhancement-invalid',
|
||||
].includes(error?.code)) return null;
|
||||
return { code: error.code, message: error.message };
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
workspaces,
|
||||
stateFor,
|
||||
agentPresetCatalog,
|
||||
modelCatalog: () => harness.listModels(),
|
||||
channel: 'whatsapp',
|
||||
});
|
||||
const supervisor = createSupervisor({
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
|||
import { installConnectionRpcChannel } from '../../connection-rpc-mount.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 { SET_DEFAULT_MODEL_ENDPOINT, validDefaultModelPayload } from '../shared/default-model-rpc.mjs';
|
||||
|
||||
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
|
||||
export const WHATSAPP_ENDPOINTS = Object.freeze({
|
||||
|
|
@ -32,6 +33,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
|
|||
setGroupSessionScope: SET_GROUP_SESSION_SCOPE_ENDPOINT,
|
||||
setWorkspace: SET_WORKSPACE_ENDPOINT,
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setDefaultModel: SET_DEFAULT_MODEL_ENDPOINT,
|
||||
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
|
||||
resolveChannelPeer: 'bot.session.channel-peer',
|
||||
});
|
||||
|
|
@ -97,6 +99,10 @@ function payloadFailure(endpoint, payload) {
|
|||
return validAgentPresetPayload(payload)
|
||||
? null : '请选择 Agent Preset。';
|
||||
}
|
||||
if (endpoint === WHATSAPP_ENDPOINTS.setDefaultModel) {
|
||||
return validDefaultModelPayload(payload)
|
||||
? null : '请选择默认模型。';
|
||||
}
|
||||
if (endpoint === WHATSAPP_ENDPOINTS.setContextEnhancement) {
|
||||
return validContextEnhancementPayload(payload)
|
||||
? null : '请提交有效的上下文增强设置。';
|
||||
|
|
@ -221,6 +227,12 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
|
|||
await controller.updateAgentPreset(payload.botId, payload.agentPreset),
|
||||
cachedEncode,
|
||||
);
|
||||
} else if (endpoint === WHATSAPP_ENDPOINTS.setDefaultModel) {
|
||||
if (typeof controller.updateDefaultModel !== 'function') throw new Error('Default model update is unavailable');
|
||||
value = await publicStatus(
|
||||
await controller.updateDefaultModel(payload.botId, payload.defaultModel),
|
||||
cachedEncode,
|
||||
);
|
||||
} else if (endpoint === WHATSAPP_ENDPOINTS.setAccessPolicy) {
|
||||
if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable');
|
||||
value = await controller.updateAccessPolicy(
|
||||
|
|
|
|||
|
|
@ -473,6 +473,7 @@ class ModernHarnessApi {
|
|||
const claimed = new Set([...this.#pendingApprovals.values()].map((entry) => entry.approvalId));
|
||||
const decided = new Set();
|
||||
let approvalId;
|
||||
let fallbackId;
|
||||
for (let index = owner.events.length - 1; index >= 0; index -= 1) {
|
||||
const event = owner.events[index];
|
||||
if (event.type === 'approval/decided') {
|
||||
|
|
@ -480,11 +481,20 @@ class ModernHarnessApi {
|
|||
} else if (event.type === 'approval/asked') {
|
||||
const id = event.data?.id;
|
||||
if (!id || decided.has(id) || claimed.has(id)) continue;
|
||||
if ((request.callId ?? null) !== (event.data?.callId ?? null)) continue;
|
||||
approvalId = id;
|
||||
break;
|
||||
// Prefer an exact callId match; keep the newest unmatched ask as a
|
||||
// fallback so a callId shape drift cannot silently hand the prompt to
|
||||
// DSH Web while the originating IM turn is still waiting.
|
||||
if ((request.callId ?? null) === (event.data?.callId ?? null)) {
|
||||
approvalId = id;
|
||||
break;
|
||||
}
|
||||
if (fallbackId === undefined
|
||||
&& (request.toolName === undefined || request.toolName === event.data?.toolName)) {
|
||||
fallbackId = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
approvalId ??= fallbackId;
|
||||
if (approvalId === undefined) return next();
|
||||
return new Promise((resolve) => {
|
||||
const pending = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue