bunnyquery 1.6.0 → 1.6.2
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/bunnyquery.js +406 -83
- package/dist/engine.cjs +398 -81
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +132 -9
- package/dist/engine.d.ts +132 -9
- package/dist/engine.mjs +395 -82
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/config.ts +19 -0
- package/src/engine/host.ts +19 -0
- package/src/engine/index.ts +2 -1
- package/src/engine/office.ts +53 -5
- package/src/engine/prompts/index.ts +3 -0
- package/src/engine/prompts/indexing_user_message.ts +106 -29
- package/src/engine/requests.ts +106 -19
- package/src/engine/session.ts +350 -55
package/package.json
CHANGED
package/src/engine/config.ts
CHANGED
|
@@ -33,6 +33,20 @@ export interface ChatEngineConfig {
|
|
|
33
33
|
* `registerAttachmentParser()`. See attachment_parsers.ts.
|
|
34
34
|
*/
|
|
35
35
|
attachmentParsers?: AttachmentParser[];
|
|
36
|
+
/**
|
|
37
|
+
* Opt in to SERVER-DRIVEN windowed indexing for text/grid files.
|
|
38
|
+
*
|
|
39
|
+
* Off by default, and deliberately so. When on, the client emits a
|
|
40
|
+
* `_skapi_window` directive and the WORKER reads the file one window at a time,
|
|
41
|
+
* continuing until the reader says it is exhausted. When off, the agent pages the
|
|
42
|
+
* file itself with readFileContent, exactly as before.
|
|
43
|
+
*
|
|
44
|
+
* The flag exists because the backend must ship FIRST: a client emitting the
|
|
45
|
+
* directive against a worker that does not strip it leaves an unknown field in the
|
|
46
|
+
* request body, and the provider rejects the whole call with no retry. Keep it off
|
|
47
|
+
* until the worker is deployed, then flip it per environment.
|
|
48
|
+
*/
|
|
49
|
+
windowedIndexing?: boolean;
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
let _config: ChatEngineConfig | null = null;
|
|
@@ -53,6 +67,11 @@ export function chatEngineConfig(): ChatEngineConfig {
|
|
|
53
67
|
return _config;
|
|
54
68
|
}
|
|
55
69
|
|
|
70
|
+
/** True when the consumer has opted in to server-driven windowed indexing. */
|
|
71
|
+
export function windowedIndexingEnabled(): boolean {
|
|
72
|
+
return _config?.windowedIndexing === true;
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
/** Spread helper: `{ ...pollOpt() }` adds `poll` only when configured. */
|
|
57
76
|
export function pollOpt(): { poll?: number } {
|
|
58
77
|
const p = _config?.poll;
|
package/src/engine/host.ts
CHANGED
|
@@ -18,6 +18,18 @@ export interface ChatIdentity {
|
|
|
18
18
|
serviceDescription?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Project context captured at the moment the user hit Send, so a turn whose
|
|
23
|
+
* dispatch is delayed (attachment uploads are awaited first) still reaches the
|
|
24
|
+
* project the question was asked of rather than whichever project the user has
|
|
25
|
+
* navigated to by then. Both fields are identity-derived and must be snapshotted
|
|
26
|
+
* together — the system prompt embeds the service name/description/id.
|
|
27
|
+
*/
|
|
28
|
+
export interface PinnedDispatchContext {
|
|
29
|
+
identity: ChatIdentity;
|
|
30
|
+
systemPrompt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
export interface ChatMessage {
|
|
22
34
|
role: 'user' | 'assistant';
|
|
23
35
|
content: string; // raw markdown — never HTML (the view parses for display)
|
|
@@ -34,6 +46,13 @@ export interface ChatMessage {
|
|
|
34
46
|
_localId?: string;
|
|
35
47
|
_cancelling?: boolean;
|
|
36
48
|
_cancelError?: string;
|
|
49
|
+
// History cache key (`serviceId#platform`) this bubble was created under.
|
|
50
|
+
// Stamped on LOCALLY-created bubbles only (the optimistic user message and
|
|
51
|
+
// its "Thinking..." placeholder); server-mapped bubbles are identified by
|
|
52
|
+
// _serverItemId instead. The dashboard renders every project through ONE
|
|
53
|
+
// ChatSession singleton, so without this a bubble is unattributable and an
|
|
54
|
+
// in-flight turn from project A gets rescued/cached into project B.
|
|
55
|
+
_ownerKey?: string;
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
export interface ChatState {
|
package/src/engine/index.ts
CHANGED
|
@@ -57,12 +57,13 @@ export {
|
|
|
57
57
|
// Tier-2: the stateful chat orchestration (queue/poll/cancel, typewriter,
|
|
58
58
|
// bg-task drain, resolution). DOM-free; the consumer implements ChatHost.
|
|
59
59
|
export { ChatSession } from './session';
|
|
60
|
-
export type { ChatHost, ChatIdentity, ChatState, ChatMessage } from './host';
|
|
60
|
+
export type { ChatHost, ChatIdentity, ChatState, ChatMessage, PinnedDispatchContext } from './host';
|
|
61
61
|
|
|
62
62
|
export {
|
|
63
63
|
// constants
|
|
64
64
|
POLL_INTERVAL,
|
|
65
65
|
BG_INDEXING_QUEUE_SUFFIX,
|
|
66
|
+
isBgIndexingQueue,
|
|
66
67
|
MCP_NAME,
|
|
67
68
|
DEFAULT_CLAUDE_MODEL,
|
|
68
69
|
DEFAULT_OPENAI_MODEL,
|
package/src/engine/office.ts
CHANGED
|
@@ -101,11 +101,32 @@ export function isServerExtractable(name?: string, mime?: string): boolean {
|
|
|
101
101
|
/** @deprecated renamed to {@link isServerExtractable} (now also covers text files). */
|
|
102
102
|
export const isOfficeFile = isServerExtractable;
|
|
103
103
|
|
|
104
|
-
// Extensions
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
104
|
+
// Extensions read WINDOW-BY-WINDOW via the readFileContent tool instead of inlined once.
|
|
105
|
+
//
|
|
106
|
+
// Anything NOT in this set falls back to one-shot server extraction, which is capped at
|
|
107
|
+
// MAX_EXTRACTED_CHARS (200k). Measured against real files, that cap was discarding the
|
|
108
|
+
// overwhelming majority of every large non-spreadsheet upload:
|
|
109
|
+
// 5MB .txt -> 4.0% indexed (4.8M characters silently dropped)
|
|
110
|
+
// 4.8MB .json-> 4.2%
|
|
111
|
+
// 1.9M-char Korean .txt -> 10.5%
|
|
112
|
+
// .docx -> 70.6%
|
|
113
|
+
// The truncation was invisible: the agent received a plausible-looking document and had
|
|
114
|
+
// no way to know most of it was missing. Windowing these types is what makes "index the
|
|
115
|
+
// whole file" true rather than aspirational.
|
|
116
|
+
//
|
|
117
|
+
// CSV/TSV specifically must be here rather than in the inline path: the layer now gives
|
|
118
|
+
// them ROW-bounded windows with absolute row numbers, where the character windower used
|
|
119
|
+
// to split rows across boundaries and emit no row numbers at all.
|
|
120
|
+
const PAGED_READ_EXTENSIONS = new Set([
|
|
121
|
+
// grids
|
|
122
|
+
'xls', 'xlsx', 'xlsm', 'ods',
|
|
123
|
+
// delimited text (row-windowed by the layer)
|
|
124
|
+
'csv', 'tsv', 'tab',
|
|
125
|
+
// documents
|
|
126
|
+
'pdf', 'docx', 'pptx',
|
|
127
|
+
// plain text / data / markup
|
|
128
|
+
'txt', 'md', 'markdown', 'log', 'json', 'jsonl', 'ndjson', 'xml', 'yaml', 'yml',
|
|
129
|
+
]);
|
|
109
130
|
|
|
110
131
|
/**
|
|
111
132
|
* True when a file should be indexed by PAGING through readFileContent (spreadsheets and
|
|
@@ -160,6 +181,33 @@ export function makeRenderPlaceholder(seed: string): string {
|
|
|
160
181
|
// resume window (from = pass * PAGES) lines up with what the worker renders.
|
|
161
182
|
export const RENDER_PAGES_PER_WINDOW = 5;
|
|
162
183
|
|
|
184
|
+
// Token the WORKER substitutes with a human description of the next window's position.
|
|
185
|
+
// Shared with the render loop so one substitution path serves both.
|
|
186
|
+
export const WINDOW_CURSOR_TOKEN = '{{RENDER_FROM}}';
|
|
187
|
+
|
|
188
|
+
let _windowPlaceholderSeq = 0;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Placeholder the worker replaces with ONE window of a file's text/grid content.
|
|
192
|
+
* Distinct token from the render (page-image) and extract (whole-file) placeholders so
|
|
193
|
+
* a stale token from either can never be mistaken for this one.
|
|
194
|
+
*/
|
|
195
|
+
export function makeWindowPlaceholder(seed: string): string {
|
|
196
|
+
_windowPlaceholderSeq += 1;
|
|
197
|
+
const slug = (seed || 'file').replace(/[^a-zA-Z0-9]+/g, '_').slice(-48);
|
|
198
|
+
return `{{SKAPI_WINDOW::${slug}-${_windowPlaceholderSeq}}}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* True when a file should be read server-side, one window at a time, by the worker.
|
|
203
|
+
* PDFs are excluded: they go through the VISION path, where pages are rendered to
|
|
204
|
+
* images because their text layer is often absent or unreliable.
|
|
205
|
+
*/
|
|
206
|
+
export function isWindowedReadFile(name?: string, mime?: string): boolean {
|
|
207
|
+
if (isImageVisionFile(name, mime)) return false;
|
|
208
|
+
return isPagedReadFile(name, mime);
|
|
209
|
+
}
|
|
210
|
+
|
|
163
211
|
export interface ComposedUserMessage {
|
|
164
212
|
/** Clean display/history copy (attachment links, NO extraction placeholders). */
|
|
165
213
|
composed: string;
|
|
@@ -11,6 +11,9 @@ export {
|
|
|
11
11
|
buildIndexingUserMessage,
|
|
12
12
|
buildIndexingContinueMessage,
|
|
13
13
|
buildIndexingRenderMessage,
|
|
14
|
+
buildIndexingRenderContinueTemplate,
|
|
15
|
+
buildIndexingWindowMessage,
|
|
16
|
+
RENDER_FROM_TOKEN,
|
|
14
17
|
type IndexingAttachmentInfo,
|
|
15
18
|
type BuildIndexingUserMessageOptions,
|
|
16
19
|
} from './indexing_user_message';
|
|
@@ -101,16 +101,29 @@ export function buildIndexingUserMessage(
|
|
|
101
101
|
return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Token the WORKER substitutes with the 1-based first page of the window it is about to
|
|
106
|
+
* render, when it builds the next pass of a document from `RENDER_CONTINUE_TEMPLATE`.
|
|
107
|
+
* Must match the worker's RENDER_FROM_TOKEN.
|
|
108
|
+
*/
|
|
109
|
+
export const RENDER_FROM_TOKEN = '{{RENDER_FROM}}';
|
|
110
|
+
const WINDOW_CURSOR_TOKEN = RENDER_FROM_TOKEN;
|
|
111
|
+
|
|
104
112
|
/**
|
|
105
113
|
* User message for a VISION file (PDF): its pages are delivered as RENDERED PAGE IMAGES that
|
|
106
114
|
* the proxy worker injects into THIS message at the `placeholder` token (tool-result images
|
|
107
115
|
* render on neither provider, so the pages must be image blocks in the message itself). Each
|
|
108
|
-
* pass shows one WINDOW of pages starting at `renderFrom` (0-based)
|
|
109
|
-
*
|
|
116
|
+
* pass shows one WINDOW of pages starting at `renderFrom` (0-based).
|
|
117
|
+
*
|
|
118
|
+
* The WORKER advances the window: when its renderer reports pages remaining it enqueues the
|
|
119
|
+
* next pass itself, off the true page count, so a document indexes end-to-end with no browser
|
|
120
|
+
* involved. This message therefore only ever describes ONE window, and the model is never
|
|
121
|
+
* asked to decide whether the document is finished.
|
|
110
122
|
*
|
|
111
123
|
* renderFrom === 0 is the FIRST pass (leads with "A new file has just been uploaded." so the
|
|
112
|
-
* client builds the "Indexing: <name>" bubble);
|
|
113
|
-
* "CONTINUE indexing"
|
|
124
|
+
* client builds the "Indexing: <name>" bubble); a continue pass (built by the worker from
|
|
125
|
+
* buildIndexingRenderContinueTemplate) leads with "CONTINUE indexing" so it is not a duplicate
|
|
126
|
+
* primary bubble.
|
|
114
127
|
*/
|
|
115
128
|
export function buildIndexingRenderMessage(
|
|
116
129
|
attachment: IndexingAttachmentInfo,
|
|
@@ -118,44 +131,108 @@ export function buildIndexingRenderMessage(
|
|
|
118
131
|
renderFrom: number,
|
|
119
132
|
): string {
|
|
120
133
|
const from = Math.max(0, renderFrom || 0);
|
|
134
|
+
if (from > 0) return buildIndexingRenderContinueTemplate(attachment, placeholder, String(from + 1));
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
`A new file has just been uploaded. Index it now.\n\n` +
|
|
138
|
+
buildRenderMeta(attachment) +
|
|
139
|
+
`\nThis is a PDF. Its pages are delivered to you as RENDERED PAGE IMAGES embedded directly in this ` +
|
|
140
|
+
`message (you do NOT need any tool, URL, or web_fetch to see them). You are shown a WINDOW of pages ` +
|
|
141
|
+
`at a time, starting at page ${from + 1}.\n` +
|
|
142
|
+
buildRenderDatafy(placeholder)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The CONTINUE pass, as a template the worker fills in. `pageLabel` defaults to the
|
|
148
|
+
* RENDER_FROM_TOKEN placeholder, which the worker replaces with the real 1-based start page
|
|
149
|
+
* of the window it is rendering; passing an explicit label produces a ready-to-send message.
|
|
150
|
+
*/
|
|
151
|
+
export function buildIndexingRenderContinueTemplate(
|
|
152
|
+
attachment: IndexingAttachmentInfo,
|
|
153
|
+
placeholder: string,
|
|
154
|
+
pageLabel: string = RENDER_FROM_TOKEN,
|
|
155
|
+
): string {
|
|
121
156
|
const src = `src::${attachment.storagePath}`;
|
|
122
|
-
|
|
157
|
+
return (
|
|
158
|
+
`CONTINUE indexing a PDF whose previous pass did not finish.\n\n` +
|
|
159
|
+
buildRenderMeta(attachment) +
|
|
160
|
+
`\nRecords for the earlier pages are ALREADY saved (they reference "${src}"). The NEXT window of ` +
|
|
161
|
+
`rendered page images (starting at page ${pageLabel}) is embedded in this message. Datafy each page as ` +
|
|
162
|
+
`before and do NOT re-save pages that are already saved.\n` +
|
|
163
|
+
buildRenderDatafy(placeholder)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildRenderMeta(attachment: IndexingAttachmentInfo): string {
|
|
168
|
+
return (
|
|
123
169
|
`File metadata:\n` +
|
|
124
170
|
`- name: ${attachment.name}\n` +
|
|
125
171
|
`- storage path: ${attachment.storagePath}\n` +
|
|
126
|
-
(attachment.mime ? `- mime type: ${attachment.mime}\n` : '')
|
|
172
|
+
(attachment.mime ? `- mime type: ${attachment.mime}\n` : '')
|
|
173
|
+
);
|
|
174
|
+
}
|
|
127
175
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
176
|
+
// Shared datafy guidance. The placeholder is where the worker splices the note + rendered
|
|
177
|
+
// page images; instructions reference "the page images in this message" so they read
|
|
178
|
+
// correctly whether the images land before or after this text.
|
|
179
|
+
//
|
|
180
|
+
// Deliberately says nothing about INDEXING_COMPLETE or about whether the document is
|
|
181
|
+
// finished: the worker decides that from the renderer's page count. Asking the model was
|
|
182
|
+
// what used to end an 88-page file at page 15.
|
|
183
|
+
function buildRenderDatafy(placeholder: string): string {
|
|
184
|
+
return (
|
|
132
185
|
`\n${placeholder}\n\n` +
|
|
133
186
|
`LOOK at each rendered page image in this message and DATAFY what it shows: for EVERY page ` +
|
|
134
187
|
`call postRecords and save records - one record per row / table entry / line item visible on the page ` +
|
|
135
188
|
`(or one record for the page if it is prose), capturing every value you can read (OCR the text, read tables ` +
|
|
136
189
|
`cell by cell, describe any photos/diagrams). Use the storage path above for the "src::" unique_id.\n\n` +
|
|
137
|
-
`
|
|
138
|
-
`
|
|
139
|
-
`
|
|
190
|
+
`Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read ` +
|
|
191
|
+
`the rest of the file and do NOT worry about the pages after this window: if any remain, the next window ` +
|
|
192
|
+
`is rendered and sent to you automatically. Report only the pages you were actually shown - never imply ` +
|
|
193
|
+
`you have seen the whole document.`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
140
196
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
197
|
+
/**
|
|
198
|
+
* User message for a WINDOWED file: the worker splices ONE window of the file's rows or
|
|
199
|
+
* text into this message at `placeholder`, then continues from the reader's own cursor
|
|
200
|
+
* until the file is exhausted.
|
|
201
|
+
*
|
|
202
|
+
* The agent is deliberately NOT asked to page the file itself, and is NOT asked to judge
|
|
203
|
+
* whether it is finished. Both used to be its job, and both failed the same way: the
|
|
204
|
+
* traversal lived inside a single turn's budget, so a large file simply stopped partway
|
|
205
|
+
* with a confident summary of the part it had seen.
|
|
206
|
+
*/
|
|
207
|
+
export function buildIndexingWindowMessage(
|
|
208
|
+
attachment: IndexingAttachmentInfo,
|
|
209
|
+
placeholder: string,
|
|
210
|
+
isContinuation: boolean,
|
|
211
|
+
positionLabel?: string,
|
|
212
|
+
): string {
|
|
213
|
+
const src = `src::${attachment.storagePath}`;
|
|
214
|
+
const head = isContinuation
|
|
215
|
+
? `CONTINUE indexing a file whose previous pass did not finish.\n\n`
|
|
216
|
+
: `A new file has just been uploaded. Index it now.\n\n`;
|
|
217
|
+
const where = isContinuation
|
|
218
|
+
? `\nRecords for the earlier windows are ALREADY saved (they reference "${src}"). The NEXT window ` +
|
|
219
|
+
`(starting at ${positionLabel || WINDOW_CURSOR_TOKEN}) is embedded below. Do NOT re-save windows that are already saved.\n`
|
|
220
|
+
: `\nThis file is delivered to you ONE WINDOW at a time, embedded directly in this message. ` +
|
|
221
|
+
`You do NOT need any tool, URL, or web_fetch to read it.\n`;
|
|
151
222
|
|
|
152
223
|
return (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
`
|
|
158
|
-
|
|
224
|
+
head +
|
|
225
|
+
buildRenderMeta(attachment) +
|
|
226
|
+
where +
|
|
227
|
+
`\n${placeholder}\n\n` +
|
|
228
|
+
`DATAFY this window: call postRecords and save records for everything in it - ONE RECORD PER ROW ` +
|
|
229
|
+
`for tabular data (keyed by the column headers), or one record per section for prose. Capture every ` +
|
|
230
|
+
`value you can read. Use the storage path above for the "src::" unique_id on the file-level record, ` +
|
|
231
|
+
`and link every row/section record to it by reference.\n\n` +
|
|
232
|
+
`Save records for THIS window only, then stop and report what you saved. Do NOT try to read the rest ` +
|
|
233
|
+
`of the file, and do NOT call readFileContent - if more remains, the next window is read and sent to ` +
|
|
234
|
+
`you automatically. Report only what you were actually shown, and never imply you have seen the whole ` +
|
|
235
|
+
`file when the note beside the window says more remains.`
|
|
159
236
|
);
|
|
160
237
|
}
|
|
161
238
|
|
package/src/engine/requests.ts
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* state that stays in the consumer, not here;
|
|
11
11
|
* only the BgTaskEntry TYPE lives here)
|
|
12
12
|
*/
|
|
13
|
-
import { buildIndexingSystemPrompt, buildIndexingUserMessage, buildIndexingContinueMessage, buildIndexingRenderMessage } from './prompts';
|
|
14
|
-
import { isServerExtractable, isPagedReadFile, isImageVisionFile, makeExtractPlaceholder, makeRenderPlaceholder, RENDER_PAGES_PER_WINDOW, type ExtractDirective, type FileUrlDirective } from './office';
|
|
15
|
-
import { chatEngineConfig, pollOpt } from './config';
|
|
13
|
+
import { buildIndexingSystemPrompt, buildIndexingUserMessage, buildIndexingContinueMessage, buildIndexingRenderMessage, buildIndexingRenderContinueTemplate, buildIndexingWindowMessage } from './prompts';
|
|
14
|
+
import { isServerExtractable, isPagedReadFile, isImageVisionFile, isWindowedReadFile, makeExtractPlaceholder, makeRenderPlaceholder, makeWindowPlaceholder, RENDER_PAGES_PER_WINDOW, type ExtractDirective, type FileUrlDirective } from './office';
|
|
15
|
+
import { chatEngineConfig, pollOpt, windowedIndexingEnabled } from './config';
|
|
16
16
|
|
|
17
17
|
export const ANTHROPIC_MESSAGES_API_URL = 'https://api.anthropic.com/v1/messages';
|
|
18
18
|
const ANTHROPIC_MODELS_API_URL = 'https://api.anthropic.com/v1/models';
|
|
@@ -38,25 +38,47 @@ export const DEFAULT_OPENAI_MODEL = 'gpt-5.4';
|
|
|
38
38
|
const mcpUrl = () => chatEngineConfig().mcpBaseUrl;
|
|
39
39
|
const clientSecretRequest = (opts: any) => chatEngineConfig().clientSecretRequest(opts);
|
|
40
40
|
|
|
41
|
+
// Resolve the per-image `detail` for OpenAI. The version match tolerates a
|
|
42
|
+
// trailing variant/date suffix (`gpt-5.4-nano`, `-mini`, `-2026-01-01`, …):
|
|
43
|
+
// previously the pattern was anchored with no suffix allowed, so EVERY suffixed
|
|
44
|
+
// model silently fell through to 'auto' — i.e. the cheap tiers that most need
|
|
45
|
+
// resolution were the ones getting downsampled images.
|
|
46
|
+
//
|
|
47
|
+
// Base models keep their exact previous behavior ('original'). A suffixed
|
|
48
|
+
// variant resolves to 'high' rather than 'original': 'high' is the universally
|
|
49
|
+
// supported value, and we have no way to confirm a given variant accepts
|
|
50
|
+
// 'original' — sending an unsupported value would fail the whole request, which
|
|
51
|
+
// is far worse than a slightly less detailed image.
|
|
41
52
|
const getOpenAIImageDetail = (model?: string) => {
|
|
42
53
|
const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
|
|
43
|
-
const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?$/);
|
|
54
|
+
const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
|
|
44
55
|
if (!match) {
|
|
45
56
|
return DEFAULT_OPENAI_IMAGE_DETAIL;
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
const major = Number(match[1]);
|
|
49
60
|
const minor = match[2] === undefined ? null : Number(match[2]);
|
|
61
|
+
const isVariant = !!match[3];
|
|
50
62
|
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
const supportsOriginal = major > 5 || (major === 5 && minor !== null && minor >= 4);
|
|
64
|
+
if (!supportsOriginal) {
|
|
65
|
+
return DEFAULT_OPENAI_IMAGE_DETAIL;
|
|
53
66
|
}
|
|
54
67
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
68
|
+
return isVariant ? 'high' : 'original';
|
|
69
|
+
};
|
|
58
70
|
|
|
59
|
-
|
|
71
|
+
// Per-image `detail` for WORKER-RENDERED document pages (the `_skapi_render`
|
|
72
|
+
// path). Same resolution as above with one difference: these are dense scans
|
|
73
|
+
// whose entire purpose is to be read, so 'auto' is never acceptable — it lets
|
|
74
|
+
// the API downsample exactly the pixels the model needs to OCR. Floor it at
|
|
75
|
+
// 'high'; models that support full-resolution 'original' still get it.
|
|
76
|
+
//
|
|
77
|
+
// Without this the worker falls back to its own model-blind default ('high'),
|
|
78
|
+
// which silently denies the strongest models the 'original' detail they support.
|
|
79
|
+
const getRenderImageDetail = (model?: string) => {
|
|
80
|
+
const detail = getOpenAIImageDetail(model);
|
|
81
|
+
return detail === DEFAULT_OPENAI_IMAGE_DETAIL ? 'high' : detail;
|
|
60
82
|
};
|
|
61
83
|
|
|
62
84
|
export type ClaudeRole = 'user' | 'assistant';
|
|
@@ -478,20 +500,66 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
478
500
|
const visionFile = !parsedContent && isImageVisionFile(attachment.name, attachment.mime);
|
|
479
501
|
const renderFrom = Math.max(0, info.renderFrom || 0);
|
|
480
502
|
const renderPlaceholder = visionFile ? makeRenderPlaceholder(attachment.storagePath) : undefined;
|
|
503
|
+
// Tell the worker what image `detail` to stamp on the injected page blocks.
|
|
504
|
+
// The worker is model-blind, so without this it applies a one-size default and
|
|
505
|
+
// the strongest models never get the 'original' detail they support. Only
|
|
506
|
+
// meaningful for OpenAI — Claude has no per-image detail knob and the worker
|
|
507
|
+
// ignores the field for it.
|
|
508
|
+
const renderDetail = platform === 'openai'
|
|
509
|
+
? getRenderImageDetail(info.model || DEFAULT_OPENAI_MODEL)
|
|
510
|
+
: undefined;
|
|
511
|
+
// `auto_continue` + `continue_text` hand the page loop to the WORKER: when its renderer
|
|
512
|
+
// reports pages left after this window, it builds the next pass from this template
|
|
513
|
+
// (substituting the window's 1-based start page for RENDER_FROM_TOKEN) and enqueues it
|
|
514
|
+
// itself. That is what makes a 500-page document index end-to-end — the loop no longer
|
|
515
|
+
// depends on the tab staying open, nor on the model correctly declaring itself unfinished.
|
|
481
516
|
const skapiRender = visionFile && renderPlaceholder
|
|
482
517
|
? {
|
|
483
518
|
_skapi_render: [
|
|
484
|
-
{
|
|
519
|
+
{
|
|
520
|
+
path: attachment.storagePath, from: renderFrom, count: RENDER_PAGES_PER_WINDOW,
|
|
521
|
+
placeholder: renderPlaceholder, name: attachment.name, mime: attachment.mime, detail: renderDetail,
|
|
522
|
+
auto_continue: true,
|
|
523
|
+
continue_text: buildIndexingRenderContinueTemplate(attachment, renderPlaceholder),
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
}
|
|
527
|
+
: {};
|
|
528
|
+
|
|
529
|
+
// SERVER-DRIVEN windowed read. When enabled, the worker reads one window of the file
|
|
530
|
+
// per request and continues from the reader's own cursor until the file is exhausted,
|
|
531
|
+
// so the traversal no longer lives inside the model's turn budget.
|
|
532
|
+
//
|
|
533
|
+
// Flag-gated because the BACKEND MUST SHIP FIRST: against a worker that does not strip
|
|
534
|
+
// `_skapi_window`, the directive reaches the provider as an unknown body field and the
|
|
535
|
+
// call fails terminally with no retry.
|
|
536
|
+
const windowedRead =
|
|
537
|
+
!visionFile && !parsedContent && windowedIndexingEnabled() &&
|
|
538
|
+
isWindowedReadFile(attachment.name, attachment.mime);
|
|
539
|
+
const windowPlaceholder = windowedRead ? makeWindowPlaceholder(attachment.storagePath) : undefined;
|
|
540
|
+
const skapiWindow = windowedRead && windowPlaceholder
|
|
541
|
+
? {
|
|
542
|
+
_skapi_window: [
|
|
543
|
+
{
|
|
544
|
+
path: attachment.storagePath,
|
|
545
|
+
cursor: null,
|
|
546
|
+
placeholder: windowPlaceholder,
|
|
547
|
+
name: attachment.name,
|
|
548
|
+
mime: attachment.mime,
|
|
549
|
+
kind: 'window',
|
|
550
|
+
auto_continue: true,
|
|
551
|
+
continue_text: buildIndexingWindowMessage(attachment, windowPlaceholder, true),
|
|
552
|
+
},
|
|
485
553
|
],
|
|
486
554
|
}
|
|
487
555
|
: {};
|
|
488
556
|
|
|
489
557
|
// Spreadsheets are read by PAGING through readFileContent (grid rows), NOT inlined - so
|
|
490
558
|
// they skip the inline server-extract and the agent is told to page the whole file.
|
|
491
|
-
const pagedRead = !visionFile && (continuing || (!parsedContent && isPagedReadFile(attachment.name, attachment.mime)));
|
|
559
|
+
const pagedRead = !visionFile && !windowedRead && (continuing || (!parsedContent && isPagedReadFile(attachment.name, attachment.mime)));
|
|
492
560
|
|
|
493
561
|
// Client-parsed content wins over server-side extraction.
|
|
494
|
-
const serverExtract = !visionFile && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
|
|
562
|
+
const serverExtract = !visionFile && !windowedRead && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
|
|
495
563
|
const placeholder = serverExtract ? makeExtractPlaceholder(attachment.storagePath) : undefined;
|
|
496
564
|
const extractContent: ExtractDirective[] | undefined =
|
|
497
565
|
serverExtract && placeholder
|
|
@@ -502,6 +570,8 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
502
570
|
|
|
503
571
|
const userMessage = (visionFile && renderPlaceholder)
|
|
504
572
|
? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom)
|
|
573
|
+
: (windowedRead && windowPlaceholder)
|
|
574
|
+
? buildIndexingWindowMessage(attachment, windowPlaceholder, false)
|
|
505
575
|
: continuing
|
|
506
576
|
? buildIndexingContinueMessage(attachment)
|
|
507
577
|
: buildIndexingUserMessage(
|
|
@@ -541,6 +611,7 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
541
611
|
max_output_tokens: MAX_TOKENS,
|
|
542
612
|
...skapiExtract,
|
|
543
613
|
...skapiRender,
|
|
614
|
+
...skapiWindow,
|
|
544
615
|
input: [
|
|
545
616
|
{ role: 'system', content: systemPrompt },
|
|
546
617
|
{
|
|
@@ -589,6 +660,7 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
589
660
|
max_tokens: MAX_TOKENS,
|
|
590
661
|
...skapiExtract,
|
|
591
662
|
...skapiRender,
|
|
663
|
+
...skapiWindow,
|
|
592
664
|
system: [
|
|
593
665
|
{
|
|
594
666
|
type: 'text',
|
|
@@ -713,6 +785,23 @@ export async function listOpenAIModels(service: string, owner: string) {
|
|
|
713
785
|
// so the chat-history BETWEEN query never includes bg-queue items. '-' (45) works.
|
|
714
786
|
export const BG_INDEXING_QUEUE_SUFFIX = '-bg';
|
|
715
787
|
|
|
788
|
+
/**
|
|
789
|
+
* True when a request belongs to the background-indexing queue.
|
|
790
|
+
*
|
|
791
|
+
* Accepts BOTH shapes this value arrives in: the bare queue name the client sends
|
|
792
|
+
* ("<userId>-bg"), and the server qid that comes back on history/poll responses
|
|
793
|
+
* ("<service>:<queue>|<seq>"). Testing the tail of the raw value only works for the
|
|
794
|
+
* first: a qid ends in "|<seq>", so `endsWith('-bg')` is always false for it — which
|
|
795
|
+
* silently meant history items were NEVER recognised as background tasks.
|
|
796
|
+
*/
|
|
797
|
+
export function isBgIndexingQueue(queueName?: string): boolean {
|
|
798
|
+
if (typeof queueName !== 'string' || !queueName) return false;
|
|
799
|
+
const prefix = queueName.split('|')[0];
|
|
800
|
+
const idx = prefix.lastIndexOf(':');
|
|
801
|
+
const name = idx === -1 ? prefix : prefix.slice(idx + 1);
|
|
802
|
+
return name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX;
|
|
803
|
+
}
|
|
804
|
+
|
|
716
805
|
// Pending background-indexing task descriptor. NOTE: the live mutable queue
|
|
717
806
|
// (a Vue `reactive([])` in agent.vue, a plain array in bunnyquery) is app-level
|
|
718
807
|
// state owned by the consumer — only the TYPE lives in the engine.
|
|
@@ -733,18 +822,16 @@ export type BgTaskEntry = {
|
|
|
733
822
|
|
|
734
823
|
// Token the indexing agent appends to its final message ONLY when it has fully read and
|
|
735
824
|
// saved the whole file. Its ABSENCE is what tells the client to run another CONTINUE pass.
|
|
825
|
+
//
|
|
826
|
+
// Applies to the TEXT/GRID paging path only. The vision path (rendered PDF pages) no longer
|
|
827
|
+
// asks the model whether it is finished — the worker advances that loop off the renderer's
|
|
828
|
+
// page count — so this marker has no say in whether a PDF keeps going.
|
|
736
829
|
export const INDEXING_COMPLETE_MARKER = 'INDEXING_COMPLETE';
|
|
737
830
|
// Cap on CONTINUE passes per file, so a file the agent can never mark complete (or a
|
|
738
831
|
// pathological loop) stops instead of re-dispatching forever. The text/grid paging path
|
|
739
832
|
// reads MANY windows within a single pass (the agent loops readFileContent in one turn), so
|
|
740
833
|
// a small cap suffices.
|
|
741
834
|
export const MAX_INDEXING_RESUME_PASSES = 6;
|
|
742
|
-
// The VISION path (rendered PDF pages) can only advance ONE page-window per pass, because the
|
|
743
|
-
// worker injects a single window of images into each request. A big scanned PDF therefore
|
|
744
|
-
// needs one pass PER window (pages / RENDER_PAGES_PER_WINDOW), so it gets a much higher cap.
|
|
745
|
-
// The loop still terminates naturally when the agent reaches the last window (it appends
|
|
746
|
-
// INDEXING_COMPLETE); this cap is only the backstop for a doc larger than it covers.
|
|
747
|
-
export const MAX_VISION_RESUME_PASSES = 40;
|
|
748
835
|
|
|
749
836
|
export async function getChatHistory(
|
|
750
837
|
params: { service?: string; owner?: string; platform: 'claude' | 'openai'; queue?: string },
|