bunnyquery 1.6.2 → 1.8.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 +157 -29
- package/bunnyquery.css +134 -0
- package/bunnyquery.js +1057 -120
- package/dist/engine.cjs +784 -42
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +455 -4
- package/dist/engine.d.ts +455 -4
- package/dist/engine.mjs +769 -43
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/history.ts +30 -2
- package/src/engine/host.ts +34 -1
- package/src/engine/index.ts +25 -1
- package/src/engine/indexing_groups.ts +389 -0
- package/src/engine/links.ts +343 -9
- package/src/engine/prompts/chat_system_prompt.ts +4 -1
- package/src/engine/prompts/indexing_user_message.ts +4 -0
- package/src/engine/requests.ts +1 -1
- package/src/engine/session.ts +412 -33
- package/src/engine/time.ts +34 -0
- package/src/engine/viewport_fill.ts +186 -0
- package/styles/chat.css +134 -0
package/src/engine/links.ts
CHANGED
|
@@ -10,8 +10,45 @@ export var EXPIRED_ATTACHMENT_URL_HOST = '_expired_.url';
|
|
|
10
10
|
export var EXPIRED_ATTACHMENT_URL_ORIGIN = 'https://' + EXPIRED_ATTACHMENT_URL_HOST;
|
|
11
11
|
export var LINK_LABEL_MAX_DISPLAY_CHARS = 32;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Lifetime of the url minted when a user clicks an expired attachment chip.
|
|
15
|
+
*
|
|
16
|
+
* Mint it as a PLAIN get-db presign, never with generate_temporary_cdn_url: the
|
|
17
|
+
* cdn branch ignores `expires` entirely and hands back a url good for the rest of
|
|
18
|
+
* the current UTC day plus the next one, so a "20 minute" link would in fact live
|
|
19
|
+
* 24 to 48 hours. The dashboard has always done this correctly and the widget did
|
|
20
|
+
* not, which is precisely the kind of divergence a shared constant exists to stop.
|
|
21
|
+
*/
|
|
22
|
+
export var EXPIRED_LINK_REFRESH_EXPIRES_SECONDS = 20 * 60;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How long a client may keep serving an href it already minted before dropping
|
|
26
|
+
* back to the placeholder and re-minting.
|
|
27
|
+
*
|
|
28
|
+
* DERIVED from the TTL above, with five minutes of headroom, because the
|
|
29
|
+
* invariant "the cache must expire before the url does" used to be a comment
|
|
30
|
+
* next to two independent literals. If it is ever violated a client serves a
|
|
31
|
+
* dead url with no way to notice; deriving it makes that unrepresentable.
|
|
32
|
+
*/
|
|
33
|
+
export var LINK_REFRESH_WINDOW_MS = (EXPIRED_LINK_REFRESH_EXPIRES_SECONDS - 5 * 60) * 1000;
|
|
34
|
+
|
|
35
|
+
// The two "balanced parens" groups match ONE CHARACTER per step, never a `+`
|
|
36
|
+
// run, so each position has exactly one way to be matched: `[^()\n]` cannot
|
|
37
|
+
// start with `(`, and the nested-paren alternative always does. That disjointness
|
|
38
|
+
// is load-bearing, not style.
|
|
39
|
+
//
|
|
40
|
+
// The obvious spelling — `(?:[^()\n]+|\([^()\n]*\))+` — is a nested quantifier:
|
|
41
|
+
// a run of N plain characters can be split across the outer `+` in 2^N ways, and
|
|
42
|
+
// the engine tries EVERY one before it can conclude the branch failed. A branch
|
|
43
|
+
// fails on ordinary input: a link whose url contains a space (the url branch
|
|
44
|
+
// forbids spaces), a link broken across a newline, or a reply truncated
|
|
45
|
+
// mid-link. Measured on the unfixed pattern: `[label](` plus 30 characters with
|
|
46
|
+
// no closing paren took 62 SECONDS, 45 characters never finished. Since this
|
|
47
|
+
// regex is scanned over the whole reply by the typewriter (session.ts) and by
|
|
48
|
+
// every message render (parseMsgParts), that is a permanently frozen tab the
|
|
49
|
+
// moment such a message arrives. Same matches either way, linear time.
|
|
13
50
|
export function createInlineLinkRegex(): RegExp {
|
|
14
|
-
return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]
|
|
51
|
+
return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
|
|
15
52
|
}
|
|
16
53
|
|
|
17
54
|
export function safeDecodeURIComponent(v: string): string {
|
|
@@ -70,25 +107,322 @@ export function isServiceDbAttachmentHref(href: string, serviceId: string): bool
|
|
|
70
107
|
} catch (e) { return false; }
|
|
71
108
|
}
|
|
72
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Read the storage path back out of an `_expired_.url` placeholder.
|
|
112
|
+
*
|
|
113
|
+
* The placeholder is not a display detail: sanitizeAttachmentLinksForHistory
|
|
114
|
+
* writes it into PERSISTED history, and buildBoundedChatMessages replays it into
|
|
115
|
+
* the model's context. So it round-trips constantly and MUST be recognised on the
|
|
116
|
+
* way back in. Returns null for anything that is not the carrier.
|
|
117
|
+
*/
|
|
118
|
+
export function readExpiredAttachmentHref(href: string): string | null {
|
|
119
|
+
if (!href) return null;
|
|
120
|
+
try {
|
|
121
|
+
var parsed = new URL(href);
|
|
122
|
+
if (parsed.hostname !== EXPIRED_ATTACHMENT_URL_HOST) return null;
|
|
123
|
+
return normalizeAttachmentPathCandidate(parsed.pathname || '') || null;
|
|
124
|
+
} catch (e) { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
73
127
|
// Replace volatile attachment URLs with their durable `_expired_.url/<path>`
|
|
74
128
|
// placeholder so a stored/replayed copy re-mints on demand instead of going stale.
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
129
|
+
//
|
|
130
|
+
// Only THIS service's db urls are rewritten, whichever role wrote them. The user
|
|
131
|
+
// branch used to rewrite every url in any message that carried an "Attached
|
|
132
|
+
// files:" block, which quietly destroyed a third-party link the user happened to
|
|
133
|
+
// paste in the same message: it became a placeholder for a storage path that
|
|
134
|
+
// never existed. We can only re-mint what we host, so we only rewrite what we
|
|
135
|
+
// host.
|
|
79
136
|
export function sanitizeAttachmentLinksForHistory(content: string, serviceId: string, forAssistant?: boolean): string {
|
|
80
137
|
if (!content) return content;
|
|
81
138
|
if (!forAssistant && content.indexOf('Attached files:') === -1) return content;
|
|
82
139
|
return content.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, function (_m: string, label: string, href: string) {
|
|
83
|
-
if (
|
|
140
|
+
if (!isServiceDbAttachmentHref(href, serviceId)) return _m;
|
|
84
141
|
var remotePath = extractRemotePathFromAttachmentHref(href, serviceId);
|
|
85
|
-
var
|
|
86
|
-
|
|
87
|
-
if (!fullPath) return forAssistant ? _m : '[' + label + '](' + EXPIRED_ATTACHMENT_URL_ORIGIN + '/file)';
|
|
142
|
+
var fullPath = remotePath || normalizeAttachmentPathCandidate(label);
|
|
143
|
+
if (!fullPath) return _m;
|
|
88
144
|
return '[' + label + '](' + buildDisplayExpiredAttachmentHref(fullPath, label) + ')';
|
|
89
145
|
});
|
|
90
146
|
}
|
|
91
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Is this markdown link target a URL rather than a db storage path?
|
|
150
|
+
*
|
|
151
|
+
* The inline-link regex decides that by whether the target contains whitespace:
|
|
152
|
+
* its url branch forbids it, its bare-path branch allows it (a db path really can
|
|
153
|
+
* contain spaces). So a url that picked up a stray space anywhere in transit
|
|
154
|
+
* falls out of the url branch and is claimed by the path branch, and the view
|
|
155
|
+
* renders it as an `_expired_.url/https%3A/…` attachment chip that resolves to
|
|
156
|
+
* nothing. The view asks this FIRST, so what a link IS never depends on damage.
|
|
157
|
+
*/
|
|
158
|
+
export function isHttpUrlLike(target: string): boolean {
|
|
159
|
+
return /^https?:\/\//i.test((target || '').trim());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Repair whitespace inside a url. RFC 3986 has no legal whitespace anywhere in a
|
|
164
|
+
* URI, so a space in an href is always damage, never content.
|
|
165
|
+
*
|
|
166
|
+
* Two repairs, because the right one differs:
|
|
167
|
+
* - Our own `/download/<id>` capability links (skapi-mcp file-download.js) are
|
|
168
|
+
* base64url, optionally with a single `.` separating the payload and hmac of
|
|
169
|
+
* the older self-describing token. That alphabet cannot contain whitespace,
|
|
170
|
+
* so the spaces are purely damage and REMOVING them restores the exact link,
|
|
171
|
+
* which is what makes an already-sent message clickable again. A model
|
|
172
|
+
* reproducing one of these into its reply is exactly where the spaces come
|
|
173
|
+
* from, which is also why the id is now short.
|
|
174
|
+
* - Anything else keeps every character and only has the whitespace encoded,
|
|
175
|
+
* the same thing a browser does with a space in an href. Stripping would be
|
|
176
|
+
* wrong there: `…/exports/my report.csv` is a real file whose name has a
|
|
177
|
+
* space in it, and deleting it points at a file that does not exist.
|
|
178
|
+
*/
|
|
179
|
+
export function repairUrlWhitespace(href: string): string {
|
|
180
|
+
if (!href || !/\s/.test(href)) return href;
|
|
181
|
+
var stripped = href.replace(/\s+/g, '');
|
|
182
|
+
if (/^https?:\/\/[^/\s]+\/download\/[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)?$/i.test(stripped)) return stripped;
|
|
183
|
+
return href.trim().replace(/\s/g, '%20');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A model reproducing a URL sometimes HTML-escapes its `&` query separators as
|
|
188
|
+
* `&` (or the numeric `&` / `&`). Left in the href that escaping
|
|
189
|
+
* survives the client's own escapeAttr -> v-html/innerHTML decode round-trip and
|
|
190
|
+
* reaches the browser LITERALLY, so a presigned S3 URL is navigated with its
|
|
191
|
+
* parameters named `amp;Signature`, `amp;Expires`, `amp;response-content-type`,
|
|
192
|
+
* ... — the real params vanish, the signature can't be located, and S3 rejects
|
|
193
|
+
* it (the "링크가 안되" dead export link). Undo just that entity escaping.
|
|
194
|
+
*
|
|
195
|
+
* This is a no-op on a clean URL: a valid link carries a raw `&` between params
|
|
196
|
+
* and percent-encodes (`%26`) any literal ampersand that is data, so a real URL
|
|
197
|
+
* never contains `&` to begin with. Mirrors repairUrlWhitespace: it repairs
|
|
198
|
+
* model damage, not the URL. The loop collapses a doubly-escaped `&amp;` too.
|
|
199
|
+
*/
|
|
200
|
+
export function repairUrlEntities(href: string): string {
|
|
201
|
+
if (!href || href.indexOf('&') === -1) return href;
|
|
202
|
+
var out = href, prev = '';
|
|
203
|
+
while (out !== prev) {
|
|
204
|
+
prev = out;
|
|
205
|
+
out = out
|
|
206
|
+
.replace(/&/gi, '&')
|
|
207
|
+
.replace(/�*38;/g, '&')
|
|
208
|
+
.replace(/�*26;/gi, '&');
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Trim punctuation and unmatched wrappers that cling to a token in prose.
|
|
215
|
+
* `src::a/b.pdf).` -> `src::a/b.pdf`, while a balanced `file (v2).pdf` is kept.
|
|
216
|
+
*/
|
|
217
|
+
export function normalizeTrailingInlineToken(value: string): string {
|
|
218
|
+
if (!value) return value;
|
|
219
|
+
var out = value.replace(/[.,;:!?]+$/, '');
|
|
220
|
+
var trimUnmatched = function (openCh: string, closeCh: string) {
|
|
221
|
+
while (out.charAt(out.length - 1) === closeCh) {
|
|
222
|
+
var openCount = (out.match(new RegExp('\\' + openCh, 'g')) || []).length;
|
|
223
|
+
var closeCount = (out.match(new RegExp('\\' + closeCh, 'g')) || []).length;
|
|
224
|
+
if (closeCount > openCount) out = out.slice(0, -1); else break;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
trimUnmatched('(', ')');
|
|
228
|
+
trimUnmatched('[', ']');
|
|
229
|
+
trimUnmatched('{', '}');
|
|
230
|
+
out = out.replace(/[`'"*>]+$/, '');
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A link the view renders. `expired` means the href is the `_expired_.url`
|
|
235
|
+
* placeholder and a click must mint a fresh one from `remotePath`. */
|
|
236
|
+
export interface InlineLinkPart {
|
|
237
|
+
type: 'link';
|
|
238
|
+
label: string;
|
|
239
|
+
fullLabel: string;
|
|
240
|
+
href: string;
|
|
241
|
+
expired: boolean;
|
|
242
|
+
expiredHref?: string;
|
|
243
|
+
remotePath?: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface InlineLinkContext {
|
|
247
|
+
/** Current project id: the leading segment to strip off a db url. */
|
|
248
|
+
serviceId: string;
|
|
249
|
+
/** `https://db.<hostDomain>` for this deployment. */
|
|
250
|
+
dbHostPrefix: string;
|
|
251
|
+
/** A fresh url already minted for this placeholder, if the view cached one. */
|
|
252
|
+
resolveFreshHref?: (expiredHref: string) => string | undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Decide what ONE inline-link regex match actually is, and how to render it.
|
|
257
|
+
*
|
|
258
|
+
* This is the single place that answers "is this an external url, this project's
|
|
259
|
+
* db file, or a bare storage path", for every consumer. It used to live twice,
|
|
260
|
+
* once in agent.vue and once in the widget, and both copies had to be found and
|
|
261
|
+
* corrected for each of the link bugs this file's history records. A view now
|
|
262
|
+
* supplies its own context (project id, db host, cached-href lookup) and does
|
|
263
|
+
* nothing but turn the returned part into markup.
|
|
264
|
+
*
|
|
265
|
+
* `groups` is [g1..g6] from createInlineLinkRegex, in that order:
|
|
266
|
+
* g1 src::<token> g2/g3 [label](url) g4/g5 [label](path) g6 bare url
|
|
267
|
+
*/
|
|
268
|
+
export function classifyInlineLink(
|
|
269
|
+
full: string,
|
|
270
|
+
groups: Array<string | undefined>,
|
|
271
|
+
ctx: InlineLinkContext,
|
|
272
|
+
): { part: InlineLinkPart; tail?: string } | null {
|
|
273
|
+
var g1 = groups[0], g2 = groups[1], g3 = groups[2], g4 = groups[3], g5 = groups[4], g6 = groups[5];
|
|
274
|
+
var dbHostPrefix = (ctx.dbHostPrefix || '').toLowerCase();
|
|
275
|
+
var fresh = function (expiredHref: string): string | undefined {
|
|
276
|
+
return ctx.resolveFreshHref ? ctx.resolveFreshHref(expiredHref) : undefined;
|
|
277
|
+
};
|
|
278
|
+
var isDbHost = function (url: string): boolean {
|
|
279
|
+
return !!dbHostPrefix && url.toLowerCase().indexOf(dbHostPrefix) === 0;
|
|
280
|
+
};
|
|
281
|
+
// A db path rendered as the placeholder the click handler resolves.
|
|
282
|
+
var asStoredFile = function (remotePath: string, label: string): { part: InlineLinkPart } | null {
|
|
283
|
+
if (!remotePath) return null;
|
|
284
|
+
var expiredHref = buildDisplayExpiredAttachmentHref(remotePath, label);
|
|
285
|
+
var cached = fresh(expiredHref);
|
|
286
|
+
return {
|
|
287
|
+
part: {
|
|
288
|
+
type: 'link',
|
|
289
|
+
label: truncateLabelForDisplay(label),
|
|
290
|
+
fullLabel: label,
|
|
291
|
+
href: cached || expiredHref,
|
|
292
|
+
expired: !cached,
|
|
293
|
+
expiredHref: expiredHref,
|
|
294
|
+
remotePath: remotePath,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// src::<token> — a path, or a url the model copied out of a record.
|
|
300
|
+
if (g1) {
|
|
301
|
+
var rawPath = normalizeTrailingInlineToken(g1);
|
|
302
|
+
var tail = full.slice(('src::' + rawPath).length);
|
|
303
|
+
var srcIsUrl = isHttpUrlLike(rawPath);
|
|
304
|
+
// `src::` values come straight out of a record's unique_id, and the prompt
|
|
305
|
+
// says that may be "the file's storage path or original URL". http:// is as
|
|
306
|
+
// much a url as https://; testing only for https sent every plain-http
|
|
307
|
+
// source into the storage-path branch, where it became a chip pointing at
|
|
308
|
+
// this project for someone else's file.
|
|
309
|
+
if (srcIsUrl && !isDbHost(rawPath) && !readExpiredAttachmentHref(rawPath)) {
|
|
310
|
+
// decode any model-introduced `&` in the URL (tail stays keyed on
|
|
311
|
+
// the raw match length, so it is left untouched)
|
|
312
|
+
var srcUrl = repairUrlEntities(rawPath);
|
|
313
|
+
return {
|
|
314
|
+
part: { type: 'link', label: truncateLabelForDisplay(srcUrl), fullLabel: srcUrl, href: srcUrl, expired: false },
|
|
315
|
+
tail: tail,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
var srcPath = readExpiredAttachmentHref(rawPath)
|
|
319
|
+
|| (srcIsUrl
|
|
320
|
+
? (extractRemotePathFromAttachmentHref(rawPath, ctx.serviceId) || normalizeAttachmentPathCandidate(rawPath))
|
|
321
|
+
: normalizeAttachmentPathCandidate(rawPath));
|
|
322
|
+
var srcBuilt = asStoredFile(srcPath, srcPath);
|
|
323
|
+
return srcBuilt ? { part: srcBuilt.part, tail: tail } : null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// [label](target) where target is NOT a url by the regex's reckoning.
|
|
327
|
+
if (g4 && g5) {
|
|
328
|
+
// An EXPLICIT db target, `[label](db:folder/file.csv)`, says what it is
|
|
329
|
+
// instead of leaving us to infer it from the absence of "http". That is the
|
|
330
|
+
// only form here that cannot be confused with anything else, and it matches
|
|
331
|
+
// the scheme the backend already uses internally (db:<service>/<key>).
|
|
332
|
+
// Accepted now so the clients tolerate it everywhere before anything starts
|
|
333
|
+
// EMITTING it; until then this branch simply never fires.
|
|
334
|
+
var dbTarget = /^db:(.+)$/i.exec(g5.trim());
|
|
335
|
+
if (dbTarget) {
|
|
336
|
+
var declared = asStoredFile(normalizeAttachmentPathCandidate(dbTarget[1]), g4);
|
|
337
|
+
if (!declared) return null;
|
|
338
|
+
declared.part.label = truncateLabelForDisplay(g4);
|
|
339
|
+
declared.part.fullLabel = g4;
|
|
340
|
+
return declared;
|
|
341
|
+
}
|
|
342
|
+
// ...except when it is one. It only lands here because it contains
|
|
343
|
+
// whitespace, which the url branch forbids and this one allows, and
|
|
344
|
+
// reading a damaged url as a storage path is how a download link became a
|
|
345
|
+
// dead chip. Repair it and classify it as what it is.
|
|
346
|
+
if (isHttpUrlLike(g5)) {
|
|
347
|
+
return classifyInlineLink(full, [undefined, g4, repairUrlWhitespace(g5), undefined, undefined, undefined], ctx);
|
|
348
|
+
}
|
|
349
|
+
// Any OTHER scheme, or a fragment, is a link the user wrote and not a file
|
|
350
|
+
// we host. `mailto:`, `tel:` and `#section` were all being turned into
|
|
351
|
+
// download chips for storage paths of that literal text, so clicking one
|
|
352
|
+
// asked this project for a file called "mailto:a@b.com".
|
|
353
|
+
var trimmedTarget = g5.trim();
|
|
354
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmedTarget) || trimmedTarget.charAt(0) === '#') {
|
|
355
|
+
return {
|
|
356
|
+
part: { type: 'link', label: truncateLabelForDisplay(g4), fullLabel: g4, href: trimmedTarget, expired: false },
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
var built = asStoredFile(normalizeAttachmentPathCandidate(g5), g4);
|
|
360
|
+
if (!built) return null;
|
|
361
|
+
// The label is the model's, not the path: keep it verbatim.
|
|
362
|
+
built.part.label = truncateLabelForDisplay(g4);
|
|
363
|
+
built.part.fullLabel = g4;
|
|
364
|
+
return built;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// [label](url) and bare urls.
|
|
368
|
+
var originalHref = g3 || g6 || '';
|
|
369
|
+
if (!originalHref) return null;
|
|
370
|
+
// A model that reproduces the URL may have HTML-escaped its `&` separators;
|
|
371
|
+
// decode them now so every downstream check and the final href see a clean
|
|
372
|
+
// URL (otherwise a presigned link navigates with `&` and 403s).
|
|
373
|
+
originalHref = repairUrlEntities(originalHref);
|
|
374
|
+
// A bare url swallows the punctuation that ends the sentence it sits in, so
|
|
375
|
+
// `see https://host/a.pdf.` linked to `a.pdf.` and 404'd. Trim it and hand the
|
|
376
|
+
// trimmed text back as `tail`, exactly as the src:: branch does.
|
|
377
|
+
var urlTail: string | undefined;
|
|
378
|
+
if (!g3 && g6) {
|
|
379
|
+
var trimmedUrl = normalizeTrailingInlineToken(originalHref);
|
|
380
|
+
if (trimmedUrl !== originalHref) urlTail = originalHref.slice(trimmedUrl.length);
|
|
381
|
+
originalHref = trimmedUrl;
|
|
382
|
+
}
|
|
383
|
+
var withTail = function (r: { part: InlineLinkPart }): { part: InlineLinkPart; tail?: string } {
|
|
384
|
+
return urlTail ? { part: r.part, tail: urlTail } : r;
|
|
385
|
+
};
|
|
386
|
+
var urlLabel = g2 || originalHref;
|
|
387
|
+
|
|
388
|
+
// THE PLACEHOLDER, read back. sanitizeAttachmentLinksForHistory writes this
|
|
389
|
+
// form into stored history and buildBoundedChatMessages replays it to the
|
|
390
|
+
// model, so it arrives here constantly: as a rebuilt bubble on every reload,
|
|
391
|
+
// and as text the model copied out of its own context. It has to be checked
|
|
392
|
+
// BEFORE the generic https branch, because it IS https and it is NOT the db
|
|
393
|
+
// host, so that branch claimed it and rendered `expired: false` — a link to a
|
|
394
|
+
// hostname that does not resolve, with no way to ever refresh it. Every stored
|
|
395
|
+
// attachment link went dead on reload for exactly that reason.
|
|
396
|
+
var carried = readExpiredAttachmentHref(originalHref);
|
|
397
|
+
if (carried) {
|
|
398
|
+
var carriedBuilt = asStoredFile(carried, g2 || carried);
|
|
399
|
+
if (carriedBuilt) {
|
|
400
|
+
if (g2) { carriedBuilt.part.label = truncateLabelForDisplay(g2); carriedBuilt.part.fullLabel = g2; }
|
|
401
|
+
return withTail(carriedBuilt);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// This project's own db url: volatile, so render it re-mintable. A db url for
|
|
406
|
+
// a DIFFERENT project is not ours to mint, so it stays an ordinary link rather
|
|
407
|
+
// than a chip that would query this project for someone else's key.
|
|
408
|
+
if (isServiceDbAttachmentHref(originalHref, ctx.serviceId)) {
|
|
409
|
+
var remotePath = extractRemotePathFromAttachmentHref(originalHref, ctx.serviceId);
|
|
410
|
+
if (remotePath) {
|
|
411
|
+
var dbBuilt = asStoredFile(remotePath, getExpiredAttachmentVisiblePath(remotePath, urlLabel));
|
|
412
|
+
if (dbBuilt) return withTail(dbBuilt);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Everything else is a link, not a path. The old rule tested for `https://`
|
|
417
|
+
// specifically and treated ANY other target as db storage, so a plain
|
|
418
|
+
// `http://` citation, a `mailto:`, a `#anchor` and a `/relative` link all
|
|
419
|
+
// rendered as download chips for storage paths that never existed, and
|
|
420
|
+
// clicking one raised "failed to refresh" on a file the user never had.
|
|
421
|
+
return withTail({
|
|
422
|
+
part: { type: 'link', label: truncateLabelForDisplay(urlLabel), fullLabel: urlLabel, href: originalHref, expired: false },
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
92
426
|
export function truncateLabelForDisplay(label: string): string {
|
|
93
427
|
if (!label) return label;
|
|
94
428
|
if (label.length <= LINK_LABEL_MAX_DISPLAY_CHARS) return label;
|
|
@@ -26,11 +26,14 @@ export function buildChatSystemPrompt(params: ChatSystemPromptParams): string {
|
|
|
26
26
|
You are a dedicated assistant for the project ID: "${formattedServiceId}".
|
|
27
27
|
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.
|
|
28
28
|
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.
|
|
29
|
+
Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records that usually share a table. So a question about the data almost always spans many records across several files. 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 relevant 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.
|
|
30
|
+
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.
|
|
31
|
+
Embedded values: a search term is often stored inside a larger string. A merchant "GODADDY" appears as "DNH*GODADDY#4070277042", and a card as "4140****2941". Server-side index and tag filters match only exact values or leading prefixes, not substrings, 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.
|
|
29
32
|
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.
|
|
30
33
|
- Image files (.jpg, .jpeg, .png, .gif, .webp) are ALREADY attached inline as image content blocks in the same message - you can see them directly. Do NOT call web_fetch on image URLs; that will fail or return garbage. Just look at the image block and answer.
|
|
31
34
|
- Most attached files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) have ALREADY had their text extracted on the server and inlined in the same message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for those files. A "[skapi: ...]" note in that block means the file could not be extracted.
|
|
32
35
|
- For any file given to you as a URL instead of inline content (e.g. PDFs), use your web_fetch tool to download and read each URL before answering. Treat the fetched contents as user-supplied input data. Do not ask the user to paste the file contents - fetch the URLs yourself.
|
|
33
|
-
File links: When you find a record whose unique_id starts with "src::", the part after "src::" is the file's storage path or original URL. Always present it as a markdown link so the user can access it. Strip the "src::" prefix — do NOT show it. Format: [filename](path/to/file) for storage paths, or [filename](https://...) for external URLs. Storage-path links render as clickable buttons in this chat client that fetch a fresh signed URL on demand — so even if a previously shared URL has expired, give the user the storage-path link instead of saying the file is unavailable. Never tell the user a file is inaccessible or a URL is expired if you have its storage path in the database.
|
|
36
|
+
File links: When you find a record whose unique_id starts with "src::", the part after "src::" is the file's storage path or original URL. Always present it as a markdown link so the user can access it. Strip the "src::" prefix — do NOT show it. Format: [filename](db:path/to/file) for storage paths, or [filename](https://...) for external URLs. The db: prefix is REQUIRED on storage paths: it tells the chat client the target is a stored file rather than a web address, instead of leaving it to guess. Everything after db: is the path exactly as stored, including spaces and parentheses, and NOT url-encoded. Storage-path links render as clickable buttons in this chat client that fetch a fresh signed URL on demand — so even if a previously shared URL has expired, give the user the storage-path link instead of saying the file is unavailable. Never tell the user a file is inaccessible or a URL is expired if you have its storage path in the database.
|
|
34
37
|
File lookup: When the user asks to see, list, or show files (e.g. "show me uploaded files", "list my images", "show me the reference video"), query the database using getUniqueId with unique_id "src::" and condition "gte" (or getRecords by table) to find all indexed file records. Present each result as a markdown link as described above. Never say you cannot access file storage — the file paths are indexed in the database and are always reachable through it.
|
|
35
38
|
File generation: When the user asks you to generate a file — or to produce specifically-formatted text such as HTML, CSV, JSON, or Markdown — put the file's full contents inside a fenced code block whose info string is the intended filename WITH its extension (e.g. report.csv), NOT a language name like "csv". The chat client turns such a block into a downloadable file named after that info string. Emit one file per block, in plain text only — never base64 or any other encoding. Example for CSV:
|
|
36
39
|
\`\`\`filename.csv
|
|
@@ -229,6 +229,10 @@ export function buildIndexingWindowMessage(
|
|
|
229
229
|
`for tabular data (keyed by the column headers), or one record per section for prose. Capture every ` +
|
|
230
230
|
`value you can read. Use the storage path above for the "src::" unique_id on the file-level record, ` +
|
|
231
231
|
`and link every row/section record to it by reference.\n\n` +
|
|
232
|
+
`If this window has PHOTOS attached as images, LOOK at each one and datafy what it actually shows ` +
|
|
233
|
+
`into the record for the row it is anchored to (a «PHOTO A88» marker in the grid text only says WHERE ` +
|
|
234
|
+
`a picture sits - the picture itself is attached to this message). Never report that photo contents ` +
|
|
235
|
+
`could not be extracted when images are attached here.\n\n` +
|
|
232
236
|
`Save records for THIS window only, then stop and report what you saved. Do NOT try to read the rest ` +
|
|
233
237
|
`of the file, and do NOT call readFileContent - if more remains, the next window is read and sent to ` +
|
|
234
238
|
`you automatically. Report only what you were actually shown, and never imply you have seen the whole ` +
|
package/src/engine/requests.ts
CHANGED
|
@@ -33,7 +33,7 @@ const OPENAI_WEB_SEARCH_EXTERNAL_WEB_ACCESS = true;
|
|
|
33
33
|
export const MCP_NAME = 'BunnyQuery';
|
|
34
34
|
|
|
35
35
|
export const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-6';
|
|
36
|
-
export const DEFAULT_OPENAI_MODEL = 'gpt-5.
|
|
36
|
+
export const DEFAULT_OPENAI_MODEL = 'gpt-5.6-luna';
|
|
37
37
|
|
|
38
38
|
const mcpUrl = () => chatEngineConfig().mcpBaseUrl;
|
|
39
39
|
const clientSecretRequest = (opts: any) => chatEngineConfig().clientSecretRequest(opts);
|