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
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* BASE PROMPT
|
|
2
|
+
* BASE PROMPT - Background file-indexing agent (user message)
|
|
3
3
|
* ============================================================================
|
|
4
4
|
* USER-role message paired with the indexing system prompt. Sent by
|
|
5
5
|
* notifyAgentSaveAttachment() each time a file is uploaded or re-indexed.
|
|
6
6
|
*
|
|
7
7
|
* NOTE: the leading line "A new file has just been uploaded. Index it now." and
|
|
8
8
|
* the "- name: ..." line are also what the chat client parses to build the
|
|
9
|
-
* "Indexing: <name>" history bubble
|
|
9
|
+
* "Indexing: <name>" history bubble - keep those fields on their own lines.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
export type IndexingAttachmentInfo = {
|
|
@@ -24,15 +24,15 @@ export type IndexingAttachmentInfo = {
|
|
|
24
24
|
|
|
25
25
|
export type BuildIndexingUserMessageOptions = {
|
|
26
26
|
/**
|
|
27
|
-
* For
|
|
27
|
+
* For files with no paged reader (.epub/.hwp/.doc/.rtf, source code) the model can't read the binary via
|
|
28
28
|
* web_fetch, so the proxy worker extracts the text server-side and replaces
|
|
29
29
|
* this exact token with it. When provided, the message embeds the token (and
|
|
30
|
-
* drops the temporary-URL line
|
|
30
|
+
* drops the temporary-URL line - there is nothing for the model to fetch).
|
|
31
31
|
*/
|
|
32
32
|
inlineContentPlaceholder?: string;
|
|
33
33
|
/**
|
|
34
34
|
* Actual file content parsed CLIENT-SIDE by an attachment-parser plugin (e.g.
|
|
35
|
-
* an .hwp parser). Embedded inline verbatim
|
|
35
|
+
* an .hwp parser). Embedded inline verbatim - no server extraction and no
|
|
36
36
|
* web_fetch for this file. Takes precedence over `inlineContentPlaceholder`.
|
|
37
37
|
*/
|
|
38
38
|
inlineContent?: string;
|
|
@@ -58,12 +58,12 @@ export function buildIndexingUserMessage(
|
|
|
58
58
|
|
|
59
59
|
if (options?.inlineContent) {
|
|
60
60
|
// Parsed client-side (an attachment-parser plugin). The content is already
|
|
61
|
-
// inlined below
|
|
61
|
+
// inlined below - no server extraction, no URL to fetch.
|
|
62
62
|
return (
|
|
63
63
|
head +
|
|
64
64
|
`\nThe file's content was parsed by the client and is provided inline below. ` +
|
|
65
|
-
`Read it directly
|
|
66
|
-
`
|
|
65
|
+
`Read it directly - do NOT fetch any URL for this file. ` +
|
|
66
|
+
`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.\n\n` +
|
|
67
67
|
`----- BEGIN FILE CONTENT -----\n` +
|
|
68
68
|
`${options.inlineContent}\n` +
|
|
69
69
|
`----- END FILE CONTENT -----`
|
|
@@ -76,8 +76,8 @@ export function buildIndexingUserMessage(
|
|
|
76
76
|
return (
|
|
77
77
|
head +
|
|
78
78
|
`\nThe file's text content was extracted on the server and is provided inline below. ` +
|
|
79
|
-
`Read it directly
|
|
80
|
-
`
|
|
79
|
+
`Read it directly - do NOT fetch any URL for this file. ` +
|
|
80
|
+
`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.\n\n` +
|
|
81
81
|
`----- BEGIN FILE CONTENT -----\n` +
|
|
82
82
|
`${options.inlineContentPlaceholder}\n` +
|
|
83
83
|
`----- END FILE CONTENT -----`
|
|
@@ -93,7 +93,7 @@ export function buildIndexingUserMessage(
|
|
|
93
93
|
`\nRead this file with the readFileContent tool, using the storage path above - do NOT fetch a URL and do NOT rely on a single sample. ` +
|
|
94
94
|
`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. ` +
|
|
95
95
|
`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. ` +
|
|
96
|
-
`Do NOT stop after the first window and do NOT just write a summary.
|
|
96
|
+
`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.` +
|
|
97
97
|
(attachment.url ? `\n(A temporary URL is provided ONLY as a fallback if readFileContent fails: ${attachment.url})` : '')
|
|
98
98
|
);
|
|
99
99
|
}
|
|
@@ -186,7 +186,17 @@ function buildRenderDatafy(placeholder: string): string {
|
|
|
186
186
|
`LOOK at each rendered page image in this message and DATAFY what it shows: for EVERY page ` +
|
|
187
187
|
`call postRecords and save records - one record per row / table entry / line item visible on the page ` +
|
|
188
188
|
`(or one record for the page if it is prose), capturing every value you can read (OCR the text, read tables ` +
|
|
189
|
-
`cell by cell, describe any photos/diagrams).
|
|
189
|
+
`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.\n\n` +
|
|
190
|
+
`Each image is preceded by a label giving its DOCUMENT PAGE number. That label is the page's identity - ` +
|
|
191
|
+
`use it, and ignore any page number PRINTED on the document itself (a scan often restarts its own ` +
|
|
192
|
+
`numbering per section, so a footer reading "PAGE 4 OF 8" routinely disagrees with the real position). ` +
|
|
193
|
+
`Whether a page is one you have already saved is stated in the note above the images - decide from that, ` +
|
|
194
|
+
`never from a printed page number.\n\n` +
|
|
195
|
+
`Transcribe COMPLETELY, not representatively. A table with twenty rows gets twenty records, not a sample ` +
|
|
196
|
+
`of the first few - if a page has more rows than you can save comfortably, still save them all rather than ` +
|
|
197
|
+
`summarising. Where a page carries an embedded text layer it is quoted above that page's image: it is the ` +
|
|
198
|
+
`exact text and should be preferred over reading the pixels, with the image used for layout, tables, ` +
|
|
199
|
+
`stamps and handwriting.\n\n` +
|
|
190
200
|
`Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read ` +
|
|
191
201
|
`the rest of the file and do NOT worry about the pages after this window: if any remain, the next window ` +
|
|
192
202
|
`is rendered and sent to you automatically. Report only the pages you were actually shown - never imply ` +
|
|
@@ -227,12 +237,12 @@ export function buildIndexingWindowMessage(
|
|
|
227
237
|
`\n${placeholder}\n\n` +
|
|
228
238
|
`DATAFY this window: call postRecords and save records for everything in it - ONE RECORD PER ROW ` +
|
|
229
239
|
`for tabular data (keyed by the column headers), or one record per section for prose. Capture every ` +
|
|
230
|
-
`value you can read.
|
|
240
|
+
`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, ` +
|
|
231
241
|
`and link every row/section record to it by reference.\n\n` +
|
|
232
|
-
`If this window has PHOTOS attached as images, LOOK at each one and datafy what it actually shows ` +
|
|
233
|
-
`
|
|
234
|
-
|
|
235
|
-
`could not be extracted when images are attached here.\n\n` +
|
|
242
|
+
`If this window has PHOTOS attached as images, LOOK at each one and datafy what it actually shows. ` +
|
|
243
|
+
`A «PHOTO ...» marker in the grid text ties a picture to its row and comes in two forms. ` +
|
|
244
|
+
`«PHOTO A88 -> __MEDIA__/...» 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. ` +
|
|
245
|
+
`A bare «PHOTO A88» 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.\n\n` +
|
|
236
246
|
`Save records for THIS window only, then stop and report what you saved. Do NOT try to read the rest ` +
|
|
237
247
|
`of the file, and do NOT call readFileContent - if more remains, the next window is read and sent to ` +
|
|
238
248
|
`you automatically. Report only what you were actually shown, and never imply you have seen the whole ` +
|
|
@@ -253,13 +263,13 @@ export function buildIndexingContinueMessage(attachment: IndexingAttachmentInfo)
|
|
|
253
263
|
`- storage path: ${attachment.storagePath}\n` +
|
|
254
264
|
(attachment.mime ? `- mime type: ${attachment.mime}\n` : '') +
|
|
255
265
|
`\nRecords for the earlier windows/pages of this file are ALREADY saved (they reference "${src}"). ` +
|
|
256
|
-
`First call getRecords with reference "${src}" to see how far the previous pass got (the furthest
|
|
266
|
+
`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. ` +
|
|
257
267
|
`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:\n` +
|
|
258
|
-
`
|
|
259
|
-
`
|
|
260
|
-
` - Text: the cursor is the character offset already read.\n` +
|
|
268
|
+
` - 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".\n` +
|
|
269
|
+
` - Text: the cursor is the character offset already read.\n` +
|
|
261
270
|
`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. ` +
|
|
271
|
+
`A «PHOTO <cell>» 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. ` +
|
|
262
272
|
`Do NOT re-save windows that are already saved. ` +
|
|
263
|
-
`
|
|
273
|
+
`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.`
|
|
264
274
|
);
|
|
265
275
|
}
|
package/src/engine/requests.ts
CHANGED
|
@@ -44,11 +44,99 @@ const clientSecretRequest = (opts: any) => chatEngineConfig().clientSecretReques
|
|
|
44
44
|
// model silently fell through to 'auto' — i.e. the cheap tiers that most need
|
|
45
45
|
// resolution were the ones getting downsampled images.
|
|
46
46
|
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
47
|
+
// Variants used to resolve to 'high' rather than 'original' on the reasoning that
|
|
48
|
+
// 'high' is universally supported and an unsupported value would fail the whole
|
|
49
|
+
// request. The cost of that caution turned out to be real: 'high' downsamples to
|
|
50
|
+
// a 512px grid, so the small tiers that most need resolution were reading dense
|
|
51
|
+
// scans at the lower of the two settings, and gpt-5.4-nano reports it cannot make
|
|
52
|
+
// out the text where gpt-5.4 (on 'original') can. Variants now get 'original' too.
|
|
53
|
+
//
|
|
54
|
+
// If a variant rejects 'original' the failure is loud and immediate (a terminal
|
|
55
|
+
// 400 on the whole request, no retry), so flip VARIANT_IMAGE_DETAIL back to
|
|
56
|
+
// 'high' and it is undone. That is the one word to change.
|
|
57
|
+
const VARIANT_IMAGE_DETAIL = 'original';
|
|
58
|
+
|
|
59
|
+
// Extra Responses-API knobs for NANO models on INDEXING passes only.
|
|
60
|
+
//
|
|
61
|
+
// `detail` (above) governs what the model SEES; these govern how it thinks and how completely it
|
|
62
|
+
// writes. Nano strips vision detail to hit its latency target and compresses layout when
|
|
63
|
+
// transcribing, which is the documented reason to raise verbosity FOR IT SPECIFICALLY. mini and the
|
|
64
|
+
// base/named models do not need it, so they are excluded rather than paying its output cost.
|
|
65
|
+
//
|
|
66
|
+
// Deliberately NOT applied to chat: high verbosity makes ordinary replies longer and worse, and the
|
|
67
|
+
// chat path has no transcription job to justify it.
|
|
68
|
+
//
|
|
69
|
+
// Two things to keep in mind, both of which is why these are separate switches:
|
|
70
|
+
// 1. An UNKNOWN body field is fatal here, not ignored. The engine already documents it for
|
|
71
|
+
// `_skapi_window`: it "reaches the provider as an unknown body field and the call fails
|
|
72
|
+
// terminally with no retry". If a field name is wrong, EVERY indexing pass dies. Set either
|
|
73
|
+
// constant to null to remove the field entirely.
|
|
74
|
+
// 2. Reasoning tokens are billed against max_output_tokens on this API. An indexing pass spends
|
|
75
|
+
// its output budget emitting postRecords calls, so buying reasoning takes budget away from the
|
|
76
|
+
// records themselves and can truncate them.
|
|
77
|
+
//
|
|
78
|
+
// Effort was OFF, and the measurement that turned it on: across one nano run's photo records, only
|
|
79
|
+
// 5 of 28 image text fields carried a concrete identifier read off the tag (a part number, a tag id).
|
|
80
|
+
// The other 23 held a generic scene description. Reading small handwriting off a photographed label
|
|
81
|
+
// is exactly the kind of work a moment of deliberation buys, so it went on at the LOWEST setting.
|
|
82
|
+
//
|
|
83
|
+
// It stays 'low' for a nano that already transcribes correctly (5.6-nano), because effort is
|
|
84
|
+
// billed against the same budget as the records and buying it where it is not needed can only
|
|
85
|
+
// truncate them.
|
|
86
|
+
//
|
|
87
|
+
// OLDEST_NANO_REASONING_EFFORT raises it for gpt-5.4-nano and below ONLY, which is the tier that
|
|
88
|
+
// actually reads dense scans badly. It is 'high', and what makes that affordable is not one change
|
|
89
|
+
// but two:
|
|
90
|
+
// - that tier's render window is SMALL_TIER_PAGES_PER_WINDOW pages, not five, so the same cap is
|
|
91
|
+
// divided between far fewer pages to begin with; and
|
|
92
|
+
// - the worker now DETECTS a pass that ran out of output budget (_output_truncation_reason) and
|
|
93
|
+
// re-runs that window at half the page count instead of advancing past it.
|
|
94
|
+
// Before the detector, over-buying reasoning was unsafe in a way that did not show up: a truncated
|
|
95
|
+
// pass still returned 200, so the page loop moved on and the rest of that window's records were
|
|
96
|
+
// lost silently. With it, spending too much on reasoning costs an extra pass instead of data.
|
|
97
|
+
//
|
|
98
|
+
// Still the first knob to lower if the logs start showing repeated truncation retries, and
|
|
99
|
+
// re-measure that 5-of-28 ratio rather than assuming. null removes the field entirely.
|
|
100
|
+
const VARIANT_TEXT_VERBOSITY: string | null = 'high';
|
|
101
|
+
const VARIANT_REASONING_EFFORT: string | null = 'low';
|
|
102
|
+
const OLDEST_NANO_REASONING_EFFORT: string | null = 'high';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* True only for a NANO model, including a dated nano snapshot. NOT mini, not a base model, and not
|
|
106
|
+
* a named variant like gpt-5.6-luna: those transcribe faithfully on their own and do not need the
|
|
107
|
+
* knob, so paying its output cost on them would be waste.
|
|
108
|
+
*/
|
|
109
|
+
const isOpenAINano = (model?: string) => {
|
|
110
|
+
const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
|
|
111
|
+
if (!/(^|-)nano(-|$)/.test(normalized)) return false;
|
|
112
|
+
|
|
113
|
+
// Family floor, same shape as getOpenAIImageDetail's. "Contains nano" alone also matches
|
|
114
|
+
// gpt-4.1-nano and anything else a project might name, and these are body fields an older
|
|
115
|
+
// model REJECTS rather than ignores: one 400 with no retry kills every indexing pass. Only
|
|
116
|
+
// gpt-5.4 and newer are known to take `text.verbosity` and `reasoning.effort`.
|
|
117
|
+
const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
|
|
118
|
+
if (!match) return false;
|
|
119
|
+
const major = Number(match[1]);
|
|
120
|
+
const minor = match[2] === undefined ? null : Number(match[2]);
|
|
121
|
+
return major > 5 || (major === 5 && minor !== null && minor >= 4);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The indexing-only body knobs, for the ONE tier that needs them: gpt-5.4-nano.
|
|
126
|
+
*
|
|
127
|
+
* Both conditions are load-bearing and mean different things. isOpenAINano is the gpt-5.4
|
|
128
|
+
* FLOOR - below it these fields are rejected rather than ignored, and one 400 kills every
|
|
129
|
+
* indexing pass. isOldestNano is the CEILING - 5.5-nano and 5.6-nano transcribe correctly
|
|
130
|
+
* on their own, so they are left on the provider's defaults exactly like mini and the base
|
|
131
|
+
* models. Every other model gets an empty object, i.e. no `text` and no `reasoning` at all.
|
|
132
|
+
*/
|
|
133
|
+
const variantIndexingOptions = (model?: string) => {
|
|
134
|
+
if (!isOpenAINano(model) || !isOldestNano(model)) return {};
|
|
135
|
+
return {
|
|
136
|
+
...(VARIANT_TEXT_VERBOSITY ? { text: { verbosity: VARIANT_TEXT_VERBOSITY } } : {}),
|
|
137
|
+
...(OLDEST_NANO_REASONING_EFFORT ? { reasoning: { effort: OLDEST_NANO_REASONING_EFFORT } } : {}),
|
|
138
|
+
};
|
|
139
|
+
};
|
|
52
140
|
const getOpenAIImageDetail = (model?: string) => {
|
|
53
141
|
const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
|
|
54
142
|
const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
|
|
@@ -65,7 +153,7 @@ const getOpenAIImageDetail = (model?: string) => {
|
|
|
65
153
|
return DEFAULT_OPENAI_IMAGE_DETAIL;
|
|
66
154
|
}
|
|
67
155
|
|
|
68
|
-
return isVariant ?
|
|
156
|
+
return isVariant ? VARIANT_IMAGE_DETAIL : 'original';
|
|
69
157
|
};
|
|
70
158
|
|
|
71
159
|
// Per-image `detail` for WORKER-RENDERED document pages (the `_skapi_render`
|
|
@@ -81,6 +169,103 @@ const getRenderImageDetail = (model?: string) => {
|
|
|
81
169
|
return detail === DEFAULT_OPENAI_IMAGE_DETAIL ? 'high' : detail;
|
|
82
170
|
};
|
|
83
171
|
|
|
172
|
+
/**
|
|
173
|
+
* A nano at gpt-5.4 or OLDER.
|
|
174
|
+
*
|
|
175
|
+
* This is the observed quality boundary, not a guessed one: gpt-5.4-nano and below
|
|
176
|
+
* transcribe dense scans poorly, while mini, the base models and every gpt-5.6 model
|
|
177
|
+
* (5.6-nano included) read the same documents correctly. So only this tier gets
|
|
178
|
+
* compensated, and everything above it is left exactly as it was — on 'original' detail
|
|
179
|
+
* and a full window, because nothing about it needs fixing and every compensation costs
|
|
180
|
+
* either passes or output budget.
|
|
181
|
+
*/
|
|
182
|
+
/** The id parses as a gpt version we can reason about. An id that does NOT (o3,
|
|
183
|
+
* chatgpt-4o-latest, any custom name) is left entirely alone: we cannot tell what it is,
|
|
184
|
+
* and every compensation here is either a body field that can 400 or a change to how many
|
|
185
|
+
* images it receives. Unknown means untouched. */
|
|
186
|
+
const OPENAI_VERSIONED_ID = /^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/;
|
|
187
|
+
const isRecognisedOpenAIVersion = (model?: string) =>
|
|
188
|
+
OPENAI_VERSIONED_ID.test((model || DEFAULT_OPENAI_MODEL).trim().toLowerCase());
|
|
189
|
+
|
|
190
|
+
const isOldestNano = (model?: string) => {
|
|
191
|
+
const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
|
|
192
|
+
if (!/(^|-)nano(-|$)/.test(normalized)) return false;
|
|
193
|
+
const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
|
|
194
|
+
// Called "nano" but not a naming we recognise: treat it as the weak tier. It also
|
|
195
|
+
// fails the 'original' gate, so it is caught by the downsampled branch first anyway.
|
|
196
|
+
if (!match) return true;
|
|
197
|
+
const major = Number(match[1]);
|
|
198
|
+
const minor = match[2] === undefined ? null : Number(match[2]);
|
|
199
|
+
if (major < 5) return true;
|
|
200
|
+
if (major > 5) return false;
|
|
201
|
+
return minor === null || minor <= 4;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Pages in one render window for a SMALL tier, against RENDER_PAGES_PER_WINDOW (5) for a
|
|
205
|
+
// full one.
|
|
206
|
+
//
|
|
207
|
+
// This is the lever that does not risk a 400. The output budget is one number for the whole
|
|
208
|
+
// pass (MAX_TOKENS, and reasoning is billed against it), so a window of 5 dense pages leaves
|
|
209
|
+
// a small model a couple of thousand tokens per page and it starts sampling rows instead of
|
|
210
|
+
// transcribing them - which is exactly the "saved 5 line items" on a page holding twenty.
|
|
211
|
+
// Halving the window does not raise the cap, it just stops dividing it so many ways, and the
|
|
212
|
+
// worker's page loop already runs as many windows as a file needs.
|
|
213
|
+
const SMALL_TIER_PAGES_PER_WINDOW = 2;
|
|
214
|
+
|
|
215
|
+
// Horizontal bands per page for a tier whose images the API DOWNSAMPLES before the model
|
|
216
|
+
// sees them ('high' resamples onto a 512px tile grid). For those models the render DPI is
|
|
217
|
+
// irrelevant - the pixels are thrown away upstream - so the only way to hand them more
|
|
218
|
+
// readable text is to make each image cover less of the page. Two bands doubles the
|
|
219
|
+
// resolution the model effectively gets, at the cost of one extra image per page.
|
|
220
|
+
//
|
|
221
|
+
// Bands are horizontal so a table row is never cut down its middle, and they overlap
|
|
222
|
+
// slightly so a line landing on the seam appears whole in one of them.
|
|
223
|
+
const DOWNSAMPLED_TIER_TILE = 2;
|
|
224
|
+
|
|
225
|
+
/** How a given model should be shown a rendered document. */
|
|
226
|
+
export type VisionProfile = {
|
|
227
|
+
/** Per-image `detail` (OpenAI only). */
|
|
228
|
+
detail: string;
|
|
229
|
+
/** Pages the worker renders into one window. */
|
|
230
|
+
pagesPerWindow: number;
|
|
231
|
+
/** Horizontal bands per page; 1 renders the whole page as one image. */
|
|
232
|
+
tile: number;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Resolve the render profile for a model.
|
|
237
|
+
*
|
|
238
|
+
* Three tiers, and they fail for different reasons, which is why one set of knobs cannot
|
|
239
|
+
* serve all of them:
|
|
240
|
+
* - full: everything ABOVE gpt-5.4-nano - the base models, mini, and every gpt-5.6
|
|
241
|
+
* including 5.6-nano. These already transcribe dense scans correctly, so they get
|
|
242
|
+
* 'original' detail and are otherwise untouched.
|
|
243
|
+
* - gpt-5.4-nano: also gets 'original', so it sees exactly the SAME pixels as the full
|
|
244
|
+
* tier. Its gap therefore is not resolution, and tiling would do nothing for it. What
|
|
245
|
+
* it lacks is room: a smaller window leaves the same output budget divided between
|
|
246
|
+
* fewer pages.
|
|
247
|
+
* - downsampled (below the 'original' floor: gpt-5.3-nano, gpt-5-nano, gpt-4.1-nano):
|
|
248
|
+
* capped at 'high', which resamples the page onto a 512px grid no matter what DPI it
|
|
249
|
+
* was rendered at. Render resolution is wasted on these entirely; the only way to give
|
|
250
|
+
* them readable text is to make each image cover less of the page, which is `tile`.
|
|
251
|
+
*/
|
|
252
|
+
export function getVisionProfile(model?: string): VisionProfile {
|
|
253
|
+
const detail = getRenderImageDetail(model);
|
|
254
|
+
// An id we cannot parse is left exactly as it was: full window, no tiling. Treating
|
|
255
|
+
// "unknown" as "weak" would quietly change how much work every custom or aliased model
|
|
256
|
+
// does, on no evidence at all.
|
|
257
|
+
if (!isRecognisedOpenAIVersion(model)) {
|
|
258
|
+
return { detail, pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
|
|
259
|
+
}
|
|
260
|
+
if (detail !== 'original') {
|
|
261
|
+
return { detail, pagesPerWindow: SMALL_TIER_PAGES_PER_WINDOW, tile: DOWNSAMPLED_TIER_TILE };
|
|
262
|
+
}
|
|
263
|
+
if (isOldestNano(model)) {
|
|
264
|
+
return { detail, pagesPerWindow: SMALL_TIER_PAGES_PER_WINDOW, tile: 1 };
|
|
265
|
+
}
|
|
266
|
+
return { detail, pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
|
|
267
|
+
}
|
|
268
|
+
|
|
84
269
|
export type ClaudeRole = 'user' | 'assistant';
|
|
85
270
|
|
|
86
271
|
export type ClaudeMessage = {
|
|
@@ -236,6 +421,28 @@ export type CallClaudeWithMcpParams = {
|
|
|
236
421
|
// engine's own poll sites and imported by agent.vue; the widget carries its own
|
|
237
422
|
// copy in src/index.js that must be kept in step.
|
|
238
423
|
export const POLL_INTERVAL = 3000;
|
|
424
|
+
// Ceiling on how many BACKGROUND indexing polls may be attached at once, across
|
|
425
|
+
// every poll site (the engine's drain, the engine's history load, and each
|
|
426
|
+
// client's own fallback poller).
|
|
427
|
+
//
|
|
428
|
+
// Every unresolved bg item used to get its own poll, and a poll is a
|
|
429
|
+
// POLL_INTERVAL setInterval firing one request per tick. A bulk upload from the
|
|
430
|
+
// db-files page enqueues one indexing pass per FILE, so uploading 10,000 files
|
|
431
|
+
// attached 10,000 concurrent intervals: ~3,300 requests/second against a browser
|
|
432
|
+
// that opens six connections per host. The resulting request backlog starved the
|
|
433
|
+
// uploads themselves, which is the "frozen tab that eventually finishes" users
|
|
434
|
+
// reported.
|
|
435
|
+
//
|
|
436
|
+
// Capping costs nothing, because the server settles one queue's passes in FIFO
|
|
437
|
+
// order (a single SQS MessageGroupId per `<user>-bg` queue). A pass cannot
|
|
438
|
+
// finish before the ones ahead of it, so asking about the newest 9,994 is pure
|
|
439
|
+
// waste. Each resolution frees a slot, which the next-OLDEST unpolled entry
|
|
440
|
+
// takes on the drain that follows. Spending the budget oldest-first is
|
|
441
|
+
// load-bearing: spend it on the newest and the batch wedges, since those cannot
|
|
442
|
+
// settle until the ones ahead of them do and nothing ahead would hold a poll.
|
|
443
|
+
//
|
|
444
|
+
// FOREGROUND polls (a reply the user is actively waiting on) are never capped.
|
|
445
|
+
export const MAX_CONCURRENT_BG_POLLS = 6;
|
|
239
446
|
export async function callClaudeWithMcp({
|
|
240
447
|
prompt,
|
|
241
448
|
messages,
|
|
@@ -450,8 +657,20 @@ export type AttachmentSaveInfo = {
|
|
|
450
657
|
platform: 'claude' | 'openai';
|
|
451
658
|
model?: string;
|
|
452
659
|
service: string;
|
|
660
|
+
/** The PUBLIC project ID (formatted token, skapi.project_id). Shown to the model; falls back to `service`. */
|
|
661
|
+
publicProjectId?: string;
|
|
453
662
|
owner: string;
|
|
454
|
-
|
|
663
|
+
/**
|
|
664
|
+
* Queue base for this indexing pass: "<userId>-bg". REQUIRED, and it must be
|
|
665
|
+
* the SAME value the chat turn uses (ChatSession.dispatchComposedMessage's
|
|
666
|
+
* `id.userId || id.projectId`) — the backend serialises requests that share a
|
|
667
|
+
* queue name and runs different ones IN PARALLEL, so a pass enqueued under a
|
|
668
|
+
* different base does not hold the chat back at all. It was optional once,
|
|
669
|
+
* defaulting to `service`; the chatbox omitted it, and its files were indexed
|
|
670
|
+
* on "<projectId>-bg" while its question ran on "<userId>-bg" — the question
|
|
671
|
+
* was answered from a file nothing had read yet. Pass `userId || projectId`.
|
|
672
|
+
*/
|
|
673
|
+
userId: string;
|
|
455
674
|
serviceName?: string;
|
|
456
675
|
serviceDescription?: string;
|
|
457
676
|
attachment: {
|
|
@@ -516,12 +735,19 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
516
735
|
// (substituting the window's 1-based start page for RENDER_FROM_TOKEN) and enqueues it
|
|
517
736
|
// itself. That is what makes a 500-page document index end-to-end — the loop no longer
|
|
518
737
|
// depends on the tab staying open, nor on the model correctly declaring itself unfinished.
|
|
738
|
+
// Window size and per-page tiling are resolved from the MODEL, not fixed: see
|
|
739
|
+
// getVisionProfile. Claude keeps the full window (renderDetail is OpenAI-only, and its
|
|
740
|
+
// own resizing behaviour is a separate question this does not try to answer).
|
|
741
|
+
const visionProfile: VisionProfile = platform === 'openai'
|
|
742
|
+
? getVisionProfile(info.model || DEFAULT_OPENAI_MODEL)
|
|
743
|
+
: { detail: '', pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
|
|
519
744
|
const skapiRender = visionFile && renderPlaceholder
|
|
520
745
|
? {
|
|
521
746
|
_skapi_render: [
|
|
522
747
|
{
|
|
523
|
-
path: attachment.storagePath, from: renderFrom, count:
|
|
748
|
+
path: attachment.storagePath, from: renderFrom, count: visionProfile.pagesPerWindow,
|
|
524
749
|
placeholder: renderPlaceholder, name: attachment.name, mime: attachment.mime, detail: renderDetail,
|
|
750
|
+
tile: visionProfile.tile,
|
|
525
751
|
auto_continue: true,
|
|
526
752
|
continue_text: buildIndexingRenderContinueTemplate(attachment, renderPlaceholder),
|
|
527
753
|
},
|
|
@@ -550,6 +776,12 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
550
776
|
name: attachment.name,
|
|
551
777
|
mime: attachment.mime,
|
|
552
778
|
kind: 'window',
|
|
779
|
+
// Same per-image `detail` the render path sends. Without it the worker falls
|
|
780
|
+
// back to its model-blind default of 'high', so a spreadsheet's embedded
|
|
781
|
+
// photos were tiled at lower resolution than the SAME model gets for a PDF
|
|
782
|
+
// page or a chat attachment. That is why a model could describe an attached
|
|
783
|
+
// photo but reported the pictures inside a sheet as only partly legible.
|
|
784
|
+
detail: renderDetail,
|
|
553
785
|
auto_continue: true,
|
|
554
786
|
continue_text: buildIndexingWindowMessage(attachment, windowPlaceholder, true),
|
|
555
787
|
},
|
|
@@ -569,7 +801,20 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
569
801
|
? [{ path: attachment.storagePath, placeholder, name: attachment.name, mime: attachment.mime }]
|
|
570
802
|
: undefined;
|
|
571
803
|
const skapiExtract =
|
|
572
|
-
extractContent && extractContent.length
|
|
804
|
+
extractContent && extractContent.length
|
|
805
|
+
? {
|
|
806
|
+
_skapi_extract: extractContent.map((d) => ({
|
|
807
|
+
...d,
|
|
808
|
+
// FIRST pass of an INDEXING run only: tells the worker to also pull the
|
|
809
|
+
// file's embedded pictures into __MEDIA__ and register their records.
|
|
810
|
+
// Chat-turn extraction (callClaudeWithMcp / callOpenAIWithPublicMcp)
|
|
811
|
+
// never sets this, so merely ATTACHING a file to a chat message cannot
|
|
812
|
+
// write media records; a CONTINUE pass skips it because the first pass
|
|
813
|
+
// already saved (the save is whole-file, not windowed).
|
|
814
|
+
save_media: !continuing,
|
|
815
|
+
})),
|
|
816
|
+
}
|
|
817
|
+
: {};
|
|
573
818
|
|
|
574
819
|
const userMessage = (visionFile && renderPlaceholder)
|
|
575
820
|
? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom)
|
|
@@ -589,7 +834,10 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
589
834
|
);
|
|
590
835
|
|
|
591
836
|
const systemPrompt = buildIndexingSystemPrompt({
|
|
592
|
-
|
|
837
|
+
// The model copies this id verbatim into project_id tool calls, so it must be
|
|
838
|
+
// the PUBLIC token whenever the host supplied one; the raw code is rejected
|
|
839
|
+
// by the tools' schema pattern.
|
|
840
|
+
projectId: info.publicProjectId || service,
|
|
593
841
|
serviceName: info.serviceName,
|
|
594
842
|
serviceDescription: info.serviceDescription,
|
|
595
843
|
});
|
|
@@ -599,7 +847,7 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
599
847
|
const imageDetail = getOpenAIImageDetail(resolvedModel);
|
|
600
848
|
return clientSecretRequest({
|
|
601
849
|
clientSecretName: 'openai',
|
|
602
|
-
queue: (info.userId
|
|
850
|
+
queue: bgIndexingQueueName(info.userId, service),
|
|
603
851
|
service,
|
|
604
852
|
owner,
|
|
605
853
|
...pollOpt(),
|
|
@@ -612,6 +860,8 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
612
860
|
data: {
|
|
613
861
|
model: resolvedModel,
|
|
614
862
|
max_output_tokens: MAX_TOKENS,
|
|
863
|
+
// Nano-only transcription knobs. Indexing only; see variantIndexingOptions.
|
|
864
|
+
...variantIndexingOptions(resolvedModel),
|
|
615
865
|
...skapiExtract,
|
|
616
866
|
...skapiRender,
|
|
617
867
|
...skapiWindow,
|
|
@@ -646,7 +896,7 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
646
896
|
const resolvedModel = info.model || DEFAULT_CLAUDE_MODEL;
|
|
647
897
|
return clientSecretRequest({
|
|
648
898
|
clientSecretName: 'claude',
|
|
649
|
-
queue: (info.userId
|
|
899
|
+
queue: bgIndexingQueueName(info.userId, service),
|
|
650
900
|
service,
|
|
651
901
|
owner,
|
|
652
902
|
...pollOpt(),
|
|
@@ -788,6 +1038,17 @@ export async function listOpenAIModels(service: string, owner: string) {
|
|
|
788
1038
|
// so the chat-history BETWEEN query never includes bg-queue items. '-' (45) works.
|
|
789
1039
|
export const BG_INDEXING_QUEUE_SUFFIX = '-bg';
|
|
790
1040
|
|
|
1041
|
+
/**
|
|
1042
|
+
* The one place the background-indexing queue name is spelled out. The backend
|
|
1043
|
+
* serialises requests sharing a queue name and runs different names in PARALLEL,
|
|
1044
|
+
* so every indexing pass AND the chat turn that must wait behind them have to
|
|
1045
|
+
* resolve to the identical string — see AttachmentSaveInfo.userId for what
|
|
1046
|
+
* happens when they do not.
|
|
1047
|
+
*/
|
|
1048
|
+
export function bgIndexingQueueName(userId?: string, service?: string): string {
|
|
1049
|
+
return (userId || service || '') + BG_INDEXING_QUEUE_SUFFIX;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
791
1052
|
/**
|
|
792
1053
|
* True when a request belongs to the background-indexing queue.
|
|
793
1054
|
*
|
|
@@ -809,7 +1070,7 @@ export function isBgIndexingQueue(queueName?: string): boolean {
|
|
|
809
1070
|
// (a Vue `reactive([])` in agent.vue, a plain array in bunnyquery) is app-level
|
|
810
1071
|
// state owned by the consumer — only the TYPE lives in the engine.
|
|
811
1072
|
export type BgTaskEntry = {
|
|
812
|
-
|
|
1073
|
+
projectId: string;
|
|
813
1074
|
platform: 'claude' | 'openai';
|
|
814
1075
|
id: string;
|
|
815
1076
|
filename: string;
|
|
@@ -821,6 +1082,13 @@ export type BgTaskEntry = {
|
|
|
821
1082
|
poll: ((opts: { latency: number }) => Promise<any>) | undefined;
|
|
822
1083
|
/** How many CONTINUE passes have already run for this file (resume-across-passes). */
|
|
823
1084
|
resumePass?: number;
|
|
1085
|
+
/** The STAGED chat turn these files were attached to (ChatSession.stageOutgoingMessage).
|
|
1086
|
+
* drainBgTaskQueue inserts this pass's bubble directly ABOVE that turn's bubble, so the
|
|
1087
|
+
* collapsed row sits where the reader expects it — right before the message the files
|
|
1088
|
+
* came with — from the moment it appears, instead of the turn being moved down past it
|
|
1089
|
+
* once everything finishes. Absent for work with no chat turn behind it (the dbfile
|
|
1090
|
+
* page, an attachment-only send, a worker-adopted pass), which appends as before. */
|
|
1091
|
+
stageId?: string;
|
|
824
1092
|
};
|
|
825
1093
|
|
|
826
1094
|
// Token the indexing agent appends to its final message ONLY when it has fully read and
|
|
@@ -830,12 +1098,32 @@ export type BgTaskEntry = {
|
|
|
830
1098
|
// asks the model whether it is finished — the worker advances that loop off the renderer's
|
|
831
1099
|
// page count — so this marker has no say in whether a PDF keeps going.
|
|
832
1100
|
export const INDEXING_COMPLETE_MARKER = 'INDEXING_COMPLETE';
|
|
1101
|
+
// What an indexing pass's bubble says when its ENTIRE answer was the completion
|
|
1102
|
+
// token and stripping it left nothing. Without a stand-in the history mappers emit
|
|
1103
|
+
// no bubble at all for that pass (their `else if (assistantText)` guard fails on the
|
|
1104
|
+
// empty string) while the live path emits one — so the same run read as finished
|
|
1105
|
+
// live and unfinished after a reload, and the row's loader came back.
|
|
1106
|
+
export const EMPTY_INDEXING_REPLY = 'Finished reading this file.';
|
|
833
1107
|
// Cap on CONTINUE passes per file, so a file the agent can never mark complete (or a
|
|
834
1108
|
// pathological loop) stops instead of re-dispatching forever. The text/grid paging path
|
|
835
1109
|
// reads MANY windows within a single pass (the agent loops readFileContent in one turn), so
|
|
836
1110
|
// a small cap suffices.
|
|
837
1111
|
export const MAX_INDEXING_RESUME_PASSES = 6;
|
|
838
1112
|
|
|
1113
|
+
// Records per chat-history page. Bigger than skapi's own default so the first load
|
|
1114
|
+
// (and each scroll-up page) covers more of the conversation in one round-trip — a
|
|
1115
|
+
// short page leaves the box unfilled and forces the viewport-fill loop to page
|
|
1116
|
+
// again immediately. Callers that want a narrower page (the queue/status probes in
|
|
1117
|
+
// ChatSession) pass their own `limit`, which wins over this default.
|
|
1118
|
+
// 500, up from 100: an indexing run is dozens of rows that collapse into ONE visible
|
|
1119
|
+
// row, so a screenful of history behind a few indexed files took 5+ sequential
|
|
1120
|
+
// round trips to assemble (cursor paging cannot be parallelised - each page's
|
|
1121
|
+
// startKey comes from the previous response). The number is a CAP, not a payload
|
|
1122
|
+
// size: DynamoDB stops a page at 1MB regardless and hands back a cursor, and the
|
|
1123
|
+
// backend passes the limit through without looping, so heavy indexing rows page at
|
|
1124
|
+
// the same bytes per trip as before while light chat rows now arrive 500 at a time.
|
|
1125
|
+
export const CHAT_HISTORY_PAGE_LIMIT = 500;
|
|
1126
|
+
|
|
839
1127
|
/**
|
|
840
1128
|
* `queue` narrows the fetch to one processing chain; `status` narrows it to items
|
|
841
1129
|
* in one state. Passing both is how the client asks "is there still unresolved
|
|
@@ -864,6 +1152,6 @@ export async function getChatHistory(
|
|
|
864
1152
|
|
|
865
1153
|
return chatEngineConfig().clientSecretRequestHistory(
|
|
866
1154
|
p as { url: string; method: 'POST'; queue?: string; status?: string },
|
|
867
|
-
Object.assign({ ascending: false }, fetchOptions),
|
|
1155
|
+
Object.assign({ ascending: false, limit: CHAT_HISTORY_PAGE_LIMIT }, fetchOptions),
|
|
868
1156
|
);
|
|
869
1157
|
}
|