@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/panel/dom.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The small DOM and network helpers the panel's files share. A panel imports only its own files, so these are
|
|
2
|
+
// its own; they follow the host page's conventions (a `#toasts` container, the `.toast` classes) documented in
|
|
3
|
+
// the module's README.
|
|
4
|
+
import type { ApiEnvelope } from "../protocol";
|
|
5
|
+
|
|
6
|
+
export const $ = (id: string): HTMLElement => document.getElementById(id) as HTMLElement;
|
|
7
|
+
|
|
8
|
+
/** Build a DOM node quickly. */
|
|
9
|
+
export function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, parent?: Element | null): HTMLElementTagNameMap[K] {
|
|
10
|
+
const e = document.createElement(tag);
|
|
11
|
+
if (cls) e.className = cls;
|
|
12
|
+
if (parent) parent.appendChild(e);
|
|
13
|
+
return e;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Toast notification in the host's `#toasts` container (a host without one shows none). kind: "" | "ok" | "err". */
|
|
17
|
+
export function toast(msg: string, kind = "", ms = 4200): void {
|
|
18
|
+
const container = document.getElementById("toasts");
|
|
19
|
+
if (!container) return;
|
|
20
|
+
const t = el("div", "toast " + kind, container);
|
|
21
|
+
t.textContent = msg;
|
|
22
|
+
setTimeout(() => { t.style.transition = "opacity .4s,transform .4s"; t.style.opacity = "0"; t.style.transform = "translateY(10px)"; setTimeout(() => t.remove(), 420); }, ms);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const injectedStyleIds = new Set<string>();
|
|
26
|
+
|
|
27
|
+
/** Append a `<style>` block to `<head>` exactly once per `id`. */
|
|
28
|
+
export function injectStyles(id: string, css: string): void {
|
|
29
|
+
if (injectedStyleIds.has(id)) return;
|
|
30
|
+
injectedStyleIds.add(id);
|
|
31
|
+
const style = document.createElement("style");
|
|
32
|
+
style.id = id;
|
|
33
|
+
style.textContent = css;
|
|
34
|
+
document.head.appendChild(style);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A focus trap for a popover or drawer: Tab and Shift+Tab cycle within the container while `isOpen()` is true, and
|
|
38
|
+
* focus returns to whatever triggered it when it closes. */
|
|
39
|
+
export interface FocusTrap {
|
|
40
|
+
/** Call right before opening: remembers the trigger to restore focus to. */
|
|
41
|
+
capture(): void;
|
|
42
|
+
/** Call right after closing. */
|
|
43
|
+
restore(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function trapFocus(container: HTMLElement, isOpen: () => boolean): FocusTrap {
|
|
47
|
+
let lastFocused: HTMLElement | null = null;
|
|
48
|
+
const focusables = (): HTMLElement[] => [...container.querySelectorAll(
|
|
49
|
+
'a[href],button:not([disabled]),textarea,input:not([disabled]),select,[tabindex]:not([tabindex="-1"])'
|
|
50
|
+
)].filter((e) => (e as HTMLElement).offsetParent !== null || e === document.activeElement) as HTMLElement[];
|
|
51
|
+
container.addEventListener("keydown", (e) => {
|
|
52
|
+
const ke = e as KeyboardEvent;
|
|
53
|
+
if (ke.key !== "Tab" || !isOpen()) return;
|
|
54
|
+
const items = focusables();
|
|
55
|
+
if (!items.length) return;
|
|
56
|
+
const first = items[0]!, last = items[items.length - 1]!;
|
|
57
|
+
if (ke.shiftKey && document.activeElement === first) { ke.preventDefault(); last.focus(); }
|
|
58
|
+
else if (!ke.shiftKey && document.activeElement === last) { ke.preventDefault(); first.focus(); }
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
capture() { lastFocused = document.activeElement as HTMLElement | null; },
|
|
62
|
+
restore() { if (lastFocused && lastFocused.focus) lastFocused.focus(); lastFocused = null; },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The page the address names among the sections the host page mounted; an empty or unknown address is the chat. */
|
|
67
|
+
export function currentRoute(): string {
|
|
68
|
+
const route = (location.hash || "").replace(/^#\/?/, "").split("?")[0] || "";
|
|
69
|
+
return [...document.querySelectorAll<HTMLElement>("section[data-route]")].some(section => section.dataset.route === route) ? route : "chat";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type ApiOpts = (Omit<RequestInit, "body"> & { body?: unknown }) | undefined;
|
|
73
|
+
|
|
74
|
+
/** Fetch JSON with graceful error handling. Throws only on transport failure: an HTTP failure is reflected in the
|
|
75
|
+
* returned envelope, so callers can always `await api(...)`. OpenAI-envelope errors (`{error:{message}}`) are
|
|
76
|
+
* unwrapped to a plain string. */
|
|
77
|
+
export async function api<T extends ApiEnvelope = ApiEnvelope>(path: string, opts?: ApiOpts): Promise<T> {
|
|
78
|
+
const { body, ...rest } = opts || {};
|
|
79
|
+
const init: RequestInit = {
|
|
80
|
+
headers: { "content-type": "application/json" },
|
|
81
|
+
...rest,
|
|
82
|
+
...(body !== undefined ? { body: typeof body === "string" ? body : JSON.stringify(body) } : {}),
|
|
83
|
+
};
|
|
84
|
+
const r = await fetch(path, init);
|
|
85
|
+
const text = await r.text();
|
|
86
|
+
let data: ApiEnvelope;
|
|
87
|
+
try { data = text ? JSON.parse(text) : {}; } catch { data = { ok: false, error: text.slice(0, 400) || ("HTTP " + r.status) }; }
|
|
88
|
+
if (data && data.error && typeof data.error === "object") {
|
|
89
|
+
const errObj = data.error as { message?: string };
|
|
90
|
+
data.error = errObj.message || JSON.stringify(errObj).slice(0, 400);
|
|
91
|
+
}
|
|
92
|
+
if (!r.ok && data.ok === undefined) data = { ok: false, error: (data.error as string) || data.message || ("HTTP " + r.status) };
|
|
93
|
+
return data as T;
|
|
94
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// What the panel takes from the page that hosts it, beyond its connection. Everything is optional: a host
|
|
2
|
+
// with none gets a chat that stands alone (no memory chips, no settings section). The shell sets it as the
|
|
3
|
+
// element's `host` property before the panel attaches.
|
|
4
|
+
|
|
5
|
+
/** A tool card a host draws in place of the default one (memory's provenance chip). */
|
|
6
|
+
export interface ToolCardHandle {
|
|
7
|
+
/** The card's element, already appended to the bubble it was given. */
|
|
8
|
+
readonly wrap: HTMLElement;
|
|
9
|
+
/** Called with the tool's streamed and final results. */
|
|
10
|
+
setResult(result: unknown): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ChatHost {
|
|
14
|
+
/** Called once the panel's markup exists and its controls are wired: the host attaches what lives beside the chat (the
|
|
15
|
+
* memory entry in the sidebar, the first-run consent card) and keeps the element for its own calls. */
|
|
16
|
+
mounted?(panel: HTMLElement): void;
|
|
17
|
+
/** Draws a tool call in place of the default card; null leaves it to the panel. */
|
|
18
|
+
toolCard?(parent: HTMLElement, tool: string, args: unknown): ToolCardHandle | null;
|
|
19
|
+
/** The agent-tools section of the host's settings dialog. */
|
|
20
|
+
settings?: {
|
|
21
|
+
/** The browser's saved wish for file-changing tools (re-asserted on every connection). */
|
|
22
|
+
codingToolsPreference(): boolean;
|
|
23
|
+
/** The server's report: `active` is what this chat was built with, `pending` the request that applies to the next one. */
|
|
24
|
+
codingTools(active: boolean, pending: boolean): void;
|
|
25
|
+
/** The always-allow list, with the action that forgets one entry. */
|
|
26
|
+
approvals(tools: readonly string[], forget: (tool: string) => void): void;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// `<mlx-chat-panel>`: the chat as a workspace panel. It is the product's own page, so the shell gives it the whole
|
|
2
|
+
// page, keeps it attached while another page shows (a turn in flight and the socket survive a visit elsewhere) and
|
|
3
|
+
// tells it when its page is shown (`enter`) and hidden (`leave`). It renders into the light DOM, with its own
|
|
4
|
+
// markup and stylesheet, and takes only its host's design tokens; what lives beside it on the host page (memory's
|
|
5
|
+
// sidebar entry, the settings dialog's agent-tools section) is wired through the `host` property (`ChatHost`).
|
|
6
|
+
// The panel talks to the module's socket (`/ws/chat`) and routes (`/api/sessions/*`, `/api/settings/tool-approvals`),
|
|
7
|
+
// which keep their shipped paths.
|
|
8
|
+
import { createChatController } from "./chat";
|
|
9
|
+
import { exportSession, openSessionRowByPath } from "./sessions";
|
|
10
|
+
import { $, injectStyles, trapFocus, type FocusTrap } from "./dom";
|
|
11
|
+
import type { ChatHost } from "./host";
|
|
12
|
+
import { closeTopPopover, hasOpenPopover } from "./popovers";
|
|
13
|
+
import { STYLE } from "./style";
|
|
14
|
+
import { MARKUP } from "./template";
|
|
15
|
+
|
|
16
|
+
export type { ChatHost, ToolCardHandle } from "./host";
|
|
17
|
+
|
|
18
|
+
// Importing the entry outside a browser (package verification, a server-side inventory) loads without defining the element.
|
|
19
|
+
const Base: typeof HTMLElement = (globalThis as { HTMLElement?: typeof HTMLElement }).HTMLElement ?? (class {} as unknown as typeof HTMLElement);
|
|
20
|
+
|
|
21
|
+
class ChatPanel extends Base {
|
|
22
|
+
/** Set by the shell before the panel attaches; unused (the module's routes keep their shipped paths). */
|
|
23
|
+
connection: unknown;
|
|
24
|
+
host: ChatHost | undefined;
|
|
25
|
+
#controller: ReturnType<typeof createChatController> | undefined;
|
|
26
|
+
#drawer: FocusTrap | undefined;
|
|
27
|
+
|
|
28
|
+
connectedCallback() {
|
|
29
|
+
if (this.#controller) return;
|
|
30
|
+
injectStyles("mlx-chat-panel-style", STYLE);
|
|
31
|
+
this.innerHTML = MARKUP;
|
|
32
|
+
const sidebar = $("chat-sidebar");
|
|
33
|
+
this.#drawer = trapFocus(sidebar, () => sidebar.classList.contains("drawer-open"));
|
|
34
|
+
$("chat-drawer-backdrop").addEventListener("click", () => this.closeDrawer());
|
|
35
|
+
this.#controller = createChatController({
|
|
36
|
+
host: this.host, element: this,
|
|
37
|
+
isActive: () => !!this.closest("section")?.classList.contains("active"),
|
|
38
|
+
closeDrawer: () => this.closeDrawer(),
|
|
39
|
+
});
|
|
40
|
+
this.#controller.init();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The chat page is shown: connect if the socket is down, focus the composer, refresh the adapter list. */
|
|
44
|
+
enter() { this.#controller?.enter(); }
|
|
45
|
+
/** The chat page is hidden: the socket stays up, since closing it would drop a turn in flight. */
|
|
46
|
+
leave() { this.#controller?.leave(); }
|
|
47
|
+
|
|
48
|
+
/** Start a fresh chat (the old one stays on disk). */
|
|
49
|
+
newChat() { (this.#controller?.newChat as (() => void) | undefined)?.(); }
|
|
50
|
+
/** Copy the last assistant response, as its own Copy button does. */
|
|
51
|
+
copyLastResponse() { (this.#controller?.copyLastResponse as (() => void) | undefined)?.(); }
|
|
52
|
+
/** Ask for (or stop asking for) file-changing tools; applies from the next new, opened or forked chat. */
|
|
53
|
+
setCodingTools(enabled: boolean) { this.#controller?.setCodingTools(enabled); }
|
|
54
|
+
/** Reload the adapter list (a training job finished). */
|
|
55
|
+
refreshAdapters() { this.#controller?.refreshAdapters(); }
|
|
56
|
+
focusComposer() { $("chat-box")?.focus(); }
|
|
57
|
+
/** Toggle the model's reasoning, as its pill does. */
|
|
58
|
+
toggleThinking() { $("chat-think")?.click(); }
|
|
59
|
+
|
|
60
|
+
/** The recent chats the sidebar lists (or filters), for the host's palette. */
|
|
61
|
+
recentChats(): { title: string; path: string }[] {
|
|
62
|
+
return [...document.querySelectorAll<HTMLElement>("#chat-sessions .sess")].flatMap(row =>
|
|
63
|
+
row.dataset.path ? [{ title: row.querySelector(".stitle")?.textContent || "New chat", path: row.dataset.path }] : []);
|
|
64
|
+
}
|
|
65
|
+
/** Open a recent chat by its file path, as clicking its row does; false when the sidebar does not list it. */
|
|
66
|
+
openSession(path: string): boolean { return openSessionRowByPath(path); }
|
|
67
|
+
/** Download the chat the sidebar marks active (Markdown by default); nothing when there is none. */
|
|
68
|
+
async exportActiveChat(format: "md" | "json" = "md"): Promise<void> {
|
|
69
|
+
const row = document.querySelector<HTMLElement>("#chat-sessions .sess.active");
|
|
70
|
+
if (!row || !row.dataset.path) return;
|
|
71
|
+
await exportSession(row.dataset.path, row.querySelector(".stitle")?.textContent || "chat", format);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The mobile drawer: the recent-chats sidebar as a slide-over. */
|
|
75
|
+
toggleDrawer() { $("chat-sidebar").classList.contains("drawer-open") ? this.closeDrawer() : this.openDrawer(); }
|
|
76
|
+
openDrawer() {
|
|
77
|
+
if (!this.#drawer) return;
|
|
78
|
+
this.#drawer.capture();
|
|
79
|
+
$("chat-sidebar").classList.add("drawer-open");
|
|
80
|
+
$("chat-drawer-backdrop").classList.add("open");
|
|
81
|
+
this.#drawerTrigger()?.setAttribute("aria-expanded", "true");
|
|
82
|
+
setTimeout(() => { const search = $("chat-sess-search"); if (search) search.focus(); }, 30);
|
|
83
|
+
}
|
|
84
|
+
closeDrawer() {
|
|
85
|
+
if (!this.#drawer) return;
|
|
86
|
+
$("chat-sidebar").classList.remove("drawer-open");
|
|
87
|
+
$("chat-drawer-backdrop").classList.remove("open");
|
|
88
|
+
this.#drawerTrigger()?.setAttribute("aria-expanded", "false");
|
|
89
|
+
this.#drawer.restore();
|
|
90
|
+
}
|
|
91
|
+
drawerOpen(): boolean { return !!this.#drawer && $("chat-sidebar").classList.contains("drawer-open"); }
|
|
92
|
+
/** The host's control that opens the drawer, if it has one (it names the sidebar in `aria-controls`). */
|
|
93
|
+
#drawerTrigger(): Element | null { return document.querySelector('[aria-controls="chat-sidebar"]'); }
|
|
94
|
+
|
|
95
|
+
/** The popovers (sampling, system prompt, adapter table) for the host's Escape sweep. */
|
|
96
|
+
popoverOpen(): boolean { return hasOpenPopover(); }
|
|
97
|
+
closePopover(): boolean { return closeTopPopover(); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (typeof customElements !== "undefined" && !customElements.get("mlx-chat-panel")) customElements.define("mlx-chat-panel", ChatPanel);
|