bunnyquery 1.7.0 → 1.8.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/bunnyquery.css +12 -0
- package/bunnyquery.js +65 -10
- package/dist/engine.cjs +63 -6
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +80 -11
- package/dist/engine.d.ts +80 -11
- package/dist/engine.mjs +61 -7
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/history.ts +10 -0
- package/src/engine/host.ts +6 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/indexing_groups.ts +50 -11
- package/src/engine/links.ts +35 -1
- package/src/engine/prompts/chat_system_prompt.ts +3 -0
- package/src/engine/session.ts +18 -3
- package/src/engine/time.ts +34 -0
- package/styles/chat.css +12 -0
package/package.json
CHANGED
package/src/engine/history.ts
CHANGED
|
@@ -64,6 +64,13 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
64
64
|
var assistantText = isPending ? '' : ((extractAssistantText(response) || '').trim() || '');
|
|
65
65
|
var isErrorResponse = !isPending && (isFailed || isErrorResponseBody(response));
|
|
66
66
|
var serverItemId = item && typeof item.id === 'string' && item.id ? item.id : undefined;
|
|
67
|
+
// A USER bubble shows when the request was made (`created`); an ASSISTANT
|
|
68
|
+
// bubble shows when its response landed (`updated`). Fall back to the other
|
|
69
|
+
// if one is missing (older records only carried `updated`).
|
|
70
|
+
var createdTs = Number(item && item.created);
|
|
71
|
+
var updatedTs = Number(item && item.updated);
|
|
72
|
+
var userTs = isFinite(createdTs) && createdTs > 0 ? createdTs : (isFinite(updatedTs) && updatedTs > 0 ? updatedTs : undefined);
|
|
73
|
+
var replyTs = isFinite(updatedTs) && updatedTs > 0 ? updatedTs : (isFinite(createdTs) && createdTs > 0 ? createdTs : undefined);
|
|
67
74
|
|
|
68
75
|
if (userText) {
|
|
69
76
|
var displayContent;
|
|
@@ -110,6 +117,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
110
117
|
if (indexFile) userMsg._indexFile = indexFile;
|
|
111
118
|
if (item._isOnBgQueue) userMsg._useBgQueue = true;
|
|
112
119
|
if (serverItemId !== undefined) userMsg._serverItemId = serverItemId;
|
|
120
|
+
if (userTs !== undefined) userMsg._ts = userTs;
|
|
113
121
|
mapped.push(userMsg);
|
|
114
122
|
}
|
|
115
123
|
if (isCancelledItem) { /* no assistant bubble */ }
|
|
@@ -123,6 +131,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
123
131
|
var em: any = { role: 'assistant', content: getErrorMessage(response), isError: true };
|
|
124
132
|
if (item._isBgTask) em.isBackgroundTask = true;
|
|
125
133
|
if (serverItemId !== undefined) em._serverItemId = serverItemId;
|
|
134
|
+
if (replyTs !== undefined) em._ts = replyTs;
|
|
126
135
|
mapped.push(em);
|
|
127
136
|
} else if (assistantText) {
|
|
128
137
|
// Safe db-only sanitize (forAssistant) so a volatile db url the model
|
|
@@ -130,6 +139,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
130
139
|
var okm: any = { role: 'assistant', content: sanitizeAttachmentLinksForHistory(assistantText, opts.serviceId, true) };
|
|
131
140
|
if (item._isBgTask) okm.isBackgroundTask = true;
|
|
132
141
|
if (serverItemId !== undefined) okm._serverItemId = serverItemId;
|
|
142
|
+
if (replyTs !== undefined) okm._ts = replyTs;
|
|
133
143
|
mapped.push(okm);
|
|
134
144
|
}
|
|
135
145
|
});
|
package/src/engine/host.ts
CHANGED
|
@@ -67,6 +67,12 @@ export interface ChatMessage {
|
|
|
67
67
|
_localId?: string;
|
|
68
68
|
_cancelling?: boolean;
|
|
69
69
|
_cancelError?: string;
|
|
70
|
+
/** Epoch ms shown as small text under the bubble. From the request history a
|
|
71
|
+
* USER bubble carries the request's `created` time and an ASSISTANT bubble the
|
|
72
|
+
* `updated` (response) time; a live bubble is stamped with the wall clock when
|
|
73
|
+
* it is created, then reconciled to the server value on the next history load.
|
|
74
|
+
* Absent while a turn is still pending, so no time shows on a "Thinking" bubble. */
|
|
75
|
+
_ts?: number;
|
|
70
76
|
// History cache key (`serviceId#platform`) this bubble was created under.
|
|
71
77
|
// Stamped on LOCALLY-created bubbles only (the optimistic user message and
|
|
72
78
|
// its "Thinking..." placeholder); server-mapped bubbles are identified by
|
package/src/engine/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ export * from './prompts';
|
|
|
46
46
|
export { getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError } from './errors';
|
|
47
47
|
export * from './budget';
|
|
48
48
|
export * from './links';
|
|
49
|
+
export * from './time';
|
|
49
50
|
export {
|
|
50
51
|
filterListByClearHorizon,
|
|
51
52
|
normalizeTextContent,
|
|
@@ -7,12 +7,34 @@
|
|
|
7
7
|
* repeating forever, and any real question the user asks in between gets buried.
|
|
8
8
|
*
|
|
9
9
|
* buildChatDisplayList turns the flat message array into a DISPLAY list in which
|
|
10
|
-
* every message belonging to one file (however far apart
|
|
11
|
-
* else is interleaved between them) is represented by a single
|
|
12
|
-
* rendered at the position of that
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* every message belonging to one run of one file (however far apart the passes
|
|
11
|
+
* sit, and whatever else is interleaved between them) is represented by a single
|
|
12
|
+
* group entry, rendered at the position of that run's FIRST loaded pass.
|
|
13
|
+
*
|
|
14
|
+
* First, not newest. The message array is ordered by request CREATION time, and
|
|
15
|
+
* a run's later passes are created one at a time as the previous one resolves —
|
|
16
|
+
* by the client for the paged text path, and by the WORKER itself for the
|
|
17
|
+
* rendered-page (PDF) path, which the client only learns about on its next
|
|
18
|
+
* first-page history fetch. So a pass is routinely created minutes after the
|
|
19
|
+
* upload, on a queue that runs in parallel with the foreground chat. Anchoring
|
|
20
|
+
* at the newest pass meant one such late pass dragged the whole run — every
|
|
21
|
+
* earlier pass with it — below any question the user had asked in the meantime,
|
|
22
|
+
* and made a run that had visibly finished before the question render after it.
|
|
23
|
+
* Anchoring at the first pass puts the row where the run actually began, which
|
|
24
|
+
* is a position no later pass can change:
|
|
25
|
+
* - a new pass never relocates the row, so nothing under the reader shifts;
|
|
26
|
+
* - a question asked after the upload always renders below the row, which is
|
|
27
|
+
* the order it happened in.
|
|
28
|
+
* The cost is that a long-running index does not follow the conversation to the
|
|
29
|
+
* bottom: once the user has chatted past the upload, the spinner and the Stop
|
|
30
|
+
* button sit above their newest turns. That is a scroll away, and the row no
|
|
31
|
+
* longer lies about when the work started.
|
|
32
|
+
*
|
|
33
|
+
* Paging older history is the one thing that CAN move the row: an older page
|
|
34
|
+
* carrying earlier passes of a run already on screen moves it up into that page.
|
|
35
|
+
* That happens at most once per run, only for a run whose start was never
|
|
36
|
+
* loaded (`mayHaveOlder`), and it is the same event that already re-derives
|
|
37
|
+
* `runKey` — so the view treats it as a new row either way.
|
|
16
38
|
*
|
|
17
39
|
* The group deliberately reports no authoritative pass TOTAL. History is paged
|
|
18
40
|
* newest-first, so any total computed from loaded messages is a lower bound that
|
|
@@ -37,9 +59,10 @@ export type IndexingGroup = {
|
|
|
37
59
|
* Monday and re-indexed on Wednesday is two runs, and collapsing them into
|
|
38
60
|
* one row erased Monday's from Monday's place in the conversation, claimed
|
|
39
61
|
* its passes for Wednesday, and let Monday's failure be overwritten by
|
|
40
|
-
* Wednesday's success.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
62
|
+
* Wednesday's success. Named after the run's FIRST loaded pass (see where it
|
|
63
|
+
* is assigned below), so passes appended to the run and other runs appearing
|
|
64
|
+
* on either side of it never rename a row already on screen. This is the
|
|
65
|
+
* render key and the expansion key. */
|
|
43
66
|
runKey: string;
|
|
44
67
|
name: string;
|
|
45
68
|
path?: string;
|
|
@@ -65,8 +88,16 @@ export type IndexingGroup = {
|
|
|
65
88
|
/** The file's first pass is not among the loaded messages, so earlier passes
|
|
66
89
|
* exist in history that has not been paged in yet. */
|
|
67
90
|
mayHaveOlder: boolean;
|
|
68
|
-
/** Position in the source array this collapsed row renders at
|
|
91
|
+
/** Position in the source array this collapsed row renders at: the index of
|
|
92
|
+
* the run's FIRST loaded pass (see the file docstring for why not the last). */
|
|
69
93
|
anchorIndex: number;
|
|
94
|
+
/** Identity of the turn at `anchorIndex` — its server item id, or its local id
|
|
95
|
+
* while it has none, or `''` when it has neither. The views stamp this on the
|
|
96
|
+
* row (`data-row-pos`) so the scroll anchor can tell a row that RELOCATED (an
|
|
97
|
+
* older page moved the run's start) from one that merely gained a pass. They
|
|
98
|
+
* must not re-derive it from `members`: which member the row renders at is
|
|
99
|
+
* this module's decision, and the two silently disagreed once already. */
|
|
100
|
+
anchorId: string;
|
|
70
101
|
};
|
|
71
102
|
|
|
72
103
|
export type DisplayEntry =
|
|
@@ -230,7 +261,10 @@ export function buildChatDisplayList(
|
|
|
230
261
|
cancellableIds: [],
|
|
231
262
|
cancelling: false,
|
|
232
263
|
mayHaveOlder: false,
|
|
264
|
+
// The run's first loaded pass, and never re-stamped: see the file
|
|
265
|
+
// docstring. `anchorId` is filled in once every member is known.
|
|
233
266
|
anchorIndex: i,
|
|
267
|
+
anchorId: '',
|
|
234
268
|
};
|
|
235
269
|
order.push(runId);
|
|
236
270
|
}
|
|
@@ -246,7 +280,6 @@ export function buildChatDisplayList(
|
|
|
246
280
|
g.passCount++;
|
|
247
281
|
}
|
|
248
282
|
g.members.push({ msg: msg, index: i });
|
|
249
|
-
g.anchorIndex = i;
|
|
250
283
|
runOfIndex[i] = runId;
|
|
251
284
|
if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
|
|
252
285
|
if (ref && ref.name) keyByName[ref.name] = g.key;
|
|
@@ -334,6 +367,12 @@ export function buildChatDisplayList(
|
|
|
334
367
|
if (pref && !pref.continued) { sawFirstPass = true; break; }
|
|
335
368
|
}
|
|
336
369
|
grp.mayHaveOlder = !sawFirstPass && hasMoreHistory;
|
|
370
|
+
// Where the row renders, and WHICH turn that is. Derived from the same
|
|
371
|
+
// member so the two can never drift apart; a group always has a member
|
|
372
|
+
// (it is created by the first one).
|
|
373
|
+
var anchor = grp.members[0];
|
|
374
|
+
grp.anchorIndex = anchor.index;
|
|
375
|
+
grp.anchorId = anchor.msg._serverItemId || anchor.msg._localId || '';
|
|
337
376
|
}
|
|
338
377
|
|
|
339
378
|
var out: DisplayEntry[] = [];
|
package/src/engine/links.ts
CHANGED
|
@@ -183,6 +183,33 @@ export function repairUrlWhitespace(href: string): string {
|
|
|
183
183
|
return href.trim().replace(/\s/g, '%20');
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* A model reproducing a URL sometimes HTML-escapes its `&` query separators as
|
|
188
|
+
* `&` (or the numeric `&` / `&`). Left in the href that escaping
|
|
189
|
+
* survives the client's own escapeAttr -> v-html/innerHTML decode round-trip and
|
|
190
|
+
* reaches the browser LITERALLY, so a presigned S3 URL is navigated with its
|
|
191
|
+
* parameters named `amp;Signature`, `amp;Expires`, `amp;response-content-type`,
|
|
192
|
+
* ... — the real params vanish, the signature can't be located, and S3 rejects
|
|
193
|
+
* it (the "링크가 안되" dead export link). Undo just that entity escaping.
|
|
194
|
+
*
|
|
195
|
+
* This is a no-op on a clean URL: a valid link carries a raw `&` between params
|
|
196
|
+
* and percent-encodes (`%26`) any literal ampersand that is data, so a real URL
|
|
197
|
+
* never contains `&` to begin with. Mirrors repairUrlWhitespace: it repairs
|
|
198
|
+
* model damage, not the URL. The loop collapses a doubly-escaped `&` too.
|
|
199
|
+
*/
|
|
200
|
+
export function repairUrlEntities(href: string): string {
|
|
201
|
+
if (!href || href.indexOf('&') === -1) return href;
|
|
202
|
+
var out = href, prev = '';
|
|
203
|
+
while (out !== prev) {
|
|
204
|
+
prev = out;
|
|
205
|
+
out = out
|
|
206
|
+
.replace(/&/gi, '&')
|
|
207
|
+
.replace(/�*38;/g, '&')
|
|
208
|
+
.replace(/�*26;/gi, '&');
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
186
213
|
/**
|
|
187
214
|
* Trim punctuation and unmatched wrappers that cling to a token in prose.
|
|
188
215
|
* `src::a/b.pdf).` -> `src::a/b.pdf`, while a balanced `file (v2).pdf` is kept.
|
|
@@ -280,8 +307,11 @@ export function classifyInlineLink(
|
|
|
280
307
|
// source into the storage-path branch, where it became a chip pointing at
|
|
281
308
|
// this project for someone else's file.
|
|
282
309
|
if (srcIsUrl && !isDbHost(rawPath) && !readExpiredAttachmentHref(rawPath)) {
|
|
310
|
+
// decode any model-introduced `&` in the URL (tail stays keyed on
|
|
311
|
+
// the raw match length, so it is left untouched)
|
|
312
|
+
var srcUrl = repairUrlEntities(rawPath);
|
|
283
313
|
return {
|
|
284
|
-
part: { type: 'link', label: truncateLabelForDisplay(
|
|
314
|
+
part: { type: 'link', label: truncateLabelForDisplay(srcUrl), fullLabel: srcUrl, href: srcUrl, expired: false },
|
|
285
315
|
tail: tail,
|
|
286
316
|
};
|
|
287
317
|
}
|
|
@@ -337,6 +367,10 @@ export function classifyInlineLink(
|
|
|
337
367
|
// [label](url) and bare urls.
|
|
338
368
|
var originalHref = g3 || g6 || '';
|
|
339
369
|
if (!originalHref) return null;
|
|
370
|
+
// A model that reproduces the URL may have HTML-escaped its `&` separators;
|
|
371
|
+
// decode them now so every downstream check and the final href see a clean
|
|
372
|
+
// URL (otherwise a presigned link navigates with `&` and 403s).
|
|
373
|
+
originalHref = repairUrlEntities(originalHref);
|
|
340
374
|
// A bare url swallows the punctuation that ends the sentence it sits in, so
|
|
341
375
|
// `see https://host/a.pdf.` linked to `a.pdf.` and 404'd. Trim it and hand the
|
|
342
376
|
// trimmed text back as `tail`, exactly as the src:: branch does.
|
|
@@ -26,6 +26,9 @@ export function buildChatSystemPrompt(params: ChatSystemPromptParams): string {
|
|
|
26
26
|
You are a dedicated assistant for the project ID: "${formattedServiceId}".
|
|
27
27
|
Scope: Only answer questions about this project and its data. Do not answer questions about other projects or topics unrelated to this project. When the user refers to "my database", "my data", or "my files", treat those as references to this project's database and file storage.
|
|
28
28
|
Knowledge lookup: Before saying you don't know or that something isn't in the chat history, ALWAYS query this project's database through the available MCP tools to look for the answer. The user's data is the source of truth - the chat transcript is not. Only respond with "I don't know" or "I couldn't find that" after you have actually searched the project's data and come back empty.
|
|
29
|
+
Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records that usually share a table. So a question about the data almost always spans many records across several files. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "없어?", "하나도 없나?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY relevant table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset.
|
|
30
|
+
Never assert absence from a partial read. Do not say "there is no X", "none", "not found", or "아니요, 없습니다" until a complete scan has come back empty. If you have not finished scanning every relevant table and file, keep querying instead of guessing. A confident "no" that later turns out wrong is worse than telling the user you are still checking.
|
|
31
|
+
Embedded values: a search term is often stored inside a larger string. A merchant "GODADDY" appears as "DNH*GODADDY#4070277042", and a card as "4140****2941". Server-side index and tag filters match only exact values or leading prefixes, not substrings, so filtering on such a field silently drops rows. When the value you are looking for may be embedded, do not trust a narrow filter to be complete. Fetch the full set with fetch_all and match the substring yourself.
|
|
29
32
|
File attachments: When a user message contains an "Attached files:" section with markdown links, those links point to short-lived signed URLs in this project's db storage and will expire.
|
|
30
33
|
- Image files (.jpg, .jpeg, .png, .gif, .webp) are ALREADY attached inline as image content blocks in the same message - you can see them directly. Do NOT call web_fetch on image URLs; that will fail or return garbage. Just look at the image block and answer.
|
|
31
34
|
- Most attached files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) have ALREADY had their text extracted on the server and inlined in the same message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for those files. A "[skapi: ...]" note in that block means the file could not be extracted.
|
package/src/engine/session.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, ge
|
|
|
40
40
|
import { buildBoundedChatMessages } from './budget';
|
|
41
41
|
import { createInlineLinkRegex } from './links';
|
|
42
42
|
import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
|
|
43
|
+
import { wallClockNow } from './time';
|
|
43
44
|
import { parseAttachmentContent } from './attachment_parsers';
|
|
44
45
|
import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
|
|
45
46
|
import type { IndexingGroup } from './indexing_groups';
|
|
@@ -477,7 +478,7 @@ export class ChatSession {
|
|
|
477
478
|
var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
|
|
478
479
|
this.aiChatHistoryCache[key] = {
|
|
479
480
|
messages: offExisting.messages.concat([
|
|
480
|
-
{ role: 'user', content: composed, _ownerKey: key },
|
|
481
|
+
{ role: 'user', content: composed, _ownerKey: key, _ts: wallClockNow() },
|
|
481
482
|
{ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
|
|
482
483
|
]),
|
|
483
484
|
endOfList: offExisting.endOfList,
|
|
@@ -500,7 +501,7 @@ export class ChatSession {
|
|
|
500
501
|
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
|
|
501
502
|
history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
|
|
502
503
|
});
|
|
503
|
-
var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true };
|
|
504
|
+
var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
|
|
504
505
|
if (key) queuedBubble._ownerKey = key;
|
|
505
506
|
if (useBgQueue) queuedBubble._useBgQueue = true;
|
|
506
507
|
this.state.messages.push(queuedBubble);
|
|
@@ -542,7 +543,7 @@ export class ChatSession {
|
|
|
542
543
|
// view unmount), then rendered from the cache via typewriteLatestReply. A
|
|
543
544
|
// later resumePendingRequest() re-renders it if the view remounted while the
|
|
544
545
|
// request was still in flight.
|
|
545
|
-
this.state.messages.push({ role: 'user', content: composed, ...(key ? { _ownerKey: key } : {}) });
|
|
546
|
+
this.state.messages.push({ role: 'user', content: composed, _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) });
|
|
546
547
|
this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
|
|
547
548
|
this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
|
|
548
549
|
|
|
@@ -598,6 +599,7 @@ export class ChatSession {
|
|
|
598
599
|
var existing = this.state.messages[nextIdx];
|
|
599
600
|
var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
|
|
600
601
|
if (existing._indexFile) promoted._indexFile = existing._indexFile;
|
|
602
|
+
if (existing._ts !== undefined) promoted._ts = existing._ts;
|
|
601
603
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
602
604
|
if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
|
|
603
605
|
this.state.messages[nextIdx] = promoted;
|
|
@@ -618,6 +620,7 @@ export class ChatSession {
|
|
|
618
620
|
var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
|
|
619
621
|
if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
|
|
620
622
|
if (existing._indexFile) promoted._indexFile = existing._indexFile;
|
|
623
|
+
if (existing._ts !== undefined) promoted._ts = existing._ts;
|
|
621
624
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
622
625
|
if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
|
|
623
626
|
if (existing.isSendingToServer) promoted.isSendingToServer = true;
|
|
@@ -677,6 +680,7 @@ export class ChatSession {
|
|
|
677
680
|
var repl: ChatMessage = { role: 'user', content: exist.content };
|
|
678
681
|
if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
|
|
679
682
|
if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
|
|
683
|
+
if (exist._ts !== undefined) repl._ts = exist._ts;
|
|
680
684
|
this.state.messages[userIdx] = repl;
|
|
681
685
|
}
|
|
682
686
|
var thinkingIdx = userIdx >= 0
|
|
@@ -686,6 +690,9 @@ export class ChatSession {
|
|
|
686
690
|
}
|
|
687
691
|
|
|
688
692
|
insertAtTarget(msg: ChatMessage, targetIdx: number): void {
|
|
693
|
+
// Error/direct replies land here rather than through the typewriter, so this
|
|
694
|
+
// is where they pick up their display time.
|
|
695
|
+
if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = wallClockNow();
|
|
689
696
|
if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) this.state.messages[targetIdx] = msg;
|
|
690
697
|
else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
|
|
691
698
|
else this.state.messages.push(msg);
|
|
@@ -1000,6 +1007,12 @@ export class ChatSession {
|
|
|
1000
1007
|
private typewriterQueue: Promise<any> = Promise.resolve();
|
|
1001
1008
|
enqueueTypewrite(idx: number, fullText: string, localId?: string): Promise<any> {
|
|
1002
1009
|
var self = this;
|
|
1010
|
+
// Stamp the reply bubble's display time as it starts revealing. This is the
|
|
1011
|
+
// single chokepoint every typed reply (immediate, queued, and bg-resolution)
|
|
1012
|
+
// passes through, so it is where a live reply gets its "when it arrived"
|
|
1013
|
+
// timestamp; a history reload later replaces it with the server `updated`.
|
|
1014
|
+
var target = this.state.messages[idx];
|
|
1015
|
+
if (target && target._ts === undefined) target._ts = wallClockNow();
|
|
1003
1016
|
this.typewriterQueue = this.typewriterQueue.then(function () { return self.typewriteIntoIndex(idx, fullText, localId); });
|
|
1004
1017
|
return this.typewriterQueue;
|
|
1005
1018
|
}
|
|
@@ -1068,6 +1081,7 @@ export class ChatSession {
|
|
|
1068
1081
|
var u = this.state.messages[uIdx];
|
|
1069
1082
|
var cleaned: ChatMessage = { role: 'user', content: u.content, _serverItemId: itemId };
|
|
1070
1083
|
if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
|
|
1084
|
+
if (u._ts !== undefined) cleaned._ts = u._ts;
|
|
1071
1085
|
// Carry the file ref: it is what keeps this pass in its file's collapsed
|
|
1072
1086
|
// row (the label parser is only a fallback for older cached bubbles).
|
|
1073
1087
|
if (u._indexFile) cleaned._indexFile = u._indexFile;
|
|
@@ -1156,6 +1170,7 @@ export class ChatSession {
|
|
|
1156
1170
|
// bg-queued chat cancelling against the right queue) once the pass settles.
|
|
1157
1171
|
var settledUser: ChatMessage = { role: 'user', content: ex.content, _serverItemId: itemId };
|
|
1158
1172
|
if (ex.isBackgroundTask) settledUser.isBackgroundTask = true;
|
|
1173
|
+
if (ex._ts !== undefined) settledUser._ts = ex._ts;
|
|
1159
1174
|
if (ex._indexFile) settledUser._indexFile = ex._indexFile;
|
|
1160
1175
|
if (ex._useBgQueue) settledUser._useBgQueue = true;
|
|
1161
1176
|
this.state.messages[userIdx] = settledUser;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat timestamp formatting, shared so agent.vue and the widget render an
|
|
3
|
+
* identical "small text under the bubble". Pure and locale-aware: it formats a
|
|
4
|
+
* given epoch-ms value, it never reads the current time, so it stays testable and
|
|
5
|
+
* DOM-free.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Wall-clock epoch ms. Separate from the engine's monotonic nowMs() (which is
|
|
9
|
+
* performance.now() when available and therefore NOT epoch): a displayed
|
|
10
|
+
* timestamp must be wall time. */
|
|
11
|
+
export function wallClockNow(): number {
|
|
12
|
+
return Date.now();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* "Jul 24, 2026, 3:42:07 PM" (locale-formatted). Empty string for a missing or
|
|
17
|
+
* non-finite value, so a caller can gate rendering on the result being truthy and
|
|
18
|
+
* a pending bubble (no timestamp yet) simply shows nothing.
|
|
19
|
+
*/
|
|
20
|
+
export function formatChatTimestamp(ms?: number): string {
|
|
21
|
+
if (typeof ms !== 'number' || !isFinite(ms) || ms <= 0) return '';
|
|
22
|
+
try {
|
|
23
|
+
return new Date(ms).toLocaleString(undefined, {
|
|
24
|
+
year: 'numeric',
|
|
25
|
+
month: 'short',
|
|
26
|
+
day: 'numeric',
|
|
27
|
+
hour: 'numeric',
|
|
28
|
+
minute: '2-digit',
|
|
29
|
+
second: '2-digit',
|
|
30
|
+
});
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
}
|
package/styles/chat.css
CHANGED
|
@@ -76,6 +76,18 @@
|
|
|
76
76
|
font-style: italic;
|
|
77
77
|
clear: both;
|
|
78
78
|
}
|
|
79
|
+
/* Timestamp under a settled bubble (created for a user turn, response time for an
|
|
80
|
+
assistant turn). Muted and small so it never competes with the message. */
|
|
81
|
+
.bq-msg-time {
|
|
82
|
+
display: block;
|
|
83
|
+
margin-top: 0.25rem;
|
|
84
|
+
font-size: 0.62rem;
|
|
85
|
+
line-height: 1;
|
|
86
|
+
color: var(--bq-muted);
|
|
87
|
+
opacity: 0.7;
|
|
88
|
+
clear: both;
|
|
89
|
+
}
|
|
90
|
+
.bq-message.is-user .bq-msg-time { text-align: right; }
|
|
79
91
|
.bq-cancel-queue-btn {
|
|
80
92
|
float: right;
|
|
81
93
|
margin: -0.25rem -0.25rem 0.2rem 0.4rem;
|