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