import { PERSONAS, type Persona } from "@/chat/personas"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { apiPath } from "@/lib/api"; import { Bot, Loader2, RefreshCw, Send, Sparkles, User } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; type ChatTurn = { role: "user" | "assistant"; content: string }; const LS_BASE = "pywinauto_llm_base_url"; const LS_MODEL = "pywinauto_llm_model"; const LS_PERSONA = "pywinauto_llm_persona"; const LS_REPO = "pywinauto_llm_include_repo"; type Preset = { id: string; label: string; base_url: string }; export function Chat() { const [baseUrl, setBaseUrl] = useState(""); const [presets, setPresets] = useState([]); const [models, setModels] = useState([]); const [model, setModel] = useState(""); const [personaId, setPersonaId] = useState("default"); const [includeRepo, setIncludeRepo] = useState(true); const [repoMarkdown, setRepoMarkdown] = useState(""); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [modelsLoading, setModelsLoading] = useState(false); const [error, setError] = useState(null); const persona: Persona = useMemo( () => PERSONAS.find((p) => p.id === personaId) ?? PERSONAS[0], [personaId], ); useEffect(() => { const b = localStorage.getItem(LS_BASE); const m = localStorage.getItem(LS_MODEL); const p = localStorage.getItem(LS_PERSONA); const r = localStorage.getItem(LS_REPO); if (p) setPersonaId(p); if (r !== null) setIncludeRepo(r === "1"); fetch(apiPath("/api/v1/llm/config")) .then((res) => res.json()) .then((cfg: { default_base_url?: string; presets?: Preset[] }) => { setPresets(cfg.presets ?? []); const def = b || cfg.default_base_url || "http://127.0.0.1:11434/v1"; setBaseUrl(def); if (m) setModel(m); }) .catch(() => setError("Could not load LLM config (is the backend running?)"), ); fetch(apiPath("/api/v1/llm/repo-context")) .then((res) => res.json()) .then((d: { markdown?: string }) => setRepoMarkdown(d.markdown ?? "")) .catch(() => setRepoMarkdown("")); }, []); useEffect(() => { localStorage.setItem(LS_BASE, baseUrl); }, [baseUrl]); useEffect(() => { localStorage.setItem(LS_MODEL, model); }, [model]); useEffect(() => { localStorage.setItem(LS_PERSONA, personaId); }, [personaId]); useEffect(() => { localStorage.setItem(LS_REPO, includeRepo ? "1" : "0"); }, [includeRepo]); const loadModels = useCallback(async () => { if (!baseUrl.trim()) return; setModelsLoading(true); setError(null); try { const u = new URLSearchParams({ base_url: baseUrl.trim() }); const res = await fetch(`${apiPath("/api/v1/llm/models")}?${u}`); if (!res.ok) { const t = await res.text(); throw new Error(t || `HTTP ${res.status}`); } const data = await res.json(); const raw = data?.data ?? data?.models ?? []; const ids: string[] = Array.isArray(raw) ? raw .map((x: { id?: string }) => x?.id) .filter( (id): id is string => typeof id === "string" && id.length > 0, ) : []; setModels(ids); if (ids.length && !ids.includes(model)) { setModel(ids[0]); } } catch (e) { setError(String(e)); setModels([]); } finally { setModelsLoading(false); } }, [baseUrl, model]); useEffect(() => { if (baseUrl) void loadModels(); }, [baseUrl, loadModels]); const buildSystemPrompt = () => { const parts = [persona.system]; if (includeRepo && repoMarkdown.trim()) { parts.push( `--- Repository knowledge (windows-computer-use-mcp) ---\n${repoMarkdown}`, ); } return parts.join("\n\n"); }; const send = async () => { const text = input.trim(); if (!text || !model) return; setLoading(true); setError(null); setInput(""); const nextHistory: ChatTurn[] = [ ...messages, { role: "user", content: text }, ]; setMessages(nextHistory); const apiMessages = [ { role: "system" as const, content: buildSystemPrompt() }, ...nextHistory.map((m) => ({ role: m.role, content: m.content })), ]; try { const res = await fetch(apiPath("/api/v1/llm/chat"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, base_url: baseUrl.trim() || null, messages: apiMessages, temperature: 0.7, }), }); const data = await res.json(); if (!res.ok) { throw new Error( (data as { detail?: string }).detail || JSON.stringify(data), ); } const out = data as { content?: string | null; raw_error?: string | null; }; if (out.raw_error) { throw new Error(out.raw_error); } const reply = out.content?.trim() || "(empty reply)"; setMessages([...nextHistory, { role: "assistant", content: reply }]); } catch (e) { setError(String(e)); setMessages((prev) => prev.slice(0, -1)); setInput(text); } finally { setLoading(false); } }; const refineDraft = async () => { const draft = input.trim(); if (!draft || !model) return; setLoading(true); setError(null); try { const res = await fetch(apiPath("/api/v1/llm/refine"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft, model, base_url: baseUrl.trim() || null, }), }); const data = await res.json(); if (!res.ok) { throw new Error( (data as { detail?: string }).detail || JSON.stringify(data), ); } const out = data as { content?: string | null; raw_error?: string | null; }; if (out.raw_error) throw new Error(out.raw_error); if (out.content) setInput(out.content.trim()); } catch (e) { setError(String(e)); } finally { setLoading(false); } }; return (

Local LLM chat

Gloms onto your OpenAI-compatible server:{" "} Ollama ( 11434) or{" "} LM Studio ( 1234). Personas + repo knowledge answer questions like "can I click and drag?" with project-accurate context.

Backend proxy only (localhost)
Connection & model
setBaseUrl(e.target.value)} placeholder="http://127.0.0.1:11434/v1" />
{presets.map((pr) => ( ))}

{persona.description}

Injects curated facts from{" "} llm_repo_context.py{" "} (tools, safety, drag/click scope).

{error && (
{error}
)}
{messages.length === 0 && (

Ask about this repo: e.g. whether you can click-and-drag, what requires approval, or which tool lists windows.

)} {messages.map((m, i) => (
{m.role === "user" ? ( ) : ( )}
{m.role === "user" ? "You" : "Assistant"}

{m.content}

))}
Rewrites your draft for clarity (same local model).