@rezti/dsh-rez-wechat 0.1.22 → 0.1.24
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/index.d.ts +2 -0
- package/lib/index.js +38 -1
- package/lib/session-history.d.ts +14 -0
- package/lib/session-history.js +21 -0
- package/lib/web-shim.d.ts +14 -1
- package/lib/web-shim.js +120 -58
- package/lib/wecom-channel.d.ts +5 -0
- package/lib/wecom-channel.js +61 -8
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
2
3
|
export declare const name = "rez-wechat";
|
|
3
4
|
export declare const inject: string[];
|
|
5
|
+
export declare function channelHistoryRoute(ctx: Context): WebRoute;
|
|
4
6
|
export declare function apply(ctx: Context): Promise<void>;
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { WeixinChannel } from './channel.js';
|
|
|
3
3
|
import { WecomChannel, shouldStartWecomWebhook } from './wecom-channel.js';
|
|
4
4
|
import { revealSessionInRegistry } from './unarchive.js';
|
|
5
5
|
import { WECOM_BINDABLE_ROOMS } from './wecom-store.js';
|
|
6
|
+
import { channelHistorySnapshot } from './session-history.js';
|
|
6
7
|
export const name = 'rez-wechat';
|
|
7
8
|
export const inject = ['systemPrompt', 'webServer', 'rezSso'];
|
|
8
9
|
const GUIDANCE = [
|
|
@@ -165,6 +166,42 @@ function getWorkspaceRegistry(ctx) {
|
|
|
165
166
|
return undefined;
|
|
166
167
|
}
|
|
167
168
|
}
|
|
169
|
+
export function channelHistoryRoute(ctx) {
|
|
170
|
+
return {
|
|
171
|
+
kind: 'exact',
|
|
172
|
+
path: '/api/dsh-rez-suite/channel/history',
|
|
173
|
+
handler: async (req, res) => {
|
|
174
|
+
if (!isLoopbackRequest(req))
|
|
175
|
+
return writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
176
|
+
if (req.method !== 'POST')
|
|
177
|
+
return writeJson(res, 405, { error: 'method not allowed' });
|
|
178
|
+
if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) {
|
|
179
|
+
return writeJson(res, 403, { error: 'forbidden: cross-origin' });
|
|
180
|
+
}
|
|
181
|
+
const body = await readJsonBody(req);
|
|
182
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
183
|
+
const maxMessages = body?.maxMessages ?? 40;
|
|
184
|
+
if (!sessionId || typeof maxMessages !== 'number' || !Number.isInteger(maxMessages) || maxMessages < 1 || maxMessages > 100) {
|
|
185
|
+
return writeJson(res, 400, { error: 'invalid sessionId or maxMessages' });
|
|
186
|
+
}
|
|
187
|
+
const abort = new AbortController();
|
|
188
|
+
const close = () => { abort.abort(); };
|
|
189
|
+
res.on('close', close);
|
|
190
|
+
try {
|
|
191
|
+
const controller = ctx.get('sessionController');
|
|
192
|
+
const snapshot = await channelHistorySnapshot(controller, sessionId, maxMessages, abort.signal);
|
|
193
|
+
writeJson(res, 200, snapshot);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (!res.destroyed)
|
|
197
|
+
writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
res.off('close', close);
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
168
205
|
export async function apply(ctx) {
|
|
169
206
|
const channel = new WeixinChannel();
|
|
170
207
|
channel.startIfLoggedIn();
|
|
@@ -175,7 +212,7 @@ export async function apply(ctx) {
|
|
|
175
212
|
order: 162,
|
|
176
213
|
text: GUIDANCE,
|
|
177
214
|
}), 'dsh-rez-wechat: prompt');
|
|
178
|
-
const routes = [...weixinRoutes(channel, ctx), ...wecomRoutes(wecom)];
|
|
215
|
+
const routes = [channelHistoryRoute(ctx), ...weixinRoutes(channel, ctx), ...wecomRoutes(wecom)];
|
|
179
216
|
ctx.effect(() => {
|
|
180
217
|
const disposers = routes.map(route => ctx.webServer.register(route));
|
|
181
218
|
return () => { for (const dispose of disposers)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Reuse the host's official SessionController; never implement a second stream carrier. */
|
|
2
|
+
export interface ChannelSessionController {
|
|
3
|
+
follow(request: {
|
|
4
|
+
address: {
|
|
5
|
+
kind: 'session';
|
|
6
|
+
sessionId: string;
|
|
7
|
+
};
|
|
8
|
+
maxMessages: number;
|
|
9
|
+
}, signal: AbortSignal): AsyncIterable<unknown>;
|
|
10
|
+
}
|
|
11
|
+
export declare function channelHistorySnapshot(controller: ChannelSessionController | undefined, sessionId: string, maxMessages?: number, signal?: AbortSignal): Promise<{
|
|
12
|
+
records: unknown[];
|
|
13
|
+
cursor: number;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export async function channelHistorySnapshot(controller, sessionId, maxMessages = 40, signal) {
|
|
2
|
+
if (!controller)
|
|
3
|
+
throw new Error('未找到官方 sessionController,请使用 Harness 0.1.5-rc.2 启动本测试版。');
|
|
4
|
+
const abort = new AbortController();
|
|
5
|
+
const combined = AbortSignal.any([abort.signal, AbortSignal.timeout(10_000), ...(signal ? [signal] : [])]);
|
|
6
|
+
const stream = controller.follow({ address: { kind: 'session', sessionId }, maxMessages }, combined);
|
|
7
|
+
try {
|
|
8
|
+
for await (const value of stream) {
|
|
9
|
+
combined.throwIfAborted();
|
|
10
|
+
const frame = value;
|
|
11
|
+
if (frame?.type !== 'snapshot' || !Array.isArray(frame.records) || !Number.isSafeInteger(frame.cursor)) {
|
|
12
|
+
throw new Error('官方会话快照格式不兼容,无法读取回复。');
|
|
13
|
+
}
|
|
14
|
+
return { records: frame.records, cursor: frame.cursor };
|
|
15
|
+
}
|
|
16
|
+
throw new Error('官方会话订阅未返回快照。');
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
abort.abort();
|
|
20
|
+
}
|
|
21
|
+
}
|
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;
|
|
@@ -122,7 +135,7 @@ export declare function weixinBridgeEnv(opts: {
|
|
|
122
135
|
launcherPath: string;
|
|
123
136
|
shimScript: string;
|
|
124
137
|
}): NodeJS.ProcessEnv;
|
|
125
|
-
export declare function postRpc(baseUrl: string, method: string, params: Record<string, unknown>, timeoutMs?: number, env?: NodeJS.ProcessEnv): Promise<unknown>;
|
|
138
|
+
export declare function postRpc(baseUrl: string, method: string, params: Record<string, unknown>, timeoutMs?: number, env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<unknown>;
|
|
126
139
|
export declare function extractArchivedSessionIds(body: unknown): string[];
|
|
127
140
|
/** Stock dsh archives one-way and hides blank sessions. Restore + attach + title. */
|
|
128
141
|
export declare function ensureSessionVisible(rpc: RpcFn, sessionId: string, env?: NodeJS.ProcessEnv, workspaceId?: string, loopback?: boolean): Promise<void>;
|
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;
|
|
738
793
|
for (const event of events) {
|
|
739
794
|
if (typeof event !== 'object' || event === null)
|
|
740
795
|
continue;
|
|
741
796
|
const rec = event;
|
|
742
797
|
const type = typeof rec.type === 'string' ? rec.type : '';
|
|
743
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 = [];
|
|
805
|
+
for (const event of events) {
|
|
806
|
+
if (typeof event !== 'object' || event === null)
|
|
807
|
+
continue;
|
|
808
|
+
const rec = event;
|
|
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
|
+
}
|
|
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;
|
|
@@ -875,7 +956,7 @@ export function weixinBridgeEnv(opts) {
|
|
|
875
956
|
async function sleep(ms) {
|
|
876
957
|
await new Promise(resolve => setTimeout(resolve, ms));
|
|
877
958
|
}
|
|
878
|
-
export async function postRpc(baseUrl, method, params, timeoutMs = RPC_TIMEOUT_MS, env = process.env) {
|
|
959
|
+
export async function postRpc(baseUrl, method, params, timeoutMs = RPC_TIMEOUT_MS, env = process.env, signal) {
|
|
879
960
|
const origin = (() => {
|
|
880
961
|
try {
|
|
881
962
|
const withScheme = /:\/\//.test(baseUrl) ? baseUrl : `http://${baseUrl}`;
|
|
@@ -888,7 +969,9 @@ export async function postRpc(baseUrl, method, params, timeoutMs = RPC_TIMEOUT_M
|
|
|
888
969
|
})();
|
|
889
970
|
const endpoint = normalizeRpcEndpoint(method);
|
|
890
971
|
const url = `${origin}/api/${endpoint}`;
|
|
891
|
-
const envelope =
|
|
972
|
+
const envelope = endpoint === 'dsh-rez-suite/channel/history'
|
|
973
|
+
? params
|
|
974
|
+
: encodeClientRequest(endpoint, params);
|
|
892
975
|
const send = async (cookie) => {
|
|
893
976
|
const headers = { 'content-type': 'application/json' };
|
|
894
977
|
if (cookie !== undefined && cookie.length > 0)
|
|
@@ -897,7 +980,7 @@ export async function postRpc(baseUrl, method, params, timeoutMs = RPC_TIMEOUT_M
|
|
|
897
980
|
method: 'POST',
|
|
898
981
|
headers,
|
|
899
982
|
body: JSON.stringify(envelope),
|
|
900
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
983
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
901
984
|
});
|
|
902
985
|
};
|
|
903
986
|
let cookie = await authCookieFor(origin, env);
|
|
@@ -976,38 +1059,21 @@ async function maybeSelectModel(rpc, sessionId, env) {
|
|
|
976
1059
|
}
|
|
977
1060
|
}
|
|
978
1061
|
}
|
|
979
|
-
/**
|
|
1062
|
+
/** Read the official follow opening snapshot via our authenticated loopback route.
|
|
1063
|
+
* session/page requires a real cursor; -1 means an empty log, not its tip.
|
|
1064
|
+
*/
|
|
980
1065
|
async function fetchSessionHistory(rpc, sessionId, maxMessages = 40) {
|
|
981
|
-
|
|
982
|
-
{
|
|
983
|
-
method: 'session/page',
|
|
984
|
-
params: {
|
|
985
|
-
address: { kind: 'session', sessionId },
|
|
986
|
-
// Official tip-of-log sentinel. MAX_SAFE_INTEGER is "past cursor" and always 400s.
|
|
987
|
-
throughSeq: -1,
|
|
988
|
-
maxMessages,
|
|
989
|
-
},
|
|
990
|
-
},
|
|
991
|
-
{ method: 'session/history', params: { sessionId, maxMessages } },
|
|
992
|
-
];
|
|
993
|
-
let lastError;
|
|
994
|
-
for (const attempt of attempts) {
|
|
995
|
-
try {
|
|
996
|
-
return historyEvents(await rpc(attempt.method, attempt.params));
|
|
997
|
-
}
|
|
998
|
-
catch (error) {
|
|
999
|
-
lastError = error;
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1066
|
+
return historyEvents(await rpc('dsh-rez-suite/channel/history', { sessionId, maxMessages }));
|
|
1003
1067
|
}
|
|
1004
1068
|
async function sessionAlive(rpc, sessionId) {
|
|
1005
1069
|
try {
|
|
1006
1070
|
await fetchSessionHistory(rpc, sessionId, 1);
|
|
1007
1071
|
return true;
|
|
1008
1072
|
}
|
|
1009
|
-
catch {
|
|
1010
|
-
|
|
1073
|
+
catch (error) {
|
|
1074
|
+
if (/not.found|不存在/i.test(error instanceof Error ? error.message : String(error)))
|
|
1075
|
+
return false;
|
|
1076
|
+
throw error;
|
|
1011
1077
|
}
|
|
1012
1078
|
}
|
|
1013
1079
|
function promptPayload(sessionId, text) {
|
|
@@ -1124,21 +1190,17 @@ function composeWechatReply(events, ignorePending) {
|
|
|
1124
1190
|
}
|
|
1125
1191
|
return { text: visible, pendingQuestions };
|
|
1126
1192
|
}
|
|
1127
|
-
async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePending, stuckText = STUCK_ACK, signal) {
|
|
1193
|
+
async function waitForAssistant(rpc, sessionId, beforeText, beforeSeq, timeoutMs, ignorePending, stuckText = STUCK_ACK, signal) {
|
|
1128
1194
|
const deadline = Date.now() + timeoutMs;
|
|
1129
1195
|
let last = { text: '', pendingQuestions: [] };
|
|
1130
|
-
|
|
1196
|
+
const emptyDone = '(本轮无文字回复,请到网页查看)';
|
|
1131
1197
|
while (Date.now() < deadline) {
|
|
1132
1198
|
if (signal?.aborted === true) {
|
|
1133
1199
|
throw new Error('本轮已被新消息打断(可发「重启对话」或再问一句)');
|
|
1134
1200
|
}
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
}
|
|
1139
|
-
catch {
|
|
1140
|
-
events = [];
|
|
1141
|
-
}
|
|
1201
|
+
const history = await fetchSessionHistory(rpc, sessionId, 40);
|
|
1202
|
+
const cursorAware = beforeSeq >= 0 || history.some(event => eventSeq(event) >= 0);
|
|
1203
|
+
const events = cursorAware ? history.filter(event => eventSeq(event) > beforeSeq) : history;
|
|
1142
1204
|
const composed = composeWechatReply(events, ignorePending);
|
|
1143
1205
|
// 旧题(本轮 prompt 前就在的 Selection)不能立刻发回微信;只发新 turn 里新出现的题。
|
|
1144
1206
|
if (composed.pendingQuestions.length > 0 && composed.text !== '') {
|
|
@@ -1148,12 +1210,17 @@ async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePen
|
|
|
1148
1210
|
return { text: stuckText, pendingQuestions: [] };
|
|
1149
1211
|
if (composed.text !== '')
|
|
1150
1212
|
last = composed;
|
|
1213
|
+
if (cursorAware && turnSettledAfter(events, beforeSeq)) {
|
|
1214
|
+
if (composed.text !== '')
|
|
1215
|
+
return composed;
|
|
1216
|
+
// Turn finished but no new usable text (tool-only / filtered monologue).
|
|
1217
|
+
return {
|
|
1218
|
+
text: emptyDone,
|
|
1219
|
+
pendingQuestions: composed.pendingQuestions,
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1151
1222
|
const idle = turnIsIdle(events);
|
|
1152
|
-
if (idle)
|
|
1153
|
-
idleOnce = true;
|
|
1154
|
-
if (idle && composed.text !== '' && composed.text !== beforeText)
|
|
1155
|
-
return composed;
|
|
1156
|
-
if (idleOnce && composed.text !== '' && composed.text !== beforeText)
|
|
1223
|
+
if (!cursorAware && idle && composed.text !== '' && composed.text !== beforeText)
|
|
1157
1224
|
return composed;
|
|
1158
1225
|
await sleep(400);
|
|
1159
1226
|
}
|
|
@@ -1170,7 +1237,7 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1170
1237
|
const storeFile = sessionStorePath(env);
|
|
1171
1238
|
const key = basename(opts.cwd ?? process.cwd()) || 'default';
|
|
1172
1239
|
const customRpc = opts.rpc !== undefined;
|
|
1173
|
-
const rpc = opts.rpc ?? ((method, params) => postRpc(webBaseUrl(env), method, params));
|
|
1240
|
+
const rpc = opts.rpc ?? ((method, params) => postRpc(webBaseUrl(env), method, params, RPC_TIMEOUT_MS, env, opts.signal));
|
|
1174
1241
|
const timeoutMs = opts.timeoutMs ?? Number(env.DSH_BRIDGE_TIMEOUT_MS ?? 10 * 60 * 1000);
|
|
1175
1242
|
const signal = opts.signal;
|
|
1176
1243
|
const sidebarUnarchive = opts.sidebarUnarchive !== false && !customRpc;
|
|
@@ -1207,15 +1274,9 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1207
1274
|
if (signal?.aborted === true)
|
|
1208
1275
|
throw new Error('本轮已被新消息打断');
|
|
1209
1276
|
const promptText = restart ? RESTART_SEED : text;
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
events = await fetchSessionHistory(rpc, sessionId, 40);
|
|
1214
|
-
before = usableAssistantText(lastAssistantText(events));
|
|
1215
|
-
}
|
|
1216
|
-
catch {
|
|
1217
|
-
before = '';
|
|
1218
|
-
}
|
|
1277
|
+
const events = await fetchSessionHistory(rpc, sessionId, 40);
|
|
1278
|
+
const before = usableAssistantText(lastAssistantText(events));
|
|
1279
|
+
const beforeSeq = historyCursor(events);
|
|
1219
1280
|
const pending = extractPendingQuestions(events);
|
|
1220
1281
|
const questions = pending.length > 0
|
|
1221
1282
|
? pending
|
|
@@ -1240,7 +1301,8 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1240
1301
|
}
|
|
1241
1302
|
await rpc('session/prompt', promptPayload(sessionId, promptText));
|
|
1242
1303
|
}
|
|
1243
|
-
const reply = await waitForAssistant(rpc, sessionId, before, timeoutMs, questions, stuckAck(folder), signal);
|
|
1304
|
+
const reply = await waitForAssistant(rpc, sessionId, before, beforeSeq, timeoutMs, questions, stuckAck(folder), signal);
|
|
1305
|
+
signal?.throwIfAborted();
|
|
1244
1306
|
// Stuck / approval-blocked sessions poison the sticky pointer — drop it so
|
|
1245
1307
|
// the next WeCom/WeChat turn opens a fresh web session instead of hanging.
|
|
1246
1308
|
if (reply.text === stuckAck(folder)) {
|
package/lib/wecom-channel.d.ts
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
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
|
+
/** Official replyStream content limit, measured in UTF-8 bytes. */
|
|
8
|
+
export declare const WECOM_REPLY_MAX_BYTES = 20480;
|
|
9
|
+
export declare function clipWecomReply(text: string): string;
|
|
10
|
+
/** Bound even a turn implementation that fails to observe cancellation. */
|
|
11
|
+
export declare function abortableTurn<T>(work: Promise<T>, signal: AbortSignal): Promise<T>;
|
|
7
12
|
export type WecomBindingPhase = 'idle' | 'connecting' | 'authenticated' | 'error';
|
|
8
13
|
export interface WecomBindingStatus {
|
|
9
14
|
room: string;
|
package/lib/wecom-channel.js
CHANGED
|
@@ -7,6 +7,42 @@ 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
|
+
/** Official replyStream content limit, measured in UTF-8 bytes. */
|
|
11
|
+
export const WECOM_REPLY_MAX_BYTES = 20_480;
|
|
12
|
+
export function clipWecomReply(text) {
|
|
13
|
+
if (Buffer.byteLength(text, 'utf8') <= WECOM_REPLY_MAX_BYTES)
|
|
14
|
+
return text;
|
|
15
|
+
const suffix = '\n…(已截断,请到网页查看完整回复)';
|
|
16
|
+
const budget = WECOM_REPLY_MAX_BYTES - Buffer.byteLength(suffix, 'utf8');
|
|
17
|
+
let bytes = 0;
|
|
18
|
+
let end = 0;
|
|
19
|
+
for (const character of text) {
|
|
20
|
+
bytes += Buffer.byteLength(character, 'utf8');
|
|
21
|
+
if (bytes > budget)
|
|
22
|
+
break;
|
|
23
|
+
end += character.length;
|
|
24
|
+
}
|
|
25
|
+
return text.slice(0, end) + suffix;
|
|
26
|
+
}
|
|
27
|
+
/** Bound even a turn implementation that fails to observe cancellation. */
|
|
28
|
+
export async function abortableTurn(work, signal) {
|
|
29
|
+
let abort;
|
|
30
|
+
try {
|
|
31
|
+
return await Promise.race([
|
|
32
|
+
work,
|
|
33
|
+
new Promise((_, reject) => {
|
|
34
|
+
abort = () => reject(signal.reason ?? new Error('本轮已被新消息打断'));
|
|
35
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
36
|
+
if (signal.aborted)
|
|
37
|
+
abort();
|
|
38
|
+
}),
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
if (abort)
|
|
43
|
+
signal.removeEventListener('abort', abort);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
10
46
|
/** Cap how long one WeCom turn can block the per-room queue (stuck Kimi / approval). */
|
|
11
47
|
export const WECOM_TURN_TIMEOUT_MS = 90_000;
|
|
12
48
|
const MAX_SEEN_MSGIDS = 200;
|
|
@@ -163,6 +199,7 @@ export class WecomChannel {
|
|
|
163
199
|
}
|
|
164
200
|
dropBindings() {
|
|
165
201
|
for (const row of this.bindings.values()) {
|
|
202
|
+
row.turnAbort?.abort();
|
|
166
203
|
try {
|
|
167
204
|
row.client.disconnect();
|
|
168
205
|
}
|
|
@@ -229,6 +266,8 @@ export class WecomChannel {
|
|
|
229
266
|
* queue reaches it). Otherwise「重启对话」waits behind a hung「正在处理…」.
|
|
230
267
|
*/
|
|
231
268
|
enqueueText(row, frame) {
|
|
269
|
+
if (this.rememberMsgid(row, frame))
|
|
270
|
+
return;
|
|
232
271
|
const text = extractWecomText(frame);
|
|
233
272
|
if (text.length > 0) {
|
|
234
273
|
row.turnAbort?.abort();
|
|
@@ -236,7 +275,7 @@ export class WecomChannel {
|
|
|
236
275
|
clearSessionStoreKey(join(this.home, 'dsh-rez-wecom', row.bot.room, 'web-sessions.json'), row.bot.room);
|
|
237
276
|
}
|
|
238
277
|
}
|
|
239
|
-
this.enqueue(row, () => this.handleText(row, frame));
|
|
278
|
+
this.enqueue(row, () => this.handleText(row, frame, true));
|
|
240
279
|
}
|
|
241
280
|
rememberMsgid(row, frame) {
|
|
242
281
|
const id = wecomMsgid(frame);
|
|
@@ -249,8 +288,8 @@ export class WecomChannel {
|
|
|
249
288
|
row.seen.splice(0, row.seen.length - MAX_SEEN_MSGIDS);
|
|
250
289
|
return false;
|
|
251
290
|
}
|
|
252
|
-
async handleText(row, frame) {
|
|
253
|
-
if (this.rememberMsgid(row, frame))
|
|
291
|
+
async handleText(row, frame, remembered = false) {
|
|
292
|
+
if (!remembered && this.rememberMsgid(row, frame))
|
|
254
293
|
return;
|
|
255
294
|
const text = extractWecomText(frame);
|
|
256
295
|
const streamId = newStreamId();
|
|
@@ -262,21 +301,29 @@ export class WecomChannel {
|
|
|
262
301
|
row.turnAbort = turnAbort;
|
|
263
302
|
const timeout = AbortSignal.timeout(WECOM_TURN_TIMEOUT_MS);
|
|
264
303
|
const signal = AbortSignal.any([turnAbort.signal, timeout]);
|
|
304
|
+
let finished = false;
|
|
305
|
+
const finish = async (content) => {
|
|
306
|
+
if (finished)
|
|
307
|
+
return;
|
|
308
|
+
await row.client.replyStream(frame, streamId, clipWecomReply(content), true);
|
|
309
|
+
finished = true;
|
|
310
|
+
};
|
|
265
311
|
try {
|
|
266
312
|
await row.client.replyStream(frame, streamId, '正在处理…', false);
|
|
267
|
-
const reply = await this.runTurn(row.bot.room, text, signal);
|
|
313
|
+
const reply = await abortableTurn(this.runTurn(row.bot.room, text, signal), signal);
|
|
268
314
|
if (turnAbort.signal.aborted) {
|
|
269
|
-
await
|
|
315
|
+
await finish('已取消(收到新消息)');
|
|
270
316
|
return;
|
|
271
317
|
}
|
|
272
|
-
await
|
|
318
|
+
await finish(reply.length > 0 ? reply : '(空回复)');
|
|
273
319
|
}
|
|
274
320
|
catch (error) {
|
|
275
321
|
const why = error instanceof Error ? error.message : String(error);
|
|
322
|
+
row.hint = `「${row.bot.room}」回复未完成:${why}`;
|
|
276
323
|
const superseded = turnAbort.signal.aborted && !timeout.aborted;
|
|
277
324
|
if (superseded) {
|
|
278
325
|
try {
|
|
279
|
-
await
|
|
326
|
+
await finish('已取消(收到新消息)');
|
|
280
327
|
}
|
|
281
328
|
catch { /* WS may already be closed */ }
|
|
282
329
|
return;
|
|
@@ -286,7 +333,7 @@ export class WecomChannel {
|
|
|
286
333
|
? '处理超时。请发「重启对话」再试,或到网页左侧该房间看是否在等批准。'
|
|
287
334
|
: `处理失败:${why}`;
|
|
288
335
|
try {
|
|
289
|
-
await
|
|
336
|
+
await finish(message);
|
|
290
337
|
}
|
|
291
338
|
catch {
|
|
292
339
|
/* WS may already be closed */
|
|
@@ -295,6 +342,12 @@ export class WecomChannel {
|
|
|
295
342
|
clearSessionStoreKey(join(this.home, 'dsh-rez-wecom', row.bot.room, 'web-sessions.json'), row.bot.room);
|
|
296
343
|
}
|
|
297
344
|
finally {
|
|
345
|
+
if (!finished) {
|
|
346
|
+
try {
|
|
347
|
+
await finish('(回复中断,请再发一句或「重启对话」)');
|
|
348
|
+
}
|
|
349
|
+
catch { /* WS may already be closed */ }
|
|
350
|
+
}
|
|
298
351
|
if (row.turnAbort === turnAbort)
|
|
299
352
|
delete row.turnAbort;
|
|
300
353
|
}
|
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.24",
|
|
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": {
|