bunnyquery 1.6.2 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -29
- package/bunnyquery.css +134 -0
- package/bunnyquery.js +1057 -120
- package/dist/engine.cjs +784 -42
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +455 -4
- package/dist/engine.d.ts +455 -4
- package/dist/engine.mjs +769 -43
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/history.ts +30 -2
- package/src/engine/host.ts +34 -1
- package/src/engine/index.ts +25 -1
- package/src/engine/indexing_groups.ts +389 -0
- package/src/engine/links.ts +343 -9
- package/src/engine/prompts/chat_system_prompt.ts +4 -1
- package/src/engine/prompts/indexing_user_message.ts +4 -0
- package/src/engine/requests.ts +1 -1
- package/src/engine/session.ts +412 -33
- package/src/engine/time.ts +34 -0
- package/src/engine/viewport_fill.ts +186 -0
- package/styles/chat.css +134 -0
package/package.json
CHANGED
package/src/engine/history.ts
CHANGED
|
@@ -44,7 +44,7 @@ export type MapHistoryOptions = {
|
|
|
44
44
|
clearedAt: number;
|
|
45
45
|
serviceId: string;
|
|
46
46
|
/** View-side display formatter for "Indexing:/Reindexing: …" bubbles. */
|
|
47
|
-
formatIndexingLabel: (name: string, mime?: string, size?: number | null, storagePath?: string) => string;
|
|
47
|
+
formatIndexingLabel: (name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean, continued?: boolean) => string;
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'openai', opts: MapHistoryOptions) {
|
|
@@ -64,21 +64,45 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
64
64
|
var assistantText = isPending ? '' : ((extractAssistantText(response) || '').trim() || '');
|
|
65
65
|
var isErrorResponse = !isPending && (isFailed || isErrorResponseBody(response));
|
|
66
66
|
var serverItemId = item && typeof item.id === 'string' && item.id ? item.id : undefined;
|
|
67
|
+
// A USER bubble shows when the request was made (`created`); an ASSISTANT
|
|
68
|
+
// bubble shows when its response landed (`updated`). Fall back to the other
|
|
69
|
+
// if one is missing (older records only carried `updated`).
|
|
70
|
+
var createdTs = Number(item && item.created);
|
|
71
|
+
var updatedTs = Number(item && item.updated);
|
|
72
|
+
var userTs = isFinite(createdTs) && createdTs > 0 ? createdTs : (isFinite(updatedTs) && updatedTs > 0 ? updatedTs : undefined);
|
|
73
|
+
var replyTs = isFinite(updatedTs) && updatedTs > 0 ? updatedTs : (isFinite(createdTs) && createdTs > 0 ? createdTs : undefined);
|
|
67
74
|
|
|
68
75
|
if (userText) {
|
|
69
76
|
var displayContent;
|
|
77
|
+
// Structured file ref for the display layer's per-file collapsing, kept
|
|
78
|
+
// alongside the formatted label so grouping never has to parse it back.
|
|
79
|
+
var indexFile: any = undefined;
|
|
70
80
|
if (item._isBgTask) {
|
|
71
81
|
var nameMatch = userText.match(/^- name: (.+)$/m);
|
|
72
82
|
if (nameMatch) {
|
|
73
83
|
var mimeMatch = userText.match(/^- mime type: (.+)$/m);
|
|
74
84
|
var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
|
|
75
85
|
var pathMatch = userText.match(/^- storage path: (.+)$/m);
|
|
86
|
+
// A CONTINUE pass ("CONTINUE indexing …") gets the compact
|
|
87
|
+
// continuation label; a first pass ("A new file …") gets the full
|
|
88
|
+
// one. Mirrors agent.vue's mapHistoryListToMessages so a big file's
|
|
89
|
+
// windows read as progress, not the same task repeating.
|
|
90
|
+
var isContinuePass = userText.indexOf('CONTINUE indexing') === 0;
|
|
76
91
|
displayContent = opts.formatIndexingLabel(
|
|
77
92
|
nameMatch[1].trim(),
|
|
78
93
|
mimeMatch ? mimeMatch[1].trim() : '',
|
|
79
94
|
sizeMatch ? Number(sizeMatch[1]) : null,
|
|
80
|
-
pathMatch ? pathMatch[1].trim() : undefined
|
|
95
|
+
pathMatch ? pathMatch[1].trim() : undefined,
|
|
96
|
+
false,
|
|
97
|
+
isContinuePass
|
|
81
98
|
);
|
|
99
|
+
indexFile = {
|
|
100
|
+
name: nameMatch[1].trim(),
|
|
101
|
+
path: pathMatch ? pathMatch[1].trim() : undefined,
|
|
102
|
+
mime: mimeMatch ? mimeMatch[1].trim() : undefined,
|
|
103
|
+
size: sizeMatch ? Number(sizeMatch[1]) : undefined,
|
|
104
|
+
continued: isContinuePass,
|
|
105
|
+
};
|
|
82
106
|
} else {
|
|
83
107
|
displayContent = userText;
|
|
84
108
|
}
|
|
@@ -90,8 +114,10 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
90
114
|
if (isQueued) userMsg.isPendingQueued = true;
|
|
91
115
|
if (isCancelledItem) userMsg.isCancelled = true;
|
|
92
116
|
if (item._isBgTask) userMsg.isBackgroundTask = true;
|
|
117
|
+
if (indexFile) userMsg._indexFile = indexFile;
|
|
93
118
|
if (item._isOnBgQueue) userMsg._useBgQueue = true;
|
|
94
119
|
if (serverItemId !== undefined) userMsg._serverItemId = serverItemId;
|
|
120
|
+
if (userTs !== undefined) userMsg._ts = userTs;
|
|
95
121
|
mapped.push(userMsg);
|
|
96
122
|
}
|
|
97
123
|
if (isCancelledItem) { /* no assistant bubble */ }
|
|
@@ -105,6 +131,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
105
131
|
var em: any = { role: 'assistant', content: getErrorMessage(response), isError: true };
|
|
106
132
|
if (item._isBgTask) em.isBackgroundTask = true;
|
|
107
133
|
if (serverItemId !== undefined) em._serverItemId = serverItemId;
|
|
134
|
+
if (replyTs !== undefined) em._ts = replyTs;
|
|
108
135
|
mapped.push(em);
|
|
109
136
|
} else if (assistantText) {
|
|
110
137
|
// Safe db-only sanitize (forAssistant) so a volatile db url the model
|
|
@@ -112,6 +139,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
112
139
|
var okm: any = { role: 'assistant', content: sanitizeAttachmentLinksForHistory(assistantText, opts.serviceId, true) };
|
|
113
140
|
if (item._isBgTask) okm.isBackgroundTask = true;
|
|
114
141
|
if (serverItemId !== undefined) okm._serverItemId = serverItemId;
|
|
142
|
+
if (replyTs !== undefined) okm._ts = replyTs;
|
|
115
143
|
mapped.push(okm);
|
|
116
144
|
}
|
|
117
145
|
});
|
package/src/engine/host.ts
CHANGED
|
@@ -30,6 +30,25 @@ export interface PinnedDispatchContext {
|
|
|
30
30
|
systemPrompt: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The file a background-indexing bubble belongs to. Stamped on the REQUEST
|
|
35
|
+
* bubble (both the live one and the one rebuilt from history) so the display
|
|
36
|
+
* layer can group a file's many passes without reverse-parsing the view's
|
|
37
|
+
* formatted label. See indexing_groups.buildChatDisplayList.
|
|
38
|
+
*/
|
|
39
|
+
export interface IndexingFileRef {
|
|
40
|
+
name: string;
|
|
41
|
+
/** Storage path, when known. Preferred group identity: a file can be
|
|
42
|
+
* re-uploaded under a name that already exists elsewhere. */
|
|
43
|
+
path?: string;
|
|
44
|
+
mime?: string;
|
|
45
|
+
size?: number;
|
|
46
|
+
isReindex?: boolean;
|
|
47
|
+
/** A CONTINUE pass. Its absence across every loaded pass is what tells the
|
|
48
|
+
* display layer that earlier passes are still unpaged. */
|
|
49
|
+
continued?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
33
52
|
export interface ChatMessage {
|
|
34
53
|
role: 'user' | 'assistant';
|
|
35
54
|
content: string; // raw markdown — never HTML (the view parses for display)
|
|
@@ -41,11 +60,19 @@ export interface ChatMessage {
|
|
|
41
60
|
isCancelled?: boolean;
|
|
42
61
|
isError?: boolean;
|
|
43
62
|
isBackgroundTask?: boolean;
|
|
63
|
+
/** Set on background-indexing REQUEST bubbles only (see IndexingFileRef). */
|
|
64
|
+
_indexFile?: IndexingFileRef;
|
|
44
65
|
_useBgQueue?: boolean;
|
|
45
66
|
_serverItemId?: string;
|
|
46
67
|
_localId?: string;
|
|
47
68
|
_cancelling?: boolean;
|
|
48
69
|
_cancelError?: string;
|
|
70
|
+
/** Epoch ms shown as small text under the bubble. From the request history a
|
|
71
|
+
* USER bubble carries the request's `created` time and an ASSISTANT bubble the
|
|
72
|
+
* `updated` (response) time; a live bubble is stamped with the wall clock when
|
|
73
|
+
* it is created, then reconciled to the server value on the next history load.
|
|
74
|
+
* Absent while a turn is still pending, so no time shows on a "Thinking" bubble. */
|
|
75
|
+
_ts?: number;
|
|
49
76
|
// History cache key (`serviceId#platform`) this bubble was created under.
|
|
50
77
|
// Stamped on LOCALLY-created bubbles only (the optimistic user message and
|
|
51
78
|
// its "Thinking..." placeholder); server-mapped bubbles are identified by
|
|
@@ -86,6 +113,12 @@ export interface ChatHost {
|
|
|
86
113
|
scrollToBottom(smooth?: boolean): Promise<void> | void;
|
|
87
114
|
/** Scroll only if the user is pinned to the bottom (does not force-pin). */
|
|
88
115
|
scrollToBottomIfSticky(smooth?: boolean): Promise<void> | void;
|
|
116
|
+
/** A history page finished loading and rendering. OPTIONAL. The view uses it
|
|
117
|
+
* to page further when the message box came out too short to scroll — the
|
|
118
|
+
* only trigger for loading older history is a scroll to the top, so a box
|
|
119
|
+
* that cannot scroll has no way to reach page 2 (see viewport_fill). Only
|
|
120
|
+
* the view can measure that, which is why the engine merely announces it. */
|
|
121
|
+
onHistoryLoaded?(fetchMore: boolean, token: number): void;
|
|
89
122
|
|
|
90
123
|
// --- skapi surface beyond configureChatEngine() ---
|
|
91
124
|
cancelRequest(opts: {
|
|
@@ -95,7 +128,7 @@ export interface ChatHost {
|
|
|
95
128
|
|
|
96
129
|
// --- bg-indexing display ---
|
|
97
130
|
/** Build the "Indexing:/Reindexing: …" label (view-side display formatting). */
|
|
98
|
-
formatIndexingLabel(name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean): string;
|
|
131
|
+
formatIndexingLabel(name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean, continued?: boolean): string;
|
|
99
132
|
/** drainBgTaskQueue is a no-op until the chat view is mounted. */
|
|
100
133
|
isViewMounted(): boolean;
|
|
101
134
|
|
package/src/engine/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ export * from './prompts';
|
|
|
46
46
|
export { getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError } from './errors';
|
|
47
47
|
export * from './budget';
|
|
48
48
|
export * from './links';
|
|
49
|
+
export * from './time';
|
|
49
50
|
export {
|
|
50
51
|
filterListByClearHorizon,
|
|
51
52
|
normalizeTextContent,
|
|
@@ -54,10 +55,33 @@ export {
|
|
|
54
55
|
type MapHistoryOptions,
|
|
55
56
|
} from './history';
|
|
56
57
|
|
|
58
|
+
// Older history is reachable only by scrolling to the top of the message box, so
|
|
59
|
+
// a box too short to scroll strands the user on page 1 — the normal state once a
|
|
60
|
+
// page of history collapses into one indexing row. Shared so both chatboxes page
|
|
61
|
+
// their way out of it identically.
|
|
62
|
+
export {
|
|
63
|
+
fillHistoryViewport,
|
|
64
|
+
createHistoryFiller,
|
|
65
|
+
HISTORY_FILL_SLACK_PX,
|
|
66
|
+
MAX_HISTORY_FILL_PAGES,
|
|
67
|
+
type FillHistoryViewportOptions,
|
|
68
|
+
} from './viewport_fill';
|
|
69
|
+
|
|
57
70
|
// Tier-2: the stateful chat orchestration (queue/poll/cancel, typewriter,
|
|
58
71
|
// bg-task drain, resolution). DOM-free; the consumer implements ChatHost.
|
|
59
72
|
export { ChatSession } from './session';
|
|
60
|
-
export type { ChatHost, ChatIdentity, ChatState, ChatMessage, PinnedDispatchContext } from './host';
|
|
73
|
+
export type { ChatHost, ChatIdentity, ChatState, ChatMessage, IndexingFileRef, PinnedDispatchContext } from './host';
|
|
74
|
+
|
|
75
|
+
// Display transform: collapse a file's many background-indexing turns into one
|
|
76
|
+
// row, wherever they sit in the conversation. Shared so both chatboxes match.
|
|
77
|
+
export {
|
|
78
|
+
buildChatDisplayList,
|
|
79
|
+
parseIndexingLabel,
|
|
80
|
+
type DisplayEntry,
|
|
81
|
+
type IndexingGroup,
|
|
82
|
+
type IndexingGroupStatus,
|
|
83
|
+
type BuildDisplayListOptions,
|
|
84
|
+
} from './indexing_groups';
|
|
61
85
|
|
|
62
86
|
export {
|
|
63
87
|
// constants
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background file-indexing turns, collapsed into ONE row per file.
|
|
3
|
+
*
|
|
4
|
+
* A single upload can produce many chat turns: the first "Indexing: <file>" pass
|
|
5
|
+
* plus up to MAX_INDEXING_RESUME_PASSES CONTINUE passes, each with its own
|
|
6
|
+
* request AND response bubble. Rendered flat, that reads as the same task
|
|
7
|
+
* repeating forever, and any real question the user asks in between gets buried.
|
|
8
|
+
*
|
|
9
|
+
* buildChatDisplayList turns the flat message array into a DISPLAY list in which
|
|
10
|
+
* every message belonging to one run of one file (however far apart the passes
|
|
11
|
+
* sit, and whatever else is interleaved between them) is represented by a single
|
|
12
|
+
* group entry, rendered at the position of that run's FIRST loaded pass.
|
|
13
|
+
*
|
|
14
|
+
* First, not newest. The message array is ordered by request CREATION time, and
|
|
15
|
+
* a run's later passes are created one at a time as the previous one resolves —
|
|
16
|
+
* by the client for the paged text path, and by the WORKER itself for the
|
|
17
|
+
* rendered-page (PDF) path, which the client only learns about on its next
|
|
18
|
+
* first-page history fetch. So a pass is routinely created minutes after the
|
|
19
|
+
* upload, on a queue that runs in parallel with the foreground chat. Anchoring
|
|
20
|
+
* at the newest pass meant one such late pass dragged the whole run — every
|
|
21
|
+
* earlier pass with it — below any question the user had asked in the meantime,
|
|
22
|
+
* and made a run that had visibly finished before the question render after it.
|
|
23
|
+
* Anchoring at the first pass puts the row where the run actually began, which
|
|
24
|
+
* is a position no later pass can change:
|
|
25
|
+
* - a new pass never relocates the row, so nothing under the reader shifts;
|
|
26
|
+
* - a question asked after the upload always renders below the row, which is
|
|
27
|
+
* the order it happened in.
|
|
28
|
+
* The cost is that a long-running index does not follow the conversation to the
|
|
29
|
+
* bottom: once the user has chatted past the upload, the spinner and the Stop
|
|
30
|
+
* button sit above their newest turns. That is a scroll away, and the row no
|
|
31
|
+
* longer lies about when the work started.
|
|
32
|
+
*
|
|
33
|
+
* Paging older history is the one thing that CAN move the row: an older page
|
|
34
|
+
* carrying earlier passes of a run already on screen moves it up into that page.
|
|
35
|
+
* That happens at most once per run, only for a run whose start was never
|
|
36
|
+
* loaded (`mayHaveOlder`), and it is the same event that already re-derives
|
|
37
|
+
* `runKey` — so the view treats it as a new row either way.
|
|
38
|
+
*
|
|
39
|
+
* The group deliberately reports no authoritative pass TOTAL. History is paged
|
|
40
|
+
* newest-first, so any total computed from loaded messages is a lower bound that
|
|
41
|
+
* a later scroll-up would contradict. It reports STATE (indexing / indexed /
|
|
42
|
+
* failed), how many passes are currently loaded, and `mayHaveOlder` when the
|
|
43
|
+
* file's first pass is not among them.
|
|
44
|
+
*
|
|
45
|
+
* Pure and view-agnostic: agent.vue and the BunnyQuery widget both render from
|
|
46
|
+
* this, so the two stay identical.
|
|
47
|
+
*/
|
|
48
|
+
import type { ChatMessage, IndexingFileRef } from './host';
|
|
49
|
+
|
|
50
|
+
export type IndexingGroupStatus = 'active' | 'done' | 'error' | 'cancelled';
|
|
51
|
+
|
|
52
|
+
export type IndexingGroup = {
|
|
53
|
+
/** The FILE this row is about: storage path when known (a file can be
|
|
54
|
+
* re-uploaded under a name that already exists elsewhere), else name. Shared
|
|
55
|
+
* by every run of that file, and what ChatSession.cancelIndexingGroup and
|
|
56
|
+
* _indexKeyOf match on — never use it as a render key. */
|
|
57
|
+
key: string;
|
|
58
|
+
/** Identity of this ROW: one indexing RUN of that file. A file indexed on
|
|
59
|
+
* Monday and re-indexed on Wednesday is two runs, and collapsing them into
|
|
60
|
+
* one row erased Monday's from Monday's place in the conversation, claimed
|
|
61
|
+
* its passes for Wednesday, and let Monday's failure be overwritten by
|
|
62
|
+
* Wednesday's success. Named after the run's FIRST loaded pass (see where it
|
|
63
|
+
* 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. This is the
|
|
65
|
+
* render key and the expansion key. */
|
|
66
|
+
runKey: string;
|
|
67
|
+
name: string;
|
|
68
|
+
path?: string;
|
|
69
|
+
mime?: string;
|
|
70
|
+
size?: number;
|
|
71
|
+
/** True when any loaded pass was a re-index of an already-stored file. */
|
|
72
|
+
isReindex: boolean;
|
|
73
|
+
/** Every message of this file, in chat order, with its index in the source
|
|
74
|
+
* array (so cancel/typewriter paths keep addressing the real message). */
|
|
75
|
+
members: { msg: ChatMessage; index: number }[];
|
|
76
|
+
/** Indexing passes LOADED (request bubbles), never a server-side total. */
|
|
77
|
+
passCount: number;
|
|
78
|
+
status: IndexingGroupStatus;
|
|
79
|
+
/** Server item ids of the passes that are still queued/running, so the row can
|
|
80
|
+
* offer a stop button (ChatSession.cancelIndexingGroup cancels each). Empty
|
|
81
|
+
* when nothing is cancellable — a finished file, or a live pass whose server
|
|
82
|
+
* id has not come back yet. */
|
|
83
|
+
cancellableIds: string[];
|
|
84
|
+
/** A cancel request is in flight for one of the passes. */
|
|
85
|
+
cancelling: boolean;
|
|
86
|
+
/** Why the last cancel attempt failed (e.g. the pass had already finished). */
|
|
87
|
+
cancelError?: string;
|
|
88
|
+
/** The file's first pass is not among the loaded messages, so earlier passes
|
|
89
|
+
* exist in history that has not been paged in yet. */
|
|
90
|
+
mayHaveOlder: boolean;
|
|
91
|
+
/** Position in the source array this collapsed row renders at: the index of
|
|
92
|
+
* the run's FIRST loaded pass (see the file docstring for why not the last). */
|
|
93
|
+
anchorIndex: number;
|
|
94
|
+
/** Identity of the turn at `anchorIndex` — its server item id, or its local id
|
|
95
|
+
* while it has none, or `''` when it has neither. The views stamp this on the
|
|
96
|
+
* row (`data-row-pos`) so the scroll anchor can tell a row that RELOCATED (an
|
|
97
|
+
* older page moved the run's start) from one that merely gained a pass. They
|
|
98
|
+
* must not re-derive it from `members`: which member the row renders at is
|
|
99
|
+
* this module's decision, and the two silently disagreed once already. */
|
|
100
|
+
anchorId: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type DisplayEntry =
|
|
104
|
+
| { kind: 'message'; msg: ChatMessage; index: number }
|
|
105
|
+
| { kind: 'indexing'; group: IndexingGroup; index: number };
|
|
106
|
+
|
|
107
|
+
export type BuildDisplayListOptions = {
|
|
108
|
+
/** True while older history remains unpaged, which is what makes a group
|
|
109
|
+
* with no first pass genuinely incomplete rather than merely odd. */
|
|
110
|
+
hasMoreHistory?: boolean;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// The indexing label is view-formatted (formatIndexingLabel), so parsing it is
|
|
114
|
+
// the FALLBACK path only: it exists for bubbles restored from a history cache
|
|
115
|
+
// written before `_indexFile` was stamped. Live and freshly-mapped bubbles carry
|
|
116
|
+
// the structured ref and never reach this.
|
|
117
|
+
//
|
|
118
|
+
// Shapes produced by formatIndexingLabel:
|
|
119
|
+
// "Indexing: [name](path) · mime · 1.2 MB"
|
|
120
|
+
// "Reindexing: name · mime"
|
|
121
|
+
// "Indexing (continuing) [name](path)"
|
|
122
|
+
const INDEXING_LABEL_RE = /^(Re)?[Ii]ndexing(\s*\(continuing\))?\s*:?\s+(.+)$/;
|
|
123
|
+
const LEADING_MD_LINK_RE = /^\[([^\]]+)\]\(([^)]+)\)/;
|
|
124
|
+
|
|
125
|
+
export function parseIndexingLabel(
|
|
126
|
+
content: string,
|
|
127
|
+
): { name: string; path?: string; continued: boolean; isReindex: boolean } | null {
|
|
128
|
+
if (typeof content !== 'string' || !content) return null;
|
|
129
|
+
var firstLine = content.split('\n')[0].trim();
|
|
130
|
+
var m = firstLine.match(INDEXING_LABEL_RE);
|
|
131
|
+
if (!m) return null;
|
|
132
|
+
// Only the first " · " segment is the file; the rest is mime/size trivia.
|
|
133
|
+
var head = m[3].split(' · ')[0].trim();
|
|
134
|
+
var link = head.match(LEADING_MD_LINK_RE);
|
|
135
|
+
var name = link ? link[1].trim() : head;
|
|
136
|
+
if (!name) return null;
|
|
137
|
+
return {
|
|
138
|
+
name: name,
|
|
139
|
+
path: link ? link[2].trim() : undefined,
|
|
140
|
+
continued: !!m[2],
|
|
141
|
+
isReindex: !!m[1],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The file a background-indexing bubble belongs to, structured ref preferred. */
|
|
146
|
+
function readFileRef(msg: ChatMessage): (IndexingFileRef & { continued: boolean }) | null {
|
|
147
|
+
var ref = msg && msg._indexFile;
|
|
148
|
+
if (ref && (ref.path || ref.name)) {
|
|
149
|
+
return {
|
|
150
|
+
name: ref.name || ref.path || '',
|
|
151
|
+
path: ref.path,
|
|
152
|
+
mime: ref.mime,
|
|
153
|
+
size: ref.size,
|
|
154
|
+
isReindex: ref.isReindex,
|
|
155
|
+
continued: !!ref.continued,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
var parsed = parseIndexingLabel(msg && msg.content);
|
|
159
|
+
if (!parsed) return null;
|
|
160
|
+
return {
|
|
161
|
+
name: parsed.name,
|
|
162
|
+
path: parsed.path,
|
|
163
|
+
isReindex: parsed.isReindex,
|
|
164
|
+
continued: parsed.continued,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isPendingMsg(m: ChatMessage): boolean {
|
|
169
|
+
return !!(m.isPending || m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Collapse background-indexing turns into per-file groups.
|
|
174
|
+
*
|
|
175
|
+
* Messages that are not background-indexing pass through untouched, at their
|
|
176
|
+
* original positions and with their original indices.
|
|
177
|
+
*/
|
|
178
|
+
export function buildChatDisplayList(
|
|
179
|
+
messages: ChatMessage[],
|
|
180
|
+
opts?: BuildDisplayListOptions,
|
|
181
|
+
): DisplayEntry[] {
|
|
182
|
+
var list = Array.isArray(messages) ? messages : [];
|
|
183
|
+
var hasMoreHistory = !!(opts && opts.hasMoreHistory);
|
|
184
|
+
|
|
185
|
+
// One entry per RUN (see IndexingGroup.runKey), addressed by an internal id
|
|
186
|
+
// while the list is being walked; runKey is assigned at the end, once the
|
|
187
|
+
// number of runs per file is known.
|
|
188
|
+
var groups: { [runId: string]: IndexingGroup } = {};
|
|
189
|
+
var order: string[] = [];
|
|
190
|
+
var runOfIndex: (string | undefined)[] = new Array(list.length);
|
|
191
|
+
// A response bubble carries no file metadata of its own; it is tied to its
|
|
192
|
+
// request by the server item id, with the immediately preceding request as
|
|
193
|
+
// the fallback for locally-pushed bubbles that have no id yet.
|
|
194
|
+
var runByItemId: { [itemId: string]: string } = {};
|
|
195
|
+
// Row already opened for a file NAME, so a pass that reports no storage path
|
|
196
|
+
// joins it instead of opening a second row for the same file.
|
|
197
|
+
var keyByName: { [name: string]: string } = {};
|
|
198
|
+
// The run currently being accumulated for each file, every run of it, and the
|
|
199
|
+
// file each run belongs to (a run id is opaque — a file key can contain any
|
|
200
|
+
// character, so it must never be parsed back out of one).
|
|
201
|
+
var openRunOfKey: { [key: string]: string } = {};
|
|
202
|
+
var runsOfKey: { [key: string]: string[] } = {};
|
|
203
|
+
var keyOfRun: { [runId: string]: string } = {};
|
|
204
|
+
var runSeq = 0;
|
|
205
|
+
|
|
206
|
+
for (var i = 0; i < list.length; i++) {
|
|
207
|
+
var msg = list[i];
|
|
208
|
+
if (!msg || !msg.isBackgroundTask) continue;
|
|
209
|
+
|
|
210
|
+
var runId: string | undefined;
|
|
211
|
+
var ref = msg.role === 'user' ? readFileRef(msg) : null;
|
|
212
|
+
if (ref) {
|
|
213
|
+
// Path first — a file can be re-uploaded under a name that already
|
|
214
|
+
// exists elsewhere. But a pass that supplied no path (a compact
|
|
215
|
+
// continuation label recovered from an old history cache) is still the
|
|
216
|
+
// same file as the path-bearing passes above it, so let it join them
|
|
217
|
+
// rather than splitting one file across two collapsed rows.
|
|
218
|
+
var key = ref.path || keyByName[ref.name] || ref.name;
|
|
219
|
+
// A FIRST pass ("A new file has just been uploaded") when this file
|
|
220
|
+
// already has a run open starts a new one: it is a re-index, or a
|
|
221
|
+
// re-upload over the same storage path. Continuations join the run.
|
|
222
|
+
if (!ref.continued && openRunOfKey[key]) delete openRunOfKey[key];
|
|
223
|
+
runId = openRunOfKey[key];
|
|
224
|
+
if (!runId) {
|
|
225
|
+
runId = 'run' + (runSeq++);
|
|
226
|
+
openRunOfKey[key] = runId;
|
|
227
|
+
keyOfRun[runId] = key;
|
|
228
|
+
(runsOfKey[key] || (runsOfKey[key] = [])).push(runId);
|
|
229
|
+
}
|
|
230
|
+
} else if (msg._serverItemId && runByItemId[msg._serverItemId]) {
|
|
231
|
+
runId = runByItemId[msg._serverItemId];
|
|
232
|
+
} else if (msg.role !== 'user') {
|
|
233
|
+
// ADJACENT only. Both paths that create a pass emit its request and
|
|
234
|
+
// response bubbles together, so an id-less response belongs to the
|
|
235
|
+
// message right before it. Falling back to the last file seen ANYWHERE
|
|
236
|
+
// above swallowed stray background bubbles — an indexing prompt whose
|
|
237
|
+
// shape we could not parse, a response whose request is not loaded —
|
|
238
|
+
// into whichever file happened to be indexed most recently, however
|
|
239
|
+
// much unrelated conversation sat in between.
|
|
240
|
+
runId = runOfIndex[i - 1];
|
|
241
|
+
}
|
|
242
|
+
// A background bubble we cannot attribute to a file (an unrecognised label,
|
|
243
|
+
// or a response whose request is not loaded) stays an ordinary message
|
|
244
|
+
// rather than being folded into whichever group happens to be nearest.
|
|
245
|
+
if (!runId) continue;
|
|
246
|
+
|
|
247
|
+
var g = groups[runId];
|
|
248
|
+
if (!g) {
|
|
249
|
+
var fileKey = keyOfRun[runId];
|
|
250
|
+
g = groups[runId] = {
|
|
251
|
+
key: fileKey,
|
|
252
|
+
runKey: runId, // provisional; renumbered newest-first below
|
|
253
|
+
name: ref ? ref.name : fileKey,
|
|
254
|
+
path: ref ? ref.path : undefined,
|
|
255
|
+
mime: ref ? ref.mime : undefined,
|
|
256
|
+
size: ref ? ref.size : undefined,
|
|
257
|
+
isReindex: !!(ref && ref.isReindex),
|
|
258
|
+
members: [],
|
|
259
|
+
passCount: 0,
|
|
260
|
+
status: 'done',
|
|
261
|
+
cancellableIds: [],
|
|
262
|
+
cancelling: false,
|
|
263
|
+
mayHaveOlder: false,
|
|
264
|
+
// The run's first loaded pass, and never re-stamped: see the file
|
|
265
|
+
// docstring. `anchorId` is filled in once every member is known.
|
|
266
|
+
anchorIndex: i,
|
|
267
|
+
anchorId: '',
|
|
268
|
+
};
|
|
269
|
+
order.push(runId);
|
|
270
|
+
}
|
|
271
|
+
if (ref) {
|
|
272
|
+
// Later passes carry the compact continuation label with no mime/size,
|
|
273
|
+
// so keep the richest values any pass supplied.
|
|
274
|
+
if (ref.name) g.name = ref.name;
|
|
275
|
+
if (ref.path) g.path = ref.path;
|
|
276
|
+
if (ref.mime) g.mime = ref.mime;
|
|
277
|
+
if (typeof ref.size === 'number') g.size = ref.size;
|
|
278
|
+
if (ref.isReindex) g.isReindex = true;
|
|
279
|
+
if (!ref.continued) g.mayHaveOlder = false;
|
|
280
|
+
g.passCount++;
|
|
281
|
+
}
|
|
282
|
+
g.members.push({ msg: msg, index: i });
|
|
283
|
+
runOfIndex[i] = runId;
|
|
284
|
+
if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
|
|
285
|
+
if (ref && ref.name) keyByName[ref.name] = g.key;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Name each run after its FIRST loaded pass. That id survives everything the
|
|
289
|
+
// list does around it: passes appended to this run, other runs of the same
|
|
290
|
+
// file appearing before OR after it, and an older page prepending. (An
|
|
291
|
+
// ordinal would not — numbering from either end renames existing rows as soon
|
|
292
|
+
// as a run appears at that end, which silently moves the user's expansion to
|
|
293
|
+
// a row they never opened.) The one thing that changes it is the run's own
|
|
294
|
+
// true first pass finally paging in, which happens at most once per run.
|
|
295
|
+
for (var rk in runsOfKey) {
|
|
296
|
+
var runIds = runsOfKey[rk];
|
|
297
|
+
for (var ri = 0; ri < runIds.length; ri++) {
|
|
298
|
+
var grpR = groups[runIds[ri]];
|
|
299
|
+
if (!grpR) continue;
|
|
300
|
+
var first = grpR.members[0];
|
|
301
|
+
var firstId = first && first.msg && (first.msg._serverItemId || first.msg._localId);
|
|
302
|
+
// A run whose passes are all still local (no server id yet) falls back to
|
|
303
|
+
// its position, which is all there is to go on.
|
|
304
|
+
grpR.runKey = rk + '#' + (firstId || 'n' + ri);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Status + completeness, once every member is known.
|
|
309
|
+
for (var oi = 0; oi < order.length; oi++) {
|
|
310
|
+
var grp = groups[order[oi]];
|
|
311
|
+
// A file's passes run strictly one after another, so a pass still marked
|
|
312
|
+
// queued/running with a SETTLED pass after it is stale history, not live
|
|
313
|
+
// work — the server's row for it simply never left "running". Ignoring
|
|
314
|
+
// those matters most for pages reached by scrolling up (or by the viewport
|
|
315
|
+
// fill), which get no poll attached to resolve them: one stale row would
|
|
316
|
+
// otherwise flip a long-finished file back to a spinner with a "Stop"
|
|
317
|
+
// button aimed at a dead item id, permanently.
|
|
318
|
+
var lastSettled = -1;
|
|
319
|
+
for (var si = 0; si < grp.members.length; si++) {
|
|
320
|
+
if (!isPendingMsg(grp.members[si].msg)) lastSettled = si;
|
|
321
|
+
}
|
|
322
|
+
var active = false;
|
|
323
|
+
for (var mi = lastSettled + 1; mi < grp.members.length; mi++) {
|
|
324
|
+
if (isPendingMsg(grp.members[mi].msg)) { active = true; break; }
|
|
325
|
+
}
|
|
326
|
+
// What a stop button would act on: the REQUEST bubble of every pass that is
|
|
327
|
+
// still queued or running server-side. The assistant placeholder shares its
|
|
328
|
+
// pass's server id, so ids are de-duplicated. A pass mid-cancel is left out
|
|
329
|
+
// (its request is already on its way) but keeps the row in a cancelling
|
|
330
|
+
// state so the button does not flicker back to "stop".
|
|
331
|
+
for (var xi = 0; xi < grp.members.length; xi++) {
|
|
332
|
+
if (grp.members[xi].msg._cancelling) { grp.cancelling = true; break; }
|
|
333
|
+
}
|
|
334
|
+
var seenIds: { [id: string]: boolean } = {};
|
|
335
|
+
for (var ci = 0; ci < grp.members.length; ci++) {
|
|
336
|
+
var cm = grp.members[ci].msg;
|
|
337
|
+
// Report a failed Stop only while the stop is still something the user
|
|
338
|
+
// can act on. "Could not remove from queue" (the pass had just started)
|
|
339
|
+
// stays on that bubble, so reporting it from ANY member meant a row that
|
|
340
|
+
// went on to finish normally kept describing a one-off transient failure
|
|
341
|
+
// as a permanent property of the file, until a full history refresh
|
|
342
|
+
// rebuilt the bubbles.
|
|
343
|
+
if (cm._cancelError && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
|
|
344
|
+
if (cm.role !== 'user' || !cm._serverItemId || cm._cancelling || cm.isSendingToServer) continue;
|
|
345
|
+
if (!(cm.isPendingQueued || cm.isPendingInProcess)) continue;
|
|
346
|
+
// Same staleness rule: never offer to stop a pass a later one outlived.
|
|
347
|
+
if (ci < lastSettled) continue;
|
|
348
|
+
if (seenIds[cm._serverItemId]) continue;
|
|
349
|
+
seenIds[cm._serverItemId] = true;
|
|
350
|
+
grp.cancellableIds.push(cm._serverItemId);
|
|
351
|
+
}
|
|
352
|
+
if (active) {
|
|
353
|
+
grp.status = 'active';
|
|
354
|
+
} else {
|
|
355
|
+
// The newest loaded outcome is the file's state: an early pass may have
|
|
356
|
+
// errored and a later one succeeded.
|
|
357
|
+
var last = grp.members[grp.members.length - 1].msg;
|
|
358
|
+
grp.status = last.isError ? 'error' : last.isCancelled ? 'cancelled' : 'done';
|
|
359
|
+
}
|
|
360
|
+
// A group whose passes are ALL continuations began before the loaded
|
|
361
|
+
// window; its earlier passes arrive when older history is paged in.
|
|
362
|
+
var sawFirstPass = false;
|
|
363
|
+
for (var pi = 0; pi < grp.members.length; pi++) {
|
|
364
|
+
var pm = grp.members[pi].msg;
|
|
365
|
+
if (pm.role !== 'user') continue;
|
|
366
|
+
var pref = readFileRef(pm);
|
|
367
|
+
if (pref && !pref.continued) { sawFirstPass = true; break; }
|
|
368
|
+
}
|
|
369
|
+
grp.mayHaveOlder = !sawFirstPass && hasMoreHistory;
|
|
370
|
+
// Where the row renders, and WHICH turn that is. Derived from the same
|
|
371
|
+
// member so the two can never drift apart; a group always has a member
|
|
372
|
+
// (it is created by the first one).
|
|
373
|
+
var anchor = grp.members[0];
|
|
374
|
+
grp.anchorIndex = anchor.index;
|
|
375
|
+
grp.anchorId = anchor.msg._serverItemId || anchor.msg._localId || '';
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
var out: DisplayEntry[] = [];
|
|
379
|
+
for (var j = 0; j < list.length; j++) {
|
|
380
|
+
var r = runOfIndex[j];
|
|
381
|
+
if (r === undefined) {
|
|
382
|
+
out.push({ kind: 'message', msg: list[j], index: j });
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
// Every other member of the run is represented by the row at the anchor.
|
|
386
|
+
if (groups[r].anchorIndex === j) out.push({ kind: 'indexing', group: groups[r], index: j });
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|