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.
Files changed (2) hide show
  1. package/bunnyquery.js +182 -1
  2. 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
- bubble.textContent = msg.content || '';
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "BunnyQuery AI chat client",
5
5
  "main": "bunnyquery.js",
6
6
  "files": [