bunnyquery 1.3.4 → 1.4.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 CHANGED
@@ -24,6 +24,9 @@ you can build your own chat UI on top of it. See
24
24
  first load and on scroll-up.
25
25
  - **Attachments** — drag-and-drop files and folders, per-file upload status
26
26
  (uploading / failed / indexed), and overflow collapsing for large batches.
27
+ - **Attachment parser plugins** — register a client-side parser so the widget
28
+ extracts text in the browser from formats the model can't otherwise read, and
29
+ indexes it directly. See [Attachment parser plugins](#attachment-parser-plugins).
27
30
  - **Settings panel** — in-place inside the chat: light/dark theme, account details,
28
31
  newsletter subscription, clear history, and remove account.
29
32
  - **Theming** — light and dark modes via CSS custom properties; the choice is
@@ -110,17 +113,19 @@ Mounts the widget. Returns the `BunnyQuery` object.
110
113
  | `dev` | `boolean` | `false` | Use the development MCP host and `skapi.app` db-CDN host instead of production. |
111
114
  | `mcpBaseUrl` | `string` | `null` | Override the MCP OAuth server base URL entirely (advanced). |
112
115
  | `hostDomain` | `string` | `null` | db-CDN host for temporary file URLs. Defaults to `skapi.app` (dev) / `skapi.com` (prod). |
116
+ | `attachmentParsers` | `array` | `null` | Client-side attachment parsers. See [Attachment parser plugins](#attachment-parser-plugins). |
113
117
 
114
118
  ### Methods
115
119
 
116
120
  The `BunnyQuery` global also exposes:
117
121
 
118
- | Method | Description |
119
- | -------------------- | ----------------------------------------------------------------- |
120
- | `setTheme(theme)` | Apply `"light"` or `"dark"` and persist it. |
121
- | `toggleTheme()` | Switch between light and dark. |
122
- | `logout()` | Sign the current user out and return to the login view. |
123
- | `version` | The widget version string. |
122
+ | Method | Description |
123
+ | ---------------------------------- | ----------------------------------------------------------------------------------- |
124
+ | `setTheme(theme)` | Apply `"light"` or `"dark"` and persist it. |
125
+ | `toggleTheme()` | Switch between light and dark. |
126
+ | `logout()` | Sign the current user out and return to the login view. |
127
+ | `registerAttachmentParser(parser)` | Register a client-side attachment parser. May be called before or after `init()`. See [Attachment parser plugins](#attachment-parser-plugins). |
128
+ | `version` | The widget's package version string. Also logged to the console on `init()`. |
124
129
 
125
130
  ```js
126
131
  BunnyQuery.setTheme("dark");
@@ -129,7 +134,71 @@ BunnyQuery.logout();
129
134
  ```
130
135
 
131
136
  > `init()` is idempotent — calling it twice logs a warning and returns the existing
132
- > instance rather than re-mounting.
137
+ > instance rather than re-mounting. On a successful mount it logs its version, e.g.
138
+ > `[bunnyquery] v1.3.5`.
139
+
140
+ ## Attachment parser plugins
141
+
142
+ By default the chat agent reads most uploads via the model's `web_fetch` tool
143
+ (text, CSV, PDF, …), and Office/OpenDocument/EPUB files are extracted on the
144
+ server. For any format that can be read by **neither** (e.g. a proprietary
145
+ binary format), register a **parser plugin**: it runs in the browser, turns the
146
+ uploaded file into text (or an HTML string), and the widget sends that content
147
+ **inline** for indexing — no `web_fetch`, no server extraction for that file.
148
+
149
+ BunnyQuery ships only the **mechanism**. You bring the parsing library (so the
150
+ widget stays lean and you choose which formats and which library).
151
+
152
+ A parser is a plain object:
153
+
154
+ ```ts
155
+ interface AttachmentParser {
156
+ name?: string; // label, used in logs
157
+ match: (file: { name: string; mime?: string }) => boolean; // handle this file?
158
+ parse: (file: File) => string | null | undefined | Promise<string | null | undefined>; // text or HTML; falsy = skip
159
+ }
160
+ ```
161
+
162
+ The first parser whose `match` returns `true` wins. A parser that throws or
163
+ returns nothing is ignored — the file falls back to its normal path. Output is
164
+ capped (~200k chars) before it is inlined.
165
+
166
+ ### Example
167
+
168
+ Load whatever parsing library reads your format, then register a parser that
169
+ turns a `File` into text:
170
+
171
+ ```html
172
+ <!-- bring your own parsing library, e.g. from a CDN -->
173
+ <script src="https://cdn.example.com/my-format-parser.js"></script>
174
+ <script>
175
+ BunnyQuery.registerAttachmentParser({
176
+ name: "my-format",
177
+ match: (file) => /\.myext$/i.test(file.name),
178
+ parse: async (file) => {
179
+ const bytes = new Uint8Array(await file.arrayBuffer());
180
+ return window.myFormatParser.toText(bytes); // return plain text OR an HTML string
181
+ },
182
+ });
183
+
184
+ BunnyQuery.init(skapi, "chatbox", { theme: "light" });
185
+ </script>
186
+ ```
187
+
188
+ Equivalent one-shot form via init options:
189
+
190
+ ```js
191
+ BunnyQuery.init(skapi, "chatbox", {
192
+ attachmentParsers: [ myParser ],
193
+ });
194
+ ```
195
+
196
+ Bundler consumers can import the same registry from the engine:
197
+
198
+ ```js
199
+ import { registerAttachmentParser } from "bunnyquery/engine";
200
+ registerAttachmentParser(myParser);
201
+ ```
133
202
 
134
203
  ## Theming
135
204
 
package/bunnyquery.js CHANGED
@@ -1,10 +1,52 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
+ // src/engine/attachment_parsers.ts
5
+ var MAX_PARSED_CONTENT_CHARS = 2e5;
6
+ var _parsers = [];
7
+ function registerAttachmentParser(parser) {
8
+ if (parser && typeof parser.match === "function" && typeof parser.parse === "function" && _parsers.indexOf(parser) === -1) {
9
+ _parsers.push(parser);
10
+ }
11
+ }
12
+ function findAttachmentParser(name, mime) {
13
+ for (let i = 0; i < _parsers.length; i++) {
14
+ try {
15
+ if (_parsers[i].match({ name, mime })) return _parsers[i];
16
+ } catch {
17
+ }
18
+ }
19
+ return void 0;
20
+ }
21
+ async function parseAttachmentContent(file, name, mime) {
22
+ const parser = findAttachmentParser(name, mime);
23
+ if (!parser) return null;
24
+ let raw;
25
+ try {
26
+ raw = await parser.parse(file);
27
+ } catch (err) {
28
+ console.error(
29
+ `[chat-engine] attachment parser ${parser.name || "(unnamed)"} failed for ${name}:`,
30
+ err
31
+ );
32
+ return null;
33
+ }
34
+ let text = (raw == null ? "" : String(raw)).trim();
35
+ if (!text) return null;
36
+ if (text.length > MAX_PARSED_CONTENT_CHARS) {
37
+ text = text.slice(0, MAX_PARSED_CONTENT_CHARS) + `
38
+ ...[truncated for length; original ${text.length} characters]`;
39
+ }
40
+ return text;
41
+ }
42
+
4
43
  // src/engine/config.ts
5
44
  var _config = null;
6
45
  function configureChatEngine(config) {
7
46
  _config = config;
47
+ if (config.attachmentParsers) {
48
+ for (const parser of config.attachmentParsers) registerAttachmentParser(parser);
49
+ }
8
50
  }
9
51
  function chatEngineConfig() {
10
52
  if (!_config) {
@@ -31,13 +73,41 @@
31
73
  "pptx",
32
74
  "pptm",
33
75
  "hwp",
34
- "hwpx"
76
+ "hwpx",
77
+ "ods",
78
+ "odt",
79
+ "odp",
80
+ "epub"
81
+ ]);
82
+ var WEB_FETCHABLE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
83
+ "csv",
84
+ "tsv",
85
+ "tab",
86
+ "txt",
87
+ "text",
88
+ "md",
89
+ "markdown",
90
+ "json",
91
+ "ndjson",
92
+ "jsonl",
93
+ "xml",
94
+ "yaml",
95
+ "yml",
96
+ "log",
97
+ // RTF is a TEXT format (not a binary zip), so web_fetch can read it and the
98
+ // model decodes its control words. Pin it here so a `.rtf` reported as
99
+ // `application/msword` isn't misrouted to server-side extraction (which has no
100
+ // .rtf extractor → "unsupported format" note).
101
+ "rtf",
102
+ "htm",
103
+ "html"
35
104
  ]);
36
105
  function isOfficeFile(name, mime) {
37
106
  const ext = (name || "").split(".").pop()?.toLowerCase() || "";
38
107
  if (OFFICE_FILE_EXTENSIONS.has(ext)) return true;
108
+ if (WEB_FETCHABLE_TEXT_EXTENSIONS.has(ext)) return false;
39
109
  const m = (mime || "").toLowerCase();
40
- return m.includes("officedocument") || m.includes("hwp") || m === "application/msword" || m === "application/vnd.ms-excel" || m === "application/vnd.ms-powerpoint";
110
+ return m.includes("officedocument") || m.includes("opendocument") || m.includes("hwp") || m.includes("epub") || m === "application/msword" || m === "application/vnd.ms-excel" || m === "application/vnd.ms-powerpoint";
41
111
  }
42
112
  var _extractPlaceholderSeq = 0;
43
113
  function makeExtractPlaceholder(seed) {
@@ -158,6 +228,14 @@ File metadata:
158
228
  ` + (attachment.mime ? `- mime type: ${attachment.mime}
159
229
  ` : "") + (typeof attachment.size === "number" ? `- size (bytes): ${attachment.size}
160
230
  ` : "");
231
+ if (options?.inlineContent) {
232
+ return head + `
233
+ The file's content was parsed by the client and is provided inline below. Read it directly \u2014 do NOT fetch any URL for this file. Use the storage path above (not this content) for the "src::" unique_id.
234
+
235
+ ----- BEGIN FILE CONTENT -----
236
+ ${options.inlineContent}
237
+ ----- END FILE CONTENT -----`;
238
+ }
161
239
  if (options?.inlineContentPlaceholder) {
162
240
  return head + `
163
241
  The file's text content was extracted on the server and is provided inline below. Read it directly \u2014 do NOT fetch any URL for this file. Use the storage path above (not this content) for the "src::" unique_id.
@@ -653,14 +731,14 @@ ${options.inlineContentPlaceholder}
653
731
  });
654
732
  }
655
733
  async function notifyAgentSaveAttachment(info) {
656
- const { platform, service, owner, attachment } = info;
657
- const office = isOfficeFile(attachment.name, attachment.mime);
734
+ const { platform, service, owner, attachment, parsedContent } = info;
735
+ const office = !parsedContent && isOfficeFile(attachment.name, attachment.mime);
658
736
  const placeholder = office ? makeExtractPlaceholder(attachment.storagePath) : void 0;
659
737
  const extractContent = office && placeholder ? [{ path: attachment.storagePath, placeholder, name: attachment.name, mime: attachment.mime }] : void 0;
660
738
  const skapiExtract = extractContent && extractContent.length ? { _skapi_extract: extractContent } : {};
661
739
  const userMessage = buildIndexingUserMessage(
662
740
  attachment,
663
- placeholder ? { inlineContentPlaceholder: placeholder } : void 0
741
+ parsedContent ? { inlineContent: parsedContent } : placeholder ? { inlineContentPlaceholder: placeholder } : void 0
664
742
  );
665
743
  const systemPrompt = buildIndexingSystemPrompt({
666
744
  service,
@@ -1221,6 +1299,7 @@ ${options.inlineContentPlaceholder}
1221
1299
  this.enqueueTypewrite(aiIdx, answer, lid);
1222
1300
  }
1223
1301
  }
1302
+ this._removeStrayPendingAssistants();
1224
1303
  this.promoteNextQueuedToRunning();
1225
1304
  this.updateHistoryCache();
1226
1305
  this.host.notify();
@@ -1263,6 +1342,7 @@ ${options.inlineContentPlaceholder}
1263
1342
  if (thPos2 !== -1) this.state.messages.splice(thPos2, 1);
1264
1343
  }
1265
1344
  if (serverId) this.cancelledServerIds.delete(serverId);
1345
+ this._removeStrayPendingAssistants();
1266
1346
  this.promoteNextQueuedToRunning();
1267
1347
  this.updateHistoryCache();
1268
1348
  this.host.notify();
@@ -1276,6 +1356,7 @@ ${options.inlineContentPlaceholder}
1276
1356
  return;
1277
1357
  }
1278
1358
  this.insertAtTarget({ role: "assistant", content: getErrorMessage(err), isError: true }, targetIdx);
1359
+ this._removeStrayPendingAssistants();
1279
1360
  this.promoteNextQueuedToRunning();
1280
1361
  this.updateHistoryCache();
1281
1362
  this.host.notify();
@@ -1437,16 +1518,48 @@ ${options.inlineContentPlaceholder}
1437
1518
  if (pendingIdx === -1) return Promise.resolve();
1438
1519
  if (latest.isError || !latest.content) {
1439
1520
  this.state.messages[pendingIdx] = { role: "assistant", content: latest.content || "", isError: !!latest.isError };
1521
+ this._removeStrayPendingAssistants();
1440
1522
  this.host.notify();
1441
1523
  this.promoteNextQueuedToRunning();
1442
1524
  return Promise.resolve();
1443
1525
  }
1444
1526
  var lid = this._newLocalId();
1445
1527
  this.state.messages[pendingIdx] = { role: "assistant", content: "", isPending: false, _localId: lid };
1528
+ this._removeStrayPendingAssistants();
1446
1529
  this.host.notify();
1447
1530
  this.promoteNextQueuedToRunning();
1448
1531
  return this.enqueueTypewrite(pendingIdx, latest.content, lid);
1449
1532
  }
1533
+ // Remove any leftover non-background pending ("Thinking…") assistant bubbles.
1534
+ // There is normally at most ONE such bubble at a time (promoteNext* refuses to
1535
+ // add a second), so any extra is a duplicate — it appears when a concurrent
1536
+ // history refetch re-maps the still-"running" turn into a pending placeholder
1537
+ // (with a real _serverItemId) while the local pending bubble (no _serverItemId)
1538
+ // is rescued and re-appended (see loadHistory rescue below). Each resolve path
1539
+ // only replaces the FIRST pending bubble, so without this a stray "Thinking…"
1540
+ // survives next to the reply/error. MUST run AFTER the resolved bubble has been
1541
+ // made non-pending and BEFORE promoteNext*() (so a freshly-promoted Thinking,
1542
+ // which is added only once no pending assistant remains, is preserved).
1543
+ _removeStrayPendingAssistants() {
1544
+ for (var k = this.state.messages.length - 1; k >= 0; k--) {
1545
+ var m = this.state.messages[k];
1546
+ if (m.isPending && m.role === "assistant" && !m.isBackgroundTask) this.state.messages.splice(k, 1);
1547
+ }
1548
+ }
1549
+ // Drop the pending flags on the resolved turn's USER bubble (preserving its
1550
+ // content + background-task marker). Needed because a bg "Indexing:" turn's user
1551
+ // bubble carries isPendingInProcess; leaving it set keeps the bubble visually
1552
+ // stuck and keeps its bgTaskQueue entry alive forever.
1553
+ _clearPendingUserBubble(itemId) {
1554
+ var uIdx = this.state.messages.findIndex(function(m) {
1555
+ return m.role === "user" && m._serverItemId === itemId && (m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
1556
+ });
1557
+ if (uIdx === -1) return;
1558
+ var u = this.state.messages[uIdx];
1559
+ var cleaned = { role: "user", content: u.content, _serverItemId: itemId };
1560
+ if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
1561
+ this.state.messages[uIdx] = cleaned;
1562
+ }
1450
1563
  // If an immediate-send request for the current cache key is still in flight
1451
1564
  // (e.g. the view unmounted then remounted mid-request), show the sending
1452
1565
  // state, await it, then render the reply from the cache. Skipped when the
@@ -1485,6 +1598,7 @@ ${options.inlineContentPlaceholder}
1485
1598
  return m.isPending && m._serverItemId === itemId;
1486
1599
  });
1487
1600
  if (idx !== -1) {
1601
+ this._clearPendingUserBubble(itemId);
1488
1602
  if (isErr) {
1489
1603
  this.state.messages[idx] = { role: "assistant", content: answer, isError: true, _serverItemId: itemId };
1490
1604
  this.host.notify();
@@ -1634,12 +1748,16 @@ ${options.inlineContentPlaceholder}
1634
1748
  self.state.messages.forEach(function(m) {
1635
1749
  if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = 1;
1636
1750
  });
1751
+ var mappedHasPendingAssistant = mapped.some(function(m) {
1752
+ return m.isPending && m.role === "assistant" && !m.isBackgroundTask;
1753
+ });
1637
1754
  var rescued = [];
1638
1755
  for (var ri = 0; ri < self.state.messages.length; ri++) {
1639
1756
  var mm = self.state.messages[ri];
1640
1757
  if (mm.isBackgroundTask) continue;
1641
1758
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
1642
1759
  if (!mm._serverItemId) {
1760
+ if (mappedHasPendingAssistant) continue;
1643
1761
  if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
1644
1762
  else if (self.state.sending && mm.role === "user") {
1645
1763
  var next = self.state.messages[ri + 1];
@@ -1797,44 +1915,49 @@ ${options.inlineContentPlaceholder}
1797
1915
  att.storagePath = member.storagePath;
1798
1916
  }
1799
1917
  var mime = member.file.type || self.host.getMimeType(member.file.name);
1800
- return notifyAgentSaveAttachment({
1801
- platform: id.platform,
1802
- model: id.model,
1803
- service: id.serviceId,
1804
- owner: id.owner,
1805
- userId: id.userId || id.serviceId,
1806
- serviceName: id.serviceName,
1807
- serviceDescription: id.serviceDescription,
1808
- attachment: {
1809
- name: member.file.name,
1810
- storagePath: member.storagePath,
1811
- mime: mime || void 0,
1812
- size: member.file.size,
1813
- url
1814
- }
1815
- }).then(function(ack) {
1816
- if (ack && typeof ack.id === "string") {
1817
- self.bgTaskQueue.push({
1818
- serviceId: id.serviceId,
1819
- platform: id.platform,
1820
- id: ack.id,
1821
- filename: member.file.name,
1918
+ return Promise.resolve(
1919
+ parseAttachmentContent(member.file, member.file.name, mime || void 0)
1920
+ ).then(function(parsedContent) {
1921
+ return notifyAgentSaveAttachment({
1922
+ platform: id.platform,
1923
+ model: id.model,
1924
+ service: id.serviceId,
1925
+ owner: id.owner,
1926
+ userId: id.userId || id.serviceId,
1927
+ serviceName: id.serviceName,
1928
+ serviceDescription: id.serviceDescription,
1929
+ attachment: {
1930
+ name: member.file.name,
1822
1931
  storagePath: member.storagePath,
1823
- isReindex: hadExists,
1824
1932
  mime: mime || void 0,
1825
1933
  size: member.file.size,
1826
- status: ack.status === "running" ? "running" : "pending",
1827
- poll: ack.poll
1828
- });
1829
- self.drainBgTaskQueue();
1830
- }
1831
- }, function(e) {
1832
- console.error("[chat-engine] indexing request failed", e);
1833
- anyIndexFailed = true;
1834
- if (!att.errorCode && !att.errorDetail) {
1835
- att.errorCode = e && (e.code || e.body && e.body.code) || "";
1836
- att.errorDetail = e && (e.message || e.body && e.body.message) || (typeof e === "string" ? e : "");
1837
- }
1934
+ url
1935
+ },
1936
+ parsedContent: parsedContent || void 0
1937
+ }).then(function(ack) {
1938
+ if (ack && typeof ack.id === "string") {
1939
+ self.bgTaskQueue.push({
1940
+ serviceId: id.serviceId,
1941
+ platform: id.platform,
1942
+ id: ack.id,
1943
+ filename: member.file.name,
1944
+ storagePath: member.storagePath,
1945
+ isReindex: hadExists,
1946
+ mime: mime || void 0,
1947
+ size: member.file.size,
1948
+ status: ack.status === "running" ? "running" : "pending",
1949
+ poll: ack.poll
1950
+ });
1951
+ self.drainBgTaskQueue();
1952
+ }
1953
+ }, function(e) {
1954
+ console.error("[chat-engine] indexing request failed", e);
1955
+ anyIndexFailed = true;
1956
+ if (!att.errorCode && !att.errorDetail) {
1957
+ att.errorCode = e && (e.code || e.body && e.body.code) || "";
1958
+ att.errorDetail = e && (e.message || e.body && e.body.message) || (typeof e === "string" ? e : "");
1959
+ }
1960
+ });
1838
1961
  });
1839
1962
  });
1840
1963
  });
@@ -1918,6 +2041,7 @@ ${options.inlineContentPlaceholder}
1918
2041
  (function() {
1919
2042
  var MCP_PROD = "https://mcp.broadwayinc.computer";
1920
2043
  var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
2044
+ var BQ_VERSION = "1.4.0" ;
1921
2045
  var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
1922
2046
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
1923
2047
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -2139,7 +2263,7 @@ ${options.inlineContentPlaceholder}
2139
2263
  if (typeof S.skapi.getConnectionInfo === "function") return S.skapi.getConnectionInfo();
2140
2264
  return S.skapi.connection || null;
2141
2265
  }).then(function(conn) {
2142
- console.log("[bunnyquery] loadServiceInfo", conn);
2266
+ if (S.opts && S.opts.dev) console.log("[bunnyquery] loadServiceInfo", conn);
2143
2267
  if (conn) {
2144
2268
  S.serviceId = conn.service || S.serviceId;
2145
2269
  S.owner = conn.owner || S.owner;
@@ -4961,8 +5085,10 @@ ${options.inlineContentPlaceholder}
4961
5085
  googleClientSecretName: "ggl",
4962
5086
  signupConfirmationUrl: null,
4963
5087
  // defaults to current host page
4964
- hostDomain: null
5088
+ hostDomain: null,
4965
5089
  // db-CDN host; null → skapi.app (dev) / skapi.com (prod)
5090
+ attachmentParsers: null
5091
+ // client-side attachment parsers, e.g. [createHwpParser()]
4966
5092
  }, opts || {});
4967
5093
  S.mountEl = mountEl;
4968
5094
  clear(mountEl);
@@ -4970,6 +5096,7 @@ ${options.inlineContentPlaceholder}
4970
5096
  mountEl.appendChild(S.root);
4971
5097
  applyTheme(loadTheme());
4972
5098
  S.booted = true;
5099
+ console.log("[bunnyquery] v" + BQ_VERSION);
4973
5100
  configureChatEngine({
4974
5101
  clientSecretRequest: function(o) {
4975
5102
  return S.skapi.clientSecretRequest(o);
@@ -4978,7 +5105,9 @@ ${options.inlineContentPlaceholder}
4978
5105
  return S.skapi.clientSecretRequestHistory(p, f);
4979
5106
  },
4980
5107
  mcpBaseUrl: mcpBaseUrl(),
4981
- poll: 0
5108
+ poll: 0,
5109
+ // Client-side attachment parsers (e.g. an .hwp parser) passed via init opts.
5110
+ attachmentParsers: S.opts.attachmentParsers || void 0
4982
5111
  });
4983
5112
  if (!S._resizeBound && typeof window !== "undefined" && window.addEventListener) {
4984
5113
  S._resizeBound = true;
@@ -4991,12 +5120,16 @@ ${options.inlineContentPlaceholder}
4991
5120
  }
4992
5121
  var PUBLIC = {
4993
5122
  init,
5123
+ // Register a client-side attachment parser (e.g. createHwpParser()) so the
5124
+ // widget parses matching uploads in-browser and sends the text for indexing.
5125
+ // Can be called before or after init(); also settable via init opts.attachmentParsers.
5126
+ registerAttachmentParser,
4994
5127
  setTheme: function(t) {
4995
5128
  applyTheme(t);
4996
5129
  },
4997
5130
  toggleTheme,
4998
5131
  logout,
4999
- version: "0.1.0",
5132
+ version: BQ_VERSION,
5000
5133
  _state: S
5001
5134
  // exposed for later-phase modules / debugging
5002
5135
  };