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
+137 -7
View File
@@ -417,6 +417,7 @@
<div id="chat-context-strip" style="display:flex;gap:6px;overflow-x:auto;padding:6px 4px;flex:1;min-width:0;">
<!-- wird von renderContextStrip() befuellt -->
</div>
<div id="ctx-model-bar" style="flex:0 0 auto;display:flex;align-items:center;gap:6px;"></div>
<button class="btn secondary" onclick="openCreateProjectModal()"
title="Neues Projekt anlegen — wird direkt angelegt, aktiviert und hier fokussiert"
style="flex:0 0 auto;padding:4px 10px;font-size:11px;margin-right:4px;">+ Projekt</button>
@@ -1993,6 +1994,7 @@
localStorage.setItem('diag_focused_context_id', focusedContextId);
updateChatVisibilityByFocus();
renderContextStrip();
renderCtxModelBar();
renderDiagQueue();
}
@@ -2039,7 +2041,7 @@
const cards = [];
// Hauptchat
const mainDot = dotFor('__main__');
cards.push(chip('', '💬 Hauptchat', focusedContextId === '', mainDot.color, mainDot.label || 'idle'));
cards.push(chip('', '💬 Hauptchat', focusedContextId === '', mainDot.color, (mainDot.label || 'idle') + ctxModelSub('')));
// Projekte — nur active/ended, sortiert nach letzter Aktivitaet.
// Versteckte standardmaessig raus; per Toggle-Chip einblendbar.
let hiddenCount = 0;
@@ -2050,7 +2052,7 @@
if (!diagShowHiddenStrip) continue;
}
const d = dotFor(p.id);
const sub = d.label || `${p.turn_count} Turns`;
const sub = (d.label || `${p.turn_count} Turns`) + ctxModelSub(p.id);
cards.push(projChip(p, focusedContextId === p.id, d.color, sub));
}
// Toggle-Chip am Ende (nur wenn es versteckte gibt oder gerade gezeigt werden)
@@ -2131,6 +2133,9 @@
diagProjectsCache = d?.projects || [];
renderContextStrip();
} catch {}
// Modelle pro Chat im selben Takt — neue Projekte brauchen eine Zeile,
// und ein abgelaufener Ausweich-Vermerk soll von selbst verschwinden.
refreshCtxModels();
}
// Beim Load: Projekte laden + Polling starten
@@ -2678,7 +2683,12 @@
backupTs: p.backupTs,
projectId: p.projectId || '',
answeredBy: p.answeredBy || '',
model: p.model || '',
modelReason: p.modelReason || '',
});
// Wechselt ein Turn aufs Ausweich-Modell, soll Streifen + Auswahl
// das sofort zeigen, nicht erst beim naechsten 15s-Poll.
if (sender === 'aria' && p.model) refreshCtxModels();
// ── Pro-Kontext-Queue-Automat: auf ARIAs Antwort reagieren ──
if (sender === 'aria') {
const apid = p.projectId || '';
@@ -2788,7 +2798,7 @@
const trashBtn = m.ts
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${m.ts})">🗑</button>`
: '';
const histBadge = srcBadgeHtml(m.type, m.answeredBy || '');
const histBadge = srcBadgeHtml(m.type, m.answeredBy || '', m.model || '', m.modelReason || '');
const innerHtml = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)}${histBadge} — ${time}</div>`;
for (const b of boxes) {
const el = document.createElement('div');
@@ -3278,15 +3288,134 @@
}
// Quell-Badge (local/claude/fast-path) fuer ARIA-Bubbles — in Live + History genutzt.
function srcBadgeHtml(type, answeredBy) {
function srcBadgeHtml(type, answeredBy, model, reason) {
if (type !== 'received' || !answeredBy) return '';
const M = {
'local': { t: '⚡ lokal', c: '#34C759' },
'claude': { t: 'Claude', c: '#0096FF' },
'fast-path': { t: '⚡ Fast-Path', c: '#AF7BFF' },
};
const b = M[answeredBy] || { t: answeredBy, c: '#8888AA' };
return `<span title="Antwort erzeugt von: ${escapeHtml(answeredBy)}" style="display:inline-block;margin-left:6px;padding:1px 6px;border-radius:8px;font-size:9px;font-weight:bold;background:${b.c}22;color:${b.c};border:1px solid ${b.c}55;">${b.t}</span>`;
let b = M[answeredBy] || { t: answeredBy, c: '#8888AA' };
let title = 'Antwort erzeugt von: ' + answeredBy;
// Bei Claude das tatsaechliche Modell zeigen. Aeltere Bubbles (vor der
// Modell-Anzeige) haben keins — die bleiben bei "Claude".
if (answeredBy === 'claude' && model) {
const name = prettyModel(model);
if (reason === 'ausweich') {
b = { t: '↪ ' + name, c: '#FF9F0A' };
title = name + ' — Ausweich-Modell, weil Anthropics Sicherheitsfilter das gewählte Modell blockiert hat';
} else if (reason === 'pro-chat') {
b = { t: name + ' ·', c: '#0096FF' };
title = name + ' — für diesen Chat gewählt (' + model + ')';
} else {
b = { t: name, c: '#0096FF' };
title = name + ' — aus der Einstellung (' + model + ')';
}
}
return `<span title="${escapeHtml(title)}" style="display:inline-block;margin-left:6px;padding:1px 6px;border-radius:8px;font-size:9px;font-weight:bold;background:${b.c}22;color:${b.c};border:1px solid ${b.c}55;">${escapeHtml(b.t)}</span>`;
}
// ── Modell pro Chat ───────────────────────────────────────────────
// Zustand vom Brain (/context-model/list): pro Kontext gewaehltes und
// effektives Modell + Grund, dazu die Auswahl aus Anthropics Katalog.
let diagCtxModels = { default: '', rows: [], choices: [], names: {} };
let diagModelNames = {};
// "claude-sonnet-5-5" → "Sonnet 5.5". Katalog-Namen haben Vorrang; der
// Fallback greift fuer Aliase und Versionen, die der Katalog nicht kennt.
// Datums-Suffixe ("-20251001") gehoeren nicht zur Version.
function prettyModel(id) {
if (!id) return '';
if (diagModelNames[id]) return diagModelNames[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);
}
function ctxModelRow(cid) {
return (diagCtxModels.rows || []).find(r => r.context_id === (cid || '')) || null;
}
async function refreshCtxModels() {
try {
const r = await fetch('/api/brain/context-model/list');
if (!r.ok) return;
diagCtxModels = await r.json();
diagModelNames = diagCtxModels.names || {};
renderContextStrip();
renderCtxModelBar();
} catch {}
}
// Auswahl fuer den fokussierten Chat — neben "+ Projekt".
function renderCtxModelBar() {
const bar = document.getElementById('ctx-model-bar');
if (!bar) return;
const row = ctxModelRow(focusedContextId);
if (!row) { bar.innerHTML = ''; return; }
const def = prettyModel(diagCtxModels.default) || diagCtxModels.default || '?';
const groups = { tier: 'Automatisch (neueste Version)', main: 'Aktuelle Versionen', overflow: 'Ältere Versionen' };
let opts = `<option value="">Einstellung (${escapeHtml(def)})</option>`;
for (const [sec, label] of Object.entries(groups)) {
const inSec = (diagCtxModels.choices || []).filter(c => (c.section || 'tier') === sec);
if (!inSec.length) continue;
opts += `<optgroup label="${escapeHtml(label)}">` + inSec.map(c =>
`<option value="${escapeHtml(c.id)}"${c.id === row.override ? ' selected' : ''}>${escapeHtml(c.name)}</option>`
).join('') + '</optgroup>';
}
// Ein gesetzter Override, den der Katalog nicht (mehr) kennt, darf nicht
// still aus der Auswahl fallen — sonst saehe es aus wie "Einstellung".
if (row.override && !(diagCtxModels.choices || []).some(c => c.id === row.override)) {
opts += `<option value="${escapeHtml(row.override)}" selected>${escapeHtml(row.override)}</option>`;
}
let status = '';
if (row.reason === 'ausweich') {
status = `<span title="Anthropics Sicherheitsfilter hat ${escapeHtml(row.selected)} in diesem Chat blockiert — der Turn liegt noch im Fenster" style="color:#FF9F0A;font-size:10px;font-weight:600;white-space:nowrap;">↪ läuft auf ${escapeHtml(prettyModel(row.effective))}</span>
<button class="btn secondary" onclick="ctxModelBack()" title="Vermerk löschen — der nächste Turn nutzt wieder ${escapeHtml(prettyModel(row.selected))}. Blockt der Filter erneut, weicht ARIA eben wieder aus." style="padding:2px 8px;font-size:10px;white-space:nowrap;">↩ zurück zu ${escapeHtml(prettyModel(row.selected))}</button>`;
}
bar.innerHTML = `<span style="font-size:11px;color:#8888AA;" title="Modell für diesen Chat">🧠</span>
<select onchange="setCtxModel(this.value)" title="Modell für diesen Chat — gilt nur hier, die Einstellung bleibt für alle anderen"
style="max-width:180px;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:3px 6px;color:#E0E0F0;font-family:inherit;font-size:11px;">${opts}</select>
${status}`;
}
async function setCtxModel(model) {
try {
const r = await fetch('/api/brain/context-model/set', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context_id: focusedContextId || '', model }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
diagCtxModels = await r.json();
diagModelNames = diagCtxModels.names || {};
renderContextStrip();
renderCtxModelBar();
} catch (e) { alert('Modell setzen fehlgeschlagen: ' + e.message); }
}
async function ctxModelBack() {
try {
const r = await fetch('/api/brain/context-model/back-to-selected', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context_id: focusedContextId || '' }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
diagCtxModels = await r.json();
diagModelNames = diagCtxModels.names || {};
renderContextStrip();
renderCtxModelBar();
try { loadSafeguard(); } catch {}
} catch (e) { alert('Zurückschalten fehlgeschlagen: ' + e.message); }
}
// Untertitel der Kontext-Karten: welches Modell der naechste Turn nutzt.
function ctxModelSub(cid) {
const row = ctxModelRow(cid);
if (!row || !row.effective) return '';
const name = escapeHtml(prettyModel(row.effective));
if (row.reason === 'ausweich') return ` · <span style="color:#FF9F0A;" title="Ausweich-Modell (Sicherheitsfilter)">↪ ${name}</span>`;
if (row.reason === 'pro-chat') return ` · <span style="color:#0096FF;" title="Für diesen Chat gewählt">${name}</span>`;
return ` · ${name}`;
}
function addChat(type, text, meta, options) {
@@ -3324,7 +3453,8 @@
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${backupTs})">🗑</button>`
: '';
// Quell-Badge: welcher Backend die Antwort erzeugt hat (nur ARIA-Bubbles)
const srcBadge = srcBadgeHtml(type, (options && options.answeredBy) || '');
const srcBadge = srcBadgeHtml(type, (options && options.answeredBy) || '',
(options && options.model) || '', (options && options.modelReason) || '');
const html = `${trashBtn}${linked}${ttsBlock}${gpsBlock}<div class="meta">${escapeHtml(meta)}${srcBadge} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
// Thinking-Indikator ausblenden bei neuer Nachricht