From aec27bc136cc40984383453a4ce42a50751cd575 Mon Sep 17 00:00:00 2001 From: Jonny Date: Mon, 14 Sep 2026 08:26:06 +0200 Subject: [PATCH] feat: wire Settings page and dynamic assistant name into the frontend Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5 --- web/src/App.tsx | 63 ++++++++++++ web/src/components/Chat.tsx | 184 ++++++++++++++++++++++++++++++++++++ web/src/index.css | 3 + 3 files changed, 250 insertions(+) create mode 100644 web/src/App.tsx create mode 100644 web/src/components/Chat.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..1ec19b1 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,63 @@ +import { useState, useEffect } from "react"; +import { apiFetch, getStoredKey, onUnauthorized, logout } from "./api"; +import { getSpeechMuted, setSpeechMuted } from "./speech"; +import Login from "./components/Login"; +import Chat from "./components/Chat"; +import Dashboard from "./components/Dashboard"; +import Settings from "./components/Settings"; +import WeatherWidget from "./components/WeatherWidget"; + +type View = "chat" | "dashboard" | "settings"; + +export default function App() { + const [authenticated, setAuthenticated] = useState(() => getStoredKey() !== null); + const [view, setView] = useState("chat"); + const [muted, setMuted] = useState(() => getSpeechMuted()); + const [assistantName, setAssistantName] = useState("JARVIS"); + + useEffect(() => onUnauthorized(() => setAuthenticated(false)), []); + + useEffect(() => { + if (!authenticated) return; + async function loadAssistantName() { + try { + const response = await apiFetch("/api/v1/settings"); + if (!response.ok) return; + const data = await response.json(); + setAssistantName(data.assistant_name); + document.title = data.assistant_name; + } catch { + // Default "JARVIS" bleibt bei Fehlern erhalten. + } + } + loadAssistantName(); + }, [authenticated]); + + function toggleMuted() { + const next = !muted; + setSpeechMuted(next); + setMuted(next); + } + + if (!authenticated) { + return setAuthenticated(true)} />; + } + + return ( +
+ + {view === "chat" && } + {view === "dashboard" && } + {view === "settings" && } +
+ ); +} diff --git a/web/src/components/Chat.tsx b/web/src/components/Chat.tsx new file mode 100644 index 0000000..1d004ed --- /dev/null +++ b/web/src/components/Chat.tsx @@ -0,0 +1,184 @@ +import { useState, useEffect, KeyboardEvent } from "react"; +import { apiFetch } from "../api"; +import { isSpeechRecognitionSupported, startListening, stopListening, speak } from "../speech"; + +function describeSpeechError(error: string): string { + switch (error) { + case "not-allowed": + case "service-not-allowed": + return "Mikrofon-Zugriff wurde blockiert. Bitte im Browser (Schloss-Symbol in der Adressleiste) fuer diese Seite erlauben."; + case "no-speech": + return "Es wurde nichts gehoert. Bitte erneut versuchen."; + case "audio-capture": + return "Kein Mikrofon gefunden."; + case "network": + return "Netzwerkfehler bei der Spracherkennung."; + case "not-supported": + return "Dieser Browser unterstuetzt keine Spracheingabe."; + default: + return `Spracherkennung fehlgeschlagen (${error}).`; + } +} + +type Message = { role: "user" | "assistant" | "system"; content: string }; + +const CONVERSATION_STORAGE_KEY = "jarvis_conversation_id"; + +function getStoredConversationId(): number | null { + const raw = localStorage.getItem(CONVERSATION_STORAGE_KEY); + return raw ? Number(raw) : null; +} + +function storeConversationId(id: number): void { + localStorage.setItem(CONVERSATION_STORAGE_KEY, String(id)); +} + +function clearStoredConversationId(): void { + localStorage.removeItem(CONVERSATION_STORAGE_KEY); +} + +type ChatProps = { assistantName: string }; + +export default function Chat({ assistantName }: ChatProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [conversationId, setConversationId] = useState(null); + const [sending, setSending] = useState(false); + const [listening, setListening] = useState(false); + + useEffect(() => { + const storedId = getStoredConversationId(); + if (storedId === null) return; + + async function restore() { + try { + const response = await apiFetch(`/api/v1/conversations/${storedId}`); + if (!response.ok) { + clearStoredConversationId(); + return; + } + const data = await response.json(); + setConversationId(data.conversation_id); + setMessages( + data.messages.map((m: { role: string; content: string }) => ({ + role: m.role, + content: m.content, + })) + ); + } catch { + // Server unreachable on load - leave the chat empty, storedId stays + // for the next successful load rather than being discarded here. + } + } + + restore(); + }, []); + + async function sendMessage() { + const text = input.trim(); + if (!text || sending) return; + setMessages((prev) => [...prev, { role: "user", content: text }]); + setInput(""); + setSending(true); + try { + const response = await apiFetch("/api/v1/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversation_id: conversationId, message: text }), + }); + const data = await response.json(); + if (!response.ok) { + setMessages((prev) => [ + ...prev, + { role: "system", content: `Fehler: ${data.detail ?? response.statusText}` }, + ]); + return; + } + setConversationId(data.conversation_id); + storeConversationId(data.conversation_id); + setMessages((prev) => [...prev, { role: "assistant", content: data.response }]); + speak(data.response); + } catch { + setMessages((prev) => [...prev, { role: "system", content: "Server nicht erreichbar." }]); + } finally { + setSending(false); + } + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Enter") sendMessage(); + } + + function startNewChat() { + if (listening) { + stopListening(); + setListening(false); + } + window.speechSynthesis?.cancel(); + clearStoredConversationId(); + setConversationId(null); + setMessages([]); + setInput(""); + } + + function toggleListening() { + if (listening) { + stopListening(); + setListening(false); + return; + } + setListening(true); + startListening( + (transcript) => setInput(transcript), + () => setListening(false), + (error) => { + setListening(false); + setMessages((prev) => [...prev, { role: "system", content: describeSpeechError(error) }]); + } + ); + } + + return ( +
+
+ +
+
+ {messages.map((m, i) => ( +
+ {m.content} +
+ ))} +
+
+ {isSpeechRecognitionSupported() && ( + + )} + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={`Nachricht an ${assistantName}...`} + disabled={sending} + /> + +
+
+ ); +} diff --git a/web/src/index.css b/web/src/index.css index febbfb1..54569b1 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -27,3 +27,6 @@ body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color .badge-ok { color: #4ade80; } .badge-error { color: #f87171; } .orders-widget { margin-top: 1rem; } +.settings { padding: 1rem; max-width: 400px; } +.settings label { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 1rem; } +.settings input { padding: 0.5rem; }