155 lines
4.5 KiB
TypeScript
155 lines
4.5 KiB
TypeScript
import { Signal } from "@char/aftercare";
|
|
import { ChatResponse } from "./response.tsx";
|
|
|
|
const route = new Signal<string | undefined>(window.location.hash.substring(1) || undefined);
|
|
let lastRoute: string | undefined = "";
|
|
|
|
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" _onclick={e => (e.preventDefault(), route.set(conversation.id))}>
|
|
{conversation.name}
|
|
</button>
|
|
);
|
|
|
|
nav.append(button);
|
|
}
|
|
|
|
nav.append(
|
|
<button type="button" _onclick={e => (e.preventDefault(), route.set("new"))}>
|
|
new
|
|
</button>,
|
|
);
|
|
|
|
return nav;
|
|
}
|
|
|
|
function conversationUI(id: string) {
|
|
window.location.hash = `#${id}`;
|
|
|
|
let socket: WebSocket;
|
|
let connected = false;
|
|
const connect = () => {
|
|
const u = new URL(`/api/conversation/${id}/connect`, window.location.href);
|
|
if (connected) u.searchParams.set("continue", "1");
|
|
if ("llm_model" in globalThis && Reflect.get(globalThis, "llm_model"))
|
|
u.searchParams.set("model", Reflect.get(globalThis, "llm_model"));
|
|
|
|
socket = new WebSocket(u);
|
|
socket.addEventListener("open", () => (connected = true));
|
|
socket.addEventListener("close", () => (socket = connect()));
|
|
socket.addEventListener("error", ev => {
|
|
console.warn(ev);
|
|
// TODO: handle errors
|
|
});
|
|
return socket;
|
|
};
|
|
socket = connect();
|
|
|
|
const header = (
|
|
<header>
|
|
<h1 />
|
|
<button tabIndex={-1} type="button" className="danger">
|
|
delete
|
|
</button>
|
|
</header>
|
|
);
|
|
const name = new Signal("");
|
|
name.subscribeImmediate(it => (header.querySelector("h1")!.textContent = it));
|
|
header.querySelector("button")!.addEventListener("click", async e => {
|
|
e.preventDefault();
|
|
await fetch(`/api/conversation/${id}`, { method: "DELETE" });
|
|
window.location.hash = "";
|
|
});
|
|
|
|
const chatlog = <section className="chatlog" />;
|
|
const inFlightMessages = new Map<string, ChatResponse>();
|
|
|
|
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 ("i" in message) {
|
|
window.history.replaceState(null, "", "#" + message.i);
|
|
id = message.i;
|
|
} else if ("u" in message) {
|
|
chatlog.append(<article className="user">{message.u}</article>);
|
|
} else if ("f" in message) {
|
|
const response = new ChatResponse();
|
|
response.append(message.f);
|
|
response.finalize();
|
|
chatlog.append(response.element);
|
|
} else if ("s" in message) {
|
|
const response = new ChatResponse();
|
|
inFlightMessages.set(message.s, response);
|
|
chatlog.append(response.element);
|
|
} else if ("r" in message && "c" in message) {
|
|
const article = inFlightMessages.get(message.r)!;
|
|
article.append(message.c);
|
|
} else if ("d" in message) {
|
|
const response = inFlightMessages.get(message.d);
|
|
if (response) response.finalize();
|
|
inFlightMessages.delete(message.d);
|
|
} else if ("n" in message) {
|
|
name.set(message.n);
|
|
} else if ("sys" in message) {
|
|
chatlog.append(<article className="system">{message.sys}</article>);
|
|
} else if ("err" in message) {
|
|
chatlog.append(<article className="system error">{message.err}</article>);
|
|
if ("r" in message) {
|
|
inFlightMessages.get(message.r)?.element?.remove();
|
|
inFlightMessages.delete(message.r);
|
|
}
|
|
} else if ("m" in message) {
|
|
chatlog.append(<article className="system info">model: {message.m}</article>);
|
|
}
|
|
|
|
if (scrolledToBottom) chatlog.scrollTop = chatlog.scrollHeight - chatlog.clientHeight;
|
|
});
|
|
|
|
const form = (
|
|
<form>
|
|
<input type="text" placeholder="Enter a prompt…" required />
|
|
</form>
|
|
);
|
|
// TODO: support multiline input via shift + enter
|
|
const input = form.querySelector("input")!;
|
|
form.addEventListener("submit", e => {
|
|
e.preventDefault();
|
|
socket.send(input.value);
|
|
input.value = "";
|
|
});
|
|
|
|
return (
|
|
<section className="conversation">
|
|
{header}
|
|
{chatlog}
|
|
{form}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
window.addEventListener("hashchange", () => {
|
|
route.set(window.location.hash.substring(1));
|
|
});
|
|
route.subscribeImmediate(async r => {
|
|
if (r === lastRoute) return;
|
|
|
|
main.innerHTML = "";
|
|
|
|
if (r) {
|
|
main.append(conversationUI(r));
|
|
} else {
|
|
main.append(await nav());
|
|
}
|
|
|
|
lastRoute = r;
|
|
});
|