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
});
module.exports = __toCommonJS(index_exports);
var React25 = __toESM(require("react"), 1);
var React26 = __toESM(require("react"), 1);
// package.json
var package_default = {
@ -11122,6 +11122,177 @@ function installWeixinStyles() {
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
var ACCESS_GRANT_VERSION = 1;
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",
setWorkspace: "bot.workspace.set",
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setDefaultModel: SET_DEFAULT_MODEL_ENDPOINT,
setContextEnhancement: "bot.context-enhancement.set",
resolveChannelPeer: "bot.session.channel-peer"
});
@ -11426,6 +11598,7 @@ function normalizeBot6(value) {
state: connected ? "connected" : state,
workspace: text5(value.workspace, "", 4096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
defaultModel: normalizeDefaultModelSelection(value.defaultModel),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...Object.hasOwn(value, "accessPolicy") ? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) } : {},
...Object.hasOwn(value, "accessGrant") ? { accessGrant: normalizeAccessGrant(value.accessGrant) } : {},
@ -11456,7 +11629,8 @@ function normalizeSnapshot8(value) {
bots,
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
provisioning: source.provisioning ? normalizeProvisioning6(source.provisioning) : null,
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog)
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog),
modelCatalog: normalizeHostModelCatalog(source.modelCatalog)
};
}
function presentError9(error) {
@ -11471,7 +11645,7 @@ function formatRemaining6(milliseconds) {
}
// 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
var WHATSAPP_STYLE_ID = "xmanrui-dsh-im-whatsapp-settings";
@ -11496,7 +11670,7 @@ function installWhatsappStyles() {
// plugin-src/client/channels/whatsapp/index.js
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", {
...props,
ref,
@ -11719,6 +11893,7 @@ function WhatsappAccountCard({
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onDefaultModelSave,
onContextEnhancementSave,
onRequestRemove,
onConfirmRemove,
@ -11783,6 +11958,11 @@ function WhatsappAccountCard({
disabled: Boolean(busy),
onSave: onAgentPresetSave
}),
h2(DefaultModelEditor, {
defaultModel: account.defaultModel,
disabled: Boolean(busy),
onSave: onDefaultModelSave
}),
h2(ContextEnhancementEditor, {
config: account.contextEnhancement,
disabled: Boolean(busy),
@ -11830,23 +12010,24 @@ function WhatsappAccountCard({
);
}
function WhatsappSettingsTab({ rpcCall }) {
const [model, setModel] = React19.useState({
const [model, setModel] = React20.useState({
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] = React19.useState(null);
const [busy, setBusy] = React19.useState(false);
const [busyByBot, setBusyByBot] = React19.useState({});
const [testNoticeByBot, setTestNoticeByBot] = React19.useState({});
const [removeTarget, setRemoveTarget] = React19.useState(null);
const [now, setNow] = React19.useState(Date.now());
const mounted = React19.useRef(true);
const [provision, setProvision] = React20.useState(null);
const [busy, setBusy] = React20.useState(false);
const [busyByBot, setBusyByBot] = React20.useState({});
const [testNoticeByBot, setTestNoticeByBot] = React20.useState({});
const [removeTarget, setRemoveTarget] = React20.useState(null);
const [now, setNow] = React20.useState(Date.now());
const mounted = React20.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const addButtonRef = React19.useRef(null);
React19.useEffect(() => {
const addButtonRef = React20.useRef(null);
React20.useEffect(() => {
const disposeDingtalk = installDingtalkStyles();
const disposeWhatsapp = installWhatsappStyles();
mounted.current = true;
@ -11856,11 +12037,11 @@ function WhatsappSettingsTab({ rpcCall }) {
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");
return unwrapRpcResult9(await rpcCall(endpoint, payload, signal));
}, [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();
if (workspaceVersion === null) return void 0;
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null }));
@ -11872,7 +12053,8 @@ function WhatsappSettingsTab({ rpcCall }) {
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({
...snapshot.provisioning,
@ -11890,12 +12072,12 @@ function WhatsappSettingsTab({ rpcCall }) {
return void 0;
}
}, [invoke, workspaceFence]);
React19.useEffect(() => {
React20.useEffect(() => {
const controller = new AbortController();
void loadStatus({ signal: controller.signal, restore: true });
return () => controller.abort();
}, [loadStatus]);
React19.useEffect(() => {
React20.useEffect(() => {
if (model.phase !== "ready") return void 0;
const controller = new AbortController();
const timer = window.setInterval(
@ -11907,12 +12089,12 @@ function WhatsappSettingsTab({ rpcCall }) {
window.clearInterval(timer);
};
}, [loadStatus, model.phase]);
React19.useEffect(() => {
React20.useEffect(() => {
if (!provision || !ACTIVE_STATES3.has(provision.status)) return void 0;
const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3);
return () => window.clearInterval(timer);
}, [provision?.attemptId, provision?.status]);
const startProvisioning = React19.useCallback(async (replace = false) => {
const startProvisioning = React20.useCallback(async (replace = false) => {
setBusy(true);
try {
if (replace && provision?.attemptId) {
@ -11930,7 +12112,7 @@ function WhatsappSettingsTab({ rpcCall }) {
if (mounted.current) setBusy(false);
}
}, [invoke, provision?.attemptId]);
const closeProvision = React19.useCallback(async () => {
const closeProvision = React20.useCallback(async () => {
setBusy(true);
try {
if (provision?.attemptId && ACTIVE_STATES3.has(provision.status)) {
@ -11941,7 +12123,7 @@ function WhatsappSettingsTab({ rpcCall }) {
if (mounted.current) setBusy(false);
}
}, [invoke, provision?.attemptId, provision?.status]);
React19.useEffect(() => {
React20.useEffect(() => {
const attemptId = provision?.attemptId;
if (!attemptId || !ACTIVE_STATES3.has(provision.status)) return void 0;
const controller = new AbortController();
@ -11982,7 +12164,7 @@ function WhatsappSettingsTab({ rpcCall }) {
if (timer) window.clearTimeout(timer);
};
}, [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();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
if (operation === "reconnect") {
@ -12001,7 +12183,8 @@ function WhatsappSettingsTab({ rpcCall }) {
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") {
setTestNoticeByBot((current) => ({
@ -12059,6 +12242,12 @@ function WhatsappSettingsTab({ rpcCall }) {
WHATSAPP_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset }
),
onDefaultModelSave: (defaultModel) => botAction(
account,
"model",
WHATSAPP_ENDPOINTS.setDefaultModel,
{ botId: account.botId, defaultModel }
),
onContextEnhancementSave: (config) => botAction(
account,
"context-enhancement",
@ -12078,6 +12267,8 @@ function WhatsappSettingsTab({ rpcCall }) {
) : null;
return h2(AgentPresetCatalogContext.Provider, {
value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG
}, h2(ModelCatalogContext.Provider, {
value: model.modelCatalog ?? EMPTY_MODEL_CATALOG
}, h2(
"section",
{
@ -12101,7 +12292,7 @@ function WhatsappSettingsTab({ rpcCall }) {
h2(Button14, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6")
)
) : h2(
React19.Fragment,
React20.Fragment,
null,
provision?.status === "pending" ? h2(QrPanel5, {
provision,
@ -12117,11 +12308,11 @@ function WhatsappSettingsTab({ rpcCall }) {
}) : model.bots.length === 0 ? h2(EmptyView6, { busy, onStart: () => void startProvisioning(false) }) : null,
botList
)
));
)));
}
// plugin-src/client/channel-peer-label.js
var React20 = __toESM(require("react"), 1);
var React21 = __toESM(require("react"), 1);
var CHANNEL_LABELS = Object.freeze({
whatsapp: "WhatsApp",
feishu: "\u98DE\u4E66",
@ -12135,8 +12326,8 @@ var CHANNEL_LABELS = Object.freeze({
office: "AI Office"
});
function ChannelPeerLabel({ sessionId, resolveChannelPeer }) {
const [peer, setPeer] = React20.useState(null);
React20.useEffect(() => {
const [peer, setPeer] = React21.useState(null);
React21.useEffect(() => {
if (typeof resolveChannelPeer !== "function" || typeof sessionId !== "string" || !sessionId) {
setPeer(null);
return void 0;
@ -12193,10 +12384,10 @@ async function resolveWhatsappChannelPeer(whatsappRpcCall, sessionId, signal) {
}
// 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
var React21 = __toESM(require("react"), 1);
var React22 = __toESM(require("react"), 1);
var ACCESS_POLICY_ENDPOINT = "bot.access-policy.set";
var GROUP_SESSION_SCOPE_ENDPOINT = "bot.group-session-scope.set";
var ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
@ -12299,8 +12490,8 @@ function ScenePolicyEditor({
unsupported = false,
onChange
}) {
const ownerHelpId = React21.useId();
const emptyAllowlistHelpId = React21.useId();
const ownerHelpId = React22.useId();
const emptyAllowlistHelpId = React22.useId();
const allowlist = policy.mode === "allowlist";
const collectionKey = allowlist ? "users" : "commandPermissionOverrides";
const branchKey = allowlist ? "allowlist" : "open";
@ -12352,7 +12543,7 @@ function ScenePolicyEditor({
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(
React21.Fragment,
React22.Fragment,
null,
h2(
"div",
@ -12494,18 +12685,18 @@ function AccessPolicySettingsPage({ channel: channel4, account, rpcCall, onSaved
const initialPolicy = normalizeAccessPolicy(account?.accessPolicy);
const initialKey = JSON.stringify(initialPolicy);
const initialScope = normalizeGroupSessionScope(account?.groupSessionScope);
const [draft, setDraft] = React21.useState(() => clonePolicy(
const [draft, setDraft] = React22.useState(() => clonePolicy(
initialPolicy ?? DEFAULT_ACCESS_POLICY
));
const [groupSessionScope, setGroupSessionScope] = React21.useState(initialScope);
const [saving, setSaving] = React21.useState(false);
const [feedback, setFeedback] = React21.useState(null);
React21.useEffect(() => {
const [groupSessionScope, setGroupSessionScope] = React22.useState(initialScope);
const [saving, setSaving] = React22.useState(false);
const [feedback, setFeedback] = React22.useState(null);
React22.useEffect(() => {
const next = normalizeAccessPolicy(account?.accessPolicy);
setDraft(clonePolicy(next ?? DEFAULT_ACCESS_POLICY));
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
}, [account?.botId, initialKey, account?.groupSessionScope]);
React21.useEffect(() => {
React22.useEffect(() => {
setFeedback(null);
}, [account?.botId]);
if (!definition) {
@ -12658,7 +12849,7 @@ function AccessPolicySettingsPage({ channel: channel4, account, rpcCall, onSaved
}
// 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_PENDING_RESOLVE_ENDPOINT = "bot.access-pending.resolve";
var ACCESS_GROUP_TITLES_REFRESH_ENDPOINT = "bot.access-group-titles.refresh";
@ -12750,13 +12941,13 @@ function PhoneTypeahead({
disabled,
requirePhone = true
}) {
const [query, setQuery] = React22.useState(value || "");
const [open, setOpen] = React22.useState(false);
const blurTimer = React22.useRef(null);
React22.useEffect(() => {
const [query, setQuery] = React23.useState(value || "");
const [open, setOpen] = React23.useState(false);
const blurTimer = React23.useRef(null);
React23.useEffect(() => {
setQuery(value || "");
}, [value]);
React22.useEffect(() => () => {
React23.useEffect(() => () => {
if (blurTimer.current) window.clearTimeout(blurTimer.current);
}, []);
const q = query.trim().toLowerCase();
@ -13064,21 +13255,21 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
const initialGrant = normalizeAccessGrant(account?.accessGrant);
const initialKey = JSON.stringify(initialGrant);
const initialScope = normalizeGroupSessionScope(account?.groupSessionScope);
const [draft, setDraft] = React22.useState(() => cloneGrant(initialGrant, ownerPhone));
const [groupSessionScope, setGroupSessionScope] = React22.useState(initialScope);
const [saving, setSaving] = React22.useState(false);
const [feedback, setFeedback] = React22.useState(null);
const [newGroupJid, setNewGroupJid] = React22.useState("");
const [loadingGrant, setLoadingGrant] = React22.useState(!initialGrant);
const contextCatalog = React22.useContext(AgentPresetCatalogContext);
const [presetCatalog, setPresetCatalog] = React22.useState(() => normalizeAgentPresetCatalog(
const [draft, setDraft] = React23.useState(() => cloneGrant(initialGrant, ownerPhone));
const [groupSessionScope, setGroupSessionScope] = React23.useState(initialScope);
const [saving, setSaving] = React23.useState(false);
const [feedback, setFeedback] = React23.useState(null);
const [newGroupJid, setNewGroupJid] = React23.useState("");
const [loadingGrant, setLoadingGrant] = React23.useState(!initialGrant);
const contextCatalog = React23.useContext(AgentPresetCatalogContext);
const [presetCatalog, setPresetCatalog] = React23.useState(() => normalizeAgentPresetCatalog(
account?.agentPresetCatalog ?? contextCatalog ?? EMPTY_AGENT_PRESET_CATALOG
));
React22.useEffect(() => {
React23.useEffect(() => {
setDraft(cloneGrant(normalizeAccessGrant(account?.accessGrant), ownerPhone));
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
}, [account?.botId, initialKey, account?.groupSessionScope, ownerPhone]);
React22.useEffect(() => {
React23.useEffect(() => {
if (typeof rpcCall !== "function" || !account?.botId) return void 0;
let cancelled = false;
setLoadingGrant(true);
@ -13132,7 +13323,7 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
};
}, [account?.botId, rpcCall, ownerPhone]);
const contacts = draft.contacts ?? [];
const knownGroupJids = React22.useMemo(() => {
const knownGroupJids = React23.useMemo(() => {
const set = new Set(Object.keys(draft.groups ?? {}));
for (const contact of contacts) {
for (const jid of contact.groupJids ?? []) set.add(jid);
@ -13142,7 +13333,7 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
}
return [...set].sort();
}, [draft.groups, draft.pending, contacts]);
const actionableContacts = React22.useMemo(
const actionableContacts = React23.useMemo(
() => contacts.filter((contact) => contactNeedsGrantAction(contact, draft, knownGroupJids)),
[contacts, draft, knownGroupJids]
);
@ -13821,13 +14012,13 @@ function TargetForm({
}) {
const editing = mode === "edit";
const initialKind = initialValue?.kind && definition.fields[initialValue.kind] ? initialValue.kind : definition.kinds[0].value;
const [targetId, setTargetId] = React23.useState(initialValue?.targetId ?? "");
const [name2, setName] = React23.useState(initialValue?.name ?? "");
const [kind, setKind] = React23.useState(initialKind);
const [route, setRoute] = React23.useState(initialValue?.route ?? {});
const [error, setError] = React23.useState(null);
const [testing, setTesting] = React23.useState(false);
const [testState, setTestState] = React23.useState(null);
const [targetId, setTargetId] = React24.useState(initialValue?.targetId ?? "");
const [name2, setName] = React24.useState(initialValue?.name ?? "");
const [kind, setKind] = React24.useState(initialKind);
const [route, setRoute] = React24.useState(initialValue?.route ?? {});
const [error, setError] = React24.useState(null);
const [testing, setTesting] = React24.useState(false);
const [testState, setTestState] = React24.useState(null);
const currentTarget = () => {
const normalizedRoute = Object.fromEntries(fieldsFor(definition, kind).map((field) => {
const raw = String(route[field.key] ?? "").trim();
@ -14042,7 +14233,7 @@ function TargetSuggestionPicker({
}
},
// Platform display names must stay raw (bypass h() i18n phrase rewrite).
React23.createElement(
React24.createElement(
"strong",
null,
suggestionDisplayName(suggestion) || suggestionFallbackName(definition, suggestion)
@ -14051,7 +14242,7 @@ function TargetSuggestionPicker({
localizeText(kindLabel(definition, suggestion.kind)),
added ? localizeText("\u5DF2\u6DFB\u52A0") : null
].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 }) {
const [action, setAction] = React23.useState(null);
const [testState, setTestState] = React23.useState(null);
const [copyState, setCopyState] = React23.useState(null);
const [confirmDelete, setConfirmDelete] = React23.useState(false);
const [action, setAction] = React24.useState(null);
const [testState, setTestState] = React24.useState(null);
const [copyState, setCopyState] = React24.useState(null);
const [confirmDelete, setConfirmDelete] = React24.useState(false);
const testTarget = async () => {
setAction("test");
setTestState(null);
@ -14110,7 +14301,7 @@ function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, o
h2(
"div",
{ 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("code", null, `targetId: ${target.targetId}`)
@ -14173,28 +14364,28 @@ function DeliveryTargetSettingsPage({
onBack
}) {
const definition = CHANNEL_DEFINITIONS[channel4];
const [activeTabId, setActiveTabId] = React23.useState(BOT_SETTINGS_TABS[0].id);
const [phase, setPhase] = React23.useState("loading");
const [targets, setTargets] = React23.useState([]);
const [suggestionPhase, setSuggestionPhase] = React23.useState("idle");
const [suggestions, setSuggestions] = React23.useState([]);
const [suggestionError, setSuggestionError] = React23.useState(null);
const [error, setError] = React23.useState(null);
const [editor, setEditor] = React23.useState(null);
const [saving, setSaving] = React23.useState(false);
const [botCopyState, setBotCopyState] = React23.useState(null);
const [accessPolicy, setAccessPolicy] = React23.useState(account.accessPolicy);
const [accessGrant, setAccessGrant] = React23.useState(account.accessGrant);
const mounted = React23.useRef(true);
React23.useEffect(() => {
const [activeTabId, setActiveTabId] = React24.useState(BOT_SETTINGS_TABS[0].id);
const [phase, setPhase] = React24.useState("loading");
const [targets, setTargets] = React24.useState([]);
const [suggestionPhase, setSuggestionPhase] = React24.useState("idle");
const [suggestions, setSuggestions] = React24.useState([]);
const [suggestionError, setSuggestionError] = React24.useState(null);
const [error, setError] = React24.useState(null);
const [editor, setEditor] = React24.useState(null);
const [saving, setSaving] = React24.useState(false);
const [botCopyState, setBotCopyState] = React24.useState(null);
const [accessPolicy, setAccessPolicy] = React24.useState(account.accessPolicy);
const [accessGrant, setAccessGrant] = React24.useState(account.accessGrant);
const mounted = React24.useRef(true);
React24.useEffect(() => {
setAccessPolicy(account.accessPolicy);
setAccessGrant(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");
return unwrapRpcResult12(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
const loadTargets = React23.useCallback(async ({ signal, silent = false } = {}) => {
const loadTargets = React24.useCallback(async ({ signal, silent = false } = {}) => {
if (!silent) setPhase("loading");
setError(null);
try {
@ -14208,7 +14399,7 @@ function DeliveryTargetSettingsPage({
setPhase("error");
}
}, [account.botId, invoke]);
const loadSuggestions = React23.useCallback(async () => {
const loadSuggestions = React24.useCallback(async () => {
setSuggestionPhase("loading");
setSuggestionError(null);
try {
@ -14222,7 +14413,7 @@ function DeliveryTargetSettingsPage({
setSuggestionPhase("error");
}
}, [account.botId, definition, invoke]);
React23.useEffect(() => {
React24.useEffect(() => {
mounted.current = true;
const controller = new AbortController();
void loadTargets({ signal: controller.signal });
@ -14349,7 +14540,7 @@ function DeliveryTargetSettingsPage({
rpcCall: accessRpcCall,
onSaved: setAccessPolicy
}) : h2(
React23.Fragment,
React24.Fragment,
null,
h2(
"section",
@ -15170,7 +15361,7 @@ function installImStyles() {
}
// 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_valid = __toESM(require_valid(), 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}`;
}
function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
const [copyState, setCopyState] = React24.useState("idle");
const commandRef = React24.useRef(null);
const mounted = React24.useRef(false);
const copying = React24.useRef(false);
React24.useEffect(() => {
const [copyState, setCopyState] = React25.useState("idle");
const commandRef = React25.useRef(null);
const mounted = React25.useRef(false);
const copying = React25.useRef(false);
React25.useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
@ -15296,7 +15487,7 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
{ className: "dim-updateManual", "aria-label": "\u624B\u5DE5\u66F4\u65B0" },
h2("h4", { className: "dim-updateManualHeading" }, "\u624B\u5DE5\u66F4\u65B0"),
command ? h2(
React24.Fragment,
React25.Fragment,
null,
h2("p", { className: "dim-updateManualHint" }, "\u81EA\u52A8\u66F4\u65B0\u5931\u8D25\u53EF\u4EE5\u4F7F\u7528\u547D\u4EE4\u66F4\u65B0\uFF1A"),
h2(
@ -15332,7 +15523,7 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
"aria-hidden": "true",
focusable: "false"
}, copyState === "copied" ? h2("path", { d: "m4 10 4 4 8-8" }) : h2(
React24.Fragment,
React25.Fragment,
null,
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" })
@ -15357,10 +15548,10 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
);
}
function UpdateDialog({ children, onClose }) {
const dialogRef = React24.useRef(null);
const titleId = React24.useId();
const descriptionId = React24.useId();
React24.useEffect(() => {
const dialogRef = React25.useRef(null);
const titleId = React25.useId();
const descriptionId = React25.useId();
React25.useEffect(() => {
const previous = globalThis.document?.activeElement;
dialogRef.current?.focus?.();
return () => {
@ -15418,19 +15609,19 @@ function UpdateDialog({ children, onClose }) {
return typeof document !== "undefined" && document.body ? (0, import_react_dom3.createPortal)(content, document.body) : content;
}
function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const [snapshot, setSnapshot] = React24.useState(null);
const [action, setAction] = React24.useState("status");
const [error, setError] = React24.useState(null);
const [open, setOpen] = React24.useState(false);
const [uncertainInstall, setUncertainInstall] = React24.useState(false);
const mounted = React24.useRef(false);
const busy = React24.useRef(false);
const readController = React24.useRef(null);
const pollReadController = React24.useRef(null);
const installRequest = React24.useRef(null);
const onStatusRef = React24.useRef(onStatus);
const [snapshot, setSnapshot] = React25.useState(null);
const [action, setAction] = React25.useState("status");
const [error, setError] = React25.useState(null);
const [open, setOpen] = React25.useState(false);
const [uncertainInstall, setUncertainInstall] = React25.useState(false);
const mounted = React25.useRef(false);
const busy = React25.useRef(false);
const readController = React25.useRef(null);
const pollReadController = React25.useRef(null);
const installRequest = React25.useRef(null);
const onStatusRef = React25.useRef(onStatus);
onStatusRef.current = onStatus;
const accept = React24.useCallback((next) => {
const accept = React25.useCallback((next) => {
setSnapshot(next);
onStatusRef.current?.(next);
}, []);
@ -15439,7 +15630,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
setUncertainInstall(false);
accept(next);
};
const invoke = React24.useCallback(async (endpoint, payload = {}, signal) => {
const invoke = React25.useCallback(async (endpoint, payload = {}, signal) => {
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");
unavailable.code = "update-unavailable";
@ -15447,7 +15638,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
}
return unwrapSnapshot(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
React24.useEffect(() => {
React25.useEffect(() => {
mounted.current = true;
busy.current = true;
const controller = new AbortController();
@ -15470,7 +15661,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const activeJob = ACTIVE_STATES4.has(snapshot?.job?.state);
const restartRequired = snapshot?.job?.state === "restart-required" || snapshot?.blockedReason === "pending-restart";
const shouldPoll = activeJob || uncertainInstall;
React24.useEffect(() => {
React25.useEffect(() => {
if (!shouldPoll) return void 0;
let controller;
const scheduler = createPollScheduler({
@ -15568,7 +15759,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
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";
return h2(
React24.Fragment,
React25.Fragment,
null,
h2("button", {
type: "button",
@ -15593,13 +15784,13 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
h2("dt", null, "\u8FD0\u884C\u7248\u672C"),
h2("dd", null, `v${snapshot?.runningVersion ?? clientVersion}`),
snapshot?.installedVersion && snapshot.installedVersion !== snapshot.runningVersion ? h2(
React24.Fragment,
React25.Fragment,
null,
h2("dt", null, "\u5DF2\u5B89\u88C5\u7248\u672C"),
h2("dd", null, `v${snapshot.installedVersion}`)
) : null,
targetVersion ? h2(
React24.Fragment,
React25.Fragment,
null,
h2("dt", null, "\u76EE\u6807\u7248\u672C"),
h2("dd", null, `v${targetVersion}`)
@ -15812,20 +16003,20 @@ function IMSettingsTab({
browserLocation = globalThis.location,
navigateToRecoveryUrl = replacePageLocation
}) {
const [selected, setSelected] = React25.useState("weixin");
const [loopbackRecovery, setLoopbackRecovery] = React25.useState(null);
const [runningVersion, setRunningVersion] = React25.useState(IM_PLUGIN_VERSION);
const [deliverySettings, setDeliverySettings] = React25.useState(null);
const githubTooltipId = React25.useId();
const channelSelectId = React25.useId();
const [selected, setSelected] = React26.useState("weixin");
const [loopbackRecovery, setLoopbackRecovery] = React26.useState(null);
const [runningVersion, setRunningVersion] = React26.useState(IM_PLUGIN_VERSION);
const [deliverySettings, setDeliverySettings] = React26.useState(null);
const githubTooltipId = React26.useId();
const channelSelectId = React26.useId();
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);
}, []);
const reportUpdateStatus = React25.useCallback((snapshot) => {
const reportUpdateStatus = React26.useCallback((snapshot) => {
setRunningVersion(snapshot.runningVersion);
}, []);
const rpcCalls = React25.useMemo(() => createLoopbackAwareRpcCalls({
const rpcCalls = React26.useMemo(() => createLoopbackAwareRpcCalls({
dingtalkRpcCall,
discordRpcCall,
feishuRpcCall,
@ -15857,7 +16048,7 @@ function IMSettingsTab({
weixinRpcCall,
whatsappRpcCall
]);
const botSettingsContext = React25.useMemo(() => Object.freeze({
const botSettingsContext = React26.useMemo(() => Object.freeze({
openBotSettings: setDeliverySettings
}), []);
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 {
normalizeDefaultModelSelection,
normalizeHostModelCatalog,
SET_DEFAULT_MODEL_ENDPOINT,
} from '../../default-model.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeAccessGrant } from '../../../../src/channels/shared/access-grant.mjs';
import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
@ -21,6 +26,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
setGroupSessionScope: 'bot.group-session-scope.set',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setDefaultModel: SET_DEFAULT_MODEL_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
resolveChannelPeer: 'bot.session.channel-peer',
});
@ -98,6 +104,7 @@ function normalizeBot(value) {
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
defaultModel: normalizeDefaultModelSelection(value.defaultModel),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
...(Object.hasOwn(value, 'accessPolicy')
? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
@ -137,6 +144,7 @@ export function normalizeSnapshot(value) {
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog),
modelCatalog: normalizeHostModelCatalog(source.modelCatalog),
};
}

View file

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

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',
'agent-preset-invalid',
'agent-preset-unavailable',
'default-model-invalid',
'default-model-unavailable',
'context-enhancement-invalid',
].includes(error?.code)) return null;
return { code: error.code, message: error.message };

View file

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

View file

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

View file

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

View file

@ -13,6 +13,11 @@ import {
normalizeAgentPresetCatalog,
validateAgentPresetId,
} from './agent-preset.mjs';
import {
catalogHasModel,
normalizeModelCatalog,
validateDefaultModel,
} from './default-model.mjs';
import {
normalizeAccessPolicy,
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 = {};
if (value.groupSessionScopes !== undefined) {
if (!value.groupSessionScopes || typeof value.groupSessionScopes !== 'object'
@ -235,6 +255,7 @@ function normalizeDocument(value) {
version,
workspaces,
agentPresets,
defaultModels,
groupSessionScopes,
contextEnhancement,
deliveryTargets,
@ -247,6 +268,7 @@ function storedDocument({
version,
workspaces,
agentPresets,
defaultModels,
groupSessionScopes,
contextEnhancement,
deliveryTargets,
@ -255,6 +277,7 @@ function storedDocument({
}) {
const document = { version, workspaces };
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(contextEnhancement).length > 0) {
document.contextEnhancement = contextEnhancement;
@ -310,6 +333,7 @@ export class BotWorkspaceStore {
#version = 1;
#workspaces = {};
#agentPresets = {};
#defaultModels = {};
#groupSessionScopes = {};
#contextEnhancement = {};
#deliveryTargets = Object.create(null);
@ -338,6 +362,7 @@ export class BotWorkspaceStore {
this.#version = normalized.version;
this.#workspaces = normalized.workspaces;
this.#agentPresets = normalized.agentPresets;
this.#defaultModels = normalized.defaultModels;
this.#groupSessionScopes = normalized.groupSessionScopes;
this.#contextEnhancement = normalized.contextEnhancement;
this.#deliveryTargets = normalized.deliveryTargets;
@ -348,6 +373,7 @@ export class BotWorkspaceStore {
this.#version = 1;
this.#workspaces = {};
this.#agentPresets = {};
this.#defaultModels = {};
this.#groupSessionScopes = {};
this.#contextEnhancement = {};
this.#deliveryTargets = Object.create(null);
@ -384,6 +410,10 @@ export class BotWorkspaceStore {
return this.#agentPresets[botIdOf(botId)] ?? null;
}
defaultModelFor(botId) {
return this.#defaultModels[botIdOf(botId)] ?? null;
}
groupSessionScopeFor(botId) {
const id = botIdOf(botId);
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 } = {}) {
const id = botIdOf(botId);
if (!this.has(id)
@ -949,6 +1013,7 @@ export class BotWorkspaceStore {
const candidates = new Set([
...Object.keys(this.#workspaces),
...Object.keys(this.#agentPresets),
...Object.keys(this.#defaultModels),
...Object.keys(this.#groupSessionScopes),
...Object.keys(this.#contextEnhancement),
...Object.keys(this.#deliveryTargets),
@ -970,6 +1035,7 @@ export class BotWorkspaceStore {
...bot,
workspace: this.workspaceFor(bot.botId),
agentPreset: this.agentPresetFor(bot.botId),
defaultModel: this.defaultModelFor(bot.botId),
groupSessionScope: this.groupSessionScopeFor(bot.botId),
contextEnhancement: this.contextEnhancementFor(bot.botId),
accessPolicy: this.accessPolicyFor(bot.botId),
@ -1003,15 +1069,18 @@ export class BotWorkspaceStore {
async #retireCurrentIncarnation(id) {
const hadWorkspace = Object.hasOwn(this.#workspaces, id);
const hadPreset = Object.hasOwn(this.#agentPresets, id);
const hadDefaultModel = Object.hasOwn(this.#defaultModels, id);
const hadGroupSessionScope = Object.hasOwn(this.#groupSessionScopes, id);
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, 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);
delete this.#workspaces[id];
delete this.#agentPresets[id];
delete this.#defaultModels[id];
delete this.#groupSessionScopes[id];
delete this.#contextEnhancement[id];
delete this.#deliveryTargets[id];
@ -1056,6 +1125,7 @@ export class BotWorkspaceStore {
version,
workspaces: this.#workspaces,
agentPresets: this.#agentPresets,
defaultModels: this.#defaultModels,
groupSessionScopes: this.#groupSessionScopes,
contextEnhancement,
deliveryTargets,
@ -1068,6 +1138,7 @@ export class BotWorkspaceStore {
async #persistCurrentDocument() {
if (Object.keys(this.#workspaces).length > 0
|| Object.keys(this.#agentPresets).length > 0
|| Object.keys(this.#defaultModels).length > 0
|| Object.keys(this.#groupSessionScopes).length > 0
|| Object.keys(this.#contextEnhancement).length > 0
|| Object.keys(this.#deliveryTargets).length > 0
@ -1107,17 +1178,41 @@ function assertCurrentBotScope(isCurrentScope) {
throw error;
}
function decorateResult(workspaces, result, catalog) {
function decorateResult(workspaces, result, catalog, modelCatalog) {
const decorate = (value) => {
const decorated = workspaces.decorateStatus(value);
if (!catalog || !decorated || typeof decorated !== 'object') return decorated;
const attachCatalog = (agentPresetCatalog) => (
agentPresetCatalog ? { ...decorated, agentPresetCatalog } : decorated
);
const agentPresetCatalog = resolveAgentPresetCatalog(catalog);
return agentPresetCatalog && typeof agentPresetCatalog.then === 'function'
? agentPresetCatalog.then(attachCatalog)
: attachCatalog(agentPresetCatalog);
if (!decorated || typeof decorated !== 'object') return decorated;
const attach = (agentPresetCatalog, resolvedModelCatalog) => {
let next = decorated;
if (agentPresetCatalog) next = { ...next, agentPresetCatalog };
if (resolvedModelCatalog) next = { ...next, modelCatalog: resolvedModelCatalog };
return next;
};
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'
? result.then(decorate)
@ -1358,6 +1453,19 @@ export function createBotWorkspaceScope(
...(agentPreset == null ? {} : { agentPreset }),
});
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;
};
}
@ -1522,6 +1630,7 @@ export function createWorkspaceAwareController(controller, {
workspaces,
stateFor,
agentPresetCatalog,
modelCatalog = null,
channel = null,
} = {}) {
if (!controller || !workspaces || typeof stateFor !== 'function') {
@ -1537,7 +1646,7 @@ export function createWorkspaceAwareController(controller, {
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) => {
// Capture at API invocation, before even waiting for an older outer
// transition. A queued request still belongs to the incarnation that the
@ -1580,6 +1689,40 @@ export function createWorkspaceAwareController(controller, {
workspaces,
await controller.status(),
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) {
if (property === 'updateWorkspace') return updateWorkspace;
if (property === 'updateAgentPreset') return updateAgentPreset;
if (property === 'updateDefaultModel') return updateDefaultModel;
if (property === 'updateContextEnhancement') return updateContextEnhancement;
if (property === 'updateAccessPolicy') return updateAccessPolicy;
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'],
]);
const APPROVAL_PROMPT = '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。';
const APPROVAL_PROMPT = '请在本聊天精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。勿到 DeepSeek Harness 网页里点,否则无人看会一直卡住。';
const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。';
const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。';
const APPROVAL_TIMEOUT_TEXT = '审批等待超时,已自动拒绝此次操作。请重新发送问题,或调整工具审批策略后重试。';
const RESOLVED_ROUTE_TTL_MS = 5 * 60_000;
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) {
return typeof value === 'string' ? value.trim() : '';
@ -103,13 +113,19 @@ function approvalOutcomeText(outcome) {
export class HarnessApprovalQueue {
#label;
#logger;
#timeoutMs;
#byId = new Map();
#routes = new Map();
#resolvedRoutes = new Map();
constructor({ label = 'IM', logger = console } = {}) {
constructor({
label = 'IM',
logger = console,
timeoutMs = interactionTimeoutMs(),
} = {}) {
this.#label = label;
this.#logger = logger;
this.#timeoutMs = interactionTimeoutMs(timeoutMs);
}
hasPending(key) {
@ -225,7 +241,13 @@ export class HarnessApprovalQueue {
}
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;
}
@ -283,11 +305,13 @@ export class HarnessApprovalQueue {
closedOutcome: null,
resolutionNotified: false,
activationTask: null,
timeoutTimer: null,
};
this.#byId.set(approvalId, pending);
const route = this.#routes.get(key) ?? { items: [] };
route.items.push(pending);
this.#routes.set(key, route);
this.#armTimeout(pending);
if (route.items[0] === pending) await this.#present(pending);
return true;
}
@ -456,6 +480,7 @@ export class HarnessApprovalQueue {
#remove(pending) {
if (pending.inactive) return null;
pending.inactive = true;
this.#clearTimeout(pending);
this.#rememberResolvedRoute(pending.key);
this.#byId.delete(pending.approvalId);
const route = this.#routes.get(pending.key);
@ -470,6 +495,36 @@ export class HarnessApprovalQueue {
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) {
const now = Date.now();
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
* this Session. The modern Harness adapter uses the same ownership registry
* 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 = []) {
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;
for (const ownership of owners) consumeInteractionOwnership(ownership, entries);
return [...owners].some((ownership) => (
ownership.active
&& !ownership.completed
!ownership.completed
&& typeof ownership.reconnect === 'function'
&& (ownership.active || ownership.started || ownership.promptRpcId)
));
}
@ -1248,6 +1253,18 @@ export class HarnessClient {
.sort((left, right) => left.order - right.order);
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
// an earlier runtime before its queued prompt starts. Let the oldest such
// 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.',
'当前模型不存在或暂不可用。请发送 /models 查看并使用 /model 切换模型。':
'The current model does not exist or is unavailable. Send /models and use /model to switch models.',
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。':
'The current model does not exist or does not support the selected settings. Send /models and use /model to choose again.',
'当前会话绑定的模型已不存在或不可用。请发送 /new 开启新会话后重试;或发送 /models,再用 /model 切换模型。':
'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.',
'当前模型不支持所选配置。请切换模型或推理等级后重试。':
@ -77,8 +79,8 @@ export default {
'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.',
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。':
'The task is waiting for an action that cannot be completed in this channel. Handle it in DeepSeek Harness, then try again.',
'任务正在等待审批或问答,但当前聊天未能收到该请求(可能只出现在 DeepSeek Harness 网页)。请发送 /stop 取消后重试;若再次出现,请在本聊天回复「批准」或「拒绝」。':
'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.',
'当前会话已不存在。请发送 /new 创建新会话后重试。':
@ -182,4 +184,10 @@ export default {
'Failed to submit the answer. Please resend your answer to the current question.',
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。':
'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:
'当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。',
MODEL_UNAVAILABLE:
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。',
'当前会话绑定的模型已不存在或不可用。请发送 /new 开启新会话后重试;或发送 /models,再用 /model 切换模型。',
MODEL_CONFIG:
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。',
MODEL_TIMEOUT:
@ -121,7 +121,7 @@ const FAILURE_MESSAGES = Object.freeze({
MODEL_OUTPUT_LIMIT:
'模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。',
TURN_BLOCKED:
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。',
'任务正在等待审批或问答,但当前聊天未能收到该请求(可能只出现在 DeepSeek Harness 网页)。请发送 /stop 取消后重试;若再次出现,请在本聊天回复「批准」或「拒绝」。',
TURN_INTERRUPTED:
'任务被意外中断,本次未完成。请重试。',
SESSION_NOT_FOUND:

View file

@ -57,6 +57,7 @@ import {
} from './semantic/delivery.mjs';
import {
channelDeliveryFailure,
classifyMessageFailure,
clearLastMessageFailure,
messageFailureText,
setLastMessageFailure,
@ -732,7 +733,7 @@ export class TextHarnessBridge {
}));
contextEnhanced = content !== originalContent;
}
const { answer, artifacts = [] } = await askInWorkspaceSession({
const askArgs = {
harness: this.#harness,
state: this.#state,
key: conversationKey,
@ -770,14 +771,35 @@ export class TextHarnessBridge {
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
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) {
this.#batches.complete(conversationKey, batchSubmission.token);
}
const fileOnlyCompletion = !cleanText(answer) && artifacts.length > 0;
const visibleAnswer = fileOnlyCompletion
? t(FILE_ONLY_COMPLETION_TEXT)
: answer;
: sessionResetNotice && cleanText(answer)
? `${sessionResetNotice}\n\n${answer}`
: (sessionResetNotice || answer);
const answerFormat = fileOnlyCompletion ? 'plain' : 'markdown';
let textDeliveryError = 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 }) => (
toolName === 'second-tool' && text.includes('second-tool --run')
)), true);
assert.equal(earlyTexts[0].includes('请精准回复'), true);
assert.equal(earlyTexts[0].includes('精准回复'), true);
await queue.claimReply({
key: 'direct:actor-a',
@ -237,7 +237,7 @@ test('a failed presentation cannot be followed by a blind approval', async () =>
}).process();
assert.equal(responses.length, 0);
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({
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.equal(sent[1], '已拒绝此次操作。');
assert.match(sent[2], /second-tool --run/);
assert.match(sent[3], /请精准回复/);
assert.match(sent[3], /精准回复/);
assert.equal(secondResponses.length, 0);
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[0].value.outcome, 'allowed-once');
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 () => {

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, /class="dim-presetSelect"/);
assert.match(markup, /默认模型/);
assert.match(markup, /Agent Preset/);
assert.match(markup, /aria-label="更多机器人设置"/);
assert.doesNotMatch(markup, /仅自己模式(默认)|指定联系人模式|开放响应模式/);
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));
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');
});