feat: wire Settings page and dynamic assistant name into the frontend

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
This commit is contained in:
Jonny 2026-09-14 08:26:06 +02:00
parent e6f0ce6116
commit aec27bc136
3 changed files with 250 additions and 0 deletions

63
web/src/App.tsx Normal file
View File

@ -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<boolean>(() => getStoredKey() !== null);
const [view, setView] = useState<View>("chat");
const [muted, setMuted] = useState<boolean>(() => getSpeechMuted());
const [assistantName, setAssistantName] = useState<string>("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 <Login onSuccess={() => setAuthenticated(true)} />;
}
return (
<div className="app">
<nav className="nav">
<button onClick={() => setView("chat")} disabled={view === "chat"}>Chat</button>
<button onClick={() => setView("dashboard")} disabled={view === "dashboard"}>Dashboard</button>
<button onClick={() => setView("settings")} disabled={view === "settings"}>Einstellungen</button>
<button onClick={toggleMuted} title={muted ? "Sprachausgabe einschalten" : "Sprachausgabe stummschalten"}>
{muted ? "🔇" : "🔊"}
</button>
<button onClick={() => logout()}>Logout</button>
<WeatherWidget />
</nav>
{view === "chat" && <Chat assistantName={assistantName} />}
{view === "dashboard" && <Dashboard />}
{view === "settings" && <Settings />}
</div>
);
}

184
web/src/components/Chat.tsx Normal file
View File

@ -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<Message[]>([]);
const [input, setInput] = useState("");
const [conversationId, setConversationId] = useState<number | null>(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<HTMLInputElement>) {
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 (
<div className="chat">
<div className="chat-header">
<button
onClick={startNewChat}
disabled={sending || (conversationId === null && messages.length === 0)}
className="new-chat-button"
title="Neue Unterhaltung starten"
>
🆕 Neuer Chat
</button>
</div>
<div className="messages">
{messages.map((m, i) => (
<div key={i} className={`message ${m.role}`}>
{m.content}
</div>
))}
</div>
<div className="input-row">
{isSpeechRecognitionSupported() && (
<button
onClick={toggleListening}
disabled={sending}
className={listening ? "mic-button listening" : "mic-button"}
title={listening ? "Aufnahme stoppen" : "Spracheingabe starten"}
>
🎤
</button>
)}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Nachricht an ${assistantName}...`}
disabled={sending}
/>
<button onClick={sendMessage} disabled={sending || input.trim().length === 0}>
Senden
</button>
</div>
</div>
);
}

View File

@ -27,3 +27,6 @@ body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color
.badge-ok { color: #4ade80; } .badge-ok { color: #4ade80; }
.badge-error { color: #f87171; } .badge-error { color: #f87171; }
.orders-widget { margin-top: 1rem; } .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; }