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-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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue