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 +76 -7
- package/bunnyquery.js +177 -44
- package/dist/engine.cjs +175 -40
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +72 -1
- package/dist/engine.d.ts +72 -1
- package/dist/engine.mjs +170 -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 +63 -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> {
|
|
@@ -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
|
});
|