diff --git a/client/lucy-desktop/src/main/index.ts b/client/lucy-desktop/src/main/index.ts index 94a318b..b982435 100644 --- a/client/lucy-desktop/src/main/index.ts +++ b/client/lucy-desktop/src/main/index.ts @@ -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 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) ---- const CAP = { width: 1280, height: 720 } ipcMain.handle("screen:capture", async () => { diff --git a/client/lucy-desktop/src/preload/index.ts b/client/lucy-desktop/src/preload/index.ts index d4d0299..db46276 100644 --- a/client/lucy-desktop/src/preload/index.ts +++ b/client/lucy-desktop/src/preload/index.ts @@ -13,6 +13,7 @@ contextBridge.exposeInMainWorld("lucy", { 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 + setClickThrough: (on: boolean) => ipcRenderer.invoke("win:setClickThrough", on), // Geistmodus (Overlay v2) // Events aus dem Main-Prozess (Tray/Hotkey/Modus) onMode: (cb: (m: "full" | "overlay") => void) => { const h = (_e: unknown, m: "full" | "overlay") => cb(m) diff --git a/client/lucy-desktop/src/renderer/src/App.tsx b/client/lucy-desktop/src/renderer/src/App.tsx index 3957738..e6e46e7 100644 --- a/client/lucy-desktop/src/renderer/src/App.tsx +++ b/client/lucy-desktop/src/renderer/src/App.tsx @@ -23,6 +23,7 @@ export default function App() { const endRef = useRef(null) const [mode, setMode] = useState<"full" | "overlay">("full") const [pinned, setPinned] = useState(false) + const [ghost, setGhost] = useState(false) // Geistmodus: Fenster durchklickbar (nur Overlay) useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }) }, [messages]) @@ -32,6 +33,22 @@ export default function App() { 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) useEffect(() => { const isField = (el: EventTarget | null) => @@ -68,6 +85,10 @@ export default function App() { const statusColor = status === "error" ? "#f87171" : speaking ? "#7dd3fc" : "#9ca3af" const overlay = mode === "overlay" 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 = ( + {overlay && } @@ -116,6 +138,24 @@ export default function App() { {!overlay &&
Die lokale Stimme wird vorbereitet — einen Moment, Commander.
} )} + {overlay && (ovLinks.length > 0 || ovCode) && ( +
+ {ovLinks.map((l, i) => ( +
+ 🔗 {shortUrl(l)} + + +
+ ))} + {ovCode && ( +
+ ⌘ Code-Schnipsel + + +
+ )} +
+ )} {overlay && (
{busy && } @@ -123,7 +163,7 @@ export default function App() {
)} -
+
{!overlay && (
{busy && } diff --git a/client/lucy-desktop/src/renderer/src/config.ts b/client/lucy-desktop/src/renderer/src/config.ts index df4fbb2..835f514 100644 --- a/client/lucy-desktop/src/renderer/src/config.ts +++ b/client/lucy-desktop/src/renderer/src/config.ts @@ -13,7 +13,10 @@ export const SYSTEM_PROMPT = "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." + "„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: , , " + + ", , oder . Er wird nicht angezeigt oder vorgelesen " + + "und steuert nur deinen Gesichtsausdruck — wĂ€hle ihn passend zum Inhalt deiner Antwort." declare global { interface Window { @@ -30,6 +33,7 @@ declare global { captureWindow: (id: string) => Promise captureByName: (q: string) => Promise<{ name: string; image: string } | null> openExternal: (url: string) => Promise + setClickThrough: (on: boolean) => Promise } } } diff --git a/client/lucy-desktop/src/renderer/src/lib/voice/useVoiceAgent.ts b/client/lucy-desktop/src/renderer/src/lib/voice/useVoiceAgent.ts new file mode 100644 index 0000000..5a965de --- /dev/null +++ b/client/lucy-desktop/src/renderer/src/lib/voice/useVoiceAgent.ts @@ -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 ( ...). Steuert NUR den +// Gesichtsausdruck -> wird vor Anzeige UND vor dem Vorlesen entfernt. Fallback = Sentiment-Heuristik. +const EMO_TAG = //i +function stripEmoTag(s: string): string { return s.replace(//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 { + 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 { + 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("warming") + const [ready, setReady] = useState(false) + const [messages, setMessages] = useState([]) + const [error, setError] = useState(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("neutral") + const queueRef = useRef(null) + const sessionId = useRef(newSessionId()) + // Live-Status fuer die Eingabe-Sperre (ohne pressStart-Closure neu zu binden) + const statusRef = useRef("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 } +} diff --git a/client/lucy-desktop/src/renderer/src/styles.css b/client/lucy-desktop/src/renderer/src/styles.css index 8d60615..6bc079d 100644 --- a/client/lucy-desktop/src/renderer/src/styles.css +++ b/client/lucy-desktop/src/renderer/src/styles.css @@ -114,6 +114,26 @@ body { 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 { 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);