fix: Chat-Isolation, Tab-Besitz und eigene Seiten für Einstellungen/Verlauf

Ereignisse landeten im falschen Chat: der Broker markiert jedes Ereignis mit
chatId, das Panel filtert aber auf sessionId — handleBrokerMessage reichte die
Nachricht unverändert weiter, der Filter griff also nie und alles landete im
gerade sichtbaren Chat. chatId wird jetzt beim Weiterreichen übersetzt.

Tab-Isolation war reine Optik — die Gruppen wurden nirgends durchgesetzt:
- getActiveTab() nahm sich den gerade sichtbaren Tab (auch den eines anderen
  Chats oder einen losen Tab des Nutzers) und zog ihn in die eigene Gruppe.
  Fremde Tabs werden jetzt nie adoptiert; der Chat öffnet sich einen eigenen.
- browser_tabs_create legte den neuen Tab in die Gruppe der SICHTBAREN statt
  der aufrufenden Konversation (sessionId fehlte).
- browser_tabs_select/close prüften den Besitz gar nicht — der Agent konnte
  jeden Tab übernehmen oder schließen, auch die des Nutzers.
- browser_tabs_list zeigte jeden offenen Tab; Tabs anderer Chats sind jetzt
  unsichtbar, Nutzer-Tabs klar als "nicht anfassen" markiert.
Besitz wird über Chromes Tab-Gruppen bestimmt — überlebt einen Neustart des
Service Workers, anders als reiner Speicher.

Session wird explizit durch runTool/dispatchTool/getActiveTab gereicht. Der
bisherige Trick, activeSessionId kurz umzubiegen, hielt nur ohne Parallelität:
zwischen Setzen und Zurücksetzen liegt ein await.

Einstellungen und Verlauf nehmen dem Chat keinen Platz mehr weg: Einstellungen
sind eine eigene Seite (vorher ausklappbar bis 45vh), der Verlauf ein Overlay
über dem Header-Knopf. Abschnitte als eigene Module mit Icon und Akzentkante.
Nebenbei repariert: nach der Umbenennung .settings -> .settings-panel zeigten
mehrere Regeln ins Leere.

Kimi K2.5, GLM-5 und Gemini 3.1 Flash Lite im Nexus-Katalog; Kimi/GLM laufen
über /moonshot bzw. /zai statt über den Azure-Deployment-Pfad. Ollama als
lokaler Anbieter wählbar (kein Key nötig).

Enthält außerdem ältere, noch nicht committete Arbeit, die nicht von diesem
Durchgang stammt und sich nicht sauber abtrennen ließ: kräftigerer Glow mit
Farbverlauf (spotlight.ts), Raster-Overlay für Screenshots (computer.ts) und
ein Ein/Aus-Schalter für CDP (cdp.ts).
This commit is contained in:
2026-08-12 11:24:35 +02:00
parent 8d03f36b0e
commit 02d3d11523
12 changed files with 1223 additions and 370 deletions
+250 -23
View File
@@ -76,7 +76,7 @@ const modeBadge = need<HTMLSpanElement>('modeBadge');
const headerModelSel = need<HTMLSelectElement>('headerModel');
const modeOffBtn = need<HTMLButtonElement>('modeOff');
const modeConfirmBtn = need<HTMLButtonElement>('modeConfirm');
const settingsPanel = need<HTMLDetailsElement>('settingsPanel');
const settingsPanel = need<HTMLDivElement>('settingsPanel');
const settingsToggle = need<HTMLButtonElement>('settingsToggle');
const directGroup = need<HTMLDivElement>('directGroup');
@@ -110,11 +110,32 @@ const nexusModelSel = need<HTMLSelectElement>('nexusModel');
const nexusModelRefresh = need<HTMLButtonElement>('nexusModelRefresh');
const nexusModelProbe = need<HTMLButtonElement>('nexusModelProbe');
const nexusModelHint = need<HTMLDivElement>('nexusModelHint');
const convBar = need<HTMLDivElement>('convBar');
const historyToggle = need<HTMLButtonElement>('historyToggle');
const convSelect = need<HTMLSelectElement>('convSelect');
const newConvBtn = need<HTMLButtonElement>('newConvBtn');
const syncMemoryBtn = need<HTMLButtonElement>('syncMemoryBtn');
const diagToggle = need<HTMLButtonElement>('diagToggle');
const diagPanel = need<HTMLDivElement>('diagPanel');
const statsToggle = need<HTMLButtonElement>('statsToggle');
const statsPanel = need<HTMLElement>('statsPanel');
const statsRefresh = need<HTMLButtonElement>('statsRefresh');
const statCalls = need<HTMLDivElement>('statCalls');
const statRate = need<HTMLDivElement>('statRate');
const statChanges = need<HTMLDivElement>('statChanges');
const statRetries = need<HTMLDivElement>('statRetries');
const statErrors = need<HTMLDivElement>('statErrors');
const statTokens = need<HTMLDivElement>('statTokens');
const statsTools = need<HTMLDivElement>('statsTools');
const statsHistory = need<HTMLDivElement>('statsHistory');
// Insgesamt (alle Sitzungen) — persistiert am Broker, siehe agent/ext_bridge.py cumulative_summary()
const atCalls = need<HTMLDivElement>('atCalls');
const atTurns = need<HTMLDivElement>('atTurns');
const atChanges = need<HTMLDivElement>('atChanges');
const atRetries = need<HTMLDivElement>('atRetries');
const atErrors = need<HTMLDivElement>('atErrors');
const atTokens = need<HTMLDivElement>('atTokens');
const atTools = need<HTMLDivElement>('atTools');
const diagVersion = need<HTMLSpanElement>('diagVersion');
const diagReloadBtn = need<HTMLButtonElement>('diagReloadBtn');
const diagBroker = need<HTMLSpanElement>('diagBroker');
@@ -153,10 +174,22 @@ let config: ExtensionConfig = { ...DEFAULT_CONFIG };
let brokerConnected = false;
let brokerUnreachable = false;
let isRunning = false;
/** IDs der Sessions, die gerade laufen — für die ●-Markierung im Dropdown. */
let runningSessions: Set<string> = new Set();
let currentStreamEl: HTMLElement | null = null;
/** Kumulative Token-Nutzung über die gesamte Session. */
let sessionUsage = { inputTokens: 0, outputTokens: 0, turns: 0 };
let localStats = { toolCalls: 0, toolErrors: 0, stateChanges: 0, startedAt: Date.now(), tools: new Map<string, number>() };
/** Stats pro Konversation — damit man sieht, was jeder Agent verbraucht hat. */
const perSessionStats = new Map<string, { inputTokens: number; outputTokens: number; turns: number; toolCalls: number; toolErrors: number }>();
function getSessionStats(sid: string) {
let s = perSessionStats.get(sid);
if (!s) { s = { inputTokens: 0, outputTokens: 0, turns: 0, toolCalls: 0, toolErrors: 0 }; perSessionStats.set(sid, s); }
return s;
}
/** Offene Rueckfragen, damit sie bei Verbindungsverlust ehrlich entwertet werden koennen. */
const openRequests = new Map<string, { stale: () => void }>();
@@ -228,10 +261,19 @@ function send(msg: PanelMessage): boolean {
// ─── Nachrichten vom Hintergrund ──────────────────────────────────────────────
function handleMessage(msg: BackgroundMessage): void {
// Events mit sessionId nur verarbeiten, wenn sie zur aktiven Konversation gehören.
// Ausnahme: state, sync_start, connection_status — die sind global.
const msgSessionId = (msg as any).sessionId as string | undefined;
const isForOtherSession = msgSessionId && msgSessionId !== activeConvId;
switch (msg.type) {
case 'state':
applyState(msg.config, msg.connected, msg.running);
applyWorkerVersion(msg.version);
if ((msg as any).runningSessions) {
runningSessions = new Set((msg as any).runningSessions as string[]);
refreshConvSelect(convCache, activeConvId);
}
break;
case 'sync_start':
@@ -256,29 +298,44 @@ function handleMessage(msg: BackgroundMessage): void {
break;
case 'text_delta':
if (isForOtherSession) break;
appendStreamingText(msg.text);
break;
case 'round':
if (isForOtherSession) break;
roundCounter.textContent = `${msg.n}/${msg.maxRounds}`;
setRunning(true);
break;
case 'tool_executing':
if (isForOtherSession) break;
finalizeStreaming();
lastToolName = msg.name;
addToolEvent(`${msg.name}`, previewOf(msg.input), false);
break;
case 'tool_done': {
if (isForOtherSession) break;
const failure = msg.error ?? errorMessageOf(msg.result);
localStats.toolCalls++;
localStats.tools.set(msg.name, (localStats.tools.get(msg.name) ?? 0) + 1);
if (failure) localStats.toolErrors++;
const resultObj = msg.result && typeof msg.result === 'object' ? msg.result as any : null;
if (resultObj?.afterState || resultObj?.stateChange) localStats.stateChanges++;
// Per-Session-Stats
const ss = getSessionStats(activeConvId);
ss.toolCalls++;
if (failure) ss.toolErrors++;
const meta = `${msg.durationMs} ms · ${msg.method}`;
if (failure) addToolEvent(`${msg.name}${meta}`, failure, true);
else addToolEvent(`${msg.name}${meta}`, previewOf(msg.result), false);
if (statsPanel.classList.contains('open')) renderLocalStats();
break;
}
case 'tool_result_event':
if (isForOtherSession) break;
addToolEvent(
`${msg.isError ? '✗' : '✓'} ${msg.name} — Broker`,
previewOf(msg.result),
@@ -287,10 +344,12 @@ function handleMessage(msg: BackgroundMessage): void {
break;
case 'screenshot':
if (isForOtherSession) break;
addScreenshot(msg.callId, msg.dataUrl);
break;
case 'risk_request':
if (isForOtherSession) break;
finalizeStreaming();
addRiskCard(msg.requestId, msg.name, msg.verdict, msg.timeoutMs);
break;
@@ -302,6 +361,7 @@ function handleMessage(msg: BackgroundMessage): void {
break;
case 'done':
if (isForOtherSession) break;
finalizeStreaming();
setRunning(false);
roundCounter.textContent = '';
@@ -312,22 +372,30 @@ function handleMessage(msg: BackgroundMessage): void {
sessionUsage.inputTokens += msg.usage.inputTokens;
sessionUsage.outputTokens += msg.usage.outputTokens;
sessionUsage.turns++;
// Per-Session-Stats
const ss = getSessionStats(activeConvId);
ss.inputTokens += msg.usage.inputTokens;
ss.outputTokens += msg.usage.outputTokens;
ss.turns++;
updateUsageBar();
const cost = estimateCost(sessionUsage.inputTokens, sessionUsage.outputTokens);
addSystemMessage(
`Tokens: ${fmtNum(msg.usage.inputTokens)} ein / ${fmtNum(msg.usage.outputTokens)} aus` +
` · Session: ${fmtNum(sessionUsage.inputTokens)}/${fmtNum(sessionUsage.outputTokens)} (${sessionUsage.turns} Turns${cost ? ', ~$' + cost : ''})`,
` · Konv: ${fmtNum(ss.inputTokens)}/${fmtNum(ss.outputTokens)} (${ss.turns} Turns)` +
` · Gesamt: ${fmtNum(sessionUsage.inputTokens)}/${fmtNum(sessionUsage.outputTokens)}${cost ? ' ~$' + cost : ''}`,
);
}
break;
case 'error':
if (isForOtherSession) break;
finalizeStreaming();
addErrorMessage(msg.text);
if (msg.fatal) { setRunning(false); roundCounter.textContent = ''; }
break;
case 'log':
if (isForOtherSession) break;
if (msg.level === 'error') addErrorMessage(msg.text);
else addSystemMessage(msg.text, msg.level === 'warn');
break;
@@ -624,6 +692,8 @@ function wireSettings(): void {
anthropic: 'https://api.anthropic.com/v1',
openai: 'https://api.openai.com/v1',
requesty: 'https://router.requesty.ai/v1',
kimi: 'https://api.moonshot.ai/v1',
ollama: 'http://localhost:11434/v1',
nexus: NEXUS_BASE_URL,
};
// Nexus setzt zusaetzlich das Modell: eine Anthropic-ID wie 'claude-sonnet-5'
@@ -639,8 +709,12 @@ function wireSettings(): void {
const preset = presets[provider];
if (preset && (!config.baseUrl || Object.values(presets).includes(config.baseUrl))) {
patchConfigValue({ provider, baseUrl: preset });
// Ollama laeuft lokal ohne echten Key — Platzhalter, damit das Feld nicht
// faelschlich als "fehlt" erscheint (der Server prueft ihn ohnehin nicht).
const apiKey = provider === 'ollama' && !config.apiKey ? 'ollama' : config.apiKey;
patchConfigValue({ provider, baseUrl: preset, apiKey });
setFieldValue(baseUrlInput, preset);
setFieldValue(apiKeyInput, apiKey);
applyNexusUi(config);
return null; // patchConfigValue schon aufgerufen
}
@@ -698,13 +772,7 @@ function wireSettings(): void {
}
});
settingsToggle.addEventListener('click', () => {
settingsPanel.open = !settingsPanel.open;
settingsToggle.setAttribute('aria-expanded', String(settingsPanel.open));
});
settingsPanel.addEventListener('toggle', () => {
settingsToggle.setAttribute('aria-expanded', String(settingsPanel.open));
});
settingsToggle.addEventListener('click', () => toggleSettings());
// Das Panel kann jederzeit geschlossen werden — offene Eingaben vorher wegschreiben.
window.addEventListener('pagehide', flushAllFields);
@@ -721,7 +789,7 @@ function wireSettings(): void {
* Brokers und schickt die Auswahl zurück.
*
* Nur im Broker-Modus sinnvoll: bei driveMode 'direct' spricht der Service Worker
* direkt mit Anthropic/OpenAI/Requesty, dort gilt das Feld "Modell" weiter unten.
* direkt mit Anthropic/OpenAI/Requesty/Kimi, dort gilt das Feld "Modell" weiter unten.
*/
interface NexusModel {
@@ -755,8 +823,129 @@ function brokerHttpBase(): string {
}
}
interface StatsSession {
ts?: string;
duration_sec?: number;
turns?: number;
tool_calls?: number;
tool_errors?: number;
tools_per_min?: number;
state_changes?: number;
throttle_retries?: number;
input_tokens?: number;
output_tokens?: number;
top_tools?: Array<[string, number]>;
}
function safeText(value: unknown): string {
const el = document.createElement('span');
el.textContent = String(value ?? '');
return el.innerHTML;
}
function renderStatsData(current?: StatsSession | null, history: StatsSession[] = []): void {
const elapsedMin = Math.max((Date.now() - localStats.startedAt) / 60000, 1 / 60);
const localTop = [...localStats.tools.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
const data: StatsSession = current ?? {
tool_calls: localStats.toolCalls,
tool_errors: localStats.toolErrors,
tools_per_min: localStats.toolCalls / elapsedMin,
state_changes: localStats.stateChanges,
throttle_retries: 0,
input_tokens: sessionUsage.inputTokens,
output_tokens: sessionUsage.outputTokens,
top_tools: localTop,
};
statCalls.textContent = fmtNum(data.tool_calls ?? 0);
statRate.textContent = (data.tools_per_min ?? 0).toFixed(1);
statChanges.textContent = fmtNum(data.state_changes ?? 0);
statRetries.textContent = fmtNum(data.throttle_retries ?? 0);
statErrors.textContent = fmtNum(data.tool_errors ?? 0);
statTokens.textContent = fmtNum((data.input_tokens ?? 0) + (data.output_tokens ?? 0));
const tools = data.top_tools ?? localTop;
statsTools.innerHTML = tools.length
? `<table class="stats-table">${tools.map(([name, count]) => `<tr><td>${safeText(name)}</td><td>${count}</td></tr>`).join('')}</table>`
: '<div class="stats-empty">Noch keine Tool-Aufrufe</div>';
statsHistory.innerHTML = history.length
? `<table class="stats-table">${history.slice().reverse().map(s => `<tr><td>${safeText(s.ts?.slice(0, 16).replace('T', ' ') || 'Session')}</td><td>${s.tool_calls ?? 0} Calls · ${s.tools_per_min ?? 0}/min</td></tr>`).join('')}</table>`
: '<div class="stats-empty">Historie entsteht nach dem ersten Broker-Disconnect.</div>';
}
/**
* "Insgesamt"-Kacheln — kommen NUR vom Broker (agent/ext_bridge.py
* cumulative_summary()), der sie persistiert und bei jedem Tool-Aufruf sofort
* weiterschreibt. Es gibt keinen lokalen Fallback: anders als die
* Session-Werte oben ist das hier die eine Zahl, die tatsächlich wächst statt
* bei jedem Neustart/Reconnect bei null anzufangen — ohne Broker also lieber
* "—" zeigen als eine falsche, session-lokale Notlösung.
*/
function renderAllTimeData(allTime?: StatsSession | null): void {
if (!allTime) return;
atCalls.textContent = fmtNum(allTime.tool_calls ?? 0);
atTurns.textContent = fmtNum(allTime.turns ?? 0);
atChanges.textContent = fmtNum(allTime.state_changes ?? 0);
atRetries.textContent = fmtNum(allTime.throttle_retries ?? 0);
atErrors.textContent = fmtNum(allTime.tool_errors ?? 0);
atTokens.textContent = fmtNum((allTime.input_tokens ?? 0) + (allTime.output_tokens ?? 0));
const tools = allTime.top_tools ?? [];
atTools.innerHTML = tools.length
? `<table class="stats-table">${tools.map(([name, count]) => `<tr><td>${safeText(name)}</td><td>${count}</td></tr>`).join('')}</table>`
: '<div class="stats-empty">Noch keine Tool-Aufrufe</div>';
}
function renderLocalStats(): void { renderStatsData(null, []); }
async function loadStats(): Promise<void> {
renderLocalStats();
if (config.driveMode !== 'broker') return;
statsRefresh.disabled = true;
try {
const res = await fetch(`${brokerHttpBase()}/ext/v1/stats`, { signal: AbortSignal.timeout(4000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
renderStatsData(body.currentSession ?? null, body.recentSessions ?? []);
renderAllTimeData(body.allTime ?? null);
} catch {
// Lokale Live-Werte bleiben sichtbar; Broker-Historie/Insgesamt sind optional.
} finally {
statsRefresh.disabled = false;
}
}
function toggleStats(): void {
const open = !statsPanel.classList.contains('open');
statsPanel.classList.toggle('open', open);
if (open) toggleSettings(false); // beide sind volle Seiten — nur eine auf einmal
chatContainer.style.display = open ? 'none' : '';
inputArea.style.display = open ? 'none' : '';
attachStrip.style.display = open ? 'none' : '';
statsToggle.setAttribute('aria-pressed', String(open));
if (open) loadStats();
}
function toggleSettings(force?: boolean): void {
const open = force ?? !settingsPanel.classList.contains('open');
settingsPanel.classList.toggle('open', open);
if (open) {
statsPanel.classList.remove('open'); // beide sind volle Seiten — nur eine auf einmal
statsToggle.setAttribute('aria-pressed', 'false');
}
chatContainer.style.display = open ? 'none' : '';
inputArea.style.display = open ? 'none' : '';
attachStrip.style.display = open ? 'none' : '';
settingsToggle.setAttribute('aria-expanded', String(open));
}
let nexusModels: NexusModel[] = [];
let nexusPollTimer: number | null = null;
// Das Modell IST pro Konversation (siehe agent/ext_bridge.py _model_for) — der
// per-Modell `.active`-Flag aus dem Katalog ist dagegen global (models.py
// _active) und würde beim Rendern das GLOBALE zuletzt gewählte Modell zeigen,
// nicht das dieser Konversation. Deshalb eigene, chat-lokale Variable statt
// nexusModels.find(m => m.active).
let activeNexusModelId = '';
function setNexusHint(text: string, kind: '' | 'ok' | 'err' | 'throttled'): void {
nexusModelHint.textContent = text;
@@ -775,7 +964,7 @@ function stateMark(m: NexusModel): string {
}
function renderNexusModels(): void {
const active = nexusModels.find(m => m.active);
const active = nexusModels.find(m => m.id === activeNexusModelId);
nexusModelSel.innerHTML = '';
if (!nexusModels.length) {
@@ -795,7 +984,7 @@ function renderNexusModels(): void {
// Nicht vorhandene oder gesperrte Modelle nicht wählbar machen — ein Wechsel
// dorthin würde jeden Aufruf scheitern lassen.
if (m.state === 'missing' || m.state === 'unauthorized') opt.disabled = true;
if (m.active) opt.selected = true;
if (m.id === activeNexusModelId) opt.selected = true;
nexusModelSel.appendChild(opt);
}
@@ -827,12 +1016,14 @@ function renderNexusModels(): void {
async function loadNexusModels(silent = true): Promise<void> {
if (config.driveMode !== 'broker') return;
try {
const res = await fetch(`${brokerHttpBase()}/api/models`, {
const q = activeConvId ? `?ext_chat_id=${encodeURIComponent(activeConvId)}` : '';
const res = await fetch(`${brokerHttpBase()}/api/models${q}`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
nexusModels = Array.isArray(data.models) ? data.models : [];
activeNexusModelId = data.active || '';
renderNexusModels();
refreshHeaderModel(config);
} catch (e: unknown) {
@@ -851,19 +1042,22 @@ async function setNexusModel(modelId: string): Promise<void> {
const res = await fetch(`${brokerHttpBase()}/api/models/active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: modelId }),
// extChatId: nur DIESE Konversation bekommt das neue Modell — andere,
// parallel laufende Chats behalten ihres (siehe web.py api_models_active).
body: JSON.stringify({ model: modelId, extChatId: activeConvId }),
signal: AbortSignal.timeout(8000),
});
const data = await res.json();
if (!res.ok) {
// 409: der Broker arbeitet gerade — ein Wechsel würde den Verlauf zerreißen.
// 409: DIESER Chat arbeitet gerade — ein Wechsel würde seinen Verlauf zerreißen.
setNexusHint(data.error || `Wechsel abgelehnt (HTTP ${res.status})`, 'err');
await loadNexusModels();
return;
}
nexusModels = Array.isArray(data.models) ? data.models : nexusModels;
activeNexusModelId = data.active || modelId;
renderNexusModels();
const m = nexusModels.find(x => x.active);
const m = nexusModels.find(x => x.id === activeNexusModelId);
addSystemMessage(`Modell gewechselt: ${m?.label ?? modelId}`);
} catch (e: unknown) {
setNexusHint(`Wechsel fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}`, 'err');
@@ -1110,7 +1304,7 @@ function sendUserMessage(): void {
// Lauf läuft — Nachricht puffern und visuell kennzeichnen
addUserMessage(`📥 ${text}`);
addSystemMessage('Nachricht gepuffert — wird nach aktuellem Turn ausgeführt.');
send({ type: 'user_message', text: payloadText, images, maxRounds: config.maxRounds, isContinue });
send({ type: 'user_message', text: payloadText, images, maxRounds: config.maxRounds, isContinue, sessionId: activeConvId });
chatInput.value = '';
clearAttachments();
autoResize();
@@ -1123,7 +1317,7 @@ function sendUserMessage(): void {
addUserMessage(text, images);
}
if (!send({ type: 'user_message', text: payloadText, images, maxRounds: config.maxRounds, isContinue })) {
if (!send({ type: 'user_message', text: payloadText, images, maxRounds: config.maxRounds, isContinue, sessionId: activeConvId })) {
addErrorMessage('Keine Verbindung zum Hintergrund — die Nachricht wurde nicht gesendet.');
return;
}
@@ -1259,7 +1453,7 @@ function wireAttachments(): void {
}
function abortRun(): void {
send({ type: 'abort' });
send({ type: 'abort', sessionId: activeConvId });
finalizeStreaming();
setRunning(false);
roundCounter.textContent = '';
@@ -1839,7 +2033,8 @@ function refreshConvSelect(convs: StoredConversation[], activeId: string): void
for (const c of convs) {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.title || 'Unbenannt';
const running = runningSessions.has(c.id);
opt.textContent = (running ? '● ' : '') + (c.title || 'Unbenannt');
if (c.id === activeId) opt.selected = true;
convSelect.appendChild(opt);
}
@@ -1854,6 +2049,10 @@ async function loadAndDisplayConversation(id: string): Promise<void> {
scheduleStorageWrite();
chatContainer.innerHTML = '';
// Worker über Session-Wechsel informieren — Target-Tab und Running-Status wechseln mit.
send({ type: 'switch_session', sessionId: id });
loadNexusModels(); // Modell-Pille auf das Modell DIESER Konversation umschalten
for (const msg of conv.messages) {
if (msg.role === 'user') {
chatContainer.appendChild(makeEl('div', 'message user', msg.content));
@@ -1884,7 +2083,10 @@ async function startNewConversation(): Promise<void> {
scheduleStorageWrite();
chatContainer.innerHTML = '';
refreshConvSelect(convCache, conv.id);
send({ type: 'new_conversation' });
send({ type: 'new_conversation', sessionId: conv.id });
// Worker über neue aktive Session informieren
send({ type: 'switch_session', sessionId: conv.id });
loadNexusModels(); // frische Konversation erbt den globalen Default
}
async function syncCurrentToMemory(): Promise<void> {
@@ -2117,11 +2319,34 @@ function wireDiag(): void {
});
}
function toggleHistoryOverlay(open?: boolean): void {
const next = open ?? !convBar.classList.contains('open');
convBar.classList.toggle('open', next);
historyToggle.setAttribute('aria-expanded', String(next));
}
function wireConversations(): void {
newConvBtn.addEventListener('click', startNewConversation);
historyToggle.addEventListener('click', (e) => {
e.stopPropagation();
toggleHistoryOverlay();
});
// Klick außerhalb schließt — Overlay soll nicht offen im Weg stehen, sobald
// man die Auswahl getroffen hat oder woanders weiterarbeitet.
document.addEventListener('click', (e) => {
if (!convBar.classList.contains('open')) return;
const target = e.target as Node;
if (!convBar.contains(target) && !historyToggle.contains(target)) {
toggleHistoryOverlay(false);
}
});
newConvBtn.addEventListener('click', () => {
startNewConversation();
toggleHistoryOverlay(false);
});
syncMemoryBtn.addEventListener('click', syncCurrentToMemory);
convSelect.addEventListener('change', () => {
loadAndDisplayConversation(convSelect.value);
toggleHistoryOverlay(false);
});
}
@@ -2139,6 +2364,8 @@ async function init(): Promise<void> {
wireMemoryLogin();
wireConversations();
wireDiag();
statsToggle.addEventListener('click', toggleStats);
statsRefresh.addEventListener('click', loadStats);
sendBtn.addEventListener('click', sendUserMessage);
stopBtn.addEventListener('click', abortRun);
+185 -57
View File
@@ -212,61 +212,84 @@
.mode-btn[aria-pressed="true"] .s { color: var(--text-dim); }
/* ── Einstellungen ───────────────────────────────────────────────────── */
.settings {
/* Eigene Seite statt ausklappbarer Leiste (war <details>, klappte bis zu
45vh in den Chatbereich rein) — derselbe Ein/Aus-Ansatz wie .stats-panel:
ersetzt den Chat komplett, statt ihm dauerhaft Höhe wegzunehmen. */
.settings-panel {
display: none; flex: 1 1 auto; min-height: 0; flex-direction: column;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
max-height: 45vh;
overflow-y: auto;
}
.settings > summary {
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
cursor: pointer;
user-select: none;
outline: none;
list-style: none;
.settings-panel.open { display: flex; }
.settings-head {
display: flex; align-items: baseline; gap: 8px;
padding: 14px 14px 12px; border-bottom: 1px solid var(--border); flex: 0 0 auto;
}
.settings > summary::-webkit-details-marker { display: none; }
.settings > summary::before { content: '▸ '; color: var(--text-mute); }
.settings[open] > summary::before { content: '▾ '; }
.settings > summary:hover { color: var(--text); }
.settings-body { padding: 2px 10px 10px; }
.settings-head h2 {
font-size: 15px; font-weight: 650; letter-spacing: -0.01em; color: var(--text);
}
.settings-head .settings-sub {
font-size: 10.5px; color: var(--text-mute); letter-spacing: 0.2px;
}
.settings-head .spacer { flex: 1; }
.settings-body { padding: 10px; overflow-y: auto; flex: 1 1 auto; min-height: 0; }
/* Klappbare Abschnitte: 18 Zeilen auf einer Ebene sind keine Bedienung,
sondern eine Liste. Was man taeglich anfasst, steht offen; der Rest
liegt zugeklappt darunter. */
.sgroup { border-top: 1px solid var(--border); margin-top: 6px; }
/* Klappbare Abschnitte als eigenständige Module (Cockpit-Sprache der
restlichen UI weitergedacht: jedes Modul hat eine eigene Fläche und
"leuchtet" auf, sobald es offen ist — statt einer reinen Textliste
mit Trennlinien, wie es vorher war). Was man taeglich anfasst, steht
offen; der Rest liegt zugeklappt darunter. */
.sgroup {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 9px;
margin-top: 7px;
overflow: hidden;
transition: border-color 0.15s;
}
.sgroup:first-child { margin-top: 0; }
.sgroup[open] { border-color: var(--border-lt); }
.sgroup > summary {
padding: 6px 0 5px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--text-mute);
padding: 9px 11px;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.15px;
color: var(--text-dim);
cursor: pointer;
user-select: none;
list-style: none;
outline: none;
display: flex;
align-items: center;
gap: 5px;
gap: 8px;
position: relative;
}
/* Linke Akzentkante: aus statt lackiert, an statt lackiert — "Modul aktiv"
statt reiner Dekoration; einzige Farbstelle in einer sonst ruhigen Liste. */
.sgroup > summary::after {
content: '';
position: absolute; left: 0; top: 0; bottom: 0; width: 2.5px;
background: var(--accent);
opacity: 0; transition: opacity 0.15s;
}
.sgroup[open] > summary::after { opacity: 1; }
.sgroup-icon { flex: 0 0 auto; font-size: 12px; opacity: 0.85; line-height: 1; }
.sgroup-sub {
font-size: 9.5px; font-weight: 500; letter-spacing: 0; text-transform: none;
color: var(--text-mute); font-family: ui-monospace, 'Cascadia Code', 'Consolas', monospace;
}
.sgroup > summary::-webkit-details-marker { display: none; }
.sgroup > summary::before {
content: '';
.sgroup-chevron {
margin-left: auto; flex: 0 0 auto;
width: 0; height: 0;
border-left: 4px solid currentColor;
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
color: var(--text-mute);
transition: transform 0.15s;
flex: 0 0 auto;
}
.sgroup[open] > summary::before { transform: rotate(90deg); }
.sgroup[open] .sgroup-chevron { transform: rotate(90deg); color: var(--accent); }
.sgroup > summary:hover { color: var(--text); }
.sgroup-body { padding: 1px 0 7px; }
.sgroup-body { padding: 2px 11px 11px; border-top: 1px solid var(--border); }
/* Modellwahl in der Kopfzeile — der am haeufigsten benutzte Schalter
gehoert nicht ans Ende einer Einstellungsliste. */
@@ -286,7 +309,7 @@
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
padding: 4px 0;
}
.row > label {
flex: 0 0 88px;
@@ -302,15 +325,19 @@
min-width: 0;
background: var(--bg);
border: 1px solid var(--border-lt);
border-radius: 5px;
border-radius: 6px;
color: var(--text);
padding: 4px 7px;
padding: 5px 8px;
font-size: 11px;
font-family: inherit;
outline: none;
transition: border-color 0.12s, box-shadow 0.12s;
}
.row input[type="text"], .row input[type="password"] { font-family: ui-monospace, 'Cascadia Code', 'Consolas', monospace; }
.row input:focus, .row select:focus, .settings textarea:focus { border-color: var(--accent); }
.row input:focus, .row select:focus, .settings-panel textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(78, 163, 200, 0.16);
}
.row.check { gap: 6px; }
.row.check > label { flex: 1 1 auto; order: 2; }
.row input[type="checkbox"] {
@@ -320,14 +347,14 @@
cursor: pointer;
flex: 0 0 auto;
}
.settings .field-block { padding: 4px 0; }
.settings .field-block > label {
.settings-panel .field-block { padding: 4px 0; }
.settings-panel .field-block > label {
display: block;
font-size: 11px;
color: var(--text-dim);
margin-bottom: 3px;
}
.settings textarea {
.settings-panel textarea {
width: 100%;
background: var(--bg);
border: 1px solid var(--border-lt);
@@ -434,15 +461,35 @@
margin-top: 2px;
}
.diag-section:first-child { border-top: none; padding-top: 0; margin-top: 0; }
/* Overlay statt fester Zeile: schwebt ÜBER dem Chat, kostet ihm also nie
dauerhaft Höhe — anders als vorher (fixe Leiste, immer sichtbar, immer
ein Stück Chatbereich weg). Öffnen/Schließen über #historyToggle im
Header, Klick daneben schließt (siehe wireHistoryOverlay() in app.ts). */
.conv-bar {
display: flex;
flex-direction: column;
gap: 5px;
padding: 5px 10px;
padding: 8px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
align-items: center;
border: 1px solid var(--border-lt);
border-radius: 10px;
box-shadow: 0 10px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(0,0,0,0.08);
position: absolute;
top: 44px;
left: 8px;
right: 8px;
z-index: 50;
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.conv-bar.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.conv-bar .conv-row { display: flex; gap: 5px; align-items: center; }
.conv-bar select {
flex: 1 1 auto;
min-width: 0;
@@ -929,6 +976,47 @@
#sendBtn:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
#stopBtn { background: var(--error); color: #fff; }
#stopBtn:hover { filter: brightness(1.1); }
/* ── Statistik-Ansicht ───────────────────────────────────────────────── */
.stats-panel {
display: none; flex: 1 1 auto; min-height: 0; overflow: auto;
padding: 12px; background: var(--bg);
}
.stats-panel.open { display: block; }
.stats-head { display:flex; align-items:center; gap:8px; margin-bottom:12px; }
.stats-head h2 { font-size:15px; }
.stats-head .spacer { flex:1; }
.stats-grid {
display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));
gap:8px; margin-bottom:12px;
}
.stat-card { background:var(--surface); border:1px solid var(--border); border-radius:9px; padding:10px; }
.stat-value { font-size:20px; font-weight:700; color:var(--accent); font-variant-numeric:tabular-nums; }
.stat-label { font-size:10px; color:var(--text-dim); margin-top:2px; }
.stats-section { background:var(--surface); border:1px solid var(--border); border-radius:9px; padding:10px; margin-top:8px; }
.stats-section h3 { font-size:12px; margin-bottom:8px; color:var(--text); }
.stats-table { width:100%; border-collapse:collapse; font-size:11px; }
.stats-table td { padding:5px 3px; border-top:1px solid var(--border); color:var(--text-dim); }
.stats-table td:last-child { text-align:right; color:var(--text); font-variant-numeric:tabular-nums; }
.stats-empty { color:var(--text-mute); padding:16px 0; text-align:center; }
/* Schmale Panels: Header entrümpeln, Eingabe bleibt bedienbar. */
@media (max-width: 520px) {
.header { flex-wrap:wrap; }
.header h1 { flex:1; }
.header .spacer { display:none; }
.target-tab { order:10; max-width:100%; flex:1 1 100%; }
.mcp-status, .usage-bar-wrap { display:none; }
.mode-bar { padding:6px; }
.input-area { gap:5px; padding:7px; }
#sendBtn, #stopBtn { padding:8px 10px; }
.stats-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
}
@media (min-width: 760px) {
body { font-size:14px; }
.chat-container { padding-left:16px; padding-right:16px; }
.message { max-width:900px; }
}
</style>
</head>
<body>
@@ -946,6 +1034,8 @@
<span class="mcp-status" id="mcpStatus"></span>
<select id="headerModel" title="Aktives Modell"></select>
<span class="status-text" id="statusText">getrennt</span>
<button class="icon-btn" id="historyToggle" type="button" title="Konversationen" aria-label="Konversationen" aria-expanded="false">🕘</button>
<button class="icon-btn" id="statsToggle" type="button" title="Statistiken" aria-label="Statistiken"></button>
<button class="icon-btn" id="settingsToggle" type="button" title="Einstellungen ein-/ausklappen" aria-label="Einstellungen"></button>
<button class="icon-btn" id="diagToggle" type="button" title="Diagnose / Tests" aria-label="Diagnose">🔬</button>
</div>
@@ -1021,16 +1111,22 @@
</div>
<div class="conv-bar" id="convBar">
<button class="conv-btn" id="newConvBtn" type="button" title="Neue Konversation"></button>
<select id="convSelect" title="Konversation wählen"></select>
<button class="conv-btn" id="syncMemoryBtn" type="button" title="Zu Memory synchronisieren"></button>
<div class="conv-row">
<button class="conv-btn" id="newConvBtn" type="button" title="Neue Konversation"></button>
<select id="convSelect" title="Konversation wählen"></select>
<button class="conv-btn" id="syncMemoryBtn" type="button" title="Zu Memory synchronisieren"></button>
</div>
</div>
<details class="settings" id="settingsPanel">
<summary>Einstellungen</summary>
<div class="settings-panel" id="settingsPanel">
<div class="settings-head">
<h2>Einstellungen</h2>
<span class="settings-sub">Änderungen wirken sofort</span>
<span class="spacer"></span>
</div>
<div class="settings-body">
<details class="sgroup"><summary>Verbindung &amp; Antrieb</summary><div class="sgroup-body">
<details class="sgroup"><summary><span class="sgroup-icon">🔌</span>Verbindung &amp; Antrieb<span class="sgroup-chevron"></span></summary><div class="sgroup-body">
<div class="row">
<label for="brokerUrl">Broker</label>
<input type="text" id="brokerUrl" spellcheck="false" autocomplete="off" placeholder="ws://127.0.0.1:8765/ext/ws">
@@ -1066,7 +1162,7 @@
</div>
</div></details>
<details class="sgroup"><summary>Verhalten</summary><div class="sgroup-body">
<details class="sgroup"><summary><span class="sgroup-icon">🎚</span>Verhalten<span class="sgroup-chevron"></span></summary><div class="sgroup-body">
<div class="row">
<label for="inputMode">Eingabe</label>
<select id="inputMode">
@@ -1085,7 +1181,7 @@
</div>
</div></details>
<details class="sgroup"><summary>Anzeige &amp; Sicherheit</summary><div class="sgroup-body">
<details class="sgroup"><summary><span class="sgroup-icon">🛡</span>Anzeige &amp; Sicherheit<span class="sgroup-chevron"></span></summary><div class="sgroup-body">
<div class="row">
<label for="pageOverlay">Anzeige</label>
<select id="pageOverlay" title="Sichtbare Rückmeldung auf der Seite, an der der Agent arbeitet.">
@@ -1110,7 +1206,7 @@
</div></details>
<details class="sgroup" open id="directSection"><summary>Modell &amp; Zugang</summary><div class="sgroup-body">
<details class="sgroup" open id="directSection"><summary><span class="sgroup-icon">🧠</span>Modell &amp; Zugang<span class="sgroup-chevron"></span></summary><div class="sgroup-body">
<div id="directGroup" hidden>
<div class="row">
<label for="provider">Anbieter</label>
@@ -1119,6 +1215,8 @@
<option value="anthropic">anthropic</option>
<option value="openai">openai</option>
<option value="requesty">requesty</option>
<option value="kimi">kimi (Moonshot)</option>
<option value="ollama">ollama (lokal)</option>
</select>
</div>
<div class="row">
@@ -1150,7 +1248,7 @@
</div></details>
<details class="sgroup"><summary>Memory (memory.cnull.net)</summary><div class="sgroup-body">
<details class="sgroup"><summary><span class="sgroup-icon">🗂</span>Memory<span class="sgroup-sub">memory.cnull.net</span><span class="sgroup-chevron"></span></summary><div class="sgroup-body">
<div class="row check">
<input type="checkbox" id="memoryEnabled">
<label for="memoryEnabled">Memory-Server anbinden</label>
@@ -1196,7 +1294,7 @@
</div></details>
</div>
</details>
</div>
<!-- Spotlight: zeigt was die KI gerade sieht -->
<div id="spotlight">
@@ -1211,6 +1309,36 @@
<div class="spotlight-action" id="spotlightAction"></div>
</div>
<section class="stats-panel" id="statsPanel" aria-label="Statistiken">
<div class="stats-head">
<h2>Statistiken</h2><span class="spacer"></span>
<button class="icon-btn" id="statsRefresh" type="button">Aktualisieren</button>
</div>
<div class="stats-section"><h3>Insgesamt (alle Sitzungen, übersteht Neustarts)</h3></div>
<div class="stats-grid">
<div class="stat-card"><div class="stat-value" id="atCalls"></div><div class="stat-label">Tool-Aufrufe</div></div>
<div class="stat-card"><div class="stat-value" id="atTurns"></div><div class="stat-label">Turns</div></div>
<div class="stat-card"><div class="stat-value" id="atChanges"></div><div class="stat-label">State-Änderungen</div></div>
<div class="stat-card"><div class="stat-value" id="atRetries"></div><div class="stat-label">Gateway-Retries</div></div>
<div class="stat-card"><div class="stat-value" id="atErrors"></div><div class="stat-label">Tool-Fehler</div></div>
<div class="stat-card"><div class="stat-value" id="atTokens"></div><div class="stat-label">Tokens gesamt</div></div>
</div>
<div class="stats-section"><h3>Top-Tools (insgesamt)</h3><div id="atTools" class="stats-empty">Noch keine Daten</div></div>
<div class="stats-section"><h3>Diese Sitzung (seit letztem Broker-Connect)</h3></div>
<div class="stats-grid">
<div class="stat-card"><div class="stat-value" id="statCalls"></div><div class="stat-label">Tool-Aufrufe</div></div>
<div class="stat-card"><div class="stat-value" id="statRate"></div><div class="stat-label">Tools / Minute</div></div>
<div class="stat-card"><div class="stat-value" id="statChanges"></div><div class="stat-label">State-Änderungen</div></div>
<div class="stat-card"><div class="stat-value" id="statRetries"></div><div class="stat-label">Gateway-Retries</div></div>
<div class="stat-card"><div class="stat-value" id="statErrors"></div><div class="stat-label">Tool-Fehler</div></div>
<div class="stat-card"><div class="stat-value" id="statTokens"></div><div class="stat-label">Tokens gesamt</div></div>
</div>
<div class="stats-section"><h3>Top-Tools (diese Sitzung)</h3><div id="statsTools" class="stats-empty">Noch keine Daten</div></div>
<div class="stats-section"><h3>Letzte Sessions</h3><div id="statsHistory" class="stats-empty">Noch keine Daten</div></div>
</section>
<div class="chat-container" id="chatContainer"></div>
<div class="attach-strip" id="attachStrip" hidden></div>