@yeaft/webchat-agent 0.1.820 → 0.1.822
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/unify/attachments.js +49 -2
- package/unify/conversation/persist.js +12 -0
- package/unify/debug-trace.js +28 -5
- package/unify/web-bridge.js +94 -12
package/package.json
CHANGED
package/unify/attachments.js
CHANGED
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
* than swallowing it in a console.warn.
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
44
|
-
import { basename, extname, join } from 'node:path';
|
|
43
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync } from 'node:fs';
|
|
44
|
+
import { basename, extname, join, resolve, relative, isAbsolute } from 'node:path';
|
|
45
45
|
import { randomBytes } from 'node:crypto';
|
|
46
46
|
|
|
47
47
|
// Same dir name Chat mode uses, so ".gitignore" rules and tool-side
|
|
@@ -54,6 +54,7 @@ const TEMP_UPLOAD_DIR = '.claude-tmp-attachments';
|
|
|
54
54
|
// - MAX_TOTAL_BYTES: 50 MiB across all files in one turn.
|
|
55
55
|
export const MAX_FILES_PER_TURN = 16;
|
|
56
56
|
export const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
57
|
+
export const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
60
|
* Sanitize a user-supplied filename's basename for use as an on-disk
|
|
@@ -228,3 +229,49 @@ export function attachmentsForPersistence(promptAttachments) {
|
|
|
228
229
|
isImage: !!f.isImage,
|
|
229
230
|
}));
|
|
230
231
|
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolve a persisted Unify attachment path to an on-disk file. The persisted
|
|
235
|
+
* path is intentionally relative (for tool use), so preview hydration must
|
|
236
|
+
* keep it inside the upload root instead of serving arbitrary files.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} attachmentPath
|
|
239
|
+
* @param {{ cwd?: string }} [opts]
|
|
240
|
+
* @returns {string|null}
|
|
241
|
+
*/
|
|
242
|
+
export function resolvePersistedAttachmentPath(attachmentPath, opts = {}) {
|
|
243
|
+
if (!attachmentPath || typeof attachmentPath !== 'string') return null;
|
|
244
|
+
if (isAbsolute(attachmentPath)) return null;
|
|
245
|
+
const cwd = resolve(opts.cwd || process.cwd());
|
|
246
|
+
const uploadRoot = resolve(cwd, TEMP_UPLOAD_DIR);
|
|
247
|
+
const absPath = resolve(cwd, attachmentPath);
|
|
248
|
+
const rel = relative(uploadRoot, absPath);
|
|
249
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return null;
|
|
250
|
+
return absPath;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Read a persisted image attachment into a short-lived preview payload for the
|
|
255
|
+
* web server. Returns null for non-images, bad paths, missing files, or files
|
|
256
|
+
* too large to cache as previews.
|
|
257
|
+
*
|
|
258
|
+
* @param {{name?:string, path?:string, mimeType?:string, isImage?:boolean}} att
|
|
259
|
+
* @param {{ cwd?: string }} [opts]
|
|
260
|
+
* @returns {{data:string,mimeType:string,filename:string}|null}
|
|
261
|
+
*/
|
|
262
|
+
export function persistedAttachmentPreviewPayload(att, opts = {}) {
|
|
263
|
+
if (!att || !att.isImage || !att.path) return null;
|
|
264
|
+
const absPath = resolvePersistedAttachmentPath(att.path, opts);
|
|
265
|
+
if (!absPath) return null;
|
|
266
|
+
try {
|
|
267
|
+
const st = statSync(absPath);
|
|
268
|
+
if (!st.isFile() || st.size > MAX_PREVIEW_BYTES) return null;
|
|
269
|
+
return {
|
|
270
|
+
data: readFileSync(absPath).toString('base64'),
|
|
271
|
+
mimeType: att.mimeType || 'application/octet-stream',
|
|
272
|
+
filename: att.name || basename(absPath),
|
|
273
|
+
};
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -107,6 +107,12 @@ function serializeMessage(msg) {
|
|
|
107
107
|
// the speaker so the UI can render the message on the correct VP track.
|
|
108
108
|
// For real user messages this is unset.
|
|
109
109
|
if (msg.speakerVpId) fm.push(`speakerVpId: ${msg.speakerVpId}`);
|
|
110
|
+
if (Array.isArray(msg.attachments) && msg.attachments.length > 0) {
|
|
111
|
+
try {
|
|
112
|
+
const b64 = Buffer.from(JSON.stringify(msg.attachments)).toString('base64');
|
|
113
|
+
fm.push(`attachmentsB64: ${b64}`);
|
|
114
|
+
} catch { /* best-effort: attachments are UI metadata, not engine-critical */ }
|
|
115
|
+
}
|
|
110
116
|
// Internal/synthetic rows must round-trip so refresh/history replay can
|
|
111
117
|
// keep them out of the user-visible conversation. Reflection folding uses
|
|
112
118
|
// `_reflection`; other engine-only rows may use one of the explicit flags.
|
|
@@ -213,6 +219,12 @@ export function parseMessage(raw) {
|
|
|
213
219
|
case 'sourceThreadId': msg.sourceThreadId = value; break;
|
|
214
220
|
case 'groupId': msg.groupId = value; break;
|
|
215
221
|
case 'speakerVpId': msg.speakerVpId = value; break;
|
|
222
|
+
case 'attachmentsB64':
|
|
223
|
+
try {
|
|
224
|
+
const parsed = JSON.parse(Buffer.from(value, 'base64').toString('utf8'));
|
|
225
|
+
if (Array.isArray(parsed)) msg.attachments = parsed;
|
|
226
|
+
} catch { /* best-effort: ignore malformed attachment metadata */ }
|
|
227
|
+
break;
|
|
216
228
|
case '_reflection': msg._reflection = value === 'true'; break;
|
|
217
229
|
case 'internal': msg.internal = value === 'true'; break;
|
|
218
230
|
case 'systemOnly': msg.systemOnly = value === 'true'; break;
|
package/unify/debug-trace.js
CHANGED
|
@@ -364,11 +364,14 @@ export class DebugTrace {
|
|
|
364
364
|
* fields the panel expects. JSON columns are parsed; truncated /
|
|
365
365
|
* malformed payloads degrade to null instead of failing the call.
|
|
366
366
|
*
|
|
367
|
-
* @param {{ limit?: number, groupId?: string|null, threadId?: string|null }} [opts]
|
|
368
|
-
* @returns {{ loops: object[], turns: object[] }}
|
|
367
|
+
* @param {{ limit?: number, dreamLimit?: number, groupId?: string|null, threadId?: string|null }} [opts]
|
|
368
|
+
* @returns {{ loops: object[], turns: object[], dreamEvents: object[] }}
|
|
369
369
|
*/
|
|
370
|
-
fetchRecentDebugHistory({ limit = 100, groupId = null, threadId = null } = {}) {
|
|
370
|
+
fetchRecentDebugHistory({ limit = 100, dreamLimit = 5, groupId = null, threadId = null } = {}) {
|
|
371
371
|
const lim = Math.max(1, Math.min(500, Number(limit) || 100));
|
|
372
|
+
const dreamLim = Number.isFinite(Number(dreamLimit))
|
|
373
|
+
? Math.max(0, Math.min(50, Number(dreamLimit)))
|
|
374
|
+
: 5;
|
|
372
375
|
const where = [];
|
|
373
376
|
const args = [];
|
|
374
377
|
if (groupId) { where.push('group_id = ?'); args.push(groupId); }
|
|
@@ -471,10 +474,30 @@ export class DebugTrace {
|
|
|
471
474
|
isError: !!tool.is_error,
|
|
472
475
|
});
|
|
473
476
|
}
|
|
477
|
+
const dreamEvents = [];
|
|
478
|
+
if (dreamLim > 0) {
|
|
479
|
+
const eventRows = this.#db.prepare(`
|
|
480
|
+
SELECT * FROM trace_events WHERE event_type = 'dream_progress' ORDER BY created_at DESC LIMIT ?
|
|
481
|
+
`).all(Math.max(dreamLim * 5, dreamLim));
|
|
482
|
+
for (const er of eventRows) {
|
|
483
|
+
const data = parseJsonSafe(er.event_data) || {};
|
|
484
|
+
const evtGroupId = typeof data.groupId === 'string' && data.groupId ? data.groupId : null;
|
|
485
|
+
const target = typeof data.target === 'string' ? data.target : '';
|
|
486
|
+
if (groupId) {
|
|
487
|
+
const isBroadcast = !evtGroupId && !target;
|
|
488
|
+
const isThisGroup = evtGroupId === groupId || target === `group/${groupId}`;
|
|
489
|
+
if (!isBroadcast && !isThisGroup) continue;
|
|
490
|
+
}
|
|
491
|
+
dreamEvents.push({ type: 'dream_progress', ...data, at: er.created_at, ts: data.ts || er.created_at });
|
|
492
|
+
if (dreamEvents.length >= dreamLim) break;
|
|
493
|
+
}
|
|
494
|
+
dreamEvents.reverse();
|
|
495
|
+
}
|
|
496
|
+
|
|
474
497
|
// Reverse to oldest-first so the panel's existing append-driven UI
|
|
475
498
|
// renders in chronological order on hydration.
|
|
476
499
|
loops.reverse();
|
|
477
|
-
return { loops, turns: Array.from(turnsById.values()) };
|
|
500
|
+
return { loops, turns: Array.from(turnsById.values()), dreamEvents };
|
|
478
501
|
}
|
|
479
502
|
|
|
480
503
|
/**
|
|
@@ -624,7 +647,7 @@ export class NullTrace {
|
|
|
624
647
|
cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0 }; }
|
|
625
648
|
purge() {}
|
|
626
649
|
close() {}
|
|
627
|
-
fetchRecentDebugHistory() { return { loops: [], turns: [] }; }
|
|
650
|
+
fetchRecentDebugHistory() { return { loops: [], turns: [], dreamEvents: [] }; }
|
|
628
651
|
}
|
|
629
652
|
|
|
630
653
|
/**
|
package/unify/web-bridge.js
CHANGED
|
@@ -52,7 +52,7 @@ import { seedDefaultGroup } from './groups/seed-default.js';
|
|
|
52
52
|
import {
|
|
53
53
|
trimSnapshotForBudget,
|
|
54
54
|
} from './history-compact.js';
|
|
55
|
-
import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
|
|
55
|
+
import { persistUnifyAttachments, attachmentsForPersistence, persistedAttachmentPreviewPayload } from './attachments.js';
|
|
56
56
|
import { parseSeqFromId } from './conversation/persist.js';
|
|
57
57
|
import { sliceLastNTurns } from './turn-utils.js';
|
|
58
58
|
import { createVpStatusBroker } from './vp-status-broker.js';
|
|
@@ -497,6 +497,56 @@ function makeGroupContextStub() {
|
|
|
497
497
|
};
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Parse persisted content that may have been stringified from provider content
|
|
502
|
+
* blocks, then return only user-visible text. Image/file binary blocks are UI
|
|
503
|
+
* metadata and must never be rendered as bubble text; attachment chips ride in
|
|
504
|
+
* `attachments` instead.
|
|
505
|
+
*
|
|
506
|
+
* @param {unknown} content
|
|
507
|
+
* @returns {string}
|
|
508
|
+
*/
|
|
509
|
+
export function __testNormalizePersistedVisibleContent(content) {
|
|
510
|
+
let value = content;
|
|
511
|
+
if (typeof value === 'string') {
|
|
512
|
+
const trimmed = value.trim();
|
|
513
|
+
if ((trimmed.startsWith('[') && trimmed.endsWith(']')) || (trimmed.startsWith('{') && trimmed.endsWith('}'))) {
|
|
514
|
+
try { value = JSON.parse(trimmed); } catch { value = content; }
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (Array.isArray(value)) {
|
|
519
|
+
return value
|
|
520
|
+
.map((part) => {
|
|
521
|
+
if (typeof part === 'string') return part;
|
|
522
|
+
if (!part || typeof part !== 'object') return '';
|
|
523
|
+
if (part.type === 'text' && typeof part.text === 'string') return part.text;
|
|
524
|
+
if (part.type === 'input_text' && typeof part.text === 'string') return part.text;
|
|
525
|
+
return '';
|
|
526
|
+
})
|
|
527
|
+
.join('')
|
|
528
|
+
.replace(/\n\n\[Uploaded files\][\s\S]*$/m, '')
|
|
529
|
+
.trim();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (value && typeof value === 'object') {
|
|
533
|
+
if (typeof value.text === 'string') return value.text.trim();
|
|
534
|
+
if (typeof value.content === 'string') return value.content.trim();
|
|
535
|
+
return '';
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return typeof value === 'string'
|
|
539
|
+
? value.replace(/\n\n\[Uploaded files\][\s\S]*$/m, '').trim()
|
|
540
|
+
: '';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function isPersistedInternalMessage(m) {
|
|
544
|
+
if (!m) return true;
|
|
545
|
+
if (m._reflection || m.internal || m.systemOnly || m.systemOnlyMessage) return true;
|
|
546
|
+
if (m.kind === 'compact_summary' || m._compactSummary) return true;
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
|
|
500
550
|
/**
|
|
501
551
|
* Project a persisted message record into the in-memory history shape.
|
|
502
552
|
* Accepts `role:'tool'` and preserves `toolCalls`/`toolCallId` so the
|
|
@@ -509,8 +559,8 @@ function makeGroupContextStub() {
|
|
|
509
559
|
function projectPersistedToHistoryEntry(m) {
|
|
510
560
|
if (!m) return null;
|
|
511
561
|
if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') return null;
|
|
512
|
-
if (m
|
|
513
|
-
const entry = { role: m.role, content: m.content };
|
|
562
|
+
if (isPersistedInternalMessage(m)) return null;
|
|
563
|
+
const entry = { role: m.role, content: m.role === 'tool' ? m.content : __testNormalizePersistedVisibleContent(m.content) };
|
|
514
564
|
if (m.id) entry.id = m.id;
|
|
515
565
|
if (m.groupId) entry.groupId = m.groupId;
|
|
516
566
|
if (m.speakerVpId) entry.speakerVpId = m.speakerVpId;
|
|
@@ -523,6 +573,9 @@ function projectPersistedToHistoryEntry(m) {
|
|
|
523
573
|
}));
|
|
524
574
|
}
|
|
525
575
|
if (m.isError) entry.isError = true;
|
|
576
|
+
if (m.ts) entry.ts = m.ts;
|
|
577
|
+
if (Array.isArray(m.attachments) && m.attachments.length > 0) entry.attachments = m.attachments;
|
|
578
|
+
if ((entry.role === 'user' || entry.role === 'assistant') && !entry.content && !entry.attachments) return null;
|
|
526
579
|
return entry;
|
|
527
580
|
}
|
|
528
581
|
|
|
@@ -531,6 +584,16 @@ function projectPersistedToVisibleHistoryEntry(m) {
|
|
|
531
584
|
return entry && (entry.role === 'user' || entry.role === 'assistant') ? entry : null;
|
|
532
585
|
}
|
|
533
586
|
|
|
587
|
+
function hydrateHistoryAttachmentPreviews(attachments) {
|
|
588
|
+
if (!Array.isArray(attachments) || attachments.length === 0) return [];
|
|
589
|
+
return attachments.map((att) => {
|
|
590
|
+
if (!att || typeof att !== 'object') return att;
|
|
591
|
+
if (!att.isImage || att.preview || att.previewData) return att;
|
|
592
|
+
const payload = persistedAttachmentPreviewPayload(att);
|
|
593
|
+
return payload ? { ...att, previewData: payload } : att;
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
534
597
|
function loadVisibleGroupHistoryPage(store, groupId, limit, beforeSeq = null) {
|
|
535
598
|
if (!store || !groupId || !(limit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
|
|
536
599
|
|
|
@@ -902,6 +965,7 @@ async function routeEnvelopeToVpThread(groupId, vpId, envelope) {
|
|
|
902
965
|
threadId: thread.threadId,
|
|
903
966
|
role: envelope?.msg?.meta?.injectedBy === 'route_forward' ? 'assistant' : 'user',
|
|
904
967
|
speakerVpId: envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null,
|
|
968
|
+
attachments: Array.isArray(envelope?.msg?.meta?.attachments) ? envelope.msg.meta.attachments : [],
|
|
905
969
|
});
|
|
906
970
|
thread.updatedAt = Date.now();
|
|
907
971
|
try {
|
|
@@ -987,6 +1051,7 @@ function ensureDriverRunning(groupId, vpId, threadId = 'main') {
|
|
|
987
1051
|
threadId: thread.threadId,
|
|
988
1052
|
role: isForward ? 'assistant' : 'user',
|
|
989
1053
|
speakerVpId: senderVpId,
|
|
1054
|
+
attachments: Array.isArray(meta.attachments) ? meta.attachments : [],
|
|
990
1055
|
});
|
|
991
1056
|
}
|
|
992
1057
|
} catch { /* never crash WS pipeline */ }
|
|
@@ -2857,17 +2922,17 @@ function appendTurnToGroupHistory(groupId, threadId, prompts, assistantTextParts
|
|
|
2857
2922
|
* Best-effort: a write failure does NOT abort the turn — engines can
|
|
2858
2923
|
* still run, and the next user message will trigger another append.
|
|
2859
2924
|
*
|
|
2860
|
-
* Note: we mirror `engine.#persistMessages`'s
|
|
2861
|
-
*
|
|
2862
|
-
*
|
|
2863
|
-
*
|
|
2864
|
-
*
|
|
2925
|
+
* Note: we mirror `engine.#persistMessages`'s core user-row fields
|
|
2926
|
+
* (role/content/threadId/groupId) so existing parsers keep working.
|
|
2927
|
+
* Attachment UI metadata is persisted separately (without base64) so
|
|
2928
|
+
* refresh replay can render chips without leaking image source data into
|
|
2929
|
+
* the message body.
|
|
2865
2930
|
*
|
|
2866
|
-
* @param {{ msgId:string, text:string, groupId:string, role?:string, speakerVpId?:string|null }} args
|
|
2931
|
+
* @param {{ msgId:string, text:string, groupId:string, role?:string, speakerVpId?:string|null, attachments?:Array<object> }} args
|
|
2867
2932
|
* @returns {boolean} true if this call wrote the row, false if a prior
|
|
2868
2933
|
* call already wrote it (dedup hit).
|
|
2869
2934
|
*/
|
|
2870
|
-
function persistInboundMessageOnceByMsgId({ msgId, text, groupId, threadId = 'main', role, speakerVpId }) {
|
|
2935
|
+
function persistInboundMessageOnceByMsgId({ msgId, text, groupId, threadId = 'main', role, speakerVpId, attachments }) {
|
|
2871
2936
|
if (!session?.conversationStore) return false;
|
|
2872
2937
|
// No msgId means no dedup key — caller is responsible for guarding.
|
|
2873
2938
|
// Both call sites already do (`if (envMsgId && text)` and
|
|
@@ -2918,6 +2983,9 @@ function persistInboundMessageOnceByMsgId({ msgId, text, groupId, threadId = 'ma
|
|
|
2918
2983
|
if (persistRole === 'assistant' && speakerVpId && typeof speakerVpId === 'string') {
|
|
2919
2984
|
record.speakerVpId = speakerVpId;
|
|
2920
2985
|
}
|
|
2986
|
+
if (persistRole === 'user' && Array.isArray(attachments) && attachments.length > 0) {
|
|
2987
|
+
record.attachments = attachments;
|
|
2988
|
+
}
|
|
2921
2989
|
session.conversationStore.append(record);
|
|
2922
2990
|
return true;
|
|
2923
2991
|
} catch (err) {
|
|
@@ -3357,21 +3425,25 @@ export async function handleUnifyFetchToolStats(_msg = {}) {
|
|
|
3357
3425
|
*/
|
|
3358
3426
|
export async function handleUnifyFetchDebugHistory(msg = {}) {
|
|
3359
3427
|
const limit = Number.isFinite(msg?.limit) ? Number(msg.limit) : 100;
|
|
3428
|
+
const dreamLimit = Number.isFinite(msg?.dreamLimit) ? Number(msg.dreamLimit) : 5;
|
|
3360
3429
|
const groupId = typeof msg?.groupId === 'string' && msg.groupId ? msg.groupId : null;
|
|
3361
3430
|
const threadId = typeof msg?.threadId === 'string' && msg.threadId ? msg.threadId : null;
|
|
3362
3431
|
let loops = [];
|
|
3363
3432
|
let turns = [];
|
|
3433
|
+
let dreamEvents = [];
|
|
3364
3434
|
try {
|
|
3365
3435
|
if (session?.trace && typeof session.trace.fetchRecentDebugHistory === 'function') {
|
|
3366
|
-
const out = session.trace.fetchRecentDebugHistory({ limit, groupId, threadId });
|
|
3436
|
+
const out = session.trace.fetchRecentDebugHistory({ limit, dreamLimit, groupId, threadId });
|
|
3367
3437
|
loops = Array.isArray(out?.loops) ? out.loops : [];
|
|
3368
3438
|
turns = Array.isArray(out?.turns) ? out.turns : [];
|
|
3439
|
+
dreamEvents = Array.isArray(out?.dreamEvents) ? out.dreamEvents : [];
|
|
3369
3440
|
}
|
|
3370
3441
|
} catch (err) {
|
|
3371
3442
|
sendToServer({
|
|
3372
3443
|
type: 'unify_debug_history',
|
|
3373
3444
|
loops: [],
|
|
3374
3445
|
turns: [],
|
|
3446
|
+
dreamEvents: [],
|
|
3375
3447
|
error: err && err.message ? err.message : String(err),
|
|
3376
3448
|
});
|
|
3377
3449
|
return;
|
|
@@ -3380,6 +3452,7 @@ export async function handleUnifyFetchDebugHistory(msg = {}) {
|
|
|
3380
3452
|
type: 'unify_debug_history',
|
|
3381
3453
|
loops,
|
|
3382
3454
|
turns,
|
|
3455
|
+
dreamEvents,
|
|
3383
3456
|
groupId,
|
|
3384
3457
|
threadId,
|
|
3385
3458
|
});
|
|
@@ -3493,7 +3566,15 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
3493
3566
|
|
|
3494
3567
|
for (const entry of replayEntries) {
|
|
3495
3568
|
if (entry.role === 'user') {
|
|
3496
|
-
sendUnifyOutput({
|
|
3569
|
+
sendUnifyOutput({
|
|
3570
|
+
type: 'user',
|
|
3571
|
+
message: {
|
|
3572
|
+
content: entry.content,
|
|
3573
|
+
id: entry.id || null,
|
|
3574
|
+
...(Array.isArray(entry.attachments) && entry.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(entry.attachments) } : {}),
|
|
3575
|
+
},
|
|
3576
|
+
ts: entry.ts || null,
|
|
3577
|
+
}, { groupId: entry.groupId || null });
|
|
3497
3578
|
} else if (entry.role === 'assistant') {
|
|
3498
3579
|
// speakerVpId rides on the envelope so the frontend can route this
|
|
3499
3580
|
// replayed assistant text to the correct VP track. Without it, the
|
|
@@ -3584,6 +3665,7 @@ export async function handleUnifyLoadMoreHistory(msg) {
|
|
|
3584
3665
|
role: m.role,
|
|
3585
3666
|
content: m.content,
|
|
3586
3667
|
groupId: m.groupId || null,
|
|
3668
|
+
...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments) } : {}),
|
|
3587
3669
|
...(m.speakerVpId ? { speakerVpId: m.speakerVpId } : {}),
|
|
3588
3670
|
}));
|
|
3589
3671
|
|