@mlx-bun/module-chat 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +85 -2
- package/package.json +39 -4
- package/src/app-tools.ts +101 -0
- package/src/backend.ts +108 -0
- package/src/describe.ts +47 -0
- package/src/events.ts +75 -0
- package/src/extensions.ts +44 -0
- package/src/history.ts +188 -0
- package/src/index.ts +74 -0
- package/src/manifest.ts +26 -0
- package/src/model-bridge.ts +75 -0
- package/src/panel/adapters-panel.ts +287 -0
- package/src/panel/assistant.ts +469 -0
- package/src/panel/chat.ts +1076 -0
- package/src/panel/composer.ts +947 -0
- package/src/panel/dom.ts +94 -0
- package/src/panel/host.ts +28 -0
- package/src/panel/index.ts +100 -0
- package/src/panel/markdown.ts +547 -0
- package/src/panel/popovers.ts +17 -0
- package/src/panel/rag.ts +274 -0
- package/src/panel/sessions.ts +470 -0
- package/src/panel/style.ts +532 -0
- package/src/panel/template.ts +189 -0
- package/src/panel/ui-catalog.ts +77 -0
- package/src/panel/voice.ts +141 -0
- package/src/pi-backend.ts +989 -0
- package/src/policy.ts +417 -0
- package/src/protocol.ts +265 -0
- package/src/provider.ts +72 -0
- package/src/routes.ts +49 -0
- package/src/session-files.ts +36 -0
- package/src/session-search.ts +175 -0
- package/src/surface.ts +26 -0
- package/src/tool-approvals.ts +108 -0
- package/src/web-tools.ts +478 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// Part of the chat panel (`@mlx-bun/module-chat`), bundled into the app's browser build.
|
|
2
|
+
//
|
|
3
|
+
// Recent-chats sidebar (list/search/fork/delete), the last-user-message
|
|
4
|
+
// sibling `< i/n >` toggle (edit-and-resend branching), and the
|
|
5
|
+
// per-message action row (copy/regenerate/edit). Extracted from the
|
|
6
|
+
// original monolithic controllers.chat closure in app.html — behavior
|
|
7
|
+
// identical, but state that used to be closed-over local variables is now
|
|
8
|
+
// passed explicitly (SidebarState/SiblingState) so this module has no
|
|
9
|
+
// hidden coupling to chat.ts beyond what's passed in.
|
|
10
|
+
//
|
|
11
|
+
// Later additions (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
12
|
+
// beat-matrix Axis 10/11): full-text
|
|
13
|
+
// "in messages" search fallback below the title filter (GET
|
|
14
|
+
// /api/sessions/search, backed by packages/module-chat/src/session-search.ts), an export
|
|
15
|
+
// action per session row (Markdown/JSON, via GET /api/sessions/export —
|
|
16
|
+
// path-validated server-side under sessionDir exactly like pi-web's
|
|
17
|
+
// isUnderSessionDir), and openSessionRowByPath(), the DOM-click bridge the
|
|
18
|
+
// command palette (palette.ts) uses to open a session without this module
|
|
19
|
+
// needing any reference back into chat.ts's WebSocket (chat.ts already
|
|
20
|
+
// wires each rendered `.sess` row's click to `onOpen` below — this just
|
|
21
|
+
// finds and clicks the right row instead of duplicating that send()).
|
|
22
|
+
|
|
23
|
+
import type { SessionListItem } from "../protocol";
|
|
24
|
+
import { $, api, el, injectStyles } from "./dom";
|
|
25
|
+
|
|
26
|
+
const SESSIONS_STYLE_ID = "mlxbun-sessions-style";
|
|
27
|
+
|
|
28
|
+
/** Injected once (shell.ts's injectStyles is idempotent per id): the small
|
|
29
|
+
* set of new classes this module's later additions need
|
|
30
|
+
* (full-text-search fallback rows + the export micro-menu) that have no
|
|
31
|
+
* home in app.html's own <style> block, since this module doesn't touch
|
|
32
|
+
* that file. References the existing :root design tokens so it matches
|
|
33
|
+
* the current theme automatically. */
|
|
34
|
+
function injectSessionsStyles(): void {
|
|
35
|
+
injectStyles(SESSIONS_STYLE_ID, `
|
|
36
|
+
.sess-msg-search{margin-top:6px;padding-top:6px;border-top:1px solid var(--hairline,rgba(255,255,255,.14));}
|
|
37
|
+
.sess-msg-search-hdr{font-size:11px;color:var(--dim,#86868b);text-transform:uppercase;
|
|
38
|
+
letter-spacing:.04em;padding:4px 10px;}
|
|
39
|
+
.sess-msg-search-loading{color:var(--dim,#86868b);font-size:12px;padding:6px 10px;}
|
|
40
|
+
.sess-msg-hit{padding:7px 10px;border-radius:8px;cursor:pointer;}
|
|
41
|
+
.sess-msg-hit:hover{background:var(--card-hover,rgba(255,255,255,.08));}
|
|
42
|
+
.sess-msg-hit .stitle{font-size:12.5px;}
|
|
43
|
+
.sess-msg-snippet{color:var(--dim,#86868b);font-size:11px;overflow:hidden;text-overflow:ellipsis;
|
|
44
|
+
white-space:nowrap;margin-top:2px;}
|
|
45
|
+
.sess-msg-snippet mark{background:rgba(255,214,10,.35);color:inherit;border-radius:2px;}
|
|
46
|
+
.sess-export-menu{position:fixed;z-index:950;background:var(--bg,#000);
|
|
47
|
+
border:1px solid var(--hairline-strong,rgba(255,255,255,.28));border-radius:10px;
|
|
48
|
+
box-shadow:0 12px 30px rgba(0,0,0,.4);overflow:hidden;min-width:150px;}
|
|
49
|
+
.sess-export-item{display:block;width:100%;box-sizing:border-box;appearance:none;background:none;
|
|
50
|
+
border:0;text-align:left;color:var(--ink,#fff);font-size:12.5px;padding:9px 12px;cursor:pointer;}
|
|
51
|
+
.sess-export-item:hover{background:var(--card-hover,rgba(255,255,255,.08));}
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
56
|
+
Sidebar (session list + search)
|
|
57
|
+
──────────────────────────────────────────────────────────────────── */
|
|
58
|
+
|
|
59
|
+
export interface SidebarCallbacks {
|
|
60
|
+
onOpen(path: string): void;
|
|
61
|
+
onFork(path: string): void;
|
|
62
|
+
onDelete(path: string): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Mutable box so renderSessions/applySessSearch share the last-rendered
|
|
66
|
+
* item list without a module-level global (kept per chat-controller
|
|
67
|
+
* instance — there's only ever one, but this avoids reintroducing a
|
|
68
|
+
* bare module-level `let` for state that's really the controller's). */
|
|
69
|
+
export interface SidebarState {
|
|
70
|
+
lastSessionItems: SessionListItem[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function newSidebarState(): SidebarState {
|
|
74
|
+
return { lastSessionItems: [] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function relTime(ms: number | undefined): string {
|
|
78
|
+
if (!ms) return "";
|
|
79
|
+
const s = (Date.now() - ms) / 1000;
|
|
80
|
+
if (s < 60) return "just now";
|
|
81
|
+
if (s < 3600) return Math.floor(s / 60) + "m ago";
|
|
82
|
+
if (s < 86400) return Math.floor(s / 3600) + "h ago";
|
|
83
|
+
if (s < 604800) return Math.floor(s / 86400) + "d ago";
|
|
84
|
+
try { return new Date(ms).toLocaleDateString(); } catch { return ""; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Local esc() to avoid importing markdown.ts's whole surface just for
|
|
88
|
+
// escaping — identical logic.
|
|
89
|
+
function esc(s: unknown): string {
|
|
90
|
+
return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ } as Record<string, string>)[c]!);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function renderSessions(state: SidebarState, items: SessionListItem[] | undefined, activePath: string | undefined, cb: SidebarCallbacks): void {
|
|
94
|
+
injectSessionsStyles();
|
|
95
|
+
state.lastSessionItems = items || [];
|
|
96
|
+
const box = $("chat-sessions");
|
|
97
|
+
if (!items || !items.length) { box.innerHTML = '<div class="sessempty">No saved chats yet. Your conversations are saved locally here.</div>'; return; }
|
|
98
|
+
box.innerHTML = "";
|
|
99
|
+
for (const s of items) {
|
|
100
|
+
const row = el("div", "sess" + (s.path === activePath ? " active" : ""), box);
|
|
101
|
+
row.dataset.title = (s.title || "New chat").toLowerCase();
|
|
102
|
+
row.dataset.path = s.path;
|
|
103
|
+
row.innerHTML =
|
|
104
|
+
'<div class="stitle">' + esc(s.title || "New chat") + '</div>' +
|
|
105
|
+
'<div class="smeta"><span>' + esc(relTime(s.modified)) + '</span>' +
|
|
106
|
+
(s.messageCount ? '<span>· ' + s.messageCount + ' msg' + (s.messageCount === 1 ? '' : 's') + '</span>' : '') +
|
|
107
|
+
(s.forked ? '<span>· forked</span>' : '') + '</div>' +
|
|
108
|
+
'<button class="sbtn sexport" title="Export this chat">⇩</button>' +
|
|
109
|
+
'<button class="sbtn sfork" title="New chat from here">⑂</button>' +
|
|
110
|
+
'<button class="sbtn sdel" title="Delete chat">✕</button>';
|
|
111
|
+
row.addEventListener("click", () => { cb.onOpen(s.path); });
|
|
112
|
+
row.querySelector(".sexport")!.addEventListener("click", (e) => { e.stopPropagation(); openExportMenu(row.querySelector(".sexport") as HTMLElement, s.path, s.title); });
|
|
113
|
+
row.querySelector(".sfork")!.addEventListener("click", (e) => { e.stopPropagation(); cb.onFork(s.path); });
|
|
114
|
+
row.querySelector(".sdel")!.addEventListener("click", (e) => { e.stopPropagation(); cb.onDelete(s.path); });
|
|
115
|
+
}
|
|
116
|
+
applySessSearch(state);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Find the rendered `.sess` row for `path` (present for every session —
|
|
120
|
+
* renderSessions draws the full list, title-search only hides rows via
|
|
121
|
+
* `.sess-hidden`) and click it, reusing chat.ts's existing onOpen wiring
|
|
122
|
+
* instead of this module needing its own WebSocket reference. Un-hides the
|
|
123
|
+
* row first so a click reaches an element that isn't `display:none`-adjacent
|
|
124
|
+
* (sess-hidden only dims/collapses via CSS, but clearing it keeps the
|
|
125
|
+
* sidebar visibly consistent with "this is the chat you're now in").
|
|
126
|
+
* Returns false (no-op) if the row isn't in the DOM yet (e.g. sessions
|
|
127
|
+
* frame hasn't arrived) — callers (palette.ts) treat that as "can't open
|
|
128
|
+
* yet", never a throw. */
|
|
129
|
+
export function openSessionRowByPath(path: string): boolean {
|
|
130
|
+
const box = document.getElementById("chat-sessions");
|
|
131
|
+
if (!box) return false;
|
|
132
|
+
const row = box.querySelector<HTMLElement>('.sess[data-path="' + cssEscape(path) + '"]');
|
|
133
|
+
if (!row) return false;
|
|
134
|
+
row.classList.remove("sess-hidden");
|
|
135
|
+
row.click();
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Minimal CSS.escape fallback (attribute-selector value escaping) — avoids
|
|
140
|
+
* depending on the global CSS.escape existing (it does in every real
|
|
141
|
+
* browser, but happy-dom's test environment is the reason this is spelled
|
|
142
|
+
* out defensively rather than assumed). */
|
|
143
|
+
function cssEscape(s: string): string {
|
|
144
|
+
if (typeof CSS !== "undefined" && CSS.escape) return CSS.escape(s);
|
|
145
|
+
return s.replace(/["\\]/g, "\\$&");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Client-side substring filter over session titles (no server round
|
|
149
|
+
* trip). Re-applied after every re-render since #chat-sessions is
|
|
150
|
+
* rebuilt wholesale on each `sessions` frame. When the title filter yields
|
|
151
|
+
* few/no hits, debounced-fetches a full-text "in messages" fallback
|
|
152
|
+
* section (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
153
|
+
* beat-matrix Axis 10/11's full-text BEAT) below the title-filtered rows. */
|
|
154
|
+
export function applySessSearch(state: SidebarState): void {
|
|
155
|
+
const input = $("chat-sess-search") as HTMLInputElement | null;
|
|
156
|
+
const q = input ? input.value.trim().toLowerCase() : "";
|
|
157
|
+
const rows = $("chat-sessions").querySelectorAll<HTMLElement>(".sess");
|
|
158
|
+
let visibleCount = 0;
|
|
159
|
+
rows.forEach((row) => {
|
|
160
|
+
const match = !q || (row.dataset.title || "").includes(q);
|
|
161
|
+
row.classList.toggle("sess-hidden", !match);
|
|
162
|
+
if (match) visibleCount++;
|
|
163
|
+
});
|
|
164
|
+
let empty = $("chat-sessions").querySelector(".sess-search-empty");
|
|
165
|
+
if (q && !visibleCount && state.lastSessionItems.length) {
|
|
166
|
+
if (!empty) { empty = el("div", "sessempty sess-search-empty", $("chat-sessions")); }
|
|
167
|
+
empty.textContent = "No chats match “" + q + "”.";
|
|
168
|
+
} else if (empty) {
|
|
169
|
+
empty.remove();
|
|
170
|
+
}
|
|
171
|
+
scheduleMessageSearch(state, q, visibleCount);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
175
|
+
Full-text "in messages" fallback (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
176
|
+
beat-matrix Axis 10/11): when the
|
|
177
|
+
title filter yields few/no hits, debounce a GET /api/sessions/search
|
|
178
|
+
and render title+snippet rows below the sidebar's own list. Clicking a
|
|
179
|
+
row opens that session via the existing sidebar click flow (the row is
|
|
180
|
+
already rendered by renderSessions above — a full-text hit is always
|
|
181
|
+
for a session that's IN the sidebar, since the sidebar lists every
|
|
182
|
+
session on disk, just some hidden by the title filter).
|
|
183
|
+
──────────────────────────────────────────────────────────────────── */
|
|
184
|
+
|
|
185
|
+
const MESSAGE_SEARCH_DEBOUNCE_MS = 250;
|
|
186
|
+
/** Only bother with the server round trip once the title filter has mostly
|
|
187
|
+
* given up — a query that already matches plenty of titles doesn't need a
|
|
188
|
+
* second, slower full-text pass under it. */
|
|
189
|
+
const FEW_HITS_THRESHOLD = 3;
|
|
190
|
+
|
|
191
|
+
let messageSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
192
|
+
let messageSearchSeq = 0;
|
|
193
|
+
|
|
194
|
+
function scheduleMessageSearch(state: SidebarState, q: string, titleHits: number): void {
|
|
195
|
+
if (messageSearchTimer) clearTimeout(messageSearchTimer);
|
|
196
|
+
const section = document.getElementById("chat-sessions")?.querySelector(".sess-msg-search");
|
|
197
|
+
if (!q || q.length < 2 || titleHits > FEW_HITS_THRESHOLD) {
|
|
198
|
+
if (section) section.remove();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const seq = ++messageSearchSeq;
|
|
202
|
+
messageSearchTimer = setTimeout(() => runMessageSearch(state, q, seq), MESSAGE_SEARCH_DEBOUNCE_MS);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function runMessageSearch(state: SidebarState, q: string, seq: number): Promise<void> {
|
|
206
|
+
const box = $("chat-sessions");
|
|
207
|
+
let section = box.querySelector(".sess-msg-search") as HTMLElement | null;
|
|
208
|
+
if (!section) section = el("div", "sess-msg-search", box);
|
|
209
|
+
section.innerHTML = '<div class="sess-msg-search-hdr">In messages</div><div class="sess-msg-search-loading">Searching…</div>';
|
|
210
|
+
|
|
211
|
+
const d = await api("/api/sessions/search?q=" + encodeURIComponent(q)).catch(() => ({ ok: false } as { ok: boolean }));
|
|
212
|
+
if (seq !== messageSearchSeq) return; // superseded by a newer keystroke
|
|
213
|
+
// The input may have been cleared/changed while the request was in flight.
|
|
214
|
+
const input = $("chat-sess-search") as HTMLInputElement | null;
|
|
215
|
+
if (!input || input.value.trim().toLowerCase() !== q) return;
|
|
216
|
+
|
|
217
|
+
const body = d as { ok: boolean; results?: Array<{ sessionPath: string; sessionTitle: string; matches: Array<{ snippet: string; ranges: Array<[number, number]>; role: string }> }> };
|
|
218
|
+
if (!body.ok || !body.results || !body.results.length) {
|
|
219
|
+
section.innerHTML = '<div class="sess-msg-search-hdr">In messages</div><div class="sessempty">No message matches for “' + esc(q) + '”.</div>';
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
section.innerHTML = '<div class="sess-msg-search-hdr">In messages</div>';
|
|
223
|
+
for (const r of body.results) {
|
|
224
|
+
const item = el("div", "sess-msg-hit", section);
|
|
225
|
+
item.dataset.path = r.sessionPath;
|
|
226
|
+
const first = r.matches[0];
|
|
227
|
+
item.innerHTML =
|
|
228
|
+
'<div class="stitle">' + esc(r.sessionTitle) + '</div>' +
|
|
229
|
+
(first ? '<div class="sess-msg-snippet">' + highlightSnippetHtml(first.snippet, first.ranges) + '</div>' : "");
|
|
230
|
+
item.addEventListener("click", () => { openSessionRowByPath(r.sessionPath); });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Escape a plain-text snippet then re-insert `<mark>` around the reported
|
|
235
|
+
* [start,end) ranges — same escape-then-restore discipline markdown.ts
|
|
236
|
+
* uses for code spans, applied here to search highlighting instead. Ranges
|
|
237
|
+
* are assumed non-overlapping and sorted (buildSnippet in
|
|
238
|
+
* packages/module-chat/src/session-search.ts only ever emits one range per snippet
|
|
239
|
+
* today, but this loop handles more without change). */
|
|
240
|
+
export function highlightSnippetHtml(snippet: string, ranges: Array<[number, number]>): string {
|
|
241
|
+
if (!ranges.length) return esc(snippet);
|
|
242
|
+
let out = "";
|
|
243
|
+
let cursor = 0;
|
|
244
|
+
for (const [start, end] of ranges) {
|
|
245
|
+
if (start < cursor || end <= start || end > snippet.length) continue;
|
|
246
|
+
out += esc(snippet.slice(cursor, start));
|
|
247
|
+
out += "<mark>" + esc(snippet.slice(start, end)) + "</mark>";
|
|
248
|
+
cursor = end;
|
|
249
|
+
}
|
|
250
|
+
out += esc(snippet.slice(cursor));
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
255
|
+
Message actions (copy / regenerate / edit) + sibling toggle
|
|
256
|
+
──────────────────────────────────────────────────────────────────── */
|
|
257
|
+
|
|
258
|
+
export interface SiblingInfo {
|
|
259
|
+
entryId: string | null;
|
|
260
|
+
index: number;
|
|
261
|
+
count: number;
|
|
262
|
+
siblingIds: string[];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function newSiblingInfo(): SiblingInfo {
|
|
266
|
+
return { entryId: null, index: 0, count: 0, siblingIds: [] };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface MsgActionOpts {
|
|
270
|
+
regenerate?: boolean;
|
|
271
|
+
edit?: boolean;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Append a hover/focus action row to a message element. `text` is the
|
|
275
|
+
* RAW SOURCE (markdown for assistant, plain for user) — never innerHTML —
|
|
276
|
+
* so Copy puts back exactly what the model said / the user typed.
|
|
277
|
+
* `onRegenerate`/`onEdit` are the click handlers (owned by chat.ts, which
|
|
278
|
+
* knows about the WS connection and turnActive state this module doesn't). */
|
|
279
|
+
export function addMsgActions(
|
|
280
|
+
m: HTMLElement,
|
|
281
|
+
text: string,
|
|
282
|
+
opts: MsgActionOpts,
|
|
283
|
+
handlers: { onRegenerate?: () => void; onEdit?: (m: HTMLElement) => void },
|
|
284
|
+
): HTMLElement {
|
|
285
|
+
const row = el("div", "msg-actions", m);
|
|
286
|
+
const copyBtn = el("button", "maction", row);
|
|
287
|
+
copyBtn.type = "button"; copyBtn.textContent = "Copy";
|
|
288
|
+
copyBtn.addEventListener("click", () => {
|
|
289
|
+
if (!navigator.clipboard) return;
|
|
290
|
+
navigator.clipboard.writeText(text || "").then(() => {
|
|
291
|
+
copyBtn.textContent = "Copied"; setTimeout(() => { copyBtn.textContent = "Copy"; }, 1200);
|
|
292
|
+
}).catch(() => {});
|
|
293
|
+
});
|
|
294
|
+
if (opts.regenerate) {
|
|
295
|
+
const btn = el("button", "maction", row);
|
|
296
|
+
btn.type = "button"; btn.textContent = "Regenerate";
|
|
297
|
+
btn.title = "Re-run this reply (keeps the old one on disk as a branch)";
|
|
298
|
+
btn.addEventListener("click", () => { handlers.onRegenerate && handlers.onRegenerate(); });
|
|
299
|
+
}
|
|
300
|
+
if (opts.edit) {
|
|
301
|
+
const btn = el("button", "maction", row);
|
|
302
|
+
btn.type = "button"; btn.textContent = "Edit";
|
|
303
|
+
btn.title = "Edit and resend — keeps the original as a numbered branch";
|
|
304
|
+
btn.addEventListener("click", () => { handlers.onEdit && handlers.onEdit(m); });
|
|
305
|
+
}
|
|
306
|
+
return row;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Render (or clear) the `< i/n >` sibling toggle above the last user
|
|
310
|
+
* message. Rebuilt whenever a `siblings` frame arrives or the thread is
|
|
311
|
+
* rebuilt. Hidden entirely when count <= 1 (the common no-edits case). */
|
|
312
|
+
export function renderSiblingToggle(
|
|
313
|
+
lastUserMsgEl: HTMLElement | null,
|
|
314
|
+
lastUserEntryId: string | null,
|
|
315
|
+
siblingInfo: SiblingInfo,
|
|
316
|
+
onSwitch: (delta: number) => void,
|
|
317
|
+
): void {
|
|
318
|
+
if (!lastUserMsgEl) return;
|
|
319
|
+
let box = lastUserMsgEl.querySelector(".sib-toggle") as HTMLElement | null;
|
|
320
|
+
if (siblingInfo.count <= 1 || siblingInfo.entryId !== lastUserEntryId) {
|
|
321
|
+
if (box) box.remove();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (!box) {
|
|
325
|
+
box = el("div", "sib-toggle", null);
|
|
326
|
+
lastUserMsgEl.insertBefore(box, lastUserMsgEl.firstChild);
|
|
327
|
+
}
|
|
328
|
+
const { index, count } = siblingInfo;
|
|
329
|
+
box.innerHTML =
|
|
330
|
+
'<button class="sib-prev" type="button" ' + (index <= 1 ? "disabled" : "") + '><</button>' +
|
|
331
|
+
'<span>' + index + '/' + count + '</span>' +
|
|
332
|
+
'<button class="sib-next" type="button" ' + (index >= count ? "disabled" : "") + '>></button>';
|
|
333
|
+
box.querySelector(".sib-prev")!.addEventListener("click", () => onSwitch(-1));
|
|
334
|
+
box.querySelector(".sib-next")!.addEventListener("click", () => onSwitch(1));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Resolve the sibling target id for a +-1 direction move from siblingInfo,
|
|
338
|
+
* or null if there's no such neighbor (caller should no-op). Pure — split
|
|
339
|
+
* out from the button wiring above so it's independently testable. */
|
|
340
|
+
export function switchSiblingTarget(siblingInfo: SiblingInfo, delta: number): string | null {
|
|
341
|
+
const ids = siblingInfo.siblingIds || [];
|
|
342
|
+
return ids[siblingInfo.index - 1 + delta] ?? null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
346
|
+
Export (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
347
|
+
beat-matrix Axis 10 "Chat export" + Axis 11 "Portable JSON export / import"):
|
|
348
|
+
Markdown or raw JSON, built from
|
|
349
|
+
GET /api/sessions/export?path= (server-validated under sessionDir) — used
|
|
350
|
+
uniformly for the open session and any other session in the sidebar,
|
|
351
|
+
so this module needs no reference into chat.ts's in-memory thread state.
|
|
352
|
+
──────────────────────────────────────────────────────────────────── */
|
|
353
|
+
|
|
354
|
+
/** Slugify a session title into a filesystem-safe download filename stem
|
|
355
|
+
* (lowercase, spaces/punctuation -> hyphens, collapsed, trimmed, capped —
|
|
356
|
+
* same shape as a typical static-site slug). Pure — independently
|
|
357
|
+
* testable and reused by palette.ts's export action. */
|
|
358
|
+
export function slugTitle(title: string): string {
|
|
359
|
+
const slug = (title || "chat")
|
|
360
|
+
.toLowerCase()
|
|
361
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
362
|
+
.replace(/^-+|-+$/g, "");
|
|
363
|
+
return (slug || "chat").slice(0, 60);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
interface ExportEntry {
|
|
367
|
+
type?: string;
|
|
368
|
+
message?: { role?: string; content?: unknown };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function entryContentText(content: unknown): string {
|
|
372
|
+
if (typeof content === "string") return content;
|
|
373
|
+
if (Array.isArray(content)) {
|
|
374
|
+
return content
|
|
375
|
+
.filter((p): p is { type: string; text: string } =>
|
|
376
|
+
!!p && (p as { type?: string }).type === "text" && typeof (p as { text?: unknown }).text === "string")
|
|
377
|
+
.map((p) => p.text)
|
|
378
|
+
.join(" ");
|
|
379
|
+
}
|
|
380
|
+
return "";
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Render a session's raw JSONL entries (as returned by GET
|
|
384
|
+
* /api/sessions/export) into plain Markdown: one `### User`/`### Assistant`
|
|
385
|
+
* heading per turn plus its text. Pure — takes the already-parsed entry
|
|
386
|
+
* array, not a fetch, so it's testable without a network stub and reusable
|
|
387
|
+
* for both "export the open session" and "export any sidebar session"
|
|
388
|
+
* (both go through the same server endpoint in this module, see
|
|
389
|
+
* exportSession below). */
|
|
390
|
+
export function sessionEntriesToMarkdown(entries: unknown[], title: string): string {
|
|
391
|
+
const lines: string[] = ["# " + (title || "Chat"), ""];
|
|
392
|
+
for (const raw of entries) {
|
|
393
|
+
const e = raw as ExportEntry;
|
|
394
|
+
if (e.type !== "message" || !e.message) continue;
|
|
395
|
+
const role = e.message.role;
|
|
396
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
397
|
+
const text = entryContentText(e.message.content);
|
|
398
|
+
if (!text) continue;
|
|
399
|
+
lines.push("### " + (role === "user" ? "User" : "Assistant"), "", text, "");
|
|
400
|
+
}
|
|
401
|
+
return lines.join("\n");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function downloadBlob(filename: string, content: string, mime: string): void {
|
|
405
|
+
const blob = new Blob([content], { type: mime });
|
|
406
|
+
const url = URL.createObjectURL(blob);
|
|
407
|
+
const a = document.createElement("a");
|
|
408
|
+
a.href = url; a.download = filename;
|
|
409
|
+
document.body.appendChild(a);
|
|
410
|
+
a.click();
|
|
411
|
+
a.remove();
|
|
412
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Fetch a session's raw entries + title and trigger a browser download in
|
|
416
|
+
* the requested format. Always goes through GET /api/sessions/export
|
|
417
|
+
* (works uniformly whether or not this is the currently-open session —
|
|
418
|
+
* SessionManager flushes each turn to disk as it happens, so the file is
|
|
419
|
+
* never meaningfully stale relative to what's on screen). */
|
|
420
|
+
export async function exportSession(path: string, title: string, format: "md" | "json"): Promise<void> {
|
|
421
|
+
const d = await api("/api/sessions/export?path=" + encodeURIComponent(path))
|
|
422
|
+
.catch(() => ({ ok: false } as { ok: boolean }));
|
|
423
|
+
const body = d as { ok: boolean; entries?: unknown[] };
|
|
424
|
+
if (!body.ok || !body.entries) return;
|
|
425
|
+
const stem = slugTitle(title);
|
|
426
|
+
if (format === "json") {
|
|
427
|
+
downloadBlob(stem + ".json", JSON.stringify(body.entries, null, 2), "application/json");
|
|
428
|
+
} else {
|
|
429
|
+
downloadBlob(stem + ".md", sessionEntriesToMarkdown(body.entries, title), "text/markdown");
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let exportMenuEl: HTMLElement | null = null;
|
|
434
|
+
|
|
435
|
+
function closeExportMenu(): void {
|
|
436
|
+
if (exportMenuEl) { exportMenuEl.remove(); exportMenuEl = null; }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Tiny two-item popover (Markdown / JSON) anchored under the export
|
|
440
|
+
* button. Not a FocusTrap-managed overlay (it's a two-button micro-menu,
|
|
441
|
+
* not a modal) — a document-level click-outside listener closes it, and
|
|
442
|
+
* Escape closes it via the same listener path since Escape isn't
|
|
443
|
+
* special-cased elsewhere for this menu. */
|
|
444
|
+
function openExportMenu(anchor: HTMLElement, path: string, title: string): void {
|
|
445
|
+
closeExportMenu();
|
|
446
|
+
const menu = el("div", "sess-export-menu", document.body);
|
|
447
|
+
const rect = anchor.getBoundingClientRect();
|
|
448
|
+
menu.style.position = "fixed";
|
|
449
|
+
menu.style.top = (rect.bottom + 4) + "px";
|
|
450
|
+
menu.style.left = Math.max(4, rect.right - 140) + "px";
|
|
451
|
+
menu.innerHTML =
|
|
452
|
+
'<button class="sess-export-item" data-fmt="md">Export as Markdown</button>' +
|
|
453
|
+
'<button class="sess-export-item" data-fmt="json">Export as JSON</button>';
|
|
454
|
+
menu.querySelectorAll<HTMLButtonElement>(".sess-export-item").forEach((btn) => {
|
|
455
|
+
btn.addEventListener("click", (e) => {
|
|
456
|
+
e.stopPropagation();
|
|
457
|
+
exportSession(path, title, (btn.dataset.fmt as "md" | "json"));
|
|
458
|
+
closeExportMenu();
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
exportMenuEl = menu;
|
|
462
|
+
setTimeout(() => {
|
|
463
|
+
document.addEventListener("click", closeExportMenu, { once: true });
|
|
464
|
+
document.addEventListener("keydown", onExportMenuKeydown);
|
|
465
|
+
}, 0);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function onExportMenuKeydown(e: KeyboardEvent): void {
|
|
469
|
+
if (e.key === "Escape") { closeExportMenu(); document.removeEventListener("keydown", onExportMenuKeydown); }
|
|
470
|
+
}
|