feat: Modell pro Chat waehlen — und sehen, welches gerade laeuft

Nach einem Sicherheitsfilter-Treffer fuhr ein Projekt still auf dem
Ausweich-Modell weiter, und nirgends war zu sehen, auf welchem. Zurueck ging
nur ueber "Liste leeren" im Filter-Panel, und das gleich fuer alle Chats.

Jetzt drei Ebenen (aria-brain/context_model.py):
  Einstellung (brainModel) < pro Chat < Ausweich (Sicherheitsfilter)

- Pro Chat: Hauptchat und jedes Projekt koennen ein eigenes Modell haben, in
  /shared/config/context_models.json. Bewusst kein Feld im Projekt-Manifest —
  der Hauptchat ist kein Projekt und waere sonst aussen vor.
- "↩ zurueck" pro Chat: loescht nur die Filter-Vermerke DIESES Chats. Blockt
  der Filter erneut, weicht das Brain eben wieder aus.
- Angezeigt wird, was wirklich laeuft: chat() gibt das Modell aus modelUsage
  der CLI zurueck (aufgeloeste Version statt Alias) samt Grund; Brain → Bridge
  → chat_backup + Live-Payload → App und Diagnostic. Als Grundeinstellung
  zaehlt das Modell, mit dem das Brain laeuft, nicht der Dateiwert — so faellt
  ein ausstehender Neustart nach brainModel-Aenderung auf.

Diagnostic: 🧠-Auswahl neben "+ Projekt" fuer den fokussierten Chat, Modell im
Untertitel jeder Kontext-Karte, Modell statt "Claude" am Bubble-Badge (orange
mit ↪ bei Ausweich).

App: hinter der Quell-Anzeige (Einstellungen, Default aus — Kopfzeile bleibt
fuer Mama unveraendert) ein Modell-Chip in der Kopfzeile; Tippen oeffnet die
Auswahl fuer diesen Chat inkl. "Zurueck". Laeuft ueber den vorhandenen
brain_request-Kanal, also keine neuen RVS-Typen.

Tests: 47/47 (12 neue fuer die Aufloesungsreihenfolge gegen agent.chat()),
tsc ohne Fehler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:39:30 +02:00
co-authored by Claude Opus 5.5
parent d221c1a320
commit 6d978d72b3
9 changed files with 596 additions and 19 deletions
+136 -2
View File
@@ -37,7 +37,7 @@ import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
import ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi, { Project as BrainProject } from '../services/brainApi';
import brainApi, { Project as BrainProject, ContextModels, prettyModel } from '../services/brainApi';
import projectFocus from '../services/projectFocus';
import phoneCallService from '../services/phoneCall';
import { playWakeReadySound } from '../services/wakeReadySound';
@@ -87,6 +87,11 @@ interface ChatMessage {
/** Welcher Backend die Antwort erzeugt hat: 'local' | 'claude' | 'fast-path'.
* Fuer den optionalen Quell-Badge (Einstellung aria_show_source, default aus). */
answeredBy?: string;
/** Welches Claude-Modell geantwortet hat (aufgeloeste Version) und warum:
* 'einstellung' | 'pro-chat' | 'ausweich' (Sicherheitsfilter). Leer bei
* Fast-Path/lokal und bei aelteren Bubbles. */
model?: string;
modelReason?: string;
/** Bridge-Message-ID zur Zuordnung von TTS-Audio */
messageId?: string;
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
@@ -349,6 +354,11 @@ const ChatScreen: React.FC = () => {
// Wechsel des Focus stoppt NICHT ARIAs Arbeit in anderen Projekten —
// die laufen im Brain weiter, wir sehen sie hier nur nicht.
const [focusedProjectId, setFocusedProjectId] = useState<string>('');
// Modell pro Chat (Brain /context-model/list). Nur geladen, wenn die
// Quell-Anzeige an ist — Mama braucht weder Chip noch Netzlast dafuer.
const [ctxModels, setCtxModels] = useState<ContextModels | null>(null);
const [modelPickerVisible, setModelPickerVisible] = useState(false);
const refreshCtxModelsRef = useRef<() => void>(() => {});
// Lookup-Map id → Projekt (fuer Drawer + Referenzen)
const [projectNameById, setProjectNameById] = useState<Record<string, string>>({});
// Queue-Status pro Kontext — polled alle 2s, fuer Status-Dots im Drawer
@@ -760,6 +770,17 @@ const ChatScreen: React.FC = () => {
return () => clearInterval(interval);
}, []);
// Modell pro Chat laden — bei eingeschalteter Quell-Anzeige und bei jedem
// Kontextwechsel. Kein Polling: ein Ausweich-Vermerk aendert sich nur durch
// einen Turn, und nach jedem Turn wird ohnehin neu geladen (s. chat-Handler).
useEffect(() => {
refreshCtxModelsRef.current = () => {
if (!showSource) return;
brainApi.getContextModels().then(setCtxModels).catch(() => {});
};
refreshCtxModelsRef.current();
}, [showSource, focusedProjectId]);
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
useEffect(() => {
wakeWordService.loadFromStorage().catch(() => {});
@@ -1045,6 +1066,8 @@ const ChatScreen: React.FC = () => {
backupTs: typeof m.ts === 'number' ? m.ts : undefined,
projectId: typeof m.project_id === 'string' ? m.project_id : '',
answeredBy: typeof m.answeredBy === 'string' ? m.answeredBy : '',
model: typeof m.model === 'string' ? m.model : '',
modelReason: typeof m.modelReason === 'string' ? m.modelReason : '',
...(cmid && { clientMsgId: cmid }),
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
@@ -1439,7 +1462,12 @@ const ChatScreen: React.FC = () => {
backupTs: (message.payload.backupTs as number) || undefined,
projectId: ((message.payload as any).projectId as string) || '',
answeredBy: ((message.payload as any).answeredBy as string) || '',
model: ((message.payload as any).model as string) || '',
modelReason: ((message.payload as any).modelReason as string) || '',
};
// Modell-Chip oben nachziehen — ein Turn kann aufs Ausweich-Modell
// gewechselt sein, das soll man sofort sehen.
if ((message.payload as any).model) refreshCtxModelsRef.current();
// Die separate file_from_aria-Bubble (leerer Text, nur Datei), die kurz
// zuvor fuer DIESE Datei ankam, entfernen — sonst doppelt (einmal solo,
// einmal an der Text-Bubble). Nur solche mit passendem serverPath.
@@ -3137,7 +3165,15 @@ const ChatScreen: React.FC = () => {
claude: { t: 'Claude', c: '#0096FF' },
'fast-path': { t: '⚡ fast', c: '#AF7BFF' },
};
const b = map[ab] || { t: ab, c: '#8A8AA0' };
let b = map[ab] || { t: ab, c: '#8A8AA0' };
// Bei Claude das echte Modell — aeltere Bubbles ohne Modell
// bleiben bei "Claude".
if (ab === 'claude' && item.model) {
const n = prettyModel(item.model, ctxModels?.names);
b = item.modelReason === 'ausweich'
? { t: '↪ ' + n, c: '#FF9F0A' }
: { t: n, c: '#0096FF' };
}
return <Text style={{ fontSize: 9, fontWeight: 'bold', color: b.c, marginLeft: 6 }}>{b.t}</Text>;
})() : null}
{item.text.length > 0 ? (
@@ -3383,6 +3419,27 @@ const ChatScreen: React.FC = () => {
</Text>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
<Text style={{ fontSize: 10, color: '#8888AA' }}>{dot.label}</Text>
{/* Modell dieses Chats — nur mit Quell-Anzeige (Einstellungen),
sonst bleibt die Kopfzeile so schlicht wie bisher. Orange mit
↪, wenn der Chat gerade aufs Ausweich-Modell ausweicht. */}
{showSource && (() => {
const row = ctxModels?.rows.find(r => r.context_id === (focusedProjectId || ''));
if (!row) return null;
const fb = row.reason === 'ausweich';
const color = fb ? '#FF9F0A' : row.reason === 'pro-chat' ? '#0096FF' : '#8888AA';
return (
<TouchableOpacity
onPress={() => setModelPickerVisible(true)}
hitSlop={{top:8,bottom:8,left:8,right:8}}
style={{ paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10,
borderWidth: 1, borderColor: color + '88', backgroundColor: color + '22' }}
>
<Text style={{ color, fontSize: 10, fontWeight: '700' }} numberOfLines={1}>
{(fb ? '↪ ' : '🧠 ') + prettyModel(row.effective, ctxModels?.names)}
</Text>
</TouchableOpacity>
);
})()}
{/* Direkter Zurueck-zum-Hauptchat-Button — nur wenn man in einem
Projekt ist. Ein Tap statt Drawer→Hauptchat. */}
{!isMain && (
@@ -3403,6 +3460,83 @@ const ChatScreen: React.FC = () => {
);
})()}
{/* Modell-Auswahl fuer den fokussierten Chat */}
<Modal visible={modelPickerVisible} transparent animationType="fade"
onRequestClose={() => setModelPickerVisible(false)}>
<Pressable style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 }}
onPress={() => setModelPickerVisible(false)}>
<Pressable style={{ backgroundColor: '#15151E', borderRadius: 12, padding: 16, maxHeight: '80%' }}
onPress={() => {}}>
{(() => {
const cid = focusedProjectId || '';
const row = ctxModels?.rows.find(r => r.context_id === cid);
const names = ctxModels?.names;
const pick = (model: string) => {
brainApi.setContextModel(cid, model)
.then(d => { setCtxModels(d); setModelPickerVisible(false); })
.catch(e => Alert.alert('Modell', 'Setzen fehlgeschlagen: ' + (e?.message || e)));
};
const Item = ({ id, label, active }: { id: string; label: string; active: boolean }) => (
<TouchableOpacity onPress={() => pick(id)}
style={{ paddingVertical: 10, paddingHorizontal: 10, borderRadius: 8,
backgroundColor: active ? 'rgba(0,150,255,0.18)' : 'transparent' }}>
<Text style={{ color: active ? '#0096FF' : '#E0E0F0', fontSize: 14, fontWeight: active ? '700' : '400' }}>
{(active ? '● ' : '○ ') + label}
</Text>
</TouchableOpacity>
);
const sections: [string, string][] = [
['tier', 'Automatisch (neueste Version)'],
['main', 'Aktuelle Versionen'],
['overflow', 'Ältere Versionen'],
];
return (
<>
<Text style={{ color: '#E0E0F0', fontSize: 16, fontWeight: '700' }}>
Modell für {cid ? (projectNameById[cid] || cid) : 'den Hauptchat'}
</Text>
<Text style={{ color: '#8888AA', fontSize: 12, marginTop: 4, marginBottom: 10 }}>
{row ? 'Läuft gerade auf ' + prettyModel(row.effective, names)
+ (row.reason === 'ausweich' ? ' (Ausweich — Sicherheitsfilter)'
: row.reason === 'pro-chat' ? ' (für diesen Chat gewählt)' : ' (aus der Einstellung)')
: 'Lade …'}
</Text>
{row?.reason === 'ausweich' && (
<TouchableOpacity
onPress={() => brainApi.contextModelBack(cid)
.then(d => { setCtxModels(d); setModelPickerVisible(false); })
.catch(e => Alert.alert('Modell', 'Zurück fehlgeschlagen: ' + (e?.message || e)))}
style={{ backgroundColor: 'rgba(255,159,10,0.18)', borderColor: '#FF9F0A', borderWidth: 1,
borderRadius: 8, padding: 10, marginBottom: 10 }}>
<Text style={{ color: '#FF9F0A', fontSize: 13, fontWeight: '700' }}>
↩ Zurück zu {prettyModel(row.selected, names)}
</Text>
<Text style={{ color: '#B0905A', fontSize: 11, marginTop: 2 }}>
Blockt der Filter erneut, weicht ARIA eben wieder aus.
</Text>
</TouchableOpacity>
)}
<ScrollView>
<Item id="" label={'Einstellung (' + prettyModel(ctxModels?.default || '', names) + ')'}
active={!row?.override} />
{sections.map(([sec, label]) => {
const list = (ctxModels?.choices || []).filter(c => (c.section || 'tier') === sec);
if (!list.length) return null;
return (
<View key={sec} style={{ marginTop: 8 }}>
<Text style={{ color: '#555570', fontSize: 11, marginBottom: 2 }}>{label}</Text>
{list.map(c => <Item key={c.id} id={c.id} label={c.name} active={row?.override === c.id} />)}
</View>
);
})}
</ScrollView>
</>
);
})()}
</Pressable>
</Pressable>
</Modal>
{/* Projekt-Drawer als Modal */}
<ProjectsBrowser
visible={projectsVisible}
+56
View File
@@ -260,6 +260,43 @@ export interface Trigger {
// ── Memory CRUD ──────────────────────────────────────────────────────
// ── Modell pro Chat ──────────────────────────────────────────────────
// reason: 'einstellung' (brainModel) | 'pro-chat' (fuer diesen Chat gewaehlt)
// | 'ausweich' (Sicherheitsfilter hat das gewaehlte Modell blockiert)
export interface ContextModelRow {
context_id: string;
name: string;
override: string; // '' = folgt der Einstellung
selected: string; // gewaehlt (Einstellung oder pro Chat)
effective: string; // was der naechste Turn tatsaechlich nutzt
reason: 'einstellung' | 'pro-chat' | 'ausweich' | string;
blocked: string[];
}
export interface ContextModelChoice {
id: string;
name: string;
section: 'tier' | 'main' | 'overflow' | string;
}
export interface ContextModels {
default: string;
rows: ContextModelRow[];
choices: ContextModelChoice[];
names: Record<string, string>;
}
/** "claude-sonnet-5-5" → "Sonnet 5.5". Katalog-Namen (names) haben Vorrang;
* Datums-Suffixe ("-20251001") gehoeren nicht zur Version. Gleiche Logik wie
* prettyModel() in der Diagnostic. */
export function prettyModel(id: string, names?: Record<string, string>): string {
if (!id) return '';
if (names && names[id]) return names[id];
const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-|$)/.exec(id);
if (m) return m[1][0].toUpperCase() + m[1].slice(1) + ' ' + m[2] + (m[3] ? '.' + m[3] : '');
return id.charAt(0).toUpperCase() + id.slice(1);
}
export const brainApi = {
/** Einzelne Memory holen (mit allen Feldern inkl. Anhaenge) */
getMemory(id: string): Promise<Memory> {
@@ -682,6 +719,25 @@ export const brainApi = {
getProjectQueueStatus(): Promise<ProjectQueueStatus> {
return _send('/projects/queue-status');
},
/** Modelle aller Chats: gewaehlt, effektiv, Grund + Auswahl-Liste. */
getContextModels(): Promise<ContextModels> {
return _send('/context-model/list');
},
/** Modell fuer einen Chat setzen ('' als Modell = wieder die Einstellung). */
setContextModel(contextId: string, model: string): Promise<ContextModels> {
return _send('/context-model/set', {
method: 'POST',
body: { context_id: contextId || '', model: model || '' },
});
},
/** Ausweich-Modell beenden: zurueck aufs gewaehlte Modell dieses Chats. */
contextModelBack(contextId: string): Promise<ContextModels> {
return _send('/context-model/back-to-selected', {
method: 'POST',
body: { context_id: contextId || '' },
});
},
};
export default brainApi;