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/README.md
CHANGED
|
@@ -34,6 +34,11 @@ you can build your own chat UI on top of it. See
|
|
|
34
34
|
across as many passes as it takes. A file's passes collapse into a single
|
|
35
35
|
status row in the chat that can be expanded, and stopped: "Stop" cancels every
|
|
36
36
|
queued and running pass at once and ends the continuation chain.
|
|
37
|
+
- **Stop waiting**: a reply that is taking too long can be given up on. The
|
|
38
|
+
"Thinking" bubble carries a small x that cancels the running request on the
|
|
39
|
+
server, marks the question as cancelled, and lets the next message go out
|
|
40
|
+
at once instead of queueing behind it. A message still waiting in the queue
|
|
41
|
+
has the same control on its own bubble.
|
|
37
42
|
- **Attachment parser plugins**: register a client-side parser so the widget
|
|
38
43
|
extracts text in the browser from formats the model can't otherwise read, and
|
|
39
44
|
indexes it directly. See [Attachment parser plugins](#attachment-parser-plugins).
|
package/bunnyquery.css
CHANGED
|
@@ -1175,6 +1175,28 @@
|
|
|
1175
1175
|
.bq-message.is-assistant:not(.is-error):not(.is-cancelled):not(.bq-index-pass) .bq-bubble:has(> .bq-loader) > .bq-loader {
|
|
1176
1176
|
vertical-align: middle;
|
|
1177
1177
|
}
|
|
1178
|
+
/* The dot-trail cycles '.', '..', '...', so left to itself it is a box that
|
|
1179
|
+
changes width three times every 1.2s: the bubble pulsed with it, and the
|
|
1180
|
+
stop-waiting control that follows the dots moved each time, which made it hard
|
|
1181
|
+
to hit. Inside a bubble the loader therefore RESERVES the width of its widest
|
|
1182
|
+
frame: a hidden '...' sets the box and the animated dots are laid over it from
|
|
1183
|
+
the left. Sized by the text itself rather than by a length, so it is right in
|
|
1184
|
+
any font and at any letter-spacing. Scoped to bubbles on purpose: a loader that
|
|
1185
|
+
trails a label ("Saving...") has to keep hugging its text. */
|
|
1186
|
+
.bq-bubble > .bq-loader {
|
|
1187
|
+
position: relative;
|
|
1188
|
+
display: inline-block;
|
|
1189
|
+
white-space: nowrap;
|
|
1190
|
+
}
|
|
1191
|
+
.bq-bubble > .bq-loader::before {
|
|
1192
|
+
content: '...';
|
|
1193
|
+
visibility: hidden;
|
|
1194
|
+
}
|
|
1195
|
+
.bq-bubble > .bq-loader::after {
|
|
1196
|
+
position: absolute;
|
|
1197
|
+
left: 0;
|
|
1198
|
+
top: 0;
|
|
1199
|
+
}
|
|
1178
1200
|
|
|
1179
1201
|
.bq-message.is-error .bq-bubble {
|
|
1180
1202
|
background: var(--bq-danger-bg);
|
|
@@ -1187,6 +1209,14 @@
|
|
|
1187
1209
|
color: var(--bq-danger);
|
|
1188
1210
|
opacity: 0.65;
|
|
1189
1211
|
}
|
|
1212
|
+
/* A failed pass the worker has already sent again: real, but not the file's
|
|
1213
|
+
outcome (the replacement pass is), so it is told in the muted voice of a
|
|
1214
|
+
note rather than the red of a dead end. */
|
|
1215
|
+
.bq-message.is-error.is-retried .bq-bubble {
|
|
1216
|
+
background: var(--bq-paper);
|
|
1217
|
+
border-color: var(--bq-line);
|
|
1218
|
+
color: var(--bq-muted);
|
|
1219
|
+
}
|
|
1190
1220
|
.bq-message.is-pending-older .bq-bubble {
|
|
1191
1221
|
background: var(--bq-warning-bg);
|
|
1192
1222
|
border-color: var(--bq-warning-border);
|
|
@@ -1245,6 +1275,35 @@
|
|
|
1245
1275
|
}
|
|
1246
1276
|
.bq-cancel-queue-btn:hover:not(.is-disabled) { background: var(--bq-warning-bg); color: var(--bq-warning); }
|
|
1247
1277
|
.bq-cancel-queue-btn.is-disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
|
|
1278
|
+
/* Stop waiting for a reply: sits INLINE after the dot-trail in a chat turn's own
|
|
1279
|
+
"Thinking" bubble, where the bunny is inline too (:has(> .bq-loader) above).
|
|
1280
|
+
Same face as the queued-message × but placed differently: that one floats to
|
|
1281
|
+
its bubble's corner, this one simply follows the dots. Muted at rest so the
|
|
1282
|
+
dots stay the thing the eye lands on; hover changes opacity only, never its
|
|
1283
|
+
colour. Shared by both chatboxes. */
|
|
1284
|
+
.bq-cancel-wait-btn {
|
|
1285
|
+
display: inline-flex;
|
|
1286
|
+
align-items: center;
|
|
1287
|
+
justify-content: center;
|
|
1288
|
+
vertical-align: middle;
|
|
1289
|
+
margin-left: 0.55rem;
|
|
1290
|
+
width: 1.1rem;
|
|
1291
|
+
height: 1.1rem;
|
|
1292
|
+
min-height: 0;
|
|
1293
|
+
padding: 0;
|
|
1294
|
+
border: 1px solid var(--bq-line);
|
|
1295
|
+
background: transparent;
|
|
1296
|
+
color: var(--bq-muted);
|
|
1297
|
+
font-size: 0.85rem;
|
|
1298
|
+
line-height: 1;
|
|
1299
|
+
cursor: pointer;
|
|
1300
|
+
box-shadow: none;
|
|
1301
|
+
border-radius: 0;
|
|
1302
|
+
flex-shrink: 0;
|
|
1303
|
+
opacity: 0.7;
|
|
1304
|
+
}
|
|
1305
|
+
.bq-cancel-wait-btn:hover:not(.is-disabled) { opacity: 1; }
|
|
1306
|
+
.bq-cancel-wait-btn.is-disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
|
|
1248
1307
|
|
|
1249
1308
|
/* ---- unfinalized streamed answer (ask for it) ----------------------------
|
|
1250
1309
|
A turn whose answer was streamed and never finalized keeps its bytes in the
|
package/bunnyquery.js
CHANGED
|
@@ -352,10 +352,15 @@ Extracted content of attached office files (read inline below; do NOT fetch thei
|
|
|
352
352
|
const { projectId, serviceName, serviceDescription, greeting, canUpload} = params;
|
|
353
353
|
const g = params.indexAccessGroup;
|
|
354
354
|
const indexGroupLiteral = typeof g === "number" ? String(g) : g === "public" || g === "private" || g === "authorized" || g === "admin" ? `"${g}"` : '"authorized"';
|
|
355
|
+
const groundingRules = `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.
|
|
356
|
+
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 ? `another name or file to look under, or asking the project's owner to add it` : `the document to upload, or another name or file to look under`}.
|
|
357
|
+
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.
|
|
358
|
+
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.`;
|
|
355
359
|
let systemPrompt = `
|
|
356
360
|
You are a dedicated assistant for the project ID: "${projectId}".
|
|
357
361
|
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.
|
|
358
362
|
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.
|
|
363
|
+
${groundingRules}
|
|
359
364
|
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.
|
|
360
365
|
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.
|
|
361
366
|
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.
|
|
@@ -619,6 +624,11 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
619
624
|
}
|
|
620
625
|
return "Something went wrong.";
|
|
621
626
|
}
|
|
627
|
+
function isRetriedFailure(input) {
|
|
628
|
+
var envErr = csrEnvelopeError(input);
|
|
629
|
+
if (envErr !== void 0) input = envErr;
|
|
630
|
+
return !!input && typeof input === "object" && input.retried === true;
|
|
631
|
+
}
|
|
622
632
|
function isErrorResponseBody(response) {
|
|
623
633
|
var envErr = csrEnvelopeError(response);
|
|
624
634
|
if (envErr !== void 0) response = envErr;
|
|
@@ -3106,8 +3116,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
3106
3116
|
var createdTs = Number(item && item.created);
|
|
3107
3117
|
var updatedTs = Number(item && item.updated);
|
|
3108
3118
|
var executedTs = Number(item && item.executed);
|
|
3119
|
+
var respondedTs = Number(item && item.responded);
|
|
3109
3120
|
var userTs = isFinite(createdTs) && createdTs > 0 ? createdTs : isFinite(updatedTs) && updatedTs > 0 ? updatedTs : void 0;
|
|
3110
|
-
var replyTs = isFinite(updatedTs) && updatedTs > 0 ? updatedTs : isFinite(createdTs) && createdTs > 0 ? createdTs : void 0;
|
|
3121
|
+
var replyTs = isFinite(respondedTs) && respondedTs > 0 ? respondedTs : isFinite(updatedTs) && updatedTs > 0 ? updatedTs : isFinite(createdTs) && createdTs > 0 ? createdTs : void 0;
|
|
3111
3122
|
if (userText) {
|
|
3112
3123
|
var displayContent;
|
|
3113
3124
|
var indexFile = void 0;
|
|
@@ -3154,6 +3165,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
3154
3165
|
mapped.push(ph);
|
|
3155
3166
|
} else if (isQueued) ; else if (isErrorResponse) {
|
|
3156
3167
|
var em = { role: "assistant", content: getErrorMessage(response), isError: true };
|
|
3168
|
+
if (isRetriedFailure(response)) em.isRetried = true;
|
|
3157
3169
|
if (item._fromBgChain) em._fromBgChain = true;
|
|
3158
3170
|
if (item._isBgTask) em.isBackgroundTask = true;
|
|
3159
3171
|
if (serverItemId !== void 0) em._serverItemId = serverItemId;
|
|
@@ -3733,6 +3745,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
3733
3745
|
// the request budget MAX_CONCURRENT_BG_POLLS exists to protect.
|
|
3734
3746
|
/** Live streams by server item id. One per in-flight streamed turn. */
|
|
3735
3747
|
this.liveStreams = {};
|
|
3748
|
+
/** When skapi recorded a turn's response as having finished arriving, by server
|
|
3749
|
+
* item id. Read off the terminal envelope of a STREAMED turn (the only settle
|
|
3750
|
+
* that carries it, see _settleLiveStream) and used as the answer bubble's time
|
|
3751
|
+
* in place of this browser's clock. Entries are never removed: a settle reaches
|
|
3752
|
+
* here twice (the poll's onResponse, then the promise), and the value for an id
|
|
3753
|
+
* cannot change. */
|
|
3754
|
+
this._respondedAt = {};
|
|
3736
3755
|
// ─── compact-stub hydration ─────────────────────────────────────────────
|
|
3737
3756
|
// Split-fetch bg pages arrive as label stubs (no bodies). When the user
|
|
3738
3757
|
// expands a row, the real reply text is fetched per item (csr-poll point
|
|
@@ -4182,7 +4201,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
4182
4201
|
timers = [];
|
|
4183
4202
|
};
|
|
4184
4203
|
var stopBase = base && typeof base.stop === "function" ? base.stop.bind(base) : null;
|
|
4204
|
+
var resolveRaced = null;
|
|
4185
4205
|
var raced = new Promise(function(resolve, reject) {
|
|
4206
|
+
resolveRaced = resolve;
|
|
4186
4207
|
base.then(function(res) {
|
|
4187
4208
|
if (settled) return;
|
|
4188
4209
|
settled = true;
|
|
@@ -4223,9 +4244,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
4223
4244
|
});
|
|
4224
4245
|
});
|
|
4225
4246
|
raced.stop = function() {
|
|
4247
|
+
if (settled) return;
|
|
4226
4248
|
settled = true;
|
|
4227
4249
|
clearProbes();
|
|
4228
|
-
if (stopBase)
|
|
4250
|
+
if (stopBase) {
|
|
4251
|
+
try {
|
|
4252
|
+
stopBase();
|
|
4253
|
+
} catch (e) {
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
if (resolveRaced) resolveRaced({ id: itemId, status: "stopped" });
|
|
4229
4257
|
};
|
|
4230
4258
|
return raced;
|
|
4231
4259
|
}
|
|
@@ -4249,6 +4277,23 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
4249
4277
|
});
|
|
4250
4278
|
return n;
|
|
4251
4279
|
}
|
|
4280
|
+
_noteRespondedAt(itemId, response) {
|
|
4281
|
+
if (!itemId || !isCsrStatusEnvelope(response)) return;
|
|
4282
|
+
var at = Number(response.responded);
|
|
4283
|
+
if (!isFinite(at) || at <= 0) return;
|
|
4284
|
+
if (!this._respondedAt) this._respondedAt = {};
|
|
4285
|
+
this._respondedAt[itemId] = at;
|
|
4286
|
+
}
|
|
4287
|
+
/** The time a live answer bubble is stamped with: when skapi received the
|
|
4288
|
+
* response, if the settle told us, else now. "Now" is what every bubble got
|
|
4289
|
+
* before, and it is still right for a buffered turn, whose settle hands back the
|
|
4290
|
+
* destination's body and nothing of skapi's; a history load then replaces it
|
|
4291
|
+
* with the server's value. */
|
|
4292
|
+
_replyStampFor(msg) {
|
|
4293
|
+
var id = msg ? msg._serverItemId : void 0;
|
|
4294
|
+
var at = typeof id === "string" && id && this._respondedAt ? this._respondedAt[id] : void 0;
|
|
4295
|
+
return typeof at === "number" ? at : wallClockNow();
|
|
4296
|
+
}
|
|
4252
4297
|
/**
|
|
4253
4298
|
* Open (or re-open) the live stream for `itemId`, or null when this poll must
|
|
4254
4299
|
* not carry one.
|
|
@@ -4438,6 +4483,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
4438
4483
|
_settleLiveStream(st, response) {
|
|
4439
4484
|
this._closeLiveStream(st, true);
|
|
4440
4485
|
if (!isCsrStatusEnvelope(response)) return response;
|
|
4486
|
+
this._noteRespondedAt(st.id, response);
|
|
4441
4487
|
if (response.status !== "resolved") return response;
|
|
4442
4488
|
if (!this._mayFinalize(st)) this._rec().incomplete[st.id] = true;
|
|
4443
4489
|
if (st.finalBody == null) return response;
|
|
@@ -5238,6 +5284,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
5238
5284
|
}
|
|
5239
5285
|
return response;
|
|
5240
5286
|
}).then(function(response) {
|
|
5287
|
+
if (isPollStopped(response) || self._isCancelledPollResult(response)) return { content: "", isError: false, cancelled: true };
|
|
5241
5288
|
if (isErrorResponseBody(response)) return { content: getErrorMessage(response), isError: true };
|
|
5242
5289
|
var answer = params.aiPlatform === "openai" ? extractOpenAIText(response) : extractClaudeText(response);
|
|
5243
5290
|
answer = (answer || "").trim();
|
|
@@ -5247,6 +5294,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
5247
5294
|
}).then(function(result) {
|
|
5248
5295
|
delete self.pendingAgentRequests[params.key];
|
|
5249
5296
|
if (dispatchItemId) self.historyItemPolls.delete(dispatchItemId);
|
|
5297
|
+
if (result.cancelled) {
|
|
5298
|
+
if (dispatchItemId) self._settleCancelledTurn(dispatchItemId, params.key);
|
|
5299
|
+
return result;
|
|
5300
|
+
}
|
|
5250
5301
|
var reply = { role: "assistant", content: result.content, isError: result.isError };
|
|
5251
5302
|
if (dispatchItemId) reply._serverItemId = dispatchItemId;
|
|
5252
5303
|
self._applyReplyToCache(params.key, reply, dispatchItemId);
|
|
@@ -5725,8 +5776,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
5725
5776
|
}
|
|
5726
5777
|
});
|
|
5727
5778
|
Promise.resolve(run).catch(function() {
|
|
5728
|
-
}).then(function() {
|
|
5779
|
+
}).then(function(result) {
|
|
5729
5780
|
self.state.sending = false;
|
|
5781
|
+
if (result && result.cancelled) return;
|
|
5730
5782
|
if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
|
|
5731
5783
|
return Promise.resolve(self.typewriteLatestReply(key)).then(function() {
|
|
5732
5784
|
self.host.scrollToBottomIfSticky(true);
|
|
@@ -5873,7 +5925,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
5873
5925
|
return thinkingIdx !== -1 ? thinkingIdx : userIdx >= 0 ? userIdx + 1 : -1;
|
|
5874
5926
|
}
|
|
5875
5927
|
insertAtTarget(msg, targetIdx) {
|
|
5876
|
-
if (msg && msg.role === "assistant" && msg._ts === void 0) msg._ts =
|
|
5928
|
+
if (msg && msg.role === "assistant" && msg._ts === void 0) msg._ts = this._replyStampFor(msg);
|
|
5877
5929
|
var tgt = targetIdx >= 0 ? this.state.messages[targetIdx] : void 0;
|
|
5878
5930
|
var replaceable = !!tgt && !!tgt.isPending && !tgt.isBackgroundTask && this._isOwnPlaceholderOf(targetIdx, this._owningUserIndex(targetIdx));
|
|
5879
5931
|
if (replaceable) this.state.messages[targetIdx] = msg;
|
|
@@ -5905,6 +5957,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
5905
5957
|
}
|
|
5906
5958
|
onQueuedSendResponse(_composed, response, platform, serverId, ownerKey) {
|
|
5907
5959
|
if (serverId) this.historyItemPolls.delete(serverId);
|
|
5960
|
+
if (this._isCancelledPollResult(response)) {
|
|
5961
|
+
if (serverId) this._settleCancelledTurn(serverId, ownerKey);
|
|
5962
|
+
return;
|
|
5963
|
+
}
|
|
5908
5964
|
if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
|
|
5909
5965
|
var offReply = isErrorResponseBody(response) ? { role: "assistant", content: getErrorMessage(response), isError: true } : { role: "assistant", content: ((platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "").trim() || "No text response received from AI provider." };
|
|
5910
5966
|
this._applyReplyToCache(ownerKey, offReply, serverId);
|
|
@@ -6104,6 +6160,191 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
6104
6160
|
}
|
|
6105
6161
|
});
|
|
6106
6162
|
}
|
|
6163
|
+
/**
|
|
6164
|
+
* Stop waiting for the reply to the chat turn that is running now, and move on.
|
|
6165
|
+
*
|
|
6166
|
+
* `msg` is the turn's pending ASSISTANT bubble (the dot-trail "Thinking"
|
|
6167
|
+
* placeholder), which is where both views draw the control. A turn's two bubbles
|
|
6168
|
+
* carry the same _serverItemId, so it is also how the request is found.
|
|
6169
|
+
* cancelQueuedMessage is the sibling for a turn still waiting in the queue and
|
|
6170
|
+
* takes the USER bubble; the two differ in what they are handed and in what has
|
|
6171
|
+
* to be unwound afterwards, not in what the server is asked.
|
|
6172
|
+
*
|
|
6173
|
+
* What ends the wait:
|
|
6174
|
+
* 1. csr-cancel marks the running row cancelled. The worker stops relaying and
|
|
6175
|
+
* never stores an answer for it, so the reply cannot turn up later on this
|
|
6176
|
+
* device or another, and history replays the turn as cancelled;
|
|
6177
|
+
* 2. the poll on it is stopped, which settles whichever chain was awaiting it
|
|
6178
|
+
* with a stopped result: dispatchAgentRequest's for an immediate send, the
|
|
6179
|
+
* queued chain for a promoted turn, the history poll after a reload. Each
|
|
6180
|
+
* reads a stopped or cancelled result as "no reply" and releases what it
|
|
6181
|
+
* holds (state.sending, pendingAgentRequests, historyItemPolls), which is
|
|
6182
|
+
* what lets the next send go out immediately instead of queueing;
|
|
6183
|
+
* 3. the bubbles settle at once, without waiting for that chain: the user
|
|
6184
|
+
* bubble takes its cancelled form, the placeholder goes, and the next queued
|
|
6185
|
+
* turn, if there is one, is promoted so its own "Thinking" appears.
|
|
6186
|
+
*
|
|
6187
|
+
* What it does NOT do: the provider call already in flight on the worker runs
|
|
6188
|
+
* to its end (a streamed one stops within about a second). A message sent
|
|
6189
|
+
* behind it on the same queue starts once the worker acks the cancelled row.
|
|
6190
|
+
*
|
|
6191
|
+
* Refused without a server id (the ack has not come back yet; the views disable
|
|
6192
|
+
* the control until it has) and while a cancel is already out. Indexing passes
|
|
6193
|
+
* are not this method's business: their Stop is cancelIndexingGroup, which ends
|
|
6194
|
+
* the file's whole chain.
|
|
6195
|
+
*/
|
|
6196
|
+
cancelPendingReply(msg, idx) {
|
|
6197
|
+
var self = this;
|
|
6198
|
+
var id = this.host.getIdentity();
|
|
6199
|
+
var serverId = msg._serverItemId;
|
|
6200
|
+
if (!serverId || msg._cancelling) return;
|
|
6201
|
+
if (msg.role !== "assistant" || !msg.isPending || msg.isBackgroundTask) return;
|
|
6202
|
+
var platform = id.platform;
|
|
6203
|
+
if (platform !== "claude" && platform !== "openai") return;
|
|
6204
|
+
var url = platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
|
|
6205
|
+
var queueBase = id.userId || id.projectId;
|
|
6206
|
+
var queue = msg._useBgQueue ? bgIndexingQueueName(queueBase) : queueBase;
|
|
6207
|
+
var findPlaceholder = function() {
|
|
6208
|
+
return self.state.messages.findIndex(function(m) {
|
|
6209
|
+
return m._serverItemId === serverId && m.role === "assistant" && !!m.isPending;
|
|
6210
|
+
});
|
|
6211
|
+
};
|
|
6212
|
+
var at = this.state.messages[idx] && this.state.messages[idx]._serverItemId === serverId && this.state.messages[idx].role === "assistant" ? idx : findPlaceholder();
|
|
6213
|
+
if (at !== -1) {
|
|
6214
|
+
this.state.messages[at] = Object.assign({}, this.state.messages[at], { _cancelling: true, _cancelError: void 0 });
|
|
6215
|
+
}
|
|
6216
|
+
this.host.notify();
|
|
6217
|
+
var unmark = function(errMsg) {
|
|
6218
|
+
var ci = findPlaceholder();
|
|
6219
|
+
if (ci === -1) return;
|
|
6220
|
+
self.state.messages[ci] = Object.assign({}, self.state.messages[ci], { _cancelling: false, _cancelError: errMsg });
|
|
6221
|
+
self.host.notify();
|
|
6222
|
+
};
|
|
6223
|
+
Promise.resolve(this.host.cancelRequest({
|
|
6224
|
+
url,
|
|
6225
|
+
method: "POST",
|
|
6226
|
+
id: serverId,
|
|
6227
|
+
queue,
|
|
6228
|
+
service: id.projectId,
|
|
6229
|
+
owner: id.owner
|
|
6230
|
+
})).then(function(result) {
|
|
6231
|
+
if (result && result.removed) {
|
|
6232
|
+
self._stopPoll(serverId);
|
|
6233
|
+
self._settleCancelledTurn(serverId);
|
|
6234
|
+
return;
|
|
6235
|
+
}
|
|
6236
|
+
unmark(void 0);
|
|
6237
|
+
}).catch(function(err) {
|
|
6238
|
+
unmark(err && typeof err.message === "string" && err.message ? err.message : "Could not stop this request.");
|
|
6239
|
+
});
|
|
6240
|
+
}
|
|
6241
|
+
/** The cancelled form of a request bubble, with everything that keeps it in
|
|
6242
|
+
* place carried over: the file markers that hold an indexing pass in its
|
|
6243
|
+
* collapsed row, the chat it belongs to, its send time and its render key. */
|
|
6244
|
+
_cancelledCopyOf(m, itemId) {
|
|
6245
|
+
var cancelled = { role: "user", content: m.content, isCancelled: true };
|
|
6246
|
+
if (itemId !== void 0) cancelled._serverItemId = itemId;
|
|
6247
|
+
else if (m._serverItemId !== void 0) cancelled._serverItemId = m._serverItemId;
|
|
6248
|
+
if (m.isBackgroundTask) cancelled.isBackgroundTask = true;
|
|
6249
|
+
if (m._indexFile) cancelled._indexFile = m._indexFile;
|
|
6250
|
+
if (m._useBgQueue) cancelled._useBgQueue = true;
|
|
6251
|
+
if (m._ownerKey !== void 0) cancelled._ownerKey = m._ownerKey;
|
|
6252
|
+
if (m._ts !== void 0) cancelled._ts = m._ts;
|
|
6253
|
+
if (m._localId !== void 0) cancelled._localId = m._localId;
|
|
6254
|
+
return cancelled;
|
|
6255
|
+
}
|
|
6256
|
+
/**
|
|
6257
|
+
* Settle a turn as cancelled wherever it lives, and let the queue move on.
|
|
6258
|
+
*
|
|
6259
|
+
* On the chat on screen: every pending placeholder carrying the id goes (a
|
|
6260
|
+
* history refetch can have re-mapped the running turn into a second one), the
|
|
6261
|
+
* request bubble takes its cancelled form, and the next queued turn is promoted
|
|
6262
|
+
* so the conversation does not sit on an "(In queue)" bubble with nothing
|
|
6263
|
+
* running. For a chat that is NOT on screen (the user moved on while the turn
|
|
6264
|
+
* ran, and it was cancelled from elsewhere) the same edit is made in that chat's
|
|
6265
|
+
* history cache, which is what the next visit renders.
|
|
6266
|
+
*
|
|
6267
|
+
* Idempotent on purpose: a cancel this client made settles the bubbles at once,
|
|
6268
|
+
* and the chain that was awaiting the poll settles again a moment later when the
|
|
6269
|
+
* stop resolves it. The second pass finds nothing to do. Deliberately NOT
|
|
6270
|
+
* _removeStrayPendingAssistants: by then the placeholder on screen is the freshly
|
|
6271
|
+
* promoted turn's, whose user bubble is pending, and the sweep would take it.
|
|
6272
|
+
*/
|
|
6273
|
+
_settleCancelledTurn(itemId, ownerKey) {
|
|
6274
|
+
if (!itemId) return;
|
|
6275
|
+
if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
|
|
6276
|
+
this._applyCancelToCache(ownerKey, itemId);
|
|
6277
|
+
return;
|
|
6278
|
+
}
|
|
6279
|
+
var changed = false, wasBg = false;
|
|
6280
|
+
for (var i = this.state.messages.length - 1; i >= 0; i--) {
|
|
6281
|
+
var m = this.state.messages[i];
|
|
6282
|
+
if (!m || m._serverItemId !== itemId) continue;
|
|
6283
|
+
if (m.role === "assistant" && m.isPending) {
|
|
6284
|
+
this.state.messages.splice(i, 1);
|
|
6285
|
+
changed = true;
|
|
6286
|
+
continue;
|
|
6287
|
+
}
|
|
6288
|
+
if (m.role === "user" && !m.isCancelled) {
|
|
6289
|
+
if (m.isBackgroundTask) wasBg = true;
|
|
6290
|
+
this.state.messages[i] = this._cancelledCopyOf(m, itemId);
|
|
6291
|
+
changed = true;
|
|
6292
|
+
}
|
|
6293
|
+
}
|
|
6294
|
+
this.cancelledServerIds.delete(itemId);
|
|
6295
|
+
if (!changed) return;
|
|
6296
|
+
if (wasBg) this.promoteNextBgQueuedToRunning();
|
|
6297
|
+
else this.promoteNextQueuedToRunning();
|
|
6298
|
+
this.updateHistoryCache();
|
|
6299
|
+
this.host.notify();
|
|
6300
|
+
}
|
|
6301
|
+
/**
|
|
6302
|
+
* _settleCancelledTurn for a chat that is not on screen: the same edit, made in
|
|
6303
|
+
* its history cache. Matched by id where the cached bubbles carry one; an
|
|
6304
|
+
* off-chat send caches its pair before the ack (see dispatchComposedMessage), so
|
|
6305
|
+
* the fallback is positional, exactly as _applyReplyToCache's is: the trailing
|
|
6306
|
+
* pending placeholder, and the last request bubble still marked in flight.
|
|
6307
|
+
*/
|
|
6308
|
+
_applyCancelToCache(key, itemId) {
|
|
6309
|
+
var existing = key ? this.aiChatHistoryCache[key] : void 0;
|
|
6310
|
+
if (!existing) return;
|
|
6311
|
+
var msgs = existing.messages.slice();
|
|
6312
|
+
var changed = false;
|
|
6313
|
+
var pIdx = -1, uIdx = -1;
|
|
6314
|
+
for (var i = msgs.length - 1; i >= 0; i--) {
|
|
6315
|
+
var m = msgs[i];
|
|
6316
|
+
if (!m || m._serverItemId !== itemId) continue;
|
|
6317
|
+
if (m.role === "assistant" && m.isPending) {
|
|
6318
|
+
msgs.splice(i, 1);
|
|
6319
|
+
changed = true;
|
|
6320
|
+
pIdx = i;
|
|
6321
|
+
} else if (m.role === "user" && !m.isCancelled && uIdx === -1) uIdx = i;
|
|
6322
|
+
}
|
|
6323
|
+
if (pIdx === -1) {
|
|
6324
|
+
for (var p = msgs.length - 1; p >= 0; p--) {
|
|
6325
|
+
var pm = msgs[p];
|
|
6326
|
+
if (!pm || !pm.isPending || pm.role !== "assistant" || pm.isBackgroundTask || pm._serverItemId !== void 0) continue;
|
|
6327
|
+
msgs.splice(p, 1);
|
|
6328
|
+
changed = true;
|
|
6329
|
+
break;
|
|
6330
|
+
}
|
|
6331
|
+
}
|
|
6332
|
+
if (uIdx === -1) {
|
|
6333
|
+
for (var u = msgs.length - 1; u >= 0; u--) {
|
|
6334
|
+
var um = msgs[u];
|
|
6335
|
+
if (!um || um.role !== "user" || um.isBackgroundTask || um._serverItemId !== void 0 || um._stageId) continue;
|
|
6336
|
+
if (!(um.isPendingQueued || um.isPendingInProcess || um.isSendingToServer)) continue;
|
|
6337
|
+
uIdx = u;
|
|
6338
|
+
break;
|
|
6339
|
+
}
|
|
6340
|
+
}
|
|
6341
|
+
if (uIdx !== -1) {
|
|
6342
|
+
msgs[uIdx] = this._cancelledCopyOf(msgs[uIdx], itemId);
|
|
6343
|
+
changed = true;
|
|
6344
|
+
}
|
|
6345
|
+
if (!changed) return;
|
|
6346
|
+
this.aiChatHistoryCache[key] = { messages: msgs, endOfList: existing.endOfList, startKeyHistory: existing.startKeyHistory };
|
|
6347
|
+
}
|
|
6107
6348
|
/**
|
|
6108
6349
|
* Stop indexing a file, from its collapsed row — every pass at once, not just
|
|
6109
6350
|
* the bubble the user happens to see.
|
|
@@ -6170,6 +6411,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
6170
6411
|
}
|
|
6171
6412
|
}
|
|
6172
6413
|
this._adoptWorkerIndexingPasses(0);
|
|
6414
|
+
this._sweepCancelledIndexing();
|
|
6173
6415
|
var ids = group.cancellableIds || [];
|
|
6174
6416
|
if (!ids.length) {
|
|
6175
6417
|
this.host.notify();
|
|
@@ -6306,7 +6548,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
6306
6548
|
enqueueTypewrite(idx, fullText, localId, paintedText) {
|
|
6307
6549
|
var self = this;
|
|
6308
6550
|
var target = this.state.messages[idx];
|
|
6309
|
-
if (target && target._ts === void 0) target._ts =
|
|
6551
|
+
if (target && target._ts === void 0) target._ts = this._replyStampFor(target);
|
|
6310
6552
|
if (!this.typewriterQueue) this.typewriterQueue = Promise.resolve();
|
|
6311
6553
|
this.typewriterQueue = this.typewriterQueue.then(function() {
|
|
6312
6554
|
return self.typewriteIntoIndex(idx, fullText, localId, paintedText);
|
|
@@ -6452,9 +6694,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
6452
6694
|
this.state.sending = true;
|
|
6453
6695
|
this.host.scrollToBottomIfSticky(true);
|
|
6454
6696
|
return Promise.resolve(pending).catch(function() {
|
|
6455
|
-
}).then(function() {
|
|
6697
|
+
}).then(function(result) {
|
|
6456
6698
|
if (token !== self.state.gateRefreshToken) return;
|
|
6457
6699
|
self.state.sending = false;
|
|
6700
|
+
if (result && result.cancelled) return;
|
|
6458
6701
|
return Promise.resolve(self.typewriteLatestReply(key)).then(function() {
|
|
6459
6702
|
self.host.scrollToBottomIfSticky(true);
|
|
6460
6703
|
});
|
|
@@ -8083,8 +8326,19 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
8083
8326
|
} else if (stopped) {
|
|
8084
8327
|
grp.status = "cancelled";
|
|
8085
8328
|
} else {
|
|
8086
|
-
var
|
|
8087
|
-
|
|
8329
|
+
var retriedIds = {};
|
|
8330
|
+
for (var ri = 0; ri < grp.members.length; ri++) {
|
|
8331
|
+
var rm = grp.members[ri].msg;
|
|
8332
|
+
if (rm.isRetried && rm._serverItemId) retriedIds[rm._serverItemId] = true;
|
|
8333
|
+
}
|
|
8334
|
+
var li = grp.members.length - 1;
|
|
8335
|
+
while (li >= 0 && (grp.members[li].msg.isRetried || grp.members[li].msg._serverItemId && retriedIds[grp.members[li].msg._serverItemId])) li--;
|
|
8336
|
+
if (li < 0) {
|
|
8337
|
+
grp.status = "active";
|
|
8338
|
+
} else {
|
|
8339
|
+
var last = grp.members[li].msg;
|
|
8340
|
+
grp.status = last.isError ? "error" : "done";
|
|
8341
|
+
}
|
|
8088
8342
|
}
|
|
8089
8343
|
var sawFirstPass = false;
|
|
8090
8344
|
for (var pi = 0; pi < grp.members.length; pi++) {
|
|
@@ -8374,7 +8628,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
8374
8628
|
(function() {
|
|
8375
8629
|
var MCP_PROD = "https://mcp.broadwayinc.computer";
|
|
8376
8630
|
var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
|
|
8377
|
-
var BQ_VERSION = "1.
|
|
8631
|
+
var BQ_VERSION = "1.13.0" ;
|
|
8378
8632
|
var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
|
|
8379
8633
|
var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
8380
8634
|
var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
@@ -11657,12 +11911,29 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
11657
11911
|
var cls = ["bq-message"];
|
|
11658
11912
|
cls.push(msg.role === "user" ? "is-user" : "is-assistant");
|
|
11659
11913
|
if (msg.isError) cls.push("is-error");
|
|
11914
|
+
if (msg.isRetried) cls.push("is-retried");
|
|
11660
11915
|
if (msg.isCancelled) cls.push("is-cancelled");
|
|
11661
11916
|
if (msg.isPendingQueued || msg.isPendingOlder) cls.push("is-pending-older");
|
|
11662
11917
|
if (msg._dimSending || msg._cancelling) cls.push("is-sending-to-server");
|
|
11663
11918
|
var bubble;
|
|
11664
11919
|
if (msg.isPending && !msg._streaming || streamRecoveryPhase(msg) === "active") {
|
|
11665
11920
|
bubble = h("div", { class: "bq-bubble" }, h("span", { class: "bq-loader" }));
|
|
11921
|
+
if (msg.role === "assistant" && !msg.isBackgroundTask && !streamRecoveryPhase(msg)) {
|
|
11922
|
+
var waitDisabled = !msg._serverItemId || msg._cancelling;
|
|
11923
|
+
var stopWaitBtn = h("button", {
|
|
11924
|
+
class: "bq-cancel-wait-btn" + (waitDisabled ? " is-disabled" : ""),
|
|
11925
|
+
type: "button",
|
|
11926
|
+
title: "Stop waiting for this reply",
|
|
11927
|
+
"aria-label": "Stop waiting for this reply",
|
|
11928
|
+
html: "×"
|
|
11929
|
+
});
|
|
11930
|
+
if (!waitDisabled) stopWaitBtn.addEventListener("click", function(e) {
|
|
11931
|
+
e.stopPropagation();
|
|
11932
|
+
session.cancelPendingReply(msg, idx);
|
|
11933
|
+
});
|
|
11934
|
+
bubble.appendChild(stopWaitBtn);
|
|
11935
|
+
if (msg._cancelError) bubble.appendChild(h("span", { class: "bq-cancel-error", text: msg._cancelError }));
|
|
11936
|
+
}
|
|
11666
11937
|
} else if (streamRecoveryPhase(msg)) {
|
|
11667
11938
|
var labels = streamRecoveryLabels(streamRecoveryPhase(msg));
|
|
11668
11939
|
bubble = h("div", { class: "bq-bubble is-stream-recover" + (streamRecoveryPhase(msg) === "failed" ? " is-stream-failed" : "") });
|
|
@@ -11933,6 +12204,42 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
11933
12204
|
}
|
|
11934
12205
|
return byFile;
|
|
11935
12206
|
}
|
|
12207
|
+
var stopIndexOthers = { checked: false, row: null, text: null, box: null };
|
|
12208
|
+
function otherStoppableIndexGroups(target) {
|
|
12209
|
+
if (!target) return [];
|
|
12210
|
+
var list = buildChatDisplayList(CS.messages, displayListOptions());
|
|
12211
|
+
var others = [];
|
|
12212
|
+
for (var i = 0; i < list.length; i++) {
|
|
12213
|
+
var row = list[i];
|
|
12214
|
+
if (row.kind !== "indexing") continue;
|
|
12215
|
+
if (!indexGroupStoppable(row.group) || row.group.key === target.key) continue;
|
|
12216
|
+
others.push(row.group);
|
|
12217
|
+
}
|
|
12218
|
+
return others;
|
|
12219
|
+
}
|
|
12220
|
+
function countIndexFiles(groups) {
|
|
12221
|
+
var seen = {}, n = 0;
|
|
12222
|
+
for (var i = 0; i < groups.length; i++) {
|
|
12223
|
+
if (seen[groups[i].key]) continue;
|
|
12224
|
+
seen[groups[i].key] = true;
|
|
12225
|
+
n++;
|
|
12226
|
+
}
|
|
12227
|
+
return n;
|
|
12228
|
+
}
|
|
12229
|
+
function stopOthersLabel(n) {
|
|
12230
|
+
return n === 1 ? "Also stop the other file being indexed" : "Also stop the " + n + " other files being indexed";
|
|
12231
|
+
}
|
|
12232
|
+
function refreshStopIndexOthers() {
|
|
12233
|
+
if (!stopIndexOthers.row) return;
|
|
12234
|
+
var n = countIndexFiles(otherStoppableIndexGroups(findCancellableIndexGroup(stopIndexState.runKey, stopIndexState.fileKey)));
|
|
12235
|
+
if (!n) {
|
|
12236
|
+
stopIndexOthers.checked = false;
|
|
12237
|
+
if (stopIndexOthers.box) stopIndexOthers.box.checked = false;
|
|
12238
|
+
} else if (stopIndexOthers.text) {
|
|
12239
|
+
stopIndexOthers.text.textContent = stopOthersLabel(n);
|
|
12240
|
+
}
|
|
12241
|
+
stopIndexOthers.row.style.display = n ? "" : "none";
|
|
12242
|
+
}
|
|
11936
12243
|
function stopIndexModalIsOpen() {
|
|
11937
12244
|
var hnd = stopIndexState.handle;
|
|
11938
12245
|
if (hnd && hnd.root && hnd.root.parentNode) return true;
|
|
@@ -11951,6 +12258,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
11951
12258
|
function syncStopIndexModal() {
|
|
11952
12259
|
if (!stopIndexModalIsOpen()) return;
|
|
11953
12260
|
if (!findCancellableIndexGroup(stopIndexState.runKey, stopIndexState.fileKey)) closeStopIndexModal();
|
|
12261
|
+
else refreshStopIndexOthers();
|
|
11954
12262
|
}
|
|
11955
12263
|
function openStopIndexModal(group) {
|
|
11956
12264
|
if (!indexGroupStoppable(group)) return;
|
|
@@ -11963,10 +12271,24 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
11963
12271
|
stopBtn.addEventListener("click", function() {
|
|
11964
12272
|
var runKey = stopIndexState.runKey;
|
|
11965
12273
|
var fileKey = stopIndexState.fileKey;
|
|
12274
|
+
var alsoOthers = stopIndexOthers.checked;
|
|
11966
12275
|
closeStopIndexModal();
|
|
11967
12276
|
var live = findCancellableIndexGroup(runKey, fileKey);
|
|
12277
|
+
var others = alsoOthers ? otherStoppableIndexGroups(live) : [];
|
|
11968
12278
|
if (live) session.cancelIndexingGroup(live);
|
|
12279
|
+
for (var oi = 0; oi < others.length; oi++) session.cancelIndexingGroup(others[oi]);
|
|
12280
|
+
});
|
|
12281
|
+
var othersBox = h("input", { type: "checkbox" });
|
|
12282
|
+
othersBox.addEventListener("change", function() {
|
|
12283
|
+
stopIndexOthers.checked = !!othersBox.checked;
|
|
11969
12284
|
});
|
|
12285
|
+
var othersText = h("span");
|
|
12286
|
+
var othersRow = h("label", { class: "bq-overwrite-applyall" }, othersBox, othersText);
|
|
12287
|
+
stopIndexOthers.checked = false;
|
|
12288
|
+
stopIndexOthers.box = othersBox;
|
|
12289
|
+
stopIndexOthers.text = othersText;
|
|
12290
|
+
stopIndexOthers.row = othersRow;
|
|
12291
|
+
refreshStopIndexOthers();
|
|
11970
12292
|
return h(
|
|
11971
12293
|
"div",
|
|
11972
12294
|
{ class: "bq-modal" },
|
|
@@ -11986,6 +12308,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
11986
12308
|
{ class: "bq-modal-desc bq-modal-delete-warn" },
|
|
11987
12309
|
"Whatever has been indexed so far stays searchable, and the pass already running finishes on the server. The remaining passes are dropped, not paused, so the file stays partly indexed until you reindex it."
|
|
11988
12310
|
),
|
|
12311
|
+
othersRow,
|
|
11989
12312
|
h(
|
|
11990
12313
|
"div",
|
|
11991
12314
|
{ class: "bq-modal-btns" },
|