write a little frontend
This commit is contained in:
parent
2ef699600e
commit
a2367f303e
12 changed files with 496 additions and 4 deletions
5
.prettierrc
Normal file
5
.prettierrc
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"printWidth": 96
|
||||||
|
}
|
14
_build_client.ts
Normal file
14
_build_client.ts
Normal 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
103
client/main.tsx
Normal 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
143
client/web/css/char.css
Normal 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
72
client/web/css/styles.css
Normal 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
116
client/web/dist/main.js
vendored
Normal 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
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
12
client/web/index.html
Normal 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
18
deno.json
Normal 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"] }
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
])
|
])
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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())
|
||||||
|
|
Loading…
Reference in a new issue