write a little frontend

This commit is contained in:
Charlotte Som 2025-02-26 08:10:58 +00:00
parent 2ef699600e
commit a2367f303e
12 changed files with 496 additions and 4 deletions

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"tabWidth": 2,
"arrowParens": "avoid",
"printWidth": 96
}

14
_build_client.ts Normal file
View file

@ -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,
},
});
}

103
client/main.tsx Normal file
View file

@ -0,0 +1,103 @@
const main = document.querySelector("main")!;
async function nav() {
const nav = <nav />;
const conversations = await fetch("/api/conversation").then(r => r.json());
for (const conversation of conversations) {
const button = <button type="button">{conversation.name}</button>;
button.addEventListener("click", e => {
e.preventDefault();
main.append(conversationUI(conversation.id));
nav.remove();
});
nav.append(button);
}
nav.append(
<button
type="button"
_tap={b =>
b.addEventListener("click", e => {
e.preventDefault();
main.append(conversationUI("new"));
nav.remove();
})
}
>
new
</button>,
);
return nav;
}
function conversationUI(id: string) {
window.location.hash = `#${id}`;
const socket = new WebSocket(`/api/conversation/${id}/connect`);
const chatlog = <section className="chatlog" />;
const inFlightMessages = new Map<string, Element>();
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(<article className="user">{message.u}</article>);
} else if ("f" in message) {
chatlog.append(<article className="assistant">{message.f}</article>);
} else if ("s" in message) {
const article = <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 = (
<form>
<input type="text" required />
</form>
);
const input = form.querySelector("input")!;
form.addEventListener("submit", e => {
e.preventDefault();
socket.send(input.value);
input.value = "";
});
return (
<section className="conversation">
{chatlog}
{form}
</section>
);
}
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();
});

143
client/web/css/char.css Normal file
View file

@ -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;
}

72
client/web/css/styles.css Normal file
View file

@ -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;
}
}

116
client/web/dist/main.js vendored Normal file
View file

@ -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

7
client/web/dist/main.js.map vendored Normal file

File diff suppressed because one or more lines are too long

12
client/web/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<meta charset="utf-8" />
<title>llm-py-web</title>
<link rel="stylesheet" href="/css/char.css" />
<link rel="stylesheet" href="/css/styles.css" />
<main>
<noscript>turn on js :(</noscript>
</main>
<script type="module" src="/dist/main.js"></script>

18
deno.json Normal file
View file

@ -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"] }
}
}

View file

@ -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 server.inference import list_conversations, connect_to_conversation
from starlette.staticfiles import StaticFiles
async def status(request: Request) -> Response: async def status(request: Request) -> Response:
return JSONResponse({"status": "ok"}) return JSONResponse({"status": "ok"})
@ -7,5 +8,6 @@ async def status(request: Request) -> Response:
app = Starlette(debug=True, routes=[ app = Starlette(debug=True, routes=[
Route("/api/", status), Route("/api/", status),
Route("/api/conversation", list_conversations, methods=["GET"]), 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")
]) ])

View file

@ -1,5 +1,5 @@
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.routing import Route, WebSocketRoute from starlette.routing import Route, WebSocketRoute, Mount
from starlette.responses import * from starlette.responses import *
from starlette.requests import * from starlette.requests import *
from starlette.websockets import WebSocket from starlette.websockets import WebSocket

View file

@ -15,7 +15,7 @@ async def list_conversations(request: Request):
async def connect_to_conversation(ws: WebSocket): 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"] conversation_id = ws.path_params["conversation"]
if conversation_id == "new": if conversation_id == "new":
conversation = llm.AsyncConversation(llm.get_async_model()) conversation = llm.AsyncConversation(llm.get_async_model())