@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 +13 -0
- package/lib/web-shim.js +104 -7
- package/lib/wecom-channel.d.ts +3 -0
- package/lib/wecom-channel.js +24 -4
- package/package.json +1 -1
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
|
|
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)
|
|
824
|
+
const chunk = flattenVisibleText(message.content ?? data.content ?? rec.content)
|
|
825
|
+
|| (typeof rec.text === 'string' ? stripThinkTags(rec.text) : '');
|
|
750
826
|
if (chunk.trim() !== '')
|
|
751
|
-
|
|
827
|
+
fromMessage = chunk;
|
|
752
828
|
}
|
|
753
|
-
|
|
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)) {
|
package/lib/wecom-channel.d.ts
CHANGED
|
@@ -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;
|
package/lib/wecom-channel.js
CHANGED
|
@@ -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
|
|
283
|
+
await finish('已取消(收到新消息)');
|
|
270
284
|
return;
|
|
271
285
|
}
|
|
272
|
-
await
|
|
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
|
|
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
|
|
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.
|
|
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": {
|