bunnyquery 1.8.1 → 1.8.3
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/bunnyquery.js +566 -108
- package/dist/engine.cjs +480 -35
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +210 -4
- package/dist/engine.d.ts +210 -4
- package/dist/engine.mjs +461 -36
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/download_encoding.ts +281 -0
- package/src/engine/errors.ts +34 -0
- package/src/engine/host.ts +16 -0
- package/src/engine/index.ts +4 -0
- package/src/engine/office.ts +25 -8
- package/src/engine/prompts/chat_system_prompt.ts +1 -0
- package/src/engine/requests.ts +28 -4
- package/src/engine/session.ts +348 -31
package/src/engine/session.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
extractOpenAIText,
|
|
29
29
|
getChatHistory,
|
|
30
30
|
POLL_INTERVAL,
|
|
31
|
-
|
|
31
|
+
bgIndexingQueueName,
|
|
32
32
|
isBgIndexingQueue,
|
|
33
33
|
ANTHROPIC_MESSAGES_API_URL,
|
|
34
34
|
OPENAI_RESPONSES_API_URL,
|
|
@@ -42,7 +42,7 @@ import { createInlineLinkRegex } from './links';
|
|
|
42
42
|
import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText } from './history';
|
|
43
43
|
import { wallClockNow } from './time';
|
|
44
44
|
import { parseAttachmentContent } from './attachment_parsers';
|
|
45
|
-
import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
|
|
45
|
+
import type { ChatHost, ChatState, ChatMessage, ChatIdentity, PinnedDispatchContext } from './host';
|
|
46
46
|
import type { IndexingGroup } from './indexing_groups';
|
|
47
47
|
|
|
48
48
|
function sleep(ms: number): Promise<void> {
|
|
@@ -59,6 +59,30 @@ const WORKER_PASS_ADOPT_LIMIT = 20;
|
|
|
59
59
|
// that is still alive. Fixed and finite: this must never become a poll.
|
|
60
60
|
const WORKER_PASS_ADOPT_ATTEMPTS = [0, 2000, 6000];
|
|
61
61
|
|
|
62
|
+
// awaitIndexingDrained: how often it re-asks the background-indexing queue, and
|
|
63
|
+
// how many consecutive empty answers it needs before believing the chains are
|
|
64
|
+
// over. More than one for the same reason the adopt ladder above looks twice —
|
|
65
|
+
// pass N+1 is written just after pass N resolves, so a single look lands in that
|
|
66
|
+
// gap and reports an idle queue for a file that is still being read.
|
|
67
|
+
//
|
|
68
|
+
// Two cadences: while work is visibly running there is nothing to be gained by
|
|
69
|
+
// asking often (an indexing pass takes tens of seconds, and a big file can hold
|
|
70
|
+
// the queue for many minutes), but once the queue looks empty the confirming
|
|
71
|
+
// look is all that stands between the user and their answer.
|
|
72
|
+
const INDEXING_DRAIN_BUSY_POLL_MS = 8000;
|
|
73
|
+
const INDEXING_DRAIN_CONFIRM_POLL_MS = 3000;
|
|
74
|
+
const INDEXING_DRAIN_IDLE_LOOKS = 2;
|
|
75
|
+
// Floor on the whole wait. The status index is eventually consistent, so the
|
|
76
|
+
// pass this turn's own upload just enqueued can be missing from the first looks —
|
|
77
|
+
// releasing on those would send the chat ahead of the very files it is asking
|
|
78
|
+
// about. Costs nothing in the normal case, where the queue reports work
|
|
79
|
+
// immediately and the wait is far longer than this anyway.
|
|
80
|
+
const INDEXING_DRAIN_MIN_MS = 8000;
|
|
81
|
+
// Ceiling on that wait. Past it the turn is sent regardless: an answer computed
|
|
82
|
+
// against a partly-indexed file is a poor outcome, but a question that is never
|
|
83
|
+
// asked at all because one chain wedged server-side is a worse one.
|
|
84
|
+
const INDEXING_DRAIN_TIMEOUT_MS = 15 * 60 * 1000;
|
|
85
|
+
|
|
62
86
|
// requestAnimationFrame / high-res clock, reached through globalThis so the
|
|
63
87
|
// engine stays DOM-free at the type level (and degrades gracefully in non-DOM
|
|
64
88
|
// / test environments where these globals are absent).
|
|
@@ -107,6 +131,17 @@ export class ChatSession {
|
|
|
107
131
|
private _pauseReasons: Set<string>;
|
|
108
132
|
private _resuming: boolean;
|
|
109
133
|
private _lidSeq: number;
|
|
134
|
+
private _stageSeq: number;
|
|
135
|
+
/** How many attachment-upload batches are running. uploadingAttachments is a
|
|
136
|
+
* single flag but batches overlap (the composer stays live, so the user can
|
|
137
|
+
* send a second one while the first uploads), and a nested finish must not
|
|
138
|
+
* clear the flag out from under the batch still running. */
|
|
139
|
+
private _uploadBatches: number;
|
|
140
|
+
/** Indexing requests whose ack has not come back yet. Until it does the item
|
|
141
|
+
* is not on the server's queue, so awaitIndexingDrained cannot see it — and
|
|
142
|
+
* would read the gap between "pass N settled" and "pass N+1 accepted" as the
|
|
143
|
+
* file being finished. */
|
|
144
|
+
private _indexDispatchesInFlight: number;
|
|
110
145
|
|
|
111
146
|
constructor(host: ChatHost) {
|
|
112
147
|
this.host = host;
|
|
@@ -133,6 +168,18 @@ export class ChatSession {
|
|
|
133
168
|
this._pauseReasons = new Set();
|
|
134
169
|
this._resuming = false;
|
|
135
170
|
this._lidSeq = 0;
|
|
171
|
+
this._stageSeq = 0;
|
|
172
|
+
this._uploadBatches = 0;
|
|
173
|
+
this._indexDispatchesInFlight = 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Wrap an indexing-request dispatch so awaitIndexingDrained counts it as
|
|
177
|
+
* live work from the moment it is sent, not from the moment it is acked. */
|
|
178
|
+
trackIndexDispatch<T>(p: Promise<T>): Promise<T> {
|
|
179
|
+
var self = this;
|
|
180
|
+
this._indexDispatchesInFlight += 1;
|
|
181
|
+
var release = function () { self._indexDispatchesInFlight = Math.max(0, self._indexDispatchesInFlight - 1); };
|
|
182
|
+
return p.then(function (v) { release(); return v; }, function (e) { release(); throw e; });
|
|
136
183
|
}
|
|
137
184
|
|
|
138
185
|
/**
|
|
@@ -252,8 +299,15 @@ export class ChatSession {
|
|
|
252
299
|
// replay on every later visit to B. Bubbles with no _ownerKey (server
|
|
253
300
|
// history, bg tasks) are always kept. Single pass: this runs on the
|
|
254
301
|
// typewriter hot path.
|
|
302
|
+
//
|
|
303
|
+
// Staged bubbles (_stageId) are never cached. What resolves one is an
|
|
304
|
+
// in-page upload; a reload kills that upload but not the cache, so a cached
|
|
305
|
+
// staged bubble would come back as a message that uploads forever. Dropping
|
|
306
|
+
// it costs nothing while the page lives — a history refetch re-rescues it
|
|
307
|
+
// from state.messages, and a dispatch that no longer finds it just appends.
|
|
255
308
|
this.aiChatHistoryCache[key] = {
|
|
256
309
|
messages: this.state.messages.filter(function (m) {
|
|
310
|
+
if (m._stageId) return false;
|
|
257
311
|
return m._ownerKey === undefined || m._ownerKey === key;
|
|
258
312
|
}),
|
|
259
313
|
endOfList: this.state.historyEndOfList,
|
|
@@ -432,12 +486,177 @@ export class ChatSession {
|
|
|
432
486
|
return run;
|
|
433
487
|
}
|
|
434
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Put a turn on screen the INSTANT the user hits Send, before its attachments
|
|
491
|
+
* have finished uploading. Uploads run in the background now (the composer is
|
|
492
|
+
* cleared and stays usable), so without a staged bubble the message would
|
|
493
|
+
* appear only once its files were up — below anything the user sent in the
|
|
494
|
+
* meantime, in an order that never matches what they typed.
|
|
495
|
+
*
|
|
496
|
+
* Staged bubbles carry _useBgQueue because that is where a turn with
|
|
497
|
+
* attachments ultimately dispatches (behind its own indexing tasks). That flag
|
|
498
|
+
* is also what keeps promoteNextQueuedToRunning / resolveQueuedUserBubble off
|
|
499
|
+
* them: those advance the SERVER queue, and a staged turn has no server
|
|
500
|
+
* request behind it yet.
|
|
501
|
+
*
|
|
502
|
+
* Returns the id to hand back as PinnedDispatchContext.stageId at dispatch.
|
|
503
|
+
*/
|
|
504
|
+
stageOutgoingMessage(displayText: string): string {
|
|
505
|
+
this._stageSeq += 1;
|
|
506
|
+
var stageId = 'stg_' + this._stageSeq;
|
|
507
|
+
var key = this.getHistoryCacheKey();
|
|
508
|
+
var staged: ChatMessage = {
|
|
509
|
+
role: 'user', content: displayText,
|
|
510
|
+
isPendingQueued: true, isUploadingAttachments: true, isSendingToServer: true,
|
|
511
|
+
_useBgQueue: true, _stageId: stageId, _ts: wallClockNow(),
|
|
512
|
+
};
|
|
513
|
+
if (key) staged._ownerKey = key;
|
|
514
|
+
this.state.messages.push(staged);
|
|
515
|
+
this.host.notify(); this.host.scrollToBottom(true);
|
|
516
|
+
return stageId;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private _stageIndex(list: ChatMessage[], stageId?: string): number {
|
|
520
|
+
if (!stageId) return -1;
|
|
521
|
+
for (var i = 0; i < list.length; i++) {
|
|
522
|
+
if (list[i] && list[i]._stageId === stageId) return i;
|
|
523
|
+
}
|
|
524
|
+
return -1;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Where a staged turn belongs once it is finally sent: BELOW every indexing
|
|
529
|
+
* row, because that is the order it ran in and the order the server history
|
|
530
|
+
* will report on the next load (its request id is newer than every pass it
|
|
531
|
+
* waited for). While its files were uploading it sat above them — the rows
|
|
532
|
+
* are injected as each file's pass starts, after the bubble was staged.
|
|
533
|
+
*
|
|
534
|
+
* Returns the index to insert at, or -1 to leave the turn where it is. Never
|
|
535
|
+
* moves a turn UP: a bubble that already sits below the indexing rows (or a
|
|
536
|
+
* chat with no indexing at all) must not jump backwards over anything.
|
|
537
|
+
*/
|
|
538
|
+
private _settledStagePosition(fromIdx: number): number {
|
|
539
|
+
var lastBg = -1;
|
|
540
|
+
for (var i = 0; i < this.state.messages.length; i++) {
|
|
541
|
+
if (this.state.messages[i] && this.state.messages[i].isBackgroundTask) lastBg = i;
|
|
542
|
+
}
|
|
543
|
+
if (lastBg <= fromIdx) return -1;
|
|
544
|
+
// Splicing the staged bubble out first shifts everything after it down one.
|
|
545
|
+
return lastBg;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** The staged turn's files are up; it is now just waiting its place in the
|
|
549
|
+
* queue. Drops the "(Uploading files...)" note back to "(In queue)". */
|
|
550
|
+
markStagedMessageQueued(stageId: string): void {
|
|
551
|
+
var idx = this._stageIndex(this.state.messages, stageId);
|
|
552
|
+
if (idx === -1) return;
|
|
553
|
+
var ex = this.state.messages[idx];
|
|
554
|
+
if (!ex.isUploadingAttachments) return;
|
|
555
|
+
this.state.messages[idx] = Object.assign({}, ex, { isUploadingAttachments: false });
|
|
556
|
+
this.host.notify();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Resolves once this project's background-indexing queue has nothing left to
|
|
561
|
+
* run, so a chat enqueued right after it is genuinely last.
|
|
562
|
+
*
|
|
563
|
+
* Sending the chat as soon as the uploads finish is not enough, which is the
|
|
564
|
+
* whole reason this exists: indexing a file is a CHAIN, and each pass is only
|
|
565
|
+
* enqueued once the previous one lands (the client mints CONTINUE passes for
|
|
566
|
+
* text/grid files, the worker mints them for PDFs and windowed reads). Every
|
|
567
|
+
* one of those passes therefore queues up BEHIND a chat sent at upload time,
|
|
568
|
+
* and the model answers from a file it has only partly read.
|
|
569
|
+
*
|
|
570
|
+
* The queue is read from the server's status index rather than from
|
|
571
|
+
* bgTaskQueue: that mirror holds only what this client dispatched or adopted,
|
|
572
|
+
* and it stops being maintained once the view unmounts. An empty answer has to
|
|
573
|
+
* repeat before it is believed — see INDEXING_DRAIN_IDLE_LOOKS — and a look
|
|
574
|
+
* that fails counts as busy, so a dropped request delays the turn instead of
|
|
575
|
+
* releasing it early.
|
|
576
|
+
*
|
|
577
|
+
* Reads the identity PINNED at Send time, never a live one: the user may be in
|
|
578
|
+
* another project by now, and this must keep asking about the one they sent
|
|
579
|
+
* from.
|
|
580
|
+
*/
|
|
581
|
+
awaitIndexingDrained(identity: ChatIdentity): Promise<'drained' | 'timedout' | 'skipped'> {
|
|
582
|
+
var self = this;
|
|
583
|
+
var svcId = identity && identity.serviceId;
|
|
584
|
+
var platform = identity && identity.platform;
|
|
585
|
+
if (!svcId || (platform !== 'claude' && platform !== 'openai')) return Promise.resolve('skipped' as const);
|
|
586
|
+
var owner = identity.owner;
|
|
587
|
+
var queue = bgIndexingQueueName(identity.userId, svcId);
|
|
588
|
+
var startedAt = nowMs();
|
|
589
|
+
var deadline = startedAt + INDEXING_DRAIN_TIMEOUT_MS;
|
|
590
|
+
var idleLooks = 0;
|
|
591
|
+
var ask = function (status: 'pending' | 'running') {
|
|
592
|
+
return Promise.resolve(getChatHistory(
|
|
593
|
+
{ service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
|
|
594
|
+
{ limit: WORKER_PASS_ADOPT_LIMIT },
|
|
595
|
+
)).catch(function () { return null; });
|
|
596
|
+
};
|
|
597
|
+
// Ordinary chats are routed onto this queue too (_isOnBgQueue), and those
|
|
598
|
+
// are not work this turn has to wait behind — the server runs the queue in
|
|
599
|
+
// order, so being enqueued after them is enough.
|
|
600
|
+
var hasLiveIndexing = function (res: any): boolean {
|
|
601
|
+
var list = res && Array.isArray(res.list) ? res.list : [];
|
|
602
|
+
for (var i = 0; i < list.length; i++) {
|
|
603
|
+
var item = list[i];
|
|
604
|
+
if (!item || (item.status !== 'pending' && item.status !== 'running')) continue;
|
|
605
|
+
if (isIndexingRequestText(extractLastUserTextFromRequest(item.request_body))) return true;
|
|
606
|
+
}
|
|
607
|
+
return false;
|
|
608
|
+
};
|
|
609
|
+
return new Promise(function (resolve) {
|
|
610
|
+
var again = function () {
|
|
611
|
+
setTimeout(look, idleLooks > 0 ? INDEXING_DRAIN_CONFIRM_POLL_MS : INDEXING_DRAIN_BUSY_POLL_MS);
|
|
612
|
+
};
|
|
613
|
+
var look = function () {
|
|
614
|
+
if (nowMs() >= deadline) { resolve('timedout'); return; }
|
|
615
|
+
// A pass whose ack is still in flight is not on the queue yet, so no
|
|
616
|
+
// look can see it.
|
|
617
|
+
if (self._indexDispatchesInFlight > 0) { idleLooks = 0; again(); return; }
|
|
618
|
+
Promise.all([ask('running'), ask('pending')]).then(function (res: any[]) {
|
|
619
|
+
var unknown = res[0] === null || res[1] === null;
|
|
620
|
+
if (unknown || hasLiveIndexing(res[0]) || hasLiveIndexing(res[1])) idleLooks = 0;
|
|
621
|
+
else idleLooks += 1;
|
|
622
|
+
if (idleLooks >= INDEXING_DRAIN_IDLE_LOOKS && nowMs() - startedAt >= INDEXING_DRAIN_MIN_MS) {
|
|
623
|
+
resolve('drained'); return;
|
|
624
|
+
}
|
|
625
|
+
again();
|
|
626
|
+
}, function () { idleLooks = 0; again(); });
|
|
627
|
+
};
|
|
628
|
+
look();
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Abandon a staged turn — its uploads failed outright, so nothing will be
|
|
634
|
+
* dispatched. The bubble stays (the user's text is not silently thrown away)
|
|
635
|
+
* but settles into a plain, non-pending message; the caller reports the
|
|
636
|
+
* failure separately.
|
|
637
|
+
*/
|
|
638
|
+
settleStagedMessage(stageId: string): void {
|
|
639
|
+
var idx = this._stageIndex(this.state.messages, stageId);
|
|
640
|
+
if (idx === -1) return;
|
|
641
|
+
var ex = this.state.messages[idx];
|
|
642
|
+
var settled: ChatMessage = { role: 'user', content: ex.content };
|
|
643
|
+
if (ex._ownerKey !== undefined) settled._ownerKey = ex._ownerKey;
|
|
644
|
+
if (ex._ts !== undefined) settled._ts = ex._ts;
|
|
645
|
+
this.state.messages[idx] = settled;
|
|
646
|
+
this.host.notify(); this.updateHistoryCache();
|
|
647
|
+
}
|
|
648
|
+
|
|
435
649
|
// composed = clean display text; composedForLlm carries office-extraction
|
|
436
650
|
// placeholders for the provider only. useBgQueue routes a post-attachment turn
|
|
437
651
|
// onto the "-bg" queue so it runs after indexing.
|
|
438
652
|
dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any, fileUrls?: any, pinned?: PinnedDispatchContext): void {
|
|
439
653
|
var self = this;
|
|
440
|
-
|
|
654
|
+
// This turn may already have a bubble on screen, staged at Send time while
|
|
655
|
+
// its attachments uploaded. Every exit from here has to account for it:
|
|
656
|
+
// dispatching replaces it in place (so it keeps the position the user sent
|
|
657
|
+
// it in), and bailing settles it (so it never sits uploading forever).
|
|
658
|
+
var stageId = pinned ? pinned.stageId : undefined;
|
|
659
|
+
if (!composed) { if (stageId) this.settleStagedMessage(stageId); return; }
|
|
441
660
|
// A send can be dispatched LONG after the user hit Send (attachment
|
|
442
661
|
// uploads are awaited first), by which time the live identity may have
|
|
443
662
|
// moved to another project. The caller pins the identity + system prompt
|
|
@@ -445,7 +664,7 @@ export class ChatSession {
|
|
|
445
664
|
// question was actually asked of. Falls back to the live read when the
|
|
446
665
|
// caller doesn't pin (the widget, which has only one project anyway).
|
|
447
666
|
var id = pinned ? pinned.identity : this.host.getIdentity();
|
|
448
|
-
if (id.platform === 'none') return;
|
|
667
|
+
if (id.platform === 'none') { if (stageId) this.settleStagedMessage(stageId); return; }
|
|
449
668
|
|
|
450
669
|
var llmComposed = composedForLlm || composed;
|
|
451
670
|
|
|
@@ -469,7 +688,13 @@ export class ChatSession {
|
|
|
469
688
|
var aiModel = id.model || undefined;
|
|
470
689
|
var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
|
|
471
690
|
var userId = id.userId || id.serviceId;
|
|
472
|
-
|
|
691
|
+
// Same string the indexing passes are enqueued under (bgIndexingQueueName),
|
|
692
|
+
// which is the whole reason this turn ends up behind them: the backend runs
|
|
693
|
+
// different queue names in parallel and only serialises a shared one.
|
|
694
|
+
var chatQueue = useBgQueue ? bgIndexingQueueName(userId) : userId;
|
|
695
|
+
// _stageIndex is -1 when nothing was staged, or when the staged bubble is
|
|
696
|
+
// gone (a remount rebuilds the list from the cache, which never holds staged
|
|
697
|
+
// bubbles); either way the branches below fall back to appending.
|
|
473
698
|
|
|
474
699
|
if (offChat) {
|
|
475
700
|
// Stage the turn in the pinned chat's cache and dispatch. The
|
|
@@ -479,16 +704,28 @@ export class ChatSession {
|
|
|
479
704
|
// the pending bubble in this same cache entry when the reply lands.
|
|
480
705
|
var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function (m) {
|
|
481
706
|
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
|
|
482
|
-
!m.isCancelled && !m.isBackgroundTask;
|
|
707
|
+
!m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
483
708
|
});
|
|
484
709
|
var offBounded = buildBoundedChatMessages({
|
|
485
710
|
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
|
|
486
711
|
history: offHistory.concat([{ role: 'user', content: llmComposed }]),
|
|
487
712
|
});
|
|
488
713
|
var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
|
|
714
|
+
var offUser: ChatMessage = { role: 'user', content: composed, _ownerKey: key, _ts: wallClockNow() };
|
|
715
|
+
// The user hit Send here, then navigated away before the uploads
|
|
716
|
+
// finished. The staged bubble belongs to THAT chat, not the one now on
|
|
717
|
+
// screen, and the turn it stood in for is being cached below — so drop
|
|
718
|
+
// it from the live list instead of leaving a bubble that uploads
|
|
719
|
+
// forever in a project it does not belong to.
|
|
720
|
+
var offStage = this._stageIndex(this.state.messages, stageId);
|
|
721
|
+
if (offStage !== -1) {
|
|
722
|
+
if (this.state.messages[offStage]._ts !== undefined) offUser._ts = this.state.messages[offStage]._ts;
|
|
723
|
+
this.state.messages.splice(offStage, 1);
|
|
724
|
+
this.host.notify();
|
|
725
|
+
}
|
|
489
726
|
this.aiChatHistoryCache[key] = {
|
|
490
727
|
messages: offExisting.messages.concat([
|
|
491
|
-
|
|
728
|
+
offUser,
|
|
492
729
|
{ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
|
|
493
730
|
]),
|
|
494
731
|
endOfList: offExisting.endOfList,
|
|
@@ -505,7 +742,7 @@ export class ChatSession {
|
|
|
505
742
|
if (isQueuedSend) {
|
|
506
743
|
var resolvedHistory = this.state.messages.filter(function (m) {
|
|
507
744
|
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
|
|
508
|
-
!m.isCancelled && !m.isBackgroundTask;
|
|
745
|
+
!m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
509
746
|
});
|
|
510
747
|
var boundedQ = buildBoundedChatMessages({
|
|
511
748
|
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
|
|
@@ -514,7 +751,20 @@ export class ChatSession {
|
|
|
514
751
|
var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
|
|
515
752
|
if (key) queuedBubble._ownerKey = key;
|
|
516
753
|
if (useBgQueue) queuedBubble._useBgQueue = true;
|
|
517
|
-
this.state.messages
|
|
754
|
+
var qStage = this._stageIndex(this.state.messages, stageId);
|
|
755
|
+
if (qStage !== -1) {
|
|
756
|
+
// Keep the send time the user saw, not the upload's finish time.
|
|
757
|
+
if (this.state.messages[qStage]._ts !== undefined) queuedBubble._ts = this.state.messages[qStage]._ts;
|
|
758
|
+
var qTarget = this._settledStagePosition(qStage);
|
|
759
|
+
if (qTarget === -1) {
|
|
760
|
+
this.state.messages.splice(qStage, 1, queuedBubble);
|
|
761
|
+
} else {
|
|
762
|
+
this.state.messages.splice(qStage, 1);
|
|
763
|
+
this.state.messages.splice(qTarget, 0, queuedBubble);
|
|
764
|
+
}
|
|
765
|
+
} else {
|
|
766
|
+
this.state.messages.push(queuedBubble);
|
|
767
|
+
}
|
|
518
768
|
this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
|
|
519
769
|
|
|
520
770
|
var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
|
|
@@ -523,9 +773,13 @@ export class ChatSession {
|
|
|
523
773
|
// Only ack a bubble that belongs to THIS chat — the search is
|
|
524
774
|
// positional, so on another project it would stamp this turn's
|
|
525
775
|
// _serverItemId onto that project's unrelated in-flight bubble.
|
|
776
|
+
// Staged bubbles (_stageId) are skipped for the same reason: they
|
|
777
|
+
// sit EARLIER in the list and match this shape exactly, so a turn
|
|
778
|
+
// whose files are still uploading would take the ack — and the
|
|
779
|
+
// server id — belonging to the turn that actually just sent.
|
|
526
780
|
var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function (m) {
|
|
527
781
|
return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user' &&
|
|
528
|
-
(m._ownerKey === undefined || m._ownerKey === capturedKey);
|
|
782
|
+
!m._stageId && (m._ownerKey === undefined || m._ownerKey === capturedKey);
|
|
529
783
|
});
|
|
530
784
|
var serverId = result && typeof result.id === 'string' ? result.id : undefined;
|
|
531
785
|
if (sendingIdx >= 0) {
|
|
@@ -553,19 +807,56 @@ export class ChatSession {
|
|
|
553
807
|
// view unmount), then rendered from the cache via typewriteLatestReply. A
|
|
554
808
|
// later resumePendingRequest() re-renders it if the view remounted while the
|
|
555
809
|
// request was still in flight.
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
810
|
+
var immediateUser: ChatMessage = { role: 'user', content: composed, _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) };
|
|
811
|
+
var immediatePlaceholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) };
|
|
812
|
+
var iStage = this._stageIndex(this.state.messages, stageId);
|
|
813
|
+
if (iStage !== -1) {
|
|
814
|
+
// Keep the send time the user saw, not the upload's finish time.
|
|
815
|
+
if (this.state.messages[iStage]._ts !== undefined) immediateUser._ts = this.state.messages[iStage]._ts;
|
|
816
|
+
var iTarget = this._settledStagePosition(iStage);
|
|
817
|
+
if (iTarget === -1) {
|
|
818
|
+
this.state.messages.splice(iStage, 1, immediateUser, immediatePlaceholder);
|
|
819
|
+
} else {
|
|
820
|
+
this.state.messages.splice(iStage, 1);
|
|
821
|
+
this.state.messages.splice(iTarget, 0, immediateUser, immediatePlaceholder);
|
|
567
822
|
}
|
|
823
|
+
} else {
|
|
824
|
+
this.state.messages.push(immediateUser);
|
|
825
|
+
this.state.messages.push(immediatePlaceholder);
|
|
568
826
|
}
|
|
827
|
+
this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
|
|
828
|
+
|
|
829
|
+
// Same filter as the offChat and isQueuedSend paths above. It must drop the
|
|
830
|
+
// pending flags too: the `isPending` placeholder pushed two lines up is the
|
|
831
|
+
// in-flight "Thinking..." bubble, and leaving it in sent a trailing
|
|
832
|
+
// `{role:'assistant', content:''}` upstream on EVERY immediate send. That is
|
|
833
|
+
// a last-assistant-turn prefill, which the Claude platform rejects outright,
|
|
834
|
+
// and on both platforms it made the history end on an assistant turn, so
|
|
835
|
+
// prepareOpenAIMessages / prepareClaudeMessages bailed at their
|
|
836
|
+
// `last.role !== 'user'` guard and silently stopped converting attached
|
|
837
|
+
// images into image blocks.
|
|
838
|
+
//
|
|
839
|
+
// `isError` is dropped in all three paths for a different reason: those
|
|
840
|
+
// bubbles are written by THIS client, never by the model. Every site that
|
|
841
|
+
// sets `isError: true` fills content from getErrorMessage() or the literal
|
|
842
|
+
// 'Request was cancelled.', so filtering them discards no model-authored
|
|
843
|
+
// text. Keeping them handed the model a turn it never produced ("The AI
|
|
844
|
+
// provider is temporarily unreachable..."), attributed to itself, because the
|
|
845
|
+
// flag is stripped when buildBoundedChatMessages maps down to {role,content}.
|
|
846
|
+
// The cost is that a retry now follows an unanswered question with no stated
|
|
847
|
+
// reason, which is a true account of what happened rather than a false one.
|
|
848
|
+
//
|
|
849
|
+
// The turn being sent is appended LAST rather than left wherever its bubble
|
|
850
|
+
// sits. A staged turn keeps the position it was sent in, which is NOT the
|
|
851
|
+
// end of the list once a message sent after it (while its files uploaded)
|
|
852
|
+
// has already been answered — and a history that ends on an assistant turn
|
|
853
|
+
// hits exactly the `last.role !== 'user'` bail described above.
|
|
854
|
+
var historyForLlm = this.state.messages.filter(function (m) {
|
|
855
|
+
if (m === immediateUser) return false;
|
|
856
|
+
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
|
|
857
|
+
!m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
858
|
+
});
|
|
859
|
+
historyForLlm.push({ role: 'user', content: llmComposed });
|
|
569
860
|
var bounded = buildBoundedChatMessages({
|
|
570
861
|
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
|
|
571
862
|
history: historyForLlm,
|
|
@@ -811,7 +1102,7 @@ export class ChatSession {
|
|
|
811
1102
|
if (platform !== 'claude' && platform !== 'openai') return;
|
|
812
1103
|
var url = platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
|
|
813
1104
|
var queueBase = id.userId || id.serviceId;
|
|
814
|
-
var queue = (msg.isBackgroundTask || msg._useBgQueue) ? queueBase
|
|
1105
|
+
var queue = (msg.isBackgroundTask || msg._useBgQueue) ? bgIndexingQueueName(queueBase) : queueBase;
|
|
815
1106
|
this.state.messages[idx] = Object.assign({}, msg, { _cancelling: true, _cancelError: undefined });
|
|
816
1107
|
this.host.notify();
|
|
817
1108
|
Promise.resolve(this.host.cancelRequest({
|
|
@@ -1342,7 +1633,7 @@ export class ChatSession {
|
|
|
1342
1633
|
if (!id.serviceId || (platform !== 'claude' && platform !== 'openai')) return;
|
|
1343
1634
|
if (this.isPollingPaused() || !this.host.isViewMounted()) return;
|
|
1344
1635
|
var svcId = id.serviceId, owner = id.owner;
|
|
1345
|
-
var queue = (id.userId
|
|
1636
|
+
var queue = bgIndexingQueueName(id.userId, id.serviceId);
|
|
1346
1637
|
var ask = function (status: 'pending' | 'running') {
|
|
1347
1638
|
return Promise.resolve(getChatHistory(
|
|
1348
1639
|
{ service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
|
|
@@ -1469,7 +1760,7 @@ export class ChatSession {
|
|
|
1469
1760
|
var url = id.platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
|
|
1470
1761
|
Promise.resolve(this.host.cancelRequest({
|
|
1471
1762
|
url: url, method: 'POST', id: serverId,
|
|
1472
|
-
queue: (id.userId
|
|
1763
|
+
queue: bgIndexingQueueName(id.userId, id.serviceId),
|
|
1473
1764
|
service: id.serviceId, owner: id.owner,
|
|
1474
1765
|
})).catch(function () { /* the pass may already have finished; nothing to do */ });
|
|
1475
1766
|
}
|
|
@@ -1618,7 +1909,10 @@ export class ChatSession {
|
|
|
1618
1909
|
if (pass > MAX_INDEXING_RESUME_PASSES) return; // give up after the cap
|
|
1619
1910
|
var id = this.host.getIdentity();
|
|
1620
1911
|
if (!id || id.platform === 'none' || id.serviceId !== entry.serviceId) return;
|
|
1621
|
-
|
|
1912
|
+
// Counted as live work from here, not from the ack: awaitIndexingDrained
|
|
1913
|
+
// asks the SERVER what is queued, and this pass is not queued until the
|
|
1914
|
+
// call below returns. Without it a chat can slip in between two passes.
|
|
1915
|
+
this.trackIndexDispatch(notifyAgentContinueIndexing({
|
|
1622
1916
|
platform: id.platform as 'claude' | 'openai',
|
|
1623
1917
|
model: id.model,
|
|
1624
1918
|
service: id.serviceId,
|
|
@@ -1644,7 +1938,7 @@ export class ChatSession {
|
|
|
1644
1938
|
});
|
|
1645
1939
|
self.drainBgTaskQueue();
|
|
1646
1940
|
}
|
|
1647
|
-
}, function (e: any) { console.error('[chat-engine] resume-indexing dispatch failed', e); });
|
|
1941
|
+
}, function (e: any) { console.error('[chat-engine] resume-indexing dispatch failed', e); }));
|
|
1648
1942
|
} catch (e) { /* best-effort: resume must never break bg-task resolution */ }
|
|
1649
1943
|
}
|
|
1650
1944
|
|
|
@@ -1741,6 +2035,13 @@ export class ChatSession {
|
|
|
1741
2035
|
if (mm._ownerKey !== undefined && mm._ownerKey !== loadKey) continue;
|
|
1742
2036
|
if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
|
|
1743
2037
|
if (!mm._serverItemId) {
|
|
2038
|
+
// A staged turn (files still uploading) has no server request
|
|
2039
|
+
// yet, so nothing in `mapped` can stand for it — rescue it
|
|
2040
|
+
// unconditionally. The mappedHasPendingAssistant skip below is
|
|
2041
|
+
// about a turn the server ALREADY has; applying it here would
|
|
2042
|
+
// delete the user's message mid-upload whenever some other
|
|
2043
|
+
// turn happened to be in flight.
|
|
2044
|
+
if (mm._stageId) { rescued.push(mm); continue; }
|
|
1744
2045
|
if (mappedHasPendingAssistant) continue;
|
|
1745
2046
|
if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
|
|
1746
2047
|
else if (self.state.sending && mm.role === 'user') {
|
|
@@ -2014,7 +2315,9 @@ export class ChatSession {
|
|
|
2014
2315
|
return preIndex.then(function () {
|
|
2015
2316
|
return parseAttachmentContent(member.file, member.file.name, mime || undefined);
|
|
2016
2317
|
}).then(function (parsedContent: string | null) {
|
|
2017
|
-
|
|
2318
|
+
// Tracked so a chat waiting on awaitIndexingDrained cannot be sent
|
|
2319
|
+
// in the window between this call and the queue accepting it.
|
|
2320
|
+
return self.trackIndexDispatch(notifyAgentSaveAttachment({
|
|
2018
2321
|
platform: id.platform as 'claude' | 'openai',
|
|
2019
2322
|
model: id.model,
|
|
2020
2323
|
service: id.serviceId,
|
|
@@ -2049,7 +2352,7 @@ export class ChatSession {
|
|
|
2049
2352
|
att.errorCode = (e && (e.code || (e.body && e.body.code))) || '';
|
|
2050
2353
|
att.errorDetail = (e && (e.message || (e.body && e.body.message))) || (typeof e === 'string' ? e : '');
|
|
2051
2354
|
}
|
|
2052
|
-
});
|
|
2355
|
+
}));
|
|
2053
2356
|
});
|
|
2054
2357
|
});
|
|
2055
2358
|
});
|
|
@@ -2066,14 +2369,24 @@ export class ChatSession {
|
|
|
2066
2369
|
|
|
2067
2370
|
// Upload all not-yet-done attachments sequentially. Resolves to the full
|
|
2068
2371
|
// list of { name, url, storagePath } for composing the chat message.
|
|
2069
|
-
|
|
2372
|
+
//
|
|
2373
|
+
// `batchId` scopes the run to the chips stamped with it at Send time. The
|
|
2374
|
+
// composer stays live during an upload, so by the time this runs the
|
|
2375
|
+
// attachment list can already hold chips the user picked for the NEXT
|
|
2376
|
+
// message — uploading those here would attach them to the wrong turn, and
|
|
2377
|
+
// collecting the previous batch's finished urls would attach files the user
|
|
2378
|
+
// already sent. Omitted (no batch) means every chip, the old behavior.
|
|
2379
|
+
uploadPendingAttachments(batchId?: string): Promise<Array<{ name: string; url: string; storagePath?: string }>> {
|
|
2070
2380
|
var self = this;
|
|
2071
2381
|
this.host.resetOverwriteBatch();
|
|
2382
|
+
this._uploadBatches += 1;
|
|
2072
2383
|
this.state.uploadingAttachments = true;
|
|
2073
2384
|
this.host.updateComposerControls();
|
|
2074
2385
|
this.host.renderAttachmentChips();
|
|
2075
2386
|
var collected: Array<{ name: string; url: string; storagePath?: string }> = [];
|
|
2076
|
-
var snapshot = this.state.attachments.
|
|
2387
|
+
var snapshot = this.state.attachments.filter(function (a: any) {
|
|
2388
|
+
return batchId ? a._batchId === batchId : true;
|
|
2389
|
+
});
|
|
2077
2390
|
var chain: Promise<any> = Promise.resolve();
|
|
2078
2391
|
snapshot.forEach(function (att: any) {
|
|
2079
2392
|
chain = chain.then(function () {
|
|
@@ -2101,7 +2414,11 @@ export class ChatSession {
|
|
|
2101
2414
|
});
|
|
2102
2415
|
});
|
|
2103
2416
|
var done = function () {
|
|
2104
|
-
self.
|
|
2417
|
+
self._uploadBatches = Math.max(0, self._uploadBatches - 1);
|
|
2418
|
+
// Only the LAST batch standing clears the flag — a second send made
|
|
2419
|
+
// while this one was uploading is still running.
|
|
2420
|
+
self.state.uploadingAttachments = self._uploadBatches > 0;
|
|
2421
|
+
self.host.updateComposerControls(); self.host.renderAttachmentChips();
|
|
2105
2422
|
return collected;
|
|
2106
2423
|
};
|
|
2107
2424
|
return chain.then(done, done);
|