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; } }