bunnyquery 1.11.1 → 1.13.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 +5 -0
- package/bunnyquery.css +59 -0
- package/bunnyquery.js +332 -9
- package/dist/engine.cjs +263 -8
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +93 -1
- package/dist/engine.d.ts +93 -1
- package/dist/engine.mjs +263 -9
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/errors.ts +15 -0
- package/src/engine/history.ts +15 -2
- package/src/engine/host.ts +4 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/indexing_groups.ts +24 -3
- package/src/engine/prompts/chat_system_prompt.ts +23 -0
- package/src/engine/session.ts +277 -6
- package/styles/chat.css +59 -0
package/package.json
CHANGED
package/src/engine/errors.ts
CHANGED
|
@@ -113,6 +113,21 @@ export function getErrorMessage(input: any): string {
|
|
|
113
113
|
return 'Something went wrong.';
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* True when the worker has already sent this pass's window again. Its stored
|
|
118
|
+
* error then carries `retried` (the worker's _mark_pass_retried writes it only
|
|
119
|
+
* AFTER the replacement pass was queued, so the flag never promises a retry that
|
|
120
|
+
* does not exist). The failure is real but it is not the file's outcome: the
|
|
121
|
+
* replacement pass is. The file's row must not go red over it, and the bubble is
|
|
122
|
+
* drawn muted rather than as a dead end. Same one-level unwrap as every reader
|
|
123
|
+
* here, so a streamed turn's failure answers the same way as a buffered one.
|
|
124
|
+
*/
|
|
125
|
+
export function isRetriedFailure(input: any): boolean {
|
|
126
|
+
var envErr = csrEnvelopeError(input);
|
|
127
|
+
if (envErr !== undefined) input = envErr;
|
|
128
|
+
return !!input && typeof input === 'object' && input.retried === true;
|
|
129
|
+
}
|
|
130
|
+
|
|
116
131
|
export function isErrorResponseBody(response: any): boolean {
|
|
117
132
|
// A FAILED streamed row is an error however empty its payload turned out to
|
|
118
133
|
// be: the status is the fact. See csrEnvelopeError.
|
package/src/engine/history.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* view-specific display formatting. projectId is passed for link sanitization.
|
|
6
6
|
*/
|
|
7
7
|
import { extractClaudeText, extractOpenAIText, INDEXING_COMPLETE_MARKER, EMPTY_INDEXING_REPLY, getChatHistory, bgIndexingQueueName } from './requests';
|
|
8
|
-
import { isErrorResponseBody, getErrorMessage } from './errors';
|
|
8
|
+
import { isErrorResponseBody, getErrorMessage, isRetriedFailure } from './errors';
|
|
9
9
|
import { streamRecoveryEnabled } from './config';
|
|
10
10
|
import { sanitizeAttachmentLinksForHistory } from './links';
|
|
11
11
|
import type { ChatMessage } from './host';
|
|
@@ -798,8 +798,17 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
798
798
|
// cases the pass simply shows no duration, which is right: the honest answer
|
|
799
799
|
// to "how long did it take" is nothing, not the queue wait dressed up as it.
|
|
800
800
|
var executedTs = Number(item && item.executed);
|
|
801
|
+
// When skapi recorded the destination's response as having finished arriving.
|
|
802
|
+
// It is the answer's time whenever the row carries it, because `updated` is
|
|
803
|
+
// not that for a STREAMED turn: finalizing the turn stores its answer a few
|
|
804
|
+
// seconds later and moves `updated` to that moment. Absent on rows settled
|
|
805
|
+
// before the worker recorded it, on a turn that failed with no response, and
|
|
806
|
+
// under an SDK that does not project it. All three fall back to `updated`,
|
|
807
|
+
// which is exactly what this showed before.
|
|
808
|
+
var respondedTs = Number(item && item.responded);
|
|
801
809
|
var userTs = isFinite(createdTs) && createdTs > 0 ? createdTs : (isFinite(updatedTs) && updatedTs > 0 ? updatedTs : undefined);
|
|
802
|
-
var replyTs = isFinite(
|
|
810
|
+
var replyTs = isFinite(respondedTs) && respondedTs > 0 ? respondedTs
|
|
811
|
+
: (isFinite(updatedTs) && updatedTs > 0 ? updatedTs : (isFinite(createdTs) && createdTs > 0 ? createdTs : undefined));
|
|
803
812
|
|
|
804
813
|
if (userText) {
|
|
805
814
|
var displayContent;
|
|
@@ -854,6 +863,10 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
854
863
|
} else if (isQueued) { /* no assistant placeholder */ }
|
|
855
864
|
else if (isErrorResponse) {
|
|
856
865
|
var em: any = { role: 'assistant', content: getErrorMessage(response), isError: true };
|
|
866
|
+
// A failure the worker has since sent again is not this file's outcome
|
|
867
|
+
// (see isRetriedFailure): the group reader skips it when it settles the
|
|
868
|
+
// file's status, and the view draws it muted.
|
|
869
|
+
if (isRetriedFailure(response)) em.isRetried = true;
|
|
857
870
|
if (item._fromBgChain) em._fromBgChain = true;
|
|
858
871
|
if (item._isBgTask) em.isBackgroundTask = true;
|
|
859
872
|
if (serverItemId !== undefined) em._serverItemId = serverItemId;
|
package/src/engine/host.ts
CHANGED
|
@@ -95,6 +95,10 @@ export interface ChatMessage {
|
|
|
95
95
|
_dimSending?: boolean;
|
|
96
96
|
isCancelled?: boolean;
|
|
97
97
|
isError?: boolean;
|
|
98
|
+
/** With isError: the worker has already sent this pass's window again, so the
|
|
99
|
+
* failure is not the file's outcome. Set from the row's error payload
|
|
100
|
+
* (`retried`, see errors.ts isRetriedFailure); never set locally. */
|
|
101
|
+
isRetried?: boolean;
|
|
98
102
|
isBackgroundTask?: boolean;
|
|
99
103
|
/** Set on background-indexing REQUEST bubbles only (see IndexingFileRef). */
|
|
100
104
|
_indexFile?: IndexingFileRef;
|
package/src/engine/index.ts
CHANGED
|
@@ -69,6 +69,7 @@ export { buildChatGreeting, type ChatGreetingParams, type ChatGreetingParts } fr
|
|
|
69
69
|
// normalization, and history mapping — shared so both consumers stay identical.
|
|
70
70
|
export {
|
|
71
71
|
getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, isProviderApiKeyError,
|
|
72
|
+
isRetriedFailure,
|
|
72
73
|
// The csr-poll STATUS ENVELOPE, and the provider error nested one level inside a
|
|
73
74
|
// failed one. Exported because "is this an envelope or a body" is asked in two
|
|
74
75
|
// places that must agree (the streamed settle, and every error reader), and it
|
|
@@ -728,9 +728,30 @@ export function buildChatDisplayList(
|
|
|
728
728
|
grp.status = 'cancelled';
|
|
729
729
|
} else {
|
|
730
730
|
// The newest loaded outcome is the file's state: an early pass may have
|
|
731
|
-
// errored and a later one succeeded.
|
|
732
|
-
|
|
733
|
-
|
|
731
|
+
// errored and a later one succeeded. A failure the worker has already
|
|
732
|
+
// SENT AGAIN is not an outcome (its replacement pass is; see
|
|
733
|
+
// isRetriedFailure), so it is passed over. A run whose loaded passes are
|
|
734
|
+
// all such failures has no settled outcome yet: the replacement is on
|
|
735
|
+
// the queue by construction (the mark is written after its message went
|
|
736
|
+
// out), so the row stays live until it is adopted, rather than telling
|
|
737
|
+
// the user the file died on a page the worker is reading again.
|
|
738
|
+
// Both bubbles of the sent-again pass are passed over, the request as
|
|
739
|
+
// well as the failure: they share the row's id, and a request bubble
|
|
740
|
+
// is never an error, so stopping on it would read the pass as settled.
|
|
741
|
+
var retriedIds: { [id: string]: boolean } = {};
|
|
742
|
+
for (var ri = 0; ri < grp.members.length; ri++) {
|
|
743
|
+
var rm = grp.members[ri].msg;
|
|
744
|
+
if (rm.isRetried && rm._serverItemId) retriedIds[rm._serverItemId] = true;
|
|
745
|
+
}
|
|
746
|
+
var li = grp.members.length - 1;
|
|
747
|
+
while (li >= 0 && (grp.members[li].msg.isRetried ||
|
|
748
|
+
(grp.members[li].msg._serverItemId && retriedIds[grp.members[li].msg._serverItemId]))) li--;
|
|
749
|
+
if (li < 0) {
|
|
750
|
+
grp.status = 'active';
|
|
751
|
+
} else {
|
|
752
|
+
var last = grp.members[li].msg;
|
|
753
|
+
grp.status = last.isError ? 'error' : 'done';
|
|
754
|
+
}
|
|
734
755
|
}
|
|
735
756
|
// A group whose passes are ALL continuations began before the loaded
|
|
736
757
|
// window; its earlier passes arrive when older history is paged in.
|
|
@@ -72,10 +72,33 @@ export function buildChatSystemPrompt(params: ChatSystemPromptParams): string {
|
|
|
72
72
|
: (g === 'public' || g === 'private' || g === 'authorized' || g === 'admin') ? `"${g}"`
|
|
73
73
|
: '"authorized"';
|
|
74
74
|
|
|
75
|
+
// ---- Grounding ---------------------------------------------------------
|
|
76
|
+
// The rules around this block were all written against the OPPOSITE mistake: a
|
|
77
|
+
// model that says "not found" before it has really looked. Read alone they push
|
|
78
|
+
// one way only (keep searching, never say it is absent), and nothing said the
|
|
79
|
+
// other half: state only what the data returned. Customers reported answers
|
|
80
|
+
// that were not in their data at all, which is the one thing this product must
|
|
81
|
+
// never do, so the other half is spelled out here.
|
|
82
|
+
//
|
|
83
|
+
// Three rules, each closing one way an invented answer gets out: a fact with no
|
|
84
|
+
// source, a "not found" replaced by general knowledge, and an inference worded
|
|
85
|
+
// as if the data said it. The last line keeps them from swallowing what is NOT
|
|
86
|
+
// a claim about the project's data (product help, a greeting, authored text).
|
|
87
|
+
// The upload suggestion follows canUpload for the same reason the About section
|
|
88
|
+
// does: a visitor who cannot upload must never be told to.
|
|
89
|
+
const groundingRules =
|
|
90
|
+
`ANSWER ONLY FROM THE DATA. Every fact you state about this project's data - a name, a number, a date, a status, what a document says, whether something exists - must come from one of four places: a tool result returned in THIS conversation, a file or image attached to it (including one you read through its attachment link), the project description at the end of this prompt, or what the user told you themselves in this chat. Your general knowledge, the open internet and a plausible guess are NOT sources. Never use them to answer a question about the user's data, to fill a gap in it, or to complete a record that came back partial. A value that is not in what you retrieved is unknown: say it is not in the data instead of supplying a likely one.
|
|
91
|
+
WHEN THE DATA DOES NOT HAVE IT, SAY SO. Once you have searched the way these rules require and nothing answers the question, tell the user plainly, in their language, that this project's data does not contain it, and say what you searched (the tables, files or terms) so they can tell missing data from a missed search. Do not answer from memory instead, and do not turn a missing answer into a general explanation of the topic. Offer what would let you answer it: ${canUpload === false
|
|
92
|
+
? `another name or file to look under, or asking the project's owner to add it`
|
|
93
|
+
: `the document to upload, or another name or file to look under`}.
|
|
94
|
+
KEEP WHAT THE DATA SAYS APART FROM WHAT YOU WORKED OUT. A total you computed, a comparison, a trend, an interpretation or a recommendation is yours, not the data's: label it as such ("calculated from...", "this suggests...") and name the rows or files it was made from. Never present an estimate, an assumption or an inference as something the data states.
|
|
95
|
+
These three rules govern every claim about the project's data. They do not limit the About BunnyQuery section (answer product questions from it), a greeting, or text the user explicitly asks you to write.`;
|
|
96
|
+
|
|
75
97
|
let systemPrompt = `
|
|
76
98
|
You are a dedicated assistant for the project ID: "${projectId}".
|
|
77
99
|
Scope: Only answer questions about this project and its data. Do not answer questions about other projects or topics unrelated to this project. When the user refers to "my database", "my data", or "my files", treat those as references to this project's database and file storage. The ONE exception is BunnyQuery itself - what this app is, what it can do, and how to use it - which is always in scope: answer it from the "About BunnyQuery" section at the end of this prompt.
|
|
78
100
|
Knowledge lookup: Before saying you don't know or that something isn't in the chat history, ALWAYS query this project's database through the available MCP tools to look for the answer. The user's data is the source of truth - the chat transcript is not. Only respond with "I don't know" or "I couldn't find that" after you have actually searched the project's data and come back empty.
|
|
101
|
+
${groundingRules}
|
|
79
102
|
NUMBERS FROM A SPREADSHEET: use queryGrid, never mental arithmetic over records. A total, a count, an average, a "how many mention X", a "which one is biggest" - all of those are computed server-side over EVERY row of the file and come back with the sheet, the row count and the row numbers they were made from. Records are a SAMPLE, and a sample added up is a confident wrong number. Quote the row count and the sheet alongside the figure so the reader can check it.
|
|
80
103
|
CALL queryGrid describe FIRST, before any figure. Workbooks routinely state the same money more than once: a detail sheet, then per-song, per-album and per-artist sheets that each re-total it, plus a summary sheet whose bottom row is the file total. Those look like four different answers and are one. describe names which sheets restate which, and which rows are totals. Pick ONE sheet, say which you picked, and never add figures across a sheet and its summary. If the reply carries a warning about restatement, repeat it to the user.
|
|
81
104
|
A FILE TOTAL IS NOT A ROW'S TOTAL. The biggest number on a summary sheet is the whole file, not the thing that was asked about. Before quoting any figure, check it is scoped to what the question named: filter by the column that identifies it and report how many rows matched.
|
package/src/engine/session.ts
CHANGED
|
@@ -1106,8 +1106,11 @@ export class ChatSession {
|
|
|
1106
1106
|
timers = [];
|
|
1107
1107
|
};
|
|
1108
1108
|
var stopBase = base && typeof base.stop === 'function' ? base.stop.bind(base) : null;
|
|
1109
|
+
// The wrapper's own resolve, kept so that stop() below can SETTLE it.
|
|
1110
|
+
var resolveRaced: ((v: any) => void) | null = null;
|
|
1109
1111
|
|
|
1110
1112
|
var raced: any = new Promise(function (resolve, reject) {
|
|
1113
|
+
resolveRaced = resolve;
|
|
1111
1114
|
base.then(function (res: any) {
|
|
1112
1115
|
if (settled) return;
|
|
1113
1116
|
settled = true; clearProbes(); resolve(res);
|
|
@@ -1140,10 +1143,21 @@ export class ChatSession {
|
|
|
1140
1143
|
});
|
|
1141
1144
|
|
|
1142
1145
|
// _trackPoll and the cancel path both reach for .stop, so the wrapper has to carry it.
|
|
1146
|
+
//
|
|
1147
|
+
// A stop SETTLES the wrapper, with the {status:'stopped'} shape the SDK's own
|
|
1148
|
+
// stop resolves with. It used to mark itself settled and then ignore the base
|
|
1149
|
+
// poll's stopped result in the handler above, so the wrapper never resolved at
|
|
1150
|
+
// all. Harmless for a queued turn (nothing awaits that chain to the end), but a
|
|
1151
|
+
// dispatch chain awaits THIS promise to clear state.sending and
|
|
1152
|
+
// pendingAgentRequests, so stopping a running turn's poll wedged every later
|
|
1153
|
+
// send onto the queued path for good. Resolving here, rather than waiting on
|
|
1154
|
+
// the base poll, also covers an older skapi-js whose poll cannot be stopped.
|
|
1143
1155
|
raced.stop = function () {
|
|
1156
|
+
if (settled) return;
|
|
1144
1157
|
settled = true;
|
|
1145
1158
|
clearProbes();
|
|
1146
|
-
if (stopBase) stopBase();
|
|
1159
|
+
if (stopBase) { try { stopBase(); } catch (e) { /* already gone */ } }
|
|
1160
|
+
if (resolveRaced) resolveRaced({ id: itemId, status: 'stopped' });
|
|
1147
1161
|
};
|
|
1148
1162
|
return raced;
|
|
1149
1163
|
}
|
|
@@ -1206,6 +1220,35 @@ export class ChatSession {
|
|
|
1206
1220
|
/** Live streams by server item id. One per in-flight streamed turn. */
|
|
1207
1221
|
private liveStreams: { [itemId: string]: LiveStreamState } = {};
|
|
1208
1222
|
|
|
1223
|
+
/** When skapi recorded a turn's response as having finished arriving, by server
|
|
1224
|
+
* item id. Read off the terminal envelope of a STREAMED turn (the only settle
|
|
1225
|
+
* that carries it, see _settleLiveStream) and used as the answer bubble's time
|
|
1226
|
+
* in place of this browser's clock. Entries are never removed: a settle reaches
|
|
1227
|
+
* here twice (the poll's onResponse, then the promise), and the value for an id
|
|
1228
|
+
* cannot change. */
|
|
1229
|
+
private _respondedAt: { [itemId: string]: number } = {};
|
|
1230
|
+
|
|
1231
|
+
private _noteRespondedAt(itemId: string, response: any): void {
|
|
1232
|
+
if (!itemId || !isCsrStatusEnvelope(response)) return;
|
|
1233
|
+
var at = Number(response.responded);
|
|
1234
|
+
if (!isFinite(at) || at <= 0) return;
|
|
1235
|
+
// Self-healing for a session built without the constructor (the engine tests
|
|
1236
|
+
// drive Object.create(ChatSession.prototype)), like typewriterQueue below.
|
|
1237
|
+
if (!this._respondedAt) this._respondedAt = {};
|
|
1238
|
+
this._respondedAt[itemId] = at;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/** The time a live answer bubble is stamped with: when skapi received the
|
|
1242
|
+
* response, if the settle told us, else now. "Now" is what every bubble got
|
|
1243
|
+
* before, and it is still right for a buffered turn, whose settle hands back the
|
|
1244
|
+
* destination's body and nothing of skapi's; a history load then replaces it
|
|
1245
|
+
* with the server's value. */
|
|
1246
|
+
private _replyStampFor(msg: ChatMessage | undefined): number {
|
|
1247
|
+
var id = msg ? msg._serverItemId : undefined;
|
|
1248
|
+
var at = (typeof id === 'string' && id && this._respondedAt) ? this._respondedAt[id] : undefined;
|
|
1249
|
+
return typeof at === 'number' ? at : wallClockNow();
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1209
1252
|
/**
|
|
1210
1253
|
* Open (or re-open) the live stream for `itemId`, or null when this poll must
|
|
1211
1254
|
* not carry one.
|
|
@@ -1458,6 +1501,10 @@ export class ChatSession {
|
|
|
1458
1501
|
private _settleLiveStream(st: LiveStreamState, response: any): any {
|
|
1459
1502
|
this._closeLiveStream(st, true);
|
|
1460
1503
|
if (!isCsrStatusEnvelope(response)) return response;
|
|
1504
|
+
// Before any return below, and for every terminal status: the bubble that
|
|
1505
|
+
// settles this turn is stamped from it whether it ends as an answer, an error
|
|
1506
|
+
// or a truncation.
|
|
1507
|
+
this._noteRespondedAt(st.id, response);
|
|
1461
1508
|
// RESOLVED only, and this is not caution, it is correctness. 'cancelled' is the
|
|
1462
1509
|
// envelope the user's own Stop produces, and _isCancelledPollResult downstream
|
|
1463
1510
|
// is what settles the turn as stopped: hand it an assembled body instead and a
|
|
@@ -2559,6 +2606,13 @@ export class ChatSession {
|
|
|
2559
2606
|
return response;
|
|
2560
2607
|
})
|
|
2561
2608
|
.then(function (response: any) {
|
|
2609
|
+
// Neither of these is an answer. A STOPPED poll is this client giving up
|
|
2610
|
+
// on the turn (cancelPendingReply), a CANCELLED envelope is the server
|
|
2611
|
+
// saying the row was cancelled from elsewhere (another tab or device).
|
|
2612
|
+
// Read as a provider body, both extract to nothing, and the turn was
|
|
2613
|
+
// stamped "No text response received from AI provider." and the cache
|
|
2614
|
+
// kept that as the answer to a question the user had stopped waiting on.
|
|
2615
|
+
if (isPollStopped(response) || self._isCancelledPollResult(response)) return { content: '', isError: false, cancelled: true };
|
|
2562
2616
|
if (isErrorResponseBody(response)) return { content: getErrorMessage(response), isError: true };
|
|
2563
2617
|
var answer = (params.aiPlatform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response));
|
|
2564
2618
|
answer = (answer || '').trim();
|
|
@@ -2571,6 +2625,14 @@ export class ChatSession {
|
|
|
2571
2625
|
// if the chatbox unmounted mid-request.
|
|
2572
2626
|
delete self.pendingAgentRequests[params.key];
|
|
2573
2627
|
if (dispatchItemId) self.historyItemPolls.delete(dispatchItemId);
|
|
2628
|
+
if (result.cancelled) {
|
|
2629
|
+
// Settle the turn as cancelled and write NO reply. When this client made
|
|
2630
|
+
// the cancel the bubbles are already settled (cancelPendingReply does it
|
|
2631
|
+
// before the stop resolves this chain) and this finds nothing to do; when
|
|
2632
|
+
// the cancel came from elsewhere, this is what settles them.
|
|
2633
|
+
if (dispatchItemId) self._settleCancelledTurn(dispatchItemId, params.key);
|
|
2634
|
+
return result;
|
|
2635
|
+
}
|
|
2574
2636
|
var reply: ChatMessage = { role: 'assistant', content: result.content, isError: result.isError };
|
|
2575
2637
|
if (dispatchItemId) reply._serverItemId = dispatchItemId;
|
|
2576
2638
|
|
|
@@ -3165,7 +3227,7 @@ export class ChatSession {
|
|
|
3165
3227
|
// When it ISN'T (unmounted / another project), dispatchAgentRequest has
|
|
3166
3228
|
// already replaced the pending bubble with the answer IN THE CACHE, so a
|
|
3167
3229
|
// later loadChatHistory renders it.
|
|
3168
|
-
Promise.resolve(run).catch(function () { }).then(function () {
|
|
3230
|
+
Promise.resolve(run).catch(function () { }).then(function (result: any) {
|
|
3169
3231
|
// Clear `sending` UNCONDITIONALLY. It is session-global (it gates
|
|
3170
3232
|
// isQueuedSend above, plus the platform/model pickers in the view), so
|
|
3171
3233
|
// leaving it set when the user navigated to another project wedged
|
|
@@ -3175,6 +3237,12 @@ export class ChatSession {
|
|
|
3175
3237
|
// dispatchAgentRequest has already written the answer into the cache
|
|
3176
3238
|
// under `key`, so a later loadChatHistory renders it.
|
|
3177
3239
|
self.state.sending = false;
|
|
3240
|
+
// A cancelled turn has NO reply to type. typewriteLatestReply reads the
|
|
3241
|
+
// latest settled assistant bubble out of the cache and types it into the
|
|
3242
|
+
// first pending placeholder on screen, and after a cancel that placeholder
|
|
3243
|
+
// belongs to the NEXT turn (the cancel promotes it), so running it here
|
|
3244
|
+
// would paint the previous answer under a question nobody has answered.
|
|
3245
|
+
if (result && result.cancelled) return;
|
|
3178
3246
|
if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
|
|
3179
3247
|
return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
|
|
3180
3248
|
});
|
|
@@ -3342,7 +3410,7 @@ export class ChatSession {
|
|
|
3342
3410
|
insertAtTarget(msg: ChatMessage, targetIdx: number): void {
|
|
3343
3411
|
// Error/direct replies land here rather than through the typewriter, so this
|
|
3344
3412
|
// is where they pick up their display time.
|
|
3345
|
-
if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts =
|
|
3413
|
+
if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = this._replyStampFor(msg);
|
|
3346
3414
|
// Overwrite only a placeholder that genuinely belongs to the turn above it.
|
|
3347
3415
|
// Two things now sit at this index that must never be replaced by a chat
|
|
3348
3416
|
// answer: a background "Indexing:" placeholder (a file's rows are inserted
|
|
@@ -3385,6 +3453,14 @@ export class ChatSession {
|
|
|
3385
3453
|
|
|
3386
3454
|
onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string, ownerKey?: string): void {
|
|
3387
3455
|
if (serverId) this.historyItemPolls.delete(serverId);
|
|
3456
|
+
// The row was cancelled while this poll was still running (from another tab,
|
|
3457
|
+
// or under an SDK whose poll could not be stopped): settle the turn as
|
|
3458
|
+
// cancelled and write no answer. Read as a reply, the envelope below became
|
|
3459
|
+
// "No text response received from AI provider."
|
|
3460
|
+
if (this._isCancelledPollResult(response)) {
|
|
3461
|
+
if (serverId) this._settleCancelledTurn(serverId, ownerKey);
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3388
3464
|
// This turn resolved while a DIFFERENT chat is on screen (the user moved to
|
|
3389
3465
|
// another project mid-flight). state.messages now belongs to that chat, and
|
|
3390
3466
|
// resolveQueuedUserBubble's positional fallbacks would happily hijack ITS
|
|
@@ -3579,6 +3655,186 @@ export class ChatSession {
|
|
|
3579
3655
|
});
|
|
3580
3656
|
}
|
|
3581
3657
|
|
|
3658
|
+
/**
|
|
3659
|
+
* Stop waiting for the reply to the chat turn that is running now, and move on.
|
|
3660
|
+
*
|
|
3661
|
+
* `msg` is the turn's pending ASSISTANT bubble (the dot-trail "Thinking"
|
|
3662
|
+
* placeholder), which is where both views draw the control. A turn's two bubbles
|
|
3663
|
+
* carry the same _serverItemId, so it is also how the request is found.
|
|
3664
|
+
* cancelQueuedMessage is the sibling for a turn still waiting in the queue and
|
|
3665
|
+
* takes the USER bubble; the two differ in what they are handed and in what has
|
|
3666
|
+
* to be unwound afterwards, not in what the server is asked.
|
|
3667
|
+
*
|
|
3668
|
+
* What ends the wait:
|
|
3669
|
+
* 1. csr-cancel marks the running row cancelled. The worker stops relaying and
|
|
3670
|
+
* never stores an answer for it, so the reply cannot turn up later on this
|
|
3671
|
+
* device or another, and history replays the turn as cancelled;
|
|
3672
|
+
* 2. the poll on it is stopped, which settles whichever chain was awaiting it
|
|
3673
|
+
* with a stopped result: dispatchAgentRequest's for an immediate send, the
|
|
3674
|
+
* queued chain for a promoted turn, the history poll after a reload. Each
|
|
3675
|
+
* reads a stopped or cancelled result as "no reply" and releases what it
|
|
3676
|
+
* holds (state.sending, pendingAgentRequests, historyItemPolls), which is
|
|
3677
|
+
* what lets the next send go out immediately instead of queueing;
|
|
3678
|
+
* 3. the bubbles settle at once, without waiting for that chain: the user
|
|
3679
|
+
* bubble takes its cancelled form, the placeholder goes, and the next queued
|
|
3680
|
+
* turn, if there is one, is promoted so its own "Thinking" appears.
|
|
3681
|
+
*
|
|
3682
|
+
* What it does NOT do: the provider call already in flight on the worker runs
|
|
3683
|
+
* to its end (a streamed one stops within about a second). A message sent
|
|
3684
|
+
* behind it on the same queue starts once the worker acks the cancelled row.
|
|
3685
|
+
*
|
|
3686
|
+
* Refused without a server id (the ack has not come back yet; the views disable
|
|
3687
|
+
* the control until it has) and while a cancel is already out. Indexing passes
|
|
3688
|
+
* are not this method's business: their Stop is cancelIndexingGroup, which ends
|
|
3689
|
+
* the file's whole chain.
|
|
3690
|
+
*/
|
|
3691
|
+
cancelPendingReply(msg: ChatMessage, idx: number): void {
|
|
3692
|
+
var self = this;
|
|
3693
|
+
var id = this.host.getIdentity();
|
|
3694
|
+
var serverId = msg._serverItemId;
|
|
3695
|
+
if (!serverId || msg._cancelling) return;
|
|
3696
|
+
if (msg.role !== 'assistant' || !msg.isPending || msg.isBackgroundTask) return;
|
|
3697
|
+
var platform = id.platform;
|
|
3698
|
+
if (platform !== 'claude' && platform !== 'openai') return;
|
|
3699
|
+
var url = platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
|
|
3700
|
+
var queueBase = id.userId || id.projectId;
|
|
3701
|
+
var queue = msg._useBgQueue ? bgIndexingQueueName(queueBase) : queueBase;
|
|
3702
|
+
var findPlaceholder = function () {
|
|
3703
|
+
return self.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.role === 'assistant' && !!m.isPending; });
|
|
3704
|
+
};
|
|
3705
|
+
// Same rule as cancelQueuedMessage: the rendered index is a hint, the server
|
|
3706
|
+
// id is the identity, and the role test keeps this off the user bubble that
|
|
3707
|
+
// shares the id.
|
|
3708
|
+
var at = (this.state.messages[idx] && this.state.messages[idx]._serverItemId === serverId && this.state.messages[idx].role === 'assistant')
|
|
3709
|
+
? idx
|
|
3710
|
+
: findPlaceholder();
|
|
3711
|
+
if (at !== -1) {
|
|
3712
|
+
this.state.messages[at] = Object.assign({}, this.state.messages[at], { _cancelling: true, _cancelError: undefined });
|
|
3713
|
+
}
|
|
3714
|
+
this.host.notify();
|
|
3715
|
+
var unmark = function (errMsg?: string) {
|
|
3716
|
+
var ci = findPlaceholder();
|
|
3717
|
+
if (ci === -1) return;
|
|
3718
|
+
self.state.messages[ci] = Object.assign({}, self.state.messages[ci], { _cancelling: false, _cancelError: errMsg });
|
|
3719
|
+
self.host.notify();
|
|
3720
|
+
};
|
|
3721
|
+
Promise.resolve(this.host.cancelRequest({
|
|
3722
|
+
url: url, method: 'POST', id: serverId, queue: queue, service: id.projectId, owner: id.owner,
|
|
3723
|
+
})).then(function (result: any) {
|
|
3724
|
+
if (result && result.removed) {
|
|
3725
|
+
// Stop asking about a row that will never answer, THEN settle the
|
|
3726
|
+
// bubbles. The stop resolves the awaiting chain with a stopped result,
|
|
3727
|
+
// and that chain's own settle then finds nothing left to do.
|
|
3728
|
+
self._stopPoll(serverId as string);
|
|
3729
|
+
self._settleCancelledTurn(serverId as string);
|
|
3730
|
+
return;
|
|
3731
|
+
}
|
|
3732
|
+
// Not removed: the row had already finished (or was already gone), so the
|
|
3733
|
+
// answer is on its way through the poll that is still attached. Nothing to
|
|
3734
|
+
// unwind and nothing to report: the user gets the reply they were waiting
|
|
3735
|
+
// for, a moment late.
|
|
3736
|
+
unmark(undefined);
|
|
3737
|
+
}).catch(function (err: any) {
|
|
3738
|
+
unmark(err && typeof err.message === 'string' && err.message ? err.message : 'Could not stop this request.');
|
|
3739
|
+
});
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
/** The cancelled form of a request bubble, with everything that keeps it in
|
|
3743
|
+
* place carried over: the file markers that hold an indexing pass in its
|
|
3744
|
+
* collapsed row, the chat it belongs to, its send time and its render key. */
|
|
3745
|
+
private _cancelledCopyOf(m: ChatMessage, itemId?: string): ChatMessage {
|
|
3746
|
+
var cancelled: ChatMessage = { role: 'user', content: m.content, isCancelled: true };
|
|
3747
|
+
if (itemId !== undefined) cancelled._serverItemId = itemId;
|
|
3748
|
+
else if (m._serverItemId !== undefined) cancelled._serverItemId = m._serverItemId;
|
|
3749
|
+
if (m.isBackgroundTask) cancelled.isBackgroundTask = true;
|
|
3750
|
+
if (m._indexFile) cancelled._indexFile = m._indexFile;
|
|
3751
|
+
if (m._useBgQueue) cancelled._useBgQueue = true;
|
|
3752
|
+
if (m._ownerKey !== undefined) cancelled._ownerKey = m._ownerKey;
|
|
3753
|
+
if (m._ts !== undefined) cancelled._ts = m._ts;
|
|
3754
|
+
if (m._localId !== undefined) cancelled._localId = m._localId;
|
|
3755
|
+
return cancelled;
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
/**
|
|
3759
|
+
* Settle a turn as cancelled wherever it lives, and let the queue move on.
|
|
3760
|
+
*
|
|
3761
|
+
* On the chat on screen: every pending placeholder carrying the id goes (a
|
|
3762
|
+
* history refetch can have re-mapped the running turn into a second one), the
|
|
3763
|
+
* request bubble takes its cancelled form, and the next queued turn is promoted
|
|
3764
|
+
* so the conversation does not sit on an "(In queue)" bubble with nothing
|
|
3765
|
+
* running. For a chat that is NOT on screen (the user moved on while the turn
|
|
3766
|
+
* ran, and it was cancelled from elsewhere) the same edit is made in that chat's
|
|
3767
|
+
* history cache, which is what the next visit renders.
|
|
3768
|
+
*
|
|
3769
|
+
* Idempotent on purpose: a cancel this client made settles the bubbles at once,
|
|
3770
|
+
* and the chain that was awaiting the poll settles again a moment later when the
|
|
3771
|
+
* stop resolves it. The second pass finds nothing to do. Deliberately NOT
|
|
3772
|
+
* _removeStrayPendingAssistants: by then the placeholder on screen is the freshly
|
|
3773
|
+
* promoted turn's, whose user bubble is pending, and the sweep would take it.
|
|
3774
|
+
*/
|
|
3775
|
+
private _settleCancelledTurn(itemId: string, ownerKey?: string): void {
|
|
3776
|
+
if (!itemId) return;
|
|
3777
|
+
if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
|
|
3778
|
+
this._applyCancelToCache(ownerKey, itemId);
|
|
3779
|
+
return;
|
|
3780
|
+
}
|
|
3781
|
+
var changed = false, wasBg = false;
|
|
3782
|
+
for (var i = this.state.messages.length - 1; i >= 0; i--) {
|
|
3783
|
+
var m = this.state.messages[i];
|
|
3784
|
+
if (!m || m._serverItemId !== itemId) continue;
|
|
3785
|
+
if (m.role === 'assistant' && m.isPending) { this.state.messages.splice(i, 1); changed = true; continue; }
|
|
3786
|
+
if (m.role === 'user' && !m.isCancelled) {
|
|
3787
|
+
if (m.isBackgroundTask) wasBg = true;
|
|
3788
|
+
this.state.messages[i] = this._cancelledCopyOf(m, itemId);
|
|
3789
|
+
changed = true;
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
this.cancelledServerIds.delete(itemId);
|
|
3793
|
+
if (!changed) return;
|
|
3794
|
+
if (wasBg) this.promoteNextBgQueuedToRunning(); else this.promoteNextQueuedToRunning();
|
|
3795
|
+
this.updateHistoryCache();
|
|
3796
|
+
this.host.notify();
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
/**
|
|
3800
|
+
* _settleCancelledTurn for a chat that is not on screen: the same edit, made in
|
|
3801
|
+
* its history cache. Matched by id where the cached bubbles carry one; an
|
|
3802
|
+
* off-chat send caches its pair before the ack (see dispatchComposedMessage), so
|
|
3803
|
+
* the fallback is positional, exactly as _applyReplyToCache's is: the trailing
|
|
3804
|
+
* pending placeholder, and the last request bubble still marked in flight.
|
|
3805
|
+
*/
|
|
3806
|
+
private _applyCancelToCache(key: string, itemId: string): void {
|
|
3807
|
+
var existing = key ? this.aiChatHistoryCache[key] : undefined;
|
|
3808
|
+
if (!existing) return;
|
|
3809
|
+
var msgs = existing.messages.slice();
|
|
3810
|
+
var changed = false;
|
|
3811
|
+
var pIdx = -1, uIdx = -1;
|
|
3812
|
+
for (var i = msgs.length - 1; i >= 0; i--) {
|
|
3813
|
+
var m = msgs[i];
|
|
3814
|
+
if (!m || m._serverItemId !== itemId) continue;
|
|
3815
|
+
if (m.role === 'assistant' && m.isPending) { msgs.splice(i, 1); changed = true; pIdx = i; }
|
|
3816
|
+
else if (m.role === 'user' && !m.isCancelled && uIdx === -1) uIdx = i;
|
|
3817
|
+
}
|
|
3818
|
+
if (pIdx === -1) {
|
|
3819
|
+
for (var p = msgs.length - 1; p >= 0; p--) {
|
|
3820
|
+
var pm = msgs[p];
|
|
3821
|
+
if (!pm || !pm.isPending || pm.role !== 'assistant' || pm.isBackgroundTask || pm._serverItemId !== undefined) continue;
|
|
3822
|
+
msgs.splice(p, 1); changed = true; break;
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
if (uIdx === -1) {
|
|
3826
|
+
for (var u = msgs.length - 1; u >= 0; u--) {
|
|
3827
|
+
var um = msgs[u];
|
|
3828
|
+
if (!um || um.role !== 'user' || um.isBackgroundTask || um._serverItemId !== undefined || um._stageId) continue;
|
|
3829
|
+
if (!(um.isPendingQueued || um.isPendingInProcess || um.isSendingToServer)) continue;
|
|
3830
|
+
uIdx = u; break;
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
if (uIdx !== -1) { msgs[uIdx] = this._cancelledCopyOf(msgs[uIdx], itemId); changed = true; }
|
|
3834
|
+
if (!changed) return;
|
|
3835
|
+
this.aiChatHistoryCache[key] = { messages: msgs, endOfList: existing.endOfList, startKeyHistory: existing.startKeyHistory };
|
|
3836
|
+
}
|
|
3837
|
+
|
|
3582
3838
|
/**
|
|
3583
3839
|
* Stop indexing a file, from its collapsed row — every pass at once, not just
|
|
3584
3840
|
* the bubble the user happens to see.
|
|
@@ -3679,6 +3935,16 @@ export class ChatSession {
|
|
|
3679
3935
|
// server-cancelled by _applyIndexCancellations on the next drain, which is
|
|
3680
3936
|
// also what makes the worker stop enqueueing windows.
|
|
3681
3937
|
this._adoptWorkerIndexingPasses(0);
|
|
3938
|
+
// And every pass of this file the client ALREADY knows to be live, now, not on
|
|
3939
|
+
// the next drain. `cancellableIds` below is the row's own list, and it leaves
|
|
3940
|
+
// out an in-flight pass that a later pass has already outlived - a rule written
|
|
3941
|
+
// for one pass at a time, which a PDF no longer is: the worker runs several
|
|
3942
|
+
// windows of one file at once and they settle out of order, so the row's list
|
|
3943
|
+
// can be empty while eight passes are running. The sweep reads the key just
|
|
3944
|
+
// recorded above and cancels each known pending pass once (it skips one already
|
|
3945
|
+
// being cancelled, so the loop below cannot cancel a bubble twice). Server-side,
|
|
3946
|
+
// one cancelled window stops the whole document.
|
|
3947
|
+
this._sweepCancelledIndexing();
|
|
3682
3948
|
var ids = group.cancellableIds || [];
|
|
3683
3949
|
if (!ids.length) { this.host.notify(); return; }
|
|
3684
3950
|
ids.forEach(function (serverId) {
|
|
@@ -3836,9 +4102,11 @@ export class ChatSession {
|
|
|
3836
4102
|
// Stamp the reply bubble's display time as it starts revealing. This is the
|
|
3837
4103
|
// single chokepoint every typed reply (immediate, queued, and bg-resolution)
|
|
3838
4104
|
// passes through, so it is where a live reply gets its "when it arrived"
|
|
3839
|
-
// timestamp; a history reload later replaces it with the server
|
|
4105
|
+
// timestamp; a history reload later replaces it with the server's own.
|
|
4106
|
+
// _replyStampFor prefers the time skapi received the response when the settle
|
|
4107
|
+
// reported one (a streamed turn), over this browser's clock.
|
|
3840
4108
|
var target = this.state.messages[idx];
|
|
3841
|
-
if (target && target._ts === undefined) target._ts =
|
|
4109
|
+
if (target && target._ts === undefined) target._ts = this._replyStampFor(target);
|
|
3842
4110
|
// SELF-HEALING, because this is now reached from the live paint path too. The
|
|
3843
4111
|
// queue is a class field, so it is only initialised by the constructor; any
|
|
3844
4112
|
// caller holding a ChatSession built another way (the engine tests drive
|
|
@@ -4016,9 +4284,12 @@ export class ChatSession {
|
|
|
4016
4284
|
if (this.state.messages.some(function (m) { return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue; })) return Promise.resolve();
|
|
4017
4285
|
this.state.sending = true;
|
|
4018
4286
|
this.host.scrollToBottomIfSticky(true);
|
|
4019
|
-
return Promise.resolve(pending).catch(function () { }).then(function () {
|
|
4287
|
+
return Promise.resolve(pending).catch(function () { }).then(function (result: any) {
|
|
4020
4288
|
if (token !== self.state.gateRefreshToken) return;
|
|
4021
4289
|
self.state.sending = false;
|
|
4290
|
+
// Same guard as the immediate-send settle: a cancelled turn has no reply
|
|
4291
|
+
// to type, and the placeholder on screen may be the next turn's by now.
|
|
4292
|
+
if (result && result.cancelled) return;
|
|
4022
4293
|
return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
|
|
4023
4294
|
});
|
|
4024
4295
|
}
|
package/styles/chat.css
CHANGED
|
@@ -83,6 +83,28 @@
|
|
|
83
83
|
.bq-message.is-assistant:not(.is-error):not(.is-cancelled):not(.bq-index-pass) .bq-bubble:has(> .bq-loader) > .bq-loader {
|
|
84
84
|
vertical-align: middle;
|
|
85
85
|
}
|
|
86
|
+
/* The dot-trail cycles '.', '..', '...', so left to itself it is a box that
|
|
87
|
+
changes width three times every 1.2s: the bubble pulsed with it, and the
|
|
88
|
+
stop-waiting control that follows the dots moved each time, which made it hard
|
|
89
|
+
to hit. Inside a bubble the loader therefore RESERVES the width of its widest
|
|
90
|
+
frame: a hidden '...' sets the box and the animated dots are laid over it from
|
|
91
|
+
the left. Sized by the text itself rather than by a length, so it is right in
|
|
92
|
+
any font and at any letter-spacing. Scoped to bubbles on purpose: a loader that
|
|
93
|
+
trails a label ("Saving...") has to keep hugging its text. */
|
|
94
|
+
.bq-bubble > .bq-loader {
|
|
95
|
+
position: relative;
|
|
96
|
+
display: inline-block;
|
|
97
|
+
white-space: nowrap;
|
|
98
|
+
}
|
|
99
|
+
.bq-bubble > .bq-loader::before {
|
|
100
|
+
content: '...';
|
|
101
|
+
visibility: hidden;
|
|
102
|
+
}
|
|
103
|
+
.bq-bubble > .bq-loader::after {
|
|
104
|
+
position: absolute;
|
|
105
|
+
left: 0;
|
|
106
|
+
top: 0;
|
|
107
|
+
}
|
|
86
108
|
|
|
87
109
|
.bq-message.is-error .bq-bubble {
|
|
88
110
|
background: var(--bq-danger-bg);
|
|
@@ -95,6 +117,14 @@
|
|
|
95
117
|
color: var(--bq-danger);
|
|
96
118
|
opacity: 0.65;
|
|
97
119
|
}
|
|
120
|
+
/* A failed pass the worker has already sent again: real, but not the file's
|
|
121
|
+
outcome (the replacement pass is), so it is told in the muted voice of a
|
|
122
|
+
note rather than the red of a dead end. */
|
|
123
|
+
.bq-message.is-error.is-retried .bq-bubble {
|
|
124
|
+
background: var(--bq-paper);
|
|
125
|
+
border-color: var(--bq-line);
|
|
126
|
+
color: var(--bq-muted);
|
|
127
|
+
}
|
|
98
128
|
.bq-message.is-pending-older .bq-bubble {
|
|
99
129
|
background: var(--bq-warning-bg);
|
|
100
130
|
border-color: var(--bq-warning-border);
|
|
@@ -153,6 +183,35 @@
|
|
|
153
183
|
}
|
|
154
184
|
.bq-cancel-queue-btn:hover:not(.is-disabled) { background: var(--bq-warning-bg); color: var(--bq-warning); }
|
|
155
185
|
.bq-cancel-queue-btn.is-disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
|
|
186
|
+
/* Stop waiting for a reply: sits INLINE after the dot-trail in a chat turn's own
|
|
187
|
+
"Thinking" bubble, where the bunny is inline too (:has(> .bq-loader) above).
|
|
188
|
+
Same face as the queued-message × but placed differently: that one floats to
|
|
189
|
+
its bubble's corner, this one simply follows the dots. Muted at rest so the
|
|
190
|
+
dots stay the thing the eye lands on; hover changes opacity only, never its
|
|
191
|
+
colour. Shared by both chatboxes. */
|
|
192
|
+
.bq-cancel-wait-btn {
|
|
193
|
+
display: inline-flex;
|
|
194
|
+
align-items: center;
|
|
195
|
+
justify-content: center;
|
|
196
|
+
vertical-align: middle;
|
|
197
|
+
margin-left: 0.55rem;
|
|
198
|
+
width: 1.1rem;
|
|
199
|
+
height: 1.1rem;
|
|
200
|
+
min-height: 0;
|
|
201
|
+
padding: 0;
|
|
202
|
+
border: 1px solid var(--bq-line);
|
|
203
|
+
background: transparent;
|
|
204
|
+
color: var(--bq-muted);
|
|
205
|
+
font-size: 0.85rem;
|
|
206
|
+
line-height: 1;
|
|
207
|
+
cursor: pointer;
|
|
208
|
+
box-shadow: none;
|
|
209
|
+
border-radius: 0;
|
|
210
|
+
flex-shrink: 0;
|
|
211
|
+
opacity: 0.7;
|
|
212
|
+
}
|
|
213
|
+
.bq-cancel-wait-btn:hover:not(.is-disabled) { opacity: 1; }
|
|
214
|
+
.bq-cancel-wait-btn.is-disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
|
|
156
215
|
|
|
157
216
|
/* ---- unfinalized streamed answer (ask for it) ----------------------------
|
|
158
217
|
A turn whose answer was streamed and never finalized keeps its bytes in the
|