bunnyquery 1.9.7 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -22
- package/bunnyquery.css +53 -2
- package/bunnyquery.js +1880 -197
- package/dist/engine.cjs +1806 -133
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +1335 -11
- package/dist/engine.d.ts +1335 -11
- package/dist/engine.mjs +1765 -134
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/budget.ts +46 -2
- package/src/engine/config.ts +243 -0
- package/src/engine/errors.ts +87 -1
- package/src/engine/history.ts +113 -2
- package/src/engine/host.ts +85 -0
- package/src/engine/index.ts +111 -2
- package/src/engine/office.ts +16 -4
- package/src/engine/project_settings.ts +303 -0
- package/src/engine/prompts/chat_system_prompt.ts +21 -6
- package/src/engine/prompts/indexing_system_prompt.ts +8 -4
- package/src/engine/prompts/indexing_user_message.ts +39 -3
- package/src/engine/requests.ts +203 -8
- package/src/engine/session.ts +1553 -29
- package/src/engine/sse.ts +1054 -0
- package/src/widget.css +21 -2
- package/styles/chat.css +32 -0
package/src/engine/host.ts
CHANGED
|
@@ -130,6 +130,91 @@ export interface ChatMessage {
|
|
|
130
130
|
* still dimmed. Cleared (with _dimSending) by markStagedMessageReady the moment
|
|
131
131
|
* the queue drains, which is when the turn genuinely becomes "(In queue)". */
|
|
132
132
|
isAwaitingIndexing?: boolean;
|
|
133
|
+
/** PROTOCOL + PRESENTATIONAL: this bubble is being painted from a LIVE stream.
|
|
134
|
+
*
|
|
135
|
+
* It sits alongside `isPending`, never instead of it. The bubble is still the
|
|
136
|
+
* turn's "Thinking..." placeholder as far as every queue mechanism is concerned
|
|
137
|
+
* (_ownThinkingIndex, resolveQueuedUserBubble, typewriteLatestReply and the
|
|
138
|
+
* stray-pending sweep all find their target by isPending), and clearing that flag
|
|
139
|
+
* to mean "it has text now" would strand the turn's real answer beside an orphan.
|
|
140
|
+
* What this adds is the one thing those mechanisms do not care about and the VIEW
|
|
141
|
+
* does: `content` is already worth rendering, so draw the text instead of the
|
|
142
|
+
* spinner.
|
|
143
|
+
*
|
|
144
|
+
* Cleared the moment the turn settles, BEFORE the authoritative answer replaces
|
|
145
|
+
* the live text: from that instant the bubble is an ordinary reply being typed.
|
|
146
|
+
* The partially painted `content` is deliberately left in place across that clear,
|
|
147
|
+
* because it is what the typewriter resumes from instead of replaying from zero.
|
|
148
|
+
*
|
|
149
|
+
* Also read by shouldRescueInFlightMessage: a streaming bubble that has no server
|
|
150
|
+
* id yet is unrepresentable in a freshly fetched page, so it must survive the
|
|
151
|
+
* merge or its stream is orphaned with nothing left to paint into. */
|
|
152
|
+
_streaming?: boolean;
|
|
153
|
+
/**
|
|
154
|
+
* This turn's row is TERMINAL but carries no stored answer, because the answer
|
|
155
|
+
* was streamed and nobody ever finalized it: the bytes are in the chunk store,
|
|
156
|
+
* not on the row. The bubble's `content` is therefore UNKNOWN, not empty.
|
|
157
|
+
*
|
|
158
|
+
* That distinction is the whole of the fix for two bugs that looked unrelated.
|
|
159
|
+
* A refetch landing in the window between the row going 'resolved' and finalize
|
|
160
|
+
* storing the body used to ERASE the answer off the screen (the server copy has
|
|
161
|
+
* no content, so the merge dropped the local bubble that did); and a turn that
|
|
162
|
+
* settled while no poll was attached (closed tab, slept device) used to be
|
|
163
|
+
* unrecoverable, because the mapper emitted no assistant bubble at all for a
|
|
164
|
+
* terminal-but-empty row. Both are the same question, "what should the merge
|
|
165
|
+
* believe when the server copy is authoritative but empty", and the answer is
|
|
166
|
+
* this flag: an unknown answer NEVER overwrites a known one, and an unknown one
|
|
167
|
+
* left over after the merge is resolved by reading the chunks back
|
|
168
|
+
* (ChatSession.recoverStreamedAnswer).
|
|
169
|
+
*
|
|
170
|
+
* For a view it is a rendering hint and nothing more: a bubble carrying it with
|
|
171
|
+
* empty content is being fetched, so draw whatever this client draws for a
|
|
172
|
+
* loading answer. A host that ignores it renders an empty bubble for the second
|
|
173
|
+
* or two the recovery takes, which is what it would have rendered anyway.
|
|
174
|
+
*
|
|
175
|
+
* WHEN IT COMES OFF, because that is the half that loses answers. It comes off
|
|
176
|
+
* for a FACT about the turn and never for an event in the client: an answer was
|
|
177
|
+
* recovered and written in, or the chunks were read and were genuinely empty (in
|
|
178
|
+
* which case the empty bubble is removed as well, restoring the list the mapper
|
|
179
|
+
* used to produce). It stays ON when the read FAILED, when the read was STOPPED,
|
|
180
|
+
* and when a live turn settled having painted nothing - three states that say
|
|
181
|
+
* nothing whatever about the turn, and in which the chunks are all still there.
|
|
182
|
+
* A marker cleared on one of those is an answer nothing will ever go back for,
|
|
183
|
+
* so a host may see the same bubble marked across several loads while the reads
|
|
184
|
+
* keep failing; that is the recoverable state, not a stuck one.
|
|
185
|
+
*/
|
|
186
|
+
_streamPending?: boolean;
|
|
187
|
+
/**
|
|
188
|
+
* IS ANYTHING ACTUALLY DRIVING THIS BUBBLE RIGHT NOW? The second half of
|
|
189
|
+
* `_streamPending`, and the half a view cannot do without.
|
|
190
|
+
*
|
|
191
|
+
* `_streamPending` says the answer is elsewhere; it does NOT say somebody is on
|
|
192
|
+
* their way to fetch it, and the two are different states that used to render
|
|
193
|
+
* identically. Recovery is capped per history load (STREAM_RECOVERY_PER_LOAD),
|
|
194
|
+
* so the third and later marked turns on a page are marked and nobody is reading
|
|
195
|
+
* them; a read that FAILED leaves the marker on with the attempt forgotten, which
|
|
196
|
+
* is also nobody. Both drew the same loader as a live turn, so a bubble could
|
|
197
|
+
* spin for the rest of the session with nothing behind it - the one thing a
|
|
198
|
+
* spinner must never do, because it is a promise that something is coming.
|
|
199
|
+
*
|
|
200
|
+
* Three states, and only the first of them may draw a spinner:
|
|
201
|
+
* 'active' a chunk read is in flight or queued for this turn. Something IS
|
|
202
|
+
* coming; the loader is honest.
|
|
203
|
+
* 'failed' the last read failed. Nothing is coming until somebody asks
|
|
204
|
+
* again, so the view owes the reader a way to ask.
|
|
205
|
+
* undefined nothing has been tried, or the attempt is over. Same obligation.
|
|
206
|
+
*
|
|
207
|
+
* Written by the engine only, and never persisted anywhere: it describes THIS
|
|
208
|
+
* session's fetching, not the turn. A fresh history page therefore arrives
|
|
209
|
+
* without it, and _adoptLocalAnswers re-stamps the page's still-marked bubbles
|
|
210
|
+
* from the session's own bookkeeping, so a reload during a read does not turn a
|
|
211
|
+
* live loader into a button (and back a second later).
|
|
212
|
+
*
|
|
213
|
+
* Read it through streamRecoveryPhase(msg), never directly: the phase folds in
|
|
214
|
+
* "does this bubble need the affordance at all", and both clients must not
|
|
215
|
+
* answer that twice.
|
|
216
|
+
*/
|
|
217
|
+
_streamRecovery?: 'active' | 'failed';
|
|
133
218
|
_serverItemId?: string;
|
|
134
219
|
_localId?: string;
|
|
135
220
|
_cancelling?: boolean;
|
package/src/engine/index.ts
CHANGED
|
@@ -11,11 +11,31 @@ export {
|
|
|
11
11
|
configureChatEngine,
|
|
12
12
|
chatEngineConfig,
|
|
13
13
|
type ChatEngineConfig,
|
|
14
|
+
// The live-stream observation hook's payload. Exported so a consumer can type
|
|
15
|
+
// its handler against the engine rather than restate the shape.
|
|
16
|
+
type LiveStreamUpdate,
|
|
17
|
+
// "Can a streamed turn whose answer never reached its row be read back?" - which
|
|
18
|
+
// asks for the chunk READER and deliberately not for liveStreaming, because a
|
|
19
|
+
// row that already streamed stays recoverable long after streaming is switched
|
|
20
|
+
// off. Exported because a client with its OWN history mapper (agent.vue's fork)
|
|
21
|
+
// has to gate the `_streamPending` mark on exactly this predicate: gate it on
|
|
22
|
+
// liveStreaming and every already-streamed row is stranded the day the flag goes
|
|
23
|
+
// back off; gate it on nothing and a host with no reader gets a permanently
|
|
24
|
+
// empty bubble where it used to get none, which is strictly worse than the bug.
|
|
25
|
+
streamRecoveryEnabled,
|
|
26
|
+
// Whether a given skapi INSTANCE can carry skapi's half of the stream flag. Both
|
|
27
|
+
// clients gate their liveStreaming opt-in on it and degrade to buffered when it
|
|
28
|
+
// says no: an SDK too old to know the key drops it silently, leaving the
|
|
29
|
+
// destination streaming SSE into a buffered row that reads back empty.
|
|
30
|
+
skapiSupportsStreaming,
|
|
14
31
|
} from './config';
|
|
15
32
|
|
|
16
33
|
export {
|
|
17
34
|
isServerExtractable,
|
|
18
35
|
isOfficeFile,
|
|
36
|
+
isPagedReadFile,
|
|
37
|
+
isImageVisionFile,
|
|
38
|
+
isWindowedReadFile,
|
|
19
39
|
makeExtractPlaceholder,
|
|
20
40
|
composeUserMessage,
|
|
21
41
|
type ExtractDirective,
|
|
@@ -47,7 +67,15 @@ export { buildChatGreeting, type ChatGreetingParams, type ChatGreetingParts } fr
|
|
|
47
67
|
|
|
48
68
|
// Pure helpers (Tier-1.5): error detection, token budgeting, link/path
|
|
49
69
|
// normalization, and history mapping — shared so both consumers stay identical.
|
|
50
|
-
export {
|
|
70
|
+
export {
|
|
71
|
+
getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, isProviderApiKeyError,
|
|
72
|
+
// The csr-poll STATUS ENVELOPE, and the provider error nested one level inside a
|
|
73
|
+
// failed one. Exported because "is this an envelope or a body" is asked in two
|
|
74
|
+
// places that must agree (the streamed settle, and every error reader), and it
|
|
75
|
+
// was answered twice before, one of the two one level too shallow, which is how
|
|
76
|
+
// a wrong API key on a streamed turn reported "No text response received".
|
|
77
|
+
isCsrStatusEnvelope, csrEnvelopeError,
|
|
78
|
+
} from './errors';
|
|
51
79
|
export * from './budget';
|
|
52
80
|
// Per-format UTF-8 declaration for files offered as a download. Shared so a fenced
|
|
53
81
|
// block and a server-published file open identically in Excel, Word and a browser.
|
|
@@ -57,6 +85,11 @@ export * from './link_markup';
|
|
|
57
85
|
export * from './image_preview';
|
|
58
86
|
export * from './time';
|
|
59
87
|
export * from './ai_agent';
|
|
88
|
+
// The SSE parser. skapi relays the provider's bytes without reading them, so this is
|
|
89
|
+
// where BunnyQuery's knowledge of the Anthropic and OpenAI wire formats lives. It has
|
|
90
|
+
// to be on the barrel or the built engine bundle does not contain it and
|
|
91
|
+
// www.skapi.com's `bunnyquery/engine` import resolves to undefined at runtime.
|
|
92
|
+
export * from './sse';
|
|
60
93
|
export {
|
|
61
94
|
filterListByClearHorizon,
|
|
62
95
|
normalizeTextContent,
|
|
@@ -73,6 +106,11 @@ export {
|
|
|
73
106
|
// rendered twice and then persisted into the history cache.
|
|
74
107
|
shouldRescueInFlightMessage,
|
|
75
108
|
type RescueDecisionContext,
|
|
109
|
+
// What the merge does when the server's copy of a turn is authoritative but
|
|
110
|
+
// EMPTY (a streamed row nobody finalized): the local answer wins, because an
|
|
111
|
+
// unknown answer is not an empty one. Shared for the same reason the rescue
|
|
112
|
+
// rule is: a client mapper that forks it erases answers off the screen.
|
|
113
|
+
adoptLocalAnswerIntoPage,
|
|
76
114
|
// One bounded look at the bg-indexing queue: which files still have a live
|
|
77
115
|
// pass. The dbfile browser's "indexed" badge uses this so a file only goes
|
|
78
116
|
// green once the run is confirmed over, not when its src:: record appears.
|
|
@@ -114,7 +152,31 @@ export {
|
|
|
114
152
|
|
|
115
153
|
// Tier-2: the stateful chat orchestration (queue/poll/cancel, typewriter,
|
|
116
154
|
// bg-task drain, resolution). DOM-free; the consumer implements ChatHost.
|
|
117
|
-
|
|
155
|
+
//
|
|
156
|
+
// liveSafePrefix / typewriterResumeIndex are the two pure halves of live
|
|
157
|
+
// rendering: what is safe to show while the answer is still arriving, and where
|
|
158
|
+
// the typewriter picks up once the authoritative answer replaces it. Exported so
|
|
159
|
+
// they can be tested (and read) without a DOM.
|
|
160
|
+
//
|
|
161
|
+
// mayKeepStreamedAnswer is the ONE keep policy for a streamed turn: may this parse
|
|
162
|
+
// be stored as the row's permanent answer, given the row's own status. It is on the
|
|
163
|
+
// barrel because finalize is also the only way to release chunks, so every path
|
|
164
|
+
// that can reach it has to answer this identically - the live settle and the
|
|
165
|
+
// read-back once answered it separately and disagreed, and the disagreement
|
|
166
|
+
// released the chunks of a failed turn.
|
|
167
|
+
//
|
|
168
|
+
// streamRecoveryPhase / streamRecoveryLabels are the RENDER half of the same
|
|
169
|
+
// policy: given a bubble whose answer is still in the chunk store, is anything
|
|
170
|
+
// actually fetching it, and if not, what does the reader get offered instead of a
|
|
171
|
+
// spinner that will never resolve. Both clients read the phase from here rather
|
|
172
|
+
// than from `_streamRecovery` directly - the alternative is each of them deciding
|
|
173
|
+
// on its own when a loader is honest, which is precisely the fork this barrel
|
|
174
|
+
// exists to prevent.
|
|
175
|
+
export {
|
|
176
|
+
ChatSession, liveSafePrefix, typewriterResumeIndex, mayKeepStreamedAnswer,
|
|
177
|
+
streamRecoveryPhase, streamRecoveryLabels,
|
|
178
|
+
type StreamDispatchContext,
|
|
179
|
+
} from './session';
|
|
118
180
|
export type { ChatHost, ChatIdentity, ChatState, ChatMessage, IndexingFileRef, PinnedDispatchContext } from './host';
|
|
119
181
|
|
|
120
182
|
// Display transform: collapse a file's many background-indexing turns into one
|
|
@@ -133,6 +195,7 @@ export {
|
|
|
133
195
|
export {
|
|
134
196
|
// constants
|
|
135
197
|
POLL_INTERVAL,
|
|
198
|
+
STREAM_POLL_INTERVAL,
|
|
136
199
|
MAX_CONCURRENT_BG_POLLS,
|
|
137
200
|
getVisionProfile,
|
|
138
201
|
type VisionProfile,
|
|
@@ -147,6 +210,11 @@ export {
|
|
|
147
210
|
MCP_NAME,
|
|
148
211
|
DEFAULT_CLAUDE_MODEL,
|
|
149
212
|
DEFAULT_OPENAI_MODEL,
|
|
213
|
+
// The ONE producer of the two `stream` flags a streamed chat turn needs. Exported
|
|
214
|
+
// so a test can assert the pair moves together, which is the whole point of it
|
|
215
|
+
// being a single function.
|
|
216
|
+
chatStreamWiring,
|
|
217
|
+
type ChatStreamWiring,
|
|
150
218
|
// request builders + dispatch
|
|
151
219
|
callClaudeWithMcp,
|
|
152
220
|
callClaudeWithPublicMcp,
|
|
@@ -179,3 +247,44 @@ export {
|
|
|
179
247
|
type AttachmentSaveInfo,
|
|
180
248
|
type BgTaskEntry,
|
|
181
249
|
} from './requests';
|
|
250
|
+
|
|
251
|
+
// The project's BunnyQuery settings, stored as a public record in the customer's
|
|
252
|
+
// own project ("bq::settings" in "__SETTINGS__") rather than on the skapi service
|
|
253
|
+
// record. Both clients prime it when a chat page opens and await it before the
|
|
254
|
+
// first upload writes an access group onto a record. See project_settings.ts.
|
|
255
|
+
export {
|
|
256
|
+
// where the record lives
|
|
257
|
+
PROJECT_SETTINGS_TABLE,
|
|
258
|
+
PROJECT_SETTINGS_UNIQUE_ID,
|
|
259
|
+
PROJECT_SETTINGS_ACCESS_GROUP,
|
|
260
|
+
// the choice, its labels and its default
|
|
261
|
+
UPLOAD_ACCESS_GROUPS,
|
|
262
|
+
UPLOAD_ACCESS_LABELS,
|
|
263
|
+
UPLOAD_ACCESS_HINTS,
|
|
264
|
+
UPLOAD_ACCESS_OPTIONS,
|
|
265
|
+
DEFAULT_UPLOAD_ACCESS_GROUP,
|
|
266
|
+
// pure readers over a settings `data` object
|
|
267
|
+
normalizeUploadAccessGroup,
|
|
268
|
+
normalizeProjectAccessSetting,
|
|
269
|
+
accessSettingFrom,
|
|
270
|
+
uploadAccessGroupFrom,
|
|
271
|
+
asksUploadAccessFrom,
|
|
272
|
+
// the per-project store
|
|
273
|
+
configureProjectSettings,
|
|
274
|
+
loadProjectSettings,
|
|
275
|
+
primeProjectSettings,
|
|
276
|
+
readyProjectSettings,
|
|
277
|
+
cachedProjectSettings,
|
|
278
|
+
projectSettingsSettled,
|
|
279
|
+
projectAccessSetting,
|
|
280
|
+
projectUploadAccessGroup,
|
|
281
|
+
projectAsksUploadAccess,
|
|
282
|
+
setProjectSettings,
|
|
283
|
+
patchProjectSettings,
|
|
284
|
+
clearProjectSettings,
|
|
285
|
+
// types
|
|
286
|
+
type UploadAccessGroup,
|
|
287
|
+
type ProjectAccessSetting,
|
|
288
|
+
type ProjectSettingsData,
|
|
289
|
+
type ProjectSettingsReader,
|
|
290
|
+
} from './project_settings';
|
package/src/engine/office.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Office-file server-side extraction helpers.
|
|
3
3
|
*
|
|
4
4
|
* Office documents (Microsoft .docx/.xlsx/.pptx, Hancom .hwpx, etc.) can't be
|
|
5
|
-
* read by web_fetch (binary/zip)
|
|
5
|
+
* read by web_fetch (binary/zip), and neither can an RFC822 email (.eml), whose
|
|
6
|
+
* body and attachments are MIME-encoded. The proxy worker downloads them from db
|
|
6
7
|
* storage, extracts their text server-side, and substitutes that text for a
|
|
7
8
|
* placeholder token in the request body (carried under the reserved
|
|
8
9
|
* `_skapi_extract` key, which the producer strips before the upstream call).
|
|
@@ -36,7 +37,11 @@ export type FileUrlDirective = {
|
|
|
36
37
|
// Files whose text the worker extracts SERVER-SIDE and inlines for indexing,
|
|
37
38
|
// instead of handing the agent a URL to fetch. Two groups:
|
|
38
39
|
// (1) BINARY document formats the model can't read at all (OOXML, Hancom
|
|
39
|
-
// HWP/HWPX, OpenDocument, EPUB)
|
|
40
|
+
// HWP/HWPX, OpenDocument, EPUB): the worker parses them. RFC822 email
|
|
41
|
+
// (.eml) belongs here although it is nominally text: it is a MIME
|
|
42
|
+
// container whose body is quoted-printable/base64 and whose attachments
|
|
43
|
+
// are base64 blobs, so it must be PARSED on the server, never decoded as
|
|
44
|
+
// text (a text route would inline the attachment blobs as prose).
|
|
40
45
|
// (2) TEXT/data/markup/code formats. These ARE readable via web_fetch, BUT
|
|
41
46
|
// some providers (OpenAI's Responses API) have no working file-fetch tool,
|
|
42
47
|
// so the agent can't retrieve the URL at all. Extracting them server-side
|
|
@@ -48,6 +53,7 @@ const OFFICE_FILE_EXTENSIONS = new Set([
|
|
|
48
53
|
'hwp', 'hwpx',
|
|
49
54
|
'ods', 'odt', 'odp',
|
|
50
55
|
'epub',
|
|
56
|
+
'eml',
|
|
51
57
|
]);
|
|
52
58
|
|
|
53
59
|
const TEXT_FILE_EXTENSIONS = new Set([
|
|
@@ -129,6 +135,11 @@ export const isOfficeFile = isServerExtractable;
|
|
|
129
135
|
// .sql, .css, .toml ...). They are windowable, but adding them moves every small code
|
|
130
136
|
// upload from one pass to multi-pass worker indexing, which is a cost change that wants
|
|
131
137
|
// measuring on its own rather than riding along with this one.
|
|
138
|
+
//
|
|
139
|
+
// .eml is paged for the same reason the documents are: an email carrying a
|
|
140
|
+
// spreadsheet or a long reply thread routinely exceeds the 200k one-shot cap, and
|
|
141
|
+
// its layer extractor ships in the same change as this entry (listed before the
|
|
142
|
+
// layer lands, it would page UNSUPPORTED_FORMAT on every window).
|
|
132
143
|
const PAGED_READ_EXTENSIONS = new Set([
|
|
133
144
|
// grids
|
|
134
145
|
'xls', 'xlsx', 'xlsm', 'ods',
|
|
@@ -142,6 +153,7 @@ const PAGED_READ_EXTENSIONS = new Set([
|
|
|
142
153
|
'odt', 'odp',
|
|
143
154
|
// other long-form documents
|
|
144
155
|
'epub', 'rtf', 'html', 'htm',
|
|
156
|
+
'eml', // email (RFC822): body plus attachment text, char-windowed
|
|
145
157
|
// plain text / data / markup
|
|
146
158
|
'txt', 'md', 'markdown', 'log', 'json', 'jsonl', 'ndjson', 'xml', 'yaml', 'yml',
|
|
147
159
|
]);
|
|
@@ -239,8 +251,8 @@ export interface ComposedUserMessage {
|
|
|
239
251
|
|
|
240
252
|
// Compose the user's chat message from the typed text + uploaded attachment URLs.
|
|
241
253
|
// Identical for every consumer (agent.vue + the BunnyQuery widget): appends a
|
|
242
|
-
// markdown "Attached files" link block, and for
|
|
243
|
-
// (.docx/.xlsx/.pptx/.hwpx) adds inline extraction placeholders to the LLM copy
|
|
254
|
+
// markdown "Attached files" link block, and for server-extractable files
|
|
255
|
+
// (.docx/.xlsx/.pptx/.hwpx/.eml, text/data/code) adds inline extraction placeholders to the LLM copy
|
|
244
256
|
// ONLY (the proxy worker substitutes their text server-side; the display/history
|
|
245
257
|
// copy stays clean so stale tokens never accumulate across replayed turns).
|
|
246
258
|
// The directive `path` is the db storage path (uid-prefixed where applicable),
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's BunnyQuery settings, held as a record in the project's own
|
|
3
|
+
* database rather than on the skapi service record.
|
|
4
|
+
*
|
|
5
|
+
* WHY A RECORD. The upload access group used to live on the service record as
|
|
6
|
+
* `default_access_group`, which was ALSO the skapi SDK's project-wide default
|
|
7
|
+
* for `table.access_group`. One field meant two things: "what BunnyQuery indexes
|
|
8
|
+
* new files at" and "what every SDK record call on this project defaults to".
|
|
9
|
+
* That coupling is gone. The SDK no longer has a project default at all, so this
|
|
10
|
+
* setting needs a home of its own, and a plain public record in the customer's
|
|
11
|
+
* own project is one every client can already reach with the calls it has.
|
|
12
|
+
*
|
|
13
|
+
* SHAPE. One record per project, holding an OBJECT rather than a single value:
|
|
14
|
+
*
|
|
15
|
+
* unique_id: 'bq::settings'
|
|
16
|
+
* table: { name: '__SETTINGS__', access_group: 'public' }
|
|
17
|
+
* data: { upload_access_group: 'authorized' }
|
|
18
|
+
*
|
|
19
|
+
* One record and one fetch covers every present and future project setting. A
|
|
20
|
+
* second setting is a new key, not a new record, so the "wait for settings
|
|
21
|
+
* before the first upload" hand-off below never has to become several waits.
|
|
22
|
+
*
|
|
23
|
+
* WHY PUBLIC. The widget reads this, and the widget frequently runs before there
|
|
24
|
+
* is any session. Group 0 is the only group an unauthenticated caller is served
|
|
25
|
+
* (`check_rec_access` returns immediately for "00" and refuses the rest). Note
|
|
26
|
+
* this is NOT sufficient on its own: skapi's `require_login` gate refuses ALL
|
|
27
|
+
* database reads from a signed-out visitor, and it defaults to true, so on most
|
|
28
|
+
* projects a signed-out widget still cannot read this and falls back to the
|
|
29
|
+
* default. That is survivable because the only thing a signed-out visitor could
|
|
30
|
+
* do with the value is upload, which they cannot do either.
|
|
31
|
+
*
|
|
32
|
+
* WHY THE VALUE MATTERS. The file BYTES are not what the access group controls.
|
|
33
|
+
* BunnyQuery uploads to db storage, whose object key carries no access group and
|
|
34
|
+
* whose read path performs no access check. What carries the group is the
|
|
35
|
+
* RECORDS: the `src::` file record in `file_summaries`, the `run::`/`done::`
|
|
36
|
+
* markers in `__INDEXING__`, and every content record the indexing agent
|
|
37
|
+
* extracts. Those are what a chat answers from, so those are what decide who the
|
|
38
|
+
* file is visible to. The same value is also handed to the chat system prompt as
|
|
39
|
+
* `indexAccessGroup`, because a record written under a different group is in a
|
|
40
|
+
* different table and never comes back with the rest of the file.
|
|
41
|
+
*
|
|
42
|
+
* TRANSPORT-FREE, like the rest of the engine. The store never imports a skapi
|
|
43
|
+
* instance; the consumer injects a reader. See configureProjectSettings.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** The access groups a BunnyQuery upload may be recorded at. */
|
|
47
|
+
export type UploadAccessGroup = 'public' | 'authorized' | 'private';
|
|
48
|
+
|
|
49
|
+
export const UPLOAD_ACCESS_GROUPS: UploadAccessGroup[] = ['public', 'authorized', 'private'];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What the project's upload-access setting may be: one of the three groups the
|
|
53
|
+
* dashboard offers, or 'ask' to be prompted per upload.
|
|
54
|
+
*
|
|
55
|
+
* `'admin'` (99) is deliberately not offered: a file only a master can read is
|
|
56
|
+
* indistinguishable from one that failed to upload, and no dashboard control
|
|
57
|
+
* would produce it.
|
|
58
|
+
*/
|
|
59
|
+
export type ProjectAccessSetting = UploadAccessGroup | 'ask';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `authorized` is the default because it is what every record written before
|
|
63
|
+
* this setting existed was hardcoded to. A project that never opens the setting
|
|
64
|
+
* keeps exactly the visibility it already had. It is also what the abandoned
|
|
65
|
+
* `default_access_group` service field was seeded to at project creation, so a
|
|
66
|
+
* project carrying that old value reads the same before and after the move.
|
|
67
|
+
*/
|
|
68
|
+
export const DEFAULT_UPLOAD_ACCESS_GROUP: UploadAccessGroup = 'authorized';
|
|
69
|
+
|
|
70
|
+
/** Where the settings record lives. Shared so no client re-derives it. */
|
|
71
|
+
export const PROJECT_SETTINGS_TABLE = '__SETTINGS__';
|
|
72
|
+
export const PROJECT_SETTINGS_UNIQUE_ID = 'bq::settings';
|
|
73
|
+
export const PROJECT_SETTINGS_ACCESS_GROUP = 'public';
|
|
74
|
+
|
|
75
|
+
export const UPLOAD_ACCESS_LABELS: Record<UploadAccessGroup, string> = {
|
|
76
|
+
public: 'Public',
|
|
77
|
+
authorized: 'Signed in users',
|
|
78
|
+
private: 'Only me',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const UPLOAD_ACCESS_HINTS: Record<UploadAccessGroup, string> = {
|
|
82
|
+
public: 'Anyone can ask about this file, including visitors who are not logged in.',
|
|
83
|
+
authorized: 'Only users signed in to this project can ask about this file.',
|
|
84
|
+
private: 'Only you can ask about this file.',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Menu/modal option list, in the order they should be shown. */
|
|
88
|
+
export const UPLOAD_ACCESS_OPTIONS = UPLOAD_ACCESS_GROUPS.map((value) => ({
|
|
89
|
+
value,
|
|
90
|
+
label: UPLOAD_ACCESS_LABELS[value],
|
|
91
|
+
hint: UPLOAD_ACCESS_HINTS[value],
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
/** The settings record's `data`. Open-ended: future settings are new keys. */
|
|
95
|
+
export type ProjectSettingsData = {
|
|
96
|
+
upload_access_group?: unknown;
|
|
97
|
+
[key: string]: unknown;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Narrow an unknown stored value to a usable group, falling back to the default. */
|
|
101
|
+
export function normalizeUploadAccessGroup(value: any): UploadAccessGroup {
|
|
102
|
+
return UPLOAD_ACCESS_GROUPS.indexOf(value) === -1
|
|
103
|
+
? DEFAULT_UPLOAD_ACCESS_GROUP
|
|
104
|
+
: (value as UploadAccessGroup);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The stored setting as written, or null when the project has never set one.
|
|
109
|
+
*
|
|
110
|
+
* Returns null rather than a default so callers can tell "unset" from "set to
|
|
111
|
+
* authorized". The settings page needs that distinction to decide what the
|
|
112
|
+
* control shows; upload paths do not and use uploadAccessGroupFrom instead.
|
|
113
|
+
*/
|
|
114
|
+
export function normalizeProjectAccessSetting(value: any): ProjectAccessSetting | null {
|
|
115
|
+
if (value === 'ask') return 'ask';
|
|
116
|
+
return UPLOAD_ACCESS_GROUPS.indexOf(value) === -1 ? null : (value as ProjectAccessSetting);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The setting held in a settings-record `data`, or null when unset. */
|
|
120
|
+
export function accessSettingFrom(
|
|
121
|
+
data: ProjectSettingsData | null | undefined,
|
|
122
|
+
): ProjectAccessSetting | null {
|
|
123
|
+
return normalizeProjectAccessSetting(data?.upload_access_group);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The group an upload lands in when the project is NOT set to 'ask'. */
|
|
127
|
+
export function uploadAccessGroupFrom(
|
|
128
|
+
data: ProjectSettingsData | null | undefined,
|
|
129
|
+
): UploadAccessGroup {
|
|
130
|
+
const v = accessSettingFrom(data);
|
|
131
|
+
return v && v !== 'ask' ? v : DEFAULT_UPLOAD_ACCESS_GROUP;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True when the project wants to be asked per upload rather than told once. */
|
|
135
|
+
export function asksUploadAccessFrom(data: ProjectSettingsData | null | undefined): boolean {
|
|
136
|
+
return accessSettingFrom(data) === 'ask';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fetch one project's settings record. Resolves the record's `data`, or null
|
|
141
|
+
* when there is no record.
|
|
142
|
+
*
|
|
143
|
+
* MAY REJECT, and the store treats a rejection as "no record": a signed-out
|
|
144
|
+
* visitor on a `require_login` project gets REQUIRE_LOGIN here, which is a
|
|
145
|
+
* normal outcome and not an error the user should ever see.
|
|
146
|
+
*/
|
|
147
|
+
export type ProjectSettingsReader = (service: string) => Promise<ProjectSettingsData | null>;
|
|
148
|
+
|
|
149
|
+
type Entry = {
|
|
150
|
+
/** Resolved data, or null for "fetched, and there is none". */
|
|
151
|
+
data: ProjectSettingsData | null;
|
|
152
|
+
/** True once a fetch has SETTLED, so `data` is authoritative rather than absent. */
|
|
153
|
+
settled: boolean;
|
|
154
|
+
/** The in-flight fetch, deduped so N callers share ONE request. */
|
|
155
|
+
inflight: Promise<ProjectSettingsData | null> | null;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
let reader: ProjectSettingsReader | null = null;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Keyed by service id, NEVER global.
|
|
162
|
+
*
|
|
163
|
+
* One client routinely serves MANY projects: the console switches projects
|
|
164
|
+
* without reloading, and an upload outlives the page that started it. A flat
|
|
165
|
+
* single-value cache lets one project's setting decide another project's upload
|
|
166
|
+
* group, which is a silent cross-project data-visibility bug, not a stale-read
|
|
167
|
+
* annoyance. Every accessor takes the service id for this reason.
|
|
168
|
+
*/
|
|
169
|
+
const cache = new Map<string, Entry>();
|
|
170
|
+
|
|
171
|
+
export function configureProjectSettings(fn: ProjectSettingsReader | null): void {
|
|
172
|
+
reader = fn;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function entry(service: string): Entry {
|
|
176
|
+
let e = cache.get(service);
|
|
177
|
+
if (!e) {
|
|
178
|
+
e = { data: null, settled: false, inflight: null };
|
|
179
|
+
cache.set(service, e);
|
|
180
|
+
}
|
|
181
|
+
return e;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Start the fetch and hand back the promise, deduping concurrent callers.
|
|
186
|
+
*
|
|
187
|
+
* Never rejects: a failed read settles as null, which every accessor reads as
|
|
188
|
+
* "unset" and answers with the default. A settings fetch must not be able to
|
|
189
|
+
* fail an upload.
|
|
190
|
+
*/
|
|
191
|
+
export function loadProjectSettings(service: string): Promise<ProjectSettingsData | null> {
|
|
192
|
+
if (!service) return Promise.resolve(null);
|
|
193
|
+
const e = entry(service);
|
|
194
|
+
if (e.settled) return Promise.resolve(e.data);
|
|
195
|
+
if (e.inflight) return e.inflight;
|
|
196
|
+
if (!reader) return Promise.resolve(null);
|
|
197
|
+
|
|
198
|
+
const run: Promise<ProjectSettingsData | null> = reader(service)
|
|
199
|
+
.then((data) => (data && typeof data === 'object' ? data : null))
|
|
200
|
+
.catch(() => null)
|
|
201
|
+
.then((data) => {
|
|
202
|
+
// Guard against a clear() that landed while this was in flight: the
|
|
203
|
+
// entry may have been replaced, so write through the map rather than
|
|
204
|
+
// through the captured object.
|
|
205
|
+
const cur = entry(service);
|
|
206
|
+
if (cur.inflight === run) {
|
|
207
|
+
cur.data = data;
|
|
208
|
+
cur.settled = true;
|
|
209
|
+
cur.inflight = null;
|
|
210
|
+
}
|
|
211
|
+
return data;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
e.inflight = run;
|
|
215
|
+
return run;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Kick the fetch off without waiting for it. Call on chat/page open.
|
|
220
|
+
*
|
|
221
|
+
* Fire-and-forget by design: the page paints on the default and the first upload
|
|
222
|
+
* awaits the real value via readyProjectSettings. Nothing blocks on this.
|
|
223
|
+
*/
|
|
224
|
+
export function primeProjectSettings(service: string): void {
|
|
225
|
+
void loadProjectSettings(service);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Await the settings for this project. What the FIRST upload calls.
|
|
230
|
+
*
|
|
231
|
+
* Cheap after the first call: a settled entry resolves immediately, and a
|
|
232
|
+
* primed-but-unsettled one joins the in-flight request rather than starting a
|
|
233
|
+
* second.
|
|
234
|
+
*/
|
|
235
|
+
export function readyProjectSettings(service: string): Promise<ProjectSettingsData | null> {
|
|
236
|
+
return loadProjectSettings(service);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The cached data WITHOUT waiting, or null when nothing has settled yet.
|
|
241
|
+
*
|
|
242
|
+
* For synchronous readers (a template, a menu's current value). A caller that is
|
|
243
|
+
* about to WRITE an access group onto a record must use readyProjectSettings
|
|
244
|
+
* instead: answering from an unsettled cache is how a file lands in the wrong
|
|
245
|
+
* group on the first upload after a page load.
|
|
246
|
+
*/
|
|
247
|
+
export function cachedProjectSettings(service: string): ProjectSettingsData | null {
|
|
248
|
+
const e = cache.get(service);
|
|
249
|
+
return e && e.settled ? e.data : null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** True once this project's settings have been fetched (whether or not one existed). */
|
|
253
|
+
export function projectSettingsSettled(service: string): boolean {
|
|
254
|
+
const e = cache.get(service);
|
|
255
|
+
return !!e && e.settled;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Sync convenience: the project's setting as stored, or null when unset/unsettled. */
|
|
259
|
+
export function projectAccessSetting(service: string): ProjectAccessSetting | null {
|
|
260
|
+
return accessSettingFrom(cachedProjectSettings(service));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Sync convenience: the upload group, falling back to the default. */
|
|
264
|
+
export function projectUploadAccessGroup(service: string): UploadAccessGroup {
|
|
265
|
+
return uploadAccessGroupFrom(cachedProjectSettings(service));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Sync convenience: does this project want a per-upload prompt? */
|
|
269
|
+
export function projectAsksUploadAccess(service: string): boolean {
|
|
270
|
+
return asksUploadAccessFrom(cachedProjectSettings(service));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Adopt a value the caller just WROTE, so the settings page reflects its own
|
|
275
|
+
* save without a re-fetch.
|
|
276
|
+
*
|
|
277
|
+
* Marks the entry settled: the writer knows the stored value better than a
|
|
278
|
+
* refetch would, and leaving it unsettled would send the next upload back to the
|
|
279
|
+
* network for a value already in hand.
|
|
280
|
+
*/
|
|
281
|
+
export function setProjectSettings(service: string, data: ProjectSettingsData | null): void {
|
|
282
|
+
if (!service) return;
|
|
283
|
+
cache.set(service, { data: data || null, settled: true, inflight: null });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Merge one key into the cached settings, preserving the rest. */
|
|
287
|
+
export function patchProjectSettings(service: string, patch: ProjectSettingsData): void {
|
|
288
|
+
if (!service) return;
|
|
289
|
+
const cur = cachedProjectSettings(service) || {};
|
|
290
|
+
setProjectSettings(service, Object.assign({}, cur, patch));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Drop cached settings. Pass a service to drop one, omit to drop all.
|
|
295
|
+
*
|
|
296
|
+
* An in-flight fetch is abandoned rather than cancelled: its `.then` checks that
|
|
297
|
+
* the entry it is writing into is still its own, so a late response cannot
|
|
298
|
+
* repopulate a cleared project.
|
|
299
|
+
*/
|
|
300
|
+
export function clearProjectSettings(service?: string): void {
|
|
301
|
+
if (service) cache.delete(service);
|
|
302
|
+
else cache.clear();
|
|
303
|
+
}
|