10 Commits

Author SHA1 Message Date
Hitonabi f88c2a95c1 Report: VL-30B-Bench (90-92 t/s, Vision-Upgrade-Kandidat) + gpt-oss-Handoff dokumentiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:01:15 +02:00
Hitonabi ed55612cfa Report: Lemonade-Verdikt — nicht als Unterbau; GPU-Klon besser mit llama.cpp-Vulkan + llama-swap (Box-Stack)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:59:52 +02:00
Hitonabi 47ade040e6 gitignore: F5-Eval-venv (lokal, mehrere GB)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:58:44 +02:00
Hitonabi 852e723cc8 Report: Nachtrag 2 — P2/P3-Vollausbau dokumentiert (Turn-Detection, F5-Finale, Electron 43, Mem0 v3, Kandidaten, Lemonade)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:57:44 +02:00
Hitonabi f90e5f4519 Lucy: Electron 33 -> 43 (Chromium 150, Node 24) — Boot-Smoke-Test gruen (Review P2-13b)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:31:42 +02:00
Hitonabi c3d1cc87e2 Lucy: useVoiceAgent entflochten (Review P2-13a)
textPipeline.ts (Umlaute/TTS-Cleaning/Emo-Tags/Session-Id), visionIntent.ts, perf.ts —
alles pure Funktionen, einzeln testbar; der Hook (397->305 Z) orchestriert nur noch.
Verhalten unveraendert, tsc gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:29:10 +02:00
Hitonabi 88c2659a9c P3: llama-swap capabilities deklariert + Plugin lernt Tool-Namen (Hermes-0.18-API)
- deploy-Config: capabilities (in/out/tools/context) je Modell — /v1/models informiert
  Clients korrekt; context = nutzbarer Kontext pro Request (c/parallel)
- mc2-memory: sync_turn nutzt messages aus Hermes >=0.18 — nur Tool-NAMEN (Ergebnisse
  bleiben draussen: untrusted/Poisoning-Vektor); deployt, Postcheck gruen
- Mem0 v3-Algorithmus auf der Box verifiziert (BM25/Entity/Hybrid in 2.0.8 aktiv)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:25:46 +02:00
Hitonabi 6f7498a956 Turn-Detection: Smart Turn v3 semantisch (Review P2-11) — live verifiziert
- voice_service /turn: smart-turn-v3.2 (8MB ONNX, ~110ms warm inkl. Features); Audio muss
  LINKS gepadded werden (rechts-Padding -> konstant 'complete', live diagnostiziert) und
  der Output ist empirisch P(unfertig) — Doku sagt es andersherum, Messung gewinnt
- backend /api/voice/turn: Proxy mit fail-open (Turn-Check ist Optimierung, kein Blocker)
- useVAD: Semantik-Hold — bei 'incomplete' bis 1,8s auf Fortsetzung warten und anhaengen,
  statt mitten im Gedanken zu antworten; Deckel 30s; fail-open bei Netzfehlern
- Verifiziert: fertig=true(0.74), mitten-im-Wort=false(0.04), via :9001 ok

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:21:45 +02:00
Hitonabi a1cea1a1ea deploy-Config: Kommentar aus dem cmd-Literalblock heraus (waere Teil des Kommandos gewesen)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:09:29 +02:00
Hitonabi 60765469aa Frontend: globale Error Boundary (Review F4) + dist-Rebuild
JS-Fehler in einer View fuehrten zum weissen Screen ohne Meldung; jetzt Fehleranzeige
mit Neu-laden-Knopf am App-Root (GraphView behaelt seine eigene Boundary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:08:25 +02:00
21 changed files with 490 additions and 920 deletions
+1
View File
@@ -41,3 +41,4 @@ client/lucy-f5/*.onnx
client/lucy-f5/*.safetensors
client/lucy-f5/*.wav
client/lucy-f5/*.zip
client/lucy-f5/venv/
+19
View File
@@ -137,6 +137,25 @@ async def voice_stt(audio: UploadFile = File(...), language: str = Form(default=
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
@router.post("/voice/turn")
async def voice_turn(audio: UploadFile = File(...)) -> dict:
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
with Timer("turn"):
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
log.warning("Turn-Check fehlgeschlagen: %s", exc)
return {"complete": True, "probability": 1.0, "engine": "fallback"}
@router.post("/voice/reference")
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
+65 -752
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -27,7 +27,7 @@
"@types/react-dom": "^18.3.1",
"@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.4",
"electron": "^33.2.1",
"electron": "^43.0.0",
"electron-vite": "^3.0.0",
"typescript": "^5.6.3",
"vite": "^6.0.3"
@@ -0,0 +1,8 @@
// Perf-Diagnose (Client): Zeit pro Stufe ab „Mikro losgelassen". Opt-in via localStorage lucy_perf=1.
// NUR Console (kein POST an den pocket_server — der generiert Audio; POSTs mittendrin
// verursachten hakelige Sprachausgabe). (Aus useVoiceAgent extrahiert, Review P2-13a.)
export const PERF = typeof localStorage !== "undefined" && localStorage.getItem("lucy_perf") === "1"
export function plogSend(msg: string) { if (PERF) console.log(`[lucy-perf] ${msg}`) }
export function plog(label: string, ms: number) { plogSend(`${label} = ${Math.round(ms)}ms`) }
@@ -0,0 +1,74 @@
// Pure Text-Verarbeitung für den Voice-Loop (aus useVoiceAgent extrahiert, Review P2-13a).
// Alles hier ist zustandslos und einzeln testbar — der Hook orchestriert nur noch.
// 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.
export const EMO_TAG = /<emo:(happy|sad|angry|surprised|relaxed|neutral)>/i
export function stripEmoTag(s: string): string { return s.replace(/<emo:\w+>/gi, "").trimEnd() }
// Hermes hängt bei selbst erzeugten Medien (z.B. eigener Screenshot via pc-control) einen Roh-Token
// „MEDIA:<datei>" an. Das ist weder Sprech- noch Anzeigetext -> überall entfernen (bis Medien echt
// gerendert werden). Betrifft auch Lucys eigene Bildschirm-Sicht, die den Screenshot ohnehin schon liefert.
export function stripMedia(s: string): string {
return s.replace(/\bMEDIA:\S+/gi, "").replace(/[ \t]{2,}/g, " ").replace(/[ \t]+\n/g, "\n").trimEnd()
}
export function cleanForTTS(s: string): string {
return restoreUmlauts(s)
.replace(/```[\s\S]*?```/g, " ").replace(/`([^`]*)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/\bMEDIA:\S+/gi, " ")
// Zeitzonen-Codes (UTC/CET/GMT…) + evtl. angehängte Zeit: Pocket mangelt die Akronyme zu
// Kauderwelsch -> aus der Stimme entfernen (Anzeige behält sie). Lucy sagt die lokale Zeit ohnehin.
.replace(/\b(?:UTC|GMT|CET|CEST|MEZ|MESZ|PST|PDT|EST|EDT)\b\s*[+\-]?\d{0,2}(?::\d{2})?/gi, " ")
// Zahlen/Uhrzeiten (18:01 -> „achtzehn Uhr eins", 2026 -> „zweitausend…") normalisiert jetzt
// der lokale pocket_server (text_norm.py, num2words) — kontextsicher (Modellnummern bleiben).
.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.
export function newSessionId(): string {
return "lucy-" + Math.random().toString(36).slice(2) + Date.now().toString(36)
}
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react"
import { MicVAD } from "@ricky0123/vad-web"
import { BOX_URL } from "../../config"
// Freisprech-VAD: lauscht dauerhaft am Mikro und liefert komplette Äußerungen als Blob.
// Seit Review P1-7 (2026-07-02) Silero VAD v5 (neuronal, via vad-web/onnxruntime-wasm) statt
@@ -19,6 +20,11 @@ const REDEMPTION_MS = 450 // so lange Stille -> Äußerung zu Ende (war 900
const PRE_SPEECH_PAD_MS = 320 // Vorlauf mitschneiden (erster Wortanfang nicht abschneiden)
const MIN_SPEECH_MS = 160 // kürzer = Klick/Räuspern -> verwerfen
const COOLDOWN_MS = 450 // nach Lucys Antwort kurz taub (Echo/Lautsprecher abklingen lassen)
// Semantische Turn-Detection (Smart Turn v3 auf der Box): meldet sie 'incomplete' (User denkt
// mitten im Satz nach), warten wir bis zu HOLD_MS auf die Fortsetzung und hängen sie an,
// statt mitten im Gedanken zu antworten. Hart begrenzt, damit Lucy nie ewig schweigt.
const HOLD_MS = 1800
const MAX_UTTERANCE_S = 30 // Sicherheitsdeckel fürs Zusammenhängen
// Float32-Samples (16 kHz mono) -> WAV-Blob (PCM16). Ersetzt den MediaRecorder-webm-Umweg:
// die Box muss kein Opus mehr dekodieren, Parakeet/Whisper bekommen direkt sauberes WAV.
@@ -38,6 +44,25 @@ function toWavBlob(samples: Float32Array, sampleRate = 16000): Blob {
return new Blob([buf], { type: "audio/wav" })
}
// Fragt die Box, ob die Äußerung semantisch fertig ist. Fehler => true (nie blockieren).
async function isTurnComplete(audio: Float32Array): Promise<boolean> {
try {
const fd = new FormData()
fd.append("audio", toWavBlob(audio.subarray(Math.max(0, audio.length - 8 * 16000))), "rec.wav")
const r = await fetch(`${BOX_URL}/api/voice/turn`, { method: "POST", body: fd })
if (!r.ok) return true
return (await r.json()).complete !== false
} catch {
return true
}
}
function concatAudio(a: Float32Array, b: Float32Array): Float32Array {
const out = new Float32Array(a.length + b.length)
out.set(a); out.set(b, a.length)
return out
}
export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions) {
const pausedRef = useRef(paused); pausedRef.current = paused
const onUtt = useRef(onUtterance); onUtt.current = onUtterance
@@ -51,6 +76,15 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions
if (!enabled) return
let cancelled = false
let vad: Awaited<ReturnType<typeof MicVAD.new>> | null = null
// Semantik-Hold: bei 'incomplete' gepufferte Äußerung, auf die die Fortsetzung wartet.
let pending: Float32Array | null = null
let holdTimer: ReturnType<typeof setTimeout> | null = null
const emit = (audio: Float32Array) => {
pending = null
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
onUtt.current(toWavBlob(audio))
}
;(async () => {
try {
@@ -69,15 +103,28 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions
}),
onSpeechStart: () => {
if (pausedRef.current || performance.now() < resumeAt.current) return
// User spricht weiter, während eine 'incomplete'-Äußerung gehalten wird ->
// Timer stoppen; die Fortsetzung wird in onSpeechEnd angehängt.
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
onLst.current?.(true)
},
onSpeechEnd: (audio: Float32Array) => {
onSpeechEnd: async (audio: Float32Array) => {
onLst.current?.(false)
// Während Lucy denkt/spricht (oder direkt danach) erkannte Sprache = ihr eigenes
// Echo bzw. Nachhall -> verwerfen statt transkribieren.
if (cancelled || pausedRef.current || performance.now() < resumeAt.current) return
if (audio.length < MIN_SPEECH_MS * 16) return // 16 Samples/ms @16 kHz
onUtt.current(toWavBlob(audio))
if (cancelled || pausedRef.current || performance.now() < resumeAt.current) { pending = null; return }
if (!pending && audio.length < MIN_SPEECH_MS * 16) return // 16 Samples/ms @16 kHz
let combined = pending ? concatAudio(pending, audio) : audio
if (combined.length > MAX_UTTERANCE_S * 16000) {
combined = combined.subarray(combined.length - MAX_UTTERANCE_S * 16000)
}
const complete = await isTurnComplete(combined)
if (cancelled) return
if (complete) { emit(combined); return }
// Mitten im Gedanken pausiert: kurz auf die Fortsetzung warten, dann notfalls doch senden.
pending = combined
if (holdTimer) clearTimeout(holdTimer)
holdTimer = setTimeout(() => { if (!cancelled && pending) emit(pending) }, HOLD_MS)
},
onVADMisfire: () => onLst.current?.(false),
})
@@ -90,6 +137,7 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions
return () => {
cancelled = true
if (holdTimer) clearTimeout(holdTimer)
try { vad?.destroy() } catch { /* */ }
onLst.current?.(false)
}
@@ -5,103 +5,19 @@ import { AudioQueue } from "./audio"
import { sentimentToEmotion, type Emotion } from "./sentiment"
import { BOX_URL, SYSTEM_PROMPT } from "../../config"
import { stt, tts, SpeechScheduler } from "../../voice-core"
import { EMO_TAG, cleanForTTS, newSessionId, restoreUmlauts, stripEmoTag, stripMedia } from "./textPipeline"
import { VISION_RX, extractWindowName } from "./visionIntent"
import { PERF, plog, plogSend } from "./perf"
// Voll-Duplex-Schleife: PTT -> Box /stt -> Box /chat (SSE Hermes) -> CHUNKS -> lokal /tts -> AudioQueue.
// Voll-Duplex-Schleife: PTT/VAD -> 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).
// gebuendelt und satzweise frueh gesprochen. Warm-Gate beim Start: Bedienung erst frei, wenn der
// TTS-Dienst antwortet. Text-Verarbeitung: textPipeline.ts · Vision-Intent: visionIntent.ts ·
// Perf-Logging: perf.ts (Review P2-13a: Hook = nur noch Orchestrierung).
export type VoiceStatus = "warming" | "idle" | "listening" | "transcribing" | "thinking" | "speaking" | "error"
export interface ChatMsg { role: "user" | "assistant"; text: string }
// Perf-Diagnose (Client): Zeit pro Stufe ab „Mikro losgelassen". Aus via localStorage lucy_perf=0.
// Perf-Diagnose: NUR Console (kein POST an den pocket_server mehr — der generiert Audio; POSTs
// mittendrin verursachten hakelige Sprachausgabe). Diagnose ist durch (Thinking war die Ursache).
const PERF = typeof localStorage !== "undefined" && localStorage.getItem("lucy_perf") === "1" // opt-in statt default
function plogSend(msg: string) { if (PERF) console.log(`[lucy-perf] ${msg}`) }
function plog(label: string, ms: number) { plogSend(`${label} = ${Math.round(ms)}ms`) }
// 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() }
// Hermes hängt bei selbst erzeugten Medien (z.B. eigener Screenshot via pc-control) einen Roh-Token
// „MEDIA:<datei>" an. Das ist weder Sprech- noch Anzeigetext -> überall entfernen (bis Medien echt
// gerendert werden). Betrifft auch Lucys eigene Bildschirm-Sicht, die den Screenshot ohnehin schon liefert.
function stripMedia(s: string): string {
return s.replace(/\bMEDIA:\S+/gi, "").replace(/[ \t]{2,}/g, " ").replace(/[ \t]+\n/g, "\n").trimEnd()
}
function cleanForTTS(s: string): string {
return restoreUmlauts(s)
.replace(/```[\s\S]*?```/g, " ").replace(/`([^`]*)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/\bMEDIA:\S+/gi, " ")
// Zeitzonen-Codes (UTC/CET/GMT…) + evtl. angehängte Zeit: Pocket mangelt die Akronyme zu
// Kauderwelsch -> aus der Stimme entfernen (Anzeige behält sie). Lucy sagt die lokale Zeit ohnehin.
.replace(/\b(?:UTC|GMT|CET|CEST|MEZ|MESZ|PST|PDT|EST|EDT)\b\s*[+\-]?\d{0,2}(?::\d{2})?/gi, " ")
// Zahlen/Uhrzeiten (18:01 -> „achtzehn Uhr eins", 2026 -> „zweitausend…") normalisiert jetzt
// der lokale pocket_server (text_norm.py, num2words) — kontextsicher (Modellnummern bleiben).
.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)
}
// 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)
@@ -0,0 +1,11 @@
// Bildschirm-Sicht-Intent: erkennt im Gesagten die Bitte, auf den Schirm zu schauen
// (aus useVoiceAgent extrahiert, Review P2-13a).
export 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
export 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
}
+31 -1
View File
@@ -4,33 +4,53 @@ globalTTL: 0
models:
Qwen3.6-35B-A3B:
cmd: |
# parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Mem0-Lern-Extraktion, blockiert Voice-Turns
# nicht mehr); KV Q8_0 macht das speicherneutral zu vorher (65k f16). Bench 2026-07-02:
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec.
cmd: |
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-mtp --spec-draft-n-max 3
ttl: 0
aliases:
- hermes
- fast
# context = nutzbarer Kontext PRO REQUEST (c geteilt durch parallel-Slots).
capabilities:
in: [text]
out: [text]
tools: true
context: 65536
Qwen3.5-122B-A10B:
cmd: |
llama-server -m /srv/models/Qwen3.5-122B-A10B-GGUF/Q4_K_M/Qwen3.5-122B-A10B-Q4_K_M-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
aliases:
- heavy
capabilities:
in: [text]
out: [text]
tools: true
context: 32768
Qwen3-Coder-Next:
cmd: |
llama-server -m /srv/models/Qwen3-Coder-Next-GGUF/Qwen3-Coder-Next-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
aliases:
- coder
capabilities:
in: [text]
out: [text]
tools: true
context: 131072
Qwen3-VL-8B-Instruct:
cmd: |
llama-server -m /srv/models/Qwen3-VL-8B-Instruct-GGUF/Qwen3VL-8B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-8B-Instruct-GGUF/mmproj-Qwen3VL-8B-Instruct-F16.gguf --jinja
ttl: 300
aliases:
- vision
capabilities:
in: [text, image]
out: [text]
context: 32768
Qwen3-Embedding-0.6B:
cmd: |
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
@@ -43,12 +63,22 @@ models:
ttl: 300
aliases:
- coder-lite
capabilities:
in: [text]
out: [text]
tools: true
context: 131072
GLM-4.6V-Flash:
cmd: |
llama-server -m /srv/models/GLM-4.6V-Flash-GGUF/GLM-4.6V-Flash-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/GLM-4.6V-Flash-GGUF/mmproj-BF16.gguf --jinja
ttl: 300
aliases:
- scout
capabilities:
in: [text, image]
out: [text]
tools: true
context: 131072
groups:
brains:
swap: false
+15
View File
@@ -249,6 +249,21 @@ Quelle: `/api/voice/metrics` (live) + eigener TTFT-Test gegen llama-swap.
- **C5 war Fehlalarm** (s. Findings-Tabelle).
- **L2 gefixt:** TTS-Warm-Gate mit Retry/Backoff + sichtbarer Fehlermeldung (vorher: nach 120 s stumm „ready" ohne Stimme).
## 7. Nachtrag 2: P2/P3-Vollausbau (02.07., zweite Session-Hälfte)
| Punkt | Ergebnis |
|---|---|
| **P2-11 Turn-Detection** | ✅ Smart Turn v3.2 (8 MB ONNX) im Voice-Sidecar (`/turn`, ~110 ms warm) + Semantik-Hold im Client (bei „unfertig" bis 1,8 s auf Fortsetzung warten). **Zwei Upstream-Fallen live diagnostiziert:** Audio muss LINKS gepadded werden (sonst konstant „complete"), und der Output ist P(unfertig) — entgegen der Doku. Verifiziert: fertig=0,74→true, mitten im Wort=0,04→false |
| **P2-12 F5 Phase C/D** | ✅ **Verdikt: Pocket bleibt.** F5-ONNX auf 9070 XT/DirectML: RTF 0,59@NFE32 / 0,30@NFE16 — aber nicht streamfähig (TTFA = ganze Satzdauer), NFE16-Qualitätsrisiko, GPU-Dauerbelegung. F5 taugt als Offline-Renderer, nicht für den Dialog-Loop |
| **P2-13a Refactor** | ✅ useVoiceAgent 397→305 Z; textPipeline/visionIntent/perf als pure Module |
| **P2-13b Electron** | ✅ 33→43 (Chromium 150/Node 24), Boot-Smoke-Test grün (allow-scripts-Falle: install.js manuell) |
| **P2-14 Cockpit/SystemDrawer** | ⏸ **bewusst vertagt:** reiner Qualitäts-Refactor von live deployter UI; braucht eine eigene Session mit Browser-Verifikation (MC_API_TARGET-Workflow) statt eines Blind-Splits am Session-Ende |
| **P3-15 Mem0 v3** | ✅ verifiziert aktiv (BM25/Entity/Hybrid in 2.0.8); mc2-memory nutzt jetzt `sync_turn(messages)` — lernt Tool-NAMEN mit (Ergebnisse bewusst nicht: Poisoning-Vektor). Deployt, Postcheck grün |
| **P3-16 llama-swap** | ✅ `capabilities` (in/out/tools/context) je Modell in deploy-Config; Swap-Matrix aktuell unnötig (brains-Gruppe reicht) |
| **P3-17 Kandidaten** | 🟢/🔄 **Qwen3-VL-30B-A3B heruntergeladen + gebencht: tg 9092 t/s** (MoE 3B aktiv, 19 GB, Vulkan) — klarer Upgrade-Kandidat für die Bildschirm-Sicht (`MC_VISION_MODEL`), Qualitäts-Check mit echten Screenshots + brains-Budget-Entscheid (19 vs. 6 GB warm) beim User. gpt-oss-120B lädt noch (nohup); danach: `bash /tmp/candidate_bench.sh` auf der Box vergleicht ihn gegen heavy (Skript liegt bereit) |
| **P3-18 Lemonade** | ✅ **Verdikt: nicht als Unterbau.** lemonade-sdk 9.1.4 (Python) installiert + Server lief (OpenAI-API, gute Registry inkl. gpt-oss/GLM) — aber: Python-Edition **offiziell deprecated** (C++-Installer ist der Weg), Server blockiert komplett während Model-Downloads (Health tot >10 min bei 400-MB-Modell), Ryzen-AI-Hybrid auf 9700X unsupported. **Empfehlung für den lokalen GPU-Klon: llama.cpp-Vulkan + llama-swap — derselbe Stack wie die Box** (ein Betriebsmodell, ein Know-how, bewährte Configs). Lemonade-C++ nur, falls Whisper+TTS+LLM aus einer Hand gewünscht |
| **MC3-Vollbau** | ⛔ außerhalb des Review-Scopes — eigenes Projekt (Prototyp steht unter #mc3) |
---
*Erhoben am 02.07.2026 durch Claude Code (Live-SSH auf Box, lokale PC-Prüfung, 3 Codebase-Agents, 3 Web-Recherche-Runden). Messwerte: /api/voice/metrics (n=13 STT, n=18 chat_ttfb), TTFT-Direktmessung llama-swap :8080.*
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-KW1c6Ww8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BljJ8MOS.css">
<script type="module" crossorigin src="/assets/index-CrYzXlx6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-_Rap01H_.css">
</head>
<body>
<div id="root"></div>
@@ -0,0 +1,38 @@
import React from "react"
// Globale Error Boundary (Review F4): Ein JS-Fehler in einer View riss vorher die komplette
// App in einen weißen Screen. Hier: Fehler anzeigen + Neu-laden-Knopf, Rest bleibt bedienbar
// nach Reload. (GraphView hat zusätzlich seine eigene Boundary für Three.js-Crashes.)
interface State { error: Error | null }
export class AppErrorBoundary extends React.Component<React.PropsWithChildren, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Unbehandelter UI-Fehler:", error, info.componentStack)
}
render() {
if (!this.state.error) return this.props.children
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-950 text-neutral-200 p-8">
<div className="max-w-lg space-y-4 text-center">
<div className="text-2xl">Da ist etwas schiefgelaufen.</div>
<div className="text-sm text-neutral-400 break-all">
{this.state.error.message}
</div>
<button
className="px-4 py-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700"
onClick={() => window.location.reload()}
>
Neu laden
</button>
</div>
</div>
)
}
}
+3
View File
@@ -2,6 +2,7 @@ import React from "react"
import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import App from "./App"
import { AppErrorBoundary } from "./components/AppErrorBoundary"
import "./index.css"
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
@@ -17,8 +18,10 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AppErrorBoundary>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</AppErrorBoundary>
</React.StrictMode>,
)
+12
View File
@@ -150,6 +150,18 @@ class MC2MemoryProvider(MemoryProvider):
return
msgs: List[Dict[str, str]] = [{"role": "user", "content": u}]
a = (assistant_content or "").strip()
# Hermes ≥0.18 liefert optional den vollen Turn-Kontext (`messages`, inkl. Tool-Calls).
# Bewusst NUR die Tool-NAMEN mitlernen ("hat X per Tool Y geprüft") — Tool-ERGEBNISSE
# sind untrusted (Web-Inhalte!) und wären ein Poisoning-Vektor am Junk-Guard vorbei.
if messages:
tools = []
for m in messages:
for tc in (m.get("tool_calls") or []):
name = ((tc.get("function") or {}).get("name") or "").strip()
if name and name not in tools:
tools.append(name)
if tools:
a = (a + f"\n[genutzte Tools: {', '.join(tools[:6])}]").strip()
if a:
msgs.append({"role": "assistant", "content": a[:4000]})
try:
+80
View File
@@ -195,6 +195,76 @@ def transcribe(audio_bytes: bytes, suffix: str, language: str, engine: str = "")
return transcribe_whisper(audio_bytes, suffix, language)
# =================================================================================
# Turn-Detection — Smart Turn v3 (pipecat, BSD-2): semantisches Äußerungs-Ende.
# Whisper-Tiny-Encoder + Klassifikator (8 MB int8, ~12 ms CPU). Der Lucy-Client fragt
# nach dem akustischen VAD-Ende hier nach: "War das ein fertiger Satz?" — bei
# 'incomplete' wartet er kurz weiter, statt mitten im Gedanken loszuantworten.
# Inferenz-Pfad 1:1 aus pipecat-ai/smart-turn inference.py (Feature-Shape muss passen).
# =================================================================================
TURN_REPO = os.environ.get("VOICE_TURN_REPO", "pipecat-ai/smart-turn-v3")
TURN_FILE = os.environ.get("VOICE_TURN_FILE", "smart-turn-v3.2-cpu.onnx")
_turn = None # (session, feature_extractor)
_turn_failed = False
def turn_model():
global _turn, _turn_failed
if _turn is None and not _turn_failed:
try:
import onnxruntime as ort
from huggingface_hub import hf_hub_download
from transformers import WhisperFeatureExtractor
log.info("Lade Smart Turn '%s/%s'", TURN_REPO, TURN_FILE)
path = hf_hub_download(TURN_REPO, TURN_FILE)
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess = ort.InferenceSession(path, sess_options=so, providers=["CPUExecutionProvider"])
_turn = (sess, WhisperFeatureExtractor(chunk_length=8))
except Exception:
_turn_failed = True
log.exception("Smart Turn nicht verfügbar — /turn meldet complete=true (Fallback).")
return _turn
def check_turn(audio_bytes: bytes, suffix: str) -> dict:
model = turn_model()
if model is None:
return {"complete": True, "probability": 1.0, "engine": "none"}
sess, fe = model
from faster_whisper.audio import decode_audio
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf:
tf.write(audio_bytes)
src = tf.name
try:
import numpy as np
arr = decode_audio(src, sampling_rate=16000)
# Smart Turn erwartet das Audio AM ENDE des 8-s-Fensters (Zeros vorn) — die Turn-Ende-
# Hinweise liegen in den letzten Frames. Der FeatureExtractor padded rechts (Zeros hinten),
# damit sah das Modell immer nur Padding und meldete konstant 'complete' (live diagnostiziert).
# Darum manuell links auffüllen und exakt 8 s übergeben.
n = 8 * 16000
arr = arr[-n:]
if len(arr) < n:
arr = np.concatenate([np.zeros(n - len(arr), dtype=np.float32), arr.astype(np.float32)])
inputs = fe(arr, sampling_rate=16000, return_tensors="np", padding="do_not_pad",
truncation=True, do_normalize=True)
out = sess.run(None, {"input_features": inputs.input_features})
# Output-Tensor heißt 'logits' — je nach Export roher Logit ODER schon Sigmoid.
# Robust: Werte außerhalb [0,1] durch Sigmoid schicken, sonst direkt nutzen.
raw = float(out[0][0].item() if hasattr(out[0][0], "item") else out[0][0])
p = raw if 0.0 <= raw <= 1.0 else 1.0 / (1.0 + float(np.exp(-raw)))
# EMPIRISCH VERIFIZIERT (2026-07-02, Box): v3.2-cpu liefert P(UNFERTIG) —
# fertiger Satz -> 0.26, mitten im Wort abgeschnitten -> 0.96, Stille -> 0.99.
# (Die Upstream-Doku beschreibt es andersherum; Messung schlägt Doku.)
return {"complete": p <= 0.5, "probability": round(1.0 - p, 3), "engine": "smart-turn-v3"}
finally:
try:
os.unlink(src)
except OSError:
pass
# =================================================================================
# TTS — Piper (offizielles Binary; Stimme = ONNX-Datei + .json daneben)
# =================================================================================
@@ -467,6 +537,16 @@ async def stt(audio: UploadFile = File(...), language: str = Form(default=""),
return {"text": text}
@app.post("/turn")
async def turn(audio: UploadFile = File(...)) -> dict:
"""Semantische Turn-Detection: War die Äußerung ein fertiger Gedanke? (Smart Turn v3)"""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
suffix = Path(audio.filename or "rec.wav").suffix or ".wav"
return check_turn(data, suffix)
@app.post("/tts")
def tts(body: TTSIn) -> Response:
text = (body.text or "").strip()
+2
View File
@@ -11,3 +11,5 @@ edge-tts
# STT-Default seit Review 2026-07-02: Parakeet-TDT 0.6B v3 (DE-WER besser + ~10x schneller
# als whisper-medium auf CPU, via onnx-asr). whisper bleibt als Fallback installiert.
onnx-asr[cpu,hub]
# Semantische Turn-Detection (Smart Turn v3, Whisper-Mel-Features)
transformers