bunnyquery 1.9.7 → 1.9.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/bunnyquery.css +53 -2
- package/bunnyquery.js +1822 -177
- package/dist/engine.cjs +1746 -114
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +1271 -6
- package/dist/engine.d.ts +1271 -6
- package/dist/engine.mjs +1709 -115
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- 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 +108 -2
- package/src/engine/project_settings.ts +303 -0
- package/src/engine/prompts/chat_system_prompt.ts +15 -3
- package/src/engine/requests.ts +130 -1
- 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/index.ts
CHANGED
|
@@ -11,6 +11,23 @@ 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 {
|
|
@@ -47,7 +64,15 @@ export { buildChatGreeting, type ChatGreetingParams, type ChatGreetingParts } fr
|
|
|
47
64
|
|
|
48
65
|
// Pure helpers (Tier-1.5): error detection, token budgeting, link/path
|
|
49
66
|
// normalization, and history mapping — shared so both consumers stay identical.
|
|
50
|
-
export {
|
|
67
|
+
export {
|
|
68
|
+
getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, isProviderApiKeyError,
|
|
69
|
+
// The csr-poll STATUS ENVELOPE, and the provider error nested one level inside a
|
|
70
|
+
// failed one. Exported because "is this an envelope or a body" is asked in two
|
|
71
|
+
// places that must agree (the streamed settle, and every error reader), and it
|
|
72
|
+
// was answered twice before, one of the two one level too shallow, which is how
|
|
73
|
+
// a wrong API key on a streamed turn reported "No text response received".
|
|
74
|
+
isCsrStatusEnvelope, csrEnvelopeError,
|
|
75
|
+
} from './errors';
|
|
51
76
|
export * from './budget';
|
|
52
77
|
// Per-format UTF-8 declaration for files offered as a download. Shared so a fenced
|
|
53
78
|
// block and a server-published file open identically in Excel, Word and a browser.
|
|
@@ -57,6 +82,11 @@ export * from './link_markup';
|
|
|
57
82
|
export * from './image_preview';
|
|
58
83
|
export * from './time';
|
|
59
84
|
export * from './ai_agent';
|
|
85
|
+
// The SSE parser. skapi relays the provider's bytes without reading them, so this is
|
|
86
|
+
// where BunnyQuery's knowledge of the Anthropic and OpenAI wire formats lives. It has
|
|
87
|
+
// to be on the barrel or the built engine bundle does not contain it and
|
|
88
|
+
// www.skapi.com's `bunnyquery/engine` import resolves to undefined at runtime.
|
|
89
|
+
export * from './sse';
|
|
60
90
|
export {
|
|
61
91
|
filterListByClearHorizon,
|
|
62
92
|
normalizeTextContent,
|
|
@@ -73,6 +103,11 @@ export {
|
|
|
73
103
|
// rendered twice and then persisted into the history cache.
|
|
74
104
|
shouldRescueInFlightMessage,
|
|
75
105
|
type RescueDecisionContext,
|
|
106
|
+
// What the merge does when the server's copy of a turn is authoritative but
|
|
107
|
+
// EMPTY (a streamed row nobody finalized): the local answer wins, because an
|
|
108
|
+
// unknown answer is not an empty one. Shared for the same reason the rescue
|
|
109
|
+
// rule is: a client mapper that forks it erases answers off the screen.
|
|
110
|
+
adoptLocalAnswerIntoPage,
|
|
76
111
|
// One bounded look at the bg-indexing queue: which files still have a live
|
|
77
112
|
// pass. The dbfile browser's "indexed" badge uses this so a file only goes
|
|
78
113
|
// green once the run is confirmed over, not when its src:: record appears.
|
|
@@ -114,7 +149,31 @@ export {
|
|
|
114
149
|
|
|
115
150
|
// Tier-2: the stateful chat orchestration (queue/poll/cancel, typewriter,
|
|
116
151
|
// bg-task drain, resolution). DOM-free; the consumer implements ChatHost.
|
|
117
|
-
|
|
152
|
+
//
|
|
153
|
+
// liveSafePrefix / typewriterResumeIndex are the two pure halves of live
|
|
154
|
+
// rendering: what is safe to show while the answer is still arriving, and where
|
|
155
|
+
// the typewriter picks up once the authoritative answer replaces it. Exported so
|
|
156
|
+
// they can be tested (and read) without a DOM.
|
|
157
|
+
//
|
|
158
|
+
// mayKeepStreamedAnswer is the ONE keep policy for a streamed turn: may this parse
|
|
159
|
+
// be stored as the row's permanent answer, given the row's own status. It is on the
|
|
160
|
+
// barrel because finalize is also the only way to release chunks, so every path
|
|
161
|
+
// that can reach it has to answer this identically - the live settle and the
|
|
162
|
+
// read-back once answered it separately and disagreed, and the disagreement
|
|
163
|
+
// released the chunks of a failed turn.
|
|
164
|
+
//
|
|
165
|
+
// streamRecoveryPhase / streamRecoveryLabels are the RENDER half of the same
|
|
166
|
+
// policy: given a bubble whose answer is still in the chunk store, is anything
|
|
167
|
+
// actually fetching it, and if not, what does the reader get offered instead of a
|
|
168
|
+
// spinner that will never resolve. Both clients read the phase from here rather
|
|
169
|
+
// than from `_streamRecovery` directly - the alternative is each of them deciding
|
|
170
|
+
// on its own when a loader is honest, which is precisely the fork this barrel
|
|
171
|
+
// exists to prevent.
|
|
172
|
+
export {
|
|
173
|
+
ChatSession, liveSafePrefix, typewriterResumeIndex, mayKeepStreamedAnswer,
|
|
174
|
+
streamRecoveryPhase, streamRecoveryLabels,
|
|
175
|
+
type StreamDispatchContext,
|
|
176
|
+
} from './session';
|
|
118
177
|
export type { ChatHost, ChatIdentity, ChatState, ChatMessage, IndexingFileRef, PinnedDispatchContext } from './host';
|
|
119
178
|
|
|
120
179
|
// Display transform: collapse a file's many background-indexing turns into one
|
|
@@ -133,6 +192,7 @@ export {
|
|
|
133
192
|
export {
|
|
134
193
|
// constants
|
|
135
194
|
POLL_INTERVAL,
|
|
195
|
+
STREAM_POLL_INTERVAL,
|
|
136
196
|
MAX_CONCURRENT_BG_POLLS,
|
|
137
197
|
getVisionProfile,
|
|
138
198
|
type VisionProfile,
|
|
@@ -147,6 +207,11 @@ export {
|
|
|
147
207
|
MCP_NAME,
|
|
148
208
|
DEFAULT_CLAUDE_MODEL,
|
|
149
209
|
DEFAULT_OPENAI_MODEL,
|
|
210
|
+
// The ONE producer of the two `stream` flags a streamed chat turn needs. Exported
|
|
211
|
+
// so a test can assert the pair moves together, which is the whole point of it
|
|
212
|
+
// being a single function.
|
|
213
|
+
chatStreamWiring,
|
|
214
|
+
type ChatStreamWiring,
|
|
150
215
|
// request builders + dispatch
|
|
151
216
|
callClaudeWithMcp,
|
|
152
217
|
callClaudeWithPublicMcp,
|
|
@@ -179,3 +244,44 @@ export {
|
|
|
179
244
|
type AttachmentSaveInfo,
|
|
180
245
|
type BgTaskEntry,
|
|
181
246
|
} from './requests';
|
|
247
|
+
|
|
248
|
+
// The project's BunnyQuery settings, stored as a public record in the customer's
|
|
249
|
+
// own project ("bq::settings" in "__SETTINGS__") rather than on the skapi service
|
|
250
|
+
// record. Both clients prime it when a chat page opens and await it before the
|
|
251
|
+
// first upload writes an access group onto a record. See project_settings.ts.
|
|
252
|
+
export {
|
|
253
|
+
// where the record lives
|
|
254
|
+
PROJECT_SETTINGS_TABLE,
|
|
255
|
+
PROJECT_SETTINGS_UNIQUE_ID,
|
|
256
|
+
PROJECT_SETTINGS_ACCESS_GROUP,
|
|
257
|
+
// the choice, its labels and its default
|
|
258
|
+
UPLOAD_ACCESS_GROUPS,
|
|
259
|
+
UPLOAD_ACCESS_LABELS,
|
|
260
|
+
UPLOAD_ACCESS_HINTS,
|
|
261
|
+
UPLOAD_ACCESS_OPTIONS,
|
|
262
|
+
DEFAULT_UPLOAD_ACCESS_GROUP,
|
|
263
|
+
// pure readers over a settings `data` object
|
|
264
|
+
normalizeUploadAccessGroup,
|
|
265
|
+
normalizeProjectAccessSetting,
|
|
266
|
+
accessSettingFrom,
|
|
267
|
+
uploadAccessGroupFrom,
|
|
268
|
+
asksUploadAccessFrom,
|
|
269
|
+
// the per-project store
|
|
270
|
+
configureProjectSettings,
|
|
271
|
+
loadProjectSettings,
|
|
272
|
+
primeProjectSettings,
|
|
273
|
+
readyProjectSettings,
|
|
274
|
+
cachedProjectSettings,
|
|
275
|
+
projectSettingsSettled,
|
|
276
|
+
projectAccessSetting,
|
|
277
|
+
projectUploadAccessGroup,
|
|
278
|
+
projectAsksUploadAccess,
|
|
279
|
+
setProjectSettings,
|
|
280
|
+
patchProjectSettings,
|
|
281
|
+
clearProjectSettings,
|
|
282
|
+
// types
|
|
283
|
+
type UploadAccessGroup,
|
|
284
|
+
type ProjectAccessSetting,
|
|
285
|
+
type ProjectSettingsData,
|
|
286
|
+
type ProjectSettingsReader,
|
|
287
|
+
} from './project_settings';
|
|
@@ -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
|
+
}
|
|
@@ -37,8 +37,10 @@ export type ChatSystemPromptParams = {
|
|
|
37
37
|
*/
|
|
38
38
|
client?: 'console' | 'widget';
|
|
39
39
|
/**
|
|
40
|
-
* The access group THIS project's indexer writes its records at, from
|
|
41
|
-
* project's `
|
|
40
|
+
* The access group THIS project's indexer writes its records at, read from
|
|
41
|
+
* the project's BunnyQuery settings record (`bq::settings`, key
|
|
42
|
+
* `upload_access_group`). It used to come from the service record's
|
|
43
|
+
* `default_access_group`, which no longer exists.
|
|
42
44
|
*
|
|
43
45
|
* The MCP auto-fills an index/tag query that names a table but no group with
|
|
44
46
|
* "authorized", which used to be right because every BunnyQuery record was
|
|
@@ -46,6 +48,16 @@ export type ChatSystemPromptParams = {
|
|
|
46
48
|
* visitor can read it) or "private", and on those projects the auto-fill
|
|
47
49
|
* silently searches a group the data is not in and answers "nothing found".
|
|
48
50
|
* Defaults to 'authorized', which is what an unset project still uses.
|
|
51
|
+
*
|
|
52
|
+
* A PLAIN table query needs the group just as much, and this is newer: the
|
|
53
|
+
* SDK no longer fills a group in for a table that arrives without one, so the
|
|
54
|
+
* SERVER resolves it, and it resolves it differently per caller. A master
|
|
55
|
+
* (the project's owner) is answered across every access group; a normal
|
|
56
|
+
* signed-in user is answered from access_group 0 alone. So an end user asking
|
|
57
|
+
* about a table indexed at "authorized" would silently search public only,
|
|
58
|
+
* and get "nothing found" over data that is right there. The prompt therefore
|
|
59
|
+
* asks for the group on EVERY query that names a table, not just index/tag
|
|
60
|
+
* ones.
|
|
49
61
|
*/
|
|
50
62
|
indexAccessGroup?: string;
|
|
51
63
|
};
|
|
@@ -64,7 +76,7 @@ export function buildChatSystemPrompt(params: ChatSystemPromptParams): string {
|
|
|
64
76
|
You are a dedicated assistant for the project ID: "${projectId}".
|
|
65
77
|
Scope: Only answer questions about this project and its data. Do not answer questions about other projects or topics unrelated to this project. When the user refers to "my database", "my data", or "my files", treat those as references to this project's database and file storage. The ONE exception is BunnyQuery itself - what this app is, what it can do, and how to use it - which is always in scope: answer it from the "About BunnyQuery" section at the end of this prompt.
|
|
66
78
|
Knowledge lookup: Before saying you don't know or that something isn't in the chat history, ALWAYS query this project's database through the available MCP tools to look for the answer. The user's data is the source of truth - the chat transcript is not. Only respond with "I don't know" or "I couldn't find that" after you have actually searched the project's data and come back empty.
|
|
67
|
-
Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records. ONE file is routinely SPLIT ACROSS SEVERAL TABLES - a summary row in one table, its page or row content in another, its extracted photos and other media in "__MEDIA__", and the indexer often invents a differently-named table on each pass. An index or tag filter matches inside ONE table only and requires table_name: on getRecords, an index or tag sent with table_name but no access_group is auto-filled with access_group "authorized", but THIS project indexes at access_group ${indexGroupLiteral}, so pass access_group ${indexGroupLiteral} EXPLICITLY on
|
|
79
|
+
Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records. ONE file is routinely SPLIT ACROSS SEVERAL TABLES - a summary row in one table, its page or row content in another, its extracted photos and other media in "__MEDIA__", and the indexer often invents a differently-named table on each pass. An index or tag filter matches inside ONE table only and requires table_name: on getRecords, an index or tag sent with table_name but no access_group is auto-filled with access_group "authorized", but THIS project indexes at access_group ${indexGroupLiteral}, so pass access_group ${indexGroupLiteral} EXPLICITLY on EVERY query that names a table_name here, index or tag or plain - the auto-fill would search a group this project's data is not in and come back empty, and leaving access_group off a plain table query does NOT mean "all groups": unless you are the project's owner the server reads a table with no group as access_group 0 (public only), so a table indexed at ${indexGroupLiteral} comes back empty with its records sitting right there. Files uploaded before the project's setting changed may sit at another group, so when a scoped query comes back empty, retry it across the other groups (0, 1, "private") before concluding there is nothing, while an index or tag WITHOUT table_name FAILS with an error instead of answering, so read the error rather than guessing. Reference is the exception: reference ALONE spans EVERY table and EVERY access group, so getRecords with reference "src::<the file's storage path>" is the one call that returns a whole file's records wherever the indexer put them. Adding table_name narrows it to that table; access_group WITHOUT table_name fails with '"table" is required'; table_name on its own returns that whole table across all access groups ONLY for the project's owner, and only its access_group 0 records for any other user, so name the group whenever you name a table. For anything NOT scoped to a single file, call getTables FIRST, run the query once per table that could hold the answer, and combine the results. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "없어?", "하나도 없나?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset. If you already answered from one table and then realise another table holds more, do not simply apologise: re-run the sweep and give the complete answer.
|
|
68
80
|
Never assert absence from a partial read. Do not say "there is no X", "none", "not found", or "아니요, 없습니다" until a complete scan has come back empty. If you have not finished scanning every relevant table and file, keep querying instead of guessing. A confident "no" that later turns out wrong is worse than telling the user you are still checking.
|
|
69
81
|
Embedded values: a search term is often stored inside a larger string. A merchant "BAKSA" appears as "DNH*BAKSA#4070277042", and a card as "5860****5173". Server-side index filters match only exact values, leading prefixes, or trailing suffixes, and tag filters only EXACT whole-tag values - never a partial or interior substring - so filtering on such a field silently drops rows. When the value you are looking for may be embedded, do not trust a narrow filter to be complete. Fetch the full set with fetch_all and match the substring yourself.
|
|
70
82
|
File attachments: When a user message contains an "Attached files:" section with markdown links, those links point to short-lived signed URLs in this project's db storage and will expire.
|