@mindtdilly / klaw-chat / commits / 66cb99e

Add chat UI and styles

mindtdilly committed Sep 29, 2026 at 10:29 UTC 66cb99e03a3659a1a58b142d2b10d7885322c13d
2 files changed +206
src/App.jsx new
+163
@@ -0,0 +1,163 @@
1 +import { useEffect, useMemo, useRef, useState } from "react";
2 +
3 +const WORKERS = [
4 + { id: "claw-v1", color: "#00E5A0", specialty: "Matter, security, commissioning" },
5 + { id: "claw-v2", color: "#00B8FF", specialty: "BLE, GATT, mobile links" },
6 + { id: "claw-v3", color: "#7C6AFF", specialty: "Thread and border routers" },
7 + { id: "claw-v4", color: "#FF6B6B", specialty: "Zigbee, Z-Wave, KNX" },
8 + { id: "claw-v5", color: "#FFB347", specialty: "Examples, sensors, training" },
9 + { id: "claw-v6", color: "#FF4FA3", specialty: "Wi-Fi and SiWx91x" },
10 + { id: "claw-v7", color: "#50C8FF", specialty: "MCU peripherals and drivers" },
11 + { id: "claw-v8", color: "#A0FF60", specialty: "TinyML and LoRaWAN" },
12 + { id: "claw-v9", color: "#FFE040", specialty: "Zephyr, MCUboot, RTOS" },
13 + { id: "claw-v10", color: "#E040FB", specialty: "Docs, CI, Renode, Docker" },
14 + { id: "claw-v11", color: "#FF9500", specialty: "KONNWEI OBD, GATT discovery, bridges" },
15 + { id: "claw-v12", color: "#FF4D94", specialty: "Dashboards, HUD projection, rclone, Atlas claw" }
16 +];
17 +
18 +const PROMPTS = {
19 + "claw-v11": "You are claw-v11. Give realistic KONNWEI KDIAG/MAXOBD and ELM327 guidance: Bluetooth full-system scans, live data, DTC read/clear, and app-supported bidirectional tests only. Explain custom BLE GATT discovery. Include safety notes. Do not claim this browser can flash an ECU.",
20 + "claw-v12": "You are claw-v12. Help build web dashboards from exported OBD logs or a local WebSocket bridge, phone-to-HUD projection, rclone sync of diagnostic logs, and Atlas Copco claw couplings as workshop air fittings."
21 +};
22 +
23 +const QUICK = [
24 + "GATT discovery steps for a BLE OBD adapter",
25 + "KONNWEI KDIAG live-data workflow",
26 + "Local ELM327 to WebSocket bridge outline",
27 + "Self-expanding OBD dashboard layout",
28 + "AndrOBD to HUD projection setup",
29 + "rclone sync for diagnostic logs",
30 + "Atlas Copco claw coupling workshop use"
31 +];
32 +
33 +const FEEDS = [
34 + ["KONNWEI KDIAG / MAXOBD", "Bluetooth 5.x full-system scanner. Live graphing and DTC read/clear happen in the official app. Export logs, then bridge them."],
35 + ["GATT", "Most BLE OBD adapters use a custom service, commonly the FFE0 family, with a write characteristic for AT/PID commands and a notify characteristic for replies."],
36 + ["Bridge", "Phone app or ELM327 serial to a local script to WebSocket JSON. This hosted UI does not talk to the car directly."],
37 + ["Dashboard", "Gauges expand from numeric keys in the JSON feed: RPM, SPEED, COOLANT_TEMP, ENGINE_LOAD."],
38 + ["HUD", "AndrOBD or a dedicated OBD/GPS projector. Phone mirroring is the realistic path from this app."],
39 + ["Atlas Copco claw", "Drop-forged zinc-plated air coupling for workshop pneumatic tools. Physical fitting, not a software API."]
40 +];
41 +
42 +function route(text) {
43 + const t = text.toLowerCase();
44 + if (/gatt|konnwei|kdiag|maxobd|obd|elm327|dtc|can|bridge/.test(t)) return "claw-v11";
45 + if (/dashboard|hud|gauge|rclone|atlas|projection|feed/.test(t)) return "claw-v12";
46 + if (/ble|bluetooth/.test(t)) return "claw-v2";
47 + if (/thread/.test(t)) return "claw-v3";
48 + if (/zigbee|zwave|z-wave/.test(t)) return "claw-v4";
49 + if (/wifi|wi-fi|siwx/.test(t)) return "claw-v6";
50 + return "claw-v1";
51 +}
52 +
53 +function localAnswer(workerId) {
54 + const worker = WORKERS.find((w) => w.id === workerId);
55 + if (workerId === "claw-v11") {
56 + return "claw-v11 local brief\n\nKONNWEI KDIAG/MAXOBD path:\n1. Plug the scanner into the OBD-II port and pair Bluetooth in the KDIAG or MAXOBD app.\n2. Read a full-system report, then open live data and record RPM, speed, coolant, and load.\n3. Export the log. This site cannot open the car bus by itself.\n\nGATT discovery:\n- Filter for service 0000ffe0-0000-1000-8000-00805f9b34fb when the adapter advertises it.\n- Discover characteristics. Write AT commands to the write characteristic. Enable notifications on the notify characteristic.\n- Queue one command at a time and wait for the reply. Retry discovery after a disconnect.\n\nBidirectional actions stay inside the official app and only on supported modules.";
57 + }
58 + if (workerId === "claw-v12") {
59 + return "claw-v12 local brief\n\nDashboard: consume JSON { rpm, speed, coolant } over ws://localhost:8765 and add a gauge for every new numeric key.\nHUD: run AndrOBD on the phone, then mirror that screen to a projector or a dedicated OBD windshield HUD.\nrclone: rclone sync ./obd_logs remote:car_diagnostics --progress\nAtlas Copco claw coupling: same coupling head across hose sizes for workshop air tools. It is a pneumatic fitting, not a data feed.";
60 + }
61 + return worker.id + " local brief\n\n" + worker.specialty + ". Set ANTHROPIC_API_KEY on Netlify to enable live model replies.";
62 +}
63 +
64 +export default function App() {
65 + const [messages, setMessages] = useState([]);
66 + const [input, setInput] = useState("");
67 + const [autoRoute, setAutoRoute] = useState(true);
68 + const [selected, setSelected] = useState("claw-v11");
69 + const [busy, setBusy] = useState(false);
70 + const [tab, setTab] = useState("workers");
71 + const [menu, setMenu] = useState(false);
72 + const [proxy, setProxy] = useState("checking");
73 + const endRef = useRef(null);
74 +
75 + useEffect(() => {
76 + setMessages([{
77 + id: "boot",
78 + role: "assistant",
79 + workerId: "claw-v1",
80 + content: "KLAW.CHAT v3.9 is live.\n\nFeeds loaded: KONNWEI KDIAG/MAXOBD, GATT discovery, local bridge, dashboard, HUD projection, rclone, Atlas Copco claw couplings.\n\nThis host does not connect to a vehicle. Use a scanner app or a local bridge, then ask here for the workflow."
81 + }]);
82 + fetch("/api/chat", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })
83 + .then((r) => setProxy(r.status === 503 ? "key-missing" : r.ok ? "ready" : "offline"))
84 + .catch(() => setProxy("offline"));
85 + }, []);
86 +
87 + useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, busy]);
88 + const active = useMemo(() => WORKERS.find((w) => w.id === selected), [selected]);
89 +
90 + async function send(text) {
91 + const content = (text || input).trim();
92 + if (!content || busy) return;
93 + const workerId = autoRoute ? route(content) : selected;
94 + setSelected(workerId);
95 + setInput("");
96 + setMenu(false);
97 + const user = { id: crypto.randomUUID(), role: "user", content };
98 + const pending = { id: crypto.randomUUID(), role: "assistant", workerId, content: "" };
99 + setMessages((prev) => [...prev, user, pending]);
100 + setBusy(true);
101 + try {
102 + const history = [...messages, user].filter((m) => m.role === "user" || m.role === "assistant").slice(-8).map((m) => ({ role: m.role, content: m.content }));
103 + const resp = await fetch("/api/chat", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ messages: history, system: PROMPTS[workerId] || PROMPTS["claw-v11"], workerId }) });
104 + const data = await resp.json();
105 + const modelText = data?.content?.[0]?.text;
106 + const answer = modelText || localAnswer(workerId) + (data?.error ? "\n\nProxy: " + data.error : "");
107 + setMessages((prev) => prev.map((m) => m.id === pending.id ? { ...m, content: answer } : m));
108 + setProxy(resp.ok ? "ready" : "key-missing");
109 + } catch (error) {
110 + setMessages((prev) => prev.map((m) => m.id === pending.id ? { ...m, content: localAnswer(workerId) + "\n\nLocal fallback: " + error.message } : m));
111 + setProxy("offline");
112 + } finally {
113 + setBusy(false);
114 + }
115 + }
116 +
117 + return (
118 + <div className="app">
119 + <header className="top">
120 + <button className="ghost" onClick={() => setMenu((v) => !v)}>MENU</button>
121 + <div className="mark">K</div>
122 + <div><div className="title">KLAW.CHAT</div><div className="sub">v3.9 · feeds persistent</div></div>
123 + <div className="grow">
124 + <button className={"chip " + (autoRoute ? "on" : "")} onClick={() => setAutoRoute((v) => !v)}>{autoRoute ? "AUTO" : "MANUAL"}</button>
125 + <button className="ghost" onClick={() => setMessages([])}>CLEAR</button>
126 + </div>
127 + </header>
128 + <div className="body">
129 + <aside className={"side " + (menu ? "open" : "")}>
130 + <div className="tabs">{["workers", "feeds"].map((name) => <button key={name} className={tab === name ? "on" : ""} onClick={() => setTab(name)}>{name.toUpperCase()}</button>)}</div>
131 + <div className="list">
132 + {tab === "workers" && WORKERS.map((w) => (
133 + <button key={w.id} className={"worker " + (selected === w.id ? "on" : "")} onClick={() => { setSelected(w.id); setAutoRoute(false); setMenu(false); }}>
134 + <b style={{ color: w.color }}>{w.id}</b><span>{w.specialty}</span>
135 + </button>
136 + ))}
137 + {tab === "feeds" && FEEDS.map(([title, body]) => <div className="card" key={title}><b>{title}</b>{body}</div>)}
138 + </div>
139 + </aside>
140 + <main className="main">
141 + <div className="head"><span className="dot" style={{ background: active.color }} /><b style={{ color: active.color }}>{active.id}</b><span className="sub">{active.specialty}</span></div>
142 + <div className="msgs">
143 + {messages.map((m) => (
144 + <article key={m.id} className={"msg " + m.role}>
145 + <div className="meta">{m.role === "user" ? "OPERATOR" : (m.workerId || "claw").toUpperCase()}</div>
146 + <div className="bubble">{m.content || (busy ? "…" : "")}</div>
147 + </article>
148 + ))}
149 + <div ref={endRef} />
150 + </div>
151 + <div className="composer">
152 + <div className="quick">{QUICK.map((q) => <button key={q} onClick={() => send(q)}>{q}</button>)}</div>
153 + <div className="row">
154 + <textarea value={input} placeholder={"Route to " + selected + (autoRoute ? " (auto)" : "")} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }} />
155 + <button className="send" disabled={busy || !input.trim()} onClick={() => send()}>↑</button>
156 + </div>
157 + </div>
158 + </main>
159 + </div>
160 + <footer className="status"><span>12 WORKERS</span><span>PROXY {proxy.toUpperCase()}</span><span>{busy ? "STREAMING" : "IDLE"}</span><span style={{ marginLeft: "auto", color: active.color }}>{selected}</span></footer>
161 + </div>
162 + );
163 +}
src/styles.css new
+43
@@ -0,0 +1,43 @@
1 +:root { color-scheme: dark; --bg: #060a0d; --panel: #080c0f; --line: #1e2a30; --text: #d0e0e8; --muted: #4a6270; --accent: #00e5a0; }
2 +* { box-sizing: border-box; }
3 +html, body, #root { height: 100%; margin: 0; background: var(--bg); color: var(--text); }
4 +body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
5 +button, textarea, input { font: inherit; }
6 +button { cursor: pointer; }
7 +.app { display: flex; flex-direction: column; height: 100%; }
8 +.top { display: flex; align-items: center; gap: 10px; height: 48px; padding: 0 12px; background: var(--panel); border-bottom: 1px solid var(--line); }
9 +.mark { width: 28px; height: 28px; border-radius: 6px; background: linear-gradient(135deg, #00e5a0, #00b8ff); color: #060a0d; display: grid; place-items: center; font-weight: 700; }
10 +.title { font-weight: 700; letter-spacing: 0.08em; }
11 +.sub { color: var(--muted); font-size: 11px; }
12 +.grow { margin-left: auto; display: flex; gap: 8px; }
13 +.chip, .ghost { border: 1px solid var(--line); background: transparent; color: var(--muted); border-radius: 4px; padding: 4px 8px; font-size: 11px; }
14 +.chip.on { color: var(--accent); border-color: var(--accent); }
15 +.body { display: flex; min-height: 0; flex: 1; }
16 +.side { width: 230px; background: var(--panel); border-right: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; }
17 +.tabs { display: flex; border-bottom: 1px solid var(--line); }
18 +.tabs button { flex: 1; background: transparent; border: 0; color: var(--muted); padding: 8px 0; font-size: 10px; }
19 +.tabs button.on { color: var(--accent); box-shadow: inset 0 -2px 0 var(--accent); }
20 +.list { overflow: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
21 +.worker { text-align: left; background: transparent; border: 1px solid var(--line); border-radius: 6px; padding: 8px; color: var(--text); }
22 +.worker.on { border-color: var(--accent); background: #00e5a012; }
23 +.worker b { display: block; font-size: 12px; }
24 +.worker span { display: block; color: var(--muted); font-size: 10px; margin-top: 2px; }
25 +.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
26 +.head { padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel); display: flex; gap: 10px; align-items: center; font-size: 12px; }
27 +.dot { width: 8px; height: 8px; border-radius: 50%; }
28 +.msgs { flex: 1; overflow: auto; padding: 14px; }
29 +.msg { max-width: 820px; margin: 0 auto 14px; }
30 +.meta { font-size: 10px; color: var(--muted); margin-bottom: 4px; }
31 +.bubble { white-space: pre-wrap; line-height: 1.55; font-size: 13px; border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: #0a1218; }
32 +.msg.user .bubble { background: #0e1a20; }
33 +.composer { border-top: 1px solid var(--line); background: var(--panel); padding: 10px 12px; }
34 +.quick { display: flex; gap: 6px; overflow: auto; padding-bottom: 8px; }
35 +.quick button { flex: 0 0 auto; background: transparent; border: 1px solid var(--line); color: var(--muted); border-radius: 4px; padding: 4px 8px; font-size: 10px; }
36 +.row { display: flex; gap: 8px; }
37 +textarea { flex: 1; resize: none; min-height: 44px; background: #0a1218; color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 10px; }
38 +.send { width: 44px; border: 0; border-radius: 8px; background: var(--accent); color: #060a0d; font-weight: 700; }
39 +.send:disabled { opacity: 0.4; }
40 +.status { height: 28px; display: flex; gap: 12px; align-items: center; padding: 0 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 10px; background: #080c0f; }
41 +.card { border: 1px solid var(--line); border-radius: 6px; padding: 8px; font-size: 11px; color: var(--muted); }
42 +.card b { color: var(--text); display: block; margin-bottom: 4px; }
43 +@media (max-width: 800px) { .side { display: none; } .side.open { display: flex; position: absolute; inset: 48px 0 28px auto; width: min(320px, 100%); z-index: 5; } }