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/README.md +76 -7
- package/bunnyquery.js +137 -44
- package/dist/engine.cjs +135 -40
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +70 -1
- package/dist/engine.d.ts +70 -1
- package/dist/engine.mjs +130 -41
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/attachment_parsers.ts +112 -0
- package/src/engine/config.ts +11 -0
- package/src/engine/index.ts +12 -0
- package/src/engine/office.ts +26 -4
- package/src/engine/prompts/indexing_user_message.ts +20 -0
- package/src/engine/requests.ts +17 -5
- package/src/engine/session.ts +9 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/engine/config.ts
CHANGED
|
@@ -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 {
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
package/src/engine/office.ts
CHANGED
|
@@ -21,24 +21,46 @@ export type ExtractDirective = {
|
|
|
21
21
|
mime?: string;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
// (.docx/.xlsx/.pptx)
|
|
26
|
-
// (
|
|
27
|
-
// graceful note instead of the model
|
|
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.
|
package/src/engine/requests.ts
CHANGED
|
@@ -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).
|
|
431
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
454
|
+
parsedContent
|
|
455
|
+
? { inlineContent: parsedContent }
|
|
456
|
+
: placeholder
|
|
457
|
+
? { inlineContentPlaceholder: placeholder }
|
|
458
|
+
: undefined,
|
|
447
459
|
);
|
|
448
460
|
|
|
449
461
|
const systemPrompt = buildIndexingSystemPrompt({
|
package/src/engine/session.ts
CHANGED
|
@@ -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
|
});
|