Lucy v2 (Etappe 3+4): Mimik nach Bedeutung + Overlay v2

Etappe 3 (natuerlich reagieren, App-Teil):
- optionaler <emo:...>-Tag von Hermes -> Avatar-Mimik nach Inhalt (Fallback: Sentiment-Heuristik)
- Tag wird aus Anzeige UND Sprachausgabe entfernt (stripEmoTag)

Etappe 4 (aus der App ausbrechen):
- Geistmodus: Overlay durchklickbar (setIgnoreMouseEvents+forward), AUSSER ueber .clickable-Zonen
  -> Lucy sitzt auf dem Desktop, Arbeit dahinter laeuft weiter, Bedienelemente bleiben klickbar
- Overlay-Blasen: Links (oeffnen/kopieren) + Code (kopieren/in App ansehen) griffbereit ohne Chat-Panel
- neue IPC win:setClickThrough + preload setClickThrough

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-30 19:20:39 +02:00
parent 16f4683859
commit feba73a11a
6 changed files with 389 additions and 3 deletions
+7
View File
@@ -114,6 +114,13 @@ ipcMain.handle("win:togglePin", () => { pinned = !pinned; win?.setAlwaysOnTop(pi
// Links im echten System-Browser oeffnen (nicht im App-Fenster); nur http(s) erlauben // 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) }) ipcMain.handle("open:external", (_e, url: string) => { if (/^https?:\/\//i.test(url)) void shell.openExternal(url) })
// Geistmodus (Overlay v2): Fenster durchklickbar machen -> Lucy "sitzt" auf dem Desktop, du arbeitest
// dahinter weiter. forward:true liefert weiter Hover-Events an den Renderer, damit er ueber Lucys
// Bedienelementen kurzzeitig wieder klickbar schalten kann.
ipcMain.handle("win:setClickThrough", (_e, on: boolean) => {
win?.setIgnoreMouseEvents(!!on, { forward: true }); return !!on
})
// ---- Bildschirm-Sicht (Lucys Augen) ---- // ---- Bildschirm-Sicht (Lucys Augen) ----
const CAP = { width: 1280, height: 720 } const CAP = { width: 1280, height: 720 }
ipcMain.handle("screen:capture", async () => { ipcMain.handle("screen:capture", async () => {
+1
View File
@@ -13,6 +13,7 @@ contextBridge.exposeInMainWorld("lucy", {
captureWindow: (id: string) => ipcRenderer.invoke("screen:captureWindow", id), captureWindow: (id: string) => ipcRenderer.invoke("screen:captureWindow", id),
captureByName: (q: string) => ipcRenderer.invoke("screen:captureByName", q), captureByName: (q: string) => ipcRenderer.invoke("screen:captureByName", q),
openExternal: (url: string) => ipcRenderer.invoke("open:external", url), // Link im System-Browser openExternal: (url: string) => ipcRenderer.invoke("open:external", url), // Link im System-Browser
setClickThrough: (on: boolean) => ipcRenderer.invoke("win:setClickThrough", on), // Geistmodus (Overlay v2)
// Events aus dem Main-Prozess (Tray/Hotkey/Modus) // Events aus dem Main-Prozess (Tray/Hotkey/Modus)
onMode: (cb: (m: "full" | "overlay") => void) => { onMode: (cb: (m: "full" | "overlay") => void) => {
const h = (_e: unknown, m: "full" | "overlay") => cb(m) const h = (_e: unknown, m: "full" | "overlay") => cb(m)
+42 -2
View File
@@ -23,6 +23,7 @@ export default function App() {
const endRef = useRef<HTMLDivElement>(null) const endRef = useRef<HTMLDivElement>(null)
const [mode, setMode] = useState<"full" | "overlay">("full") const [mode, setMode] = useState<"full" | "overlay">("full")
const [pinned, setPinned] = useState(false) const [pinned, setPinned] = useState(false)
const [ghost, setGhost] = useState(false) // Geistmodus: Fenster durchklickbar (nur Overlay)
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }) }, [messages]) useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }) }, [messages])
@@ -32,6 +33,22 @@ export default function App() {
return window.lucy?.onMode(setMode) return window.lucy?.onMode(setMode)
}, []) }, [])
// Geistmodus (Overlay v2): Fenster durchklickbar, AUSSER der Zeiger ist ueber Lucys Bedienelementen
// (.clickable). So sitzt sie auf dem Desktop, du arbeitest dahinter weiter, kannst sie aber bedienen.
useEffect(() => {
if (!(ghost && mode === "overlay")) { void window.lucy?.setClickThrough(false); return }
let ignoring = false
const set = (ig: boolean) => { if (ig !== ignoring) { ignoring = ig; void window.lucy?.setClickThrough(ig) } }
set(true)
const onMove = (e: MouseEvent) => {
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null
set(!(el && el.closest(".clickable")))
}
window.addEventListener("mousemove", onMove)
return () => { window.removeEventListener("mousemove", onMove); void window.lucy?.setClickThrough(false) }
}, [ghost, mode])
useEffect(() => { if (mode !== "overlay") setGhost(false) }, [mode]) // Geistmodus nur im Overlay
// Leertaste = Push-to-talk (halten) // Leertaste = Push-to-talk (halten)
useEffect(() => { useEffect(() => {
const isField = (el: EventTarget | null) => const isField = (el: EventTarget | null) =>
@@ -68,6 +85,10 @@ export default function App() {
const statusColor = status === "error" ? "#f87171" : speaking ? "#7dd3fc" : "#9ca3af" const statusColor = status === "error" ? "#f87171" : speaking ? "#7dd3fc" : "#9ca3af"
const overlay = mode === "overlay" const overlay = mode === "overlay"
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.text?.trim() || "" const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.text?.trim() || ""
// Overlay-Blasen: Links/Code aus der letzten Antwort griffbereit (im Overlay gibt es kein Chat-Panel)
const ovLinks = overlay ? Array.from(new Set(lastAssistant.match(/https?:\/\/[^\s)]+/g) || [])).slice(0, 3) : []
const ovCode = overlay ? (lastAssistant.match(/```[\w]*\n?([\s\S]*?)```/)?.[1] || "").trim() : ""
const shortUrl = (u: string) => u.replace(/^https?:\/\/(www\.)?/, "")
const micBtn = ( const micBtn = (
<button <button
@@ -87,7 +108,7 @@ export default function App() {
return ( return (
<div className={"app " + mode}> <div className={"app " + mode}>
<div className="titlebar"> <div className="titlebar clickable">
<div className="drag"> <div className="drag">
<span className="dot" style={{ background: statusColor }} /> <span className="dot" style={{ background: statusColor }} />
<span className="ttl">Lucy</span> <span className="ttl">Lucy</span>
@@ -95,6 +116,7 @@ export default function App() {
</div> </div>
<div className="winbtns"> <div className="winbtns">
<button className="wb" onClick={() => void lookAtScreen()} title="Lucy auf den Bildschirm schauen lassen" disabled={!ready}>👁</button> <button className="wb" onClick={() => void lookAtScreen()} title="Lucy auf den Bildschirm schauen lassen" disabled={!ready}>👁</button>
{overlay && <button className="wb" onClick={() => setGhost((g) => !g)} title="Geistmodus: durchklickbar" data-on={ghost}>👻</button>}
<button className="wb" onClick={togglePin} title="Immer im Vordergrund" data-on={pinned}>📌</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={toggleMode} title={overlay ? "Vollfenster" : "Overlay-Modus"}>{overlay ? "▣" : "▢"}</button>
<button className="wb" onClick={() => window.lucy?.minimize()} title="Minimieren"></button> <button className="wb" onClick={() => window.lucy?.minimize()} title="Minimieren"></button>
@@ -116,6 +138,24 @@ export default function App() {
{!overlay && <div className="warmSub">Die lokale Stimme wird vorbereitet einen Moment, Commander.</div>} {!overlay && <div className="warmSub">Die lokale Stimme wird vorbereitet einen Moment, Commander.</div>}
</div> </div>
)} )}
{overlay && (ovLinks.length > 0 || ovCode) && (
<div className="overlayBubbles clickable">
{ovLinks.map((l, i) => (
<div className="bubbleChip" key={"l" + i}>
<span className="chipTxt" title={l}>🔗 {shortUrl(l)}</span>
<button onClick={() => void window.lucy?.openExternal(l)} title="Im Browser öffnen"></button>
<button onClick={() => { void navigator.clipboard.writeText(l).catch(() => {}) }} title="Link kopieren"></button>
</div>
))}
{ovCode && (
<div className="bubbleChip">
<span className="chipTxt"> Code-Schnipsel</span>
<button onClick={() => { void navigator.clipboard.writeText(ovCode).catch(() => {}) }} title="Code kopieren"></button>
<button onClick={toggleMode} title="In der App ansehen"></button>
</div>
)}
</div>
)}
{overlay && ( {overlay && (
<div className="overlayCaption"> <div className="overlayCaption">
{busy && <span className="spinner" />} {busy && <span className="spinner" />}
@@ -123,7 +163,7 @@ export default function App() {
</div> </div>
)} )}
</div> </div>
<div className="controls"> <div className="controls clickable">
{!overlay && ( {!overlay && (
<div className="status" style={{ color: statusColor }}> <div className="status" style={{ color: statusColor }}>
{busy && <span className="spinner" />} {busy && <span className="spinner" />}
@@ -13,7 +13,10 @@ export const SYSTEM_PROMPT =
"sprich dann nur eine kurze Einleitung dazu (z. B. „Hier ist das Skript, Commander.“). " + "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. " + "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“, " + "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." "„25 Grad“) — schreibe Ziffern NIEMALS als Wörter aus und interpretiere sie nicht als Komma-/Dezimalzahlen. " +
"Du kannst optional GANZ AM ENDE deiner Antwort einen Stimmungs-Tag anhängen: <emo:happy>, <emo:sad>, " +
"<emo:angry>, <emo:surprised>, <emo:relaxed> oder <emo:neutral>. Er wird nicht angezeigt oder vorgelesen " +
"und steuert nur deinen Gesichtsausdruck — wähle ihn passend zum Inhalt deiner Antwort."
declare global { declare global {
interface Window { interface Window {
@@ -30,6 +33,7 @@ declare global {
captureWindow: (id: string) => Promise<string | null> captureWindow: (id: string) => Promise<string | null>
captureByName: (q: string) => Promise<{ name: string; image: string } | null> captureByName: (q: string) => Promise<{ name: string; image: string } | null>
openExternal: (url: string) => Promise<void> openExternal: (url: string) => Promise<void>
setClickThrough: (on: boolean) => Promise<boolean>
} }
} }
} }
@@ -0,0 +1,314 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { usePushToTalk } from "./usePushToTalk"
import { useVAD } from "./useVAD"
import { AudioQueue } from "./audio"
import { sentimentToEmotion, type Emotion } from "./sentiment"
import { BOX_URL, TTS_URL, SYSTEM_PROMPT } from "../../config"
// Voll-Duplex-Schleife: PTT -> Box /stt -> Box /chat (SSE Hermes) -> CHUNKS -> lokal /tts -> AudioQueue.
// Hirn+STT = Box (wie WebUI), Stimme = lokal (pocket-tts, CPU). Antwort wird in groessere Bloecke
// gebuendelt (>=MIN_CHUNK Zeichen, kein Mini-Satz-Babble) und satzweise frueh gesprochen.
// Warm-Gate beim Start: Bedienung erst frei, wenn der TTS-Dienst antwortet (Modell geladen).
export type VoiceStatus = "warming" | "idle" | "listening" | "transcribing" | "thinking" | "speaking" | "error"
export interface ChatMsg { role: "user" | "assistant"; text: string }
// Hermes schreibt manchmal ASCII-Umlaute (ue/ae/oe/ss) statt ä/ö/ü/ß -> pocket-tts liest die falsch vor.
// Gezielt häufige UMLAUT-Stämme zurückwandeln. KONSERVATIV: nur Muster, bei denen ASCII fast immer ein
// Umlaut ist (echte "ue"-Wörter wie aktuell/neue/Quelle bleiben unangetastet — die sind hier NICHT gelistet).
const UMLAUT_FIX: [RegExp, string][] = [
[/ueber/gi, "über"], [/\bfuer\b/gi, "für"], [/natuerlich/gi, "natürlich"], [/zurueck/gi, "zurück"],
[/unterstuetz/gi, "unterstütz"], [/ueberpruef/gi, "überprüf"], [/\bpruef/gi, "prüf"], [/gefuehl/gi, "gefühl"],
[/\bfuehl/gi, "fühl"], [/\bfuehr/gi, "führ"], [/\bfuenf/gi, "fünf"], [/\bgruen/gi, "grün"], [/\bfrueh/gi, "früh"],
[/\bglueck/gi, "glück"], [/\bstueck/gi, "stück"], [/gemuetlich/gi, "gemütlich"], [/verfueg/gi, "verfüg"],
[/\bmoecht/gi, "möcht"], [/\bkoenn/gi, "könn"], [/\bwuerd/gi, "würd"], [/\bmuess/gi, "müss"], [/\bduerf/gi, "dürf"],
[/\bhaett/gi, "hätt"], [/\bwaer/gi, "wär"], [/\bspaet/gi, "spät"],
[/\btaeglich/gi, "täglich"], [/\bnaechst/gi, "nächst"], [/\baehnlich/gi, "ähnlich"], [/waehrend/gi, "während"],
[/\bwaehl/gi, "wähl"], [/erklaer/gi, "erklär"], [/\bschoen/gi, "schön"], [/\bgroess/gi, "größ"], [/\bhoer/gi, "hör"],
[/\boeffn/gi, "öffn"], [/\bboese/gi, "böse"], [/\bloesch/gi, "lösch"], [/\bstoer/gi, "stör"], [/koennt/gi, "könnt"],
// erweitert (30.06.): weitere häufige Stämme + sicheres -ität-Suffix. KONSERVATIV gewählt;
// der TTS-Server (_fix_umlauts) hat zusätzlich ein Netz. neu/aktuell/Steuer/Feuer bleiben unberührt.
[/itaet/gi, "ität"],
[/\bmoeglich/gi, "möglich"], [/gespraech/gi, "gespräch"], [/\bmaerz/gi, "märz"],
[/geschaeft/gi, "geschäft"], [/gefaehr/gi, "gefähr"], [/\bhaeng/gi, "häng"], [/\blaeng/gi, "läng"],
[/\bmaenner/gi, "männer"], [/\bmaedchen/gi, "mädchen"], [/\bvaeter/gi, "väter"], [/\btraeum/gi, "träum"],
[/\bsaetz/gi, "sätz"], [/\bplaetz/gi, "plätz"], [/\bkaelte/gi, "kälte"], [/\bzaehl/gi, "zähl"],
[/\baerg/gi, "ärg"], [/\baerzt/gi, "ärzt"], [/aeusser/gi, "äußer"],
[/\btuer/gi, "tür"], [/\bkueche/gi, "küche"], [/buech/gi, "büch"], [/\bbuero/gi, "büro"], [/\bbuerg/gi, "bürg"],
[/\bsued/gi, "süd"], [/schueler/gi, "schüler"], [/uebung/gi, "übung"], [/kuenstl/gi, "künstl"],
[/\bmuede/gi, "müde"], [/\bmuell/gi, "müll"], [/\bdrueck/gi, "drück"], [/stuetz/gi, "stütz"],
[/\bmoebel/gi, "möbel"], [/loes/gi, "lös"], [/voellig/gi, "völlig"], [/zwoelf/gi, "zwölf"],
[/\bkoenig/gi, "könig"], [/\bhoeh/gi, "höh"], [/\bdoerf/gi, "dörf"], [/\bwoert/gi, "wört"],
]
export function restoreUmlauts(s: string): string {
let out = s
for (const [re, rep] of UMLAUT_FIX) out = out.replace(re, rep as string)
return out
}
// Optionaler Stimmungs-Tag, den Hermes ans Ende haengen kann (<emo:happy> ...). Steuert NUR den
// Gesichtsausdruck -> wird vor Anzeige UND vor dem Vorlesen entfernt. Fallback = Sentiment-Heuristik.
const EMO_TAG = /<emo:(happy|sad|angry|surprised|relaxed|neutral)>/i
function stripEmoTag(s: string): string { return s.replace(/<emo:\w+>/gi, "").trimEnd() }
function cleanForTTS(s: string): string {
return restoreUmlauts(s)
.replace(/```[\s\S]*?```/g, " ").replace(/`([^`]*)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/https?:\/\/\S+/gi, " ").replace(/www\.\S+/gi, " ").replace(/\b\S+@\S+\.\S+\b/g, " ")
.replace(/[*_#>~|`]+/g, " ").replace(/^\s*[-•·]\s+/gm, " ")
.replace(/\s*&\s*/g, " und ")
.replace(/(\d)\s*%/g, "$1 Prozent").replace(/%/g, " Prozent ")
.replace(/(\d)\s*°\s*C?/g, "$1 Grad").replace(/°/g, " Grad ")
.replace(/\s*=\s*/g, " gleich ").replace(/\s*\/\s*/g, " ")
.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "")
.replace(/\s+/g, " ").trim()
}
// FRISCHE Session pro App-Start (NICHT in localStorage persistieren). Sonst wächst der Hermes-Verlauf
// über alle Starts hinweg unbegrenzt (gesehen: 97k+ Tokens) -> Degeneration/Wiederhol-Schleifen.
// Dauerhaftes Gedächtnis liegt ohnehin in Mem0 (sessionunabhängig) -> Continuity bleibt erhalten.
function newSessionId(): string {
return "lucy-" + Math.random().toString(36).slice(2) + Date.now().toString(36)
}
async function ttsToBuffer(text: string): Promise<ArrayBuffer> {
const r = await fetch(`${TTS_URL}/tts`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }),
})
if (!r.ok) throw new Error(`TTS ${r.status}`)
return r.arrayBuffer()
}
// Streamt die Stimme (PCM16) und spielt sie lueckenlos ab, sobald die ersten Chunks da sind
// (Time-to-first-audio ~1s statt der ganzen Generierung). Liefert true, wenn etwas gesprochen wurde.
async function ttsStreamPlay(queue: AudioQueue, text: string): Promise<boolean> {
const r = await fetch(`${TTS_URL}/tts/stream`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }),
})
if (!r.ok || !r.body) throw new Error(`TTS ${r.status}`)
const sr = Number(r.headers.get("X-Sample-Rate") || "24000")
await queue.playPcmStream(r.body, sr)
return true
}
// Bildschirm-Sicht: erkennt im Gesagten die Bitte, auf den Schirm zu schauen
const VISION_RX = /\b(schau|sieh|siehst|guck|guckst|zeig|bildschirm|screen|monitor|fenster|erkennst?|lies (mir|das)|was (steht|ist) (da|hier|auf)|auf meinem (bildschirm|schirm|screen))\b/i
function extractWindowName(text: string): string | null {
// grobe Heuristik: Wort/Phrase nach 'fenster|app|programm|in|auf|bei|im'
const m = text.match(/(?:fenster|app|programm|in|auf|bei|im)\s+([A-Za-zÄÖÜäöü][\w.+\- ]{2,28})/i)
if (!m) return null
return m[1].replace(/\b(an|fenster|programm|app|bildschirm|schirm|screen|siehst|du)\b/gi, "").trim() || null
}
export function useVoiceAgent() {
const [status, setStatus] = useState<VoiceStatus>("warming")
const [ready, setReady] = useState(false)
const [messages, setMessages] = useState<ChatMsg[]>([])
const [error, setError] = useState<string | null>(null)
const [inputMode, setInputModeState] = useState<"ptt" | "vad">(
() => (localStorage.getItem("lucy_input_mode") === "vad" ? "vad" : "ptt"))
const [vadListening, setVadListening] = useState(false)
const setInputMode = useCallback((m: "ptt" | "vad") => {
localStorage.setItem("lucy_input_mode", m); setInputModeState(m)
}, [])
const audioLevel = useRef({ current: 0 })
const emotion = useRef<Emotion>("neutral")
const queueRef = useRef<AudioQueue | null>(null)
const sessionId = useRef<string>(newSessionId())
// Live-Status fuer die Eingabe-Sperre (ohne pressStart-Closure neu zu binden)
const statusRef = useRef<VoiceStatus>("warming")
useEffect(() => { statusRef.current = status }, [status])
const ensureQueue = useCallback(() => {
if (!queueRef.current) {
const q = new AudioQueue()
q.onSpeaking = (sp) => setStatus((s) => (sp ? "speaking" : s === "speaking" ? "idle" : s))
queueRef.current = q
audioLevel.current = q.level
}
return queueRef.current
}, [])
// --- Warm-Gate: warten bis der lokale TTS-Dienst geladen ist, bevor die Bedienung frei wird ---
// pocket_server serviert /health erst, wenn das Modell fertig geladen ist (FastAPI-lifespan).
// Der Main-Prozess spawnt den Server beim App-Start; hier nur pollen.
useEffect(() => {
let cancelled = false
;(async () => {
for (let i = 0; i < 120 && !cancelled; i++) {
try {
const h = await (await fetch(`${TTS_URL}/health`)).json()
if (h.status === "ok") break
} catch { /* Dienst startet evtl. noch (Spawn + Modell-Load) */ }
await new Promise((r) => setTimeout(r, 1000))
}
if (cancelled) return
// ein Aufwaerm-Satz (primt alle lazy Pfade); Audio verwerfen
try { await ttsToBuffer("Alles bereit, Commander.") } catch { /* */ }
if (cancelled) return
setReady(true); setStatus("idle")
})()
return () => { cancelled = true }
}, [])
// Ein kompletter Turn: User-Text (+ optional Bildschirm-Bilder, Multi-Monitor) -> Hermes (SSE) -> Stimme.
const runTurn = useCallback(async (userText: string, images?: string[]) => {
const queue = ensureQueue()
setStatus("thinking")
let assistant = ""
setMessages((m) => [...m, { role: "assistant", text: "" }])
let spokeAny = false, ttsFailed = false
try {
const r = await fetch(`${BOX_URL}/api/voice/chat`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: userText, session_id: sessionId.current, system: SYSTEM_PROMPT, images: images || [] }),
})
if (!r.ok || !r.body) throw new Error(`Agent ${r.status}`)
const reader = r.body.getReader()
const dec = new TextDecoder()
let sse = ""
for (;;) {
const { done, value } = await reader.read()
if (done) break
sse += dec.decode(value, { stream: true })
const events = sse.split("\n\n"); sse = events.pop() || ""
for (const ev of events) {
const lines = ev.split("\n")
// Hermes mischt Tool-Fortschritt (event: hermes.tool.progress) in den Stream. Das sind KEINE
// Chat-Chunks -> nicht als solche parsen (ein Tool-Hickup mit error-Objekt darf die Antwort
// nicht abbrechen). Optional koennte man hier Tool-Status anzeigen.
const evType = lines.find((l) => l.startsWith("event:"))?.slice(6).trim()
if (evType && evType.startsWith("hermes.")) continue // Tool-Fortschritt (Status bleibt 'thinking')
const line = lines.find((l) => l.startsWith("data:"))
if (!line) continue
const data = line.slice(5).trim()
if (data === "[DONE]") continue
let json: any
try { json = JSON.parse(data) } catch { continue }
if (json.error) {
const msg = typeof json.error === "string"
? json.error
: (json.error?.message || JSON.stringify(json.error))
throw new Error(msg)
}
const delta = json.choices?.[0]?.delta?.content || ""
if (!delta) continue
assistant += delta
emotion.current = sentimentToEmotion(assistant)
setMessages((m) => { const c = m.slice(); c[c.length - 1] = { role: "assistant", text: stripEmoTag(restoreUmlauts(assistant)) }; return c })
}
}
if (!assistant.trim()) { setStatus("idle"); return }
// Defensiv: falls Hermes mal seinen internen Umschlag durchreicht (Mid-Turn), NICHT vorlesen.
if (assistant.includes("OUT-OF-BAND USER MESSAGE")) {
setStatus("error")
setError("Nachricht kam mitten im Turn an — bitte warten, bis Lucy fertig ist, dann erneut fragen.")
return
}
// Stimmungs-Tag (falls vorhanden) -> Avatar-Mimik nach BEDEUTUNG (sonst bleibt Sentiment-Heuristik).
const emo = assistant.match(EMO_TAG)
if (emo) emotion.current = emo[1].toLowerCase() as Emotion
// Ganze Antwort STREAMEN -> erstes Audio nach ~1s, lueckenlos (pocket-tts splittet intern in
// Saetze mit natuerlicher Prosodie). Kein Chunking noetig (real-time RTF ~0.74).
const spoken = cleanForTTS(stripEmoTag(assistant))
if (spoken) {
try { await ttsStreamPlay(queue, spoken); spokeAny = true }
catch (e) { ttsFailed = true; console.error("TTS-Fehler:", e) }
}
if (!spokeAny) {
setStatus("error")
setError(ttsFailed ? "Sprachausgabe fehlgeschlagen — laeuft der lokale TTS-Dienst?" : "Keine Sprachausgabe erzeugt.")
}
} catch (e: any) {
setStatus("error"); setError(`Agent-Antwort fehlgeschlagen: ${e.message}`)
}
}, [ensureQueue])
// Bildschirm-Sicht: Bild(er) erfassen (gezieltes Fenster per Name, sonst ALLE Monitore)
const captureForVision = useCallback(async (userText: string): Promise<{ images: string[]; label: string }> => {
if (!window.lucy) return { images: [], label: "" }
try {
const name = extractWindowName(userText)
if (name) {
const hit = await window.lucy.captureByName(name)
if (hit?.image) return { images: [hit.image], label: ` 👁 ${hit.name}` }
}
const shots = (await window.lucy.captureScreen()).filter(Boolean)
if (shots.length) return { images: shots, label: shots.length > 1 ? ` 👁 ${shots.length} Bildschirme` : " 👁 Bildschirm" }
} catch (e) { console.error("Bildschirm-Capture:", e) }
return { images: [], label: "" }
}, [])
const handleAudio = useCallback(async (blob: Blob) => {
setError(null)
ensureQueue().clear()
setStatus("transcribing")
let userText = ""
try {
const fd = new FormData()
fd.append("audio", blob, "rec.webm")
const r = await fetch(`${BOX_URL}/api/voice/stt`, { method: "POST", body: fd })
if (!r.ok) throw new Error(`STT ${r.status}`)
userText = (await r.json()).text?.trim() || ""
} catch (e: any) {
setStatus("error"); setError(`Spracherkennung fehlgeschlagen: ${e.message}`); return
}
if (!userText) { setStatus("idle"); return }
// Bittet der Nutzer Lucy, auf den Bildschirm zu schauen? -> Bild(er) erfassen + mitschicken.
const vis = VISION_RX.test(userText) ? await captureForVision(userText) : { images: [] as string[], label: "" }
setMessages((m) => [...m, { role: "user", text: userText + vis.label }])
await runTurn(userText, vis.images)
}, [ensureQueue, runTurn, captureForVision])
// Auge-Button: Lucy aktiv auf den Bildschirm schauen lassen (ohne Sprachbefehl)
const lookAtScreen = useCallback(async () => {
if (!ready || statusRef.current === "thinking" || statusRef.current === "transcribing") return
setError(null); ensureQueue().clear()
let images: string[] = []
try { images = ((await window.lucy?.captureScreen()) || []).filter(Boolean) } catch { /* */ }
const prompt = "Schau auf meinen Bildschirm und sag mir kurz, was du darauf siehst."
const label = images.length > 1 ? ` 👁 ${images.length} Bildschirme` : " 👁 Bildschirm"
setMessages((m) => [...m, { role: "user", text: prompt + label }])
await runTurn(prompt, images)
}, [ensureQueue, runTurn, ready])
const { recording, start, stop } = usePushToTalk(handleAudio)
// Freisprech-VAD: nur im vad-Modus, und nur wenn Lucy IDLE ist (paused sonst) -> keine Rückkopplung,
// keine Mid-Turn-Nachricht. ensureQueue() vor handleAudio, damit Barge-in/Audio bereit ist.
useVAD({
enabled: ready && inputMode === "vad",
paused: status !== "idle",
onUtterance: (blob) => { ensureQueue(); void handleAudio(blob) },
onListening: setVadListening,
})
const pressStart = useCallback(() => {
if (!ready) return
// Waehrend das Gehirn arbeitet (STT/Hermes-Turn laeuft) KEINEN neuen Turn starten: eine zweite
// Nachricht mitten im Turn liefert Hermes als "OUT-OF-BAND" aus -> Modell echot den Umschlag.
// Barge-in beim SPRECHEN bleibt erlaubt (Hermes-Turn ist dann fertig) -> queue.clear() in handleAudio.
if (statusRef.current === "transcribing" || statusRef.current === "thinking") return
ensureQueue()
setStatus("listening")
void start()
}, [ready, ensureQueue, start])
const pressEnd = useCallback(() => { stop() }, [stop])
const reset = useCallback(() => {
queueRef.current?.clear()
setMessages([]); setError(null); setStatus("idle")
sessionId.current = newSessionId() // frischer Gesprächsfaden (Mem0-Gedächtnis bleibt)
}, [])
useEffect(() => {
if (!recording && status === "listening") setStatus("transcribing")
}, [recording, status])
return { status, ready, messages, error, recording, audioLevel, emotion, pressStart, pressEnd, reset,
inputMode, setInputMode, vadListening, lookAtScreen }
}
@@ -114,6 +114,26 @@ body {
align-items: center; font-size: 12px; color: #cbd5e1; text-shadow: 0 1px 4px #000; pointer-events: none; align-items: center; font-size: 12px; color: #cbd5e1; text-shadow: 0 1px 4px #000; pointer-events: none;
} }
/* --- Overlay v2: griffbereite Blasen fuer Links/Code (behindern nicht, jederzeit ignorierbar) --- */
.overlayBubbles {
position: absolute; left: 6px; right: 6px; bottom: 28px; z-index: 4;
display: flex; flex-direction: column; gap: 5px; align-items: stretch;
}
.bubbleChip {
display: flex; align-items: center; gap: 4px; padding: 3px 4px 3px 9px; border-radius: 9px;
background: rgba(12,12,20,0.82); border: 1px solid rgba(125,211,252,0.25);
backdrop-filter: blur(4px); box-shadow: 0 3px 12px rgba(0,0,0,0.4);
}
.bubbleChip .chipTxt {
flex: 1; min-width: 0; font-size: 11px; color: #cbd5e1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.bubbleChip button {
flex-shrink: 0; width: 22px; height: 20px; border: none; border-radius: 6px; cursor: pointer;
background: rgba(125,211,252,0.14); color: #7dd3fc; font-size: 11px; -webkit-app-region: no-drag;
}
.bubbleChip button:hover { background: rgba(125,211,252,0.28); }
.controls { .controls {
flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 12px; 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); padding: 16px; border-top: 1px solid rgba(255,255,255,0.07); background: rgba(0,0,0,0.25);