Compare commits

...

10 commits

Author SHA1 Message Date
bcbe85fdb1 Make WhatsApp member presets follow channel mapping by default.
Some checks failed
CI / Test, build, and verify package (push) Has been cancelled
Empty member/group Agent Preset now resolves to the bot workspace mapping (not Host global); settings label is Follow channel mapping, and /new createSession carries that mapping explicitly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 23:11:33 +08:00
f1d85d2985 Map Host branded create failures and retry without broken presets.
Classify gateway/internal, workspace/*, and agent-preset/* into actionable IM codes, enrich field diagnostics, realpath-match workspaces, and retry session.create once without a failing Agent Preset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 21:41:05 +08:00
f1c9abfd1d Reuse Host workspaces when Windows path casing differs.
Match workspace.list entries case-insensitively on Win32 so D: vs d: no longer misses an existing workspace and falls through to SESSION_CREATE.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 21:27:39 +08:00
7cdf1d6b94 Rebuild lib so UI and Host report 4.9.1-ops.33.
The previous release bumped package.json without rebuilding the embedded manifest in lib/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 20:58:19 +08:00
208c25ebbd Classify session/workspace create failures and persist field diagnostics.
Map Host internal errors on session.create and workspace.create to actionable codes, and write last-message-failure.json so field hosts can triage without guessing from HARNESS_SERVICE.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 20:43:44 +08:00
f989b8bd63 Release 4.9.1-ops.32 with rebuilt lib for workspace session recreate.
The previous commit only updated src/; Host loads lib/index.js, so field installs never picked up the fix until the bundle was rebuilt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 20:32:29 +08:00
303ca34177 Recreate IM sessions when a bound Session is outside the bot workspace.
Avoid HARNESS_SERVICE from zombie bindings after workspace switches by verifying membership and creating with the configured workspace, preset, and default model.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 20:22:26 +08:00
74c58bdcde 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>
2026-09-15 20:02:45 +08:00
2723e94541 Classify IM turn failures instead of falling back to INTERNAL_UNKNOWN.
Map PI_AI_ERROR/UNKNOWN/TLS wording and Host RPC internals so WhatsApp shows an actionable code, and treat missing sessions as recreateable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 16:38:46 +08:00
45f430fb28 Sync bot chat language with DSH system locale.
Localize WhatsApp access-grant approvals via t() and push Host language from the UI on load and locale change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 22:38:16 +08:00
40 changed files with 2371 additions and 468 deletions

View file

@ -6,6 +6,31 @@ This file records the notable changes in each dsh-im release. Its format follows
## [Unreleased]
### Fixed / 修复
- WhatsApp 成员/群 Agent Preset 空值(UI 原「跟随全局」)现在明确回落到**频道工作区映射预设**,不再让人误以为跟 Host 全局默认;设置页文案改为「跟随频道映射」。`/new` 后建会话也会带上该映射,避免改 Host 全局预设后误用坏默认导致 `SESSION_CREATE`。
Empty WhatsApp member/group Agent Preset (UI formerly “Follow global”) now clearly falls back to the **channel workspace mapping preset**, not the Host catalog default; settings copy is “Follow channel mapping”. `/new` session creation also carries that mapping so a broken Host global default after operators change it does not surface as `SESSION_CREATE`.
- Host 带斜杠的错误码(如 `gateway/internal`、`workspace/invalid-path`、`agent-preset/not-found`)现在会正确映射为 `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE` / `PRESET_UNAVAILABLE`;`last-message-failure.json` 额外写入 workspace、Host details 与脱敏 message,便于现场对上真实 RPC。
Host slash-branded failure codes (e.g. `gateway/internal`, `workspace/invalid-path`, `agent-preset/not-found`) now map to `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE` / `PRESET_UNAVAILABLE`; `last-message-failure.json` also records workspace, Host details, and redacted messages for field triage.
- `session.create` 在 Agent Preset 失败或 Host `internal` 时会自动去掉该 preset 重试一次,避免坏的成员/机器人预设覆盖把首次建会话永久卡死;工作区路径匹配进一步用 `realpath`(存在时)对齐 Host 规范路径。
`session.create` now retries once without the Agent Preset after preset failures or Host `internal`, so a bad member/bot preset override cannot permanently block first Session creation; workspace path matching also prefers `realpath` when the directory exists, matching Host canonicalization.
- Windows 下机器人工作区路径与 Host `workspace.list` 仅盘符/路径大小写不同(如 `D:\…` vs `d:\…`)时,不再因严格字符串匹配漏掉已有工作区而去 `workspace.create` / `session.create`,避免误报 `SESSION_CREATE`。
On Windows, when the bot workspace path differs from a Host `workspace.list` entry only by drive/path casing (e.g. `D:\…` vs `d:\…`), IM now reuses that workspace instead of missing it via exact string match and falling through to `workspace.create` / `session.create` (which surfaced as `SESSION_CREATE`).
- `session.create` / `workspace.create` 的 Host `internal` 错误不再笼统显示 `HARNESS_SERVICE`:分别映射为 `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE`,并写入 `%USERPROFILE%\\.dsh\\integrations\\dsh-im-ops\\last-message-failure.json`(含 method 与脱敏诊断),便于现场定位工作区路径问题。
Host `internal` failures on `session.create` / `workspace.create` no longer collapse to generic `HARNESS_SERVICE`: they map to `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE`, and write `%USERPROFILE%\\.dsh\\integrations\\dsh-im-ops\\last-message-failure.json` (method + redacted diagnostics) for field triage of workspace path issues.
- 发消息时若聊天绑定的 Session 不在机器人当前配置工作区,或 history 探针返回非「会话不存在」错误,自动解绑并按机器人工作区 / Agent Preset / 默认模型新建 Session,避免换工作区后幽灵绑定一直报 `HARNESS_SERVICE`(含重建后的 Host/Client 产物 `lib/`)。
When a chat’s bound Session is outside the bot’s configured workspace, or the history probe fails with a non-missing Host error, IM now clears the binding and creates a new Session using the bot workspace / Agent Preset / default model, instead of sticking on a zombie binding that surfaces as `HARNESS_SERVICE` (includes rebuilt Host/Client `lib/` artifacts).
### Changed / 变更
- 机器人聊天文案(含 WhatsApp 访问审批)跟随 DeepSeek Harness 系统语言:打开 UI 或切换语言时通过 `host.language.set` 同步到 Host;无界面时仍可用 `language` / `DSH_IM_LANGUAGE` 作为启动默认值。
Bot chat copy (including WhatsApp access-grant approvals) now follows the DeepSeek Harness system language: opening the UI or switching language syncs the Host via `host.language.set`. Headless runs can still bootstrap with `language` / `DSH_IM_LANGUAGE`.
## [4.9.1] - 2026-09-04
### Fixed / 修复

View file

@ -197,7 +197,7 @@ See the [Proactive Delivery Guide](PROACTIVE_DELIVERY.en.md) ([简体中文](PRO
- Registers one top-level **IM Bot** settings page containing nine IM channels and one AI Office Connector.
- Maintains the Host, client, and runtime sources for all nine channels and the Office Connector in this repository without external standalone plugins.
- Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English. Bot chat messages follow the Host's `language` config (Chinese by default; `en` switches them to English), with Chinese always as the fallback so untranslated text is sent verbatim.
- Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English. Bot chat messages follow the same Harness system language (synced when the settings UI loads or the language changes), with Chinese always as the fallback so untranslated text is sent verbatim.
- Uses logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, WhatsApp, and AI Office navigation without enable/disable switches.
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by IM channel; the Office Connector separately owns Device credentials, Job leases, approval waits, and concurrency limits.
- Returns only QR codes, the public Slack Manifest, redacted status data, and access modes or allowlist identifiers explicitly saved for the current Telegram or WhatsApp bot. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, AI Office Device Tokens, or other raw user identifiers observed from platform messages.
@ -224,7 +224,9 @@ IM management RPCs accept loopback browsers by default. When a Web profile is de
### Bot chat message language
Bot chat messages are in Chinese by default. To switch them to English, set `language: en` in the plugin config (also accepts `en-US` or `english`), or set the `DSH_IM_LANGUAGE=en` environment variable:
Bot chat messages follow the **DeepSeek Harness system language** (Settings → Language): Chinese UI → Chinese bot copy, English UI → English bot copy. Opening the settings UI or switching language syncs the Host automatically.
Before any UI connects (or in headless runs), you can still set a bootstrap default via plugin config or env:
```yaml
- id: xmanrui-dsh-im
@ -232,7 +234,7 @@ Bot chat messages are in Chinese by default. To switch them to English, set `lan
language: en
```
Without a setting, Chinese is used. Chinese is always the fallback language — any text missing from the English dictionary is sent verbatim in Chinese, so this feature never changes the behavior of existing Chinese users.
or `DSH_IM_LANGUAGE=en`. After the UI syncs, the active Harness language wins. Chinese is always the dictionary fallback — any text missing from the English dictionary is sent verbatim in Chinese.
---

View file

@ -200,7 +200,7 @@ dsh web
- Harness 一级设置菜单中只注册一个「IM机器人」设置页,其中包含九个 IM 渠道和一个 AI Office Connector;
- 九个渠道及 Office Connector 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立插件;
- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;机器人发出的聊天消息跟随 Host 的 `language` 配置(默认中文;设为 `en` 即为英文),中文始终为兜底,未收录的文案原样输出;
- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;机器人发出的聊天消息同样跟随 Harness 系统语言(打开设置页或切换语言时同步到 Host),中文始终为兜底,未收录的文案原样输出;
- 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关;
- 九个 IM 渠道保持独立的 RPC、凭据、连接监督和会话映射;Office Connector 另行维护设备凭据、Job 租约、审批等待与并发上限;
- 浏览器只获得二维码、Manifest、脱敏状态,以及用户为当前 Telegram 或 WhatsApp 机器人主动保存的访问模式和白名单标识;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥、AI Office Device Token,或从平台消息中观察到的其他原始用户标识。
@ -227,7 +227,9 @@ IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任
### 聊天消息语言
机器人发出的聊天消息默认使用中文。要切换为英文,在插件配置中设置 `language: en`(也接受 `en-US`、`english`),或设置环境变量 `DSH_IM_LANGUAGE=en`:
机器人发出的聊天消息默认跟随 **DeepSeek Harness 的系统语言**(设置 → 语言):界面为中文时发中文,为 English 时发英文。打开设置页或切换语言时会自动同步到 Host。
在尚未打开过 UI、或纯无界面运行时,仍可用插件配置 / 环境变量作为启动默认值:
```yaml
- id: xmanrui-dsh-im
@ -235,7 +237,7 @@ IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任
language: en
```
未设置时保持中文;中文始终是兜底语言,任何未收录到英文词典的文案都会原样以中文输出,因此该功能不会改变现有中文用户的行为。
或 `DSH_IM_LANGUAGE=en`。UI 连上并同步后,以 Harness 当前语言为准。中文始终是词典兜底:未收录的英文文案会原样以中文输出。
---

View file

@ -566,12 +566,12 @@ __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 = {
name: "dsh-im-ops",
version: "4.9.1-ops.31",
version: "4.9.1-ops.36",
description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 - access/session policies owned here.",
keywords: [
"deepseek-harness",
@ -855,6 +855,7 @@ var EN = Object.freeze({
"\u6E20\u9053\u7528\u6237": "Channel user",
"\uFF08\u4E0D\u53EF\u7528\uFF09": " (unavailable)",
"\u7FA4 Agent Preset": "Group Agent Preset",
"\u8DDF\u968F\u9891\u9053\u6620\u5C04": "Follow channel mapping",
"\u8DDF\u968F\u5168\u5C40": "Follow global",
"\u2014": "\u2014",
"\u6765\u6E90": "Source",
@ -11122,6 +11123,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;
@ -11320,7 +11492,8 @@ var ACCESS_GRANT_COPY = Object.freeze({
approvedGroup: "\u672C\u7FA4\u8BBF\u95EE\u5DF2\u6279\u51C6\uFF0C\u8BF7\u5728\u672C\u7FA4 @ \u673A\u5668\u4EBA\u7EE7\u7EED\u3002",
denied: "\u8BBF\u95EE\u7533\u8BF7\u672A\u901A\u8FC7\u3002",
adminApproved: "\u5DF2\u6279\u51C6\u8BE5\u8BBF\u95EE\u7533\u8BF7\u3002",
adminDenied: "\u5DF2\u62D2\u7EDD\u8BE5\u8BBF\u95EE\u7533\u8BF7\u3002"
adminDenied: "\u5DF2\u62D2\u7EDD\u8BE5\u8BBF\u95EE\u7533\u8BF7\u3002",
processFailed: "\u65E0\u6CD5\u5904\u7406\u8BE5\u5BA1\u6279\u3002"
});
// src/channels/shared/session-scope.mjs
@ -11358,6 +11531,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"
});
@ -11425,6 +11599,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) } : {},
@ -11455,7 +11630,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) {
@ -11470,7 +11646,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";
@ -11495,7 +11671,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,
@ -11718,6 +11894,7 @@ function WhatsappAccountCard({
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onDefaultModelSave,
onContextEnhancementSave,
onRequestRemove,
onConfirmRemove,
@ -11782,6 +11959,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),
@ -11829,23 +12011,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;
@ -11855,11 +12038,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 }));
@ -11871,7 +12054,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,
@ -11889,12 +12073,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(
@ -11906,12 +12090,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) {
@ -11929,7 +12113,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)) {
@ -11940,7 +12124,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();
@ -11981,7 +12165,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") {
@ -12000,7 +12184,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) => ({
@ -12058,6 +12243,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",
@ -12077,6 +12268,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",
{
@ -12100,7 +12293,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,
@ -12116,11 +12309,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",
@ -12134,8 +12327,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;
@ -12192,10 +12385,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({
@ -12298,8 +12491,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";
@ -12351,7 +12544,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",
@ -12493,18 +12686,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) {
@ -12657,7 +12850,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";
@ -12749,13 +12942,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();
@ -12844,7 +13037,7 @@ function AgentPresetSelect({
"aria-label": ariaLabel,
onChange: (event) => onChange(event.target.value || "")
},
h2("option", { value: "" }, "\u8DDF\u968F\u5168\u5C40"),
h2("option", { value: "" }, localizeText("\u8DDF\u968F\u9891\u9053\u6620\u5C04")),
...items.map((item) => h2("option", {
key: item.id,
value: item.id
@ -13063,21 +13256,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);
@ -13131,7 +13324,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);
@ -13141,7 +13334,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]
);
@ -13820,13 +14013,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();
@ -14041,7 +14234,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)
@ -14050,7 +14243,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 "))
)
);
})
@ -14064,10 +14257,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);
@ -14109,7 +14302,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}`)
@ -14172,28 +14365,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 {
@ -14207,7 +14400,7 @@ function DeliveryTargetSettingsPage({
setPhase("error");
}
}, [account.botId, invoke]);
const loadSuggestions = React23.useCallback(async () => {
const loadSuggestions = React24.useCallback(async () => {
setSuggestionPhase("loading");
setSuggestionError(null);
try {
@ -14221,7 +14414,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 });
@ -14348,7 +14541,7 @@ function DeliveryTargetSettingsPage({
rpcCall: accessRpcCall,
onSaved: setAccessPolicy
}) : h2(
React23.Fragment,
React24.Fragment,
null,
h2(
"section",
@ -15169,7 +15362,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);
@ -15259,11 +15452,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;
@ -15295,7 +15488,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(
@ -15331,7 +15524,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" })
@ -15356,10 +15549,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 () => {
@ -15417,19 +15610,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);
}, []);
@ -15438,7 +15631,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";
@ -15446,7 +15639,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();
@ -15469,7 +15662,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({
@ -15567,7 +15760,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",
@ -15592,13 +15785,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}`)
@ -15670,6 +15863,15 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
var name = "im-settings";
var inject = ["slots", "connection", "locale", "workspaces"];
var IM_PLUGIN_VERSION = package_default.version;
function syncHostChatLanguage(ctx, language) {
if (typeof language !== "string" || !language.trim()) return;
void ctx.connection.rpc.call(
UPDATE_RPC_CHANNEL,
"host.language.set",
{ language }
).catch(() => {
});
}
function callWorkspaceDirectoryApi(ctx, method, ...args) {
const uiWorkspace = typeof ctx.get === "function" ? ctx.get("uiWorkspace") : void 0;
const service = typeof uiWorkspace?.[method] === "function" ? uiWorkspace : ctx.workspaces;
@ -15802,20 +16004,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,
@ -15847,7 +16049,7 @@ function IMSettingsTab({
weixinRpcCall,
whatsappRpcCall
]);
const botSettingsContext = React25.useMemo(() => Object.freeze({
const botSettingsContext = React26.useMemo(() => Object.freeze({
openBotSettings: setDeliverySettings
}), []);
return h2(
@ -15965,6 +16167,12 @@ function apply(ctx) {
);
const t = ctx.locale.bind(IM_LOCALE_NAMESPACE);
setImTranslator(t);
ctx.effect(() => {
syncHostChatLanguage(ctx, ctx.locale.getLocale().active);
return ctx.on("locale/change", (snapshot) => {
syncHostChatLanguage(ctx, snapshot?.active);
});
}, "im-settings: sync host chat language with DSH locale");
ctx.effect(() => {
const disposers = [
installFeishuStyles(),

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
{
"name": "dsh-im-ops",
"version": "4.9.1-ops.31",
"version": "4.9.1-ops.36",
"description": "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 - access/session policies owned here.",
"keywords": [
"deepseek-harness",

View file

@ -224,7 +224,7 @@ function AgentPresetSelect({
'aria-label': ariaLabel,
onChange: (event) => onChange(event.target.value || ''),
},
h('option', { value: '' }, '跟随全局'),
h('option', { value: '' }, localizeText('跟随频道映射')),
...items.map((item) => h('option', {
key: item.id,
value: item.id,

View file

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

View file

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

View file

@ -0,0 +1,105 @@
import * as React from 'react';
import {
defaultModelKey,
normalizeDefaultModel,
normalizeModelCatalog,
} from '../../src/channels/shared/default-model.mjs';
import { h } from './i18n.js';
export const SET_DEFAULT_MODEL_ENDPOINT = 'bot.model.set';
export const EMPTY_MODEL_CATALOG = Object.freeze({
groups: Object.freeze([]),
failures: Object.freeze([]),
items: Object.freeze([]),
});
export const ModelCatalogContext = React.createContext(EMPTY_MODEL_CATALOG);
export function normalizeDefaultModelSelection(value) {
return normalizeDefaultModel(value);
}
export function normalizeHostModelCatalog(value) {
return normalizeModelCatalog(value);
}
export function DefaultModelEditor({ defaultModel = null, disabled = false, onSave }) {
const catalog = React.useContext(ModelCatalogContext) ?? EMPTY_MODEL_CATALOG;
const helpId = React.useId();
const current = normalizeDefaultModel(defaultModel);
const currentKey = defaultModelKey(current);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState(null);
const items = [];
const seen = new Set();
for (const item of Array.isArray(catalog.items) ? catalog.items : []) {
if (!item?.key || seen.has(item.key)) continue;
seen.add(item.key);
items.push(item);
}
const currentUnavailable = Boolean(currentKey && !seen.has(currentKey));
if (currentUnavailable && current) {
items.push({
key: currentKey,
provider: current.provider,
model: current.model,
label: currentKey,
unavailable: true,
});
}
const change = async (event) => {
const nextKey = event.target.value;
if (nextKey === currentKey || saving || disabled) return;
setSaving(true);
setError(null);
try {
await onSave?.(nextKey ? normalizeDefaultModel(nextKey) : null);
} catch (cause) {
setError(cause?.message ?? '默认模型修改失败,请重试。');
} finally {
setSaving(false);
}
};
return h('div', { className: 'dim-preset' },
h('div', { className: 'dim-presetHeader' },
h('span', { className: 'dim-presetTitle' },
h('span', null, '默认模型'),
h('span', { className: 'dim-presetHelp' },
h('button', {
type: 'button',
className: 'dim-presetHelpButton',
'aria-label': '查看默认模型说明',
'aria-describedby': helpId,
}, h('span', { 'aria-hidden': 'true' }, '?')),
h('span', {
id: helpId,
className: 'dim-presetTooltip',
role: 'tooltip',
}, '为该 WhatsApp 机器人的新建会话指定模型,避免每人各自 /model。已有会话需先 /new 再发消息才会生效。'))),
saving ? h('span', { className: 'dim-presetStatus' }, '保存中…') : null),
React.createElement('select', {
className: 'dim-presetSelect',
value: currentKey,
disabled: disabled || saving,
'aria-label': '默认模型',
onChange: (event) => { void change(event); },
},
h('option', { value: '' }, '跟随 Host 默认'),
...items.map((item) => h(
'option',
{ key: item.key, value: item.key },
item.unavailable ? [item.key, '(已不可用)'] : item.label,
)),
),
error || currentUnavailable ? h(
'p',
{ className: 'dim-presetError', role: error ? 'alert' : 'status' },
error ?? '当前默认模型已不可用,请选择其他模型或跟随 Host 默认。',
) : null,
);
}

View file

@ -14,6 +14,7 @@ const EN = Object.freeze({
'渠道用户': 'Channel user',
"(不可用)": " (unavailable)",
"群 Agent Preset": "Group Agent Preset",
"跟随频道映射": "Follow channel mapping",
"跟随全局": "Follow global",
"—": "—",
"来源": "Source",

View file

@ -64,6 +64,18 @@ export const name = 'im-settings';
export const inject = ['slots', 'connection', 'locale', 'workspaces'];
export const IM_PLUGIN_VERSION = manifest.version;
/** Push the active DSH UI locale to the Host so bot chat copy stays aligned. */
function syncHostChatLanguage(ctx, language) {
if (typeof language !== 'string' || !language.trim()) return;
void ctx.connection.rpc.call(
UPDATE_RPC_CHANNEL,
'host.language.set',
{ language },
).catch(() => {
// Host may be offline or on an older build without the endpoint.
});
}
function callWorkspaceDirectoryApi(ctx, method, ...args) {
// Current DSH owns directory operations on uiWorkspace; legacy Hosts keep them on workspaces.
const uiWorkspace = typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : undefined;
@ -332,6 +344,13 @@ export function apply(ctx) {
const t = ctx.locale.bind(IM_LOCALE_NAMESPACE);
setImTranslator(t);
ctx.effect(() => {
syncHostChatLanguage(ctx, ctx.locale.getLocale().active);
return ctx.on('locale/change', (snapshot) => {
syncHostChatLanguage(ctx, snapshot?.active);
});
}, 'im-settings: sync host chat language with DSH locale');
ctx.effect(() => {
const disposers = [
installFeishuStyles(),

View file

@ -0,0 +1,27 @@
import { normalizeDefaultModel } from '../../../../src/channels/shared/default-model.mjs';
export const SET_DEFAULT_MODEL_ENDPOINT = 'bot.model.set';
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
export function validDefaultModelPayload(payload) {
if (!isRecord(payload)
|| !Object.keys(payload).every((key) => ['botId', 'defaultModel'].includes(key))
|| typeof payload.botId !== 'string'
|| !/^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)) {
return false;
}
if (payload.defaultModel === null) return true;
return Boolean(normalizeDefaultModel(payload.defaultModel));
}
export function publicDefaultModelError(error) {
if (![
'default-model-invalid',
'default-model-unavailable',
'workspace-bot-not-found',
].includes(error?.code)) return null;
return { code: error.code, message: error.message };
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import { getImHostLanguage, setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
import { createUpdateRuntime } from './update-runtime.mjs';
import { createUpdateService } from './update-service.mjs';
import { installConnectionRpcChannel } from './connection-rpc-mount.mjs';
@ -5,8 +6,20 @@ import { resolveRpcAuthority } from './rpc-authority.mjs';
export const UPDATE_RPC_CHANNEL = '/dsh-im';
export const UPDATE_ENDPOINTS = Object.freeze(['update.status', 'update.check', 'update.install']);
export const HOST_LANGUAGE_SET_ENDPOINT = 'host.language.set';
function validLanguagePayload(payload) {
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
const keys = Object.keys(payload);
return keys.length === 1
&& keys[0] === 'language'
&& typeof payload.language === 'string'
&& payload.language.trim().length > 0
&& payload.language.length <= 32;
}
function validPayload(endpoint, payload) {
if (endpoint === HOST_LANGUAGE_SET_ENDPOINT) return validLanguagePayload(payload);
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
const keys = Object.keys(payload);
if (endpoint !== 'update.install') return keys.length === 0;
@ -25,9 +38,14 @@ const PUBLIC_ERRORS = new Set([
export function createUpdateRpcHandler(service) {
return async (endpoint, payload, signal) => {
if (!UPDATE_ENDPOINTS.includes(endpoint) || !validPayload(endpoint, payload)) {
if (endpoint !== HOST_LANGUAGE_SET_ENDPOINT && !UPDATE_ENDPOINTS.includes(endpoint)
|| !validPayload(endpoint, payload)) {
return { ok: false, error: { code: 'bad-request', message: 'Invalid update request.' } };
}
if (endpoint === HOST_LANGUAGE_SET_ENDPOINT) {
setImHostLanguage(payload.language);
return { ok: true, value: { language: getImHostLanguage() } };
}
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'Request cancelled.' } };
try {
// The submitted install belongs to the Host, not the lifetime of this browser request.

View file

@ -310,8 +310,9 @@ export function normalizeAccessGrant(input) {
/**
* Resolve a chat-scoped Agent Preset override from the access grant.
* Returns null when the chat should follow the bot-level (global) preset.
* Priority: direct-member or group override > bot global (caller applies global).
* Returns null when the chat should follow the bot/channel mapping preset
* (workspaces.agentPresets[botId]), not the Host catalog default.
* Priority: direct-member or group override > bot mapping (caller applies).
* @param {unknown} grant
* @param {{ kind?: string, phone?: string, senderId?: string, groupJid?: string, conversationId?: string }} [context]
* @returns {string|null}
@ -332,6 +333,25 @@ export function resolveAccessAgentPreset(grant, context = {}) {
return normalizeOptionalAgentPresetId(member?.agentPreset) ?? null;
}
/**
* Member/group override, else the bot's workspace-mapped preset.
* Does not fall through to Host catalog default — that is only used when the
* bot mapping itself is empty (createSession omits agentPreset).
* @param {unknown} grant
* @param {{ kind?: string, phone?: string, senderId?: string, groupJid?: string, conversationId?: string }} [context]
* @param {string|null|undefined} botAgentPreset
* @returns {string|null}
*/
export function resolveChatAgentPreset(grant, context = {}, botAgentPreset = null) {
const fromGrant = resolveAccessAgentPreset(grant, context);
if (fromGrant) return fromGrant;
if (typeof botAgentPreset === 'string') {
const trimmed = botAgentPreset.trim();
if (trimmed) return trimmed;
}
return null;
}
/**
* Seed owner phone as the first global admin when missing.
* @param {object|null} grant
@ -744,7 +764,10 @@ export function parsePendingIdFromNotifyText(text) {
return match?.[1] ?? null;
}
/** Notify / ack copy (zh). */
/**
* Notify / ack copy keys (Chinese source literals for host `t()`).
* Callers must pass through `t()` after `setImHostLanguage` — do not send raw.
*/
export const ACCESS_GRANT_COPY = Object.freeze({
pendingAckDirect: '已提交私聊访问申请,请等待全局管理员审批。',
pendingAckGroup: '已提交本群访问申请,请等待管理员审批。',
@ -755,26 +778,38 @@ export const ACCESS_GRANT_COPY = Object.freeze({
denied: '访问申请未通过。',
adminApproved: '已批准该访问申请。',
adminDenied: '已拒绝该访问申请。',
processFailed: '无法处理该审批。',
});
/** Identity translator that still fills `{name}` placeholders (zh default). */
function fillPlaceholders(text, params) {
if (typeof text !== 'string' || params == null) return text;
return text.replace(/\{(\w+)\}/g, (match, name) =>
Object.hasOwn(params, name) ? String(params[name]) : match);
}
/**
* @param {object} pending
* @param {(text: string, params?: Record<string, unknown>) => string} [translate]
* @returns {string}
*/
export function formatPendingNotifyBody(pending) {
export function formatPendingNotifyBody(pending, translate = fillPlaceholders) {
const scene = pending.kind === 'group'
? `群聊 ${pending.groupJid ?? ''}`
: '私聊';
? translate('群聊 {jid}', { jid: pending.groupJid ?? '' })
: translate('私聊');
const unknown = translate('未知');
const who = pending.pushName
? `${pending.pushName} (${pending.phone || pending.lid || '未知'})`
: (pending.phone || pending.lid || '未知');
const text = pending.requestText ? `\n原文:${pending.requestText.slice(0, 200)}` : '';
? `${pending.pushName} (${pending.phone || pending.lid || unknown})`
: (pending.phone || pending.lid || unknown);
const text = pending.requestText
? `\n${translate('原文:{text}', { text: pending.requestText.slice(0, 200) })}`
: '';
return [
ACCESS_GRANT_COPY.notifyTitle,
`场景:${scene}`,
`申请人:${who}`,
`请求编号:${pending.id}`,
'请引用本条消息回复「同意」或「拒绝」。',
translate(ACCESS_GRANT_COPY.notifyTitle),
translate('场景:{scene}', { scene }),
translate('申请人:{who}', { who }),
translate('请求编号:{id}', { id: pending.id }),
translate('请引用本条消息回复「同意」或「拒绝」。'),
text,
].filter(Boolean).join('\n');
}

View file

@ -13,6 +13,11 @@ import {
normalizeAgentPresetCatalog,
validateAgentPresetId,
} from './agent-preset.mjs';
import {
catalogHasModel,
normalizeModelCatalog,
validateDefaultModel,
} from './default-model.mjs';
import {
normalizeAccessPolicy,
validateAccessPolicy,
@ -191,6 +196,21 @@ function normalizeDocument(value) {
}
}
}
let defaultModels = {};
if (value.defaultModels !== undefined) {
if (!value.defaultModels || typeof value.defaultModels !== 'object'
|| Array.isArray(value.defaultModels)) return null;
for (const [botId, defaultModel] of Object.entries(value.defaultModels)) {
if (!/^[A-Za-z0-9_-]{1,128}$/.test(botId)) return null;
try {
const normalized = validateDefaultModel(defaultModel);
if (!normalized) return null;
defaultModels[botId] = normalized;
} catch {
return null;
}
}
}
let groupSessionScopes = {};
if (value.groupSessionScopes !== undefined) {
if (!value.groupSessionScopes || typeof value.groupSessionScopes !== 'object'
@ -235,6 +255,7 @@ function normalizeDocument(value) {
version,
workspaces,
agentPresets,
defaultModels,
groupSessionScopes,
contextEnhancement,
deliveryTargets,
@ -247,6 +268,7 @@ function storedDocument({
version,
workspaces,
agentPresets,
defaultModels,
groupSessionScopes,
contextEnhancement,
deliveryTargets,
@ -255,6 +277,7 @@ function storedDocument({
}) {
const document = { version, workspaces };
if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
if (Object.keys(defaultModels).length > 0) document.defaultModels = defaultModels;
if (Object.keys(groupSessionScopes).length > 0) document.groupSessionScopes = groupSessionScopes;
if (Object.keys(contextEnhancement).length > 0) {
document.contextEnhancement = contextEnhancement;
@ -310,6 +333,7 @@ export class BotWorkspaceStore {
#version = 1;
#workspaces = {};
#agentPresets = {};
#defaultModels = {};
#groupSessionScopes = {};
#contextEnhancement = {};
#deliveryTargets = Object.create(null);
@ -338,6 +362,7 @@ export class BotWorkspaceStore {
this.#version = normalized.version;
this.#workspaces = normalized.workspaces;
this.#agentPresets = normalized.agentPresets;
this.#defaultModels = normalized.defaultModels;
this.#groupSessionScopes = normalized.groupSessionScopes;
this.#contextEnhancement = normalized.contextEnhancement;
this.#deliveryTargets = normalized.deliveryTargets;
@ -348,6 +373,7 @@ export class BotWorkspaceStore {
this.#version = 1;
this.#workspaces = {};
this.#agentPresets = {};
this.#defaultModels = {};
this.#groupSessionScopes = {};
this.#contextEnhancement = {};
this.#deliveryTargets = Object.create(null);
@ -384,6 +410,10 @@ export class BotWorkspaceStore {
return this.#agentPresets[botIdOf(botId)] ?? null;
}
defaultModelFor(botId) {
return this.#defaultModels[botIdOf(botId)] ?? null;
}
groupSessionScopeFor(botId) {
const id = botIdOf(botId);
if (this.has(id) && Object.hasOwn(this.#groupSessionScopes, id)) {
@ -654,6 +684,40 @@ export class BotWorkspaceStore {
});
}
async setDefaultModel(botId, value, { incarnation } = {}) {
const id = botIdOf(botId);
if (!this.has(id)
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const defaultModel = validateDefaultModel(value);
return this.#enqueue(id, async () => {
if (!this.has(id)
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const previous = this.#defaultModels[id] ?? null;
const same = previous?.provider === defaultModel?.provider
&& previous?.model === defaultModel?.model
&& previous?.reasoningEffort === defaultModel?.reasoningEffort;
if (same) return defaultModel;
if (defaultModel) this.#defaultModels[id] = defaultModel;
else delete this.#defaultModels[id];
try {
await this.#persist();
} catch (error) {
if (previous) this.#defaultModels[id] = previous;
else delete this.#defaultModels[id];
throw error;
}
return defaultModel;
});
}
async setGroupSessionScope(botId, value, { incarnation } = {}) {
const id = botIdOf(botId);
if (!this.has(id)
@ -949,6 +1013,7 @@ export class BotWorkspaceStore {
const candidates = new Set([
...Object.keys(this.#workspaces),
...Object.keys(this.#agentPresets),
...Object.keys(this.#defaultModels),
...Object.keys(this.#groupSessionScopes),
...Object.keys(this.#contextEnhancement),
...Object.keys(this.#deliveryTargets),
@ -970,6 +1035,7 @@ export class BotWorkspaceStore {
...bot,
workspace: this.workspaceFor(bot.botId),
agentPreset: this.agentPresetFor(bot.botId),
defaultModel: this.defaultModelFor(bot.botId),
groupSessionScope: this.groupSessionScopeFor(bot.botId),
contextEnhancement: this.contextEnhancementFor(bot.botId),
accessPolicy: this.accessPolicyFor(bot.botId),
@ -1003,15 +1069,18 @@ export class BotWorkspaceStore {
async #retireCurrentIncarnation(id) {
const hadWorkspace = Object.hasOwn(this.#workspaces, id);
const hadPreset = Object.hasOwn(this.#agentPresets, id);
const hadDefaultModel = Object.hasOwn(this.#defaultModels, id);
const hadGroupSessionScope = Object.hasOwn(this.#groupSessionScopes, id);
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
const hadAccessGrant = Object.hasOwn(this.#accessGrants, id);
const needsCleanup = hadWorkspace || hadPreset || hadGroupSessionScope || hadContextEnhancement
const needsCleanup = hadWorkspace || hadPreset || hadDefaultModel || hadGroupSessionScope
|| hadContextEnhancement
|| hadDeliveryTargets || hadAccessPolicy || hadAccessGrant || this.#dirtyRemovals.has(id);
delete this.#workspaces[id];
delete this.#agentPresets[id];
delete this.#defaultModels[id];
delete this.#groupSessionScopes[id];
delete this.#contextEnhancement[id];
delete this.#deliveryTargets[id];
@ -1056,6 +1125,7 @@ export class BotWorkspaceStore {
version,
workspaces: this.#workspaces,
agentPresets: this.#agentPresets,
defaultModels: this.#defaultModels,
groupSessionScopes: this.#groupSessionScopes,
contextEnhancement,
deliveryTargets,
@ -1068,6 +1138,7 @@ export class BotWorkspaceStore {
async #persistCurrentDocument() {
if (Object.keys(this.#workspaces).length > 0
|| Object.keys(this.#agentPresets).length > 0
|| Object.keys(this.#defaultModels).length > 0
|| Object.keys(this.#groupSessionScopes).length > 0
|| Object.keys(this.#contextEnhancement).length > 0
|| Object.keys(this.#deliveryTargets).length > 0
@ -1107,17 +1178,41 @@ function assertCurrentBotScope(isCurrentScope) {
throw error;
}
function decorateResult(workspaces, result, catalog) {
function decorateResult(workspaces, result, catalog, modelCatalog) {
const decorate = (value) => {
const decorated = workspaces.decorateStatus(value);
if (!catalog || !decorated || typeof decorated !== 'object') return decorated;
const attachCatalog = (agentPresetCatalog) => (
agentPresetCatalog ? { ...decorated, agentPresetCatalog } : decorated
);
const agentPresetCatalog = resolveAgentPresetCatalog(catalog);
return agentPresetCatalog && typeof agentPresetCatalog.then === 'function'
? agentPresetCatalog.then(attachCatalog)
: attachCatalog(agentPresetCatalog);
if (!decorated || typeof decorated !== 'object') return decorated;
const attach = (agentPresetCatalog, resolvedModelCatalog) => {
let next = decorated;
if (agentPresetCatalog) next = { ...next, agentPresetCatalog };
if (resolvedModelCatalog) next = { ...next, modelCatalog: resolvedModelCatalog };
return next;
};
const agentPresetCatalog = catalog
? resolveAgentPresetCatalog(catalog)
: null;
const resolvedModels = modelCatalog
? Promise.resolve().then(async () => {
try {
const raw = typeof modelCatalog === 'function' ? await modelCatalog() : await modelCatalog;
return normalizeModelCatalog(raw);
} catch {
return normalizeModelCatalog(null);
}
})
: null;
if (agentPresetCatalog && typeof agentPresetCatalog.then === 'function') {
if (resolvedModels) {
return Promise.all([agentPresetCatalog, resolvedModels]).then(([presets, models]) => (
attach(presets, models)
));
}
return agentPresetCatalog.then((presets) => attach(presets, null));
}
if (resolvedModels) {
return resolvedModels.then((models) => attach(agentPresetCatalog, models));
}
return attach(agentPresetCatalog, null);
};
return result && typeof result.then === 'function'
? result.then(decorate)
@ -1190,6 +1285,16 @@ export function createBotWorkspaceScope(
const sessionGenerations = new Map();
const scopedHarness = new Proxy(harness, {
get(target, property) {
if (property === 'currentWorkspace') {
return () => {
if (!isCurrentScope()) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
return workspaces.workspaceFor(botId);
};
}
if (property === 'agentPresetSettings') {
return async (options = {}) => {
options?.signal?.throwIfAborted();
@ -1358,6 +1463,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 +1640,7 @@ export function createWorkspaceAwareController(controller, {
workspaces,
stateFor,
agentPresetCatalog,
modelCatalog = null,
channel = null,
} = {}) {
if (!controller || !workspaces || typeof stateFor !== 'function') {
@ -1537,7 +1656,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 +1699,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 +1927,7 @@ export function createWorkspaceAwareController(controller, {
get(target, property) {
if (property === 'updateWorkspace') return updateWorkspace;
if (property === 'updateAgentPreset') return updateAgentPreset;
if (property === 'updateDefaultModel') return updateDefaultModel;
if (property === 'updateContextEnhancement') return updateContextEnhancement;
if (property === 'updateAccessPolicy') return updateAccessPolicy;
if (property === 'updateGroupSessionScope') return updateGroupSessionScope;

View file

@ -1,4 +1,4 @@
import { normalizeAccessPhone, phoneFromWhatsappJid, resolveAccessAgentPreset } from './access-grant.mjs';
import { normalizeAccessPhone, phoneFromWhatsappJid, resolveChatAgentPreset } from './access-grant.mjs';
/**
* Parse a durable conversation binding key into chat identity fields.
@ -118,14 +118,13 @@ export function resolveChannelPeerFromBinding(input) {
const botAgentPreset = typeof input.botAgentPreset === 'string' ? input.botAgentPreset.trim() : '';
const withPreset = (peer) => {
const fromGrant = resolveAccessAgentPreset(grant, {
const agentPreset = resolveChatAgentPreset(grant, {
kind: peer.kind === 'group' ? 'group' : 'direct',
groupJid: peer.kind === 'group' ? peer.conversationId : undefined,
conversationId: peer.conversationId,
phone: peer.phone,
senderId: peer.senderId || peer.phone,
});
const agentPreset = fromGrant || botAgentPreset || null;
}, botAgentPreset);
return Object.freeze({ ...peer, agentPreset });
};

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { isAbsolute } from 'node:path';
import { existsSync, realpathSync } from 'node:fs';
import { isAbsolute, resolve } from 'node:path';
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
import {
@ -213,13 +214,59 @@ function workspacePaths(value) {
));
}
/**
* Canonicalize for Host workspace identity: prefer realpath when the directory
* exists (matches Host `realpathNormalize`), else resolved spelling. Win32 also
* folds drive/path casing so `D:\…` and `d:\…` collide the same way Host does.
*/
function canonicalWorkspacePath(value) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const resolved = resolve(trimmed);
try {
if (existsSync(resolved)) return realpathSync(resolved);
} catch {
// Fall through to the resolved spelling when realpath is unavailable.
}
return resolved;
}
function sameWorkspacePath(left, right) {
const a = canonicalWorkspacePath(left);
const b = canonicalWorkspacePath(right);
if (!a || !b) return false;
return process.platform === 'win32'
? a.toLowerCase() === b.toLowerCase()
: a === b;
}
function isPresetCreateFailure(error) {
const code = typeof error?.code === 'string' ? error.code : '';
return code.startsWith('agent-preset/') || code.startsWith('agent-preset-');
}
function isInternalCreateFailure(error) {
const code = typeof error?.code === 'string' ? error.code : '';
return code === 'internal'
|| code === 'gateway/internal'
|| code.endsWith('/internal')
|| code === 'harness-http-failed'
|| code === 'harness-rpc-rejected';
}
function findListedWorkspace(items, workspacePath) {
if (!Array.isArray(items)) return null;
return items.find((item) => sameWorkspacePath(item?.path, workspacePath)) ?? null;
}
function workspaceFromList(workspacePath, workspaceList) {
if (!Array.isArray(workspaceList?.items)
|| !Array.isArray(workspaceList?.archivedSessionIds)) {
throw new Error('Harness returned an invalid response for workspace.list');
}
const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
const workspace = findListedWorkspace(workspaceList.items, workspacePath);
if (!workspace) return null;
if (!Array.isArray(workspace.sessionIds)
|| workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
@ -399,6 +446,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 +459,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)
));
}
@ -905,21 +957,68 @@ export class HarnessClient {
async workspaceId(options = {}) {
const { workspace = this.#workspace, ...rpcOptions } = options;
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);
const existing = items.find((item) => item.path === workspace);
// Reuse Host's listed entry under Win32 path-case aliases (D: vs d:).
const existing = findListedWorkspace(items, workspace);
if (existing) return existing.workspaceId;
const created = await this.rpc('workspace.create', { path: workspace }, 30_000, rpcOptions);
return created.workspace.workspaceId;
}
async createSession(options = {}) {
const { agentPreset: requestedPreset, ...rpcOptions } = options;
const { agentPreset: requestedPreset, workspace, ...rpcOptions } = options;
await this.ensureRunning(rpcOptions);
const workspaceId = await this.workspaceId(rpcOptions);
const workspacePath = typeof workspace === 'string' && workspace.trim()
? workspace.trim()
: this.#workspace;
const workspaceId = await this.workspaceId({
...rpcOptions,
...(workspacePath ? { workspace: workspacePath } : {}),
});
const payload = { workspaceId };
const agentPreset = requestedPreset !== undefined ? requestedPreset : this.#agentPreset;
if (agentPreset != null) payload.agentPreset = agentPreset;
const created = await this.rpc('session.create', payload, 30_000, rpcOptions);
return created.sessionId;
const annotate = (error) => {
if (error && typeof error === 'object') {
if (workspacePath) error.workspace = workspacePath;
if (error.details == null || typeof error.details !== 'object') {
error.details = { workspaceId };
} else if (!Object.hasOwn(error.details, 'workspaceId')) {
error.details = { ...error.details, workspaceId };
}
if (payload.agentPreset && !Object.hasOwn(error.details, 'agentPreset')) {
error.details = { ...error.details, agentPreset: payload.agentPreset };
}
}
return error;
};
try {
const created = await this.rpc('session.create', payload, 30_000, rpcOptions);
return created.sessionId;
} catch (error) {
// Broken per-chat/bot presets often surface as Host internal on create.
// Retry once without the preset so a bad override does not block the chat.
if (
payload.agentPreset
&& (isPresetCreateFailure(error) || isInternalCreateFailure(error))
) {
console.warn(
`[${this.#logPrefix}] session.create failed with agentPreset `
+ `${payload.agentPreset} (${error?.code ?? 'unknown'}); retrying without preset`,
);
try {
const created = await this.rpc(
'session.create',
{ workspaceId },
30_000,
rpcOptions,
);
return created.sessionId;
} catch (retryError) {
throw annotate(retryError);
}
}
throw annotate(error);
}
}
async renameSession(sessionId, title, options = {}) {
@ -964,11 +1063,43 @@ export class HarnessClient {
await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
return true;
} catch (error) {
if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false;
// Missing Session must recreate a binding; accept both legacy and branded codes.
if (error instanceof HarnessRpcError && (
error.code === 'session-not-found'
|| error.code === 'SESSION_NOT_FOUND'
|| error.code === 'not-found'
)) {
return false;
}
throw error;
}
}
/**
* Whether a Session is registered under the given workspace path.
* Prefers session.list `cwd`, then workspace.list membership.
*/
async sessionBelongsToWorkspace(sessionId, workspacePath, options = {}) {
if (typeof sessionId !== 'string' || !sessionId) return false;
if (typeof workspacePath !== 'string' || !workspacePath.trim()) return false;
const wanted = resolve(workspacePath.trim());
const sessionList = await this.rpc('session.list', {}, 30_000, options);
if (!sessionList || typeof sessionList !== 'object' || !Array.isArray(sessionList.items)) {
throw new Error('Harness returned an invalid response for session.list');
}
const item = sessionList.items.find((entry) => entry?.sessionId === sessionId);
if (!item) return false;
if (typeof item.cwd === 'string' && item.cwd.trim()) {
return sameWorkspacePath(item.cwd, wanted);
}
const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
const workspace = workspaceFromList(wanted, workspaceList);
if (!workspace) return false;
return Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId);
}
async respondInteraction(rpcId, result, options = {}) {
if (typeof rpcId !== 'string' || !rpcId) throw new TypeError('rpcId is required');
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
@ -1241,6 +1372,18 @@ export class HarnessClient {
.sort((left, right) => left.order - right.order);
if (active.length > 0) return { ownership: active[0], recovered: false };
// A live IM ask owns in-turn approvals/questions even before user/message
// flips `active`. Marking these recovered would auto-reject without ever
// showing the prompt in WhatsApp (or other text channels).
const live = owners
.filter((ownership) => (
!ownership.completed
&& typeof ownership.reconnect === 'function'
&& Boolean(ownership.promptRpcId)
))
.sort((left, right) => left.order - right.order)[0] ?? null;
if (live) return { ownership: live, recovered: false };
// A newly attached IM conversation may encounter a question left by
// an earlier runtime before its queued prompt starts. Let the oldest such
// ask adopt that replay so the Session can recover instead of deadlocking.

View file

@ -45,6 +45,8 @@ export default {
'The bot and DeepSeek Harness use incompatible APIs. Ask an administrator to check the Harness address and update the related components.',
'DeepSeek Harness 暂时无法完成请求,请稍后重试。':
'DeepSeek Harness could not complete the request. Please try again later.',
'无法在当前工作区创建新会话。请检查机器人工作区是否为绝对路径、目录存在、且运行 Host 的账号可访问;改回可用工作区后重试。':
'Could not create a new Session in the current workspace. Check that the bot workspace is an absolute path, the directory exists, and the Host process can access it; then switch back to a working workspace and retry.',
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
'Waiting for the model reply timed out and the task may still be running. Wait or send /stop before submitting it again.',
'模型凭据缺失或已失效。请管理员检查模型配置后重试。':
@ -57,8 +59,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 +81,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 +186,10 @@ export default {
'Failed to submit the answer. Please resend your answer to the current question.',
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。':
'A pending question left over in this Session was detected. It has been safely cancelled, and your latest message is being processed.',
'请在本聊天精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。勿到 DeepSeek Harness 网页里点,否则无人看会一直卡住。':
'Reply exactly with 批准 or 拒绝 in this chat (also: 同意 / 不同意 / yes / no). Do not use the DeepSeek Harness web UI — if nobody is watching it, the task will hang.',
'审批等待超时,已自动拒绝此次操作。请重新发送问题,或调整工具审批策略后重试。':
'Approval timed out and was automatically rejected. Resend your question, or adjust the tool-approval policy and try again.',
'检测到遗留的工具审批请求,已在聊天侧自动拒绝以免任务一直卡住。请重新发送你的问题。':
'A leftover tool-approval request was detected and automatically rejected in chat so the task would not hang. Please resend your question.',
};

View file

@ -14,4 +14,33 @@ export default {
'WhatsApp账号': 'WhatsApp account',
' Web 关联设备': ' Web linked device',
// Access-grant / whitelist approval (WhatsApp DM to admins & requesters)
'已提交私聊访问申请,请等待全局管理员审批。':
'Your DM access request was submitted. Please wait for a global admin to approve.',
'已提交本群访问申请,请等待管理员审批。':
'Your group access request was submitted. Please wait for an admin to approve.',
'已记录访问申请,但尚未解析到电话号码;请管理员在设置中补全后再批准。':
'Access request recorded, but the phone number is unresolved; an admin must complete it in settings before approving.',
'[dsh-im-ops] 访问申请': '[dsh-im-ops] Access request',
'私聊访问已批准,现在可以直接对话。': 'DM access approved. You can chat directly now.',
'本群访问已批准,请在本群 @ 机器人继续。': 'Group access approved. @ the bot in this group to continue.',
'访问申请未通过。': 'Access request denied.',
'已批准该访问申请。': 'Access request approved.',
'已拒绝该访问申请。': 'Access request rejected.',
'无法处理该审批。': 'Unable to process this approval.',
'找不到待批申请。': 'No pending request found.',
'该申请已失效。': 'This request is no longer valid.',
'无权审批该申请。': 'You are not allowed to approve this request.',
'审批动作无效。': 'Invalid approval action.',
'申请人电话尚未解析,请在设置中补全后再批准。':
'Requester phone is unresolved; complete it in settings before approving.',
'私聊': 'Direct chat',
'群聊 {jid}': 'Group {jid}',
'场景:{scene}': 'Scene: {scene}',
'申请人:{who}': 'Requester: {who}',
'请求编号:{id}': 'Request: {id}',
'原文:{text}': 'Original message: {text}',
'请引用本条消息回复「同意」或「拒绝」。':
'Quote this message and reply with 同意 (approve) or 拒绝 (reject). Also: yes / no.',
};

View file

@ -1,4 +1,7 @@
import { randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { t } from './i18n.mjs';
@ -13,9 +16,13 @@ const PROVIDER_FAILURES = Object.freeze({
NO_ADAPTER: 'MODEL_UNAVAILABLE',
UNSUPPORTED_OPTION: 'MODEL_CONFIG',
UNSUPPORTED_REASONING_EFFORT: 'MODEL_CONFIG',
INVALID_REQUEST: 'MODEL_CONFIG',
TIMEOUT: 'MODEL_TIMEOUT',
TRANSPORT: 'MODEL_TRANSPORT',
SERVER: 'MODEL_SERVICE',
// DSH catch-alls when the provider did not classify further.
PI_AI_ERROR: 'MODEL_SERVICE',
UNKNOWN: 'MODEL_SERVICE',
STREAM_CLOSED: 'MODEL_STREAM',
MALFORMED_RESPONSE: 'MODEL_STREAM',
EMPTY_RESPONSE: 'MODEL_EMPTY_REPLY',
@ -23,6 +30,55 @@ const PROVIDER_FAILURES = Object.freeze({
UNSUPPORTED_CONTENT: 'MODEL_CONFIG',
});
const MISSING_SESSION_CODES = new Set([
'session-not-found',
'SESSION_NOT_FOUND',
'not-found',
]);
/** Collect short diagnostic text from turn reason / message / cause chain. */
function diagnosticMessages(error) {
const out = [];
const push = (value) => {
if (typeof value === 'string' && value.trim()) out.push(value.trim());
};
const reason = error?.reason;
if (reason && typeof reason === 'object') {
const failure = reason.error ?? reason.failure;
push(failure?.message);
push(typeof failure === 'string' ? failure : null);
}
push(error?.message);
let cause = error?.cause;
const seen = new Set();
while (cause && typeof cause === 'object' && !seen.has(cause) && out.length < 6) {
seen.add(cause);
push(cause.message);
push(cause.code);
cause = cause.cause;
}
return out;
}
function providerErrorMessage(error) {
return diagnosticMessages(error).join('\n');
}
/**
* Transport / TLS wording that DSH may still emit as PI_AI_ERROR.
* Keep in sync with common Node/undici/pi-ai surfaces; do not require DSH changes.
*/
function isTransportProviderMessage(message) {
if (!message) return false;
return /\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message)
|| /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)
|| /\bterminated\b|premature close/i.test(message)
|| /stream ended (?:before|without)\b/i.test(message)
|| /\bunable to verify the first certificate\b/i.test(message)
|| /\bUNABLE_TO_VERIFY_LEAF_SIGNATURE\b|\bUNABLE_TO_GET_ISSUER_CERT(?:_LOCALLY)?\b|\bCERT_HAS_EXPIRED\b|\bDEPTH_ZERO_SELF_SIGNED_CERT\b|\bERR_TLS_CERT_ALTNAME_INVALID\b/.test(message)
|| /\bself[- ]signed certificate\b/i.test(message);
}
const FAILURE_MESSAGES = Object.freeze({
HARNESS_CONNECT:
'无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。',
@ -36,6 +92,8 @@ const FAILURE_MESSAGES = Object.freeze({
'机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。',
HARNESS_SERVICE:
'DeepSeek Harness 暂时无法完成请求,请稍后重试。',
SESSION_CREATE:
'无法在当前工作区创建新会话。请检查机器人工作区是否为绝对路径、目录存在、且运行 Host 的账号可访问;改回可用工作区后重试。',
MODEL_REPLY_TIMEOUT:
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。',
MODEL_AUTH:
@ -47,7 +105,7 @@ const FAILURE_MESSAGES = Object.freeze({
MODEL_CONTEXT_LIMIT:
'当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。',
MODEL_UNAVAILABLE:
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。',
'当前会话绑定的模型已不存在或不可用。请发送 /new 开启新会话后重试;或发送 /models,再用 /model 切换模型。',
MODEL_CONFIG:
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。',
MODEL_TIMEOUT:
@ -65,7 +123,7 @@ const FAILURE_MESSAGES = Object.freeze({
MODEL_OUTPUT_LIMIT:
'模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。',
TURN_BLOCKED:
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。',
'任务正在等待审批或问答,但当前聊天未能收到该请求(可能只出现在 DeepSeek Harness 网页)。请发送 /stop 取消后重试;若再次出现,请在本聊天回复「批准」或「拒绝」。',
TURN_INTERRUPTED:
'任务被意外中断,本次未完成。请重试。',
SESSION_NOT_FOUND:
@ -94,23 +152,52 @@ const FAILURE_MESSAGES = Object.freeze({
function providerFailureCode(error) {
if (error?.code !== 'harness-turn-failed') return null;
const value = error?.providerCode ?? error?.details?.providerCode;
if (typeof value !== 'string') return null;
return PROVIDER_FAILURES[value.trim().toUpperCase()] ?? null;
const raw = error?.providerCode ?? error?.details?.providerCode;
const key = typeof raw === 'string' ? raw.trim().toUpperCase() : '';
const mapped = key ? (PROVIDER_FAILURES[key] ?? null) : null;
// Specific provider codes win; PI_AI_ERROR/UNKNOWN are catch-alls and may still be TLS/transport.
if (mapped && key !== 'PI_AI_ERROR' && key !== 'UNKNOWN') return mapped;
if (isTransportProviderMessage(providerErrorMessage(error))) return 'MODEL_TRANSPORT';
return mapped;
}
function rpcMethod(error) {
return typeof error?.method === 'string' ? error.method.trim() : '';
}
/** Host branded codes use `family/name`; older builds may emit bare `internal`. */
function isHostInternalCode(code) {
return code === 'internal'
|| code === 'gateway/internal'
|| code.endsWith('/internal');
}
function isHostWorkspaceCode(code) {
return code.startsWith('workspace/')
|| code.startsWith('workspace-');
}
function isHostPresetCode(code) {
return code.startsWith('agent-preset/')
|| code.startsWith('agent-preset-');
}
function failureCode(error) {
const code = typeof error?.code === 'string' ? error.code : '';
const method = rpcMethod(error);
const providerCode = providerFailureCode(error);
if (providerCode) return providerCode;
// Transport wording can appear on non-turn errors (RPC/cause chain) too.
if (isTransportProviderMessage(providerErrorMessage(error))) return 'MODEL_TRANSPORT';
if (code === 'harness-connect-failed') {
return ['session.prompt', 'session.history'].includes(error?.method)
return ['session.prompt', 'session.history'].includes(method)
? 'HARNESS_RESULT_UNCERTAIN'
: 'HARNESS_CONNECT';
}
if (code === 'harness-timeout') {
return ['session.prompt', 'session.history'].includes(error?.method)
return ['session.prompt', 'session.history'].includes(method)
? 'HARNESS_RESULT_UNCERTAIN'
: 'HARNESS_TIMEOUT';
}
@ -124,16 +211,32 @@ function failureCode(error) {
return 'HARNESS_PROTOCOL';
}
if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
if (['harness-http-failed', 'harness-rpc-rejected'].includes(code)) return 'HARNESS_SERVICE';
// Branded Host failures (slash codes) before the generic internal catch-all.
if (isHostPresetCode(code)) return 'PRESET_UNAVAILABLE';
if (isHostWorkspaceCode(code)) return 'WORKSPACE_UNAVAILABLE';
if (code === 'session/workspace-attach-failed' || code === 'session/conflict') {
return 'SESSION_CREATE';
} // Host RPC catch-all — refine by method so field ops see workspace vs create vs generic.
if (
isHostInternalCode(code)
|| ['harness-http-failed', 'harness-rpc-rejected'].includes(code)
) {
if (method === 'workspace.create' || method === 'workspace.list') {
return 'WORKSPACE_UNAVAILABLE';
}
if (method === 'session.create') return 'SESSION_CREATE';
if (method.startsWith('agent-preset') || method.includes('preset')) {
return 'PRESET_UNAVAILABLE';
}
return 'HARNESS_SERVICE';
}
if (code === 'model-empty-response') return 'MODEL_EMPTY_REPLY';
if (code === 'model-max-tokens') return 'MODEL_OUTPUT_LIMIT';
if (code === 'turn-blocked') return 'TURN_BLOCKED';
if (['turn-interrupted', 'turn-aborted'].includes(code)) return 'TURN_INTERRUPTED';
if (code === 'session-not-found') return 'SESSION_NOT_FOUND';
if (MISSING_SESSION_CODES.has(code)) return 'SESSION_NOT_FOUND';
if (code === 'agent-busy') return 'SESSION_BUSY';
if (code === 'workspace-session-stale') return 'SESSION_STALE';
if (code.startsWith('workspace-')) return 'WORKSPACE_UNAVAILABLE';
if (code.startsWith('agent-preset-')) return 'PRESET_UNAVAILABLE';
if (code.startsWith('image-') || code.startsWith('inbound-file-')
|| code === 'attachment-error') return 'INPUT_INVALID';
@ -163,6 +266,89 @@ function failureCode(error) {
return 'INTERNAL_UNKNOWN';
}
function inferredFailureReason(error) {
return safeFailureReason(error?.providerCode)
?? safeFailureReason(error?.code)
?? safeFailureReason(error?.name);
}
const PERSISTED_FAILURE_CODES = new Set([
'INTERNAL_UNKNOWN',
'HARNESS_SERVICE',
'SESSION_CREATE',
'WORKSPACE_UNAVAILABLE',
'PRESET_UNAVAILABLE',
'HARNESS_CONNECT',
'HARNESS_TIMEOUT',
'HARNESS_ACCESS',
'HARNESS_PROTOCOL',
'MODEL_TRANSPORT',
]);
function redactedDetail(value, { max = 160 } = {}) {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? trimmed.slice(0, max) : null;
}
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'boolean') return value;
return null;
}
function diagnosticDetails(error) {
const details = error?.details;
if (!details || typeof details !== 'object' || Array.isArray(details)) return null;
const out = {};
for (const key of [
'path', 'workspaceId', 'sessionId', 'agentPreset', 'requestedPreset',
'existingPreset', 'requestedCwd', 'existingCwd', 'reason', 'name',
]) {
if (!Object.hasOwn(details, key)) continue;
const value = redactedDetail(details[key]);
if (value != null) out[key] = value;
}
return Object.keys(out).length > 0 ? out : null;
}
async function persistMessageFailureDiagnostic(failure, error) {
try {
const dir = join(homedir(), '.dsh', 'integrations', 'dsh-im-ops');
await mkdir(dir, { recursive: true });
const details = diagnosticDetails(error);
const workspaceHint = typeof error?.workspace === 'string'
? redactedDetail(error.workspace, { max: 240 })
: null;
const payload = {
at: new Date(failure.at).toISOString(),
referenceId: failure.referenceId,
failureCode: failure.code,
failureReason: failure.reason,
errorName: typeof error?.name === 'string' ? error.name.slice(0, 80) : null,
errorCode: typeof error?.code === 'string' ? error.code.slice(0, 80) : null,
providerCode: typeof error?.providerCode === 'string' ? error.providerCode.slice(0, 80) : null,
method: typeof error?.method === 'string' ? error.method.slice(0, 80) : null,
workspace: workspaceHint,
...(details ? { details } : {}),
messages: diagnosticMessages(error).map((text) => text.slice(0, 240)),
};
await writeFile(
join(dir, 'last-message-failure.json'),
`${JSON.stringify(payload, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 },
);
// Keep the legacy filename for INTERNAL_UNKNOWN so older runbooks still work.
if (failure.code === 'INTERNAL_UNKNOWN') {
await writeFile(
join(dir, 'last-internal-unknown.json'),
`${JSON.stringify(payload, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 },
);
}
} catch {
// Diagnostics must never mask the user-facing failure path.
}
}
function safeReferenceId(value) {
return typeof value === 'string' && /^[A-Z0-9-]{6,40}$/u.test(value)
? value
@ -180,17 +366,21 @@ export function classifyMessageFailure(error, {
referenceId,
at = Date.now(),
} = {}) {
const safeReason = safeFailureReason(reason);
const explicitReason = safeFailureReason(reason);
const classifiedCode = failureCode(error);
const code = classifiedCode === 'INTERNAL_UNKNOWN'
&& safeReason
&& explicitReason
&& typeof userMessage === 'string'
&& userMessage.trim()
? 'INPUT_INVALID'
: classifiedCode;
const methodReason = safeFailureReason(rpcMethod(error)?.replaceAll('.', '_'));
const safeReason = code === 'INTERNAL_UNKNOWN'
? (explicitReason ?? inferredFailureReason(error) ?? code)
: (explicitReason ?? methodReason ?? code);
return Object.freeze({
code,
reason: safeReason ?? code,
reason: safeReason,
message: typeof userMessage === 'string' && userMessage.trim()
? userMessage.trim()
: t(FAILURE_MESSAGES[code]),
@ -206,9 +396,18 @@ export function messageFailureText(failure) {
export function setLastMessageFailure(status, error, options) {
const failure = classifyMessageFailure(error, options);
status.lastMessageError = failure;
if (PERSISTED_FAILURE_CODES.has(failure.code)) {
void persistMessageFailureDiagnostic(failure, error);
}
return failure;
}
/** @internal exported for sessionExists / tests */
export function isMissingSessionError(error) {
const code = typeof error?.code === 'string' ? error.code : '';
return MISSING_SESSION_CODES.has(code);
}
export function clearLastMessageFailure(status) {
status.lastMessageError = null;
}

View file

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

View file

@ -1,4 +1,5 @@
import { withSessionBindingLock } from './session-binding-lock.mjs';
import { isMissingSessionError } from './message-failure.mjs';
import { initialSessionTitle } from './session-title.mjs';
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
@ -36,6 +37,69 @@ async function createSession(harness, options) {
: harness.createSession(options);
}
function configuredWorkspacePath(harness, createOptions) {
if (typeof createOptions?.workspace === 'string' && createOptions.workspace.trim()) {
return createOptions.workspace.trim();
}
if (typeof harness?.currentWorkspace === 'function') {
const value = harness.currentWorkspace();
if (typeof value === 'string' && value.trim()) return value.trim();
}
return null;
}
/**
* Prefer the chat's bound Session only when it still exists under the bot's
* configured workspace. Otherwise drop the binding and create a fresh Session
* (workspace + agent preset + default model are applied by createSession).
*/
async function boundSessionReusable({
session,
harness,
createOptions,
existsOptions,
}) {
if (!session) return false;
try {
if (!(await sessionExists(session, existsOptions))) return false;
} catch (error) {
if (error?.code === WORKSPACE_SESSION_STALE || existsOptions?.signal?.aborted) throw error;
if (isMissingSessionError(error)) return false;
// History probe failed with a non-missing code (often Host `internal` on a
// zombie binding). Recreate from bot settings instead of surfacing HARNESS_SERVICE.
console.warn(
'[dsh-im] bound Session probe failed; recreating from bot workspace settings:',
error?.message ?? error,
);
return false;
}
const workspacePath = configuredWorkspacePath(harness, createOptions);
if (!workspacePath) return true;
if (typeof harness?.sessionBelongsToWorkspace !== 'function') return true;
try {
const belongs = await harness.sessionBelongsToWorkspace(
session.sessionId,
workspacePath,
existsOptions,
);
if (!belongs) {
console.warn(
`[dsh-im] bound Session ${session.sessionId} is outside workspace ${workspacePath}; recreating`,
);
}
return belongs;
} catch (error) {
if (error?.code === WORKSPACE_SESSION_STALE || existsOptions?.signal?.aborted) throw error;
console.warn(
'[dsh-im] unable to verify Session workspace membership; recreating:',
error?.message ?? error,
);
return false;
}
}
/**
* Resolve, persist, and ask through a session that belongs to the bot's
* current workspace. A concurrent workspace switch invalidates the scoped
@ -67,7 +131,16 @@ export async function askInWorkspaceSession({
const binding = await withSessionBindingLock(state, key, async () => {
let sessionId = state.sessionFor(key);
let session = sessionId ? workspaceSession(harness, sessionId) : null;
if (!session || !(await sessionExists(session, existsOptions))) {
const reusable = await boundSessionReusable({
session,
harness,
createOptions,
existsOptions,
});
if (!reusable) {
if (sessionId && typeof state.clearSession === 'function') {
await state.clearSession(key);
}
sessionId = await createSession(harness, createOptions);
if (await state.setSession(key, sessionId) === false) return null;
session = workspaceSession(harness, sessionId);

View file

@ -22,6 +22,7 @@ import {
upsertAccessContact,
} from '../shared/access-grant.mjs';
import { isSharedLocalCommand } from '../shared/command-permission.mjs';
import { t } from '../shared/i18n.mjs';
import { isWhatsappLidJid } from './whatsapp-identity.mjs';
/**
@ -178,17 +179,17 @@ export async function tryHandleWhatsappApprovalReply({
}
await sendText(
{ jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` },
intent === 'approve' ? ACCESS_GRANT_COPY.adminApproved : ACCESS_GRANT_COPY.adminDenied,
t(intent === 'approve' ? ACCESS_GRANT_COPY.adminApproved : ACCESS_GRANT_COPY.adminDenied),
);
const requesterJid = resolved.phone
? `${resolved.phone}@s.whatsapp.net`
: (resolved.lid ?? null);
if (requesterJid) {
const body = intent === 'approve'
const body = t(intent === 'approve'
? (resolved.kind === 'group'
? ACCESS_GRANT_COPY.approvedGroup
: ACCESS_GRANT_COPY.approvedDirect)
: ACCESS_GRANT_COPY.denied;
: ACCESS_GRANT_COPY.denied);
try {
await sendText({ jid: requesterJid }, body);
} catch {
@ -201,7 +202,7 @@ export async function tryHandleWhatsappApprovalReply({
|| error?.code === 'pending-unresolved' || error?.code === 'pending-not-found') {
await sendText(
{ jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` },
error.message || '无法处理该审批。',
t(error.message || ACCESS_GRANT_COPY.processFailed),
);
return true;
}
@ -322,11 +323,11 @@ export async function gateWhatsappInbound({
await workspaces.setAccessGrant(botId, current);
}
const ack = !phone
const ack = t(!phone
? ACCESS_GRANT_COPY.pendingUnresolved
: (scene === 'group'
? ACCESS_GRANT_COPY.pendingAckGroup
: ACCESS_GRANT_COPY.pendingAckDirect);
: ACCESS_GRANT_COPY.pendingAckDirect));
try {
await sendText(message.replyTarget, ack);
} catch {
@ -335,7 +336,7 @@ export async function gateWhatsappInbound({
if (created) {
const admins = approverPhonesForPending(current, pending);
const body = formatPendingNotifyBody(pending);
const body = formatPendingNotifyBody(pending, t);
const refs = [];
for (const adminPhone of admins) {
// Never DM the linked bot account as if it were a peer when it is the only self-chat path.

View file

@ -7,7 +7,7 @@ import {
normalizeMessageContent,
} from '@whiskeysockets/baileys';
import { emptyAccessGrant, ensureGroupBucket, normalizeAccessPhone, phoneFromWhatsappJid, resolveAccessAgentPreset } from '../shared/access-grant.mjs';
import { emptyAccessGrant, ensureGroupBucket, normalizeAccessPhone, phoneFromWhatsappJid, resolveChatAgentPreset } from '../shared/access-grant.mjs';
import { splitMessageText } from '../shared/editable-message-stream.mjs';
import { t } from '../shared/i18n.mjs';
import { ImagePromptError } from '../shared/image-prompt.mjs';
@ -833,7 +833,8 @@ export class WhatsappRuntime {
resolveAgentPreset: (message) => {
if (!this.#workspaces || !this.#botId) return null;
const grant = this.#workspaces.accessGrantFor(this.#botId);
return resolveAccessAgentPreset(grant, {
// Member/group override, else WhatsApp workspace mapping — never Host global.
return resolveChatAgentPreset(grant, {
kind: message?.kind === 'group' ? 'group' : 'direct',
groupJid: message?.conversationId,
conversationId: message?.conversationId,
@ -842,7 +843,7 @@ export class WhatsappRuntime {
?? phoneFromWhatsappJid(message?.senderAlternateId)
?? normalizeAccessPhone(message?.senderAlternateId),
senderId: message?.senderId,
});
}, this.#workspaces.agentPresetFor(this.#botId));
},
});
const now = Date.now();

View file

@ -594,6 +594,101 @@ test('HarnessClient reads the nested workspace.create response used by DSH rc.6'
assert.deepEqual(methods, ['workspace.list', 'workspace.create']);
});
test('HarnessClient reuses Host workspaces when only Windows path casing differs', async (t) => {
if (process.platform !== 'win32') {
t.skip('Win32 path-case aliasing only applies on Windows');
return;
}
const methods = [];
t.mock.method(globalThis, 'fetch', async (_url, options) => {
const request = JSON.parse(options.body);
methods.push(request.method);
assert.notEqual(request.method, 'workspace.create');
const value = request.method === 'session.list'
? {
items: [{
sessionId: 'session-one',
cwd: 'd:\\ai_code\\dsh',
}],
}
: {
items: [{
workspaceId: 'workspace-existing',
path: 'd:\\ai_code\\dsh',
sessionIds: ['session-one'],
}],
archivedSessionIds: [],
};
return {
ok: true,
async json() {
return {
type: 'server-response',
rpcId: request.rpcId,
result: { ok: true, value },
};
},
};
});
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: 'D:\\ai_code\\dsh',
agentPreset: 'standard',
autostart: false,
dshBin: 'dsh',
});
assert.equal(await client.workspaceId(), 'workspace-existing');
assert.deepEqual(methods, ['workspace.list']);
assert.equal(
await client.sessionBelongsToWorkspace('session-one', 'D:\\ai_code\\dsh'),
true,
);
});
test('HarnessClient retries session.create without agentPreset after preset/internal failure', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/dsh-feishu-workspace',
agentPreset: 'broken-preset',
autostart: false,
dshBin: 'dsh',
});
const creates = [];
client.ensureRunning = async () => undefined;
client.rpc = async (method, payload) => {
if (method === 'workspace.list') {
return {
items: [{
workspaceId: 'workspace-one',
path: '/tmp/dsh-feishu-workspace',
sessionIds: [],
}],
archivedSessionIds: [],
};
}
if (method === 'session.create') {
creates.push(payload);
if (payload.agentPreset) {
const error = new Error('agent-presets: preset "broken-preset" not found');
error.code = 'agent-preset/not-found';
error.method = 'session.create';
throw error;
}
return { sessionId: 'session-recovered' };
}
throw new Error(`unexpected method ${method}`);
};
assert.equal(await client.createSession(), 'session-recovered');
assert.deepEqual(creates, [
{ workspaceId: 'workspace-one', agentPreset: 'broken-preset' },
{ workspaceId: 'workspace-one' },
]);
});
test('HarnessClient asks do not control file-return tool availability', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',

View file

@ -16,6 +16,7 @@ import {
resolveAccessPending,
upsertAccessContact,
resolveAccessAgentPreset,
resolveChatAgentPreset,
validateAccessGrant,
} from '../../../src/channels/shared/access-grant.mjs';
@ -191,7 +192,32 @@ test('quote approval helpers parse intent, pending id, and notify refs', () => {
pushName: 'Carol',
requestText: 'hi',
});
assert.match(body, /请求编号:p_abc123/);
assert.equal(parsePendingIdFromNotifyText(body), 'p_abc123');
const enBody = formatPendingNotifyBody({
id: 'p_abc123',
kind: 'direct',
phone: '8618888888888',
pushName: 'Carol',
requestText: 'hi',
}, (text, params) => {
const map = {
'[dsh-im-ops] 访问申请': '[dsh-im-ops] Access request',
'私聊': 'Direct chat',
'场景:{scene}': 'Scene: {scene}',
'申请人:{who}': 'Requester: {who}',
'请求编号:{id}': 'Request: {id}',
'原文:{text}': 'Original message: {text}',
'请引用本条消息回复「同意」或「拒绝」。': 'Quote this message…',
'未知': 'unknown',
};
const translated = map[text] ?? text;
if (params == null) return translated;
return translated.replace(/\{(\w+)\}/g, (match, name) =>
Object.hasOwn(params, name) ? String(params[name]) : match);
});
assert.match(enBody, /Request: p_abc123/);
assert.equal(parsePendingIdFromNotifyText(enBody), 'p_abc123');
const withRef = {
...grant(),
pending: [{
@ -208,7 +234,7 @@ test('quote approval helpers parse intent, pending id, and notify refs', () => {
});
test('resolveAccessAgentPreset prefers direct-member and group overrides over global', () => {
test('resolveAccessAgentPreset prefers direct-member and group overrides over channel mapping', () => {
const doc = grant({
directMembers: [
{ phone: '8618222222222', canExecuteCommands: true, agentPreset: 'ops-user' },
@ -241,6 +267,31 @@ test('resolveAccessAgentPreset prefers direct-member and group overrides over gl
}), null);
});
test('resolveChatAgentPreset falls back to bot/channel mapping, not Host global', () => {
const doc = grant({
directMembers: [
{ phone: '8618222222222', canExecuteCommands: true, agentPreset: 'ops-user' },
{ phone: '8618333333333', canExecuteCommands: true },
],
});
assert.equal(resolveChatAgentPreset(doc, {
kind: 'direct',
phone: '8618222222222',
}, 'wa-mapped'), 'ops-user');
assert.equal(resolveChatAgentPreset(doc, {
kind: 'direct',
phone: '8618333333333',
}, 'wa-mapped'), 'wa-mapped');
assert.equal(resolveChatAgentPreset(doc, {
kind: 'direct',
phone: '8618333333333',
}, null), null);
assert.equal(resolveChatAgentPreset(null, {
kind: 'direct',
phone: '8618333333333',
}, 'wa-mapped'), 'wa-mapped');
});
test('validateAccessGrant keeps optional agentPreset on members and groups', () => {
const doc = grant({
directMembers: [{ phone: '8618222222222', canExecuteCommands: false, agentPreset: 'desk' }],

View file

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

View file

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

View file

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

View file

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

View file

@ -40,6 +40,8 @@ test('message failures use verified turn-end provider codes without exposing pro
['STREAM_CLOSED', 'MODEL_STREAM'],
['EMPTY_RESPONSE', 'MODEL_EMPTY_REPLY'],
['CONTENT_FILTER', 'MODEL_CONTENT_REJECTED'],
['INVALID_REQUEST', 'MODEL_CONFIG'],
['PI_AI_ERROR', 'MODEL_SERVICE'],
]) {
const failure = classifyMessageFailure({
code: 'harness-turn-failed',
@ -59,11 +61,89 @@ test('message failures use verified turn-end provider codes without exposing pro
}, options).code, 'INTERNAL_UNKNOWN');
});
test('PI_AI_ERROR with TLS or transport wording maps to MODEL_TRANSPORT', () => {
for (const message of [
'unable to verify the first certificate',
'request to https://api.example.com failed, reason: UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'fetch failed: other side closed',
'Stream ended without finish_reason',
]) {
const failure = classifyMessageFailure({
code: 'harness-turn-failed',
providerCode: 'PI_AI_ERROR',
reason: { kind: 'error', failure: { code: 'PI_AI_ERROR', message } },
}, options);
assert.equal(failure.code, 'MODEL_TRANSPORT', message);
assert.match(failure.message, /无法连接模型服务/);
assert.doesNotMatch(JSON.stringify(failure), /certificate|UNABLE_TO|finish_reason|example\.com/);
}
assert.equal(classifyMessageFailure({
code: 'harness-turn-failed',
providerCode: 'PI_AI_ERROR',
reason: { kind: 'error', error: { code: 'PI_AI_ERROR', message: 'unable to verify the first certificate' } },
}, options).code, 'MODEL_TRANSPORT');
});
test('RPC internal and UNKNOWN provider codes no longer fall through to INTERNAL_UNKNOWN', () => {
assert.equal(classifyMessageFailure({
code: 'internal', method: 'session.create',
}, options).code, 'SESSION_CREATE');
assert.equal(classifyMessageFailure({
code: 'internal', method: 'workspace.create',
}, options).code, 'WORKSPACE_UNAVAILABLE');
assert.equal(classifyMessageFailure({
code: 'internal', method: 'session.prompt',
}, options).code, 'HARNESS_SERVICE');
assert.equal(classifyMessageFailure({
code: 'harness-turn-failed', providerCode: 'UNKNOWN',
}, options).code, 'MODEL_SERVICE');
assert.equal(classifyMessageFailure({
code: 'harness-turn-failed',
providerCode: 'UNKNOWN',
reason: { kind: 'error', error: { code: 'UNKNOWN', message: 'unable to verify the first certificate' } },
}, options).code, 'MODEL_TRANSPORT');
const plain = classifyMessageFailure(new Error('secret-shaped internal detail'), options);
assert.equal(plain.code, 'INTERNAL_UNKNOWN');
assert.equal(plain.reason, 'ERROR');
});
test('session.create failures tell operators to check the bot workspace path', () => {
const failure = classifyMessageFailure({
code: 'internal', method: 'session.create',
}, options);
assert.equal(failure.code, 'SESSION_CREATE');
assert.equal(failure.reason, 'SESSION_CREATE');
assert.match(failure.message, /工作区/);
});
test('Host branded gateway/internal on session.create maps to SESSION_CREATE', () => {
assert.equal(classifyMessageFailure({
code: 'gateway/internal', method: 'session.create',
message: 'failed to create session "session-x": Error: boom',
}, options).code, 'SESSION_CREATE');
});
test('Host branded workspace/invalid-path maps to WORKSPACE_UNAVAILABLE', () => {
assert.equal(classifyMessageFailure({
code: 'workspace/invalid-path', method: 'workspace.create',
message: 'cannot create a Workspace at "D:\\\\missing": ENOENT',
details: { path: 'D:\\missing' },
}, options).code, 'WORKSPACE_UNAVAILABLE');
});
test('Host branded agent-preset/not-found maps to PRESET_UNAVAILABLE', () => {
assert.equal(classifyMessageFailure({
code: 'agent-preset/not-found', method: 'session.create',
details: { agentPreset: 'gone' },
}, options).code, 'PRESET_UNAVAILABLE');
});
test('message failure text contains a safe code and traceable reference', () => {
const failure = classifyMessageFailure(new Error('secret-shaped internal detail'), options);
assert.deepEqual(failure, {
code: 'INTERNAL_UNKNOWN',
reason: 'INTERNAL_UNKNOWN',
reason: 'ERROR',
message: '任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
referenceId: 'MF-TEST01',
at: 123,

View file

@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createUpdateRpcHandler, installUpdateRpc } from '../plugin-src/host/update-rpc.mjs';
import { createUpdateRpcHandler, installUpdateRpc, HOST_LANGUAGE_SET_ENDPOINT } from '../plugin-src/host/update-rpc.mjs';
import { createImHostPlugin } from '../plugin-src/host/index.mjs';
import { getImHostLanguage, setImHostLanguage } from '../src/channels/shared/i18n.mjs';
test('update RPC rejects arbitrary commands, paths, profiles, sources and invalid request identifiers', async () => {
const calls = [];
@ -13,22 +14,53 @@ test('update RPC rejects arbitrary commands, paths, profiles, sources and invali
['update.install', { checkId: 'id', requestId: 'request', command: 'anything' }],
['update.install', { checkId: 'id', requestId: '; rm -rf /' }],
['update.install', { checkId: 'id', requestId: 'request', version: '9.0.0' }],
[HOST_LANGUAGE_SET_ENDPOINT, {}],
[HOST_LANGUAGE_SET_ENDPOINT, { language: 'en', extra: true }],
[HOST_LANGUAGE_SET_ENDPOINT, { language: '' }],
]) {
assert.equal((await handle(endpoint, payload)).error.code, 'bad-request');
}
assert.deepEqual(calls, []);
});
test('update RPC keeps local authority even when other management uses trusted-host', () => {
const calls = [];
test('host.language.set follows DSH locale ids and returns the normalized host language', async () => {
const handle = createUpdateRpcHandler({});
setImHostLanguage('zh');
try {
assert.deepEqual(await handle(HOST_LANGUAGE_SET_ENDPOINT, { language: 'en' }), {
ok: true, value: { language: 'en' },
});
assert.equal(getImHostLanguage(), 'en');
assert.deepEqual(await handle(HOST_LANGUAGE_SET_ENDPOINT, { language: 'zh-CN' }), {
ok: true, value: { language: 'zh' },
});
assert.equal(getImHostLanguage(), 'zh');
} finally {
setImHostLanguage('zh');
}
});
test('update RPC mounts on /dsh-im through webServer', () => {
const routes = [];
const service = { close: async () => {} };
const ctx = {
connection: { rpc: { handle: (...args) => calls.push(args) } },
effect: () => {},
connection: {
requestRejection: () => undefined,
},
webServer: {
register: (route) => {
routes.push(route);
return () => {};
},
},
effect: (factory) => {
const dispose = factory();
return typeof dispose === 'function' ? dispose : () => {};
},
};
installUpdateRpc(ctx, { service, runtime: {} });
assert.equal(calls[0][0], '/dsh-im');
assert.deepEqual(calls[0][2], { authority: 'loopback' });
assert.equal(routes[0]?.path, '/dsh-im');
assert.equal(routes[0]?.kind, 'prefix');
});
test('update RPC returns only safe codes for unanticipated runtime errors', async () => {

View file

@ -80,6 +80,96 @@ test('workspace asks collect result files without an explicit Gate', async () =>
assert.equal(typeof observed[0].onArtifact, 'function');
});
test('ask recreates when the bound Session is outside the configured workspace', async () => {
const calls = [];
const state = {
sessions: { chat: 'session-old-workspace' },
sessionFor(key) { return this.sessions[key] ?? null; },
async clearSession(key) { delete this.sessions[key]; },
async setSession(key, sessionId) {
this.sessions[key] = sessionId;
return true;
},
};
const harness = {
currentWorkspace: () => '/bot/workspace',
async sessionExists(sessionId) { return sessionId === 'session-old-workspace'; },
async sessionBelongsToWorkspace(sessionId, workspace) {
calls.push(['belongs', sessionId, workspace]);
return false;
},
async createSession(options) {
calls.push(['create', options]);
return 'session-new-workspace';
},
async ask(sessionId, text) {
calls.push(['ask', sessionId, text]);
return `ok:${sessionId}`;
},
};
assert.deepEqual(await askInWorkspaceSession({
harness,
state,
key: 'chat',
text: 'hello',
createOptions: { workspace: '/bot/workspace', agentPreset: 'netxops' },
}), {
sessionId: 'session-new-workspace',
answer: 'ok:session-new-workspace',
});
assert.deepEqual(calls, [
['belongs', 'session-old-workspace', '/bot/workspace'],
['create', { workspace: '/bot/workspace', agentPreset: 'netxops' }],
['ask', 'session-new-workspace', 'hello'],
]);
assert.equal(state.sessionFor('chat'), 'session-new-workspace');
});
test('ask recreates when the bound Session probe fails with a non-missing Host error', async () => {
const calls = [];
const state = {
sessions: { chat: 'session-zombie' },
sessionFor(key) { return this.sessions[key] ?? null; },
async clearSession(key) { delete this.sessions[key]; },
async setSession(key, sessionId) {
this.sessions[key] = sessionId;
return true;
},
};
const harness = {
async sessionExists(sessionId) {
calls.push(['exists', sessionId]);
const error = new Error('session.history: boom');
error.code = 'internal';
throw error;
},
async createSession() {
calls.push(['create']);
return 'session-fresh';
},
async ask(sessionId, text) {
calls.push(['ask', sessionId, text]);
return 'recovered';
},
};
assert.deepEqual(await askInWorkspaceSession({
harness,
state,
key: 'chat',
text: 'retry',
}), {
sessionId: 'session-fresh',
answer: 'recovered',
});
assert.deepEqual(calls, [
['exists', 'session-zombie'],
['create'],
['ask', 'session-fresh', 'retry'],
]);
});
test('BotWorkspaceStore persists the creation default and keeps bots isolated', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();