agentgui 1.0.1117 → 1.0.1119

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,123 @@
1
+ const CHAT_KEY = 'agentgui.chat';
2
+ // Cap the persisted footprint: the last 100 messages, with any tool args/result
3
+ // payload over 4KB truncated in the SAVED copy only (in-memory state keeps the
4
+ // full payload). A quota failure is surfaced once instead of silently dropping
5
+ // persistence forever.
6
+ const PERSIST_MSG_CAP = 100;
7
+ const PERSIST_PART_CAP = 4096;
8
+
9
+ // An assistant turn with no content AND no parts is an empty shell (an aborted
10
+ // turn that produced nothing). The view already renders it as nothing; drop it
11
+ // from persisted/in-memory state so a restored chat never carries blank bubbles.
12
+ function isEmptyTurn(m) {
13
+ return m.role === 'assistant' && !m.content && !(Array.isArray(m.parts) && m.parts.length);
14
+ }
15
+
16
+ function trimPartForStorage(p) {
17
+ if (!p || typeof p !== 'object') return p;
18
+ let out = p;
19
+ for (const k of ['result', 'args']) {
20
+ const v = out[k];
21
+ if (v == null) continue;
22
+ let s;
23
+ if (typeof v === 'string') s = v;
24
+ else { try { s = JSON.stringify(v); } catch { s = String(v); } }
25
+ if (s.length > PERSIST_PART_CAP) {
26
+ if (out === p) out = { ...p };
27
+ out[k] = s.slice(0, PERSIST_PART_CAP);
28
+ out.truncatedForStorage = true;
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function computeTotalCost(state) {
35
+ return (state.chat.messages || []).reduce((s, m) => s + (typeof m.costUsd === 'number' ? m.costUsd : 0), 0);
36
+ }
37
+
38
+ // Factory: app.js calls this once at module load, passing the live `state`
39
+ // object (mutated by reference, same as every other app.js concern) and the
40
+ // shared helpers (announce/scheduleRender/lsGet/lsSet/lsRemove/debounce)
41
+ // already defined there. Returns the same function set app.js previously
42
+ // defined inline, wired to app.js's own state/render/announce.
43
+ export function createChatPersistence(state, { announce, scheduleRender, lsGet, lsRemove, debounce }) {
44
+ let _persistFailedOnce = false;
45
+ let _lastPersistTs = 0;
46
+
47
+ function persistChat() {
48
+ try {
49
+ const eligible = state.chat.messages.filter(m => !isEmptyTurn(m));
50
+ // On-screen transcript can exceed the cap; a reload only restores the last
51
+ // PERSIST_MSG_CAP turns - surface that once so it's not a silent surprise,
52
+ // same Alert pattern as the sibling quota-failure banner below.
53
+ const wasCapped = eligible.length > PERSIST_MSG_CAP;
54
+ if (wasCapped !== !!state.chat.persistTruncated) {
55
+ state.chat.persistTruncated = wasCapped;
56
+ scheduleRender();
57
+ }
58
+ const msgs = eligible
59
+ .slice(-PERSIST_MSG_CAP)
60
+ .map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, costUsd: m.costUsd, stopped: m.stopped || undefined, parts: Array.isArray(m.parts) ? m.parts.map(trimPartForStorage) : m.parts }));
61
+ const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
62
+ // Persist when there is a transcript OR a non-empty draft (a typed-but-not-
63
+ // sent message must survive a reload too).
64
+ if (!msgs.length && !draft) { lsRemove(CHAT_KEY); return; }
65
+ _lastPersistTs = Date.now();
66
+ localStorage.setItem(CHAT_KEY, JSON.stringify({ ts: _lastPersistTs, messages: msgs, draft, resumeSid: state.chat.resumeSid, totalCost: state.chat.totalCost || 0, agent: state.selectedAgent, model: state.selectedModel }));
67
+ } catch {
68
+ if (!_persistFailedOnce) {
69
+ _persistFailedOnce = true;
70
+ state.chat.persistError = true;
71
+ announce('chat too large to save locally - export it from settings');
72
+ scheduleRender();
73
+ }
74
+ }
75
+ }
76
+
77
+ const debouncedPersistDraft = debounce(persistChat, 500);
78
+
79
+ // Another GUI tab rewrote the shared chat key: never silently diverge - surface
80
+ // a banner offering to reload the newer copy (last-writer-wins with a ts guard).
81
+ window.addEventListener('storage', (e) => {
82
+ if (e.key !== CHAT_KEY || !e.newValue) return;
83
+ try {
84
+ const remote = JSON.parse(e.newValue);
85
+ if (remote && remote.ts && remote.ts > _lastPersistTs) {
86
+ state.chat.externalUpdate = true;
87
+ scheduleRender();
88
+ }
89
+ } catch {}
90
+ });
91
+
92
+ function restoreChat() {
93
+ try {
94
+ const raw = lsGet(CHAT_KEY);
95
+ if (!raw) return;
96
+ const saved = JSON.parse(raw);
97
+ if (typeof saved.draft === 'string' && saved.draft.trim()) {
98
+ state.chat.draft = saved.draft;
99
+ // A restored draft is set programmatically, so Ctrl+Z has nothing in the
100
+ // browser's native undo history to act on - a one-time note explains why
101
+ // undo appears to do nothing right after a reload.
102
+ announce('draft restored - note: undo history does not carry over a reload');
103
+ }
104
+ if (typeof saved.totalCost === 'number') state.chat.totalCost = saved.totalCost;
105
+ if (Array.isArray(saved.messages) && saved.messages.length) {
106
+ state.chat.messages = saved.messages
107
+ .filter(m => !isEmptyTurn(m))
108
+ .map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
109
+ // Prefer the per-message cost sum when present (it self-corrects after
110
+ // edit/retry truncation); the scalar is only the legacy fallback.
111
+ const derived = computeTotalCost(state);
112
+ if (derived) state.chat.totalCost = derived;
113
+ state.chat.resumeSid = saved.resumeSid || null;
114
+ // Restore the agent/model the transcript belongs to, so a restored chat
115
+ // isn't silently shown under whatever agent the picker defaulted to.
116
+ state.chat.restoredAgent = saved.agent || null;
117
+ state.chat.restoredModel = saved.model || null;
118
+ }
119
+ } catch {}
120
+ }
121
+
122
+ return { persistChat, restoreChat, debouncedPersistDraft, isEmptyTurn, computeTotalCost: () => computeTotalCost(state) };
123
+ }