diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..addff06
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,5 @@
+{
+ "tabWidth": 2,
+ "arrowParens": "avoid",
+ "printWidth": 96
+}
diff --git a/_build_client.ts b/_build_client.ts
new file mode 100644
index 0000000..483e981
--- /dev/null
+++ b/_build_client.ts
@@ -0,0 +1,14 @@
+import { build } from "@char/aftercare/esbuild";
+
+if (import.meta.main) {
+ const watch = Deno.args.includes("--watch");
+ await build({
+ in: ["./client/main.tsx"],
+ outDir: "./client/web/dist",
+ watch,
+ extraOptions: {
+ splitting: true,
+ minify: false,
+ },
+ });
+}
diff --git a/client/main.tsx b/client/main.tsx
new file mode 100644
index 0000000..bb4263a
--- /dev/null
+++ b/client/main.tsx
@@ -0,0 +1,103 @@
+const main = document.querySelector("main")!;
+
+async function nav() {
+ const nav = ;
+
+ const conversations = await fetch("/api/conversation").then(r => r.json());
+ for (const conversation of conversations) {
+ const button = ;
+ button.addEventListener("click", e => {
+ e.preventDefault();
+
+ main.append(conversationUI(conversation.id));
+ nav.remove();
+ });
+
+ nav.append(button);
+ }
+
+ nav.append(
+ ,
+ );
+
+ return nav;
+}
+
+function conversationUI(id: string) {
+ window.location.hash = `#${id}`;
+
+ const socket = new WebSocket(`/api/conversation/${id}/connect`);
+
+ const chatlog = ;
+ const inFlightMessages = new Map();
+
+ socket.addEventListener("message", event => {
+ if (typeof event.data !== "string") return;
+ const message = JSON.parse(event.data);
+
+ const scrolledToBottom =
+ chatlog.scrollTop + 16 >= chatlog.scrollHeight - chatlog.clientHeight;
+
+ if ("u" in message) {
+ chatlog.append({message.u});
+ } else if ("f" in message) {
+ chatlog.append({message.f});
+ } else if ("s" in message) {
+ const article = ;
+ inFlightMessages.set(message.s, article);
+ chatlog.append(article);
+ } else if ("r" in message && "c" in message) {
+ const article = inFlightMessages.get(message.r)!;
+ article.append(message.c);
+ } else if ("d" in message) {
+ inFlightMessages.delete(message.d);
+ }
+
+ if (scrolledToBottom) chatlog.scrollTop = chatlog.scrollHeight - chatlog.clientHeight;
+ });
+
+ const form = (
+
+ );
+ const input = form.querySelector("input")!;
+ form.addEventListener("submit", e => {
+ e.preventDefault();
+ socket.send(input.value);
+ input.value = "";
+ });
+
+ return (
+
+ );
+}
+
+const showUI = async () => {
+ main.innerHTML = "";
+
+ if (window.location.hash) {
+ main.append(conversationUI(window.location.hash.substring(1)));
+ } else {
+ main.append(await nav());
+ }
+};
+
+await showUI();
+window.addEventListener("hashchange", async () => {
+ await showUI();
+});
diff --git a/client/web/css/char.css b/client/web/css/char.css
new file mode 100644
index 0000000..d07a028
--- /dev/null
+++ b/client/web/css/char.css
@@ -0,0 +1,143 @@
+* {
+ box-sizing: border-box;
+}
+
+:root {
+ color-scheme: light;
+
+ --color-bg: 255 255 255;
+ --color-bg-x: 243 243 243;
+ --color-fg: 0 0 0;
+ --color-accent: 252 91 205;
+ --color-off-accent: var(--color-bg);
+ --color-accent-x: 242 59 199;
+
+ --color-text: var(--color-fg);
+ --alpha-text: 0.8;
+
+ --font-sans:
+ system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Roboto,
+ Cantarell, sans-serif;
+ --font-mono: monospace;
+
+ --border-radius: 0.25em;
+
+ background-color: rgb(var(--color-bg) / 1);
+ color: rgb(var(--color-fg) / 1);
+
+ tab-size: 4;
+ overflow-wrap: break-word;
+ cursor: default;
+}
+
+:root,
+input,
+textarea {
+ font-family: var(--font-sans);
+ font-size: 1rem;
+ line-height: 1.5;
+ color: rgb(var(--color-text) / var(--alpha-text));
+
+ text-rendering: optimizeLegibility;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button,
+input[type="button"],
+input[type="submit"] {
+ appearance: none;
+
+ display: inline-block;
+ font-size: 1rem;
+ font-weight: 400;
+
+ padding: 0.5em 1em;
+
+ border: 1px solid transparent;
+ border-radius: var(--border-radius);
+
+ background-color: rgb(var(--color-accent) / 1);
+ color: rgb(var(--color-off-accent) / 1);
+
+ transition: all 0.15s ease-in-out;
+ transition-property: background-color;
+
+ &:hover {
+ background-color: rgb(var(--color-accent-x) / 1);
+ }
+}
+
+input:not([type="button"], [type="submit"]),
+textarea {
+ background-color: rgb(var(--color-bg) / 1);
+ color: rgb(var(--color-fg) / 1);
+
+ padding: 0.5em 1em;
+
+ border: 1px solid rgb(var(--color-fg) / 0.25);
+ border-radius: var(--border-radius);
+
+ outline: none;
+
+ transition: all 0.15s ease-in-out;
+ transition-property: border-color, box-shadow;
+
+ &:focus {
+ border-color: rgb(var(--color-accent) / 1);
+ box-shadow: 0 0 0 0.2rem rgb(var(--color-accent) / 0.25);
+ }
+}
+
+a {
+ color: rgb(var(--color-accent) / 1);
+ border-bottom: 1px solid transparent;
+ transition: all 0.15s ease-in-out;
+ transition-property: border-color;
+ text-decoration: none;
+
+ &:hover {
+ border-color: rgb(var(--color-accent) / 1);
+ }
+}
+
+p,
+:is(h1, h2, h3, h4, h5, h6),
+:is(article, aside, details, footer, header, section, summary) {
+ margin: 0;
+ margin-bottom: 1em;
+}
+
+:is(article, aside, details, footer, header, section, summary) {
+ width: 100%;
+}
+
+nav,
+main,
+footer {
+ width: 100%;
+ max-width: 80ch;
+ margin: 0 auto;
+}
+
+footer p {
+ margin-bottom: 0;
+}
+
+pre {
+ padding: 1em;
+ background-color: rgb(var(--color-bg-x) / 1);
+ color: rgb(var(--color-text) / var(--alpha-text));
+ border-radius: var(--border-radius);
+ border: 1px solid rgb(var(--color-fg) / 0.25);
+}
+
+pre,
+code {
+ font-family: var(--font-mono);
+ white-space: pre-wrap;
+}
diff --git a/client/web/css/styles.css b/client/web/css/styles.css
new file mode 100644
index 0000000..e026340
--- /dev/null
+++ b/client/web/css/styles.css
@@ -0,0 +1,72 @@
+:root {
+ --color-bg: 0 0 0;
+ --color-fg: 255 255 255;
+ --color-off-accent: var(--color-fg);
+ --alpha-text: 1;
+}
+
+::selection {
+ background-color: rgb(var(--color-accent) / 0.5);
+}
+
+input {
+ --color-bg: 16 16 16;
+}
+
+button {
+ --color-accent: 32 32 32;
+}
+
+nav {
+ display: flex;
+ flex-direction: column;
+ gap: 1em;
+ padding: 1em;
+}
+
+form input {
+ width: 100%;
+}
+
+section,
+article {
+ margin: 0;
+}
+
+body,
+main,
+.conversation {
+ height: 100%;
+ min-height: 100vh;
+}
+
+.conversation {
+ display: flex;
+ flex-direction: column;
+ max-height: 100vh;
+
+ & > * {
+ padding: 1em;
+ }
+}
+
+.chatlog {
+ flex: 1;
+
+ display: flex;
+ flex-direction: column;
+ overflow-y: scroll;
+
+ article {
+ white-space: preserve;
+ padding: 1em;
+ border-bottom: 1px solid rgb(var(--color-fg) / 0.5);
+ }
+
+ article.user {
+ text-align: right;
+ border-bottom: none;
+ padding-bottom: 0;
+ opacity: 0.5;
+ }
+}
diff --git a/client/web/dist/main.js b/client/web/dist/main.js
new file mode 100644
index 0000000..8d9426c
--- /dev/null
+++ b/client/web/dist/main.js
@@ -0,0 +1,116 @@
+var __defProp = Object.defineProperty;
+var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
+
+// https://jsr.io/@char/aftercare/0.2.0/src/elem.ts
+function elem(tag, attrs = {}, children = [], extras = {}) {
+ const element = typeof tag === "string" ? document.createElement(tag) : new tag();
+ Object.assign(
+ element,
+ Object.fromEntries(Object.entries(attrs).filter(([_k, v]) => v !== void 0))
+ );
+ if (extras.classList) extras.classList.forEach((c) => element.classList.add(c));
+ if (extras.dataset && (element instanceof HTMLElement || element instanceof SVGElement))
+ Object.entries(extras.dataset).filter(([_k, v]) => v !== void 0).forEach(([k, v]) => element.dataset[k] = v);
+ const childNodes = children.map(
+ (e) => typeof e === "string" ? document.createTextNode(e) : e
+ );
+ element.append(...childNodes);
+ if (extras._tap) extras._tap(element);
+ return element;
+}
+__name(elem, "elem");
+
+// https://jsr.io/@char/aftercare/0.2.0/src/jsx.ts
+function jsx(tag, props, _key) {
+ if (tag === void 0) {
+ throw new Error("fragments are not supported");
+ }
+ const { children = [], classList, dataset, _tap, ...attrs } = props;
+ const childrenArray = Array.isArray(children) ? children : [children];
+ const extras = { classList, dataset, _tap };
+ return elem(tag, attrs, childrenArray, extras);
+}
+__name(jsx, "jsx");
+
+// client/main.tsx
+var main = document.querySelector("main");
+async function nav() {
+ const nav2 = /* @__PURE__ */ jsx("nav", {});
+ const conversations = await fetch("/api/conversation").then((r) => r.json());
+ for (const conversation of conversations) {
+ const button = /* @__PURE__ */ jsx("button", { type: "button", children: conversation.name });
+ button.addEventListener("click", (e) => {
+ e.preventDefault();
+ main.append(conversationUI(conversation.id));
+ nav2.remove();
+ });
+ nav2.append(button);
+ }
+ nav2.append(
+ /* @__PURE__ */ jsx(
+ "button",
+ {
+ type: "button",
+ _tap: (b) => b.addEventListener("click", (e) => {
+ e.preventDefault();
+ main.append(conversationUI("new"));
+ nav2.remove();
+ }),
+ children: "new"
+ }
+ )
+ );
+ return nav2;
+}
+__name(nav, "nav");
+function conversationUI(id) {
+ window.location.hash = `#${id}`;
+ const socket = new WebSocket(`/api/conversation/${id}/connect`);
+ const chatlog = /* @__PURE__ */ jsx("section", { className: "chatlog" });
+ const inFlightMessages = /* @__PURE__ */ new Map();
+ socket.addEventListener("message", (event) => {
+ if (typeof event.data !== "string") return;
+ const message = JSON.parse(event.data);
+ const scrolledToBottom = chatlog.scrollTop + 16 >= chatlog.scrollHeight - chatlog.clientHeight;
+ if ("u" in message) {
+ chatlog.append(/* @__PURE__ */ jsx("article", { className: "user", children: message.u }));
+ } else if ("f" in message) {
+ chatlog.append(/* @__PURE__ */ jsx("article", { className: "assistant", children: message.f }));
+ } else if ("s" in message) {
+ const article = /* @__PURE__ */ jsx("article", { className: "assistant" });
+ inFlightMessages.set(message.s, article);
+ chatlog.append(article);
+ } else if ("r" in message && "c" in message) {
+ const article = inFlightMessages.get(message.r);
+ article.append(message.c);
+ } else if ("d" in message) {
+ inFlightMessages.delete(message.d);
+ }
+ if (scrolledToBottom) chatlog.scrollTop = chatlog.scrollHeight - chatlog.clientHeight;
+ });
+ const form = /* @__PURE__ */ jsx("form", { children: /* @__PURE__ */ jsx("input", { type: "text", required: true }) });
+ const input = form.querySelector("input");
+ form.addEventListener("submit", (e) => {
+ e.preventDefault();
+ socket.send(input.value);
+ input.value = "";
+ });
+ return /* @__PURE__ */ jsx("section", { className: "conversation", children: [
+ chatlog,
+ form
+ ] });
+}
+__name(conversationUI, "conversationUI");
+var showUI = /* @__PURE__ */ __name(async () => {
+ main.innerHTML = "";
+ if (window.location.hash) {
+ main.append(conversationUI(window.location.hash.substring(1)));
+ } else {
+ main.append(await nav());
+ }
+}, "showUI");
+await showUI();
+window.addEventListener("hashchange", async () => {
+ await showUI();
+});
+//# sourceMappingURL=main.js.map
diff --git a/client/web/dist/main.js.map b/client/web/dist/main.js.map
new file mode 100644
index 0000000..66792dc
--- /dev/null
+++ b/client/web/dist/main.js.map
@@ -0,0 +1,7 @@
+{
+ "version": 3,
+ "sources": ["https://jsr.io/@char/aftercare/0.2.0/src/elem.ts", "https://jsr.io/@char/aftercare/0.2.0/src/jsx.ts", "../../main.tsx"],
+ "sourcesContent": ["type GenericElement = HTMLElement | SVGElement;\n\ntype IsNullish = [T] extends [null] ? true : [T] extends [undefined] ? true : false;\ntype IsFunctionIsh =\n IsNullish extends true\n ? false\n : // deno-lint-ignore ban-types\n T extends Function | null | undefined\n ? true\n : false;\n\nexport type ElementProps = {\n [K in keyof E as IsFunctionIsh extends true ? never : K]?: E[K];\n};\n\nexport interface ElementExtras {\n classList?: string[];\n dataset?: Partial>;\n /** extra function to run on the element */\n _tap?: (elem: E) => void;\n}\n\nexport type TagName = keyof HTMLElementTagNameMap;\nexport type CustomTagType = new () => T;\nexport type ElementType = T extends TagName\n ? HTMLElementTagNameMap[T]\n : T extends CustomTagType\n ? E\n : never;\n\nexport function elem(\n tag: T,\n attrs: ElementProps> = {},\n children: (Element | string | Text)[] = [],\n extras: ElementExtras> = {},\n): ElementType {\n const element = typeof tag === \"string\" ? document.createElement(tag) : new tag();\n\n Object.assign(\n element,\n Object.fromEntries(Object.entries(attrs).filter(([_k, v]) => v !== undefined)),\n );\n\n if (extras.classList) extras.classList.forEach(c => element.classList.add(c));\n if (extras.dataset && (element instanceof HTMLElement || element instanceof SVGElement))\n Object.entries(extras.dataset)\n .filter(([_k, v]) => v !== undefined)\n .forEach(([k, v]) => (element.dataset[k] = v));\n\n const childNodes = children.map(e =>\n typeof e === \"string\" ? document.createTextNode(e) : e,\n );\n element.append(...childNodes);\n\n if (extras._tap) extras._tap(element as ElementType);\n\n return element as ElementType;\n}\n\nexport function rewrite(element: Element, children: (Element | string | Text)[] = []) {\n element.innerHTML = \"\";\n const nodes = children.map(e => (typeof e === \"string\" ? document.createTextNode(e) : e));\n element.append(...nodes);\n}\n", "import {\n type CustomTagType,\n elem,\n type ElementExtras,\n type ElementProps,\n type ElementType,\n type TagName,\n} from \"./elem.ts\";\n\n// deno-lint-ignore no-namespace\nnamespace JSX {\n export type Element = HTMLElement | SVGElement;\n export type IntrinsicElements = {\n [K in TagName]: Omit>, \"children\"> & {\n children?: JSX.Element | JSX.Element[] | undefined;\n } & Partial>>;\n };\n}\n\nfunction Fragment(props: Record, _key?: string): never {\n return jsx(undefined, props, _key) as never;\n}\n\nfunction jsx(\n tag: T | undefined,\n props: Record,\n _key?: string,\n): ElementType {\n if (tag === undefined) {\n throw new Error(\"fragments are not supported\");\n }\n\n const { children = [], classList, dataset, _tap, ...attrs } = props;\n const childrenArray = Array.isArray(children) ? children : [children];\n const extras = { classList, dataset, _tap } as ElementExtras>;\n return elem(tag, attrs as ElementProps>, childrenArray, extras);\n}\n\nexport { Fragment, jsx, jsx as jsxDEV, jsx as jsxs };\nexport type { JSX };\n", "const main = document.querySelector(\"main\")!;\n\nasync function nav() {\n const nav = ;\n\n const conversations = await fetch(\"/api/conversation\").then(r => r.json());\n for (const conversation of conversations) {\n const button = ;\n button.addEventListener(\"click\", e => {\n e.preventDefault();\n\n main.append(conversationUI(conversation.id));\n nav.remove();\n });\n\n nav.append(button);\n }\n\n nav.append(\n ,\n );\n\n return nav;\n}\n\nfunction conversationUI(id: string) {\n window.location.hash = `#${id}`;\n\n const socket = new WebSocket(`/api/conversation/${id}/connect`);\n\n const chatlog = ;\n const inFlightMessages = new Map();\n\n socket.addEventListener(\"message\", event => {\n if (typeof event.data !== \"string\") return;\n const message = JSON.parse(event.data);\n\n const scrolledToBottom =\n chatlog.scrollTop + 16 >= chatlog.scrollHeight - chatlog.clientHeight;\n\n if (\"u\" in message) {\n chatlog.append({message.u});\n } else if (\"f\" in message) {\n chatlog.append({message.f});\n } else if (\"s\" in message) {\n const article = ;\n inFlightMessages.set(message.s, article);\n chatlog.append(article);\n } else if (\"r\" in message && \"c\" in message) {\n const article = inFlightMessages.get(message.r)!;\n article.append(message.c);\n } else if (\"d\" in message) {\n inFlightMessages.delete(message.d);\n }\n\n if (scrolledToBottom) chatlog.scrollTop = chatlog.scrollHeight - chatlog.clientHeight;\n });\n\n const form = (\n \n );\n const input = form.querySelector(\"input\")!;\n form.addEventListener(\"submit\", e => {\n e.preventDefault();\n socket.send(input.value);\n input.value = \"\";\n });\n\n return (\n \n );\n}\n\nconst showUI = async () => {\n main.innerHTML = \"\";\n\n if (window.location.hash) {\n main.append(conversationUI(window.location.hash.substring(1)));\n } else {\n main.append(await nav());\n }\n};\n\nawait showUI();\nwindow.addEventListener(\"hashchange\", async () => {\n await showUI();\n});\n"],
+ "mappings": "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",
+ "names": ["nav"]
+}
diff --git a/client/web/index.html b/client/web/index.html
new file mode 100644
index 0000000..f70f67a
--- /dev/null
+++ b/client/web/index.html
@@ -0,0 +1,12 @@
+
+
+llm-py-web
+
+
+
+
+
+
+
+
+
diff --git a/deno.json b/deno.json
new file mode 100644
index 0000000..6f6d04c
--- /dev/null
+++ b/deno.json
@@ -0,0 +1,18 @@
+{
+ "lock": false,
+ "tasks": {
+ "client:build": "deno run -A ./_build_client.ts",
+ "server:run": "uv run uvicorn server:app"
+ },
+ "imports": {
+ "@char/aftercare": "jsr:@char/aftercare@^0.2.0"
+ },
+ "compilerOptions": {
+ "lib": ["deno.window", "deno.unstable", "dom"],
+ "jsx": "react-jsx",
+ "jsxImportSource": "@char/aftercare"
+ },
+ "lint": {
+ "rules": { "exclude": ["no-window", "no-window-prefix"] }
+ }
+}
diff --git a/server/__init__.py b/server/__init__.py
index 927ecdf..fcaa1a2 100644
--- a/server/__init__.py
+++ b/server/__init__.py
@@ -1,5 +1,6 @@
-from server.http import Starlette, Route, Request, Response, JSONResponse, WebSocketRoute
+from server.http import Starlette, Route, Request, Response, JSONResponse, WebSocketRoute, Mount
from server.inference import list_conversations, connect_to_conversation
+from starlette.staticfiles import StaticFiles
async def status(request: Request) -> Response:
return JSONResponse({"status": "ok"})
@@ -7,5 +8,6 @@ async def status(request: Request) -> Response:
app = Starlette(debug=True, routes=[
Route("/api/", status),
Route("/api/conversation", list_conversations, methods=["GET"]),
- WebSocketRoute("/api/conversation/{conversation}/connect", connect_to_conversation)
+ WebSocketRoute("/api/conversation/{conversation}/connect", connect_to_conversation),
+ Mount("/", app=StaticFiles(directory="client/web", html=True), name="client")
])
diff --git a/server/http.py b/server/http.py
index e0572b0..c5948cc 100644
--- a/server/http.py
+++ b/server/http.py
@@ -1,5 +1,5 @@
from starlette.applications import Starlette
-from starlette.routing import Route, WebSocketRoute
+from starlette.routing import Route, WebSocketRoute, Mount
from starlette.responses import *
from starlette.requests import *
from starlette.websockets import WebSocket
diff --git a/server/inference.py b/server/inference.py
index eed4356..1b48751 100644
--- a/server/inference.py
+++ b/server/inference.py
@@ -15,7 +15,7 @@ async def list_conversations(request: Request):
async def connect_to_conversation(ws: WebSocket):
- continuing = bool(ws.query_params["continue"])
+ continuing = bool(ws.query_params.get("continue"))
conversation_id = ws.path_params["conversation"]
if conversation_id == "new":
conversation = llm.AsyncConversation(llm.get_async_model())