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:
Generated
+1469
-3
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,8 @@
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"three": "^0.169.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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() })
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user