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/requests.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { buildIndexingSystemPrompt, buildIndexingUserMessage, buildIndexingContinueMessage, buildIndexingRenderMessage, buildIndexingRenderContinueTemplate, buildIndexingWindowMessage } from './prompts';
|
|
14
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';
|
|
15
|
+
import { chatEngineConfig, pollOpt, windowedIndexingEnabled, liveStreamingEnabled, liveStreamingRealtimeEnabled } from './config';
|
|
16
16
|
// Output sizing lives in budget.ts so the request cap and the reserve the input
|
|
17
17
|
// budget subtracts cannot drift; getMaxOutputTokens also clamps per model.
|
|
18
18
|
import { getMaxOutputTokens } from './budget';
|
|
@@ -72,6 +72,85 @@ function mcpEndpointFor(
|
|
|
72
72
|
}
|
|
73
73
|
const clientSecretRequest = (opts: any) => chatEngineConfig().clientSecretRequest(opts);
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* THE two `stream` flags of a streamed chat turn, produced together or not at all.
|
|
77
|
+
*
|
|
78
|
+
* There are two of them and they are NOT the same flag:
|
|
79
|
+
*
|
|
80
|
+
* * `transport.stream` is SKAPI's. It tells the polling worker to read the
|
|
81
|
+
* destination's response incrementally and append the raw bytes to the chunk
|
|
82
|
+
* table, and it is never sent on to the destination.
|
|
83
|
+
* * `body.stream` is the DESTINATION's own field, and BunnyQuery is the party
|
|
84
|
+
* that may set it: skapi relays bytes and knows no vendor, so it cannot know
|
|
85
|
+
* that Anthropic Messages and OpenAI Responses both happen to spell it
|
|
86
|
+
* `stream` at the top level of the body.
|
|
87
|
+
*
|
|
88
|
+
* Setting one without the other fails QUIETLY, which is why they are produced by
|
|
89
|
+
* one function from one boolean and returned as one object:
|
|
90
|
+
*
|
|
91
|
+
* * body streams, skapi buffers -> the row stores an SSE TRANSCRIPT where
|
|
92
|
+
* extractClaudeText / extractOpenAIText expect a parsed document, so the turn
|
|
93
|
+
* reads back as an empty answer with nothing in the logs to say why.
|
|
94
|
+
* * skapi streams, the body never asked -> the destination sends one plain
|
|
95
|
+
* document, the relay chops it into chunks, the frame parser finds no framing
|
|
96
|
+
* at all, and the row settles with a status and no body.
|
|
97
|
+
*
|
|
98
|
+
* Two frozen constants rather than a fresh object per call: the pair is a
|
|
99
|
+
* CONSTANT, and an object literal built at each call site is exactly the shape
|
|
100
|
+
* that drifts when someone edits one arm.
|
|
101
|
+
*/
|
|
102
|
+
export type ChatStreamWiring = {
|
|
103
|
+
/** Spread into the clientSecretRequest OPTIONS (skapi's relay switch). `realtime`
|
|
104
|
+
* belongs here and never in `body`: it is skapi's, not the destination's. */
|
|
105
|
+
transport: { stream?: true; realtime?: true };
|
|
106
|
+
/** Spread into `data` (the destination's own switch). */
|
|
107
|
+
body: { stream?: true };
|
|
108
|
+
};
|
|
109
|
+
const CHAT_STREAM_ON: ChatStreamWiring = Object.freeze({
|
|
110
|
+
transport: Object.freeze({ stream: true as true }),
|
|
111
|
+
body: Object.freeze({ stream: true as true }),
|
|
112
|
+
}) as ChatStreamWiring;
|
|
113
|
+
const CHAT_STREAM_OFF: ChatStreamWiring = Object.freeze({
|
|
114
|
+
transport: Object.freeze({}),
|
|
115
|
+
body: Object.freeze({}),
|
|
116
|
+
}) as ChatStreamWiring;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Both stream flags for a CHAT turn, from the one `liveStreaming` opt-in.
|
|
120
|
+
*
|
|
121
|
+
* Chat turns only. Deliberately NOT called from:
|
|
122
|
+
* * the model LISTING calls (listClaudeModels / listOpenAIModels) - plain GETs
|
|
123
|
+
* with no body at all, and a listing has nothing to stream;
|
|
124
|
+
* * notifyAgentSaveAttachment (background INDEXING) - see the note there.
|
|
125
|
+
*
|
|
126
|
+
* `queue` is asked for because a chat turn sent WITH ATTACHMENTS runs on the
|
|
127
|
+
* background queue ("<userId>-bg") so it waits behind its own files, and that one
|
|
128
|
+
* must not stream either. Not for the indexing reasons above: the reason is
|
|
129
|
+
* RECOVERY. A streamed row keeps no body, so its only recovery after a reload is a
|
|
130
|
+
* poll re-attached with a reader, and the re-attach loop classifies everything on
|
|
131
|
+
* the bg queue as a background poll (flat cadence, no reader, that is what the
|
|
132
|
+
* MAX_CONCURRENT_BG_POLLS budget is for). Such a turn would therefore settle on an
|
|
133
|
+
* empty envelope and the answer would be gone. Losing the live rendering on the
|
|
134
|
+
* one kind of turn that already waited minutes for its files is the cheaper half of
|
|
135
|
+
* that trade.
|
|
136
|
+
*/
|
|
137
|
+
const CHAT_STREAM_ON_REALTIME: ChatStreamWiring = Object.freeze({
|
|
138
|
+
// `realtime` rides on the TRANSPORT arm only. It is a skapi option, not a field
|
|
139
|
+
// the destination understands, so it must never reach `data`: the body arm stays
|
|
140
|
+
// exactly what it is with the socket off.
|
|
141
|
+
transport: Object.freeze({ stream: true as true, realtime: true as true }),
|
|
142
|
+
body: Object.freeze({ stream: true as true }),
|
|
143
|
+
}) as ChatStreamWiring;
|
|
144
|
+
|
|
145
|
+
export function chatStreamWiring(queue?: string): ChatStreamWiring {
|
|
146
|
+
if (!liveStreamingEnabled()) return CHAT_STREAM_OFF;
|
|
147
|
+
if (isBgIndexingQueue(queue)) return CHAT_STREAM_OFF;
|
|
148
|
+
// Socket delivery is a separate opt-in from streaming itself: see
|
|
149
|
+
// liveStreamingRealtime for why a host that does not own its skapi instance
|
|
150
|
+
// should leave it off.
|
|
151
|
+
return liveStreamingRealtimeEnabled() ? CHAT_STREAM_ON_REALTIME : CHAT_STREAM_ON;
|
|
152
|
+
}
|
|
153
|
+
|
|
75
154
|
// Resolve the per-image `detail` for OpenAI. The version match tolerates a
|
|
76
155
|
// trailing variant/date suffix (`gpt-5.4-nano`, `-mini`, `-2026-01-01`, …):
|
|
77
156
|
// previously the pattern was anchored with no suffix allowed, so EVERY suffixed
|
|
@@ -455,6 +534,17 @@ export type CallClaudeWithMcpParams = {
|
|
|
455
534
|
// engine's own poll sites and imported by agent.vue; the widget carries its own
|
|
456
535
|
// copy in src/index.js that must be kept in step.
|
|
457
536
|
export const POLL_INTERVAL = 3000;
|
|
537
|
+
// Poll cadence while a turn is STREAMING, i.e. while the poll is also reading the
|
|
538
|
+
// relayed chunks. Faster than POLL_INTERVAL because on a streaming poll the tick is
|
|
539
|
+
// not just "is it done yet", it is the delivery of the answer: at 3s the reader
|
|
540
|
+
// watches the text arrive in three-second steps.
|
|
541
|
+
//
|
|
542
|
+
// 1s and not less, because the relay coalesces its writes to about one per second
|
|
543
|
+
// (the worker's byte budget, see the polling worker's _STREAM_FLUSH_INTERVAL_S), so
|
|
544
|
+
// a faster poll reads the same rows again and buys nothing but requests. It is also
|
|
545
|
+
// what makes "roughly one paint per second" fall out of the transport rather than
|
|
546
|
+
// out of a timer the paint path has to guess at.
|
|
547
|
+
export const STREAM_POLL_INTERVAL = 1000;
|
|
458
548
|
// Ceiling on how many BACKGROUND indexing polls may be attached at once, across
|
|
459
549
|
// every poll site (the engine's drain, the engine's history load, and each
|
|
460
550
|
// client's own fallback poller).
|
|
@@ -500,12 +590,20 @@ export async function callClaudeWithMcp({
|
|
|
500
590
|
mcpServerDefinition.authorization_token = mcpServer.authorizationToken;
|
|
501
591
|
}
|
|
502
592
|
|
|
593
|
+
// ONE decision, spread in TWO places. See chatStreamWiring: the transport half
|
|
594
|
+
// is skapi's relay switch and the body half is Anthropic's own, and a turn that
|
|
595
|
+
// carries one without the other fails silently rather than loudly. The queue is
|
|
596
|
+
// the same expression the request uses below, so an attachment turn (which runs
|
|
597
|
+
// on the bg queue) is recognised and left buffered.
|
|
598
|
+
const stream = chatStreamWiring(userId || service);
|
|
599
|
+
|
|
503
600
|
return clientSecretRequest({
|
|
504
601
|
clientSecretName: 'claude',
|
|
505
602
|
queue: userId || service,
|
|
506
603
|
service,
|
|
507
604
|
owner,
|
|
508
605
|
...pollOpt(),
|
|
606
|
+
...stream.transport,
|
|
509
607
|
url: ANTHROPIC_MESSAGES_API_URL,
|
|
510
608
|
method: 'POST',
|
|
511
609
|
headers: {
|
|
@@ -517,6 +615,9 @@ export async function callClaudeWithMcp({
|
|
|
517
615
|
data: {
|
|
518
616
|
model,
|
|
519
617
|
max_tokens: maxTokens,
|
|
618
|
+
// Top level beside model/messages/mcp_servers, which is where the
|
|
619
|
+
// Messages API takes it.
|
|
620
|
+
...stream.body,
|
|
520
621
|
...(extractContent && extractContent.length
|
|
521
622
|
? { _skapi_extract: extractContent }
|
|
522
623
|
: {}),
|
|
@@ -648,12 +749,16 @@ export async function callOpenAIWithPublicMcp(
|
|
|
648
749
|
})),
|
|
649
750
|
];
|
|
650
751
|
|
|
752
|
+
// ONE decision, spread in TWO places - see chatStreamWiring.
|
|
753
|
+
const stream = chatStreamWiring(userId || service);
|
|
754
|
+
|
|
651
755
|
return clientSecretRequest({
|
|
652
756
|
clientSecretName: 'openai',
|
|
653
757
|
queue: userId || service,
|
|
654
758
|
service,
|
|
655
759
|
owner,
|
|
656
760
|
...pollOpt(),
|
|
761
|
+
...stream.transport,
|
|
657
762
|
url: OPENAI_RESPONSES_API_URL,
|
|
658
763
|
method: 'POST',
|
|
659
764
|
headers: {
|
|
@@ -663,6 +768,9 @@ export async function callOpenAIWithPublicMcp(
|
|
|
663
768
|
data: {
|
|
664
769
|
model: resolvedModel,
|
|
665
770
|
max_output_tokens: getMaxOutputTokens('openai', resolvedModel),
|
|
771
|
+
// Top level beside model/input/tools, which is where the Responses API
|
|
772
|
+
// takes it.
|
|
773
|
+
...stream.body,
|
|
666
774
|
...(extractContent && extractContent.length
|
|
667
775
|
? { _skapi_extract: extractContent }
|
|
668
776
|
: {}),
|
|
@@ -945,6 +1053,22 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
|
|
|
945
1053
|
accessGroup: attachment.accessGroup,
|
|
946
1054
|
});
|
|
947
1055
|
|
|
1056
|
+
// INDEXING NEVER STREAMS, on either platform, whatever `liveStreaming` says.
|
|
1057
|
+
// chatStreamWiring is deliberately not called below, and neither `stream` flag
|
|
1058
|
+
// appears in either branch. Three reasons, any one of which is sufficient:
|
|
1059
|
+
//
|
|
1060
|
+
// 1. Nobody is watching. A pass runs on the background queue behind a chain
|
|
1061
|
+
// that can span days; there is no bubble to paint it into, so the only
|
|
1062
|
+
// thing streaming would buy is a chunk table to clean up.
|
|
1063
|
+
// 2. The worker has to READ this response. `auto_continue` (render and window
|
|
1064
|
+
// passes) decides whether to enqueue the next window from the reply, and
|
|
1065
|
+
// the truncation and auth-outage checks that stop a chain read it too.
|
|
1066
|
+
// Those need a real JSON body, and a streamed row settles with a status and
|
|
1067
|
+
// no body at all.
|
|
1068
|
+
// 3. The worker degrades such a pass anyway (it clears `strm` before the call
|
|
1069
|
+
// fires), so asking would be a request the backend is guaranteed to refuse
|
|
1070
|
+
// to honour - and one that leaves the row briefly marked as streaming,
|
|
1071
|
+
// which is exactly the state csr-finalize gates on.
|
|
948
1072
|
if (platform === 'openai') {
|
|
949
1073
|
const resolvedModel = info.model || DEFAULT_OPENAI_MODEL;
|
|
950
1074
|
const imageDetail = getOpenAIImageDetail(resolvedModel);
|
|
@@ -1112,6 +1236,11 @@ export function extractOpenAIText(response: any) {
|
|
|
1112
1236
|
return '';
|
|
1113
1237
|
}
|
|
1114
1238
|
|
|
1239
|
+
// MODEL LISTINGS NEVER STREAM. Both are plain GETs with no body: there is no
|
|
1240
|
+
// destination-side `stream` field to pair skapi's with, and a listing is one small
|
|
1241
|
+
// document that a caller reads whole. Neither carries `poll` either, so there is
|
|
1242
|
+
// not even a reader to hand chunks to. chatStreamWiring is deliberately not called
|
|
1243
|
+
// here - the pair is chat-turn-only.
|
|
1115
1244
|
export async function listClaudeModels(service: string, owner: string) {
|
|
1116
1245
|
return clientSecretRequest({
|
|
1117
1246
|
clientSecretName: 'claude',
|