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:
oliver 2026-09-15 20:02:45 +08:00
parent 2723e94541
commit 74c58bdcde
21 changed files with 1388 additions and 395 deletions

View file

@ -566,7 +566,7 @@ __export(index_exports, {
name: () => name name: () => name
}); });
module.exports = __toCommonJS(index_exports); module.exports = __toCommonJS(index_exports);
var React25 = __toESM(require("react"), 1); var React26 = __toESM(require("react"), 1);
// package.json // package.json
var package_default = { var package_default = {
@ -11122,6 +11122,177 @@ function installWeixinStyles() {
return () => style.remove(); return () => style.remove();
} }
// plugin-src/client/default-model.js
var React19 = __toESM(require("react"), 1);
// src/channels/shared/default-model.mjs
function normalizeDefaultModel(value) {
if (value == null || value === "") return null;
if (typeof value === "string") {
const trimmed = value.trim();
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash === trimmed.length - 1) return null;
return {
provider: trimmed.slice(0, slash),
model: trimmed.slice(slash + 1)
};
}
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const provider = typeof value.provider === "string" ? value.provider.trim() : "";
const model = typeof value.model === "string" ? value.model.trim() : "";
if (!provider || !model) return null;
const selection = { provider, model };
if (value.reasoningEffort !== void 0) {
if (typeof value.reasoningEffort !== "string" || !value.reasoningEffort.trim()) return null;
selection.reasoningEffort = value.reasoningEffort.trim();
}
return selection;
}
function defaultModelKey(selection) {
const normalized = normalizeDefaultModel(selection);
return normalized ? `${normalized.provider}/${normalized.model}` : "";
}
function modelCatalogItems(catalog) {
const items = [];
const seen = /* @__PURE__ */ new Set();
for (const group of Array.isArray(catalog?.groups) ? catalog.groups : []) {
const provider = typeof group?.id === "string" ? group.id.trim() : "";
if (!provider || !Array.isArray(group.models)) continue;
for (const model of group.models) {
const modelId = typeof model?.id === "string" ? model.id.trim() : "";
if (!modelId) continue;
const key = `${provider}/${modelId}`;
if (seen.has(key)) continue;
seen.add(key);
const label = typeof model.name === "string" && model.name.trim() ? model.name.trim() : modelId;
items.push({
provider,
model: modelId,
key,
label: label === modelId ? key : `${label}\uFF08${key}\uFF09`
});
}
}
return items;
}
function normalizeModelCatalog(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { groups: [], failures: [], items: [] };
}
const groups = Array.isArray(value.groups) ? value.groups : [];
const failures = Array.isArray(value.failures) ? value.failures : [];
return {
groups,
failures,
items: modelCatalogItems({ groups, failures })
};
}
// plugin-src/client/default-model.js
var SET_DEFAULT_MODEL_ENDPOINT = "bot.model.set";
var EMPTY_MODEL_CATALOG = Object.freeze({
groups: Object.freeze([]),
failures: Object.freeze([]),
items: Object.freeze([])
});
var ModelCatalogContext = React19.createContext(EMPTY_MODEL_CATALOG);
function normalizeDefaultModelSelection(value) {
return normalizeDefaultModel(value);
}
function normalizeHostModelCatalog(value) {
return normalizeModelCatalog(value);
}
function DefaultModelEditor({ defaultModel = null, disabled = false, onSave }) {
const catalog = React19.useContext(ModelCatalogContext) ?? EMPTY_MODEL_CATALOG;
const helpId = React19.useId();
const current = normalizeDefaultModel(defaultModel);
const currentKey = defaultModelKey(current);
const [saving, setSaving] = React19.useState(false);
const [error, setError] = React19.useState(null);
const items = [];
const seen = /* @__PURE__ */ 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 ?? "\u9ED8\u8BA4\u6A21\u578B\u4FEE\u6539\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002");
} finally {
setSaving(false);
}
};
return h2(
"div",
{ className: "dim-preset" },
h2(
"div",
{ className: "dim-presetHeader" },
h2(
"span",
{ className: "dim-presetTitle" },
h2("span", null, "\u9ED8\u8BA4\u6A21\u578B"),
h2(
"span",
{ className: "dim-presetHelp" },
h2("button", {
type: "button",
className: "dim-presetHelpButton",
"aria-label": "\u67E5\u770B\u9ED8\u8BA4\u6A21\u578B\u8BF4\u660E",
"aria-describedby": helpId
}, h2("span", { "aria-hidden": "true" }, "?")),
h2("span", {
id: helpId,
className: "dim-presetTooltip",
role: "tooltip"
}, "\u4E3A\u8BE5 WhatsApp \u673A\u5668\u4EBA\u7684\u65B0\u5EFA\u4F1A\u8BDD\u6307\u5B9A\u6A21\u578B\uFF0C\u907F\u514D\u6BCF\u4EBA\u5404\u81EA /model\u3002\u5DF2\u6709\u4F1A\u8BDD\u9700\u5148 /new \u518D\u53D1\u6D88\u606F\u624D\u4F1A\u751F\u6548\u3002")
)
),
saving ? h2("span", { className: "dim-presetStatus" }, "\u4FDD\u5B58\u4E2D\u2026") : null
),
React19.createElement(
"select",
{
className: "dim-presetSelect",
value: currentKey,
disabled: disabled || saving,
"aria-label": "\u9ED8\u8BA4\u6A21\u578B",
onChange: (event) => {
void change(event);
}
},
h2("option", { value: "" }, "\u8DDF\u968F Host \u9ED8\u8BA4"),
...items.map((item) => h2(
"option",
{ key: item.key, value: item.key },
item.unavailable ? [item.key, "\uFF08\u5DF2\u4E0D\u53EF\u7528\uFF09"] : item.label
))
),
error || currentUnavailable ? h2(
"p",
{ className: "dim-presetError", role: error ? "alert" : "status" },
error ?? "\u5F53\u524D\u9ED8\u8BA4\u6A21\u578B\u5DF2\u4E0D\u53EF\u7528\uFF0C\u8BF7\u9009\u62E9\u5176\u4ED6\u6A21\u578B\u6216\u8DDF\u968F Host \u9ED8\u8BA4\u3002"
) : null
);
}
// src/channels/shared/access-grant.mjs // src/channels/shared/access-grant.mjs
var ACCESS_GRANT_VERSION = 1; var ACCESS_GRANT_VERSION = 1;
var ACCESS_PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1e3; var ACCESS_PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
@ -11359,6 +11530,7 @@ var WHATSAPP_ENDPOINTS = Object.freeze({
setGroupSessionScope: "bot.group-session-scope.set", setGroupSessionScope: "bot.group-session-scope.set",
setWorkspace: "bot.workspace.set", setWorkspace: "bot.workspace.set",
setAgentPreset: SET_AGENT_PRESET_ENDPOINT, setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setDefaultModel: SET_DEFAULT_MODEL_ENDPOINT,
setContextEnhancement: "bot.context-enhancement.set", setContextEnhancement: "bot.context-enhancement.set",
resolveChannelPeer: "bot.session.channel-peer" resolveChannelPeer: "bot.session.channel-peer"
}); });
@ -11426,6 +11598,7 @@ function normalizeBot6(value) {
state: connected ? "connected" : state, state: connected ? "connected" : state,
workspace: text5(value.workspace, "", 4096), workspace: text5(value.workspace, "", 4096),
agentPreset: normalizeAgentPresetId(value.agentPreset), agentPreset: normalizeAgentPresetId(value.agentPreset),
defaultModel: normalizeDefaultModelSelection(value.defaultModel),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement), contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...Object.hasOwn(value, "accessPolicy") ? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) } : {}, ...Object.hasOwn(value, "accessPolicy") ? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) } : {},
...Object.hasOwn(value, "accessGrant") ? { accessGrant: normalizeAccessGrant(value.accessGrant) } : {}, ...Object.hasOwn(value, "accessGrant") ? { accessGrant: normalizeAccessGrant(value.accessGrant) } : {},
@ -11456,7 +11629,8 @@ function normalizeSnapshot8(value) {
bots, bots,
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length }, totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
provisioning: source.provisioning ? normalizeProvisioning6(source.provisioning) : null, provisioning: source.provisioning ? normalizeProvisioning6(source.provisioning) : null,
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog) agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog),
modelCatalog: normalizeHostModelCatalog(source.modelCatalog)
}; };
} }
function presentError9(error) { function presentError9(error) {
@ -11471,7 +11645,7 @@ function formatRemaining6(milliseconds) {
} }
// plugin-src/client/channels/whatsapp/index.js // plugin-src/client/channels/whatsapp/index.js
var React19 = __toESM(require("react"), 1); var React20 = __toESM(require("react"), 1);
// plugin-src/client/channels/whatsapp/styles.js // plugin-src/client/channels/whatsapp/styles.js
var WHATSAPP_STYLE_ID = "xmanrui-dsh-im-whatsapp-settings"; var WHATSAPP_STYLE_ID = "xmanrui-dsh-im-whatsapp-settings";
@ -11496,7 +11670,7 @@ function installWhatsappStyles() {
// plugin-src/client/channels/whatsapp/index.js // plugin-src/client/channels/whatsapp/index.js
var ACTIVE_STATES3 = /* @__PURE__ */ new Set(["pending", "connecting"]); var ACTIVE_STATES3 = /* @__PURE__ */ new Set(["pending", "connecting"]);
var Button14 = React19.forwardRef(function Button15({ children, kind = "secondary", className = "", ...props }, ref) { var Button14 = React20.forwardRef(function Button15({ children, kind = "secondary", className = "", ...props }, ref) {
return h2("button", { return h2("button", {
...props, ...props,
ref, ref,
@ -11719,6 +11893,7 @@ function WhatsappAccountCard({
onReconnect, onReconnect,
onWorkspaceSave, onWorkspaceSave,
onAgentPresetSave, onAgentPresetSave,
onDefaultModelSave,
onContextEnhancementSave, onContextEnhancementSave,
onRequestRemove, onRequestRemove,
onConfirmRemove, onConfirmRemove,
@ -11783,6 +11958,11 @@ function WhatsappAccountCard({
disabled: Boolean(busy), disabled: Boolean(busy),
onSave: onAgentPresetSave onSave: onAgentPresetSave
}), }),
h2(DefaultModelEditor, {
defaultModel: account.defaultModel,
disabled: Boolean(busy),
onSave: onDefaultModelSave
}),
h2(ContextEnhancementEditor, { h2(ContextEnhancementEditor, {
config: account.contextEnhancement, config: account.contextEnhancement,
disabled: Boolean(busy), disabled: Boolean(busy),
@ -11830,23 +12010,24 @@ function WhatsappAccountCard({
); );
} }
function WhatsappSettingsTab({ rpcCall }) { function WhatsappSettingsTab({ rpcCall }) {
const [model, setModel] = React19.useState({ const [model, setModel] = React20.useState({
phase: "loading", phase: "loading",
bots: [], bots: [],
totals: { configured: 0, connected: 0 }, totals: { configured: 0, connected: 0 },
error: null, error: null,
agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
modelCatalog: EMPTY_MODEL_CATALOG
}); });
const [provision, setProvision] = React19.useState(null); const [provision, setProvision] = React20.useState(null);
const [busy, setBusy] = React19.useState(false); const [busy, setBusy] = React20.useState(false);
const [busyByBot, setBusyByBot] = React19.useState({}); const [busyByBot, setBusyByBot] = React20.useState({});
const [testNoticeByBot, setTestNoticeByBot] = React19.useState({}); const [testNoticeByBot, setTestNoticeByBot] = React20.useState({});
const [removeTarget, setRemoveTarget] = React19.useState(null); const [removeTarget, setRemoveTarget] = React20.useState(null);
const [now, setNow] = React19.useState(Date.now()); const [now, setNow] = React20.useState(Date.now());
const mounted = React19.useRef(true); const mounted = React20.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence(); const workspaceFence = useWorkspaceSnapshotFence();
const addButtonRef = React19.useRef(null); const addButtonRef = React20.useRef(null);
React19.useEffect(() => { React20.useEffect(() => {
const disposeDingtalk = installDingtalkStyles(); const disposeDingtalk = installDingtalkStyles();
const disposeWhatsapp = installWhatsappStyles(); const disposeWhatsapp = installWhatsappStyles();
mounted.current = true; mounted.current = true;
@ -11856,11 +12037,11 @@ function WhatsappSettingsTab({ rpcCall }) {
disposeDingtalk(); disposeDingtalk();
}; };
}, []); }, []);
const invoke = React19.useCallback(async (endpoint, payload = {}, signal) => { const invoke = React20.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== "function") throw new TypeError("WhatsApp \u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5"); if (typeof rpcCall !== "function") throw new TypeError("WhatsApp \u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5");
return unwrapRpcResult9(await rpcCall(endpoint, payload, signal)); return unwrapRpcResult9(await rpcCall(endpoint, payload, signal));
}, [rpcCall]); }, [rpcCall]);
const loadStatus = React19.useCallback(async ({ signal, silent = false, restore = false } = {}) => { const loadStatus = React20.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus(); const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return void 0; if (workspaceVersion === null) return void 0;
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null })); if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null }));
@ -11872,7 +12053,8 @@ function WhatsappSettingsTab({ rpcCall }) {
bots: snapshot.bots, bots: snapshot.bots,
totals: snapshot.totals, totals: snapshot.totals,
error: null, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG
}); });
if (restore && snapshot.provisioning) setProvision({ if (restore && snapshot.provisioning) setProvision({
...snapshot.provisioning, ...snapshot.provisioning,
@ -11890,12 +12072,12 @@ function WhatsappSettingsTab({ rpcCall }) {
return void 0; return void 0;
} }
}, [invoke, workspaceFence]); }, [invoke, workspaceFence]);
React19.useEffect(() => { React20.useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
void loadStatus({ signal: controller.signal, restore: true }); void loadStatus({ signal: controller.signal, restore: true });
return () => controller.abort(); return () => controller.abort();
}, [loadStatus]); }, [loadStatus]);
React19.useEffect(() => { React20.useEffect(() => {
if (model.phase !== "ready") return void 0; if (model.phase !== "ready") return void 0;
const controller = new AbortController(); const controller = new AbortController();
const timer = window.setInterval( const timer = window.setInterval(
@ -11907,12 +12089,12 @@ function WhatsappSettingsTab({ rpcCall }) {
window.clearInterval(timer); window.clearInterval(timer);
}; };
}, [loadStatus, model.phase]); }, [loadStatus, model.phase]);
React19.useEffect(() => { React20.useEffect(() => {
if (!provision || !ACTIVE_STATES3.has(provision.status)) return void 0; if (!provision || !ACTIVE_STATES3.has(provision.status)) return void 0;
const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3); const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [provision?.attemptId, provision?.status]); }, [provision?.attemptId, provision?.status]);
const startProvisioning = React19.useCallback(async (replace = false) => { const startProvisioning = React20.useCallback(async (replace = false) => {
setBusy(true); setBusy(true);
try { try {
if (replace && provision?.attemptId) { if (replace && provision?.attemptId) {
@ -11930,7 +12112,7 @@ function WhatsappSettingsTab({ rpcCall }) {
if (mounted.current) setBusy(false); if (mounted.current) setBusy(false);
} }
}, [invoke, provision?.attemptId]); }, [invoke, provision?.attemptId]);
const closeProvision = React19.useCallback(async () => { const closeProvision = React20.useCallback(async () => {
setBusy(true); setBusy(true);
try { try {
if (provision?.attemptId && ACTIVE_STATES3.has(provision.status)) { if (provision?.attemptId && ACTIVE_STATES3.has(provision.status)) {
@ -11941,7 +12123,7 @@ function WhatsappSettingsTab({ rpcCall }) {
if (mounted.current) setBusy(false); if (mounted.current) setBusy(false);
} }
}, [invoke, provision?.attemptId, provision?.status]); }, [invoke, provision?.attemptId, provision?.status]);
React19.useEffect(() => { React20.useEffect(() => {
const attemptId = provision?.attemptId; const attemptId = provision?.attemptId;
if (!attemptId || !ACTIVE_STATES3.has(provision.status)) return void 0; if (!attemptId || !ACTIVE_STATES3.has(provision.status)) return void 0;
const controller = new AbortController(); const controller = new AbortController();
@ -11982,7 +12164,7 @@ function WhatsappSettingsTab({ rpcCall }) {
if (timer) window.clearTimeout(timer); if (timer) window.clearTimeout(timer);
}; };
}, [invoke, loadStatus, provision?.attemptId, provision?.status]); }, [invoke, loadStatus, provision?.attemptId, provision?.status]);
const botAction = React19.useCallback(async (account, operation, endpoint, payload) => { const botAction = React20.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation(); const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation })); setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
if (operation === "reconnect") { if (operation === "reconnect") {
@ -12001,7 +12183,8 @@ function WhatsappSettingsTab({ rpcCall }) {
bots: snapshot.bots, bots: snapshot.bots,
totals: snapshot.totals, totals: snapshot.totals,
error: null, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG
}); });
if (operation === "reconnect") { if (operation === "reconnect") {
setTestNoticeByBot((current) => ({ setTestNoticeByBot((current) => ({
@ -12059,6 +12242,12 @@ function WhatsappSettingsTab({ rpcCall }) {
WHATSAPP_ENDPOINTS.setAgentPreset, WHATSAPP_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset } { botId: account.botId, agentPreset }
), ),
onDefaultModelSave: (defaultModel) => botAction(
account,
"model",
WHATSAPP_ENDPOINTS.setDefaultModel,
{ botId: account.botId, defaultModel }
),
onContextEnhancementSave: (config) => botAction( onContextEnhancementSave: (config) => botAction(
account, account,
"context-enhancement", "context-enhancement",
@ -12078,6 +12267,8 @@ function WhatsappSettingsTab({ rpcCall }) {
) : null; ) : null;
return h2(AgentPresetCatalogContext.Provider, { return h2(AgentPresetCatalogContext.Provider, {
value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG
}, h2(ModelCatalogContext.Provider, {
value: model.modelCatalog ?? EMPTY_MODEL_CATALOG
}, h2( }, h2(
"section", "section",
{ {
@ -12101,7 +12292,7 @@ function WhatsappSettingsTab({ rpcCall }) {
h2(Button14, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") h2(Button14, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6")
) )
) : h2( ) : h2(
React19.Fragment, React20.Fragment,
null, null,
provision?.status === "pending" ? h2(QrPanel5, { provision?.status === "pending" ? h2(QrPanel5, {
provision, provision,
@ -12117,11 +12308,11 @@ function WhatsappSettingsTab({ rpcCall }) {
}) : model.bots.length === 0 ? h2(EmptyView6, { busy, onStart: () => void startProvisioning(false) }) : null, }) : model.bots.length === 0 ? h2(EmptyView6, { busy, onStart: () => void startProvisioning(false) }) : null,
botList botList
) )
)); )));
} }
// plugin-src/client/channel-peer-label.js // plugin-src/client/channel-peer-label.js
var React20 = __toESM(require("react"), 1); var React21 = __toESM(require("react"), 1);
var CHANNEL_LABELS = Object.freeze({ var CHANNEL_LABELS = Object.freeze({
whatsapp: "WhatsApp", whatsapp: "WhatsApp",
feishu: "\u98DE\u4E66", feishu: "\u98DE\u4E66",
@ -12135,8 +12326,8 @@ var CHANNEL_LABELS = Object.freeze({
office: "AI Office" office: "AI Office"
}); });
function ChannelPeerLabel({ sessionId, resolveChannelPeer }) { function ChannelPeerLabel({ sessionId, resolveChannelPeer }) {
const [peer, setPeer] = React20.useState(null); const [peer, setPeer] = React21.useState(null);
React20.useEffect(() => { React21.useEffect(() => {
if (typeof resolveChannelPeer !== "function" || typeof sessionId !== "string" || !sessionId) { if (typeof resolveChannelPeer !== "function" || typeof sessionId !== "string" || !sessionId) {
setPeer(null); setPeer(null);
return void 0; return void 0;
@ -12193,10 +12384,10 @@ async function resolveWhatsappChannelPeer(whatsappRpcCall, sessionId, signal) {
} }
// plugin-src/client/delivery-settings.js // plugin-src/client/delivery-settings.js
var React23 = __toESM(require("react"), 1); var React24 = __toESM(require("react"), 1);
// plugin-src/client/access-policy-settings.js // plugin-src/client/access-policy-settings.js
var React21 = __toESM(require("react"), 1); var React22 = __toESM(require("react"), 1);
var ACCESS_POLICY_ENDPOINT = "bot.access-policy.set"; var ACCESS_POLICY_ENDPOINT = "bot.access-policy.set";
var GROUP_SESSION_SCOPE_ENDPOINT = "bot.group-session-scope.set"; var GROUP_SESSION_SCOPE_ENDPOINT = "bot.group-session-scope.set";
var ACCESS_CHANNEL_DEFINITIONS = Object.freeze({ var ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
@ -12299,8 +12490,8 @@ function ScenePolicyEditor({
unsupported = false, unsupported = false,
onChange onChange
}) { }) {
const ownerHelpId = React21.useId(); const ownerHelpId = React22.useId();
const emptyAllowlistHelpId = React21.useId(); const emptyAllowlistHelpId = React22.useId();
const allowlist = policy.mode === "allowlist"; const allowlist = policy.mode === "allowlist";
const collectionKey = allowlist ? "users" : "commandPermissionOverrides"; const collectionKey = allowlist ? "users" : "commandPermissionOverrides";
const branchKey = allowlist ? "allowlist" : "open"; const branchKey = allowlist ? "allowlist" : "open";
@ -12352,7 +12543,7 @@ function ScenePolicyEditor({
h2("strong", null, "\u5F53\u524D\u6E20\u9053\u4E0D\u652F\u6301\u7FA4\u804A"), h2("strong", null, "\u5F53\u524D\u6E20\u9053\u4E0D\u652F\u6301\u7FA4\u804A"),
h2("p", null, "\u6B64\u533A\u57DF\u65E0\u9700\u914D\u7F6E\uFF0C\u4FDD\u5B58\u79C1\u804A\u8BBE\u7F6E\u65F6\u4F1A\u4FDD\u7559\u73B0\u6709\u7FA4\u804A\u7B56\u7565\u3002") h2("p", null, "\u6B64\u533A\u57DF\u65E0\u9700\u914D\u7F6E\uFF0C\u4FDD\u5B58\u79C1\u804A\u8BBE\u7F6E\u65F6\u4F1A\u4FDD\u7559\u73B0\u6709\u7FA4\u804A\u7B56\u7565\u3002")
) : h2( ) : h2(
React21.Fragment, React22.Fragment,
null, null,
h2( h2(
"div", "div",
@ -12494,18 +12685,18 @@ function AccessPolicySettingsPage({ channel: channel4, account, rpcCall, onSaved
const initialPolicy = normalizeAccessPolicy(account?.accessPolicy); const initialPolicy = normalizeAccessPolicy(account?.accessPolicy);
const initialKey = JSON.stringify(initialPolicy); const initialKey = JSON.stringify(initialPolicy);
const initialScope = normalizeGroupSessionScope(account?.groupSessionScope); const initialScope = normalizeGroupSessionScope(account?.groupSessionScope);
const [draft, setDraft] = React21.useState(() => clonePolicy( const [draft, setDraft] = React22.useState(() => clonePolicy(
initialPolicy ?? DEFAULT_ACCESS_POLICY initialPolicy ?? DEFAULT_ACCESS_POLICY
)); ));
const [groupSessionScope, setGroupSessionScope] = React21.useState(initialScope); const [groupSessionScope, setGroupSessionScope] = React22.useState(initialScope);
const [saving, setSaving] = React21.useState(false); const [saving, setSaving] = React22.useState(false);
const [feedback, setFeedback] = React21.useState(null); const [feedback, setFeedback] = React22.useState(null);
React21.useEffect(() => { React22.useEffect(() => {
const next = normalizeAccessPolicy(account?.accessPolicy); const next = normalizeAccessPolicy(account?.accessPolicy);
setDraft(clonePolicy(next ?? DEFAULT_ACCESS_POLICY)); setDraft(clonePolicy(next ?? DEFAULT_ACCESS_POLICY));
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope)); setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
}, [account?.botId, initialKey, account?.groupSessionScope]); }, [account?.botId, initialKey, account?.groupSessionScope]);
React21.useEffect(() => { React22.useEffect(() => {
setFeedback(null); setFeedback(null);
}, [account?.botId]); }, [account?.botId]);
if (!definition) { if (!definition) {
@ -12658,7 +12849,7 @@ function AccessPolicySettingsPage({ channel: channel4, account, rpcCall, onSaved
} }
// plugin-src/client/access-grant-settings.js // plugin-src/client/access-grant-settings.js
var React22 = __toESM(require("react"), 1); var React23 = __toESM(require("react"), 1);
var ACCESS_GRANT_ENDPOINT = "bot.access-grant.set"; var ACCESS_GRANT_ENDPOINT = "bot.access-grant.set";
var ACCESS_PENDING_RESOLVE_ENDPOINT = "bot.access-pending.resolve"; var ACCESS_PENDING_RESOLVE_ENDPOINT = "bot.access-pending.resolve";
var ACCESS_GROUP_TITLES_REFRESH_ENDPOINT = "bot.access-group-titles.refresh"; var ACCESS_GROUP_TITLES_REFRESH_ENDPOINT = "bot.access-group-titles.refresh";
@ -12750,13 +12941,13 @@ function PhoneTypeahead({
disabled, disabled,
requirePhone = true requirePhone = true
}) { }) {
const [query, setQuery] = React22.useState(value || ""); const [query, setQuery] = React23.useState(value || "");
const [open, setOpen] = React22.useState(false); const [open, setOpen] = React23.useState(false);
const blurTimer = React22.useRef(null); const blurTimer = React23.useRef(null);
React22.useEffect(() => { React23.useEffect(() => {
setQuery(value || ""); setQuery(value || "");
}, [value]); }, [value]);
React22.useEffect(() => () => { React23.useEffect(() => () => {
if (blurTimer.current) window.clearTimeout(blurTimer.current); if (blurTimer.current) window.clearTimeout(blurTimer.current);
}, []); }, []);
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
@ -13064,21 +13255,21 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
const initialGrant = normalizeAccessGrant(account?.accessGrant); const initialGrant = normalizeAccessGrant(account?.accessGrant);
const initialKey = JSON.stringify(initialGrant); const initialKey = JSON.stringify(initialGrant);
const initialScope = normalizeGroupSessionScope(account?.groupSessionScope); const initialScope = normalizeGroupSessionScope(account?.groupSessionScope);
const [draft, setDraft] = React22.useState(() => cloneGrant(initialGrant, ownerPhone)); const [draft, setDraft] = React23.useState(() => cloneGrant(initialGrant, ownerPhone));
const [groupSessionScope, setGroupSessionScope] = React22.useState(initialScope); const [groupSessionScope, setGroupSessionScope] = React23.useState(initialScope);
const [saving, setSaving] = React22.useState(false); const [saving, setSaving] = React23.useState(false);
const [feedback, setFeedback] = React22.useState(null); const [feedback, setFeedback] = React23.useState(null);
const [newGroupJid, setNewGroupJid] = React22.useState(""); const [newGroupJid, setNewGroupJid] = React23.useState("");
const [loadingGrant, setLoadingGrant] = React22.useState(!initialGrant); const [loadingGrant, setLoadingGrant] = React23.useState(!initialGrant);
const contextCatalog = React22.useContext(AgentPresetCatalogContext); const contextCatalog = React23.useContext(AgentPresetCatalogContext);
const [presetCatalog, setPresetCatalog] = React22.useState(() => normalizeAgentPresetCatalog( const [presetCatalog, setPresetCatalog] = React23.useState(() => normalizeAgentPresetCatalog(
account?.agentPresetCatalog ?? contextCatalog ?? EMPTY_AGENT_PRESET_CATALOG account?.agentPresetCatalog ?? contextCatalog ?? EMPTY_AGENT_PRESET_CATALOG
)); ));
React22.useEffect(() => { React23.useEffect(() => {
setDraft(cloneGrant(normalizeAccessGrant(account?.accessGrant), ownerPhone)); setDraft(cloneGrant(normalizeAccessGrant(account?.accessGrant), ownerPhone));
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope)); setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
}, [account?.botId, initialKey, account?.groupSessionScope, ownerPhone]); }, [account?.botId, initialKey, account?.groupSessionScope, ownerPhone]);
React22.useEffect(() => { React23.useEffect(() => {
if (typeof rpcCall !== "function" || !account?.botId) return void 0; if (typeof rpcCall !== "function" || !account?.botId) return void 0;
let cancelled = false; let cancelled = false;
setLoadingGrant(true); setLoadingGrant(true);
@ -13132,7 +13323,7 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
}; };
}, [account?.botId, rpcCall, ownerPhone]); }, [account?.botId, rpcCall, ownerPhone]);
const contacts = draft.contacts ?? []; const contacts = draft.contacts ?? [];
const knownGroupJids = React22.useMemo(() => { const knownGroupJids = React23.useMemo(() => {
const set = new Set(Object.keys(draft.groups ?? {})); const set = new Set(Object.keys(draft.groups ?? {}));
for (const contact of contacts) { for (const contact of contacts) {
for (const jid of contact.groupJids ?? []) set.add(jid); for (const jid of contact.groupJids ?? []) set.add(jid);
@ -13142,7 +13333,7 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
} }
return [...set].sort(); return [...set].sort();
}, [draft.groups, draft.pending, contacts]); }, [draft.groups, draft.pending, contacts]);
const actionableContacts = React22.useMemo( const actionableContacts = React23.useMemo(
() => contacts.filter((contact) => contactNeedsGrantAction(contact, draft, knownGroupJids)), () => contacts.filter((contact) => contactNeedsGrantAction(contact, draft, knownGroupJids)),
[contacts, draft, knownGroupJids] [contacts, draft, knownGroupJids]
); );
@ -13821,13 +14012,13 @@ function TargetForm({
}) { }) {
const editing = mode === "edit"; const editing = mode === "edit";
const initialKind = initialValue?.kind && definition.fields[initialValue.kind] ? initialValue.kind : definition.kinds[0].value; const initialKind = initialValue?.kind && definition.fields[initialValue.kind] ? initialValue.kind : definition.kinds[0].value;
const [targetId, setTargetId] = React23.useState(initialValue?.targetId ?? ""); const [targetId, setTargetId] = React24.useState(initialValue?.targetId ?? "");
const [name2, setName] = React23.useState(initialValue?.name ?? ""); const [name2, setName] = React24.useState(initialValue?.name ?? "");
const [kind, setKind] = React23.useState(initialKind); const [kind, setKind] = React24.useState(initialKind);
const [route, setRoute] = React23.useState(initialValue?.route ?? {}); const [route, setRoute] = React24.useState(initialValue?.route ?? {});
const [error, setError] = React23.useState(null); const [error, setError] = React24.useState(null);
const [testing, setTesting] = React23.useState(false); const [testing, setTesting] = React24.useState(false);
const [testState, setTestState] = React23.useState(null); const [testState, setTestState] = React24.useState(null);
const currentTarget = () => { const currentTarget = () => {
const normalizedRoute = Object.fromEntries(fieldsFor(definition, kind).map((field) => { const normalizedRoute = Object.fromEntries(fieldsFor(definition, kind).map((field) => {
const raw = String(route[field.key] ?? "").trim(); const raw = String(route[field.key] ?? "").trim();
@ -14042,7 +14233,7 @@ function TargetSuggestionPicker({
} }
}, },
// Platform display names must stay raw (bypass h() i18n phrase rewrite). // Platform display names must stay raw (bypass h() i18n phrase rewrite).
React23.createElement( React24.createElement(
"strong", "strong",
null, null,
suggestionDisplayName(suggestion) || suggestionFallbackName(definition, suggestion) suggestionDisplayName(suggestion) || suggestionFallbackName(definition, suggestion)
@ -14051,7 +14242,7 @@ function TargetSuggestionPicker({
localizeText(kindLabel(definition, suggestion.kind)), localizeText(kindLabel(definition, suggestion.kind)),
added ? localizeText("\u5DF2\u6DFB\u52A0") : null added ? localizeText("\u5DF2\u6DFB\u52A0") : null
].filter(Boolean).join(" \xB7 ")), ].filter(Boolean).join(" \xB7 ")),
React23.createElement("code", null, fieldsFor(definition, suggestion.kind).map((field) => maskRouteValue(suggestion.route?.[field.key])).filter(Boolean).join(" \xB7 ")) React24.createElement("code", null, fieldsFor(definition, suggestion.kind).map((field) => maskRouteValue(suggestion.route?.[field.key])).filter(Boolean).join(" \xB7 "))
) )
); );
}) })
@ -14065,10 +14256,10 @@ function TargetSuggestionPicker({
); );
} }
function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, onEdit }) { function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, onEdit }) {
const [action, setAction] = React23.useState(null); const [action, setAction] = React24.useState(null);
const [testState, setTestState] = React23.useState(null); const [testState, setTestState] = React24.useState(null);
const [copyState, setCopyState] = React23.useState(null); const [copyState, setCopyState] = React24.useState(null);
const [confirmDelete, setConfirmDelete] = React23.useState(false); const [confirmDelete, setConfirmDelete] = React24.useState(false);
const testTarget = async () => { const testTarget = async () => {
setAction("test"); setAction("test");
setTestState(null); setTestState(null);
@ -14110,7 +14301,7 @@ function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, o
h2( h2(
"div", "div",
{ className: "dim-targetTitle" }, { className: "dim-targetTitle" },
React23.createElement("strong", null, target.name || target.targetId), React24.createElement("strong", null, target.name || target.targetId),
h2("span", null, kindLabel(definition, target.kind)) h2("span", null, kindLabel(definition, target.kind))
), ),
h2("code", null, `targetId: ${target.targetId}`) h2("code", null, `targetId: ${target.targetId}`)
@ -14173,28 +14364,28 @@ function DeliveryTargetSettingsPage({
onBack onBack
}) { }) {
const definition = CHANNEL_DEFINITIONS[channel4]; const definition = CHANNEL_DEFINITIONS[channel4];
const [activeTabId, setActiveTabId] = React23.useState(BOT_SETTINGS_TABS[0].id); const [activeTabId, setActiveTabId] = React24.useState(BOT_SETTINGS_TABS[0].id);
const [phase, setPhase] = React23.useState("loading"); const [phase, setPhase] = React24.useState("loading");
const [targets, setTargets] = React23.useState([]); const [targets, setTargets] = React24.useState([]);
const [suggestionPhase, setSuggestionPhase] = React23.useState("idle"); const [suggestionPhase, setSuggestionPhase] = React24.useState("idle");
const [suggestions, setSuggestions] = React23.useState([]); const [suggestions, setSuggestions] = React24.useState([]);
const [suggestionError, setSuggestionError] = React23.useState(null); const [suggestionError, setSuggestionError] = React24.useState(null);
const [error, setError] = React23.useState(null); const [error, setError] = React24.useState(null);
const [editor, setEditor] = React23.useState(null); const [editor, setEditor] = React24.useState(null);
const [saving, setSaving] = React23.useState(false); const [saving, setSaving] = React24.useState(false);
const [botCopyState, setBotCopyState] = React23.useState(null); const [botCopyState, setBotCopyState] = React24.useState(null);
const [accessPolicy, setAccessPolicy] = React23.useState(account.accessPolicy); const [accessPolicy, setAccessPolicy] = React24.useState(account.accessPolicy);
const [accessGrant, setAccessGrant] = React23.useState(account.accessGrant); const [accessGrant, setAccessGrant] = React24.useState(account.accessGrant);
const mounted = React23.useRef(true); const mounted = React24.useRef(true);
React23.useEffect(() => { React24.useEffect(() => {
setAccessPolicy(account.accessPolicy); setAccessPolicy(account.accessPolicy);
setAccessGrant(account.accessGrant); setAccessGrant(account.accessGrant);
}, [account.botId, account.accessPolicy, account.accessGrant]); }, [account.botId, account.accessPolicy, account.accessGrant]);
const invoke = React23.useCallback(async (endpoint, payload = {}, signal) => { const invoke = React24.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== "function") throw new Error("\u6295\u9012\u76EE\u6807\u8BBE\u7F6E\u6682\u4E0D\u53EF\u7528\u3002"); if (typeof rpcCall !== "function") throw new Error("\u6295\u9012\u76EE\u6807\u8BBE\u7F6E\u6682\u4E0D\u53EF\u7528\u3002");
return unwrapRpcResult12(await rpcCall(endpoint, payload, signal)); return unwrapRpcResult12(await rpcCall(endpoint, payload, signal));
}, [rpcCall]); }, [rpcCall]);
const loadTargets = React23.useCallback(async ({ signal, silent = false } = {}) => { const loadTargets = React24.useCallback(async ({ signal, silent = false } = {}) => {
if (!silent) setPhase("loading"); if (!silent) setPhase("loading");
setError(null); setError(null);
try { try {
@ -14208,7 +14399,7 @@ function DeliveryTargetSettingsPage({
setPhase("error"); setPhase("error");
} }
}, [account.botId, invoke]); }, [account.botId, invoke]);
const loadSuggestions = React23.useCallback(async () => { const loadSuggestions = React24.useCallback(async () => {
setSuggestionPhase("loading"); setSuggestionPhase("loading");
setSuggestionError(null); setSuggestionError(null);
try { try {
@ -14222,7 +14413,7 @@ function DeliveryTargetSettingsPage({
setSuggestionPhase("error"); setSuggestionPhase("error");
} }
}, [account.botId, definition, invoke]); }, [account.botId, definition, invoke]);
React23.useEffect(() => { React24.useEffect(() => {
mounted.current = true; mounted.current = true;
const controller = new AbortController(); const controller = new AbortController();
void loadTargets({ signal: controller.signal }); void loadTargets({ signal: controller.signal });
@ -14349,7 +14540,7 @@ function DeliveryTargetSettingsPage({
rpcCall: accessRpcCall, rpcCall: accessRpcCall,
onSaved: setAccessPolicy onSaved: setAccessPolicy
}) : h2( }) : h2(
React23.Fragment, React24.Fragment,
null, null,
h2( h2(
"section", "section",
@ -15170,7 +15361,7 @@ function installImStyles() {
} }
// plugin-src/client/update-panel.js // plugin-src/client/update-panel.js
var React24 = __toESM(require("react"), 1); var React25 = __toESM(require("react"), 1);
var import_react_dom3 = require("react-dom"); var import_react_dom3 = require("react-dom");
var import_valid = __toESM(require_valid(), 1); var import_valid = __toESM(require_valid(), 1);
var import_rcompare = __toESM(require_rcompare(), 1); var import_rcompare = __toESM(require_rcompare(), 1);
@ -15260,11 +15451,11 @@ function manualUpdateCommand(snapshot) {
return `dsh plugin --profile ${profileArgument} add -w dsh-im-ops@${version}`; return `dsh plugin --profile ${profileArgument} add -w dsh-im-ops@${version}`;
} }
function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) { function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
const [copyState, setCopyState] = React24.useState("idle"); const [copyState, setCopyState] = React25.useState("idle");
const commandRef = React24.useRef(null); const commandRef = React25.useRef(null);
const mounted = React24.useRef(false); const mounted = React25.useRef(false);
const copying = React24.useRef(false); const copying = React25.useRef(false);
React24.useEffect(() => { React25.useEffect(() => {
mounted.current = true; mounted.current = true;
return () => { return () => {
mounted.current = false; mounted.current = false;
@ -15296,7 +15487,7 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
{ className: "dim-updateManual", "aria-label": "\u624B\u5DE5\u66F4\u65B0" }, { className: "dim-updateManual", "aria-label": "\u624B\u5DE5\u66F4\u65B0" },
h2("h4", { className: "dim-updateManualHeading" }, "\u624B\u5DE5\u66F4\u65B0"), h2("h4", { className: "dim-updateManualHeading" }, "\u624B\u5DE5\u66F4\u65B0"),
command ? h2( command ? h2(
React24.Fragment, React25.Fragment,
null, null,
h2("p", { className: "dim-updateManualHint" }, "\u81EA\u52A8\u66F4\u65B0\u5931\u8D25\u53EF\u4EE5\u4F7F\u7528\u547D\u4EE4\u66F4\u65B0\uFF1A"), h2("p", { className: "dim-updateManualHint" }, "\u81EA\u52A8\u66F4\u65B0\u5931\u8D25\u53EF\u4EE5\u4F7F\u7528\u547D\u4EE4\u66F4\u65B0\uFF1A"),
h2( h2(
@ -15332,7 +15523,7 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
"aria-hidden": "true", "aria-hidden": "true",
focusable: "false" focusable: "false"
}, copyState === "copied" ? h2("path", { d: "m4 10 4 4 8-8" }) : h2( }, copyState === "copied" ? h2("path", { d: "m4 10 4 4 8-8" }) : h2(
React24.Fragment, React25.Fragment,
null, null,
h2("rect", { x: 7, y: 7, width: 10, height: 11, rx: 1.5 }), h2("rect", { x: 7, y: 7, width: 10, height: 11, rx: 1.5 }),
h2("path", { d: "M5 13H3.5A1.5 1.5 0 0 1 2 11.5v-8A1.5 1.5 0 0 1 3.5 2h8A1.5 1.5 0 0 1 13 3.5V5" }) h2("path", { d: "M5 13H3.5A1.5 1.5 0 0 1 2 11.5v-8A1.5 1.5 0 0 1 3.5 2h8A1.5 1.5 0 0 1 13 3.5V5" })
@ -15357,10 +15548,10 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
); );
} }
function UpdateDialog({ children, onClose }) { function UpdateDialog({ children, onClose }) {
const dialogRef = React24.useRef(null); const dialogRef = React25.useRef(null);
const titleId = React24.useId(); const titleId = React25.useId();
const descriptionId = React24.useId(); const descriptionId = React25.useId();
React24.useEffect(() => { React25.useEffect(() => {
const previous = globalThis.document?.activeElement; const previous = globalThis.document?.activeElement;
dialogRef.current?.focus?.(); dialogRef.current?.focus?.();
return () => { return () => {
@ -15418,19 +15609,19 @@ function UpdateDialog({ children, onClose }) {
return typeof document !== "undefined" && document.body ? (0, import_react_dom3.createPortal)(content, document.body) : content; return typeof document !== "undefined" && document.body ? (0, import_react_dom3.createPortal)(content, document.body) : content;
} }
function UpdatePanel({ rpcCall, clientVersion, onStatus }) { function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const [snapshot, setSnapshot] = React24.useState(null); const [snapshot, setSnapshot] = React25.useState(null);
const [action, setAction] = React24.useState("status"); const [action, setAction] = React25.useState("status");
const [error, setError] = React24.useState(null); const [error, setError] = React25.useState(null);
const [open, setOpen] = React24.useState(false); const [open, setOpen] = React25.useState(false);
const [uncertainInstall, setUncertainInstall] = React24.useState(false); const [uncertainInstall, setUncertainInstall] = React25.useState(false);
const mounted = React24.useRef(false); const mounted = React25.useRef(false);
const busy = React24.useRef(false); const busy = React25.useRef(false);
const readController = React24.useRef(null); const readController = React25.useRef(null);
const pollReadController = React24.useRef(null); const pollReadController = React25.useRef(null);
const installRequest = React24.useRef(null); const installRequest = React25.useRef(null);
const onStatusRef = React24.useRef(onStatus); const onStatusRef = React25.useRef(onStatus);
onStatusRef.current = onStatus; onStatusRef.current = onStatus;
const accept = React24.useCallback((next) => { const accept = React25.useCallback((next) => {
setSnapshot(next); setSnapshot(next);
onStatusRef.current?.(next); onStatusRef.current?.(next);
}, []); }, []);
@ -15439,7 +15630,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
setUncertainInstall(false); setUncertainInstall(false);
accept(next); accept(next);
}; };
const invoke = React24.useCallback(async (endpoint, payload = {}, signal) => { const invoke = React25.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== "function") { if (typeof rpcCall !== "function") {
const unavailable = new Error("\u5F53\u524D Host \u4E0D\u652F\u6301\u66F4\u65B0\u63A5\u53E3\uFF0C\u8BF7\u5148\u624B\u52A8\u66F4\u65B0\u63D2\u4EF6\u5E76\u91CD\u542F\u3002"); const unavailable = new Error("\u5F53\u524D Host \u4E0D\u652F\u6301\u66F4\u65B0\u63A5\u53E3\uFF0C\u8BF7\u5148\u624B\u52A8\u66F4\u65B0\u63D2\u4EF6\u5E76\u91CD\u542F\u3002");
unavailable.code = "update-unavailable"; unavailable.code = "update-unavailable";
@ -15447,7 +15638,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
} }
return unwrapSnapshot(await rpcCall(endpoint, payload, signal)); return unwrapSnapshot(await rpcCall(endpoint, payload, signal));
}, [rpcCall]); }, [rpcCall]);
React24.useEffect(() => { React25.useEffect(() => {
mounted.current = true; mounted.current = true;
busy.current = true; busy.current = true;
const controller = new AbortController(); const controller = new AbortController();
@ -15470,7 +15661,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const activeJob = ACTIVE_STATES4.has(snapshot?.job?.state); const activeJob = ACTIVE_STATES4.has(snapshot?.job?.state);
const restartRequired = snapshot?.job?.state === "restart-required" || snapshot?.blockedReason === "pending-restart"; const restartRequired = snapshot?.job?.state === "restart-required" || snapshot?.blockedReason === "pending-restart";
const shouldPoll = activeJob || uncertainInstall; const shouldPoll = activeJob || uncertainInstall;
React24.useEffect(() => { React25.useEffect(() => {
if (!shouldPoll) return void 0; if (!shouldPoll) return void 0;
let controller; let controller;
const scheduler = createPollScheduler({ const scheduler = createPollScheduler({
@ -15568,7 +15759,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const manualCommand = manualUpdateCommand(snapshot); const manualCommand = manualUpdateCommand(snapshot);
const buttonLabel = action === "checking" ? "\u68C0\u67E5\u4E2D\u2026" : action === "starting" || activeJob ? "\u6B63\u5728\u66F4\u65B0\u2026" : restartRequired ? "\u5F85\u624B\u52A8\u91CD\u542F" : snapshot?.canInstall ? "\u66F4\u65B0\u81F3" : "\u68C0\u67E5\u66F4\u65B0"; const buttonLabel = action === "checking" ? "\u68C0\u67E5\u4E2D\u2026" : action === "starting" || activeJob ? "\u6B63\u5728\u66F4\u65B0\u2026" : restartRequired ? "\u5F85\u624B\u52A8\u91CD\u542F" : snapshot?.canInstall ? "\u66F4\u65B0\u81F3" : "\u68C0\u67E5\u66F4\u65B0";
return h2( return h2(
React24.Fragment, React25.Fragment,
null, null,
h2("button", { h2("button", {
type: "button", type: "button",
@ -15593,13 +15784,13 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
h2("dt", null, "\u8FD0\u884C\u7248\u672C"), h2("dt", null, "\u8FD0\u884C\u7248\u672C"),
h2("dd", null, `v${snapshot?.runningVersion ?? clientVersion}`), h2("dd", null, `v${snapshot?.runningVersion ?? clientVersion}`),
snapshot?.installedVersion && snapshot.installedVersion !== snapshot.runningVersion ? h2( snapshot?.installedVersion && snapshot.installedVersion !== snapshot.runningVersion ? h2(
React24.Fragment, React25.Fragment,
null, null,
h2("dt", null, "\u5DF2\u5B89\u88C5\u7248\u672C"), h2("dt", null, "\u5DF2\u5B89\u88C5\u7248\u672C"),
h2("dd", null, `v${snapshot.installedVersion}`) h2("dd", null, `v${snapshot.installedVersion}`)
) : null, ) : null,
targetVersion ? h2( targetVersion ? h2(
React24.Fragment, React25.Fragment,
null, null,
h2("dt", null, "\u76EE\u6807\u7248\u672C"), h2("dt", null, "\u76EE\u6807\u7248\u672C"),
h2("dd", null, `v${targetVersion}`) h2("dd", null, `v${targetVersion}`)
@ -15812,20 +16003,20 @@ function IMSettingsTab({
browserLocation = globalThis.location, browserLocation = globalThis.location,
navigateToRecoveryUrl = replacePageLocation navigateToRecoveryUrl = replacePageLocation
}) { }) {
const [selected, setSelected] = React25.useState("weixin"); const [selected, setSelected] = React26.useState("weixin");
const [loopbackRecovery, setLoopbackRecovery] = React25.useState(null); const [loopbackRecovery, setLoopbackRecovery] = React26.useState(null);
const [runningVersion, setRunningVersion] = React25.useState(IM_PLUGIN_VERSION); const [runningVersion, setRunningVersion] = React26.useState(IM_PLUGIN_VERSION);
const [deliverySettings, setDeliverySettings] = React25.useState(null); const [deliverySettings, setDeliverySettings] = React26.useState(null);
const githubTooltipId = React25.useId(); const githubTooltipId = React26.useId();
const channelSelectId = React25.useId(); const channelSelectId = React26.useId();
const active = CHANNELS.find((channel4) => channel4.id === selected) ?? CHANNELS[0]; const active = CHANNELS.find((channel4) => channel4.id === selected) ?? CHANNELS[0];
const reportLoopbackRecovery = React25.useCallback((recovery) => { const reportLoopbackRecovery = React26.useCallback((recovery) => {
setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery); setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery);
}, []); }, []);
const reportUpdateStatus = React25.useCallback((snapshot) => { const reportUpdateStatus = React26.useCallback((snapshot) => {
setRunningVersion(snapshot.runningVersion); setRunningVersion(snapshot.runningVersion);
}, []); }, []);
const rpcCalls = React25.useMemo(() => createLoopbackAwareRpcCalls({ const rpcCalls = React26.useMemo(() => createLoopbackAwareRpcCalls({
dingtalkRpcCall, dingtalkRpcCall,
discordRpcCall, discordRpcCall,
feishuRpcCall, feishuRpcCall,
@ -15857,7 +16048,7 @@ function IMSettingsTab({
weixinRpcCall, weixinRpcCall,
whatsappRpcCall whatsappRpcCall
]); ]);
const botSettingsContext = React25.useMemo(() => Object.freeze({ const botSettingsContext = React26.useMemo(() => Object.freeze({
openBotSettings: setDeliverySettings openBotSettings: setDeliverySettings
}), []); }), []);
return h2( return h2(

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,9 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js'; 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 { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessGrant } from '../../../../src/channels/shared/access-grant.mjs'; import { normalizeAccessGrant } from '../../../../src/channels/shared/access-grant.mjs';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.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', setGroupSessionScope: 'bot.group-session-scope.set',
setWorkspace: 'bot.workspace.set', setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT, setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setDefaultModel: SET_DEFAULT_MODEL_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set', setContextEnhancement: 'bot.context-enhancement.set',
resolveChannelPeer: 'bot.session.channel-peer', resolveChannelPeer: 'bot.session.channel-peer',
}); });
@ -98,6 +104,7 @@ function normalizeBot(value) {
state: connected ? 'connected' : state, state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096), workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset), agentPreset: normalizeAgentPresetId(value.agentPreset),
defaultModel: normalizeDefaultModelSelection(value.defaultModel),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement), contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, 'accessPolicy') ...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(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 }, totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null, provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog), agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog),
modelCatalog: normalizeHostModelCatalog(source.modelCatalog),
}; };
} }

View file

@ -10,6 +10,11 @@ import {
AgentPresetEditor, AgentPresetEditor,
EMPTY_AGENT_PRESET_CATALOG, EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js'; } from '../../agent-preset.js';
import {
DefaultModelEditor,
EMPTY_MODEL_CATALOG,
ModelCatalogContext,
} from '../../default-model.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js'; import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { import {
BotSettingsButton, BotSettingsButton,
@ -186,6 +191,7 @@ export function WhatsappAccountCard({
onReconnect, onReconnect,
onWorkspaceSave, onWorkspaceSave,
onAgentPresetSave, onAgentPresetSave,
onDefaultModelSave,
onContextEnhancementSave, onContextEnhancementSave,
onRequestRemove, onRequestRemove,
onConfirmRemove, onConfirmRemove,
@ -233,6 +239,11 @@ export function WhatsappAccountCard({
disabled: Boolean(busy), disabled: Boolean(busy),
onSave: onAgentPresetSave, onSave: onAgentPresetSave,
}), }),
h(DefaultModelEditor, {
defaultModel: account.defaultModel,
disabled: Boolean(busy),
onSave: onDefaultModelSave,
}),
h(ContextEnhancementEditor, { h(ContextEnhancementEditor, {
config: account.contextEnhancement, config: account.contextEnhancement,
disabled: Boolean(busy), disabled: Boolean(busy),
@ -268,6 +279,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({ const [model, setModel] = React.useState({
phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null, phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null,
agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG, agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
modelCatalog: EMPTY_MODEL_CATALOG,
}); });
const [provision, setProvision] = React.useState(null); const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false); const [busy, setBusy] = React.useState(false);
@ -306,6 +318,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
setModel({ setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null, phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG, agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
}); });
if (restore && snapshot.provisioning) setProvision({ if (restore && snapshot.provisioning) setProvision({
...snapshot.provisioning, ...snapshot.provisioning,
@ -437,6 +450,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
setModel({ setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null, phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG, agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
}); });
if (operation === 'reconnect') { if (operation === 'reconnect') {
setTestNoticeByBot((current) => ({ setTestNoticeByBot((current) => ({
@ -495,6 +509,12 @@ export function WhatsappSettingsTab({ rpcCall }) {
WHATSAPP_ENDPOINTS.setAgentPreset, WHATSAPP_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset }, { botId: account.botId, agentPreset },
), ),
onDefaultModelSave: (defaultModel) => botAction(
account,
'model',
WHATSAPP_ENDPOINTS.setDefaultModel,
{ botId: account.botId, defaultModel },
),
onContextEnhancementSave: (config) => botAction( onContextEnhancementSave: (config) => botAction(
account, account,
'context-enhancement', 'context-enhancement',
@ -515,6 +535,8 @@ export function WhatsappSettingsTab({ rpcCall }) {
return h(AgentPresetCatalogContext.Provider, { return h(AgentPresetCatalogContext.Provider, {
value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG, value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
}, h(ModelCatalogContext.Provider, {
value: model.modelCatalog ?? EMPTY_MODEL_CATALOG,
}, h('section', { }, h('section', {
className: 'ddt-page dwa-page dim-channelPage', className: 'ddt-page dwa-page dim-channelPage',
'aria-label': 'WhatsApp 设置', 'aria-label': 'WhatsApp 设置',
@ -552,5 +574,5 @@ export function WhatsappSettingsTab({ rpcCall }) {
: model.bots.length === 0 : model.bots.length === 0
? h(EmptyView, { busy, onStart: () => void startProvisioning(false) }) ? h(EmptyView, { busy, onStart: () => void startProvisioning(false) })
: null, : null,
botList))); botList))));
} }

View 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,
);
}

View 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 };
}

View file

@ -22,6 +22,8 @@ export function publicWorkspaceError(error) {
'workspace-bot-not-found', 'workspace-bot-not-found',
'agent-preset-invalid', 'agent-preset-invalid',
'agent-preset-unavailable', 'agent-preset-unavailable',
'default-model-invalid',
'default-model-unavailable',
'context-enhancement-invalid', 'context-enhancement-invalid',
].includes(error?.code)) return null; ].includes(error?.code)) return null;
return { code: error.code, message: error.message }; return { code: error.code, message: error.message };

View file

@ -158,6 +158,7 @@ export async function createProductionController(ctx, config = {}, internals = {
workspaces, workspaces,
stateFor, stateFor,
agentPresetCatalog, agentPresetCatalog,
modelCatalog: () => harness.listModels(),
channel: 'whatsapp', channel: 'whatsapp',
}); });
const supervisor = createSupervisor({ const supervisor = createSupervisor({

View file

@ -16,6 +16,7 @@ import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs'; import { installConnectionRpcChannel } from '../../connection-rpc-mount.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.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_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_RPC_CHANNEL = '/whatsapp';
export const WHATSAPP_ENDPOINTS = Object.freeze({ export const WHATSAPP_ENDPOINTS = Object.freeze({
@ -32,6 +33,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
setGroupSessionScope: SET_GROUP_SESSION_SCOPE_ENDPOINT, setGroupSessionScope: SET_GROUP_SESSION_SCOPE_ENDPOINT,
setWorkspace: SET_WORKSPACE_ENDPOINT, setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT, setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setDefaultModel: SET_DEFAULT_MODEL_ENDPOINT,
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT, setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
resolveChannelPeer: 'bot.session.channel-peer', resolveChannelPeer: 'bot.session.channel-peer',
}); });
@ -97,6 +99,10 @@ function payloadFailure(endpoint, payload) {
return validAgentPresetPayload(payload) return validAgentPresetPayload(payload)
? null : '请选择 Agent Preset。'; ? null : '请选择 Agent Preset。';
} }
if (endpoint === WHATSAPP_ENDPOINTS.setDefaultModel) {
return validDefaultModelPayload(payload)
? null : '请选择默认模型。';
}
if (endpoint === WHATSAPP_ENDPOINTS.setContextEnhancement) { if (endpoint === WHATSAPP_ENDPOINTS.setContextEnhancement) {
return validContextEnhancementPayload(payload) return validContextEnhancementPayload(payload)
? null : '请提交有效的上下文增强设置。'; ? null : '请提交有效的上下文增强设置。';
@ -221,6 +227,12 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
await controller.updateAgentPreset(payload.botId, payload.agentPreset), await controller.updateAgentPreset(payload.botId, payload.agentPreset),
cachedEncode, 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) { } else if (endpoint === WHATSAPP_ENDPOINTS.setAccessPolicy) {
if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable'); if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable');
value = await controller.updateAccessPolicy( value = await controller.updateAccessPolicy(

View file

@ -473,6 +473,7 @@ class ModernHarnessApi {
const claimed = new Set([...this.#pendingApprovals.values()].map((entry) => entry.approvalId)); const claimed = new Set([...this.#pendingApprovals.values()].map((entry) => entry.approvalId));
const decided = new Set(); const decided = new Set();
let approvalId; let approvalId;
let fallbackId;
for (let index = owner.events.length - 1; index >= 0; index -= 1) { for (let index = owner.events.length - 1; index >= 0; index -= 1) {
const event = owner.events[index]; const event = owner.events[index];
if (event.type === 'approval/decided') { if (event.type === 'approval/decided') {
@ -480,11 +481,20 @@ class ModernHarnessApi {
} else if (event.type === 'approval/asked') { } else if (event.type === 'approval/asked') {
const id = event.data?.id; const id = event.data?.id;
if (!id || decided.has(id) || claimed.has(id)) continue; if (!id || decided.has(id) || claimed.has(id)) continue;
if ((request.callId ?? null) !== (event.data?.callId ?? null)) continue; // Prefer an exact callId match; keep the newest unmatched ask as a
approvalId = id; // fallback so a callId shape drift cannot silently hand the prompt to
break; // 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(); if (approvalId === undefined) return next();
return new Promise((resolve) => { return new Promise((resolve) => {
const pending = { const pending = {

View file

@ -13,6 +13,11 @@ import {
normalizeAgentPresetCatalog, normalizeAgentPresetCatalog,
validateAgentPresetId, validateAgentPresetId,
} from './agent-preset.mjs'; } from './agent-preset.mjs';
import {
catalogHasModel,
normalizeModelCatalog,
validateDefaultModel,
} from './default-model.mjs';
import { import {
normalizeAccessPolicy, normalizeAccessPolicy,
validateAccessPolicy, validateAccessPolicy,
@ -191,6 +196,21 @@ function normalizeDocument(value) {
} }
} }
} }
let defaultModels = {};
if (value.defaultModels !== undefined) {
if (!value.defaultModels || typeof value.defaultModels !== 'object'
|| Array.isArray(value.defaultModels)) return null;
for (const [botId, defaultModel] of Object.entries(value.defaultModels)) {
if (!/^[A-Za-z0-9_-]{1,128}$/.test(botId)) return null;
try {
const normalized = validateDefaultModel(defaultModel);
if (!normalized) return null;
defaultModels[botId] = normalized;
} catch {
return null;
}
}
}
let groupSessionScopes = {}; let groupSessionScopes = {};
if (value.groupSessionScopes !== undefined) { if (value.groupSessionScopes !== undefined) {
if (!value.groupSessionScopes || typeof value.groupSessionScopes !== 'object' if (!value.groupSessionScopes || typeof value.groupSessionScopes !== 'object'
@ -235,6 +255,7 @@ function normalizeDocument(value) {
version, version,
workspaces, workspaces,
agentPresets, agentPresets,
defaultModels,
groupSessionScopes, groupSessionScopes,
contextEnhancement, contextEnhancement,
deliveryTargets, deliveryTargets,
@ -247,6 +268,7 @@ function storedDocument({
version, version,
workspaces, workspaces,
agentPresets, agentPresets,
defaultModels,
groupSessionScopes, groupSessionScopes,
contextEnhancement, contextEnhancement,
deliveryTargets, deliveryTargets,
@ -255,6 +277,7 @@ function storedDocument({
}) { }) {
const document = { version, workspaces }; const document = { version, workspaces };
if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets; if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
if (Object.keys(defaultModels).length > 0) document.defaultModels = defaultModels;
if (Object.keys(groupSessionScopes).length > 0) document.groupSessionScopes = groupSessionScopes; if (Object.keys(groupSessionScopes).length > 0) document.groupSessionScopes = groupSessionScopes;
if (Object.keys(contextEnhancement).length > 0) { if (Object.keys(contextEnhancement).length > 0) {
document.contextEnhancement = contextEnhancement; document.contextEnhancement = contextEnhancement;
@ -310,6 +333,7 @@ export class BotWorkspaceStore {
#version = 1; #version = 1;
#workspaces = {}; #workspaces = {};
#agentPresets = {}; #agentPresets = {};
#defaultModels = {};
#groupSessionScopes = {}; #groupSessionScopes = {};
#contextEnhancement = {}; #contextEnhancement = {};
#deliveryTargets = Object.create(null); #deliveryTargets = Object.create(null);
@ -338,6 +362,7 @@ export class BotWorkspaceStore {
this.#version = normalized.version; this.#version = normalized.version;
this.#workspaces = normalized.workspaces; this.#workspaces = normalized.workspaces;
this.#agentPresets = normalized.agentPresets; this.#agentPresets = normalized.agentPresets;
this.#defaultModels = normalized.defaultModels;
this.#groupSessionScopes = normalized.groupSessionScopes; this.#groupSessionScopes = normalized.groupSessionScopes;
this.#contextEnhancement = normalized.contextEnhancement; this.#contextEnhancement = normalized.contextEnhancement;
this.#deliveryTargets = normalized.deliveryTargets; this.#deliveryTargets = normalized.deliveryTargets;
@ -348,6 +373,7 @@ export class BotWorkspaceStore {
this.#version = 1; this.#version = 1;
this.#workspaces = {}; this.#workspaces = {};
this.#agentPresets = {}; this.#agentPresets = {};
this.#defaultModels = {};
this.#groupSessionScopes = {}; this.#groupSessionScopes = {};
this.#contextEnhancement = {}; this.#contextEnhancement = {};
this.#deliveryTargets = Object.create(null); this.#deliveryTargets = Object.create(null);
@ -384,6 +410,10 @@ export class BotWorkspaceStore {
return this.#agentPresets[botIdOf(botId)] ?? null; return this.#agentPresets[botIdOf(botId)] ?? null;
} }
defaultModelFor(botId) {
return this.#defaultModels[botIdOf(botId)] ?? null;
}
groupSessionScopeFor(botId) { groupSessionScopeFor(botId) {
const id = botIdOf(botId); const id = botIdOf(botId);
if (this.has(id) && Object.hasOwn(this.#groupSessionScopes, id)) { if (this.has(id) && Object.hasOwn(this.#groupSessionScopes, id)) {
@ -654,6 +684,40 @@ export class BotWorkspaceStore {
}); });
} }
async setDefaultModel(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 defaultModel = validateDefaultModel(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.#defaultModels[id] ?? null;
const same = previous?.provider === defaultModel?.provider
&& previous?.model === defaultModel?.model
&& previous?.reasoningEffort === defaultModel?.reasoningEffort;
if (same) return defaultModel;
if (defaultModel) this.#defaultModels[id] = defaultModel;
else delete this.#defaultModels[id];
try {
await this.#persist();
} catch (error) {
if (previous) this.#defaultModels[id] = previous;
else delete this.#defaultModels[id];
throw error;
}
return defaultModel;
});
}
async setGroupSessionScope(botId, value, { incarnation } = {}) { async setGroupSessionScope(botId, value, { incarnation } = {}) {
const id = botIdOf(botId); const id = botIdOf(botId);
if (!this.has(id) if (!this.has(id)
@ -949,6 +1013,7 @@ export class BotWorkspaceStore {
const candidates = new Set([ const candidates = new Set([
...Object.keys(this.#workspaces), ...Object.keys(this.#workspaces),
...Object.keys(this.#agentPresets), ...Object.keys(this.#agentPresets),
...Object.keys(this.#defaultModels),
...Object.keys(this.#groupSessionScopes), ...Object.keys(this.#groupSessionScopes),
...Object.keys(this.#contextEnhancement), ...Object.keys(this.#contextEnhancement),
...Object.keys(this.#deliveryTargets), ...Object.keys(this.#deliveryTargets),
@ -970,6 +1035,7 @@ export class BotWorkspaceStore {
...bot, ...bot,
workspace: this.workspaceFor(bot.botId), workspace: this.workspaceFor(bot.botId),
agentPreset: this.agentPresetFor(bot.botId), agentPreset: this.agentPresetFor(bot.botId),
defaultModel: this.defaultModelFor(bot.botId),
groupSessionScope: this.groupSessionScopeFor(bot.botId), groupSessionScope: this.groupSessionScopeFor(bot.botId),
contextEnhancement: this.contextEnhancementFor(bot.botId), contextEnhancement: this.contextEnhancementFor(bot.botId),
accessPolicy: this.accessPolicyFor(bot.botId), accessPolicy: this.accessPolicyFor(bot.botId),
@ -1003,15 +1069,18 @@ export class BotWorkspaceStore {
async #retireCurrentIncarnation(id) { async #retireCurrentIncarnation(id) {
const hadWorkspace = Object.hasOwn(this.#workspaces, id); const hadWorkspace = Object.hasOwn(this.#workspaces, id);
const hadPreset = Object.hasOwn(this.#agentPresets, id); const hadPreset = Object.hasOwn(this.#agentPresets, id);
const hadDefaultModel = Object.hasOwn(this.#defaultModels, id);
const hadGroupSessionScope = Object.hasOwn(this.#groupSessionScopes, id); const hadGroupSessionScope = Object.hasOwn(this.#groupSessionScopes, id);
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id); const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id); const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id); const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
const hadAccessGrant = Object.hasOwn(this.#accessGrants, id); const hadAccessGrant = Object.hasOwn(this.#accessGrants, id);
const needsCleanup = hadWorkspace || hadPreset || hadGroupSessionScope || hadContextEnhancement const needsCleanup = hadWorkspace || hadPreset || hadDefaultModel || hadGroupSessionScope
|| hadContextEnhancement
|| hadDeliveryTargets || hadAccessPolicy || hadAccessGrant || this.#dirtyRemovals.has(id); || hadDeliveryTargets || hadAccessPolicy || hadAccessGrant || this.#dirtyRemovals.has(id);
delete this.#workspaces[id]; delete this.#workspaces[id];
delete this.#agentPresets[id]; delete this.#agentPresets[id];
delete this.#defaultModels[id];
delete this.#groupSessionScopes[id]; delete this.#groupSessionScopes[id];
delete this.#contextEnhancement[id]; delete this.#contextEnhancement[id];
delete this.#deliveryTargets[id]; delete this.#deliveryTargets[id];
@ -1056,6 +1125,7 @@ export class BotWorkspaceStore {
version, version,
workspaces: this.#workspaces, workspaces: this.#workspaces,
agentPresets: this.#agentPresets, agentPresets: this.#agentPresets,
defaultModels: this.#defaultModels,
groupSessionScopes: this.#groupSessionScopes, groupSessionScopes: this.#groupSessionScopes,
contextEnhancement, contextEnhancement,
deliveryTargets, deliveryTargets,
@ -1068,6 +1138,7 @@ export class BotWorkspaceStore {
async #persistCurrentDocument() { async #persistCurrentDocument() {
if (Object.keys(this.#workspaces).length > 0 if (Object.keys(this.#workspaces).length > 0
|| Object.keys(this.#agentPresets).length > 0 || Object.keys(this.#agentPresets).length > 0
|| Object.keys(this.#defaultModels).length > 0
|| Object.keys(this.#groupSessionScopes).length > 0 || Object.keys(this.#groupSessionScopes).length > 0
|| Object.keys(this.#contextEnhancement).length > 0 || Object.keys(this.#contextEnhancement).length > 0
|| Object.keys(this.#deliveryTargets).length > 0 || Object.keys(this.#deliveryTargets).length > 0
@ -1107,17 +1178,41 @@ function assertCurrentBotScope(isCurrentScope) {
throw error; throw error;
} }
function decorateResult(workspaces, result, catalog) { function decorateResult(workspaces, result, catalog, modelCatalog) {
const decorate = (value) => { const decorate = (value) => {
const decorated = workspaces.decorateStatus(value); const decorated = workspaces.decorateStatus(value);
if (!catalog || !decorated || typeof decorated !== 'object') return decorated; if (!decorated || typeof decorated !== 'object') return decorated;
const attachCatalog = (agentPresetCatalog) => ( const attach = (agentPresetCatalog, resolvedModelCatalog) => {
agentPresetCatalog ? { ...decorated, agentPresetCatalog } : decorated let next = decorated;
); if (agentPresetCatalog) next = { ...next, agentPresetCatalog };
const agentPresetCatalog = resolveAgentPresetCatalog(catalog); if (resolvedModelCatalog) next = { ...next, modelCatalog: resolvedModelCatalog };
return agentPresetCatalog && typeof agentPresetCatalog.then === 'function' return next;
? agentPresetCatalog.then(attachCatalog) };
: attachCatalog(agentPresetCatalog); const agentPresetCatalog = catalog
? resolveAgentPresetCatalog(catalog)
: null;
const resolvedModels = modelCatalog
? Promise.resolve().then(async () => {
try {
const raw = typeof modelCatalog === 'function' ? await modelCatalog() : await modelCatalog;
return normalizeModelCatalog(raw);
} catch {
return normalizeModelCatalog(null);
}
})
: null;
if (agentPresetCatalog && typeof agentPresetCatalog.then === 'function') {
if (resolvedModels) {
return Promise.all([agentPresetCatalog, resolvedModels]).then(([presets, models]) => (
attach(presets, models)
));
}
return agentPresetCatalog.then((presets) => attach(presets, null));
}
if (resolvedModels) {
return resolvedModels.then((models) => attach(agentPresetCatalog, models));
}
return attach(agentPresetCatalog, null);
}; };
return result && typeof result.then === 'function' return result && typeof result.then === 'function'
? result.then(decorate) ? result.then(decorate)
@ -1358,6 +1453,19 @@ export function createBotWorkspaceScope(
...(agentPreset == null ? {} : { agentPreset }), ...(agentPreset == null ? {} : { agentPreset }),
}); });
sessionGenerations.set(sessionId, generation); sessionGenerations.set(sessionId, generation);
const defaultModel = workspaces.defaultModelFor(botId);
if (defaultModel && typeof target.selectSessionModel === 'function') {
try {
await target.selectSessionModel(sessionId, defaultModel);
} catch (error) {
// Keep the new Session usable on Host default when the configured
// model disappeared; the settings page marks unavailable choices.
console.warn(
`[dsh-im] failed to apply default model for bot ${botId}:`,
error?.message ?? error,
);
}
}
return sessionId; return sessionId;
}; };
} }
@ -1522,6 +1630,7 @@ export function createWorkspaceAwareController(controller, {
workspaces, workspaces,
stateFor, stateFor,
agentPresetCatalog, agentPresetCatalog,
modelCatalog = null,
channel = null, channel = null,
} = {}) { } = {}) {
if (!controller || !workspaces || typeof stateFor !== 'function') { if (!controller || !workspaces || typeof stateFor !== 'function') {
@ -1537,7 +1646,7 @@ export function createWorkspaceAwareController(controller, {
if (transitions.get(botId) === current) transitions.delete(botId); if (transitions.get(botId) === current) transitions.delete(botId);
}); });
}; };
const decorate = (value) => decorateResult(workspaces, value, agentPresetCatalog); const decorate = (value) => decorateResult(workspaces, value, agentPresetCatalog, modelCatalog);
const updateWorkspace = (botId, workspace) => { const updateWorkspace = (botId, workspace) => {
// Capture at API invocation, before even waiting for an older outer // Capture at API invocation, before even waiting for an older outer
// transition. A queued request still belongs to the incarnation that the // transition. A queued request still belongs to the incarnation that the
@ -1580,6 +1689,40 @@ export function createWorkspaceAwareController(controller, {
workspaces, workspaces,
await controller.status(), await controller.status(),
catalog ?? agentPresetCatalog, catalog ?? agentPresetCatalog,
modelCatalog,
);
});
};
const updateDefaultModel = (botId, defaultModel) => {
const incarnation = workspaces.incarnationFor(botId);
const normalized = validateDefaultModel(defaultModel);
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;
}
let catalog = null;
if (normalized && modelCatalog) {
try {
const raw = typeof modelCatalog === 'function' ? await modelCatalog() : await modelCatalog;
catalog = normalizeModelCatalog(raw);
} catch {
catalog = normalizeModelCatalog(null);
}
if (!catalogHasModel(catalog, normalized)) {
const error = new Error('默认模型不存在或当前不可用。');
error.code = 'default-model-unavailable';
throw error;
}
}
await workspaces.setDefaultModel(botId, normalized, { incarnation });
return decorateResult(
workspaces,
await controller.status(),
agentPresetCatalog,
catalog ?? modelCatalog,
); );
}); });
}; };
@ -1774,6 +1917,7 @@ export function createWorkspaceAwareController(controller, {
get(target, property) { get(target, property) {
if (property === 'updateWorkspace') return updateWorkspace; if (property === 'updateWorkspace') return updateWorkspace;
if (property === 'updateAgentPreset') return updateAgentPreset; if (property === 'updateAgentPreset') return updateAgentPreset;
if (property === 'updateDefaultModel') return updateDefaultModel;
if (property === 'updateContextEnhancement') return updateContextEnhancement; if (property === 'updateContextEnhancement') return updateContextEnhancement;
if (property === 'updateAccessPolicy') return updateAccessPolicy; if (property === 'updateAccessPolicy') return updateAccessPolicy;
if (property === 'updateGroupSessionScope') return updateGroupSessionScope; if (property === 'updateGroupSessionScope') return updateGroupSessionScope;

View file

@ -0,0 +1,87 @@
/** Per-bot default Session model (provider + model id), applied on session.create. */
export function normalizeDefaultModel(value) {
if (value == null || value === '') return null;
if (typeof value === 'string') {
const trimmed = value.trim();
const slash = trimmed.indexOf('/');
if (slash <= 0 || slash === trimmed.length - 1) return null;
return {
provider: trimmed.slice(0, slash),
model: trimmed.slice(slash + 1),
};
}
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const provider = typeof value.provider === 'string' ? value.provider.trim() : '';
const model = typeof value.model === 'string' ? value.model.trim() : '';
if (!provider || !model) return null;
const selection = { provider, model };
if (value.reasoningEffort !== undefined) {
if (typeof value.reasoningEffort !== 'string' || !value.reasoningEffort.trim()) return null;
selection.reasoningEffort = value.reasoningEffort.trim();
}
return selection;
}
export function validateDefaultModel(value) {
if (value == null || value === '') return null;
const normalized = normalizeDefaultModel(value);
if (!normalized) {
const error = new Error('默认模型无效。');
error.code = 'default-model-invalid';
throw error;
}
return normalized;
}
export function defaultModelKey(selection) {
const normalized = normalizeDefaultModel(selection);
return normalized ? `${normalized.provider}/${normalized.model}` : '';
}
export function modelCatalogItems(catalog) {
const items = [];
const seen = new Set();
for (const group of Array.isArray(catalog?.groups) ? catalog.groups : []) {
const provider = typeof group?.id === 'string' ? group.id.trim() : '';
if (!provider || !Array.isArray(group.models)) continue;
for (const model of group.models) {
const modelId = typeof model?.id === 'string' ? model.id.trim() : '';
if (!modelId) continue;
const key = `${provider}/${modelId}`;
if (seen.has(key)) continue;
seen.add(key);
const label = typeof model.name === 'string' && model.name.trim()
? model.name.trim()
: modelId;
items.push({
provider,
model: modelId,
key,
label: label === modelId ? key : `${label}(${key})`,
});
}
}
return items;
}
export function catalogHasModel(catalog, selection) {
const normalized = normalizeDefaultModel(selection);
if (!normalized) return false;
return modelCatalogItems(catalog).some((item) => (
item.provider === normalized.provider && item.model === normalized.model
));
}
export function normalizeModelCatalog(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { groups: [], failures: [], items: [] };
}
const groups = Array.isArray(value.groups) ? value.groups : [];
const failures = Array.isArray(value.failures) ? value.failures : [];
return {
groups,
failures,
items: modelCatalogItems({ groups, failures }),
};
}

View file

@ -9,11 +9,21 @@ const APPROVAL_REPLIES = new Map([
['no', 'rejected'], ['no', 'rejected'],
]); ]);
const APPROVAL_PROMPT = '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。'; const APPROVAL_PROMPT = '请在本聊天精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。勿到 DeepSeek Harness 网页里点,否则无人看会一直卡住。';
const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。'; const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。';
const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。'; const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。';
const APPROVAL_TIMEOUT_TEXT = '审批等待超时,已自动拒绝此次操作。请重新发送问题,或调整工具审批策略后重试。';
const RESOLVED_ROUTE_TTL_MS = 5 * 60_000; const RESOLVED_ROUTE_TTL_MS = 5 * 60_000;
const MAX_RESOLVED_ROUTES = 2_048; const MAX_RESOLVED_ROUTES = 2_048;
/** Default 5 minutes; set DSH_IM_INTERACTION_TIMEOUT_MS=0 to disable. */
const DEFAULT_INTERACTION_TIMEOUT_MS = 5 * 60_000;
function interactionTimeoutMs(value = process.env.DSH_IM_INTERACTION_TIMEOUT_MS) {
if (value === undefined || value === null || value === '') return DEFAULT_INTERACTION_TIMEOUT_MS;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_INTERACTION_TIMEOUT_MS;
return parsed === 0 ? 0 : Math.max(5_000, Math.floor(parsed));
}
function cleanText(value) { function cleanText(value) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
@ -103,13 +113,19 @@ function approvalOutcomeText(outcome) {
export class HarnessApprovalQueue { export class HarnessApprovalQueue {
#label; #label;
#logger; #logger;
#timeoutMs;
#byId = new Map(); #byId = new Map();
#routes = new Map(); #routes = new Map();
#resolvedRoutes = new Map(); #resolvedRoutes = new Map();
constructor({ label = 'IM', logger = console } = {}) { constructor({
label = 'IM',
logger = console,
timeoutMs = interactionTimeoutMs(),
} = {}) {
this.#label = label; this.#label = label;
this.#logger = logger; this.#logger = logger;
this.#timeoutMs = interactionTimeoutMs(timeoutMs);
} }
hasPending(key) { hasPending(key) {
@ -225,7 +241,13 @@ export class HarnessApprovalQueue {
} }
if (interaction.recovered === true) { if (interaction.recovered === true) {
await this.#rejectInteraction(interaction, payload); const rejected = await this.#rejectInteraction(interaction, payload);
const send = context?.send;
if (rejected && typeof send === 'function') {
await send(t(
'检测到遗留的工具审批请求,已在聊天侧自动拒绝以免任务一直卡住。请重新发送你的问题。',
)).catch(() => undefined);
}
return true; return true;
} }
@ -283,11 +305,13 @@ export class HarnessApprovalQueue {
closedOutcome: null, closedOutcome: null,
resolutionNotified: false, resolutionNotified: false,
activationTask: null, activationTask: null,
timeoutTimer: null,
}; };
this.#byId.set(approvalId, pending); this.#byId.set(approvalId, pending);
const route = this.#routes.get(key) ?? { items: [] }; const route = this.#routes.get(key) ?? { items: [] };
route.items.push(pending); route.items.push(pending);
this.#routes.set(key, route); this.#routes.set(key, route);
this.#armTimeout(pending);
if (route.items[0] === pending) await this.#present(pending); if (route.items[0] === pending) await this.#present(pending);
return true; return true;
} }
@ -456,6 +480,7 @@ export class HarnessApprovalQueue {
#remove(pending) { #remove(pending) {
if (pending.inactive) return null; if (pending.inactive) return null;
pending.inactive = true; pending.inactive = true;
this.#clearTimeout(pending);
this.#rememberResolvedRoute(pending.key); this.#rememberResolvedRoute(pending.key);
this.#byId.delete(pending.approvalId); this.#byId.delete(pending.approvalId);
const route = this.#routes.get(pending.key); const route = this.#routes.get(pending.key);
@ -470,6 +495,36 @@ export class HarnessApprovalQueue {
return wasCurrent ? route.items[0] : null; return wasCurrent ? route.items[0] : null;
} }
#armTimeout(pending) {
this.#clearTimeout(pending);
if (!(this.#timeoutMs > 0)) return;
pending.timeoutTimer = setTimeout(() => {
pending.timeoutTimer = null;
if (pending.inactive || pending.submitting || pending.resolving) return;
this.#logger.warn?.(
`[dsh-im:${this.#label}] approval ${pending.approvalId} timed out after ${this.#timeoutMs}ms; auto-rejecting`,
);
void (async () => {
pending.resolutionNotified = true;
await pending.send(t(APPROVAL_TIMEOUT_TEXT)).catch(() => undefined);
if (pending.inactive || pending.submitting || pending.resolving) return;
await this.#submit(pending, 'rejected');
})().catch((error) => {
this.#logger.error?.(
`[dsh-im:${this.#label}] failed to auto-reject a timed-out approval:`,
error,
);
});
}, this.#timeoutMs);
pending.timeoutTimer.unref?.();
}
#clearTimeout(pending) {
if (!pending?.timeoutTimer) return;
clearTimeout(pending.timeoutTimer);
pending.timeoutTimer = null;
}
#rememberResolvedRoute(key) { #rememberResolvedRoute(key) {
const now = Date.now(); const now = Date.now();
for (const [routeKey, expiresAt] of this.#resolvedRoutes) { for (const [routeKey, expiresAt] of this.#resolvedRoutes) {

View file

@ -399,6 +399,11 @@ function consumeInteractionOwnership(ownership, entries) {
* Decide whether the current Host has a live dsh-im interaction watcher for * Decide whether the current Host has a live dsh-im interaction watcher for
* this Session. The modern Harness adapter uses the same ownership registry * this Session. The modern Harness adapter uses the same ownership registry
* as HarnessClient so it never steals a browser-owned question or approval. * as HarnessClient so it never steals a browser-owned question or approval.
*
* Claim while an IM ask is still open — not only after `user/message` has
* flipped `active`. Waiting for `active` races tool approvals that fire before
* ownership consumption catches up, which silently delegates to DSH Web and
* leaves WhatsApp/etc. hung with no visible prompt.
*/ */
export function hasActiveHarnessInteractionOwner(scope, sessionId, entries = []) { export function hasActiveHarnessInteractionOwner(scope, sessionId, entries = []) {
if (!scope || !['object', 'function', 'string'].includes(typeof scope) if (!scope || !['object', 'function', 'string'].includes(typeof scope)
@ -407,9 +412,9 @@ export function hasActiveHarnessInteractionOwner(scope, sessionId, entries = [])
if (!owners || owners.size === 0) return false; if (!owners || owners.size === 0) return false;
for (const ownership of owners) consumeInteractionOwnership(ownership, entries); for (const ownership of owners) consumeInteractionOwnership(ownership, entries);
return [...owners].some((ownership) => ( return [...owners].some((ownership) => (
ownership.active !ownership.completed
&& !ownership.completed
&& typeof ownership.reconnect === 'function' && typeof ownership.reconnect === 'function'
&& (ownership.active || ownership.started || ownership.promptRpcId)
)); ));
} }
@ -1248,6 +1253,18 @@ export class HarnessClient {
.sort((left, right) => left.order - right.order); .sort((left, right) => left.order - right.order);
if (active.length > 0) return { ownership: active[0], recovered: false }; if (active.length > 0) return { ownership: active[0], recovered: false };
// A live IM ask owns in-turn approvals/questions even before user/message
// flips `active`. Marking these recovered would auto-reject without ever
// showing the prompt in WhatsApp (or other text channels).
const live = owners
.filter((ownership) => (
!ownership.completed
&& typeof ownership.reconnect === 'function'
&& Boolean(ownership.promptRpcId)
))
.sort((left, right) => left.order - right.order)[0] ?? null;
if (live) return { ownership: live, recovered: false };
// A newly attached IM conversation may encounter a question left by // A newly attached IM conversation may encounter a question left by
// an earlier runtime before its queued prompt starts. Let the oldest such // an earlier runtime before its queued prompt starts. Let the oldest such
// ask adopt that replay so the Session can recover instead of deadlocking. // ask adopt that replay so the Session can recover instead of deadlocking.

View file

@ -57,8 +57,10 @@ export default {
'This conversation exceeds the model context limit. Send /compact or /new, then try again.', 'This conversation exceeds the model context limit. Send /compact or /new, then try again.',
'当前模型不存在或暂不可用。请发送 /models 查看并使用 /model 切换模型。': '当前模型不存在或暂不可用。请发送 /models 查看并使用 /model 切换模型。':
'The current model does not exist or is unavailable. Send /models and use /model to switch models.', 'The current model does not exist or is unavailable. Send /models and use /model to switch models.',
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。': '当前会话绑定的模型已不存在或不可用。请发送 /new 开启新会话后重试;或发送 /models,再用 /model 切换模型。':
'The current model does not exist or does not support the selected settings. Send /models and use /model to choose again.', 'The model bound to this Session no longer exists or is unavailable. Send /new to start a new Session, or send /models and use /model to switch.',
'原会话绑定的模型已失效,已自动开启新会话并重试。':
'The model bound to the previous Session was unavailable, so a new Session was started and the request was retried.',
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。': '当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。':
'The current model does not support this content or the selected settings. Adjust the content, model, or reasoning effort and try again.', 'The current model does not support this content or the selected settings. Adjust the content, model, or reasoning effort and try again.',
'当前模型不支持所选配置。请切换模型或推理等级后重试。': '当前模型不支持所选配置。请切换模型或推理等级后重试。':
@ -77,8 +79,8 @@ export default {
'The model rejected the current content. Revise the request and try again.', 'The model rejected the current content. Revise the request and try again.',
'模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。': '模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。':
'The model reached its output limit without producing a displayable result. Reduce the task scope and try again.', 'The model reached its output limit without producing a displayable result. Reduce the task scope and try again.',
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。': '任务正在等待审批或问答,但当前聊天未能收到该请求(可能只出现在 DeepSeek Harness 网页)。请发送 /stop 取消后重试;若再次出现,请在本聊天回复「批准」或「拒绝」。':
'The task is waiting for an action that cannot be completed in this channel. Handle it in DeepSeek Harness, then try again.', 'The task is waiting for an approval or question that did not reach this chat (it may only appear in the DeepSeek Harness web UI). Send /stop to cancel and try again; if it happens again, reply 批准 / 拒绝 (or approve / reject) here in chat.',
'任务被意外中断,本次未完成。请重试。': '任务被意外中断,本次未完成。请重试。':
'The task was unexpectedly interrupted and did not finish. Please try again.', 'The task was unexpectedly interrupted and did not finish. Please try again.',
'当前会话已不存在。请发送 /new 创建新会话后重试。': '当前会话已不存在。请发送 /new 创建新会话后重试。':
@ -182,4 +184,10 @@ export default {
'Failed to submit the answer. Please resend your answer to the current question.', 'Failed to submit the answer. Please resend your answer to the current question.',
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。': '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。':
'A pending question left over in this Session was detected. It has been safely cancelled, and your latest message is being processed.', 'A pending question left over in this Session was detected. It has been safely cancelled, and your latest message is being processed.',
'请在本聊天精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。勿到 DeepSeek Harness 网页里点,否则无人看会一直卡住。':
'Reply exactly with 批准 or 拒绝 in this chat (also: 同意 / 不同意 / yes / no). Do not use the DeepSeek Harness web UI — if nobody is watching it, the task will hang.',
'审批等待超时,已自动拒绝此次操作。请重新发送问题,或调整工具审批策略后重试。':
'Approval timed out and was automatically rejected. Resend your question, or adjust the tool-approval policy and try again.',
'检测到遗留的工具审批请求,已在聊天侧自动拒绝以免任务一直卡住。请重新发送你的问题。':
'A leftover tool-approval request was detected and automatically rejected in chat so the task would not hang. Please resend your question.',
}; };

View file

@ -103,7 +103,7 @@ const FAILURE_MESSAGES = Object.freeze({
MODEL_CONTEXT_LIMIT: MODEL_CONTEXT_LIMIT:
'当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。', '当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。',
MODEL_UNAVAILABLE: MODEL_UNAVAILABLE:
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。', '当前会话绑定的模型已不存在或不可用。请发送 /new 开启新会话后重试;或发送 /models,再用 /model 切换模型。',
MODEL_CONFIG: MODEL_CONFIG:
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。', '当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。',
MODEL_TIMEOUT: MODEL_TIMEOUT:
@ -121,7 +121,7 @@ const FAILURE_MESSAGES = Object.freeze({
MODEL_OUTPUT_LIMIT: MODEL_OUTPUT_LIMIT:
'模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。', '模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。',
TURN_BLOCKED: TURN_BLOCKED:
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。', '任务正在等待审批或问答,但当前聊天未能收到该请求(可能只出现在 DeepSeek Harness 网页)。请发送 /stop 取消后重试;若再次出现,请在本聊天回复「批准」或「拒绝」。',
TURN_INTERRUPTED: TURN_INTERRUPTED:
'任务被意外中断,本次未完成。请重试。', '任务被意外中断,本次未完成。请重试。',
SESSION_NOT_FOUND: SESSION_NOT_FOUND:

View file

@ -57,6 +57,7 @@ import {
} from './semantic/delivery.mjs'; } from './semantic/delivery.mjs';
import { import {
channelDeliveryFailure, channelDeliveryFailure,
classifyMessageFailure,
clearLastMessageFailure, clearLastMessageFailure,
messageFailureText, messageFailureText,
setLastMessageFailure, setLastMessageFailure,
@ -732,7 +733,7 @@ export class TextHarnessBridge {
})); }));
contextEnhanced = content !== originalContent; contextEnhanced = content !== originalContent;
} }
const { answer, artifacts = [] } = await askInWorkspaceSession({ const askArgs = {
harness: this.#harness, harness: this.#harness,
state: this.#state, state: this.#state,
key: conversationKey, key: conversationKey,
@ -770,14 +771,35 @@ export class TextHarnessBridge {
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution), onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files: message.files, files: message.files,
}, },
}); };
// Session remembers provider/model. When catalog/preset defaults change, the
// stale binding surfaces as MODEL_UNAVAILABLE — recreate once so field users
// need not find the Session in DSH UI or remember /new.
let sessionResetNotice = null;
let askResult;
try {
askResult = await askInWorkspaceSession(askArgs);
} catch (error) {
const staleModel = classifyMessageFailure(error).code === 'MODEL_UNAVAILABLE'
&& typeof this.#state.clearSession === 'function';
if (!staleModel) throw error;
this.#logger.warn?.(
`[dsh-im:${this.#descriptor.key}] session model unavailable; recreating Session and retrying once`,
);
await this.#state.clearSession(conversationKey);
askResult = await askInWorkspaceSession(askArgs);
sessionResetNotice = t('原会话绑定的模型已失效,已自动开启新会话并重试。');
}
const { answer, artifacts = [] } = askResult;
if (batchSubmission) { if (batchSubmission) {
this.#batches.complete(conversationKey, batchSubmission.token); this.#batches.complete(conversationKey, batchSubmission.token);
} }
const fileOnlyCompletion = !cleanText(answer) && artifacts.length > 0; const fileOnlyCompletion = !cleanText(answer) && artifacts.length > 0;
const visibleAnswer = fileOnlyCompletion const visibleAnswer = fileOnlyCompletion
? t(FILE_ONLY_COMPLETION_TEXT) ? t(FILE_ONLY_COMPLETION_TEXT)
: answer; : sessionResetNotice && cleanText(answer)
? `${sessionResetNotice}\n\n${answer}`
: (sessionResetNotice || answer);
const answerFormat = fileOnlyCompletion ? 'plain' : 'markdown'; const answerFormat = fileOnlyCompletion ? 'plain' : 'markdown';
let textDeliveryError = null; let textDeliveryError = null;
let textReceipt = null; let textReceipt = null;

View file

@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
catalogHasModel,
defaultModelKey,
normalizeDefaultModel,
normalizeModelCatalog,
validateDefaultModel,
} from '../../../src/channels/shared/default-model.mjs';
import {
BotWorkspaceStore,
createBotWorkspaceScope,
} from '../../../src/channels/shared/bot-workspace-store.mjs';
test('normalizeDefaultModel accepts object and provider/model strings', () => {
assert.deepEqual(normalizeDefaultModel({ provider: 'deepseek', model: 'chat' }), {
provider: 'deepseek',
model: 'chat',
});
assert.deepEqual(normalizeDefaultModel('openai/gpt-4.1'), {
provider: 'openai',
model: 'gpt-4.1',
});
assert.equal(normalizeDefaultModel(''), null);
assert.equal(normalizeDefaultModel({ provider: '', model: 'x' }), null);
assert.equal(defaultModelKey({ provider: 'a', model: 'b' }), 'a/b');
});
test('validateDefaultModel rejects junk', () => {
assert.equal(validateDefaultModel(null), null);
assert.throws(() => validateDefaultModel({ provider: 'x' }), (error) => (
error.code === 'default-model-invalid'
));
});
test('model catalog helpers flatten groups', () => {
const catalog = normalizeModelCatalog({
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'chat', name: 'Chat' }, { id: 'reasoner', name: 'Reasoner' }],
}],
failures: [],
});
assert.equal(catalog.items.length, 2);
assert.equal(catalogHasModel(catalog, 'deepseek/chat'), true);
assert.equal(catalogHasModel(catalog, 'deepseek/missing'), false);
});
test('BotWorkspaceStore persists and applies default model on createSession', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-default-model-'));
try {
const store = await new BotWorkspaceStore(join(root, 'workspaces.json'), {
defaultWorkspace: root,
}).load();
await store.ensure('bot-a');
await store.setDefaultModel('bot-a', { provider: 'deepseek', model: 'chat' });
assert.deepEqual(store.defaultModelFor('bot-a'), {
provider: 'deepseek',
model: 'chat',
});
const selected = [];
const harness = {
async createSession() { return 'session-1'; },
async selectSessionModel(sessionId, selection) {
selected.push({ sessionId, selection });
return { selected: selection };
},
};
const state = {
async clearSessions() {},
sessionFor() { return null; },
async setSession() { return true; },
};
const scoped = createBotWorkspaceScope(harness, {
botId: 'bot-a',
workspaces: store,
state,
});
const sessionId = await scoped.harness.createSession();
assert.equal(sessionId, 'session-1');
assert.deepEqual(selected, [{
sessionId: 'session-1',
selection: { provider: 'deepseek', model: 'chat' },
}]);
await store.setDefaultModel('bot-a', null);
assert.equal(store.defaultModelFor('bot-a'), null);
} finally {
await rm(root, { recursive: true, force: true });
}
});

View file

@ -198,7 +198,7 @@ test('never submits the next FIFO approval before its operation is presented', a
assert.equal(sent.some(({ toolName, text }) => ( assert.equal(sent.some(({ toolName, text }) => (
toolName === 'second-tool' && text.includes('second-tool --run') toolName === 'second-tool' && text.includes('second-tool --run')
)), true); )), true);
assert.equal(earlyTexts[0].includes('请精准回复'), true); assert.equal(earlyTexts[0].includes('精准回复'), true);
await queue.claimReply({ await queue.claimReply({
key: 'direct:actor-a', key: 'direct:actor-a',
@ -237,7 +237,7 @@ test('a failed presentation cannot be followed by a blind approval', async () =>
}).process(); }).process();
assert.equal(responses.length, 0); assert.equal(responses.length, 0);
assert.equal(sent.some((text) => text.includes('bash --run')), true); assert.equal(sent.some((text) => text.includes('bash --run')), true);
assert.equal(sent.some((text) => text.includes('请精准回复')), true); assert.equal(sent.some((text) => text.includes('精准回复')), true);
await queue.claimReply({ await queue.claimReply({
key: 'direct:actor-a', key: 'direct:actor-a',
@ -321,7 +321,7 @@ test('resolved waits for an in-flight presentation before showing the next appro
assert.match(sent[0], /first-tool --run/); assert.match(sent[0], /first-tool --run/);
assert.equal(sent[1], '已拒绝此次操作。'); assert.equal(sent[1], '已拒绝此次操作。');
assert.match(sent[2], /second-tool --run/); assert.match(sent[2], /second-tool --run/);
assert.match(sent[3], /请精准回复/); assert.match(sent[3], /精准回复/);
assert.equal(secondResponses.length, 0); assert.equal(secondResponses.length, 0);
await queue.claimReply({ await queue.claimReply({
@ -539,7 +539,7 @@ test('approval decisions stay bound to the initiating actor, route, and group me
assert.equal(responses.length, 1); assert.equal(responses.length, 1);
assert.equal(responses[0].value.outcome, 'allowed-once'); assert.equal(responses[0].value.outcome, 'allowed-once');
assert.equal(sent.filter((text) => text.includes('只有发起当前任务')).length, 2); assert.equal(sent.filter((text) => text.includes('只有发起当前任务')).length, 2);
assert.equal(sent.some((text) => text.includes('请精准回复')), true); assert.equal(sent.some((text) => text.includes('精准回复')), true);
}); });
test('a deferred approval reply stays silent when the approval resolves with its question unfinished', async () => { test('a deferred approval reply stays silent when the approval resolves with its question unfinished', async () => {

View file

@ -71,6 +71,8 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
assert.match(markup, /检查连接/); assert.match(markup, /检查连接/);
assert.match(markup, /移除接入/); assert.match(markup, /移除接入/);
assert.match(markup, /class="dim-presetSelect"/); assert.match(markup, /class="dim-presetSelect"/);
assert.match(markup, /默认模型/);
assert.match(markup, /Agent Preset/);
assert.match(markup, /aria-label="更多机器人设置"/); assert.match(markup, /aria-label="更多机器人设置"/);
assert.doesNotMatch(markup, /仅自己模式(默认)|指定联系人模式|开放响应模式/); assert.doesNotMatch(markup, /仅自己模式(默认)|指定联系人模式|开放响应模式/);
assert.match(markup, /role="status"[^>]*>测试消息已发送/); assert.match(markup, /role="status"[^>]*>测试消息已发送/);

View file

@ -455,3 +455,184 @@ test('modern adapter delegates interactions when a Session exposes no readable e
}, () => Promise.resolve(questionAnswer)); }, () => Promise.resolve(questionAnswer));
assert.deepEqual(question, questionAnswer); assert.deepEqual(question, questionAnswer);
}); });
forEachSessionApi('an approval before user/message', async (sessionApi) => {
const { events, session } = sessionFixture(sessionApi);
const eventRecord = (event) => ({ type: 'event', event });
let fixture;
let turnTask;
const append = (event) => {
events.push(event);
fixture.emit('session/event', session, event);
};
const gateway = {
async invoke(request) {
const endpoint = `${request.namespace}/${request.method}`;
if (endpoint === 'session/page') {
return { records: events.map(eventRecord), hasMore: false };
}
if (endpoint === 'session/prompt') {
turnTask = (async () => {
// Race that previously delegated to DSH Web: approval arrives while the
// IM ask is live (reconnect set) but before user/message flips `active`.
append({ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } });
append({
type: 'approval/asked', seq: 1, time: 1,
data: { id: 'approval-early', toolName: 'bash', callId: 'call-early' },
});
const outcome = await fixture.waterfall('approval/request', {
agent: { id: 'session', session },
toolName: 'bash',
callId: 'call-early',
}, () => Promise.resolve('browser-owned'));
append({
type: 'user/message', seq: 2, time: 2,
data: {
turn: 1,
source: { kind: 'user', rpcId: request.args.request.requestId },
message: { content: [] },
},
});
append({
type: 'approval/decided', seq: 3, time: 3,
data: { id: 'approval-early', outcome },
});
append({
type: 'assistant/message', seq: 4, time: 4,
data: { turn: 1, message: { content: [{ type: 'text', text: 'early-ok' }] } },
});
append({
type: 'turn/end', seq: 5, time: 5,
data: { turn: 1, reason: { kind: 'completed' } },
});
})();
return { accepted: true };
}
throw new Error(`unexpected invoke ${endpoint}`);
},
async stream(request) {
if (`${request.namespace}/${request.method}` !== 'session/follow') {
throw new Error('unexpected stream');
}
return asyncValues({
type: 'snapshot', cursor: -1, records: [], hasMore: false,
projections: { asOfSeq: -1, values: {} },
});
},
};
fixture = fakeContext(gateway);
const client = new HarnessClient({
...harnessConnection(fixture.ctx),
workspace: '/workspace',
rpcIdPrefix: 'early-approval',
logPrefix: 'early-approval',
});
const interactions = [];
const answer = await client.ask('session', 'approve early', {
timeoutMs: 5_000,
onInteraction: async (interaction) => {
interactions.push(interaction);
await interaction.respond({
ok: true,
value: {
sessionId: interaction.sessionId,
approvalId: interaction.payload.approvalId,
outcome: 'allowed-once',
},
});
},
});
await turnTask;
assert.equal(answer, 'early-ok');
assert.equal(interactions.length, 1);
assert.equal(interactions[0].kind, 'approval');
assert.equal(events.find((event) => event.type === 'approval/decided')?.data.outcome, 'allowed-once');
});
test('modern adapter still claims approval when callId shape drifts but toolName matches', async () => {
const { events, session } = sessionFixture('snapshotEvents');
const eventRecord = (event) => ({ type: 'event', event });
let fixture;
let turnTask;
const append = (event) => {
events.push(event);
fixture.emit('session/event', session, event);
};
const gateway = {
async invoke(request) {
const endpoint = `${request.namespace}/${request.method}`;
if (endpoint === 'session/page') {
return { records: events.map(eventRecord), hasMore: false };
}
if (endpoint === 'session/prompt') {
const rpcId = request.args.request.requestId;
turnTask = (async () => {
append({ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } });
append({
type: 'user/message', seq: 1, time: 1,
data: { turn: 1, source: { kind: 'user', rpcId }, message: { content: [] } },
});
append({
type: 'approval/asked', seq: 2, time: 2,
// Event omits callId while the request carries one — previously next()'d to Web.
data: { id: 'approval-drift', toolName: 'bash' },
});
const outcome = await fixture.waterfall('approval/request', {
agent: { id: 'session', session },
toolName: 'bash',
callId: 'call-drift',
}, () => Promise.resolve('browser-owned'));
append({
type: 'approval/decided', seq: 3, time: 3,
data: { id: 'approval-drift', outcome },
});
append({
type: 'assistant/message', seq: 4, time: 4,
data: { turn: 1, message: { content: [{ type: 'text', text: 'drift-ok' }] } },
});
append({
type: 'turn/end', seq: 5, time: 5,
data: { turn: 1, reason: { kind: 'completed' } },
});
})();
return { accepted: true };
}
throw new Error(`unexpected invoke ${endpoint}`);
},
async stream(request) {
if (`${request.namespace}/${request.method}` !== 'session/follow') {
throw new Error('unexpected stream');
}
return asyncValues({
type: 'snapshot', cursor: -1, records: [], hasMore: false,
projections: { asOfSeq: -1, values: {} },
});
},
};
fixture = fakeContext(gateway);
const client = new HarnessClient({
...harnessConnection(fixture.ctx),
workspace: '/workspace',
rpcIdPrefix: 'drift-approval',
logPrefix: 'drift-approval',
});
const interactions = [];
const answer = await client.ask('session', 'approve drift', {
timeoutMs: 5_000,
onInteraction: async (interaction) => {
interactions.push(interaction);
await interaction.respond({
ok: true,
value: {
sessionId: interaction.sessionId,
approvalId: interaction.payload.approvalId,
outcome: 'allowed-once',
},
});
},
});
await turnTask;
assert.equal(answer, 'drift-ok');
assert.equal(interactions.length, 1);
assert.equal(events.find((event) => event.type === 'approval/decided')?.data.outcome, 'allowed-once');
});