bunnyquery 1.8.2 → 1.8.4
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.md +38 -39
- package/bunnyquery.css +108 -2
- package/bunnyquery.js +1859 -310
- package/dist/engine.cjs +1503 -188
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +906 -37
- package/dist/engine.d.ts +906 -37
- package/dist/engine.mjs +1480 -189
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/budget.ts +11 -11
- package/src/engine/history.ts +23 -6
- package/src/engine/host.ts +77 -3
- package/src/engine/image_preview.ts +0 -0
- package/src/engine/index.ts +13 -0
- package/src/engine/indexing_groups.ts +323 -6
- package/src/engine/link_markup.ts +124 -0
- package/src/engine/links.ts +159 -26
- package/src/engine/office.ts +25 -8
- package/src/engine/prompts/chat_system_prompt.ts +24 -13
- package/src/engine/prompts/indexing_system_prompt.ts +19 -11
- package/src/engine/prompts/indexing_user_message.ts +32 -22
- package/src/engine/requests.ts +302 -14
- package/src/engine/session.ts +1424 -114
- package/src/engine/viewport_fill.ts +51 -4
- package/styles/chat.css +108 -2
|
@@ -42,10 +42,20 @@
|
|
|
42
42
|
* failed), how many passes are currently loaded, and `mayHaveOlder` when the
|
|
43
43
|
* file's first pass is not among them.
|
|
44
44
|
*
|
|
45
|
+
* For the same reason it also reports NOT KNOWING. A run whose start is still
|
|
46
|
+
* being paged in, and a worker-driven run whose queue state has not been asked
|
|
47
|
+
* for yet, are both rows whose state is a moving target — and on a chatbox that
|
|
48
|
+
* was just opened, that is most of them. `resolving` marks those, so the view can
|
|
49
|
+
* say which wait it is waiting on rather than committing to "indexing" or
|
|
50
|
+
* "indexed" on a fraction of the evidence.
|
|
51
|
+
*
|
|
45
52
|
* Pure and view-agnostic: agent.vue and the BunnyQuery widget both render from
|
|
46
53
|
* this, so the two stay identical.
|
|
47
54
|
*/
|
|
48
55
|
import type { ChatMessage, IndexingFileRef } from './host';
|
|
56
|
+
import { isPagedReadFile, isImageVisionFile } from './office';
|
|
57
|
+
import { windowedIndexingEnabled } from './config';
|
|
58
|
+
import { MAX_INDEXING_RESUME_PASSES } from './requests';
|
|
49
59
|
|
|
50
60
|
export type IndexingGroupStatus = 'active' | 'done' | 'error' | 'cancelled';
|
|
51
61
|
|
|
@@ -53,7 +63,16 @@ export type IndexingGroup = {
|
|
|
53
63
|
/** The FILE this row is about: storage path when known (a file can be
|
|
54
64
|
* re-uploaded under a name that already exists elsewhere), else name. Shared
|
|
55
65
|
* by every run of that file, and what ChatSession.cancelIndexingGroup and
|
|
56
|
-
* _indexKeyOf match on — never use it as a render key.
|
|
66
|
+
* _indexKeyOf match on — never use it as a render key.
|
|
67
|
+
*
|
|
68
|
+
* It IS the key for persistent view state, above all the expansion state. That
|
|
69
|
+
* used to be keyed by runKey, which is renamed the moment a run's true first
|
|
70
|
+
* pass loads (see below) — so a row the user had opened silently closed itself
|
|
71
|
+
* mid-indexing, every time a pass arrived ahead of the earlier ones. This never
|
|
72
|
+
* changes for the life of a file. The cost is that two runs OF THE SAME FILE
|
|
73
|
+
* (an index and a later re-index) open and close together, which is a fair
|
|
74
|
+
* reading of "show me this file's steps" and is not a state the user can be
|
|
75
|
+
* surprised out of. */
|
|
57
76
|
key: string;
|
|
58
77
|
/** Identity of this ROW: one indexing RUN of that file. A file indexed on
|
|
59
78
|
* Monday and re-indexed on Wednesday is two runs, and collapsing them into
|
|
@@ -61,8 +80,13 @@ export type IndexingGroup = {
|
|
|
61
80
|
* its passes for Wednesday, and let Monday's failure be overwritten by
|
|
62
81
|
* Wednesday's success. Named after the run's FIRST loaded pass (see where it
|
|
63
82
|
* is assigned below), so passes appended to the run and other runs appearing
|
|
64
|
-
* on either side of it never rename a row already on screen.
|
|
65
|
-
*
|
|
83
|
+
* on either side of it never rename a row already on screen.
|
|
84
|
+
*
|
|
85
|
+
* This is the RENDER key, and only that. It is renamed when the run's true
|
|
86
|
+
* first pass finally loads — routine while a worker-driven chain is running,
|
|
87
|
+
* since a pass adopted from the queue can reach the client before the earlier
|
|
88
|
+
* ones are paged in — and a rename is exactly right for a DOM key (the row did
|
|
89
|
+
* change identity) but wrong for anything the USER set. Key that on `key`. */
|
|
66
90
|
runKey: string;
|
|
67
91
|
name: string;
|
|
68
92
|
path?: string;
|
|
@@ -81,8 +105,19 @@ export type IndexingGroup = {
|
|
|
81
105
|
* when nothing is cancellable — a finished file, or a live pass whose server
|
|
82
106
|
* id has not come back yet. */
|
|
83
107
|
cancellableIds: string[];
|
|
84
|
-
/**
|
|
108
|
+
/** This row is in the middle of stopping: a cancel request is in flight for one
|
|
109
|
+
* of its passes, or the user stopped the run and a pass is still running. Both
|
|
110
|
+
* mean the same thing to a view — the Stop has been spent, so the button reads
|
|
111
|
+
* "Stopping..." and is not offered again. */
|
|
85
112
|
cancelling: boolean;
|
|
113
|
+
/** The user stopped this run.
|
|
114
|
+
*
|
|
115
|
+
* NOT the same as `status === 'cancelled'`, and the difference is the whole
|
|
116
|
+
* reason it exists: a stop landing on a pass that is already RUNNING cannot
|
|
117
|
+
* un-run it, so the row stays `active` until that pass settles. `status` then
|
|
118
|
+
* describes the work (something is still running) and this describes the user's
|
|
119
|
+
* decision (no more of it will be started). */
|
|
120
|
+
stopped: boolean;
|
|
86
121
|
/** Why the last cancel attempt failed (e.g. the pass had already finished). */
|
|
87
122
|
cancelError?: string;
|
|
88
123
|
/** The file's first pass is not among the loaded messages, so earlier passes
|
|
@@ -98,6 +133,71 @@ export type IndexingGroup = {
|
|
|
98
133
|
* must not re-derive it from `members`: which member the row renders at is
|
|
99
134
|
* this module's decision, and the two silently disagreed once already. */
|
|
100
135
|
anchorId: string;
|
|
136
|
+
/** `members` minus the turns an EXPANDED row should not show: every CONTINUE
|
|
137
|
+
* request, and the running pass's empty placeholder.
|
|
138
|
+
*
|
|
139
|
+
* A continuation's request bubble says "Indexing (continuing) <file>" and
|
|
140
|
+
* nothing else — it repeats the row's own header once per pass, so a long file
|
|
141
|
+
* read as the same line over and over with the actual findings buried between
|
|
142
|
+
* them. The pass is still represented, by its RESPONSE. The placeholder goes
|
|
143
|
+
* for a different reason: the row now carries one loader of its own for as long
|
|
144
|
+
* as work remains, and two spinners in one open row is noise.
|
|
145
|
+
*
|
|
146
|
+
* Additive. `members` is untouched and is still what every count, status,
|
|
147
|
+
* cancel and anchor decision reads — several of them are only correct on the
|
|
148
|
+
* full list (a `mayHaveOlder` run's members[0] IS a continuation). */
|
|
149
|
+
visibleMembers: { msg: ChatMessage; index: number }[];
|
|
150
|
+
/** Who advances this file's chain, which decides what can confirm it is over:
|
|
151
|
+
* 'single' one pass and done; 'client' this client dispatches each CONTINUE
|
|
152
|
+
* pass and stops on the model's completion marker; 'worker' the server advances
|
|
153
|
+
* the loop off the renderer's page count and the client is only a spectator. */
|
|
154
|
+
driver: 'single' | 'client' | 'worker';
|
|
155
|
+
/** Positively established that no further indexing work will happen for this
|
|
156
|
+
* run. NOT "the file was fully read" — a cap-out, a failure and a stop are all
|
|
157
|
+
* finished, and the row's own status says which.
|
|
158
|
+
*
|
|
159
|
+
* False means "not established", which includes "still running" AND "we have
|
|
160
|
+
* not been able to find out". The view shows a loader for both, deliberately:
|
|
161
|
+
* the alternative default is the failure this exists to prevent, a row that
|
|
162
|
+
* reads "Indexed" between two passes of a file still being read. */
|
|
163
|
+
finished: boolean;
|
|
164
|
+
/** This row cannot honestly claim a state yet, because something it is derived
|
|
165
|
+
* FROM is still being fetched. Both `status` and `finished` are read off the
|
|
166
|
+
* passes that happen to be LOADED, and on a freshly opened chatbox that is a
|
|
167
|
+
* moving target: history pages newest-first, so a long run arrives as a tail
|
|
168
|
+
* of CONTINUE passes while its beginning is still being paged in. The row was
|
|
169
|
+
* picking a side through that window — a spinner reading "Indexing" for a file
|
|
170
|
+
* that finished last week, or a green "Indexed" for one still being read — and
|
|
171
|
+
* both are verdicts drawn from a fraction of the run.
|
|
172
|
+
*
|
|
173
|
+
* Only ever set from status 'done'. A loaded pending pass PROVES the run is
|
|
174
|
+
* live, and an error or a stop is the newest pass's own outcome, which
|
|
175
|
+
* newest-first paging always has in hand — none of those is a guess, and
|
|
176
|
+
* hiding any of them behind a loader would lose something the user needs.
|
|
177
|
+
*
|
|
178
|
+
* For the 'history' reason this means "a fetch is IN FLIGHT", not "the picture
|
|
179
|
+
* is incomplete". Older history is paged in by explicit triggers only (the
|
|
180
|
+
* viewport fill, the user scrolling to the top) and nothing auto-fetches on a
|
|
181
|
+
* row's behalf, so a run whose start is still unloaded once the paging stops
|
|
182
|
+
* has to go back to reporting what it does know — `mayHaveOlder` and the `+` on
|
|
183
|
+
* the pass count carry the rest. A "loading..." that never ends is the same lie
|
|
184
|
+
* pointing the other way.
|
|
185
|
+
*
|
|
186
|
+
* The 'status' reason is weaker on purpose: "the queue has not answered", which
|
|
187
|
+
* a permanently failing query never resolves. That is deliberate, because it is
|
|
188
|
+
* the SAME question as what the row should say when it cannot find out, and
|
|
189
|
+
* every alternative is worse: a grey clock reading "checking" claims less than
|
|
190
|
+
* the yellow spinner reading "Indexing" that it replaced. It also self-heals in
|
|
191
|
+
* practice — the answer is re-sought on every first-page history load and every
|
|
192
|
+
* settling pass — and gating it on an in-flight query instead would mean
|
|
193
|
+
* threading a second liveness flag through a retry ladder with nine exit
|
|
194
|
+
* points, i.e. trading this for a flag that can stick in the other direction. */
|
|
195
|
+
resolving: boolean;
|
|
196
|
+
/** Which wait, so the row can name it instead of just spinning. 'history':
|
|
197
|
+
* older pages are being fetched and this run's first pass is not among the
|
|
198
|
+
* loaded ones. 'status': the queue has not yet said whether this file is still
|
|
199
|
+
* being worked on, which is the only thing that can end a worker-driven run. */
|
|
200
|
+
resolvingReason?: 'history' | 'status';
|
|
101
201
|
};
|
|
102
202
|
|
|
103
203
|
export type DisplayEntry =
|
|
@@ -108,6 +208,30 @@ export type BuildDisplayListOptions = {
|
|
|
108
208
|
/** True while older history remains unpaged, which is what makes a group
|
|
109
209
|
* with no first pass genuinely incomplete rather than merely odd. */
|
|
110
210
|
hasMoreHistory?: boolean;
|
|
211
|
+
/** An OLDER-history fetch is in flight right now — a single page, or the whole
|
|
212
|
+
* viewport-fill loop (createHistoryFiller's onRunningChange, which spans the
|
|
213
|
+
* pages between which a per-request flag keeps dropping to false).
|
|
214
|
+
*
|
|
215
|
+
* Older specifically. A first-page refresh cannot bring in a run's earlier
|
|
216
|
+
* passes, so counting it here would flip every incomplete row to "still
|
|
217
|
+
* loading" for the length of a poll that could never have answered it. */
|
|
218
|
+
loadingOlderHistory?: boolean;
|
|
219
|
+
/** Files the SERVER still has unresolved indexing work for, keyed exactly like
|
|
220
|
+
* IndexingGroup.key (ChatSession.getLiveIndexState). */
|
|
221
|
+
liveIndexKeys?: { [fileKey: string]: boolean };
|
|
222
|
+
/** Whether `liveIndexKeys` has been answered at least once for this chat. False
|
|
223
|
+
* is "we do not know", and a worker-driven run stays unfinished on it. */
|
|
224
|
+
liveIndexChecked?: boolean;
|
|
225
|
+
/** Server item ids of passes that were on a row when the user STOPPED it
|
|
226
|
+
* (ChatSession.state.stoppedIndexIds). A run holding any of them is a run the
|
|
227
|
+
* user stopped — see the status derivation for why a stop usually leaves no
|
|
228
|
+
* other trace in the messages. Ids rather than a file key on purpose: they name
|
|
229
|
+
* one RUN, so a later re-index of the same file cannot inherit the stop. */
|
|
230
|
+
stoppedIndexIds?: { [serverItemId: string]: boolean };
|
|
231
|
+
/** Whether the WORKER drives the windowed text/grid loop (chatEngineConfig's
|
|
232
|
+
* windowedIndexing). Passed in rather than read from config so this stays a
|
|
233
|
+
* pure function of its inputs and can be exercised for both settings. */
|
|
234
|
+
windowedIndexing?: boolean;
|
|
111
235
|
};
|
|
112
236
|
|
|
113
237
|
// The indexing label is view-formatted (formatIndexingLabel), so parsing it is
|
|
@@ -169,6 +293,24 @@ function isPendingMsg(m: ChatMessage): boolean {
|
|
|
169
293
|
return !!(m.isPending || m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
|
|
170
294
|
}
|
|
171
295
|
|
|
296
|
+
/**
|
|
297
|
+
* A member an EXPANDED row should not render. See IndexingGroup.visibleMembers.
|
|
298
|
+
*
|
|
299
|
+
* A CANCELLED continuation is an exception and stays: a cancelled item is given no
|
|
300
|
+
* response bubble at all, so hiding its request would erase the only evidence that
|
|
301
|
+
* the pass existed. A user bubble we cannot classify (it joined the run by server
|
|
302
|
+
* id, not by a label we could parse) also stays — showing an unexpected turn is a
|
|
303
|
+
* far smaller error than silently dropping one.
|
|
304
|
+
*/
|
|
305
|
+
function isHiddenPass(m: ChatMessage): boolean {
|
|
306
|
+
if (m.role === 'user') {
|
|
307
|
+
if (m.isCancelled) return false;
|
|
308
|
+
var ref = readFileRef(m);
|
|
309
|
+
return !!(ref && ref.continued);
|
|
310
|
+
}
|
|
311
|
+
return !!m.isPending;
|
|
312
|
+
}
|
|
313
|
+
|
|
172
314
|
/**
|
|
173
315
|
* Collapse background-indexing turns into per-file groups.
|
|
174
316
|
*
|
|
@@ -180,7 +322,14 @@ export function buildChatDisplayList(
|
|
|
180
322
|
opts?: BuildDisplayListOptions,
|
|
181
323
|
): DisplayEntry[] {
|
|
182
324
|
var list = Array.isArray(messages) ? messages : [];
|
|
325
|
+
var liveIndexKeys = (opts && opts.liveIndexKeys) || {};
|
|
326
|
+
var liveIndexChecked = !!(opts && opts.liveIndexChecked);
|
|
327
|
+
var stoppedIndexIds = (opts && opts.stoppedIndexIds) || {};
|
|
328
|
+
var windowedIndexing = opts && opts.windowedIndexing !== undefined
|
|
329
|
+
? !!opts.windowedIndexing
|
|
330
|
+
: windowedIndexingEnabled();
|
|
183
331
|
var hasMoreHistory = !!(opts && opts.hasMoreHistory);
|
|
332
|
+
var loadingOlderHistory = !!(opts && opts.loadingOlderHistory);
|
|
184
333
|
|
|
185
334
|
// One entry per RUN (see IndexingGroup.runKey), addressed by an internal id
|
|
186
335
|
// while the list is being walked; runKey is assigned at the end, once the
|
|
@@ -260,11 +409,17 @@ export function buildChatDisplayList(
|
|
|
260
409
|
status: 'done',
|
|
261
410
|
cancellableIds: [],
|
|
262
411
|
cancelling: false,
|
|
412
|
+
stopped: false,
|
|
263
413
|
mayHaveOlder: false,
|
|
264
414
|
// The run's first loaded pass, and never re-stamped: see the file
|
|
265
415
|
// docstring. `anchorId` is filled in once every member is known.
|
|
266
416
|
anchorIndex: i,
|
|
267
417
|
anchorId: '',
|
|
418
|
+
// All five are derived once every member is known, below.
|
|
419
|
+
visibleMembers: [],
|
|
420
|
+
driver: 'single',
|
|
421
|
+
finished: false,
|
|
422
|
+
resolving: false,
|
|
268
423
|
};
|
|
269
424
|
order.push(runId);
|
|
270
425
|
}
|
|
@@ -292,6 +447,15 @@ export function buildChatDisplayList(
|
|
|
292
447
|
// as a run appears at that end, which silently moves the user's expansion to
|
|
293
448
|
// a row they never opened.) The one thing that changes it is the run's own
|
|
294
449
|
// true first pass finally paging in, which happens at most once per run.
|
|
450
|
+
// Whether a run is the NEWEST of its file. Only that one can still be running:
|
|
451
|
+
// `liveIndexKeys` is keyed by FILE, shared by every run of it, so without this a
|
|
452
|
+
// re-index left last week's finished row spinning for the whole of this week's
|
|
453
|
+
// run — one file, two rows, both claiming to be working.
|
|
454
|
+
var newestRunOfKey: { [runId: string]: boolean } = {};
|
|
455
|
+
for (var nk in runsOfKey) {
|
|
456
|
+
var nrs = runsOfKey[nk];
|
|
457
|
+
if (nrs.length) newestRunOfKey[nrs[nrs.length - 1]] = true;
|
|
458
|
+
}
|
|
295
459
|
for (var rk in runsOfKey) {
|
|
296
460
|
var runIds = runsOfKey[rk];
|
|
297
461
|
for (var ri = 0; ri < runIds.length; ri++) {
|
|
@@ -323,6 +487,35 @@ export function buildChatDisplayList(
|
|
|
323
487
|
for (var mi = lastSettled + 1; mi < grp.members.length; mi++) {
|
|
324
488
|
if (isPendingMsg(grp.members[mi].msg)) { active = true; break; }
|
|
325
489
|
}
|
|
490
|
+
// Did the user STOP this run? Asked of the WHOLE run, because a stop routinely
|
|
491
|
+
// leaves nothing behind at the end of it:
|
|
492
|
+
// - only a QUEUED pass can be removed from the queue, and that is the one
|
|
493
|
+
// case that produces a cancelled bubble. The pass that is actually running
|
|
494
|
+
// cannot be un-run — it is flagged cancelled server-side, which is what
|
|
495
|
+
// stops the worker enqueueing the next window, and then finishes its own
|
|
496
|
+
// provider call and writes an ordinary, successful answer;
|
|
497
|
+
// - a PDF (or any worker-driven file) usually has exactly ONE pass live, so
|
|
498
|
+
// that running pass is normally all a stop has to act on;
|
|
499
|
+
// - continuations still sitting in bgTaskQueue are dropped by
|
|
500
|
+
// _applyIndexCancellations before they are ever surfaced, so they leave no
|
|
501
|
+
// bubble either.
|
|
502
|
+
// The messages of a stopped run therefore END in a successful pass, and reading
|
|
503
|
+
// the status off the last member alone reported the stop as a green "Indexed"
|
|
504
|
+
// over a file that was only partly read — with the newest pass's own text still
|
|
505
|
+
// saying "More pages remain" right underneath it.
|
|
506
|
+
//
|
|
507
|
+
// Two witnesses, both scoped to one RUN so a later re-index of the same file
|
|
508
|
+
// can never inherit a stop: a cancelled member (durable — it comes back from
|
|
509
|
+
// history), and the ids ChatSession recorded when the user hit Stop (this
|
|
510
|
+
// page's memory only, which is what covers the run that has no cancelled
|
|
511
|
+
// member at all).
|
|
512
|
+
var stopped = false;
|
|
513
|
+
for (var ki = 0; ki < grp.members.length; ki++) {
|
|
514
|
+
var km = grp.members[ki].msg;
|
|
515
|
+
if (km.isCancelled) { stopped = true; break; }
|
|
516
|
+
if (km._serverItemId && stoppedIndexIds[km._serverItemId]) { stopped = true; break; }
|
|
517
|
+
}
|
|
518
|
+
grp.stopped = stopped;
|
|
326
519
|
// What a stop button would act on: the REQUEST bubble of every pass that is
|
|
327
520
|
// still queued or running server-side. The assistant placeholder shares its
|
|
328
521
|
// pass's server id, so ids are de-duplicated. A pass mid-cancel is left out
|
|
@@ -340,7 +533,14 @@ export function buildChatDisplayList(
|
|
|
340
533
|
// went on to finish normally kept describing a one-off transient failure
|
|
341
534
|
// as a permanent property of the file, until a full history refresh
|
|
342
535
|
// rebuilt the bubbles.
|
|
343
|
-
|
|
536
|
+
//
|
|
537
|
+
// And never for a run that IS stopped, which is the case the message gets
|
|
538
|
+
// wrong: the only thing that failed there is removing the pass already in
|
|
539
|
+
// flight (it cannot be un-run — it finishes and answers), while the stop
|
|
540
|
+
// itself holds. The row says "Stopping..." and then "Indexing cancelled",
|
|
541
|
+
// which is the accurate account; "Could not stop this file" beside it is
|
|
542
|
+
// not.
|
|
543
|
+
if (cm._cancelError && !stopped && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
|
|
344
544
|
if (cm.role !== 'user' || !cm._serverItemId || cm._cancelling || cm.isSendingToServer) continue;
|
|
345
545
|
if (!(cm.isPendingQueued || cm.isPendingInProcess)) continue;
|
|
346
546
|
// Same staleness rule: never offer to stop a pass a later one outlived.
|
|
@@ -350,12 +550,19 @@ export function buildChatDisplayList(
|
|
|
350
550
|
grp.cancellableIds.push(cm._serverItemId);
|
|
351
551
|
}
|
|
352
552
|
if (active) {
|
|
553
|
+
// A pass IS still running, and a row that claimed otherwise would hide work
|
|
554
|
+
// the user can still see the effects of. The stop is reported as `cancelling`
|
|
555
|
+
// instead — "Stopping...", Stop spent — and the row settles to 'cancelled'
|
|
556
|
+
// when that last pass lands.
|
|
353
557
|
grp.status = 'active';
|
|
558
|
+
if (stopped) grp.cancelling = true;
|
|
559
|
+
} else if (stopped) {
|
|
560
|
+
grp.status = 'cancelled';
|
|
354
561
|
} else {
|
|
355
562
|
// The newest loaded outcome is the file's state: an early pass may have
|
|
356
563
|
// errored and a later one succeeded.
|
|
357
564
|
var last = grp.members[grp.members.length - 1].msg;
|
|
358
|
-
grp.status = last.isError ? 'error' :
|
|
565
|
+
grp.status = last.isError ? 'error' : 'done';
|
|
359
566
|
}
|
|
360
567
|
// A group whose passes are ALL continuations began before the loaded
|
|
361
568
|
// window; its earlier passes arrive when older history is paged in.
|
|
@@ -373,6 +580,116 @@ export function buildChatDisplayList(
|
|
|
373
580
|
var anchor = grp.members[0];
|
|
374
581
|
grp.anchorIndex = anchor.index;
|
|
375
582
|
grp.anchorId = anchor.msg._serverItemId || anchor.msg._localId || '';
|
|
583
|
+
|
|
584
|
+
// --- what an expanded row renders, and whether the run is over -------------
|
|
585
|
+
var sawComplete = false;
|
|
586
|
+
for (var vi = 0; vi < grp.members.length; vi++) {
|
|
587
|
+
var vm = grp.members[vi];
|
|
588
|
+
if (vm.msg._indexComplete) sawComplete = true;
|
|
589
|
+
if (!isHiddenPass(vm.msg)) grp.visibleMembers.push(vm);
|
|
590
|
+
}
|
|
591
|
+
// Mirrors ChatSession.maybeResumeIndexing's own routing, which is what makes
|
|
592
|
+
// the answer match who will actually dispatch the next pass.
|
|
593
|
+
grp.driver = !isPagedReadFile(grp.name, grp.mime) ? 'single'
|
|
594
|
+
: isImageVisionFile(grp.name, grp.mime) ? 'worker'
|
|
595
|
+
: (windowedIndexing ? 'worker' : 'client');
|
|
596
|
+
if (grp.status === 'active') {
|
|
597
|
+
grp.finished = false;
|
|
598
|
+
} else if (grp.status === 'cancelled') {
|
|
599
|
+
// The user stopped it. Nothing more will run, by construction.
|
|
600
|
+
grp.finished = true;
|
|
601
|
+
} else if (grp.driver === 'single') {
|
|
602
|
+
// One pass is the whole job: nothing continues a file that is not a paged
|
|
603
|
+
// read, so a settled pass IS the end. Exact, and survives a reload.
|
|
604
|
+
grp.finished = true;
|
|
605
|
+
} else if (grp.driver === 'client') {
|
|
606
|
+
// The three branches this client itself stops dispatching on. Using the
|
|
607
|
+
// same rule means the row cannot disagree with the pipeline: if the client
|
|
608
|
+
// will send no more passes, the run is over whether or not the file was
|
|
609
|
+
// fully read.
|
|
610
|
+
grp.finished = sawComplete || grp.status === 'error' ||
|
|
611
|
+
grp.passCount >= MAX_INDEXING_RESUME_PASSES;
|
|
612
|
+
} else {
|
|
613
|
+
// WORKER-driven, and the interesting case. Nothing in the messages can
|
|
614
|
+
// settle it: the loop is advanced inside the worker off the renderer's page
|
|
615
|
+
// count, the client only ever sees passes appear and settle, and the prompt
|
|
616
|
+
// for these paths deliberately never asks for a completion marker — a model
|
|
617
|
+
// that volunteers one is guessing, which is how an 88-page file once
|
|
618
|
+
// "finished" at page 15. So sawComplete is deliberately NOT consulted here.
|
|
619
|
+
// Either the server said this was the last pass, or the queue has been
|
|
620
|
+
// asked and holds nothing for this file.
|
|
621
|
+
//
|
|
622
|
+
// A run that is NOT the newest of its file is over by construction: a newer
|
|
623
|
+
// run exists, so this one's chain ended when that one began, and the
|
|
624
|
+
// file-keyed queue answer describes the newer run, not this one.
|
|
625
|
+
//
|
|
626
|
+
// There used to be a third disjunct here, a server-stamped "this was the
|
|
627
|
+
// run's last pass" (`_indexFinal`, off an `index_final` field). Nothing ever
|
|
628
|
+
// wrote that field — not the SDK's history mapper, not the worker — so it was
|
|
629
|
+
// permanently false and the two tests below were already carrying the whole
|
|
630
|
+
// decision. It is gone rather than left as a hook, so this reads as what it
|
|
631
|
+
// actually is: for a worker-driven run, ONLY the queue can say it is over.
|
|
632
|
+
grp.finished = !newestRunOfKey[order[oi]] ||
|
|
633
|
+
(liveIndexChecked && !liveIndexKeys[grp.key]);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// --- is any of that knowable YET? ------------------------------------------
|
|
637
|
+
// See IndexingGroup.resolving. Both answers above are read off the passes
|
|
638
|
+
// that happen to be loaded, and while a fetch that would change WHICH passes
|
|
639
|
+
// those are is still running, the row states the wait instead of picking a
|
|
640
|
+
// side. Two waits, checked in that order because the first subsumes the
|
|
641
|
+
// second: a run whose start has not arrived cannot be judged by any queue
|
|
642
|
+
// answer either.
|
|
643
|
+
if (grp.status !== 'done') {
|
|
644
|
+
// 'active' is proof the run is live; 'error' and 'cancelled' are the
|
|
645
|
+
// newest pass's own outcome, and newest-first paging always has that pass.
|
|
646
|
+
grp.resolving = false;
|
|
647
|
+
} else if (grp.mayHaveOlder && loadingOlderHistory &&
|
|
648
|
+
!liveIndexKeys[grp.key] && newestRunOfKey[order[oi]]) {
|
|
649
|
+
// The line this draws, and it is the same line the 'status' branch draws:
|
|
650
|
+
// withhold what is UNKNOWN, and what is only INFERRED settled. Never
|
|
651
|
+
// withhold what is PROVEN, in either direction. Two proofs, and an older
|
|
652
|
+
// page can revise neither:
|
|
653
|
+
// liveIndexKeys hit — the server just said this file is on the queue RIGHT
|
|
654
|
+
// NOW. Yellow and spinning, and no amount of older history makes that
|
|
655
|
+
// untrue. (Survives an unanswered `checked` for the reason above: only
|
|
656
|
+
// ABSENCE needs the truncation guard.)
|
|
657
|
+
// not the newest run of this file — a LATER run exists in the loaded
|
|
658
|
+
// messages, so this run's chain ended when that one began. Prepending
|
|
659
|
+
// adds OLDER messages, so it can never add a run after this one.
|
|
660
|
+
// (A third proof used to sit here, a server-stamped last-pass flag, but
|
|
661
|
+
// nothing ever wrote the field it read — see `finished` above.)
|
|
662
|
+
//
|
|
663
|
+
// What is deliberately NOT proof here: `finished` as a whole. Its remaining
|
|
664
|
+
// source is `liveIndexChecked && !liveIndexKeys[key]`, which is an inference
|
|
665
|
+
// from ABSENCE and is exactly what the user asked to stop reading as a green
|
|
666
|
+
// "Indexed" mid-fetch. And a blanket `!grp.finished` would be wrong for a
|
|
667
|
+
// second reason: driver 'single' also yields finished, and THAT verdict an
|
|
668
|
+
// older page really can revise — the first pass is what carries the mime, so
|
|
669
|
+
// a page bringing it in re-classifies the file as paged and hands the
|
|
670
|
+
// question back to the queue.
|
|
671
|
+
grp.resolving = true;
|
|
672
|
+
grp.resolvingReason = 'history';
|
|
673
|
+
} else if (!grp.finished && grp.driver === 'worker' && !liveIndexChecked && !liveIndexKeys[grp.key]) {
|
|
674
|
+
// Worker-driven and the queue has not answered. `finished` is already
|
|
675
|
+
// false here and the row would spin "Indexing" on the strength of nothing
|
|
676
|
+
// — the same false claim, one round trip long, on every chatbox open of a
|
|
677
|
+
// file that finished days ago. A 'client' run in the same shape is NOT
|
|
678
|
+
// resolving: no answer is being waited for, this client is the one that
|
|
679
|
+
// dispatches the next pass, so "Indexing" is a statement about its own
|
|
680
|
+
// intent and is true.
|
|
681
|
+
//
|
|
682
|
+
// A POSITIVE key survives `!liveIndexChecked`, and must. `checked` is set
|
|
683
|
+
// from `!truncated`: a capped page suppresses the whole answer, which is
|
|
684
|
+
// right for ABSENCE (an omitted file would otherwise read as finished) and
|
|
685
|
+
// wrong for PRESENCE, because an item the query did return is proof the
|
|
686
|
+
// file is live. Hiding that behind "checking..." would withhold the one
|
|
687
|
+
// thing here that is actually known.
|
|
688
|
+
grp.resolving = true;
|
|
689
|
+
grp.resolvingReason = 'status';
|
|
690
|
+
} else {
|
|
691
|
+
grp.resolving = false;
|
|
692
|
+
}
|
|
376
693
|
}
|
|
377
694
|
|
|
378
695
|
var out: DisplayEntry[] = [];
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chip / preview markup for one classified inline link.
|
|
3
|
+
*
|
|
4
|
+
* `classifyInlineLink` was consolidated into the engine because deciding what a
|
|
5
|
+
* link IS had drifted between the two clients and every link bug had to be found
|
|
6
|
+
* and fixed twice. The EMITTER stayed forked, byte for byte identical in
|
|
7
|
+
* agent.vue and the widget. The image preview is the first behaviour that would
|
|
8
|
+
* have had to be written twice, so the emitter moves here too.
|
|
9
|
+
*
|
|
10
|
+
* Pure string in, pure string out: no DOM, no globals, nothing reactive. That is
|
|
11
|
+
* what lets agent.vue keep memoizing parseMsgParts on the message text alone.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Neither client sanitizes bubble HTML (no DOMPurify, no marked sanitize), so
|
|
15
|
+
* everything interpolated here is escaped at the point of interpolation. */
|
|
16
|
+
export function escapeInlineHtml(v: string | null | undefined): string {
|
|
17
|
+
return String(v == null ? '' : v).replace(/[&<>"']/g, function (ch) {
|
|
18
|
+
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } as Record<string, string>)[ch];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Previews per MESSAGE. Each one costs a presign call and an image download the
|
|
24
|
+
* moment it is hydrated, and a reply listing a folder can name dozens. Past this
|
|
25
|
+
* many the link renders as the ordinary text chip.
|
|
26
|
+
*/
|
|
27
|
+
export var IMAGE_PREVIEWS_PER_MESSAGE = 8;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The glyph IS the promise: ↗ says "click this and the file opens". When the
|
|
31
|
+
* client could not get a url for the file, keeping that glyph on a chip it knows
|
|
32
|
+
* is dead is the bug: the click either opens a tab on a 403/404 or, once the
|
|
33
|
+
* href is gone, does nothing at all with no explanation. ✕ says what happened.
|
|
34
|
+
*/
|
|
35
|
+
export var INLINE_LINK_GLYPH = '↗';
|
|
36
|
+
export var INLINE_LINK_UNAVAILABLE_GLYPH = '✕';
|
|
37
|
+
export var INLINE_LINK_UNAVAILABLE_SUFFIX = ' (unavailable)';
|
|
38
|
+
|
|
39
|
+
/** Widened so each client's local link-part type is assignable. */
|
|
40
|
+
export interface RenderableInlineLink {
|
|
41
|
+
label: string;
|
|
42
|
+
fullLabel?: string;
|
|
43
|
+
href: string;
|
|
44
|
+
expired: boolean;
|
|
45
|
+
expiredHref?: string;
|
|
46
|
+
remotePath?: string;
|
|
47
|
+
image?: { ext: string; contentType: string };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface InlineLinkMarkupOptions {
|
|
51
|
+
/** The view's own "a mint is in flight for this href" flag. */
|
|
52
|
+
refreshing?: boolean;
|
|
53
|
+
/** False once the caller has spent its per-message preview budget. */
|
|
54
|
+
allowImagePreview?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* The view already tried to get a url for this file and failed (see
|
|
57
|
+
* isLinkUnavailable). Renders a dead chip: ✕, greyed, no href.
|
|
58
|
+
*/
|
|
59
|
+
unavailable?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function renderInlineLinkHtml(link: RenderableInlineLink, opts?: InlineLinkMarkupOptions): string {
|
|
63
|
+
var o = opts || {};
|
|
64
|
+
var unavailable = !!o.unavailable;
|
|
65
|
+
// A dead chip is never also "fetching...": the mint that would have cleared
|
|
66
|
+
// that state is the one that failed.
|
|
67
|
+
var refreshing = !unavailable && !!o.refreshing;
|
|
68
|
+
var full = link.fullLabel || link.label;
|
|
69
|
+
// No preview either. The <img> hides itself when its url fails, but a
|
|
70
|
+
// re-render emits a FRESH element with no state, so leaving the preview in
|
|
71
|
+
// would re-mint and re-fail once per render for the rest of the session.
|
|
72
|
+
var preview = !!link.image && !!link.remotePath && o.allowImagePreview !== false && !unavailable;
|
|
73
|
+
|
|
74
|
+
var cls = ['bq-link-button'];
|
|
75
|
+
if (link.expired) cls.push('is-expired');
|
|
76
|
+
if (refreshing) cls.push('is-refreshing');
|
|
77
|
+
if (unavailable) cls.push('is-unavailable');
|
|
78
|
+
if (preview) cls.push('is-image-preview');
|
|
79
|
+
|
|
80
|
+
var labelText = (unavailable ? INLINE_LINK_UNAVAILABLE_GLYPH : INLINE_LINK_GLYPH) + ' ' + link.label
|
|
81
|
+
+ (unavailable ? INLINE_LINK_UNAVAILABLE_SUFFIX : refreshing ? ' (fetching...)' : '');
|
|
82
|
+
var attrs = ['class="' + cls.join(' ') + '"'];
|
|
83
|
+
// NO href when the file is unavailable. That is what disables the click:
|
|
84
|
+
// an anchor with no href does not navigate, is not a tab stop and takes the
|
|
85
|
+
// default cursor, so nothing else has to remember to swallow the event.
|
|
86
|
+
if (unavailable) attrs.push('aria-disabled="true"', 'data-bq-unavailable="1"');
|
|
87
|
+
else attrs.push('href="' + escapeInlineHtml(link.href) + '"', 'target="_blank"', 'rel="noopener noreferrer"');
|
|
88
|
+
attrs.push('title="' + escapeInlineHtml(unavailable ? full + INLINE_LINK_UNAVAILABLE_SUFFIX : full) + '"');
|
|
89
|
+
// `download` is ignored cross-origin and forces a save same-origin. On a
|
|
90
|
+
// preview it states the wrong intent: the user clicked a picture to LOOK at
|
|
91
|
+
// it. Every other chip keeps the attribute exactly where it was.
|
|
92
|
+
if (!preview && !unavailable) attrs.push('download="' + escapeInlineHtml(full) + '"');
|
|
93
|
+
attrs.push('data-bq-link="1"');
|
|
94
|
+
// Deliberately NOT marked expired: `data-bq-expired` is what tells the
|
|
95
|
+
// delegated click handler to mint a fresh url, and this is the chip whose
|
|
96
|
+
// mint just failed.
|
|
97
|
+
if (link.expired && !unavailable) attrs.push('data-bq-expired="1"');
|
|
98
|
+
if (link.expiredHref) attrs.push('data-bq-expired-href="' + escapeInlineHtml(link.expiredHref) + '"');
|
|
99
|
+
if (link.remotePath) attrs.push('data-bq-remote-path="' + escapeInlineHtml(link.remotePath) + '"');
|
|
100
|
+
if (link.fullLabel) attrs.push('data-bq-full-label="' + escapeInlineHtml(link.fullLabel) + '"');
|
|
101
|
+
|
|
102
|
+
if (!preview) return '<a ' + attrs.join(' ') + '>' + escapeInlineHtml(labelText) + '</a>';
|
|
103
|
+
|
|
104
|
+
// NO src attribute. A stored file always classifies as the _expired_.url
|
|
105
|
+
// placeholder until something mints a real url, so a src written here would be
|
|
106
|
+
// a guaranteed broken image on every reload. The element carries the PATH and
|
|
107
|
+
// hydrateImagePreviews fills the src in a DOM pass that runs after render.
|
|
108
|
+
//
|
|
109
|
+
// The caption is not decoration, it is the FALLBACK: if the url dies or the
|
|
110
|
+
// file is gone the <img> hides itself and what is left is exactly the text
|
|
111
|
+
// chip this feature replaced, still clickable.
|
|
112
|
+
return '<a ' + attrs.join(' ') + '>' +
|
|
113
|
+
'<img class="bq-img-preview" alt="' + escapeInlineHtml(full) + '"' +
|
|
114
|
+
' data-bq-img-path="' + escapeInlineHtml(link.remotePath || '') + '"' +
|
|
115
|
+
' data-bq-img-type="' + escapeInlineHtml(link.image ? link.image.contentType : '') + '"' +
|
|
116
|
+
' loading="lazy" decoding="async">' +
|
|
117
|
+
// Minting the url is a network round trip before the image even starts
|
|
118
|
+
// downloading, so the wait is real and needs a state. Inline load, so the
|
|
119
|
+
// dot trail, never the jumping bunny. CSS hides it the moment the <img>
|
|
120
|
+
// gains a src, so no JS removes DOM and nothing re-parses.
|
|
121
|
+
'<span class="bq-loader" data-bq-img-loader="1"></span>' +
|
|
122
|
+
'<span class="bq-img-preview-caption" translate="no">' + escapeInlineHtml(labelText) + '</span>' +
|
|
123
|
+
'</a>';
|
|
124
|
+
}
|