Lucy v2 (Etappe 2): guter Chat + animierter digitaler Raum

Chat:
- react-markdown/remark-gfm: Lucys Antworten formatiert (fett/listen/inline-code)
- Code-Bloecke mit Kopier-Knopf; Links oeffnen im System-Browser (shell.openExternal via IPC)
- Kopier-Knopf pro Antwort (Hover)
- Persona erlaubt Code/Links SCHRIFTLICH auf Nachfrage; Stimme liest nur kurze Einleitung (cleanForTTS filtert)

Digitaler Raum:
- driftendes Aurora-Licht + schwebende Partikel (2 Parallax-Ebenen) + Vignette ueber dem Neon-Gitter
- reine CSS-Animation (GPU), im Overlay weiter ausgeblendet

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-30 19:12:12 +02:00
parent 41f46569bf
commit 16f4683859
8 changed files with 2155 additions and 3 deletions
+150
View File
@@ -0,0 +1,150 @@
import { app, BrowserWindow, ipcMain, Tray, Menu, globalShortcut, nativeImage, desktopCapturer, shell } from "electron"
import { spawn, ChildProcess } from "child_process"
import { join } from "path"
// Lucy — Main-Prozess. Spawnt die lokale Stimme (pocket-tts, :8130) + verwaltet Fenster/Tray/Hotkey.
// Fenster ist frameless+transparent: eigene Titelleiste im Renderer. Zwei Modi: 'full' (Arbeitsfenster)
// und 'overlay' (kleine schwebende Lucy, always-on-top). pocket-tts ist CPU-kühl -> kein /wake-/sleep.
const PORT = process.env.LUCY_TTS_PORT || "8130"
const TTS = `http://127.0.0.1:${PORT}`
const TTS_DIR = process.env.LUCY_TTS_DIR || join(app.getAppPath(), "..", "lucy-tts")
const PY = process.env.LUCY_TTS_PY || join(TTS_DIR, "ptts-venv", "Scripts", "python.exe")
const HOTKEY = process.env.LUCY_HOTKEY || "CommandOrControl+Shift+Space"
const FULL_SIZE = { width: 1100, height: 720 }
const OVERLAY_SIZE = { width: 360, height: 480 }
let win: BrowserWindow | null = null
let tray: Tray | null = null
let ttsProc: ChildProcess | null = null
let mode: "full" | "overlay" = "full"
let pinned = false
async function ensureTtsServer() {
try {
const r = await fetch(`${TTS}/health`)
if (r.ok) { console.log("[lucy] TTS-Server läuft bereits auf", TTS); return }
} catch { /* selbst starten */ }
console.log("[lucy] starte pocket_server:", PY, "(cwd:", TTS_DIR + ")")
ttsProc = spawn(PY, ["-m", "uvicorn", "pocket_server:app", "--host", "127.0.0.1", "--port", PORT],
{ cwd: TTS_DIR, windowsHide: true, env: { ...process.env } })
ttsProc.stdout?.on("data", (d) => console.log("[tts]", d.toString().trimEnd()))
ttsProc.stderr?.on("data", (d) => console.log("[tts]", d.toString().trimEnd()))
ttsProc.on("error", (e) => console.error("[lucy] pocket_server-Spawn fehlgeschlagen:", e))
ttsProc.on("exit", (code) => { console.log("[lucy] pocket_server beendet, code", code); ttsProc = null })
}
function stopTtsServer() {
if (ttsProc && !ttsProc.killed) { try { ttsProc.kill() } catch { /* */ }; ttsProc = null }
}
function applyMode(next: "full" | "overlay") {
if (!win) return
mode = next
const overlay = next === "overlay"
const size = overlay ? OVERLAY_SIZE : FULL_SIZE
win.setAlwaysOnTop(overlay || pinned, "floating")
win.setSize(size.width, size.height, false)
win.setResizable(!overlay)
if (overlay) win.setMinimumSize(280, 360); else win.setMinimumSize(420, 420)
win.webContents.send("lucy:mode", next)
}
function createWindow() {
win = new BrowserWindow({
width: FULL_SIZE.width, height: FULL_SIZE.height, minWidth: 420, minHeight: 420,
frame: false, transparent: true, backgroundColor: "#00000000", resizable: true,
title: "Lucy", show: false,
webPreferences: {
preload: join(__dirname, "../preload/index.mjs"),
sandbox: false, webSecurity: false,
},
})
if (process.env.ELECTRON_RENDERER_URL) win.loadURL(process.env.ELECTRON_RENDERER_URL)
else win.loadFile(join(__dirname, "../renderer/index.html"))
win.once("ready-to-show", () => win?.show())
win.on("closed", () => { win = null })
}
function showWin() {
if (!win) { createWindow(); return }
if (win.isMinimized()) win.restore()
win.show(); win.focus()
}
function lucyIcon() {
// 16x16 Icon zur Laufzeit (silberblauer Kreis) -> sichtbar im Tray, ohne Asset-Datei
const s = 16, buf = Buffer.alloc(s * s * 4)
for (let y = 0; y < s; y++) for (let x = 0; x < s; x++) {
const i = (y * s + x) * 4
const dx = x - 7.5, dy = y - 7.5, inside = dx * dx + dy * dy <= 56
buf[i] = 0xfc; buf[i + 1] = 0xd3; buf[i + 2] = 0x7d; buf[i + 3] = inside ? 0xff : 0x00 // BGRA
}
return nativeImage.createFromBitmap(buf, { width: s, height: s })
}
function buildTray() {
tray = new Tray(lucyIcon())
tray.setToolTip("Lucy")
const menu = Menu.buildFromTemplate([
{ label: "Lucy zeigen", click: showWin },
{ label: "Overlay-Modus", type: "checkbox", checked: false,
click: (mi) => { applyMode(mi.checked ? "overlay" : "full"); showWin() } },
{ type: "separator" },
{ label: "Beenden", click: () => { app.quit() } },
])
tray.setContextMenu(menu)
tray.on("click", showWin)
}
function registerHotkey() {
globalShortcut.unregisterAll()
// Global: Sprach-Aufnahme an/aus (Toggle, da Global-Shortcuts kein key-up liefern)
const ok = globalShortcut.register(HOTKEY, () => { showWin(); win?.webContents.send("lucy:hotkey") })
if (!ok) console.error("[lucy] Global-Hotkey-Registrierung fehlgeschlagen:", HOTKEY)
}
// ---- IPC vom Renderer (Titelleiste/Modus) ----
ipcMain.handle("win:minimize", () => win?.minimize())
ipcMain.handle("win:close", () => win?.hide()) // in den Tray, bleibt warm; echtes Beenden über Tray
ipcMain.handle("win:setMode", (_e, m: "full" | "overlay") => { applyMode(m); return mode })
ipcMain.handle("win:getMode", () => mode)
ipcMain.handle("win:togglePin", () => { pinned = !pinned; win?.setAlwaysOnTop(pinned || mode === "overlay", "floating"); return pinned })
// Links im echten System-Browser oeffnen (nicht im App-Fenster); nur http(s) erlauben
ipcMain.handle("open:external", (_e, url: string) => { if (/^https?:\/\//i.test(url)) void shell.openExternal(url) })
// ---- Bildschirm-Sicht (Lucys Augen) ----
const CAP = { width: 1280, height: 720 }
ipcMain.handle("screen:capture", async () => {
const sources = await desktopCapturer.getSources({ types: ["screen"], thumbnailSize: CAP })
return sources.map((s) => s.thumbnail.toDataURL()) // ALLE Bildschirme (Multi-Monitor) als PNG-DataURLs
})
ipcMain.handle("screen:listWindows", async () => {
const sources = await desktopCapturer.getSources({ types: ["window"], thumbnailSize: { width: 0, height: 0 } })
return sources.filter((s) => s.name && s.name !== "Lucy").map((s) => ({ id: s.id, name: s.name }))
})
ipcMain.handle("screen:captureWindow", async (_e, id: string) => {
const sources = await desktopCapturer.getSources({ types: ["window"], thumbnailSize: CAP })
return sources.find((s) => s.id === id)?.thumbnail.toDataURL() ?? null
})
// Fuzzy-Fenstersuche: bester Treffer für einen im Gesagten erkannten App-/Fensternamen
ipcMain.handle("screen:captureByName", async (_e, query: string) => {
const q = query.toLowerCase().trim()
const sources = await desktopCapturer.getSources({ types: ["window"], thumbnailSize: CAP })
const cand = sources.filter((s) => s.name && s.name !== "Lucy")
const hit = cand.find((s) => s.name.toLowerCase().includes(q))
|| cand.find((s) => q.split(/\s+/).some((w) => w.length > 2 && s.name.toLowerCase().includes(w)))
return hit ? { name: hit.name, image: hit.thumbnail.toDataURL() } : null
})
app.whenReady().then(() => {
void ensureTtsServer()
createWindow()
buildTray()
registerHotkey()
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() })
})
app.on("window-all-closed", () => { /* im Tray weiterlaufen lassen; Beenden nur über Tray/Quit */ })
app.on("before-quit", () => { stopTtsServer(); globalShortcut.unregisterAll(); tray?.destroy() })
+27
View File
@@ -0,0 +1,27 @@
import { contextBridge, ipcRenderer } from "electron"
// Schmale Bruecke Renderer -> Main: Fenster-Chrome (frameless), Modus-Umschaltung, Tray/Hotkey-Events.
contextBridge.exposeInMainWorld("lucy", {
minimize: () => ipcRenderer.invoke("win:minimize"),
close: () => ipcRenderer.invoke("win:close"),
setMode: (m: "full" | "overlay") => ipcRenderer.invoke("win:setMode", m),
getMode: () => ipcRenderer.invoke("win:getMode"),
togglePin: () => ipcRenderer.invoke("win:togglePin"),
// Bildschirm-Sicht
captureScreen: () => ipcRenderer.invoke("screen:capture"), // alle Monitore -> string[]
listWindows: () => ipcRenderer.invoke("screen:listWindows"),
captureWindow: (id: string) => ipcRenderer.invoke("screen:captureWindow", id),
captureByName: (q: string) => ipcRenderer.invoke("screen:captureByName", q),
openExternal: (url: string) => ipcRenderer.invoke("open:external", url), // Link im System-Browser
// Events aus dem Main-Prozess (Tray/Hotkey/Modus)
onMode: (cb: (m: "full" | "overlay") => void) => {
const h = (_e: unknown, m: "full" | "overlay") => cb(m)
ipcRenderer.on("lucy:mode", h)
return () => ipcRenderer.removeListener("lucy:mode", h)
},
onHotkey: (cb: () => void) => {
const h = () => cb()
ipcRenderer.on("lucy:hotkey", h)
return () => ipcRenderer.removeListener("lucy:hotkey", h)
},
})
@@ -0,0 +1,182 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { Avatar3D } from "./components/Avatar3D"
import { AuraGlow } from "./components/AuraGlow"
import { ChatMarkdown } from "./components/ChatMarkdown"
import { useVoiceAgent } from "./lib/voice/useVoiceAgent"
const FIXED_AVATAR = "/avatar.vrm"
const STATUS_LABEL: Record<string, string> = {
warming: "Lucy waermt auf …",
idle: "Bereit — halte zum Sprechen",
listening: "Hoere zu …",
transcribing: "Verstehe …",
thinking: "Lucy denkt …",
speaking: "Lucy spricht …",
error: "Fehler",
}
export default function App() {
const { status, ready, messages, error, recording, audioLevel, emotion, pressStart, pressEnd, reset,
inputMode, setInputMode, vadListening, lookAtScreen } = useVoiceAgent()
const holding = useRef(false)
const endRef = useRef<HTMLDivElement>(null)
const [mode, setMode] = useState<"full" | "overlay">("full")
const [pinned, setPinned] = useState(false)
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }) }, [messages])
// Fenster-Modus vom Main synchronisieren (Tray/Hotkey koennen ihn aendern)
useEffect(() => {
window.lucy?.getMode().then(setMode).catch(() => {})
return window.lucy?.onMode(setMode)
}, [])
// Leertaste = Push-to-talk (halten)
useEffect(() => {
const isField = (el: EventTarget | null) =>
el instanceof HTMLElement && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)
const down = (e: KeyboardEvent) => {
if (e.code !== "Space" || e.repeat || holding.current || isField(e.target)) return
e.preventDefault(); holding.current = true; pressStart()
}
const up = (e: KeyboardEvent) => {
if (e.code !== "Space" || !holding.current) return
e.preventDefault(); holding.current = false; pressEnd()
}
window.addEventListener("keydown", down); window.addEventListener("keyup", up)
return () => { window.removeEventListener("keydown", down); window.removeEventListener("keyup", up) }
}, [pressStart, pressEnd])
// Global-Hotkey = TOGGLE (Global-Shortcuts liefern kein key-up) -> Druck startet/stoppt Aufnahme
const recRef = useRef(recording); recRef.current = recording
useEffect(() => {
return window.lucy?.onHotkey(() => {
if (recRef.current || holding.current) { holding.current = false; pressEnd() }
else { holding.current = true; pressStart() }
})
}, [pressStart, pressEnd])
const toggleMode = useCallback(() => {
const next = mode === "full" ? "overlay" : "full"
window.lucy?.setMode(next).then(setMode).catch(() => setMode(next))
}, [mode])
const togglePin = useCallback(() => { window.lucy?.togglePin().then(setPinned).catch(() => {}) }, [])
const speaking = status === "speaking"
const busy = status === "transcribing" || status === "thinking"
const statusColor = status === "error" ? "#f87171" : speaking ? "#7dd3fc" : "#9ca3af"
const overlay = mode === "overlay"
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.text?.trim() || ""
const micBtn = (
<button
className={"mic" + (recording ? " rec" : "") + (!ready ? " disabled" : "")}
disabled={!ready}
onPointerDown={(e) => { if (!ready) return; e.preventDefault(); holding.current = true; pressStart() }}
onPointerUp={() => { if (holding.current) { holding.current = false; pressEnd() } }}
onPointerLeave={() => { if (holding.current) { holding.current = false; pressEnd() } }}
title={ready ? "Gedrueckt halten zum Sprechen (oder Leertaste)" : "Lucy waermt noch auf …"}
>
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" /><line x1="12" y1="19" x2="12" y2="22" />
</svg>
</button>
)
return (
<div className={"app " + mode}>
<div className="titlebar">
<div className="drag">
<span className="dot" style={{ background: statusColor }} />
<span className="ttl">Lucy</span>
{!overlay && <span className="ttlStatus">{error || STATUS_LABEL[status]}</span>}
</div>
<div className="winbtns">
<button className="wb" onClick={() => void lookAtScreen()} title="Lucy auf den Bildschirm schauen lassen" disabled={!ready}>👁</button>
<button className="wb" onClick={togglePin} title="Immer im Vordergrund" data-on={pinned}>📌</button>
<button className="wb" onClick={toggleMode} title={overlay ? "Vollfenster" : "Overlay-Modus"}>{overlay ? "▣" : "▢"}</button>
<button className="wb" onClick={() => window.lucy?.minimize()} title="Minimieren"></button>
<button className="wb close" onClick={() => window.lucy?.close()} title="In den Tray"></button>
</div>
</div>
<div className="body">
<div className="stage">
<div className="avatarWrap">
<div className="digitalRoom"><div className="dust" /></div>
<AuraGlow status={status} audioLevel={audioLevel} />
<Avatar3D url={FIXED_AVATAR} audioLevel={audioLevel} emotion={emotion} status={status} />
{speaking && lastAssistant && <div className="subtitle">{lastAssistant}</div>}
{!ready && (
<div className="warmOverlay">
<span className="spinner big" />
<div className="warmTitle">Lucy waermt auf </div>
{!overlay && <div className="warmSub">Die lokale Stimme wird vorbereitet einen Moment, Commander.</div>}
</div>
)}
{overlay && (
<div className="overlayCaption">
{busy && <span className="spinner" />}
<span>{error || STATUS_LABEL[status]}</span>
</div>
)}
</div>
<div className="controls">
{!overlay && (
<div className="status" style={{ color: statusColor }}>
{busy && <span className="spinner" />}
<span>{error || STATUS_LABEL[status]}</span>
</div>
)}
{micBtn}
{ready && (
<div className="modeToggle" title="Eingabe-Modus">
<button className={inputMode === "ptt" ? "on" : ""} onClick={() => setInputMode("ptt")}>Push-to-talk</button>
<button className={inputMode === "vad" ? "on" : ""} onClick={() => setInputMode("vad")}>Freisprechen</button>
</div>
)}
{!overlay && (
<div className="hint">
{inputMode === "vad"
? <>Freisprechen aktiv{vadListening ? <span className="vadDot" /> : null} · <kbd>Leertaste</kbd> geht auch · Hotkey global</>
: <>Halten zum Sprechen · <kbd>Leertaste</kbd> · Hotkey global</>}
</div>
)}
</div>
</div>
{!overlay && (
<div className="side">
<div className="sideHead">
<span>Gespräch</span>
<button className="iconBtn" onClick={reset} title="Neues Gespräch"></button>
</div>
<div className="conv">
{messages.length === 0 && (
<p className="empty">Halte den Knopf (oder die Leertaste) und sprich. Lucy hört zu, denkt mit
Hermes' vollem Gedächtnis und antwortet in ihrer lokalen Stimme.</p>
)}
{messages.map((m, i) => (
<div key={i} className={"msg " + (m.role === "user" ? "user" : "assistant")}>
<span className="who">{m.role === "user" ? "Du" : "Lucy"}</span>
<div className="bubble">
{m.text
? (m.role === "assistant" ? <ChatMarkdown text={m.text} /> : m.text)
: <span className="dots"><i /><i /><i /></span>}
{m.role === "assistant" && m.text && (
<button className="msgCopy" title="Antwort kopieren"
onClick={() => { void navigator.clipboard.writeText(m.text).catch(() => {}) }}></button>
)}
</div>
</div>
))}
<div ref={endRef} />
</div>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,60 @@
import { useState, type ReactNode } from "react"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
// Chat-Markdown fuer Lucys Antworten: Code-Bloecke mit Kopier-Knopf, Links oeffnen im System-Browser,
// gaengige Formatierung (fett/listen/inline-code). Die STIMME liest davon nichts vor — cleanForTTS
// filtert Code/Links vorm Sprechen; hier geht es rein um die lesbare/kopierbare Anzeige.
function CopyBtn({ text, label = "Kopieren" }: { text: string; label?: string }) {
const [done, setDone] = useState(false)
return (
<button
className="copyBtn"
onClick={async () => {
try { await navigator.clipboard.writeText(text); setDone(true); setTimeout(() => setDone(false), 1200) } catch { /* */ }
}}
>{done ? "✓ Kopiert" : label}</button>
)
}
function nodeText(children: ReactNode): string {
if (children == null) return ""
if (typeof children === "string" || typeof children === "number") return String(children)
if (Array.isArray(children)) return children.map(nodeText).join("")
// @ts-expect-error React-Element-Kinder
if (children?.props?.children) return nodeText(children.props.children)
return ""
}
export function ChatMarkdown({ text }: { text: string }) {
return (
<div className="md">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children }) => (
<a className="mdLink" href={href}
onClick={(e) => { e.preventDefault(); if (href) void window.lucy?.openExternal(href) }}>
{children}
</a>
),
// pre durchreichen -> der Code-Block baut seinen eigenen Container (kein <div> in <pre>)
pre: ({ children }) => <>{children}</>,
code: ({ className, children, ...props }) => {
const raw = nodeText(children).replace(/\n$/, "")
const isBlock = /language-/.test(className || "") || raw.includes("\n")
if (!isBlock) return <code className="mdInlineCode" {...props}>{children}</code>
const lang = (className || "").replace(/language-/, "") || "code"
return (
<div className="codeBlock">
<div className="codeBar"><span className="codeLang">{lang}</span><CopyBtn text={raw} /></div>
<pre><code className={className}>{children}</code></pre>
</div>
)
},
}}
>{text}</ReactMarkdown>
</div>
)
}
@@ -0,0 +1,35 @@
// Zentrale Endpunkte. Hirn + STT laufen auf der BOX (wie die WebUI), die Stimme LOKAL (pocket-tts, CPU).
export const BOX_URL = "http://192.168.178.151:9001" // MC2-Backend: /api/voice/stt, /api/voice/chat
export const TTS_URL = "http://127.0.0.1:8130" // lokaler Lucy-TTS (pocket-tts, vom Main-Prozess gespawnt)
// Persona/Anrede: Lucy spricht den Nutzer als "Commander" an. ECHTE Umlaute erzwingen (sonst klingt TTS grausam).
export const SYSTEM_PROMPT =
"Du bist Lucy, eine gesprochene Assistentin, und redest den Nutzer mit „Commander“ an. " +
"Antworte natürlich und freundlich, aber SEHR KNAPP: in der Regel 1 bis 3 ganze, gut vorlesbare Sätze, " +
"nur das Wesentliche. Hol nicht aus, zähle nicht alles auf — lange Erklärungen nur, wenn ausdrücklich gewünscht. " +
"Halte den gesprochenen Teil in reinem Fließtext (keine Aufzählungszeichen, keine Emojis). " +
"NUR wenn der Commander ausdrücklich nach Code, Befehlen oder einem Link fragt, gib diese im Text aus — " +
"Code in Markdown-Codeblöcken (```), Links als vollständige URL. Diese werden angezeigt, aber NICHT vorgelesen; " +
"sprich dann nur eine kurze Einleitung dazu (z. B. „Hier ist das Skript, Commander.“). " +
"Verwende IMMER echte deutsche Umlaute (ä, ö, ü, ß) und NIEMALS ae, oe, ue oder ss als Ersatz. " +
"Schreibe Zahlen, Modellbezeichnungen und Abkürzungen EXAKT und normal (z. B. „RX 9070 XT“, „4 GB“, " +
"„25 Grad“) — schreibe Ziffern NIEMALS als Wörter aus und interpretiere sie nicht als Komma-/Dezimalzahlen."
declare global {
interface Window {
lucy?: {
minimize: () => Promise<void>
close: () => Promise<void>
setMode: (m: "full" | "overlay") => Promise<"full" | "overlay">
getMode: () => Promise<"full" | "overlay">
togglePin: () => Promise<boolean>
onMode: (cb: (m: "full" | "overlay") => void) => () => void
onHotkey: (cb: () => void) => () => void
captureScreen: () => Promise<string[]>
listWindows: () => Promise<{ id: string; name: string }[]>
captureWindow: (id: string) => Promise<string | null>
captureByName: (q: string) => Promise<{ name: string; image: string } | null>
openExternal: (url: string) => Promise<void>
}
}
}
@@ -0,0 +1,230 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
:root { color-scheme: dark; }
html, body, #root { height: 100%; }
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: #e5e7eb; overflow: hidden; background: transparent; /* frameless+transparent: App malt den Hintergrund */
}
/* ---- App-Rahmen: Titelleiste + Body ---- */
.app { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.app.full {
background: radial-gradient(1200px 800px at 50% 30%, #14141f 0%, #0a0a0f 70%);
border: 1px solid rgba(255,255,255,0.08); border-radius: 14px;
}
.app.overlay { background: transparent; }
.titlebar {
flex-shrink: 0; height: 34px; display: flex; align-items: center; justify-content: space-between;
padding: 0 6px 0 12px; -webkit-app-region: drag; user-select: none;
}
.app.overlay .titlebar { height: 28px; opacity: 0.35; transition: opacity 0.2s; }
.app.overlay:hover .titlebar { opacity: 1; }
.drag { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
.dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; box-shadow: 0 0 8px currentColor; }
.ttl { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; color: #cbd5e1; }
.ttlStatus { font-size: 11px; color: #6b7280; margin-left: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.winbtns { display: flex; gap: 2px; -webkit-app-region: no-drag; }
.wb {
width: 28px; height: 24px; border: none; background: transparent; color: #9ca3af; cursor: pointer;
border-radius: 6px; font-size: 13px; display: flex; align-items: center; justify-content: center;
}
.wb:hover { background: rgba(255,255,255,0.1); color: #e5e7eb; }
.wb.close:hover { background: #ef4444; color: #fff; }
.wb[data-on="true"] { color: #7dd3fc; }
.body { flex: 1; min-height: 0; display: flex; gap: 14px; padding: 0 14px 14px; }
.app.overlay .body { padding: 0; }
.stage {
position: relative; display: flex; flex-direction: column; flex: 1; min-width: 0;
border: 1px solid rgba(255,255,255,0.07); border-radius: 14px;
background: rgba(255,255,255,0.02); overflow: hidden;
}
.app.overlay .stage { border: none; background: transparent; }
.avatarWrap { flex: 1; min-height: 0; position: relative; overflow: hidden; border-radius: 14px; }
/* --- digitaler Raum (animierter Sci-Fi-Backdrop, Etappe 2B) --- */
.digitalRoom {
position: absolute; inset: 0; z-index: 0; overflow: hidden;
background:
radial-gradient(60% 50% at 50% 16%, rgba(125,211,252,0.10), transparent 70%),
radial-gradient(70% 60% at 82% 92%, rgba(167,139,250,0.10), transparent 70%),
radial-gradient(60% 60% at 12% 88%, rgba(52,211,153,0.06), transparent 70%),
linear-gradient(180deg, #0b0b15 0%, #07070d 100%);
box-shadow: inset 0 0 130px 36px rgba(0,0,0,0.55); /* Vignette -> Tiefe */
}
.digitalRoom::before { /* sanft driftendes Aurora-Licht */
content: ""; position: absolute; inset: -20%; z-index: 0;
background:
radial-gradient(40% 40% at 30% 32%, rgba(125,211,252,0.12), transparent 70%),
radial-gradient(45% 45% at 74% 42%, rgba(167,139,250,0.12), transparent 70%);
filter: blur(22px); animation: auroraDrift 22s ease-in-out infinite alternate;
}
.digitalRoom::after { /* perspektivisches Neon-Gitter am Boden */
content: ""; position: absolute; left: -25%; right: -25%; bottom: -12%; height: 58%;
background-image: linear-gradient(rgba(125,211,252,0.13) 1px, transparent 1px),
linear-gradient(90deg, rgba(125,211,252,0.10) 1px, transparent 1px);
background-size: 46px 46px;
transform: perspective(440px) rotateX(62deg); transform-origin: bottom center;
-webkit-mask-image: linear-gradient(transparent, #000 65%);
animation: gridDrift 14s linear infinite;
}
/* schwebende Partikel (zwei Ebenen -> Parallax-Tiefe), nahtlos gekachelt */
.dust {
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0.7;
background-image: radial-gradient(1.6px 1.6px at 50% 50%, rgba(125,211,252,0.55), transparent 60%);
background-size: 96px 96px; animation: dustRise 24s linear infinite;
}
.dust::after {
content: ""; position: absolute; inset: 0;
background-image: radial-gradient(1.3px 1.3px at 30% 70%, rgba(167,139,250,0.5), transparent 60%);
background-size: 140px 140px; animation: dustRise2 38s linear infinite;
}
@keyframes gridDrift { to { background-position: 0 46px, 46px 0; } }
@keyframes auroraDrift { from { transform: translate(-3%, -2%) scale(1); } to { transform: translate(4%, 3%) scale(1.08); } }
@keyframes dustRise { to { background-position: 0 -96px; } }
@keyframes dustRise2 { to { background-position: 0 -140px; } }
.app.overlay .digitalRoom { display: none; } /* Overlay floatet -> nur Avatar + Aura */
.aura { position: absolute; inset: 0; z-index: 1; pointer-events: none; will-change: transform, opacity; }
.avatarCanvas { z-index: 2; }
.subtitle {
position: absolute; left: 7%; right: 7%; bottom: 14px; z-index: 3; text-align: center; max-height: 40%;
overflow: hidden; pointer-events: none; border-radius: 10px; padding: 8px 14px;
font-size: 15px; line-height: 1.45; color: #eef2f7;
text-shadow: 0 1px 6px #000, 0 0 16px rgba(0,0,0,0.6);
background: linear-gradient(transparent, rgba(8,8,14,0.45)); animation: subIn 0.2s ease;
}
@keyframes subIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.app.overlay .subtitle { font-size: 13px; bottom: 34px; }
.warmOverlay {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 14px; text-align: center; padding: 24px;
background: rgba(10,10,15,0.72); backdrop-filter: blur(3px); z-index: 5;
}
.warmTitle { font-size: 16px; color: #e5e7eb; }
.warmSub { font-size: 13px; color: #9ca3af; max-width: 340px; line-height: 1.5; }
.spinner.big { width: 30px; height: 30px; border-width: 3px; }
.overlayCaption {
position: absolute; left: 0; right: 0; bottom: 8px; display: flex; gap: 6px; justify-content: center;
align-items: center; font-size: 12px; color: #cbd5e1; text-shadow: 0 1px 4px #000; pointer-events: none;
}
.controls {
flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 16px; border-top: 1px solid rgba(255,255,255,0.07); background: rgba(0,0,0,0.25);
}
.app.overlay .controls { border: none; background: transparent; padding: 8px; gap: 6px; }
.status { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.spinner {
width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.25); border-top-color: #7dd3fc;
border-radius: 50%; animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.mic {
width: 72px; height: 72px; border-radius: 50%; cursor: pointer; user-select: none;
display: flex; align-items: center; justify-content: center;
border: 2px solid rgba(125,211,252,0.5); background: rgba(125,211,252,0.12); color: #7dd3fc;
transition: transform 0.12s, background 0.12s, border-color 0.12s;
}
.app.overlay .mic { width: 56px; height: 56px; }
.mic:hover { background: rgba(125,211,252,0.2); transform: scale(1.05); }
.mic.rec {
border-color: #f87171; background: rgba(248,113,113,0.2); color: #fca5a5; transform: scale(1.1);
box-shadow: 0 0 24px rgba(248,113,113,0.35);
}
.mic.disabled { opacity: 0.4; cursor: default; border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.04); color: #6b7280; }
.mic.disabled:hover { transform: none; background: rgba(255,255,255,0.04); }
.hint { font-size: 11px; color: #6b7280; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: center; }
.hint kbd { background: rgba(255,255,255,0.1); border-radius: 4px; padding: 1px 5px; font-family: monospace; }
.modeToggle { display: inline-flex; gap: 2px; padding: 2px; border-radius: 9px; background: rgba(255,255,255,0.05); }
.modeToggle button {
border: none; background: transparent; color: #9ca3af; cursor: pointer;
font-size: 11px; padding: 4px 10px; border-radius: 7px; transition: background 0.12s, color 0.12s;
}
.modeToggle button:hover { color: #e5e7eb; }
.modeToggle button.on { background: rgba(125,211,252,0.18); color: #7dd3fc; }
.app.overlay .modeToggle { display: none; }
.vadDot { width: 7px; height: 7px; border-radius: 50%; background: #34d399; box-shadow: 0 0 8px #34d399; animation: pulse 1s infinite; }
.side {
width: 320px; flex-shrink: 0; display: flex; flex-direction: column;
border: 1px solid rgba(255,255,255,0.07); border-radius: 14px; background: rgba(255,255,255,0.02);
}
.sideHead {
display: flex; justify-content: space-between; align-items: center; padding: 12px 16px;
border-bottom: 1px solid rgba(255,255,255,0.07);
font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #9ca3af;
}
.iconBtn { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 15px; }
.iconBtn:hover { color: #e5e7eb; }
.conv { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.empty { font-size: 12px; color: #9ca3af; line-height: 1.6; }
.msg { display: flex; flex-direction: column; gap: 4px; }
.msg.user { align-items: flex-end; }
.msg.assistant { align-items: flex-start; }
.who { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; padding: 0 4px; }
.bubble {
max-width: 88%; white-space: pre-wrap; word-break: break-word; border-radius: 16px;
padding: 8px 12px; font-size: 14px; line-height: 1.5;
}
.msg.user .bubble { background: rgba(125,211,252,0.14); border-bottom-right-radius: 4px; }
.msg.assistant .bubble { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.07); border-bottom-left-radius: 4px; }
.dots { display: inline-flex; gap: 4px; }
.dots i { width: 6px; height: 6px; border-radius: 50%; background: #6b7280; animation: pulse 1s infinite; }
.dots i:nth-child(2) { animation-delay: 0.15s; }
.dots i:nth-child(3) { animation-delay: 0.3s; }
@keyframes pulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
/* ---- Etappe 2: guter Chat (Markdown, Code-Bloecke, Links, Kopieren) ---- */
.bubble { position: relative; }
.md { white-space: normal; }
.md > :first-child { margin-top: 0; }
.md > :last-child { margin-bottom: 0; }
.md p { margin: 0 0 8px; }
.md ul, .md ol { margin: 4px 0 8px; padding-left: 20px; }
.md li { margin: 2px 0; }
.md strong { color: #f1f5f9; font-weight: 650; }
.md a.mdLink { color: #7dd3fc; text-decoration: underline; cursor: pointer; word-break: break-all; }
.md a.mdLink:hover { color: #bae6fd; }
.mdInlineCode {
font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 12.5px;
background: rgba(125,211,252,0.10); border: 1px solid rgba(125,211,252,0.18);
border-radius: 5px; padding: 1px 5px; word-break: break-word;
}
.codeBlock {
margin: 8px 0; border-radius: 10px; overflow: hidden;
border: 1px solid rgba(255,255,255,0.10); background: #0c0c14;
}
.codeBar {
display: flex; align-items: center; justify-content: space-between;
padding: 4px 8px 4px 12px; background: rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.08);
}
.codeLang { font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: #6b7280; }
.codeBlock pre { margin: 0; padding: 10px 12px; overflow-x: auto; }
.codeBlock code {
font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 12.5px; line-height: 1.5;
color: #e5e7eb; white-space: pre;
}
.copyBtn {
border: 1px solid rgba(125,211,252,0.3); background: rgba(125,211,252,0.10); color: #7dd3fc;
font-size: 11px; padding: 2px 8px; border-radius: 6px; cursor: pointer; -webkit-app-region: no-drag;
}
.copyBtn:hover { background: rgba(125,211,252,0.2); }
.msgCopy {
position: absolute; top: 4px; right: 4px; width: 22px; height: 22px; border: none;
background: rgba(255,255,255,0.06); color: #9ca3af; border-radius: 6px; cursor: pointer;
font-size: 12px; opacity: 0; transition: opacity 0.12s; -webkit-app-region: no-drag;
}
.msg.assistant .bubble:hover .msgCopy { opacity: 1; }
.msgCopy:hover { background: rgba(255,255,255,0.14); color: #e5e7eb; }