@xmanrui/dsh-im 4.19.0 → 4.19.2
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.en.md +18 -4
- package/README.md +18 -4
- package/lib/client.js +188 -10
- package/lib/index.js +262 -261
- package/package.json +5 -1
- package/plugin-src/client/bot-alias.js +78 -1
- package/plugin-src/client/index.js +15 -0
- package/plugin-src/client/interface-language.js +89 -0
- package/plugin-src/client/styles.js +17 -8
- package/plugin-src/host/channels/shared/startup.mjs +23 -0
- package/plugin-src/host/host-language-rpc.mjs +71 -0
- package/plugin-src/host/host-language.mjs +157 -0
- package/plugin-src/host/index.mjs +15 -2
- package/plugin-src/host/session-sync-coordinator.mjs +14 -13
- package/scripts/verify-interface-language.mjs +333 -0
- package/src/channels/discord/discord-runtime.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +219 -262
- package/src/channels/shared/i18n-en/discord.mjs +2 -0
- package/src/channels/shared/i18n-en/telegram.mjs +5 -0
- package/src/channels/shared/i18n.mjs +46 -3
- package/src/channels/shared/interface-language-store.mjs +127 -0
- package/src/channels/shared/interface-language.mjs +51 -0
- package/src/channels/shared/session-sync-registry.mjs +16 -28
- package/src/channels/shared/token-bot-controller.mjs +26 -0
- package/src/channels/telegram/telegram-runtime.mjs +75 -13
|
@@ -28,12 +28,9 @@ import {
|
|
|
28
28
|
validHarnessQuestion,
|
|
29
29
|
} from '../shared/harness-question.mjs';
|
|
30
30
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
31
|
-
import { textFromHarnessContent } from '../shared/harness-client.mjs';
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
claimSessionSyncMirror,
|
|
35
|
-
releaseSessionSyncMirror,
|
|
36
|
-
} from '../shared/session-sync-registry.mjs';
|
|
31
|
+
import { AssistantTextAccumulator, textFromHarnessContent } from '../shared/harness-client.mjs';
|
|
32
|
+
import { registerSessionSyncMirror } from '../shared/session-sync-registry.mjs';
|
|
33
|
+
import { extractCompletedTurnAnswer } from '../shared/deferred-delivery.mjs';
|
|
37
34
|
import {
|
|
38
35
|
BatchInputManager,
|
|
39
36
|
batchInputBusyMessage,
|
|
@@ -195,8 +192,8 @@ const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
|
|
|
195
192
|
/** Streaming-card mode coalesces card renders behind one PATCH per interval —
|
|
196
193
|
* patching the same message is far more rate sensitive than posting. */
|
|
197
194
|
const STEP_STREAM_PATCH_MIN_INTERVAL_MS = 1_000;
|
|
198
|
-
/**
|
|
199
|
-
const
|
|
195
|
+
/** Confirm missed boundaries from history; elapsed time is never completion. */
|
|
196
|
+
const MIRROR_CHECK_MS = 30_000;
|
|
200
197
|
/** One answer chunk inside the streaming card: small enough that the block
|
|
201
198
|
* splitter can always distribute blocks across sealed/live cards. */
|
|
202
199
|
const STEP_STREAM_ANSWER_CHUNK_MAX_BYTES = 18_000;
|
|
@@ -644,14 +641,9 @@ export class FeishuHarnessBridge {
|
|
|
644
641
|
#failedWatchSeqs = new Map();
|
|
645
642
|
/** Host resolver: sessionId -> synced DM targets [{ openId, botId }]. */
|
|
646
643
|
#sessionSyncTargetsFor = null;
|
|
647
|
-
/**
|
|
648
|
-
#
|
|
649
|
-
|
|
650
|
-
#sessionSyncAdopting = new Set();
|
|
651
|
-
/** Latest interim assistant text per mirrored session (folded on tool). */
|
|
652
|
-
#sessionSyncPendingStep = new Map();
|
|
653
|
-
/** Idle-seal timers: no events for a while = the turn ended (mux may
|
|
654
|
-
* not forward turn/end), so seal the mirror card with what it has. */
|
|
644
|
+
/** Per-turn mirrors and recent delivery receipts, scoped to this bot. */
|
|
645
|
+
#sessionSyncTurns = new Map();
|
|
646
|
+
#sessionSyncCurrentTurns = new Map();
|
|
655
647
|
#sessionSyncIdleTimers = new Map();
|
|
656
648
|
/** Conversation keys with an IM ask in flight (set BEFORE the turn starts). */
|
|
657
649
|
#imTurnKeys = new Set();
|
|
@@ -743,87 +735,63 @@ export class FeishuHarnessBridge {
|
|
|
743
735
|
harness, state, signal, logger, watch: false,
|
|
744
736
|
deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
|
|
745
737
|
});
|
|
746
|
-
|
|
747
|
-
|
|
738
|
+
if (this.#sessionSyncTargetsFor && this.#botId && !this.#signal?.aborted) {
|
|
739
|
+
const unregister = registerSessionSyncMirror({ channel: 'feishu', botId: this.#botId },
|
|
740
|
+
(request) => this.#deliverSessionSyncMirror(request));
|
|
741
|
+
this.#signal?.addEventListener('abort', () => {
|
|
742
|
+
unregister();
|
|
743
|
+
for (const timer of this.#sessionSyncIdleTimers.values()) clearTimeout(timer);
|
|
744
|
+
this.#sessionSyncIdleTimers.clear();
|
|
745
|
+
}, { once: true });
|
|
746
|
+
}
|
|
747
|
+
// Persisted watches and mirrors resume without waiting for an IM message.
|
|
748
748
|
if (typeof this.#harness?.watchHarnessEvents === 'function') {
|
|
749
749
|
queueMicrotask(() => {
|
|
750
750
|
this.#ensureEventWatcher();
|
|
751
|
-
|
|
752
|
-
// be streaming; sealing them at t=0 would wipe live cards. 90s gives
|
|
753
|
-
// the turn's own events a window to re-adopt and finish normally.
|
|
754
|
-
setTimeout(() => { void this.#sealOrphanMirrors(); }, 90_000);
|
|
751
|
+
void this.#sealOrphanMirrors();
|
|
755
752
|
});
|
|
756
753
|
}
|
|
757
754
|
}
|
|
758
755
|
|
|
759
|
-
/**
|
|
760
|
-
* Seal mirrors left running by a previous process: a restart kills the
|
|
761
|
-
* turn without a turn/end event, so the persisted card would stay
|
|
762
|
-
* "running" forever. Mark each orphan sealed (stopped) on delivery.
|
|
763
|
-
*/
|
|
756
|
+
/** Recover only a known finished turn, using its last successful card JSON. */
|
|
764
757
|
async #sealOrphanMirrors() {
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
for (const [sessionId, entry] of entries) {
|
|
773
|
-
if (!entry?.chatId || !Array.isArray(entry.cardIds)) continue;
|
|
774
|
-
if (typeof entry.claimedAt === 'number' && Date.now() - entry.claimedAt < ORPHAN_AFTER_MS) {
|
|
775
|
-
this.#logger.warn?.('[dsh-feishu] mirror entry too fresh to be orphaned; leaving untouched:', sessionId);
|
|
776
|
-
continue;
|
|
777
|
-
}
|
|
778
|
-
// The live card's last delivered content is persisted with the mirror:
|
|
779
|
-
// re-patch the live card with a stopped status line, keeping every
|
|
780
|
-
// panel intact. Sealed earlier chunks already carry no status line and
|
|
781
|
-
// keep their original content untouched.
|
|
782
|
-
let sealContent = null;
|
|
783
|
-
if (typeof entry.lastContent === 'string' && entry.lastContent) {
|
|
758
|
+
for (const [key, entry] of this.#state.mirrorEntries?.() ?? []) {
|
|
759
|
+
if (!entry?.chatId || !entry.sessionId || !Number.isSafeInteger(entry.turn)
|
|
760
|
+
|| !Array.isArray(entry.cardIds) || !entry.cardIds.length) continue;
|
|
761
|
+
await this.#queueEventTask(entry.sessionId, async () => {
|
|
762
|
+
if (this.#signal?.aborted) return;
|
|
763
|
+
// An adopted turn now owns this record and will finish through its queue.
|
|
764
|
+
if (this.#sessionSyncTurns.has(key)) return;
|
|
784
765
|
try {
|
|
785
|
-
const
|
|
786
|
-
const
|
|
787
|
-
if (
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
&& last.content.startsWith('_') && last.content.endsWith('_')) {
|
|
791
|
-
last.content = `_${stepStatusText('stopped')}_`;
|
|
792
|
-
} else {
|
|
793
|
-
elements.push({ tag: 'markdown', content: `_${stepStatusText('stopped')}_` });
|
|
794
|
-
}
|
|
795
|
-
sealContent = parsed;
|
|
766
|
+
const events = await this.#mirrorHistory(entry.sessionId, entry.turn);
|
|
767
|
+
const outcome = extractCompletedTurnAnswer(events, { turn: entry.turn });
|
|
768
|
+
if (outcome.endSeq < 0) {
|
|
769
|
+
this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors());
|
|
770
|
+
return;
|
|
796
771
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
.
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
this.#logger.warn?.('[dsh-feishu] orphan mirror seal failed:', error?.message ?? error);
|
|
821
|
-
});
|
|
822
|
-
}
|
|
823
|
-
await this.#state.clearMirror?.(sessionId);
|
|
824
|
-
this.#logger.warn?.(
|
|
825
|
-
`[dsh-feishu] sealed ${entry.cardIds.length} orphan mirror card(s) for ${sessionId}`,
|
|
826
|
-
);
|
|
772
|
+
if (this.#restoreMirrorCard(key, entry)) {
|
|
773
|
+
const result = await this.#finishStepCard(key, {
|
|
774
|
+
answerText: outcome.text, stopped: outcome.reason !== 'completed',
|
|
775
|
+
});
|
|
776
|
+
if (!result?.ok) throw new Error('Recovered mirror could not deliver its final answer');
|
|
777
|
+
await this.#state.clearMirror?.(key);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const content = JSON.parse(entry.lastContent);
|
|
781
|
+
const elements = content?.body?.elements;
|
|
782
|
+
if (!Array.isArray(elements)) return;
|
|
783
|
+
const last = elements.at(-1);
|
|
784
|
+
const status = `_${stepStatusText(outcome.reason === 'completed' ? 'completed' : 'stopped')}_`;
|
|
785
|
+
if (last?.tag === 'markdown' && /^_.*_$/s.test(last.content ?? '')) last.content = status;
|
|
786
|
+
else elements.push({ tag: 'markdown', content: status });
|
|
787
|
+
// Earlier chunks are sealed history; update only the live card.
|
|
788
|
+
await this.#patchStepCard(entry.cardIds.at(-1), JSON.stringify(content));
|
|
789
|
+
await this.#state.clearMirror?.(key);
|
|
790
|
+
} catch (error) {
|
|
791
|
+
this.#logger.warn?.('[dsh-feishu] mirror recovery failed:', error?.message ?? error);
|
|
792
|
+
this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors());
|
|
793
|
+
}
|
|
794
|
+
});
|
|
827
795
|
}
|
|
828
796
|
}
|
|
829
797
|
|
|
@@ -3354,12 +3322,14 @@ export class FeishuHarnessBridge {
|
|
|
3354
3322
|
this.#eventWatcher = this.#harness.watchHarnessEvents({
|
|
3355
3323
|
signal,
|
|
3356
3324
|
onSessionEvent: (payload) => {
|
|
3357
|
-
console.error('[ss-final] mux:', payload?.sessionId?.slice(-12), payload?.event?.type);
|
|
3358
3325
|
this.#onHarnessEvent(payload);
|
|
3359
3326
|
},
|
|
3360
3327
|
onReconnect: () => {
|
|
3361
3328
|
void this.#compensateMissedEvents();
|
|
3362
3329
|
void this.#deferred.resume();
|
|
3330
|
+
for (const mirror of this.#sessionSyncTurns.values()) {
|
|
3331
|
+
if (!mirror.finishedAt) void this.#checkMirror(mirror);
|
|
3332
|
+
}
|
|
3363
3333
|
},
|
|
3364
3334
|
});
|
|
3365
3335
|
Promise.resolve(this.#eventWatcher).catch((error) => {
|
|
@@ -3698,179 +3668,177 @@ export class FeishuHarnessBridge {
|
|
|
3698
3668
|
);
|
|
3699
3669
|
}
|
|
3700
3670
|
|
|
3701
|
-
/**
|
|
3702
|
-
/**
|
|
3703
|
-
* Mirror one Harness session event into the session-sync process card for
|
|
3704
|
-
* this session (a Web/CLI-initiated turn with a synced DM target). Events
|
|
3705
|
-
* are translated into the same update shapes the ask callbacks produce, so
|
|
3706
|
-
* the regular #stepCards ladder renders them identically: tool/call ->
|
|
3707
|
-
* tool block, assistant/message -> live answer draft, turn/end -> sealed
|
|
3708
|
-
* terminal card with the final answer.
|
|
3709
|
-
*/
|
|
3710
|
-
/** True when this session's running turn was opened by one of OUR asks. */
|
|
3671
|
+
/** True while this bridge owns the IM ask; capture before queuing events. */
|
|
3711
3672
|
#isImTurn(sessionId) {
|
|
3712
|
-
for (const
|
|
3713
|
-
if (this.#state.sessionFor?.(
|
|
3673
|
+
for (const key of this.#imTurnKeys) {
|
|
3674
|
+
if (this.#state.sessionFor?.(key) === sessionId) return true;
|
|
3714
3675
|
}
|
|
3715
3676
|
return false;
|
|
3716
3677
|
}
|
|
3717
3678
|
|
|
3718
|
-
#beginImTurn(key) {
|
|
3719
|
-
|
|
3720
|
-
}
|
|
3679
|
+
#beginImTurn(key) { this.#imTurnKeys.add(key); }
|
|
3680
|
+
#endImTurn(key) { this.#imTurnKeys.delete(key); }
|
|
3721
3681
|
|
|
3722
|
-
#
|
|
3723
|
-
|
|
3682
|
+
#mirrorKey(sessionId, turn) { return `session-sync\0${sessionId}\0${turn}`; }
|
|
3683
|
+
|
|
3684
|
+
#restoreMirrorCard(key, entry) {
|
|
3685
|
+
if (!Array.isArray(entry?.blocks) || !entry.cardIds?.length) return false;
|
|
3686
|
+
const card = this.#ensureStepCard(key, entry.chatId, null);
|
|
3687
|
+
Object.assign(card, {
|
|
3688
|
+
blocks: structuredClone(entry.blocks), cardIds: [...entry.cardIds],
|
|
3689
|
+
messageId: entry.cardIds.at(-1), chunkCount: entry.cardIds.length,
|
|
3690
|
+
answerStart: entry.answerStart ?? null, answerEnd: entry.answerEnd ?? null,
|
|
3691
|
+
deliveryViaOpenId: true,
|
|
3692
|
+
});
|
|
3693
|
+
return true;
|
|
3724
3694
|
}
|
|
3725
3695
|
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
* NOT turn/start|turn/end, so the mirror cannot observe the turn boundary
|
|
3729
|
-
* directly. Instead, arm an idle timer on every event: when no event
|
|
3730
|
-
* arrives for MIRROR_IDLE_SEAL_MS, the turn is over — seal the card with
|
|
3731
|
-
* its current content (answer draft included) as completed.
|
|
3732
|
-
*/
|
|
3733
|
-
#armSessionSyncIdleTimer(sessionId, key, openId) {
|
|
3734
|
-
const previous = this.#sessionSyncIdleTimers.get(sessionId);
|
|
3735
|
-
if (previous) clearTimeout(previous);
|
|
3696
|
+
#scheduleMirrorCheck(key, task) {
|
|
3697
|
+
if (this.#signal?.aborted || this.#sessionSyncIdleTimers.has(key)) return;
|
|
3736
3698
|
const timer = setTimeout(() => {
|
|
3737
|
-
this.#sessionSyncIdleTimers.delete(
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
.catch((error) => {
|
|
3743
|
-
console.error('[ss-final] idle seal failed:', error?.message ?? error);
|
|
3744
|
-
});
|
|
3745
|
-
}, MIRROR_IDLE_SEAL_MS);
|
|
3746
|
-
this.#sessionSyncIdleTimers.set(sessionId, timer);
|
|
3699
|
+
this.#sessionSyncIdleTimers.delete(key);
|
|
3700
|
+
if (!this.#signal?.aborted) void task();
|
|
3701
|
+
}, MIRROR_CHECK_MS);
|
|
3702
|
+
timer.unref?.();
|
|
3703
|
+
this.#sessionSyncIdleTimers.set(key, timer);
|
|
3747
3704
|
}
|
|
3748
3705
|
|
|
3749
|
-
async #
|
|
3750
|
-
if (this.#
|
|
3751
|
-
const
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3706
|
+
async #mirrorHistory(sessionId, turn) {
|
|
3707
|
+
if (typeof this.#harness.rpc !== 'function') return null;
|
|
3708
|
+
const events = [];
|
|
3709
|
+
let beforeSeq;
|
|
3710
|
+
for (let page = 0; page < 10; page += 1) {
|
|
3711
|
+
const history = await this.#harness.rpc('session.history', {
|
|
3712
|
+
sessionId, maxMessages: 100,
|
|
3713
|
+
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
|
3714
|
+
}, 10_000, { signal: this.#signal });
|
|
3715
|
+
const batch = orderedHistoryEvents(history);
|
|
3716
|
+
events.unshift(...batch);
|
|
3717
|
+
if (!history?.hasMore || batch.some((event) => event.type === 'turn/start' && event.data?.turn === turn)) {
|
|
3718
|
+
return events;
|
|
3760
3719
|
}
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
const targets = await this.#sessionSyncTargetsFor?.(sessionId);
|
|
3765
|
-
const owned = (Array.isArray(targets) ? targets : [])
|
|
3766
|
-
.find((target) => target.botId === this.#botId);
|
|
3767
|
-
if (!owned?.openId) {
|
|
3768
|
-
return;
|
|
3769
|
-
}
|
|
3770
|
-
this.#sessionSyncTargets.set(sessionId, owned);
|
|
3771
|
-
// chatId carries the openId; #sendCard branches on the delivery marker.
|
|
3772
|
-
this.#ensureStepCard(key, owned.openId, null);
|
|
3773
|
-
this.#stepCards.get(key).deliveryViaOpenId = true;
|
|
3774
|
-
this.#stepCards.get(key).sessionSyncSessionId = sessionId;
|
|
3775
|
-
this.#stepCards.get(key).sessionSyncTargetId = owned.targetId ?? '';
|
|
3776
|
-
// Persist the mirror so a restart can seal an orphaned running card.
|
|
3777
|
-
await this.#state.setMirror?.(sessionId, { chatId: owned.openId, targetId: owned.targetId ?? '', cardIds: [], claimedAt: Date.now() });
|
|
3778
|
-
// Claim only THIS target: the coordinator suppresses its plain text for
|
|
3779
|
-
// the mirrored target while other synced targets keep their delivery.
|
|
3780
|
-
claimSessionSyncMirror(sessionId, owned.targetId ?? '');
|
|
3781
|
-
return;
|
|
3720
|
+
const oldest = batch[0]?.seq;
|
|
3721
|
+
if (!validEventSeq(oldest) || oldest === beforeSeq) break;
|
|
3722
|
+
beforeSeq = oldest;
|
|
3782
3723
|
}
|
|
3724
|
+
return null; // A truncated history cannot prove the final answer is complete.
|
|
3725
|
+
}
|
|
3783
3726
|
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
const owned = (Array.isArray(targets) ? targets : [])
|
|
3796
|
-
.find((target) => target.botId === this.#botId);
|
|
3797
|
-
if (!owned?.openId) return null;
|
|
3798
|
-
this.#sessionSyncTargets.set(sessionId, owned);
|
|
3799
|
-
// Adopt = open the mirror card NOW, then handle this event.
|
|
3800
|
-
this.#ensureStepCard(key, owned.openId, null);
|
|
3801
|
-
this.#stepCards.get(key).deliveryViaOpenId = true;
|
|
3802
|
-
this.#stepCards.get(key).sessionSyncSessionId = sessionId;
|
|
3803
|
-
this.#stepCards.get(key).sessionSyncTargetId = owned.targetId ?? '';
|
|
3804
|
-
void this.#state.setMirror?.(sessionId, { chatId: owned.openId, targetId: owned.targetId ?? '', cardIds: [], claimedAt: Date.now() });
|
|
3805
|
-
claimSessionSyncMirror(sessionId, owned.targetId ?? '');
|
|
3806
|
-
return this.#feedSessionSyncTurn(sessionId, event);
|
|
3807
|
-
})
|
|
3808
|
-
.catch((error) => {
|
|
3809
|
-
this.#logger.warn?.('[dsh-feishu] session-sync adopt failed:', error?.message ?? error);
|
|
3810
|
-
})
|
|
3811
|
-
.finally(() => this.#sessionSyncAdopting.delete(sessionId));
|
|
3727
|
+
#checkMirror(mirror) {
|
|
3728
|
+
return this.#queueEventTask(mirror.sessionId, async () => {
|
|
3729
|
+
if (mirror.finishedAt || this.#signal?.aborted) return;
|
|
3730
|
+
try {
|
|
3731
|
+
const events = await this.#mirrorHistory(mirror.sessionId, mirror.turn);
|
|
3732
|
+
const outcome = extractCompletedTurnAnswer(events, { turn: mirror.turn });
|
|
3733
|
+
if (outcome.endSeq >= 0) {
|
|
3734
|
+
await this.#finishSessionSyncMirror(mirror, outcome.text ?? mirror.assistant.text, outcome.reason);
|
|
3735
|
+
}
|
|
3736
|
+
} catch (error) {
|
|
3737
|
+
this.#logger.warn?.('[dsh-feishu] mirror history check failed:', error?.message ?? error);
|
|
3812
3738
|
}
|
|
3813
|
-
|
|
3814
|
-
}
|
|
3815
|
-
|
|
3816
|
-
// to release the mirror claim and clear the state so later turns and the
|
|
3817
|
-
// plain-text fallback work again.
|
|
3818
|
-
if (card.broken && type !== 'turn/end') return;
|
|
3739
|
+
if (!mirror.finishedAt) this.#scheduleMirrorCheck(mirror.key, () => this.#checkMirror(mirror));
|
|
3740
|
+
});
|
|
3741
|
+
}
|
|
3819
3742
|
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
const
|
|
3743
|
+
async #finishSessionSyncMirror(mirror, text, reason = 'completed') {
|
|
3744
|
+
if (mirror.finishedAt) return mirror.result?.ok === true && mirror.result.text === text;
|
|
3745
|
+
const recovering = mirror.recovered;
|
|
3746
|
+
if (mirror.recovered) {
|
|
3747
|
+
const events = await this.#mirrorHistory(mirror.sessionId, mirror.turn);
|
|
3748
|
+
const outcome = extractCompletedTurnAnswer(events, { turn: mirror.turn });
|
|
3749
|
+
if (outcome.endSeq < 0) return false;
|
|
3750
|
+
text = outcome.text ?? text;
|
|
3751
|
+
reason = outcome.reason;
|
|
3752
|
+
mirror.recovered = false;
|
|
3753
|
+
}
|
|
3754
|
+
clearTimeout(this.#sessionSyncIdleTimers.get(mirror.key));
|
|
3755
|
+
this.#sessionSyncIdleTimers.delete(mirror.key);
|
|
3756
|
+
const result = await this.#finishStepCard(mirror.key, {
|
|
3757
|
+
stopped: reason !== 'completed', answerText: text,
|
|
3758
|
+
});
|
|
3759
|
+
mirror.result = { ok: result?.ok === true, text };
|
|
3760
|
+
mirror.finishedAt = Date.now();
|
|
3761
|
+
// Keep the last successful snapshot on failure so recovery can preserve it.
|
|
3762
|
+
if (result?.ok) await this.#state.clearMirror?.(mirror.key);
|
|
3763
|
+
else if (recovering) {
|
|
3764
|
+
this.#sessionSyncTurns.delete(mirror.key);
|
|
3765
|
+
this.#scheduleMirrorCheck(`recovery\0${mirror.key}`, () => this.#sealOrphanMirrors());
|
|
3766
|
+
}
|
|
3767
|
+
return result?.ok === true;
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
async #deliverSessionSyncMirror({ target, sessionId, turn, text }) {
|
|
3771
|
+
if (this.#signal?.aborted) return false;
|
|
3772
|
+
return await this.#queueEventTask(sessionId, async () => {
|
|
3773
|
+
const mirror = this.#sessionSyncTurns.get(this.#mirrorKey(sessionId, turn));
|
|
3774
|
+
if (!mirror || mirror.target.targetId !== target.targetId) return false;
|
|
3775
|
+
return this.#finishSessionSyncMirror(mirror, text);
|
|
3776
|
+
}) === true;
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
async #feedSessionSyncTurn(sessionId, event, imOwned = false) {
|
|
3780
|
+
if (this.#signal?.aborted || imOwned) return;
|
|
3781
|
+
const type = event.type;
|
|
3782
|
+
// Retain recent receipts for coordinator callbacks that lag the event mux.
|
|
3783
|
+
for (const [key, prior] of this.#sessionSyncTurns) {
|
|
3784
|
+
if (prior.finishedAt && Date.now() - prior.finishedAt > 300_000) this.#sessionSyncTurns.delete(key);
|
|
3785
|
+
}
|
|
3786
|
+
let turn = event.data?.turn;
|
|
3787
|
+
if (!Number.isSafeInteger(turn)) turn = this.#sessionSyncCurrentTurns.get(sessionId);
|
|
3788
|
+
if (!Number.isSafeInteger(turn)) return;
|
|
3789
|
+
const key = this.#mirrorKey(sessionId, turn);
|
|
3790
|
+
let mirror = this.#sessionSyncTurns.get(key);
|
|
3791
|
+
if (!mirror) {
|
|
3792
|
+
if (!['turn/start', 'user/message', 'assistant/message', 'tool/call'].includes(type)) return;
|
|
3793
|
+
const targets = await this.#sessionSyncTargetsFor(sessionId);
|
|
3794
|
+
const target = (Array.isArray(targets) ? targets : []).find((item) => item.botId === this.#botId);
|
|
3795
|
+
if (!target?.openId || !target?.targetId || this.#signal?.aborted) return;
|
|
3796
|
+
mirror = { key, sessionId, turn, target, assistant: new AssistantTextAccumulator(), pendingStep: null, lastSeq: -1 };
|
|
3797
|
+
this.#sessionSyncTurns.set(key, mirror);
|
|
3798
|
+
this.#sessionSyncCurrentTurns.set(sessionId, turn);
|
|
3799
|
+
const saved = this.#state.mirrorEntries?.().find(([entryKey]) => entryKey === key)?.[1];
|
|
3800
|
+
if (saved?.targetId === target.targetId && saved.chatId === target.openId
|
|
3801
|
+
&& this.#restoreMirrorCard(key, saved)) {
|
|
3802
|
+
mirror.recovered = true;
|
|
3803
|
+
mirror.lastSeq = saved.lastSeq ?? -1;
|
|
3804
|
+
mirror.pendingStep = saved.pendingStep ?? null;
|
|
3805
|
+
}
|
|
3806
|
+
const card = this.#ensureStepCard(key, target.openId, null);
|
|
3807
|
+
Object.assign(card, {
|
|
3808
|
+
deliveryViaOpenId: true, sessionSyncSessionId: sessionId,
|
|
3809
|
+
sessionSyncTargetId: target.targetId, sessionSyncTurn: turn, sessionSyncKey: key,
|
|
3810
|
+
});
|
|
3811
|
+
}
|
|
3812
|
+
if (mirror.finishedAt || event.seq <= mirror.lastSeq) return;
|
|
3813
|
+
mirror.lastSeq = event.seq;
|
|
3814
|
+
this.#scheduleMirrorCheck(key, () => this.#checkMirror(mirror));
|
|
3815
|
+
const openId = mirror.target.openId;
|
|
3816
|
+
if (type === 'user/message' && event.surfaceOp === 'append') {
|
|
3817
|
+
const text = textFromHarnessContent(event.data?.content);
|
|
3825
3818
|
if (text.trim()) {
|
|
3826
3819
|
const excerpt = text.length > 400 ? `${text.slice(0, 399)}…` : text;
|
|
3827
|
-
await this.#appendStepCardUpdate(
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
this.#sessionSyncPendingStep.delete(sessionId);
|
|
3842
|
-
}
|
|
3843
|
-
await this.#appendStepCardUpdate(
|
|
3844
|
-
key, openId, null,
|
|
3845
|
-
this.#stepCardToolBlock({
|
|
3846
|
-
name: event?.data?.name ?? '',
|
|
3847
|
-
arguments: typeof event?.data?.arguments === 'string' ? event.data.arguments : '',
|
|
3848
|
-
}),
|
|
3849
|
-
{ billable: false },
|
|
3850
|
-
);
|
|
3851
|
-
return;
|
|
3852
|
-
}
|
|
3853
|
-
if (type === 'assistant/message') {
|
|
3854
|
-
const text = textFromHarnessContent(event?.data?.message?.content);
|
|
3820
|
+
await this.#appendStepCardUpdate(key, openId, null,
|
|
3821
|
+
{ kind: 'message', text: `> 👤 **我问:**${excerpt.replaceAll('\n', '\n> ')}` }, { billable: false });
|
|
3822
|
+
}
|
|
3823
|
+
} else if (type === 'tool/call') {
|
|
3824
|
+
if (mirror.pendingStep) {
|
|
3825
|
+
this.#morphStepCardAnswerToNote(key, mirror.pendingStep);
|
|
3826
|
+
mirror.pendingStep = null;
|
|
3827
|
+
}
|
|
3828
|
+
await this.#appendStepCardUpdate(key, openId, null, this.#stepCardToolBlock({
|
|
3829
|
+
name: event.data?.name ?? '',
|
|
3830
|
+
arguments: typeof event.data?.arguments === 'string' ? event.data.arguments : '',
|
|
3831
|
+
}), { billable: false });
|
|
3832
|
+
} else if (type === 'assistant/message' && event.surfaceOp === 'append' && event.data?.interrupted !== true) {
|
|
3833
|
+
const text = textFromHarnessContent(event.data?.message?.content);
|
|
3855
3834
|
if (text.trim()) {
|
|
3856
|
-
|
|
3835
|
+
mirror.assistant.setCanonical(event.data?.step, text);
|
|
3836
|
+
mirror.pendingStep = text;
|
|
3857
3837
|
this.#streamStepCardAnswer(key, openId, null, text);
|
|
3858
3838
|
}
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
this.#sessionSyncTargets.delete(sessionId);
|
|
3863
|
-
this.#sessionSyncPendingStep.delete(sessionId);
|
|
3864
|
-
releaseSessionSyncMirror(sessionId, card?.sessionSyncTargetId ?? '');
|
|
3865
|
-
await this.#finishStepCard(key, {
|
|
3866
|
-
stopped: event?.data?.reason?.kind === 'aborted',
|
|
3867
|
-
answerText: null,
|
|
3868
|
-
});
|
|
3869
|
-
// Clear AFTER the seal: the render chain may still write mirror state
|
|
3870
|
-
// while it finishes, so clearing earlier would be resurrected by the
|
|
3871
|
-
// trailing setMirror from the last successful render.
|
|
3872
|
-
await this.#state.clearMirror?.(sessionId);
|
|
3873
|
-
return;
|
|
3839
|
+
} else if (type === 'turn/end') {
|
|
3840
|
+
const reason = typeof event.data?.reason === 'string' ? event.data.reason : event.data?.reason?.kind;
|
|
3841
|
+
await this.#finishSessionSyncMirror(mirror, mirror.assistant.text, reason);
|
|
3874
3842
|
}
|
|
3875
3843
|
}
|
|
3876
3844
|
|
|
@@ -3888,14 +3856,8 @@ export class FeishuHarnessBridge {
|
|
|
3888
3856
|
// IM-opened turns are skipped — they already own their card via the ask
|
|
3889
3857
|
// callbacks. turn/end ALSO continues below for watch completions.
|
|
3890
3858
|
if (this.#sessionSyncTargetsFor) {
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
}
|
|
3894
|
-
void this.#queueEventTask(`session-sync\0${sessionId}`, async () => {
|
|
3895
|
-
await this.#feedSessionSyncTurn(sessionId, event);
|
|
3896
|
-
}).catch((error) => {
|
|
3897
|
-
console.error('[ss-final] mirror task failed:', event?.type, error?.message ?? error);
|
|
3898
|
-
});
|
|
3859
|
+
const imOwned = this.#isImTurn(sessionId);
|
|
3860
|
+
void this.#queueEventTask(sessionId, () => this.#feedSessionSyncTurn(sessionId, event, imOwned));
|
|
3899
3861
|
if (event.type !== 'turn/end') return;
|
|
3900
3862
|
}
|
|
3901
3863
|
if (event.type !== 'turn/end') return;
|
|
@@ -4370,14 +4332,18 @@ export class FeishuHarnessBridge {
|
|
|
4370
4332
|
* their lifecycle is owned by the ask path, not the mirror recovery.
|
|
4371
4333
|
* lastContent keeps stepStreamCard's raw JSON string (single-encoded).
|
|
4372
4334
|
*/
|
|
4373
|
-
#persistMirrorState(card, liveBlocks, status) {
|
|
4335
|
+
async #persistMirrorState(card, liveBlocks, status) {
|
|
4374
4336
|
const sessionId = card.sessionSyncSessionId;
|
|
4375
4337
|
if (!sessionId || typeof this.#state.setMirror !== 'function') return;
|
|
4376
|
-
|
|
4338
|
+
await this.#state.setMirror(card.sessionSyncKey, {
|
|
4339
|
+
sessionId, turn: card.sessionSyncTurn, targetId: card.sessionSyncTargetId,
|
|
4377
4340
|
chatId: card.chatId,
|
|
4378
|
-
cardIds: card.cardIds,
|
|
4341
|
+
cardIds: [...card.cardIds],
|
|
4379
4342
|
claimedAt: Date.now(),
|
|
4380
4343
|
lastContent: stepStreamCard(liveBlocks, { status }),
|
|
4344
|
+
blocks: structuredClone(card.blocks), answerStart: card.answerStart, answerEnd: card.answerEnd,
|
|
4345
|
+
lastSeq: this.#sessionSyncTurns.get(card.sessionSyncKey)?.lastSeq ?? -1,
|
|
4346
|
+
pendingStep: this.#sessionSyncTurns.get(card.sessionSyncKey)?.pendingStep ?? null,
|
|
4381
4347
|
});
|
|
4382
4348
|
}
|
|
4383
4349
|
|
|
@@ -4405,7 +4371,7 @@ export class FeishuHarnessBridge {
|
|
|
4405
4371
|
if (isLive) card.messageId = id;
|
|
4406
4372
|
}
|
|
4407
4373
|
card.chunkCount = chunks.length;
|
|
4408
|
-
this.#persistMirrorState(card, live, 'running');
|
|
4374
|
+
await this.#persistMirrorState(card, live, 'running');
|
|
4409
4375
|
card.lastRenderAt = this.#stepPushClock.now();
|
|
4410
4376
|
card.renderedAnswerVersion = card.answerVersion ?? 0;
|
|
4411
4377
|
return;
|
|
@@ -4431,10 +4397,10 @@ export class FeishuHarnessBridge {
|
|
|
4431
4397
|
if (isLive) card.messageId = id;
|
|
4432
4398
|
}
|
|
4433
4399
|
card.chunkCount = chunks.length;
|
|
4434
|
-
this.#persistMirrorState(card, live, 'running');
|
|
4435
4400
|
} else {
|
|
4436
4401
|
await this.#patchStepCard(card.messageId, stepStreamCard(live, { status: 'running' }));
|
|
4437
4402
|
}
|
|
4403
|
+
await this.#persistMirrorState(card, live, 'running');
|
|
4438
4404
|
card.lastRenderAt = this.#stepPushClock.now();
|
|
4439
4405
|
card.renderedAnswerVersion = card.answerVersion ?? 0;
|
|
4440
4406
|
} catch (error) {
|
|
@@ -4446,15 +4412,6 @@ export class FeishuHarnessBridge {
|
|
|
4446
4412
|
if (card.deliveryViaOpenId) {
|
|
4447
4413
|
this.#logger.warn?.('[dsh-feishu] session-sync mirror card render failed:',
|
|
4448
4414
|
error?.message ?? String(error));
|
|
4449
|
-
// Release the mirror claim at the FIRST failure so the plain-text
|
|
4450
|
-
// coordinator takes over delivery for the rest of the turn (its
|
|
4451
|
-
// user-echo suppression lifts immediately, and its recipients stay
|
|
4452
|
-
// usable for the final answer fallback).
|
|
4453
|
-
const sessionId = card.sessionSyncSessionId;
|
|
4454
|
-
if (sessionId) {
|
|
4455
|
-
releaseSessionSyncMirror(sessionId, card.sessionSyncTargetId ?? '');
|
|
4456
|
-
void this.#state.clearMirror?.(sessionId);
|
|
4457
|
-
}
|
|
4458
4415
|
}
|
|
4459
4416
|
}
|
|
4460
4417
|
}
|
|
@@ -5,4 +5,6 @@ export default {
|
|
|
5
5
|
'The Discord Gateway Intents are misconfigured. Please check the Bot settings in the Developer Portal.',
|
|
6
6
|
'Discord机器人': 'Discord Bot',
|
|
7
7
|
' Gateway 长连接': ' Gateway long-lived connection',
|
|
8
|
+
'Thread 创建结果暂时无法确认。若已创建,请在对应 Thread 中重试;若未创建,请稍后重新 @机器人。':
|
|
9
|
+
'The Thread creation result cannot be confirmed yet. If the Thread was created, retry inside it; if it was not, mention the bot again shortly.',
|
|
8
10
|
};
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// English translations (telegram area). Keys are exact Chinese literals passed to t().
|
|
2
2
|
export default {
|
|
3
|
+
// Terminal status written back over a placeholder whose in-place edit was
|
|
4
|
+
// rejected, so it is the last thing a reader sees on a degraded reply.
|
|
5
|
+
'回复已发送。': 'The reply was sent.',
|
|
6
|
+
'回复发送结果未能确认。': 'The reply delivery result could not be confirmed.',
|
|
7
|
+
'消息发送失败,请稍后重试。': 'The message could not be sent. Try again later.',
|
|
3
8
|
'开启一个全新会话': 'Start a brand-new Session',
|
|
4
9
|
'压缩当前会话的较早上下文': 'Compact the earlier context of the current Session',
|
|
5
10
|
'切换工作区': 'Switch Workspace',
|