@xmanrui/dsh-im 4.12.0 → 4.13.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.
- package/PROACTIVE_DELIVERY.en.md +4 -0
- package/PROACTIVE_DELIVERY.md +4 -0
- package/README.en.md +11 -2
- package/README.md +11 -2
- package/assets/dingtalk-menu-template.json +6 -0
- package/lib/client.js +3 -2
- package/lib/index.js +268 -263
- package/package.json +1 -1
- package/plugin-src/client/styles.js +2 -1
- package/plugin-src/host/harness-command-executor.mjs +13 -6
- package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +136 -2
- package/src/channels/dingtalk/dingtalk-menu.mjs +91 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +17 -0
- package/src/channels/dingtalk/state-store.mjs +8 -0
- package/src/channels/feishu/bridge.mjs +74 -3
- package/src/channels/feishu/state-store.mjs +11 -0
- package/src/channels/qq/qq-bridge.mjs +121 -1
- package/src/channels/qq/qq-menu.mjs +205 -0
- package/src/channels/qq/state-store.mjs +16 -0
- package/src/channels/shared/bot-workspace-store.mjs +3 -0
- package/src/channels/shared/control-command.mjs +8 -0
- package/src/channels/shared/conversation-state-store.mjs +8 -0
- package/src/channels/shared/deferred-delivery-coordinator.mjs +241 -0
- package/src/channels/shared/deferred-delivery.mjs +161 -0
- package/src/channels/shared/deferred-state.mjs +53 -0
- package/src/channels/shared/harness-client.mjs +21 -1
- package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
- package/src/channels/shared/i18n-en/feishu.mjs +6 -0
- package/src/channels/shared/i18n-en/qq.mjs +39 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/text-harness-bridge.mjs +15 -0
- package/src/channels/shared/workspace-session.mjs +16 -1
- package/src/channels/wecom/state-store.mjs +8 -0
- package/src/channels/wecom/wecom-bridge.mjs +19 -4
- package/src/channels/weixin/state-store.mjs +64 -1
- package/src/channels/weixin/weixin-bridge.mjs +31 -5
- package/src/channels/weixin/weixin-runtime.mjs +33 -8
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { createDeferredDeliveryRegistry, extractCompletedTurnAnswer } from './deferred-delivery.mjs';
|
|
2
|
+
import { t } from './i18n.mjs';
|
|
3
|
+
import { channelDeliveryFailure } from './message-failure.mjs';
|
|
4
|
+
|
|
5
|
+
export function deferredOutcomeText(outcome) {
|
|
6
|
+
if (outcome.found && outcome.text) return outcome.text;
|
|
7
|
+
const reason = outcome.reason === 'stopped' ? t('已停止')
|
|
8
|
+
: ['aborted', 'cancelled'].includes(outcome.reason) ? t('已中止')
|
|
9
|
+
: outcome.reason === 'failed' ? t('任务失败') : t('已结束');
|
|
10
|
+
return t('后台任务已结束({reason}),没有可推送的最终结果。', { reason });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function orderedEvents(history) {
|
|
14
|
+
return (Array.isArray(history?.events) ? history.events : [])
|
|
15
|
+
.map((row) => row?.event ?? row)
|
|
16
|
+
.filter((event) => Number.isSafeInteger(event?.seq) && event.seq >= 0)
|
|
17
|
+
.sort((a, b) => a.seq - b.seq);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One coordinator per bot. All history reads, sends, removal and stops for a
|
|
21
|
+
* Session use the same queue. Queued work stores ids, never stale entry objects.
|
|
22
|
+
* Durable pending rows survive crashes; there is no persisted pre-send claim.
|
|
23
|
+
*/
|
|
24
|
+
export function createDeferredDeliveryCoordinator({
|
|
25
|
+
harness, state, deliver, signal, logger = console,
|
|
26
|
+
retryDelayMs = 1_000, pollDelayMs = 30_000, watch = true,
|
|
27
|
+
} = {}) {
|
|
28
|
+
const enabled = ['deferredEntries', 'putDeferred', 'patchDeferred', 'removeDeferred']
|
|
29
|
+
.every((name) => typeof state?.[name] === 'function');
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const activeSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
32
|
+
const tails = new Map();
|
|
33
|
+
const timers = new Map();
|
|
34
|
+
const registry = createDeferredDeliveryRegistry({
|
|
35
|
+
listDeferred: async () => state.deferredEntries(),
|
|
36
|
+
putDeferred: (entry) => state.putDeferred(entry),
|
|
37
|
+
patchDeferred: (id, patch) => state.patchDeferred(id, patch),
|
|
38
|
+
removeDeferred: (id) => state.removeDeferred(id),
|
|
39
|
+
}, { logger });
|
|
40
|
+
const warn = (error) => { if (!activeSignal.aborted) logger.warn?.('[dsh-im] deferred delivery:', error?.message ?? String(error)); };
|
|
41
|
+
const pending = async (id) => (await registry.allPending()).find((entry) => entry.id === id);
|
|
42
|
+
const bound = (entry) => !activeSignal.aborted && state.sessionFor(entry.key) === entry.sessionId;
|
|
43
|
+
function enqueue(sessionId, operation) {
|
|
44
|
+
const previous = tails.get(sessionId) ?? Promise.resolve();
|
|
45
|
+
const task = previous.then(() => activeSignal.aborted ? undefined : operation());
|
|
46
|
+
const tail = task.catch(warn).finally(() => { if (tails.get(sessionId) === tail) tails.delete(sessionId); });
|
|
47
|
+
tails.set(sessionId, tail);
|
|
48
|
+
return task;
|
|
49
|
+
}
|
|
50
|
+
function schedule(entry, delay = pollDelayMs) {
|
|
51
|
+
if (activeSignal.aborted || timers.has(entry.id)) return;
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
timers.delete(entry.id);
|
|
54
|
+
void enqueue(entry.sessionId, () => process(entry.id)).catch(warn);
|
|
55
|
+
}, delay);
|
|
56
|
+
timer.unref?.();
|
|
57
|
+
timers.set(entry.id, timer);
|
|
58
|
+
}
|
|
59
|
+
function clearTimer(id) {
|
|
60
|
+
clearTimeout(timers.get(id));
|
|
61
|
+
timers.delete(id);
|
|
62
|
+
}
|
|
63
|
+
async function remove(entry) {
|
|
64
|
+
clearTimer(entry.id);
|
|
65
|
+
await registry.remove(entry);
|
|
66
|
+
}
|
|
67
|
+
async function readOutcome(entry) {
|
|
68
|
+
// A result may be older than the latest history page after a restart.
|
|
69
|
+
// Recover the whole target turn instead of treating a missing page as empty.
|
|
70
|
+
const events = new Map();
|
|
71
|
+
const historySignal = AbortSignal.any([activeSignal, AbortSignal.timeout(30_000)]);
|
|
72
|
+
let beforeSeq;
|
|
73
|
+
let targetTurn = Number.isSafeInteger(entry.turn) ? entry.turn : null;
|
|
74
|
+
for (let page = 0; page < 20; page += 1) {
|
|
75
|
+
historySignal.throwIfAborted();
|
|
76
|
+
const history = await harness.rpc('session.history', {
|
|
77
|
+
sessionId: entry.sessionId, maxMessages: 100,
|
|
78
|
+
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
|
79
|
+
}, 30_000, { signal: historySignal });
|
|
80
|
+
const batch = orderedEvents(history);
|
|
81
|
+
for (const event of batch) events.set(event.seq, event);
|
|
82
|
+
const ordered = [...events.values()].sort((a, b) => a.seq - b.seq);
|
|
83
|
+
if (targetTurn === null && entry.promptRpcId) {
|
|
84
|
+
let openTurn = null;
|
|
85
|
+
for (const event of ordered) {
|
|
86
|
+
if (event.type === 'turn/start') openTurn = event.data?.turn ?? null;
|
|
87
|
+
if (event.type === 'user/message' && event.data?.source?.rpcId === entry.promptRpcId) {
|
|
88
|
+
targetTurn = Number.isSafeInteger(event.data?.turn) ? event.data.turn : openTurn;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
if (event.type === 'turn/end') openTurn = null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const reachedCursor = !history.hasMore || ordered[0]?.seq <= entry.afterSeq;
|
|
95
|
+
if (targetTurn === null && !entry.promptRpcId && reachedCursor) {
|
|
96
|
+
// Legacy #153 rows had no prompt identity. Never select a completion
|
|
97
|
+
// preceding the saved cursor, or a newer arbitrary completed turn.
|
|
98
|
+
targetTurn = ordered.find((event) => event.type === 'turn/end'
|
|
99
|
+
&& event.seq > entry.afterSeq)?.data?.turn ?? null;
|
|
100
|
+
}
|
|
101
|
+
const outcome = targetTurn === null
|
|
102
|
+
? { found: false, endSeq: -1, turn: null }
|
|
103
|
+
: extractCompletedTurnAnswer(ordered, { turn: targetTurn });
|
|
104
|
+
const hasStart = ordered.some((event) => event.type === 'turn/start' && event.data?.turn === targetTurn);
|
|
105
|
+
if (!history.hasMore || hasStart) return { ...outcome, turn: targetTurn };
|
|
106
|
+
const oldest = batch[0]?.seq;
|
|
107
|
+
if (!Number.isSafeInteger(oldest) || oldest === beforeSeq) break;
|
|
108
|
+
beforeSeq = oldest;
|
|
109
|
+
}
|
|
110
|
+
// Bounded reads cannot prove that a truncated answer is complete.
|
|
111
|
+
return { found: false, endSeq: -1, turn: targetTurn };
|
|
112
|
+
}
|
|
113
|
+
async function process(id, { stopping = false, urgent = false } = {}) {
|
|
114
|
+
let entry = await pending(id);
|
|
115
|
+
if (!entry || activeSignal.aborted) return 'none';
|
|
116
|
+
clearTimer(id);
|
|
117
|
+
if (!bound(entry)) { await remove(entry); return 'none'; }
|
|
118
|
+
let sending = false;
|
|
119
|
+
try {
|
|
120
|
+
const outcome = await readOutcome(entry);
|
|
121
|
+
// Binding, status and cancellation can change while the RPC is in flight.
|
|
122
|
+
entry = await pending(id);
|
|
123
|
+
if (!entry || activeSignal.aborted) return 'none';
|
|
124
|
+
if (!bound(entry)) { await remove(entry); return 'none'; }
|
|
125
|
+
if (outcome.endSeq === -1) {
|
|
126
|
+
if (Number.isSafeInteger(outcome.turn) && entry.turn !== outcome.turn) {
|
|
127
|
+
await state.patchDeferred(id, { turn: outcome.turn });
|
|
128
|
+
entry = { ...entry, turn: outcome.turn };
|
|
129
|
+
}
|
|
130
|
+
if (stopping) {
|
|
131
|
+
const session = harness.workspaceSession?.(entry.sessionId);
|
|
132
|
+
const stopped = typeof session?.stopDeferredTurn === 'function'
|
|
133
|
+
? await session.stopDeferredTurn({ turn: entry.turn, promptRpcId: entry.promptRpcId }, {
|
|
134
|
+
signal: activeSignal, isCurrent: () => bound(entry),
|
|
135
|
+
})
|
|
136
|
+
: typeof harness.stopDeferredTurn === 'function'
|
|
137
|
+
? await harness.stopDeferredTurn(entry.sessionId, { turn: entry.turn, promptRpcId: entry.promptRpcId }, {
|
|
138
|
+
signal: activeSignal, isCurrent: () => bound(entry),
|
|
139
|
+
}) : false;
|
|
140
|
+
schedule(entry, retryDelayMs);
|
|
141
|
+
return stopped ? 'stopped' : 'unavailable';
|
|
142
|
+
}
|
|
143
|
+
schedule(entry, urgent ? retryDelayMs : pollDelayMs);
|
|
144
|
+
return 'waiting';
|
|
145
|
+
}
|
|
146
|
+
sending = true;
|
|
147
|
+
const result = await deliver(entry, outcome);
|
|
148
|
+
if (result === false || result?.deliveryOutcome === 'failed' || result?.deliveryOutcome === 'unknown') {
|
|
149
|
+
throw Object.assign(new Error('Deferred delivery was not confirmed'), {
|
|
150
|
+
deliveryOutcome: result?.deliveryOutcome ?? 'failed',
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
await remove(entry);
|
|
154
|
+
return 'delivered';
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (activeSignal.aborted) return 'none';
|
|
157
|
+
if (error?.code === 'session-not-found') { await remove(entry); return 'none'; }
|
|
158
|
+
const current = await pending(id);
|
|
159
|
+
if (!current) return 'none';
|
|
160
|
+
if (!sending) {
|
|
161
|
+
warn(error);
|
|
162
|
+
schedule(current);
|
|
163
|
+
return stopping ? 'unavailable' : 'waiting';
|
|
164
|
+
}
|
|
165
|
+
const uncertain = error?.deliveryOutcome === 'unknown'
|
|
166
|
+
|| (error?.deliveryOutcome !== 'failed'
|
|
167
|
+
&& channelDeliveryFailure(error).code === 'channel-delivery-uncertain');
|
|
168
|
+
if (uncertain) {
|
|
169
|
+
// The provider may already have accepted the message. Preserve the
|
|
170
|
+
// existing delivery-uncertain rule instead of retrying a possible copy.
|
|
171
|
+
await state.patchDeferred(id, { status: 'failed', deliveryOutcome: 'unknown', attempts: (current.attempts ?? 0) + 1 });
|
|
172
|
+
warn(error);
|
|
173
|
+
return stopping ? 'unavailable' : 'unknown';
|
|
174
|
+
}
|
|
175
|
+
const attempts = await registry.markFailedAttempt(current);
|
|
176
|
+
warn(error);
|
|
177
|
+
if (attempts < 3) schedule(current, retryDelayMs * 2 ** (attempts - 1));
|
|
178
|
+
return stopping ? 'unavailable' : 'retry';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function resume() {
|
|
182
|
+
if (!enabled || activeSignal.aborted) return;
|
|
183
|
+
const entries = await registry.allPending();
|
|
184
|
+
await Promise.allSettled(entries.map((entry) => enqueue(entry.sessionId, () => process(entry.id))));
|
|
185
|
+
}
|
|
186
|
+
const startup = Promise.resolve().then(async () => {
|
|
187
|
+
if (!enabled || activeSignal.aborted) return;
|
|
188
|
+
if (watch && typeof harness.watchHarnessEvents === 'function') {
|
|
189
|
+
try {
|
|
190
|
+
Promise.resolve(harness.watchHarnessEvents({ signal: activeSignal,
|
|
191
|
+
onSessionEvent: (payload) => { void api.onEvent(payload).catch(warn); },
|
|
192
|
+
onReconnect: () => { void resume().catch(warn); },
|
|
193
|
+
})).catch(warn);
|
|
194
|
+
} catch (error) { warn(error); }
|
|
195
|
+
}
|
|
196
|
+
await resume();
|
|
197
|
+
}).catch(warn);
|
|
198
|
+
activeSignal.addEventListener('abort', () => {
|
|
199
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
200
|
+
timers.clear();
|
|
201
|
+
}, { once: true });
|
|
202
|
+
const api = {
|
|
203
|
+
async trackTimeout(error, { key, sessionId, ...delivery }) {
|
|
204
|
+
if (!enabled || error?.code !== 'harness-reply-timeout' || activeSignal.aborted) return;
|
|
205
|
+
const details = error.details ?? {};
|
|
206
|
+
const target = details.sessionId ?? sessionId ?? state.sessionFor(key);
|
|
207
|
+
if (!target || state.sessionFor(key) !== target) return;
|
|
208
|
+
const entry = await registry.register({ key, sessionId: target,
|
|
209
|
+
turn: details.turn, promptRpcId: details.promptRpcId,
|
|
210
|
+
afterSeq: details.baselineSeq ?? details.lastSeq ?? -1,
|
|
211
|
+
...delivery,
|
|
212
|
+
});
|
|
213
|
+
void enqueue(target, () => process(entry.id)).catch(warn);
|
|
214
|
+
},
|
|
215
|
+
async onEvent({ sessionId, event } = {}) {
|
|
216
|
+
if (!enabled || activeSignal.aborted || event?.type !== 'turn/end') return;
|
|
217
|
+
const entries = await registry.pendingForSession(sessionId);
|
|
218
|
+
await Promise.allSettled(entries
|
|
219
|
+
.filter((entry) => !Number.isSafeInteger(entry.turn) || entry.turn === event.data?.turn)
|
|
220
|
+
.map((entry) => enqueue(sessionId, () => process(entry.id, { urgent: true }))));
|
|
221
|
+
},
|
|
222
|
+
resume,
|
|
223
|
+
async stop(key) {
|
|
224
|
+
if (!enabled || activeSignal.aborted) return 'none';
|
|
225
|
+
const entries = await registry.pendingForKey(key);
|
|
226
|
+
let outcome = 'none';
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
const result = await enqueue(entry.sessionId, () => process(entry.id, { stopping: true }));
|
|
229
|
+
if (result === 'stopped') outcome = 'stopped';
|
|
230
|
+
else if (result === 'unavailable' && outcome !== 'stopped') outcome = 'unavailable';
|
|
231
|
+
}
|
|
232
|
+
return outcome;
|
|
233
|
+
},
|
|
234
|
+
async whenIdle() {
|
|
235
|
+
await startup;
|
|
236
|
+
while (tails.size) await Promise.allSettled([...tails.values()]);
|
|
237
|
+
},
|
|
238
|
+
close() { controller.abort(); },
|
|
239
|
+
};
|
|
240
|
+
return api;
|
|
241
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { AssistantTextAccumulator } from './harness-client.mjs';
|
|
2
|
+
|
|
3
|
+
export const MAX_DEFERRED_PER_KEY = 4;
|
|
4
|
+
|
|
5
|
+
/** Classify one turn/end event into a terminal-reason label. */
|
|
6
|
+
export function terminalOutcomeOf(turnEndEvent) {
|
|
7
|
+
const raw = turnEndEvent?.data?.reason;
|
|
8
|
+
const kind = typeof raw === 'string'
|
|
9
|
+
? raw
|
|
10
|
+
: typeof raw?.kind === 'string' ? raw.kind : null;
|
|
11
|
+
return { kind: kind ?? 'other' };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function eventOf(entry) {
|
|
15
|
+
return entry?.event ?? (entry?.type ? entry : null);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function orderedEvents(entries) {
|
|
19
|
+
return (Array.isArray(entries) ? entries : [])
|
|
20
|
+
.map(eventOf)
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function textFromContent(content) {
|
|
26
|
+
if (!Array.isArray(content)) return '';
|
|
27
|
+
return content
|
|
28
|
+
.flatMap((block) => (block?.type === 'text' && typeof block.text === 'string' ? [block.text] : []))
|
|
29
|
+
.join('\n')
|
|
30
|
+
.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract the final answer of one completed turn from durable history events.
|
|
35
|
+
*
|
|
36
|
+
* Reuses HarnessReplyTracker text accumulation: chunk deltas accumulate per
|
|
37
|
+
* (step, part index); a canonical assistant/message replaces its step; steps
|
|
38
|
+
* join with a blank line. `turn` omitted → prefer the latest completed answer.
|
|
39
|
+
* Returns `{ found, turn, text, reason, endSeq }`; `found: false` when the
|
|
40
|
+
* turn ended without a completed final answer (stopped/failed/empty/
|
|
41
|
+
* interrupted) — `reason`/`endSeq` still identify the confirmed terminal.
|
|
42
|
+
*/
|
|
43
|
+
export function extractCompletedTurnAnswer(entries, { turn } = {}) {
|
|
44
|
+
const events = orderedEvents(entries);
|
|
45
|
+
const wantTurn = Number.isSafeInteger(turn) ? turn : null;
|
|
46
|
+
function turnAnswer(turnEnd) {
|
|
47
|
+
const accumulator = new AssistantTextAccumulator();
|
|
48
|
+
let interrupted = false;
|
|
49
|
+
for (const event of events) {
|
|
50
|
+
if (event.seq > turnEnd.seq || event.data?.turn !== turnEnd.data.turn) continue;
|
|
51
|
+
if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
|
|
52
|
+
accumulator.appendDelta(event.data.step, event.data.chunk.index, event.data.chunk.text);
|
|
53
|
+
} else if (event.type === 'assistant/message') {
|
|
54
|
+
accumulator.setCanonical(event.data.step, textFromContent(event.data.message?.content));
|
|
55
|
+
interrupted = event.data.interrupted === true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { text: accumulator.text, interrupted };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function scan(matchTurn, acceptAnyTerminal = false) {
|
|
62
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
63
|
+
const event = events[index];
|
|
64
|
+
if (event?.type !== 'turn/end' || !Number.isSafeInteger(event.data?.turn)) continue;
|
|
65
|
+
if (matchTurn !== null && event.data.turn !== matchTurn) continue;
|
|
66
|
+
const raw = event.data?.reason;
|
|
67
|
+
const reason = typeof raw === 'string' ? raw : typeof raw?.kind === 'string' ? raw.kind : null;
|
|
68
|
+
const answer = turnAnswer(event);
|
|
69
|
+
const hasAnswer = reason === 'completed' && !answer.interrupted;
|
|
70
|
+
const text = hasAnswer ? answer.text : null;
|
|
71
|
+
if (hasAnswer && text) {
|
|
72
|
+
return { found: true, turn: event.data.turn, text, reason, endSeq: event.seq ?? -1 };
|
|
73
|
+
}
|
|
74
|
+
if (matchTurn !== null || acceptAnyTerminal) {
|
|
75
|
+
return { found: false, turn: event.data.turn, text: null, reason, endSeq: event.seq ?? -1 };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const completed = scan(wantTurn);
|
|
82
|
+
if (completed) return completed;
|
|
83
|
+
const anyTerminal = scan(wantTurn, true);
|
|
84
|
+
if (anyTerminal) return anyTerminal;
|
|
85
|
+
return { found: false, turn: wantTurn, text: null, reason: null, endSeq: -1 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Registry over a tiny persistence interface:
|
|
90
|
+
* `listDeferred() / putDeferred(entry) / patchDeferred(id, patch) / removeDeferred(id)`.
|
|
91
|
+
* Entries are owned snapshots; prompt ids distinguish tasks even before turn/start.
|
|
92
|
+
*/
|
|
93
|
+
export function createDeferredDeliveryRegistry(store, {
|
|
94
|
+
now = () => Date.now(),
|
|
95
|
+
maxPerKey = MAX_DEFERRED_PER_KEY,
|
|
96
|
+
logger = console,
|
|
97
|
+
} = {}) {
|
|
98
|
+
function deferredId(key, sessionId, turn) {
|
|
99
|
+
return `${key} ${sessionId} ${Number.isSafeInteger(turn) ? turn : 'any'}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
deferredId,
|
|
104
|
+
async register({
|
|
105
|
+
key, chatId = null, target = null, replyToMessageId = null, sessionId, turn = null, afterSeq = -1, promptRpcId = null,
|
|
106
|
+
}) {
|
|
107
|
+
if (typeof key !== 'string' || !key) throw new TypeError('key is required');
|
|
108
|
+
if ((!chatId || typeof chatId !== 'string') && (!target || typeof target !== 'object')) {
|
|
109
|
+
throw new TypeError('chatId or a channel target is required');
|
|
110
|
+
}
|
|
111
|
+
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
112
|
+
const entry = {
|
|
113
|
+
id: promptRpcId ? `${key} ${sessionId} ${promptRpcId}` : deferredId(key, sessionId, turn),
|
|
114
|
+
key,
|
|
115
|
+
chatId,
|
|
116
|
+
...(target ? { target: structuredClone(target) } : {}),
|
|
117
|
+
replyToMessageId: typeof replyToMessageId === 'string' ? replyToMessageId : null,
|
|
118
|
+
sessionId,
|
|
119
|
+
promptRpcId,
|
|
120
|
+
turn: Number.isSafeInteger(turn) ? turn : null,
|
|
121
|
+
afterSeq: Number.isSafeInteger(afterSeq) ? afterSeq : -1,
|
|
122
|
+
lastSeenEndSeq: -1,
|
|
123
|
+
attempts: 0,
|
|
124
|
+
status: 'pending',
|
|
125
|
+
createdAt: now(),
|
|
126
|
+
};
|
|
127
|
+
const sameKey = (await store.listDeferred())
|
|
128
|
+
.filter((candidate) => candidate.key === key && candidate.status === 'pending');
|
|
129
|
+
const existing = sameKey.find((candidate) => candidate.id === entry.id);
|
|
130
|
+
if (existing) return existing;
|
|
131
|
+
if (sameKey.length >= maxPerKey) {
|
|
132
|
+
logger?.warn?.('[dsh-im] deferred delivery limit reached for key; dropping the oldest entry');
|
|
133
|
+
await store.removeDeferred(sameKey[0].id);
|
|
134
|
+
}
|
|
135
|
+
await store.putDeferred(entry);
|
|
136
|
+
return entry;
|
|
137
|
+
},
|
|
138
|
+
async pendingForSession(sessionId) {
|
|
139
|
+
return (await store.listDeferred())
|
|
140
|
+
.filter((entry) => entry.status === 'pending' && entry.sessionId === sessionId);
|
|
141
|
+
},
|
|
142
|
+
async pendingForKey(key) {
|
|
143
|
+
return (await store.listDeferred())
|
|
144
|
+
.filter((entry) => entry.status === 'pending' && entry.key === key);
|
|
145
|
+
},
|
|
146
|
+
async allPending() {
|
|
147
|
+
return (await store.listDeferred()).filter((entry) => entry.status === 'pending');
|
|
148
|
+
},
|
|
149
|
+
async markFailedAttempt(entry) {
|
|
150
|
+
// Read fresh state: the caller's snapshot goes stale across attempts.
|
|
151
|
+
const current = (await store.listDeferred())
|
|
152
|
+
.find((candidate) => candidate.id === entry.id) ?? entry;
|
|
153
|
+
const attempts = (current.attempts ?? 0) + 1;
|
|
154
|
+
await store.patchDeferred(entry.id, { attempts, status: attempts >= 3 ? 'failed' : 'pending' });
|
|
155
|
+
return attempts;
|
|
156
|
+
},
|
|
157
|
+
async remove(entry) {
|
|
158
|
+
await store.removeDeferred(entry.id);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Shared durable outbox surface, stored alongside each bot's conversation state. */
|
|
2
|
+
export function validDeferredEntry(value) {
|
|
3
|
+
return value && typeof value === 'object'
|
|
4
|
+
&& ['id', 'key', 'sessionId'].every((key) => typeof value[key] === 'string' && value[key])
|
|
5
|
+
&& ['pending', 'failed'].includes(value.status);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function normalizeDeferredState(value) {
|
|
9
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
10
|
+
return Object.fromEntries(Object.entries(value).flatMap(([key, rows]) => {
|
|
11
|
+
const entries = Array.isArray(rows)
|
|
12
|
+
? rows.filter((entry) => validDeferredEntry(entry) && entry.key === key) : [];
|
|
13
|
+
return entries.length ? [[key, entries]] : [];
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function deferredStateAccess(read, persist) {
|
|
18
|
+
const rows = () => Object.values(read().deferred ?? {}).flat().filter(validDeferredEntry);
|
|
19
|
+
return {
|
|
20
|
+
entries: () => structuredClone(rows()),
|
|
21
|
+
async put(entry) {
|
|
22
|
+
if (!validDeferredEntry(entry)) throw new TypeError('Invalid deferred delivery entry');
|
|
23
|
+
const state = read();
|
|
24
|
+
const groups = state.deferred ??= {};
|
|
25
|
+
const list = Object.hasOwn(groups, entry.key) ? groups[entry.key] : [];
|
|
26
|
+
const next = [...list];
|
|
27
|
+
const index = next.findIndex((candidate) => candidate.id === entry.id);
|
|
28
|
+
if (index < 0) next.push(structuredClone(entry));
|
|
29
|
+
else next[index] = structuredClone(entry);
|
|
30
|
+
Object.defineProperty(groups, entry.key, { value: next, enumerable: true, writable: true, configurable: true });
|
|
31
|
+
await persist();
|
|
32
|
+
},
|
|
33
|
+
async patch(id, patch) {
|
|
34
|
+
const current = rows().find((entry) => entry.id === id);
|
|
35
|
+
if (!current) return null;
|
|
36
|
+
const next = { ...current, ...patch, id: current.id, key: current.key };
|
|
37
|
+
await this.put(next);
|
|
38
|
+
return structuredClone(next);
|
|
39
|
+
},
|
|
40
|
+
async remove(id) {
|
|
41
|
+
const groups = read().deferred ?? {};
|
|
42
|
+
for (const [key, list] of Object.entries(groups)) {
|
|
43
|
+
if (!Array.isArray(list)) continue;
|
|
44
|
+
const next = list.filter((entry) => entry.id !== id);
|
|
45
|
+
if (next.length === list.length) continue;
|
|
46
|
+
if (next.length) groups[key] = next;
|
|
47
|
+
else delete groups[key];
|
|
48
|
+
await persist();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -1206,6 +1206,23 @@ export class HarnessClient {
|
|
|
1206
1206
|
return Boolean(await this.#refreshControlOwnership(sessionId, control, options));
|
|
1207
1207
|
}
|
|
1208
1208
|
|
|
1209
|
+
/** Cancel only the persisted prompt's exact live turn, in the host's JS tick.
|
|
1210
|
+
* HTTP session.cancel cannot express this precondition, so never fall back to it.
|
|
1211
|
+
*/
|
|
1212
|
+
stopDeferredTurn(sessionId, { turn, promptRpcId } = {}, { signal, isCurrent } = {}) {
|
|
1213
|
+
signal?.throwIfAborted();
|
|
1214
|
+
if (typeof sessionId !== 'string' || !sessionId
|
|
1215
|
+
|| !Number.isSafeInteger(turn) || turn < 0
|
|
1216
|
+
|| typeof promptRpcId !== 'string' || !promptRpcId
|
|
1217
|
+
|| typeof this.#controlExecutor !== 'function') return false;
|
|
1218
|
+
if (isCurrent && !isCurrent()) return false;
|
|
1219
|
+
const accepted = this.#controlExecutor({ sessionId, expectedTurn: turn, promptRpcId, action: 'stop' });
|
|
1220
|
+
if (accepted && typeof accepted.then === 'function') {
|
|
1221
|
+
throw new TypeError('controlExecutor must return synchronously');
|
|
1222
|
+
}
|
|
1223
|
+
return accepted === true;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1209
1226
|
async stopActiveTurn(sessionId, control, options = {}) {
|
|
1210
1227
|
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
1211
1228
|
const ownership = await this.#refreshControlOwnership(sessionId, control, options);
|
|
@@ -1585,7 +1602,10 @@ export class HarnessClient {
|
|
|
1585
1602
|
lastProgressAt = Date.now();
|
|
1586
1603
|
continue;
|
|
1587
1604
|
}
|
|
1588
|
-
|
|
1605
|
+
const timeoutError = new HarnessTurnError('harness-reply-timeout');
|
|
1606
|
+
// Data-only context for deferred delivery; timeout semantics unchanged.
|
|
1607
|
+
timeoutError.details = { sessionId, promptRpcId, baselineSeq, turn: tracker.turn, lastSeq: tracker.lastSeq };
|
|
1608
|
+
throw timeoutError;
|
|
1589
1609
|
}
|
|
1590
1610
|
} catch (error) {
|
|
1591
1611
|
// Once cancellation was accepted, transport/poll failures and timeouts
|
|
@@ -5,6 +5,11 @@ export default {
|
|
|
5
5
|
'已连接 DeepSeek Harness,正在思考…': 'Connected to DeepSeek Harness, thinking…',
|
|
6
6
|
|
|
7
7
|
'钉钉机器人已连接 DeepSeek Harness。': 'The DingTalk bot is connected to DeepSeek Harness.',
|
|
8
|
+
'/m 或 /menu 打开下拉操作菜单': '/m or /menu Open the dropdown action menu',
|
|
9
|
+
'会话或工作区已变化,请重新发送 /m。': 'The session or workspace changed. Send /m again.',
|
|
10
|
+
'会话或工作区已变化,菜单已刷新,请重新选择。': 'The session or workspace changed. The menu was refreshed; please select again.',
|
|
11
|
+
'当前任务尚未结束,请先停止任务或等待完成后再操作。': 'A task is still running. Stop it or wait until it finishes before making changes.',
|
|
12
|
+
'操作未完成,请重新发送 /m 后重试。': 'The action did not complete. Send /m again and retry.',
|
|
8
13
|
|
|
9
14
|
|
|
10
15
|
|
|
@@ -367,4 +367,10 @@ export default {
|
|
|
367
367
|
'🔐 工具审批': '🔐 Tool approval',
|
|
368
368
|
// feishu/feishu-cards.mjs — question card
|
|
369
369
|
'❓ 请补充信息{progress}': '❓ Please provide more information{progress}',
|
|
370
|
+
// feishu/bridge.mjs — deferred delivery terminal statuses
|
|
371
|
+
'后台任务已结束({reason}),没有可推送的最终结果。':
|
|
372
|
+
'The background task has finished ({reason}); there is no final result to deliver.',
|
|
373
|
+
'已请求停止后台任务。': 'Requested to stop the background task.',
|
|
374
|
+
'无法安全停止后台任务,请在 Harness 中查看并停止对应任务。': 'Unable to safely stop the background task. Please inspect and stop the matching task in Harness.',
|
|
375
|
+
'任务失败': 'Failed',
|
|
370
376
|
};
|
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
// English translations (qq area). Keys are exact Chinese literals passed to t().
|
|
2
2
|
export default {
|
|
3
|
+
'返回主菜单': 'Back to main menu',
|
|
4
|
+
'模式/预设': 'Mode / preset',
|
|
5
|
+
'归档显示': 'Archived sessions',
|
|
6
|
+
'第 {page} / {pages} 页': 'Page {page} / {pages}',
|
|
7
|
+
'当前会话:{session}': 'Current session: {session}',
|
|
8
|
+
'模式/预设:{preset}': 'Mode / preset: {preset}',
|
|
9
|
+
'当前模型:{model}': 'Current model: {model}',
|
|
10
|
+
'暂不可用': 'Unavailable',
|
|
11
|
+
'归档会话:{state}': 'Archived sessions: {state}',
|
|
12
|
+
'显示': 'Shown',
|
|
13
|
+
'隐藏': 'Hidden',
|
|
14
|
+
'任务状态:{state}': 'Task status: {state}',
|
|
15
|
+
'处理中': 'Processing',
|
|
16
|
+
'空闲': 'Idle',
|
|
17
|
+
'部分设置暂未加载,可进入对应列表重试。': 'Some settings could not be loaded. Open the corresponding list to retry.',
|
|
18
|
+
'💬 会话选择': '💬 Select session',
|
|
19
|
+
'📁 工作区': '📁 Workspace',
|
|
20
|
+
'🤖 模式/预设': '🤖 Mode / preset',
|
|
21
|
+
'🧠 模型': '🧠 Model',
|
|
22
|
+
'💬 补充指令': '💬 Steer task',
|
|
23
|
+
'给当前正在运行的任务补充指令。': 'Send an additional instruction to the running task.',
|
|
24
|
+
'自定义指令': 'Custom instruction',
|
|
25
|
+
'请发送 /steer 你的补充指令。': 'Send /steer followed by your instruction.',
|
|
26
|
+
'发送 /m 打开助手中心,点击按钮或回复当前列表的数字选择。': 'Send /m to open the assistant menu. Click a button or reply with a number from the current list.',
|
|
27
|
+
'设置:会话、工作区、模式/预设、模型。': 'Settings: session, workspace, mode / preset, model.',
|
|
28
|
+
'会话:新会话、会话列表、归档显示切换。': 'Sessions: new session, session list, show or hide archived sessions.',
|
|
29
|
+
'任务:停止、压缩、快捷或自定义补充指令。': 'Tasks: stop, compact, quick or custom steering instructions.',
|
|
30
|
+
'信息:状态、帮助。': 'Information: status, help.',
|
|
31
|
+
'菜单有效期为 15 分钟;发送普通消息会退出数字选择。': 'Menus expire after 15 minutes. Sending an ordinary message exits number selection.',
|
|
32
|
+
'回答问题、审批和批量输入时,数字优先用于当前交互。': 'During questions, approvals and batch input, numbers belong to that interaction.',
|
|
33
|
+
'完整命令说明:/help': 'Full command reference: /help',
|
|
34
|
+
'当前无法展示按钮,请回复数字选择。': 'Buttons are unavailable. Reply with a number to select.',
|
|
35
|
+
'点击按钮或回复数字选择,/m 返回主菜单。': 'Click a button or reply with a number. Send /m for the main menu.',
|
|
36
|
+
'回答问题、审批和批量输入时,请先完成当前交互。': 'Finish any pending question, approval or batch input before replying with a menu number.',
|
|
37
|
+
'已选择': 'Selected',
|
|
38
|
+
'请回复数字选择,或发送 /m 重新打开菜单。': 'Reply with a number or send /m to reopen the menu.',
|
|
39
|
+
'当前正在批量输入,请先发送 /send 或 /cancel,再打开菜单。': 'Batch input is active. Send /send or /cancel before opening the menu.',
|
|
40
|
+
'菜单命令无效,请发送 /m 重新打开。': 'Invalid menu command. Send /m to reopen the menu.',
|
|
41
|
+
'当前任务仍在运行,请先停止任务或等待任务完成后再执行此操作。': 'A task is still running. Stop it or wait for it to finish before performing this action.',
|
|
3
42
|
|
|
4
43
|
'QQ 机器人已连接 DeepSeek Harness。':
|
|
5
44
|
'The QQ bot is connected to DeepSeek Harness.',
|
|
@@ -12,6 +12,8 @@ export default {
|
|
|
12
12
|
'微信交互问题发送失败。': 'Failed to send the WeChat interaction question.',
|
|
13
13
|
'回复已经生成,但微信发送失败,可能只收到部分内容。请将下面的诊断信息完整反馈给管理员。': 'The reply was generated, but WeChat delivery failed and only part of it may have arrived. Please send the complete diagnostic below to the administrator.',
|
|
14
14
|
'微信发送诊断:{diagnostic}': 'WeChat delivery diagnostic: {diagnostic}',
|
|
15
|
+
'微信主动消息发送失败。长轮询在线不代表消息可以发送。': 'WeChat outbound delivery failed. Healthy long polling does not guarantee messages can be sent.',
|
|
16
|
+
'iLink 拒绝发送,可能涉及会话有效期、发送额度或消息内容。可让接收者发一条消息后重试;不要通过反复发送心跳尝试续期。': 'iLink rejected the message; conversation lifetime, sending quota, or message content may be involved. Ask the recipient to send a message before retrying. Repeated heartbeat messages do not renew the conversation.',
|
|
15
17
|
|
|
16
18
|
// weixin-controller.mjs
|
|
17
19
|
'微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。': 'WeChat was authorized, but the existing login credential could not be read. Please check the DSH credential store.',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createDeferredDeliveryCoordinator, deferredOutcomeText } from './deferred-delivery-coordinator.mjs';
|
|
1
2
|
import { t } from './i18n.mjs';
|
|
2
3
|
import {
|
|
3
4
|
COMMAND_PERMISSION_DENIED_MESSAGE,
|
|
@@ -135,6 +136,7 @@ export class TextHarnessBridge {
|
|
|
135
136
|
#bot;
|
|
136
137
|
#harness;
|
|
137
138
|
#state;
|
|
139
|
+
#deferred;
|
|
138
140
|
#contextEnhancement;
|
|
139
141
|
#accessPolicy;
|
|
140
142
|
#status;
|
|
@@ -176,6 +178,9 @@ export class TextHarnessBridge {
|
|
|
176
178
|
this.#logger = logger;
|
|
177
179
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
178
180
|
this.#signal = signal;
|
|
181
|
+
this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
|
|
182
|
+
deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
|
|
183
|
+
});
|
|
179
184
|
this.#approvals = new HarnessApprovalQueue({
|
|
180
185
|
label: descriptor.key,
|
|
181
186
|
logger,
|
|
@@ -449,6 +454,13 @@ export class TextHarnessBridge {
|
|
|
449
454
|
return current;
|
|
450
455
|
}
|
|
451
456
|
|
|
457
|
+
async #deliverDeferredOutcome(entry, outcome) {
|
|
458
|
+
const text = deferredOutcomeText(outcome);
|
|
459
|
+
return typeof this.#bot.sendDelivery === 'function'
|
|
460
|
+
? this.#bot.sendDelivery(entry.target, createTextDeliveryBlock(text, outcome.found ? 'markdown' : 'plain'))
|
|
461
|
+
: this.#bot.sendText(entry.target, text);
|
|
462
|
+
}
|
|
463
|
+
|
|
452
464
|
async waitForIdle() {
|
|
453
465
|
await Promise.allSettled([
|
|
454
466
|
...this.#queues.values(),
|
|
@@ -458,6 +470,7 @@ export class TextHarnessBridge {
|
|
|
458
470
|
...this.#approvalTasks,
|
|
459
471
|
...this.#commandTasks,
|
|
460
472
|
]);
|
|
473
|
+
await this.#deferred.whenIdle();
|
|
461
474
|
}
|
|
462
475
|
|
|
463
476
|
async #processFastCommand(message, messageId, key, runner) {
|
|
@@ -480,6 +493,7 @@ export class TextHarnessBridge {
|
|
|
480
493
|
pendingInteraction: this.#pendingInteractions.has(key)
|
|
481
494
|
|| this.#approvals.hasPending(key),
|
|
482
495
|
control: { owner: this, key },
|
|
496
|
+
deferredDelivery: this.#deferred,
|
|
483
497
|
},
|
|
484
498
|
);
|
|
485
499
|
if (result?.stopped) {
|
|
@@ -696,6 +710,7 @@ export class TextHarnessBridge {
|
|
|
696
710
|
contextEnhanced = content !== originalContent;
|
|
697
711
|
}
|
|
698
712
|
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
713
|
+
deferredDelivery: () => ({ coordinator: this.#deferred, target: this.#descriptor.key === 'whatsapp' ? { jid: target.jid, selfChat: target.selfChat } : target }),
|
|
699
714
|
harness: this.#harness,
|
|
700
715
|
state: this.#state,
|
|
701
716
|
key: conversationKey,
|