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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.3.4",
3
+ "version": "1.4.0",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Client-side attachment-parser plugins.
3
+ *
4
+ * Some attachment formats can't be read by the model's web_fetch (binary) and
5
+ * have no server-side extractor either — e.g. legacy Hancom .hwp. A parser
6
+ * plugin runs IN THE BROWSER, turns the uploaded File into indexable text (or an
7
+ * HTML string), and the engine sends that content INLINE in the background
8
+ * indexing request — so the model indexes the parsed content directly, with no
9
+ * upload-side server extraction and no web_fetch for that file.
10
+ *
11
+ * Register a parser with `registerAttachmentParser()`, or via
12
+ * `configureChatEngine({ attachmentParsers: [...] })`. The BunnyQuery widget also
13
+ * exposes `BunnyQuery.registerAttachmentParser()` and an `attachmentParsers`
14
+ * init option. First matching parser wins.
15
+ */
16
+
17
+ export interface AttachmentParser {
18
+ /** Human-readable label — used only in logs. */
19
+ name?: string;
20
+ /**
21
+ * Return true if this parser handles the file. Receives the file name and
22
+ * (when known) its MIME type. Keep it cheap — it runs for every upload.
23
+ */
24
+ match: (file: { name: string; mime?: string }) => boolean;
25
+ /**
26
+ * Parse the File into indexable plain text OR an HTML string (the model reads
27
+ * either). Runs in the browser; may be async. Return a falsy/empty value to
28
+ * skip (the file then falls back to web_fetch / server extraction).
29
+ */
30
+ parse: (
31
+ file: File,
32
+ ) => string | null | undefined | Promise<string | null | undefined>;
33
+ }
34
+
35
+ // Hard ceiling on inlined parsed content (characters), mirroring the worker's
36
+ // server-side MAX_EXTRACTED_CHARS, so a huge document can't blow the model's
37
+ // context window or the request size.
38
+ export const MAX_PARSED_CONTENT_CHARS = 200_000;
39
+
40
+ const _parsers: AttachmentParser[] = [];
41
+
42
+ /** Register an attachment parser. Ignores duplicates (by reference) and invalid plugins. */
43
+ export function registerAttachmentParser(parser: AttachmentParser): void {
44
+ if (
45
+ parser &&
46
+ typeof parser.match === 'function' &&
47
+ typeof parser.parse === 'function' &&
48
+ _parsers.indexOf(parser) === -1
49
+ ) {
50
+ _parsers.push(parser);
51
+ }
52
+ }
53
+
54
+ /** Remove all registered parsers (mainly for tests / re-init). */
55
+ export function clearAttachmentParsers(): void {
56
+ _parsers.length = 0;
57
+ }
58
+
59
+ /** Snapshot of the registered parsers. */
60
+ export function getAttachmentParsers(): AttachmentParser[] {
61
+ return _parsers.slice();
62
+ }
63
+
64
+ /** First parser whose `match` returns true for the given file, if any. */
65
+ export function findAttachmentParser(
66
+ name: string,
67
+ mime?: string,
68
+ ): AttachmentParser | undefined {
69
+ for (let i = 0; i < _parsers.length; i++) {
70
+ try {
71
+ if (_parsers[i].match({ name: name, mime: mime })) return _parsers[i];
72
+ } catch {
73
+ /* a throwing matcher must not break uploads */
74
+ }
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ /**
80
+ * Run the matching parser (if any) and return capped, trimmed content — or null
81
+ * when there is no parser, the parser throws, or it yields nothing. Never throws:
82
+ * a parser failure degrades to null so the upload still completes (the file then
83
+ * resolves via its normal path).
84
+ */
85
+ export async function parseAttachmentContent(
86
+ file: File,
87
+ name: string,
88
+ mime?: string,
89
+ ): Promise<string | null> {
90
+ const parser = findAttachmentParser(name, mime);
91
+ if (!parser) return null;
92
+
93
+ let raw: string | null | undefined;
94
+ try {
95
+ raw = await parser.parse(file);
96
+ } catch (err) {
97
+ console.error(
98
+ `[chat-engine] attachment parser ${parser.name || '(unnamed)'} failed for ${name}:`,
99
+ err,
100
+ );
101
+ return null;
102
+ }
103
+
104
+ let text = (raw == null ? '' : String(raw)).trim();
105
+ if (!text) return null;
106
+ if (text.length > MAX_PARSED_CONTENT_CHARS) {
107
+ text =
108
+ text.slice(0, MAX_PARSED_CONTENT_CHARS) +
109
+ `\n...[truncated for length; original ${text.length} characters]`;
110
+ }
111
+ return text;
112
+ }
@@ -13,6 +13,8 @@
13
13
  * send cancel). So the request builders include `poll` only when it is set.
14
14
  */
15
15
 
16
+ import { type AttachmentParser, registerAttachmentParser } from './attachment_parsers';
17
+
16
18
  export interface ChatEngineConfig {
17
19
  /** skapi.clientSecretRequest, bound to the consumer's skapi instance. */
18
20
  clientSecretRequest: (opts: any) => Promise<any>;
@@ -25,12 +27,21 @@ export interface ChatEngineConfig {
25
27
  * the `poll` key is omitted entirely (agent.vue). BunnyQuery sets `0`.
26
28
  */
27
29
  poll?: number;
30
+ /**
31
+ * Optional client-side attachment parsers (e.g. an .hwp parser). Each is
32
+ * registered at configure time; more can be added later via
33
+ * `registerAttachmentParser()`. See attachment_parsers.ts.
34
+ */
35
+ attachmentParsers?: AttachmentParser[];
28
36
  }
29
37
 
30
38
  let _config: ChatEngineConfig | null = null;
31
39
 
32
40
  export function configureChatEngine(config: ChatEngineConfig): void {
33
41
  _config = config;
42
+ if (config.attachmentParsers) {
43
+ for (const parser of config.attachmentParsers) registerAttachmentParser(parser);
44
+ }
34
45
  }
35
46
 
36
47
  export function chatEngineConfig(): ChatEngineConfig {
@@ -26,6 +26,18 @@ export {
26
26
  type AttachmentFailureGroup,
27
27
  } from './attachments';
28
28
 
29
+ // Client-side attachment-parser plugins (e.g. .hwp). Register your own parser to
30
+ // turn an uploaded File into indexable text/HTML, sent inline for indexing.
31
+ export {
32
+ registerAttachmentParser,
33
+ clearAttachmentParsers,
34
+ getAttachmentParsers,
35
+ findAttachmentParser,
36
+ parseAttachmentContent,
37
+ MAX_PARSED_CONTENT_CHARS,
38
+ type AttachmentParser,
39
+ } from './attachment_parsers';
40
+
29
41
  export * from './prompts';
30
42
 
31
43
  // Pure helpers (Tier-1.5): error detection, token budgeting, link/path
@@ -21,24 +21,46 @@ export type ExtractDirective = {
21
21
  mime?: string;
22
22
  };
23
23
 
24
- // Office formats whose text the model cannot read via web_fetch. OOXML
25
- // (.docx/.xlsx/.pptx) and Hancom .hwpx are extracted server-side; the other
26
- // (legacy/macro/binary) extensions are still flagged so the worker returns a
27
- // graceful note instead of the model fetching binary garbage.
24
+ // Binary/zip document formats the model cannot read via web_fetch. OOXML
25
+ // (.docx/.xlsx/.pptx), Hancom .hwpx, OpenDocument (.ods/.odt/.odp), and EPUB
26
+ // (.epub) are extracted server-side; the other (legacy/macro/binary) extensions
27
+ // are still flagged so the worker returns a graceful note instead of the model
28
+ // fetching binary garbage.
28
29
  const OFFICE_FILE_EXTENSIONS = new Set([
29
30
  'doc', 'docx', 'docm',
30
31
  'xls', 'xlsx', 'xlsm',
31
32
  'ppt', 'pptx', 'pptm',
32
33
  'hwp', 'hwpx',
34
+ 'ods', 'odt', 'odp',
35
+ 'epub',
36
+ ]);
37
+
38
+ // Plain-text/data formats web_fetch CAN read. These must NEVER be treated as
39
+ // office files even when the OS/browser reports an Office MIME for them — most
40
+ // notably .csv, which Windows/Excel reports as `application/vnd.ms-excel`. The
41
+ // extension is authoritative here; without this guard a .csv is wrongly flagged
42
+ // for server-side extraction, the worker can't extract `.csv`, and the model
43
+ // gets an "unsupported format" note instead of the file's contents.
44
+ const WEB_FETCHABLE_TEXT_EXTENSIONS = new Set([
45
+ 'csv', 'tsv', 'tab', 'txt', 'text', 'md', 'markdown',
46
+ 'json', 'ndjson', 'jsonl', 'xml', 'yaml', 'yml', 'log',
47
+ // RTF is a TEXT format (not a binary zip), so web_fetch can read it and the
48
+ // model decodes its control words. Pin it here so a `.rtf` reported as
49
+ // `application/msword` isn't misrouted to server-side extraction (which has no
50
+ // .rtf extractor → "unsupported format" note).
51
+ 'rtf', 'htm', 'html',
33
52
  ]);
34
53
 
35
54
  export function isOfficeFile(name?: string, mime?: string): boolean {
36
55
  const ext = (name || '').split('.').pop()?.toLowerCase() || '';
37
56
  if (OFFICE_FILE_EXTENSIONS.has(ext)) return true;
57
+ if (WEB_FETCHABLE_TEXT_EXTENSIONS.has(ext)) return false;
38
58
  const m = (mime || '').toLowerCase();
39
59
  return (
40
60
  m.includes('officedocument') ||
61
+ m.includes('opendocument') ||
41
62
  m.includes('hwp') ||
63
+ m.includes('epub') ||
42
64
  m === 'application/msword' ||
43
65
  m === 'application/vnd.ms-excel' ||
44
66
  m === 'application/vnd.ms-powerpoint'
@@ -30,6 +30,12 @@ export type BuildIndexingUserMessageOptions = {
30
30
  * drops the temporary-URL line — there is nothing for the model to fetch).
31
31
  */
32
32
  inlineContentPlaceholder?: string;
33
+ /**
34
+ * Actual file content parsed CLIENT-SIDE by an attachment-parser plugin (e.g.
35
+ * an .hwp parser). Embedded inline verbatim — no server extraction and no
36
+ * web_fetch for this file. Takes precedence over `inlineContentPlaceholder`.
37
+ */
38
+ inlineContent?: string;
33
39
  };
34
40
 
35
41
  export function buildIndexingUserMessage(
@@ -44,6 +50,20 @@ export function buildIndexingUserMessage(
44
50
  (attachment.mime ? `- mime type: ${attachment.mime}\n` : '') +
45
51
  (typeof attachment.size === 'number' ? `- size (bytes): ${attachment.size}\n` : '');
46
52
 
53
+ if (options?.inlineContent) {
54
+ // Parsed client-side (an attachment-parser plugin). The content is already
55
+ // inlined below — no server extraction, no URL to fetch.
56
+ return (
57
+ head +
58
+ `\nThe file's content was parsed by the client and is provided inline below. ` +
59
+ `Read it directly — do NOT fetch any URL for this file. ` +
60
+ `Use the storage path above (not this content) for the "src::" unique_id.\n\n` +
61
+ `----- BEGIN FILE CONTENT -----\n` +
62
+ `${options.inlineContent}\n` +
63
+ `----- END FILE CONTENT -----`
64
+ );
65
+ }
66
+
47
67
  if (options?.inlineContentPlaceholder) {
48
68
  // Office file: text was extracted on the server and is inlined below
49
69
  // between the markers. Do NOT fetch any URL for this file.
@@ -425,14 +425,22 @@ export type AttachmentSaveInfo = {
425
425
  size?: number;
426
426
  url: string;
427
427
  };
428
+ /**
429
+ * Content parsed CLIENT-SIDE by an attachment-parser plugin (e.g. an .hwp
430
+ * parser). When set, it is inlined into the indexing message verbatim and
431
+ * takes precedence over server-side office extraction / web_fetch.
432
+ */
433
+ parsedContent?: string;
428
434
  };
429
435
 
430
- // Background "save into knowledge" call (not a chat turn). Office files get the
431
- // _skapi_extract directive + an inline-content placeholder instead of a URL.
436
+ // Background "save into knowledge" call (not a chat turn). A client-parsed file
437
+ // (parser plugin) is inlined directly; otherwise office files get the
438
+ // _skapi_extract directive + a placeholder, and everything else gets a URL.
432
439
  export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
433
- const { platform, service, owner, attachment } = info;
440
+ const { platform, service, owner, attachment, parsedContent } = info;
434
441
 
435
- const office = isOfficeFile(attachment.name, attachment.mime);
442
+ // Client-parsed content wins over server-side office extraction.
443
+ const office = !parsedContent && isOfficeFile(attachment.name, attachment.mime);
436
444
  const placeholder = office ? makeExtractPlaceholder(attachment.storagePath) : undefined;
437
445
  const extractContent: ExtractDirective[] | undefined =
438
446
  office && placeholder
@@ -443,7 +451,11 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
443
451
 
444
452
  const userMessage = buildIndexingUserMessage(
445
453
  attachment,
446
- placeholder ? { inlineContentPlaceholder: placeholder } : undefined,
454
+ parsedContent
455
+ ? { inlineContent: parsedContent }
456
+ : placeholder
457
+ ? { inlineContentPlaceholder: placeholder }
458
+ : undefined,
447
459
  );
448
460
 
449
461
  const systemPrompt = buildIndexingSystemPrompt({
@@ -34,6 +34,7 @@ import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, ge
34
34
  import { buildBoundedChatMessages } from './budget';
35
35
  import { createInlineLinkRegex } from './links';
36
36
  import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
37
+ import { parseAttachmentContent } from './attachment_parsers';
37
38
  import type { ChatHost, ChatState, ChatMessage } from './host';
38
39
 
39
40
  function sleep(ms: number): Promise<void> {
@@ -352,6 +353,7 @@ export class ChatSession {
352
353
  this.host.notify(); this.enqueueTypewrite(aiIdx, answer, lid);
353
354
  }
354
355
  }
356
+ this._removeStrayPendingAssistants();
355
357
  this.promoteNextQueuedToRunning();
356
358
  this.updateHistoryCache();
357
359
  this.host.notify();
@@ -386,12 +388,14 @@ export class ChatSession {
386
388
  if (thPos2 !== -1) this.state.messages.splice(thPos2, 1);
387
389
  }
388
390
  if (serverId) this.cancelledServerIds.delete(serverId);
391
+ this._removeStrayPendingAssistants();
389
392
  this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
390
393
  return;
391
394
  }
392
395
  var targetIdx = this.resolveQueuedUserBubble(serverId);
393
396
  if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
394
397
  this.insertAtTarget({ role: 'assistant', content: getErrorMessage(err), isError: true }, targetIdx);
398
+ this._removeStrayPendingAssistants();
395
399
  this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
396
400
  }
397
401
 
@@ -516,17 +520,52 @@ export class ChatSession {
516
520
  if (pendingIdx === -1) return Promise.resolve();
517
521
  if (latest.isError || !latest.content) {
518
522
  this.state.messages[pendingIdx] = { role: 'assistant', content: latest.content || '', isError: !!latest.isError };
523
+ this._removeStrayPendingAssistants();
519
524
  this.host.notify();
520
525
  this.promoteNextQueuedToRunning();
521
526
  return Promise.resolve();
522
527
  }
523
528
  var lid = this._newLocalId();
524
529
  this.state.messages[pendingIdx] = { role: 'assistant', content: '', isPending: false, _localId: lid };
530
+ this._removeStrayPendingAssistants();
525
531
  this.host.notify();
526
532
  this.promoteNextQueuedToRunning();
527
533
  return this.enqueueTypewrite(pendingIdx, latest.content, lid);
528
534
  }
529
535
 
536
+ // Remove any leftover non-background pending ("Thinking…") assistant bubbles.
537
+ // There is normally at most ONE such bubble at a time (promoteNext* refuses to
538
+ // add a second), so any extra is a duplicate — it appears when a concurrent
539
+ // history refetch re-maps the still-"running" turn into a pending placeholder
540
+ // (with a real _serverItemId) while the local pending bubble (no _serverItemId)
541
+ // is rescued and re-appended (see loadHistory rescue below). Each resolve path
542
+ // only replaces the FIRST pending bubble, so without this a stray "Thinking…"
543
+ // survives next to the reply/error. MUST run AFTER the resolved bubble has been
544
+ // made non-pending and BEFORE promoteNext*() (so a freshly-promoted Thinking,
545
+ // which is added only once no pending assistant remains, is preserved).
546
+ _removeStrayPendingAssistants(): void {
547
+ for (var k = this.state.messages.length - 1; k >= 0; k--) {
548
+ var m = this.state.messages[k];
549
+ if (m.isPending && m.role === 'assistant' && !m.isBackgroundTask) this.state.messages.splice(k, 1);
550
+ }
551
+ }
552
+
553
+ // Drop the pending flags on the resolved turn's USER bubble (preserving its
554
+ // content + background-task marker). Needed because a bg "Indexing:" turn's user
555
+ // bubble carries isPendingInProcess; leaving it set keeps the bubble visually
556
+ // stuck and keeps its bgTaskQueue entry alive forever.
557
+ _clearPendingUserBubble(itemId: string): void {
558
+ var uIdx = this.state.messages.findIndex(function (m) {
559
+ return m.role === 'user' && m._serverItemId === itemId &&
560
+ (m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
561
+ });
562
+ if (uIdx === -1) return;
563
+ var u = this.state.messages[uIdx];
564
+ var cleaned: ChatMessage = { role: 'user', content: u.content, _serverItemId: itemId };
565
+ if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
566
+ this.state.messages[uIdx] = cleaned;
567
+ }
568
+
530
569
  // If an immediate-send request for the current cache key is still in flight
531
570
  // (e.g. the view unmounted then remounted mid-request), show the sending
532
571
  // state, await it, then render the reply from the cache. Skipped when the
@@ -561,6 +600,11 @@ export class ChatSession {
561
600
  : ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim();
562
601
  var idx = this.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === itemId; });
563
602
  if (idx !== -1) {
603
+ // A bg "Indexing:" turn pushes a user bubble (isPendingInProcess) ALONGSIDE
604
+ // the assistant Thinking; replacing only the assistant leaves that user
605
+ // bubble stuck pending — and drainBgTaskQueue then never clears its queue
606
+ // entry (its stillPending check stays true). Un-pend it here too.
607
+ this._clearPendingUserBubble(itemId);
564
608
  if (isErr) {
565
609
  this.state.messages[idx] = { role: 'assistant', content: answer, isError: true, _serverItemId: itemId };
566
610
  this.host.notify(); this.updateHistoryCache(); return;
@@ -690,12 +734,23 @@ export class ChatSession {
690
734
  mapped.forEach(function (m: any) { if (m._serverItemId) serverIds[m._serverItemId] = 1; });
691
735
  var locallyCancelled: any = {};
692
736
  self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = 1; });
737
+ // If the freshly-mapped server list ALREADY shows this in-flight turn
738
+ // as a pending placeholder (a non-bg pending assistant, which carries
739
+ // a real _serverItemId), re-pushing the local no-_serverItemId
740
+ // user+Thinking would DUPLICATE it. Each resolve path only clears the
741
+ // first pending bubble, so the duplicate strands a "Thinking…" beside
742
+ // the reply/error. There is at most one in-flight regular turn, so a
743
+ // mapped pending assistant IS this turn — skip the redundant local copy.
744
+ var mappedHasPendingAssistant = mapped.some(function (m: any) {
745
+ return m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
746
+ });
693
747
  var rescued: ChatMessage[] = [];
694
748
  for (var ri = 0; ri < self.state.messages.length; ri++) {
695
749
  var mm = self.state.messages[ri];
696
750
  if (mm.isBackgroundTask) continue;
697
751
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
698
752
  if (!mm._serverItemId) {
753
+ if (mappedHasPendingAssistant) continue;
699
754
  if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
700
755
  else if (self.state.sending && mm.role === 'user') {
701
756
  var next = self.state.messages[ri + 1];
@@ -827,6 +882,12 @@ export class ChatSession {
827
882
  urls.push({ name: member.relPath, url: url, storagePath: member.storagePath });
828
883
  if (att.kind !== 'folder') { att.uploadedUrl = url; att.storagePath = member.storagePath; }
829
884
  var mime = member.file.type || self.host.getMimeType(member.file.name);
885
+ // Run a client-side attachment parser (e.g. .hwp) if one matches; its
886
+ // output is inlined into the indexing request (falls back to office
887
+ // extraction / web_fetch when no parser matches or it yields nothing).
888
+ return Promise.resolve(
889
+ parseAttachmentContent(member.file, member.file.name, mime || undefined),
890
+ ).then(function (parsedContent: string | null) {
830
891
  return notifyAgentSaveAttachment({
831
892
  platform: id.platform as 'claude' | 'openai',
832
893
  model: id.model,
@@ -839,6 +900,7 @@ export class ChatSession {
839
900
  name: member.file.name, storagePath: member.storagePath,
840
901
  mime: mime || undefined, size: member.file.size, url: url,
841
902
  },
903
+ parsedContent: parsedContent || undefined,
842
904
  }).then(function (ack: any) {
843
905
  if (ack && typeof ack.id === 'string') {
844
906
  self.bgTaskQueue.push({
@@ -862,6 +924,7 @@ export class ChatSession {
862
924
  att.errorDetail = (e && (e.message || (e.body && e.body.message))) || (typeof e === 'string' ? e : '');
863
925
  }
864
926
  });
927
+ });
865
928
  });
866
929
  });
867
930
  });