@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,947 @@
|
|
|
1
|
+
// Part of the chat panel (`@mlx-bun/module-chat`), bundled into the app's browser build.
|
|
2
|
+
//
|
|
3
|
+
// The composer's non-thread surface: attachments (files + images), the
|
|
4
|
+
// sampling popover (full parameter set incl. Advanced), the LoRA adapter
|
|
5
|
+
// selector, the reasoning-toggle presentation, and the perf strip (tok/s,
|
|
6
|
+
// TTFT, context-fill). Extracted from the original monolithic
|
|
7
|
+
// controllers.chat closure in app.html — behavior identical. Owns its own
|
|
8
|
+
// module-level state (there's only ever one composer on screen, same as
|
|
9
|
+
// the original single IIFE), but every WS send goes through a callback
|
|
10
|
+
// passed in from chat.ts, which owns the actual WebSocket.
|
|
11
|
+
|
|
12
|
+
import type { ApiEnvelope, ReadyGenDefaults, SamplingOverrides } from "../protocol";
|
|
13
|
+
import { $, api, el, toast, trapFocus, type FocusTrap } from "./dom";
|
|
14
|
+
import { setPopoverClose } from "./popovers";
|
|
15
|
+
import { buildContextBlock, retrieve, shouldRetrieve, type Citation } from "./rag";
|
|
16
|
+
|
|
17
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
18
|
+
Attachments (files + images)
|
|
19
|
+
──────────────────────────────────────────────────────────────────── */
|
|
20
|
+
|
|
21
|
+
export interface Attachment {
|
|
22
|
+
id: number;
|
|
23
|
+
kind: "image" | "text";
|
|
24
|
+
name: string;
|
|
25
|
+
mimeType?: string;
|
|
26
|
+
data?: string;
|
|
27
|
+
text?: string;
|
|
28
|
+
truncated?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MAX_TEXT_BYTES = 256 * 1024;
|
|
32
|
+
const TEXT_EXTS = /\.(txt|md|markdown|csv|tsv|json|jsonl|ya?ml|toml|ini|cfg|conf|log|xml|html?|css|scss|js|mjs|cjs|ts|tsx|jsx|py|rs|go|java|c|h|cpp|hpp|cc|cs|rb|php|sh|bash|zsh|fish|sql|swift|kt|lua|r|jl|tex|svg|env|gitignore|dockerfile|makefile)$/i;
|
|
33
|
+
|
|
34
|
+
function isTextFile(f: File): boolean {
|
|
35
|
+
if (f.type.startsWith("text/")) return true;
|
|
36
|
+
if (/(json|xml|javascript|yaml|x-sh|x-toml|csv|markdown)/i.test(f.type)) return true;
|
|
37
|
+
return TEXT_EXTS.test(f.name || "");
|
|
38
|
+
}
|
|
39
|
+
function readDataUrl(file: File): Promise<string> {
|
|
40
|
+
return new Promise((res, rej) => {
|
|
41
|
+
const r = new FileReader(); r.onerror = rej;
|
|
42
|
+
r.onload = () => { const s = String(r.result); const i = s.indexOf(","); res(i >= 0 ? s.slice(i + 1) : s); };
|
|
43
|
+
r.readAsDataURL(file);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function readText(file: File): Promise<string> {
|
|
47
|
+
return new Promise((res, rej) => { const r = new FileReader(); r.onerror = rej; r.onload = () => res(String(r.result)); r.readAsText(file); });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ComposerState {
|
|
51
|
+
attachments: Attachment[] = [];
|
|
52
|
+
visionCapable = false; // from the `ready` frame; gates image attachments
|
|
53
|
+
transcriptionCapable = false; // from the `ready` frame; shows the hold-to-talk mic
|
|
54
|
+
thinkingCapable = false; // from `ready`; gates the reasoning toggle
|
|
55
|
+
// default on: with tools (read/web_search) the reasoning step gives the
|
|
56
|
+
// small model far better tool judgment (answers math/writing itself,
|
|
57
|
+
// only searches when needed). The thinking box defaults collapsed.
|
|
58
|
+
thinkingOn = true;
|
|
59
|
+
attachSeq = 0;
|
|
60
|
+
|
|
61
|
+
// Per-request sampling. Each field is either a user override (a number)
|
|
62
|
+
// or null = "use the recommended default" (resolved server-side).
|
|
63
|
+
sampling: Required<SamplingOverrides> = {
|
|
64
|
+
temperature: null, top_p: null, top_k: null, min_p: null,
|
|
65
|
+
xtc_probability: null, xtc_threshold: null, repetition_penalty: null,
|
|
66
|
+
repetition_context_size: null, presence_penalty: null, frequency_penalty: null,
|
|
67
|
+
seed: null,
|
|
68
|
+
};
|
|
69
|
+
// Populated from the `ready` frame (ReadyGenDefaults); null entries fall
|
|
70
|
+
// back to the built-in constants below until a ready frame arrives.
|
|
71
|
+
genDefaults: ReadyGenDefaults = { temperature: null, topP: null, topK: null };
|
|
72
|
+
|
|
73
|
+
// Per-message sampling scope (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
74
|
+
// beat-matrix Axis 4): "session"
|
|
75
|
+
// (default) behaves exactly as today — overrides persist until changed.
|
|
76
|
+
// "next_turn" is a one-shot: the NEXT change the user makes is sent with
|
|
77
|
+
// `scope:"next_turn"` and the server reverts to the prior session-level
|
|
78
|
+
// sampling once that turn completes (turn_end). armedOneShot mirrors the
|
|
79
|
+
// server's one-shot state locally purely for the indicator chip/dot — it's
|
|
80
|
+
// cleared on turn_end (see clearOneShotArmed in chat.ts's handle()).
|
|
81
|
+
samplingScope: "session" | "next_turn" = "session";
|
|
82
|
+
oneShotArmed = false;
|
|
83
|
+
|
|
84
|
+
// Per-chat custom system prompt (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
85
|
+
// beat-matrix Axis 4),
|
|
86
|
+
// layered onto the built-in surface prompt server-side (see
|
|
87
|
+
// packages/module-chat/src/policy.ts's injectSystemPrompt and
|
|
88
|
+
// packages/module-chat/src/pi-backend.ts's installSystemPromptHook). null/"" = none set.
|
|
89
|
+
systemPrompt: string | null = null;
|
|
90
|
+
|
|
91
|
+
recTemp(): number { return this.genDefaults.temperature != null ? this.genDefaults.temperature : (this.thinkingOn ? 0.9 : 0.7); }
|
|
92
|
+
recTopP(): number { return this.genDefaults.topP != null ? this.genDefaults.topP : 0.95; }
|
|
93
|
+
recTopK(): number { return this.genDefaults.topK != null ? this.genDefaults.topK : 0; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function addFiles(state: ComposerState, files: File[]): Promise<void> {
|
|
97
|
+
for (const f of files) {
|
|
98
|
+
if (f.type.startsWith("image/")) {
|
|
99
|
+
if (!state.visionCapable) { toast("This model can't see images — serve a vision-capable model (look for the vision tag in the Library).", "err"); continue; }
|
|
100
|
+
try { state.attachments.push({ id: ++state.attachSeq, kind: "image", name: f.name || "image", mimeType: f.type, data: await readDataUrl(f) }); }
|
|
101
|
+
catch { toast("Couldn't read " + (f.name || "image"), "err"); }
|
|
102
|
+
} else if (isTextFile(f)) {
|
|
103
|
+
try {
|
|
104
|
+
let text = await readText(f); const truncated = text.length > MAX_TEXT_BYTES; if (truncated) text = text.slice(0, MAX_TEXT_BYTES);
|
|
105
|
+
state.attachments.push({ id: ++state.attachSeq, kind: "text", name: f.name || "file", text, truncated });
|
|
106
|
+
} catch { toast("Couldn't read " + (f.name || "file"), "err"); }
|
|
107
|
+
} else {
|
|
108
|
+
toast("Unsupported file type: " + (f.name || "file"), "err");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
renderAttachments(state);
|
|
112
|
+
}
|
|
113
|
+
export function renderAttachments(state: ComposerState): void {
|
|
114
|
+
const box = $("chat-attach");
|
|
115
|
+
box.innerHTML = "";
|
|
116
|
+
box.style.display = state.attachments.length ? "flex" : "none";
|
|
117
|
+
// Retrieval-mode tag (task brief item 4, transparency principle): whether
|
|
118
|
+
// ANY text file currently gets RAG'd is a property of the WHOLE attached
|
|
119
|
+
// set (buildMessageText sums all text-file chars together), so every text
|
|
120
|
+
// chip shows the tag together, not per-file — a file isn't individually
|
|
121
|
+
// "over threshold", the attachment set is.
|
|
122
|
+
const totalTextChars = state.attachments.reduce((n, a) => n + (a.kind === "text" ? (a.text || "").length : 0), 0);
|
|
123
|
+
const retrievalMode = shouldRetrieve(totalTextChars);
|
|
124
|
+
for (const a of state.attachments) {
|
|
125
|
+
const chip = el("div", "attach-chip", box);
|
|
126
|
+
chip.dataset.attId = String(a.id); // mention-picker pulse target (composer.ts's initMentionPicker)
|
|
127
|
+
if (a.kind === "image") { const im = el("img", "", chip); im.src = "data:" + a.mimeType + ";base64," + a.data; im.alt = a.name; }
|
|
128
|
+
else el("span", "att-ico", chip).textContent = "📄";
|
|
129
|
+
el("span", "att-name", chip).textContent = a.name + (a.truncated ? " (truncated)" : "");
|
|
130
|
+
if (a.kind === "text" && retrievalMode) {
|
|
131
|
+
const tag = el("span", "att-tag", chip);
|
|
132
|
+
tag.textContent = "retrieval";
|
|
133
|
+
tag.title = "This file is large enough that only the most relevant excerpts are sent, not the whole file";
|
|
134
|
+
}
|
|
135
|
+
const x = el("button", "att-x", chip); x.type = "button"; x.textContent = "✕";
|
|
136
|
+
x.onclick = () => { state.attachments = state.attachments.filter((z) => z.id !== a.id); renderAttachments(state); };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function clearAttachments(state: ComposerState): void { state.attachments = []; renderAttachments(state); }
|
|
140
|
+
|
|
141
|
+
/** Result of buildMessageText: the outgoing text the model sees, plus (when
|
|
142
|
+
* retrieval mode fired) the citation list chat.ts needs to attach a Sources
|
|
143
|
+
* panel to the just-sent turn. `citations` is empty for the common small-
|
|
144
|
+
* attachment case — inline mode has nothing to cite, matching today's
|
|
145
|
+
* behavior exactly (task brief item 2: "zero behavior change"). */
|
|
146
|
+
export interface BuiltMessage {
|
|
147
|
+
text: string;
|
|
148
|
+
citations: Citation[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Chat-with-files RAG v1 (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
152
|
+
* beat-matrix Axis 5 — optiq
|
|
153
|
+
* Lab's dependency-free BM25 bar). Size-aware retrieval over attached text
|
|
154
|
+
* files, decided per send:
|
|
155
|
+
* - total attached text <= INLINE_THRESHOLD_CHARS: inline every file
|
|
156
|
+
* verbatim exactly as before this feature existed (LM Studio's
|
|
157
|
+
* transparent "fits context -> just inline it" half of the dual mode).
|
|
158
|
+
* - above threshold: retrieve the top-K chunks (packages/module-chat/src/panel/rag.ts's
|
|
159
|
+
* BM25 index) scored against the outgoing message text, and inject a
|
|
160
|
+
* numbered [1]..[K] context block instead of the raw files. The index
|
|
161
|
+
* is rebuilt fresh on every send (attachments can change between
|
|
162
|
+
* turns) — cheap for the few-dozen-chunk scale this targets.
|
|
163
|
+
* The user's visible message (what addUserMsg renders) is untouched
|
|
164
|
+
* either way — the injection composes into the outgoing WS text only,
|
|
165
|
+
* same as plain file-inlining does today. */
|
|
166
|
+
export function buildMessageText(state: ComposerState, userText: string): BuiltMessage {
|
|
167
|
+
const files = state.attachments.filter((a): a is Attachment & { text: string } => a.kind === "text" && a.text != null);
|
|
168
|
+
if (!files.length) return { text: userText, citations: [] };
|
|
169
|
+
const totalChars = files.reduce((n, a) => n + a.text.length, 0);
|
|
170
|
+
if (!shouldRetrieve(totalChars)) {
|
|
171
|
+
let pre = "";
|
|
172
|
+
for (const a of files) pre += "Attached file: " + a.name + "\n```\n" + a.text + "\n```\n\n";
|
|
173
|
+
return { text: pre + userText, citations: [] };
|
|
174
|
+
}
|
|
175
|
+
const citations = retrieve(files.map((a) => ({ id: a.id, name: a.name, text: a.text })), userText);
|
|
176
|
+
if (!citations.length) return { text: userText, citations: [] }; // no term overlap at all — nothing useful to inject
|
|
177
|
+
return { text: buildContextBlock(citations) + userText, citations };
|
|
178
|
+
}
|
|
179
|
+
export function updateAttachHint(state: ComposerState): void {
|
|
180
|
+
const btn = $("chat-attach-btn");
|
|
181
|
+
if (btn) btn.title = state.visionCapable ? "Attach files or images" : "Attach files (this model can't see images)";
|
|
182
|
+
const mic = $("chat-mic-btn");
|
|
183
|
+
if (mic) mic.style.display = state.transcriptionCapable ? "" : "none";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
187
|
+
LoRA adapter selector
|
|
188
|
+
──────────────────────────────────────────────────────────────────── */
|
|
189
|
+
|
|
190
|
+
export interface AdapterInfo {
|
|
191
|
+
id: string;
|
|
192
|
+
path: string;
|
|
193
|
+
rank?: number;
|
|
194
|
+
mounted?: boolean;
|
|
195
|
+
compatible?: boolean;
|
|
196
|
+
base_model?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function escHtml(s: unknown): string {
|
|
200
|
+
return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ } as Record<string, string>)[c]!);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Pure: builds the <option> markup for the adapter <select> from the
|
|
204
|
+
* /v1/adapters/available list. Split out from refreshAdapters() (which
|
|
205
|
+
* does the fetch + DOM write) so packages/module-chat/tests/panel.test.ts can exercise the
|
|
206
|
+
* esc() discipline on this template directly without a DOM or network —
|
|
207
|
+
* every interpolated field (id, rank, path, base_model) must be escaped,
|
|
208
|
+
* since adapter ids/paths are user-controlled (on-disk directory names). */
|
|
209
|
+
export function renderAdapterOptionsHtml(list: AdapterInfo[]): string {
|
|
210
|
+
return '<option value="">no adapter</option>' + list.map((a) => {
|
|
211
|
+
const label = escHtml(a.id) + (a.rank ? " · r" + escHtml(a.rank) : "") + (a.mounted ? " · mounted" : "");
|
|
212
|
+
if (a.compatible) return `<option value="${escHtml(a.id)}" data-path="${escHtml(a.path)}">${label}</option>`;
|
|
213
|
+
const why = a.base_model
|
|
214
|
+
? `trained for ${escHtml(a.base_model)}, not the currently-served model`
|
|
215
|
+
: "not compatible with the currently-served model";
|
|
216
|
+
return `<option value="${escHtml(a.id)}" disabled title="${why}">${label} (incompatible)</option>`;
|
|
217
|
+
}).join("");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// — LoRA adapter selector. Pi-native: app.html lists + mounts adapters, then
|
|
221
|
+
// tells pi-web which one is active; the before_provider_request hook injects
|
|
222
|
+
// it into every provider request. Default "" = none (base model). Still a
|
|
223
|
+
// single-select <select> (the full three-state routing table lives in
|
|
224
|
+
// adapters-panel.ts) — the presentation upgrade here is showing every adapter found
|
|
225
|
+
// on disk (not just ones that fit the served model) with incompatible
|
|
226
|
+
// entries grayed via the `compatible` flag /v1/adapters/available now
|
|
227
|
+
// returns, each with a title explaining why it's unusable right now. —
|
|
228
|
+
export async function refreshAdapters(): Promise<void> {
|
|
229
|
+
const sel = $("chat-adapter") as HTMLSelectElement | null;
|
|
230
|
+
if (!sel) return;
|
|
231
|
+
let list: AdapterInfo[] = [];
|
|
232
|
+
try { const d = await api("/v1/adapters/available"); list = (d as { adapters?: AdapterInfo[] }).adapters || []; } catch { /* leave list empty */ }
|
|
233
|
+
const cur = sel.value;
|
|
234
|
+
sel.innerHTML = renderAdapterOptionsHtml(list);
|
|
235
|
+
if (cur && list.some((a) => a.id === cur)) sel.value = cur; // keep prior selection
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function onSelectAdapter(sel: HTMLSelectElement, send: (obj: unknown) => boolean): Promise<void> {
|
|
239
|
+
const id = sel.value;
|
|
240
|
+
if (!id) { send({ type: "set_adapter", id: null }); return; } // none -> base model
|
|
241
|
+
const opt = sel.options[sel.selectedIndex];
|
|
242
|
+
const path = opt && opt.getAttribute("data-path");
|
|
243
|
+
if (path) { // auto-load on select; mount() is idempotent server-side
|
|
244
|
+
const r = await api("/v1/adapters", { method: "POST", body: { id, path } });
|
|
245
|
+
if (r && (r.error || r.ok === false)) {
|
|
246
|
+
toast("adapter: " + (r.error || "mount failed"), "err");
|
|
247
|
+
sel.value = ""; send({ type: "set_adapter", id: null }); return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
send({ type: "set_adapter", id });
|
|
251
|
+
// Adapter-namespaced KV means a swap can never reuse the outgoing
|
|
252
|
+
// adapter's cache — make that concrete instead of implicit.
|
|
253
|
+
toast("adapter mounted — new turns start a fresh KV segment", "ok");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function updateThinkingToggle(state: ComposerState): void {
|
|
257
|
+
const b = $("chat-think");
|
|
258
|
+
if (!b) return;
|
|
259
|
+
b.style.display = state.thinkingCapable ? "inline-flex" : "none"; // only for thinking-capable models
|
|
260
|
+
b.classList.toggle("think-on", state.thinkingOn);
|
|
261
|
+
b.title = state.thinkingOn ? "Reasoning on — click to answer directly" : "Reasoning off — click to let the model think";
|
|
262
|
+
b.setAttribute("aria-pressed", String(state.thinkingOn));
|
|
263
|
+
refreshSamplingRecs(state); // recommended temperature follows the thinking state
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
267
|
+
Sampling popover
|
|
268
|
+
──────────────────────────────────────────────────────────────────── */
|
|
269
|
+
|
|
270
|
+
type SamplingField = keyof ComposerState["sampling"];
|
|
271
|
+
|
|
272
|
+
interface SampFieldDef {
|
|
273
|
+
id: string;
|
|
274
|
+
field: SamplingField;
|
|
275
|
+
valId: string;
|
|
276
|
+
rec?: (state: ComposerState) => number;
|
|
277
|
+
fmt: (v: number) => string;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Each row is a slider + a live value readout. A field is "auto" (null
|
|
281
|
+
// override -> the server uses its mode-aware default; temperature/top_p/top_k
|
|
282
|
+
// recommendations come from genDefaults, resolved per-model) until the user
|
|
283
|
+
// drags it, which records an explicit override. Reset returns every field to
|
|
284
|
+
// auto. The three primary fields have a live "rec" (a meaningful recommended
|
|
285
|
+
// number always shown dimmed); the Advanced fields have no natural default
|
|
286
|
+
// number to display, so they just show "off" until overridden.
|
|
287
|
+
const SAMP_FIELDS: SampFieldDef[] = [
|
|
288
|
+
{ id: "samp-temp", field: "temperature", valId: "samp-temp-val", rec: (s) => s.recTemp(), fmt: (v) => v.toFixed(2) },
|
|
289
|
+
{ id: "samp-topp", field: "top_p", valId: "samp-topp-val", rec: (s) => s.recTopP(), fmt: (v) => v.toFixed(2) },
|
|
290
|
+
{ id: "samp-topk", field: "top_k", valId: "samp-topk-val", rec: (s) => s.recTopK(), fmt: (v) => (v >= 1 ? String(Math.round(v)) : "off") },
|
|
291
|
+
];
|
|
292
|
+
// Advanced fields: no meaningful "recommended" number — auto just reads "off".
|
|
293
|
+
const SAMP_ADV_FIELDS: SampFieldDef[] = [
|
|
294
|
+
{ id: "samp-minp", field: "min_p", valId: "samp-minp-val", fmt: (v) => v.toFixed(2) },
|
|
295
|
+
{ id: "samp-xtcp", field: "xtc_probability", valId: "samp-xtcp-val", fmt: (v) => v.toFixed(2) },
|
|
296
|
+
{ id: "samp-xtct", field: "xtc_threshold", valId: "samp-xtct-val", fmt: (v) => v.toFixed(2) },
|
|
297
|
+
{ id: "samp-reppen", field: "repetition_penalty", valId: "samp-reppen-val", fmt: (v) => v.toFixed(2) },
|
|
298
|
+
{ id: "samp-repctx", field: "repetition_context_size", valId: "samp-repctx-val", fmt: (v) => String(Math.round(v)) },
|
|
299
|
+
{ id: "samp-prespen", field: "presence_penalty", valId: "samp-prespen-val", fmt: (v) => v.toFixed(2) },
|
|
300
|
+
{ id: "samp-freqpen", field: "frequency_penalty", valId: "samp-freqpen-val", fmt: (v) => v.toFixed(2) },
|
|
301
|
+
];
|
|
302
|
+
const SAMP_SLIDER_FIELDS = SAMP_FIELDS.concat(SAMP_ADV_FIELDS);
|
|
303
|
+
|
|
304
|
+
// Sync every slider + readout to current state: an overridden field shows its
|
|
305
|
+
// set value (bright readout); an auto primary field shows the recommended
|
|
306
|
+
// value (dimmed) and tracks it live — e.g. temperature when the thinking
|
|
307
|
+
// toggle flips; an auto Advanced field just shows "off". Also syncs the seed
|
|
308
|
+
// text input. Called on thinking-toggle flips, ready-frame arrival, popover
|
|
309
|
+
// open, and reset.
|
|
310
|
+
export function refreshSamplingRecs(state: ComposerState): void {
|
|
311
|
+
for (const f of SAMP_FIELDS) {
|
|
312
|
+
const slider = $(f.id) as HTMLInputElement | null, val = $(f.valId); if (!slider || !val) continue;
|
|
313
|
+
const over = state.sampling[f.field];
|
|
314
|
+
const v = over != null ? over : f.rec!(state);
|
|
315
|
+
slider.value = String(v);
|
|
316
|
+
val.textContent = f.fmt(v);
|
|
317
|
+
val.classList.toggle("auto", over == null);
|
|
318
|
+
}
|
|
319
|
+
for (const f of SAMP_ADV_FIELDS) {
|
|
320
|
+
const slider = $(f.id) as HTMLInputElement | null, val = $(f.valId); if (!slider || !val) continue;
|
|
321
|
+
const over = state.sampling[f.field];
|
|
322
|
+
slider.value = String(over != null ? over : slider.min);
|
|
323
|
+
val.textContent = over != null ? f.fmt(over) : "off";
|
|
324
|
+
val.classList.toggle("auto", over == null);
|
|
325
|
+
}
|
|
326
|
+
const seedInput = $("samp-seed") as HTMLInputElement | null;
|
|
327
|
+
if (seedInput) seedInput.value = state.sampling.seed != null ? String(state.sampling.seed) : "";
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// User dragged a slider -> record the explicit override, brighten the readout,
|
|
331
|
+
// and push the whole sampling state to the server.
|
|
332
|
+
function onSlide(state: ComposerState, f: SampFieldDef, send: (obj: unknown) => boolean): void {
|
|
333
|
+
const slider = $(f.id) as HTMLInputElement | null; if (!slider) return;
|
|
334
|
+
const v = Number(slider.value);
|
|
335
|
+
(state.sampling[f.field] as number) = v;
|
|
336
|
+
const val = $(f.valId); if (val) { val.textContent = f.fmt(v); val.classList.remove("auto"); }
|
|
337
|
+
updateSamplingUi(state);
|
|
338
|
+
pushSampling(state, send);
|
|
339
|
+
}
|
|
340
|
+
function onSeedInput(state: ComposerState, send: (obj: unknown) => boolean): void {
|
|
341
|
+
const input = $("samp-seed") as HTMLInputElement | null; if (!input) return;
|
|
342
|
+
const raw = input.value.trim();
|
|
343
|
+
let seed: number | null = raw === "" ? null : Number(raw);
|
|
344
|
+
if (seed != null && !Number.isFinite(seed)) seed = null;
|
|
345
|
+
state.sampling.seed = seed;
|
|
346
|
+
updateSamplingUi(state);
|
|
347
|
+
pushSampling(state, send);
|
|
348
|
+
}
|
|
349
|
+
function pushSampling(state: ComposerState, send: (obj: unknown) => boolean): void {
|
|
350
|
+
// Session mode omits `scope` entirely — identical wire shape to before
|
|
351
|
+
// this feature existed ("session mode behaves exactly as today," task
|
|
352
|
+
// brief item 2). Only next-turn mode adds the field, and only arms the
|
|
353
|
+
// one-shot indicator once there's actually an override to apply for it
|
|
354
|
+
// (sending scope:"next_turn" with every field null would arm a chip for
|
|
355
|
+
// an override that doesn't exist).
|
|
356
|
+
if (state.samplingScope === "next_turn") {
|
|
357
|
+
send({ type: "set_sampling", scope: "next_turn", ...state.sampling });
|
|
358
|
+
state.oneShotArmed = Object.values(state.sampling).some((v) => v != null);
|
|
359
|
+
} else {
|
|
360
|
+
send({ type: "set_sampling", ...state.sampling });
|
|
361
|
+
state.oneShotArmed = false;
|
|
362
|
+
}
|
|
363
|
+
updateSamplingUi(state);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// The pill shows a lit dot whenever any field is overridden, and a distinct
|
|
367
|
+
// "armed" dot state while a one-shot next-turn override is queued but hasn't
|
|
368
|
+
// applied yet (task brief item 2: "the Sampling pill's dot shows a distinct
|
|
369
|
+
// state while a one-shot override is armed").
|
|
370
|
+
function updateSamplingUi(state: ComposerState): void {
|
|
371
|
+
const dirty = Object.values(state.sampling).some((v) => v != null);
|
|
372
|
+
const pill = $("chat-sampling");
|
|
373
|
+
if (pill) {
|
|
374
|
+
pill.classList.toggle("dirty", dirty && !state.oneShotArmed);
|
|
375
|
+
pill.classList.toggle("armed", state.oneShotArmed);
|
|
376
|
+
pill.title = state.oneShotArmed
|
|
377
|
+
? "Sampling override armed for your next message only"
|
|
378
|
+
: dirty
|
|
379
|
+
? "Sampling overridden — click to edit or reset"
|
|
380
|
+
: "Sampling controls (temperature · top_p · top_k · Advanced)";
|
|
381
|
+
}
|
|
382
|
+
const chip = $("samp-oneshot-chip");
|
|
383
|
+
if (chip) chip.style.display = state.oneShotArmed ? "inline-flex" : "none";
|
|
384
|
+
const scopeSelect = $("samp-scope") as HTMLSelectElement | null;
|
|
385
|
+
if (scopeSelect && scopeSelect.value !== state.samplingScope) scopeSelect.value = state.samplingScope;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Called from chat.ts on every `turn_end` — the server applies a one-shot
|
|
389
|
+
* next_turn override for exactly one turn then reverts, so the client-side
|
|
390
|
+
* indicator must clear in lockstep rather than guessing a timeout. A no-op
|
|
391
|
+
* when no one-shot was armed (the common case: session-mode sampling, or no
|
|
392
|
+
* overrides set at all). Does NOT touch state.sampling — the server is the
|
|
393
|
+
* source of truth for what the NEXT turn's session-level values revert to;
|
|
394
|
+
* this only clears the local "armed" indicator and flips scope back to
|
|
395
|
+
* session so the popover doesn't silently keep applying next-turn-only
|
|
396
|
+
* semantics to values the user may tweak next without re-selecting scope. */
|
|
397
|
+
export function clearOneShotArmed(state: ComposerState): void {
|
|
398
|
+
if (!state.oneShotArmed) return;
|
|
399
|
+
state.oneShotArmed = false;
|
|
400
|
+
state.samplingScope = "session";
|
|
401
|
+
updateSamplingUi(state);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function resetSampling(state: ComposerState, send: (obj: unknown) => boolean): void {
|
|
405
|
+
for (const k of Object.keys(state.sampling) as SamplingField[]) (state.sampling[k] as number | null) = null;
|
|
406
|
+
state.samplingScope = "session";
|
|
407
|
+
refreshSamplingRecs(state); updateSamplingUi(state);
|
|
408
|
+
pushSampling(state, send);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function initSampling(state: ComposerState, send: (obj: unknown) => boolean): void {
|
|
412
|
+
const pill = $("chat-sampling"), pop = $("chat-sampling-pop");
|
|
413
|
+
if (!pill || !pop) return;
|
|
414
|
+
const trap: FocusTrap = trapFocus(pop, () => pop.classList.contains("open"));
|
|
415
|
+
const setOpen = (open: boolean) => {
|
|
416
|
+
const was = pop.classList.contains("open");
|
|
417
|
+
if (open === was) return;
|
|
418
|
+
if (open) trap.capture();
|
|
419
|
+
pop.classList.toggle("open", open);
|
|
420
|
+
pill.classList.toggle("on", open);
|
|
421
|
+
pill.setAttribute("aria-expanded", String(open));
|
|
422
|
+
if (open) refreshSamplingRecs(state);
|
|
423
|
+
else trap.restore();
|
|
424
|
+
};
|
|
425
|
+
// Exposed so the global Escape sweep (closeTopOverlay) can close this
|
|
426
|
+
// popover uniformly instead of keeping a second Escape listener.
|
|
427
|
+
setPopoverClose("chat-sampling-pop", () => setOpen(false));
|
|
428
|
+
const toggle = () => setOpen(!pop.classList.contains("open"));
|
|
429
|
+
pill.onclick = (e) => { e.stopPropagation(); toggle(); };
|
|
430
|
+
pill.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter" || (e as KeyboardEvent).key === " ") { e.preventDefault(); toggle(); } });
|
|
431
|
+
pop.addEventListener("click", (e) => e.stopPropagation());
|
|
432
|
+
// Click-away closes the popover (Escape is handled globally, see
|
|
433
|
+
// closeTopOverlay — kept out of this listener to avoid a second,
|
|
434
|
+
// divergent Escape path).
|
|
435
|
+
document.addEventListener("click", () => setOpen(false));
|
|
436
|
+
for (const f of SAMP_SLIDER_FIELDS) {
|
|
437
|
+
const e = $(f.id); if (!e) continue;
|
|
438
|
+
e.addEventListener("input", () => onSlide(state, f, send));
|
|
439
|
+
}
|
|
440
|
+
const seedInput = $("samp-seed");
|
|
441
|
+
if (seedInput) seedInput.addEventListener("input", () => onSeedInput(state, send));
|
|
442
|
+
const reset = $("samp-reset"); if (reset) reset.onclick = (e) => { e.stopPropagation(); resetSampling(state, send); };
|
|
443
|
+
// Scope control (task brief item 2): switching to "next message only"
|
|
444
|
+
// doesn't itself send anything — it just changes what the NEXT slider
|
|
445
|
+
// drag/seed edit means. Switching back to "this session" while a one-shot
|
|
446
|
+
// was armed but not yet consumed just clears the local indicator; the
|
|
447
|
+
// server-side revert already only ever happens at turn_end, so there's
|
|
448
|
+
// nothing to un-arm server-side from a scope flip alone (no changed value
|
|
449
|
+
// was ever sent for it to apply to session-persistently and no frame
|
|
450
|
+
// corresponds to that alone).
|
|
451
|
+
const scopeSelect = $("samp-scope") as HTMLSelectElement | null;
|
|
452
|
+
if (scopeSelect) scopeSelect.addEventListener("change", (e) => {
|
|
453
|
+
e.stopPropagation();
|
|
454
|
+
state.samplingScope = scopeSelect.value === "next_turn" ? "next_turn" : "session";
|
|
455
|
+
if (state.samplingScope === "session") { state.oneShotArmed = false; }
|
|
456
|
+
updateSamplingUi(state);
|
|
457
|
+
});
|
|
458
|
+
updateSamplingUi(state); refreshSamplingRecs(state);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
462
|
+
System prompt + presets v1 (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
463
|
+
beat-matrix Axis 4)
|
|
464
|
+
──────────────────────────────────────────────────────────────────── */
|
|
465
|
+
|
|
466
|
+
const SYS_PROMPT_MAX = 4000;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* One saved preset bundle: a name, the system-prompt text, and a sampling
|
|
470
|
+
* snapshot. Shaped so the persona-bundle stretch item (system prompt +
|
|
471
|
+
* adapter selection + tool allowlist + memory scope;
|
|
472
|
+
* `02d723a:docs/design/web-chat-redesign.md` §5) is an
|
|
473
|
+
* ADDITIVE extension of this same object — new optional fields, no
|
|
474
|
+
* migration of the fields already here. Saved to localStorage, never sent
|
|
475
|
+
* to the server (presets are a client-only convenience over the existing
|
|
476
|
+
* set_system_prompt/set_sampling frames).
|
|
477
|
+
*/
|
|
478
|
+
export interface Preset {
|
|
479
|
+
name: string;
|
|
480
|
+
systemPrompt: string | null;
|
|
481
|
+
sampling: SamplingOverrides;
|
|
482
|
+
// Persona-bundle fields (not read/written by v1, documented here so the
|
|
483
|
+
// shape doesn't need revisiting): adapterId?: string | null;
|
|
484
|
+
// toolAllowlist?: string[]; memoryScope?: "off" | "read" | "read+write".
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const PRESETS_KEY = "mlxbun.presets";
|
|
488
|
+
|
|
489
|
+
/** Load saved presets from localStorage. Corrupt/missing storage -> empty
|
|
490
|
+
* list (never throws — a settings-picker feature must never break the
|
|
491
|
+
* composer it lives next to). */
|
|
492
|
+
export function loadPresets(): Preset[] {
|
|
493
|
+
try {
|
|
494
|
+
const raw = localStorage.getItem(PRESETS_KEY);
|
|
495
|
+
if (!raw) return [];
|
|
496
|
+
const parsed = JSON.parse(raw);
|
|
497
|
+
if (!Array.isArray(parsed)) return [];
|
|
498
|
+
return parsed.filter((p): p is Preset =>
|
|
499
|
+
!!p && typeof p === "object" && typeof (p as Preset).name === "string");
|
|
500
|
+
} catch {
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function savePresets(presets: Preset[]): void {
|
|
506
|
+
try { localStorage.setItem(PRESETS_KEY, JSON.stringify(presets)); } catch { /* storage full/unavailable — best effort */ }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Build a preset snapshot from the current composer state. Pure. */
|
|
510
|
+
export function presetFromState(name: string, state: ComposerState): Preset {
|
|
511
|
+
return { name, systemPrompt: state.systemPrompt, sampling: { ...state.sampling } };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Insert-or-replace by name (case-sensitive), so re-saving "Concise" updates
|
|
515
|
+
* it in place rather than piling up duplicates. Pure — returns the new
|
|
516
|
+
* array, doesn't touch storage (callers persist via savePresets). */
|
|
517
|
+
export function upsertPreset(presets: Preset[], preset: Preset): Preset[] {
|
|
518
|
+
const i = presets.findIndex((p) => p.name === preset.name);
|
|
519
|
+
if (i === -1) return [...presets, preset];
|
|
520
|
+
const out = presets.slice();
|
|
521
|
+
out[i] = preset;
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Remove a preset by name. Pure. */
|
|
526
|
+
export function removePreset(presets: Preset[], name: string): Preset[] {
|
|
527
|
+
return presets.filter((p) => p.name !== name);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Apply a preset's system prompt + sampling to the composer state and push
|
|
531
|
+
* both through the EXISTING set_system_prompt/set_sampling frames (no new
|
|
532
|
+
* wire shape for presets — they're a client-side convenience over frames
|
|
533
|
+
* that already exist). */
|
|
534
|
+
export function applyPreset(state: ComposerState, preset: Preset, send: (obj: unknown) => boolean): void {
|
|
535
|
+
state.systemPrompt = preset.systemPrompt ?? null;
|
|
536
|
+
state.sampling = {
|
|
537
|
+
temperature: null, top_p: null, top_k: null, min_p: null,
|
|
538
|
+
xtc_probability: null, xtc_threshold: null, repetition_penalty: null,
|
|
539
|
+
repetition_context_size: null, presence_penalty: null, frequency_penalty: null,
|
|
540
|
+
seed: null,
|
|
541
|
+
...preset.sampling,
|
|
542
|
+
};
|
|
543
|
+
refreshSystemPromptUi(state);
|
|
544
|
+
pushSystemPrompt(state, send);
|
|
545
|
+
refreshSamplingRecs(state);
|
|
546
|
+
updateSamplingUi(state);
|
|
547
|
+
pushSampling(state, send);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function pushSystemPrompt(state: ComposerState, send: (obj: unknown) => boolean): void {
|
|
551
|
+
send({ type: "set_system_prompt", text: state.systemPrompt });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Sync the textarea, character count, and indicator pill to current state.
|
|
555
|
+
* Called on popover open and after every edit/clear/preset-apply. */
|
|
556
|
+
export function refreshSystemPromptUi(state: ComposerState): void {
|
|
557
|
+
const ta = $("sysprompt-text") as HTMLTextAreaElement | null;
|
|
558
|
+
const count = $("sysprompt-count");
|
|
559
|
+
const text = state.systemPrompt ?? "";
|
|
560
|
+
if (ta && ta.value !== text) ta.value = text;
|
|
561
|
+
if (count) count.textContent = text.length + " / " + SYS_PROMPT_MAX;
|
|
562
|
+
// The pill must always visibly show a custom prompt is shaping replies —
|
|
563
|
+
// a trust rule, not a nicety (task brief item 2).
|
|
564
|
+
const active = text.trim().length > 0;
|
|
565
|
+
const pill = $("chat-sysprompt");
|
|
566
|
+
if (pill) {
|
|
567
|
+
pill.classList.toggle("dirty", active);
|
|
568
|
+
pill.title = active
|
|
569
|
+
? "Custom system prompt active — click to edit or clear"
|
|
570
|
+
: "System prompt (shapes how the assistant replies in this chat)";
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function onSystemPromptInput(state: ComposerState, send: (obj: unknown) => boolean): void {
|
|
575
|
+
const ta = $("sysprompt-text") as HTMLTextAreaElement | null;
|
|
576
|
+
if (!ta) return;
|
|
577
|
+
const text = ta.value.slice(0, SYS_PROMPT_MAX);
|
|
578
|
+
if (text !== ta.value) ta.value = text; // hard cap, mirrors the live count
|
|
579
|
+
state.systemPrompt = text.length ? text : null;
|
|
580
|
+
const count = $("sysprompt-count");
|
|
581
|
+
if (count) count.textContent = text.length + " / " + SYS_PROMPT_MAX;
|
|
582
|
+
const pill = $("chat-sysprompt");
|
|
583
|
+
if (pill) {
|
|
584
|
+
const active = text.trim().length > 0;
|
|
585
|
+
pill.classList.toggle("dirty", active);
|
|
586
|
+
}
|
|
587
|
+
pushSystemPrompt(state, send);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export function clearSystemPrompt(state: ComposerState, send: (obj: unknown) => boolean): void {
|
|
591
|
+
state.systemPrompt = null;
|
|
592
|
+
refreshSystemPromptUi(state);
|
|
593
|
+
pushSystemPrompt(state, send);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Pure: builds the <option> markup for the presets <select>. Split out for
|
|
597
|
+
* the same reason renderAdapterOptionsHtml is — testable esc() discipline
|
|
598
|
+
* without a DOM. Preset names are user-authored, so every interpolation
|
|
599
|
+
* must be escaped. */
|
|
600
|
+
export function renderPresetOptionsHtml(presets: Preset[]): string {
|
|
601
|
+
return '<option value="">— presets —</option>' +
|
|
602
|
+
presets.map((p) => `<option value="${escHtml(p.name)}">${escHtml(p.name)}</option>`).join("");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function renderPresetSelect(): void {
|
|
606
|
+
const sel = $("sysprompt-preset-select") as HTMLSelectElement | null;
|
|
607
|
+
if (!sel) return;
|
|
608
|
+
const cur = sel.value;
|
|
609
|
+
sel.innerHTML = renderPresetOptionsHtml(loadPresets());
|
|
610
|
+
if (cur && [...sel.options].some((o) => o.value === cur)) sel.value = cur;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export function initSystemPrompt(state: ComposerState, send: (obj: unknown) => boolean): void {
|
|
614
|
+
const pill = $("chat-sysprompt"), pop = $("chat-sysprompt-pop");
|
|
615
|
+
if (!pill || !pop) return;
|
|
616
|
+
const trap: FocusTrap = trapFocus(pop, () => pop.classList.contains("open"));
|
|
617
|
+
const setOpen = (open: boolean) => {
|
|
618
|
+
const was = pop.classList.contains("open");
|
|
619
|
+
if (open === was) return;
|
|
620
|
+
if (open) trap.capture();
|
|
621
|
+
pop.classList.toggle("open", open);
|
|
622
|
+
pill.classList.toggle("on", open);
|
|
623
|
+
pill.setAttribute("aria-expanded", String(open));
|
|
624
|
+
if (open) { refreshSystemPromptUi(state); renderPresetSelect(); }
|
|
625
|
+
else trap.restore();
|
|
626
|
+
};
|
|
627
|
+
setPopoverClose("chat-sysprompt-pop", () => setOpen(false));
|
|
628
|
+
const toggle = () => setOpen(!pop.classList.contains("open"));
|
|
629
|
+
pill.onclick = (e) => { e.stopPropagation(); toggle(); };
|
|
630
|
+
pill.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter" || (e as KeyboardEvent).key === " ") { e.preventDefault(); toggle(); } });
|
|
631
|
+
pop.addEventListener("click", (e) => e.stopPropagation());
|
|
632
|
+
document.addEventListener("click", () => setOpen(false));
|
|
633
|
+
|
|
634
|
+
const ta = $("sysprompt-text");
|
|
635
|
+
if (ta) ta.addEventListener("input", () => onSystemPromptInput(state, send));
|
|
636
|
+
const clearBtn = $("sysprompt-clear");
|
|
637
|
+
if (clearBtn) clearBtn.onclick = (e) => { e.stopPropagation(); clearSystemPrompt(state, send); };
|
|
638
|
+
|
|
639
|
+
// — Presets v1: save-current / apply / delete over the select above. —
|
|
640
|
+
const presetSelect = $("sysprompt-preset-select") as HTMLSelectElement | null;
|
|
641
|
+
if (presetSelect) presetSelect.addEventListener("change", (e) => {
|
|
642
|
+
e.stopPropagation();
|
|
643
|
+
const name = presetSelect.value;
|
|
644
|
+
if (!name) return;
|
|
645
|
+
const preset = loadPresets().find((p) => p.name === name);
|
|
646
|
+
if (preset) { applyPreset(state, preset, send); toast('Applied preset "' + name + '"', "ok"); }
|
|
647
|
+
});
|
|
648
|
+
const saveBtn = $("sysprompt-preset-save");
|
|
649
|
+
if (saveBtn) saveBtn.onclick = (e) => {
|
|
650
|
+
e.stopPropagation();
|
|
651
|
+
const name = (window.prompt("Save current system prompt + sampling as a preset named:") || "").trim();
|
|
652
|
+
if (!name) return;
|
|
653
|
+
const presets = upsertPreset(loadPresets(), presetFromState(name, state));
|
|
654
|
+
savePresets(presets);
|
|
655
|
+
renderPresetSelect();
|
|
656
|
+
if (presetSelect) presetSelect.value = name;
|
|
657
|
+
toast('Saved preset "' + name + '"', "ok");
|
|
658
|
+
};
|
|
659
|
+
const delBtn = $("sysprompt-preset-delete");
|
|
660
|
+
if (delBtn) delBtn.onclick = (e) => {
|
|
661
|
+
e.stopPropagation();
|
|
662
|
+
const name = presetSelect?.value;
|
|
663
|
+
if (!name) { toast("Select a preset to delete first", "err"); return; }
|
|
664
|
+
savePresets(removePreset(loadPresets(), name));
|
|
665
|
+
renderPresetSelect();
|
|
666
|
+
toast('Deleted preset "' + name + '"', "ok");
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
refreshSystemPromptUi(state);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
673
|
+
Perf strip: tok/s, TTFT, context-fill
|
|
674
|
+
──────────────────────────────────────────────────────────────────── */
|
|
675
|
+
|
|
676
|
+
export function fmtTok(n: number | null | undefined): string {
|
|
677
|
+
if (n == null) return "—";
|
|
678
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
679
|
+
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k";
|
|
680
|
+
return String(n);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export interface ContextFrame {
|
|
684
|
+
tokens: number | null;
|
|
685
|
+
contextWindow: number;
|
|
686
|
+
percent: number | null;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export function renderContext(m: ContextFrame): void {
|
|
690
|
+
const node = $("pf-fill");
|
|
691
|
+
if (!node || !m.contextWindow) return;
|
|
692
|
+
const pct = m.percent;
|
|
693
|
+
const pctStr = pct == null ? "" : " (" + Math.round(pct) + "%)";
|
|
694
|
+
node.textContent = "ctx " + fmtTok(m.tokens) + "/" + fmtTok(m.contextWindow) + pctStr;
|
|
695
|
+
node.style.color = pct == null ? "var(--dimmer)" : pct >= 85 ? "var(--orange)" : pct >= 70 ? "var(--yellow)" : "var(--dimmer)";
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
699
|
+
Unified "#" retrieval mention: typing "#" opens a
|
|
700
|
+
picker over BOTH currently-attached files and vault articles (via
|
|
701
|
+
/api/memory/search) as one retrieval gesture — Open WebUI's `#`-mention
|
|
702
|
+
pattern, extended so "attach a file" and "search my memory" are the same
|
|
703
|
+
interaction. Selecting a file pulses its existing attach chip (the file
|
|
704
|
+
is already attached — nothing to insert); selecting an article inserts
|
|
705
|
+
`[[Article Name]]` at the query span (the wikilink syntax
|
|
706
|
+
src/memory/vault.ts's resolveWikilinkToStem/extractWikilinkTargets
|
|
707
|
+
already resolves, so the agent's memory tools pick it up naturally, same
|
|
708
|
+
as in the vault's own prose). No vault -> files-only picker. Pure
|
|
709
|
+
query-detection/insertion helpers below are unit-tested directly
|
|
710
|
+
(packages/module-chat/tests/panel.test.ts); DOM wiring (initMentionPicker) owns the
|
|
711
|
+
picker's open/close/keyboard-nav state.
|
|
712
|
+
──────────────────────────────────────────────────────────────────── */
|
|
713
|
+
|
|
714
|
+
export interface MentionQuery {
|
|
715
|
+
/** Index (in the textarea value) where the "#" itself sits. */
|
|
716
|
+
hashIndex: number;
|
|
717
|
+
/** The text typed after "#", up to the caret (no spaces — a space ends
|
|
718
|
+
* the mention span, matching Open WebUI/Slack-style "#" pickers). */
|
|
719
|
+
query: string;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Pure: does the caret currently sit inside an open "#mention" span? Scans
|
|
723
|
+
* backward from the caret for a "#" that isn't preceded by a word
|
|
724
|
+
* character (so "C#" or "issue#12" don't trigger it) and has no whitespace
|
|
725
|
+
* between it and the caret. Returns null when there's no open mention. */
|
|
726
|
+
export function detectMentionQuery(text: string, caret: number): MentionQuery | null {
|
|
727
|
+
if (caret < 0 || caret > text.length) return null;
|
|
728
|
+
let i = caret - 1;
|
|
729
|
+
while (i >= 0) {
|
|
730
|
+
const ch = text[i]!;
|
|
731
|
+
if (ch === "#") {
|
|
732
|
+
const before = i > 0 ? text[i - 1]! : "";
|
|
733
|
+
if (/[A-Za-z0-9_]/.test(before)) return null; // "C#", "issue#12" — not a mention
|
|
734
|
+
return { hashIndex: i, query: text.slice(i + 1, caret) };
|
|
735
|
+
}
|
|
736
|
+
if (/\s/.test(ch)) return null; // whitespace ends the mention span
|
|
737
|
+
i--;
|
|
738
|
+
}
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export type MentionItem =
|
|
743
|
+
| { kind: "file"; id: number; name: string }
|
|
744
|
+
| { kind: "article"; name: string; excerpt?: string };
|
|
745
|
+
|
|
746
|
+
const MAX_MENTION_FILE_RESULTS = 6;
|
|
747
|
+
const MAX_MENTION_ARTICLE_RESULTS = 8;
|
|
748
|
+
|
|
749
|
+
/** Pure: filters currently-attached files by the mention query (case-
|
|
750
|
+
* insensitive substring on name; empty query matches everything, so
|
|
751
|
+
* opening the picker with just "#" shows all attachments first). */
|
|
752
|
+
export function filterFileMentions(attachments: Attachment[], query: string): MentionItem[] {
|
|
753
|
+
const q = query.trim().toLowerCase();
|
|
754
|
+
const matches = attachments.filter((a) => !q || a.name.toLowerCase().includes(q));
|
|
755
|
+
return matches.slice(0, MAX_MENTION_FILE_RESULTS).map((a) => ({ kind: "file", id: a.id, name: a.name }));
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Pure: combines file matches (always first — already-attached context is
|
|
759
|
+
* the cheapest/most relevant hit) with article search hits into one flat
|
|
760
|
+
* list for keyboard nav. Article list is capped independently of files so
|
|
761
|
+
* a vault with many hits doesn't crowd out attachments. */
|
|
762
|
+
export function buildMentionItems(fileMatches: MentionItem[], articleNames: string[]): MentionItem[] {
|
|
763
|
+
const articles: MentionItem[] = articleNames.slice(0, MAX_MENTION_ARTICLE_RESULTS).map((name) => ({ kind: "article", name }));
|
|
764
|
+
return [...fileMatches, ...articles];
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Pure: replaces the open "#query" span with the mention's insertion text
|
|
768
|
+
* and returns the new full text + where the caret should land. Files
|
|
769
|
+
* insert nothing (the file is already attached — the span is just
|
|
770
|
+
* deleted); articles insert `[[Name]] ` (trailing space so typing
|
|
771
|
+
* continues naturally, matching resolveWikilinkToStem's bracket syntax). */
|
|
772
|
+
export function applyMention(text: string, q: MentionQuery, item: MentionItem, caret: number): { text: string; caret: number } {
|
|
773
|
+
const insertion = item.kind === "article" ? "[[" + item.name + "]] " : "";
|
|
774
|
+
const before = text.slice(0, q.hashIndex);
|
|
775
|
+
const after = text.slice(caret);
|
|
776
|
+
const newText = before + insertion + after;
|
|
777
|
+
return { text: newText, caret: before.length + insertion.length };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function escMention(s: unknown): string { return escHtml(s); }
|
|
781
|
+
|
|
782
|
+
/** Pure: builds the picker's inner HTML for a given item list + selected
|
|
783
|
+
* index. Split out for esc()-discipline testing without a DOM, same
|
|
784
|
+
* reasoning as renderAdapterOptionsHtml/renderPresetOptionsHtml above —
|
|
785
|
+
* file/article names are user/vault data and must be escaped. */
|
|
786
|
+
export function renderMentionListHtml(items: MentionItem[], selected: number, memoryEnabled: boolean): string {
|
|
787
|
+
if (!items.length) {
|
|
788
|
+
return '<div class="mention-empty">' +
|
|
789
|
+
(memoryEnabled ? "No matching files or memory articles." : "No matching attached files.") +
|
|
790
|
+
"</div>";
|
|
791
|
+
}
|
|
792
|
+
let filesHtml = "", articlesHtml = "";
|
|
793
|
+
items.forEach((item, i) => {
|
|
794
|
+
const active = i === selected ? " active" : "";
|
|
795
|
+
const row = item.kind === "file"
|
|
796
|
+
? '<div class="mention-row' + active + '" data-idx="' + i + '" role="option">' +
|
|
797
|
+
'<span class="mention-ico" aria-hidden="true">📄</span>' +
|
|
798
|
+
'<span class="mention-label">' + escMention(item.name) + "</span>" +
|
|
799
|
+
'<span class="mention-tag">attached</span></div>'
|
|
800
|
+
: '<div class="mention-row' + active + '" data-idx="' + i + '" role="option">' +
|
|
801
|
+
'<span class="mention-ico" aria-hidden="true">◆</span>' +
|
|
802
|
+
'<span class="mention-label">' + escMention(item.name.replace(/_/g, " ")) + "</span>" +
|
|
803
|
+
'<span class="mention-tag">memory</span></div>';
|
|
804
|
+
if (item.kind === "file") filesHtml += row; else articlesHtml += row;
|
|
805
|
+
});
|
|
806
|
+
const sections: string[] = [];
|
|
807
|
+
if (filesHtml) sections.push('<div class="mention-sec-title">Attached files</div>' + filesHtml);
|
|
808
|
+
if (articlesHtml) sections.push('<div class="mention-sec-title">Memory</div>' + articlesHtml);
|
|
809
|
+
return sections.join("");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
type MentionSearchResp = ApiEnvelope & { summaries?: { article: string }[]; enabled?: boolean };
|
|
813
|
+
|
|
814
|
+
/** DOM wiring for the picker: opens on "#", filters attachments locally +
|
|
815
|
+
* debounces a /api/memory/search round trip, keyboard nav (arrows/Enter/
|
|
816
|
+
* Escape), click-to-select, and returns focus to the textarea at the
|
|
817
|
+
* right caret position on every path (selection, Escape, click-away). */
|
|
818
|
+
export function initMentionPicker(state: ComposerState, box: HTMLTextAreaElement, opts: { onFileSelected: (a: Attachment) => void }): void {
|
|
819
|
+
const pop = $("chat-mention-pop");
|
|
820
|
+
if (!pop) return;
|
|
821
|
+
let items: MentionItem[] = [];
|
|
822
|
+
let selected = 0;
|
|
823
|
+
let activeQuery: MentionQuery | null = null;
|
|
824
|
+
let memoryEnabled = true; // optimistic; a failed/empty search just yields no article rows
|
|
825
|
+
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
826
|
+
let searchSeq = 0;
|
|
827
|
+
|
|
828
|
+
function render(): void {
|
|
829
|
+
pop.innerHTML = renderMentionListHtml(items, selected, memoryEnabled);
|
|
830
|
+
pop.querySelectorAll<HTMLElement>(".mention-row").forEach((row) => {
|
|
831
|
+
row.addEventListener("mousedown", (e) => { e.preventDefault(); const idx = Number(row.dataset.idx); if (Number.isFinite(idx)) select(idx); });
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function close(): void {
|
|
836
|
+
pop.classList.remove("open");
|
|
837
|
+
activeQuery = null;
|
|
838
|
+
items = [];
|
|
839
|
+
selected = 0;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function select(idx: number): void {
|
|
843
|
+
const item = items[idx];
|
|
844
|
+
if (!item || !activeQuery) { close(); return; }
|
|
845
|
+
if (item.kind === "file") {
|
|
846
|
+
const att = state.attachments.find((a) => a.id === item.id);
|
|
847
|
+
const q = activeQuery;
|
|
848
|
+
// File mentions don't rewrite the text — the file is already attached
|
|
849
|
+
// context — just clear the typed "#query" span and pulse its chip.
|
|
850
|
+
const before = box.value.slice(0, q.hashIndex);
|
|
851
|
+
const after = box.value.slice(box.selectionStart ?? box.value.length);
|
|
852
|
+
box.value = before + after;
|
|
853
|
+
box.selectionStart = box.selectionEnd = before.length;
|
|
854
|
+
if (att) opts.onFileSelected(att);
|
|
855
|
+
} else {
|
|
856
|
+
const q = activeQuery;
|
|
857
|
+
const caret = box.selectionStart ?? box.value.length;
|
|
858
|
+
const result = applyMention(box.value, q, item, caret);
|
|
859
|
+
box.value = result.text;
|
|
860
|
+
box.selectionStart = box.selectionEnd = result.caret;
|
|
861
|
+
}
|
|
862
|
+
close();
|
|
863
|
+
box.focus();
|
|
864
|
+
box.dispatchEvent(new Event("input")); // resize + any other input-driven UI stays in sync
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function runSearch(query: string): Promise<void> {
|
|
868
|
+
const seq = ++searchSeq;
|
|
869
|
+
if (!query.trim()) { updateItems([]); return; }
|
|
870
|
+
try {
|
|
871
|
+
const d = await api<MentionSearchResp>("/api/memory/search?q=" + encodeURIComponent(query));
|
|
872
|
+
if (seq !== searchSeq) return; // stale response — a newer keystroke already superseded it
|
|
873
|
+
// enabled:false is the REST layer's explicit "no vault yet" signal
|
|
874
|
+
// (apps/mlx-bun/src/server/memory-routes.ts's noVault()) — only THAT latches the picker into
|
|
875
|
+
// files-only mode; any other failure (a transient search error) just
|
|
876
|
+
// yields an empty article list for this keystroke, not a permanent
|
|
877
|
+
// downgrade for the rest of the picker session.
|
|
878
|
+
if (d.enabled === false) { memoryEnabled = false; updateItems([]); return; }
|
|
879
|
+
if (d.ok === false) { updateItems([]); return; }
|
|
880
|
+
updateItems((d.summaries || []).map((s) => s.article));
|
|
881
|
+
} catch {
|
|
882
|
+
if (seq !== searchSeq) return;
|
|
883
|
+
updateItems([]);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function updateItems(articleNames: string[]): void {
|
|
888
|
+
if (!activeQuery) return;
|
|
889
|
+
const fileMatches = filterFileMentions(state.attachments, activeQuery.query);
|
|
890
|
+
items = buildMentionItems(fileMatches, articleNames);
|
|
891
|
+
selected = 0;
|
|
892
|
+
render();
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function openFor(q: MentionQuery): void {
|
|
896
|
+
activeQuery = q;
|
|
897
|
+
pop.classList.add("open");
|
|
898
|
+
const fileMatches = filterFileMentions(state.attachments, q.query);
|
|
899
|
+
items = buildMentionItems(fileMatches, []);
|
|
900
|
+
selected = 0;
|
|
901
|
+
render();
|
|
902
|
+
clearTimeout(debounce);
|
|
903
|
+
debounce = setTimeout(() => runSearch(q.query), 180);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
box.addEventListener("input", () => {
|
|
907
|
+
const caret = box.selectionStart ?? box.value.length;
|
|
908
|
+
const q = detectMentionQuery(box.value, caret);
|
|
909
|
+
if (!q) { close(); return; }
|
|
910
|
+
if (!activeQuery || activeQuery.hashIndex !== q.hashIndex) { openFor(q); return; }
|
|
911
|
+
activeQuery = q;
|
|
912
|
+
const fileMatches = filterFileMentions(state.attachments, q.query);
|
|
913
|
+
items = buildMentionItems(fileMatches, items.filter((i) => i.kind === "article").map((i) => (i as { name: string }).name));
|
|
914
|
+
selected = 0;
|
|
915
|
+
render();
|
|
916
|
+
clearTimeout(debounce);
|
|
917
|
+
debounce = setTimeout(() => runSearch(q.query), 180);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
box.addEventListener("keydown", (e) => {
|
|
921
|
+
if (!pop.classList.contains("open") || !items.length) return;
|
|
922
|
+
if (e.key === "ArrowDown") { e.preventDefault(); selected = Math.min(selected + 1, items.length - 1); render(); }
|
|
923
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); selected = Math.max(selected - 1, 0); render(); }
|
|
924
|
+
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); select(selected); }
|
|
925
|
+
else if (e.key === "Escape") { e.preventDefault(); close(); }
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
box.addEventListener("blur", () => {
|
|
929
|
+
// Delay so a click on a .mention-row (which also blurs the textarea)
|
|
930
|
+
// still registers via its mousedown handler before we tear the popover
|
|
931
|
+
// down — mousedown fires before blur, but keep this defensive.
|
|
932
|
+
setTimeout(() => { if (document.activeElement !== box) close(); }, 120);
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export function renderQueue(q: { steering?: readonly string[]; followUp?: readonly string[] }): void {
|
|
937
|
+
const bar = $("chat-queue");
|
|
938
|
+
const parts: string[] = [];
|
|
939
|
+
// steering/followUp are arrays (pi-web queue_update) — an empty array is
|
|
940
|
+
// truthy, so gate on .length; show the latest steering note and one pill
|
|
941
|
+
// per queued follow-up.
|
|
942
|
+
const steering = q.steering || [];
|
|
943
|
+
if (steering.length) parts.push('<span class="qtag">steering: <b>' + escHtml(String(steering[steering.length - 1]).slice(0, 60)) + "</b></span>");
|
|
944
|
+
for (const f of q.followUp || []) parts.push('<span class="qtag">queued: <b>' + escHtml(String(f).slice(0, 60)) + "</b></span>");
|
|
945
|
+
bar.innerHTML = parts.join("");
|
|
946
|
+
bar.style.display = parts.length ? "flex" : "none";
|
|
947
|
+
}
|