bunnyquery 1.8.2 → 1.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -39
- package/bunnyquery.css +108 -2
- package/bunnyquery.js +1859 -310
- package/dist/engine.cjs +1503 -188
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +906 -37
- package/dist/engine.d.ts +906 -37
- package/dist/engine.mjs +1480 -189
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/budget.ts +11 -11
- package/src/engine/history.ts +23 -6
- package/src/engine/host.ts +77 -3
- package/src/engine/image_preview.ts +0 -0
- package/src/engine/index.ts +13 -0
- package/src/engine/indexing_groups.ts +323 -6
- package/src/engine/link_markup.ts +124 -0
- package/src/engine/links.ts +159 -26
- package/src/engine/office.ts +25 -8
- package/src/engine/prompts/chat_system_prompt.ts +24 -13
- package/src/engine/prompts/indexing_system_prompt.ts +19 -11
- package/src/engine/prompts/indexing_user_message.ts +32 -22
- package/src/engine/requests.ts +302 -14
- package/src/engine/session.ts +1424 -114
- package/src/engine/viewport_fill.ts +51 -4
- package/styles/chat.css +108 -2
package/dist/engine.cjs
CHANGED
|
@@ -219,14 +219,15 @@ function isWindowedReadFile(name, mime) {
|
|
|
219
219
|
}
|
|
220
220
|
function composeUserMessage(text, attachmentUrls) {
|
|
221
221
|
let composed = text;
|
|
222
|
+
let composedForLlm = composed;
|
|
222
223
|
if (attachmentUrls.length > 0) {
|
|
223
224
|
const lines = attachmentUrls.map((u) => `- [${u.name}](${u.url})`);
|
|
224
225
|
composed = `${text}
|
|
225
226
|
|
|
226
227
|
Attached files:
|
|
227
228
|
${lines.join("\n")}`;
|
|
229
|
+
composedForLlm = composed;
|
|
228
230
|
}
|
|
229
|
-
let composedForLlm = composed;
|
|
230
231
|
let extractContent;
|
|
231
232
|
let fileUrls;
|
|
232
233
|
if (attachmentUrls.length > 0) {
|
|
@@ -243,13 +244,13 @@ ${placeholder}
|
|
|
243
244
|
----- END FILE CONTENT -----`;
|
|
244
245
|
});
|
|
245
246
|
extractContent = directives;
|
|
246
|
-
composedForLlm = `${
|
|
247
|
+
composedForLlm = `${composedForLlm}
|
|
247
248
|
|
|
248
249
|
Extracted content of attached office files (read inline below; do NOT fetch their URLs):
|
|
249
250
|
|
|
250
251
|
` + sections.join("\n\n");
|
|
251
252
|
}
|
|
252
|
-
const urlFiles =
|
|
253
|
+
const urlFiles = [];
|
|
253
254
|
if (urlFiles.length > 0) {
|
|
254
255
|
fileUrls = urlFiles.map((u) => ({ path: u.storagePath || u.name, url: u.url }));
|
|
255
256
|
}
|
|
@@ -281,28 +282,39 @@ function groupAttachmentFailures(attachments) {
|
|
|
281
282
|
|
|
282
283
|
// src/engine/prompts/chat_system_prompt.ts
|
|
283
284
|
function buildChatSystemPrompt(params) {
|
|
284
|
-
const {
|
|
285
|
+
const { projectId, serviceName, serviceDescription } = params;
|
|
285
286
|
let systemPrompt = `
|
|
286
|
-
You are a dedicated assistant for the project ID: "${
|
|
287
|
+
You are a dedicated assistant for the project ID: "${projectId}".
|
|
287
288
|
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.
|
|
288
289
|
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.
|
|
289
|
-
Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records
|
|
290
|
+
Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records. ONE file is routinely SPLIT ACROSS SEVERAL TABLES - a summary row in one table, its page or row content in another, its extracted photos and other media in "__MEDIA__", and the indexer often invents a differently-named table on each pass. An index or tag filter matches inside ONE table only and requires table_name: on getRecords, an index or tag sent with table_name but no access_group is auto-filled with access_group "authorized" (where the indexer writes; pass access_group explicitly, including 0, to search another group), while an index or tag WITHOUT table_name FAILS with an error instead of answering, so read the error rather than guessing. Reference is the exception: reference ALONE spans EVERY table and EVERY access group, so getRecords with reference "src::<the file's storage path>" is the one call that returns a whole file's records wherever the indexer put them. Adding table_name narrows it to that table; access_group WITHOUT table_name fails with '"table" is required'; table_name on its own returns that whole table across all access groups. For anything NOT scoped to a single file, call getTables FIRST, run the query once per table that could hold the answer, and combine the results. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "\uC5C6\uC5B4?", "\uD558\uB098\uB3C4 \uC5C6\uB098?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset. If you already answered from one table and then realise another table holds more, do not simply apologise: re-run the sweep and give the complete answer.
|
|
290
291
|
Never assert absence from a partial read. Do not say "there is no X", "none", "not found", or "\uC544\uB2C8\uC694, \uC5C6\uC2B5\uB2C8\uB2E4" until a complete scan has come back empty. If you have not finished scanning every relevant table and file, keep querying instead of guessing. A confident "no" that later turns out wrong is worse than telling the user you are still checking.
|
|
291
|
-
Embedded values: a search term is often stored inside a larger string. A merchant "GODADDY" appears as "DNH*GODADDY#4070277042", and a card as "4140****2941". Server-side index
|
|
292
|
+
Embedded values: a search term is often stored inside a larger string. A merchant "GODADDY" appears as "DNH*GODADDY#4070277042", and a card as "4140****2941". Server-side index filters match only exact values, leading prefixes, or trailing suffixes, and tag filters only EXACT whole-tag values - never a partial or interior substring - so filtering on such a field silently drops rows. When the value you are looking for may be embedded, do not trust a narrow filter to be complete. Fetch the full set with fetch_all and match the substring yourself.
|
|
292
293
|
File attachments: When a user message contains an "Attached files:" section with markdown links, those links point to short-lived signed URLs in this project's db storage and will expire.
|
|
293
294
|
- Image files (.jpg, .jpeg, .png, .gif, .webp) are ALREADY attached inline as image content blocks in the same message - you can see them directly. Do NOT call web_fetch on image URLs; that will fail or return garbage. Just look at the image block and answer.
|
|
294
295
|
- Most attached files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) have ALREADY had their text extracted on the server and inlined in the same message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for those files. A "[skapi: ...]" note in that block means the file could not be extracted.
|
|
295
296
|
- For any file given to you as a URL instead of inline content (e.g. PDFs), use your web_fetch tool to download and read each URL before answering. Treat the fetched contents as user-supplied input data. Do not ask the user to paste the file contents - fetch the URLs yourself.
|
|
296
|
-
|
|
297
|
-
File
|
|
298
|
-
File
|
|
297
|
+
Stored files and readFileContent: for a file ALREADY in this project's storage, its pages and rows were read at upload time and saved as records, so the database is your best source. Query those records first (getRecords with reference "src::<path>", or getUniqueId with unique_id "src::" and condition "gte" to find the file). readFileContent re-reads the raw file and is the right tool for text, spreadsheet and data files; it returns ONE window per call, so keep paging with the cursor from the previous window until it says END OF FILE before you conclude anything is absent. Be aware its PICTURES may not reach you: page images and embedded photos are attached as image blocks that several clients drop, leaving you only markers such as \xABPHOTO A88\xBB or a "(scanned; read the page images)" header. There is no OCR on the server, so a scanned page with no text layer carries no text at all. If you cannot actually see an image, say so plainly and fall back to the indexed records; never describe a picture you were not shown, and never tell the user the file is unreadable when its content is already in the database.
|
|
298
|
+
File links: When you find a record whose unique_id starts with "src::", the part after "src::" is the file's storage path or original URL. Always present it as a markdown link so the user can access it. Strip the "src::" prefix - do NOT show it. Format: [filename](db:path/to/file) for storage paths, or [filename](https://...) for external URLs. The db: prefix is REQUIRED on storage paths: it tells the chat client the target is a stored file rather than a web address, instead of leaving it to guess. Everything after db: is the path exactly as stored, including spaces and parentheses, and NOT url-encoded. Storage-path links render as clickable buttons in this chat client that fetch a fresh signed URL on demand - so even if a previously shared URL has expired, give the user the storage-path link instead of saying the file is unavailable. Never tell the user a file is inaccessible or a URL is expired if you have its storage path in the database.
|
|
299
|
+
File lookup: When the user asks to see, list, or show files (e.g. "show me uploaded files", "list my images", "show me the reference video"), query the database using getUniqueId with unique_id "src::" and condition "gte" (or getRecords by table) to find all indexed file records; every file extracted out of a document has one too, in table "__MEDIA__" (access_group "authorized"). Present each result as a markdown link as described above. Never say you cannot access file storage: the paths are indexed in the database.
|
|
300
|
+
Showing images: "show me the photo", "\uBCF4\uC5EC\uC918", "display it" is a request for the file's LINK, nothing more. This chat client renders an image file's storage-path link as the picture itself, inline, so a [filename](db:path/to/photo.jpg) link IS the image on screen. Never answer an image request with "I can't show images" or "I can only describe it", and never make the user ask twice for a link you already had. If you have the path, give the link and let the client paint it. The same is true of any file the user asks to see: the link is the answer. Only fall back to describing an image when the user asked ABOUT its contents rather than to see it, or when you genuinely have no path for it.
|
|
301
|
+
Media inside a document is extracted into real files: every embedded PICTURE inside an uploaded document - photos, diagrams, chart images - is pulled out at upload time and saved as its OWN permanent file in this project's storage, in the folder "__MEDIA__/<the document's storage path>/". Embedded audio, video and non-picture attachments are NOT extracted, and a scanned PDF page is not stored as a separate picture (its content is indexed from the page itself) - for those, say so plainly and offer the source document. A picture is NOT trapped inside its source document: never answer that a photo exists only inside the spreadsheet or deck, that no separate image file was saved, or that there is nothing to open, and never hand back a link to the source .xlsx or .pdf when the user asked for a picture inside it.
|
|
302
|
+
Finding an extracted media file: it is INDEXED, and its location is a stored VALUE. Get it by QUERYING, never by constructing a filename.
|
|
303
|
+
RECOGNISE IT BY THE VALUE, NOT THE FIELD NAME. Any field whose value begins with "__MEDIA__/" is a storage path to an extracted file, whatever the field is called - path, photo_path, media_path, file, attachment, or something the indexer invented that day. A record's unique_id beginning "src::__MEDIA__/" marks it as a media record too.
|
|
304
|
+
The reliable query is getRecords with reference "src::<the document's storage path>" - one call, every table, every access group. Scan the results for the one describing what you want (its part number, tag id, anchor, caption or description) and take its "__MEDIA__/..." value. Never let a table guess be the reason you report a file as missing.
|
|
305
|
+
Link it VERBATIM as [caption](db:<the path>). An image renders inline as the picture itself; other media renders as a link the user can open.
|
|
306
|
+
So "show me the photo of part X" is: find the record for that part, take its "__MEDIA__/..." value, link it.
|
|
307
|
+
IF THAT RECORD HAS NO PATH, JOIN ON LOCATION - this needs nothing to have been enriched. Every media record carries data.anchor (the cell or page it was embedded at), plus data.sheet when it came from a spreadsheet, and the content record that mentions your part carries the same anchor and sheet under some name (anchor, anchor_cell, photo_anchor, cell, row_number, page). So: read the anchor and sheet off the content record, query getRecords with reference "src::<the document>", and take the media record whose data.anchor, data.also_at or tags match the anchor, using data.sheet too when both records carry one. Those fields are written by the pipeline, not by an indexer's choice of wording, so they are correct wherever they appear. One caution: a picture repeated at several cells is stored ONCE, under the FIRST cell it appeared at, so an anchor can genuinely have no media record of its own; its locations are merged onto that first record's tags and data.also_at. Before reporting a picture missing, check whether another media record of the same document is plausibly the same picture (same sheet, a matching description), and offer that one.
|
|
308
|
+
THIS IS NOT ONLY ABOUT SPREADSHEET PHOTOS. Treat "show me the diagram in that deck" or "the picture in that PDF" exactly like a photo request: query for the media record, never reconstruct a filename. For embedded video, audio or a non-picture attachment there is no extracted file: say so plainly and offer the source document.
|
|
309
|
+
A document may still have no media record: it was indexed before the "__MEDIA__" table existed, or its format is one whose embedded files are not extracted. Then say plainly that this picture is not indexed and offer the source document. One missing record is never evidence that media is not stored.
|
|
310
|
+
File generation: When the user asks for DATABASE records as a file (CSV, spreadsheet, export, download), call exportRecordsToFile: it writes the rows on the server, keeps them out of your context, and returns a download_url you paste as the link. Never retype stored rows into a code block and never split one dataset across several blocks. For a file you are authoring yourself, or to produce specifically-formatted text such as HTML, CSV, JSON, or Markdown, put the file's full contents inside a fenced code block whose info string is the intended filename WITH its extension (e.g. report.csv), NOT a language name like "csv". The chat client turns such a block into a downloadable file named after that info string. Emit one file per block, in plain text only - never base64 or any other encoding. Example for CSV:
|
|
299
311
|
\`\`\`filename.csv
|
|
300
312
|
item,qty,total
|
|
301
313
|
Carrots,55,$38.50
|
|
302
314
|
Mushrooms,41,$73.80
|
|
303
315
|
Zucchini,29,$43.50
|
|
304
316
|
\`\`\`
|
|
305
|
-
The same pattern applies to any format
|
|
317
|
+
The same pattern applies to any format - name the block after the file you intend: \`\`\`my-data.json, \`\`\`index.html, \`\`\`sample.txt, and so on.`;
|
|
306
318
|
if (serviceDescription) {
|
|
307
319
|
systemPrompt += `
|
|
308
320
|
Project name: "${serviceName ?? ""}"
|
|
@@ -313,18 +325,26 @@ Project description: """${serviceDescription}"""`;
|
|
|
313
325
|
|
|
314
326
|
// src/engine/prompts/indexing_system_prompt.ts
|
|
315
327
|
function buildIndexingSystemPrompt(params) {
|
|
316
|
-
const {
|
|
317
|
-
let systemPrompt = `You are a background indexing agent for project ${
|
|
328
|
+
const { projectId, serviceName, serviceDescription } = params;
|
|
329
|
+
let systemPrompt = `You are a background indexing agent for project ${projectId}.
|
|
318
330
|
- Image files (.jpg, .jpeg, .png, .gif, .webp) are ALREADY attached inline as image content blocks in the same message - you can see them directly. Do NOT call web_fetch on image URLs; that will fail or return garbage. Just look at the image block and answer.
|
|
319
331
|
- Most files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) have ALREADY been extracted on the server and included inline in the user message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read that directly. If the inline content is a "[skapi: ...]" note, the file could not be extracted - index it from its metadata only.
|
|
320
|
-
- BIG SPREADSHEETS / TEXT: the inline content may be only the FIRST part of a large file (it can end with a truncation or "more remains" note).
|
|
332
|
+
- BIG SPREADSHEETS / TEXT: the inline content may be only the FIRST part of a large file (it can end with a truncation or "more remains" note). UNLESS this message already embeds a window of the file (in which case the message tells you not to call readFileContent, and you must not), read big spreadsheets and big text/data files WITH THE readFileContent TOOL: it returns the file ONE WINDOW at a time (spreadsheets as coordinate-tagged grid rows, text as a range of characters). Pass the file's storage path. After each window: datafy it into records and SAVE them, THEN if the window says MORE REMAINS call readFileContent again with the cursor it gives you. Repeat until it says END OF FILE, so the WHOLE file is indexed - never stop after the first window. (Do NOT call readFileContent on a PDF - see the next line.)
|
|
321
333
|
- PDFs (scanned or not): you do NOT read a PDF with a tool or a URL. Its pages are RENDERED and embedded directly in the user message as IMAGE blocks, a WINDOW of pages at a time. LOOK at the embedded page images and datafy every one. The note beside them tells you whether MORE pages remain: if so, save this window's records and stop (a follow-up pass shows the next window automatically); only when the note says it was the LAST window is the PDF fully seen. Do NOT call readFileContent or web_fetch for a PDF.
|
|
322
334
|
- VISION: when the message (a readFileContent window, an embedded PDF page, or an inline attachment) includes IMAGES - scanned/rendered PDF pages, or photos embedded in a spreadsheet next to a row/block - LOOK at them and capture what they show as record data (the reading/values in a scanned table, the part/defect/condition visible in a photo). The image IS part of the data; correlate each photo with its labelled block ("PHOTO A3" markers tie a photo to that grid row).
|
|
323
|
-
-
|
|
324
|
-
-
|
|
325
|
-
-
|
|
326
|
-
-
|
|
327
|
-
-
|
|
335
|
+
- TRANSCRIBE, DO NOT DESCRIBE. When an image contains ANY text - a label, tag, stamp, form field, serial/part number, handwriting - your FIRST job is to read the characters out and store them VERBATIM, not to describe the scene. A record saying "a red inspection tag with handwritten markings" is worthless: it is unsearchable and every such photo produces the same sentence. Put the characters you can actually read into these EXACT fields, not variations of them: "printed_text" (the pre-printed wording), "handwritten_text" (what a person wrote by hand), and, when you can resolve one, "part_no", "tag_id" and "date". Same reason as the fixed table names: a field called photo_text in one pass and visible_text_notes in the next cannot be queried together. Read PARTIAL values rather than skipping: "500.7402.52__" beats nothing. Only when a character is genuinely unreadable, leave that field null or mark the unreadable span - do NOT invent it, and do NOT replace the whole transcription with a description of what the object looks like. A scene description is a nice extra AFTER the text, never instead of it.
|
|
336
|
+
- IMAGE FILES uploaded as the file itself: if ANY readable character appears ANYWHERE in the image (a label, a stamp, a sign in the background) it counts as an image WITH text - transcribe it per the rule above, and also capture the layout (what appears where) and every entity named. Only a truly text-free image gets description first: a one-line caption, then the objects present with their attributes (type, color, count, condition, position). Either way, save what you extract onto the file's "src::" record with updateRecords, TAG every entity and identifier visible, and INDEX the one number the image offers (a measured value, an amount, a count).
|
|
337
|
+
- Whatever the file type, this file's identity is "src::" + its storage path (the "storage path" metadata line) - never the inline content or a temporary URL. That record ALREADY EXISTS: the upload pipeline creates it in table "file_summaries" (access group "authorized") before indexing starts, so posting it again is rejected as a duplicate unique_id. Reference it from every record you write, and add what you learn to it with updateRecords. If that update unexpectedly reports the record does not exist, post it yourself ONCE with that exact "src::" unique_id (table "file_summaries", access group "authorized") and carry on; this is the ONE exception to the do-NOT-post-the-file-record rules elsewhere in these instructions, because the source identity must never be dropped just because an update failed.
|
|
338
|
+
- REACHABILITY (hard rule): every record you write while indexing this file MUST be reachable from the file's "src::<storage path>" record by following reference - either reference that record directly, or reference something that already reaches it. A record with no reference, or one pointing outside this file's chain, is an ORPHAN: deleting or re-indexing the file removes the reachable records and leaves the orphan behind forever, where it keeps turning up in later answers as stale data. If you create an intermediate record that OTHER records reference (a page record that rows hang off, a sheet or section record), set source.can_remove_referencing_records to true on it; the delete cascade passes a delete through a record only when that record carries the flag OR a unique_id starting "src::" (the file record cascades because its unique_id starts with "src::"; the intermediates you create carry no "src::" id, so they need the flag), and it cascades ONE LEVEL AT A TIME, so EVERY intermediate record in a chain needs its own marker - an unmarked link stops the cascade there and everything below it survives as orphans. When in doubt, reference the file record directly and keep the chain flat.
|
|
339
|
+
- TABULAR data (any spreadsheet - .csv/.tsv/.xlsx/.xls/.ods, or sheet-like rows): you MUST save EVERY data row as its own record (ONE record per row) with that row's actual column values in the record's "data", keyed by the header names, in a table named EXACTLY "spreadsheet_rows". Do NOT summarize, sample only a few rows, or save just file metadata - index the whole sheet, window by window, until it ends. Make MULTIPLE postRecords calls in batches (e.g. 30-50 rows per call) rather than one oversized call. This per-row completeness OVERRIDES brevity. The file-level "src::" record ALREADY EXISTS - the upload pipeline creates it before indexing starts - so do NOT create it. Link EVERY per-row record to it via reference (set each row record's reference to exactly "src::" + the storage path, with NO sheet/window/summary suffix added; the row records themselves do NOT carry a src:: unique_id). Enrich that same record with sheet name(s), column headers and total row count via updateRecords rather than posting another one. The per-row records AND this reference linkage are BOTH mandatory: the linkage is what lets the whole sheet be found and cleaned up together when the file is re-indexed. INDEX each row record on the row's most useful NUMERIC column (named by its header) so rows sort and range-query; when the row has no numeric column, index the grid row number instead. TAG each row record with the sheet name, the file name, and the row's categorical values (a status, a category, a type) - tags are how rows are filtered without scanning the table.
|
|
340
|
+
- ONE RECORD PER GRID ROW, ALWAYS. "Row" means the numbered row of the sheet (R37 is one record), never a visual block, item, section or left/right pair. Sheets that repeat the same columns side by side (an A/B block beside a C/D block, "paired" or "mirrored" layouts) still get ONE record per grid row, holding BOTH sides - suffix the keys to keep them apart (PART_NO_A / PART_NO_B). Collapsing a 16-row window into 2 or 3 "block" records is the single most damaging mistake here: it silently loses most of the cells and makes every later total wrong, because some windows were counted per row and others per block. If a window shows rows R37 to R52, you save records for R37..R52 and the count you report is the number of grid rows you actually wrote.
|
|
341
|
+
- FIXED TABLE NAMES. Never invent a table name for one pass, and never vary the name between passes of the SAME file: that scatters one file's data across tables nobody can enumerate later, so the data is effectively lost even though every save succeeded. Use exactly "spreadsheet_rows" for spreadsheet row records, "book_chapters" for a chapter record, and "file_summaries" for the file-level record (which already exists, so update it and never post it). Embedded photos and other embedded files get NO table of your choosing: their records already exist in table "__MEDIA__", see EXTRACTED MEDIA below. For a content type none of those fit, choose ONE plain descriptive name, use that same name for every pass of the file, and never mint variants of it (inspection_items / item_records / sheet_items / inspection_data are four names for what is one table).
|
|
342
|
+
- EXTRACTED MEDIA: every PICTURE embedded in an uploaded document (photos, diagrams, chart images) is pulled out and saved as a real permanent file under "__MEDIA__/<the document's storage path>/<name>", and a record for each one ALREADY EXISTS in table "__MEDIA__" with unique_id "src::<that path>", reference "src::<the document>", and its path, anchor and sheet already in data. Do NOT create it - the unique_id is taken and your post is rejected. UPDATE it with updateRecords, addressed by that unique_id, adding what the file actually SHOWS plus TAGS for every identifier visible in it (part numbers, tag ids, item names, serial numbers). An update REPLACES the fields you send, so send the existing tags back with your new ones and keep every field already in data (path, anchor, sheet, source, mime, bytes). ONE FILE, ONE RECORD: never also create a photo record in another table. If the update reports that the record does not exist, create it with that same unique_id, reference and data.path - the path must never be lost. Audio and video clips and non-picture attachments are NOT extracted, so never claim a separate file or a "__MEDIA__" record exists for one of those.
|
|
343
|
+
- AUDIO files: transcribe the speech, and capture speakers (named where identifiable), the topics discussed, and timestamps of key moments in the record's data. TAG the language, the audio type (call, meeting, dictation, music), each speaker and every named entity; INDEX the duration in seconds as duration_seconds. VIDEO files: everything audio gets, PLUS transcribe on-screen text verbatim (same transcription discipline as photos) and capture the visual timeline - scene changes and what each scene shows, with timestamps. Same tags as audio plus every entity visible on screen, and INDEX duration_seconds here too. These audio and video rules apply to files UPLOADED AS FILES: the transcript and timeline land on the file's own "src::" record, which already exists. Audio or video embedded inside a document is NOT extracted, so never look for or promise a "__MEDIA__" record for it.
|
|
344
|
+
- EPUB / e-books / long-form books (.epub or any book-length prose, provided inline in reading order with chapter headings preserved): you MUST save ONE record per CHAPTER (or, when chapters are unclear, per major section/topic) in the table "book_chapters" - never collapse the whole book into a single record. INDEX each chapter record on its chapter number (so chapters sort and range-query in order) and include the chapter title among its tags; the record's "data" must capture the chapter title plus its order/number AND a substantive summary of that chapter's content (key events, arguments, characters, places, concepts, terms, notable quotes). Apply AS MANY relevant tags as possible to EVERY chapter record (characters, locations, themes, topics, key concepts, key terms, dates, named entities) so the book is easy to SEARCH and cross-reference later - this is the whole point. ALSO put the book-level facts (title, author, language, overall summary, chapter list / table of contents, genre/subjects) onto the "src::" file record that ALREADY EXISTS in "file_summaries", using updateRecords. Do NOT post a second book-level record, and set every chapter record's reference to exactly "src::" + the storage path. This per-chapter completeness OVERRIDES brevity; human-readable summaries only, never raw/binary bytes.
|
|
345
|
+
- URL SOURCES: when the source being indexed is a URL rather than an uploaded file (a temporary or signed URL that merely DELIVERS an uploaded file's bytes is not a URL source; that file keeps its storage-path identity), its identity is "src::" + the FULL URL INCLUDING the query string (the query string often selects the content, so dropping it collapses different pages into one identity). If no record with that unique_id exists, create it; if the slot is already taken, update that record or reference it - never mint a variant id. For a WEB PAGE: extract everything on it, infer the page's primary entity type when it is not obvious (product, listing, article, profile), TAG that entity type plus the entities on the page, and INDEX the ONE number every entity of that type can be compared by (a price for a product, a date for an article). Any OTHER URL (a file behind a link) is downloaded and indexed under whichever per-type rule above matches its content. When the URL's content offers more index points than one record carries, add reference-linked records reachable from its "src::" record.
|
|
346
|
+
- This is a background indexing task: do ALL the MCP saving FIRST, never reply mid-task, and never ask the user questions. Be exhaustive about meaning (and, for tabular data, about every row). SAVE AS YOU GO: persist each window's records before reading the next, so progress is never lost. If the file is so large you cannot finish in one turn, still save everything you have read so far; a follow-up pass will automatically continue from where you stopped. NEVER store raw or encoded file bytes in ANY field: no base64, no data: URIs, no hex or blob dumps. A long opaque non-human-readable string is not data - replace it with a structured description of what it encodes. If base64 or a data: URI is all you have for something, describe it conceptually and never paste it; if nothing human-readable can be extracted at all, OMIT that record rather than saving noise.
|
|
347
|
+
- COMPLETION SIGNAL: only when YOU paged the file yourself with readFileContent and it reported "END OF FILE", with every row/item saved, end your final message with the token INDEXING_COMPLETE on its own line. If more rows remain, do NOT write that token - leaving it out is how the system knows to run another pass to continue. When the file arrives INSIDE this message one window at a time (an embedded window of rows/text, or rendered PDF page images), you are NOT the one who decides it is finished: the system advances the window off the real page/row count and sends the next pass automatically, so save this window, report what you saved, and never imply you have seen the whole file.
|
|
328
348
|
- Only AFTER every save is done, send exactly ONE final message summarizing what you indexed - never just "Indexing complete", and never a raw/base64/binary value or a large pasted dump. Keep it to a few factual sentences or a short markdown bullet list covering: the file name, its content type, each table you wrote to with its record/row count and the key columns/fields or topics captured, and anything that could not be extracted. Follow this shape - Indexed <file name> (<content type>): saved <N> records to <table(s)> capturing <key columns/fields or topics>; could not extract: <gaps, or none>.`;
|
|
329
349
|
if (serviceDescription) {
|
|
330
350
|
systemPrompt += `
|
|
@@ -346,7 +366,7 @@ File metadata:
|
|
|
346
366
|
` : "");
|
|
347
367
|
if (options?.inlineContent) {
|
|
348
368
|
return head + `
|
|
349
|
-
The file's content was parsed by the client and is provided inline below. Read it directly
|
|
369
|
+
The file's content was parsed by the client and is provided inline below. Read it directly - do NOT fetch any URL for this file. Set every record's reference to exactly "src::" + the storage path above (not this content). That file record already exists, so enrich it with updateRecords rather than posting it.
|
|
350
370
|
|
|
351
371
|
----- BEGIN FILE CONTENT -----
|
|
352
372
|
${options.inlineContent}
|
|
@@ -354,7 +374,7 @@ ${options.inlineContent}
|
|
|
354
374
|
}
|
|
355
375
|
if (options?.inlineContentPlaceholder) {
|
|
356
376
|
return head + `
|
|
357
|
-
The file's text content was extracted on the server and is provided inline below. Read it directly
|
|
377
|
+
The file's text content was extracted on the server and is provided inline below. Read it directly - do NOT fetch any URL for this file. Set every record's reference to exactly "src::" + the storage path above (not this content). That file record already exists, so enrich it with updateRecords rather than posting it.
|
|
358
378
|
|
|
359
379
|
----- BEGIN FILE CONTENT -----
|
|
360
380
|
${options.inlineContentPlaceholder}
|
|
@@ -362,7 +382,7 @@ ${options.inlineContentPlaceholder}
|
|
|
362
382
|
}
|
|
363
383
|
if (options?.pagedRead) {
|
|
364
384
|
return head + `
|
|
365
|
-
Read this file with the readFileContent tool, using the storage path above - do NOT fetch a URL and do NOT rely on a single sample. readFileContent returns the file ONE WINDOW at a time: spreadsheets as coordinate-tagged grid rows (e.g. 'R4 A:E&I NUMBER | B:E1007'), scanned/large PDFs as rendered PAGE IMAGES, and windows may include embedded photos - LOOK at any images and datafy what they show. Page through EVERY window: for each window SAVE records for its rows/items/pages (postRecords, one record per row/item), THEN if the window says MORE REMAINS call readFileContent again with the cursor it gives you. Repeat until it says END OF FILE, so the WHOLE file is indexed. Do NOT stop after the first window and do NOT just write a summary.
|
|
385
|
+
Read this file with the readFileContent tool, using the storage path above - do NOT fetch a URL and do NOT rely on a single sample. readFileContent returns the file ONE WINDOW at a time: spreadsheets as coordinate-tagged grid rows (e.g. 'R4 A:E&I NUMBER | B:E1007'), scanned/large PDFs as rendered PAGE IMAGES, and windows may include embedded photos - LOOK at any images and datafy what they show. Page through EVERY window: for each window SAVE records for its rows/items/pages (postRecords, one record per row/item), THEN if the window says MORE REMAINS call readFileContent again with the cursor it gives you. Repeat until it says END OF FILE, so the WHOLE file is indexed. Do NOT stop after the first window and do NOT just write a summary. Set every record's reference to exactly "src::" + the storage path above; that file record already exists, so enrich it with updateRecords instead of posting it again.` + (attachment.url ? `
|
|
366
386
|
(A temporary URL is provided ONLY as a fallback if readFileContent fails: ${attachment.url})` : "");
|
|
367
387
|
}
|
|
368
388
|
return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
|
|
@@ -397,7 +417,11 @@ function buildRenderDatafy(placeholder) {
|
|
|
397
417
|
return `
|
|
398
418
|
${placeholder}
|
|
399
419
|
|
|
400
|
-
LOOK at each rendered page image in this message and DATAFY what it shows: for EVERY page call postRecords and save records - one record per row / table entry / line item visible on the page (or one record for the page if it is prose), capturing every value you can read (OCR the text, read tables cell by cell, describe any photos/diagrams).
|
|
420
|
+
LOOK at each rendered page image in this message and DATAFY what it shows: for EVERY page call postRecords and save records - one record per row / table entry / line item visible on the page (or one record for the page if it is prose), capturing every value you can read (OCR the text, read tables cell by cell, describe any photos/diagrams). Set EVERY record's reference to exactly "src::" + the storage path above. That file record ALREADY EXISTS, so do NOT post it, and do NOT give your page records a "src::" unique_id of their own. A record with no reference back to it is an ORPHAN: re-indexing the file deletes the linked records and leaves the orphan behind forever as stale data.
|
|
421
|
+
|
|
422
|
+
Each image is preceded by a label giving its DOCUMENT PAGE number. That label is the page's identity - use it, and ignore any page number PRINTED on the document itself (a scan often restarts its own numbering per section, so a footer reading "PAGE 4 OF 8" routinely disagrees with the real position). Whether a page is one you have already saved is stated in the note above the images - decide from that, never from a printed page number.
|
|
423
|
+
|
|
424
|
+
Transcribe COMPLETELY, not representatively. A table with twenty rows gets twenty records, not a sample of the first few - if a page has more rows than you can save comfortably, still save them all rather than summarising. Where a page carries an embedded text layer it is quoted above that page's image: it is the exact text and should be preferred over reading the pixels, with the image used for layout, tables, stamps and handwriting.
|
|
401
425
|
|
|
402
426
|
Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read the rest of the file and do NOT worry about the pages after this window: if any remain, the next window is rendered and sent to you automatically. Report only the pages you were actually shown - never imply you have seen the whole document.`;
|
|
403
427
|
}
|
|
@@ -416,9 +440,9 @@ This file is delivered to you ONE WINDOW at a time, embedded directly in this me
|
|
|
416
440
|
return head + buildRenderMeta(attachment) + where + `
|
|
417
441
|
${placeholder}
|
|
418
442
|
|
|
419
|
-
DATAFY this window: call postRecords and save records for everything in it - ONE RECORD PER ROW for tabular data (keyed by the column headers), or one record per section for prose. Capture every value you can read.
|
|
443
|
+
DATAFY this window: call postRecords and save records for everything in it - ONE RECORD PER ROW for tabular data (keyed by the column headers), or one record per section for prose. Capture every value you can read. The file-level record ALREADY EXISTS with unique_id "src::" + the storage path above: do NOT post it (a duplicate unique_id is rejected), enrich it with updateRecords, and link every row/section record to it by reference.
|
|
420
444
|
|
|
421
|
-
If this window has PHOTOS attached as images, LOOK at each one and datafy what it actually shows
|
|
445
|
+
If this window has PHOTOS attached as images, LOOK at each one and datafy what it actually shows. A \xABPHOTO ...\xBB marker in the grid text ties a picture to its row and comes in two forms. \xABPHOTO A88 -> __MEDIA__/...\xBB means the picture at cell A88 is saved as a permanent file at exactly that storage path, and its record in table "__MEDIA__" has unique_id "src::" + that path: UPDATE that record with updateRecords, adding what the picture SHOWS and TAGS for every identifier visible in it (part numbers, tag ids, item names, serial numbers). Do NOT create a duplicate and do NOT add a second photo record in another table: one file, one record. If that update reports the record does not exist, create it ONCE with that same unique_id, reference "src::" + the storage path above, table "__MEDIA__", access group "authorized", and data carrying the path - the path must never be lost. A bare \xABPHOTO A88\xBB marker with no arrow is a picture with no stored path of its own in this window: usually a repeat stored under an earlier anchor, or one too small to keep. NEVER construct a storage path or unique_id for it: find its record, if any, with getRecords reference "src::" + the storage path above, matching the cell against data.anchor or tags, and enrich what you find. The row record stays about its row's cells. Never report that photo contents could not be extracted when images are attached here.
|
|
422
446
|
|
|
423
447
|
Save records for THIS window only, then stop and report what you saved. Do NOT try to read the rest of the file, and do NOT call readFileContent - if more remains, the next window is read and sent to you automatically. Report only what you were actually shown, and never imply you have seen the whole file when the note beside the window says more remains.`;
|
|
424
448
|
}
|
|
@@ -431,11 +455,10 @@ File metadata:
|
|
|
431
455
|
- storage path: ${attachment.storagePath}
|
|
432
456
|
` + (attachment.mime ? `- mime type: ${attachment.mime}
|
|
433
457
|
` : "") + `
|
|
434
|
-
Records for the earlier windows/pages of this file are ALREADY saved (they reference "${src}"). First call getRecords with reference "${src}" to see how far the previous pass got (the furthest
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
Index the REMAINING windows - one record per row/item, looking at any page images or embedded photos - saving as you go until readFileContent reports END OF FILE. Do NOT re-save windows that are already saved. Use the storage path above for the "src::" unique_id. When the ENTIRE file is finally indexed, end your message with the token INDEXING_COMPLETE.`;
|
|
458
|
+
Records for the earlier windows/pages of this file are ALREADY saved (they reference "${src}"). First call getRecords with reference "${src}" to see how far the previous pass got (the furthest row/window already saved). The reference ALONE is the whole query: it returns every record written from this file across ALL tables and ALL access groups, so do NOT add table_name or access_group to narrow it. The response is PAGED, so keep fetching pages until it reports there are no more, and take the furthest point from the WHOLE set, never from the first page. Then call readFileContent with the storage path above and a CURSOR that RESUMES just after that point - do NOT start at the beginning. The cursor is derivable from what you already saved:
|
|
459
|
+
- Spreadsheet: the cursor is "<sheetIndex>:<nextRow>" (0-based sheet index, 1-based row). If you saved up to row R of sheet S, use cursor="S:R+1".
|
|
460
|
+
- Text: the cursor is the character offset already read.
|
|
461
|
+
Index the REMAINING windows - one record per row/item, looking at any page images or embedded photos - saving as you go until readFileContent reports END OF FILE. A \xABPHOTO <cell>\xBB marker in a window marks an embedded picture whose extracted file already has a record in table "__MEDIA__": find it with getRecords reference "src::" + the storage path above and match the cell against data.anchor or tags (a repeated picture is stored under its first anchor only), then enrich it with updateRecords. Never create a photo record of your own and never construct a path for one. Do NOT re-save windows that are already saved. Set every record's reference to exactly "src::" + the storage path above (no sheet, window or summary suffix added). That file record already exists, so do NOT post it; enrich it with updateRecords. When the ENTIRE file is finally indexed, end your message with the token INDEXING_COMPLETE.`;
|
|
439
462
|
}
|
|
440
463
|
|
|
441
464
|
// src/engine/errors.ts
|
|
@@ -549,6 +572,7 @@ var EXPIRED_ATTACHMENT_URL_HOST = "_expired_.url";
|
|
|
549
572
|
var EXPIRED_ATTACHMENT_URL_ORIGIN = "https://" + EXPIRED_ATTACHMENT_URL_HOST;
|
|
550
573
|
var LINK_LABEL_MAX_DISPLAY_CHARS = 32;
|
|
551
574
|
var EXPIRED_LINK_REFRESH_EXPIRES_SECONDS = 20 * 60;
|
|
575
|
+
var PREVIEW_BROWSER_CACHE_SECONDS = 7 * 24 * 60 * 60;
|
|
552
576
|
var LINK_REFRESH_WINDOW_MS = (EXPIRED_LINK_REFRESH_EXPIRES_SECONDS - 5 * 60) * 1e3;
|
|
553
577
|
function createInlineLinkRegex() {
|
|
554
578
|
return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
|
|
@@ -568,7 +592,7 @@ function encodePathSegments(path) {
|
|
|
568
592
|
function normalizeAttachmentPathCandidate(value) {
|
|
569
593
|
return safeDecodeURIComponent((value || "").trim()).replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/");
|
|
570
594
|
}
|
|
571
|
-
function extractRemotePathFromAttachmentHref(href,
|
|
595
|
+
function extractRemotePathFromAttachmentHref(href, projectId) {
|
|
572
596
|
try {
|
|
573
597
|
var parsed = new URL(href);
|
|
574
598
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
@@ -576,7 +600,7 @@ function extractRemotePathFromAttachmentHref(href, serviceId) {
|
|
|
576
600
|
var segs = path.split("/").filter(Boolean);
|
|
577
601
|
if (!segs.length) return null;
|
|
578
602
|
var HEX = /^[a-f0-9]{32,}$/i;
|
|
579
|
-
var sid =
|
|
603
|
+
var sid = projectId || "";
|
|
580
604
|
var start = 0;
|
|
581
605
|
while (start < segs.length) {
|
|
582
606
|
var seg = segs[start];
|
|
@@ -600,13 +624,13 @@ function getExpiredAttachmentVisiblePath(remotePath, fallback) {
|
|
|
600
624
|
function buildDisplayExpiredAttachmentHref(remotePath, fallback) {
|
|
601
625
|
return EXPIRED_ATTACHMENT_URL_ORIGIN + "/" + encodePathSegments(getExpiredAttachmentVisiblePath(remotePath, fallback));
|
|
602
626
|
}
|
|
603
|
-
function isServiceDbAttachmentHref(href,
|
|
604
|
-
if (!
|
|
627
|
+
function isServiceDbAttachmentHref(href, projectId) {
|
|
628
|
+
if (!projectId) return false;
|
|
605
629
|
try {
|
|
606
630
|
var parsed = new URL(href);
|
|
607
631
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
608
632
|
var segs = normalizeAttachmentPathCandidate(parsed.pathname || "").split("/").filter(Boolean);
|
|
609
|
-
return segs.length > 0 && segs[0] ===
|
|
633
|
+
return segs.length > 0 && segs[0] === projectId;
|
|
610
634
|
} catch (e) {
|
|
611
635
|
return false;
|
|
612
636
|
}
|
|
@@ -621,12 +645,12 @@ function readExpiredAttachmentHref(href) {
|
|
|
621
645
|
return null;
|
|
622
646
|
}
|
|
623
647
|
}
|
|
624
|
-
function sanitizeAttachmentLinksForHistory(content,
|
|
648
|
+
function sanitizeAttachmentLinksForHistory(content, projectId, forAssistant) {
|
|
625
649
|
if (!content) return content;
|
|
626
650
|
if (!forAssistant && content.indexOf("Attached files:") === -1) return content;
|
|
627
651
|
return content.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, function(_m, label, href) {
|
|
628
|
-
if (!isServiceDbAttachmentHref(href,
|
|
629
|
-
var remotePath = extractRemotePathFromAttachmentHref(href,
|
|
652
|
+
if (!isServiceDbAttachmentHref(href, projectId)) return _m;
|
|
653
|
+
var remotePath = extractRemotePathFromAttachmentHref(href, projectId);
|
|
630
654
|
var fullPath = remotePath || normalizeAttachmentPathCandidate(label);
|
|
631
655
|
if (!fullPath) return _m;
|
|
632
656
|
return "[" + label + "](" + buildDisplayExpiredAttachmentHref(fullPath, label) + ")";
|
|
@@ -667,6 +691,31 @@ function normalizeTrailingInlineToken(value) {
|
|
|
667
691
|
out = out.replace(/[`'"*>]+$/, "");
|
|
668
692
|
return out;
|
|
669
693
|
}
|
|
694
|
+
var PREVIEWABLE_IMAGE_CONTENT_TYPES = {
|
|
695
|
+
png: "image/png",
|
|
696
|
+
jpg: "image/jpeg",
|
|
697
|
+
jpeg: "image/jpeg",
|
|
698
|
+
gif: "image/gif",
|
|
699
|
+
webp: "image/webp",
|
|
700
|
+
avif: "image/avif",
|
|
701
|
+
bmp: "image/bmp"
|
|
702
|
+
};
|
|
703
|
+
function previewableExtOf(nameOrPath) {
|
|
704
|
+
var v = String(nameOrPath || "");
|
|
705
|
+
var cut = v.search(/[?#]/);
|
|
706
|
+
if (cut !== -1) v = v.slice(0, cut);
|
|
707
|
+
v = v.replace(/[\\/]+$/, "");
|
|
708
|
+
var dot = v.lastIndexOf(".");
|
|
709
|
+
if (dot <= 0) return "";
|
|
710
|
+
var ext = v.slice(dot + 1).trim().toLowerCase();
|
|
711
|
+
return /^[a-z0-9]+$/.test(ext) ? ext : "";
|
|
712
|
+
}
|
|
713
|
+
function isPreviewableImagePath(nameOrPath) {
|
|
714
|
+
return !!PREVIEWABLE_IMAGE_CONTENT_TYPES[previewableExtOf(nameOrPath)];
|
|
715
|
+
}
|
|
716
|
+
function previewImageContentType(nameOrPath) {
|
|
717
|
+
return PREVIEWABLE_IMAGE_CONTENT_TYPES[previewableExtOf(nameOrPath)] || null;
|
|
718
|
+
}
|
|
670
719
|
function classifyInlineLink(full, groups, ctx) {
|
|
671
720
|
var g1 = groups[0], g2 = groups[1], g3 = groups[2], g4 = groups[3], g5 = groups[4], g6 = groups[5];
|
|
672
721
|
var dbHostPrefix = (ctx.dbHostPrefix || "").toLowerCase();
|
|
@@ -680,17 +729,19 @@ function classifyInlineLink(full, groups, ctx) {
|
|
|
680
729
|
if (!remotePath2) return null;
|
|
681
730
|
var expiredHref = buildDisplayExpiredAttachmentHref(remotePath2, label);
|
|
682
731
|
var cached = fresh(expiredHref);
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
remotePath: remotePath2
|
|
692
|
-
}
|
|
732
|
+
var part = {
|
|
733
|
+
type: "link",
|
|
734
|
+
label: truncateLabelForDisplay(label),
|
|
735
|
+
fullLabel: label,
|
|
736
|
+
href: cached || expiredHref,
|
|
737
|
+
expired: !cached,
|
|
738
|
+
expiredHref,
|
|
739
|
+
remotePath: remotePath2
|
|
693
740
|
};
|
|
741
|
+
var ext = previewableExtOf(remotePath2);
|
|
742
|
+
var ct = PREVIEWABLE_IMAGE_CONTENT_TYPES[ext];
|
|
743
|
+
if (ct) part.image = { ext, contentType: ct };
|
|
744
|
+
return { part };
|
|
694
745
|
};
|
|
695
746
|
if (g1) {
|
|
696
747
|
var rawPath = normalizeTrailingInlineToken(g1);
|
|
@@ -703,14 +754,15 @@ function classifyInlineLink(full, groups, ctx) {
|
|
|
703
754
|
tail
|
|
704
755
|
};
|
|
705
756
|
}
|
|
706
|
-
var srcPath = readExpiredAttachmentHref(rawPath) || (srcIsUrl ? extractRemotePathFromAttachmentHref(rawPath, ctx.
|
|
757
|
+
var srcPath = readExpiredAttachmentHref(rawPath) || (srcIsUrl ? extractRemotePathFromAttachmentHref(rawPath, ctx.projectId) || normalizeAttachmentPathCandidate(rawPath) : rawPath.trim().replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/"));
|
|
707
758
|
var srcBuilt = asStoredFile(srcPath, srcPath);
|
|
708
759
|
return srcBuilt ? { part: srcBuilt.part, tail } : null;
|
|
709
760
|
}
|
|
710
761
|
if (g4 && g5) {
|
|
711
762
|
var dbTarget = /^db:(.+)$/i.exec(g5.trim());
|
|
712
763
|
if (dbTarget) {
|
|
713
|
-
var
|
|
764
|
+
var rawDbPath = dbTarget[1].trim().replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/");
|
|
765
|
+
var declared = asStoredFile(rawDbPath, g4);
|
|
714
766
|
if (!declared) return null;
|
|
715
767
|
declared.part.label = truncateLabelForDisplay(g4);
|
|
716
768
|
declared.part.fullLabel = g4;
|
|
@@ -755,8 +807,8 @@ function classifyInlineLink(full, groups, ctx) {
|
|
|
755
807
|
return withTail(carriedBuilt);
|
|
756
808
|
}
|
|
757
809
|
}
|
|
758
|
-
if (isServiceDbAttachmentHref(originalHref, ctx.
|
|
759
|
-
var remotePath = extractRemotePathFromAttachmentHref(originalHref, ctx.
|
|
810
|
+
if (isServiceDbAttachmentHref(originalHref, ctx.projectId)) {
|
|
811
|
+
var remotePath = extractRemotePathFromAttachmentHref(originalHref, ctx.projectId);
|
|
760
812
|
if (remotePath) {
|
|
761
813
|
var dbBuilt = asStoredFile(remotePath, getExpiredAttachmentVisiblePath(remotePath, urlLabel));
|
|
762
814
|
if (dbBuilt) return withTail(dbBuilt);
|
|
@@ -766,6 +818,19 @@ function classifyInlineLink(full, groups, ctx) {
|
|
|
766
818
|
part: { type: "link", label: truncateLabelForDisplay(urlLabel), fullLabel: urlLabel, href: originalHref, expired: false }
|
|
767
819
|
});
|
|
768
820
|
}
|
|
821
|
+
function linkUnavailableKeyForPath(remotePath) {
|
|
822
|
+
return "path:" + (remotePath || "");
|
|
823
|
+
}
|
|
824
|
+
function linkUnavailableKeyForHref(href) {
|
|
825
|
+
return "href:" + (href || "");
|
|
826
|
+
}
|
|
827
|
+
function isLinkUnavailable(link, map) {
|
|
828
|
+
if (!link || !map) return false;
|
|
829
|
+
if (link.remotePath && map[linkUnavailableKeyForPath(link.remotePath)]) return true;
|
|
830
|
+
if (link.expiredHref && map[linkUnavailableKeyForHref(link.expiredHref)]) return true;
|
|
831
|
+
if (link.href && map[linkUnavailableKeyForHref(link.href)]) return true;
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
769
834
|
function truncateLabelForDisplay(label) {
|
|
770
835
|
if (!label) return label;
|
|
771
836
|
if (label.length <= LINK_LABEL_MAX_DISPLAY_CHARS) return label;
|
|
@@ -805,15 +870,15 @@ function registerModelContextWindows(models) {
|
|
|
805
870
|
}
|
|
806
871
|
}
|
|
807
872
|
var projectContextWindows = {};
|
|
808
|
-
function setProjectContextWindow(
|
|
809
|
-
var key = (
|
|
873
|
+
function setProjectContextWindow(projectId, tokens) {
|
|
874
|
+
var key = (projectId || "").trim();
|
|
810
875
|
if (!key) return;
|
|
811
876
|
var n = Number(tokens);
|
|
812
877
|
if (Number.isFinite(n) && n > 0) projectContextWindows[key] = Math.floor(n);
|
|
813
878
|
else delete projectContextWindows[key];
|
|
814
879
|
}
|
|
815
|
-
function getProjectContextWindow(
|
|
816
|
-
var key = (
|
|
880
|
+
function getProjectContextWindow(projectId) {
|
|
881
|
+
var key = (projectId || "").trim();
|
|
817
882
|
return key && projectContextWindows[key] ? projectContextWindows[key] : null;
|
|
818
883
|
}
|
|
819
884
|
var OUTPUT_TOKEN_RESERVE = 22e3;
|
|
@@ -830,8 +895,8 @@ function estimateTextTokens(text) {
|
|
|
830
895
|
function estimateMessageTokens(msg) {
|
|
831
896
|
return estimateTextTokens(msg.content) + estimateTextTokens(msg.role) + 6;
|
|
832
897
|
}
|
|
833
|
-
function getContextWindow(platform, model,
|
|
834
|
-
var override =
|
|
898
|
+
function getContextWindow(platform, model, projectId) {
|
|
899
|
+
var override = projectId ? getProjectContextWindow(projectId) : null;
|
|
835
900
|
if (override) return override;
|
|
836
901
|
var normalized = (model || "").trim().toLowerCase();
|
|
837
902
|
if (normalized) {
|
|
@@ -850,12 +915,12 @@ function stripFileBlocksFromHistory(content) {
|
|
|
850
915
|
return content.replace(/```([^\n`]+?\.[^\s.`]+)\n[\s\S]*?```/g, "[file previously attached: $1]");
|
|
851
916
|
}
|
|
852
917
|
function buildBoundedChatMessages(options) {
|
|
853
|
-
var contextWindow = getContextWindow(options.platform, options.model, options.
|
|
918
|
+
var contextWindow = getContextWindow(options.platform, options.model, options.projectId);
|
|
854
919
|
var contextBasedBudget = Math.max(
|
|
855
920
|
MIN_INPUT_TOKEN_BUDGET,
|
|
856
921
|
contextWindow - OUTPUT_TOKEN_RESERVE - TOOL_AND_RESPONSE_BUFFER
|
|
857
922
|
);
|
|
858
|
-
var scaled = !!(options.
|
|
923
|
+
var scaled = !!(options.projectId && getProjectContextWindow(options.projectId));
|
|
859
924
|
var claudeInputCap = scaled ? Math.max(CLAUDE_PER_REQUEST_INPUT_CAP, Math.round(contextBasedBudget * CLAUDE_INPUT_CAP_RATIO)) : CLAUDE_PER_REQUEST_INPUT_CAP;
|
|
860
925
|
var availableInputBudget = options.platform === "claude" ? Math.min(contextBasedBudget, claudeInputCap) : contextBasedBudget;
|
|
861
926
|
var systemCost = estimateTextTokens(options.systemPrompt) + 12;
|
|
@@ -867,7 +932,7 @@ function buildBoundedChatMessages(options) {
|
|
|
867
932
|
var trimmed = windowed.map(function(m, i2) {
|
|
868
933
|
if (i2 === latestIndex) return m;
|
|
869
934
|
var stripped = stripFileBlocksFromHistory(m.content);
|
|
870
|
-
var sanitized = sanitizeAttachmentLinksForHistory(stripped, options.
|
|
935
|
+
var sanitized = sanitizeAttachmentLinksForHistory(stripped, options.projectId, m.role !== "user");
|
|
871
936
|
return Object.assign({}, m, { content: sanitized });
|
|
872
937
|
});
|
|
873
938
|
var bounded = [], used = 0;
|
|
@@ -1047,6 +1112,147 @@ function prepareDownloadText(filename, body) {
|
|
|
1047
1112
|
};
|
|
1048
1113
|
}
|
|
1049
1114
|
|
|
1115
|
+
// src/engine/link_markup.ts
|
|
1116
|
+
function escapeInlineHtml(v) {
|
|
1117
|
+
return String(v == null ? "" : v).replace(/[&<>"']/g, function(ch) {
|
|
1118
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch];
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
var IMAGE_PREVIEWS_PER_MESSAGE = 8;
|
|
1122
|
+
var INLINE_LINK_GLYPH = "\u2197";
|
|
1123
|
+
var INLINE_LINK_UNAVAILABLE_GLYPH = "\u2715";
|
|
1124
|
+
var INLINE_LINK_UNAVAILABLE_SUFFIX = " (unavailable)";
|
|
1125
|
+
function renderInlineLinkHtml(link, opts) {
|
|
1126
|
+
var o = opts || {};
|
|
1127
|
+
var unavailable = !!o.unavailable;
|
|
1128
|
+
var refreshing = !unavailable && !!o.refreshing;
|
|
1129
|
+
var full = link.fullLabel || link.label;
|
|
1130
|
+
var preview = !!link.image && !!link.remotePath && o.allowImagePreview !== false && !unavailable;
|
|
1131
|
+
var cls = ["bq-link-button"];
|
|
1132
|
+
if (link.expired) cls.push("is-expired");
|
|
1133
|
+
if (refreshing) cls.push("is-refreshing");
|
|
1134
|
+
if (unavailable) cls.push("is-unavailable");
|
|
1135
|
+
if (preview) cls.push("is-image-preview");
|
|
1136
|
+
var labelText = (unavailable ? INLINE_LINK_UNAVAILABLE_GLYPH : INLINE_LINK_GLYPH) + " " + link.label + (unavailable ? INLINE_LINK_UNAVAILABLE_SUFFIX : refreshing ? " (fetching...)" : "");
|
|
1137
|
+
var attrs = ['class="' + cls.join(" ") + '"'];
|
|
1138
|
+
if (unavailable) attrs.push('aria-disabled="true"', 'data-bq-unavailable="1"');
|
|
1139
|
+
else attrs.push('href="' + escapeInlineHtml(link.href) + '"', 'target="_blank"', 'rel="noopener noreferrer"');
|
|
1140
|
+
attrs.push('title="' + escapeInlineHtml(unavailable ? full + INLINE_LINK_UNAVAILABLE_SUFFIX : full) + '"');
|
|
1141
|
+
if (!preview && !unavailable) attrs.push('download="' + escapeInlineHtml(full) + '"');
|
|
1142
|
+
attrs.push('data-bq-link="1"');
|
|
1143
|
+
if (link.expired && !unavailable) attrs.push('data-bq-expired="1"');
|
|
1144
|
+
if (link.expiredHref) attrs.push('data-bq-expired-href="' + escapeInlineHtml(link.expiredHref) + '"');
|
|
1145
|
+
if (link.remotePath) attrs.push('data-bq-remote-path="' + escapeInlineHtml(link.remotePath) + '"');
|
|
1146
|
+
if (link.fullLabel) attrs.push('data-bq-full-label="' + escapeInlineHtml(link.fullLabel) + '"');
|
|
1147
|
+
if (!preview) return "<a " + attrs.join(" ") + ">" + escapeInlineHtml(labelText) + "</a>";
|
|
1148
|
+
return "<a " + attrs.join(" ") + '><img class="bq-img-preview" alt="' + escapeInlineHtml(full) + '" data-bq-img-path="' + escapeInlineHtml(link.remotePath || "") + '" data-bq-img-type="' + escapeInlineHtml(link.image ? link.image.contentType : "") + '" loading="lazy" decoding="async"><span class="bq-loader" data-bq-img-loader="1"></span><span class="bq-img-preview-caption" translate="no">' + escapeInlineHtml(labelText) + "</span></a>";
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// src/engine/image_preview.ts
|
|
1152
|
+
var previewUrlCache = /* @__PURE__ */ Object.create(null);
|
|
1153
|
+
var previewInFlight = /* @__PURE__ */ Object.create(null);
|
|
1154
|
+
function cacheKey(scope, path) {
|
|
1155
|
+
return scope + "\0" + path;
|
|
1156
|
+
}
|
|
1157
|
+
function clearImagePreviewCache(scope) {
|
|
1158
|
+
if (!scope) {
|
|
1159
|
+
previewUrlCache = /* @__PURE__ */ Object.create(null);
|
|
1160
|
+
previewInFlight = /* @__PURE__ */ Object.create(null);
|
|
1161
|
+
staleImagePreviews = /* @__PURE__ */ Object.create(null);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
var prefix = scope + "\0";
|
|
1165
|
+
for (var k in previewUrlCache) if (k.indexOf(prefix) === 0) delete previewUrlCache[k];
|
|
1166
|
+
for (var f in previewInFlight) if (f.indexOf(prefix) === 0) delete previewInFlight[f];
|
|
1167
|
+
for (var s in staleImagePreviews) if (s.indexOf(prefix) === 0) delete staleImagePreviews[s];
|
|
1168
|
+
}
|
|
1169
|
+
function peekImagePreviewUrl(ctx, remotePath) {
|
|
1170
|
+
var hit = previewUrlCache[cacheKey(ctx.scope, remotePath)];
|
|
1171
|
+
if (hit && Date.now() - hit.at < LINK_REFRESH_WINDOW_MS) return hit.url;
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
function resolveImagePreviewUrl(ctx, remotePath, contentType, refresh) {
|
|
1175
|
+
var key = cacheKey(ctx.scope, remotePath);
|
|
1176
|
+
if (staleImagePreviews[key]) {
|
|
1177
|
+
delete staleImagePreviews[key];
|
|
1178
|
+
refresh = true;
|
|
1179
|
+
}
|
|
1180
|
+
if (refresh) {
|
|
1181
|
+
delete previewUrlCache[key];
|
|
1182
|
+
delete previewInFlight[key];
|
|
1183
|
+
} else {
|
|
1184
|
+
var warm = peekImagePreviewUrl(ctx, remotePath);
|
|
1185
|
+
if (warm) return Promise.resolve(warm);
|
|
1186
|
+
var flight = previewInFlight[key];
|
|
1187
|
+
if (flight) return flight;
|
|
1188
|
+
}
|
|
1189
|
+
var run = ctx.mint(remotePath, contentType, refresh).then(function(url) {
|
|
1190
|
+
if (previewInFlight[key] === run) {
|
|
1191
|
+
previewUrlCache[key] = { url, at: Date.now() };
|
|
1192
|
+
delete previewInFlight[key];
|
|
1193
|
+
}
|
|
1194
|
+
return url;
|
|
1195
|
+
}, function(e) {
|
|
1196
|
+
if (previewInFlight[key] === run) delete previewInFlight[key];
|
|
1197
|
+
throw e;
|
|
1198
|
+
});
|
|
1199
|
+
previewInFlight[key] = run;
|
|
1200
|
+
return run;
|
|
1201
|
+
}
|
|
1202
|
+
function markImagePreviewStale(scope, remotePath) {
|
|
1203
|
+
if (!scope || !remotePath) return;
|
|
1204
|
+
staleImagePreviews[cacheKey(scope, remotePath)] = true;
|
|
1205
|
+
delete previewUrlCache[cacheKey(scope, remotePath)];
|
|
1206
|
+
}
|
|
1207
|
+
var staleImagePreviews = /* @__PURE__ */ Object.create(null);
|
|
1208
|
+
function hydrateImagePreviews(imgs, ctx) {
|
|
1209
|
+
for (var i = 0; i < imgs.length; i++) hydrateOne(imgs[i], ctx);
|
|
1210
|
+
}
|
|
1211
|
+
function hydrateOne(img, ctx) {
|
|
1212
|
+
if (img.getAttribute("data-bq-img-state")) return;
|
|
1213
|
+
var path = img.getAttribute("data-bq-img-path");
|
|
1214
|
+
var type = img.getAttribute("data-bq-img-type") || "";
|
|
1215
|
+
if (!path) {
|
|
1216
|
+
img.setAttribute("data-bq-img-state", "error");
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
img.setAttribute("data-bq-img-state", "loading");
|
|
1220
|
+
img.addEventListener("load", function() {
|
|
1221
|
+
img.setAttribute("data-bq-img-state", "ready");
|
|
1222
|
+
if (ctx.onLoad) ctx.onLoad(path);
|
|
1223
|
+
});
|
|
1224
|
+
img.addEventListener("error", function() {
|
|
1225
|
+
onImageError(img, ctx, path, type);
|
|
1226
|
+
});
|
|
1227
|
+
var warm = peekImagePreviewUrl(ctx, path);
|
|
1228
|
+
if (warm) {
|
|
1229
|
+
img.setAttribute("src", warm);
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
resolveImagePreviewUrl(ctx, path, type).then(function(url) {
|
|
1233
|
+
if (img.getAttribute("data-bq-img-state") !== "loading") return;
|
|
1234
|
+
img.setAttribute("src", url);
|
|
1235
|
+
}, function(e) {
|
|
1236
|
+
img.setAttribute("data-bq-img-state", "error");
|
|
1237
|
+
if (ctx.onError) ctx.onError(path, e);
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
function onImageError(img, ctx, path, type) {
|
|
1241
|
+
if (img.getAttribute("data-bq-img-retry") === "1") {
|
|
1242
|
+
img.setAttribute("data-bq-img-state", "error");
|
|
1243
|
+
if (ctx.onError) ctx.onError(path, new Error("image preview failed to load"));
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
img.setAttribute("data-bq-img-retry", "1");
|
|
1247
|
+
img.removeAttribute("src");
|
|
1248
|
+
resolveImagePreviewUrl(ctx, path, type, true).then(function(url) {
|
|
1249
|
+
img.setAttribute("src", url);
|
|
1250
|
+
}, function(e) {
|
|
1251
|
+
img.setAttribute("data-bq-img-state", "error");
|
|
1252
|
+
if (ctx.onError) ctx.onError(path, e);
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1050
1256
|
// src/engine/time.ts
|
|
1051
1257
|
function wallClockNow() {
|
|
1052
1258
|
return Date.now();
|
|
@@ -1121,6 +1327,25 @@ var DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
|
|
|
1121
1327
|
var DEFAULT_OPENAI_MODEL = "gpt-5.6-luna";
|
|
1122
1328
|
var mcpUrl = () => chatEngineConfig().mcpBaseUrl;
|
|
1123
1329
|
var clientSecretRequest = (opts) => chatEngineConfig().clientSecretRequest(opts);
|
|
1330
|
+
var VARIANT_IMAGE_DETAIL = "original";
|
|
1331
|
+
var VARIANT_TEXT_VERBOSITY = "high";
|
|
1332
|
+
var OLDEST_NANO_REASONING_EFFORT = "high";
|
|
1333
|
+
var isOpenAINano = (model) => {
|
|
1334
|
+
const normalized = (model).trim().toLowerCase();
|
|
1335
|
+
if (!/(^|-)nano(-|$)/.test(normalized)) return false;
|
|
1336
|
+
const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
|
|
1337
|
+
if (!match) return false;
|
|
1338
|
+
const major = Number(match[1]);
|
|
1339
|
+
const minor = match[2] === void 0 ? null : Number(match[2]);
|
|
1340
|
+
return major > 5 || major === 5 && minor !== null && minor >= 4;
|
|
1341
|
+
};
|
|
1342
|
+
var variantIndexingOptions = (model) => {
|
|
1343
|
+
if (!isOpenAINano(model) || !isOldestNano(model)) return {};
|
|
1344
|
+
return {
|
|
1345
|
+
...{ text: { verbosity: VARIANT_TEXT_VERBOSITY } } ,
|
|
1346
|
+
...{ reasoning: { effort: OLDEST_NANO_REASONING_EFFORT } }
|
|
1347
|
+
};
|
|
1348
|
+
};
|
|
1124
1349
|
var getOpenAIImageDetail = (model) => {
|
|
1125
1350
|
const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
|
|
1126
1351
|
const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
|
|
@@ -1134,12 +1359,40 @@ var getOpenAIImageDetail = (model) => {
|
|
|
1134
1359
|
if (!supportsOriginal) {
|
|
1135
1360
|
return DEFAULT_OPENAI_IMAGE_DETAIL;
|
|
1136
1361
|
}
|
|
1137
|
-
return isVariant ?
|
|
1362
|
+
return isVariant ? VARIANT_IMAGE_DETAIL : "original";
|
|
1138
1363
|
};
|
|
1139
1364
|
var getRenderImageDetail = (model) => {
|
|
1140
1365
|
const detail = getOpenAIImageDetail(model);
|
|
1141
1366
|
return detail === DEFAULT_OPENAI_IMAGE_DETAIL ? "high" : detail;
|
|
1142
1367
|
};
|
|
1368
|
+
var OPENAI_VERSIONED_ID = /^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/;
|
|
1369
|
+
var isRecognisedOpenAIVersion = (model) => OPENAI_VERSIONED_ID.test((model || DEFAULT_OPENAI_MODEL).trim().toLowerCase());
|
|
1370
|
+
var isOldestNano = (model) => {
|
|
1371
|
+
const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
|
|
1372
|
+
if (!/(^|-)nano(-|$)/.test(normalized)) return false;
|
|
1373
|
+
const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
|
|
1374
|
+
if (!match) return true;
|
|
1375
|
+
const major = Number(match[1]);
|
|
1376
|
+
const minor = match[2] === void 0 ? null : Number(match[2]);
|
|
1377
|
+
if (major < 5) return true;
|
|
1378
|
+
if (major > 5) return false;
|
|
1379
|
+
return minor === null || minor <= 4;
|
|
1380
|
+
};
|
|
1381
|
+
var SMALL_TIER_PAGES_PER_WINDOW = 2;
|
|
1382
|
+
var DOWNSAMPLED_TIER_TILE = 2;
|
|
1383
|
+
function getVisionProfile(model) {
|
|
1384
|
+
const detail = getRenderImageDetail(model);
|
|
1385
|
+
if (!isRecognisedOpenAIVersion(model)) {
|
|
1386
|
+
return { detail, pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
|
|
1387
|
+
}
|
|
1388
|
+
if (detail !== "original") {
|
|
1389
|
+
return { detail, pagesPerWindow: SMALL_TIER_PAGES_PER_WINDOW, tile: DOWNSAMPLED_TIER_TILE };
|
|
1390
|
+
}
|
|
1391
|
+
if (isOldestNano(model)) {
|
|
1392
|
+
return { detail, pagesPerWindow: SMALL_TIER_PAGES_PER_WINDOW, tile: 1 };
|
|
1393
|
+
}
|
|
1394
|
+
return { detail, pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
|
|
1395
|
+
}
|
|
1143
1396
|
var IMAGE_URL_REGEX = /\bhttps?:\/\/[^\s<>"'()\[\]]+?\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s<>"'()\[\]]*)?/gi;
|
|
1144
1397
|
function transformContentWithImages(content) {
|
|
1145
1398
|
if (typeof content !== "string" || !content) {
|
|
@@ -1220,6 +1473,7 @@ function applyHistoryCacheBreakpoint(messages) {
|
|
|
1220
1473
|
});
|
|
1221
1474
|
}
|
|
1222
1475
|
var POLL_INTERVAL = 3e3;
|
|
1476
|
+
var MAX_CONCURRENT_BG_POLLS = 6;
|
|
1223
1477
|
async function callClaudeWithMcp({
|
|
1224
1478
|
prompt,
|
|
1225
1479
|
messages,
|
|
@@ -1384,16 +1638,18 @@ async function notifyAgentSaveAttachment(info) {
|
|
|
1384
1638
|
const renderFrom = Math.max(0, info.renderFrom || 0);
|
|
1385
1639
|
const renderPlaceholder = visionFile ? makeRenderPlaceholder(attachment.storagePath) : void 0;
|
|
1386
1640
|
const renderDetail = platform === "openai" ? getRenderImageDetail(info.model || DEFAULT_OPENAI_MODEL) : void 0;
|
|
1641
|
+
const visionProfile = platform === "openai" ? getVisionProfile(info.model || DEFAULT_OPENAI_MODEL) : { pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
|
|
1387
1642
|
const skapiRender = visionFile && renderPlaceholder ? {
|
|
1388
1643
|
_skapi_render: [
|
|
1389
1644
|
{
|
|
1390
1645
|
path: attachment.storagePath,
|
|
1391
1646
|
from: renderFrom,
|
|
1392
|
-
count:
|
|
1647
|
+
count: visionProfile.pagesPerWindow,
|
|
1393
1648
|
placeholder: renderPlaceholder,
|
|
1394
1649
|
name: attachment.name,
|
|
1395
1650
|
mime: attachment.mime,
|
|
1396
1651
|
detail: renderDetail,
|
|
1652
|
+
tile: visionProfile.tile,
|
|
1397
1653
|
auto_continue: true,
|
|
1398
1654
|
continue_text: buildIndexingRenderContinueTemplate(attachment, renderPlaceholder)
|
|
1399
1655
|
}
|
|
@@ -1410,6 +1666,12 @@ async function notifyAgentSaveAttachment(info) {
|
|
|
1410
1666
|
name: attachment.name,
|
|
1411
1667
|
mime: attachment.mime,
|
|
1412
1668
|
kind: "window",
|
|
1669
|
+
// Same per-image `detail` the render path sends. Without it the worker falls
|
|
1670
|
+
// back to its model-blind default of 'high', so a spreadsheet's embedded
|
|
1671
|
+
// photos were tiled at lower resolution than the SAME model gets for a PDF
|
|
1672
|
+
// page or a chat attachment. That is why a model could describe an attached
|
|
1673
|
+
// photo but reported the pictures inside a sheet as only partly legible.
|
|
1674
|
+
detail: renderDetail,
|
|
1413
1675
|
auto_continue: true,
|
|
1414
1676
|
continue_text: buildIndexingWindowMessage(attachment, windowPlaceholder, true)
|
|
1415
1677
|
}
|
|
@@ -1419,13 +1681,27 @@ async function notifyAgentSaveAttachment(info) {
|
|
|
1419
1681
|
const serverExtract = !visionFile && !windowedRead && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
|
|
1420
1682
|
const placeholder = serverExtract ? makeExtractPlaceholder(attachment.storagePath) : void 0;
|
|
1421
1683
|
const extractContent = serverExtract && placeholder ? [{ path: attachment.storagePath, placeholder, name: attachment.name, mime: attachment.mime }] : void 0;
|
|
1422
|
-
const skapiExtract = extractContent && extractContent.length ? {
|
|
1684
|
+
const skapiExtract = extractContent && extractContent.length ? {
|
|
1685
|
+
_skapi_extract: extractContent.map((d) => ({
|
|
1686
|
+
...d,
|
|
1687
|
+
// FIRST pass of an INDEXING run only: tells the worker to also pull the
|
|
1688
|
+
// file's embedded pictures into __MEDIA__ and register their records.
|
|
1689
|
+
// Chat-turn extraction (callClaudeWithMcp / callOpenAIWithPublicMcp)
|
|
1690
|
+
// never sets this, so merely ATTACHING a file to a chat message cannot
|
|
1691
|
+
// write media records; a CONTINUE pass skips it because the first pass
|
|
1692
|
+
// already saved (the save is whole-file, not windowed).
|
|
1693
|
+
save_media: !continuing
|
|
1694
|
+
}))
|
|
1695
|
+
} : {};
|
|
1423
1696
|
const userMessage = visionFile && renderPlaceholder ? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom) : windowedRead && windowPlaceholder ? buildIndexingWindowMessage(attachment, windowPlaceholder, false) : continuing ? buildIndexingContinueMessage(attachment) : buildIndexingUserMessage(
|
|
1424
1697
|
attachment,
|
|
1425
1698
|
parsedContent ? { inlineContent: parsedContent } : placeholder ? { inlineContentPlaceholder: placeholder } : pagedRead ? { pagedRead: true } : void 0
|
|
1426
1699
|
);
|
|
1427
1700
|
const systemPrompt = buildIndexingSystemPrompt({
|
|
1428
|
-
|
|
1701
|
+
// The model copies this id verbatim into project_id tool calls, so it must be
|
|
1702
|
+
// the PUBLIC token whenever the host supplied one; the raw code is rejected
|
|
1703
|
+
// by the tools' schema pattern.
|
|
1704
|
+
projectId: info.publicProjectId || service,
|
|
1429
1705
|
serviceName: info.serviceName,
|
|
1430
1706
|
serviceDescription: info.serviceDescription
|
|
1431
1707
|
});
|
|
@@ -1434,7 +1710,7 @@ async function notifyAgentSaveAttachment(info) {
|
|
|
1434
1710
|
const imageDetail = getOpenAIImageDetail(resolvedModel2);
|
|
1435
1711
|
return clientSecretRequest({
|
|
1436
1712
|
clientSecretName: "openai",
|
|
1437
|
-
queue: (info.userId
|
|
1713
|
+
queue: bgIndexingQueueName(info.userId, service),
|
|
1438
1714
|
service,
|
|
1439
1715
|
owner,
|
|
1440
1716
|
...pollOpt(),
|
|
@@ -1447,6 +1723,8 @@ async function notifyAgentSaveAttachment(info) {
|
|
|
1447
1723
|
data: {
|
|
1448
1724
|
model: resolvedModel2,
|
|
1449
1725
|
max_output_tokens: MAX_TOKENS,
|
|
1726
|
+
// Nano-only transcription knobs. Indexing only; see variantIndexingOptions.
|
|
1727
|
+
...variantIndexingOptions(resolvedModel2),
|
|
1450
1728
|
...skapiExtract,
|
|
1451
1729
|
...skapiRender,
|
|
1452
1730
|
...skapiWindow,
|
|
@@ -1478,7 +1756,7 @@ async function notifyAgentSaveAttachment(info) {
|
|
|
1478
1756
|
const resolvedModel = info.model || DEFAULT_CLAUDE_MODEL;
|
|
1479
1757
|
return clientSecretRequest({
|
|
1480
1758
|
clientSecretName: "claude",
|
|
1481
|
-
queue: (info.userId
|
|
1759
|
+
queue: bgIndexingQueueName(info.userId, service),
|
|
1482
1760
|
service,
|
|
1483
1761
|
owner,
|
|
1484
1762
|
...pollOpt(),
|
|
@@ -1592,6 +1870,9 @@ async function listOpenAIModels(service, owner) {
|
|
|
1592
1870
|
});
|
|
1593
1871
|
}
|
|
1594
1872
|
var BG_INDEXING_QUEUE_SUFFIX = "-bg";
|
|
1873
|
+
function bgIndexingQueueName(userId, service) {
|
|
1874
|
+
return (userId || service || "") + BG_INDEXING_QUEUE_SUFFIX;
|
|
1875
|
+
}
|
|
1595
1876
|
function isBgIndexingQueue(queueName) {
|
|
1596
1877
|
if (typeof queueName !== "string" || !queueName) return false;
|
|
1597
1878
|
const prefix = queueName.split("|")[0];
|
|
@@ -1600,7 +1881,9 @@ function isBgIndexingQueue(queueName) {
|
|
|
1600
1881
|
return name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX;
|
|
1601
1882
|
}
|
|
1602
1883
|
var INDEXING_COMPLETE_MARKER = "INDEXING_COMPLETE";
|
|
1884
|
+
var EMPTY_INDEXING_REPLY = "Finished reading this file.";
|
|
1603
1885
|
var MAX_INDEXING_RESUME_PASSES = 6;
|
|
1886
|
+
var CHAT_HISTORY_PAGE_LIMIT = 500;
|
|
1604
1887
|
async function getChatHistory(params, fetchOptions) {
|
|
1605
1888
|
const url = params.platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
|
|
1606
1889
|
const p = Object.assign(
|
|
@@ -1614,7 +1897,7 @@ async function getChatHistory(params, fetchOptions) {
|
|
|
1614
1897
|
);
|
|
1615
1898
|
return chatEngineConfig().clientSecretRequestHistory(
|
|
1616
1899
|
p,
|
|
1617
|
-
Object.assign({ ascending: false }, fetchOptions)
|
|
1900
|
+
Object.assign({ ascending: false, limit: CHAT_HISTORY_PAGE_LIMIT }, fetchOptions)
|
|
1618
1901
|
);
|
|
1619
1902
|
}
|
|
1620
1903
|
|
|
@@ -1681,6 +1964,8 @@ function mapHistoryListToMessages(list, platform, opts) {
|
|
|
1681
1964
|
var userText = extractLastUserTextFromRequest(requestBody);
|
|
1682
1965
|
var assistantText = isPending ? "" : (extractAssistantText(response) || "").trim() || "";
|
|
1683
1966
|
var isErrorResponse = !isPending && (isFailed || isErrorResponseBody(response));
|
|
1967
|
+
var reportedComplete = !!(item && item._isBgTask) && !isErrorResponse && !!assistantText && assistantText.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
|
|
1968
|
+
if (reportedComplete) assistantText = assistantText.split(INDEXING_COMPLETE_MARKER).join("").trim();
|
|
1684
1969
|
var serverItemId = item && typeof item.id === "string" && item.id ? item.id : void 0;
|
|
1685
1970
|
var createdTs = Number(item && item.created);
|
|
1686
1971
|
var updatedTs = Number(item && item.updated);
|
|
@@ -1705,7 +1990,7 @@ function mapHistoryListToMessages(list, platform, opts) {
|
|
|
1705
1990
|
displayContent = userText;
|
|
1706
1991
|
}
|
|
1707
1992
|
} else {
|
|
1708
|
-
displayContent = sanitizeAttachmentLinksForHistory(userText, opts.
|
|
1993
|
+
displayContent = sanitizeAttachmentLinksForHistory(userText, opts.projectId);
|
|
1709
1994
|
}
|
|
1710
1995
|
var userMsg = { role: "user", content: displayContent };
|
|
1711
1996
|
if (isInProcess) userMsg.isPendingInProcess = true;
|
|
@@ -1732,11 +2017,12 @@ function mapHistoryListToMessages(list, platform, opts) {
|
|
|
1732
2017
|
if (serverItemId !== void 0) em._serverItemId = serverItemId;
|
|
1733
2018
|
if (replyTs !== void 0) em._ts = replyTs;
|
|
1734
2019
|
mapped.push(em);
|
|
1735
|
-
} else if (assistantText) {
|
|
1736
|
-
var okm = { role: "assistant", content: sanitizeAttachmentLinksForHistory(assistantText, opts.
|
|
2020
|
+
} else if (assistantText || reportedComplete) {
|
|
2021
|
+
var okm = { role: "assistant", content: sanitizeAttachmentLinksForHistory(assistantText, opts.projectId, true) || EMPTY_INDEXING_REPLY };
|
|
1737
2022
|
if (item._isBgTask) okm.isBackgroundTask = true;
|
|
1738
2023
|
if (serverItemId !== void 0) okm._serverItemId = serverItemId;
|
|
1739
2024
|
if (replyTs !== void 0) okm._ts = replyTs;
|
|
2025
|
+
if (reportedComplete) okm._indexComplete = true;
|
|
1740
2026
|
mapped.push(okm);
|
|
1741
2027
|
}
|
|
1742
2028
|
});
|
|
@@ -1795,6 +2081,16 @@ async function fillHistoryViewport(opts) {
|
|
|
1795
2081
|
function createHistoryFiller(base) {
|
|
1796
2082
|
var pending = [];
|
|
1797
2083
|
var running = false;
|
|
2084
|
+
var fetching = false;
|
|
2085
|
+
function announce(next) {
|
|
2086
|
+
if (fetching === next) return;
|
|
2087
|
+
fetching = next;
|
|
2088
|
+
if (!base.onRunningChange) return;
|
|
2089
|
+
try {
|
|
2090
|
+
base.onRunningChange(next);
|
|
2091
|
+
} catch (e) {
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
1798
2094
|
async function allSatisfied() {
|
|
1799
2095
|
var next = [];
|
|
1800
2096
|
for (var i = 0; i < pending.length; i++) {
|
|
@@ -1804,23 +2100,33 @@ function createHistoryFiller(base) {
|
|
|
1804
2100
|
return pending.length === 0;
|
|
1805
2101
|
}
|
|
1806
2102
|
return {
|
|
2103
|
+
// The published fact, so a view and `isRunning()` can never disagree about
|
|
2104
|
+
// what they are showing. A fill that never fetches is not something anyone
|
|
2105
|
+
// outside this module has any use for knowing about.
|
|
1807
2106
|
isRunning: function() {
|
|
1808
|
-
return
|
|
2107
|
+
return fetching;
|
|
1809
2108
|
},
|
|
1810
2109
|
fill: function(isSatisfied) {
|
|
1811
2110
|
pending.push(isSatisfied);
|
|
1812
2111
|
if (running) return Promise.resolve();
|
|
1813
2112
|
running = true;
|
|
1814
2113
|
var done = function() {
|
|
1815
|
-
running = false;
|
|
1816
2114
|
pending = [];
|
|
2115
|
+
running = false;
|
|
2116
|
+
announce(false);
|
|
1817
2117
|
};
|
|
1818
2118
|
return fillHistoryViewport({
|
|
1819
2119
|
isSatisfied: allSatisfied,
|
|
1820
2120
|
isEndOfList: base.isEndOfList,
|
|
1821
2121
|
isLoading: base.isLoading,
|
|
1822
2122
|
messageCount: base.messageCount,
|
|
1823
|
-
|
|
2123
|
+
// The span opens HERE, at the first real page request: past
|
|
2124
|
+
// isEndOfList, past isStale, past isSatisfied. Everything before this
|
|
2125
|
+
// point is a fill that concluded there was nothing to do.
|
|
2126
|
+
fetchOlder: function() {
|
|
2127
|
+
announce(true);
|
|
2128
|
+
return base.fetchOlder();
|
|
2129
|
+
},
|
|
1824
2130
|
isStale: base.isStale,
|
|
1825
2131
|
maxPages: base.maxPages
|
|
1826
2132
|
}).then(done, done);
|
|
@@ -1830,7 +2136,16 @@ function createHistoryFiller(base) {
|
|
|
1830
2136
|
|
|
1831
2137
|
// src/engine/session.ts
|
|
1832
2138
|
var WORKER_PASS_ADOPT_LIMIT = 20;
|
|
2139
|
+
var LIVE_INDEX_SNAPSHOT_MAX_AGE_MS = 5e3;
|
|
2140
|
+
var INDEX_DISPATCH_CLAIM_MS = 2 * 60 * 1e3;
|
|
1833
2141
|
var WORKER_PASS_ADOPT_ATTEMPTS = [0, 2e3, 6e3];
|
|
2142
|
+
var INDEXING_DRAIN_BUSY_POLL_MS = 8e3;
|
|
2143
|
+
var INDEXING_DRAIN_CONFIRM_POLL_MS = 3e3;
|
|
2144
|
+
var INDEXING_DRAIN_IDLE_LOOKS = 2;
|
|
2145
|
+
var INDEXING_DRAIN_MIN_MS = 8e3;
|
|
2146
|
+
var INDEXING_DRAIN_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
2147
|
+
var INDEXING_DRAIN_LOOK_TIMEOUT_MS = 45e3;
|
|
2148
|
+
var INDEXING_DRAIN_NUDGE_MIN_GAP_MS = 1500;
|
|
1834
2149
|
var _g = typeof globalThis !== "undefined" ? globalThis : {};
|
|
1835
2150
|
function nowMs() {
|
|
1836
2151
|
return _g.performance && typeof _g.performance.now === "function" ? _g.performance.now() : Date.now();
|
|
@@ -1892,7 +2207,10 @@ var ChatSession = class {
|
|
|
1892
2207
|
historyEndOfList: false,
|
|
1893
2208
|
historyStartKeyHistory: [],
|
|
1894
2209
|
historyRequestToken: 0,
|
|
1895
|
-
gateRefreshToken: 0
|
|
2210
|
+
gateRefreshToken: 0,
|
|
2211
|
+
liveIndexKeys: {},
|
|
2212
|
+
liveIndexChecked: false,
|
|
2213
|
+
stoppedIndexIds: {}
|
|
1896
2214
|
};
|
|
1897
2215
|
this.bgTaskQueue = [];
|
|
1898
2216
|
this.cancelledServerIds = /* @__PURE__ */ new Set();
|
|
@@ -1903,6 +2221,298 @@ var ChatSession = class {
|
|
|
1903
2221
|
this._pauseReasons = /* @__PURE__ */ new Set();
|
|
1904
2222
|
this._resuming = false;
|
|
1905
2223
|
this._lidSeq = 0;
|
|
2224
|
+
this._stageSeq = 0;
|
|
2225
|
+
this._uploadBatches = 0;
|
|
2226
|
+
this._indexDispatchesInFlight = 0;
|
|
2227
|
+
this._drainNudges = [];
|
|
2228
|
+
this._liveStages = {};
|
|
2229
|
+
this._liveIndexKey = "";
|
|
2230
|
+
this._liveIndexAt = 0;
|
|
2231
|
+
this._indexClaims = {};
|
|
2232
|
+
}
|
|
2233
|
+
/** What the display layer needs to decide whether a run is finished. `keys` holds
|
|
2234
|
+
* every file the server still has indexing work for; `checked` is false until the
|
|
2235
|
+
* first answer for this chat, and false means "we do not know yet". */
|
|
2236
|
+
getLiveIndexState() {
|
|
2237
|
+
return { keys: this.state.liveIndexKeys, checked: this.state.liveIndexChecked };
|
|
2238
|
+
}
|
|
2239
|
+
/** Passes that were on a row when the user stopped it, so the display layer can
|
|
2240
|
+
* still tell that this run was stopped once the stop has left no other trace.
|
|
2241
|
+
* See cancelIndexingGroup, which fills it, and buildChatDisplayList, which is
|
|
2242
|
+
* the only reader. */
|
|
2243
|
+
getStoppedIndexIds() {
|
|
2244
|
+
return this.state.stoppedIndexIds;
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Is this file ALREADY being indexed by this client?
|
|
2248
|
+
*
|
|
2249
|
+
* One live run per file, and the reason is what a second one looks like: the
|
|
2250
|
+
* conversation grows a SECOND collapsed row for the same file (a run is opened
|
|
2251
|
+
* by every FIRST pass, so two of them are two rows), the same document is read
|
|
2252
|
+
* twice at full provider cost, and the two chains fight over the same records —
|
|
2253
|
+
* the delete-then-repost that starts run 2 wipes what run 1 has saved so far.
|
|
2254
|
+
*
|
|
2255
|
+
* Asked of this client's own live work, so it cannot be wrong in the dangerous
|
|
2256
|
+
* direction: a queued/running pass keeps its bgTaskQueue entry until its bubble
|
|
2257
|
+
* settles, and a settled run answers false, which is what a genuine later
|
|
2258
|
+
* re-index needs.
|
|
2259
|
+
*
|
|
2260
|
+
* The retry that made this necessary: a chip whose INDEX request failed is
|
|
2261
|
+
* handed back to the composer to be retried on the next send, and an index
|
|
2262
|
+
* request can fail from the client's side (a lost ack, an expired token on the
|
|
2263
|
+
* response) while the server has already queued the pass. The retry then indexes
|
|
2264
|
+
* a file that was never not being indexed.
|
|
2265
|
+
*/
|
|
2266
|
+
hasLiveIndexRun(storagePath) {
|
|
2267
|
+
if (!storagePath) return false;
|
|
2268
|
+
var claimed = this._indexClaims[this._indexClaimKey(storagePath)];
|
|
2269
|
+
if (claimed && nowMs() - claimed < INDEX_DISPATCH_CLAIM_MS) return true;
|
|
2270
|
+
var id = this.host.getIdentity();
|
|
2271
|
+
for (var i = 0; i < this.bgTaskQueue.length; i++) {
|
|
2272
|
+
var e = this.bgTaskQueue[i];
|
|
2273
|
+
if (e && e.storagePath === storagePath && e.projectId === id.projectId && e.platform === id.platform) return true;
|
|
2274
|
+
}
|
|
2275
|
+
return this.state.messages.some(function(m) {
|
|
2276
|
+
if (!m.isBackgroundTask || m.role !== "user" || m.isCancelled) return false;
|
|
2277
|
+
if (!(m.isPendingQueued || m.isPendingInProcess || m.isSendingToServer)) return false;
|
|
2278
|
+
return !!m._indexFile && m._indexFile.path === storagePath;
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
/** Storage paths are project-relative, and one ChatSession serves every
|
|
2282
|
+
* project, so a claim has to be scoped the way a stop is (_indexKeyOf). */
|
|
2283
|
+
_indexClaimKey(storagePath) {
|
|
2284
|
+
return this.getHistoryCacheKey() + "|" + storagePath;
|
|
2285
|
+
}
|
|
2286
|
+
/**
|
|
2287
|
+
* Take this file's indexing slot, or report that someone already has it.
|
|
2288
|
+
*
|
|
2289
|
+
* The check-and-CLAIM is what makes it safe against a second caller arriving
|
|
2290
|
+
* mid-flight: the claim is written SYNCHRONOUSLY, before the first await, so a
|
|
2291
|
+
* concurrent caller sees it even though no request has completed and no queue
|
|
2292
|
+
* has admitted anything. Ask-then-dispatch could not do that — every source it
|
|
2293
|
+
* consults only learns about a dispatch after the ack.
|
|
2294
|
+
*
|
|
2295
|
+
* Returns true when the caller owns the slot and should dispatch. A caller that
|
|
2296
|
+
* then fails to dispatch MUST releaseIndexRun, or the file waits out the claim
|
|
2297
|
+
* (a few minutes) before it can be retried.
|
|
2298
|
+
*/
|
|
2299
|
+
claimIndexRun(storagePath) {
|
|
2300
|
+
var self = this;
|
|
2301
|
+
if (!storagePath) return Promise.resolve(true);
|
|
2302
|
+
if (this.hasLiveIndexRun(storagePath)) return Promise.resolve(false);
|
|
2303
|
+
this._indexClaims[this._indexClaimKey(storagePath)] = nowMs();
|
|
2304
|
+
return this._refreshLiveIndexKeys(LIVE_INDEX_SNAPSHOT_MAX_AGE_MS).then(function() {
|
|
2305
|
+
if (!self.state.liveIndexKeys[storagePath]) return true;
|
|
2306
|
+
self.releaseIndexRun(storagePath);
|
|
2307
|
+
return false;
|
|
2308
|
+
}).catch(function() {
|
|
2309
|
+
return true;
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
/** Give the slot back — the dispatch failed, or was abandoned. */
|
|
2313
|
+
releaseIndexRun(storagePath) {
|
|
2314
|
+
if (storagePath) delete this._indexClaims[this._indexClaimKey(storagePath)];
|
|
2315
|
+
}
|
|
2316
|
+
/**
|
|
2317
|
+
* The same question, asked of the SERVER when this page cannot answer it.
|
|
2318
|
+
*
|
|
2319
|
+
* hasLiveIndexRun only knows what this page did. That is not enough for the
|
|
2320
|
+
* case duplicates actually come from: the first run was started before a
|
|
2321
|
+
* reload, or in another tab, or its bubble has since been paged out of the
|
|
2322
|
+
* loaded window — and then the retry finds nothing locally and starts a second
|
|
2323
|
+
* run of a file that is still being indexed. The queue is the one place that
|
|
2324
|
+
* knows, and it is already asked for exactly this list.
|
|
2325
|
+
*
|
|
2326
|
+
* Only a POSITIVE answer is used. Absence proves nothing here (the query is
|
|
2327
|
+
* capped, and `liveIndexChecked` records that), so an unanswerable question
|
|
2328
|
+
* falls back to dispatching — the cost of a wrong "no" is the duplicate this
|
|
2329
|
+
* exists to prevent, and the cost of a wrong "yes" is a file that never gets
|
|
2330
|
+
* indexed at all. Only one of those is recoverable by the user.
|
|
2331
|
+
*/
|
|
2332
|
+
isIndexRunLive(storagePath) {
|
|
2333
|
+
var self = this;
|
|
2334
|
+
if (!storagePath) return Promise.resolve(false);
|
|
2335
|
+
if (this.hasLiveIndexRun(storagePath)) return Promise.resolve(true);
|
|
2336
|
+
return this._refreshLiveIndexKeys(LIVE_INDEX_SNAPSHOT_MAX_AGE_MS).then(function() {
|
|
2337
|
+
return !!self.state.liveIndexKeys[storagePath];
|
|
2338
|
+
}).catch(function() {
|
|
2339
|
+
return false;
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
/** Re-ask the queue which files are still being indexed, unless the answer we
|
|
2343
|
+
* have is younger than `maxAgeMs`. Shared by every caller that needs a current
|
|
2344
|
+
* one; the display layer's own refresh path is the adopt ladder. */
|
|
2345
|
+
_refreshLiveIndexKeys(maxAgeMs) {
|
|
2346
|
+
var self = this;
|
|
2347
|
+
var id = this.host.getIdentity();
|
|
2348
|
+
var platform = id.platform;
|
|
2349
|
+
if (!id.projectId || platform !== "claude" && platform !== "openai") return Promise.resolve();
|
|
2350
|
+
var askedKey = this.getHistoryCacheKey();
|
|
2351
|
+
if (this._liveIndexKey === askedKey && nowMs() - this._liveIndexAt < maxAgeMs) {
|
|
2352
|
+
return Promise.resolve();
|
|
2353
|
+
}
|
|
2354
|
+
var queue = bgIndexingQueueName(id.userId, id.projectId);
|
|
2355
|
+
var ask = function(status) {
|
|
2356
|
+
return Promise.resolve(getChatHistory(
|
|
2357
|
+
{ service: id.projectId, owner: id.owner, platform, queue, status },
|
|
2358
|
+
{ limit: WORKER_PASS_ADOPT_LIMIT }
|
|
2359
|
+
)).catch(function() {
|
|
2360
|
+
return null;
|
|
2361
|
+
});
|
|
2362
|
+
};
|
|
2363
|
+
return Promise.all([ask("pending"), ask("running")]).then(function(results) {
|
|
2364
|
+
if (results[0] === null || results[1] === null) return;
|
|
2365
|
+
if (self.getHistoryCacheKey() !== askedKey) return;
|
|
2366
|
+
self._liveIndexKey = askedKey;
|
|
2367
|
+
self._recordLiveIndexKeys(results);
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* Replace the live-index snapshot from a queue query's raw items.
|
|
2372
|
+
*
|
|
2373
|
+
* Whole-snapshot, never incremental: the query returns everything unresolved on
|
|
2374
|
+
* the queue, so a file MISSING from it is precisely the fact we are after. Merging
|
|
2375
|
+
* would make a finished file impossible to observe.
|
|
2376
|
+
*/
|
|
2377
|
+
_recordLiveIndexKeys(lists) {
|
|
2378
|
+
var next = {};
|
|
2379
|
+
var truncated = false;
|
|
2380
|
+
var settledIds = {};
|
|
2381
|
+
this.state.messages.forEach(function(m) {
|
|
2382
|
+
if (!m._serverItemId) return;
|
|
2383
|
+
if (m.isPending || m.isPendingInProcess || m.isPendingQueued) return;
|
|
2384
|
+
settledIds[m._serverItemId] = true;
|
|
2385
|
+
});
|
|
2386
|
+
for (var li = 0; li < lists.length; li++) {
|
|
2387
|
+
var list = lists[li] && Array.isArray(lists[li].list) ? lists[li].list : [];
|
|
2388
|
+
if (list.length >= WORKER_PASS_ADOPT_LIMIT) truncated = true;
|
|
2389
|
+
for (var i = 0; i < list.length; i++) {
|
|
2390
|
+
var item = list[i];
|
|
2391
|
+
if (!item || item.status !== "pending" && item.status !== "running") continue;
|
|
2392
|
+
if (item.id && settledIds[item.id]) continue;
|
|
2393
|
+
var text = extractLastUserTextFromRequest(item.request_body);
|
|
2394
|
+
if (!isIndexingRequestText(text)) continue;
|
|
2395
|
+
var ref = parseIndexingRequestText(text);
|
|
2396
|
+
if (!ref) continue;
|
|
2397
|
+
if (ref.path) next[ref.path] = true;
|
|
2398
|
+
if (ref.name) next[ref.name] = true;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
var nowChecked = !truncated;
|
|
2402
|
+
var was = this.state.liveIndexKeys, changed = this.state.liveIndexChecked !== nowChecked;
|
|
2403
|
+
if (!changed) {
|
|
2404
|
+
for (var k in next) if (!was[k]) {
|
|
2405
|
+
changed = true;
|
|
2406
|
+
break;
|
|
2407
|
+
}
|
|
2408
|
+
if (!changed) {
|
|
2409
|
+
for (var k2 in was) if (!next[k2]) {
|
|
2410
|
+
changed = true;
|
|
2411
|
+
break;
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
this.state.liveIndexKeys = next;
|
|
2416
|
+
this.state.liveIndexChecked = nowChecked;
|
|
2417
|
+
this._liveIndexAt = nowMs();
|
|
2418
|
+
this._liveIndexKey = this.getHistoryCacheKey();
|
|
2419
|
+
if (changed) this.host.notify();
|
|
2420
|
+
}
|
|
2421
|
+
/** Forget the snapshot: it describes ONE chat's queue, and the answer for the
|
|
2422
|
+
* project the user just switched to is unknown until it is asked for again. */
|
|
2423
|
+
_resetLiveIndexKeys() {
|
|
2424
|
+
this.state.liveIndexKeys = {};
|
|
2425
|
+
this.state.liveIndexChecked = false;
|
|
2426
|
+
this._liveIndexAt = 0;
|
|
2427
|
+
}
|
|
2428
|
+
/**
|
|
2429
|
+
* Ask the queue what is still indexing, once, for the chat that is on screen.
|
|
2430
|
+
*
|
|
2431
|
+
* Seeds the snapshot on a history load. Without it a reloaded chat has no way to
|
|
2432
|
+
* learn that a run it can see is over: the adopt ladder that normally answers this
|
|
2433
|
+
* only fires when a pass SETTLES, and after a reload there is no pass left to
|
|
2434
|
+
* settle — so every finished worker-driven row would spin forever.
|
|
2435
|
+
*
|
|
2436
|
+
* Best-effort: a failure leaves `checked` false, which reads as "still working"
|
|
2437
|
+
* rather than as a false all-clear.
|
|
2438
|
+
*
|
|
2439
|
+
* Delegates to the adopt ladder rather than asking once. A single empty look is
|
|
2440
|
+
* exactly what that ladder exists to distrust — the worker writes pass N+1 a few
|
|
2441
|
+
* milliseconds AFTER flipping pass N to resolved, so a query landing in that gap
|
|
2442
|
+
* sees an empty queue for a chain that is very much alive. One look would turn
|
|
2443
|
+
* that into a confident "Indexed" with a green check, on the one scenario this
|
|
2444
|
+
* whole feature is for, and nothing would ever re-ask: the ladder is normally
|
|
2445
|
+
* triggered by a pass SETTLING, and after a reload there is no pass left to
|
|
2446
|
+
* settle. The ladder re-asks at 0/2s/6s, records each answer, and as a bonus
|
|
2447
|
+
* adopts and polls any live pass it finds, which makes the row genuinely active
|
|
2448
|
+
* instead of merely unconfirmed.
|
|
2449
|
+
*/
|
|
2450
|
+
refreshLiveIndexState() {
|
|
2451
|
+
this._adoptWorkerIndexingPasses(0);
|
|
2452
|
+
}
|
|
2453
|
+
/** Forget what we know about which files are indexing — but ONLY when the
|
|
2454
|
+
* snapshot was taken for a different chat than the one on screen now. For a
|
|
2455
|
+
* consumer whose history loading is its own fork and so never reaches
|
|
2456
|
+
* loadHistory's reset — a snapshot describes ONE chat's queue, and carrying it
|
|
2457
|
+
* into another project would let a row there claim to be finished on someone
|
|
2458
|
+
* else's evidence.
|
|
2459
|
+
*
|
|
2460
|
+
* Conditional for the same reason loadHistory's own reset is (the
|
|
2461
|
+
* `loadKey !== _liveIndexKey` gate): the view calls this on every mount, and
|
|
2462
|
+
* an unconditional wipe turned every re-entry to the chat into a grey
|
|
2463
|
+
* "Checking status:" sweep across rows whose state was already known. A
|
|
2464
|
+
* RE-entry keeps showing the last answer (green/yellow) while the first-page
|
|
2465
|
+
* refresh re-asks quietly; only a genuine project/platform switch starts from
|
|
2466
|
+
* "not known yet". Claiming `_liveIndexKey` here (before any answer) is the
|
|
2467
|
+
* same fudge loadHistory makes: it marks WHOSE chat the empty snapshot is
|
|
2468
|
+
* for, so repeated calls do not re-wipe, and _recordLiveIndexKeys re-claims
|
|
2469
|
+
* it when the real answer lands. */
|
|
2470
|
+
resetLiveIndexState() {
|
|
2471
|
+
var key = this.getHistoryCacheKey();
|
|
2472
|
+
if (key === this._liveIndexKey) return;
|
|
2473
|
+
this._liveIndexKey = key;
|
|
2474
|
+
this._resetLiveIndexKeys();
|
|
2475
|
+
}
|
|
2476
|
+
/** Wrap an indexing-request dispatch so awaitIndexingDrained counts it as
|
|
2477
|
+
* live work from the moment it is sent, not from the moment it is acked. */
|
|
2478
|
+
trackIndexDispatch(p) {
|
|
2479
|
+
var self = this;
|
|
2480
|
+
this._indexDispatchesInFlight += 1;
|
|
2481
|
+
var release = function() {
|
|
2482
|
+
self._indexDispatchesInFlight = Math.max(0, self._indexDispatchesInFlight - 1);
|
|
2483
|
+
};
|
|
2484
|
+
return p.then(function(v) {
|
|
2485
|
+
release();
|
|
2486
|
+
return v;
|
|
2487
|
+
}, function(e) {
|
|
2488
|
+
release();
|
|
2489
|
+
throw e;
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
/**
|
|
2493
|
+
* Something just happened that plausibly ENDED indexing work, so let any waiting
|
|
2494
|
+
* turn look now instead of sitting out the rest of its busy interval.
|
|
2495
|
+
*
|
|
2496
|
+
* A nudge changes only WHEN a look happens, never what it concludes: the two
|
|
2497
|
+
* agreeing idle looks, the confirm gap between them, "a failed look counts as
|
|
2498
|
+
* busy" and the minimum wait are all untouched. That is why it is safe to fire
|
|
2499
|
+
* from places that are merely good guesses.
|
|
2500
|
+
*
|
|
2501
|
+
* Fired from end-of-chain points ONLY: the adopt ladder giving up, a resume
|
|
2502
|
+
* declining to continue, a pass failing. Not from every settling pass (one nudge
|
|
2503
|
+
* per pass per file for the whole run), and not from an indexing request being
|
|
2504
|
+
* accepted — see the note in trackIndexDispatch for why that one is actively
|
|
2505
|
+
* harmful rather than merely wasteful.
|
|
2506
|
+
*/
|
|
2507
|
+
_nudgeIndexingDrain() {
|
|
2508
|
+
if (!this._drainNudges.length) return;
|
|
2509
|
+
var list = this._drainNudges.slice();
|
|
2510
|
+
for (var i = 0; i < list.length; i++) {
|
|
2511
|
+
try {
|
|
2512
|
+
list[i]();
|
|
2513
|
+
} catch (e) {
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
1906
2516
|
}
|
|
1907
2517
|
/**
|
|
1908
2518
|
* Register a live poll so (a) a remount dedupes against it instead of stacking a
|
|
@@ -1919,6 +2529,18 @@ var ChatSession = class {
|
|
|
1919
2529
|
this.historyItemPolls.set(id, { kind, stop });
|
|
1920
2530
|
return p;
|
|
1921
2531
|
}
|
|
2532
|
+
/** Background polls currently attached, for the MAX_CONCURRENT_BG_POLLS budget.
|
|
2533
|
+
* Counts the registry rather than a separate tally so it cannot drift: every
|
|
2534
|
+
* attach goes through _trackPoll and every detach deletes the entry. Note an
|
|
2535
|
+
* entry left behind by pausePolling on an older skapi-js (no stop handle)
|
|
2536
|
+
* still counts, which is correct — that poll really is still running. */
|
|
2537
|
+
_countBgPolls() {
|
|
2538
|
+
var n = 0;
|
|
2539
|
+
this.historyItemPolls.forEach(function(handle) {
|
|
2540
|
+
if (handle && handle.kind === "bg") n++;
|
|
2541
|
+
});
|
|
2542
|
+
return n;
|
|
2543
|
+
}
|
|
1922
2544
|
/**
|
|
1923
2545
|
* Stop and forget one item's poll. Used after a cancel: the row is either gone
|
|
1924
2546
|
* (cancelled while queued) or flagged cancelled (cancelled while running), so
|
|
@@ -2000,8 +2622,8 @@ var ChatSession = class {
|
|
|
2000
2622
|
}
|
|
2001
2623
|
getHistoryCacheKey() {
|
|
2002
2624
|
var id = this.host.getIdentity();
|
|
2003
|
-
if (!id.
|
|
2004
|
-
return id.
|
|
2625
|
+
if (!id.projectId || id.platform === "none") return "";
|
|
2626
|
+
return id.projectId + "#" + id.platform;
|
|
2005
2627
|
}
|
|
2006
2628
|
updateHistoryCache() {
|
|
2007
2629
|
var key = this.getHistoryCacheKey();
|
|
@@ -2054,6 +2676,7 @@ var ChatSession = class {
|
|
|
2054
2676
|
for (var j = 0; j < msgs.length; j++) {
|
|
2055
2677
|
var u = msgs[j];
|
|
2056
2678
|
if (!u || u.role !== "user" || u.isBackgroundTask) continue;
|
|
2679
|
+
if (u._stageId) continue;
|
|
2057
2680
|
if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
|
|
2058
2681
|
if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
|
|
2059
2682
|
var settled = { role: "user", content: u.content };
|
|
@@ -2069,26 +2692,26 @@ var ChatSession = class {
|
|
|
2069
2692
|
};
|
|
2070
2693
|
}
|
|
2071
2694
|
/**
|
|
2072
|
-
*
|
|
2695
|
+
* projectId/owner are passed explicitly by every caller: a request can be
|
|
2073
2696
|
* dispatched after the user moved to another project, and re-reading the live
|
|
2074
2697
|
* identity here would silently send the turn to THAT project instead of the
|
|
2075
2698
|
* one it was composed for. Falls back to the live read only when a caller
|
|
2076
2699
|
* omits them.
|
|
2077
2700
|
*/
|
|
2078
|
-
_callProviderFor(platform, prompt, messages, system, model, userId, extractContent, fileUrls,
|
|
2079
|
-
if (
|
|
2701
|
+
_callProviderFor(platform, prompt, messages, system, model, userId, extractContent, fileUrls, projectId, owner) {
|
|
2702
|
+
if (projectId === void 0 || owner === void 0) {
|
|
2080
2703
|
var id = this.host.getIdentity();
|
|
2081
|
-
if (
|
|
2704
|
+
if (projectId === void 0) projectId = id.projectId;
|
|
2082
2705
|
if (owner === void 0) owner = id.owner;
|
|
2083
2706
|
}
|
|
2084
|
-
return platform === "openai" ? callOpenAIWithPublicMcp(prompt,
|
|
2707
|
+
return platform === "openai" ? callOpenAIWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls) : callClaudeWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls);
|
|
2085
2708
|
}
|
|
2086
2709
|
dispatchAgentRequest(params) {
|
|
2087
2710
|
var self = this;
|
|
2088
2711
|
var dispatchItemId;
|
|
2089
2712
|
var sendAndPoll = function() {
|
|
2090
2713
|
return Promise.resolve(
|
|
2091
|
-
self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.
|
|
2714
|
+
self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.projectId, params.owner)
|
|
2092
2715
|
).then(function(initial) {
|
|
2093
2716
|
if (initial && initial.poll && (initial.status === "pending" || initial.status === "running")) {
|
|
2094
2717
|
if (initial.id) {
|
|
@@ -2147,16 +2770,304 @@ var ChatSession = class {
|
|
|
2147
2770
|
this.pendingAgentRequests[params.key] = run;
|
|
2148
2771
|
return run;
|
|
2149
2772
|
}
|
|
2773
|
+
/**
|
|
2774
|
+
* Put a turn on screen the INSTANT the user hits Send, before its attachments
|
|
2775
|
+
* have finished uploading. Uploads run in the background now (the composer is
|
|
2776
|
+
* cleared and stays usable), so without a staged bubble the message would
|
|
2777
|
+
* appear only once its files were up — below anything the user sent in the
|
|
2778
|
+
* meantime, in an order that never matches what they typed.
|
|
2779
|
+
*
|
|
2780
|
+
* Staged bubbles carry _useBgQueue because that is where a turn with
|
|
2781
|
+
* attachments ultimately dispatches (behind its own indexing tasks). That flag
|
|
2782
|
+
* is also what keeps promoteNextQueuedToRunning / resolveQueuedUserBubble off
|
|
2783
|
+
* them: those advance the SERVER queue, and a staged turn has no server
|
|
2784
|
+
* request behind it yet.
|
|
2785
|
+
*
|
|
2786
|
+
* Returns the id to hand back as PinnedDispatchContext.stageId at dispatch.
|
|
2787
|
+
*/
|
|
2788
|
+
stageOutgoingMessage(displayText) {
|
|
2789
|
+
this._stageSeq += 1;
|
|
2790
|
+
var stageId = "stg_" + this._stageSeq;
|
|
2791
|
+
var key = this.getHistoryCacheKey();
|
|
2792
|
+
var staged = {
|
|
2793
|
+
role: "user",
|
|
2794
|
+
content: displayText,
|
|
2795
|
+
isPendingQueued: true,
|
|
2796
|
+
isUploadingAttachments: true,
|
|
2797
|
+
isSendingToServer: true,
|
|
2798
|
+
_dimSending: true,
|
|
2799
|
+
// A staged bubble has no server id for minutes, and its indexing rows are
|
|
2800
|
+
// now inserted ABOVE it — so its array index moves. Both views fall back to
|
|
2801
|
+
// the index when a bubble has no id, which would re-key (and in Vue, remount)
|
|
2802
|
+
// this bubble on every file, restarting its transition and losing it as a
|
|
2803
|
+
// scroll anchor. A local id it keeps for its whole life fixes both.
|
|
2804
|
+
_localId: this._newLocalId(),
|
|
2805
|
+
_useBgQueue: true,
|
|
2806
|
+
_stageId: stageId,
|
|
2807
|
+
_ts: wallClockNow()
|
|
2808
|
+
};
|
|
2809
|
+
if (key) staged._ownerKey = key;
|
|
2810
|
+
this._liveStages[stageId] = true;
|
|
2811
|
+
this.state.messages.push(staged);
|
|
2812
|
+
this.host.notify();
|
|
2813
|
+
this.host.scrollToBottom(true);
|
|
2814
|
+
return stageId;
|
|
2815
|
+
}
|
|
2816
|
+
/** Is anything in this page still uploading/dispatching for this stage? */
|
|
2817
|
+
isLiveStage(stageId) {
|
|
2818
|
+
return !!stageId && !!this._liveStages[stageId];
|
|
2819
|
+
}
|
|
2820
|
+
/**
|
|
2821
|
+
* Settle any staged bubble in `list` whose chain no longer exists, and return the
|
|
2822
|
+
* list (a new array only if something changed).
|
|
2823
|
+
*
|
|
2824
|
+
* The caller is a cache restore. A staged bubble is the one kind of message whose
|
|
2825
|
+
* resolution lives entirely in page memory — no server request stands behind it
|
|
2826
|
+
* yet — so a copy that outlives its upload would render "(Uploading files...)"
|
|
2827
|
+
* forever with nothing left to finish it. Today nothing can: this cache dies with
|
|
2828
|
+
* the page, so every restored stage is still live and this is a no-op. It exists
|
|
2829
|
+
* so that stops being a silent assumption.
|
|
2830
|
+
*/
|
|
2831
|
+
settleDeadStagedMessages(list) {
|
|
2832
|
+
if (!Array.isArray(list)) return list;
|
|
2833
|
+
var self = this;
|
|
2834
|
+
var dead = false;
|
|
2835
|
+
for (var i = 0; i < list.length; i++) {
|
|
2836
|
+
var m = list[i];
|
|
2837
|
+
if (m && m._stageId && !self._liveStages[m._stageId]) {
|
|
2838
|
+
dead = true;
|
|
2839
|
+
break;
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
if (!dead) return list;
|
|
2843
|
+
return list.map(function(m2) {
|
|
2844
|
+
if (!m2 || !m2._stageId || self._liveStages[m2._stageId]) return m2;
|
|
2845
|
+
var settled = { role: "user", content: m2.content };
|
|
2846
|
+
if (m2._ownerKey !== void 0) settled._ownerKey = m2._ownerKey;
|
|
2847
|
+
if (m2._ts !== void 0) settled._ts = m2._ts;
|
|
2848
|
+
if (m2._localId !== void 0) settled._localId = m2._localId;
|
|
2849
|
+
return settled;
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
_stageIndex(list, stageId) {
|
|
2853
|
+
if (!stageId) return -1;
|
|
2854
|
+
for (var i = 0; i < list.length; i++) {
|
|
2855
|
+
if (list[i] && list[i]._stageId === stageId) return i;
|
|
2856
|
+
}
|
|
2857
|
+
return -1;
|
|
2858
|
+
}
|
|
2859
|
+
/**
|
|
2860
|
+
* Staged turn, phase 2: its files are up and it is now waiting for the whole
|
|
2861
|
+
* indexing chain behind them. Swaps "(Uploading files...)" for
|
|
2862
|
+
* "(Indexing files...)"; the bubble stays dimmed, because from the user's side
|
|
2863
|
+
* nothing has been handed over yet.
|
|
2864
|
+
*
|
|
2865
|
+
* It deliberately does NOT say "(In queue)" here. The turn is not queued behind
|
|
2866
|
+
* anything the server knows about yet — it is waiting on work that can run for
|
|
2867
|
+
* minutes — and claiming otherwise is what made the wait look like a stall.
|
|
2868
|
+
*/
|
|
2869
|
+
markStagedMessageIndexing(stageId) {
|
|
2870
|
+
var idx = this._stageIndex(this.state.messages, stageId);
|
|
2871
|
+
if (idx === -1) return;
|
|
2872
|
+
var ex = this.state.messages[idx];
|
|
2873
|
+
if (!ex.isUploadingAttachments) return;
|
|
2874
|
+
this.state.messages[idx] = Object.assign({}, ex, {
|
|
2875
|
+
isUploadingAttachments: false,
|
|
2876
|
+
isAwaitingIndexing: true
|
|
2877
|
+
});
|
|
2878
|
+
this.host.notify();
|
|
2879
|
+
}
|
|
2880
|
+
/**
|
|
2881
|
+
* Staged turn, phase 3: the last of its files has finished indexing, so the turn
|
|
2882
|
+
* is genuinely just queued now. Full opacity + "(In queue)".
|
|
2883
|
+
*
|
|
2884
|
+
* Clears the PRESENTATIONAL _dimSending only; isSendingToServer stays set until
|
|
2885
|
+
* the server actually acks (it is the token that ack matches on). Called by the
|
|
2886
|
+
* clients the instant awaitIndexingDrained resolves, i.e. immediately before the
|
|
2887
|
+
* dispatch that replaces this bubble — dispatchComposedMessage carries the
|
|
2888
|
+
* cleared flag onto the replacement so the turn does not blink back to dimmed.
|
|
2889
|
+
*/
|
|
2890
|
+
markStagedMessageReady(stageId) {
|
|
2891
|
+
var idx = this._stageIndex(this.state.messages, stageId);
|
|
2892
|
+
if (idx === -1) return;
|
|
2893
|
+
var ex = this.state.messages[idx];
|
|
2894
|
+
if (!ex.isAwaitingIndexing && !ex._dimSending && !ex.isUploadingAttachments) return;
|
|
2895
|
+
this.state.messages[idx] = Object.assign({}, ex, {
|
|
2896
|
+
isUploadingAttachments: false,
|
|
2897
|
+
isAwaitingIndexing: false,
|
|
2898
|
+
_dimSending: false
|
|
2899
|
+
});
|
|
2900
|
+
this.host.notify();
|
|
2901
|
+
}
|
|
2902
|
+
/**
|
|
2903
|
+
* Resolves once this project's background-indexing queue has nothing left to
|
|
2904
|
+
* run, so a chat enqueued right after it is genuinely last.
|
|
2905
|
+
*
|
|
2906
|
+
* Sending the chat as soon as the uploads finish is not enough, which is the
|
|
2907
|
+
* whole reason this exists: indexing a file is a CHAIN, and each pass is only
|
|
2908
|
+
* enqueued once the previous one lands (the client mints CONTINUE passes for
|
|
2909
|
+
* text/grid files, the worker mints them for PDFs and windowed reads). Every
|
|
2910
|
+
* one of those passes therefore queues up BEHIND a chat sent at upload time,
|
|
2911
|
+
* and the model answers from a file it has only partly read.
|
|
2912
|
+
*
|
|
2913
|
+
* The queue is read from the server's status index rather than from
|
|
2914
|
+
* bgTaskQueue: that mirror holds only what this client dispatched or adopted,
|
|
2915
|
+
* and it stops being maintained once the view unmounts. An empty answer has to
|
|
2916
|
+
* repeat before it is believed — see INDEXING_DRAIN_IDLE_LOOKS — and a look
|
|
2917
|
+
* that fails counts as busy, so a dropped request delays the turn instead of
|
|
2918
|
+
* releasing it early.
|
|
2919
|
+
*
|
|
2920
|
+
* Reads the identity PINNED at Send time, never a live one: the user may be in
|
|
2921
|
+
* another project by now, and this must keep asking about the one they sent
|
|
2922
|
+
* from.
|
|
2923
|
+
*/
|
|
2924
|
+
awaitIndexingDrained(identity) {
|
|
2925
|
+
var self = this;
|
|
2926
|
+
var svcId = identity && identity.projectId;
|
|
2927
|
+
var platform = identity && identity.platform;
|
|
2928
|
+
if (!svcId || platform !== "claude" && platform !== "openai") return Promise.resolve("skipped");
|
|
2929
|
+
var owner = identity.owner;
|
|
2930
|
+
var queue = bgIndexingQueueName(identity.userId, svcId);
|
|
2931
|
+
var startedAt = nowMs();
|
|
2932
|
+
var deadline = startedAt + INDEXING_DRAIN_TIMEOUT_MS;
|
|
2933
|
+
var idleLooks = 0;
|
|
2934
|
+
var ask = function(status) {
|
|
2935
|
+
var answered = false;
|
|
2936
|
+
return new Promise(function(res) {
|
|
2937
|
+
var bail = null;
|
|
2938
|
+
var settle = function(v) {
|
|
2939
|
+
if (answered) return;
|
|
2940
|
+
answered = true;
|
|
2941
|
+
if (bail) {
|
|
2942
|
+
clearTimeout(bail);
|
|
2943
|
+
bail = null;
|
|
2944
|
+
}
|
|
2945
|
+
res(v);
|
|
2946
|
+
};
|
|
2947
|
+
bail = setTimeout(function() {
|
|
2948
|
+
settle(null);
|
|
2949
|
+
}, INDEXING_DRAIN_LOOK_TIMEOUT_MS);
|
|
2950
|
+
Promise.resolve(getChatHistory(
|
|
2951
|
+
{ service: svcId, owner, platform, queue, status },
|
|
2952
|
+
{ limit: WORKER_PASS_ADOPT_LIMIT }
|
|
2953
|
+
)).then(function(r) {
|
|
2954
|
+
settle(r);
|
|
2955
|
+
}, function() {
|
|
2956
|
+
settle(null);
|
|
2957
|
+
});
|
|
2958
|
+
});
|
|
2959
|
+
};
|
|
2960
|
+
var hasLiveIndexing = function(res) {
|
|
2961
|
+
var list = res && Array.isArray(res.list) ? res.list : [];
|
|
2962
|
+
for (var i = 0; i < list.length; i++) {
|
|
2963
|
+
var item = list[i];
|
|
2964
|
+
if (!item || item.status !== "pending" && item.status !== "running") continue;
|
|
2965
|
+
if (isIndexingRequestText(extractLastUserTextFromRequest(item.request_body))) return true;
|
|
2966
|
+
}
|
|
2967
|
+
return false;
|
|
2968
|
+
};
|
|
2969
|
+
return new Promise(function(resolve) {
|
|
2970
|
+
var timer = null;
|
|
2971
|
+
var lastLookAt = -Infinity;
|
|
2972
|
+
var nudgedThisInterval = false;
|
|
2973
|
+
var inFlight = false;
|
|
2974
|
+
var finish = function(v) {
|
|
2975
|
+
if (timer) {
|
|
2976
|
+
clearTimeout(timer);
|
|
2977
|
+
timer = null;
|
|
2978
|
+
}
|
|
2979
|
+
var ni = self._drainNudges.indexOf(nudge);
|
|
2980
|
+
if (ni !== -1) self._drainNudges.splice(ni, 1);
|
|
2981
|
+
resolve(v);
|
|
2982
|
+
};
|
|
2983
|
+
var again = function(ms) {
|
|
2984
|
+
if (timer) {
|
|
2985
|
+
clearTimeout(timer);
|
|
2986
|
+
timer = null;
|
|
2987
|
+
}
|
|
2988
|
+
var wait = ms == null ? idleLooks > 0 ? INDEXING_DRAIN_CONFIRM_POLL_MS : INDEXING_DRAIN_BUSY_POLL_MS : ms;
|
|
2989
|
+
timer = setTimeout(look, wait);
|
|
2990
|
+
};
|
|
2991
|
+
var nudge = function() {
|
|
2992
|
+
if (idleLooks > 0) return;
|
|
2993
|
+
if (inFlight) return;
|
|
2994
|
+
if (nudgedThisInterval) return;
|
|
2995
|
+
if (self._indexDispatchesInFlight > 0) return;
|
|
2996
|
+
nudgedThisInterval = true;
|
|
2997
|
+
again(Math.max(0, INDEXING_DRAIN_NUDGE_MIN_GAP_MS - (nowMs() - lastLookAt)));
|
|
2998
|
+
};
|
|
2999
|
+
var look = function() {
|
|
3000
|
+
timer = null;
|
|
3001
|
+
if (inFlight) return;
|
|
3002
|
+
lastLookAt = nowMs();
|
|
3003
|
+
nudgedThisInterval = false;
|
|
3004
|
+
if (nowMs() >= deadline) {
|
|
3005
|
+
finish("timedout");
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
if (self._indexDispatchesInFlight > 0) {
|
|
3009
|
+
idleLooks = 0;
|
|
3010
|
+
again();
|
|
3011
|
+
return;
|
|
3012
|
+
}
|
|
3013
|
+
inFlight = true;
|
|
3014
|
+
Promise.all([ask("running"), ask("pending")]).then(function(res) {
|
|
3015
|
+
inFlight = false;
|
|
3016
|
+
var unknown = res[0] === null || res[1] === null;
|
|
3017
|
+
if (unknown || hasLiveIndexing(res[0]) || hasLiveIndexing(res[1])) idleLooks = 0;
|
|
3018
|
+
else idleLooks += 1;
|
|
3019
|
+
if (idleLooks >= INDEXING_DRAIN_IDLE_LOOKS && nowMs() - startedAt >= INDEXING_DRAIN_MIN_MS) {
|
|
3020
|
+
finish("drained");
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
again();
|
|
3024
|
+
}, function() {
|
|
3025
|
+
inFlight = false;
|
|
3026
|
+
idleLooks = 0;
|
|
3027
|
+
again();
|
|
3028
|
+
});
|
|
3029
|
+
};
|
|
3030
|
+
self._drainNudges.push(nudge);
|
|
3031
|
+
look();
|
|
3032
|
+
});
|
|
3033
|
+
}
|
|
3034
|
+
/**
|
|
3035
|
+
* Abandon a staged turn — its uploads failed outright, so nothing will be
|
|
3036
|
+
* dispatched. The bubble stays (the user's text is not silently thrown away)
|
|
3037
|
+
* but settles into a plain, non-pending message; the caller reports the
|
|
3038
|
+
* failure separately.
|
|
3039
|
+
*/
|
|
3040
|
+
settleStagedMessage(stageId) {
|
|
3041
|
+
delete this._liveStages[stageId];
|
|
3042
|
+
var idx = this._stageIndex(this.state.messages, stageId);
|
|
3043
|
+
if (idx === -1) return;
|
|
3044
|
+
var ex = this.state.messages[idx];
|
|
3045
|
+
var settled = { role: "user", content: ex.content };
|
|
3046
|
+
if (ex._ownerKey !== void 0) settled._ownerKey = ex._ownerKey;
|
|
3047
|
+
if (ex._ts !== void 0) settled._ts = ex._ts;
|
|
3048
|
+
if (ex._localId !== void 0) settled._localId = ex._localId;
|
|
3049
|
+
this.state.messages[idx] = settled;
|
|
3050
|
+
this.host.notify();
|
|
3051
|
+
this.updateHistoryCache();
|
|
3052
|
+
}
|
|
2150
3053
|
// composed = clean display text; composedForLlm carries office-extraction
|
|
2151
3054
|
// placeholders for the provider only. useBgQueue routes a post-attachment turn
|
|
2152
3055
|
// onto the "-bg" queue so it runs after indexing.
|
|
2153
3056
|
dispatchComposedMessage(composed, useBgQueue, composedForLlm, extractContent, fileUrls, pinned) {
|
|
2154
3057
|
var self = this;
|
|
2155
|
-
|
|
3058
|
+
var stageId = pinned ? pinned.stageId : void 0;
|
|
3059
|
+
if (!composed) {
|
|
3060
|
+
if (stageId) this.settleStagedMessage(stageId);
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
2156
3063
|
var id = pinned ? pinned.identity : this.host.getIdentity();
|
|
2157
|
-
if (id.platform === "none")
|
|
3064
|
+
if (id.platform === "none") {
|
|
3065
|
+
if (stageId) this.settleStagedMessage(stageId);
|
|
3066
|
+
return;
|
|
3067
|
+
}
|
|
3068
|
+
if (stageId) delete this._liveStages[stageId];
|
|
2158
3069
|
var llmComposed = composedForLlm || composed;
|
|
2159
|
-
var key = !id.
|
|
3070
|
+
var key = !id.projectId ? "" : id.projectId + "#" + id.platform;
|
|
2160
3071
|
var offChat = !!key && key !== this.getHistoryCacheKey();
|
|
2161
3072
|
var isQueuedSend = !offChat && (useBgQueue || this.state.sending || this.state.messages.some(function(m) {
|
|
2162
3073
|
return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
|
|
@@ -2164,8 +3075,8 @@ var ChatSession = class {
|
|
|
2164
3075
|
var aiPlatform = id.platform;
|
|
2165
3076
|
var aiModel = id.model || void 0;
|
|
2166
3077
|
var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
|
|
2167
|
-
var userId = id.userId || id.
|
|
2168
|
-
var chatQueue = useBgQueue ? userId
|
|
3078
|
+
var userId = id.userId || id.projectId;
|
|
3079
|
+
var chatQueue = useBgQueue ? bgIndexingQueueName(userId) : userId;
|
|
2169
3080
|
if (offChat) {
|
|
2170
3081
|
var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function(m) {
|
|
2171
3082
|
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
@@ -2174,13 +3085,28 @@ var ChatSession = class {
|
|
|
2174
3085
|
platform: aiPlatform,
|
|
2175
3086
|
model: aiModel,
|
|
2176
3087
|
systemPrompt,
|
|
2177
|
-
|
|
3088
|
+
projectId: id.projectId,
|
|
2178
3089
|
history: offHistory.concat([{ role: "user", content: llmComposed }])
|
|
2179
3090
|
});
|
|
2180
3091
|
var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
|
|
3092
|
+
var offUser = { role: "user", content: composed, _ownerKey: key, _ts: wallClockNow() };
|
|
3093
|
+
var offStage = this._stageIndex(this.state.messages, stageId);
|
|
3094
|
+
if (offStage !== -1) {
|
|
3095
|
+
if (this.state.messages[offStage]._ts !== void 0) offUser._ts = this.state.messages[offStage]._ts;
|
|
3096
|
+
this.state.messages.splice(offStage, 1);
|
|
3097
|
+
this.host.notify();
|
|
3098
|
+
}
|
|
3099
|
+
var offCached = offExisting.messages;
|
|
3100
|
+
if (stageId) {
|
|
3101
|
+
offCached = offCached.filter(function(m) {
|
|
3102
|
+
if (m._stageId !== stageId) return true;
|
|
3103
|
+
if (offStage === -1 && m._ts !== void 0) offUser._ts = m._ts;
|
|
3104
|
+
return false;
|
|
3105
|
+
});
|
|
3106
|
+
}
|
|
2181
3107
|
this.aiChatHistoryCache[key] = {
|
|
2182
|
-
messages:
|
|
2183
|
-
|
|
3108
|
+
messages: offCached.concat([
|
|
3109
|
+
offUser,
|
|
2184
3110
|
{ role: "assistant", content: "", isPending: true, isPendingInProcess: true, _ownerKey: key }
|
|
2185
3111
|
]),
|
|
2186
3112
|
endOfList: offExisting.endOfList,
|
|
@@ -2188,7 +3114,7 @@ var ChatSession = class {
|
|
|
2188
3114
|
};
|
|
2189
3115
|
this.dispatchAgentRequest({
|
|
2190
3116
|
key,
|
|
2191
|
-
|
|
3117
|
+
projectId: id.projectId,
|
|
2192
3118
|
owner: id.owner,
|
|
2193
3119
|
aiPlatform,
|
|
2194
3120
|
aiModel,
|
|
@@ -2209,24 +3135,33 @@ var ChatSession = class {
|
|
|
2209
3135
|
platform: aiPlatform,
|
|
2210
3136
|
model: aiModel,
|
|
2211
3137
|
systemPrompt,
|
|
2212
|
-
|
|
3138
|
+
projectId: id.projectId,
|
|
2213
3139
|
history: resolvedHistory.concat([{ role: "user", content: llmComposed }])
|
|
2214
3140
|
});
|
|
2215
|
-
var queuedBubble = { role: "user", content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
|
|
3141
|
+
var queuedBubble = { role: "user", content: composed, isPendingQueued: true, isSendingToServer: true, _dimSending: true, _localId: this._newLocalId(), _ts: wallClockNow() };
|
|
2216
3142
|
if (key) queuedBubble._ownerKey = key;
|
|
2217
3143
|
if (useBgQueue) queuedBubble._useBgQueue = true;
|
|
2218
|
-
this.state.messages
|
|
3144
|
+
var qStage = this._stageIndex(this.state.messages, stageId);
|
|
3145
|
+
if (qStage !== -1) {
|
|
3146
|
+
var qEx = this.state.messages[qStage];
|
|
3147
|
+
if (qEx._ts !== void 0) queuedBubble._ts = qEx._ts;
|
|
3148
|
+
if (qEx._dimSending === false) queuedBubble._dimSending = false;
|
|
3149
|
+
if (qEx._localId) queuedBubble._localId = qEx._localId;
|
|
3150
|
+
this.state.messages.splice(qStage, 1, queuedBubble);
|
|
3151
|
+
} else {
|
|
3152
|
+
this.state.messages.push(queuedBubble);
|
|
3153
|
+
}
|
|
2219
3154
|
this.host.notify();
|
|
2220
3155
|
this.updateHistoryCache();
|
|
2221
3156
|
this.host.scrollToBottom(true);
|
|
2222
3157
|
var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
|
|
2223
|
-
Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.
|
|
3158
|
+
Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.projectId, id.owner)).then(function(result) {
|
|
2224
3159
|
var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function(m) {
|
|
2225
|
-
return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user" && (m._ownerKey === void 0 || m._ownerKey === capturedKey);
|
|
3160
|
+
return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user" && !m._stageId && (m._ownerKey === void 0 || m._ownerKey === capturedKey);
|
|
2226
3161
|
});
|
|
2227
3162
|
var serverId = result && typeof result.id === "string" ? result.id : void 0;
|
|
2228
3163
|
if (sendingIdx >= 0) {
|
|
2229
|
-
var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false });
|
|
3164
|
+
var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false, _dimSending: false });
|
|
2230
3165
|
if (serverId) upd._serverItemId = serverId;
|
|
2231
3166
|
self.state.messages[sendingIdx] = upd;
|
|
2232
3167
|
self.host.notify();
|
|
@@ -2247,33 +3182,37 @@ var ChatSession = class {
|
|
|
2247
3182
|
});
|
|
2248
3183
|
return;
|
|
2249
3184
|
}
|
|
2250
|
-
|
|
2251
|
-
|
|
3185
|
+
var immediateUser = { role: "user", content: composed, _localId: this._newLocalId(), _ts: wallClockNow(), ...key ? { _ownerKey: key } : {} };
|
|
3186
|
+
var immediatePlaceholder = { role: "assistant", content: "", isPending: true, isPendingInProcess: true, ...key ? { _ownerKey: key } : {} };
|
|
3187
|
+
var iStage = this._stageIndex(this.state.messages, stageId);
|
|
3188
|
+
if (iStage !== -1) {
|
|
3189
|
+
var iEx = this.state.messages[iStage];
|
|
3190
|
+
if (iEx._ts !== void 0) immediateUser._ts = iEx._ts;
|
|
3191
|
+
if (iEx._localId) immediateUser._localId = iEx._localId;
|
|
3192
|
+
this.state.messages.splice(iStage, 1, immediateUser, immediatePlaceholder);
|
|
3193
|
+
} else {
|
|
3194
|
+
this.state.messages.push(immediateUser);
|
|
3195
|
+
this.state.messages.push(immediatePlaceholder);
|
|
3196
|
+
}
|
|
2252
3197
|
this.host.notify();
|
|
2253
3198
|
this.updateHistoryCache();
|
|
2254
3199
|
this.state.sending = true;
|
|
2255
3200
|
this.host.scrollToBottom(true);
|
|
2256
3201
|
var historyForLlm = this.state.messages.filter(function(m) {
|
|
3202
|
+
if (m === immediateUser) return false;
|
|
2257
3203
|
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
2258
3204
|
});
|
|
2259
|
-
|
|
2260
|
-
for (var li = historyForLlm.length - 1; li >= 0; li--) {
|
|
2261
|
-
if (historyForLlm[li].role === "user" && historyForLlm[li].content === composed) {
|
|
2262
|
-
historyForLlm[li] = Object.assign({}, historyForLlm[li], { content: llmComposed });
|
|
2263
|
-
break;
|
|
2264
|
-
}
|
|
2265
|
-
}
|
|
2266
|
-
}
|
|
3205
|
+
historyForLlm.push({ role: "user", content: llmComposed });
|
|
2267
3206
|
var bounded = buildBoundedChatMessages({
|
|
2268
3207
|
platform: aiPlatform,
|
|
2269
3208
|
model: aiModel,
|
|
2270
3209
|
systemPrompt,
|
|
2271
|
-
|
|
3210
|
+
projectId: id.projectId,
|
|
2272
3211
|
history: historyForLlm
|
|
2273
3212
|
});
|
|
2274
3213
|
var run = this.dispatchAgentRequest({
|
|
2275
3214
|
key,
|
|
2276
|
-
|
|
3215
|
+
projectId: id.projectId,
|
|
2277
3216
|
owner: id.owner,
|
|
2278
3217
|
aiPlatform,
|
|
2279
3218
|
aiModel,
|
|
@@ -2330,6 +3269,8 @@ var ChatSession = class {
|
|
|
2330
3269
|
if (existing._serverItemId !== void 0) promoted._serverItemId = existing._serverItemId;
|
|
2331
3270
|
if (existing._ownerKey !== void 0) promoted._ownerKey = existing._ownerKey;
|
|
2332
3271
|
if (existing.isSendingToServer) promoted.isSendingToServer = true;
|
|
3272
|
+
if (existing._dimSending) promoted._dimSending = true;
|
|
3273
|
+
if (existing._localId !== void 0) promoted._localId = existing._localId;
|
|
2333
3274
|
this.state.messages[nextIdx] = promoted;
|
|
2334
3275
|
var placeholder = { role: "assistant", content: "", isPending: true };
|
|
2335
3276
|
if (existing._serverItemId !== void 0) placeholder._serverItemId = existing._serverItemId;
|
|
@@ -2337,6 +3278,33 @@ var ChatSession = class {
|
|
|
2337
3278
|
this.state.messages.splice(nextIdx + 1, 0, placeholder);
|
|
2338
3279
|
this.host.notify();
|
|
2339
3280
|
}
|
|
3281
|
+
/**
|
|
3282
|
+
* The "Thinking..." placeholder belonging to the user bubble at `userIdx`, or -1.
|
|
3283
|
+
*
|
|
3284
|
+
* Every path that creates one puts it IMMEDIATELY after its user bubble
|
|
3285
|
+
* (promoteNextQueuedToRunning, the immediate-send pair, applyHistoryItemResolution),
|
|
3286
|
+
* so ownership is adjacency — modulo background bubbles, which get spliced in
|
|
3287
|
+
* around them. Taking the first pending assistant ANYWHERE below instead was a
|
|
3288
|
+
* hijack: a turn sent with attachments never gets a placeholder of its own
|
|
3289
|
+
* (promoteNextQueuedToRunning skips _useBgQueue turns) and now keeps the position
|
|
3290
|
+
* it was sent in, so an ordinary turn sent while its files indexed sits BELOW it
|
|
3291
|
+
* with a placeholder of its own — and the attachment turn's answer was rendered
|
|
3292
|
+
* as the answer to that unrelated question.
|
|
3293
|
+
*/
|
|
3294
|
+
_ownThinkingIndex(userIdx, serverId) {
|
|
3295
|
+
if (userIdx < 0) return -1;
|
|
3296
|
+
for (var i = userIdx + 1; i < this.state.messages.length; i++) {
|
|
3297
|
+
var m = this.state.messages[i];
|
|
3298
|
+
if (!m) return -1;
|
|
3299
|
+
if (m.isBackgroundTask) continue;
|
|
3300
|
+
if (m.isPending && m.role === "assistant") {
|
|
3301
|
+
if (serverId && m._serverItemId && m._serverItemId !== serverId) return -1;
|
|
3302
|
+
return i;
|
|
3303
|
+
}
|
|
3304
|
+
return -1;
|
|
3305
|
+
}
|
|
3306
|
+
return -1;
|
|
3307
|
+
}
|
|
2340
3308
|
resolveQueuedUserBubble(serverId) {
|
|
2341
3309
|
var liveKey = this.getHistoryCacheKey();
|
|
2342
3310
|
var isLocal = function(m) {
|
|
@@ -2363,9 +3331,7 @@ var ChatSession = class {
|
|
|
2363
3331
|
if (userIdx >= 0) {
|
|
2364
3332
|
var ex = this.state.messages[userIdx];
|
|
2365
3333
|
this.state.messages[userIdx] = { role: "user", content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...ex._ownerKey !== void 0 ? { _ownerKey: ex._ownerKey } : {} };
|
|
2366
|
-
var thIdx = this.
|
|
2367
|
-
return i > userIdx && m.isPending && m.role === "assistant" && !m.isBackgroundTask;
|
|
2368
|
-
});
|
|
3334
|
+
var thIdx = this._ownThinkingIndex(userIdx, serverId);
|
|
2369
3335
|
if (thIdx !== -1) this.state.messages.splice(thIdx, 1);
|
|
2370
3336
|
}
|
|
2371
3337
|
this.promoteNextQueuedToRunning();
|
|
@@ -2377,16 +3343,17 @@ var ChatSession = class {
|
|
|
2377
3343
|
if (exist._serverItemId !== void 0) repl._serverItemId = exist._serverItemId;
|
|
2378
3344
|
if (exist._ownerKey !== void 0) repl._ownerKey = exist._ownerKey;
|
|
2379
3345
|
if (exist._ts !== void 0) repl._ts = exist._ts;
|
|
3346
|
+
if (exist._localId !== void 0) repl._localId = exist._localId;
|
|
2380
3347
|
this.state.messages[userIdx] = repl;
|
|
2381
3348
|
}
|
|
2382
|
-
var thinkingIdx =
|
|
2383
|
-
return i > userIdx && m.isPending && m.role === "assistant" && !m.isBackgroundTask;
|
|
2384
|
-
}) : -1;
|
|
3349
|
+
var thinkingIdx = this._ownThinkingIndex(userIdx, serverId);
|
|
2385
3350
|
return thinkingIdx !== -1 ? thinkingIdx : userIdx >= 0 ? userIdx + 1 : -1;
|
|
2386
3351
|
}
|
|
2387
3352
|
insertAtTarget(msg, targetIdx) {
|
|
2388
3353
|
if (msg && msg.role === "assistant" && msg._ts === void 0) msg._ts = wallClockNow();
|
|
2389
|
-
|
|
3354
|
+
var tgt = targetIdx >= 0 ? this.state.messages[targetIdx] : void 0;
|
|
3355
|
+
var replaceable = !!tgt && !!tgt.isPending && !tgt.isBackgroundTask && this._isOwnPlaceholderOf(targetIdx, this._owningUserIndex(targetIdx));
|
|
3356
|
+
if (replaceable) this.state.messages[targetIdx] = msg;
|
|
2390
3357
|
else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
|
|
2391
3358
|
else this.state.messages.push(msg);
|
|
2392
3359
|
}
|
|
@@ -2503,16 +3470,21 @@ var ChatSession = class {
|
|
|
2503
3470
|
var platform = id.platform;
|
|
2504
3471
|
if (platform !== "claude" && platform !== "openai") return;
|
|
2505
3472
|
var url = platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
|
|
2506
|
-
var queueBase = id.userId || id.
|
|
2507
|
-
var queue = msg.isBackgroundTask || msg._useBgQueue ? queueBase
|
|
2508
|
-
this.state.messages[idx]
|
|
3473
|
+
var queueBase = id.userId || id.projectId;
|
|
3474
|
+
var queue = msg.isBackgroundTask || msg._useBgQueue ? bgIndexingQueueName(queueBase) : queueBase;
|
|
3475
|
+
var at = this.state.messages[idx] && this.state.messages[idx]._serverItemId === serverId && this.state.messages[idx].role === msg.role ? idx : this.state.messages.findIndex(function(m) {
|
|
3476
|
+
return m._serverItemId === serverId && m.role === msg.role;
|
|
3477
|
+
});
|
|
3478
|
+
if (at !== -1) {
|
|
3479
|
+
this.state.messages[at] = Object.assign({}, this.state.messages[at], { _cancelling: true, _cancelError: void 0 });
|
|
3480
|
+
}
|
|
2509
3481
|
this.host.notify();
|
|
2510
3482
|
Promise.resolve(this.host.cancelRequest({
|
|
2511
3483
|
url,
|
|
2512
3484
|
method: "POST",
|
|
2513
3485
|
id: serverId,
|
|
2514
3486
|
queue,
|
|
2515
|
-
service: id.
|
|
3487
|
+
service: id.projectId,
|
|
2516
3488
|
owner: id.owner
|
|
2517
3489
|
})).then(function(result) {
|
|
2518
3490
|
if (result && result.removed) {
|
|
@@ -2582,7 +3554,10 @@ var ChatSession = class {
|
|
|
2582
3554
|
* 2. the file is remembered in cancelledIndexKeys, so the client-driven
|
|
2583
3555
|
* resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
|
|
2584
3556
|
* 3. any of its passes still sitting in bgTaskQueue is dropped by the next
|
|
2585
|
-
* drain rather than surfacing a fresh "Indexing…" bubble
|
|
3557
|
+
* drain rather than surfacing a fresh "Indexing…" bubble; and
|
|
3558
|
+
* 4. the RUN is remembered (state.stoppedIndexIds), because none of the above
|
|
3559
|
+
* necessarily leaves a mark on the conversation — see below — and without
|
|
3560
|
+
* it the collapsed row reported the stopped file as finished.
|
|
2586
3561
|
*
|
|
2587
3562
|
* Records already written by the passes that DID run are kept — this stops the
|
|
2588
3563
|
* work, it does not undo it.
|
|
@@ -2592,6 +3567,19 @@ var ChatSession = class {
|
|
|
2592
3567
|
if (!group || !group.key) return;
|
|
2593
3568
|
var scoped = this.getHistoryCacheKey() + "|" + group.key;
|
|
2594
3569
|
this.cancelledIndexKeys.add(scoped);
|
|
3570
|
+
if (!group.finished) {
|
|
3571
|
+
var stoppedIds = {};
|
|
3572
|
+
for (var sk in this.state.stoppedIndexIds) stoppedIds[sk] = true;
|
|
3573
|
+
(group.members || []).forEach(function(m) {
|
|
3574
|
+
var sid = m && m.msg && m.msg._serverItemId;
|
|
3575
|
+
if (sid) stoppedIds[sid] = true;
|
|
3576
|
+
});
|
|
3577
|
+
this.bgTaskQueue.forEach(function(e) {
|
|
3578
|
+
if (e && e.id && self._indexKeyOf(e) === scoped) stoppedIds[e.id] = true;
|
|
3579
|
+
});
|
|
3580
|
+
this.state.stoppedIndexIds = stoppedIds;
|
|
3581
|
+
}
|
|
3582
|
+
this._adoptWorkerIndexingPasses(0);
|
|
2595
3583
|
var ids = group.cancellableIds || [];
|
|
2596
3584
|
if (!ids.length) {
|
|
2597
3585
|
this.host.notify();
|
|
@@ -2765,22 +3753,70 @@ var ChatSession = class {
|
|
|
2765
3753
|
this.promoteNextQueuedToRunning();
|
|
2766
3754
|
return this.enqueueTypewrite(pendingIdx, latest.content, lid);
|
|
2767
3755
|
}
|
|
2768
|
-
// Remove
|
|
2769
|
-
//
|
|
2770
|
-
//
|
|
2771
|
-
//
|
|
2772
|
-
//
|
|
2773
|
-
//
|
|
2774
|
-
//
|
|
2775
|
-
//
|
|
2776
|
-
//
|
|
2777
|
-
//
|
|
3756
|
+
// Remove leftover non-background pending ("Thinking…") assistant bubbles: the
|
|
3757
|
+
// duplicate that appears when a concurrent history refetch re-maps the still-
|
|
3758
|
+
// "running" turn into a pending placeholder (with a real _serverItemId) while the
|
|
3759
|
+
// local pending bubble (no _serverItemId) is rescued and re-appended (see the
|
|
3760
|
+
// loadHistory rescue below), and the orphan a resolve leaves when it splices its
|
|
3761
|
+
// reply beside a placeholder instead of into it. Each resolve path only replaces
|
|
3762
|
+
// ONE pending bubble, so without this a stray "Thinking…" survives forever next to
|
|
3763
|
+
// the reply. MUST run AFTER the resolved bubble has been made non-pending and
|
|
3764
|
+
// BEFORE promoteNext*() (which only adds a Thinking once none remains).
|
|
3765
|
+
//
|
|
3766
|
+
// It used to take EVERY one, on the premise that there is at most one at a time
|
|
3767
|
+
// because promoteNext* refuses to add a second. That premise never covered the
|
|
3768
|
+
// immediate-send path, which creates its pair directly — and a turn sent with
|
|
3769
|
+
// attachments does not block the composer and resolves on its own queue, so an
|
|
3770
|
+
// ordinary question asked while files index is in flight, with a placeholder of
|
|
3771
|
+
// its own, exactly when the attachment turn resolves. Sweeping it left that
|
|
3772
|
+
// question with no spinner and, worse, nowhere for its answer to land:
|
|
3773
|
+
// typewriteLatestReply bails when there is no pending assistant, so the reply
|
|
3774
|
+
// reached the cache and never the screen.
|
|
3775
|
+
//
|
|
3776
|
+
// The discriminator is the owning USER bubble. A live immediate send's user bubble
|
|
3777
|
+
// carries NO pending flags (its in-flight-ness lives in state.sending), while every
|
|
3778
|
+
// duplicate this sweep is for belongs to a user bubble that is still pending — and
|
|
3779
|
+
// an orphan has no user bubble above it at all.
|
|
2778
3780
|
_removeStrayPendingAssistants() {
|
|
2779
3781
|
for (var k = this.state.messages.length - 1; k >= 0; k--) {
|
|
2780
3782
|
var m = this.state.messages[k];
|
|
2781
|
-
if (m.isPending
|
|
3783
|
+
if (!m || !m.isPending || m.role !== "assistant" || m.isBackgroundTask) continue;
|
|
3784
|
+
if (this._isLiveImmediatePlaceholder(k)) continue;
|
|
3785
|
+
this.state.messages.splice(k, 1);
|
|
2782
3786
|
}
|
|
2783
3787
|
}
|
|
3788
|
+
/** Index of the USER bubble the message at `idx` belongs to — the nearest one
|
|
3789
|
+
* above it, stepping over background bubbles (a file's indexing rows are
|
|
3790
|
+
* inserted between turns). -1 when the nearest thing above is not a user turn,
|
|
3791
|
+
* which for a placeholder means it is an orphan. */
|
|
3792
|
+
_owningUserIndex(idx) {
|
|
3793
|
+
for (var j = idx - 1; j >= 0; j--) {
|
|
3794
|
+
var p = this.state.messages[j];
|
|
3795
|
+
if (!p) return -1;
|
|
3796
|
+
if (p.isBackgroundTask) continue;
|
|
3797
|
+
return p.role === "user" ? j : -1;
|
|
3798
|
+
}
|
|
3799
|
+
return -1;
|
|
3800
|
+
}
|
|
3801
|
+
/** The bubble at `idx` is the "Thinking…" of a DIFFERENT turn that is still
|
|
3802
|
+
* waiting for its answer, so the sweep above must leave it alone. */
|
|
3803
|
+
_isLiveImmediatePlaceholder(idx) {
|
|
3804
|
+
var ui = this._owningUserIndex(idx);
|
|
3805
|
+
if (ui === -1) return false;
|
|
3806
|
+
var p = this.state.messages[ui];
|
|
3807
|
+
return !p.isPending && !p.isPendingQueued && !p.isPendingInProcess && !p.isPendingOlder && !p.isSendingToServer && !p.isCancelled;
|
|
3808
|
+
}
|
|
3809
|
+
/** A pending assistant at `idx` is the placeholder OF the turn above it, so a
|
|
3810
|
+
* reply may take its slot. Every path that makes one copies the parent's
|
|
3811
|
+
* _serverItemId (or neither has one yet), so a mismatch means the slot belongs to
|
|
3812
|
+
* some other request and the reply must be spliced in beside it, not on top. */
|
|
3813
|
+
_isOwnPlaceholderOf(idx, userIdx) {
|
|
3814
|
+
if (userIdx === -1) return false;
|
|
3815
|
+
var ph = this.state.messages[idx], u = this.state.messages[userIdx];
|
|
3816
|
+
if (!ph || !u) return false;
|
|
3817
|
+
if (ph._serverItemId === void 0 || u._serverItemId === void 0) return true;
|
|
3818
|
+
return ph._serverItemId === u._serverItemId;
|
|
3819
|
+
}
|
|
2784
3820
|
// Drop the pending flags on the resolved turn's USER bubble (preserving its
|
|
2785
3821
|
// content + background-task marker). Needed because a bg "Indexing:" turn's user
|
|
2786
3822
|
// bubble carries isPendingInProcess; leaving it set keeps the bubble visually
|
|
@@ -2827,6 +3863,7 @@ var ChatSession = class {
|
|
|
2827
3863
|
var indexRef = this._indexRefOfItem(itemId);
|
|
2828
3864
|
this.applyHistoryItemResolution(itemId, response, platform);
|
|
2829
3865
|
this.promoteNextBgQueuedToRunning();
|
|
3866
|
+
this.drainBgTaskQueue();
|
|
2830
3867
|
if (indexRef) this._followWorkerIndexingChain(indexRef.name, indexRef.mime);
|
|
2831
3868
|
}
|
|
2832
3869
|
/** The file an already-rendered background pass is about, off its request
|
|
@@ -2840,11 +3877,67 @@ var ChatSession = class {
|
|
|
2840
3877
|
}
|
|
2841
3878
|
return null;
|
|
2842
3879
|
}
|
|
3880
|
+
/**
|
|
3881
|
+
* Settle a turn the server reports as cancelled: the request bubble goes to its
|
|
3882
|
+
* cancelled form and the "Thinking..." placeholder goes away. The same shape
|
|
3883
|
+
* cancelQueuedMessage produces locally, so a cancel this client made and one it
|
|
3884
|
+
* merely found out about render identically — and an indexing pass keeps the
|
|
3885
|
+
* markers that hold it in its file's collapsed row.
|
|
3886
|
+
*/
|
|
3887
|
+
_settleCancelledItem(itemId) {
|
|
3888
|
+
var uIdx = this.state.messages.findIndex(function(m) {
|
|
3889
|
+
return m.role === "user" && m._serverItemId === itemId && !m.isCancelled;
|
|
3890
|
+
});
|
|
3891
|
+
if (uIdx !== -1) {
|
|
3892
|
+
var u = this.state.messages[uIdx];
|
|
3893
|
+
var cancelled = { role: "user", content: u.content, isCancelled: true, _serverItemId: itemId };
|
|
3894
|
+
if (u.isBackgroundTask) cancelled.isBackgroundTask = true;
|
|
3895
|
+
if (u._indexFile) cancelled._indexFile = u._indexFile;
|
|
3896
|
+
if (u._useBgQueue) cancelled._useBgQueue = true;
|
|
3897
|
+
if (u._ownerKey !== void 0) cancelled._ownerKey = u._ownerKey;
|
|
3898
|
+
if (u._ts !== void 0) cancelled._ts = u._ts;
|
|
3899
|
+
this.state.messages[uIdx] = cancelled;
|
|
3900
|
+
}
|
|
3901
|
+
var pIdx = this.state.messages.findIndex(function(m) {
|
|
3902
|
+
return m.isPending && m.role === "assistant" && m._serverItemId === itemId;
|
|
3903
|
+
});
|
|
3904
|
+
if (pIdx !== -1) this.state.messages.splice(pIdx, 1);
|
|
3905
|
+
this.cancelledServerIds.delete(itemId);
|
|
3906
|
+
this._removeStrayPendingAssistants();
|
|
3907
|
+
this.host.notify();
|
|
3908
|
+
this.updateHistoryCache();
|
|
3909
|
+
}
|
|
3910
|
+
/**
|
|
3911
|
+
* A poll that came back saying the request was CANCELLED, rather than with an
|
|
3912
|
+
* answer.
|
|
3913
|
+
*
|
|
3914
|
+
* The server keeps a cancelled request as a terminal row instead of deleting it
|
|
3915
|
+
* (that row is the durable record of the stop, and the chat history it belongs
|
|
3916
|
+
* to), so a poll still running when the cancel lands now RESOLVES on it. It used
|
|
3917
|
+
* to reject with NOT_EXISTS, and the resolution path below reads a status object
|
|
3918
|
+
* as an answer with no text — which would stamp "No text response received from
|
|
3919
|
+
* AI provider" over a turn the user had just stopped.
|
|
3920
|
+
*
|
|
3921
|
+
* Reachable whenever the poll was not stopped by whoever cancelled: another tab,
|
|
3922
|
+
* another device, or the row being cancelled server-side by the file's own stop.
|
|
3923
|
+
*/
|
|
3924
|
+
_isCancelledPollResult(response) {
|
|
3925
|
+
if (!response || typeof response !== "object" || response.status !== "cancelled") return false;
|
|
3926
|
+
if (response.content !== void 0 || response.output !== void 0) return false;
|
|
3927
|
+
return response.queue_name !== void 0 || response.in_queue !== void 0;
|
|
3928
|
+
}
|
|
2843
3929
|
applyHistoryItemResolution(itemId, response, platform) {
|
|
2844
3930
|
this.historyItemPolls.delete(itemId);
|
|
3931
|
+
if (this._isCancelledPollResult(response)) {
|
|
3932
|
+
this._settleCancelledItem(itemId);
|
|
3933
|
+
return;
|
|
3934
|
+
}
|
|
2845
3935
|
var isErr = isErrorResponseBody(response);
|
|
2846
3936
|
var answer = isErr ? getErrorMessage(response) : ((platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "").trim();
|
|
2847
|
-
|
|
3937
|
+
var reportedComplete = !isErr && !!answer && answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
|
|
3938
|
+
var stripMarker = function(t) {
|
|
3939
|
+
return reportedComplete ? t.split(INDEXING_COMPLETE_MARKER).join("").trim() : t;
|
|
3940
|
+
};
|
|
2848
3941
|
var idx = this.state.messages.findIndex(function(m) {
|
|
2849
3942
|
return m.isPending && m._serverItemId === itemId;
|
|
2850
3943
|
});
|
|
@@ -2860,7 +3953,7 @@ var ChatSession = class {
|
|
|
2860
3953
|
}
|
|
2861
3954
|
var text = answer || "No text response received from AI provider.";
|
|
2862
3955
|
if (wasBgTask) {
|
|
2863
|
-
this.state.messages[idx] = { role: "assistant", content: text, isBackgroundTask: true, _serverItemId: itemId };
|
|
3956
|
+
this.state.messages[idx] = { role: "assistant", content: stripMarker(text) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...reportedComplete ? { _indexComplete: true } : {} };
|
|
2864
3957
|
this.host.notify();
|
|
2865
3958
|
this.updateHistoryCache();
|
|
2866
3959
|
return;
|
|
@@ -2893,7 +3986,7 @@ var ChatSession = class {
|
|
|
2893
3986
|
}
|
|
2894
3987
|
var text2 = answer || "No text response received from AI provider.";
|
|
2895
3988
|
if (ex.isBackgroundTask) {
|
|
2896
|
-
this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: text2, isBackgroundTask: true, _serverItemId: itemId });
|
|
3989
|
+
this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: stripMarker(text2) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...reportedComplete ? { _indexComplete: true } : {} });
|
|
2897
3990
|
this.host.notify();
|
|
2898
3991
|
this.updateHistoryCache();
|
|
2899
3992
|
return;
|
|
@@ -2914,7 +4007,7 @@ var ChatSession = class {
|
|
|
2914
4007
|
if (!entry) return "";
|
|
2915
4008
|
var file = entry.storagePath || entry.filename;
|
|
2916
4009
|
if (!file) return "";
|
|
2917
|
-
return entry.
|
|
4010
|
+
return entry.projectId + "#" + entry.platform + "|" + file;
|
|
2918
4011
|
}
|
|
2919
4012
|
/**
|
|
2920
4013
|
* Reconcile the bg queue with the files the user has stopped.
|
|
@@ -2924,17 +4017,33 @@ var ChatSession = class {
|
|
|
2924
4017
|
* path, and without this an earlier cancel would silently kill every future
|
|
2925
4018
|
* index of the same path. A continuation of a stopped file is dropped instead,
|
|
2926
4019
|
* covering the pass that was dispatched in the moment before the cancel landed.
|
|
4020
|
+
*
|
|
4021
|
+
* "Fresh" is the load-bearing word, and it used to be missing. A run's OWN first
|
|
4022
|
+
* pass sits in this queue for as long as it runs (entries are only dropped once
|
|
4023
|
+
* their bubble settles), so stopping a file during its first pass — which is
|
|
4024
|
+
* exactly when a user who has just uploaded it does — met that first-pass entry
|
|
4025
|
+
* on the very next drain and lifted the stop the user had just asked for. The
|
|
4026
|
+
* chain then carried on, one worker-minted window after another, with nothing
|
|
4027
|
+
* client-side left to suppress it. The ids recorded at stop time are what tells
|
|
4028
|
+
* the two apart: a pass that was already there when the user hit Stop cannot be
|
|
4029
|
+
* the new request that lifts it.
|
|
2927
4030
|
*/
|
|
2928
4031
|
_applyIndexCancellations() {
|
|
2929
4032
|
if (!this.cancelledIndexKeys.size) return;
|
|
4033
|
+
var surfaced = {};
|
|
4034
|
+
this.state.messages.forEach(function(m) {
|
|
4035
|
+
if (!m._serverItemId) return;
|
|
4036
|
+
if (m.isPending || m.isPendingQueued || m.isPendingInProcess) surfaced[m._serverItemId] = true;
|
|
4037
|
+
});
|
|
2930
4038
|
for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
|
|
2931
4039
|
var entry = this.bgTaskQueue[i];
|
|
2932
4040
|
var key = this._indexKeyOf(entry);
|
|
2933
4041
|
if (!key || !this.cancelledIndexKeys.has(key)) continue;
|
|
2934
|
-
if (!entry.resumePass) {
|
|
4042
|
+
if (!entry.resumePass && !this.state.stoppedIndexIds[entry.id]) {
|
|
2935
4043
|
this.cancelledIndexKeys.delete(key);
|
|
2936
4044
|
continue;
|
|
2937
4045
|
}
|
|
4046
|
+
if (surfaced[entry.id]) continue;
|
|
2938
4047
|
this.bgTaskQueue.splice(i, 1);
|
|
2939
4048
|
this._stopPoll(entry.id);
|
|
2940
4049
|
this._cancelServerItem(entry.id);
|
|
@@ -2987,10 +4096,10 @@ var ChatSession = class {
|
|
|
2987
4096
|
if (this._adoptingWorkerPasses) return;
|
|
2988
4097
|
var id = this.host.getIdentity();
|
|
2989
4098
|
var platform = id.platform;
|
|
2990
|
-
if (!id.
|
|
4099
|
+
if (!id.projectId || platform !== "claude" && platform !== "openai") return;
|
|
2991
4100
|
if (this.isPollingPaused() || !this.host.isViewMounted()) return;
|
|
2992
|
-
var svcId = id.
|
|
2993
|
-
var queue = (id.userId
|
|
4101
|
+
var svcId = id.projectId, owner = id.owner;
|
|
4102
|
+
var queue = bgIndexingQueueName(id.userId, id.projectId);
|
|
2994
4103
|
var ask = function(status) {
|
|
2995
4104
|
return Promise.resolve(getChatHistory(
|
|
2996
4105
|
{ service: svcId, owner, platform, queue, status },
|
|
@@ -3003,8 +4112,9 @@ var ChatSession = class {
|
|
|
3003
4112
|
Promise.all([ask("running"), ask("pending")]).then(function(results) {
|
|
3004
4113
|
self._adoptingWorkerPasses = false;
|
|
3005
4114
|
var now = self.host.getIdentity();
|
|
3006
|
-
if (now.
|
|
4115
|
+
if (now.projectId !== svcId || now.platform !== platform) return;
|
|
3007
4116
|
if (!self.host.isViewMounted()) return;
|
|
4117
|
+
if (results[0] !== null && results[1] !== null) self._recordLiveIndexKeys(results);
|
|
3008
4118
|
var adoptedIds = [];
|
|
3009
4119
|
for (var ri = 0; ri < results.length; ri++) {
|
|
3010
4120
|
var list = results[ri] && Array.isArray(results[ri].list) ? results[ri].list : [];
|
|
@@ -3016,10 +4126,13 @@ var ChatSession = class {
|
|
|
3016
4126
|
self.drainBgTaskQueue();
|
|
3017
4127
|
if (self._isTrackingAny(adoptedIds)) return;
|
|
3018
4128
|
}
|
|
3019
|
-
if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length)
|
|
4129
|
+
if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) {
|
|
4130
|
+
self._nudgeIndexingDrain();
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
3020
4133
|
setTimeout(function() {
|
|
3021
4134
|
var later = self.host.getIdentity();
|
|
3022
|
-
if (later.
|
|
4135
|
+
if (later.projectId !== svcId || later.platform !== platform) return;
|
|
3023
4136
|
if (self.isPollingPaused() || !self.host.isViewMounted()) return;
|
|
3024
4137
|
self._adoptWorkerIndexingPasses(attempt + 1);
|
|
3025
4138
|
}, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
|
|
@@ -3061,7 +4174,7 @@ var ChatSession = class {
|
|
|
3061
4174
|
if (!ref || !ref.name) return false;
|
|
3062
4175
|
if (!this._isWorkerDrivenIndexing(ref.name, ref.mime)) return false;
|
|
3063
4176
|
this.bgTaskQueue.push({
|
|
3064
|
-
|
|
4177
|
+
projectId: svcId,
|
|
3065
4178
|
platform,
|
|
3066
4179
|
id: item.id,
|
|
3067
4180
|
filename: ref.name,
|
|
@@ -3092,8 +4205,8 @@ var ChatSession = class {
|
|
|
3092
4205
|
url,
|
|
3093
4206
|
method: "POST",
|
|
3094
4207
|
id: serverId,
|
|
3095
|
-
queue: (id.userId
|
|
3096
|
-
service: id.
|
|
4208
|
+
queue: bgIndexingQueueName(id.userId, id.projectId),
|
|
4209
|
+
service: id.projectId,
|
|
3097
4210
|
owner: id.owner
|
|
3098
4211
|
})).catch(function() {
|
|
3099
4212
|
});
|
|
@@ -3102,7 +4215,7 @@ var ChatSession = class {
|
|
|
3102
4215
|
drainBgTaskQueue() {
|
|
3103
4216
|
var self = this;
|
|
3104
4217
|
var id = this.host.getIdentity();
|
|
3105
|
-
var svcId = id.
|
|
4218
|
+
var svcId = id.projectId, plat = id.platform;
|
|
3106
4219
|
if (!svcId || plat === "none" || !this.host.isViewMounted()) return;
|
|
3107
4220
|
this._applyIndexCancellations();
|
|
3108
4221
|
this._sweepCancelledIndexing();
|
|
@@ -3116,11 +4229,13 @@ var ChatSession = class {
|
|
|
3116
4229
|
});
|
|
3117
4230
|
for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
|
|
3118
4231
|
var e = this.bgTaskQueue[i];
|
|
3119
|
-
if (e.
|
|
4232
|
+
if (e.projectId !== svcId || e.platform !== plat) continue;
|
|
3120
4233
|
if (presentIds[e.id] && !pendingIds[e.id]) this.bgTaskQueue.splice(i, 1);
|
|
3121
4234
|
}
|
|
4235
|
+
var bgPollBudget = MAX_CONCURRENT_BG_POLLS - this._countBgPolls();
|
|
4236
|
+
var injectedAny = false;
|
|
3122
4237
|
this.bgTaskQueue.forEach(function(entry) {
|
|
3123
|
-
if (entry.
|
|
4238
|
+
if (entry.projectId !== svcId || entry.platform !== plat) return;
|
|
3124
4239
|
if (!presentIds[entry.id]) {
|
|
3125
4240
|
var isRunning = entry.status === "running";
|
|
3126
4241
|
var userBubble = {
|
|
@@ -3141,16 +4256,21 @@ var ChatSession = class {
|
|
|
3141
4256
|
};
|
|
3142
4257
|
if (isRunning) userBubble.isPendingInProcess = true;
|
|
3143
4258
|
else userBubble.isPendingQueued = true;
|
|
3144
|
-
self.state.messages.
|
|
3145
|
-
|
|
3146
|
-
|
|
4259
|
+
var stageAt = self._stageIndex(self.state.messages, entry.stageId);
|
|
4260
|
+
var runningBubble = isRunning ? { role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id } : null;
|
|
4261
|
+
if (stageAt === -1) {
|
|
4262
|
+
self.state.messages.push(userBubble);
|
|
4263
|
+
if (runningBubble) self.state.messages.push(runningBubble);
|
|
4264
|
+
} else if (runningBubble) {
|
|
4265
|
+
self.state.messages.splice(stageAt, 0, userBubble, runningBubble);
|
|
4266
|
+
} else {
|
|
4267
|
+
self.state.messages.splice(stageAt, 0, userBubble);
|
|
3147
4268
|
}
|
|
3148
4269
|
presentIds[entry.id] = true;
|
|
3149
|
-
|
|
3150
|
-
self.updateHistoryCache();
|
|
3151
|
-
self.host.scrollToBottomIfSticky(false);
|
|
4270
|
+
injectedAny = true;
|
|
3152
4271
|
}
|
|
3153
|
-
if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
|
|
4272
|
+
if (bgPollBudget > 0 && !self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
|
|
4273
|
+
bgPollBudget--;
|
|
3154
4274
|
var capturedId = entry.id, capturedPlat = plat;
|
|
3155
4275
|
var capturedEntry = entry;
|
|
3156
4276
|
var wasStopped = false;
|
|
@@ -3183,15 +4303,24 @@ var ChatSession = class {
|
|
|
3183
4303
|
}
|
|
3184
4304
|
self.host.notify();
|
|
3185
4305
|
self.updateHistoryCache();
|
|
4306
|
+
if (!self._isWorkerDrivenIndexing(capturedEntry.filename, capturedEntry.mime)) {
|
|
4307
|
+
self._nudgeIndexingDrain();
|
|
4308
|
+
}
|
|
3186
4309
|
}).then(function() {
|
|
3187
4310
|
if (wasStopped) return;
|
|
3188
4311
|
var qi = self.bgTaskQueue.findIndex(function(q) {
|
|
3189
4312
|
return q.id === capturedId;
|
|
3190
4313
|
});
|
|
3191
4314
|
if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
|
|
4315
|
+
self.drainBgTaskQueue();
|
|
3192
4316
|
});
|
|
3193
4317
|
}
|
|
3194
4318
|
});
|
|
4319
|
+
if (injectedAny) {
|
|
4320
|
+
this.host.notify();
|
|
4321
|
+
this.updateHistoryCache();
|
|
4322
|
+
this.host.scrollToBottomIfSticky(false);
|
|
4323
|
+
}
|
|
3195
4324
|
this.promoteNextBgQueuedToRunning();
|
|
3196
4325
|
}
|
|
3197
4326
|
// Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
|
|
@@ -3208,25 +4337,45 @@ var ChatSession = class {
|
|
|
3208
4337
|
// as well would now double-index every window.
|
|
3209
4338
|
maybeResumeIndexing(entry, response, platform) {
|
|
3210
4339
|
var self = this;
|
|
4340
|
+
var endOfClientChain = function() {
|
|
4341
|
+
self._nudgeIndexingDrain();
|
|
4342
|
+
};
|
|
3211
4343
|
try {
|
|
3212
4344
|
if (!entry || !entry.storagePath) return;
|
|
3213
4345
|
if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
|
|
3214
|
-
if (!isPagedReadFile(entry.filename, entry.mime))
|
|
4346
|
+
if (!isPagedReadFile(entry.filename, entry.mime)) {
|
|
4347
|
+
endOfClientChain();
|
|
4348
|
+
return;
|
|
4349
|
+
}
|
|
3215
4350
|
if (isImageVisionFile(entry.filename, entry.mime)) return;
|
|
3216
4351
|
if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
|
|
3217
|
-
if (isErrorResponseBody(response))
|
|
4352
|
+
if (isErrorResponseBody(response)) {
|
|
4353
|
+
endOfClientChain();
|
|
4354
|
+
return;
|
|
4355
|
+
}
|
|
3218
4356
|
var answer = (platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "";
|
|
3219
|
-
if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1)
|
|
4357
|
+
if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) {
|
|
4358
|
+
endOfClientChain();
|
|
4359
|
+
return;
|
|
4360
|
+
}
|
|
3220
4361
|
var pass = (entry.resumePass || 0) + 1;
|
|
3221
|
-
if (pass > MAX_INDEXING_RESUME_PASSES)
|
|
4362
|
+
if (pass > MAX_INDEXING_RESUME_PASSES) {
|
|
4363
|
+
endOfClientChain();
|
|
4364
|
+
return;
|
|
4365
|
+
}
|
|
3222
4366
|
var id = this.host.getIdentity();
|
|
3223
|
-
if (!id || id.platform === "none" || id.
|
|
3224
|
-
notifyAgentContinueIndexing({
|
|
4367
|
+
if (!id || id.platform === "none" || id.projectId !== entry.projectId) return;
|
|
4368
|
+
this.trackIndexDispatch(notifyAgentContinueIndexing({
|
|
3225
4369
|
platform: id.platform,
|
|
3226
4370
|
model: id.model,
|
|
3227
|
-
service: id.
|
|
4371
|
+
service: id.projectId,
|
|
4372
|
+
// Without this the resume pass rebuilds its system prompt from the RAW
|
|
4373
|
+
// regional id (requests.ts falls back to `service`), and the model copies
|
|
4374
|
+
// that id verbatim into project_id tool calls, which the MCP schema
|
|
4375
|
+
// pattern rejects - the whole continue pass saves nothing.
|
|
4376
|
+
publicProjectId: id.publicProjectId,
|
|
3228
4377
|
owner: id.owner,
|
|
3229
|
-
userId: id.userId || id.
|
|
4378
|
+
userId: id.userId || id.projectId,
|
|
3230
4379
|
serviceName: id.serviceName,
|
|
3231
4380
|
serviceDescription: id.serviceDescription,
|
|
3232
4381
|
attachment: {
|
|
@@ -3239,7 +4388,7 @@ var ChatSession = class {
|
|
|
3239
4388
|
}).then(function(ack) {
|
|
3240
4389
|
if (ack && typeof ack.id === "string") {
|
|
3241
4390
|
self.bgTaskQueue.push({
|
|
3242
|
-
|
|
4391
|
+
projectId: id.projectId,
|
|
3243
4392
|
platform: id.platform,
|
|
3244
4393
|
id: ack.id,
|
|
3245
4394
|
filename: entry.filename,
|
|
@@ -3250,12 +4399,19 @@ var ChatSession = class {
|
|
|
3250
4399
|
status: ack.status === "running" ? "running" : "pending",
|
|
3251
4400
|
poll: ack.poll,
|
|
3252
4401
|
resumePass: pass
|
|
4402
|
+
// Deliberately NOT stamped with entry.stageId. Only a batch's FIRST
|
|
4403
|
+
// pass anchors to the turn; a continuation appends, which is the
|
|
4404
|
+
// order the server queued it in and therefore the order
|
|
4405
|
+
// promoteNextBgQueuedToRunning should spin it in. It costs nothing
|
|
4406
|
+
// on screen: a continuation is folded into the run whose row
|
|
4407
|
+
// already sits above the turn, and renders nothing at its own
|
|
4408
|
+
// index (indexing_groups anchors a run at its FIRST loaded pass).
|
|
3253
4409
|
});
|
|
3254
4410
|
self.drainBgTaskQueue();
|
|
3255
4411
|
}
|
|
3256
4412
|
}, function(e) {
|
|
3257
4413
|
console.error("[chat-engine] resume-indexing dispatch failed", e);
|
|
3258
|
-
});
|
|
4414
|
+
}));
|
|
3259
4415
|
} catch (e) {
|
|
3260
4416
|
}
|
|
3261
4417
|
}
|
|
@@ -3268,21 +4424,25 @@ var ChatSession = class {
|
|
|
3268
4424
|
loadHistory(fetchMore, token) {
|
|
3269
4425
|
var self = this;
|
|
3270
4426
|
var id = this.host.getIdentity();
|
|
3271
|
-
var loadKey = !id.
|
|
4427
|
+
var loadKey = !id.projectId || id.platform === "none" ? "" : id.projectId + "#" + id.platform;
|
|
3272
4428
|
if (token === void 0) token = this.state.gateRefreshToken;
|
|
3273
|
-
if (this.state.loadingHistory && this.state.historyRequestToken === token || id.platform === "none" || !id.
|
|
4429
|
+
if (this.state.loadingHistory && this.state.historyRequestToken === token || id.platform === "none" || !id.projectId) {
|
|
3274
4430
|
return Promise.resolve();
|
|
3275
4431
|
}
|
|
3276
4432
|
this.state.historyRequestToken = token;
|
|
3277
4433
|
this.state.loadingHistory = true;
|
|
4434
|
+
if (!fetchMore && loadKey !== this._liveIndexKey) {
|
|
4435
|
+
this._liveIndexKey = loadKey;
|
|
4436
|
+
this._resetLiveIndexKeys();
|
|
4437
|
+
}
|
|
3278
4438
|
if (fetchMore) this.state.loadingOlderHistory = true;
|
|
3279
4439
|
this.host.notify();
|
|
3280
4440
|
var platform = id.platform;
|
|
3281
|
-
var
|
|
4441
|
+
var projectId = id.projectId, owner = id.owner;
|
|
3282
4442
|
var options = { fetchMore };
|
|
3283
4443
|
if (fetchMore && this.state.historyStartKeyHistory.length) options.startKeyHistory = this.state.historyStartKeyHistory.slice();
|
|
3284
4444
|
var fetchHistory = function() {
|
|
3285
|
-
return getChatHistory({ service:
|
|
4445
|
+
return getChatHistory({ service: projectId, owner, platform }, options);
|
|
3286
4446
|
};
|
|
3287
4447
|
return Promise.resolve().then(fetchHistory).catch(function(err) {
|
|
3288
4448
|
if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
|
|
@@ -3302,7 +4462,7 @@ var ChatSession = class {
|
|
|
3302
4462
|
});
|
|
3303
4463
|
var mapped = mapHistoryListToMessages(list, platform, {
|
|
3304
4464
|
clearedAt: self.host.getClearedAt(),
|
|
3305
|
-
|
|
4465
|
+
projectId: id.projectId,
|
|
3306
4466
|
formatIndexingLabel: self.host.formatIndexingLabel
|
|
3307
4467
|
}).messages;
|
|
3308
4468
|
var keptOlderPages = false;
|
|
@@ -3333,6 +4493,10 @@ var ChatSession = class {
|
|
|
3333
4493
|
if (mm._ownerKey !== void 0 && mm._ownerKey !== loadKey) continue;
|
|
3334
4494
|
if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
|
|
3335
4495
|
if (!mm._serverItemId) {
|
|
4496
|
+
if (mm._stageId) {
|
|
4497
|
+
rescued.push(mm);
|
|
4498
|
+
continue;
|
|
4499
|
+
}
|
|
3336
4500
|
if (mappedHasPendingAssistant) continue;
|
|
3337
4501
|
if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
|
|
3338
4502
|
else if (self.state.sending && mm.role === "user") {
|
|
@@ -3404,12 +4568,26 @@ var ChatSession = class {
|
|
|
3404
4568
|
self.updateHistoryCache();
|
|
3405
4569
|
self.host.notify();
|
|
3406
4570
|
if (!fetchMore) {
|
|
4571
|
+
var bgAllow = {};
|
|
4572
|
+
var bgHistBudget = MAX_CONCURRENT_BG_POLLS - self._countBgPolls();
|
|
4573
|
+
if (bgHistBudget > 0) {
|
|
4574
|
+
var bgIds = chatList.filter(function(it) {
|
|
4575
|
+
if (it.status !== "running" && it.status !== "pending") return false;
|
|
4576
|
+
if (!it.poll || !it.id) return false;
|
|
4577
|
+
if (!(it._isBgTask || it._isOnBgQueue)) return false;
|
|
4578
|
+
return !self.historyItemPolls.has(it.id);
|
|
4579
|
+
}).map(function(it) {
|
|
4580
|
+
return it.id;
|
|
4581
|
+
}).sort();
|
|
4582
|
+
for (var ba = 0; ba < bgIds.length && ba < bgHistBudget; ba++) bgAllow[bgIds[ba]] = true;
|
|
4583
|
+
}
|
|
3407
4584
|
chatList.forEach(function(item) {
|
|
3408
4585
|
if (item.status !== "running" && item.status !== "pending") return;
|
|
3409
4586
|
if (!item.poll || !item.id) return;
|
|
3410
4587
|
if (self.historyItemPolls.has(item.id)) return;
|
|
3411
4588
|
if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
|
|
3412
4589
|
if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
|
|
4590
|
+
if ((item._isBgTask || item._isOnBgQueue) && !bgAllow[item.id]) return;
|
|
3413
4591
|
var capturedId = item.id;
|
|
3414
4592
|
var pp = item.poll({
|
|
3415
4593
|
latency: POLL_INTERVAL,
|
|
@@ -3463,6 +4641,7 @@ var ChatSession = class {
|
|
|
3463
4641
|
});
|
|
3464
4642
|
self.drainBgTaskQueue();
|
|
3465
4643
|
}
|
|
4644
|
+
if (!fetchMore) self.refreshLiveIndexState();
|
|
3466
4645
|
if (!fetchMore) return self.host.scrollToBottomIfSticky();
|
|
3467
4646
|
}).catch(function(err) {
|
|
3468
4647
|
console.warn("[chat-engine] getChatHistory failed", err);
|
|
@@ -3480,7 +4659,7 @@ var ChatSession = class {
|
|
|
3480
4659
|
// Upload one attachment (a file = 1 member, a folder = N) to db storage and
|
|
3481
4660
|
// queue indexing per member. The bytes I/O + chip rendering go through host
|
|
3482
4661
|
// hooks; the overwrite/reindex flow, status lifecycle, and indexing live here.
|
|
3483
|
-
uploadSingleAttachment(att) {
|
|
4662
|
+
uploadSingleAttachment(att, stageId) {
|
|
3484
4663
|
var self = this;
|
|
3485
4664
|
var id = this.host.getIdentity();
|
|
3486
4665
|
att.status = "uploading";
|
|
@@ -3527,6 +4706,7 @@ var ChatSession = class {
|
|
|
3527
4706
|
return self.host.promptOverwrite(member.file.name).then(function(choice) {
|
|
3528
4707
|
if (choice === "overwrite") {
|
|
3529
4708
|
existedBefore = true;
|
|
4709
|
+
markImagePreviewStale(self.host.getIdentity().projectId || "default", member.storagePath);
|
|
3530
4710
|
return doMemberUpload(false);
|
|
3531
4711
|
}
|
|
3532
4712
|
if (choice === "skip") {
|
|
@@ -3547,17 +4727,39 @@ var ChatSession = class {
|
|
|
3547
4727
|
att.storagePath = member.storagePath;
|
|
3548
4728
|
}
|
|
3549
4729
|
var mime = member.file.type || self.host.getMimeType(member.file.name);
|
|
3550
|
-
var
|
|
3551
|
-
|
|
4730
|
+
var alreadyIndexing = false;
|
|
4731
|
+
var preIndex = self.claimIndexRun(member.storagePath).then(function(claimed) {
|
|
4732
|
+
alreadyIndexing = !claimed;
|
|
4733
|
+
if (alreadyIndexing) {
|
|
4734
|
+
console.log("[chat-engine] skipping a duplicate index request for", member.storagePath);
|
|
4735
|
+
return;
|
|
4736
|
+
}
|
|
4737
|
+
if (existedBefore && typeof self.host.deleteExistingFileRecord === "function") {
|
|
4738
|
+
return Promise.resolve(self.host.deleteExistingFileRecord(member.storagePath)).catch(function() {
|
|
4739
|
+
});
|
|
4740
|
+
}
|
|
4741
|
+
});
|
|
4742
|
+
preIndex = preIndex.then(function() {
|
|
4743
|
+
if (alreadyIndexing) return;
|
|
4744
|
+
if (typeof self.host.ensureFileIndexRecord !== "function") return;
|
|
4745
|
+
return Promise.resolve(self.host.ensureFileIndexRecord(member.storagePath, {
|
|
4746
|
+
name: member.file.name,
|
|
4747
|
+
mime: mime || void 0,
|
|
4748
|
+
size: member.file.size
|
|
4749
|
+
})).catch(function() {
|
|
4750
|
+
});
|
|
4751
|
+
});
|
|
3552
4752
|
return preIndex.then(function() {
|
|
3553
4753
|
return parseAttachmentContent(member.file, member.file.name, mime || void 0);
|
|
3554
4754
|
}).then(function(parsedContent) {
|
|
3555
|
-
|
|
4755
|
+
if (alreadyIndexing) return;
|
|
4756
|
+
return self.trackIndexDispatch(notifyAgentSaveAttachment({
|
|
3556
4757
|
platform: id.platform,
|
|
3557
4758
|
model: id.model,
|
|
3558
|
-
service: id.
|
|
4759
|
+
service: id.projectId,
|
|
4760
|
+
publicProjectId: id.publicProjectId,
|
|
3559
4761
|
owner: id.owner,
|
|
3560
|
-
userId: id.userId || id.
|
|
4762
|
+
userId: id.userId || id.projectId,
|
|
3561
4763
|
serviceName: id.serviceName,
|
|
3562
4764
|
serviceDescription: id.serviceDescription,
|
|
3563
4765
|
attachment: {
|
|
@@ -3571,7 +4773,7 @@ var ChatSession = class {
|
|
|
3571
4773
|
}).then(function(ack) {
|
|
3572
4774
|
if (ack && typeof ack.id === "string") {
|
|
3573
4775
|
self.bgTaskQueue.push({
|
|
3574
|
-
|
|
4776
|
+
projectId: id.projectId,
|
|
3575
4777
|
platform: id.platform,
|
|
3576
4778
|
id: ack.id,
|
|
3577
4779
|
filename: member.file.name,
|
|
@@ -3580,18 +4782,23 @@ var ChatSession = class {
|
|
|
3580
4782
|
mime: mime || void 0,
|
|
3581
4783
|
size: member.file.size,
|
|
3582
4784
|
status: ack.status === "running" ? "running" : "pending",
|
|
3583
|
-
poll: ack.poll
|
|
4785
|
+
poll: ack.poll,
|
|
4786
|
+
// Puts this file's row directly above the chat turn it was
|
|
4787
|
+
// attached to (drainBgTaskQueue). Undefined for an
|
|
4788
|
+
// attachment-only send, which appends.
|
|
4789
|
+
stageId
|
|
3584
4790
|
});
|
|
3585
4791
|
self.drainBgTaskQueue();
|
|
3586
4792
|
}
|
|
3587
4793
|
}, function(e) {
|
|
3588
4794
|
console.error("[chat-engine] indexing request failed", e);
|
|
4795
|
+
self.releaseIndexRun(member.storagePath);
|
|
3589
4796
|
anyIndexFailed = true;
|
|
3590
4797
|
if (!att.errorCode && !att.errorDetail) {
|
|
3591
4798
|
att.errorCode = e && (e.code || e.body && e.body.code) || "";
|
|
3592
4799
|
att.errorDetail = e && (e.message || e.body && e.body.message) || (typeof e === "string" ? e : "");
|
|
3593
4800
|
}
|
|
3594
|
-
});
|
|
4801
|
+
}));
|
|
3595
4802
|
});
|
|
3596
4803
|
});
|
|
3597
4804
|
});
|
|
@@ -3610,14 +4817,28 @@ var ChatSession = class {
|
|
|
3610
4817
|
}
|
|
3611
4818
|
// Upload all not-yet-done attachments sequentially. Resolves to the full
|
|
3612
4819
|
// list of { name, url, storagePath } for composing the chat message.
|
|
3613
|
-
|
|
4820
|
+
//
|
|
4821
|
+
// `batchId` scopes the run to the chips stamped with it at Send time. The
|
|
4822
|
+
// composer stays live during an upload, so by the time this runs the
|
|
4823
|
+
// attachment list can already hold chips the user picked for the NEXT
|
|
4824
|
+
// message — uploading those here would attach them to the wrong turn, and
|
|
4825
|
+
// collecting the previous batch's finished urls would attach files the user
|
|
4826
|
+
// already sent. Omitted (no batch) means every chip, the old behavior.
|
|
4827
|
+
//
|
|
4828
|
+
// `stageId` is the turn these chips were attached to, carried onto every indexing
|
|
4829
|
+
// task so its collapsed row renders directly ABOVE that turn's bubble (see
|
|
4830
|
+
// BgTaskEntry.stageId). Omitted for an attachment-only send, which has no turn.
|
|
4831
|
+
uploadPendingAttachments(batchId, stageId) {
|
|
3614
4832
|
var self = this;
|
|
3615
4833
|
this.host.resetOverwriteBatch();
|
|
4834
|
+
this._uploadBatches += 1;
|
|
3616
4835
|
this.state.uploadingAttachments = true;
|
|
3617
4836
|
this.host.updateComposerControls();
|
|
3618
4837
|
this.host.renderAttachmentChips();
|
|
3619
4838
|
var collected = [];
|
|
3620
|
-
var snapshot = this.state.attachments.
|
|
4839
|
+
var snapshot = this.state.attachments.filter(function(a) {
|
|
4840
|
+
return batchId ? a._batchId === batchId : true;
|
|
4841
|
+
});
|
|
3621
4842
|
var chain = Promise.resolve();
|
|
3622
4843
|
snapshot.forEach(function(att) {
|
|
3623
4844
|
chain = chain.then(function() {
|
|
@@ -3636,7 +4857,7 @@ var ChatSession = class {
|
|
|
3636
4857
|
return;
|
|
3637
4858
|
}
|
|
3638
4859
|
}
|
|
3639
|
-
return self.uploadSingleAttachment(att).then(function(us) {
|
|
4860
|
+
return self.uploadSingleAttachment(att, stageId).then(function(us) {
|
|
3640
4861
|
collected.push.apply(collected, us);
|
|
3641
4862
|
}).catch(function(err) {
|
|
3642
4863
|
var removed = !self.state.attachments.some(function(a) {
|
|
@@ -3653,7 +4874,8 @@ var ChatSession = class {
|
|
|
3653
4874
|
});
|
|
3654
4875
|
});
|
|
3655
4876
|
var done = function() {
|
|
3656
|
-
self.
|
|
4877
|
+
self._uploadBatches = Math.max(0, self._uploadBatches - 1);
|
|
4878
|
+
self.state.uploadingAttachments = self._uploadBatches > 0;
|
|
3657
4879
|
self.host.updateComposerControls();
|
|
3658
4880
|
self.host.renderAttachmentChips();
|
|
3659
4881
|
return collected;
|
|
@@ -3714,9 +4936,22 @@ function readFileRef(msg) {
|
|
|
3714
4936
|
function isPendingMsg(m) {
|
|
3715
4937
|
return !!(m.isPending || m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
|
|
3716
4938
|
}
|
|
4939
|
+
function isHiddenPass(m) {
|
|
4940
|
+
if (m.role === "user") {
|
|
4941
|
+
if (m.isCancelled) return false;
|
|
4942
|
+
var ref = readFileRef(m);
|
|
4943
|
+
return !!(ref && ref.continued);
|
|
4944
|
+
}
|
|
4945
|
+
return !!m.isPending;
|
|
4946
|
+
}
|
|
3717
4947
|
function buildChatDisplayList(messages, opts) {
|
|
3718
4948
|
var list = Array.isArray(messages) ? messages : [];
|
|
4949
|
+
var liveIndexKeys = opts && opts.liveIndexKeys || {};
|
|
4950
|
+
var liveIndexChecked = !!(opts && opts.liveIndexChecked);
|
|
4951
|
+
var stoppedIndexIds = opts && opts.stoppedIndexIds || {};
|
|
4952
|
+
var windowedIndexing = opts && opts.windowedIndexing !== void 0 ? !!opts.windowedIndexing : windowedIndexingEnabled();
|
|
3719
4953
|
var hasMoreHistory = !!(opts && opts.hasMoreHistory);
|
|
4954
|
+
var loadingOlderHistory = !!(opts && opts.loadingOlderHistory);
|
|
3720
4955
|
var groups = {};
|
|
3721
4956
|
var order = [];
|
|
3722
4957
|
var runOfIndex = new Array(list.length);
|
|
@@ -3764,11 +4999,17 @@ function buildChatDisplayList(messages, opts) {
|
|
|
3764
4999
|
status: "done",
|
|
3765
5000
|
cancellableIds: [],
|
|
3766
5001
|
cancelling: false,
|
|
5002
|
+
stopped: false,
|
|
3767
5003
|
mayHaveOlder: false,
|
|
3768
5004
|
// The run's first loaded pass, and never re-stamped: see the file
|
|
3769
5005
|
// docstring. `anchorId` is filled in once every member is known.
|
|
3770
5006
|
anchorIndex: i,
|
|
3771
|
-
anchorId: ""
|
|
5007
|
+
anchorId: "",
|
|
5008
|
+
// All five are derived once every member is known, below.
|
|
5009
|
+
visibleMembers: [],
|
|
5010
|
+
driver: "single",
|
|
5011
|
+
finished: false,
|
|
5012
|
+
resolving: false
|
|
3772
5013
|
};
|
|
3773
5014
|
order.push(runId);
|
|
3774
5015
|
}
|
|
@@ -3786,6 +5027,11 @@ function buildChatDisplayList(messages, opts) {
|
|
|
3786
5027
|
if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
|
|
3787
5028
|
if (ref && ref.name) keyByName[ref.name] = g.key;
|
|
3788
5029
|
}
|
|
5030
|
+
var newestRunOfKey = {};
|
|
5031
|
+
for (var nk in runsOfKey) {
|
|
5032
|
+
var nrs = runsOfKey[nk];
|
|
5033
|
+
if (nrs.length) newestRunOfKey[nrs[nrs.length - 1]] = true;
|
|
5034
|
+
}
|
|
3789
5035
|
for (var rk in runsOfKey) {
|
|
3790
5036
|
var runIds = runsOfKey[rk];
|
|
3791
5037
|
for (var ri = 0; ri < runIds.length; ri++) {
|
|
@@ -3809,6 +5055,19 @@ function buildChatDisplayList(messages, opts) {
|
|
|
3809
5055
|
break;
|
|
3810
5056
|
}
|
|
3811
5057
|
}
|
|
5058
|
+
var stopped = false;
|
|
5059
|
+
for (var ki = 0; ki < grp.members.length; ki++) {
|
|
5060
|
+
var km = grp.members[ki].msg;
|
|
5061
|
+
if (km.isCancelled) {
|
|
5062
|
+
stopped = true;
|
|
5063
|
+
break;
|
|
5064
|
+
}
|
|
5065
|
+
if (km._serverItemId && stoppedIndexIds[km._serverItemId]) {
|
|
5066
|
+
stopped = true;
|
|
5067
|
+
break;
|
|
5068
|
+
}
|
|
5069
|
+
}
|
|
5070
|
+
grp.stopped = stopped;
|
|
3812
5071
|
for (var xi = 0; xi < grp.members.length; xi++) {
|
|
3813
5072
|
if (grp.members[xi].msg._cancelling) {
|
|
3814
5073
|
grp.cancelling = true;
|
|
@@ -3818,7 +5077,7 @@ function buildChatDisplayList(messages, opts) {
|
|
|
3818
5077
|
var seenIds = {};
|
|
3819
5078
|
for (var ci = 0; ci < grp.members.length; ci++) {
|
|
3820
5079
|
var cm = grp.members[ci].msg;
|
|
3821
|
-
if (cm._cancelError && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
|
|
5080
|
+
if (cm._cancelError && !stopped && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
|
|
3822
5081
|
if (cm.role !== "user" || !cm._serverItemId || cm._cancelling || cm.isSendingToServer) continue;
|
|
3823
5082
|
if (!(cm.isPendingQueued || cm.isPendingInProcess)) continue;
|
|
3824
5083
|
if (ci < lastSettled) continue;
|
|
@@ -3828,9 +5087,12 @@ function buildChatDisplayList(messages, opts) {
|
|
|
3828
5087
|
}
|
|
3829
5088
|
if (active) {
|
|
3830
5089
|
grp.status = "active";
|
|
5090
|
+
if (stopped) grp.cancelling = true;
|
|
5091
|
+
} else if (stopped) {
|
|
5092
|
+
grp.status = "cancelled";
|
|
3831
5093
|
} else {
|
|
3832
5094
|
var last = grp.members[grp.members.length - 1].msg;
|
|
3833
|
-
grp.status = last.isError ? "error" :
|
|
5095
|
+
grp.status = last.isError ? "error" : "done";
|
|
3834
5096
|
}
|
|
3835
5097
|
var sawFirstPass = false;
|
|
3836
5098
|
for (var pi = 0; pi < grp.members.length; pi++) {
|
|
@@ -3846,6 +5108,35 @@ function buildChatDisplayList(messages, opts) {
|
|
|
3846
5108
|
var anchor = grp.members[0];
|
|
3847
5109
|
grp.anchorIndex = anchor.index;
|
|
3848
5110
|
grp.anchorId = anchor.msg._serverItemId || anchor.msg._localId || "";
|
|
5111
|
+
var sawComplete = false;
|
|
5112
|
+
for (var vi = 0; vi < grp.members.length; vi++) {
|
|
5113
|
+
var vm = grp.members[vi];
|
|
5114
|
+
if (vm.msg._indexComplete) sawComplete = true;
|
|
5115
|
+
if (!isHiddenPass(vm.msg)) grp.visibleMembers.push(vm);
|
|
5116
|
+
}
|
|
5117
|
+
grp.driver = !isPagedReadFile(grp.name, grp.mime) ? "single" : isImageVisionFile(grp.name, grp.mime) ? "worker" : windowedIndexing ? "worker" : "client";
|
|
5118
|
+
if (grp.status === "active") {
|
|
5119
|
+
grp.finished = false;
|
|
5120
|
+
} else if (grp.status === "cancelled") {
|
|
5121
|
+
grp.finished = true;
|
|
5122
|
+
} else if (grp.driver === "single") {
|
|
5123
|
+
grp.finished = true;
|
|
5124
|
+
} else if (grp.driver === "client") {
|
|
5125
|
+
grp.finished = sawComplete || grp.status === "error" || grp.passCount >= MAX_INDEXING_RESUME_PASSES;
|
|
5126
|
+
} else {
|
|
5127
|
+
grp.finished = !newestRunOfKey[order[oi]] || liveIndexChecked && !liveIndexKeys[grp.key];
|
|
5128
|
+
}
|
|
5129
|
+
if (grp.status !== "done") {
|
|
5130
|
+
grp.resolving = false;
|
|
5131
|
+
} else if (grp.mayHaveOlder && loadingOlderHistory && !liveIndexKeys[grp.key] && newestRunOfKey[order[oi]]) {
|
|
5132
|
+
grp.resolving = true;
|
|
5133
|
+
grp.resolvingReason = "history";
|
|
5134
|
+
} else if (!grp.finished && grp.driver === "worker" && !liveIndexChecked && !liveIndexKeys[grp.key]) {
|
|
5135
|
+
grp.resolving = true;
|
|
5136
|
+
grp.resolvingReason = "status";
|
|
5137
|
+
} else {
|
|
5138
|
+
grp.resolving = false;
|
|
5139
|
+
}
|
|
3849
5140
|
}
|
|
3850
5141
|
var out = [];
|
|
3851
5142
|
for (var j = 0; j < list.length; j++) {
|
|
@@ -3869,6 +5160,7 @@ exports.CONTEXT_WINDOW_DEFAULT = CONTEXT_WINDOW_DEFAULT;
|
|
|
3869
5160
|
exports.ChatSession = ChatSession;
|
|
3870
5161
|
exports.DEFAULT_CLAUDE_MODEL = DEFAULT_CLAUDE_MODEL;
|
|
3871
5162
|
exports.DEFAULT_OPENAI_MODEL = DEFAULT_OPENAI_MODEL;
|
|
5163
|
+
exports.EMPTY_INDEXING_REPLY = EMPTY_INDEXING_REPLY;
|
|
3872
5164
|
exports.EXPIRED_ATTACHMENT_URL_HOST = EXPIRED_ATTACHMENT_URL_HOST;
|
|
3873
5165
|
exports.EXPIRED_ATTACHMENT_URL_ORIGIN = EXPIRED_ATTACHMENT_URL_ORIGIN;
|
|
3874
5166
|
exports.EXPIRED_LINK_REFRESH_EXPIRES_SECONDS = EXPIRED_LINK_REFRESH_EXPIRES_SECONDS;
|
|
@@ -3878,8 +5170,14 @@ exports.HISTORY_FILL_SLACK_PX = HISTORY_FILL_SLACK_PX;
|
|
|
3878
5170
|
exports.HISTORY_TOKEN_BUDGET = HISTORY_TOKEN_BUDGET;
|
|
3879
5171
|
exports.HTML_EXTS = HTML_EXTS;
|
|
3880
5172
|
exports.HTML_HEAD_WINDOW = HTML_HEAD_WINDOW;
|
|
5173
|
+
exports.IMAGE_PREVIEWS_PER_MESSAGE = IMAGE_PREVIEWS_PER_MESSAGE;
|
|
5174
|
+
exports.INDEXING_COMPLETE_MARKER = INDEXING_COMPLETE_MARKER;
|
|
5175
|
+
exports.INLINE_LINK_GLYPH = INLINE_LINK_GLYPH;
|
|
5176
|
+
exports.INLINE_LINK_UNAVAILABLE_GLYPH = INLINE_LINK_UNAVAILABLE_GLYPH;
|
|
5177
|
+
exports.INLINE_LINK_UNAVAILABLE_SUFFIX = INLINE_LINK_UNAVAILABLE_SUFFIX;
|
|
3881
5178
|
exports.LINK_LABEL_MAX_DISPLAY_CHARS = LINK_LABEL_MAX_DISPLAY_CHARS;
|
|
3882
5179
|
exports.LINK_REFRESH_WINDOW_MS = LINK_REFRESH_WINDOW_MS;
|
|
5180
|
+
exports.MAX_CONCURRENT_BG_POLLS = MAX_CONCURRENT_BG_POLLS;
|
|
3883
5181
|
exports.MAX_HISTORY_FILL_PAGES = MAX_HISTORY_FILL_PAGES;
|
|
3884
5182
|
exports.MAX_HISTORY_MESSAGES = MAX_HISTORY_MESSAGES;
|
|
3885
5183
|
exports.MAX_PARSED_CONTENT_CHARS = MAX_PARSED_CONTENT_CHARS;
|
|
@@ -3887,11 +5185,14 @@ exports.MCP_NAME = MCP_NAME;
|
|
|
3887
5185
|
exports.MIN_INPUT_TOKEN_BUDGET = MIN_INPUT_TOKEN_BUDGET;
|
|
3888
5186
|
exports.OUTPUT_TOKEN_RESERVE = OUTPUT_TOKEN_RESERVE;
|
|
3889
5187
|
exports.POLL_INTERVAL = POLL_INTERVAL;
|
|
5188
|
+
exports.PREVIEWABLE_IMAGE_CONTENT_TYPES = PREVIEWABLE_IMAGE_CONTENT_TYPES;
|
|
5189
|
+
exports.PREVIEW_BROWSER_CACHE_SECONDS = PREVIEW_BROWSER_CACHE_SECONDS;
|
|
3890
5190
|
exports.RENDER_FROM_TOKEN = RENDER_FROM_TOKEN;
|
|
3891
5191
|
exports.RTF_EXTS = RTF_EXTS;
|
|
3892
5192
|
exports.TOOL_AND_RESPONSE_BUFFER = TOOL_AND_RESPONSE_BUFFER;
|
|
3893
5193
|
exports.XML_EXTS = XML_EXTS;
|
|
3894
5194
|
exports.applyEncodingDeclaration = applyEncodingDeclaration;
|
|
5195
|
+
exports.bgIndexingQueueName = bgIndexingQueueName;
|
|
3895
5196
|
exports.buildAiAgentValue = buildAiAgentValue;
|
|
3896
5197
|
exports.buildBoundedChatMessages = buildBoundedChatMessages;
|
|
3897
5198
|
exports.buildChatDisplayList = buildChatDisplayList;
|
|
@@ -3909,6 +5210,7 @@ exports.callOpenAIWithPublicMcp = callOpenAIWithPublicMcp;
|
|
|
3909
5210
|
exports.chatEngineConfig = chatEngineConfig;
|
|
3910
5211
|
exports.classifyInlineLink = classifyInlineLink;
|
|
3911
5212
|
exports.clearAttachmentParsers = clearAttachmentParsers;
|
|
5213
|
+
exports.clearImagePreviewCache = clearImagePreviewCache;
|
|
3912
5214
|
exports.composeUserMessage = composeUserMessage;
|
|
3913
5215
|
exports.configureChatEngine = configureChatEngine;
|
|
3914
5216
|
exports.contentTypeForExt = contentTypeForExt;
|
|
@@ -3918,6 +5220,7 @@ exports.encodePathSegments = encodePathSegments;
|
|
|
3918
5220
|
exports.encodingClassForExt = encodingClassForExt;
|
|
3919
5221
|
exports.ensureHtmlCharset = ensureHtmlCharset;
|
|
3920
5222
|
exports.ensureXmlEncoding = ensureXmlEncoding;
|
|
5223
|
+
exports.escapeInlineHtml = escapeInlineHtml;
|
|
3921
5224
|
exports.escapeRtfNonAscii = escapeRtfNonAscii;
|
|
3922
5225
|
exports.estimateMessageTokens = estimateMessageTokens;
|
|
3923
5226
|
exports.estimateTextTokens = estimateTextTokens;
|
|
@@ -3936,22 +5239,29 @@ exports.getContextWindow = getContextWindow;
|
|
|
3936
5239
|
exports.getErrorMessage = getErrorMessage;
|
|
3937
5240
|
exports.getExpiredAttachmentVisiblePath = getExpiredAttachmentVisiblePath;
|
|
3938
5241
|
exports.getProjectContextWindow = getProjectContextWindow;
|
|
5242
|
+
exports.getVisionProfile = getVisionProfile;
|
|
3939
5243
|
exports.groupAttachmentFailures = groupAttachmentFailures;
|
|
3940
5244
|
exports.hasBom = hasBom;
|
|
5245
|
+
exports.hydrateImagePreviews = hydrateImagePreviews;
|
|
3941
5246
|
exports.isAuthExpiredError = isAuthExpiredError;
|
|
3942
5247
|
exports.isBgIndexingQueue = isBgIndexingQueue;
|
|
3943
5248
|
exports.isErrorResponseBody = isErrorResponseBody;
|
|
3944
5249
|
exports.isHttpUrlLike = isHttpUrlLike;
|
|
3945
5250
|
exports.isIndexingRequestText = isIndexingRequestText;
|
|
5251
|
+
exports.isLinkUnavailable = isLinkUnavailable;
|
|
3946
5252
|
exports.isNonRetryableRequestError = isNonRetryableRequestError;
|
|
3947
5253
|
exports.isOfficeFile = isOfficeFile;
|
|
5254
|
+
exports.isPreviewableImagePath = isPreviewableImagePath;
|
|
3948
5255
|
exports.isServerExtractable = isServerExtractable;
|
|
3949
5256
|
exports.isServiceDbAttachmentHref = isServiceDbAttachmentHref;
|
|
5257
|
+
exports.linkUnavailableKeyForHref = linkUnavailableKeyForHref;
|
|
5258
|
+
exports.linkUnavailableKeyForPath = linkUnavailableKeyForPath;
|
|
3950
5259
|
exports.listClaudeModels = listClaudeModels;
|
|
3951
5260
|
exports.listOpenAIModels = listOpenAIModels;
|
|
3952
5261
|
exports.looksLikeRtf = looksLikeRtf;
|
|
3953
5262
|
exports.makeExtractPlaceholder = makeExtractPlaceholder;
|
|
3954
5263
|
exports.mapHistoryListToMessages = mapHistoryListToMessages;
|
|
5264
|
+
exports.markImagePreviewStale = markImagePreviewStale;
|
|
3955
5265
|
exports.needsBomForExt = needsBomForExt;
|
|
3956
5266
|
exports.normalizeAttachmentPathCandidate = normalizeAttachmentPathCandidate;
|
|
3957
5267
|
exports.normalizeExt = normalizeExt;
|
|
@@ -3962,12 +5272,17 @@ exports.parseAiAgentValue = parseAiAgentValue;
|
|
|
3962
5272
|
exports.parseAttachmentContent = parseAttachmentContent;
|
|
3963
5273
|
exports.parseIndexingLabel = parseIndexingLabel;
|
|
3964
5274
|
exports.parseIndexingRequestText = parseIndexingRequestText;
|
|
5275
|
+
exports.peekImagePreviewUrl = peekImagePreviewUrl;
|
|
3965
5276
|
exports.prepareDownloadText = prepareDownloadText;
|
|
5277
|
+
exports.previewImageContentType = previewImageContentType;
|
|
5278
|
+
exports.previewableExtOf = previewableExtOf;
|
|
3966
5279
|
exports.readExpiredAttachmentHref = readExpiredAttachmentHref;
|
|
3967
5280
|
exports.registerAttachmentParser = registerAttachmentParser;
|
|
3968
5281
|
exports.registerModelContextWindows = registerModelContextWindows;
|
|
5282
|
+
exports.renderInlineLinkHtml = renderInlineLinkHtml;
|
|
3969
5283
|
exports.repairUrlEntities = repairUrlEntities;
|
|
3970
5284
|
exports.repairUrlWhitespace = repairUrlWhitespace;
|
|
5285
|
+
exports.resolveImagePreviewUrl = resolveImagePreviewUrl;
|
|
3971
5286
|
exports.safeDecodeURIComponent = safeDecodeURIComponent;
|
|
3972
5287
|
exports.sanitizeAttachmentLinksForHistory = sanitizeAttachmentLinksForHistory;
|
|
3973
5288
|
exports.setProjectContextWindow = setProjectContextWindow;
|