Compare commits

...
12 Commits
Author SHA1 Message Date
duffyduck bbfa1f73f6 release: bump version to 0.2.0.7 2026-07-11 17:14:32 +02:00
duffyduckandClaude Opus 4.8 fed3cb9f18 fix(projects): Live-Sync verstecken zwischen App und Diagnostic (ohne Refresh)
Kern-Bug: der Diagnostic-Server reichte ein von RVS empfangenes
project_changed NIE an die Browser weiter — der Handler in index.html war
toter Code, der Diagnostic aktualisierte die Projektliste also nie live.

- server.js: RVS-Handler forwardet project_changed jetzt an die Browser-Tabs;
  der /api/brain-Proxy broadcastet project_changed (RVS + lokal) nach jeder
  erfolgreichen Projekt-Mutation (create/switch/end/archive/PATCH inkl. hidden).
- App ProjectsBrowser: sendet project_changed nach dem Verstecken/Sichtbar-
  machen und laedt bei eingehendem project_changed selbst neu.

Damit: verstecken in der App -> sofort im Diagnostic weg (und umgekehrt),
ohne Seiten-Refresh. Diagnostic-Hide funktionierte schon (Daten/Proxy/Logik
korrekt) — versteckte sind per "Versteckte anzeigen (N)"-Toggle sichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 17:11:51 +02:00
duffyduckandClaude Opus 4.8 9ec9119bd9 docs(changelog): 0.2.0.6 abgebunden (Unreleased → [0.2.0.6])
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:51:10 +02:00
duffyduck 06d0064c8c release: bump version to 0.2.0.6 2026-07-11 16:50:22 +02:00
duffyduckandClaude Opus 4.8 a7cde52ee6 docs(changelog): Plan-B-Epos (0.2.0.4/5) + heutige Fixes nachgetragen
Changelog endete bei 0.2.0.3 (07-10). Nachgetragen:
- 0.2.0.4/5 (07-11): Plan B Lokales LLM (Router, Fast-Lane, SearXNG-Tools,
  llama-swap/B0.5, Quell-Badge, speak-Flag, --system-prompt Identity-Fix,
  Diskretions-Regel).
- Unreleased (07-11): Spotify-Resume via MEDIA_PLAY, TTS-Zahlen ausschreiben,
  "ARIA denkt"-Sync, weiche Tool-Fehler-Eskalation, Token-Ersparnis-Metrik,
  Projekte verstecken (Diagnostic + App), von ARIA geschaerfter Spotify-Skill.
Alt-Label "Unreleased 07-10" korrekt auf [0.2.0.3] gesetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:49:02 +02:00
duffyduckandClaude Opus 4.8 a353b62b37 feat(app): Projekte verstecken auch in der App (Auge + Toggle)
Spiegelt das Diagnostic-Feature auf App-Seite:
- Project-Interface + updateProject um hidden erweitert; setProjectHidden().
- ProjectsBrowser: versteckte Projekte standardmaessig ausgeblendet (mama
  sieht sie nicht). Auge pro Zeile (🙈 verstecken / 👁 sichtbar), Toggle
  "👁 Versteckte anzeigen (N)" blendet sie temporaer gedimmt + Badge ein
  zum Ansehen/Auswaehlen/Wieder-Sichtbarmachen. Eigener Touch am Auge, damit
  der Tap nicht das Projekt wechselt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:45:15 +02:00
duffyduckandClaude Opus 4.8 bc47c2053b feat(projects): Projekte verstecken (Auge-Toggle im Diagnostic)
Neues hidden-Flag pro Projekt (default false, bleibt voll nutzbar — nur
optisch aus der Liste ausgeblendet, unabhaengig von status/archived).
- projects.py: hidden in create_project + update_project-Patchkeys.
- main.py: ProjectUpdateBody.hidden → PATCH /projects/{id} setzt es.
- Diagnostic: pro Projekt-Bubble ein Auge (🙈 verstecken / 👁 sichtbar
  machen); Header-Toggle "Versteckte anzeigen (N)" blendet sie temporaer
  ein (gedimmt + Badge) zum Ansehen/Auswaehlen, ohne sie permanent
  sichtbar zu machen. Auge im eingeblendeten Zustand macht sie dauerhaft
  wieder sichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:41:56 +02:00
duffyduckandClaude Opus 4.8 dc043ceb4d feat(metrics): lokaler LLM-Verbrauch + Claude-Ersparnis in Diagnostic
metrics.jsonl-Eintraege tragen jetzt 'source' (claude|local|fast-path).
- log_local_call(): echte usage-Tokens vom Adapter (prompt/completion),
  sonst chars/4-Schaetzung. Geloggt pro Tool-Runde im lokalen Fast-Lane.
- log_fast_path(): reiner Skill, 0 Prompt-Tokens — gesparter Claude-Call.
- aggregate() liefert zusaetzlich by_source (calls/tokens_in/tokens_out).
  Alt-Eintraege ohne source zaehlen als claude (rueckwaerts-kompatibel).

Diagnostic Gehirn-Tab: neue Card "Lokales LLM & Claude-Ersparnis" — pro
Fenster (1h/5h/24h/30d) gesparte Claude-Calls (local + fast-path) und
lokale Token-Last (eigene HW, kein Quota) + Info-Block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:39:02 +02:00
duffyduckandClaude Opus 4.8 8bac7c56bc fix(local-llm): weiche Tool-Fehler eskalieren (Exit 0 + Fehlertext)
Die Escalate-on-Tool-Error-Logik griff nur bei hartem FEHLER-Prefix (Exit
!= 0). Action-Skills melden Fehlschlaege aber oft im stdout-Text bei Exit 0
(Spotify: "Fehler beim Uebertragen", "Geraet nicht gefunden"). Das lokale
LLM las den Fehler dann brav vor statt zu eskalieren — und wiederholte beim
Nachhaken dieselbe leere Absichtserklaerung.

Generisch (nicht Spotify-spezifisch): fuer run_*-Skills werten weiche
Fehler-Marker im Ausgabetext ebenfalls als Fehlschlag → Claude uebernimmt
das mehrstufige Mitdenken. Info-Tools (web_search/memory_search) ausgenommen,
damit deren Inhalt das Wort "Fehler" tragen darf. Eskalieren ist immer sicher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:25:52 +02:00
duffyduckandClaude Opus 4.8 3b6d36f2de fix(sync): "ARIA denkt" bleibt haengen obwohl Turn fertig
Zwei Ursachen, beide adressiert:
- App raeumte den (kontext-scoped) Thinking-Indikator nur ueber das
  agent_activity 'idle' der Bridge. Kam das nicht an (dedup/mismatch),
  blieb "ARIA denkt" + Abbrechen stehen. Jetzt raeumt die App den
  Indikator beim Eintreffen der Antwort selbst (definitiver Turn-Ende-Beweis).
- Bridge sendete 'thinking' mit der REQUEST-projectId, 'idle' aber mit der
  TURN-projectId (Brain kann umrouten, z.B. Voice-Sticky). Bei Abweichung
  bekam der Request-Kontext nie sein idle → zusaetzliches idle fuer die
  Request-projectId am Turn-Ende.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:23:41 +02:00
duffyduckandClaude Opus 4.8 7f6e266d15 fix(tts): freistehende Ganzzahlen tag-unabhaengig ausschreiben
Regression seit das lokale LLM leichte Turns (Wetter, Spotify) beantwortet:
es nutzt bewusst KEINE <voice>-Tags, an denen frueher die Zahl-Aussprache hing.
clean_text_for_tts schrieb nur Uhrzeiten/Dezimalzahlen/Zahl+Einheit aus, nie
freistehende Ganzzahlen ('23°C', '100%', '7 Nachrichten').

Neuer vollstaendiger Konverter _int_to_words_de (0..999999) + generischer
Durchlauf am Ende von clean_text_for_tts, der alle uebrigen Ganzzahlen zu
Woertern macht. Tag-unabhaengig -> gilt fuer local UND claude. Lange
Ziffernfolgen (IDs/Codes, >6 Stellen) bleiben Ziffern; an .,:/ klebende
Zahlen (IPs, Versionen) werden uebersprungen. Nebenbei: fehlendes Leerzeichen
bei '23°C' -> 'dreiundzwanzig Grad Celsius' gefixt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:18:58 +02:00
duffyduckandClaude Opus 4.8 4d1664a328 fix(app): zuverlaessiger Spotify-Resume via MEDIA_PLAY-KeyEvent statt Focus-Nudge
Native AudioFocus: dispatchMediaPlay() (echter KEYCODE_MEDIA_PLAY an die aktive
MediaSession, wie die Kopfhoerer-Play-Taste) + isMusicActive() zum Gaten.
audio.ts merkt vor dem Focus-Grab ob Musik lief und resumt am Dialog-Ende nur
dann — deterministisch statt des auf OnePlus flakigen nudgeMediaResume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:13:18 +02:00
15 changed files with 546 additions and 37 deletions
+54 -1
View File
@@ -10,7 +10,60 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
---
## [Unreleased] — 2026-07-10
## [0.2.0.6] — 2026-07-11
### Hinzugefügt
**Diagnostic + App — Projekte verstecken**
- Neues `hidden`-Flag pro Projekt (bleibt voll nutzbar, nur aus Listen ausgeblendet — unabhängig von `status`/`archived`); `PATCH /projects/{id} {hidden}`
- Diagnostic: 👁-Auge pro Projekt-Bubble (🙈 verstecken / 👁 dauerhaft sichtbar), Header-Toggle „Versteckte anzeigen (N)" blendet sie temporär gedimmt + „versteckt"-Badge ein — zum Ansehen/Auswählen ohne permanentes Enttarnen
- App (`ProjectsBrowser`): versteckte standardmäßig ausgeblendet (Mama sieht sie nicht), Auge pro Zeile + Toggle spiegeln das Diagnostic-Verhalten; geteilter `hidden`-Status übers Brain
**Diagnostic — Token-Ersparnis durch lokales LLM**
- `metrics.jsonl` trägt jetzt `source` (claude | local | fast-path); lokale Calls nutzen echte `usage`-Tokens vom Adapter, Fast-Path = 0 Prompt-Tokens (`by_source`-Aggregation, rückwärts-kompatibel)
- Neue Card „Lokales LLM & Claude-Ersparnis": pro Fenster (1h/5h/24h/30d) gesparte Claude-Calls (local + fast-path) + lokale Token-Last (eigene HW, kein Quota)
**Spotify-Skill — von ARIA selbst geschärft**
- Geräte-Transfer startet die Wiedergabe direkt mit (`play=true`) statt nur zu übertragen, inkl. Verifikation (`is_playing`-Check + expliziter Play-Fallback), Fuzzy-Gerätenamen und sauberen Exit-Codes; neue semantische Actions `play_on_device`/`search_and_play`/`playlist_play`/`queue_add`
### Behoben
- **Spotify-Resume (App):** nach einem Voice-Befehl blieb Spotify auf dem Handy pausiert. Statt des auf manchen Geräten (OnePlus) flakigen Audio-Focus-Nudge jetzt ein echter `KEYCODE_MEDIA_PLAY`-KeyEvent an die aktive MediaSession — gegated: nur wenn vor dem Dialog Musik lief (`isMusicActive`). Deterministisch, geräteunabhängig
- **TTS-Zahlen:** freistehende Ganzzahlen werden jetzt tag-unabhängig ausgeschrieben („23°C" → „dreiundzwanzig Grad Celsius", „100%" → „einhundert Prozent"). Regression, seit das lokale LLM (bewusst ohne `<voice>`-Tag) leichte Turns übernahm; neuer vollständiger Zahl→Wort-Konverter (0…999999) am Ende von `clean_text_for_tts`, lange Ziffernfolgen (IDs) bleiben Ziffern
- **„ARIA denkt" hängt:** Indikator + Abbrechen blieben stehen, obwohl der Turn laut Diagnostic fertig war. Die App räumt den kontext-scoped Indikator jetzt beim Eintreffen der Antwort selbst; die Bridge sendet zusätzlich ein `idle` für die Request-`projectId`, falls der Turn umgeroutet wurde (thinking ging mit Request-, idle mit Turn-`projectId`)
- **Lokale Tool-Fehler:** Action-Skills, die bei Exit 0 einen Fehlschlag nur im stdout-Text melden (Spotify: „Fehler beim Übertragen", „Gerät nicht gefunden"), eskalieren jetzt generisch an Claude statt vom lokalen LLM vorgelesen zu werden (Info-Tools wie web_search ausgenommen)
---
## [0.2.0.4 0.2.0.5] — 2026-07-11 — Plan B: Lokales LLM („Gemini-Feeling")
Ein kleines, schnelles Modell (**Qwen3 8B** via llama.cpp/llama-swap auf der Gamebox-GPU) übernimmt einfache Turns in **<1 s**; alles Schwere/Technische/Werkzeug-artige reicht ein Router automatisch an **Claude** weiter. Ziel: schnelle Antworten ohne die Claude-Max-Subscription aufzugeben.
### Hinzugefügt
**Lokales LLM (Brain + Bridge + Adapter)**
- Router (B1a): Heuristik + `<<ESCALATE>>`-Selbstabbruch entscheidet pro Turn lokal vs. Claude; schlanker System-Prompt mit demselben `IDENTITY_ANCHOR` wie Claude (Rolle hält), nur letzte 8 Turns (Speed)
- Lokale Tool-Fast-Lane (B1b): kuratierte Tools — `web_search` (self-hosted **SearXNG**, local-only), `memory_search`, `trigger_timer`, Spotify; Eskalation bei Tool-Fehler statt Raten
- Consumer-Kette gespiegelt zu FLUX: Brain → Bridge `/internal/local-llm` → RVS → `llm-adapter` → llama.cpp; `enable_thinking:false` (Qwen wickelte sonst die ganze Antwort in `<think>`)
- **B0.5:** llama-swap (Hot-Swap der Modelle on-demand) + Modellauswahl-Dropdown + Live-Lade-Status (loading/ready + Download-Hinweis) in Diagnostic
- **SearXNG** als 6. Container auf der ARIA-VM (keyless Meta-Suche, JSON-API)
**Quell-Badge (local / claude / fast-path)**
- Diagnostic: immer an den ARIA-Bubbles
- App: optionaler Schalter in den Einstellungen, pro Gerät gemerkt, default aus („ich will's, meine Mama nicht")
**TTS — System-Flag `speak` (ja/nein) pro Antwort**
- Die Quelle entscheidet übers Vorlesen (Fast-Path/Steuerbefehl = stumm, ARIA-Antwort = vorlesen), robust statt des fragilen leeren `<voice></voice>`-Hacks der beim Skill-Rebuild verloren ging
### Behoben / Geändert
- **Identität (Hauptchat):** Proxy nutzt jetzt `--system-prompt` (voller Replace) statt `--append-system-prompt` — die Claude-Code-Basis-Identität leakt nicht mehr in den Hauptchat (ARIA antwortete dort als „Claude Code" bzw. deutete die Persona als Injection). Dazu `IDENTITY_SEED` (synthetischer Grounding-Turn) + Gift-Wächter (Identity-Breaks werden nie in die History persistiert, Retry+Fallback) + Cleanup-Script gegen bereits vergiftete Turns
- **Datenschutz (kritisch):** harte Diskretions-Regel im `IDENTITY_ANCHOR` — ARIA kennt intime/private Details, gibt sie aber **NIE ungefragt** preis (nicht in Vorstellungen, „was weißt du über mich", Zusammenfassungen, Triggern); nur auf konkrete Nachfrage, knapp. Bereits ausgeplauderte Turns bereinigt. Lokales Tier eskaliert Personen-/Beziehungs-/Gedächtnisfragen an Claude (kennt das Gedächtnis + antwortet diskret)
- **Lokale Antwort nicht in `<voice>`** wickeln (Qwen imitierte den Tag aus dem Kontext → Anzeige war leer); **generische** Tool-Fehler-Eskalation statt per-Skill-Router-Hardcode (Router muss nicht wissen, welche Skills „schwer" sind)
---
## [0.2.0.3] — 2026-07-10
### Hinzugefügt
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20005
versionName "0.2.0.5"
versionCode 20007
versionName "0.2.0.7"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -5,7 +5,9 @@ import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
@@ -183,6 +185,50 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
promise.resolve(true)
}
/** Zuverlaessiger Spotify-Resume: einen echten MEDIA_PLAY-Tastendruck an die
* aktive MediaSession schicken — exakt das Signal der Play-Taste am
* Bluetooth-Kopfhoerer. Anders als nudgeMediaResume (Focus-Stack-Trick,
* auf manchen OEMs/Spotify-Versionen unzuverlaessig) spricht das Spotifys
* MediaSession DIREKT an und startet die Wiedergabe deterministisch wieder.
*
* Wir senden bewusst KEYCODE_MEDIA_PLAY (nicht PLAY_PAUSE) — das kann nur
* starten, nie pausieren. Aufrufer muss also selbst gaten (nur senden wenn
* vor dem Gespraech wirklich Musik lief, siehe isMusicActive()).
*/
@ReactMethod
fun dispatchMediaPlay(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
try {
val now = SystemClock.uptimeMillis()
val down = KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY, 0)
val up = KeyEvent(now, now, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY, 0)
am.dispatchMediaKeyEvent(down)
am.dispatchMediaKeyEvent(up)
Log.i(TAG, "dispatchMediaPlay: KEYCODE_MEDIA_PLAY an aktive MediaSession gesendet")
promise.resolve(true)
} catch (e: Exception) {
Log.w(TAG, "dispatchMediaPlay failed: ${e.message}")
promise.resolve(false)
}
}
/** Ob gerade Musik/Media aktiv abgespielt wird (AudioManager.isMusicActive).
* Der Aufrufer merkt sich das VOR dem Focus-Grab, um am Dialog-Ende zu
* entscheiden ob ein dispatchMediaPlay-Resume ueberhaupt gewuenscht ist. */
@ReactMethod
fun isMusicActive(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
promise.resolve(am.isMusicActive)
}
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.0.5",
"version": "0.2.0.7",
"private": true,
"scripts": {
"android": "react-native run-android",
+79 -6
View File
@@ -75,6 +75,9 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
const [editing, setEditing] = useState<Project | null>(null);
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
// Versteckte Projekte standardmaessig ausblenden; Toggle blendet sie
// temporaer (gedimmt) ein — zum Ansehen/Auswaehlen oder Wieder-Sichtbarmachen.
const [showHidden, setShowHidden] = useState(false);
// Refs damit useCallback NICHT bei jeder Re-Render des Parents neu erzeugt
// wird (parent uebergibt oft inline-arrow-Callbacks, neue Identity jedes
@@ -109,6 +112,17 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
return () => unsub();
}, [visible, load]);
// Live-Sync: ein anderer Client (Diagnostic / andere App) hat ein Projekt
// geaendert (verstecken/anlegen/beenden/…) → project_changed ueber RVS →
// Liste neu laden, ohne dass Stefan manuell refreshen muss.
useEffect(() => {
if (!visible) return;
const unsub = rvs.onMessage((msg: any) => {
if (msg?.type === 'project_changed') load();
});
return () => unsub();
}, [visible, load]);
const switchTo = useCallback((id: string) => {
// Multi-Threading: Focus-Wechsel ist reine App-lokale UI-Entscheidung.
// Brain wird nicht mehr benachrichtigt (kein globaler active_project mehr).
@@ -158,6 +172,19 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
]);
}, [load]);
// Nach einer Projekt-Mutation die anderen Clients (Diagnostic, weitere
// App-Instanzen) live aktualisieren — via RVS project_changed. RVS echot
// NICHT an den Sender zurueck, darum laden wir lokal zusaetzlich selbst.
const broadcastProjectsChanged = useCallback(() => {
try { rvs.send('project_changed' as any, { reason: 'app' }); } catch {}
}, []);
const toggleHidden = useCallback((p: Project) => {
brainApi.setProjectHidden(p.id, !p.hidden)
.then(() => { broadcastProjectsChanged(); load(); })
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, [load, broadcastProjectsChanged]);
const archiveProject = useCallback((p: Project) => {
Alert.alert(`"${p.name}" archivieren?`,
'Verschwindet aus der Standardliste. Über "archivierte zeigen" erreichbar.',
@@ -176,11 +203,12 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
const renderItem = ({ item }: { item: Project }) => {
const isActive = item.id === activeId;
const dot = _statusDot(item.id);
const hidden = !!item.hidden;
return (
<TouchableOpacity
onPress={() => switchTo(item.id)}
onLongPress={() => openEdit(item)}
style={[s.row, isActive && s.rowActive]}
style={[s.row, isActive && s.rowActive, hidden && s.rowHidden]}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
@@ -188,6 +216,7 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
)}
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
{hidden && <Text style={s.hiddenBadge}>versteckt</Text>}
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
{isActive && <Text style={s.activeBadge}> FOCUS</Text>}
</View>
@@ -199,10 +228,22 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
{dot.label ? ` · ${dot.label}` : ''}
</Text>
</View>
{/* Auge: verstecken (🙈) / wieder sichtbar (👁). Eigener Touch, damit
der Tap NICHT das Projekt wechselt. */}
<TouchableOpacity
onPress={() => toggleHidden(item)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={s.eyeBtn}
>
<Text style={s.eyeIcon}>{hidden ? '👁' : '🙈'}</Text>
</TouchableOpacity>
</TouchableOpacity>
);
};
const hiddenCount = projects.filter(p => p.hidden).length;
const visibleProjects = showHidden ? projects : projects.filter(p => !p.hidden);
const body = (
<View style={{ flex: 1, backgroundColor: '#0A0A14' }}>
{/* Header */}
@@ -243,6 +284,17 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
);
})()}
{/* Versteckte-Toggle — nur wenn es welche gibt (oder gerade eingeblendet) */}
{(hiddenCount > 0 || showHidden) && (
<TouchableOpacity onPress={() => setShowHidden(v => !v)} style={s.hiddenToggle}>
<Text style={s.hiddenToggleText}>
{showHidden
? `🙈 Versteckte ausblenden${hiddenCount ? ` (${hiddenCount})` : ''}`
: `👁 Versteckte anzeigen${hiddenCount ? ` (${hiddenCount})` : ''}`}
</Text>
</TouchableOpacity>
)}
{loading ? (
<View style={{ padding: 24, alignItems: 'center' }}>
<ActivityIndicator color="#0096FF" />
@@ -251,14 +303,21 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
<Text style={s.errorText}> {err}</Text>
) : (
<FlatList
data={projects}
data={visibleProjects}
keyExtractor={p => p.id}
renderItem={renderItem}
ListEmptyComponent={
<Text style={s.emptyText}>
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'}
Lass uns ein Projekt 'XY' anlegen".
</Text>
projects.length > 0 ? (
<Text style={s.emptyText}>
Alle {hiddenCount} Projekte sind versteckt.{'\n'}
Tipp 👁 Versteckte anzeigen".
</Text>
) : (
<Text style={s.emptyText}>
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'}
„Lass uns ein Projekt 'XY' anlegen".
</Text>
)
}
/>
)}
@@ -365,6 +424,8 @@ const s = StyleSheet.create({
headerBtnText: { color: '#0096FF', fontSize: 18, fontWeight: '600' },
headerTitle: { flex: 1, textAlign: 'center', color: '#E0E0F0', fontSize: 18, fontWeight: '700' },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
@@ -375,6 +436,18 @@ const s = StyleSheet.create({
borderLeftWidth: 3,
borderLeftColor: '#34C759',
},
rowHidden: { opacity: 0.55 },
eyeBtn: { paddingHorizontal: 8, paddingVertical: 6, marginLeft: 6 },
eyeIcon: { fontSize: 18 },
hiddenBadge: { color: '#B392F0', fontSize: 10, fontWeight: '700',
backgroundColor: 'rgba(179,146,240,0.15)', paddingHorizontal: 6,
paddingVertical: 2, borderRadius: 4 },
hiddenToggle: {
paddingHorizontal: 16, paddingVertical: 10,
borderBottomWidth: 1, borderColor: '#1E1E2E',
backgroundColor: '#0D0D18',
},
hiddenToggleText: { color: '#B392F0', fontSize: 12, fontWeight: '600' },
rowName: { color: '#E0E0F0', fontSize: 16, fontWeight: '600' },
rowDesc: { color: '#8888AA', fontSize: 13, marginTop: 4 },
rowMeta: { color: '#555570', fontSize: 11, marginTop: 4 },
+15
View File
@@ -1172,6 +1172,21 @@ const ChatScreen: React.FC = () => {
});
// ARIA hat geantwortet → Watchdog clearen, falls noch armiert
clearStuckWatchdog();
// Thinking-Indikator fuer DIESEN Kontext raeumen. Die Antwort ist der
// definitive Beweis, dass der Turn fertig ist — unabhaengig davon, ob
// das agent_activity 'idle' der Bridge ankam (es kann mit abweichender
// projectId gesendet oder wegdedupt worden sein → "ARIA denkt" blieb
// sonst haengen). Zusaetzlich global raeumen, falls der sichtbare
// Kontext ueber den Legacy-Indikator lief.
{
const ansPid = ((message.payload as any).projectId as string) || '';
setAgentActivityByCtx(prev =>
prev[ansPid] && prev[ansPid].activity !== 'idle'
? { ...prev, [ansPid]: { activity: 'idle', tool: '' } }
: prev);
setAgentActivity(cur =>
cur.activity === 'idle' ? cur : { activity: 'idle', tool: '' });
}
// ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
// ein ACK aus Netzgruenden verloren ging, soll der Retry nicht
+61 -9
View File
@@ -44,6 +44,11 @@ const { AudioFocus, PcmStreamPlayer, PcmStreamRecorder } = NativeModules as {
release: () => Promise<boolean>;
kickReleaseMedia: () => Promise<boolean>;
getMode?: () => Promise<number>;
// Zuverlaessiger Spotify-Resume via echtem MEDIA_PLAY-KeyEvent (statt
// Focus-Stack-Nudge). isMusicActive() zum Gaten: nur resumen wenn vor
// dem Gespraech wirklich Musik lief.
dispatchMediaPlay?: () => Promise<boolean>;
isMusicActive?: () => Promise<boolean>;
};
PcmStreamPlayer?: {
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
@@ -275,6 +280,13 @@ class AudioService {
// damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt.
private _conversationFocusActive: boolean = false;
// Lief unmittelbar VOR dem Focus-Grab (Wake-Word/Aufnahme) Musik? Wird beim
// Betreten des Dialogs gemerkt (latch: nur auf true), damit wir am Dialog-Ende
// NUR dann Spotify per MEDIA_PLAY-KeyEvent zuverlaessig resumen, wenn vorher
// wirklich etwas lief. Verhindert, dass wir bei Stille versehentlich Musik
// starten. Wird nach dem Resume-Dispatch wieder auf false gesetzt.
private _mediaWasActiveAtAcquire: boolean = false;
// VAD State
private vadEnabled: boolean = false;
private lastSpeechTime: number = 0;
@@ -450,15 +462,29 @@ class AudioService {
import('./logger').then(m => m.reportAppDebug('audio.focus',
'AudioFocus.release() now')).catch(()=>{});
AudioFocus?.release().catch(() => {});
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss.
// 50ms Delay damit das Abandon erst durch ist.
setTimeout(() => {
// Spotify-Resume: NUR wenn vor dem Gespraech wirklich Musik lief. Dann
// einen echten MEDIA_PLAY-KeyEvent an die aktive MediaSession schicken
// (wie die Play-Taste am Kopfhoerer) — das resumt Spotify zuverlaessig,
// im Gegensatz zum flakigen Focus-Stack-Nudge, der auf manchen Geraeten
// (OnePlus) nach Auto-Loss nicht griff. 120ms Delay, damit das Abandon
// sicher durch ist, bevor der Play-Key kommt.
const shouldResume = this._mediaWasActiveAtAcquire;
this._mediaWasActiveAtAcquire = false;
if (shouldResume) {
setTimeout(() => {
import('./logger').then(m => m.reportAppDebug('audio.focus',
'dispatchMediaPlay() now (Musik lief vor Dialog → Resume)')).catch(()=>{});
if (AudioFocus?.dispatchMediaPlay) {
AudioFocus.dispatchMediaPlay().catch(() => {});
} else {
// Fallback fuer alte Native-Builds ohne dispatchMediaPlay
AudioFocus?.nudgeMediaResume().catch(() => {});
}
}, 120);
} else {
import('./logger').then(m => m.reportAppDebug('audio.focus',
'nudgeMediaResume() now (50ms after release)')).catch(()=>{});
AudioFocus?.nudgeMediaResume().catch(() => {});
}, 50);
'kein Resume (vor Dialog lief keine Musik)')).catch(()=>{});
}
}, this.FOCUS_RELEASE_DELAY_MS);
}
@@ -469,6 +495,20 @@ class AudioService {
}
}
/** Merkt sich (latch: nur auf true), ob GERADE Musik laeuft — VOR einem
* Focus-Grab aufrufen. Am Dialog-Ende entscheidet die Flag, ob wir Spotify
* aktiv per MEDIA_PLAY resumen. Awaitet bewusst isMusicActive bevor der
* Focus-Request die Wiedergabe pausiert (sonst laese man schon 'false'). */
private async _captureMediaActive(): Promise<void> {
try {
const active = await AudioFocus?.isMusicActive?.();
if (active) {
this._mediaWasActiveAtAcquire = true;
console.log('[Audio] Musik lief vor Focus-Grab → Resume am Dialog-Ende gemerkt');
}
} catch {}
}
/** Conversation-Mode beginnt → AudioFocus dauerhaft halten (Spotify bleibt
* pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */
acquireConversationFocus(): void {
@@ -476,7 +516,11 @@ class AudioService {
this._conversationFocusActive = true;
this._cancelDeferredFocusRelease();
console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)');
AudioFocus?.requestDuck().catch(() => {});
// Erst pruefen ob Musik laeuft, DANN ducken (requestDuck wuerde sie sonst
// schon pausieren bevor wir es messen koennen).
this._captureMediaActive().finally(() => {
AudioFocus?.requestDuck().catch(() => {});
});
}
/** Conversation-Mode endet → Focus darf wieder freigegeben werden
@@ -493,6 +537,8 @@ class AudioService {
haltAllPlayback(reason: string = ''): void {
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
this._conversationFocusActive = false;
// Barge-In → User spricht gleich weiter, Spotify NICHT resumen.
this._mediaWasActiveAtAcquire = false;
this.stopPlayback();
}
@@ -503,6 +549,8 @@ class AudioService {
pauseForCall(reason: string = ''): void {
console.log('[Audio] pauseForCall: %s', reason || '(no reason)');
this._conversationFocusActive = false;
// Anruf → Spotify bleibt aus, kein Auto-Resume beim spaeteren Release.
this._mediaWasActiveAtAcquire = false;
this._pausedForCall = true;
// Queue + isPlaying ruecksetzen — sonst klemmt der naechste Play-Button
// (playAudio sieht isPlaying=true und ruft _playNext nicht mehr auf).
@@ -796,6 +844,8 @@ class AudioService {
this.setState('recording');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
// Vorher merken ob Musik lief, damit wir sie danach resumen koennen.
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
@@ -1043,6 +1093,8 @@ class AudioService {
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
+11 -2
View File
@@ -157,6 +157,7 @@ export interface Project {
name: string;
description: string;
status: 'active' | 'ended' | 'archived';
hidden?: boolean; // aus Listen ausgeblendet (bleibt nutzbar)
created_at: number;
updated_at: number;
last_activity_at: number;
@@ -593,14 +594,22 @@ export const brainApi = {
});
},
/** Projekt-Metadaten patchen (name / description). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description'>>): Promise<Project> {
/** Projekt-Metadaten patchen (name / description / hidden). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden'>>): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: patch,
});
},
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: { hidden },
});
},
/** Queue-Status: pro Kontext (project_id oder __main__ fuer Hauptchat)
* ob gerade ein Request in Verarbeitung ist + wieviele in der Queue warten.
* Wird fuer Status-Dots im Drawer periodisch gepollt. */
+40 -1
View File
@@ -30,6 +30,7 @@ from memory import Embedder, VectorStore, MemoryPoint
from prompts import build_system_prompt, IDENTITY_SEED, IDENTITY_ANCHOR, looks_like_identity_break
from proxy_client import ProxyClient, Message as ProxyMessage
import router as router_mod
import metrics
from local_llm import local_llm_chat
import skills as skills_mod
import triggers as triggers_mod
@@ -1150,6 +1151,32 @@ class Agent:
_LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed)
_LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude
# Weiche Fehler-Marker im Tool-Ausgabetext. Viele Action-Skills geben einen
# menschenlesbaren Fehler auf stdout aus und beenden sich TROTZDEM mit Exit 0
# (z.B. Spotify: "Fehler beim Uebertragen", "Geraet nicht gefunden"). Der
# harte FEHLER-Prefix (Exit != 0) faengt das nicht. Fuer run_*-Skills werten
# wir daher auch solche Marker als Fehlschlag → eskalieren an Claude, der
# mehrstufig weiterdenkt. Info-Tools (web_search/memory_search) sind bewusst
# AUSGENOMMEN: deren Inhalt darf das Wort "Fehler" tragen ohne dass der
# Tool-Call scheiterte. Eskalieren ist immer sicher (nur langsamer).
_SOFT_FAIL_MARKERS = (
"fehler", "fehlgeschlagen", "nicht gefunden", "not found",
"konnte nicht", "keine verbindung", "nicht verfuegbar",
"exception", "traceback",
)
def _local_tool_failed(self, tool_name: str, tresult: str) -> bool:
"""True wenn ein lokaler Tool-Call als gescheitert gilt (→ Claude)."""
s = (tresult or "").strip()
if not s:
return False
if s.startswith("FEHLER"): # harter Exit-Code-Fehler
return True
if tool_name.startswith("run_"): # Action-Skill: auch weiche Fehler
low = s.lower()
return any(mk in low for mk in self._SOFT_FAIL_MARKERS)
return False
# Kuratierte Tool-Auswahl fuers lokale Tier (B1b): web_search (local-only,
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill.
# Bewusst klein (Speed + Sicherheit); alles andere → Claude.
@@ -1194,6 +1221,13 @@ class Agent:
if local_only:
return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]"
return None
# Metric: dieser lokale Call (echte usage-Tokens wenn der Adapter sie
# liefert). Erfasst pro Tool-Runde — mehrere Runden = mehrere Calls.
try:
metrics.log_local_call(res.get("model") or local_model, messages,
res.get("content") or "", res.get("usage"))
except Exception:
pass
tcs = res.get("tool_calls")
if tcs:
messages.append({"role": "assistant",
@@ -1210,7 +1244,7 @@ class Agent:
logger.info("[router] lokal Tool-Call: %s(%s)", tname,
", ".join(targs.keys()))
tresult = self._dispatch_tool(tname, targs)
if (tresult or "").strip().startswith("FEHLER"):
if self._local_tool_failed(tname, tresult):
had_error = True
messages.append({"role": "tool",
"tool_call_id": tc.get("id") or "",
@@ -1299,6 +1333,11 @@ class Agent:
self.conversation.add("assistant", fast_reply, project_id=active_project_id)
if active_project_id:
projects_mod.touch_project(active_project_id)
# Metric: Fast-Path spart einen ganzen Claude-Call zum Nulltarif.
try:
metrics.log_fast_path(fast_reply)
except Exception:
pass
# Fast-Path = reiner Steuerbefehl → NICHT vorlesen (speak=False).
# System-Flag statt <voice>-Tag: robust, unabhaengig vom Skill-Inhalt.
return fast_reply, "fast-path", False
+1
View File
@@ -817,6 +817,7 @@ def projects_archive(project_id: str):
class ProjectUpdateBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
hidden: Optional[bool] = None
@app.patch("/projects/{project_id}")
+54 -10
View File
@@ -52,25 +52,55 @@ def _messages_tokens(messages: list) -> int:
return total
def log_call(model: str, messages_in: list, reply_text: str = "") -> None:
"""Eine Call-Metric anhaengen. Robust gegen Fehler (silent fail)."""
def _append(model: str, tokens_in: int, tokens_out: int, source: str) -> None:
"""Ein Metric-Entry auf Disk anhaengen. Robust (silent fail)."""
try:
tokens_in = _messages_tokens(messages_in)
tokens_out = _estimate_tokens(reply_text)
line = json.dumps({
"ts": int(time.time() * 1000),
"model": model,
"in": tokens_in,
"out": tokens_out,
"in": int(tokens_in),
"out": int(tokens_out),
"source": source, # "claude" | "local" | "fast-path"
})
METRICS_FILE.parent.mkdir(parents=True, exist_ok=True)
with METRICS_FILE.open("a", encoding="utf-8") as f:
f.write(line + "\n")
# Sanftes Rotate ohne hohe IO-Kosten — nur alle 1000 Calls checken
if (tokens_in + tokens_out) % 1000 < 4:
_maybe_rotate()
except Exception as exc:
logger.warning("metrics.log_call: %s", exc)
logger.warning("metrics._append: %s", exc)
def log_call(model: str, messages_in: list, reply_text: str = "",
source: str = "claude") -> None:
"""Claude-Call-Metric anhaengen (Tokens per chars/4-Schaetzung)."""
_append(model, _messages_tokens(messages_in), _estimate_tokens(reply_text), source)
def log_local_call(model: str, messages_in: list, reply_text: str = "",
usage: dict | None = None) -> None:
"""Lokaler-LLM-Call-Metric. Nutzt echte usage-Tokens (prompt/completion)
wenn der Adapter sie liefert, sonst chars/4-Schaetzung wie bei Claude.
Quelle = 'local' — damit die Ersparnis-Rechnung local von claude trennt."""
tokens_in = tokens_out = None
if isinstance(usage, dict):
pt = usage.get("prompt_tokens")
ct = usage.get("completion_tokens")
if isinstance(pt, (int, float)):
tokens_in = int(pt)
if isinstance(ct, (int, float)):
tokens_out = int(ct)
if tokens_in is None:
tokens_in = _messages_tokens(messages_in)
if tokens_out is None:
tokens_out = _estimate_tokens(reply_text)
_append(model or "local", tokens_in, tokens_out, "local")
def log_fast_path(reply_text: str = "") -> None:
"""Fast-Path (reiner Skill, KEIN LLM) — spart einen ganzen Claude-Call zum
Nulltarif. tokens_in=0 (kein Prompt ans LLM), out = winzige Quittung."""
_append("fast-path", 0, _estimate_tokens(reply_text), "fast-path")
def _maybe_rotate() -> None:
@@ -95,6 +125,11 @@ def aggregate(window_seconds: int) -> dict:
tokens_in = 0
tokens_out = 0
by_model: dict[str, int] = {}
# Aufschluesselung nach Quelle (claude / local / fast-path) fuer die
# Ersparnis-Anzeige im Diagnostic.
def _src_bucket() -> dict:
return {"calls": 0, "tokens_in": 0, "tokens_out": 0}
by_source: dict[str, dict] = {}
if METRICS_FILE.exists():
try:
for raw in METRICS_FILE.read_text(encoding="utf-8").splitlines():
@@ -107,11 +142,19 @@ def aggregate(window_seconds: int) -> dict:
continue
if obj.get("ts", 0) < cutoff_ms:
continue
ti = int(obj.get("in") or 0)
to = int(obj.get("out") or 0)
calls += 1
tokens_in += int(obj.get("in") or 0)
tokens_out += int(obj.get("out") or 0)
tokens_in += ti
tokens_out += to
m = obj.get("model", "?")
by_model[m] = by_model.get(m, 0) + 1
# Alt-Eintraege ohne 'source' zaehlen als claude (Rueckwaerts-Kompat).
src = obj.get("source") or "claude"
b = by_source.setdefault(src, _src_bucket())
b["calls"] += 1
b["tokens_in"] += ti
b["tokens_out"] += to
except Exception as exc:
logger.warning("metrics aggregate: %s", exc)
return {
@@ -120,6 +163,7 @@ def aggregate(window_seconds: int) -> dict:
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"by_model": by_model,
"by_source": by_source,
}
+2 -1
View File
@@ -132,6 +132,7 @@ def create_project(name: str, description: str = "") -> dict:
"name": name,
"description": description.strip(),
"status": "active", # active | ended | archived
"hidden": False, # optisch aus Listen ausblenden (bleibt nutzbar)
"created_at": now,
"updated_at": now,
"last_activity_at": now,
@@ -148,7 +149,7 @@ def update_project(project_id: str, patch: dict) -> Optional[dict]:
projects = _load_all()
for p in projects:
if p["id"] == project_id:
for k in ("name", "description", "status"):
for k in ("name", "description", "status", "hidden"):
if k in patch and patch[k] is not None:
p[k] = patch[k]
p["updated_at"] = _now()
+67 -2
View File
@@ -149,6 +149,54 @@ def _num_to_words_de(n: int) -> str:
return str(n)
# Vollstaendige Zehner fuer den generischen Konverter (inkl. 60-90).
_TENS_FULL_DE = {20: "zwanzig", 30: "dreissig", 40: "vierzig", 50: "fuenfzig",
60: "sechzig", 70: "siebzig", 80: "achtzig", 90: "neunzig"}
def _below_100_de(n: int) -> str:
if n in _NUM_WORDS_DE: # 0..20
return _NUM_WORDS_DE[n]
tens = (n // 10) * 10
ones = n % 10
if ones == 0:
return _TENS_FULL_DE[tens]
ones_word = "ein" if ones == 1 else _NUM_WORDS_DE[ones]
return f"{ones_word}und{_TENS_FULL_DE[tens]}"
def _below_1000_de(n: int) -> str:
if n < 100:
return _below_100_de(n)
h, rest = divmod(n, 100)
h_word = ("ein" if h == 1 else _NUM_WORDS_DE[h]) + "hundert"
return h_word if rest == 0 else h_word + _below_100_de(rest)
def _int_to_words_de(n: int) -> str:
"""Ganzzahl 0..999999 als zusammenhaengendes deutsches Wort
('dreiundzwanzig', 'einhundert', 'zweitausendsechsundzwanzig').
Groesser (IDs/Codes) → als Ziffern lassen (der Aufrufer gated zusaetzlich)."""
if n < 0:
return "minus " + _int_to_words_de(-n)
if n < 1000:
return _below_1000_de(n)
if n < 1_000_000:
th, rest = divmod(n, 1000)
th_word = ("ein" if th == 1 else _below_1000_de(th)) + "tausend"
return th_word if rest == 0 else th_word + _below_1000_de(rest)
return str(n)
def _spell_standalone_int(m) -> str:
"""Regex-Callback: freistehende Ganzzahl ausschreiben. Sehr lange
Ziffernfolgen (IDs, Codes, Telefonnummern) bleiben Ziffern."""
s = m.group(0)
if len(s) > 6:
return s
return _int_to_words_de(int(s))
def _time_range_to_words(m):
"""'8:00-9:00 Uhr''acht bis neun Uhr', '8-9 Uhr''acht bis neun Uhr'."""
h1 = int(m.group(1))
@@ -168,7 +216,7 @@ def _decimal_to_words(m):
"""'0.1' / '0,1''null komma eins', '1,25''eins komma zwei fuenf'."""
int_part = int(m.group(1))
dec_part = m.group(2)
int_word = _num_to_words_de(int_part) if 0 <= int_part <= 59 else str(int_part)
int_word = _int_to_words_de(int_part) if int_part <= 999999 else str(int_part)
dec_words = " ".join(_num_to_words_de(int(d)) for d in dec_part)
return f"{int_word} komma {dec_words}"
@@ -184,7 +232,7 @@ _UNIT_WORDS = [
(r'\bkm\b', 'Kilometer'),
(r'\bm/s\b', 'Meter pro Sekunde'),
(r'\bkg\b', 'Kilogramm'),
(r'\b°C\b', 'Grad Celsius'),
(r'\b°C\b', ' Grad Celsius'),
(r'°C', ' Grad Celsius'),
(r'\bMbps\b', 'Megabit pro Sekunde'),
(r'\bGbps\b', 'Gigabit pro Sekunde'),
@@ -301,6 +349,15 @@ def clean_text_for_tts(text: str) -> str:
# werden, koennen bei Bedarf explizit in _UNIT_WORDS uebersteuert werden.
t = _re_tts.sub(r'\b([A-Z]{2,5})\b', lambda m: " ".join(m.group(1)), t)
# Generisches Ausschreiben ALLER verbleibenden freistehenden Ganzzahlen
# ('23' → 'dreiundzwanzig', '100' → 'einhundert', '2026' → 'zwei-
# tausendsechsundzwanzig'). Laeuft NACH Uhrzeiten/Dezimalzahlen/Einheiten,
# die ihre Ziffern schon zu Woertern gemacht haben. Tag-unabhaengig — so
# klingen auch Antworten des lokalen LLM (das keine <voice>-Tags nutzt)
# sauber. Ziffernfolgen die an ., :, / kleben (IPs, Versionen, Uhrzeit-
# Reste) ueberspringen wir, um sie nicht zu zerreissen.
t = _re_tts.sub(r'(?<![\d.,:/])\d{1,6}(?![\d.,:/])', _spell_standalone_int, t)
# Anfuehrungszeichen
t = _re_tts.sub(r'["""„`]', '', t)
@@ -1729,6 +1786,14 @@ class ARIABridge:
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
# Das 'thinking' oben ging mit der REQUEST-projectId raus, das 'idle' in
# _process_core_response aber mit der TURN-projectId (Brain kann den Turn
# umgeroutet haben, z.B. Voice-Sticky). Weichen sie ab, bekaeme der
# Request-Kontext nie sein idle → "ARIA denkt" bliebe dort haengen.
# Darum hier zusaetzlich fuer die Request-projectId ein idle.
if (project_id or "") != (turn_project_id or ""):
await self._emit_activity("idle", "", project_id=project_id)
if data.get("distilling"):
logger.info("[brain] Destillat laeuft im Hintergrund")
+90 -2
View File
@@ -1028,6 +1028,18 @@
</div>
</div>
<div class="settings-section">
<h2>Lokales LLM & Claude-Ersparnis <button class="info-btn" onclick="showInfo('local-savings')" title="Wie wird die Ersparnis gerechnet?"></button></h2>
<div class="card">
<div style="font-size:11px;color:#8888AA;margin-bottom:10px;">
Turns, die das <strong style="color:#B392F0;">lokale LLM</strong> (Qwen) oder ein reiner <strong style="color:#F0B85E;">Skill-Fast-Path</strong> uebernommen hat — jeder davon ist ein Claude-Call, der NICHT passiert ist. Die lokalen Tokens laufen auf deiner eigenen Hardware (kein Subscription-Quota).
</div>
<div id="savings-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;font-size:12px;">
<div class="metric-cell"><div class="metric-label"></div><div class="metric-value"></div></div>
</div>
</div>
</div>
<div class="settings-section">
<h2>Bootstrap & Migration <button class="info-btn" onclick="showInfo('bootstrap')" title="Was sind die drei Wege?"></button></h2>
<div class="card" style="line-height:1.6;">
@@ -1083,6 +1095,7 @@
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">📁 Projekte</h2>
<div>
<button class="btn secondary" id="toggle-hidden-btn" onclick="toggleShowHidden()" style="padding:4px 10px;font-size:11px;" title="Versteckte Projekte ein-/ausblenden">👁 Versteckte anzeigen</button>
<button class="btn secondary" onclick="loadProjects()" style="padding:4px 10px;font-size:11px;">🔄 Aktualisieren</button>
<button class="btn" onclick="openCreateProjectModal()" style="padding:4px 10px;font-size:11px;">+ Neues Projekt</button>
</div>
@@ -2990,16 +3003,28 @@
<div style="color:${!activeId ? '#34C759' : '#E0E0F0'};font-weight:600;">💬 Hauptchat ${!activeId ? '<span style="font-size:10px;font-weight:800;">✓ AKTIV</span>' : ''}</div>
<div style="color:#555570;font-size:11px;margin-top:2px;">Standard-Verlauf, keine Projekt-Zuordnung</div>
</div>`);
const showHidden = !!window.__showHidden;
let hiddenCount = 0, shownCount = 0;
for (const p of projects) {
const hidden = !!p.hidden;
if (hidden) hiddenCount++;
// Versteckte nur zeigen wenn der Toggle an ist.
if (hidden && !showHidden) continue;
shownCount++;
const isActive = p.id === activeId;
const since = p.last_activity_at ? new Date(p.last_activity_at * 1000).toLocaleString('de-DE') : '?';
const ended = p.status === 'ended';
// Auge: versteckt → 👁 (wieder sichtbar machen), sichtbar → 🙈 (verstecken).
const eyeIcon = hidden ? '👁' : '🙈';
const eyeTitle = hidden ? 'Wieder dauerhaft sichtbar machen' : 'Verstecken (aus Listen ausblenden)';
const eyeColor = hidden ? '#B392F0' : '#8888AA';
rows.push(`
<div style="padding:12px 14px;border-bottom:1px solid #1E1E2E;${isActive ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}">
<div style="padding:12px 14px;border-bottom:1px solid #1E1E2E;${isActive ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}${hidden ? 'opacity:0.55;' : ''}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;">
📁 ${escapeHtml(p.name)}
${hidden ? '🙈' : '📁'} ${escapeHtml(p.name)}
${hidden ? '<span style="color:#B392F0;font-size:10px;font-weight:700;margin-left:6px;background:rgba(179,146,240,0.15);padding:2px 6px;border-radius:3px;">versteckt</span>' : ''}
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
</div>
@@ -3007,6 +3032,7 @@
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
</div>
<div style="display:flex;gap:4px;">
<button class="btn secondary" onclick="setProjectHidden('${p.id}', ${!hidden})" style="padding:3px 8px;font-size:10px;color:${eyeColor};" title="${eyeTitle}">${eyeIcon}</button>
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden"></button>` : ''}
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
</div>
@@ -3015,13 +3041,43 @@
}
if (projects.length === 0) {
rows.push('<div style="padding:18px;color:#555570;font-size:12px;text-align:center;">Noch keine Projekte. „+ Neues Projekt" oder sag ARIA „lass uns ein Projekt anlegen".</div>');
} else if (shownCount === 0) {
rows.push(`<div style="padding:18px;color:#555570;font-size:12px;text-align:center;">Alle ${hiddenCount} Projekte sind versteckt. Klick „👁 Versteckte anzeigen".</div>`);
}
listEl.innerHTML = rows.join('');
// Toggle-Button beschriften (mit Anzahl) + Zustand spiegeln.
const thBtn = document.getElementById('toggle-hidden-btn');
if (thBtn) {
thBtn.textContent = showHidden
? `🙈 Versteckte ausblenden${hiddenCount ? ' ('+hiddenCount+')' : ''}`
: `👁 Versteckte anzeigen${hiddenCount ? ' ('+hiddenCount+')' : ''}`;
thBtn.style.opacity = hiddenCount ? '1' : '0.5';
}
} catch (e) {
listEl.innerHTML = `<div style="padding:14px;color:#FF6E6E;font-size:12px;">Fehler: ${e.message}</div>`;
}
}
function toggleShowHidden() {
window.__showHidden = !window.__showHidden;
loadProjects();
}
async function setProjectHidden(id, hidden) {
try {
const r = await fetch(`/api/brain/projects/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: !!hidden }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
// Beim Verstecken bleibt der „anzeigen"-Modus wie er ist; beim
// Wieder-Sichtbarmachen sieht man es sofort ohne Umschalten.
loadProjects();
} catch (e) { alert('Verstecken/Anzeigen fehlgeschlagen: ' + e.message); }
}
async function switchProject(projectId) {
try {
await fetch('/api/brain/projects/switch', {
@@ -5897,6 +5953,29 @@
setCell('metrics-h24', d.h24);
setCell('metrics-d30', d.d30);
// Lokales LLM & Claude-Ersparnis — pro Fenster: wie viele Claude-Calls
// durch local/fast-path vermieden wurden + lokale Token-Last.
const savingsGrid = document.getElementById('savings-grid');
if (savingsGrid) {
const wins = [['letzte 1h', d.h1], ['letzte 5h', d.h5],
['letzte 24h', d.h24], ['letzte 30 Tage', d.d30]];
const z = { calls: 0, tokens_in: 0, tokens_out: 0 };
savingsGrid.innerHTML = wins.map(([label, w]) => {
const bs = (w && w.by_source) || {};
const loc = bs.local || z;
const fp = bs['fast-path'] || z;
const saved = (loc.calls || 0) + (fp.calls || 0);
const locTok = (loc.tokens_in || 0) + (loc.tokens_out || 0);
const color = saved > 0 ? '#3FB950' : '#555570';
return `<div class="metric-cell">
<div class="metric-label">${label}</div>
<div class="metric-value" style="color:${color};">${saved} Claude-Calls gespart</div>
<div class="metric-sub">lokal ${loc.calls || 0} · fast-path ${fp.calls || 0}</div>
<div class="metric-sub">${fmtTokens(locTok)} lokale Tokens (eigene HW)</div>
</div>`;
}).join('');
}
// 5h-Fenster gegen Plan-Limit: Warn-Klassen
const plan = getActivePlanLimit();
const limit = plan.h5;
@@ -5939,6 +6018,15 @@
// Vor-definierte Info-Blocks
const INFO_TEXTS = {
'local-savings': {
title: 'Lokales LLM & Claude-Ersparnis',
html: `
<p>Jeder Turn, den das <strong>lokale LLM</strong> oder ein <strong>Fast-Path</strong> (reiner Skill, ganz ohne LLM) beantwortet, ist ein Claude-Call, der <strong>nicht</strong> gegen dein Subscription-Quota laeuft.</p>
<p><strong>„Claude-Calls gespart"</strong> = Anzahl der local- + fast-path-Antworten im Zeitfenster. Konservativ gezaehlt: 1 Antwort = mindestens 1 gesparter Claude-Call (bei Tool-Use waeren es real oft mehr).</p>
<p><strong>„lokale Tokens"</strong> = Prompt+Antwort-Tokens, die auf deiner eigenen Gamebox-GPU verarbeitet wurden (echte <code>usage</code>-Zahlen vom Modell, sonst chars/4-Schaetzung). Die kosten dich nichts ausser Strom.</p>
<p>Fast-Path-Antworten (z.B. „nächstes Lied") haben ~0 Tokens — reiner Skill-Aufruf, kein Modell.</p>
`,
},
'local-llm': {
title: 'Lokales LLM — schnelle Antworten',
html: `
+23
View File
@@ -898,6 +898,11 @@ function connectRVS(forcePlain) {
// Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
broadcast({ type: "mode", payload: msg.payload });
} else if (msg.type === "project_changed") {
// Ein Projekt wurde geaendert (ARIA-Tool, App-Verstecken, …) → an die
// Browser-Tabs weiterreichen, damit die Projektliste live neu laedt
// (bisher wurde das NICHT geforwardet → Diagnostic aktualisierte nie).
broadcast({ type: "project_changed", payload: msg.payload || {} });
} else if (msg.type === "agent_activity") {
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
@@ -2168,6 +2173,13 @@ const server = http.createServer((req, res) => {
// mehr als eine Minute.
const isUpload = /\/attachments(\/upload)?$/.test(targetPath);
const timeout = isUpload ? 120000 : 60000;
// Projekt-Mutationen (create/switch/end/archive/patch inkl. hidden) sollen
// alle Clients live aktualisieren. Wir broadcasten nach Erfolg ein
// project_changed an RVS — App + andere Diagnostic-Tabs laden dann neu,
// ohne Seiten-Refresh (spiegelt das bestehende ARIA-project_changed-Event).
const isProjectMutation =
/^\/projects\b/.test(targetPath) &&
(req.method === "POST" || req.method === "PATCH" || req.method === "DELETE");
const proxyReq = http.request({
host: "aria-brain",
port: 8080,
@@ -2178,6 +2190,17 @@ const server = http.createServer((req, res) => {
}, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
if (isProjectMutation && proxyRes.statusCode >= 200 && proxyRes.statusCode < 300) {
try {
// An App + Bridge (RVS echot NICHT an den Sender) …
sendToRVS_raw({ type: "project_changed",
payload: { reason: "diagnostic" },
timestamp: Date.now() });
// … und an die eigenen Browser-Tabs (die haengen am Diag-Server, nicht
// direkt am RVS, kriegen den RVS-Broadcast also nicht).
broadcast({ type: "project_changed", payload: { reason: "diagnostic" } });
} catch (_) {}
}
});
proxyReq.on("error", (err) => {
res.writeHead(503, { "Content-Type": "application/json" });