bunnyquery 1.3.5 → 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.5",
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> {
@@ -881,6 +882,12 @@ export class ChatSession {
881
882
  urls.push({ name: member.relPath, url: url, storagePath: member.storagePath });
882
883
  if (att.kind !== 'folder') { att.uploadedUrl = url; att.storagePath = member.storagePath; }
883
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) {
884
891
  return notifyAgentSaveAttachment({
885
892
  platform: id.platform as 'claude' | 'openai',
886
893
  model: id.model,
@@ -893,6 +900,7 @@ export class ChatSession {
893
900
  name: member.file.name, storagePath: member.storagePath,
894
901
  mime: mime || undefined, size: member.file.size, url: url,
895
902
  },
903
+ parsedContent: parsedContent || undefined,
896
904
  }).then(function (ack: any) {
897
905
  if (ack && typeof ack.id === 'string') {
898
906
  self.bgTaskQueue.push({
@@ -916,6 +924,7 @@ export class ChatSession {
916
924
  att.errorDetail = (e && (e.message || (e.body && e.body.message))) || (typeof e === 'string' ? e : '');
917
925
  }
918
926
  });
927
+ });
919
928
  });
920
929
  });
921
930
  });