@yeaft/webchat-agent 0.1.497 → 0.1.498
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/package.json +1 -1
- package/unify/web-bridge.js +107 -49
package/package.json
CHANGED
package/unify/web-bridge.js
CHANGED
|
@@ -16,13 +16,22 @@
|
|
|
16
16
|
import { loadSession } from './session.js';
|
|
17
17
|
import { sendToServer } from '../connection/buffer.js';
|
|
18
18
|
import ctx from '../context.js';
|
|
19
|
-
import { getThreadStore } from './threads/store.js';
|
|
19
|
+
import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
|
|
20
20
|
|
|
21
21
|
/** @type {import('./session.js').Session | null} */
|
|
22
22
|
let session = null;
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* task-320: per-thread in-flight AbortController registry.
|
|
26
|
+
*
|
|
27
|
+
* A new message only cancels the prior round on the SAME thread; a message
|
|
28
|
+
* routed to a different thread runs concurrently without aliasing. Keyed by
|
|
29
|
+
* the resolved `targetThreadId` from the dispatcher's `routing_decision`
|
|
30
|
+
* event (we don't know the thread until the router has classified).
|
|
31
|
+
*
|
|
32
|
+
* @type {Map<string, AbortController>}
|
|
33
|
+
*/
|
|
34
|
+
const abortByThread = new Map();
|
|
26
35
|
|
|
27
36
|
/** Query timeout in ms — abort if LLM doesn't respond within this window */
|
|
28
37
|
const QUERY_TIMEOUT_MS = 120_000;
|
|
@@ -30,10 +39,22 @@ const QUERY_TIMEOUT_MS = 120_000;
|
|
|
30
39
|
/** Virtual conversationId for the Unify session */
|
|
31
40
|
let unifyConversationId = null;
|
|
32
41
|
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
42
|
+
/**
|
|
43
|
+
* task-320: per-thread accumulated conversation messages for context
|
|
44
|
+
* continuity. Previously a single flat array — which cross-contaminated
|
|
45
|
+
* history across threads. Keyed by threadId. Cleared on session reset or
|
|
46
|
+
* by a `consolidate` event for that thread only.
|
|
47
|
+
*
|
|
48
|
+
* @type {Map<string, Array<{role: 'user'|'assistant', content: string|Array}>>}
|
|
49
|
+
*/
|
|
50
|
+
const messagesByThread = new Map();
|
|
51
|
+
|
|
52
|
+
function getThreadMessages(threadId) {
|
|
53
|
+
if (!threadId) return [];
|
|
54
|
+
let arr = messagesByThread.get(threadId);
|
|
55
|
+
if (!arr) { arr = []; messagesByThread.set(threadId, arr); }
|
|
56
|
+
return arr;
|
|
57
|
+
}
|
|
37
58
|
|
|
38
59
|
/** Whether we've already sent a permission warning to the UI */
|
|
39
60
|
let _permissionDiagnosticSent = false;
|
|
@@ -395,8 +416,13 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
395
416
|
break;
|
|
396
417
|
|
|
397
418
|
case 'consolidate':
|
|
398
|
-
// Engine compressed the context — clear our accumulated history
|
|
399
|
-
|
|
419
|
+
// Engine compressed the context — clear our accumulated history for
|
|
420
|
+
// THIS thread only (task-320: per-thread history map).
|
|
421
|
+
if (threadId) {
|
|
422
|
+
messagesByThread.set(threadId, []);
|
|
423
|
+
} else {
|
|
424
|
+
messagesByThread.clear();
|
|
425
|
+
}
|
|
400
426
|
sendUnifyEvent({
|
|
401
427
|
type: 'consolidate',
|
|
402
428
|
archivedCount: event.archivedCount,
|
|
@@ -512,11 +538,16 @@ export async function handleUnifyChat(msg) {
|
|
|
512
538
|
// Create a stable conversationId for the Unify session
|
|
513
539
|
unifyConversationId = `unify-${Date.now()}`;
|
|
514
540
|
|
|
515
|
-
// Restore
|
|
541
|
+
// Restore per-thread history from persisted conversation store.
|
|
542
|
+
// task-320: bucket by threadId so each thread keeps its own context.
|
|
543
|
+
messagesByThread.clear();
|
|
516
544
|
const recent = session.conversationStore.loadRecent(50);
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
545
|
+
for (const m of recent) {
|
|
546
|
+
if (m.role !== 'user' && m.role !== 'assistant') continue;
|
|
547
|
+
const tid = m.threadId || MAIN_THREAD_ID;
|
|
548
|
+
const bucket = getThreadMessages(tid);
|
|
549
|
+
bucket.push({ role: m.role, content: m.content });
|
|
550
|
+
}
|
|
520
551
|
|
|
521
552
|
// Notify UI: session is ready with model info + conversationId
|
|
522
553
|
sendUnifyEvent({
|
|
@@ -533,13 +564,14 @@ export async function handleUnifyChat(msg) {
|
|
|
533
564
|
sendThreadListUpdate();
|
|
534
565
|
}
|
|
535
566
|
|
|
536
|
-
// ───
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
567
|
+
// ─── Per-call AbortController (task-320) ──
|
|
568
|
+
// Each call owns its own controller. Only once the router resolves the
|
|
569
|
+
// target thread do we register it into `abortByThread` and abort any
|
|
570
|
+
// prior controller on THAT same thread. Messages routed to different
|
|
571
|
+
// threads never alias each other's signals.
|
|
572
|
+
const abortCtrl = new AbortController();
|
|
573
|
+
/** @type {string | null} — set on routing_decision */
|
|
574
|
+
let resolvedThreadId = null;
|
|
543
575
|
|
|
544
576
|
// ─── Timeout guard: abort query if LLM hangs beyond threshold ──
|
|
545
577
|
// Resets on every event — fires only after prolonged silence.
|
|
@@ -547,9 +579,9 @@ export async function handleUnifyChat(msg) {
|
|
|
547
579
|
const resetQueryTimer = () => {
|
|
548
580
|
if (queryTimer) clearTimeout(queryTimer);
|
|
549
581
|
queryTimer = setTimeout(() => {
|
|
550
|
-
if (
|
|
582
|
+
if (!abortCtrl.signal.aborted) {
|
|
551
583
|
console.error(`[Unify] query timeout after ${QUERY_TIMEOUT_MS / 1000}s of silence — aborting`);
|
|
552
|
-
|
|
584
|
+
abortCtrl.abort();
|
|
553
585
|
}
|
|
554
586
|
}, QUERY_TIMEOUT_MS);
|
|
555
587
|
};
|
|
@@ -590,17 +622,31 @@ export async function handleUnifyChat(msg) {
|
|
|
590
622
|
onError: (err) => { throw err; },
|
|
591
623
|
};
|
|
592
624
|
|
|
593
|
-
for await (const pev of session.dispatcher.drain({ signal:
|
|
625
|
+
for await (const pev of session.dispatcher.drain({ signal: abortCtrl.signal })) {
|
|
594
626
|
resetQueryTimer();
|
|
627
|
+
// task-320: on routing_decision, bind this abort controller to the
|
|
628
|
+
// resolved target thread and abort any prior in-flight controller
|
|
629
|
+
// owned by that thread. Different threads don't alias.
|
|
630
|
+
if (pev && pev.type === 'routing_decision' && pev.targetThreadId && !resolvedThreadId) {
|
|
631
|
+
resolvedThreadId = pev.targetThreadId;
|
|
632
|
+
const prior = abortByThread.get(resolvedThreadId);
|
|
633
|
+
if (prior && prior !== abortCtrl) {
|
|
634
|
+
prior.abort();
|
|
635
|
+
}
|
|
636
|
+
abortByThread.set(resolvedThreadId, abortCtrl);
|
|
637
|
+
}
|
|
595
638
|
forwardPipelineEvent(pev, pipelineCtx);
|
|
596
639
|
}
|
|
597
640
|
|
|
598
641
|
// ─── Query complete — accumulate messages for context continuity ──
|
|
599
|
-
|
|
642
|
+
// task-320: per-thread history (no cross-thread contamination).
|
|
643
|
+
const historyThread = resolvedThreadId || MAIN_THREAD_ID;
|
|
644
|
+
const threadMessages = getThreadMessages(historyThread);
|
|
645
|
+
threadMessages.push({ role: 'user', content: cleanedPrompt });
|
|
600
646
|
|
|
601
647
|
const fullText = assistantTextParts.join('');
|
|
602
648
|
if (fullText) {
|
|
603
|
-
|
|
649
|
+
threadMessages.push({ role: 'assistant', content: fullText });
|
|
604
650
|
}
|
|
605
651
|
|
|
606
652
|
// ─── Signal turn end to UI ──
|
|
@@ -621,17 +667,14 @@ export async function handleUnifyChat(msg) {
|
|
|
621
667
|
}
|
|
622
668
|
|
|
623
669
|
} catch (err) {
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
}],
|
|
633
|
-
},
|
|
634
|
-
});
|
|
670
|
+
// task-320: classify both DOM AbortError and LLMAbortError as
|
|
671
|
+
// "aborted" — LLMAbortError is thrown by the LLM adapters when the
|
|
672
|
+
// signal trips and must NOT render as a session error bubble.
|
|
673
|
+
const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
|
|
674
|
+
if (isAbort) {
|
|
675
|
+
// Silent abort — the new in-flight round (on the same thread) will
|
|
676
|
+
// produce its own output. Still send `result` so the frontend's
|
|
677
|
+
// processing spinner for this exact send clears.
|
|
635
678
|
sendUnifyOutput({
|
|
636
679
|
type: 'result',
|
|
637
680
|
result_text: '',
|
|
@@ -672,7 +715,12 @@ export async function handleUnifyChat(msg) {
|
|
|
672
715
|
result_text: '',
|
|
673
716
|
});
|
|
674
717
|
} finally {
|
|
675
|
-
|
|
718
|
+
// task-320: only clear the per-thread slot if THIS controller is still
|
|
719
|
+
// the registered one. If a newer message already overwrote it, leaving
|
|
720
|
+
// the newer controller in the map is the correct state.
|
|
721
|
+
if (resolvedThreadId && abortByThread.get(resolvedThreadId) === abortCtrl) {
|
|
722
|
+
abortByThread.delete(resolvedThreadId);
|
|
723
|
+
}
|
|
676
724
|
}
|
|
677
725
|
}
|
|
678
726
|
|
|
@@ -863,11 +911,16 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
863
911
|
|
|
864
912
|
unifyConversationId = `unify-${Date.now()}`;
|
|
865
913
|
|
|
866
|
-
// Restore
|
|
914
|
+
// Restore per-thread history from persisted conversation store.
|
|
915
|
+
// task-320: bucket by threadId so each thread keeps its own context.
|
|
916
|
+
messagesByThread.clear();
|
|
867
917
|
const recent = session.conversationStore.loadRecent(50);
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
918
|
+
for (const m of recent) {
|
|
919
|
+
if (m.role !== 'user' && m.role !== 'assistant') continue;
|
|
920
|
+
const tid = m.threadId || MAIN_THREAD_ID;
|
|
921
|
+
const bucket = getThreadMessages(tid);
|
|
922
|
+
bucket.push({ role: m.role, content: m.content });
|
|
923
|
+
}
|
|
871
924
|
|
|
872
925
|
sendUnifyEvent({
|
|
873
926
|
type: 'session_ready',
|
|
@@ -915,16 +968,19 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
915
968
|
* session_ready so the frontend picks up updated models/config.
|
|
916
969
|
*/
|
|
917
970
|
export async function resetUnifySession() {
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
971
|
+
// task-320: abort ALL in-flight controllers (every thread) before
|
|
972
|
+
// tearing down the session. Leaves no dangling round still writing
|
|
973
|
+
// to stdout after shutdown.
|
|
974
|
+
for (const ctrl of abortByThread.values()) {
|
|
975
|
+
try { ctrl.abort(); } catch { /* ignore */ }
|
|
921
976
|
}
|
|
977
|
+
abortByThread.clear();
|
|
922
978
|
if (session) {
|
|
923
979
|
await session.shutdown();
|
|
924
980
|
session = null;
|
|
925
981
|
}
|
|
926
982
|
unifyConversationId = null;
|
|
927
|
-
|
|
983
|
+
messagesByThread.clear();
|
|
928
984
|
|
|
929
985
|
// Re-initialize session immediately so frontend gets updated config
|
|
930
986
|
try {
|
|
@@ -944,11 +1000,13 @@ export async function resetUnifySession() {
|
|
|
944
1000
|
|
|
945
1001
|
unifyConversationId = `unify-${Date.now()}`;
|
|
946
1002
|
|
|
947
|
-
// Restore
|
|
1003
|
+
// Restore per-thread history for LLM context (task-320).
|
|
948
1004
|
const recent = session.conversationStore.loadRecent(50);
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1005
|
+
for (const m of recent) {
|
|
1006
|
+
if (m.role !== 'user' && m.role !== 'assistant') continue;
|
|
1007
|
+
const tid = m.threadId || MAIN_THREAD_ID;
|
|
1008
|
+
getThreadMessages(tid).push({ role: m.role, content: m.content });
|
|
1009
|
+
}
|
|
952
1010
|
|
|
953
1011
|
sendUnifyEvent({
|
|
954
1012
|
type: 'session_ready',
|