jroshell/widget/Overlay/LLMChat.tsx
2026-06-06 13:53:38 +02:00

294 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Gtk } from "ags/gtk4";
import { createState, createEffect } from "ags";
import Pango from "gi://Pango";
import { streamCompletion, type Message, type Backend, getConfig, MODELS } from "../../lib/llm";
function MessageLabel(msg: Message) {
let displayContent = msg.content;
if (msg.role === "assistant" && !displayContent) {
displayContent = "⟳";
}
const label = new Gtk.Label({
label: displayContent,
wrap: true,
wrap_mode: Pango.WrapMode.WORD_CHAR,
selectable: true,
use_markup: false,
max_width_chars: 70,
});
label.add_css_class("message");
label.add_css_class(msg.role);
if (msg.role === "assistant" && !msg.content) {
label.add_css_class("thinking");
}
return label;
}
function EmptyStateContent() {
const box = new Gtk.Box({
orientation: Gtk.Orientation.VERTICAL,
halign: Gtk.Align.CENTER,
valign: Gtk.Align.CENTER,
hexpand: true,
vexpand: true,
cssClasses: ["empty-state"],
});
const picture = Gtk.Picture.new_for_filename("assets/ai-assistant.svg");
const title = new Gtk.Label({ label: "", cssClasses: ["empty-title"] });
const subtitle = new Gtk.Label({ label: "What do you need?", cssClasses: ["empty-subtitle"] });
box.append(picture);
box.append(title);
box.append(subtitle);
return box;
}
export default function LLMChat() {
const [messages, setMessages] = createState<Message[]>([]);
const [input, setInput] = createState("");
const [loading, setLoading] = createState(false);
const [backend, setBackend] = createState<Backend>("deepseek");
const [model, setModel] = createState(MODELS["deepseek"][0]);
let scrolled: Gtk.ScrolledWindow | undefined;
let messagesBox: Gtk.Box | undefined;
let stack: Gtk.Stack | undefined;
let entryRef: Gtk.Entry | undefined;
let modelPopover: Gtk.Popover | undefined;
let modelBtn: Gtk.Button | undefined;
let deepseekBtn: Gtk.Button | undefined;
let ollamaBtn: Gtk.Button | undefined;
// ── model popover ──────────────────────────────────────────────────────
function buildModelPopover(): Gtk.Popover {
const listBox = new Gtk.Box({
orientation: Gtk.Orientation.VERTICAL,
spacing: 4,
cssClasses: ["model-list"],
});
for (const m of MODELS[backend()]) {
const btn = new Gtk.Button({
label: m,
halign: Gtk.Align.FILL,
cssClasses: m === model() ? ["model-item", "active"] : ["model-item"],
});
btn.connect("clicked", () => {
setModel(m);
modelPopover?.popdown();
});
listBox.append(btn);
}
const pop = new Gtk.Popover({
has_arrow: false,
cssClasses: ["model-popover"],
});
pop.set_child(listBox);
return pop;
}
function openModelPopover() {
if (!modelBtn) return;
// Rebuild popover so it reflects current backend models + active state
if (modelPopover) {
modelPopover.unparent();
modelPopover = undefined;
}
modelPopover = buildModelPopover();
modelPopover.set_parent(modelBtn);
modelPopover.popup();
}
// ── backend switch ─────────────────────────────────────────────────────
function switchBackend(b: Backend) {
setBackend(b);
// Reset model to the first model of the new backend
setModel(MODELS[b][0]);
}
// Update model button label whenever backend or model changes
createEffect(() => {
const _bk = backend();
const _md = model();
if (modelBtn) {
modelBtn.label = _md;
}
});
// Update backend toggle button CSS classes whenever backend changes
createEffect(() => {
const _bk = backend();
if (deepseekBtn) {
deepseekBtn.css_classes = _bk === "deepseek" ? ["toggle-btn", "active"] : ["toggle-btn"];
}
if (ollamaBtn) {
ollamaBtn.css_classes = _bk === "ollama" ? ["toggle-btn", "active"] : ["toggle-btn"];
}
});
// ── scroll helper ──────────────────────────────────────────────────────
const scrollToBottom = () => {
if (!scrolled) return;
const adj = scrolled.vadjustment;
adj.value = adj.upper - adj.page_size;
};
// ── message rendering ──────────────────────────────────────────────────
createEffect(() => {
const msgs = messages();
if (!messagesBox) return;
// Clear existing messages
let child = messagesBox.get_first_child();
while (child) {
const next = child.get_next_sibling();
messagesBox.remove(child);
child = next;
}
// Add current messages
for (const msg of msgs) {
const rowBox = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,
hexpand: true,
cssClasses: ["message-row", msg.role],
});
const msgLabel = MessageLabel(msg);
if (msg.role === "user") {
rowBox.set_halign(Gtk.Align.END);
rowBox.append(msgLabel);
} else {
rowBox.set_halign(Gtk.Align.START);
rowBox.append(msgLabel);
}
messagesBox.append(rowBox);
}
scrollToBottom();
// Switch stack page
if (stack) {
stack.set_visible_child_name(msgs.length === 0 ? "empty" : "chat");
}
});
// ── send ───────────────────────────────────────────────────────────────
const send = async () => {
const text = input().trim();
if (!text || loading()) return;
const userMsgs: Message[] = [...messages(), { role: "user", content: text }];
setMessages([...userMsgs, { role: "assistant", content: "" }]);
setInput("");
// Forceclear the entry widget to work around the binding glitch
entryRef?.set_text("");
setLoading(true);
const cfg = getConfig(backend(), model());
await streamCompletion(
cfg,
userMsgs,
(chunk) => {
const all = messages();
const last = all[all.length - 1];
setMessages([...all.slice(0, -1), { ...last, content: last.content + chunk }]);
},
() => setLoading(false),
(err) => {
const all = messages();
setMessages([...all.slice(0, -1), { role: "assistant", content: `${err}` }]);
setLoading(false);
},
);
};
// ── UI ─────────────────────────────────────────────────────────────────
return (
<box orientation={Gtk.Orientation.VERTICAL} vexpand={true} hexpand={true} cssClasses={["llm-chat", "grid-card"]}>
<scrolledwindow
vexpand={true}
hexpand={true}
cssClasses={["chat-scroll"]}
onRealize={(self: Gtk.ScrolledWindow) => { scrolled = self; }}
>
<stack
transition_type={Gtk.StackTransitionType.CROSSFADE}
onRealize={(self: Gtk.Stack) => {
stack = self;
// Add empty state page
const emptyPage = EmptyStateContent();
self.add_named(emptyPage, "empty");
// Add chat page
const chatBox = new Gtk.Box({
orientation: Gtk.Orientation.VERTICAL,
spacing: 10,
cssClasses: ["messages"],
});
self.add_named(chatBox, "chat");
messagesBox = chatBox;
// Start with empty state visible
self.set_visible_child_name("empty");
}}
/>
</scrolledwindow>
{/* ── input row ── */}
<box spacing={10} cssClasses={["input-row"]}>
{/* ── toolbar row: backend toggle + model selector ── */}
<box orientation={Gtk.Orientation.VERTICAL} spacing={4} cssClasses={["toolbar-row"]}>
{/* Backend toggle */}
<box cssClasses={["backend-toggle"]}>
<button
cssClasses={backend() === "deepseek" ? ["toggle-btn", "active"] : ["toggle-btn"]}
label="DeepSeek"
onRealize={(self) => { deepseekBtn = self; }}
onClicked={() => switchBackend("deepseek")}
/>
<button
cssClasses={backend() === "ollama" ? ["toggle-btn", "active"] : ["toggle-btn"]}
label="Ollama"
onRealize={(self) => { ollamaBtn = self; }}
onClicked={() => switchBackend("ollama")}
/>
</box>
{/* Model selector */}
<button
cssClasses={["model-selector"]}
label={model()}
onRealize={(self) => { modelBtn = self; }}
onClicked={openModelPopover}
/>
</box>
<entry
hexpand={true}
placeholderText="Ask me anything..."
text={input()}
sensitive={!loading()}
cssClasses={["chat-input"]}
onChanged={(self) => setInput(self.text)}
onActivate={send}
/>
<button
cssClasses={loading() ? ["send-button", "loading"] : ["send-button"]}
label={loading() ? "⏳" : "➤"}
sensitive={!loading()}
onClicked={send}
/>
</box>
</box>
);
}