@xmanrui/dsh-im 4.20.2 → 4.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +21 -9
- package/README.md +21 -9
- package/lib/client.js +108 -21
- package/lib/index.js +276 -276
- package/package.json +17 -1
- package/plugin-src/client/channel-logos.js +18 -5
- package/plugin-src/client/channels/imessage/styles.js +1 -1
- package/plugin-src/client/channels/slack/styles.js +1 -1
- package/plugin-src/client/channels/weixin/connection-error.js +4 -1
- package/plugin-src/client/i18n.js +8 -0
- package/plugin-src/client/model-setting.js +4 -2
- package/plugin-src/client/session-channel-logos.js +1 -2
- package/plugin-src/client/styles.js +3 -2
- package/plugin-src/host/channels/qq/production.mjs +1 -1
- package/plugin-src/host/channels/qq/rpc.mjs +2 -1
- package/plugin-src/host/modern-harness-api.mjs +91 -3
- package/scripts/verify-model-setting.mjs +4 -1
- package/scripts/verify-package.mjs +3 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +1 -0
- package/src/channels/discord/discord-runtime.mjs +4 -1
- package/src/channels/feishu/bridge.mjs +9 -1
- package/src/channels/feishu/feishu-channel.mjs +1 -1
- package/src/channels/qq/qq-bridge.mjs +9 -1
- package/src/channels/qq/qq-controller.mjs +11 -5
- package/src/channels/qq/state-error.mjs +17 -0
- package/src/channels/qq/state-store.mjs +35 -9
- package/src/channels/shared/batch-input.mjs +22 -2
- package/src/channels/shared/bot-workspace-store.mjs +46 -26
- package/src/channels/shared/config-read-error.mjs +24 -0
- package/src/channels/shared/harness-client.mjs +13 -12
- package/src/channels/shared/harness-question.mjs +10 -2
- package/src/channels/shared/i18n-en/qq.mjs +3 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
- package/src/channels/shared/semantic/artifact.mjs +1 -1
- package/src/channels/shared/text-harness-bridge.mjs +198 -3
- package/src/channels/shared/token-config-store.mjs +23 -8
- package/src/channels/shared/workspace-session.mjs +7 -1
- package/src/channels/slack/slack-runtime.mjs +3 -1
- package/src/channels/telegram/telegram-api.mjs +62 -2
- package/src/channels/telegram/telegram-bridge.mjs +66 -1
- package/src/channels/telegram/telegram-rich-message.mjs +6 -4
- package/src/channels/telegram/telegram-runtime.mjs +128 -6
- package/src/channels/wecom/wecom-bridge.mjs +4 -0
- package/src/channels/wecom-app/config-store.mjs +3 -1
- package/src/channels/wecom-app/wecom-app-bridge.mjs +1 -0
- package/src/channels/weixin/config-store.mjs +24 -15
- package/src/channels/weixin/connection-error.en.mjs +21 -0
- package/src/channels/weixin/connection-error.mjs +21 -6
- package/src/channels/weixin/diagnostic-details.mjs +24 -1
- package/src/channels/weixin/weixin-bridge.mjs +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.21.0",
|
|
4
4
|
"description": "把十一种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect eleven IM channels and a public AI Office to a local DeepSeek Harness.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -64,6 +64,22 @@
|
|
|
64
64
|
{
|
|
65
65
|
"name": "lyzhu86",
|
|
66
66
|
"url": "https://github.com/lyzhu86"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "Librazy",
|
|
70
|
+
"url": "https://github.com/Librazy"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "Johnslee1207",
|
|
74
|
+
"url": "https://github.com/johnslee1207-commits"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "Dong09",
|
|
78
|
+
"url": "https://github.com/Dong09"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "wings1848",
|
|
82
|
+
"url": "https://github.com/wings1848"
|
|
67
83
|
}
|
|
68
84
|
],
|
|
69
85
|
"license": "MIT",
|
|
@@ -109,13 +109,26 @@ export function DiscordLogoGlyph({ size } = {}) {
|
|
|
109
109
|
export function SlackLogoGlyph({ size } = {}) {
|
|
110
110
|
return h('svg', {
|
|
111
111
|
...dimensions(size),
|
|
112
|
-
viewBox: '0 0
|
|
112
|
+
viewBox: '0 0 100 100',
|
|
113
113
|
focusable: 'false',
|
|
114
114
|
'aria-hidden': 'true',
|
|
115
115
|
'data-im-channel-logo': 'slack',
|
|
116
|
-
},
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
},
|
|
117
|
+
h('path', {
|
|
118
|
+
fill: '#36C5F0',
|
|
119
|
+
d: 'M36.5 0A10.5 10.5 0 0 0 36.5 21H47V10.5A10.5 10.5 0 0 0 36.5 0ZM10.5 26a10.5 10.5 0 0 0 0 21h26a10.5 10.5 0 0 0 0-21Z',
|
|
120
|
+
}),
|
|
121
|
+
h('path', {
|
|
122
|
+
fill: '#2EB67D',
|
|
123
|
+
d: 'M100 36.5A10.5 10.5 0 0 0 79 36.5V47h10.5A10.5 10.5 0 0 0 100 36.5ZM74 10.5a10.5 10.5 0 0 0-21 0v26a10.5 10.5 0 0 0 21 0Z',
|
|
124
|
+
}),
|
|
125
|
+
h('path', {
|
|
126
|
+
fill: '#ECB22E',
|
|
127
|
+
d: 'M63.5 100a10.5 10.5 0 0 0 0-21H53v10.5A10.5 10.5 0 0 0 63.5 100ZM89.5 74a10.5 10.5 0 0 0 0-21h-26a10.5 10.5 0 0 0 0 21Z',
|
|
128
|
+
}),
|
|
129
|
+
h('path', {
|
|
130
|
+
fill: '#E01E5A',
|
|
131
|
+
d: 'M0 63.5a10.5 10.5 0 0 0 21 0V53H10.5A10.5 10.5 0 0 0 0 63.5ZM26 89.5a10.5 10.5 0 0 0 21 0v-26a10.5 10.5 0 0 0-21 0Z',
|
|
119
132
|
}));
|
|
120
133
|
}
|
|
121
134
|
|
|
@@ -138,7 +151,7 @@ export function IMessageLogoGlyph({ size } = {}) {
|
|
|
138
151
|
'aria-hidden': 'true', 'data-im-channel-logo': 'imessage',
|
|
139
152
|
}, h('path', {
|
|
140
153
|
fill: 'currentColor',
|
|
141
|
-
d: 'M12 2.
|
|
154
|
+
d: 'M12 2.5C5.65 2.5.5 6.66.5 11.8c0 2.76 1.49 5.24 3.85 6.94.28 1.34-.35 2.76-1.6 4.01 2.1-.2 3.87-.86 5.26-1.85 1.25.4 2.6.62 3.99.62 6.35 0 11.5-4.35 11.5-9.72S18.35 2.5 12 2.5Z',
|
|
142
155
|
}));
|
|
143
156
|
}
|
|
144
157
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const IMESSAGE_STYLE_ID = 'xmanrui-dsh-im-imessage-settings';
|
|
2
2
|
const CSS = String.raw`
|
|
3
3
|
.dim-pageIMessage { --ddt-accent: #32a852; --ddt-accent-deep: #248a40; --ddt-accent-wash: #edf9f0; }
|
|
4
|
-
.dim-avatarIMessage { color: #fff; background: #
|
|
4
|
+
.dim-avatarIMessage { color: #fff; background: linear-gradient(180deg, #5bf675 0%, #28d944 50%, #0fbd2c 100%); box-shadow: inset 0 1px 1px rgb(255 255 255 / 45%), 0 1px 3px rgb(31 35 41 / 12%); }
|
|
5
5
|
.dim-avatarIMessage svg { display: block; }
|
|
6
6
|
.dim-imessagePermissionPanel { padding: 20px; }
|
|
7
7
|
.dim-imessagePermissionPanel p { color: var(--dsw-alias-label-secondary, #646a73); }
|
|
@@ -2,7 +2,7 @@ export const SLACK_STYLE_ID = 'xmanrui-dsh-im-slack-settings';
|
|
|
2
2
|
|
|
3
3
|
const CSS = String.raw`
|
|
4
4
|
.dsl-page { --ddt-accent: #4a154b; --ddt-accent-deep: #321033; --ddt-accent-wash: #f7eef7; }
|
|
5
|
-
.dsl-avatar {
|
|
5
|
+
.dsl-avatar { background: linear-gradient(145deg, #fff, #f8fafb); border: 1px solid #e5e6eb; }
|
|
6
6
|
.dsl-avatar svg { display: block; }
|
|
7
7
|
.dsl-setup { display: grid; gap: 18px; }
|
|
8
8
|
.dsl-guide { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 18px; padding: 16px; border: 1px solid color-mix(in srgb, #4a154b 18%, var(--dsw-alias-border-l2, #e5e6eb)); border-radius: 11px; background: color-mix(in srgb, #4a154b 4%, var(--dsw-alias-bg-layer-1, #fff)); }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { h, localizeText } from '../../i18n.js';
|
|
3
3
|
import { normalizeConnectionError } from './api.js';
|
|
4
|
+
import { CONFIG_ISSUE_LABELS } from '../../../../src/channels/weixin/diagnostic-details.mjs';
|
|
4
5
|
|
|
5
6
|
const STAGE_LABELS = {
|
|
6
7
|
'startup.load': '加载微信配置', 'qr.begin': '申请二维码', 'qr.encode': '生成二维码图片', 'qr.poll': '查询扫码状态',
|
|
@@ -27,7 +28,7 @@ export function formatWeixinDiagnostic(value) {
|
|
|
27
28
|
return [
|
|
28
29
|
localizeText(error.message), details.hint ? localizeText(details.hint) : null,
|
|
29
30
|
localizeText('错误码') + ': ' + error.code,
|
|
30
|
-
...['operation', 'stage', 'reason', 'httpStatus', 'providerCode', 'resource', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
|
|
31
|
+
...['operation', 'stage', 'reason', 'httpStatus', 'providerCode', 'resource', 'file', 'field', 'issue', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
|
|
31
32
|
.filter(field => details[field] !== undefined)
|
|
32
33
|
.map(field => `${field}: ${details[field]}`),
|
|
33
34
|
].filter(Boolean).join('\n');
|
|
@@ -48,6 +49,8 @@ export function WeixinConnectionError({ error: value, warning = false }) {
|
|
|
48
49
|
const fields = [
|
|
49
50
|
['错误码', error.code], ['失败阶段', localizeText(STAGE_LABELS[details.stage] ?? details.stage ?? '')],
|
|
50
51
|
['底层原因', details.reason], ['HTTP 状态', details.httpStatus], ['微信返回码', details.providerCode],
|
|
52
|
+
['配置文件', details.file], ['配置字段', details.field],
|
|
53
|
+
['校验原因', details.issue ? localizeText(CONFIG_ISSUE_LABELS[details.issue]) : undefined],
|
|
51
54
|
['参考号', details.referenceId], ['发生时间', details.occurredAt], ['插件版本', details.pluginVersion],
|
|
52
55
|
].filter(([, text]) => text !== undefined && text !== '');
|
|
53
56
|
return h('div', { className: 'dxw-summary dim-cardSummary', 'data-weixin-diagnostic': true, role: warning ? 'status' : undefined },
|
|
@@ -1020,6 +1020,14 @@ function channelName(value) {
|
|
|
1020
1020
|
}
|
|
1021
1021
|
|
|
1022
1022
|
function translateDynamic(text) {
|
|
1023
|
+
const configMessage = /^微信配置格式错误:(config\.json|workspaces\.json)。请查看诊断详情,修复后重启 DSH。$/.exec(text);
|
|
1024
|
+
if (configMessage) return EN['微信配置格式错误:{file}。请查看诊断详情,修复后重启 DSH。'].replace('{file}', configMessage[1]);
|
|
1025
|
+
const configHint = /^(.*?)请检查微信渠道数据目录中的 (config\.json|workspaces\.json),修复后重启 DSH;“重新读取”不会重新加载配置。\s*(字段位置中的序号从 0 开始,按文件中的条目顺序计数,不包含真实账号标识。)?$/.exec(text);
|
|
1026
|
+
if (configHint) return [
|
|
1027
|
+
localizeText(configHint[1].trim()),
|
|
1028
|
+
EN['请检查微信渠道数据目录中的 {file},修复后重启 DSH;“重新读取”不会重新加载配置。'].replace('{file}', configHint[2]),
|
|
1029
|
+
configHint[3] ? localizeText(configHint[3]) : '',
|
|
1030
|
+
].filter(Boolean).join(' ');
|
|
1023
1031
|
const guidanceLimit = /^增强提示词不得超过 (\d+) 个字符。$/.exec(text);
|
|
1024
1032
|
if (guidanceLimit) return `Guidance must not exceed ${guidanceLimit[1]} characters.`;
|
|
1025
1033
|
let match = /^(\d+) \/ (\d+) 在线$/.exec(text);
|
|
@@ -141,6 +141,8 @@ export function ModelEditor({ model = null, disabled = false, onSave }) {
|
|
|
141
141
|
'aria-label': '查看模型设置说明', 'aria-describedby': `${id}-help` },
|
|
142
142
|
h('span', { 'aria-hidden': true }, '?')),
|
|
143
143
|
h('span', { id: `${id}-help`, className: 'dim-presetTooltip', role: 'tooltip' },
|
|
144
|
+
effortHint && !effortUnavailable
|
|
145
|
+
? h('span', { id: `${id}-hint` }, effortHint, ' ') : null,
|
|
144
146
|
'只影响新建会话;若当前聊天已有会话,先发送 /new,再发送普通消息生效。'))),
|
|
145
147
|
saving ? h('span', { className: 'dim-presetStatus', role: 'status' }, '保存中…') : null),
|
|
146
148
|
row('model', '模型', entry?.name ?? (current ? modelSelectionId(current) : localizeText('跟随默认模型'))),
|
|
@@ -163,8 +165,8 @@ export function ModelEditor({ model = null, disabled = false, onSave }) {
|
|
|
163
165
|
...(reasoning?.efforts ?? []).map((level) => option(`effort:${level.id}`, level.name, level.description,
|
|
164
166
|
effort === level.id, { ...current, reasoningEffort: level.id })),
|
|
165
167
|
]) : null,
|
|
166
|
-
effortHint ? h('p', { id: `${id}-hint`, className: 'dim-modelHint',
|
|
167
|
-
role:
|
|
168
|
+
effortHint && effortUnavailable ? h('p', { id: `${id}-hint`, className: 'dim-modelHint',
|
|
169
|
+
role: 'status' }, effortHint) : null,
|
|
168
170
|
error || !currentAvailable ? h('p', { className: 'dim-presetError', role: error ? 'alert' : 'status' },
|
|
169
171
|
error ?? '当前模型已不可用,请选择其他模型或跟随默认模型。') : null);
|
|
170
172
|
}
|
|
@@ -26,8 +26,7 @@ const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
|
26
26
|
const installations = new WeakMap();
|
|
27
27
|
|
|
28
28
|
function hasClassPart(element, part) {
|
|
29
|
-
|
|
30
|
-
return [...element.classList].some((token) => match.test(token));
|
|
29
|
+
return [...element.classList].some((token) => token.split(/[_-]/u).includes(part));
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
function titleOf(row) {
|
|
@@ -126,10 +126,11 @@ const CSS = String.raw`
|
|
|
126
126
|
.dim-logoOffice svg { width: 23px; height: 23px; }
|
|
127
127
|
.dim-logoDiscord { color: white; background: #5865f2; }
|
|
128
128
|
.dim-logoDiscord svg { width: 21px; height: 21px; }
|
|
129
|
-
.dim-logoSlack {
|
|
129
|
+
.dim-logoSlack { background: linear-gradient(145deg, #fff, #f8fafb); border: 1px solid #e5e6eb; }
|
|
130
130
|
.dim-logoSlack svg { width: 21px; height: 21px; }
|
|
131
131
|
.dim-logoWhatsapp { color: white; background: #25d366; }
|
|
132
|
-
.dim-logoIMessage { color: white; background: #
|
|
132
|
+
.dim-logoIMessage { color: white; background: linear-gradient(180deg, #5bf675 0%, #28d944 50%, #0fbd2c 100%); box-shadow: inset 0 1px 1px rgb(255 255 255 / 45%), 0 1px 3px rgb(31 35 41 / 12%); }
|
|
133
|
+
.dim-logoIMessage svg { width: 23px; height: 23px; }
|
|
133
134
|
.dim-logoWhatsapp svg { width: 21px; height: 21px; }
|
|
134
135
|
.dim-channelCopy { min-width: 0; display: grid; }
|
|
135
136
|
.dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
|
|
@@ -74,7 +74,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
74
74
|
const stateFor = async (botId) => {
|
|
75
75
|
let state = stateStores.get(botId);
|
|
76
76
|
if (!state) {
|
|
77
|
-
state = await new StateStore(statePath(botId)).load();
|
|
77
|
+
state = await new StateStore(statePath(botId), { logger }).load();
|
|
78
78
|
stateStores.set(botId, state);
|
|
79
79
|
}
|
|
80
80
|
return state;
|
|
@@ -9,6 +9,7 @@ import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from
|
|
|
9
9
|
import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
|
|
10
10
|
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
|
11
11
|
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
|
12
|
+
import { publicQqStateError } from '../../../../src/channels/qq/state-error.mjs';
|
|
12
13
|
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
|
13
14
|
import { SET_MODEL_ENDPOINT, validModelPayload } from '../shared/model-setting-rpc.mjs';
|
|
14
15
|
|
|
@@ -232,7 +233,7 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
|
|
232
233
|
const workspaceError = publicWorkspaceError(error);
|
|
233
234
|
return signal?.aborted
|
|
234
235
|
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
|
|
235
|
-
: { ok: false, error: workspaceError
|
|
236
|
+
: { ok: false, error: publicQqStateError(error) ?? workspaceError
|
|
236
237
|
?? { code: 'qq-operation-failed', message: 'QQ 操作失败,请稍后重试。' } };
|
|
237
238
|
}
|
|
238
239
|
};
|
|
@@ -474,13 +474,97 @@ class ModernHarnessApi {
|
|
|
474
474
|
#requestQuestion(request, next) {
|
|
475
475
|
const owner = this.#claimableAgent(request?.agent);
|
|
476
476
|
if (!owner) return next();
|
|
477
|
-
|
|
477
|
+
const turnSignal = request.signal;
|
|
478
|
+
if (turnSignal?.aborted) {
|
|
478
479
|
return Promise.reject(questionError(
|
|
479
480
|
'ask_user_question was aborted before the user answered', 'ASK_ABORTED',
|
|
480
481
|
));
|
|
481
482
|
}
|
|
482
|
-
|
|
483
|
-
|
|
483
|
+
// Give this question a lifetime of its own instead of lending it the enclosing
|
|
484
|
+
// turn's. `dsh-agent-loop` mints one abort controller per turn and every tool
|
|
485
|
+
// call in that turn shares the one signal, and under PTC two consumers of it
|
|
486
|
+
// react to the abort EVENT rather than to `signal.aborted`: `run_code` flips its
|
|
487
|
+
// own program controller from an abort listener (dsh-tools `onOuterAbort`) and
|
|
488
|
+
// the worker code runtime retires the worker the same way
|
|
489
|
+
// (dsh-code-runtime-worker-thread `onAbort`). Retiring the Web card by firing
|
|
490
|
+
// that event therefore also killed the program that was merely waiting for this
|
|
491
|
+
// answer — `code run failed (abort)`. A private controller keeps the retirement
|
|
492
|
+
// below local: real upstream cancellation is forwarded into it, and every
|
|
493
|
+
// listener on this waterfall — the Remote/Web forwarder that renders the card,
|
|
494
|
+
// and our own IM pending — sees a lifetime that ends with this question rather
|
|
495
|
+
// than with the turn.
|
|
496
|
+
//
|
|
497
|
+
// Handing it downstream means replacing `request.signal`, because the Cordis
|
|
498
|
+
// waterfall passes the same request object to every remaining listener and
|
|
499
|
+
// `next()` takes no replacement arguments. The object is a per-call copy made by
|
|
500
|
+
// `UserQuestionService.ask`, and the signal it carried stays reachable here as
|
|
501
|
+
// `turnSignal`, so the caller's own view of the request is untouched.
|
|
502
|
+
const question = new AbortController();
|
|
503
|
+
const forwardTurnAbort = () => question.abort(turnSignal?.reason);
|
|
504
|
+
turnSignal?.addEventListener('abort', forwardTurnAbort, { once: true });
|
|
505
|
+
request.signal = question.signal;
|
|
506
|
+
// Race the IM answer against the host's own answerers (Web/CLI). Claiming the
|
|
507
|
+
// request exclusively used to keep the question out of Web entirely, leaving
|
|
508
|
+
// whoever happened to be looking at that Session with a card they could not
|
|
509
|
+
// answer. Racing keeps both surfaces usable: the first answer wins.
|
|
510
|
+
const im = this.#askThroughIm(request, owner);
|
|
511
|
+
let answeredByIm = false;
|
|
512
|
+
const native = Promise.resolve()
|
|
513
|
+
.then(() => next())
|
|
514
|
+
.catch((error) => {
|
|
515
|
+
// A pure-IM session has no client connected, so the host rejects with
|
|
516
|
+
// NO_PROVIDER — an expected state rather than a failure — and ASK_ABORTED
|
|
517
|
+
// is this adapter's own retire signal below. Any other error is real, so
|
|
518
|
+
// log it instead of swallowing it silently; the question still stays open
|
|
519
|
+
// for IM rather than being failed outright.
|
|
520
|
+
if (error?.code !== 'NO_PROVIDER' && error?.code !== 'ASK_ABORTED') {
|
|
521
|
+
console.warn(
|
|
522
|
+
'[dsh-im] the host user-question answerer failed; waiting for the IM answer:',
|
|
523
|
+
error?.message ?? error,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
return new Promise(() => {});
|
|
527
|
+
});
|
|
528
|
+
return Promise.race([
|
|
529
|
+
native,
|
|
530
|
+
im.promise.then((value) => {
|
|
531
|
+
answeredByIm = true;
|
|
532
|
+
return value;
|
|
533
|
+
}),
|
|
534
|
+
]).finally(() => {
|
|
535
|
+
// Losing side cleanup. A host answer already settled this request, so drop
|
|
536
|
+
// the IM pending: leaving it registered would let a late press answer a
|
|
537
|
+
// question the model has moved past. Settling is idempotent.
|
|
538
|
+
im.dismiss(new Error('another client answered this question'));
|
|
539
|
+
turnSignal?.removeEventListener('abort', forwardTurnAbort);
|
|
540
|
+
if (answeredByIm) {
|
|
541
|
+
// The host's own answerer (DSH Web) keeps its question card until its own
|
|
542
|
+
// pending settles, and a host-side adapter has no other handle on it. A Web
|
|
543
|
+
// client holds that pending through the lifetime this question handed
|
|
544
|
+
// downstream, so ending that lifetime makes the client drop a card whose
|
|
545
|
+
// answer was already given on IM — otherwise it keeps offering choices for a
|
|
546
|
+
// question the model has moved past.
|
|
547
|
+
//
|
|
548
|
+
// Ending it is `abort`, not a dispatched `abort` event: a listener that
|
|
549
|
+
// reads `signal.aborted` must see the same thing one that listens for the
|
|
550
|
+
// event does. Either way it reaches only this question's controller, so the
|
|
551
|
+
// turn and every other tool call in it — a waiting `run_code` program above
|
|
552
|
+
// all — keep running. The reason carries `ASK_ABORTED` so the host answerer
|
|
553
|
+
// rejecting alongside us stays an expected retirement rather than a logged
|
|
554
|
+
// failure.
|
|
555
|
+
question.abort(questionError('the question was answered on IM', 'ASK_ABORTED'));
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Present one question to IM clients. Returns the answer promise plus a
|
|
562
|
+
* `dismiss` used to retire the pending when the host answers first.
|
|
563
|
+
*/
|
|
564
|
+
#askThroughIm(request, owner) {
|
|
565
|
+
let pending = null;
|
|
566
|
+
const promise = new Promise((resolve, reject) => {
|
|
567
|
+
pending = {
|
|
484
568
|
rpcId: randomUUID(),
|
|
485
569
|
sessionId: owner.sessionId,
|
|
486
570
|
questions: request.questions,
|
|
@@ -505,6 +589,10 @@ class ModernHarnessApi {
|
|
|
505
589
|
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
506
590
|
this.#broadcast(this.#questionFrame(pending).payload, pending.rpcId);
|
|
507
591
|
});
|
|
592
|
+
return {
|
|
593
|
+
promise,
|
|
594
|
+
dismiss: (reason) => pending?.settle('cancelled', reason),
|
|
595
|
+
};
|
|
508
596
|
}
|
|
509
597
|
|
|
510
598
|
#approvalFrame(pending) {
|
|
@@ -22,12 +22,15 @@ const built = await build({
|
|
|
22
22
|
define: { 'process.env.NODE_ENV': '"development"' },
|
|
23
23
|
});
|
|
24
24
|
const htmlPath = join(output, 'preview.html');
|
|
25
|
+
const scriptOpen = '<scr' + 'ipt>';
|
|
26
|
+
const scriptClose = '</scr' + 'ipt>';
|
|
27
|
+
const scriptBody = built.outputFiles[0].text.replace(/<\/script/giu, '<\\/script');
|
|
25
28
|
await writeFile(htmlPath, `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>DSH-IM Model Settings</title>
|
|
26
29
|
<style>
|
|
27
30
|
body{margin:0;padding:24px;background:#fff;color:#1f2329;font:14px/20px -apple-system,BlinkMacSystemFont,sans-serif}#app{max-width:760px;margin:auto}#result{max-width:760px;margin:18px auto;font:11px/17px monospace;white-space:pre-wrap;color:#687380}
|
|
28
31
|
@media(max-width:600px){body{padding:12px}}
|
|
29
32
|
@media(prefers-color-scheme:dark){:root{color-scheme:dark;--dsw-alias-bg-layer-1:#202125;--dsw-alias-bg-layer-3:#282a30;--dsw-specific-menu:#282a30;--dsw-alias-bg-module-platform:#25272c;--dsw-alias-label-primary:#e9ebef;--dsw-alias-label-secondary:#b1b5bf;--dsw-alias-label-tertiary:#9a9faa;--dsw-alias-border-l1:#34363c;--dsw-alias-border-l2:#41434c;--dsw-alias-border-l3:#7b9ff9;--dsw-alias-interactive-bg-hover:#353842}body{background:#202125;color:#e9ebef}}
|
|
30
|
-
</style><body><div id="app"></div><pre id="result">Running browser checks…</pre
|
|
33
|
+
</style><body><div id="app"></div><pre id="result">Running browser checks…</pre>${scriptOpen}${scriptBody}${scriptClose}</body></html>`);
|
|
31
34
|
for (const [name, size, extra, query] of [
|
|
32
35
|
['desktop', '1100,1100', [], ''],
|
|
33
36
|
['mobile', '390,1250', [], '?mobile'],
|
|
@@ -238,7 +238,9 @@ if (/(?:from\s*|import\s*\(|require\s*\()\s*["'](?:@larksuiteoapi\/node-sdk|@whi
|
|
|
238
238
|
if (!/(?:from\s*|import\s*\()\s*["']undici["']/.test(host)) {
|
|
239
239
|
throw new Error('host bundle must retain undici as an external runtime dependency');
|
|
240
240
|
}
|
|
241
|
-
if ((executable.mode & 0o111) === 0)
|
|
241
|
+
if (process.platform !== 'win32' && (executable.mode & 0o111) === 0) {
|
|
242
|
+
throw new Error('dsh-im CLI is not executable');
|
|
243
|
+
}
|
|
242
244
|
if (/private-bot-token|must-be-rolled-back|DEEPSEEK_API_KEY=/.test(client + host)) {
|
|
243
245
|
throw new Error('built artifacts contain a test or environment secret marker');
|
|
244
246
|
}
|
|
@@ -66,7 +66,10 @@ function gatewayCloseError(code) {
|
|
|
66
66
|
|
|
67
67
|
function stripBotMention(text, botId) {
|
|
68
68
|
if (typeof text !== 'string') return '';
|
|
69
|
-
return text
|
|
69
|
+
return text
|
|
70
|
+
.split(`<@${botId}>`).join('')
|
|
71
|
+
.split(`<@!${botId}>`).join('')
|
|
72
|
+
.trim();
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
function cleanThreadName(message, botId) {
|
|
@@ -1037,12 +1037,16 @@ export class FeishuHarnessBridge {
|
|
|
1037
1037
|
if (result.kind === 'submit') {
|
|
1038
1038
|
const submissionEvent = {
|
|
1039
1039
|
...event,
|
|
1040
|
-
batchSubmission: { token: result.token },
|
|
1040
|
+
batchSubmission: { token: result.token, title: result.title },
|
|
1041
1041
|
message: {
|
|
1042
1042
|
...event.message,
|
|
1043
1043
|
message_type: 'text',
|
|
1044
1044
|
content: JSON.stringify({ text: result.prompt }),
|
|
1045
1045
|
mentions: [],
|
|
1046
|
+
// The submission is exactly the collected text, so quoting the
|
|
1047
|
+
// message that carried /send must not attach it to the batch.
|
|
1048
|
+
parent_id: undefined,
|
|
1049
|
+
root_id: undefined,
|
|
1046
1050
|
},
|
|
1047
1051
|
};
|
|
1048
1052
|
return this.#enqueueMessage(
|
|
@@ -4959,6 +4963,7 @@ export class FeishuHarnessBridge {
|
|
|
4959
4963
|
key,
|
|
4960
4964
|
text,
|
|
4961
4965
|
content,
|
|
4966
|
+
titleText: event.batchSubmission?.title,
|
|
4962
4967
|
contextEnhanced,
|
|
4963
4968
|
createOptions: { signal: this.#signal },
|
|
4964
4969
|
existsOptions: { signal: this.#signal },
|
|
@@ -5201,6 +5206,7 @@ export class FeishuHarnessBridge {
|
|
|
5201
5206
|
key,
|
|
5202
5207
|
text,
|
|
5203
5208
|
content,
|
|
5209
|
+
titleText: event.batchSubmission?.title,
|
|
5204
5210
|
contextEnhanced,
|
|
5205
5211
|
createOptions: { signal: this.#signal },
|
|
5206
5212
|
existsOptions: { signal: this.#signal },
|
|
@@ -5280,6 +5286,7 @@ export class FeishuHarnessBridge {
|
|
|
5280
5286
|
key,
|
|
5281
5287
|
text,
|
|
5282
5288
|
content,
|
|
5289
|
+
titleText: event.batchSubmission?.title,
|
|
5283
5290
|
contextEnhanced,
|
|
5284
5291
|
createOptions: { signal: this.#signal },
|
|
5285
5292
|
existsOptions: { signal: this.#signal },
|
|
@@ -5350,6 +5357,7 @@ export class FeishuHarnessBridge {
|
|
|
5350
5357
|
key,
|
|
5351
5358
|
text,
|
|
5352
5359
|
content,
|
|
5360
|
+
titleText: event.batchSubmission?.title,
|
|
5353
5361
|
contextEnhanced,
|
|
5354
5362
|
createOptions: { signal: this.#signal },
|
|
5355
5363
|
existsOptions: { signal: this.#signal },
|
|
@@ -576,7 +576,7 @@ export class VerifiedFeishuChannel {
|
|
|
576
576
|
assertApiSuccess('Feishu message delete', response);
|
|
577
577
|
return true;
|
|
578
578
|
} catch (error) {
|
|
579
|
-
console.warn(
|
|
579
|
+
console.warn('[bridge] unable to recall message:', label, error.message);
|
|
580
580
|
return false;
|
|
581
581
|
}
|
|
582
582
|
}
|
|
@@ -570,7 +570,14 @@ export class QqHarnessBridge {
|
|
|
570
570
|
if (result.handled) {
|
|
571
571
|
if (result.kind === 'submit') {
|
|
572
572
|
return this.#enqueueMessage(
|
|
573
|
-
{
|
|
573
|
+
{
|
|
574
|
+
...message,
|
|
575
|
+
content: result.prompt,
|
|
576
|
+
// The submission is exactly the collected text: the command
|
|
577
|
+
// message's own attachments and quoted message are not part of it.
|
|
578
|
+
attachments: [],
|
|
579
|
+
refMsgIdx: undefined,
|
|
580
|
+
},
|
|
574
581
|
messageId,
|
|
575
582
|
key,
|
|
576
583
|
{ batchSubmission: result },
|
|
@@ -1038,6 +1045,7 @@ export class QqHarnessBridge {
|
|
|
1038
1045
|
key,
|
|
1039
1046
|
text,
|
|
1040
1047
|
content,
|
|
1048
|
+
titleText: batchSubmission?.title,
|
|
1041
1049
|
contextEnhanced,
|
|
1042
1050
|
createOptions: { signal: this.#signal },
|
|
1043
1051
|
existsOptions: { signal: this.#signal },
|
|
@@ -4,6 +4,7 @@ import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
|
4
4
|
import { t } from '../shared/i18n.mjs';
|
|
5
5
|
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
|
6
6
|
import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
|
|
7
|
+
import { publicQqStateError } from './state-error.mjs';
|
|
7
8
|
|
|
8
9
|
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
|
|
9
10
|
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
|
|
@@ -17,6 +18,10 @@ function safeError(code, message) {
|
|
|
17
18
|
return Object.freeze({ code, message });
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
function connectionError(error, message) {
|
|
22
|
+
return publicQqStateError(error) ?? safeError('connection-failed', message);
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
function publicAttempt(record) {
|
|
21
26
|
if (!record) return null;
|
|
22
27
|
return {
|
|
@@ -86,7 +91,7 @@ export class QqController {
|
|
|
86
91
|
await this.#startRuntime(config, appSecret);
|
|
87
92
|
this.#errors.delete(config.botId);
|
|
88
93
|
} catch (error) {
|
|
89
|
-
this.#errors.set(config.botId,
|
|
94
|
+
this.#errors.set(config.botId, connectionError(error, t('QQ 连接未就绪,插件会自动重试。')));
|
|
90
95
|
this.#logger.warn?.(`[dsh-im:qq] bot ${config.botId} failed to initialize:`, error);
|
|
91
96
|
} finally {
|
|
92
97
|
this.#touch();
|
|
@@ -209,7 +214,7 @@ export class QqController {
|
|
|
209
214
|
await this.#startRuntime(config, normalizedSecret);
|
|
210
215
|
this.#errors.delete(identity.botId);
|
|
211
216
|
} catch (error) {
|
|
212
|
-
this.#errors.set(identity.botId,
|
|
217
|
+
this.#errors.set(identity.botId, connectionError(error, t('QQ 机器人已绑定,消息连接暂未就绪。')));
|
|
213
218
|
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} credential connection failed:`, error);
|
|
214
219
|
}
|
|
215
220
|
this.#touch();
|
|
@@ -242,7 +247,7 @@ export class QqController {
|
|
|
242
247
|
await this.#startRuntime(config, secret);
|
|
243
248
|
this.#errors.delete(botId);
|
|
244
249
|
} catch (error) {
|
|
245
|
-
this.#errors.set(botId,
|
|
250
|
+
this.#errors.set(botId, connectionError(error, t('QQ 连接仍未就绪,请稍后重试。')));
|
|
246
251
|
throw error;
|
|
247
252
|
} finally {
|
|
248
253
|
this.#touch();
|
|
@@ -326,7 +331,8 @@ export class QqController {
|
|
|
326
331
|
health: {
|
|
327
332
|
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
|
328
333
|
summary: connected ? t('QQ WebSocket 长连接运行正常')
|
|
329
|
-
:
|
|
334
|
+
: this.#errors.get(config.botId)?.message
|
|
335
|
+
?? (state === 'error' ? t('QQ 连接未就绪,插件会自动重试') : t('QQ 连接当前离线')),
|
|
330
336
|
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
|
331
337
|
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
|
|
332
338
|
},
|
|
@@ -434,7 +440,7 @@ export class QqController {
|
|
|
434
440
|
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
435
441
|
throw error;
|
|
436
442
|
}
|
|
437
|
-
this.#errors.set(identity.botId,
|
|
443
|
+
this.#errors.set(identity.botId, connectionError(error, t('QQ 机器人已绑定,消息连接暂未就绪。')));
|
|
438
444
|
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} activation connection failed:`, error);
|
|
439
445
|
}
|
|
440
446
|
this.#touch();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { t } from '../shared/i18n.mjs';
|
|
2
|
+
|
|
3
|
+
const MESSAGES = Object.freeze({
|
|
4
|
+
'state-read-failed': '无法读取 QQ 本地状态,请检查数据目录及访问权限。',
|
|
5
|
+
'state-backup-failed': 'QQ 本地状态已损坏,但无法备份,原文件已保留。',
|
|
6
|
+
'state-write-failed': '无法保存 QQ 本地状态,请检查磁盘空间及目录写入权限。',
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export function qqStateError(code, cause) {
|
|
10
|
+
return Object.assign(new Error('QQ state operation failed', { cause }), { code });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function publicQqStateError(error) {
|
|
14
|
+
return Object.hasOwn(MESSAGES, error?.code ?? '')
|
|
15
|
+
? { code: error.code, message: t(MESSAGES[error.code]) }
|
|
16
|
+
: null;
|
|
17
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
3
4
|
import { dirname } from 'node:path';
|
|
5
|
+
import { qqStateError } from './state-error.mjs';
|
|
4
6
|
|
|
5
7
|
const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] });
|
|
6
8
|
|
|
@@ -27,22 +29,46 @@ function normalizeState(value) {
|
|
|
27
29
|
|
|
28
30
|
export class QqStateStore {
|
|
29
31
|
#path;
|
|
32
|
+
#logger;
|
|
30
33
|
#state = structuredClone(EMPTY_STATE);
|
|
31
34
|
#writeQueue = Promise.resolve();
|
|
32
35
|
#deferred = deferredStateAccess(() => this.#state, () => this.#persist());
|
|
33
36
|
|
|
34
|
-
constructor(path) {
|
|
37
|
+
constructor(path, { logger = console } = {}) {
|
|
35
38
|
this.#path = path;
|
|
39
|
+
this.#logger = logger;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
async load() {
|
|
43
|
+
let bytes;
|
|
39
44
|
try {
|
|
40
|
-
|
|
45
|
+
bytes = await fs.readFile(this.#path);
|
|
41
46
|
} catch (error) {
|
|
42
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
47
|
+
if (error?.code !== 'ENOENT') throw qqStateError('state-read-failed', error);
|
|
48
|
+
this.#state = structuredClone(EMPTY_STATE);
|
|
49
|
+
await this.#persist();
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(bytes.toString('utf8'));
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
58
|
+
const backup = `${this.#path}.corrupt-${Date.now()}-${randomUUID()}`;
|
|
59
|
+
try {
|
|
60
|
+
// Copy the exact bytes before replacing anything. A failed rebuild
|
|
61
|
+
// leaves both the original and this exclusive backup available.
|
|
62
|
+
await fs.writeFile(backup, bytes, { flag: 'wx', mode: 0o600 });
|
|
63
|
+
} catch (cause) {
|
|
64
|
+
throw qqStateError('state-backup-failed', cause);
|
|
65
|
+
}
|
|
43
66
|
this.#state = structuredClone(EMPTY_STATE);
|
|
44
67
|
await this.#persist();
|
|
68
|
+
this.#logger.warn?.(`[dsh-im:qq] state-recovered: ${this.#path}; backup: ${backup}; conversation mappings, message deduplication and deferred deliveries have been reset.`);
|
|
69
|
+
return this;
|
|
45
70
|
}
|
|
71
|
+
this.#state = normalizeState(parsed);
|
|
46
72
|
return this;
|
|
47
73
|
}
|
|
48
74
|
|
|
@@ -96,7 +122,7 @@ export class QqStateStore {
|
|
|
96
122
|
|
|
97
123
|
async remove() {
|
|
98
124
|
try {
|
|
99
|
-
await unlink(this.#path);
|
|
125
|
+
await fs.unlink(this.#path);
|
|
100
126
|
} catch (error) {
|
|
101
127
|
if (error?.code !== 'ENOENT') throw error;
|
|
102
128
|
}
|
|
@@ -106,12 +132,12 @@ export class QqStateStore {
|
|
|
106
132
|
async #persist() {
|
|
107
133
|
const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`;
|
|
108
134
|
const operation = this.#writeQueue.then(async () => {
|
|
109
|
-
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
135
|
+
await fs.mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
110
136
|
const temporary = `${this.#path}.tmp`;
|
|
111
|
-
await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
|
|
112
|
-
await rename(temporary, this.#path);
|
|
137
|
+
await fs.writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
|
|
138
|
+
await fs.rename(temporary, this.#path);
|
|
113
139
|
});
|
|
114
140
|
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
115
|
-
await operation;
|
|
141
|
+
await operation.catch(error => { throw qqStateError('state-write-failed', error); });
|
|
116
142
|
}
|
|
117
143
|
}
|