Compare commits

...
9 Commits
Author SHA1 Message Date
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
14 changed files with 505 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 ## [Unreleased] — 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 ### Hinzugefügt
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20005 versionCode 20006
versionName "0.2.0.5" versionName "0.2.0.6"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -5,7 +5,9 @@ import android.media.AudioAttributes
import android.media.AudioFocusRequest import android.media.AudioFocusRequest
import android.media.AudioManager import android.media.AudioManager
import android.os.Build import android.os.Build
import android.os.SystemClock
import android.util.Log import android.util.Log
import android.view.KeyEvent
import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
@@ -183,6 +185,50 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
promise.resolve(true) 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 /** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus * resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA * nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.0.5", "version": "0.2.0.6",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+61 -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 [editing, setEditing] = useState<Project | null>(null);
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = 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 // Refs damit useCallback NICHT bei jeder Re-Render des Parents neu erzeugt
// wird (parent uebergibt oft inline-arrow-Callbacks, neue Identity jedes // wird (parent uebergibt oft inline-arrow-Callbacks, neue Identity jedes
@@ -158,6 +161,12 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
]); ]);
}, [load]); }, [load]);
const toggleHidden = useCallback((p: Project) => {
brainApi.setProjectHidden(p.id, !p.hidden)
.then(() => load())
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, [load]);
const archiveProject = useCallback((p: Project) => { const archiveProject = useCallback((p: Project) => {
Alert.alert(`"${p.name}" archivieren?`, Alert.alert(`"${p.name}" archivieren?`,
'Verschwindet aus der Standardliste. Über "archivierte zeigen" erreichbar.', 'Verschwindet aus der Standardliste. Über "archivierte zeigen" erreichbar.',
@@ -176,11 +185,12 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
const renderItem = ({ item }: { item: Project }) => { const renderItem = ({ item }: { item: Project }) => {
const isActive = item.id === activeId; const isActive = item.id === activeId;
const dot = _statusDot(item.id); const dot = _statusDot(item.id);
const hidden = !!item.hidden;
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => switchTo(item.id)} onPress={() => switchTo(item.id)}
onLongPress={() => openEdit(item)} onLongPress={() => openEdit(item)}
style={[s.row, isActive && s.rowActive]} style={[s.row, isActive && s.rowActive, hidden && s.rowHidden]}
> >
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
@@ -188,6 +198,7 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} /> <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
)} )}
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text> <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>} {item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
{isActive && <Text style={s.activeBadge}> FOCUS</Text>} {isActive && <Text style={s.activeBadge}> FOCUS</Text>}
</View> </View>
@@ -199,10 +210,22 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
{dot.label ? ` · ${dot.label}` : ''} {dot.label ? ` · ${dot.label}` : ''}
</Text> </Text>
</View> </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> </TouchableOpacity>
); );
}; };
const hiddenCount = projects.filter(p => p.hidden).length;
const visibleProjects = showHidden ? projects : projects.filter(p => !p.hidden);
const body = ( const body = (
<View style={{ flex: 1, backgroundColor: '#0A0A14' }}> <View style={{ flex: 1, backgroundColor: '#0A0A14' }}>
{/* Header */} {/* Header */}
@@ -243,6 +266,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 ? ( {loading ? (
<View style={{ padding: 24, alignItems: 'center' }}> <View style={{ padding: 24, alignItems: 'center' }}>
<ActivityIndicator color="#0096FF" /> <ActivityIndicator color="#0096FF" />
@@ -251,14 +285,21 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
<Text style={s.errorText}> {err}</Text> <Text style={s.errorText}> {err}</Text>
) : ( ) : (
<FlatList <FlatList
data={projects} data={visibleProjects}
keyExtractor={p => p.id} keyExtractor={p => p.id}
renderItem={renderItem} renderItem={renderItem}
ListEmptyComponent={ ListEmptyComponent={
<Text style={s.emptyText}> projects.length > 0 ? (
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'} <Text style={s.emptyText}>
Lass uns ein Projekt 'XY' anlegen". Alle {hiddenCount} Projekte sind versteckt.{'\n'}
</Text> 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 +406,8 @@ const s = StyleSheet.create({
headerBtnText: { color: '#0096FF', fontSize: 18, fontWeight: '600' }, headerBtnText: { color: '#0096FF', fontSize: 18, fontWeight: '600' },
headerTitle: { flex: 1, textAlign: 'center', color: '#E0E0F0', fontSize: 18, fontWeight: '700' }, headerTitle: { flex: 1, textAlign: 'center', color: '#E0E0F0', fontSize: 18, fontWeight: '700' },
row: { row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
borderBottomWidth: 1, borderBottomWidth: 1,
@@ -375,6 +418,18 @@ const s = StyleSheet.create({
borderLeftWidth: 3, borderLeftWidth: 3,
borderLeftColor: '#34C759', 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' }, rowName: { color: '#E0E0F0', fontSize: 16, fontWeight: '600' },
rowDesc: { color: '#8888AA', fontSize: 13, marginTop: 4 }, rowDesc: { color: '#8888AA', fontSize: 13, marginTop: 4 },
rowMeta: { color: '#555570', fontSize: 11, 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 // ARIA hat geantwortet → Watchdog clearen, falls noch armiert
clearStuckWatchdog(); 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 // ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn // ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
// ein ACK aus Netzgruenden verloren ging, soll der Retry nicht // 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>; release: () => Promise<boolean>;
kickReleaseMedia: () => Promise<boolean>; kickReleaseMedia: () => Promise<boolean>;
getMode?: () => Promise<number>; 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?: { PcmStreamPlayer?: {
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>; 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. // damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt.
private _conversationFocusActive: boolean = false; 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 // VAD State
private vadEnabled: boolean = false; private vadEnabled: boolean = false;
private lastSpeechTime: number = 0; private lastSpeechTime: number = 0;
@@ -450,15 +462,29 @@ class AudioService {
import('./logger').then(m => m.reportAppDebug('audio.focus', import('./logger').then(m => m.reportAppDebug('audio.focus',
'AudioFocus.release() now')).catch(()=>{}); 'AudioFocus.release() now')).catch(()=>{});
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack // Spotify-Resume: NUR wenn vor dem Gespraech wirklich Musik lief. Dann
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei // einen echten MEDIA_PLAY-KeyEvent an die aktive MediaSession schicken
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss. // (wie die Play-Taste am Kopfhoerer) — das resumt Spotify zuverlaessig,
// 50ms Delay damit das Abandon erst durch ist. // im Gegensatz zum flakigen Focus-Stack-Nudge, der auf manchen Geraeten
setTimeout(() => { // (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', import('./logger').then(m => m.reportAppDebug('audio.focus',
'nudgeMediaResume() now (50ms after release)')).catch(()=>{}); 'kein Resume (vor Dialog lief keine Musik)')).catch(()=>{});
AudioFocus?.nudgeMediaResume().catch(() => {}); }
}, 50);
}, this.FOCUS_RELEASE_DELAY_MS); }, 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 /** Conversation-Mode beginnt → AudioFocus dauerhaft halten (Spotify bleibt
* pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */ * pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */
acquireConversationFocus(): void { acquireConversationFocus(): void {
@@ -476,7 +516,11 @@ class AudioService {
this._conversationFocusActive = true; this._conversationFocusActive = true;
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)'); 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 /** Conversation-Mode endet → Focus darf wieder freigegeben werden
@@ -493,6 +537,8 @@ class AudioService {
haltAllPlayback(reason: string = ''): void { haltAllPlayback(reason: string = ''): void {
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)'); console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
this._conversationFocusActive = false; this._conversationFocusActive = false;
// Barge-In → User spricht gleich weiter, Spotify NICHT resumen.
this._mediaWasActiveAtAcquire = false;
this.stopPlayback(); this.stopPlayback();
} }
@@ -503,6 +549,8 @@ class AudioService {
pauseForCall(reason: string = ''): void { pauseForCall(reason: string = ''): void {
console.log('[Audio] pauseForCall: %s', reason || '(no reason)'); console.log('[Audio] pauseForCall: %s', reason || '(no reason)');
this._conversationFocusActive = false; this._conversationFocusActive = false;
// Anruf → Spotify bleibt aus, kein Auto-Resume beim spaeteren Release.
this._mediaWasActiveAtAcquire = false;
this._pausedForCall = true; this._pausedForCall = true;
// Queue + isPlaying ruecksetzen — sonst klemmt der naechste Play-Button // Queue + isPlaying ruecksetzen — sonst klemmt der naechste Play-Button
// (playAudio sieht isPlaying=true und ruft _playNext nicht mehr auf). // (playAudio sieht isPlaying=true und ruft _playNext nicht mehr auf).
@@ -796,6 +844,8 @@ class AudioService {
this.setState('recording'); this.setState('recording');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.) // 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(); this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {}); AudioFocus?.requestExclusive().catch(() => {});
@@ -1043,6 +1093,8 @@ class AudioService {
} }
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad. // AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {}); AudioFocus?.requestExclusive().catch(() => {});
+11 -2
View File
@@ -157,6 +157,7 @@ export interface Project {
name: string; name: string;
description: string; description: string;
status: 'active' | 'ended' | 'archived'; status: 'active' | 'ended' | 'archived';
hidden?: boolean; // aus Listen ausgeblendet (bleibt nutzbar)
created_at: number; created_at: number;
updated_at: number; updated_at: number;
last_activity_at: number; last_activity_at: number;
@@ -593,14 +594,22 @@ export const brainApi = {
}); });
}, },
/** Projekt-Metadaten patchen (name / description). */ /** Projekt-Metadaten patchen (name / description / hidden). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description'>>): Promise<Project> { updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden'>>): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, { return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH', method: 'PATCH',
body: 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) /** Queue-Status: pro Kontext (project_id oder __main__ fuer Hauptchat)
* ob gerade ein Request in Verarbeitung ist + wieviele in der Queue warten. * ob gerade ein Request in Verarbeitung ist + wieviele in der Queue warten.
* Wird fuer Status-Dots im Drawer periodisch gepollt. */ * 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 prompts import build_system_prompt, IDENTITY_SEED, IDENTITY_ANCHOR, looks_like_identity_break
from proxy_client import ProxyClient, Message as ProxyMessage from proxy_client import ProxyClient, Message as ProxyMessage
import router as router_mod import router as router_mod
import metrics
from local_llm import local_llm_chat from local_llm import local_llm_chat
import skills as skills_mod import skills as skills_mod
import triggers as triggers_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_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed)
_LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude _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, # Kuratierte Tool-Auswahl fuers lokale Tier (B1b): web_search (local-only,
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill. # SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill.
# Bewusst klein (Speed + Sicherheit); alles andere → Claude. # Bewusst klein (Speed + Sicherheit); alles andere → Claude.
@@ -1194,6 +1221,13 @@ class Agent:
if local_only: if local_only:
return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]" return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]"
return None 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") tcs = res.get("tool_calls")
if tcs: if tcs:
messages.append({"role": "assistant", messages.append({"role": "assistant",
@@ -1210,7 +1244,7 @@ class Agent:
logger.info("[router] lokal Tool-Call: %s(%s)", tname, logger.info("[router] lokal Tool-Call: %s(%s)", tname,
", ".join(targs.keys())) ", ".join(targs.keys()))
tresult = self._dispatch_tool(tname, targs) tresult = self._dispatch_tool(tname, targs)
if (tresult or "").strip().startswith("FEHLER"): if self._local_tool_failed(tname, tresult):
had_error = True had_error = True
messages.append({"role": "tool", messages.append({"role": "tool",
"tool_call_id": tc.get("id") or "", "tool_call_id": tc.get("id") or "",
@@ -1299,6 +1333,11 @@ class Agent:
self.conversation.add("assistant", fast_reply, project_id=active_project_id) self.conversation.add("assistant", fast_reply, project_id=active_project_id)
if active_project_id: if active_project_id:
projects_mod.touch_project(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). # Fast-Path = reiner Steuerbefehl → NICHT vorlesen (speak=False).
# System-Flag statt <voice>-Tag: robust, unabhaengig vom Skill-Inhalt. # System-Flag statt <voice>-Tag: robust, unabhaengig vom Skill-Inhalt.
return fast_reply, "fast-path", False return fast_reply, "fast-path", False
+1
View File
@@ -817,6 +817,7 @@ def projects_archive(project_id: str):
class ProjectUpdateBody(BaseModel): class ProjectUpdateBody(BaseModel):
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
hidden: Optional[bool] = None
@app.patch("/projects/{project_id}") @app.patch("/projects/{project_id}")
+54 -10
View File
@@ -52,25 +52,55 @@ def _messages_tokens(messages: list) -> int:
return total return total
def log_call(model: str, messages_in: list, reply_text: str = "") -> None: def _append(model: str, tokens_in: int, tokens_out: int, source: str) -> None:
"""Eine Call-Metric anhaengen. Robust gegen Fehler (silent fail).""" """Ein Metric-Entry auf Disk anhaengen. Robust (silent fail)."""
try: try:
tokens_in = _messages_tokens(messages_in)
tokens_out = _estimate_tokens(reply_text)
line = json.dumps({ line = json.dumps({
"ts": int(time.time() * 1000), "ts": int(time.time() * 1000),
"model": model, "model": model,
"in": tokens_in, "in": int(tokens_in),
"out": tokens_out, "out": int(tokens_out),
"source": source, # "claude" | "local" | "fast-path"
}) })
METRICS_FILE.parent.mkdir(parents=True, exist_ok=True) METRICS_FILE.parent.mkdir(parents=True, exist_ok=True)
with METRICS_FILE.open("a", encoding="utf-8") as f: with METRICS_FILE.open("a", encoding="utf-8") as f:
f.write(line + "\n") f.write(line + "\n")
# Sanftes Rotate ohne hohe IO-Kosten — nur alle 1000 Calls checken
if (tokens_in + tokens_out) % 1000 < 4: if (tokens_in + tokens_out) % 1000 < 4:
_maybe_rotate() _maybe_rotate()
except Exception as exc: 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: def _maybe_rotate() -> None:
@@ -95,6 +125,11 @@ def aggregate(window_seconds: int) -> dict:
tokens_in = 0 tokens_in = 0
tokens_out = 0 tokens_out = 0
by_model: dict[str, int] = {} 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(): if METRICS_FILE.exists():
try: try:
for raw in METRICS_FILE.read_text(encoding="utf-8").splitlines(): for raw in METRICS_FILE.read_text(encoding="utf-8").splitlines():
@@ -107,11 +142,19 @@ def aggregate(window_seconds: int) -> dict:
continue continue
if obj.get("ts", 0) < cutoff_ms: if obj.get("ts", 0) < cutoff_ms:
continue continue
ti = int(obj.get("in") or 0)
to = int(obj.get("out") or 0)
calls += 1 calls += 1
tokens_in += int(obj.get("in") or 0) tokens_in += ti
tokens_out += int(obj.get("out") or 0) tokens_out += to
m = obj.get("model", "?") m = obj.get("model", "?")
by_model[m] = by_model.get(m, 0) + 1 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: except Exception as exc:
logger.warning("metrics aggregate: %s", exc) logger.warning("metrics aggregate: %s", exc)
return { return {
@@ -120,6 +163,7 @@ def aggregate(window_seconds: int) -> dict:
"tokens_in": tokens_in, "tokens_in": tokens_in,
"tokens_out": tokens_out, "tokens_out": tokens_out,
"by_model": by_model, "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, "name": name,
"description": description.strip(), "description": description.strip(),
"status": "active", # active | ended | archived "status": "active", # active | ended | archived
"hidden": False, # optisch aus Listen ausblenden (bleibt nutzbar)
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
"last_activity_at": now, "last_activity_at": now,
@@ -148,7 +149,7 @@ def update_project(project_id: str, patch: dict) -> Optional[dict]:
projects = _load_all() projects = _load_all()
for p in projects: for p in projects:
if p["id"] == project_id: 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: if k in patch and patch[k] is not None:
p[k] = patch[k] p[k] = patch[k]
p["updated_at"] = _now() p["updated_at"] = _now()
+67 -2
View File
@@ -149,6 +149,54 @@ def _num_to_words_de(n: int) -> str:
return str(n) 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): def _time_range_to_words(m):
"""'8:00-9:00 Uhr''acht bis neun Uhr', '8-9 Uhr''acht bis neun Uhr'.""" """'8:00-9:00 Uhr''acht bis neun Uhr', '8-9 Uhr''acht bis neun Uhr'."""
h1 = int(m.group(1)) 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'.""" """'0.1' / '0,1''null komma eins', '1,25''eins komma zwei fuenf'."""
int_part = int(m.group(1)) int_part = int(m.group(1))
dec_part = m.group(2) 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) dec_words = " ".join(_num_to_words_de(int(d)) for d in dec_part)
return f"{int_word} komma {dec_words}" return f"{int_word} komma {dec_words}"
@@ -184,7 +232,7 @@ _UNIT_WORDS = [
(r'\bkm\b', 'Kilometer'), (r'\bkm\b', 'Kilometer'),
(r'\bm/s\b', 'Meter pro Sekunde'), (r'\bm/s\b', 'Meter pro Sekunde'),
(r'\bkg\b', 'Kilogramm'), (r'\bkg\b', 'Kilogramm'),
(r'\b°C\b', 'Grad Celsius'), (r'\b°C\b', ' Grad Celsius'),
(r'°C', ' Grad Celsius'), (r'°C', ' Grad Celsius'),
(r'\bMbps\b', 'Megabit pro Sekunde'), (r'\bMbps\b', 'Megabit pro Sekunde'),
(r'\bGbps\b', 'Gigabit 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. # 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) 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 # Anfuehrungszeichen
t = _re_tts.sub(r'["""„`]', '', t) t = _re_tts.sub(r'["""„`]', '', t)
@@ -1729,6 +1786,14 @@ class ARIABridge:
"timestamp": int(asyncio.get_event_loop().time() * 1000), "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"): if data.get("distilling"):
logger.info("[brain] Destillat laeuft im Hintergrund") logger.info("[brain] Destillat laeuft im Hintergrund")
+90 -2
View File
@@ -1028,6 +1028,18 @@
</div> </div>
</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"> <div class="settings-section">
<h2>Bootstrap & Migration <button class="info-btn" onclick="showInfo('bootstrap')" title="Was sind die drei Wege?"></button></h2> <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;"> <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;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">📁 Projekte</h2> <h2 style="margin:0;">📁 Projekte</h2>
<div> <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 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> <button class="btn" onclick="openCreateProjectModal()" style="padding:4px 10px;font-size:11px;">+ Neues Projekt</button>
</div> </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:${!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 style="color:#555570;font-size:11px;margin-top:2px;">Standard-Verlauf, keine Projekt-Zuordnung</div>
</div>`); </div>`);
const showHidden = !!window.__showHidden;
let hiddenCount = 0, shownCount = 0;
for (const p of projects) { 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 isActive = p.id === activeId;
const since = p.last_activity_at ? new Date(p.last_activity_at * 1000).toLocaleString('de-DE') : '?'; const since = p.last_activity_at ? new Date(p.last_activity_at * 1000).toLocaleString('de-DE') : '?';
const ended = p.status === 'ended'; 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(` 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 style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;"> <div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;"> <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>' : ''} ${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>' : ''} ${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
</div> </div>
@@ -3007,6 +3032,7 @@
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div> <div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
</div> </div>
<div style="display:flex;gap:4px;"> <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>` : ''} ${!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> <button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
</div> </div>
@@ -3015,13 +3041,43 @@
} }
if (projects.length === 0) { 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>'); 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(''); 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) { } catch (e) {
listEl.innerHTML = `<div style="padding:14px;color:#FF6E6E;font-size:12px;">Fehler: ${e.message}</div>`; 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) { async function switchProject(projectId) {
try { try {
await fetch('/api/brain/projects/switch', { await fetch('/api/brain/projects/switch', {
@@ -5897,6 +5953,29 @@
setCell('metrics-h24', d.h24); setCell('metrics-h24', d.h24);
setCell('metrics-d30', d.d30); 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 // 5h-Fenster gegen Plan-Limit: Warn-Klassen
const plan = getActivePlanLimit(); const plan = getActivePlanLimit();
const limit = plan.h5; const limit = plan.h5;
@@ -5939,6 +6018,15 @@
// Vor-definierte Info-Blocks // Vor-definierte Info-Blocks
const INFO_TEXTS = { 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': { 'local-llm': {
title: 'Lokales LLM — schnelle Antworten', title: 'Lokales LLM — schnelle Antworten',
html: ` html: `