@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.
@@ -0,0 +1,189 @@
1
+ // The panel's markup: the recent-chats sidebar, the conversation and composer, the adapter routing table's overlay and
2
+ // the mobile drawer's backdrop. The ids are the panel's public surface for its host (the memory entry and consent
3
+ // card in the sidebar, the composer's controls) and for the assistant, which snapshots them. `data-spotlight`
4
+ // marks the controls the assistant can point at; `data-ui-chrome="content"` keeps the rendered conversation out of
5
+ // that snapshot, since an `a[href]` inside a message is untrusted content, not app chrome.
6
+ export const MARKUP = `
7
+ <aside id="chat-sidebar">
8
+ <button id="chat-new" data-spotlight="new-chat" title="Start a new chat (or type /new)">+&nbsp; New chat</button>
9
+ <input type="search" id="chat-sess-search" data-spotlight="session-search" placeholder="Search chats…" aria-label="Search chats" autocomplete="off">
10
+ <div id="chat-sessions" class="sesslist"></div>
11
+ <button id="chat-memory-entry" data-spotlight="memory-entry" type="button" style="display:none"
12
+ aria-haspopup="dialog" aria-controls="mem-overlay" aria-expanded="false">
13
+ <span class="mi" aria-hidden="true">◆</span>
14
+ <span class="mt">Memory</span>
15
+ <span class="mcount" id="chat-memory-count">—</span>
16
+ </button>
17
+ </aside>
18
+ <div id="chat-main">
19
+ <div id="chat-scroll">
20
+ <div id="chat-hello">
21
+ <h1>Local. Private. <span class="grad g-hero">Yours.</span></h1>
22
+ <p id="chat-hello-sub">A full pi agent on this machine — local model, local tools, zero cloud. Every token below is generated here.</p>
23
+ <div class="chips" id="chat-hello-chips">
24
+ <button class="chip" data-q="What is mlx-bun and what can it do?">What is mlx-bun?</button>
25
+ <button class="chip" data-q="How do I load a bigger or different model?">Load a bigger model</button>
26
+ <button class="chip" data-q="Is this really private? Where does my data go?">Is this private?</button>
27
+ <button class="chip" data-q="What can you help me with right now?">What can you do?</button>
28
+ </div>
29
+ <div id="chat-consent" role="group" aria-label="Memory setup">
30
+ <span class="cc-icon" aria-hidden="true">◆</span>
31
+ <span class="cc-text">Let mlx-bun remember what matters? Creates a local, git-tracked vault you can open anytime.</span>
32
+ <span class="cc-actions">
33
+ <button class="btn ghost sm" id="chat-consent-skip" type="button">Skip</button>
34
+ <button class="btn primary sm" id="chat-consent-yes" type="button">Set up memory</button>
35
+ </span>
36
+ </div>
37
+ </div>
38
+ <div id="chat-thread" role="log" aria-live="polite" aria-atomic="false" data-ui-chrome="content"></div>
39
+ </div>
40
+ <div id="chat-foot">
41
+ <div class="queuebar" id="chat-queue"></div>
42
+ <div class="composer">
43
+ <div id="chat-attach" class="attach-row"></div>
44
+ <div class="inputrow">
45
+ <button id="chat-attach-btn" class="iconbtn" title="Attach files or images" aria-label="Attach files">+</button>
46
+ <input type="file" id="chat-file-input" multiple style="display:none">
47
+ <button id="chat-mic-btn" class="iconbtn" title="Hold to talk (Whisper)" aria-label="Hold to talk" style="display:none">🎙</button>
48
+ <div id="chat-box-wrap">
49
+ <textarea id="chat-box" data-spotlight="composer" rows="1" placeholder="Message the local agent…"></textarea>
50
+ <div id="chat-mention-pop" class="mentionpop" role="listbox" aria-label="Attach a file or recall a memory"></div>
51
+ </div>
52
+ <button id="chat-stop" title="Abort the current turn">Stop</button>
53
+ <button id="chat-send" data-spotlight="send">Send</button>
54
+ </div>
55
+ <div class="chat-hint">
56
+ <span id="chat-status-line">Enter to send · Shift+Enter for a new line · type while it streams to steer</span>
57
+ <span class="hint-right">
58
+ <span id="chat-sysprompt-wrap">
59
+ <span id="chat-sysprompt" data-spotlight="prompt-pill" class="pill" role="button" tabindex="0" aria-haspopup="true" aria-expanded="false"
60
+ title="System prompt (shapes how the assistant replies in this chat)"><span class="dot"></span>Prompt</span>
61
+ <div id="chat-sysprompt-pop" class="samplepop sysprompt-pop" role="dialog" aria-label="System prompt">
62
+ <h4>System prompt</h4>
63
+ <textarea id="sysprompt-text" rows="6" maxlength="4000"
64
+ placeholder="e.g. Answer in French. Keep replies under 3 sentences. Prefer Python over JS."
65
+ aria-label="Custom system prompt"></textarea>
66
+ <div class="sysprompt-foot-row">
67
+ <span class="note" id="sysprompt-count">0 / 4000</span>
68
+ <button id="sysprompt-clear" type="button">Clear</button>
69
+ </div>
70
+ <span class="note">Layered on top of the built-in assistant prompt — it doesn't replace mlx-bun's own
71
+ identity/tool guidance. Takes effect on your next message (not retroactively).</span>
72
+ <div class="sysprompt-presets">
73
+ <h4>Presets</h4>
74
+ <div class="sysprompt-preset-row">
75
+ <select id="sysprompt-preset-select" class="pill" aria-label="Saved presets">
76
+ <option value="">— presets —</option>
77
+ </select>
78
+ <button id="sysprompt-preset-save" type="button" title="Save the current prompt + sampling as a named preset">Save</button>
79
+ <button id="sysprompt-preset-delete" type="button" title="Delete the selected preset">Delete</button>
80
+ </div>
81
+ <span class="note">A preset bundles this system prompt with the current Sampling settings — applying one sets both at once.</span>
82
+ </div>
83
+ </div>
84
+ </span>
85
+ <span id="chat-sampling-wrap">
86
+ <span id="chat-sampling" data-spotlight="sampling-pill" class="pill" role="button" tabindex="0" aria-haspopup="true" aria-expanded="false"
87
+ title="Sampling controls (temperature · top_p · top_k)"><span class="dot"></span>Sampling</span>
88
+ <div id="chat-sampling-pop" class="samplepop" role="dialog" aria-label="Sampling controls">
89
+ <div class="samp-head-row">
90
+ <h4>Sampling</h4>
91
+ <span id="samp-oneshot-chip" class="samp-oneshot-chip" style="display:none">next msg only</span>
92
+ </div>
93
+ <div class="samp-scope-row">
94
+ <label for="samp-scope">Apply</label>
95
+ <select id="samp-scope" aria-label="Sampling scope">
96
+ <option value="session">this session</option>
97
+ <option value="next_turn">next message only</option>
98
+ </select>
99
+ </div>
100
+ <div class="samprow">
101
+ <label for="samp-temp">Temperature</label>
102
+ <input id="samp-temp" type="range" min="0" max="2" step="0.05" aria-label="Temperature">
103
+ <output id="samp-temp-val" class="samp-val auto">0.70</output>
104
+ </div>
105
+ <div class="samprow">
106
+ <label for="samp-topp">top_p</label>
107
+ <input id="samp-topp" type="range" min="0" max="1" step="0.01" aria-label="top_p">
108
+ <output id="samp-topp-val" class="samp-val auto">0.95</output>
109
+ </div>
110
+ <div class="samprow">
111
+ <label for="samp-topk">top_k</label>
112
+ <input id="samp-topk" type="range" min="0" max="100" step="1" aria-label="top_k">
113
+ <output id="samp-topk-val" class="samp-val auto">off</output>
114
+ </div>
115
+ <details class="samp-adv" id="samp-advanced">
116
+ <summary>Advanced</summary>
117
+ <div class="samprow">
118
+ <label for="samp-minp">min_p</label>
119
+ <input id="samp-minp" type="range" min="0" max="0.5" step="0.01" aria-label="min_p">
120
+ <output id="samp-minp-val" class="samp-val auto">off</output>
121
+ </div>
122
+ <div class="samprow">
123
+ <label for="samp-xtcp">XTC prob</label>
124
+ <input id="samp-xtcp" type="range" min="0" max="1" step="0.01" aria-label="XTC probability">
125
+ <output id="samp-xtcp-val" class="samp-val auto">off</output>
126
+ </div>
127
+ <div class="samprow">
128
+ <label for="samp-xtct">XTC thresh</label>
129
+ <input id="samp-xtct" type="range" min="0" max="1" step="0.01" aria-label="XTC threshold">
130
+ <output id="samp-xtct-val" class="samp-val auto">off</output>
131
+ </div>
132
+ <div class="samprow">
133
+ <label for="samp-reppen">repetition</label>
134
+ <input id="samp-reppen" type="range" min="1" max="2" step="0.01" aria-label="repetition penalty">
135
+ <output id="samp-reppen-val" class="samp-val auto">off</output>
136
+ </div>
137
+ <div class="samprow">
138
+ <label for="samp-repctx">rep. window</label>
139
+ <input id="samp-repctx" type="range" min="0" max="1024" step="16" aria-label="repetition context size">
140
+ <output id="samp-repctx-val" class="samp-val auto">off</output>
141
+ </div>
142
+ <div class="samprow">
143
+ <label for="samp-prespen">presence</label>
144
+ <input id="samp-prespen" type="range" min="-2" max="2" step="0.05" aria-label="presence penalty">
145
+ <output id="samp-prespen-val" class="samp-val auto">off</output>
146
+ </div>
147
+ <div class="samprow">
148
+ <label for="samp-freqpen">frequency</label>
149
+ <input id="samp-freqpen" type="range" min="-2" max="2" step="0.05" aria-label="frequency penalty">
150
+ <output id="samp-freqpen-val" class="samp-val auto">off</output>
151
+ </div>
152
+ <div class="seed-row">
153
+ <label for="samp-seed">seed</label>
154
+ <input id="samp-seed" type="text" inputmode="numeric" pattern="[0-9]*" placeholder="random" aria-label="seed">
155
+ </div>
156
+ </details>
157
+ <div class="samppop-foot">
158
+ <span class="note" id="samp-foot-note">Sliders default to this model's recommended values — temperature also follows the thinking toggle. Reset returns everything to defaults.</span>
159
+ <button id="samp-reset" type="button">Reset</button>
160
+ </div>
161
+ </div>
162
+ </span>
163
+ <select id="chat-adapter" data-spotlight="adapter-select" class="pill" title="LoRA adapter for this chat (none = base model)">
164
+ <option value="">no adapter</option>
165
+ </select>
166
+ <button id="adapters-manage" type="button" title="Manage adapters — routing table, RAM cost, stacking"
167
+ aria-haspopup="dialog" aria-controls="adapters-overlay" aria-expanded="false">⚙</button>
168
+ <span id="chat-think" class="pill" role="button" tabindex="0" title="Toggle the model's reasoning"><span class="dot"></span>Thinking</span>
169
+ </span>
170
+ </div>
171
+ <div id="chat-perf">
172
+ <span class="pf-item" id="pf-tps" title="Decode speed for the current/last turn"></span>
173
+ <span class="pf-item" id="pf-ttft" title="Time to first token"></span>
174
+ <span class="pf-item" id="pf-fill" title="Context window in use — auto-compacts before it fills"></span>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ <div id="adapters-overlay">
180
+ <div id="adapters-panel" role="dialog" aria-modal="true" aria-labelledby="adapters-title">
181
+ <div class="mem-head">
182
+ <h2 id="adapters-title">Adapters</h2>
183
+ <button class="mem-close" id="adapters-close" aria-label="Close adapter routing table">✕</button>
184
+ </div>
185
+ <div class="mem-body" id="adapters-body"></div>
186
+ </div>
187
+ </div>
188
+ <div id="chat-drawer-backdrop"></div>
189
+ `;
@@ -0,0 +1,77 @@
1
+ // Part of the chat panel (`@mlx-bun/module-chat`): the app-aware assistant's catalog of the host page's routes,
2
+ // overlays and spotlight targets (ids of the host page's own controls).
3
+ //
4
+ // Shared route IDs keep browser navigation and server validation in agreement.
5
+ // Spotlight selectors and overlay IDs belong to the browser.
6
+
7
+ /** A hash-routed page (shell.ts's Route union, minus "routes" — the DAG
8
+ * diagram tab isn't a useful assistant destination and is itself
9
+ * feature-detected/hidden when unavailable). */
10
+ import { APP_ROUTE_IDS as ROUTE_IDS } from "../protocol";
11
+ export { ROUTE_IDS };
12
+ export type RouteId = (typeof ROUTE_IDS)[number];
13
+
14
+ /** A non-route overlay/panel the assistant can navigate to or spotlight
15
+ * within. Opening one of these does not change location.hash. */
16
+ export const VIEW_IDS = ["memory-panel", "hub-panel", "settings", "adapters-panel"] as const;
17
+ export type ViewId = (typeof VIEW_IDS)[number];
18
+
19
+ export function isRouteId(v: string): v is RouteId {
20
+ return (ROUTE_IDS as readonly string[]).includes(v);
21
+ }
22
+ export function isViewId(v: string): v is ViewId {
23
+ return (VIEW_IDS as readonly string[]).includes(v);
24
+ }
25
+
26
+ /** Human labels for routes, used in the "unknown route" tool-error message
27
+ * and nowhere else — kept tiny on purpose. */
28
+ export const ROUTE_LABELS: Record<RouteId, string> = {
29
+ chat: "Chat",
30
+ quantize: "Quantize",
31
+ finetune: "Fine-tune",
32
+ dataset: "Build Dataset",
33
+ status: "Status",
34
+ };
35
+
36
+ /** A curated spotlight target: a stable id -> the selector to highlight +
37
+ * a human label for the popover title, plus which route/view it lives on
38
+ * (spotlight_ui navigates there first if the browser isn't already on it).
39
+ * This is the "catalog target" resolution path in resolve-spotlight.ts's
40
+ * terms — the fallback when ref/label/selector don't resolve from the live
41
+ * DOM or the last uiSnapshot. Anchors added to app.html via data-spotlight
42
+ * in this same wave; ids are stable and boring (element id minus "chat-"
43
+ * prefix noise where it reads fine either way). */
44
+ export interface SpotlightTarget {
45
+ selector: string;
46
+ label: string;
47
+ route?: RouteId;
48
+ view?: ViewId;
49
+ }
50
+
51
+ export const SPOTLIGHT_TARGETS: Record<string, SpotlightTarget> = {
52
+ composer: { selector: "#chat-box", label: "Message box", route: "chat" },
53
+ send: { selector: "#chat-send", label: "Send", route: "chat" },
54
+ "new-chat": { selector: "#chat-new", label: "New chat", route: "chat" },
55
+ "session-search": { selector: "#chat-sess-search", label: "Search chats", route: "chat" },
56
+ "sampling-pill": { selector: "#chat-sampling", label: "Sampling", route: "chat" },
57
+ "prompt-pill": { selector: "#chat-sysprompt", label: "System prompt", route: "chat" },
58
+ "adapter-select": { selector: "#chat-adapter", label: "LoRA adapter", route: "chat" },
59
+ "memory-entry": { selector: "#chat-memory-entry", label: "Memory", route: "chat" },
60
+ "developer-toggle": { selector: "#nav-developer", label: "Developer toggle" },
61
+ "model-picker": { selector: "#nav-model", label: "Active model" },
62
+ "hub-browse": { selector: "#nav-model", label: "Browse models" },
63
+ "quantize-source": { selector: "#q-model", label: "Source model", route: "quantize" },
64
+ "finetune-base": { selector: "#f-model", label: "Base model", route: "finetune" },
65
+ };
66
+
67
+ export function getSpotlightTarget(id: string): SpotlightTarget | null {
68
+ return SPOTLIGHT_TARGETS[id] ?? null;
69
+ }
70
+
71
+ /** Resolve a free-form route/page string (either a bare route id or a
72
+ * "#/route" hash) the way navigate_app's tool param is described to the
73
+ * model. Returns null for anything not in ROUTE_IDS. */
74
+ export function resolveRouteId(routeOrPage: string): RouteId | null {
75
+ const bare = routeOrPage.trim().replace(/^#\/?/, "");
76
+ return isRouteId(bare) ? bare : null;
77
+ }
@@ -0,0 +1,141 @@
1
+ // Hold-to-talk voice input for the composer: microphone → 16 kHz float32
2
+ // (an AudioContext opened at 16 kHz lets the browser resample the mic) →
3
+ // 250 ms chunks streamed to /v1/audio/sessions/<id>/audio while the button
4
+ // is held → finish on release → transcript inserted at the caret. Works
5
+ // with ANY served chat model: transcription is the Whisper companion, not
6
+ // the chat model's audio input. Shown only when `ready.transcription` is
7
+ // true (composer.ts updateAttachHint).
8
+
9
+ import { $, toast } from "./dom";
10
+
11
+ const SAMPLE_RATE = 16_000;
12
+ const CHUNK_SECONDS = 0.25;
13
+
14
+ interface Take {
15
+ id: string;
16
+ ctx: AudioContext;
17
+ stream: MediaStream;
18
+ node: ScriptProcessorNode;
19
+ pending: Float32Array[];
20
+ pendingLen: number;
21
+ sending: Promise<void>;
22
+ failed: string | null;
23
+ }
24
+
25
+ let take: Take | null = null;
26
+ let starting = false;
27
+
28
+ async function postChunk(id: string, pcm: Float32Array): Promise<void> {
29
+ const r = await fetch(`/v1/audio/sessions/${id}/audio`, {
30
+ method: "POST", headers: { "content-type": "audio/pcm;rate=16000" },
31
+ body: new Uint8Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength) as ArrayBuffer),
32
+ });
33
+ if (!r.ok) throw new Error(`audio chunk rejected (${r.status})`);
34
+ }
35
+
36
+ function flush(t: Take): void {
37
+ if (t.pendingLen === 0) return;
38
+ const buf = new Float32Array(t.pendingLen);
39
+ let o = 0;
40
+ for (const p of t.pending) { buf.set(p, o); o += p.length; }
41
+ t.pending = []; t.pendingLen = 0;
42
+ t.sending = t.sending.then(() => postChunk(t.id, buf)).catch((e) => { t.failed = (e as Error).message; });
43
+ }
44
+
45
+ async function begin(btn: HTMLButtonElement): Promise<void> {
46
+ if (take || starting) return;
47
+ starting = true;
48
+ btn.classList.add("busy");
49
+ try {
50
+ const r = await fetch("/v1/audio/sessions", {
51
+ method: "POST", headers: { "content-type": "application/json" },
52
+ body: JSON.stringify({ vad: true, vad_min_speech_ms: 120, temperature: 0 }),
53
+ });
54
+ if (!r.ok) throw new Error(r.status === 503 ? "no Whisper model on this server" : `session failed (${r.status})`);
55
+ const { id } = await r.json() as { id: string };
56
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
57
+ const ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
58
+ const src = ctx.createMediaStreamSource(stream);
59
+ // ScriptProcessorNode is deprecated but universally available and enough
60
+ // for 16 kHz mono capture; an AudioWorklet needs a separately served file.
61
+ const node = ctx.createScriptProcessor(4096, 1, 1);
62
+ const t: Take = { id, ctx, stream, node, pending: [], pendingLen: 0, sending: Promise.resolve(), failed: null };
63
+ node.onaudioprocess = (e) => {
64
+ if (take !== t) return;
65
+ const data = e.inputBuffer.getChannelData(0);
66
+ t.pending.push(new Float32Array(data));
67
+ t.pendingLen += data.length;
68
+ if (t.pendingLen >= SAMPLE_RATE * CHUNK_SECONDS) flush(t);
69
+ };
70
+ src.connect(node);
71
+ node.connect(ctx.destination); // required for onaudioprocess to fire in some browsers
72
+ take = t;
73
+ btn.classList.add("recording");
74
+ } catch (e) {
75
+ toast(`Voice input failed: ${(e as Error).message}`, "err");
76
+ } finally {
77
+ starting = false;
78
+ btn.classList.remove("busy");
79
+ }
80
+ }
81
+
82
+ async function end(btn: HTMLButtonElement, box: HTMLTextAreaElement): Promise<void> {
83
+ const t = take;
84
+ if (!t) return;
85
+ take = null;
86
+ btn.classList.remove("recording");
87
+ btn.classList.add("busy");
88
+ try {
89
+ t.node.disconnect();
90
+ for (const track of t.stream.getTracks()) track.stop();
91
+ void t.ctx.close();
92
+ flush(t);
93
+ await t.sending;
94
+ if (t.failed) throw new Error(t.failed);
95
+ const r = await fetch(`/v1/audio/sessions/${t.id}/finish`, { method: "POST" });
96
+ if (!r.ok) throw new Error(`transcription failed (${r.status})`);
97
+ const body = await r.json() as { text: string; mlx_bun?: { vad?: { speech: boolean } } };
98
+ const text = (body.text || "").trim();
99
+ if (!text) { toast(body.mlx_bun?.vad?.speech === false ? "No speech detected" : "Nothing transcribed", ""); return; }
100
+ insertAtCaret(box, text);
101
+ } catch (e) {
102
+ toast(`Voice input failed: ${(e as Error).message}`, "err");
103
+ } finally {
104
+ btn.classList.remove("busy");
105
+ }
106
+ }
107
+
108
+ function insertAtCaret(box: HTMLTextAreaElement, text: string): void {
109
+ const start = box.selectionStart ?? box.value.length;
110
+ const endPos = box.selectionEnd ?? start;
111
+ const before = box.value.slice(0, start);
112
+ const after = box.value.slice(endPos);
113
+ const lead = before.length && !/\s$/.test(before) ? " " : "";
114
+ const trail = after.length && !/^\s/.test(after) ? " " : "";
115
+ box.value = before + lead + text + trail + after;
116
+ const caret = (before + lead + text + trail).length;
117
+ box.setSelectionRange(caret, caret);
118
+ box.dispatchEvent(new Event("input", { bubbles: true }));
119
+ box.focus();
120
+ }
121
+
122
+ /** Wire hold-to-talk on `btn`: pointer hold, or Space/Enter held while focused. */
123
+ export function initVoiceInput(btn: HTMLButtonElement, box: HTMLTextAreaElement): void {
124
+ if (!btn) return;
125
+ if (!navigator.mediaDevices?.getUserMedia) { btn.title = "Microphone capture needs a secure context (https or localhost)"; }
126
+ btn.addEventListener("pointerdown", (e) => { e.preventDefault(); btn.setPointerCapture(e.pointerId); void begin(btn); });
127
+ const release = () => { void end(btn, box); };
128
+ btn.addEventListener("pointerup", release);
129
+ btn.addEventListener("pointercancel", release);
130
+ btn.addEventListener("lostpointercapture", release);
131
+ let keyHeld = false;
132
+ btn.addEventListener("keydown", (e) => {
133
+ if ((e.key === " " || e.key === "Enter") && !keyHeld) { e.preventDefault(); keyHeld = true; void begin(btn); }
134
+ });
135
+ btn.addEventListener("keyup", (e) => {
136
+ if ((e.key === " " || e.key === "Enter") && keyHeld) { e.preventDefault(); keyHeld = false; release(); }
137
+ });
138
+ btn.addEventListener("blur", () => { if (keyHeld) { keyHeld = false; release(); } });
139
+ // expose for tests / the palette
140
+ ($ as unknown as { voice?: unknown }).voice = { begin: () => begin(btn), end: () => end(btn, box) };
141
+ }