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([]); const [input, setInput] = createState(""); const [loading, setLoading] = createState(false); const [backend, setBackend] = createState("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(""); // Force‑clear 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 ( { scrolled = self; }} > { 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"); }} /> {/* ── input row ── */} {/* ── toolbar row: backend toggle + model selector ── */} {/* Backend toggle */}