Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aefdff89dc | ||
|
|
f99e90f524 | ||
|
|
1dd47888a8 | ||
|
|
8b22557ca5 | ||
|
|
bd78f384a0 | ||
|
|
14bdd4dbf4 | ||
|
|
bbfa1f73f6 | ||
|
|
fed3cb9f18 | ||
|
|
9ec9119bd9 | ||
|
|
06d0064c8c | ||
|
|
a7cde52ee6 | ||
|
|
a353b62b37 | ||
|
|
bc47c2053b | ||
|
|
dc043ceb4d | ||
|
|
8bac7c56bc | ||
|
|
3b6d36f2de | ||
|
|
7f6e266d15 | ||
|
|
4d1664a328 |
+54
-1
@@ -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
|
### Hinzugefügt
|
||||||
|
|
||||||
|
|||||||
@@ -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 20008
|
||||||
versionName "0.2.0.5"
|
versionName "0.2.0.8"
|
||||||
// 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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.0.5",
|
"version": "0.2.0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -109,6 +112,17 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
|
|||||||
return () => unsub();
|
return () => unsub();
|
||||||
}, [visible, load]);
|
}, [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) => {
|
const switchTo = useCallback((id: string) => {
|
||||||
// Multi-Threading: Focus-Wechsel ist reine App-lokale UI-Entscheidung.
|
// Multi-Threading: Focus-Wechsel ist reine App-lokale UI-Entscheidung.
|
||||||
// Brain wird nicht mehr benachrichtigt (kein globaler active_project mehr).
|
// 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]);
|
}, [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) => {
|
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 +203,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 +216,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 +228,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 +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 ? (
|
{loading ? (
|
||||||
<View style={{ padding: 24, alignItems: 'center' }}>
|
<View style={{ padding: 24, alignItems: 'center' }}>
|
||||||
<ActivityIndicator color="#0096FF" />
|
<ActivityIndicator color="#0096FF" />
|
||||||
@@ -251,14 +303,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={
|
||||||
|
projects.length > 0 ? (
|
||||||
|
<Text style={s.emptyText}>
|
||||||
|
Alle {hiddenCount} Projekte sind versteckt.{'\n'}
|
||||||
|
Tipp „👁 Versteckte anzeigen".
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
<Text style={s.emptyText}>
|
<Text style={s.emptyText}>
|
||||||
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'}
|
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'}
|
||||||
„Lass uns ein Projekt 'XY' anlegen".
|
„Lass uns ein Projekt 'XY' anlegen".
|
||||||
</Text>
|
</Text>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -365,6 +424,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 +436,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 },
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import VoiceButton from '../components/VoiceButton';
|
|||||||
import FileUpload, { FileData } from '../components/FileUpload';
|
import FileUpload, { FileData } from '../components/FileUpload';
|
||||||
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
||||||
import MessageText from '../components/MessageText';
|
import MessageText from '../components/MessageText';
|
||||||
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio';
|
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs } from '../services/audio';
|
||||||
import Geolocation from '@react-native-community/geolocation';
|
import Geolocation from '@react-native-community/geolocation';
|
||||||
|
|
||||||
// --- Typen ---
|
// --- Typen ---
|
||||||
@@ -562,6 +562,14 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
|
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
wakeWordService.loadFromStorage().catch(() => {});
|
wakeWordService.loadFromStorage().catch(() => {});
|
||||||
|
// Mikro-Release-Hook: vor jedem Re-Arm eine laufende (passive) Streaming-
|
||||||
|
// Aufnahme canceln, sonst haelt sie das Mikro und OpenWakeWord.start()
|
||||||
|
// schlaegt fehl → Ohr bleibt ausgegraut (state=off).
|
||||||
|
wakeWordService.setMicReleaseHook(async () => {
|
||||||
|
if (audioService.isStreamingRecording()) {
|
||||||
|
await audioService.cancelStreamingRecording('rearm-mic-free');
|
||||||
|
}
|
||||||
|
});
|
||||||
const unsub = wakeWordService.onStateChange((s) => {
|
const unsub = wakeWordService.onStateChange((s) => {
|
||||||
setWakeWordState(s);
|
setWakeWordState(s);
|
||||||
setWakeWordActive(s !== 'off');
|
setWakeWordActive(s !== 'off');
|
||||||
@@ -1172,6 +1180,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
|
||||||
@@ -1450,7 +1473,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
interrupted: wasInterrupted,
|
interrupted: wasInterrupted,
|
||||||
location: location || null,
|
location: location || null,
|
||||||
noSpeechTimeoutMs: windowMs,
|
noSpeechTimeoutMs: windowMs,
|
||||||
endpointMs: 1500,
|
endpointMs: await loadSttEndpointMs(),
|
||||||
hardCapMs: 60000,
|
hardCapMs: 60000,
|
||||||
projectId: focusedProjectIdRef.current,
|
projectId: focusedProjectIdRef.current,
|
||||||
});
|
});
|
||||||
@@ -1546,7 +1569,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
|
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
|
||||||
location: location || null,
|
location: location || null,
|
||||||
noSpeechTimeoutMs: windowMs,
|
noSpeechTimeoutMs: windowMs,
|
||||||
endpointMs: 1500,
|
endpointMs: await loadSttEndpointMs(),
|
||||||
hardCapMs: 60000,
|
hardCapMs: 60000,
|
||||||
projectId: focusedProjectIdRef.current,
|
projectId: focusedProjectIdRef.current,
|
||||||
});
|
});
|
||||||
@@ -1603,7 +1626,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
interrupted: false,
|
interrupted: false,
|
||||||
location: location || null,
|
location: location || null,
|
||||||
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
|
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
|
||||||
endpointMs: 1500,
|
endpointMs: await loadSttEndpointMs(),
|
||||||
hardCapMs: Math.max(passiveMs + 5000, 35000),
|
hardCapMs: Math.max(passiveMs + 5000, 35000),
|
||||||
projectId: focusedProjectIdRef.current,
|
projectId: focusedProjectIdRef.current,
|
||||||
});
|
});
|
||||||
@@ -2013,7 +2036,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Stoppen). Hard-Cap 5 Minuten als Notbremse — danach killt Whisper
|
// Stoppen). Hard-Cap 5 Minuten als Notbremse — danach killt Whisper
|
||||||
// die Session auch app-seitig haben wir +2s Toleranz.
|
// die Session auch app-seitig haben wir +2s Toleranz.
|
||||||
noSpeechTimeoutMs: 0,
|
noSpeechTimeoutMs: 0,
|
||||||
endpointMs: 1500,
|
endpointMs: await loadSttEndpointMs(),
|
||||||
hardCapMs: 300000,
|
hardCapMs: 300000,
|
||||||
projectId: focusedProjectIdRef.current,
|
projectId: focusedProjectIdRef.current,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -146,6 +151,26 @@ export const CONV_WINDOW_MIN_SEC = 3.0;
|
|||||||
export const CONV_WINDOW_MAX_SEC = 20.0;
|
export const CONV_WINDOW_MAX_SEC = 20.0;
|
||||||
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
|
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
|
||||||
|
|
||||||
|
// STT-Endpoint (ms Stille bis "fertig gesprochen"). Zu kurz = schneidet mitten
|
||||||
|
// im Satz ab, besonders im Auto wo man mit Pausen spricht (Reproduktion: die
|
||||||
|
// 11.8s-Frage wurde bei "…ohne dass ein" gekappt). 1500 war zu aggressiv;
|
||||||
|
// 2400 default, im Auto ggf. hoeher. Konfigurierbar in den Settings.
|
||||||
|
export const STT_ENDPOINT_DEFAULT_MS = 2400;
|
||||||
|
export const STT_ENDPOINT_MIN_MS = 1000;
|
||||||
|
export const STT_ENDPOINT_MAX_MS = 4000;
|
||||||
|
export const STT_ENDPOINT_STORAGE_KEY = 'aria_stt_endpoint_ms';
|
||||||
|
|
||||||
|
export async function loadSttEndpointMs(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
|
||||||
|
if (raw != null) {
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
if (isFinite(n) && n >= STT_ENDPOINT_MIN_MS && n <= STT_ENDPOINT_MAX_MS) return n;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return STT_ENDPOINT_DEFAULT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
// TTS-Wiedergabegeschwindigkeit — wird pro Geraet gespeichert und an die
|
// TTS-Wiedergabegeschwindigkeit — wird pro Geraet gespeichert und an die
|
||||||
// Bridge mitgegeben (speed-Param im F5-TTS infer()). 1.0 = normal.
|
// Bridge mitgegeben (speed-Param im F5-TTS infer()). 1.0 = normal.
|
||||||
export const TTS_SPEED_DEFAULT = 1.0;
|
export const TTS_SPEED_DEFAULT = 1.0;
|
||||||
@@ -275,6 +300,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 +482,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
|
||||||
|
// (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(() => {
|
setTimeout(() => {
|
||||||
import('./logger').then(m => m.reportAppDebug('audio.focus',
|
import('./logger').then(m => m.reportAppDebug('audio.focus',
|
||||||
'nudgeMediaResume() now (50ms after release)')).catch(()=>{});
|
'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(() => {});
|
AudioFocus?.nudgeMediaResume().catch(() => {});
|
||||||
}, 50);
|
}
|
||||||
|
}, 120);
|
||||||
|
} else {
|
||||||
|
import('./logger').then(m => m.reportAppDebug('audio.focus',
|
||||||
|
'kein Resume (vor Dialog lief keine Musik)')).catch(()=>{});
|
||||||
|
}
|
||||||
}, this.FOCUS_RELEASE_DELAY_MS);
|
}, this.FOCUS_RELEASE_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +515,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 +536,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)');
|
||||||
|
// Erst pruefen ob Musik laeuft, DANN ducken (requestDuck wuerde sie sonst
|
||||||
|
// schon pausieren bevor wir es messen koennen).
|
||||||
|
this._captureMediaActive().finally(() => {
|
||||||
AudioFocus?.requestDuck().catch(() => {});
|
AudioFocus?.requestDuck().catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Conversation-Mode endet → Focus darf wieder freigegeben werden
|
/** Conversation-Mode endet → Focus darf wieder freigegeben werden
|
||||||
@@ -493,6 +557,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 +569,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 +864,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 +1113,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(() => {});
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ class WakeWordService {
|
|||||||
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
|
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
|
||||||
private passiveListenCallbacks: PassiveListenCallback[] = [];
|
private passiveListenCallbacks: PassiveListenCallback[] = [];
|
||||||
|
|
||||||
|
/** Hook, der das Mikro freigibt (laufende Streaming-Aufnahme canceln) BEVOR
|
||||||
|
* wir OpenWakeWord.start() rufen. Ohne das haelt die passive/conversing
|
||||||
|
* Aufnahme das Mikro noch, start() schlaegt fehl → Ohr bleibt ausgegraut
|
||||||
|
* (state=off). ChatScreen registriert den Hook mit audioService.cancel…. */
|
||||||
|
private micReleaseHook: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
||||||
private nativeReady: boolean = false;
|
private nativeReady: boolean = false;
|
||||||
private initInProgress: Promise<boolean> | null = null;
|
private initInProgress: Promise<boolean> | null = null;
|
||||||
@@ -149,6 +155,19 @@ class WakeWordService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ChatScreen registriert hier einen Hook, der eine laufende Streaming-
|
||||||
|
* Aufnahme cancelt (Mikro freigeben) — wird vor jedem Re-Arm gerufen. */
|
||||||
|
setMicReleaseHook(fn: (() => Promise<void>) | null): void {
|
||||||
|
this.micReleaseHook = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _freeMic(): Promise<void> {
|
||||||
|
if (!this.micReleaseHook) return;
|
||||||
|
try { await this.micReleaseHook(); } catch (e) {
|
||||||
|
console.warn('[WakeWord] micReleaseHook err:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */
|
/** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */
|
||||||
async configure(keyword: string): Promise<boolean> {
|
async configure(keyword: string): Promise<boolean> {
|
||||||
const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword)
|
const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword)
|
||||||
@@ -454,6 +473,7 @@ class WakeWordService {
|
|||||||
// als state extra zu fragen, garantiert dass nach diesem Pfad
|
// als state extra zu fragen, garantiert dass nach diesem Pfad
|
||||||
// Native auch wirklich an ist falls es out-of-band gestoppt wurde.
|
// Native auch wirklich an ist falls es out-of-band gestoppt wurde.
|
||||||
try {
|
try {
|
||||||
|
await this._freeMic(); // Streaming-Aufnahme canceln → Mikro frei
|
||||||
await OpenWakeWord.start();
|
await OpenWakeWord.start();
|
||||||
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge);
|
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge);
|
||||||
import('./logger').then(m => m.reportAppDebug('wake.end',
|
import('./logger').then(m => m.reportAppDebug('wake.end',
|
||||||
@@ -520,6 +540,7 @@ class WakeWordService {
|
|||||||
// timeout oder manual → Wake-Word reaktivieren, armed-State.
|
// timeout oder manual → Wake-Word reaktivieren, armed-State.
|
||||||
if (this.nativeReady && OpenWakeWord) {
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
try {
|
try {
|
||||||
|
await this._freeMic(); // passive Streaming-Aufnahme canceln → Mikro frei
|
||||||
await OpenWakeWord.start();
|
await OpenWakeWord.start();
|
||||||
console.log('[WakeWord] zurueck zu armed nach passive-listen');
|
console.log('[WakeWord] zurueck zu armed nach passive-listen');
|
||||||
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
||||||
@@ -564,6 +585,7 @@ class WakeWordService {
|
|||||||
this.lastTriggerAt = 0;
|
this.lastTriggerAt = 0;
|
||||||
if (this.nativeReady && OpenWakeWord) {
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
try {
|
try {
|
||||||
|
await this._freeMic(); // ggf. laufende Aufnahme canceln → Mikro frei
|
||||||
await OpenWakeWord.start();
|
await OpenWakeWord.start();
|
||||||
ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT);
|
ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT);
|
||||||
this.setState('armed');
|
this.setState('armed');
|
||||||
|
|||||||
+72
-4
@@ -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
|
||||||
@@ -1017,10 +1018,36 @@ META_TOOLS = [
|
|||||||
# Diese Logik ist generisch — ARIA deklariert die Patterns selbst beim
|
# Diese Logik ist generisch — ARIA deklariert die Patterns selbst beim
|
||||||
# skill_create / skill_update, das Brain orchestriert nur.
|
# skill_create / skill_update, das Brain orchestriert nur.
|
||||||
|
|
||||||
|
def _strip_leading_hint_blocks(text: str) -> str:
|
||||||
|
"""Entfernt fuehrende Hint-Bloecke `[ ... ]`, die die Bridge an
|
||||||
|
Voice-Nachrichten haengt (GPS-Position, Barge-In-Hinweis). Ohne das matcht
|
||||||
|
KEIN Voice-Befehl je den Fast-Path (Regex ist ^...$-verankert) — selbst
|
||||||
|
'nächstes lied' landete unnoetig bei Claude statt beim 0-Token-Fast-Path."""
|
||||||
|
s = (text or "").strip()
|
||||||
|
prev = None
|
||||||
|
while prev != s:
|
||||||
|
prev = s
|
||||||
|
s = re.sub(r"^\s*\[[^\]]*\]\s*", "", s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_umlauts(s: str) -> str:
|
||||||
|
"""ä→ae, ö→oe, ü→ue, ß→ss (lowercase erwartet). Whisper liefert echte
|
||||||
|
Umlaute ('Nächstes Lied'), viele Skill-fast_patterns sind aber in ae/oe/ue
|
||||||
|
geschrieben — ohne Faltung matcht das nie."""
|
||||||
|
return (s.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
|
||||||
|
.replace("ß", "ss"))
|
||||||
|
|
||||||
|
|
||||||
def _normalize_for_fast_match(text: str) -> str:
|
def _normalize_for_fast_match(text: str) -> str:
|
||||||
norm = (text or "").strip().lower()
|
norm = _strip_leading_hint_blocks(text).lower()
|
||||||
norm = re.sub(r"[.!?]+$", "", norm)
|
norm = _fold_umlauts(norm)
|
||||||
norm = re.sub(r"\s+", " ", norm)
|
# Interne Satzzeichen raus, die Whisper einstreut ('Nächstes Lied, bitte.').
|
||||||
|
# Kommas/Semikola/Doppelpunkte → Space, Punkt/!/? ganz weg. Sonst matcht das
|
||||||
|
# ^...$-verankerte fast_pattern nie, weil das Komma dazwischenfunkt.
|
||||||
|
norm = re.sub(r"[,;:]", " ", norm)
|
||||||
|
norm = re.sub(r"[.!?]+", "", norm)
|
||||||
|
norm = re.sub(r"\s+", " ", norm).strip()
|
||||||
return norm
|
return norm
|
||||||
|
|
||||||
|
|
||||||
@@ -1116,6 +1143,9 @@ class Agent:
|
|||||||
rx = pat.get("match") or ""
|
rx = pat.get("match") or ""
|
||||||
if not rx:
|
if not rx:
|
||||||
continue
|
continue
|
||||||
|
# Pattern GLEICH falten wie den Text — egal ob der Skill-Autor
|
||||||
|
# 'naechstes' oder 'nächstes' geschrieben hat.
|
||||||
|
rx = _fold_umlauts(rx)
|
||||||
try:
|
try:
|
||||||
if not re.match(rx, norm, re.IGNORECASE):
|
if not re.match(rx, norm, re.IGNORECASE):
|
||||||
continue
|
continue
|
||||||
@@ -1150,6 +1180,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 +1250,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 +1273,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 +1362,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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+12
-1
@@ -87,6 +87,17 @@ _HARD_HINTS = re.compile(
|
|||||||
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
|
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_leading_hint_blocks(text: str) -> str:
|
||||||
|
"""Fuehrende `[ ... ]`-Hint-Bloecke (GPS, Barge-In von der Bridge) weg —
|
||||||
|
sonst blaeht der Praefix die Laenge auf und verfaelscht die Heuristik."""
|
||||||
|
s = (text or "").strip()
|
||||||
|
prev = None
|
||||||
|
while prev != s:
|
||||||
|
prev = s
|
||||||
|
s = re.sub(r"^\s*\[[^\]]*\]\s*", "", s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
def should_try_local(user_message: str, cfg: dict) -> bool:
|
def should_try_local(user_message: str, cfg: dict) -> bool:
|
||||||
"""True, wenn der Router diesen Turn (B1a, reden-only) lokal versuchen soll.
|
"""True, wenn der Router diesen Turn (B1a, reden-only) lokal versuchen soll.
|
||||||
localOnly überschreibt die Heuristik (dann IMMER lokal)."""
|
localOnly überschreibt die Heuristik (dann IMMER lokal)."""
|
||||||
@@ -94,7 +105,7 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
|
|||||||
return False
|
return False
|
||||||
if cfg.get("localOnly"):
|
if cfg.get("localOnly"):
|
||||||
return True
|
return True
|
||||||
msg = (user_message or "").strip()
|
msg = _strip_leading_hint_blocks(user_message)
|
||||||
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
|
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
|
||||||
return False
|
return False
|
||||||
if _TOOL_HINTS.search(msg):
|
if _TOOL_HINTS.search(msg):
|
||||||
|
|||||||
+85
-2
@@ -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")
|
||||||
|
|
||||||
@@ -2941,6 +3006,24 @@ class ARIABridge:
|
|||||||
if self._is_duplicate_client_msg(client_msg_id):
|
if self._is_duplicate_client_msg(client_msg_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Text-Fenster-Dedup: im Auto cancelt der App-No-Speech-Watchdog den
|
||||||
|
# Stream vorzeitig und startet passives Lauschen, waehrend die Bridge
|
||||||
|
# dieselbe Aeusserung parallel noch finalisiert → derselbe Befehl
|
||||||
|
# kaeme zweimal (aus ZWEI verschiedenen audioRequestIds, darum greift
|
||||||
|
# die ID-Idempotenz oben nicht). Identischen Endpoint-Text innerhalb
|
||||||
|
# 5s verwerfen. Genuines "nächstes, nächstes" liegt praktisch immer
|
||||||
|
# weiter auseinander (man hoert den Wechsel erst).
|
||||||
|
_norm_txt = " ".join(text.lower().split())
|
||||||
|
_now_t = asyncio.get_event_loop().time()
|
||||||
|
if (_norm_txt
|
||||||
|
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
|
||||||
|
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < 5.0):
|
||||||
|
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <5s): %r",
|
||||||
|
text[:60])
|
||||||
|
return
|
||||||
|
self._last_endpoint_norm = _norm_txt
|
||||||
|
self._last_endpoint_at = _now_t
|
||||||
|
|
||||||
# App-Focus aus stt_stream_start-Registry auflösen (falls die
|
# App-Focus aus stt_stream_start-Registry auflösen (falls die
|
||||||
# App-Version die projectId noch nicht mitschickt: leer = Hauptchat).
|
# App-Version die projectId noch nicht mitschickt: leer = Hauptchat).
|
||||||
stream_req_id = payload.get("requestId", "") or ""
|
stream_req_id = payload.get("requestId", "") or ""
|
||||||
|
|||||||
+156
-6
@@ -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>
|
||||||
@@ -1411,6 +1424,7 @@
|
|||||||
let focusedContextId = localStorage.getItem('diag_focused_context_id') || '';
|
let focusedContextId = localStorage.getItem('diag_focused_context_id') || '';
|
||||||
let diagQueueStatus = {};
|
let diagQueueStatus = {};
|
||||||
let diagProjectsCache = [];
|
let diagProjectsCache = [];
|
||||||
|
let diagShowHiddenStrip = false; // versteckte Projekte im Streifen zeigen?
|
||||||
|
|
||||||
function updateChatVisibilityByFocus() {
|
function updateChatVisibilityByFocus() {
|
||||||
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
||||||
@@ -1451,20 +1465,79 @@
|
|||||||
if (s.queue_size > 0) return { color: '#FFD60A', label: `Queue: ${s.queue_size}` };
|
if (s.queue_size > 0) return { color: '#FFD60A', label: `Queue: ${s.queue_size}` };
|
||||||
return { color: '#34C759', label: 'idle' };
|
return { color: '#34C759', label: 'idle' };
|
||||||
};
|
};
|
||||||
|
// Projekt-Chip MIT Auge (verstecken/sichtbar). Auge hat eigenes onclick
|
||||||
|
// + stopPropagation, damit der Klick nicht den Kontext wechselt.
|
||||||
|
const projChip = (p, isFocus, dotColor, subline) => {
|
||||||
|
const hidden = !!p.hidden;
|
||||||
|
const bg = isFocus ? 'rgba(52,199,89,0.15)' : '#1E1E2E';
|
||||||
|
const border = isFocus ? '#34C759' : '#2A2A3E';
|
||||||
|
const eye = hidden ? '👁' : '🙈';
|
||||||
|
const eyeTitle = hidden ? 'Wieder dauerhaft sichtbar machen' : 'Verstecken (aus dem Streifen ausblenden)';
|
||||||
|
return `<div style="flex:0 0 auto;padding:6px 10px;background:${bg};border:1px solid ${border};border-radius:6px;display:flex;align-items:center;gap:6px;min-width:120px;${hidden ? 'opacity:0.5;' : ''}">
|
||||||
|
<div onclick="switchDiagFocus('${p.id}')" style="cursor:pointer;display:flex;align-items:center;gap:6px;min-width:0;">
|
||||||
|
<div style="width:8px;height:8px;border-radius:4px;background:${dotColor};"></div>
|
||||||
|
<div style="display:flex;flex-direction:column;min-width:0;">
|
||||||
|
<div style="color:${isFocus?'#34C759':'#E0E0F0'};font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px;">📁 ${escapeHtml(p.name)}${hidden ? ' <span style="color:#B392F0;font-size:9px;font-weight:700;">versteckt</span>' : ''}</div>
|
||||||
|
<div style="color:#8888AA;font-size:10px;">${subline}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span onclick="event.stopPropagation();setStripProjectHidden('${p.id}',${!hidden})" title="${eyeTitle}" style="cursor:pointer;font-size:14px;padding:2px 4px;user-select:none;">${eye}</span>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
const cards = [];
|
const cards = [];
|
||||||
// Hauptchat
|
// Hauptchat
|
||||||
const mainDot = dotFor('__main__');
|
const mainDot = dotFor('__main__');
|
||||||
cards.push(chip('', '💬 Hauptchat', focusedContextId === '', mainDot.color, mainDot.label || 'idle'));
|
cards.push(chip('', '💬 Hauptchat', focusedContextId === '', mainDot.color, mainDot.label || 'idle'));
|
||||||
// Projekte — nur active/ended, sortiert nach letzter Aktivitaet
|
// Projekte — nur active/ended, sortiert nach letzter Aktivitaet.
|
||||||
|
// Versteckte standardmaessig raus; per Toggle-Chip einblendbar.
|
||||||
|
let hiddenCount = 0;
|
||||||
for (const p of diagProjectsCache) {
|
for (const p of diagProjectsCache) {
|
||||||
if (p.status === 'archived') continue;
|
if (p.status === 'archived') continue;
|
||||||
|
if (p.hidden) {
|
||||||
|
hiddenCount++;
|
||||||
|
if (!diagShowHiddenStrip) continue;
|
||||||
|
}
|
||||||
const d = dotFor(p.id);
|
const d = dotFor(p.id);
|
||||||
const sub = d.label || `${p.turn_count} Turns`;
|
const sub = d.label || `${p.turn_count} Turns`;
|
||||||
cards.push(chip(p.id, `📁 ${p.name}`, focusedContextId === p.id, d.color, sub));
|
cards.push(projChip(p, focusedContextId === p.id, d.color, sub));
|
||||||
|
}
|
||||||
|
// Toggle-Chip am Ende (nur wenn es versteckte gibt oder gerade gezeigt werden)
|
||||||
|
if (hiddenCount > 0 || diagShowHiddenStrip) {
|
||||||
|
const tLabel = diagShowHiddenStrip
|
||||||
|
? `🙈 versteckte ausblenden (${hiddenCount})`
|
||||||
|
: `👁 versteckte anzeigen (${hiddenCount})`;
|
||||||
|
cards.push(`<div onclick="toggleDiagHiddenStrip()" title="Versteckte Projekte ein-/ausblenden" style="cursor:pointer;flex:0 0 auto;padding:6px 10px;background:#0D0D18;border:1px dashed #3A3A50;border-radius:6px;display:flex;align-items:center;color:#B392F0;font-size:11px;font-weight:600;white-space:nowrap;">${tLabel}</div>`);
|
||||||
}
|
}
|
||||||
strip.innerHTML = cards.join('');
|
strip.innerHTML = cards.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleDiagHiddenStrip() {
|
||||||
|
diagShowHiddenStrip = !diagShowHiddenStrip;
|
||||||
|
renderContextStrip();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStripProjectHidden(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);
|
||||||
|
// Wenn wir das gerade fokussierte Projekt verstecken (und Versteckte nicht
|
||||||
|
// eingeblendet sind), zurueck auf Hauptchat — sonst haengt der Focus an
|
||||||
|
// einem unsichtbaren Chip.
|
||||||
|
if (hidden && id === focusedContextId && !diagShowHiddenStrip) {
|
||||||
|
switchDiagFocus('');
|
||||||
|
}
|
||||||
|
// Lokal sofort aktualisieren (der /api/brain-Proxy broadcastet zusaetzlich
|
||||||
|
// project_changed an App + andere Tabs).
|
||||||
|
await refreshDiagProjectsCache();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Verstecken/Anzeigen fehlgeschlagen: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshDiagQueueStatus() {
|
async function refreshDiagQueueStatus() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/brain/projects/queue-status');
|
const r = await fetch('/api/brain/projects/queue-status');
|
||||||
@@ -1762,8 +1835,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'project_changed') {
|
if (msg.type === 'project_changed') {
|
||||||
// ARIA hat in einem Tool-Call ein Projekt erstellt/betreten/verlassen/beendet.
|
// Projekt geaendert (ARIA-Tool, App/Diagnostic-Verstecken, …) →
|
||||||
// Liste neu laden falls sichtbar.
|
// BEIDE Projekt-UIs live aktualisieren: den Kontext-Streifen (Main)
|
||||||
|
// und die vertikale Liste (Einstellungen).
|
||||||
|
refreshDiagProjectsCache();
|
||||||
loadProjects();
|
loadProjects();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2990,16 +3065,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 +3094,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 +3103,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 +6015,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 +6080,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: `
|
||||||
|
|||||||
+33
-1
@@ -898,6 +898,11 @@ function connectRVS(forcePlain) {
|
|||||||
// Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
|
// Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
|
||||||
log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
|
log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
|
||||||
broadcast({ type: "mode", payload: msg.payload });
|
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") {
|
} else if (msg.type === "agent_activity") {
|
||||||
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
||||||
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
||||||
@@ -1597,7 +1602,16 @@ const htmlPath = path.join(__dirname, "index.html");
|
|||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
if (req.url === "/" || req.url === "/index.html") {
|
if (req.url === "/" || req.url === "/index.html") {
|
||||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
// no-store: das Dashboard ist eine Single-HTML-App die bei jedem Deploy
|
||||||
|
// neue Inline-JS/CSS bekommt. Ohne Cache-Header servierte der Browser die
|
||||||
|
// alte Version trotz Reload (neue Features tauchten erst nach Hard-Reload
|
||||||
|
// auf) — genau das Symptom „ich seh den Button nicht".
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0",
|
||||||
|
});
|
||||||
res.end(fs.readFileSync(htmlPath, "utf-8"));
|
res.end(fs.readFileSync(htmlPath, "utf-8"));
|
||||||
} else if (req.url === "/api/state") {
|
} else if (req.url === "/api/state") {
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
@@ -2168,6 +2182,13 @@ const server = http.createServer((req, res) => {
|
|||||||
// mehr als eine Minute.
|
// mehr als eine Minute.
|
||||||
const isUpload = /\/attachments(\/upload)?$/.test(targetPath);
|
const isUpload = /\/attachments(\/upload)?$/.test(targetPath);
|
||||||
const timeout = isUpload ? 120000 : 60000;
|
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({
|
const proxyReq = http.request({
|
||||||
host: "aria-brain",
|
host: "aria-brain",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
@@ -2178,6 +2199,17 @@ const server = http.createServer((req, res) => {
|
|||||||
}, (proxyRes) => {
|
}, (proxyRes) => {
|
||||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||||
proxyRes.pipe(res);
|
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) => {
|
proxyReq.on("error", (err) => {
|
||||||
res.writeHead(503, { "Content-Type": "application/json" });
|
res.writeHead(503, { "Content-Type": "application/json" });
|
||||||
|
|||||||
Reference in New Issue
Block a user