@rezti/dsh-rez-wechat 0.1.11 → 0.1.13

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.md CHANGED
@@ -3,10 +3,11 @@
3
3
  企业微信走现成 `wecom-mcp`;个人微信走 QClaw 同款 `dsh-wechat-bridge`(腾讯 iLink)。都不自写长连接。
4
4
 
5
5
  ```bash
6
- dsh plugin --profile web add @rezti/dsh-rez-sso
7
- dsh plugin --profile web add @rezti/dsh-rez-wechat
6
+ dsh plugin --profile web add @rezti/dsh-rez-suite
8
7
  ```
9
8
 
9
+ 以后升级只记:`dsh plugin --profile web update @rezti/dsh-rez-suite`。不要再单独 add 本包。
10
+
10
11
  | 能力 | 做法 |
11
12
  |---|---|
12
13
  | 企业微信发到群 | `wecom-mcp`(`mcp__wechat__*`)+ `REZ_WECHAT_WEBHOOK` |
@@ -18,8 +19,10 @@ dsh plugin --profile web add @rezti/dsh-rez-wechat
18
19
  - 连续消息延续同一会话
19
20
  - 模型跟网页默认(网页选了 Kimi,微信也走 Kimi)
20
21
  - 网页归档后再从微信说话会自动回到侧栏(官方会藏空白会话,所以「重启对话」会先打一句招呼)
22
+ - 网页端选择题(Selection)会编成 `1. 2. 3.` 发到微信;回复数字或选项原文即可继续,不必在网页点选
23
+ - 思考过程不会发到微信;多段正文会保留换行
21
24
  - 通道断了点「重新连接」,已登录不必再扫码
22
25
 
23
- 扫码 UI 暂时还在旧单体 `dsh-rez-suite` 面板;通道本身在本包。不要对同一 profile 既 `plugin add suite``plugin add wechat`,会重复注册。
26
+ 扫码 UI suite 面板;通道本身在本包。不要对同一 profile 既 add suite 又 add wechat,会重复注册。
24
27
 
25
28
  这是 ClawBot 客服会话,不是用个人微信号管好友/群,也不是客户层 WhatsApp/邮件通道。
package/lib/index.js CHANGED
@@ -5,7 +5,7 @@ export const name = 'rez-wechat';
5
5
  export const inject = ['systemPrompt', 'webServer', 'rezSso'];
6
6
  const GUIDANCE = [
7
7
  '企业微信群发送走官方 dsh-mcp-client + wecom-mcp(mcp__wechat__*),密钥 REZ_WECHAT_WEBHOOK。未配置时不拉起 wecom-mcp。Webhook 在 设置 → 插件 → ReZ-TI 里填。',
8
- '个人微信是 QClaw 同款 ClawBot 通道:在 Rez 面板「微信」页扫码一次,然后直接在微信里说话。消息进入网页左侧「微信」文件夹并延续同一会话,模型跟网页默认(在网页里选了 Kimi 就会走 Kimi)。网页端归档了也能从微信自动恢复到侧栏(空白会话网页会藏起来,所以新开的会话会先打一句招呼再出现)。发「重启对话」会新开一条可见的网页会话并把旧的归档。通道断了点「重新连接」(已登录不必再扫码)。不要再跑 npx weixin-mcp login,也不要调用 mcp__weixin__*。网页端必须开着。',
8
+ '个人微信是 QClaw 同款 ClawBot 通道:在 Rez 面板「微信」页扫码一次,然后直接在微信里说话。消息进入网页左侧「微信」文件夹并延续同一会话,模型跟网页默认(在网页里选了 Kimi 就会走 Kimi)。网页端归档了也能从微信自动恢复到侧栏(空白会话网页会藏起来,所以新开的会话会先打一句招呼再出现)。网页端出现选择题时会编成纯文本发到微信,回复数字或选项原文即可;思考过程不会转发。发「重启对话」会新开一条可见的网页会话并把旧的归档。通道断了点「重新连接」(已登录不必再扫码)。不要再跑 npx weixin-mcp login,也不要调用 mcp__weixin__*。网页端必须开着。',
9
9
  '这是 ClawBot 客服会话,不是用个人微信号管好友/群。对外发消息必须先征得用户批准。',
10
10
  ].join('');
11
11
  function isLoopbackRequest(request) {
package/lib/web-shim.d.ts CHANGED
@@ -13,12 +13,21 @@ export interface ModelRef {
13
13
  provider?: string;
14
14
  model: string;
15
15
  }
16
+ export interface PendingQuestion {
17
+ id: string;
18
+ header?: string;
19
+ question: string;
20
+ options: string[];
21
+ }
22
+ export interface SessionRecord {
23
+ sessionId: string;
24
+ updatedAt: number;
25
+ pendingQuestions?: PendingQuestion[];
26
+ }
16
27
  export interface SessionStore {
17
- sessions: Record<string, {
18
- sessionId: string;
19
- updatedAt: number;
20
- }>;
28
+ sessions: Record<string, SessionRecord>;
21
29
  }
30
+ export declare const ASK_USER_QUESTION_TOOL = "ask_user_question";
22
31
  export interface RpcFn {
23
32
  (method: string, params: Record<string, unknown>): Promise<unknown>;
24
33
  }
@@ -34,6 +43,10 @@ export declare const RESTART_ACK = "\u5DF2\u65B0\u5F00\u7F51\u9875\u4F1A\u8BDD\u
34
43
  export declare const RESTART_SEED = "\u65B0\u4F1A\u8BDD\u5DF2\u5F00\u59CB\u3002\u8BF7\u7528\u4E00\u53E5\u4E2D\u6587\u6253\u62DB\u547C\uFF0C\u4E0D\u8981\u89E3\u91CA\u5185\u90E8\u6B65\u9AA4\u3002";
35
44
  export declare const STUCK_ACK = "\u7F51\u9875\u7AEF\u8FD9\u6761\u4F1A\u8BDD\u5361\u4F4F\u4E86\uFF08\u5728\u7B49\u6279\u51C6\uFF0C\u6216\u6A21\u578B\u628A\u5185\u90E8\u63A8\u7406\u53D1\u51FA\u6765\u4E86\uFF09\u3002\u8BF7\u770B\u7F51\u9875\u5DE6\u4FA7\u300C\u5FAE\u4FE1\u300D\u6587\u4EF6\u5939\uFF0C\u6216\u53D1\u300C\u91CD\u542F\u5BF9\u8BDD\u300D\u3002";
36
45
  export declare const RPC_TIMEOUT_MS = 15000;
46
+ export declare const QUESTION_HINT = "\u56DE\u590D\u6570\u5B57\u6216\u9009\u9879\u539F\u6587\u5373\u53EF\u3002";
47
+ export declare function stripThinkTags(text: string): string;
48
+ /** 微信 iLink 单条 text 能保留真实换行;多段之间空一行,避免粘成一团。 */
49
+ export declare function formatWechatPlain(text: string): string;
37
50
  export declare function looksLikeInternalMonologue(text: string): boolean;
38
51
  export declare function usableAssistantText(text: string): string;
39
52
  export declare function turnIsBlocked(events: unknown[]): boolean;
@@ -55,6 +68,14 @@ export declare function rpcErrorMessage(body: unknown): string | undefined;
55
68
  export declare function extractSessionId(body: unknown): string | undefined;
56
69
  export declare function extractWorkspaceId(body: unknown): string | undefined;
57
70
  export declare function findWorkspaceId(body: unknown, cwd?: string): string | undefined;
71
+ export declare function flattenVisibleText(content: unknown): string;
72
+ export declare function historySettledQuestions(events: unknown[]): boolean;
73
+ export declare function extractPendingQuestions(events: unknown[]): PendingQuestion[];
74
+ export declare function formatQuestionForWechat(questions: PendingQuestion[]): string;
75
+ export declare function parseChoice(userText: string, questions: PendingQuestion[]): {
76
+ id: string;
77
+ label: string;
78
+ } | undefined;
58
79
  export declare function historyEvents(body: unknown): unknown[];
59
80
  export declare function lastAssistantText(events: unknown[]): string;
60
81
  export declare function turnIsIdle(events: unknown[]): boolean;
package/lib/web-shim.js CHANGED
@@ -13,6 +13,7 @@ import { homedir } from 'node:os';
13
13
  import { basename, dirname, join } from 'node:path';
14
14
  export const WECHAT_WORKSPACE_NAME = '微信';
15
15
  export const DEFAULT_WEB_URL = 'http://127.0.0.1:3080';
16
+ export const ASK_USER_QUESTION_TOOL = 'ask_user_question';
16
17
  export function parseHeadlessArgv(argv) {
17
18
  const args = argv[0]?.endsWith('node') || argv[0]?.includes('node.exe') ? argv.slice(1) : argv;
18
19
  const start = args[0]?.endsWith('.js') || args[0]?.endsWith('.mjs') || args[0]?.endsWith('.cjs') ? 1 : 0;
@@ -40,6 +41,8 @@ export const RESTART_ACK = '已新开网页会话(左侧「微信」文件夹
40
41
  export const RESTART_SEED = '新会话已开始。请用一句中文打招呼,不要解释内部步骤。';
41
42
  export const STUCK_ACK = '网页端这条会话卡住了(在等批准,或模型把内部推理发出来了)。请看网页左侧「微信」文件夹,或发「重启对话」。';
42
43
  export const RPC_TIMEOUT_MS = 15_000;
44
+ export const QUESTION_HINT = '回复数字或选项原文即可。';
45
+ const SKIP_CONTENT_TYPES = new Set(['reasoning', 'thinking', 'tool-call', 'tool-result', 'image']);
43
46
  const MONOLOGUE_MARKERS = [
44
47
  'sandbox_permissions',
45
48
  'danger-full-access',
@@ -48,16 +51,37 @@ const MONOLOGUE_MARKERS = [
48
51
  'escalate speculatively',
49
52
  'outside workspace',
50
53
  'I need danger-full-access',
54
+ '思考过程',
55
+ '思考内容',
51
56
  ];
57
+ export function stripThinkTags(text) {
58
+ return text
59
+ .replace(/<think(?:ing|thought)?>[\s\S]*?<\/think(?:ing|thought)?>/gi, '')
60
+ .replace(/<\/?think(?:ing|thought)?>/gi, '');
61
+ }
62
+ /** 微信 iLink 单条 text 能保留真实换行;多段之间空一行,避免粘成一团。 */
63
+ export function formatWechatPlain(text) {
64
+ return text
65
+ .replace(/\r\n?/g, '\n')
66
+ .replace(/[\u2028\u2029]/g, '\n')
67
+ .replace(/[ \t]+\n/g, '\n')
68
+ .replace(/\n{3,}/g, '\n\n')
69
+ .trim();
70
+ }
52
71
  export function looksLikeInternalMonologue(text) {
53
- if (text.trim().length === 0)
72
+ const trimmed = text.trim();
73
+ if (trimmed.length === 0)
54
74
  return false;
55
- if (MONOLOGUE_MARKERS.some(marker => text.includes(marker)))
75
+ if (MONOLOGUE_MARKERS.some(marker => trimmed.includes(marker)))
76
+ return true;
77
+ if (/^\s*(thought|thinking|reasoning)\b/i.test(trimmed))
56
78
  return true;
57
- return text.length > 400 && /sandbox_permissions|danger-full-access|workspace-write|AbortSignal|justification/i.test(text);
79
+ if (/<(think|thinking|thought)>/i.test(trimmed) && stripThinkTags(trimmed).trim().length === 0)
80
+ return true;
81
+ return trimmed.length > 400 && /sandbox_permissions|danger-full-access|workspace-write|AbortSignal|justification/i.test(trimmed);
58
82
  }
59
83
  export function usableAssistantText(text) {
60
- const trimmed = text.trim();
84
+ const trimmed = formatWechatPlain(stripThinkTags(text));
61
85
  if (trimmed.length === 0 || looksLikeInternalMonologue(trimmed))
62
86
  return '';
63
87
  return trimmed;
@@ -68,9 +92,10 @@ export function turnIsBlocked(events) {
68
92
  if (typeof event !== 'object' || event === null)
69
93
  continue;
70
94
  const type = event.type;
71
- if (type === 'approval/asked' || type === 'approval/requested' || type === 'question/requested')
95
+ // Selection / ask_user_question 不是批准卡住:编成纯文本发给微信,不要 STUCK_ACK。
96
+ if (type === 'approval/asked' || type === 'approval/requested')
72
97
  blocked = true;
73
- if (type === 'approval/decided' || type === 'approval/resolved' || type === 'question/resolved' || type === 'turn/end')
98
+ if (type === 'approval/decided' || type === 'approval/resolved' || type === 'turn/end')
74
99
  blocked = false;
75
100
  }
76
101
  return blocked;
@@ -225,23 +250,301 @@ export function findWorkspaceId(body, cwd) {
225
250
  }) ?? rows.find(row => row.title === WECHAT_WORKSPACE_NAME);
226
251
  return match !== undefined ? extractWorkspaceId(match) : undefined;
227
252
  }
228
- function flattenText(content) {
253
+ function asRecord(value) {
254
+ return typeof value === 'object' && value !== null ? value : undefined;
255
+ }
256
+ function eventPayload(event) {
257
+ const data = asRecord(event.data);
258
+ return data ?? event;
259
+ }
260
+ export function flattenVisibleText(content) {
229
261
  if (typeof content === 'string')
230
- return content;
262
+ return stripThinkTags(content);
231
263
  if (!Array.isArray(content))
232
264
  return '';
233
- return content
234
- .map(block => {
235
- if (typeof block === 'string')
236
- return block;
237
- if (typeof block !== 'object' || block === null)
238
- return '';
239
- const rec = block;
240
- if (typeof rec.text === 'string')
241
- return rec.text;
242
- return '';
243
- })
244
- .join('');
265
+ const parts = [];
266
+ for (const block of content) {
267
+ if (typeof block === 'string') {
268
+ const text = stripThinkTags(block).trim();
269
+ if (text !== '')
270
+ parts.push(text);
271
+ continue;
272
+ }
273
+ const rec = asRecord(block);
274
+ if (rec === undefined)
275
+ continue;
276
+ const type = typeof rec.type === 'string' ? rec.type : 'text';
277
+ if (SKIP_CONTENT_TYPES.has(type))
278
+ continue;
279
+ if (type !== 'text' && rec.type !== undefined)
280
+ continue;
281
+ if (typeof rec.text !== 'string')
282
+ continue;
283
+ const text = stripThinkTags(rec.text).trim();
284
+ if (text !== '')
285
+ parts.push(text);
286
+ }
287
+ return parts.join('\n\n');
288
+ }
289
+ function optionLabel(option) {
290
+ if (typeof option === 'string')
291
+ return option.trim();
292
+ const rec = asRecord(option);
293
+ if (rec !== undefined && typeof rec.label === 'string')
294
+ return rec.label.trim();
295
+ return '';
296
+ }
297
+ function questionsFromAskArgs(args, fallbackId) {
298
+ let parsed = args;
299
+ if (typeof args === 'string') {
300
+ try {
301
+ parsed = JSON.parse(args);
302
+ }
303
+ catch {
304
+ return [];
305
+ }
306
+ }
307
+ const rec = asRecord(parsed);
308
+ if (rec === undefined)
309
+ return [];
310
+ const rawQuestions = Array.isArray(rec.questions) ? rec.questions : [rec];
311
+ const out = [];
312
+ for (const item of rawQuestions) {
313
+ if (typeof item === 'string') {
314
+ const question = item.trim();
315
+ if (question !== '')
316
+ out.push({ id: fallbackId, question, options: [] });
317
+ continue;
318
+ }
319
+ const q = asRecord(item);
320
+ if (q === undefined)
321
+ continue;
322
+ const id = typeof q.id === 'string' && q.id.trim() !== '' ? q.id.trim() : fallbackId;
323
+ const question = typeof q.question === 'string' ? q.question.trim()
324
+ : typeof q.prompt === 'string' ? q.prompt.trim()
325
+ : '';
326
+ const header = typeof q.header === 'string' && q.header.trim() !== '' ? q.header.trim() : undefined;
327
+ const options = Array.isArray(q.options) ? q.options.map(optionLabel).filter(label => label !== '') : [];
328
+ if (question === '' && options.length === 0)
329
+ continue;
330
+ const pending = { id, question: question || header || '请选择', options };
331
+ if (header !== undefined)
332
+ pending.header = header;
333
+ out.push(pending);
334
+ }
335
+ return out;
336
+ }
337
+ function toolNameOf(rec) {
338
+ const payload = eventPayload(rec);
339
+ const name = payload.name ?? payload.tool ?? payload.toolName ?? rec.name ?? rec.tool ?? rec.toolName;
340
+ return typeof name === 'string' ? name : '';
341
+ }
342
+ function callIdOf(rec) {
343
+ const payload = eventPayload(rec);
344
+ const id = payload.callID ?? payload.callId ?? payload.toolCallId ?? rec.callID ?? rec.callId ?? rec.toolCallId ?? rec.id;
345
+ return typeof id === 'string' ? id : '';
346
+ }
347
+ function isAskUserTool(name) {
348
+ return name === ASK_USER_QUESTION_TOOL || name.endsWith(`__${ASK_USER_QUESTION_TOOL}`);
349
+ }
350
+ function addId(into, value) {
351
+ if (typeof value === 'string' && value.trim() !== '')
352
+ into.add(value.trim());
353
+ }
354
+ function resolvedIdsFrom(rec) {
355
+ const payload = eventPayload(rec);
356
+ const ids = new Set();
357
+ addId(ids, callIdOf(rec));
358
+ addId(ids, payload.questionId);
359
+ addId(ids, payload.requestId);
360
+ addId(ids, rec.questionId);
361
+ addId(ids, rec.requestId);
362
+ const buckets = [payload.answers, rec.answers, payload.questions, rec.questions];
363
+ for (const bucket of buckets) {
364
+ if (!Array.isArray(bucket))
365
+ continue;
366
+ for (const item of bucket) {
367
+ const row = asRecord(item);
368
+ if (row !== undefined)
369
+ addId(ids, row.id);
370
+ }
371
+ }
372
+ return [...ids];
373
+ }
374
+ function contentBlocks(rec) {
375
+ const payload = eventPayload(rec);
376
+ const message = asRecord(payload.message) ?? payload;
377
+ const content = message.content ?? payload.content ?? rec.content;
378
+ return Array.isArray(content) ? content : [];
379
+ }
380
+ export function historySettledQuestions(events) {
381
+ for (const event of events) {
382
+ const rec = asRecord(event);
383
+ if (rec === undefined)
384
+ continue;
385
+ const type = typeof rec.type === 'string' ? rec.type : '';
386
+ if (type === 'question/resolved' || type === 'question/answered' || type === 'question/cancelled')
387
+ return true;
388
+ if ((type === 'tool/result' || type === 'tool-result') && isAskUserTool(toolNameOf(rec)))
389
+ return true;
390
+ for (const block of contentBlocks(rec)) {
391
+ const b = asRecord(block);
392
+ if (b === undefined)
393
+ continue;
394
+ if (b.type !== 'tool-result' && b.type !== 'tool/result')
395
+ continue;
396
+ const name = typeof b.toolName === 'string' ? b.toolName : typeof b.name === 'string' ? b.name : '';
397
+ if (isAskUserTool(name))
398
+ return true;
399
+ }
400
+ }
401
+ return false;
402
+ }
403
+ export function extractPendingQuestions(events) {
404
+ const answered = new Set();
405
+ const requestedIds = [];
406
+ const pendingByKey = new Map();
407
+ const pendingOrigins = new Map();
408
+ const keyOf = (item) => `${item.id}:${item.question}:${item.options.join('|')}`;
409
+ const dropAnswered = () => {
410
+ for (const [key, item] of pendingByKey) {
411
+ const origins = pendingOrigins.get(key) ?? new Set([item.id]);
412
+ if ([...origins].some(id => answered.has(id)))
413
+ pendingByKey.delete(key);
414
+ }
415
+ };
416
+ const remember = (items, sourceId) => {
417
+ if (sourceId !== undefined && sourceId !== '')
418
+ requestedIds.push(sourceId);
419
+ for (const item of items) {
420
+ requestedIds.push(item.id);
421
+ const key = keyOf(item);
422
+ const origins = pendingOrigins.get(key) ?? new Set();
423
+ origins.add(item.id);
424
+ if (sourceId !== undefined && sourceId !== '')
425
+ origins.add(sourceId);
426
+ pendingOrigins.set(key, origins);
427
+ if ([...origins].some(id => answered.has(id)))
428
+ continue;
429
+ pendingByKey.set(key, item);
430
+ }
431
+ };
432
+ const markResolved = (rec) => {
433
+ const ids = resolvedIdsFrom(rec);
434
+ if (ids.length === 0) {
435
+ for (const id of requestedIds)
436
+ answered.add(id);
437
+ pendingByKey.clear();
438
+ return;
439
+ }
440
+ for (const id of ids)
441
+ answered.add(id);
442
+ dropAnswered();
443
+ };
444
+ for (const event of events) {
445
+ const rec = asRecord(event);
446
+ if (rec === undefined)
447
+ continue;
448
+ const type = typeof rec.type === 'string' ? rec.type : '';
449
+ const payload = eventPayload(rec);
450
+ if (type === 'question/resolved' || type === 'question/answered' || type === 'question/cancelled') {
451
+ markResolved(rec);
452
+ continue;
453
+ }
454
+ if (type === 'question/requested') {
455
+ const sourceId = callIdOf(rec) || 'question';
456
+ remember(questionsFromAskArgs(payload.questions !== undefined ? payload : rec, sourceId), sourceId);
457
+ continue;
458
+ }
459
+ if (type === 'tool/result' || type === 'tool-result') {
460
+ if (isAskUserTool(toolNameOf(rec))) {
461
+ const id = callIdOf(rec);
462
+ if (id !== '')
463
+ answered.add(id);
464
+ dropAnswered();
465
+ }
466
+ }
467
+ if ((type === 'tool/call' || type === 'tool-call') && isAskUserTool(toolNameOf(rec))) {
468
+ const id = callIdOf(rec);
469
+ if (id === '' || !answered.has(id)) {
470
+ const args = payload.arguments ?? payload.input ?? payload.params ?? rec.arguments ?? rec.input;
471
+ remember(questionsFromAskArgs(args, id || ASK_USER_QUESTION_TOOL), id);
472
+ }
473
+ }
474
+ for (const block of contentBlocks(rec)) {
475
+ const b = asRecord(block);
476
+ if (b === undefined)
477
+ continue;
478
+ if (b.type === 'tool-result' || b.type === 'tool/result') {
479
+ const name = typeof b.toolName === 'string' ? b.toolName : typeof b.name === 'string' ? b.name : '';
480
+ if (!isAskUserTool(name))
481
+ continue;
482
+ const id = typeof b.toolCallId === 'string' ? b.toolCallId : typeof b.callID === 'string' ? b.callID : '';
483
+ if (id !== '')
484
+ answered.add(id);
485
+ dropAnswered();
486
+ continue;
487
+ }
488
+ if (b.type !== 'tool-call' && b.type !== 'tool/call')
489
+ continue;
490
+ const name = typeof b.toolName === 'string' ? b.toolName : typeof b.name === 'string' ? b.name : '';
491
+ if (!isAskUserTool(name))
492
+ continue;
493
+ const id = typeof b.toolCallId === 'string' ? b.toolCallId : typeof b.callID === 'string' ? b.callID : typeof b.id === 'string' ? b.id : '';
494
+ if (id !== '' && answered.has(id))
495
+ continue;
496
+ remember(questionsFromAskArgs(b.arguments ?? b.input ?? b.params, id || ASK_USER_QUESTION_TOOL), id);
497
+ }
498
+ }
499
+ return [...pendingByKey.values()];
500
+ }
501
+ export function formatQuestionForWechat(questions) {
502
+ const parts = [];
503
+ const hint = questions.length > 1
504
+ ? '多题时回复如 1-2(第1题第2项),或回复选项原文。'
505
+ : QUESTION_HINT;
506
+ questions.forEach((question, questionIndex) => {
507
+ const title = question.header?.trim() || question.question.trim() || '请选择';
508
+ parts.push(questions.length > 1 ? `${questionIndex + 1}) ${title}` : title);
509
+ if (question.header !== undefined && question.question.trim() !== '' && question.question.trim() !== title) {
510
+ parts.push(question.question.trim());
511
+ }
512
+ question.options.forEach((option, index) => {
513
+ parts.push(`${index + 1}. ${option}`);
514
+ });
515
+ });
516
+ if (questions.length > 0)
517
+ parts.push(hint);
518
+ return formatWechatPlain(parts.join('\n'));
519
+ }
520
+ export function parseChoice(userText, questions) {
521
+ const raw = userText.trim();
522
+ if (raw === '' || questions.length === 0)
523
+ return undefined;
524
+ const pick = (question, optionIndex) => {
525
+ const label = question?.options[optionIndex];
526
+ if (question === undefined || label === undefined)
527
+ return undefined;
528
+ return { id: question.id, label };
529
+ };
530
+ const dotted = raw.match(/^(\d{1,2})[-..](\d{1,2})$/);
531
+ if (dotted !== null && questions.length > 1) {
532
+ return pick(questions[Number(dotted[1]) - 1], Number(dotted[2]) - 1);
533
+ }
534
+ const numbered = raw.match(/^(\d{1,2})(?:[..、。)]\s*)?$/);
535
+ if (numbered !== null) {
536
+ if (questions.length !== 1)
537
+ return undefined;
538
+ return pick(questions[0], Number(numbered[1]) - 1);
539
+ }
540
+ const exact = [];
541
+ for (const question of questions) {
542
+ for (const option of question.options) {
543
+ if (option === raw || option.trim() === raw)
544
+ exact.push({ id: question.id, label: option });
545
+ }
546
+ }
547
+ return exact.length === 1 ? exact[0] : undefined;
245
548
  }
246
549
  function unwrapHistoryItem(item) {
247
550
  if (typeof item !== 'object' || item === null)
@@ -276,7 +579,7 @@ export function lastAssistantText(events) {
276
579
  continue;
277
580
  const data = typeof rec.data === 'object' && rec.data !== null ? rec.data : rec;
278
581
  const message = typeof data.message === 'object' && data.message !== null ? data.message : data;
279
- const chunk = flattenText(message.content ?? data.content ?? rec.content) || (typeof rec.text === 'string' ? rec.text : '');
582
+ const chunk = flattenVisibleText(message.content ?? data.content ?? rec.content) || (typeof rec.text === 'string' ? stripThinkTags(rec.text) : '');
280
583
  if (chunk.trim() !== '')
281
584
  text = chunk;
282
585
  }
@@ -540,9 +843,52 @@ async function abortSessionTurn(rpc, sessionId) {
540
843
  return;
541
844
  }
542
845
  }
543
- async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs) {
846
+ async function tryRpcQuick(rpc, method, params, ms = 2000) {
847
+ let timer;
848
+ try {
849
+ await Promise.race([
850
+ rpc(method, params),
851
+ new Promise((_, reject) => {
852
+ timer = setTimeout(() => reject(new Error('timeout')), ms);
853
+ }),
854
+ ]);
855
+ return true;
856
+ }
857
+ catch {
858
+ return false;
859
+ }
860
+ finally {
861
+ if (timer !== undefined)
862
+ clearTimeout(timer);
863
+ }
864
+ }
865
+ async function tryAnswerPendingQuestion(rpc, sessionId, choice) {
866
+ const answer = { answers: [{ id: choice.id, selected: [choice.label] }] };
867
+ const payloads = [
868
+ { sessionId, answer },
869
+ { sessionId, answers: answer.answers },
870
+ ];
871
+ for (const method of ['question.respond', 'session.respondQuestion']) {
872
+ for (const params of payloads) {
873
+ if (await tryRpcQuick(rpc, method, params))
874
+ return true;
875
+ }
876
+ }
877
+ return false;
878
+ }
879
+ function composeWechatReply(events, ignorePending) {
880
+ const ignore = new Set((ignorePending ?? []).map(question => question.id));
881
+ const pendingQuestions = extractPendingQuestions(events).filter(question => !ignore.has(question.id));
882
+ const visible = usableAssistantText(lastAssistantText(events));
883
+ if (pendingQuestions.length > 0) {
884
+ const combined = [visible, formatQuestionForWechat(pendingQuestions)].filter(part => part.length > 0).join('\n\n');
885
+ return { text: formatWechatPlain(combined), pendingQuestions };
886
+ }
887
+ return { text: visible, pendingQuestions };
888
+ }
889
+ async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePending) {
544
890
  const deadline = Date.now() + timeoutMs;
545
- let last = '';
891
+ let last = { text: '', pendingQuestions: [] };
546
892
  let idleOnce = false;
547
893
  while (Date.now() < deadline) {
548
894
  let events = [];
@@ -552,23 +898,27 @@ async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs) {
552
898
  catch {
553
899
  events = [];
554
900
  }
901
+ const composed = composeWechatReply(events, ignorePending);
902
+ // 旧题(本轮 prompt 前就在的 Selection)不能立刻发回微信;只发新 turn 里新出现的题。
903
+ if (composed.pendingQuestions.length > 0 && composed.text !== '') {
904
+ return composed;
905
+ }
555
906
  if (turnIsBlocked(events))
556
- return STUCK_ACK;
557
- const text = usableAssistantText(lastAssistantText(events));
558
- if (text !== '')
559
- last = text;
907
+ return { text: STUCK_ACK, pendingQuestions: [] };
908
+ if (composed.text !== '')
909
+ last = composed;
560
910
  const idle = turnIsIdle(events);
561
911
  if (idle)
562
912
  idleOnce = true;
563
- if (idle && text !== '' && text !== beforeText)
564
- return text;
565
- if (idleOnce && text !== '' && text !== beforeText)
566
- return text;
913
+ if (idle && composed.text !== '' && composed.text !== beforeText)
914
+ return composed;
915
+ if (idleOnce && composed.text !== '' && composed.text !== beforeText)
916
+ return composed;
567
917
  await sleep(400);
568
918
  }
569
- if (last !== '' && last !== beforeText)
919
+ if (last.text !== '' && last.text !== beforeText)
570
920
  return last;
571
- return STUCK_ACK;
921
+ return { text: STUCK_ACK, pendingQuestions: [] };
572
922
  }
573
923
  export async function runHeadlessViaWeb(opts) {
574
924
  const env = opts.env ?? process.env;
@@ -585,6 +935,7 @@ export async function runHeadlessViaWeb(opts) {
585
935
  const restart = isRestartCommand(text);
586
936
  const fresh = isFreshBridgeTurn(opts.task) || restart;
587
937
  const store = loadSessionStore(storeFile);
938
+ const storedPending = restart ? undefined : store.sessions[key]?.pendingQuestions;
588
939
  let sessionId = store.sessions[key]?.sessionId;
589
940
  const previousId = sessionId;
590
941
  if (sessionId !== undefined && !fresh) {
@@ -607,8 +958,6 @@ export async function runHeadlessViaWeb(opts) {
607
958
  else {
608
959
  await ensureSessionVisible(rpc, sessionId, env, workspaceId, !customRpc);
609
960
  }
610
- store.sessions[key] = { sessionId, updatedAt: Date.now() };
611
- saveSessionStore(storeFile, store);
612
961
  const promptText = restart ? RESTART_SEED : text;
613
962
  let before = '';
614
963
  let events = [];
@@ -619,16 +968,46 @@ export async function runHeadlessViaWeb(opts) {
619
968
  catch {
620
969
  before = '';
621
970
  }
622
- if (!turnIsIdle(events) || turnIsBlocked(events)) {
623
- await abortSessionTurn(rpc, sessionId);
971
+ const pending = extractPendingQuestions(events);
972
+ const questions = pending.length > 0
973
+ ? pending
974
+ : (!restart && storedPending !== undefined && storedPending.length > 0 && !historySettledQuestions(events)
975
+ ? storedPending
976
+ : []);
977
+ let skipUserPrompt = false;
978
+ if (!restart && questions.length > 0) {
979
+ const choice = parseChoice(text, questions);
980
+ if (choice !== undefined) {
981
+ const answered = await tryAnswerPendingQuestion(rpc, sessionId, choice);
982
+ if (!answered) {
983
+ await abortSessionTurn(rpc, sessionId);
984
+ await rpc('session.prompt', {
985
+ sessionId,
986
+ mode: 'queue',
987
+ content: [{ type: 'text', text: `用户在微信选择了:${choice.label}` }],
988
+ });
989
+ }
990
+ skipUserPrompt = true;
991
+ }
992
+ }
993
+ if (!skipUserPrompt) {
994
+ if (!turnIsIdle(events) || turnIsBlocked(events)) {
995
+ await abortSessionTurn(rpc, sessionId);
996
+ }
997
+ await rpc('session.prompt', {
998
+ sessionId,
999
+ mode: 'queue',
1000
+ content: [{ type: 'text', text: promptText }],
1001
+ });
624
1002
  }
625
- await rpc('session.prompt', {
1003
+ const reply = await waitForAssistant(rpc, sessionId, before, timeoutMs, questions);
1004
+ store.sessions[key] = {
626
1005
  sessionId,
627
- mode: 'queue',
628
- content: [{ type: 'text', text: promptText }],
629
- });
630
- const reply = await waitForAssistant(rpc, sessionId, before, timeoutMs);
631
- if (restart && (reply === STUCK_ACK || reply === ''))
1006
+ updatedAt: Date.now(),
1007
+ ...(reply.pendingQuestions.length > 0 ? { pendingQuestions: reply.pendingQuestions } : {}),
1008
+ };
1009
+ saveSessionStore(storeFile, store);
1010
+ if (restart && (reply.text === STUCK_ACK || reply.text === ''))
632
1011
  return RESTART_ACK;
633
- return reply;
1012
+ return reply.text;
634
1013
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rezti/dsh-rez-wechat",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "ReZ-TI WeChat/WeCom bridges. Personal WeChat is QClaw/ClawBot scan-and-chat via dsh-wechat-bridge; WeCom group send uses wecom-mcp.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -28,7 +28,7 @@
28
28
  "license": "Apache-2.0",
29
29
  "dependencies": {
30
30
  "qrcode": "^1.5.4",
31
- "@rezti/dsh-rez-sso": "0.1.7"
31
+ "@rezti/dsh-rez-sso": "0.1.9"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@deepseek-ai/cordis": "^4.0.1",