bunnyquery 1.12.1 → 1.14.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 +4 -2
- package/bunnyquery.css +31 -0
- package/bunnyquery.js +653 -147
- package/dist/engine.cjs +600 -157
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +309 -2
- package/dist/engine.d.ts +309 -2
- package/dist/engine.mjs +576 -158
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/budget.ts +43 -1
- package/src/engine/citations.ts +181 -0
- package/src/engine/config.ts +44 -0
- package/src/engine/history.ts +19 -1
- package/src/engine/host.ts +7 -0
- package/src/engine/index.ts +17 -0
- package/src/engine/project_settings.ts +40 -0
- package/src/engine/prompts/chat_system_prompt.ts +50 -1
- package/src/engine/requests.ts +64 -35
- package/src/engine/retrieval_memory.ts +348 -0
- package/src/engine/session.ts +71 -7
- package/styles/chat.css +31 -0
package/src/engine/session.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { windowedIndexingEnabled, liveStreamingEnabled, streamRecoveryEnabled, c
|
|
|
48
48
|
import { createSseParser, type SseParser } from './sse';
|
|
49
49
|
import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage, isCsrStatusEnvelope } from './errors';
|
|
50
50
|
import { buildBoundedChatMessages } from './budget';
|
|
51
|
+
import { extractRetrievalNote } from './retrieval_memory';
|
|
51
52
|
import { createInlineLinkRegex, sanitizeAttachmentLinksForHistory } from './links';
|
|
52
53
|
import { markImagePreviewStale } from './image_preview';
|
|
53
54
|
import { chatCacheKey, indexScopeKey, mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage, adoptLocalAnswerIntoPage } from './history';
|
|
@@ -1220,6 +1221,35 @@ export class ChatSession {
|
|
|
1220
1221
|
/** Live streams by server item id. One per in-flight streamed turn. */
|
|
1221
1222
|
private liveStreams: { [itemId: string]: LiveStreamState } = {};
|
|
1222
1223
|
|
|
1224
|
+
/** When skapi recorded a turn's response as having finished arriving, by server
|
|
1225
|
+
* item id. Read off the terminal envelope of a STREAMED turn (the only settle
|
|
1226
|
+
* that carries it, see _settleLiveStream) and used as the answer bubble's time
|
|
1227
|
+
* in place of this browser's clock. Entries are never removed: a settle reaches
|
|
1228
|
+
* here twice (the poll's onResponse, then the promise), and the value for an id
|
|
1229
|
+
* cannot change. */
|
|
1230
|
+
private _respondedAt: { [itemId: string]: number } = {};
|
|
1231
|
+
|
|
1232
|
+
private _noteRespondedAt(itemId: string, response: any): void {
|
|
1233
|
+
if (!itemId || !isCsrStatusEnvelope(response)) return;
|
|
1234
|
+
var at = Number(response.responded);
|
|
1235
|
+
if (!isFinite(at) || at <= 0) return;
|
|
1236
|
+
// Self-healing for a session built without the constructor (the engine tests
|
|
1237
|
+
// drive Object.create(ChatSession.prototype)), like typewriterQueue below.
|
|
1238
|
+
if (!this._respondedAt) this._respondedAt = {};
|
|
1239
|
+
this._respondedAt[itemId] = at;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** The time a live answer bubble is stamped with: when skapi received the
|
|
1243
|
+
* response, if the settle told us, else now. "Now" is what every bubble got
|
|
1244
|
+
* before, and it is still right for a buffered turn, whose settle hands back the
|
|
1245
|
+
* destination's body and nothing of skapi's; a history load then replaces it
|
|
1246
|
+
* with the server's value. */
|
|
1247
|
+
private _replyStampFor(msg: ChatMessage | undefined): number {
|
|
1248
|
+
var id = msg ? msg._serverItemId : undefined;
|
|
1249
|
+
var at = (typeof id === 'string' && id && this._respondedAt) ? this._respondedAt[id] : undefined;
|
|
1250
|
+
return typeof at === 'number' ? at : wallClockNow();
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1223
1253
|
/**
|
|
1224
1254
|
* Open (or re-open) the live stream for `itemId`, or null when this poll must
|
|
1225
1255
|
* not carry one.
|
|
@@ -1472,6 +1502,10 @@ export class ChatSession {
|
|
|
1472
1502
|
private _settleLiveStream(st: LiveStreamState, response: any): any {
|
|
1473
1503
|
this._closeLiveStream(st, true);
|
|
1474
1504
|
if (!isCsrStatusEnvelope(response)) return response;
|
|
1505
|
+
// Before any return below, and for every terminal status: the bubble that
|
|
1506
|
+
// settles this turn is stamped from it whether it ends as an answer, an error
|
|
1507
|
+
// or a truncation.
|
|
1508
|
+
this._noteRespondedAt(st.id, response);
|
|
1475
1509
|
// RESOLVED only, and this is not caution, it is correctness. 'cancelled' is the
|
|
1476
1510
|
// envelope the user's own Stop produces, and _isCancelledPollResult downstream
|
|
1477
1511
|
// is what settles the turn as stopped: hand it an assembled body instead and a
|
|
@@ -2076,6 +2110,10 @@ export class ChatSession {
|
|
|
2076
2110
|
var reply: ChatMessage = isErr
|
|
2077
2111
|
? { role: 'assistant', content: getErrorMessage(body), isError: true, _serverItemId: itemId }
|
|
2078
2112
|
: { role: 'assistant', content: text, _serverItemId: itemId };
|
|
2113
|
+
if (!isErr) {
|
|
2114
|
+
var rNote = extractRetrievalNote(body, text);
|
|
2115
|
+
if (rNote) reply._retrieval = rNote;
|
|
2116
|
+
}
|
|
2079
2117
|
if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
|
|
2080
2118
|
// The reader is in another chat. Settle it where it belongs and touch
|
|
2081
2119
|
// nothing on screen - state.messages is a different conversation's list.
|
|
@@ -2583,7 +2621,9 @@ export class ChatSession {
|
|
|
2583
2621
|
if (isErrorResponseBody(response)) return { content: getErrorMessage(response), isError: true };
|
|
2584
2622
|
var answer = (params.aiPlatform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response));
|
|
2585
2623
|
answer = (answer || '').trim();
|
|
2586
|
-
|
|
2624
|
+
// Retrieval memory: what this turn read, off the body's tool blocks (a
|
|
2625
|
+
// streamed turn's `response` is already the assembled body here).
|
|
2626
|
+
return { content: answer || 'No text response received from AI provider.', isError: false, retrieval: extractRetrievalNote(response, answer) };
|
|
2587
2627
|
})
|
|
2588
2628
|
.catch(function (err: any) { return { content: getErrorMessage(err), isError: true }; })
|
|
2589
2629
|
.then(function (result: any) {
|
|
@@ -2602,6 +2642,7 @@ export class ChatSession {
|
|
|
2602
2642
|
}
|
|
2603
2643
|
var reply: ChatMessage = { role: 'assistant', content: result.content, isError: result.isError };
|
|
2604
2644
|
if (dispatchItemId) reply._serverItemId = dispatchItemId;
|
|
2645
|
+
if (result.retrieval) reply._retrieval = result.retrieval;
|
|
2605
2646
|
|
|
2606
2647
|
// REPLACE the trailing pending "Thinking..." bubble in the cache with
|
|
2607
2648
|
// the answer, regardless of whether this chat is the currently-visible
|
|
@@ -3377,7 +3418,7 @@ export class ChatSession {
|
|
|
3377
3418
|
insertAtTarget(msg: ChatMessage, targetIdx: number): void {
|
|
3378
3419
|
// Error/direct replies land here rather than through the typewriter, so this
|
|
3379
3420
|
// is where they pick up their display time.
|
|
3380
|
-
if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts =
|
|
3421
|
+
if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = this._replyStampFor(msg);
|
|
3381
3422
|
// Overwrite only a placeholder that genuinely belongs to the turn above it.
|
|
3382
3423
|
// Two things now sit at this index that must never be replaced by a chat
|
|
3383
3424
|
// answer: a background "Indexing:" placeholder (a file's rows are inserted
|
|
@@ -3437,6 +3478,10 @@ export class ChatSession {
|
|
|
3437
3478
|
var offReply: ChatMessage = isErrorResponseBody(response)
|
|
3438
3479
|
? { role: 'assistant', content: getErrorMessage(response), isError: true }
|
|
3439
3480
|
: { role: 'assistant', content: ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim() || 'No text response received from AI provider.' };
|
|
3481
|
+
if (!offReply.isError) {
|
|
3482
|
+
var offNote = extractRetrievalNote(response, offReply.content);
|
|
3483
|
+
if (offNote) offReply._retrieval = offNote;
|
|
3484
|
+
}
|
|
3440
3485
|
this._applyReplyToCache(ownerKey, offReply, serverId);
|
|
3441
3486
|
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
3442
3487
|
return;
|
|
@@ -3452,6 +3497,9 @@ export class ChatSession {
|
|
|
3452
3497
|
} else {
|
|
3453
3498
|
var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response));
|
|
3454
3499
|
answer = (answer || '').trim() || 'No text response received from AI provider.';
|
|
3500
|
+
// Retrieval memory for this turn, attached to whichever bubble below ends up
|
|
3501
|
+
// holding the answer.
|
|
3502
|
+
var qNote = extractRetrievalNote(response, answer);
|
|
3455
3503
|
var lid = this._newLocalId();
|
|
3456
3504
|
if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) {
|
|
3457
3505
|
// Same resume as the immediate-send path: keep what the stream painted
|
|
@@ -3465,14 +3513,19 @@ export class ChatSession {
|
|
|
3465
3513
|
var qSettled: any = { role: 'assistant', content: qPainted, _localId: lid };
|
|
3466
3514
|
if (prevQ._serverItemId) qSettled._serverItemId = prevQ._serverItemId;
|
|
3467
3515
|
if (prevQ._ownerKey) qSettled._ownerKey = prevQ._ownerKey;
|
|
3516
|
+
if (qNote) qSettled._retrieval = qNote;
|
|
3468
3517
|
this.state.messages[targetIdx] = qSettled;
|
|
3469
3518
|
this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid, qPainted);
|
|
3470
3519
|
} else if (targetIdx >= 0) {
|
|
3471
|
-
|
|
3520
|
+
var qInsert: ChatMessage = { role: 'assistant', content: '', _localId: lid };
|
|
3521
|
+
if (qNote) qInsert._retrieval = qNote;
|
|
3522
|
+
this.state.messages.splice(targetIdx, 0, qInsert);
|
|
3472
3523
|
this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid);
|
|
3473
3524
|
} else {
|
|
3474
3525
|
var aiIdx = this.state.messages.length;
|
|
3475
|
-
|
|
3526
|
+
var qAppend: ChatMessage = { role: 'assistant', content: '', _localId: lid };
|
|
3527
|
+
if (qNote) qAppend._retrieval = qNote;
|
|
3528
|
+
this.state.messages.push(qAppend);
|
|
3476
3529
|
this.host.notify(); this.enqueueTypewrite(aiIdx, answer, lid);
|
|
3477
3530
|
}
|
|
3478
3531
|
}
|
|
@@ -4069,9 +4122,11 @@ export class ChatSession {
|
|
|
4069
4122
|
// Stamp the reply bubble's display time as it starts revealing. This is the
|
|
4070
4123
|
// single chokepoint every typed reply (immediate, queued, and bg-resolution)
|
|
4071
4124
|
// passes through, so it is where a live reply gets its "when it arrived"
|
|
4072
|
-
// timestamp; a history reload later replaces it with the server
|
|
4125
|
+
// timestamp; a history reload later replaces it with the server's own.
|
|
4126
|
+
// _replyStampFor prefers the time skapi received the response when the settle
|
|
4127
|
+
// reported one (a streamed turn), over this browser's clock.
|
|
4073
4128
|
var target = this.state.messages[idx];
|
|
4074
|
-
if (target && target._ts === undefined) target._ts =
|
|
4129
|
+
if (target && target._ts === undefined) target._ts = this._replyStampFor(target);
|
|
4075
4130
|
// SELF-HEALING, because this is now reached from the live paint path too. The
|
|
4076
4131
|
// queue is a class field, so it is only initialised by the constructor; any
|
|
4077
4132
|
// caller holding a ChatSession built another way (the engine tests drive
|
|
@@ -4130,6 +4185,9 @@ export class ChatSession {
|
|
|
4130
4185
|
var settled: any = { role: 'assistant', content: painted, isPending: false, _localId: lid };
|
|
4131
4186
|
if (prevSettled._serverItemId) settled._serverItemId = prevSettled._serverItemId;
|
|
4132
4187
|
if (prevSettled._ownerKey) settled._ownerKey = prevSettled._ownerKey;
|
|
4188
|
+
// The retrieval note rides with the cached reply; the typewriter only
|
|
4189
|
+
// writes `content`, so it survives the reveal.
|
|
4190
|
+
if (latest._retrieval) settled._retrieval = latest._retrieval;
|
|
4133
4191
|
this.state.messages[pendingIdx] = settled;
|
|
4134
4192
|
this._removeStrayPendingAssistants();
|
|
4135
4193
|
this.host.notify();
|
|
@@ -4423,6 +4481,9 @@ export class ChatSession {
|
|
|
4423
4481
|
// before and after a reload. Only an INDEXING pass has a protocol token to
|
|
4424
4482
|
// hide.
|
|
4425
4483
|
var reportedComplete = !isErr && !!answer && answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
|
|
4484
|
+
// Retrieval memory for a CHAT turn that settled through a re-attached poll.
|
|
4485
|
+
// Read once here; the background branches below never use it.
|
|
4486
|
+
var hNote = isErr ? null : extractRetrievalNote(response, answer);
|
|
4426
4487
|
var stripMarker = function (t: string): string {
|
|
4427
4488
|
return reportedComplete ? t.split(INDEXING_COMPLETE_MARKER).join('').trim() : t;
|
|
4428
4489
|
};
|
|
@@ -4464,7 +4525,9 @@ export class ChatSession {
|
|
|
4464
4525
|
// the reader watched arrive.
|
|
4465
4526
|
var hPainted = this._paintedTextAt(idx);
|
|
4466
4527
|
var lid = this._newLocalId();
|
|
4467
|
-
|
|
4528
|
+
var hReply: ChatMessage = { role: 'assistant', content: hPainted, _localId: lid, _serverItemId: itemId };
|
|
4529
|
+
if (hNote) hReply._retrieval = hNote;
|
|
4530
|
+
this.state.messages[idx] = hReply;
|
|
4468
4531
|
this.host.notify(); this.enqueueTypewrite(idx, text, lid, hPainted);
|
|
4469
4532
|
this.updateHistoryCache(); return;
|
|
4470
4533
|
}
|
|
@@ -4497,6 +4560,7 @@ export class ChatSession {
|
|
|
4497
4560
|
}
|
|
4498
4561
|
var lid2 = this._newLocalId();
|
|
4499
4562
|
var reply: ChatMessage = { role: 'assistant', content: '', _localId: lid2, _serverItemId: itemId };
|
|
4563
|
+
if (hNote) reply._retrieval = hNote;
|
|
4500
4564
|
this.state.messages.splice(userIdx + 1, 0, reply);
|
|
4501
4565
|
this.host.notify(); this.enqueueTypewrite(userIdx + 1, text2, lid2);
|
|
4502
4566
|
this.updateHistoryCache();
|
package/styles/chat.css
CHANGED
|
@@ -149,6 +149,37 @@
|
|
|
149
149
|
font-style: italic;
|
|
150
150
|
clear: both;
|
|
151
151
|
}
|
|
152
|
+
/* ---- citations + the no-source mark -------------------------------------*/
|
|
153
|
+
/* Under an answer that read something: one chip per cited file (and page, in
|
|
154
|
+
the label only). The chips are the ordinary stored-file link chips
|
|
155
|
+
(.bq-link-button, emitted by renderInlineLinkHtml), so they inherit the
|
|
156
|
+
expired / refreshing / unavailable states and the hover rule; this only lays
|
|
157
|
+
them out as a row under a hairline. Absent entirely when there is nothing to
|
|
158
|
+
cite, so a plain bubble is laid out exactly as before. */
|
|
159
|
+
.bq-citations {
|
|
160
|
+
display: flex;
|
|
161
|
+
flex-wrap: wrap;
|
|
162
|
+
align-items: baseline;
|
|
163
|
+
gap: 0.25rem 0.6rem;
|
|
164
|
+
margin-top: 0.45rem;
|
|
165
|
+
padding-top: 0.35rem;
|
|
166
|
+
border-top: 1px solid var(--bq-line);
|
|
167
|
+
font-size: 0.72rem;
|
|
168
|
+
line-height: 1.35;
|
|
169
|
+
white-space: normal;
|
|
170
|
+
clear: both;
|
|
171
|
+
}
|
|
172
|
+
.bq-citations .bq-link-button { max-width: 16rem; }
|
|
173
|
+
/* The answer used no data tool: a small muted line, never a colour that reads
|
|
174
|
+
as an error (the answer may still be right; what it was not is READ). */
|
|
175
|
+
.bq-no-source-note {
|
|
176
|
+
display: block;
|
|
177
|
+
margin-top: 0.35rem;
|
|
178
|
+
font-size: 0.68rem;
|
|
179
|
+
font-style: italic;
|
|
180
|
+
color: var(--bq-muted);
|
|
181
|
+
clear: both;
|
|
182
|
+
}
|
|
152
183
|
/* Timestamp under a settled bubble (created for a user turn, response time for an
|
|
153
184
|
assistant turn). Muted and small so it never competes with the message. */
|
|
154
185
|
.bq-msg-time {
|