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:
parent
e6f0ce6116
commit
aec27bc136
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue