@rezti/dsh-rez-wechat 0.1.22 → 0.1.23

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/lib/web-shim.d.ts CHANGED
@@ -106,6 +106,19 @@ export declare function parseChoice(userText: string, questions: PendingQuestion
106
106
  label: string;
107
107
  } | undefined;
108
108
  export declare function historyEvents(body: unknown): unknown[];
109
+ /** Durable seq, or seq0 for batched frames like text-chunks. Missing → -1. */
110
+ export declare function eventSeq(event: unknown): number;
111
+ export declare function historyCursor(events: unknown[]): number;
112
+ /**
113
+ * True when a turn that started *after* beforeSeq has ended.
114
+ * Events without seq are always included (unit fixtures / legacy history).
115
+ */
116
+ export declare function turnSettledAfter(events: unknown[], beforeSeq: number): boolean;
117
+ /**
118
+ * Visible assistant text for WeChat.
119
+ * Web UI streams via text-chunks; final assistant/message is often tool-only.
120
+ * Prefer longer streamed chunks when they supersede a short mid-turn message.
121
+ */
109
122
  export declare function lastAssistantText(events: unknown[]): string;
110
123
  export declare function turnIsIdle(events: unknown[]): boolean;
111
124
  export declare function pickWechatModel(models: unknown, env?: NodeJS.ProcessEnv): ModelRef | undefined;
package/lib/web-shim.js CHANGED
@@ -733,24 +733,105 @@ export function historyEvents(body) {
733
733
  : []);
734
734
  return raw.map(unwrapHistoryItem);
735
735
  }
736
+ /** Durable seq, or seq0 for batched frames like text-chunks. Missing → -1. */
737
+ export function eventSeq(event) {
738
+ if (typeof event !== 'object' || event === null)
739
+ return -1;
740
+ const rec = event;
741
+ if (typeof rec.seq === 'number' && Number.isFinite(rec.seq))
742
+ return rec.seq;
743
+ if (typeof rec.seq0 === 'number' && Number.isFinite(rec.seq0))
744
+ return rec.seq0;
745
+ return -1;
746
+ }
747
+ export function historyCursor(events) {
748
+ let max = -1;
749
+ for (const event of events) {
750
+ const seq = eventSeq(event);
751
+ if (seq > max)
752
+ max = seq;
753
+ }
754
+ return max;
755
+ }
756
+ /**
757
+ * True when a turn that started *after* beforeSeq has ended.
758
+ * Events without seq are always included (unit fixtures / legacy history).
759
+ */
760
+ export function turnSettledAfter(events, beforeSeq) {
761
+ let open = 0;
762
+ let sawStart = false;
763
+ for (const event of events) {
764
+ if (typeof event !== 'object' || event === null)
765
+ continue;
766
+ const seq = eventSeq(event);
767
+ if (seq >= 0 && seq <= beforeSeq)
768
+ continue;
769
+ const type = event.type;
770
+ if (type === 'turn/start') {
771
+ open += 1;
772
+ sawStart = true;
773
+ }
774
+ if (type === 'turn/end')
775
+ open = Math.max(0, open - 1);
776
+ }
777
+ return sawStart && open === 0;
778
+ }
779
+ function textsFromChunkEvent(rec) {
780
+ const data = typeof rec.data === 'object' && rec.data !== null ? rec.data : rec;
781
+ const texts = data.texts;
782
+ if (!Array.isArray(texts))
783
+ return '';
784
+ return texts.map(part => typeof part === 'string' ? part : '').join('');
785
+ }
786
+ /**
787
+ * Visible assistant text for WeChat.
788
+ * Web UI streams via text-chunks; final assistant/message is often tool-only.
789
+ * Prefer longer streamed chunks when they supersede a short mid-turn message.
790
+ */
736
791
  export function lastAssistantText(events) {
737
- let text = '';
792
+ let lastUserSeq = -1;
793
+ for (const event of events) {
794
+ if (typeof event !== 'object' || event === null)
795
+ continue;
796
+ const rec = event;
797
+ const type = typeof rec.type === 'string' ? rec.type : '';
798
+ const role = typeof rec.role === 'string' ? rec.role : '';
799
+ if (type === 'user/message' || role === 'user') {
800
+ lastUserSeq = Math.max(lastUserSeq, eventSeq(rec));
801
+ }
802
+ }
803
+ let fromMessage = '';
804
+ const chunkParts = [];
738
805
  for (const event of events) {
739
806
  if (typeof event !== 'object' || event === null)
740
807
  continue;
741
808
  const rec = event;
742
809
  const type = typeof rec.type === 'string' ? rec.type : '';
810
+ const seq = eventSeq(rec);
811
+ const afterUser = lastUserSeq < 0 || seq < 0 || seq > lastUserSeq;
812
+ if (afterUser && type === 'text-chunks') {
813
+ const piece = textsFromChunkEvent(rec);
814
+ if (piece !== '')
815
+ chunkParts.push(piece);
816
+ continue;
817
+ }
743
818
  const role = typeof rec.role === 'string' ? rec.role : '';
744
819
  const isAssistant = type === 'assistant/message' || role === 'assistant';
745
- if (!isAssistant)
820
+ if (!isAssistant || !afterUser)
746
821
  continue;
747
822
  const data = typeof rec.data === 'object' && rec.data !== null ? rec.data : rec;
748
823
  const message = typeof data.message === 'object' && data.message !== null ? data.message : data;
749
- const chunk = flattenVisibleText(message.content ?? data.content ?? rec.content) || (typeof rec.text === 'string' ? stripThinkTags(rec.text) : '');
824
+ const chunk = flattenVisibleText(message.content ?? data.content ?? rec.content)
825
+ || (typeof rec.text === 'string' ? stripThinkTags(rec.text) : '');
750
826
  if (chunk.trim() !== '')
751
- text = chunk;
827
+ fromMessage = chunk;
752
828
  }
753
- return text.trim();
829
+ const fromChunks = chunkParts.join('');
830
+ const msg = fromMessage.trim();
831
+ const chunks = fromChunks.trim();
832
+ if (chunks.length > msg.length)
833
+ return chunks;
834
+ return msg || chunks;
754
835
  }
755
836
  export function turnIsIdle(events) {
756
837
  let open = 0;
@@ -1124,10 +1205,11 @@ function composeWechatReply(events, ignorePending) {
1124
1205
  }
1125
1206
  return { text: visible, pendingQuestions };
1126
1207
  }
1127
- async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePending, stuckText = STUCK_ACK, signal) {
1208
+ async function waitForAssistant(rpc, sessionId, beforeText, beforeSeq, timeoutMs, ignorePending, stuckText = STUCK_ACK, signal) {
1128
1209
  const deadline = Date.now() + timeoutMs;
1129
1210
  let last = { text: '', pendingQuestions: [] };
1130
1211
  let idleOnce = false;
1212
+ const emptyDone = '(本轮无文字回复,请到网页查看)';
1131
1213
  while (Date.now() < deadline) {
1132
1214
  if (signal?.aborted === true) {
1133
1215
  throw new Error('本轮已被新消息打断(可发「重启对话」或再问一句)');
@@ -1148,6 +1230,18 @@ async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePen
1148
1230
  return { text: stuckText, pendingQuestions: [] };
1149
1231
  if (composed.text !== '')
1150
1232
  last = composed;
1233
+ const cursorAware = beforeSeq >= 0 || events.some(event => eventSeq(event) >= 0);
1234
+ if (cursorAware && turnSettledAfter(events, beforeSeq)) {
1235
+ if (composed.text !== '' && composed.text !== beforeText)
1236
+ return composed;
1237
+ // Turn finished but no new usable text (tool-only / filtered monologue).
1238
+ return {
1239
+ text: composed.text !== '' && composed.text === beforeText
1240
+ ? '(本轮已完成,但没有新的文字回复;请到网页查看)'
1241
+ : emptyDone,
1242
+ pendingQuestions: composed.pendingQuestions,
1243
+ };
1244
+ }
1151
1245
  const idle = turnIsIdle(events);
1152
1246
  if (idle)
1153
1247
  idleOnce = true;
@@ -1208,13 +1302,16 @@ export async function runHeadlessViaWeb(opts) {
1208
1302
  throw new Error('本轮已被新消息打断');
1209
1303
  const promptText = restart ? RESTART_SEED : text;
1210
1304
  let before = '';
1305
+ let beforeSeq = -1;
1211
1306
  let events = [];
1212
1307
  try {
1213
1308
  events = await fetchSessionHistory(rpc, sessionId, 40);
1214
1309
  before = usableAssistantText(lastAssistantText(events));
1310
+ beforeSeq = historyCursor(events);
1215
1311
  }
1216
1312
  catch {
1217
1313
  before = '';
1314
+ beforeSeq = -1;
1218
1315
  }
1219
1316
  const pending = extractPendingQuestions(events);
1220
1317
  const questions = pending.length > 0
@@ -1240,7 +1337,7 @@ export async function runHeadlessViaWeb(opts) {
1240
1337
  }
1241
1338
  await rpc('session/prompt', promptPayload(sessionId, promptText));
1242
1339
  }
1243
- const reply = await waitForAssistant(rpc, sessionId, before, timeoutMs, questions, stuckAck(folder), signal);
1340
+ const reply = await waitForAssistant(rpc, sessionId, before, beforeSeq, timeoutMs, questions, stuckAck(folder), signal);
1244
1341
  // Stuck / approval-blocked sessions poison the sticky pointer — drop it so
1245
1342
  // the next WeCom/WeChat turn opens a fresh web session instead of hanging.
1246
1343
  if (reply.text === stuckAck(folder)) {
@@ -4,6 +4,9 @@
4
4
  */
5
5
  import { type WecomBotPublic, type WecomBotRecord } from './wecom-store.js';
6
6
  export declare const WECOM_NON_TEXT_HINT = "\u8BF7\u53D1\u6587\u5B57\u3002\u56FE\u7247\u548C\u6587\u4EF6\u8BF7\u8D70\u7F51\u9875\u7AEF\u6216 Nextcloud\u3002";
7
+ /** WeCom stream payload soft limit; oversize finish frames are dropped by the SDK. */
8
+ export declare const WECOM_REPLY_MAX_CHARS = 20000;
9
+ export declare function clipWecomReply(text: string): string;
7
10
  export type WecomBindingPhase = 'idle' | 'connecting' | 'authenticated' | 'error';
8
11
  export interface WecomBindingStatus {
9
12
  room: string;
@@ -7,6 +7,13 @@ import { dshHome } from '@rezti/dsh-rez-sso';
7
7
  import { clearSessionStoreKey, isRestartCommand, runHeadlessViaWeb, } from './web-shim.js';
8
8
  import { loadWecomChannels, publicWecomBots, saveWecomChannels, applyWecomBotPatch, WECOM_BINDABLE_ROOMS, } from './wecom-store.js';
9
9
  export const WECOM_NON_TEXT_HINT = '请发文字。图片和文件请走网页端或 Nextcloud。';
10
+ /** WeCom stream payload soft limit; oversize finish frames are dropped by the SDK. */
11
+ export const WECOM_REPLY_MAX_CHARS = 20_000;
12
+ export function clipWecomReply(text) {
13
+ if (text.length <= WECOM_REPLY_MAX_CHARS)
14
+ return text;
15
+ return `${text.slice(0, WECOM_REPLY_MAX_CHARS - 16)}\n…(已截断)`;
16
+ }
10
17
  /** Cap how long one WeCom turn can block the per-room queue (stuck Kimi / approval). */
11
18
  export const WECOM_TURN_TIMEOUT_MS = 90_000;
12
19
  const MAX_SEEN_MSGIDS = 200;
@@ -262,21 +269,28 @@ export class WecomChannel {
262
269
  row.turnAbort = turnAbort;
263
270
  const timeout = AbortSignal.timeout(WECOM_TURN_TIMEOUT_MS);
264
271
  const signal = AbortSignal.any([turnAbort.signal, timeout]);
272
+ let finished = false;
273
+ const finish = async (content) => {
274
+ if (finished)
275
+ return;
276
+ finished = true;
277
+ await row.client.replyStream(frame, streamId, clipWecomReply(content), true);
278
+ };
265
279
  try {
266
280
  await row.client.replyStream(frame, streamId, '正在处理…', false);
267
281
  const reply = await this.runTurn(row.bot.room, text, signal);
268
282
  if (turnAbort.signal.aborted) {
269
- await row.client.replyStream(frame, streamId, '已取消(收到新消息)', true);
283
+ await finish('已取消(收到新消息)');
270
284
  return;
271
285
  }
272
- await row.client.replyStream(frame, streamId, reply.length > 0 ? reply : '(空回复)', true);
286
+ await finish(reply.length > 0 ? reply : '(空回复)');
273
287
  }
274
288
  catch (error) {
275
289
  const why = error instanceof Error ? error.message : String(error);
276
290
  const superseded = turnAbort.signal.aborted && !timeout.aborted;
277
291
  if (superseded) {
278
292
  try {
279
- await row.client.replyStream(frame, streamId, '已取消(收到新消息)', true);
293
+ await finish('已取消(收到新消息)');
280
294
  }
281
295
  catch { /* WS may already be closed */ }
282
296
  return;
@@ -286,7 +300,7 @@ export class WecomChannel {
286
300
  ? '处理超时。请发「重启对话」再试,或到网页左侧该房间看是否在等批准。'
287
301
  : `处理失败:${why}`;
288
302
  try {
289
- await row.client.replyStream(frame, streamId, message, true);
303
+ await finish(message);
290
304
  }
291
305
  catch {
292
306
  /* WS may already be closed */
@@ -295,6 +309,12 @@ export class WecomChannel {
295
309
  clearSessionStoreKey(join(this.home, 'dsh-rez-wecom', row.bot.room, 'web-sessions.json'), row.bot.room);
296
310
  }
297
311
  finally {
312
+ if (!finished) {
313
+ try {
314
+ await finish('(回复中断,请再发一句或「重启对话」)');
315
+ }
316
+ catch { /* WS may already be closed */ }
317
+ }
298
318
  if (row.turnAbort === turnAbort)
299
319
  delete row.turnAbort;
300
320
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rezti/dsh-rez-wechat",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "ReZ-TI WeChat/WeCom bridges. Personal WeChat is QClaw/ClawBot scan-and-chat via dsh-wechat-bridge; WeCom AI bots use the official @wecom/aibot-node-sdk (BotID + Secret) bound per Harness room.",
5
5
  "type": "module",
6
6
  "engines": {