@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
package/src/history.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { SessionEntry, SessionInfo } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ImageAttachment, HistoryToolItem, HistoryItem, SessionListItem } from "./protocol";
|
|
4
|
+
|
|
5
|
+
/** Flatten a content value (string | content-parts) to its text. */
|
|
6
|
+
function contentText(content: unknown): string {
|
|
7
|
+
if (typeof content === "string") return content;
|
|
8
|
+
if (Array.isArray(content)) {
|
|
9
|
+
return content
|
|
10
|
+
.filter((p): p is { type: string; text: string } =>
|
|
11
|
+
!!p && (p as { type?: string }).type === "text" && typeof (p as { text?: unknown }).text === "string")
|
|
12
|
+
.map((p) => p.text)
|
|
13
|
+
.join("");
|
|
14
|
+
}
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function contentThinking(content: unknown): string {
|
|
19
|
+
if (!Array.isArray(content)) return "";
|
|
20
|
+
return content
|
|
21
|
+
.filter((p): p is { type: string; thinking: string } =>
|
|
22
|
+
!!p && (p as { type?: string }).type === "thinking" && typeof (p as { thinking?: unknown }).thinking === "string")
|
|
23
|
+
.map((p) => p.thinking)
|
|
24
|
+
.join("");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Turn a session's entries into a flat, browser-renderable transcript.
|
|
29
|
+
*
|
|
30
|
+
* Walks message entries in order: user/assistant text become items, the
|
|
31
|
+
* assistant's toolCall parts become tool items, and each later toolResult
|
|
32
|
+
* is merged back onto its tool by callId. Non-message entries (model
|
|
33
|
+
* changes, compaction, thinking) are skipped. Pure so it's unit-tested
|
|
34
|
+
* without a live session.
|
|
35
|
+
*/
|
|
36
|
+
export function serializeHistory(entries: readonly SessionEntry[]): HistoryItem[] {
|
|
37
|
+
const items: HistoryItem[] = [];
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (entry.type !== "message") continue;
|
|
40
|
+
const m = (entry as { message?: unknown }).message as
|
|
41
|
+
| { role?: string; content?: unknown; toolCallId?: string }
|
|
42
|
+
| undefined;
|
|
43
|
+
if (!m) continue;
|
|
44
|
+
if (m.role === "user") {
|
|
45
|
+
const text = contentText(m.content);
|
|
46
|
+
if (text.trim()) items.push({ role: "user", text, tools: [], entryId: entry.id });
|
|
47
|
+
} else if (m.role === "assistant") {
|
|
48
|
+
const parts = Array.isArray(m.content) ? (m.content as unknown[]) : [];
|
|
49
|
+
const text = contentText(parts);
|
|
50
|
+
const thinking = contentThinking(parts);
|
|
51
|
+
const tools: HistoryToolItem[] = parts
|
|
52
|
+
.filter((p): p is { type: string; id?: unknown; name?: unknown; arguments?: unknown } =>
|
|
53
|
+
!!p && (p as { type?: string }).type === "toolCall")
|
|
54
|
+
.map((p) => ({ callId: String(p.id ?? ""), name: String(p.name ?? "tool"), args: p.arguments, result: "" }));
|
|
55
|
+
if (text.trim() || thinking.trim() || tools.length > 0)
|
|
56
|
+
items.push({ role: "assistant", text, ...(thinking.trim() ? { thinking } : {}), tools });
|
|
57
|
+
} else if (m.role === "toolResult") {
|
|
58
|
+
const callId = String(m.toolCallId ?? "");
|
|
59
|
+
const result = contentText(m.content);
|
|
60
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
61
|
+
const it = items[i];
|
|
62
|
+
if (!it) continue;
|
|
63
|
+
const tool = it.tools.find((t) => t.callId === callId);
|
|
64
|
+
if (tool) {
|
|
65
|
+
tool.result = result;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return items;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Map browser image attachments to pi's ImageContent shape (or undefined). */
|
|
75
|
+
export function toPiImages(images?: ImageAttachment[]): ImageContent[] | undefined {
|
|
76
|
+
if (!images || images.length === 0) return undefined;
|
|
77
|
+
return images.map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- Message actions: regenerate / edit-and-resend (pure helpers) ----
|
|
81
|
+
//
|
|
82
|
+
// Both features reuse ONE pi SDK primitive: AgentSession.navigateTree(entryId)
|
|
83
|
+
// on a user-message entry moves the session's leaf pointer to that entry's
|
|
84
|
+
// PARENT (without deleting anything) and returns the message's own text in
|
|
85
|
+
// `editorText`. Calling session.prompt(...) right after appends a NEW child
|
|
86
|
+
// under that same parent — i.e. a sibling of the original message, in the
|
|
87
|
+
// SAME session file. This is exactly "regenerate" (re-send the identical
|
|
88
|
+
// content) and "edit-and-resend" (re-send edited content) without any
|
|
89
|
+
// file-level fork/session spam.
|
|
90
|
+
//
|
|
91
|
+
// navigateTree's own editorText is TEXT-ONLY (pi's editor-reopen use case
|
|
92
|
+
// doesn't carry images). We extract the full original `content` ourselves
|
|
93
|
+
// (text + images) from the entry directly so regenerate/edit-resend don't
|
|
94
|
+
// silently drop image attachments — a small improvement over what the SDK
|
|
95
|
+
// primitive alone would give us.
|
|
96
|
+
|
|
97
|
+
/** One user message entry as found by findLastUserMessageEntry/userMessageSiblings. */
|
|
98
|
+
export interface UserMessageEntryInfo {
|
|
99
|
+
id: string;
|
|
100
|
+
parentId: string | null;
|
|
101
|
+
text: string;
|
|
102
|
+
images: ImageContent[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Extract {text, images} from a user message's raw `content` field
|
|
106
|
+
* (string | (TextContent|ImageContent)[]). Pure. */
|
|
107
|
+
function extractUserContent(content: unknown): { text: string; images: ImageContent[] } {
|
|
108
|
+
if (typeof content === "string") return { text: content, images: [] };
|
|
109
|
+
if (Array.isArray(content)) {
|
|
110
|
+
const text = contentText(content);
|
|
111
|
+
const images = (content as unknown[])
|
|
112
|
+
.filter((p): p is { type: string; data: string; mimeType: string } =>
|
|
113
|
+
!!p && (p as { type?: string }).type === "image" &&
|
|
114
|
+
typeof (p as { data?: unknown }).data === "string" && typeof (p as { mimeType?: unknown }).mimeType === "string")
|
|
115
|
+
.map((p) => ({ type: "image" as const, data: p.data, mimeType: p.mimeType }));
|
|
116
|
+
return { text, images };
|
|
117
|
+
}
|
|
118
|
+
return { text: "", images: [] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Find the LAST user-message entry in a session's entries (in append order).
|
|
122
|
+
* Used by both `regenerate` (re-send it as-is) and as the sibling group's
|
|
123
|
+
* anchor for `edit_resend` (always edits the last user message, not any
|
|
124
|
+
* earlier one). Pure. */
|
|
125
|
+
export function findLastUserMessageEntry(entries: readonly SessionEntry[]): UserMessageEntryInfo | undefined {
|
|
126
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
127
|
+
const e = entries[i];
|
|
128
|
+
if (!e || e.type !== "message") continue;
|
|
129
|
+
const m = (e as { message?: { role?: string; content?: unknown } }).message;
|
|
130
|
+
if (m?.role !== "user") continue;
|
|
131
|
+
const { text, images } = extractUserContent(m.content);
|
|
132
|
+
return { id: e.id, parentId: e.parentId, text, images };
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Sibling group (edit history) for a user message: every user-message entry
|
|
138
|
+
* sharing the same parentId, in append order, plus the 1-based index of
|
|
139
|
+
* `entryId` within that group. Drives the browser's `< i/n >` toggle. Pure. */
|
|
140
|
+
export interface SiblingGroup {
|
|
141
|
+
parentId: string | null;
|
|
142
|
+
siblingIds: string[];
|
|
143
|
+
index: number; // 1-based position of the queried entryId within siblingIds
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function userMessageSiblings(entries: readonly SessionEntry[], entryId: string): SiblingGroup | undefined {
|
|
147
|
+
const target = entries.find((e) => e.id === entryId);
|
|
148
|
+
if (!target) return undefined;
|
|
149
|
+
const parentId = target.parentId;
|
|
150
|
+
const siblingIds = entries
|
|
151
|
+
.filter((e): e is SessionEntry & { type: "message" } =>
|
|
152
|
+
e.type === "message" && e.parentId === parentId &&
|
|
153
|
+
(e as { message?: { role?: string } }).message?.role === "user")
|
|
154
|
+
.map((e) => e.id);
|
|
155
|
+
const index = siblingIds.indexOf(entryId) + 1;
|
|
156
|
+
if (index === 0) return undefined;
|
|
157
|
+
return { parentId, siblingIds, index };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Walk DOWN from an entry to the deepest descendant, always following the
|
|
161
|
+
* MOST RECENTLY APPENDED child at each level (last in getChildren's
|
|
162
|
+
* insertion order) — i.e. "what would this branch show if it were the
|
|
163
|
+
* active leaf". Used by `switch_sibling` to resolve a sibling's own leaf
|
|
164
|
+
* (which may itself have further edits/regenerations under it) rather than
|
|
165
|
+
* just landing on the sibling's own root entry. Pure given a children-lookup
|
|
166
|
+
* function so it's unit-testable without a live SessionManager. */
|
|
167
|
+
export function deepestLeafFrom(fromId: string, getChildren: (parentId: string) => SessionEntry[]): string {
|
|
168
|
+
let cur = fromId;
|
|
169
|
+
for (;;) {
|
|
170
|
+
const children = getChildren(cur);
|
|
171
|
+
if (children.length === 0) return cur;
|
|
172
|
+
cur = children[children.length - 1]!.id;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Map pi's SessionInfo[] to sidebar rows, newest first. */
|
|
177
|
+
export function toSessionListItems(infos: readonly SessionInfo[]): SessionListItem[] {
|
|
178
|
+
return infos
|
|
179
|
+
.map((s) => ({
|
|
180
|
+
path: s.path,
|
|
181
|
+
id: s.id,
|
|
182
|
+
title: ((s.name && s.name.trim()) || (s.firstMessage && s.firstMessage.trim()) || "New chat").slice(0, 80),
|
|
183
|
+
modified: s.modified instanceof Date ? s.modified.getTime() : new Date(s.modified as unknown as string).getTime(),
|
|
184
|
+
messageCount: s.messageCount,
|
|
185
|
+
forked: !!s.parentSessionPath,
|
|
186
|
+
}))
|
|
187
|
+
.sort((a, b) => b.modified - a.modified);
|
|
188
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { AppModule, ModuleRuntime } from "@mlx-bun/app-core";
|
|
2
|
+
import { createChatSocket, type ChatBackendFactory } from "./backend";
|
|
3
|
+
import { describeModel, type ServedModel } from "./describe";
|
|
4
|
+
import { createExtensionSurface } from "./extensions";
|
|
5
|
+
import { manifest } from "./manifest";
|
|
6
|
+
import { startModelBridge, type ModelBridge } from "./model-bridge";
|
|
7
|
+
import { createPiBackend } from "./pi-backend";
|
|
8
|
+
import { createChatHandlers } from "./routes";
|
|
9
|
+
|
|
10
|
+
export { manifest } from "./manifest";
|
|
11
|
+
export { createChatSocket } from "./backend";
|
|
12
|
+
export type { ChatBackend, ChatBackendFactory, SendFrame } from "./backend";
|
|
13
|
+
export { describeModel } from "./describe";
|
|
14
|
+
export type { ServedModel } from "./describe";
|
|
15
|
+
export { createExtensionSurface, piTool } from "./extensions";
|
|
16
|
+
export { generateThroughHost, startModelBridge } from "./model-bridge";
|
|
17
|
+
export type { ModelBridge } from "./model-bridge";
|
|
18
|
+
export { createPiBackend } from "./pi-backend";
|
|
19
|
+
export type { PiBackendOptions, PiBackendPaths } from "./pi-backend";
|
|
20
|
+
export { createChatHandlers } from "./routes";
|
|
21
|
+
export type { ChatRoutePaths } from "./routes";
|
|
22
|
+
export { readSessionFile, recordedSessionCwd, sessionEntries } from "./session-files";
|
|
23
|
+
export { searchSessions } from "./session-search";
|
|
24
|
+
export { isToolAlwaysAllowed, listAlwaysAllowedTools, loadToolApprovals, revokeToolAlwaysAllowed, setToolAlwaysAllowed } from "./tool-approvals";
|
|
25
|
+
export type * from "./protocol";
|
|
26
|
+
|
|
27
|
+
/** What a host decides for the chat beyond its services. */
|
|
28
|
+
export interface ChatModuleOptions {
|
|
29
|
+
/** A read-only server: the agent gets no file-changing tool and every gated call is denied. Default false. */
|
|
30
|
+
readOnly?: boolean;
|
|
31
|
+
/** The working directory Pi's tools and the session header record; default the process's. */
|
|
32
|
+
cwd?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The chat module. The host implements `modelHost` (the current model's `generate`), `storage` (`sessions/`, `pi-sessions/`, `tool-approvals.json`) and `registry`. */
|
|
36
|
+
export function createChatModule(options: ChatModuleOptions = {}): AppModule<"modelHost" | "storage" | "registry"> {
|
|
37
|
+
return {
|
|
38
|
+
...manifest,
|
|
39
|
+
activate({ services }): ModuleRuntime {
|
|
40
|
+
const { modelHost, storage, registry } = services;
|
|
41
|
+
const paths = () => ({ agentDir: storage.path("agent"), sessionDir: storage.path("sessions"), toolApprovalsFile: storage.path("approvals") });
|
|
42
|
+
let bridge: ModelBridge | undefined;
|
|
43
|
+
const token = crypto.randomUUID();
|
|
44
|
+
// The loopback Pi talks to starts with the first chat connection; the port is read when that connection's backend starts.
|
|
45
|
+
const loopback = () => bridge ??= startModelBridge(modelHost, token);
|
|
46
|
+
const surface = createExtensionSurface(registry);
|
|
47
|
+
const factory: ChatBackendFactory = send => {
|
|
48
|
+
// The model is described when the connection starts (its `ready` frame); its speech-to-text flag comes from the same reading.
|
|
49
|
+
let served: ServedModel | undefined;
|
|
50
|
+
return createPiBackend({
|
|
51
|
+
paths: { ...paths(), ...(options.cwd ? { cwd: options.cwd } : {}) },
|
|
52
|
+
port: () => loopback().port, apiKey: token,
|
|
53
|
+
readOnly: options.readOnly ?? false, memory: surface,
|
|
54
|
+
model: async () => {
|
|
55
|
+
served = await describeModel(modelHost);
|
|
56
|
+
return { modelId: served.modelId, contextWindow: served.contextWindow, vision: served.vision, audio: served.audio, thinking: served.thinking, genDefaults: served.genDefaults };
|
|
57
|
+
},
|
|
58
|
+
transcription: async () => served?.transcription ?? false,
|
|
59
|
+
})(send);
|
|
60
|
+
};
|
|
61
|
+
const socket = createChatSocket(factory);
|
|
62
|
+
return {
|
|
63
|
+
routes: createChatHandlers({ sessionDir: () => storage.path("sessions"), approvalsFile: () => storage.path("approvals") }),
|
|
64
|
+
sockets: { chat: socket },
|
|
65
|
+
status: () => ({ chats: socket.connections, loopback: bridge !== undefined }),
|
|
66
|
+
async dispose() {
|
|
67
|
+
try { await socket.dispose(); } finally { await bridge?.close(); }
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default createChatModule();
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The module's static manifest: plain data, read by hosts and documentation generators without
|
|
2
|
+
// loading a model or native code (this file imports only types). `index.ts` adds `activate`.
|
|
3
|
+
import type { AppModule } from "@mlx-bun/app-core";
|
|
4
|
+
|
|
5
|
+
export const manifest = {
|
|
6
|
+
id: "chat",
|
|
7
|
+
title: "Chat",
|
|
8
|
+
summary: "The default assistant: a Pi agent over the served model, on a WebSocket per browser tab, with recent chats, tool approvals, and the tools other modules contribute.",
|
|
9
|
+
requires: ["modelHost", "storage", "registry"],
|
|
10
|
+
// Session routes keep their shipped paths: the browser and external tools read them at `/api/sessions/*`, the settings at `/api/settings/tool-approvals`.
|
|
11
|
+
routes: [
|
|
12
|
+
{ id: "sessions-search", method: "GET", path: "/api/sessions/search", summary: "Search saved chats by title and content (`q`); read-only", response: "json", mount: "root" },
|
|
13
|
+
{ id: "sessions-export", method: "GET", path: "/api/sessions/export", summary: "One saved chat's raw entries (`path`, under the session directory); read-only", response: "json", mount: "root" },
|
|
14
|
+
{ id: "approvals", method: "GET", path: "/api/settings/tool-approvals", summary: "The tools the user chose to always allow", response: "json", mount: "root" },
|
|
15
|
+
{ id: "approvals-revoke", method: "DELETE", path: "/api/settings/tool-approvals", summary: "Forget one always-allow choice (`tool` in the JSON body)", response: "json", mount: "root" },
|
|
16
|
+
],
|
|
17
|
+
sockets: [
|
|
18
|
+
{ id: "chat", path: "/ws/chat", summary: "One chat session per connection: prompts, streamed tokens, tool cards and approvals, saved-chat management", mount: "root" },
|
|
19
|
+
],
|
|
20
|
+
storage: [
|
|
21
|
+
{ key: "sessions", path: "sessions", kind: "directory", purpose: "Saved chats, one Pi JSONL file each (also read by the memory pipeline)" },
|
|
22
|
+
{ key: "agent", path: "pi-sessions", kind: "directory", purpose: "The Pi agent's own directory" },
|
|
23
|
+
{ key: "approvals", path: "tool-approvals.json", kind: "file", purpose: "Tools the user chose to always allow, mode 0600" },
|
|
24
|
+
],
|
|
25
|
+
panel: { tag: "mlx-chat-panel", entry: "@mlx-bun/module-chat/panel", title: "Chat", path: "/chat", workspace: true },
|
|
26
|
+
} as const satisfies Omit<AppModule<"modelHost" | "storage" | "registry">, "activate">;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The loopback the Pi SDK talks to. Pi's OpenAI client reaches its model over HTTP and takes no
|
|
2
|
+
// transport of ours, so the module answers on a private loopback listener, and every request goes
|
|
3
|
+
// through the `modelHost` service: a lease on the current model, its `generate` operation, and a
|
|
4
|
+
// release when the response body ends. A model switch is followed because each request asks the
|
|
5
|
+
// host for the current model; the request's own `model` field (Pi's `local`) is the host's to route.
|
|
6
|
+
// The listener is created on the first chat connection, takes only Pi's per-run bearer token, and
|
|
7
|
+
// closes with the module.
|
|
8
|
+
import type { ModelHost, ModelLease } from "@mlx-bun/app-core";
|
|
9
|
+
|
|
10
|
+
export interface ModelBridge {
|
|
11
|
+
readonly port: number;
|
|
12
|
+
/** What Pi sends as its API key; any other bearer is refused. */
|
|
13
|
+
readonly token: string;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The path the model host receives; a host's `generate` ignores the origin. */
|
|
18
|
+
const WIRE = "http://model.local/v1/chat/completions";
|
|
19
|
+
|
|
20
|
+
const failure = (status: number, message: string, type: string) => Response.json({ error: { message, type } }, { status });
|
|
21
|
+
|
|
22
|
+
/** A response that holds its lease until the body has ended, been cancelled or failed. */
|
|
23
|
+
function holding(response: Response, lease: ModelLease): Response {
|
|
24
|
+
if (!response.body) { lease.release(); return response; }
|
|
25
|
+
const reader = response.body.getReader();
|
|
26
|
+
let done = false;
|
|
27
|
+
const finish = () => { if (!done) { done = true; lease.release(); } };
|
|
28
|
+
const body = new ReadableStream<Uint8Array>({
|
|
29
|
+
async pull(controller) {
|
|
30
|
+
try {
|
|
31
|
+
const chunk = await reader.read();
|
|
32
|
+
if (chunk.done) { finish(); controller.close(); } else controller.enqueue(chunk.value);
|
|
33
|
+
} catch (error) { finish(); controller.error(error); }
|
|
34
|
+
},
|
|
35
|
+
cancel(reason) { finish(); return reader.cancel(reason).catch(() => {}); },
|
|
36
|
+
});
|
|
37
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Forwards one chat completion to the current model. */
|
|
41
|
+
export async function generateThroughHost(models: Pick<ModelHost, "acquire" | "defaultFor">, request: Request): Promise<Response> {
|
|
42
|
+
let lease: ModelLease;
|
|
43
|
+
try {
|
|
44
|
+
const id = await models.defaultFor("generate");
|
|
45
|
+
if (id === undefined) return failure(503, "no model is served", "model_unavailable");
|
|
46
|
+
lease = await models.acquire(id, { need: ["generate"], signal: request.signal });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const code = (error as { code?: string }).code;
|
|
49
|
+
return failure(code === "closed" ? 503 : code === "load-failed" ? 502 : 400, error instanceof Error ? error.message : String(error), "model_unavailable");
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const generate = lease.operations.generate;
|
|
53
|
+
if (!generate) { lease.release(); return failure(400, `model ${lease.model.id} cannot generate`, "model_unavailable"); }
|
|
54
|
+
const headers = new Headers(request.headers);
|
|
55
|
+
headers.delete("authorization");
|
|
56
|
+
return holding(await generate(new Request(WIRE, { method: "POST", headers, body: await request.arrayBuffer(), signal: request.signal })), lease);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
lease.release();
|
|
59
|
+
if (request.signal.aborted) return new Response(null, { status: 499 });
|
|
60
|
+
return failure(502, error instanceof Error ? error.message : String(error), "model_unavailable");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function startModelBridge(models: Pick<ModelHost, "acquire" | "defaultFor">, token: string = crypto.randomUUID()): ModelBridge {
|
|
65
|
+
const server = Bun.serve({
|
|
66
|
+
hostname: "127.0.0.1", port: 0, idleTimeout: 0,
|
|
67
|
+
async fetch(request) {
|
|
68
|
+
const { pathname } = new URL(request.url);
|
|
69
|
+
if (request.headers.get("authorization") !== `Bearer ${token}`) return failure(401, "unauthorized", "invalid_api_key");
|
|
70
|
+
if (request.method !== "POST" || pathname !== "/v1/chat/completions") return failure(404, "not found", "not_found");
|
|
71
|
+
return generateThroughHost(models, request);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
return { port: server.port!, token, close: async () => { await server.stop(true); } };
|
|
75
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Part of the chat panel (`@mlx-bun/module-chat`), bundled into the app's browser build.
|
|
2
|
+
//
|
|
3
|
+
// Adapter routing table: the deep surface behind
|
|
4
|
+
// the composer's ⚙ button beside #chat-adapter. Shows every on-disk
|
|
5
|
+
// adapter with its three states (available / loaded+mounted /
|
|
6
|
+
// selected-for-this-chat), base model, rank, size on disk, and RAM cost
|
|
7
|
+
// when mounted (GET /v1/adapters now reports `ram_bytes` — see
|
|
8
|
+
// `02d723a:docs/reference/server-api.md`), with compatible-graying and a why-not
|
|
9
|
+
// tooltip. Actions: mount, select, unselect, and stack two compatible
|
|
10
|
+
// adapters as "a+b" — resolveSpec/injectAdapter already support composite
|
|
11
|
+
// ids end to end (proved in packages/module-chat/tests/chat-policy.test.ts's stacking
|
|
12
|
+
// test; the shape-validated mount is proved against real weights in the
|
|
13
|
+
// MLX_BUN_TEST_LORA=1-gated `02d723a:tests/parity/lora.test.ts`).
|
|
14
|
+
//
|
|
15
|
+
// Single source of SELECTION state, not a single fetch: composer.ts's quick
|
|
16
|
+
// <select> and this table each hit /v1/adapters(+/available) independently
|
|
17
|
+
// (same endpoints, same shapes) rather than sharing one in-memory cache —
|
|
18
|
+
// but chat.ts keeps them from disagreeing about what's SELECTED by writing
|
|
19
|
+
// through both on every change (adapterSel.onchange syncs
|
|
20
|
+
// adaptersPanel.selectedSpec; this table's actions sync #chat-adapter's
|
|
21
|
+
// value back). What must never drift is which adapter(s) are active for
|
|
22
|
+
// the chat, not which HTTP call fetched the list.
|
|
23
|
+
|
|
24
|
+
import { $, api, toast, trapFocus, type FocusTrap } from "./dom";
|
|
25
|
+
import { esc } from "./markdown";
|
|
26
|
+
import { setPopoverClose } from "./popovers";
|
|
27
|
+
|
|
28
|
+
/** Shape returned by GET /v1/adapters/available (on-disk, unfiltered). */
|
|
29
|
+
export interface AvailableAdapterRow {
|
|
30
|
+
id: string;
|
|
31
|
+
path: string;
|
|
32
|
+
rank: number | null;
|
|
33
|
+
scale: number;
|
|
34
|
+
base_model: string | null;
|
|
35
|
+
mounted: boolean;
|
|
36
|
+
compatible: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Shape returned by GET /v1/adapters (mounted only, with RAM cost). */
|
|
40
|
+
export interface MountedAdapterRow {
|
|
41
|
+
id: string;
|
|
42
|
+
path: string;
|
|
43
|
+
rank: number | null;
|
|
44
|
+
scale: number;
|
|
45
|
+
size_bytes: number;
|
|
46
|
+
mounted_layers: number;
|
|
47
|
+
ram_bytes: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fmtBytes(n: number | null | undefined): string {
|
|
51
|
+
if (n == null) return "—";
|
|
52
|
+
if (n >= 2 ** 20) return (n / 2 ** 20).toFixed(1) + " MB";
|
|
53
|
+
if (n >= 2 ** 10) return (n / 2 ** 10).toFixed(1) + " KB";
|
|
54
|
+
return n + " B";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Send callback type shared with the rest of chat.ts's WS wiring. */
|
|
58
|
+
type Send = (obj: unknown) => boolean;
|
|
59
|
+
|
|
60
|
+
export class AdaptersPanelState {
|
|
61
|
+
available: AvailableAdapterRow[] = [];
|
|
62
|
+
mounted: Map<string, MountedAdapterRow> = new Map();
|
|
63
|
+
/** The composer's current single-select value ("" = none), kept in sync
|
|
64
|
+
* so the table can show which one/ones are "selected for this chat". */
|
|
65
|
+
selectedSpec: string | null = null;
|
|
66
|
+
/** Multi-select scratch state for the "stack" flow — ids the user has
|
|
67
|
+
* ticked in the table before hitting "apply stack". Cleared on apply. */
|
|
68
|
+
stackPicks: Set<string> = new Set();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let trap: FocusTrap | null = null;
|
|
72
|
+
|
|
73
|
+
/** Fetch both adapter endpoints once and refresh every dependent view: the
|
|
74
|
+
* table body (if open, with its actions re-wired against `send`) and the
|
|
75
|
+
* composer's quick <select> (renderAdapterOptionsHtml, imported from
|
|
76
|
+
* composer.ts — one source of truth, not a second fetch). `send` is
|
|
77
|
+
* required (not optional) so a refresh never silently leaves the table's
|
|
78
|
+
* mount/select/stack buttons wired to a no-op. */
|
|
79
|
+
export async function refreshAdaptersPanel(state: AdaptersPanelState, send: Send): Promise<void> {
|
|
80
|
+
const [availD, mountedD] = await Promise.all([
|
|
81
|
+
api("/v1/adapters/available").catch(() => ({ ok: false } as const)),
|
|
82
|
+
api("/v1/adapters").catch(() => ({ ok: false } as const)),
|
|
83
|
+
]);
|
|
84
|
+
state.available = ((availD as { adapters?: AvailableAdapterRow[] }).adapters) || [];
|
|
85
|
+
const mountedList = ((mountedD as { adapters?: MountedAdapterRow[] }).adapters) || [];
|
|
86
|
+
state.mounted = new Map(mountedList.map((a) => [a.id, a]));
|
|
87
|
+
// Drop stack picks for adapters that vanished from disk (e.g. an
|
|
88
|
+
// in-flight delete elsewhere).
|
|
89
|
+
for (const id of [...state.stackPicks]) if (!state.available.some((a) => a.id === id)) state.stackPicks.delete(id);
|
|
90
|
+
renderAdaptersBody(state, send);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Parse the composer's currently-selected spec ("" | "id" | "a+b") into
|
|
94
|
+
* the set of ids it names, for the table's per-row "selected" badge. */
|
|
95
|
+
function selectedIds(state: AdaptersPanelState): Set<string> {
|
|
96
|
+
const spec = state.selectedSpec;
|
|
97
|
+
if (!spec) return new Set();
|
|
98
|
+
return new Set(spec.split("+").map((s) => s.trim()).filter(Boolean));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Render one adapter row: id/badges, base-model/rank/size/RAM meta line,
|
|
102
|
+
* incompatible-why note, and the mount/select/unselect/stack actions.
|
|
103
|
+
* Pure enough to unit-test (no fetch inside), takes callbacks for the
|
|
104
|
+
* actions so the DOM wiring lives in one place (initAdaptersPanel). */
|
|
105
|
+
export function renderAdapterRow(
|
|
106
|
+
a: AvailableAdapterRow,
|
|
107
|
+
opts: {
|
|
108
|
+
mountedInfo: MountedAdapterRow | undefined;
|
|
109
|
+
isSelected: boolean;
|
|
110
|
+
stackPicked: boolean;
|
|
111
|
+
stackModeOn: boolean;
|
|
112
|
+
},
|
|
113
|
+
): string {
|
|
114
|
+
const { mountedInfo, isSelected, stackPicked, stackModeOn } = opts;
|
|
115
|
+
const rowClasses = ["ad-row"];
|
|
116
|
+
if (!a.compatible) rowClasses.push("incompatible");
|
|
117
|
+
if (isSelected) rowClasses.push("selected");
|
|
118
|
+
const badges: string[] = [];
|
|
119
|
+
if (mountedInfo) badges.push('<span class="ad-badge mounted">mounted</span>');
|
|
120
|
+
if (isSelected) badges.push('<span class="ad-badge selected">selected</span>');
|
|
121
|
+
const meta: string[] = [];
|
|
122
|
+
if (a.base_model) meta.push("base <b>" + esc(a.base_model.split("/").pop()) + "</b>");
|
|
123
|
+
if (a.rank) meta.push("rank <b>" + esc(a.rank) + "</b>");
|
|
124
|
+
meta.push("disk <b>" + esc(fmtBytes(mountedInfo ? mountedInfo.size_bytes : null) === "—" ? "—" : fmtBytes(mountedInfo ? mountedInfo.size_bytes : null)) + "</b>");
|
|
125
|
+
if (mountedInfo) meta.push("RAM <b>" + esc(fmtBytes(mountedInfo.ram_bytes)) + "</b>");
|
|
126
|
+
const why = !a.compatible
|
|
127
|
+
? '<div class="ad-why">' + (a.base_model
|
|
128
|
+
? "trained for " + esc(a.base_model) + ", not the currently-served model"
|
|
129
|
+
: "not compatible with the currently-served model") + "</div>"
|
|
130
|
+
: "";
|
|
131
|
+
const actions: string[] = [];
|
|
132
|
+
if (a.compatible) {
|
|
133
|
+
if (stackModeOn) {
|
|
134
|
+
actions.push(
|
|
135
|
+
'<label class="ad-stack-chk"><input type="checkbox" class="ad-stack-pick" data-id="' + esc(a.id) + '"' +
|
|
136
|
+
(stackPicked ? " checked" : "") + (!mountedInfo ? " disabled title=\"mount first to stack it\"" : "") + '> stack</label>',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (!mountedInfo) {
|
|
140
|
+
actions.push('<button type="button" class="ad-mount" data-id="' + esc(a.id) + '" data-path="' + esc(a.path) + '">Mount</button>');
|
|
141
|
+
} else if (!isSelected) {
|
|
142
|
+
actions.push('<button type="button" class="ad-select primary" data-id="' + esc(a.id) + '">Select</button>');
|
|
143
|
+
} else {
|
|
144
|
+
actions.push('<button type="button" class="ad-unselect" data-id="' + esc(a.id) + '">Unselect</button>');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return (
|
|
148
|
+
'<div class="' + rowClasses.join(" ") + '" data-adapter-row="' + esc(a.id) + '">' +
|
|
149
|
+
'<div class="ad-row-main">' +
|
|
150
|
+
'<div class="ad-row-head"><span class="ad-row-id" title="' + esc(a.id) + '">' + esc(a.id) + "</span>" + badges.join("") + "</div>" +
|
|
151
|
+
'<div class="ad-meta">' + meta.join(" · ") + "</div>" +
|
|
152
|
+
why +
|
|
153
|
+
"</div>" +
|
|
154
|
+
'<div class="ad-actions">' + actions.join("") + "</div>" +
|
|
155
|
+
"</div>"
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Full table body: a stack-mode toggle + composed-spec bar (when picks
|
|
160
|
+
* exist), then one section of rows. Exported so packages/module-chat/tests/panel.test.ts can
|
|
161
|
+
* exercise esc() discipline without a live DOM/network. */
|
|
162
|
+
export function renderAdaptersBodyHtml(state: AdaptersPanelState): string {
|
|
163
|
+
if (!state.available.length) {
|
|
164
|
+
return (
|
|
165
|
+
'<div class="ad-empty">No adapters found on disk yet.<br>' +
|
|
166
|
+
"Fine-tune one in the Developer tab, or drop an adapter directory into " +
|
|
167
|
+
"<code>~/.mlx-bun/adapters</code>.</div>"
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const selected = selectedIds(state);
|
|
171
|
+
const stackModeOn = state.stackPicks.size > 0 || selected.size > 1;
|
|
172
|
+
const rows = state.available.map((a) => renderAdapterRow(a, {
|
|
173
|
+
mountedInfo: state.mounted.get(a.id),
|
|
174
|
+
isSelected: selected.has(a.id),
|
|
175
|
+
stackPicked: state.stackPicks.has(a.id),
|
|
176
|
+
stackModeOn,
|
|
177
|
+
})).join("");
|
|
178
|
+
const picks = [...state.stackPicks];
|
|
179
|
+
const stackBar = picks.length
|
|
180
|
+
? '<div class="ad-stack-bar"><span class="lbl">Stack:</span><span class="expr">' +
|
|
181
|
+
esc(picks.join(" + ")) + '</span><button type="button" id="ad-stack-apply" class="ad-actions-btn">Apply</button>' +
|
|
182
|
+
'<button type="button" id="ad-stack-clear" class="ad-actions-btn">Clear</button></div>'
|
|
183
|
+
: "";
|
|
184
|
+
return (
|
|
185
|
+
'<div class="ad-note">Every adapter found on disk, mounted or not. Mounting loads it into ' +
|
|
186
|
+
"memory (RAM cost shown once mounted); selecting makes it active for this chat's next turn " +
|
|
187
|
+
"(a fresh KV segment). Tick two mounted adapters' “stack” boxes to compose them as " +
|
|
188
|
+
'<code>a+b</code> — the server already supports it end to end.</div>' +
|
|
189
|
+
stackBar +
|
|
190
|
+
'<div class="ad-sec-title">On disk (' + state.available.length + ")</div>" +
|
|
191
|
+
rows
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function renderAdaptersBody(state: AdaptersPanelState, send: Send): void {
|
|
196
|
+
const body = $("adapters-body");
|
|
197
|
+
if (!body) return;
|
|
198
|
+
body.innerHTML = renderAdaptersBodyHtml(state);
|
|
199
|
+
wireRowActions(state, send);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function wireRowActions(state: AdaptersPanelState, send: Send): void {
|
|
203
|
+
const body = $("adapters-body");
|
|
204
|
+
if (!body) return;
|
|
205
|
+
body.querySelectorAll<HTMLButtonElement>(".ad-mount").forEach((btn) => {
|
|
206
|
+
btn.onclick = async () => {
|
|
207
|
+
const id = btn.dataset.id!, path = btn.dataset.path!;
|
|
208
|
+
btn.disabled = true; btn.textContent = "Mounting…";
|
|
209
|
+
const r = await api("/v1/adapters", { method: "POST", body: { id, path } });
|
|
210
|
+
if (r && (r.error || r.ok === false)) {
|
|
211
|
+
toast("adapter: " + (r.error || "mount failed"), "err");
|
|
212
|
+
btn.disabled = false; btn.textContent = "Mount";
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
toast("mounted " + id, "ok");
|
|
216
|
+
await refreshAdaptersPanel(state, send);
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
body.querySelectorAll<HTMLButtonElement>(".ad-select").forEach((btn) => {
|
|
220
|
+
btn.onclick = () => {
|
|
221
|
+
const id = btn.dataset.id!;
|
|
222
|
+
state.selectedSpec = id;
|
|
223
|
+
state.stackPicks.clear();
|
|
224
|
+
send({ type: "set_adapter", id });
|
|
225
|
+
const sel = $("chat-adapter") as HTMLSelectElement | null;
|
|
226
|
+
if (sel && [...sel.options].some((o) => o.value === id)) sel.value = id;
|
|
227
|
+
toast("adapter selected — new turns start a fresh KV segment", "ok");
|
|
228
|
+
renderAdaptersBody(state, send);
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
body.querySelectorAll<HTMLButtonElement>(".ad-unselect").forEach((btn) => {
|
|
232
|
+
btn.onclick = () => {
|
|
233
|
+
state.selectedSpec = null;
|
|
234
|
+
send({ type: "set_adapter", id: null });
|
|
235
|
+
const sel = $("chat-adapter") as HTMLSelectElement | null;
|
|
236
|
+
if (sel) sel.value = "";
|
|
237
|
+
renderAdaptersBody(state, send);
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
body.querySelectorAll<HTMLInputElement>(".ad-stack-pick").forEach((chk) => {
|
|
241
|
+
chk.onchange = () => {
|
|
242
|
+
const id = chk.dataset.id!;
|
|
243
|
+
if (chk.checked) state.stackPicks.add(id); else state.stackPicks.delete(id);
|
|
244
|
+
renderAdaptersBody(state, send);
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
const apply = $("ad-stack-apply") as HTMLButtonElement | null;
|
|
248
|
+
if (apply) apply.onclick = () => {
|
|
249
|
+
if (state.stackPicks.size < 2) { toast("stack needs at least two ticked adapters", "err"); return; }
|
|
250
|
+
const spec = [...state.stackPicks].join("+");
|
|
251
|
+
state.selectedSpec = spec;
|
|
252
|
+
send({ type: "set_adapter", id: spec });
|
|
253
|
+
const sel = $("chat-adapter") as HTMLSelectElement | null;
|
|
254
|
+
if (sel) sel.value = ""; // composite spec has no matching single <option>
|
|
255
|
+
toast("stacked " + spec + " — new turns start a fresh KV segment", "ok");
|
|
256
|
+
renderAdaptersBody(state, send);
|
|
257
|
+
};
|
|
258
|
+
const clear = $("ad-stack-clear") as HTMLButtonElement | null;
|
|
259
|
+
if (clear) clear.onclick = () => { state.stackPicks.clear(); renderAdaptersBody(state, send); };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function openPanel(state: AdaptersPanelState, send: Send): void {
|
|
263
|
+
const ov = $("adapters-overlay");
|
|
264
|
+
ov.classList.add("open");
|
|
265
|
+
$("adapters-manage").setAttribute("aria-expanded", "true");
|
|
266
|
+
if (trap) trap.capture();
|
|
267
|
+
refreshAdaptersPanel(state, send);
|
|
268
|
+
}
|
|
269
|
+
export function closeAdaptersPanel(): void {
|
|
270
|
+
$("adapters-overlay").classList.remove("open");
|
|
271
|
+
$("adapters-manage").setAttribute("aria-expanded", "false");
|
|
272
|
+
if (trap) trap.restore();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Wire the ⚙ button + overlay close affordances. Escape is handled by the
|
|
276
|
+
* global closeTopOverlay sweep in shell.ts (one mechanism, not a bespoke
|
|
277
|
+
* listener per overlay — same pattern as the HF modal / sampling popover). */
|
|
278
|
+
export function initAdaptersPanel(state: AdaptersPanelState, send: Send): void {
|
|
279
|
+
const btn = $("adapters-manage");
|
|
280
|
+
if (!btn) return;
|
|
281
|
+
trap = trapFocus($("adapters-panel"), () => $("adapters-overlay").classList.contains("open"));
|
|
282
|
+
btn.onclick = () => openPanel(state, send);
|
|
283
|
+
const close = $("adapters-close");
|
|
284
|
+
if (close) close.onclick = closeAdaptersPanel;
|
|
285
|
+
$("adapters-overlay").addEventListener("click", (e) => { if (e.target === $("adapters-overlay")) closeAdaptersPanel(); });
|
|
286
|
+
setPopoverClose("adapters-overlay", closeAdaptersPanel);
|
|
287
|
+
}
|