bunnyquery 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bunnyquery.js +182 -1
- package/package.json +1 -1
package/bunnyquery.js
CHANGED
|
@@ -66,6 +66,165 @@
|
|
|
66
66
|
// init()) is reflected in subsequent send/auth calls.
|
|
67
67
|
const _mcpBaseUrl = () => (global.BunnyQuery && global.BunnyQuery.MCP_BASE_URL) || DEFAULTS.MCP_BASE_URL;
|
|
68
68
|
|
|
69
|
+
// ----- file-fence helpers (mirrors agent.vue) ---------------------------
|
|
70
|
+
// Bare language tags we treat as downloadable files. Maps to the
|
|
71
|
+
// extension used in the synthesized filename. Mirrors the file types the
|
|
72
|
+
// host agent (agent.vue) is known to emit as ```filename.ext fences.
|
|
73
|
+
const FENCE_LANGUAGE_EXTENSIONS = {
|
|
74
|
+
html: 'html',
|
|
75
|
+
htm: 'html',
|
|
76
|
+
csv: 'csv',
|
|
77
|
+
tsv: 'tsv',
|
|
78
|
+
json: 'json',
|
|
79
|
+
xml: 'xml',
|
|
80
|
+
svg: 'svg',
|
|
81
|
+
md: 'md',
|
|
82
|
+
markdown: 'md',
|
|
83
|
+
yaml: 'yaml',
|
|
84
|
+
yml: 'yml',
|
|
85
|
+
txt: 'txt',
|
|
86
|
+
sql: 'sql',
|
|
87
|
+
css: 'css',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const FENCE_MIME_TYPES = {
|
|
91
|
+
html: 'text/html',
|
|
92
|
+
csv: 'text/csv',
|
|
93
|
+
tsv: 'text/tab-separated-values',
|
|
94
|
+
json: 'application/json',
|
|
95
|
+
xml: 'application/xml',
|
|
96
|
+
svg: 'image/svg+xml',
|
|
97
|
+
md: 'text/markdown',
|
|
98
|
+
yaml: 'text/yaml',
|
|
99
|
+
yml: 'text/yaml',
|
|
100
|
+
txt: 'text/plain',
|
|
101
|
+
sql: 'application/sql',
|
|
102
|
+
css: 'text/css',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Closed fences with `filename.ext` after the opening backticks.
|
|
106
|
+
const FENCE_FILENAME_RE = /```([\w.-]+\.[a-zA-Z0-9]+)\n([\s\S]*?)```/g;
|
|
107
|
+
// Closed fences whose info-string is one of the known language tags
|
|
108
|
+
// above. The model often emits ```html ... ``` or ```csv ... ``` instead
|
|
109
|
+
// of a full filename — surface those as downloadable files too.
|
|
110
|
+
const FENCE_LANG_RE = new RegExp(
|
|
111
|
+
'```(' + Object.keys(FENCE_LANGUAGE_EXTENSIONS).join('|') + ')[ \\t]*\\n([\\s\\S]*?)```',
|
|
112
|
+
'gi'
|
|
113
|
+
);
|
|
114
|
+
// Open (still-streaming) fences for either pattern, used to suppress the
|
|
115
|
+
// raw fence text mid-typewriter.
|
|
116
|
+
const OPEN_FENCE_FILENAME_RE = /```([\w.-]+\.[a-zA-Z0-9]+)\n?/;
|
|
117
|
+
const OPEN_FENCE_LANG_RE = new RegExp(
|
|
118
|
+
'```(' + Object.keys(FENCE_LANGUAGE_EXTENSIONS).join('|') + ')[ \\t]*\\n?',
|
|
119
|
+
'i'
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Cache blob URLs keyed by (filename + content) so re-renders don't leak
|
|
123
|
+
// a fresh ObjectURL on every tick of streaming text.
|
|
124
|
+
const _fileBlobCache = new Map();
|
|
125
|
+
const _getOrCreateFileHref = (filename, body) => {
|
|
126
|
+
const key = filename + '\u0000' + body;
|
|
127
|
+
const existing = _fileBlobCache.get(key);
|
|
128
|
+
if (existing) return existing;
|
|
129
|
+
const ext = (filename.split('.').pop() || '').toLowerCase();
|
|
130
|
+
const type = FENCE_MIME_TYPES[ext] || 'text/plain';
|
|
131
|
+
const blob = new Blob([body], { type });
|
|
132
|
+
const href = URL.createObjectURL(blob);
|
|
133
|
+
_fileBlobCache.set(key, href);
|
|
134
|
+
return href;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Counter used to mint stable filenames for bare-language fences within
|
|
138
|
+
// a single rendered message ("download.html", "download-2.html", ...).
|
|
139
|
+
// Keyed by message identity so re-renders of the same message reuse the
|
|
140
|
+
// same names (which lets the blob cache hit).
|
|
141
|
+
const _bareFenceNameCounters = new WeakMap();
|
|
142
|
+
const _bareFilenameFor = (msgKey, ext, occurrence) => {
|
|
143
|
+
const base = 'download';
|
|
144
|
+
return occurrence === 0 ? `${base}.${ext}` : `${base}-${occurrence + 1}.${ext}`;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Split message content into a sequence of {type:'text'|'file'} parts,
|
|
148
|
+
// mirroring agent.vue's parseMsgParts behavior for fenced code blocks.
|
|
149
|
+
// Bare-language fences (```html, ```csv, ...) are also lifted into
|
|
150
|
+
// downloadable file parts with a synthesized filename.
|
|
151
|
+
const parseMsgParts = (content, msgKey, isTyping) => {
|
|
152
|
+
const parts = [];
|
|
153
|
+
if (!content) return parts;
|
|
154
|
+
|
|
155
|
+
// Combine matches from both fence regexes, sorted by position.
|
|
156
|
+
const matches = [];
|
|
157
|
+
FENCE_FILENAME_RE.lastIndex = 0;
|
|
158
|
+
let mm;
|
|
159
|
+
while ((mm = FENCE_FILENAME_RE.exec(content)) !== null) {
|
|
160
|
+
matches.push({ index: mm.index, length: mm[0].length, filename: mm[1], body: mm[2], kind: 'filename' });
|
|
161
|
+
}
|
|
162
|
+
FENCE_LANG_RE.lastIndex = 0;
|
|
163
|
+
let bareCounters = _bareFenceNameCounters.get(msgKey);
|
|
164
|
+
if (!bareCounters) {
|
|
165
|
+
bareCounters = {};
|
|
166
|
+
if (msgKey) _bareFenceNameCounters.set(msgKey, bareCounters);
|
|
167
|
+
}
|
|
168
|
+
const bareSeen = {};
|
|
169
|
+
while ((mm = FENCE_LANG_RE.exec(content)) !== null) {
|
|
170
|
+
const lang = mm[1].toLowerCase();
|
|
171
|
+
const ext = FENCE_LANGUAGE_EXTENSIONS[lang];
|
|
172
|
+
const occurrence = bareSeen[ext] || 0;
|
|
173
|
+
bareSeen[ext] = occurrence + 1;
|
|
174
|
+
matches.push({
|
|
175
|
+
index: mm.index,
|
|
176
|
+
length: mm[0].length,
|
|
177
|
+
filename: _bareFilenameFor(msgKey, ext, occurrence),
|
|
178
|
+
body: mm[2],
|
|
179
|
+
kind: 'lang',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// If both regexes matched the same span (a `filename.ext` fence is
|
|
183
|
+
// also a bare-language fence when the language coincides), prefer
|
|
184
|
+
// the filename match.
|
|
185
|
+
matches.sort((a, b) => a.index - b.index || (a.kind === 'filename' ? -1 : 1));
|
|
186
|
+
const dedup = [];
|
|
187
|
+
for (const m of matches) {
|
|
188
|
+
if (dedup.length && dedup[dedup.length - 1].index === m.index) continue;
|
|
189
|
+
dedup.push(m);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let last = 0;
|
|
193
|
+
for (const m of dedup) {
|
|
194
|
+
if (m.index > last) {
|
|
195
|
+
parts.push({ type: 'text', text: content.slice(last, m.index) });
|
|
196
|
+
}
|
|
197
|
+
parts.push({
|
|
198
|
+
type: 'file',
|
|
199
|
+
filename: m.filename,
|
|
200
|
+
href: _getOrCreateFileHref(m.filename, m.body),
|
|
201
|
+
});
|
|
202
|
+
last = m.index + m.length;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let tail = content.slice(last);
|
|
206
|
+
|
|
207
|
+
// Mid-typewriter: hide raw source for a still-open fence so the user
|
|
208
|
+
// doesn't watch literal code stream by character-by-character.
|
|
209
|
+
if (isTyping && tail) {
|
|
210
|
+
const fnOpen = tail.match(OPEN_FENCE_FILENAME_RE);
|
|
211
|
+
const langOpen = tail.match(OPEN_FENCE_LANG_RE);
|
|
212
|
+
const open = fnOpen && (!langOpen || fnOpen.index <= langOpen.index)
|
|
213
|
+
? { match: fnOpen, label: fnOpen[1] }
|
|
214
|
+
: langOpen
|
|
215
|
+
? { match: langOpen, label: 'download.' + FENCE_LANGUAGE_EXTENSIONS[langOpen[1].toLowerCase()] }
|
|
216
|
+
: null;
|
|
217
|
+
if (open) {
|
|
218
|
+
const before = tail.slice(0, open.match.index);
|
|
219
|
+
if (before) parts.push({ type: 'text', text: before });
|
|
220
|
+
parts.push({ type: 'text', text: `\n[generating ${open.label}...]` });
|
|
221
|
+
tail = '';
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (tail) parts.push({ type: 'text', text: tail });
|
|
225
|
+
return parts;
|
|
226
|
+
};
|
|
227
|
+
|
|
69
228
|
// ----- helpers ----------------------------------------------------------
|
|
70
229
|
const $ = (tag, attrs, children) => {
|
|
71
230
|
const el = document.createElement(tag);
|
|
@@ -916,7 +1075,29 @@
|
|
|
916
1075
|
if (msg.isPending) {
|
|
917
1076
|
bubble.appendChild($('span', { class: 'bq-loader' }, 'Thinking'));
|
|
918
1077
|
} else {
|
|
919
|
-
|
|
1078
|
+
// Render fenced file blocks (```filename.ext``` or
|
|
1079
|
+
// ```html / ```csv / etc.) as inline download links;
|
|
1080
|
+
// everything else stays plain text. Mirrors the
|
|
1081
|
+
// behavior of agent.vue's parseMsgParts.
|
|
1082
|
+
const parts = parseMsgParts(msg.content || '', msg, !!this.typing);
|
|
1083
|
+
if (!parts.length) {
|
|
1084
|
+
bubble.textContent = msg.content || '';
|
|
1085
|
+
} else {
|
|
1086
|
+
for (const part of parts) {
|
|
1087
|
+
if (part.type === 'file') {
|
|
1088
|
+
const a = $('a', {
|
|
1089
|
+
class: 'bq-file-download',
|
|
1090
|
+
href: part.href,
|
|
1091
|
+
target: '_blank',
|
|
1092
|
+
rel: 'noopener noreferrer',
|
|
1093
|
+
download: part.filename,
|
|
1094
|
+
}, '\u2197 ' + part.filename);
|
|
1095
|
+
bubble.appendChild(a);
|
|
1096
|
+
} else {
|
|
1097
|
+
bubble.appendChild(document.createTextNode(part.text));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
920
1101
|
}
|
|
921
1102
|
const wrapper = $('div', {
|
|
922
1103
|
class:
|