bunnyquery 1.2.2 → 1.3.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.
@@ -0,0 +1,918 @@
1
+ /**
2
+ * ChatSession — framework-agnostic stateful chat orchestration.
3
+ *
4
+ * Ported verbatim from the bunnyquery widget's in-place state machine (which was
5
+ * itself ported from agent.vue), with three mechanical substitutions:
6
+ * CS.<field> -> this.state.<field>
7
+ * renderMessages() -> this.host.notify()
8
+ * S.<x> / skapi -> this.host.getIdentity().<x> / this.host.cancelRequest / refreshSession
9
+ * scroll/refresh -> this.host.scrollToBottom(IfSticky) / refreshMessageBubble
10
+ * Module-level singletons (bgTaskQueue, aiChatHistoryCache, pendingAgentRequests,
11
+ * cancelledServerIds, historyItemPolls) become instance fields. The provider
12
+ * request builders are reached through the engine (which already has the skapi
13
+ * transport + poll injected via configureChatEngine), so cancel/poll behavior is
14
+ * preserved.
15
+ *
16
+ * The view (per consumer) keeps: rendering, markdown PARSE, DOM refs + scroll
17
+ * measurement, attachment chips, and the auth/account shell. It drives the
18
+ * session via the public methods and re-renders in host.notify().
19
+ */
20
+ import {
21
+ callClaudeWithPublicMcp,
22
+ callOpenAIWithPublicMcp,
23
+ notifyAgentSaveAttachment,
24
+ extractClaudeText,
25
+ extractOpenAIText,
26
+ getChatHistory,
27
+ POLL_INTERVAL,
28
+ BG_INDEXING_QUEUE_SUFFIX,
29
+ ANTHROPIC_MESSAGES_API_URL,
30
+ OPENAI_RESPONSES_API_URL,
31
+ type BgTaskEntry,
32
+ } from './requests';
33
+ import { isErrorResponseBody, isAuthExpiredError, getErrorMessage } from './errors';
34
+ import { buildBoundedChatMessages } from './budget';
35
+ import { createInlineLinkRegex } from './links';
36
+ import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
37
+ import type { ChatHost, ChatState, ChatMessage } from './host';
38
+
39
+ function sleep(ms: number): Promise<void> {
40
+ return new Promise(function (r) { setTimeout(r, ms); });
41
+ }
42
+
43
+ export class ChatSession {
44
+ host: ChatHost;
45
+ state: ChatState;
46
+ bgTaskQueue: BgTaskEntry[];
47
+ cancelledServerIds: Set<string>;
48
+ pendingAgentRequests: Record<string, Promise<any>>;
49
+ aiChatHistoryCache: Record<string, { messages: ChatMessage[]; endOfList: boolean; startKeyHistory: string[] }>;
50
+ historyItemPolls: Map<string, boolean>;
51
+ private _lidSeq: number;
52
+
53
+ constructor(host: ChatHost) {
54
+ this.host = host;
55
+ this.state = {
56
+ messages: [],
57
+ attachments: [],
58
+ uploadingAttachments: false,
59
+ sending: false,
60
+ typing: false,
61
+ typingAbort: false,
62
+ loadingHistory: false,
63
+ loadingOlderHistory: false,
64
+ historyEndOfList: false,
65
+ historyStartKeyHistory: [],
66
+ historyRequestToken: 0,
67
+ gateRefreshToken: 0,
68
+ };
69
+ this.bgTaskQueue = [];
70
+ this.cancelledServerIds = new Set();
71
+ this.pendingAgentRequests = {};
72
+ this.aiChatHistoryCache = {};
73
+ this.historyItemPolls = new Map();
74
+ this._lidSeq = 0;
75
+ }
76
+
77
+ private _newLocalId(): string {
78
+ this._lidSeq += 1;
79
+ return 'lid_' + this._lidSeq;
80
+ }
81
+
82
+ getHistoryCacheKey(): string {
83
+ var id = this.host.getIdentity();
84
+ if (!id.serviceId || id.platform === 'none') return '';
85
+ return id.serviceId + '#' + id.platform;
86
+ }
87
+
88
+ updateHistoryCache(): void {
89
+ var key = this.getHistoryCacheKey();
90
+ if (!key) return;
91
+ this.aiChatHistoryCache[key] = {
92
+ messages: this.state.messages.slice(),
93
+ endOfList: this.state.historyEndOfList,
94
+ startKeyHistory: this.state.historyStartKeyHistory.slice(),
95
+ };
96
+ }
97
+
98
+ private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any) {
99
+ var id = this.host.getIdentity();
100
+ return platform === 'openai'
101
+ ? callOpenAIWithPublicMcp(prompt, id.serviceId, id.owner, messages, system, model, userId, extractContent)
102
+ : callClaudeWithPublicMcp(prompt, id.serviceId, id.owner, messages, system, model, userId, extractContent);
103
+ }
104
+
105
+ dispatchAgentRequest(params: any) {
106
+ var self = this;
107
+ var sendAndPoll = function () {
108
+ return Promise.resolve(
109
+ self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent)
110
+ ).then(function (initial: any) {
111
+ if (initial && initial.poll && (initial.status === 'pending' || initial.status === 'running')) {
112
+ return initial.poll({ latency: POLL_INTERVAL });
113
+ }
114
+ return initial;
115
+ });
116
+ };
117
+ var run = sendAndPoll()
118
+ .catch(function (err: any) {
119
+ if (isAuthExpiredError(err)) return self.host.refreshSession().then(sendAndPoll);
120
+ throw err;
121
+ })
122
+ .then(function (response: any) {
123
+ if (isErrorResponseBody(response) && isAuthExpiredError(response)) {
124
+ return self.host.refreshSession().then(sendAndPoll);
125
+ }
126
+ return response;
127
+ })
128
+ .then(function (response: any) {
129
+ if (isErrorResponseBody(response)) return { content: getErrorMessage(response), isError: true };
130
+ var answer = (params.aiPlatform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response));
131
+ answer = (answer || '').trim();
132
+ return { content: answer || 'No text response received from AI provider.', isError: false };
133
+ })
134
+ .catch(function (err: any) { return { content: getErrorMessage(err), isError: true }; })
135
+ .then(function (result: any) {
136
+ // Append the resolved reply to the shared history cache. This runs
137
+ // independently of the view lifecycle, so a reply lands in the cache
138
+ // even if the view unmounted mid-request; the view renders it from
139
+ // the cache via typewriteLatestReply (and resumePendingRequest on
140
+ // remount). The displayed bubble in state.messages is updated by the
141
+ // caller's typewriteLatestReply, not here.
142
+ var existing = self.aiChatHistoryCache[params.key] || { messages: [], endOfList: false, startKeyHistory: [] };
143
+ self.aiChatHistoryCache[params.key] = {
144
+ messages: existing.messages.concat([{ role: 'assistant', content: result.content, isError: result.isError }]),
145
+ endOfList: existing.endOfList,
146
+ startKeyHistory: existing.startKeyHistory,
147
+ };
148
+ delete self.pendingAgentRequests[params.key];
149
+ return result;
150
+ });
151
+ this.pendingAgentRequests[params.key] = run;
152
+ return run;
153
+ }
154
+
155
+ // composed = clean display text; composedForLlm carries office-extraction
156
+ // placeholders for the provider only. useBgQueue routes a post-attachment turn
157
+ // onto the "-bg" queue so it runs after indexing.
158
+ dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any): void {
159
+ var self = this;
160
+ if (!composed) return;
161
+ var id = this.host.getIdentity();
162
+ if (id.platform === 'none') return;
163
+
164
+ var llmComposed = composedForLlm || composed;
165
+
166
+ var isQueuedSend = useBgQueue || this.state.sending || this.state.messages.some(function (m) {
167
+ return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
168
+ });
169
+
170
+ var aiPlatform = id.platform;
171
+ var aiModel = id.model || undefined;
172
+ var systemPrompt = this.host.buildSystemPrompt();
173
+ var userId = id.userId || id.serviceId;
174
+ var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
175
+
176
+ if (isQueuedSend) {
177
+ var resolvedHistory = this.state.messages.filter(function (m) {
178
+ return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
179
+ !m.isCancelled && !m.isBackgroundTask;
180
+ });
181
+ var boundedQ = buildBoundedChatMessages({
182
+ platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
183
+ history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
184
+ });
185
+ var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true };
186
+ if (useBgQueue) queuedBubble._useBgQueue = true;
187
+ this.state.messages.push(queuedBubble);
188
+ this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
189
+
190
+ var capturedComposed = composed, capturedPlatform = aiPlatform;
191
+ Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent))
192
+ .then(function (result: any) {
193
+ var sendingIdx = self.state.messages.findIndex(function (m) {
194
+ return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user';
195
+ });
196
+ var serverId = result && typeof result.id === 'string' ? result.id : undefined;
197
+ if (sendingIdx >= 0) {
198
+ var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false });
199
+ if (serverId) upd._serverItemId = serverId;
200
+ self.state.messages[sendingIdx] = upd; self.host.notify();
201
+ }
202
+ if (result && result.poll && (result.status === 'pending' || result.status === 'running')) {
203
+ return result.poll({ latency: POLL_INTERVAL })
204
+ .then(function (res: any) { return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId); })
205
+ .catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, serverId); });
206
+ }
207
+ return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId);
208
+ })
209
+ .catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined); });
210
+ return;
211
+ }
212
+
213
+ // immediate send — cache+resume model (mirrors agent.vue). The reply is
214
+ // appended to aiChatHistoryCache by dispatchAgentRequest (so it survives a
215
+ // view unmount), then rendered from the cache via typewriteLatestReply. A
216
+ // later resumePendingRequest() re-renders it if the view remounted while the
217
+ // request was still in flight.
218
+ this.state.messages.push({ role: 'user', content: composed });
219
+ this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true });
220
+ this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
221
+
222
+ var key = this.getHistoryCacheKey();
223
+ var historyForLlm = this.state.messages.filter(function (m) { return !m.isCancelled && !m.isBackgroundTask; });
224
+ if (llmComposed !== composed) {
225
+ for (var li = historyForLlm.length - 1; li >= 0; li--) {
226
+ if (historyForLlm[li].role === 'user' && historyForLlm[li].content === composed) {
227
+ historyForLlm[li] = Object.assign({}, historyForLlm[li], { content: llmComposed });
228
+ break;
229
+ }
230
+ }
231
+ }
232
+ var bounded = buildBoundedChatMessages({
233
+ platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
234
+ history: historyForLlm,
235
+ });
236
+ var requestToken = this.state.gateRefreshToken;
237
+ var run = this.dispatchAgentRequest({
238
+ key: key, serviceId: id.serviceId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
239
+ systemPrompt: systemPrompt, text: composed, boundedMessages: bounded.messages, userId: chatQueue,
240
+ extractContent: extractContent,
241
+ });
242
+ Promise.resolve(run).catch(function () { }).then(function () {
243
+ if (requestToken !== self.state.gateRefreshToken || self.getHistoryCacheKey() !== key) return;
244
+ self.state.sending = false;
245
+ return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottom(true); });
246
+ });
247
+ }
248
+
249
+ promoteNextBgQueuedToRunning(): void {
250
+ if (this.state.messages.some(function (m) { return m.isPending && m.role === 'assistant' && m.isBackgroundTask; })) return;
251
+ var nextIdx = this.state.messages.findIndex(function (m) {
252
+ return m.isPendingQueued && m.role === 'user' && m.isBackgroundTask;
253
+ });
254
+ if (nextIdx === -1) return;
255
+ var existing = this.state.messages[nextIdx];
256
+ var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
257
+ if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
258
+ this.state.messages[nextIdx] = promoted;
259
+ var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true };
260
+ if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
261
+ this.state.messages.splice(nextIdx + 1, 0, placeholder);
262
+ this.host.notify();
263
+ }
264
+
265
+ promoteNextQueuedToRunning(): void {
266
+ if (this.state.messages.some(function (m) { return m.isPending && m.role === 'assistant' && !m.isBackgroundTask; })) return;
267
+ var nextIdx = this.state.messages.findIndex(function (m) {
268
+ return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
269
+ });
270
+ if (nextIdx === -1) return;
271
+ var existing = this.state.messages[nextIdx];
272
+ var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
273
+ if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
274
+ if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
275
+ if (existing.isSendingToServer) promoted.isSendingToServer = true;
276
+ this.state.messages[nextIdx] = promoted;
277
+ this.state.messages.splice(nextIdx + 1, 0, { role: 'assistant', content: '', isPending: true });
278
+ this.host.notify();
279
+ }
280
+
281
+ resolveQueuedUserBubble(serverId?: string): number | undefined {
282
+ var userIdx = -1;
283
+ if (serverId) {
284
+ userIdx = this.state.messages.findIndex(function (m) {
285
+ return m._serverItemId === serverId && (m.isPendingInProcess || m.isPendingQueued) &&
286
+ m.role === 'user' && !m.isBackgroundTask;
287
+ });
288
+ }
289
+ if (userIdx === -1) {
290
+ userIdx = this.state.messages.findIndex(function (m) {
291
+ return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
292
+ });
293
+ }
294
+ if (userIdx === -1) {
295
+ userIdx = this.state.messages.findIndex(function (m) {
296
+ return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
297
+ });
298
+ }
299
+ if (serverId && this.cancelledServerIds.has(serverId)) {
300
+ this.cancelledServerIds.delete(serverId);
301
+ if (userIdx >= 0) {
302
+ var ex = this.state.messages[userIdx];
303
+ this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId };
304
+ var thIdx = this.state.messages.findIndex(function (m, i) {
305
+ return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
306
+ });
307
+ if (thIdx !== -1) this.state.messages.splice(thIdx, 1);
308
+ }
309
+ this.promoteNextQueuedToRunning();
310
+ return undefined;
311
+ }
312
+ if (userIdx >= 0) {
313
+ var exist = this.state.messages[userIdx];
314
+ var repl: ChatMessage = { role: 'user', content: exist.content };
315
+ if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
316
+ this.state.messages[userIdx] = repl;
317
+ }
318
+ var thinkingIdx = userIdx >= 0
319
+ ? this.state.messages.findIndex(function (m, i) { return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask; })
320
+ : -1;
321
+ return thinkingIdx !== -1 ? thinkingIdx : (userIdx >= 0 ? userIdx + 1 : -1);
322
+ }
323
+
324
+ insertAtTarget(msg: ChatMessage, targetIdx: number): void {
325
+ if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) this.state.messages[targetIdx] = msg;
326
+ else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
327
+ else this.state.messages.push(msg);
328
+ }
329
+
330
+ onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string): void {
331
+ var targetIdx = this.resolveQueuedUserBubble(serverId);
332
+ if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
333
+ if (isErrorResponseBody(response)) {
334
+ this.insertAtTarget({ role: 'assistant', content: getErrorMessage(response), isError: true }, targetIdx);
335
+ } else {
336
+ var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response));
337
+ answer = (answer || '').trim() || 'No text response received from AI provider.';
338
+ var lid = this._newLocalId();
339
+ if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) {
340
+ this.state.messages[targetIdx] = { role: 'assistant', content: '', _localId: lid };
341
+ this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid);
342
+ } else if (targetIdx >= 0) {
343
+ this.state.messages.splice(targetIdx, 0, { role: 'assistant', content: '', _localId: lid });
344
+ this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid);
345
+ } else {
346
+ var aiIdx = this.state.messages.length;
347
+ this.state.messages.push({ role: 'assistant', content: '', _localId: lid });
348
+ this.host.notify(); this.enqueueTypewrite(aiIdx, answer, lid);
349
+ }
350
+ }
351
+ this.promoteNextQueuedToRunning();
352
+ this.updateHistoryCache();
353
+ this.host.notify();
354
+ this.host.scrollToBottom(true);
355
+ }
356
+
357
+ onQueuedSendError(_composed: string, err: any, serverId?: string): void {
358
+ var self = this;
359
+ var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
360
+ if (isNotExists) {
361
+ var userIdx = serverId
362
+ ? this.state.messages.findIndex(function (m) { return m._serverItemId === serverId && (m.isPendingInProcess || m.isPendingQueued) && m.role === 'user' && !m.isBackgroundTask; })
363
+ : this.state.messages.findIndex(function (m) { return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue; });
364
+ if (!serverId && userIdx === -1) {
365
+ userIdx = this.state.messages.findIndex(function (m) { return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue; });
366
+ }
367
+ if (userIdx >= 0) {
368
+ var ex = this.state.messages[userIdx];
369
+ var repl: ChatMessage = { role: 'user', content: ex.content, isCancelled: true };
370
+ if (ex._serverItemId !== undefined) repl._serverItemId = ex._serverItemId;
371
+ this.state.messages[userIdx] = repl;
372
+ }
373
+ if (serverId) {
374
+ var thById = this.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.isPending && m.role === 'assistant' && !m.isBackgroundTask; });
375
+ if (thById !== -1) this.state.messages.splice(thById, 1);
376
+ else if (userIdx >= 0) {
377
+ var thPos = this.state.messages.findIndex(function (m, i) { return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask; });
378
+ if (thPos !== -1) this.state.messages.splice(thPos, 1);
379
+ }
380
+ } else if (userIdx >= 0) {
381
+ var thPos2 = this.state.messages.findIndex(function (m, i) { return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask; });
382
+ if (thPos2 !== -1) this.state.messages.splice(thPos2, 1);
383
+ }
384
+ if (serverId) this.cancelledServerIds.delete(serverId);
385
+ this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
386
+ return;
387
+ }
388
+ var targetIdx = this.resolveQueuedUserBubble(serverId);
389
+ if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
390
+ this.insertAtTarget({ role: 'assistant', content: getErrorMessage(err), isError: true }, targetIdx);
391
+ this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
392
+ }
393
+
394
+ cancelQueuedMessage(msg: ChatMessage, idx: number): void {
395
+ var self = this;
396
+ var id = this.host.getIdentity();
397
+ var serverId = msg._serverItemId;
398
+ if (!serverId || msg._cancelling) return;
399
+ var platform = id.platform;
400
+ if (platform !== 'claude' && platform !== 'openai') return;
401
+ var url = platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
402
+ var queueBase = id.userId || id.serviceId;
403
+ var queue = (msg.isBackgroundTask || msg._useBgQueue) ? queueBase + BG_INDEXING_QUEUE_SUFFIX : queueBase;
404
+ this.state.messages[idx] = Object.assign({}, msg, { _cancelling: true, _cancelError: undefined });
405
+ this.host.notify();
406
+ Promise.resolve(this.host.cancelRequest({
407
+ url: url, method: 'POST', id: serverId, queue: queue, service: id.serviceId, owner: id.owner,
408
+ })).then(function (result: any) {
409
+ if (result && result.removed) {
410
+ self.cancelledServerIds.add(serverId as string);
411
+ var qi = self.bgTaskQueue.findIndex(function (e) { return e.id === serverId; });
412
+ if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
413
+ var removeIdx = self.state.messages.findIndex(function (m) {
414
+ return m._serverItemId === serverId && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user';
415
+ });
416
+ if (removeIdx !== -1) {
417
+ self.state.messages[removeIdx] = { role: 'user', content: self.state.messages[removeIdx].content, isCancelled: true, _serverItemId: serverId };
418
+ var thById = self.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.isPending && m.role === 'assistant'; });
419
+ if (thById !== -1) self.state.messages.splice(thById, 1);
420
+ else {
421
+ var thPos = self.state.messages.findIndex(function (m, i) {
422
+ return i > removeIdx && m.isPending && m.role === 'assistant' &&
423
+ (msg.isBackgroundTask ? !!m.isBackgroundTask : !m.isBackgroundTask);
424
+ });
425
+ if (thPos !== -1) self.state.messages.splice(thPos, 1);
426
+ }
427
+ if (msg.isBackgroundTask) self.promoteNextBgQueuedToRunning(); else self.promoteNextQueuedToRunning();
428
+ self.updateHistoryCache();
429
+ }
430
+ self.host.notify();
431
+ } else {
432
+ var errMsg = result && typeof result.message === 'string' && result.message ? result.message : 'Could not remove from queue.';
433
+ var ci = self.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.role === 'user'; });
434
+ if (ci !== -1) { self.state.messages[ci] = Object.assign({}, self.state.messages[ci], { _cancelling: false, _cancelError: errMsg }); self.host.notify(); }
435
+ }
436
+ }).catch(function (err: any) {
437
+ var errMsg = err && typeof err.message === 'string' && err.message ? err.message : 'Could not remove from queue.';
438
+ var ci = self.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.role === 'user'; });
439
+ if (ci !== -1) { self.state.messages[ci] = Object.assign({}, self.state.messages[ci], { _cancelling: false, _cancelError: errMsg }); self.host.notify(); }
440
+ });
441
+ }
442
+
443
+ // --- typewriter -------------------------------------------------------
444
+ typewriteIntoIndex(idx: number, fullText: string, localId?: string): Promise<void> {
445
+ var self = this;
446
+ if (!fullText) return Promise.resolve();
447
+ var TICK_MS = 16, charsPerTick = 3, FENCE_REVEAL_MS = 200;
448
+ var fenceTicks = Math.max(1, Math.floor(FENCE_REVEAL_MS / TICK_MS));
449
+ var fenceRegions: Array<{ start: number; end: number }> = [], m;
450
+ var fenceRegex = /```[\w.-]+\.[a-zA-Z0-9]+\n[\s\S]*?```/g;
451
+ while ((m = fenceRegex.exec(fullText)) !== null) fenceRegions.push({ start: m.index, end: m.index + m[0].length });
452
+ var linkRegions: Array<{ start: number; end: number }> = [], lm;
453
+ var linkRegex = createInlineLinkRegex();
454
+ while ((lm = linkRegex.exec(fullText)) !== null) linkRegions.push({ start: lm.index, end: lm.index + lm[0].length });
455
+
456
+ this.state.typing = true; this.state.typingAbort = false;
457
+ var i = 0;
458
+ return (function loop(): Promise<void> {
459
+ if (self.state.typingAbort || i >= fullText.length) return Promise.resolve();
460
+ var step = charsPerTick;
461
+ var region = fenceRegions.find(function (r) { return i >= r.start && i < r.end; });
462
+ var linkRegion = linkRegions.find(function (r) { return i >= r.start && i < r.end; });
463
+ if (region) step = Math.max(charsPerTick, Math.ceil((region.end - i) / fenceTicks));
464
+ else if (linkRegion) step = Math.max(charsPerTick, linkRegion.end - i);
465
+ else {
466
+ var nextLink = linkRegions.find(function (r) { return i < r.start && i + step > r.start; });
467
+ if (nextLink) step = nextLink.end - i;
468
+ }
469
+ i = Math.min(fullText.length, i + step);
470
+ var currentIdx = localId ? self.state.messages.findIndex(function (mm) { return mm._localId === localId; }) : idx;
471
+ if (currentIdx === -1) return Promise.resolve();
472
+ var target = self.state.messages[currentIdx];
473
+ if (!target) return Promise.resolve();
474
+ target.content = fullText.slice(0, i);
475
+ self.host.refreshMessageBubble(currentIdx);
476
+ return Promise.resolve(self.host.scrollToBottomIfSticky()).then(function () { return sleep(TICK_MS); }).then(loop);
477
+ })().then(function () {
478
+ if (!self.state.typingAbort) {
479
+ var fi = localId ? self.state.messages.findIndex(function (mm) { return mm._localId === localId; }) : idx;
480
+ var t = fi !== -1 ? self.state.messages[fi] : self.state.messages[idx];
481
+ if (t) { t.content = fullText; self.host.refreshMessageBubble(fi !== -1 ? fi : idx); }
482
+ }
483
+ self.state.typing = false;
484
+ });
485
+ }
486
+
487
+ private typewriterQueue: Promise<any> = Promise.resolve();
488
+ enqueueTypewrite(idx: number, fullText: string, localId?: string): Promise<any> {
489
+ var self = this;
490
+ this.typewriterQueue = this.typewriterQueue.then(function () { return self.typewriteIntoIndex(idx, fullText, localId); });
491
+ return this.typewriterQueue;
492
+ }
493
+
494
+ // --- cache+resume immediate-send rendering -----------------------------
495
+ // Render the just-resolved reply (read from aiChatHistoryCache) into the
496
+ // pending assistant bubble, character-by-character. Runs AFTER the reply is
497
+ // already in the cache (dispatchAgentRequest appended it); errors are shown
498
+ // instantly (no typing). Promotes the next queued message immediately so its
499
+ // "Thinking…" bubble appears without waiting for this typewriter to finish.
500
+ typewriteLatestReply(key: string): Promise<any> {
501
+ var cached = this.aiChatHistoryCache[key];
502
+ if (!cached || !cached.messages.length) return Promise.resolve();
503
+ var latest: ChatMessage | undefined;
504
+ for (var i = cached.messages.length - 1; i >= 0; i--) {
505
+ var m = cached.messages[i];
506
+ if (m.role === 'assistant' && !m.isPending) { latest = m; break; }
507
+ }
508
+ if (!latest) return Promise.resolve();
509
+ var pendingIdx = this.state.messages.findIndex(function (mm) {
510
+ return mm.isPending && mm.role === 'assistant' && !mm.isBackgroundTask;
511
+ });
512
+ if (pendingIdx === -1) return Promise.resolve();
513
+ if (latest.isError || !latest.content) {
514
+ this.state.messages[pendingIdx] = { role: 'assistant', content: latest.content || '', isError: !!latest.isError };
515
+ this.host.notify();
516
+ this.promoteNextQueuedToRunning();
517
+ return Promise.resolve();
518
+ }
519
+ var lid = this._newLocalId();
520
+ this.state.messages[pendingIdx] = { role: 'assistant', content: '', isPending: false, _localId: lid };
521
+ this.host.notify();
522
+ this.promoteNextQueuedToRunning();
523
+ return this.enqueueTypewrite(pendingIdx, latest.content, lid);
524
+ }
525
+
526
+ // If an immediate-send request for the current cache key is still in flight
527
+ // (e.g. the view unmounted then remounted mid-request), show the sending
528
+ // state, await it, then render the reply from the cache. Skipped when the
529
+ // list already has its own pending/queued bubbles (those resolve via their
530
+ // own polls). The displayed reply also lands via dispatchComposedMessage's
531
+ // own finally if the view never unmounted — this is the remount recovery.
532
+ resumePendingRequest(token: number): Promise<void> {
533
+ var self = this;
534
+ var key = this.getHistoryCacheKey();
535
+ var pending = key ? this.pendingAgentRequests[key] : undefined;
536
+ if (!pending) return Promise.resolve();
537
+ if (this.state.messages.some(function (m) { return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue; })) return Promise.resolve();
538
+ this.state.sending = true;
539
+ this.host.scrollToBottom(true);
540
+ return Promise.resolve(pending).catch(function () { }).then(function () {
541
+ if (token !== self.state.gateRefreshToken) return;
542
+ self.state.sending = false;
543
+ return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottom(true); });
544
+ });
545
+ }
546
+
547
+ // --- background-task resolution + drain -------------------------------
548
+ handleHistoryItemResolution(itemId: string, response: any, platform: string): void {
549
+ this.applyHistoryItemResolution(itemId, response, platform);
550
+ this.promoteNextBgQueuedToRunning();
551
+ }
552
+
553
+ applyHistoryItemResolution(itemId: string, response: any, platform: string): void {
554
+ this.historyItemPolls.delete(itemId);
555
+ var isErr = isErrorResponseBody(response);
556
+ var answer = isErr ? getErrorMessage(response)
557
+ : ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim();
558
+ var idx = this.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === itemId; });
559
+ if (idx !== -1) {
560
+ if (isErr) {
561
+ this.state.messages[idx] = { role: 'assistant', content: answer, isError: true, _serverItemId: itemId };
562
+ this.host.notify(); this.updateHistoryCache(); return;
563
+ }
564
+ var lid = this._newLocalId();
565
+ this.state.messages[idx] = { role: 'assistant', content: '', _localId: lid, _serverItemId: itemId };
566
+ this.host.notify(); this.enqueueTypewrite(idx, answer || 'No text response received from AI provider.', lid);
567
+ this.updateHistoryCache(); return;
568
+ }
569
+ var userIdx = this.state.messages.findIndex(function (m) {
570
+ return m.role === 'user' && m._serverItemId === itemId && (m.isPendingQueued || m.isPendingInProcess);
571
+ });
572
+ if (userIdx === -1) return;
573
+ var ex = this.state.messages[userIdx];
574
+ this.state.messages[userIdx] = { role: 'user', content: ex.content, _serverItemId: itemId };
575
+ if (isErr) {
576
+ this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: answer, isError: true, _serverItemId: itemId });
577
+ this.host.notify(); this.updateHistoryCache(); return;
578
+ }
579
+ var lid2 = this._newLocalId();
580
+ this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: '', _localId: lid2, _serverItemId: itemId });
581
+ this.host.notify(); this.enqueueTypewrite(userIdx + 1, answer || 'No text response received from AI provider.', lid2);
582
+ this.updateHistoryCache();
583
+ }
584
+
585
+ // Inject "Indexing: <file>" bubbles for queued bg tasks + attach their polls.
586
+ drainBgTaskQueue(): void {
587
+ var self = this;
588
+ var id = this.host.getIdentity();
589
+ var svcId = id.serviceId, plat = id.platform;
590
+ if (!svcId || plat === 'none' || !this.host.isViewMounted()) return;
591
+ for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
592
+ var e = this.bgTaskQueue[i];
593
+ if (e.serviceId !== svcId || e.platform !== plat) continue;
594
+ var present = this.state.messages.some(function (m) { return m._serverItemId === e.id; });
595
+ var stillPending = this.state.messages.some(function (m) {
596
+ return m._serverItemId === e.id && (m.isPending || m.isPendingInProcess || m.isPendingQueued);
597
+ });
598
+ if (present && !stillPending) this.bgTaskQueue.splice(i, 1);
599
+ }
600
+ this.bgTaskQueue.forEach(function (entry) {
601
+ if (entry.serviceId !== svcId || entry.platform !== plat) return;
602
+ if (self.state.messages.some(function (m) { return m._serverItemId === entry.id; })) return;
603
+ var isRunning = entry.status === 'running';
604
+ var userBubble: ChatMessage = { role: 'user', content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex), isBackgroundTask: true, _serverItemId: entry.id };
605
+ if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
606
+ self.state.messages.push(userBubble);
607
+ if (isRunning) {
608
+ self.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
609
+ }
610
+ self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottom(false);
611
+ if (!self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
612
+ self.historyItemPolls.set(entry.id, true);
613
+ var capturedId = entry.id, capturedPlat = plat;
614
+ entry.poll({ latency: POLL_INTERVAL }).then(function (response: any) {
615
+ self.handleHistoryItemResolution(capturedId, response, capturedPlat);
616
+ }).catch(function (err: any) {
617
+ self.historyItemPolls.delete(capturedId);
618
+ var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
619
+ var bi = self.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === capturedId; });
620
+ if (bi !== -1) {
621
+ if (isNotExists) self.state.messages.splice(bi, 1);
622
+ else self.state.messages[bi] = { role: 'assistant', content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId };
623
+ self.host.notify(); self.updateHistoryCache();
624
+ }
625
+ }).then(function () {
626
+ var qi = self.bgTaskQueue.findIndex(function (q) { return q.id === capturedId; });
627
+ if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
628
+ });
629
+ }
630
+ });
631
+ this.promoteNextBgQueuedToRunning();
632
+ }
633
+
634
+ // --- history fetch + pagination --------------------------------------
635
+ // Initial load (fetchMore=false) replaces the list (with in-flight rescue +
636
+ // cancelled-merge) and attaches polls to running/pending items; pagination
637
+ // (fetchMore=true) prepends older messages. The DOM scroll-restore for the
638
+ // older-prepend is the VIEW's job (it captures the pre-prepend scroll position
639
+ // and restores after this resolves) — the engine never measures the DOM.
640
+ loadHistory(fetchMore?: boolean, token?: number): Promise<void> {
641
+ var self = this;
642
+ var id = this.host.getIdentity();
643
+ if (token === undefined) token = this.state.gateRefreshToken;
644
+ if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.serviceId) {
645
+ return Promise.resolve();
646
+ }
647
+ this.state.historyRequestToken = token;
648
+ this.state.loadingHistory = true;
649
+ if (fetchMore) this.state.loadingOlderHistory = true;
650
+ this.host.notify(); // surface "Fetching history..." while it loads
651
+ var platform = id.platform as 'claude' | 'openai';
652
+ var serviceId = id.serviceId, owner = id.owner;
653
+ var options: any = { fetchMore: fetchMore };
654
+ if (fetchMore && this.state.historyStartKeyHistory.length) options.startKeyHistory = this.state.historyStartKeyHistory.slice();
655
+
656
+ var fetchHistory = function () { return getChatHistory({ service: serviceId, owner: owner, platform: platform }, options); };
657
+
658
+ return Promise.resolve().then(fetchHistory).catch(function (err: any) {
659
+ if (isAuthExpiredError(err)) return self.host.refreshSession().then(fetchHistory);
660
+ throw err;
661
+ }).then(function (history: any) {
662
+ if (token !== self.state.gateRefreshToken) return;
663
+ var chatList = history && Array.isArray(history.list) ? history.list : [];
664
+ chatList.forEach(function (item: any) {
665
+ if (typeof item.queue_name === 'string' && item.queue_name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX) {
666
+ var userText = extractLastUserTextFromRequest(item.request_body);
667
+ if (typeof userText === 'string' && userText.indexOf('A new file has just been uploaded') === 0) item._isBgTask = true;
668
+ else item._isOnBgQueue = true;
669
+ }
670
+ });
671
+ var list = chatList.sort(function (a: any, b: any) {
672
+ var ai = typeof a.id === 'string' ? a.id : '', bi = typeof b.id === 'string' ? b.id : '';
673
+ return ai > bi ? -1 : (ai < bi ? 1 : 0);
674
+ });
675
+ var mapped = mapHistoryListToMessages(list, platform, {
676
+ clearedAt: self.host.getClearedAt(),
677
+ serviceId: id.serviceId,
678
+ formatIndexingLabel: self.host.formatIndexingLabel,
679
+ }).messages;
680
+
681
+ if (fetchMore) {
682
+ self.state.messages = mapped.concat(self.state.messages);
683
+ } else {
684
+ if (self.state.typing) self.state.typingAbort = true;
685
+ var serverIds: any = {};
686
+ mapped.forEach(function (m: any) { if (m._serverItemId) serverIds[m._serverItemId] = 1; });
687
+ var locallyCancelled: any = {};
688
+ self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = 1; });
689
+ var rescued: ChatMessage[] = [];
690
+ for (var ri = 0; ri < self.state.messages.length; ri++) {
691
+ var mm = self.state.messages[ri];
692
+ if (mm.isBackgroundTask) continue;
693
+ if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
694
+ if (!mm._serverItemId) {
695
+ if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
696
+ else if (self.state.sending && mm.role === 'user') {
697
+ var next = self.state.messages[ri + 1];
698
+ if (next && !next.isBackgroundTask && next.isPending && !next._serverItemId) rescued.push(mm);
699
+ }
700
+ }
701
+ }
702
+ self.state.messages = mapped;
703
+ rescued.forEach(function (m) { self.state.messages.push(m); });
704
+ if (Object.keys(locallyCancelled).length) {
705
+ for (var ci = 0; ci < self.state.messages.length; ci++) {
706
+ var c = self.state.messages[ci];
707
+ if (!c._serverItemId || !locallyCancelled[c._serverItemId] || c.isCancelled) continue;
708
+ self.state.messages[ci] = { role: 'user', content: c.content, isCancelled: true, _serverItemId: c._serverItemId };
709
+ if (ci + 1 < self.state.messages.length && self.state.messages[ci + 1].isPending && self.state.messages[ci + 1]._serverItemId === c._serverItemId) {
710
+ self.state.messages.splice(ci + 1, 1);
711
+ }
712
+ }
713
+ }
714
+ }
715
+ self.state.historyEndOfList = !!(history && history.endOfList);
716
+ self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
717
+ var clearedAt = self.host.getClearedAt();
718
+ if (clearedAt && chatList.length > 0) {
719
+ var oldestUpdated = Number(chatList[chatList.length - 1] && chatList[chatList.length - 1].updated);
720
+ if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
721
+ }
722
+ // Clear loading flags BEFORE this render so the final paint is
723
+ // indicator-free (and the view's scroll-restore math sees matching heights).
724
+ if (self.state.historyRequestToken === token) { self.state.loadingHistory = false; self.state.loadingOlderHistory = false; }
725
+ self.updateHistoryCache();
726
+ self.host.notify();
727
+
728
+ if (!fetchMore) {
729
+ chatList.forEach(function (item: any) {
730
+ if (item.status !== 'running' && item.status !== 'pending') return;
731
+ if (!item.poll || !item.id) return;
732
+ if (self.historyItemPolls.has(item.id)) return;
733
+ if (item.status === 'running' && self.pendingAgentRequests[self.getHistoryCacheKey()]) return;
734
+ self.historyItemPolls.set(item.id, true);
735
+ var capturedId = item.id;
736
+ var pp = item.poll({
737
+ latency: POLL_INTERVAL,
738
+ onResponse: function (response: any) { self.handleHistoryItemResolution(capturedId, response, platform); },
739
+ onError: function (err: any) {
740
+ self.historyItemPolls.delete(capturedId);
741
+ var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
742
+ var aIdx = self.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === capturedId; });
743
+ if (isNotExists) {
744
+ var isBg = aIdx !== -1 ? !!self.state.messages[aIdx].isBackgroundTask : false;
745
+ if (aIdx !== -1) self.state.messages.splice(aIdx, 1);
746
+ if (!isBg) {
747
+ var uIdx = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId && !m.isCancelled; });
748
+ if (uIdx !== -1) { var ex = self.state.messages[uIdx]; self.state.messages[uIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId }; }
749
+ self.cancelledServerIds.delete(capturedId); self.promoteNextQueuedToRunning();
750
+ }
751
+ self.host.notify(); self.updateHistoryCache(); return;
752
+ }
753
+ if (aIdx !== -1) {
754
+ var wasBg = self.state.messages[aIdx].isBackgroundTask;
755
+ self.state.messages[aIdx] = { role: 'assistant', content: getErrorMessage(err), isError: true };
756
+ if (wasBg) self.state.messages[aIdx].isBackgroundTask = true;
757
+ self.host.notify(); self.updateHistoryCache();
758
+ }
759
+ },
760
+ });
761
+ if (pp && pp.catch) pp.catch(function () { });
762
+ });
763
+ self.drainBgTaskQueue();
764
+ }
765
+
766
+ if (!fetchMore) return self.host.scrollToBottom();
767
+ }).catch(function (err: any) {
768
+ console.warn('[chat-engine] getChatHistory failed', err);
769
+ }).then(function () {
770
+ if (self.state.historyRequestToken === token) {
771
+ var wasLoading = self.state.loadingHistory || self.state.loadingOlderHistory;
772
+ self.state.loadingHistory = false; self.state.loadingOlderHistory = false;
773
+ if (wasLoading) self.host.notify();
774
+ }
775
+ });
776
+ }
777
+
778
+ // --- attachment upload orchestration ---------------------------------
779
+ // Upload one attachment (a file = 1 member, a folder = N) to db storage and
780
+ // queue indexing per member. The bytes I/O + chip rendering go through host
781
+ // hooks; the overwrite/reindex flow, status lifecycle, and indexing live here.
782
+ uploadSingleAttachment(att: any): Promise<Array<{ name: string; url: string; storagePath: string }>> {
783
+ var self = this;
784
+ var id = this.host.getIdentity();
785
+ att.status = 'uploading'; att.progress = 0; att.errorMessage = '';
786
+ this.host.renderAttachmentChips();
787
+ var members = (att.kind === 'folder')
788
+ ? (att.files || []).map(function (f: any) { return { file: f.file, relPath: f.path, storagePath: self.host.storagePathFor(f.path) }; })
789
+ : [{ file: att.file, relPath: att.name, storagePath: this.host.storagePathFor(att.name) }];
790
+ var total = members.length;
791
+ if (!total) return Promise.reject(new Error('Empty attachment'));
792
+ var urls: Array<{ name: string; url: string; storagePath: string }> = [];
793
+ var anyIndexFailed = false;
794
+ var chain: Promise<any> = Promise.resolve();
795
+ members.forEach(function (member: any, idx: number) {
796
+ chain = chain.then(function () {
797
+ var hadExists = false;
798
+ var onProg = function (p: any) {
799
+ if (p && p.total) {
800
+ att.progress = Math.floor(((idx + p.loaded / p.total) / total) * 100);
801
+ self.host.renderAttachmentChips();
802
+ }
803
+ };
804
+ var doMemberUpload = function (checkExistence: boolean) {
805
+ return self.host.uploadFile({
806
+ file: member.file, storagePath: member.storagePath, checkExistence: checkExistence,
807
+ onProgress: onProg, setAbort: function (abort) { att._abort = abort; },
808
+ });
809
+ };
810
+ return doMemberUpload(true).catch(function (err: any) {
811
+ var code = err && (err.code || (err.body && err.body.code));
812
+ var msg = err && (err.message || (err.body && err.body.message) || (typeof err === 'string' ? err : ''));
813
+ var isExists = code === 'EXISTS' || (msg && /exist/i.test(msg));
814
+ if (!isExists) throw err; // a member upload failed → whole attachment fails (red)
815
+ return self.host.promptOverwrite(member.file.name).then(function (choice) {
816
+ if (choice === 'overwrite') return doMemberUpload(false); // replace the existing file
817
+ hadExists = true; // keep it; reindex only
818
+ });
819
+ }).then(function () {
820
+ return self.host.getTemporaryUrl(member.storagePath);
821
+ }).then(function (url: string) {
822
+ urls.push({ name: member.relPath, url: url, storagePath: member.storagePath });
823
+ if (att.kind !== 'folder') { att.uploadedUrl = url; att.storagePath = member.storagePath; }
824
+ var mime = member.file.type || self.host.getMimeType(member.file.name);
825
+ return notifyAgentSaveAttachment({
826
+ platform: id.platform as 'claude' | 'openai',
827
+ model: id.model,
828
+ service: id.serviceId,
829
+ owner: id.owner,
830
+ userId: id.userId || id.serviceId,
831
+ serviceName: id.serviceName,
832
+ serviceDescription: id.serviceDescription,
833
+ attachment: {
834
+ name: member.file.name, storagePath: member.storagePath,
835
+ mime: mime || undefined, size: member.file.size, url: url,
836
+ },
837
+ }).then(function (ack: any) {
838
+ if (ack && typeof ack.id === 'string') {
839
+ self.bgTaskQueue.push({
840
+ serviceId: id.serviceId, platform: id.platform as 'claude' | 'openai', id: ack.id,
841
+ filename: member.file.name,
842
+ storagePath: member.storagePath,
843
+ isReindex: hadExists,
844
+ mime: mime || undefined,
845
+ size: member.file.size,
846
+ status: ack.status === 'running' ? 'running' : 'pending',
847
+ poll: ack.poll,
848
+ });
849
+ self.drainBgTaskQueue(); // surface "Indexing: <file>" as soon as THIS file uploads
850
+ }
851
+ }, function (e: any) {
852
+ console.error('[chat-engine] indexing request failed', e);
853
+ anyIndexFailed = true; // uploaded but not indexed → yellow
854
+ });
855
+ });
856
+ });
857
+ });
858
+ return chain.then(function () {
859
+ att._abort = null; att.progress = 100;
860
+ if (att.kind === 'folder') att.uploadedUrls = urls.map(function (u) { return { path: u.name, url: u.url, storagePath: u.storagePath }; });
861
+ att.status = anyIndexFailed ? 'indexError' : 'done';
862
+ if (att.status === 'indexError') att.errorMessage = 'File indexing failed';
863
+ self.host.renderAttachmentChips();
864
+ return urls;
865
+ });
866
+ }
867
+
868
+ // Upload all not-yet-done attachments sequentially. Resolves to the full
869
+ // list of { name, url, storagePath } for composing the chat message.
870
+ uploadPendingAttachments(): Promise<Array<{ name: string; url: string; storagePath?: string }>> {
871
+ var self = this;
872
+ this.host.resetOverwriteBatch();
873
+ this.state.uploadingAttachments = true;
874
+ this.host.updateComposerControls();
875
+ this.host.renderAttachmentChips();
876
+ var collected: Array<{ name: string; url: string; storagePath?: string }> = [];
877
+ var snapshot = this.state.attachments.slice();
878
+ var chain: Promise<any> = Promise.resolve();
879
+ snapshot.forEach(function (att: any) {
880
+ chain = chain.then(function () {
881
+ if (!self.state.attachments.some(function (a: any) { return a.id === att.id; })) return; // removed
882
+ if (att.status === 'done' || att.status === 'indexError') {
883
+ if (att.kind === 'folder' && att.uploadedUrls) {
884
+ att.uploadedUrls.forEach(function (u: any) { collected.push({ name: u.path, url: u.url, storagePath: u.storagePath }); });
885
+ return;
886
+ }
887
+ if (att.uploadedUrl) { collected.push({ name: att.name, url: att.uploadedUrl, storagePath: att.storagePath }); return; }
888
+ }
889
+ return self.uploadSingleAttachment(att).then(function (us) {
890
+ collected.push.apply(collected, us);
891
+ }).catch(function (err: any) {
892
+ var removed = !self.state.attachments.some(function (a: any) { return a.id === att.id; });
893
+ var aborted = err && (err.message === 'Aborted' || err === 'Aborted');
894
+ if (removed || aborted) return;
895
+ att.status = 'error';
896
+ att.errorMessage = 'File upload has failed';
897
+ self.host.renderAttachmentChips();
898
+ });
899
+ });
900
+ });
901
+ var done = function () {
902
+ self.state.uploadingAttachments = false; self.host.updateComposerControls(); self.host.renderAttachmentChips();
903
+ return collected;
904
+ };
905
+ return chain.then(done, done);
906
+ }
907
+
908
+ // Stop timers / abort the typewriter (view teardown).
909
+ stop(): void {
910
+ this.state.typingAbort = true;
911
+ }
912
+
913
+ // Bump the gate token so any in-flight immediate-send result is dropped
914
+ // (called by the view on a service/platform switch or history clear).
915
+ bumpGate(): void {
916
+ this.state.gateRefreshToken += 1;
917
+ }
918
+ }