@xmanrui/dsh-im 4.10.0 → 4.12.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.
Files changed (62) hide show
  1. package/PROACTIVE_DELIVERY.en.md +15 -1
  2. package/PROACTIVE_DELIVERY.md +15 -1
  3. package/README.en.md +7 -2
  4. package/README.md +8 -3
  5. package/lib/client.js +2341 -1315
  6. package/lib/index.js +250 -245
  7. package/package.json +4 -3
  8. package/plugin-src/client/channel-card-meta.js +9 -1
  9. package/plugin-src/client/channels/dingtalk/api.js +4 -0
  10. package/plugin-src/client/channels/dingtalk/index.js +41 -7
  11. package/plugin-src/client/channels/feishu/api.js +4 -0
  12. package/plugin-src/client/channels/feishu/group-settings.js +581 -0
  13. package/plugin-src/client/channels/feishu/index.js +64 -233
  14. package/plugin-src/client/channels/qq/api.js +4 -0
  15. package/plugin-src/client/channels/qq/index.js +41 -7
  16. package/plugin-src/client/channels/shared/collapsible-account.js +80 -0
  17. package/plugin-src/client/channels/shared/token-api.js +4 -0
  18. package/plugin-src/client/channels/shared/token-channel.js +62 -29
  19. package/plugin-src/client/channels/wecom/api.js +4 -0
  20. package/plugin-src/client/channels/wecom/index.js +41 -7
  21. package/plugin-src/client/channels/weixin/api.js +4 -0
  22. package/plugin-src/client/channels/weixin/index.js +41 -7
  23. package/plugin-src/client/channels/whatsapp/api.js +4 -0
  24. package/plugin-src/client/channels/whatsapp/index.js +40 -7
  25. package/plugin-src/client/delivery-settings.js +73 -5
  26. package/plugin-src/client/i18n.js +22 -0
  27. package/plugin-src/client/model-setting.js +104 -0
  28. package/plugin-src/client/styles.js +62 -0
  29. package/plugin-src/host/channels/dingtalk/production.mjs +3 -0
  30. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  31. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  32. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  33. package/plugin-src/host/channels/qq/production.mjs +3 -0
  34. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  35. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +19 -0
  36. package/plugin-src/host/channels/shared/production.mjs +3 -0
  37. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  38. package/plugin-src/host/channels/shared/workspace-rpc.mjs +2 -0
  39. package/plugin-src/host/channels/slack/production.mjs +3 -0
  40. package/plugin-src/host/channels/slack/rpc.mjs +9 -0
  41. package/plugin-src/host/channels/wecom/production.mjs +3 -0
  42. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  43. package/plugin-src/host/channels/weixin/production.mjs +3 -0
  44. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  45. package/plugin-src/host/channels/whatsapp/production.mjs +3 -0
  46. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  47. package/plugin-src/host/delivery-adapter.mjs +141 -3
  48. package/plugin-src/host/delivery-rpc.mjs +12 -0
  49. package/plugin-src/host/delivery-service.mjs +110 -3
  50. package/plugin-src/host/delivery-suggestions.mjs +46 -0
  51. package/plugin-src/host/harness-session-coordinator.mjs +6 -5
  52. package/plugin-src/host/index.mjs +18 -1
  53. package/plugin-src/host/session-sync-coordinator.mjs +217 -0
  54. package/scripts/verify-package.mjs +12 -6
  55. package/src/channels/shared/bot-workspace-store.mjs +285 -38
  56. package/src/channels/shared/harness-client.mjs +124 -43
  57. package/src/channels/shared/i18n-en/wecom.mjs +24 -0
  58. package/src/channels/shared/model-setting.mjs +110 -0
  59. package/src/channels/wecom/wecom-bridge.mjs +275 -2
  60. package/src/channels/wecom/wecom-cards.mjs +101 -0
  61. package/src/channels/wecom/wecom-runtime.mjs +2 -0
  62. package/src/channels/whatsapp/whatsapp-runtime.mjs +78 -26
@@ -24,6 +24,8 @@ import { watchHarnessMux } from './harness-mux.mjs';
24
24
  const interactionRegistries = new Map();
25
25
  const hostInteractionRegistries = new WeakMap();
26
26
  const MAX_ERROR_CLASSIFICATION_BYTES = 64;
27
+ const IM_INPUT_ORIGIN_TTL_MS = 30 * 60 * 1000;
28
+ const MAX_IM_INPUT_ORIGINS = 4096;
27
29
 
28
30
  async function smallResponseText(response) {
29
31
  const stream = response?.body;
@@ -91,6 +93,7 @@ function interactionRegistry(scope) {
91
93
  ownerships: new Map(),
92
94
  claims: new Map(),
93
95
  controls: new WeakMap(),
96
+ imInputOrigins: new Map(),
94
97
  nextOrder: 0,
95
98
  };
96
99
  registries.set(scope, registry);
@@ -98,6 +101,34 @@ function interactionRegistry(scope) {
98
101
  return registry;
99
102
  }
100
103
 
104
+ function pruneImInputOrigins(origins, now = Date.now()) {
105
+ for (const [rpcId, expiresAt] of origins) {
106
+ if (expiresAt > now) continue;
107
+ origins.delete(rpcId);
108
+ }
109
+ while (origins.size > MAX_IM_INPUT_ORIGINS) {
110
+ origins.delete(origins.keys().next().value);
111
+ }
112
+ }
113
+
114
+ function registerImInputOrigin(registry, rpcId) {
115
+ const origins = registry.imInputOrigins;
116
+ pruneImInputOrigins(origins);
117
+ origins.delete(rpcId);
118
+ origins.set(rpcId, Date.now() + IM_INPUT_ORIGIN_TTL_MS);
119
+ pruneImInputOrigins(origins);
120
+ return () => origins.delete(rpcId);
121
+ }
122
+
123
+ /** Consume a dsh-im prompt id while handling its durable user/message event. */
124
+ export function consumeDshImInputOrigin(scope, rpcId) {
125
+ if (!scope || !['object', 'function', 'string'].includes(typeof scope)
126
+ || typeof rpcId !== 'string' || !rpcId) return false;
127
+ const origins = interactionRegistry(scope).imInputOrigins;
128
+ pruneImInputOrigins(origins);
129
+ return origins.delete(rpcId);
130
+ }
131
+
101
132
  // A Host RPC may not accept cancellation itself. Bound the caller's wait
102
133
  // without retrying an operation that the Host may already have accepted.
103
134
  function callWithSignal(call, signal) {
@@ -311,14 +342,59 @@ function sleep(ms, signal) {
311
342
  });
312
343
  }
313
344
 
314
- function assistantMessageText(event) {
315
- return (event?.data?.message?.content ?? [])
316
- .filter((part) => part.type === 'text' && typeof part.text === 'string')
345
+ /** Join only visible text blocks from one Harness message payload. */
346
+ export function textFromHarnessContent(content) {
347
+ return (Array.isArray(content) ? content : [])
348
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
317
349
  .map((part) => part.text)
318
350
  .join('\n')
319
351
  .trim();
320
352
  }
321
353
 
354
+ function assistantMessageText(event) {
355
+ return textFromHarnessContent(event?.data?.message?.content);
356
+ }
357
+
358
+ /** Aggregate assistant text in stable step/index order for one Harness Turn. */
359
+ export class AssistantTextAccumulator {
360
+ #steps = new Map();
361
+ #legacyText = '';
362
+
363
+ appendDelta(step, index, text) {
364
+ if (typeof text !== 'string' || !text) return;
365
+ const stepNumber = Number.isSafeInteger(step) ? step : 0;
366
+ const partIndex = Number.isSafeInteger(index) ? index : 0;
367
+ this.#legacyText = '';
368
+ const parts = this.#steps.get(stepNumber) ?? new Map();
369
+ parts.set(partIndex, (parts.get(partIndex) ?? '') + text);
370
+ this.#steps.set(stepNumber, parts);
371
+ }
372
+
373
+ setCanonical(step, text) {
374
+ if (typeof text !== 'string' || !text.trim()) return;
375
+ if (!Number.isSafeInteger(step)) {
376
+ this.#steps.clear();
377
+ this.#legacyText = text.trim();
378
+ return;
379
+ }
380
+ this.#legacyText = '';
381
+ this.#steps.set(step, new Map([[0, text.trim()]]));
382
+ }
383
+
384
+ get text() {
385
+ if (this.#steps.size === 0) return this.#legacyText;
386
+ return [...this.#steps.entries()]
387
+ .sort(([left], [right]) => left - right)
388
+ .map(([, parts]) => [...parts.entries()]
389
+ .sort(([left], [right]) => left - right)
390
+ .map(([, text]) => text)
391
+ .join('\n')
392
+ .trim())
393
+ .filter(Boolean)
394
+ .join('\n\n');
395
+ }
396
+ }
397
+
322
398
  function nonEmptyText(value) {
323
399
  return typeof value === 'string' && value.trim() ? value.trim() : null;
324
400
  }
@@ -418,7 +494,7 @@ export class HarnessReplyTracker {
418
494
  #lastSeq;
419
495
  #openTurn = null;
420
496
  #targetTurn = null;
421
- #stepText = new Map();
497
+ #assistantText = new AssistantTextAccumulator();
422
498
  #latestText = '';
423
499
  #finished = false;
424
500
  #reason = null;
@@ -455,6 +531,12 @@ export class HarnessReplyTracker {
455
531
  return this.#targetTurn;
456
532
  }
457
533
 
534
+ #commitText(text, pushUpdate) {
535
+ if (!text || text === this.#latestText) return;
536
+ this.#latestText = text;
537
+ pushUpdate({ type: 'text', text });
538
+ }
539
+
458
540
  consumeAll(entries) {
459
541
  const updates = [];
460
542
  // 同一批轮询内的 text 帧只保留最新累积,其余事件逐帧透出,
@@ -499,28 +581,16 @@ export class HarnessReplyTracker {
499
581
  if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
500
582
  const step = event.data?.step ?? 0;
501
583
  const index = event.data.chunk.index ?? 0;
502
- const key = `${step}:${index}`;
503
- this.#stepText.set(key, (this.#stepText.get(key) ?? '') + event.data.chunk.text);
504
- const prefix = `${step}:`;
505
- const text = [...this.#stepText.entries()]
506
- .filter(([partKey]) => partKey.startsWith(prefix))
507
- .sort(([left], [right]) => Number(left.split(':')[1]) - Number(right.split(':')[1]))
508
- .map(([, part]) => part)
509
- .join('\n')
510
- .trim();
511
- if (text && text !== this.#latestText) {
512
- this.#latestText = text;
513
- pushUpdate({ type: 'text', text });
514
- }
584
+ this.#assistantText.appendDelta(step, index, event.data.chunk.text);
585
+ this.#commitText(this.#assistantText.text, pushUpdate);
515
586
  continue;
516
587
  }
517
588
 
518
589
  if (event.type === 'assistant/message') {
519
590
  const text = assistantMessageText(event);
520
- if (text && text !== this.#latestText) {
521
- this.#latestText = text;
522
- pushUpdate({ type: 'text', text });
523
- }
591
+ const step = Number.isSafeInteger(event.data?.step) ? event.data.step : null;
592
+ this.#assistantText.setCanonical(step, text);
593
+ this.#commitText(this.#assistantText.text, pushUpdate);
524
594
  continue;
525
595
  }
526
596
 
@@ -1188,31 +1258,40 @@ export class HarnessClient {
1188
1258
  const ownership = await this.#refreshControlOwnership(sessionId, control, options);
1189
1259
  if (!ownership || ownership.stopRequested) return false;
1190
1260
  if (this.#activeControlOwnership(sessionId, control) !== ownership) return false;
1191
- if (this.#controlExecutor) {
1192
- const accepted = this.#controlExecutor({
1193
- sessionId,
1194
- expectedTurn: ownership.turn,
1195
- promptRpcId: ownership.promptRpcId,
1196
- action: 'steer',
1197
- text,
1198
- });
1199
- if (accepted && typeof accepted.then === 'function') {
1200
- throw new TypeError('controlExecutor must return synchronously');
1201
- }
1202
- if (accepted !== undefined) {
1203
- if (typeof accepted !== 'boolean') {
1204
- throw new TypeError('controlExecutor must return a boolean or undefined');
1261
+ const inputRpcId = `${this.#rpcIdPrefix}-steer-${randomUUID()}`;
1262
+ const releaseInputOrigin = registerImInputOrigin(this.#interactionRegistry, inputRpcId);
1263
+ try {
1264
+ if (this.#controlExecutor) {
1265
+ const accepted = this.#controlExecutor({
1266
+ sessionId,
1267
+ expectedTurn: ownership.turn,
1268
+ promptRpcId: ownership.promptRpcId,
1269
+ inputRpcId,
1270
+ action: 'steer',
1271
+ text,
1272
+ });
1273
+ if (accepted && typeof accepted.then === 'function') {
1274
+ throw new TypeError('controlExecutor must return synchronously');
1275
+ }
1276
+ if (accepted !== undefined) {
1277
+ if (typeof accepted !== 'boolean') {
1278
+ throw new TypeError('controlExecutor must return a boolean or undefined');
1279
+ }
1280
+ if (!accepted) releaseInputOrigin();
1281
+ return accepted;
1205
1282
  }
1206
- return accepted;
1207
1283
  }
1284
+ await this.rpc('session.prompt', {
1285
+ sessionId,
1286
+ mode: 'steer',
1287
+ content: [{ type: 'text', text }],
1288
+ clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1289
+ }, 30_000, { ...options, rpcId: inputRpcId });
1290
+ return true;
1291
+ } catch (error) {
1292
+ releaseInputOrigin();
1293
+ throw error;
1208
1294
  }
1209
- await this.rpc('session.prompt', {
1210
- sessionId,
1211
- mode: 'steer',
1212
- content: [{ type: 'text', text }],
1213
- clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1214
- }, 30_000, options);
1215
- return true;
1216
1295
  }
1217
1296
 
1218
1297
  #consumeInteractionOwnerships(sessionId, entries) {
@@ -1302,6 +1381,7 @@ export class HarnessClient {
1302
1381
  );
1303
1382
  const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1));
1304
1383
  const promptRpcId = `${this.#rpcIdPrefix}-${randomUUID()}`;
1384
+ const releasePromptInputOrigin = registerImInputOrigin(this.#interactionRegistry, promptRpcId);
1305
1385
  const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq });
1306
1386
  const interactionController = onInteraction || onInteractionResolved
1307
1387
  ? new AbortController()
@@ -1518,6 +1598,7 @@ export class HarnessClient {
1518
1598
  throw turnStoppedError();
1519
1599
  }
1520
1600
  } finally {
1601
+ releasePromptInputOrigin();
1521
1602
  if (!promptAccepted || turnFinished) {
1522
1603
  for (const staged of stagedBatches) {
1523
1604
  try {
@@ -1,5 +1,29 @@
1
1
  // English translations (wecom area). Keys are exact Chinese literals passed to t().
2
2
  export default {
3
+ '/menu 或 /m 打开可点击的功能菜单': '/menu or /m Open the interactive menu',
4
+ '保持当前': 'Keep current',
5
+ '会话': 'Session',
6
+ '模型': 'Model',
7
+ '预设': 'Preset',
8
+ '跟随默认模型': 'Default model',
9
+ '跟随 Host 默认': 'Follow Host default',
10
+ '新会话': 'New session',
11
+ '上一页': 'Previous',
12
+ '下一页': 'Next',
13
+ '返回': 'Back',
14
+ '更多选项…': 'More options…',
15
+ '选择会话、模型或预设后,点击应用设置。': 'Choose a session, model or preset, then apply settings.',
16
+ '应用设置': 'Apply settings',
17
+ '工作区与任务': 'Workspace & tasks',
18
+ '应用选择': 'Apply selection',
19
+ '请选择一个选项后再应用。': 'Select one option before applying.',
20
+ '设置未改变。': 'Settings unchanged.',
21
+ '已收到操作,请查看下方结果。': 'Action received. See the result below.',
22
+ '重新打开菜单': 'Reopen menu',
23
+ '暂无可用选项。': 'No options are available.',
24
+ '预设仅用于之后的新会话。': 'Presets apply to future new sessions.',
25
+ '部分模型暂不可用,可稍后重试。': 'Some models are unavailable. Try again later.',
26
+ '工作区已变化,请从新菜单重新选择。': 'The workspace changed. Please select again from the new menu.',
3
27
  // Help text (wecom-bridge.mjs)
4
28
  '企业微信机器人已连接 DeepSeek Harness。': 'The Enterprise WeChat bot is connected to DeepSeek Harness.',
5
29
 
@@ -0,0 +1,110 @@
1
+ const MAX_PROVIDER_ID_LENGTH = 256;
2
+ const MAX_MODEL_ID_LENGTH = 1_024;
3
+ const MAX_LABEL_LENGTH = 256;
4
+ const UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
5
+ const UNSAFE_ID_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
6
+
7
+ function cleanId(value, maxLength) {
8
+ if (typeof value !== 'string') return '';
9
+ const id = value.trim();
10
+ if (!id || id.length > maxLength || UNSAFE_ID_TEXT.test(id)) return '';
11
+ return id;
12
+ }
13
+
14
+ function cleanLabel(value, fallback) {
15
+ if (typeof value !== 'string') return fallback;
16
+ const label = value.replace(UNSAFE_DISPLAY_TEXT, ' ').replace(/\s+/gu, ' ').trim();
17
+ return label ? label.slice(0, MAX_LABEL_LENGTH) : fallback;
18
+ }
19
+
20
+ export const EMPTY_MODEL_CATALOG = Object.freeze({
21
+ groups: Object.freeze([]),
22
+ failures: Object.freeze([]),
23
+ });
24
+
25
+ export function normalizeModelSelection(value) {
26
+ if (value == null) return null;
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
28
+ const provider = cleanId(value.provider, MAX_PROVIDER_ID_LENGTH);
29
+ const model = cleanId(value.model, MAX_MODEL_ID_LENGTH);
30
+ return provider && model ? { provider, model } : null;
31
+ }
32
+
33
+ export function validateModelSelection(value) {
34
+ if (value == null || value === '') return null;
35
+ const selection = normalizeModelSelection(value);
36
+ if (!selection) {
37
+ const error = new Error('模型无效。');
38
+ error.code = 'model-selection-invalid';
39
+ throw error;
40
+ }
41
+ return selection;
42
+ }
43
+
44
+ export function sameModelSelection(left, right) {
45
+ return left?.provider === right?.provider && left?.model === right?.model;
46
+ }
47
+
48
+ export function modelSelectionId(value) {
49
+ const selection = normalizeModelSelection(value);
50
+ return selection ? `${selection.provider}/${selection.model}` : '';
51
+ }
52
+
53
+ export function normalizeModelCatalog(value) {
54
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
55
+ return { groups: [], failures: [] };
56
+ }
57
+ const groups = [];
58
+ const seen = new Set();
59
+ for (const candidate of Array.isArray(value.groups) ? value.groups : []) {
60
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
61
+ const provider = cleanId(candidate.id, MAX_PROVIDER_ID_LENGTH);
62
+ if (!provider) continue;
63
+ const models = [];
64
+ for (const entry of Array.isArray(candidate.models) ? candidate.models : []) {
65
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
66
+ const model = cleanId(entry.id, MAX_MODEL_ID_LENGTH);
67
+ if (!model) continue;
68
+ const key = `${provider}\u0000${model}`;
69
+ if (seen.has(key)) continue;
70
+ seen.add(key);
71
+ models.push({
72
+ id: model,
73
+ name: cleanLabel(entry.name, model),
74
+ });
75
+ }
76
+ if (models.length > 0) {
77
+ groups.push({
78
+ id: provider,
79
+ name: cleanLabel(candidate.name, provider),
80
+ models,
81
+ });
82
+ }
83
+ }
84
+ const failures = [];
85
+ for (const candidate of Array.isArray(value.failures) ? value.failures : []) {
86
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
87
+ const id = cleanId(candidate.id, MAX_PROVIDER_ID_LENGTH);
88
+ if (!id) continue;
89
+ failures.push({ id, name: cleanLabel(candidate.name, id) });
90
+ }
91
+ return { groups, failures };
92
+ }
93
+
94
+ export function modelCatalogHas(catalog, selection) {
95
+ const normalized = normalizeModelSelection(selection);
96
+ if (!normalized) return false;
97
+ return normalizeModelCatalog(catalog).groups.some(
98
+ (group) => group.id === normalized.provider
99
+ && group.models.some((model) => model.id === normalized.model),
100
+ );
101
+ }
102
+
103
+ export async function listModelCatalog(harness, options = {}) {
104
+ try {
105
+ if (typeof harness?.listModels !== 'function') return normalizeModelCatalog(null);
106
+ return normalizeModelCatalog(await harness.listModels(options));
107
+ } catch {
108
+ return normalizeModelCatalog(null);
109
+ }
110
+ }