feat: Belege — jede ARIA-Antwort zeigt, was sie wirklich ausgefuehrt hat

Was ARIA selbst im Proxy ausfuehrt (Bash/ssh, Read, Edit …), war nur live
sichtbar — ob sie fuer "auf Prod nachgesehen" wirklich nachgesehen hat (und
wo), liess sich hinterher nicht pruefen. Das Befehlslog gab es nur fuer die
Host-Agenten.

Proxy (proxy-patches/routes.js): start/tool_use/tool_result/end zusaetzlich
dauerhaft nach /shared/logs/aria-tools/<tag>.jsonl (30 Tage), Passwoerter,
Tokens, API-Keys, private Schluessel und Zugangsdaten in URLs geschwaerzt.

Diagnostic: 🔍 an jeder ARIA-Bubble → alle Werkzeug-Aufrufe zwischen Stefans
Frage und dieser Antwort (Befehl + Ausgabe). Hat sie nichts ausgefuehrt,
sagt das Fenster es deutlich (Stand kommt dann aus Verlauf/Gedaechtnis).
GET /api/aria-proof?ts=&project=.

Deploy: proxy --force-recreate (Patch wirkt nur beim Start), diagnostic --build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 15:42:49 +01:00
co-authored by Claude Opus 5.5
parent f479a0781d
commit 26ad872c6c
3 changed files with 168 additions and 3 deletions
+66 -2
View File
@@ -74,6 +74,12 @@
transition:opacity 0.15s; }
.chat-msg:hover .bubble-trash { opacity: 1; }
.chat-msg .bubble-trash:hover { background:#FF3B30; color:#fff; }
.chat-msg .bubble-proof { position:absolute; top:4px; right:32px; background:rgba(0,150,255,0.15);
color:#4DB8FF; border:none; border-radius:50%; width:22px; height:22px;
font-size:11px; line-height:18px; padding:0; cursor:pointer; opacity:0;
transition:opacity 0.15s; }
.chat-msg:hover .bubble-proof { opacity: 1; }
.chat-msg .bubble-proof:hover { background:#0096FF; color:#fff; }
.chat-msg.sent { background: #0096FF; color: #fff; align-self: flex-end;
border-bottom-right-radius: 4px; }
.chat-msg.received { background: #1E1E2E; color: #E8E8F0; align-self: flex-start;
@@ -197,6 +203,17 @@
</div>
</div>
<!-- Belege: Werkzeug-Aufrufe zu einer ARIA-Antwort -->
<div id="proof-modal" style="display:none;position:fixed;inset:0;z-index:1003;background:rgba(0,0,0,0.8);align-items:center;justify-content:center;" onclick="if(event.target===this)this.style.display='none'">
<div style="background:#0D0D1A;border:1px solid #2A2A3E;border-radius:10px;padding:16px 20px;max-width:1000px;width:94%;max-height:92vh;overflow:auto;color:#C8C8D8;font-size:13px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
<h3 style="margin:0;color:#fff;">🔍 Belege zu dieser Antwort</h3>
<button onclick="document.getElementById('proof-modal').style.display='none'" style="background:none;border:none;color:#8888AA;font-size:22px;cursor:pointer;">&times;</button>
</div>
<div id="proof-body">…</div>
</div>
</div>
<!-- Bestenlisten: Ranglisten-Links + HF-Trends (direkt in den Katalog) -->
<div id="boards-modal" style="display:none;position:fixed;inset:0;z-index:1002;background:rgba(0,0,0,0.8);align-items:center;justify-content:center;" onclick="if(event.target===this)this.style.display='none'">
<div style="background:#0D0D1A;border:1px solid #2A2A3E;border-radius:10px;padding:16px 20px;max-width:1000px;width:94%;max-height:92vh;overflow:auto;color:#C8C8D8;font-size:13px;">
@@ -3005,7 +3022,10 @@
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${m.ts})">🗑</button>`
: '';
const histBadge = srcBadgeHtml(m.type, m.answeredBy || '', m.model || '', m.modelReason || '');
const innerHtml = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)}${histBadge} — ${time}</div>`;
const proofBtn = (m.ts && m.type === 'received')
? `<button class="bubble-proof" title="Belege: was hat ARIA für diese Antwort wirklich ausgeführt?" onclick="showAriaProof(${m.ts}, this)">🔍</button>`
: '';
const innerHtml = `${trashBtn}${proofBtn}${linked}<div class="meta">${escapeHtml(m.meta)}${histBadge} — ${time}</div>`;
for (const b of boxes) {
const el = document.createElement('div');
el.className = `chat-msg ${m.type}`;
@@ -3661,7 +3681,10 @@
// Quell-Badge: welcher Backend die Antwort erzeugt hat (nur ARIA-Bubbles)
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>`;
const proofBtn = (backupTs && type === 'received')
? `<button class="bubble-proof" title="Belege: was hat ARIA für diese Antwort wirklich ausgeführt?" onclick="showAriaProof(${backupTs}, this)">🔍</button>`
: '';
const html = `${trashBtn}${proofBtn}${linked}${ttsBlock}${gpsBlock}<div class="meta">${escapeHtml(meta)}${srcBadge} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
// Thinking-Indikator ausblenden bei neuer Nachricht
updateThinkingIndicator({ activity: 'idle' });
@@ -9535,6 +9558,47 @@
} catch (e) { btn.disabled = false; btn.textContent = 'In Katalog'; alert(e.message); }
}
// ── Belege: was hat ARIA fuer eine Antwort wirklich ausgefuehrt? ──
async function showAriaProof(ts, btn) {
const project = (btn && btn.closest('.chat-msg') && btn.closest('.chat-msg').dataset.projectId) || '';
const body = document.getElementById('proof-body');
document.getElementById('proof-modal').style.display = 'flex';
body.innerHTML = '<span style="color:#8888AA;">lade …</span>';
let j;
try { j = await (await fetch('/api/aria-proof?ts=' + ts + '&project=' + encodeURIComponent(project))).json(); }
catch (e) { body.innerHTML = '<span style="color:#FF6B6B;">✗ ' + escapeHtml(e.message) + '</span>'; return; }
if (!j.ok) { body.innerHTML = '<span style="color:#FF6B6B;">✗ ' + escapeHtml(j.error || 'Fehler') + '</span>'; return; }
const t = x => new Date(x).toLocaleTimeString('de-DE');
const head = '<div style="font-size:12px;color:#8888AA;margin-bottom:10px;">Zeitraum ' + t(j.from) + ' – ' + t(j.to) +
(project ? ' · Projekt <code>' + escapeHtml(project) + '</code>' : ' · Hauptchat') + '</div>';
if (!j.toolCount) {
const before = j.logSince && new Date(j.to).toISOString().slice(0, 10) < j.logSince;
body.innerHTML = head + '<div style="border:1px solid #FFB020;border-radius:8px;padding:12px;background:#2A2410;color:#FFD580;">' +
(before ? 'Für diese Antwort gibt es noch kein Protokoll (aufgezeichnet wird seit ' + escapeHtml(j.logSince) + ').'
: '<b>Keine Befehle ausgeführt.</b> ARIA hat für diese Antwort nichts nachgesehen (kein Bash/ssh, kein Lesen). ' +
'Sagt sie darin „nachgesehen" oder nennt sie einen aktuellen Stand, stammt das aus dem Verlauf oder ihrem Gedächtnis.') +
'</div><div style="font-size:11px;color:#555570;margin-top:8px;">Nicht enthalten: ARIAs eigene Werkzeuge wie Gedächtnis-Suche oder Timer — die stehen im Brain-Log.</div>';
return;
}
const prettyInput = (name, input) => {
try { const o = JSON.parse(input); if (name === 'Bash' && o.command) return o.command;
if (o.file_path) return o.file_path + (o.pattern ? ' · ' + o.pattern : '');
if (o.pattern) return o.pattern + (o.path ? ' in ' + o.path : '');
return JSON.stringify(o, null, 1); } catch { return input || ''; }
};
body.innerHTML = head + j.requests.map(r => r.tools.map(x =>
'<div style="border:1px solid #1E1E2E;border-radius:8px;padding:8px 10px;margin-bottom:8px;background:#12122A;">' +
'<div style="display:flex;justify-content:space-between;gap:8px;"><b style="color:' + (x.isError ? '#FF6B6B' : '#4DB8FF') + ';">' + escapeHtml(x.name || '?') + (x.isError ? ' · Fehler' : '') + '</b>' +
'<span style="color:#555570;font-size:11px;">' + t(x.ts) + '</span></div>' +
'<pre style="margin:6px 0 0;white-space:pre-wrap;word-break:break-all;background:#000;border-radius:6px;padding:6px 8px;color:#E0E0F0;font-size:12px;">' + escapeHtml(prettyInput(x.name, x.input)) + '</pre>' +
(x.result != null ? '<details style="margin-top:6px;"><summary style="cursor:pointer;color:#8888AA;font-size:12px;">Ausgabe' +
(x.resultTruncatedBytes ? ' (gekürzt, ' + x.resultTruncatedBytes + ' Zeichen mehr)' : '') + '</summary>' +
'<pre style="margin:4px 0 0;white-space:pre-wrap;word-break:break-all;background:#000;border-radius:6px;padding:6px 8px;color:#3FFF9F;font-size:11px;max-height:320px;overflow:auto;">' + escapeHtml(x.result) + '</pre></details>'
: '<div style="color:#8888AA;font-size:11px;margin-top:4px;">(keine Ausgabe protokolliert)</div>') +
'</div>').join('')).join('') +
'<div style="font-size:11px;color:#555570;">Passwörter/Tokens sind im Protokoll geschwärzt. Nicht enthalten: Gedächtnis-Suche/Timer (Brain-Werkzeuge).</div>';
}
function openCtxInfo() { document.getElementById('ctx-info-modal').style.display = 'flex'; }
// Kontextfenster einer Karte aendern = dasselbe Modell mit anderem -c neu
+57
View File
@@ -554,6 +554,52 @@ async function refreshLlmCatalogFromHF() {
return { models: merged, added };
}
// ── Belege: Werkzeug-Aufrufe zu einer ARIA-Antwort ─────────────────
const ARIA_TOOL_LOG_DIR = "/shared/logs/aria-tools";
function ariaProof(toTs, project) {
// Beginn = Stefans letzte Nachricht davor im selben Kontext (sonst 30 min)
let fromTs = toTs - 30 * 60 * 1000;
try {
const lines = fs.readFileSync("/shared/config/chat_backup.jsonl", "utf-8").split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
if (!lines[i]) continue;
let e; try { e = JSON.parse(lines[i]); } catch { continue; }
if (!e || !(e.ts < toTs) || e.role !== "user") continue;
if ((e.project_id || "") !== project) continue;
fromTs = Math.max(fromTs, e.ts - 2000);
break;
}
} catch (_) { }
const days = new Set([new Date(fromTs).toISOString().slice(0, 10), new Date(toTs).toISOString().slice(0, 10)]);
const reqs = new Map();
for (const d of days) {
let txt = "";
try { txt = fs.readFileSync(`${ARIA_TOOL_LOG_DIR}/${d}.jsonl`, "utf-8"); } catch { continue; }
for (const line of txt.split("\n")) {
if (!line) continue;
let ev; try { ev = JSON.parse(line); } catch { continue; }
if (!ev.requestId) continue;
const r = reqs.get(ev.requestId) || { requestId: ev.requestId, project: null, start: 0, end: 0, tools: [], byId: {} };
if (ev.kind === "start") { r.start = ev.ts; r.project = ev.projectId || ""; r.model = ev.model || ""; }
else if (ev.kind === "end") r.end = ev.ts;
else if (ev.kind === "tool_use") {
const t = { id: ev.id, name: ev.name, input: ev.input, inputTruncatedBytes: ev.inputTruncatedBytes || 0, ts: ev.ts };
r.tools.push(t); if (ev.id) r.byId[ev.id] = t;
} else if (ev.kind === "tool_result") {
const t = ev.id && r.byId[ev.id];
if (t) { t.result = ev.content; t.isError = !!ev.isError; t.resultTruncatedBytes = ev.truncatedBytes || 0; t.doneTs = ev.ts; }
}
reqs.set(ev.requestId, r);
}
}
const hit = [...reqs.values()]
.filter(r => r.start && r.project === project && r.start >= fromTs && r.start <= toTs + 5000)
.sort((a, b) => a.start - b.start)
.map(({ byId, ...r }) => r);
const logSince = (() => { try { return fs.readdirSync(ARIA_TOOL_LOG_DIR).filter(f => /\.jsonl$/.test(f)).sort()[0]?.slice(0, 10) || ""; } catch { return ""; } })();
return { from: fromTs, to: toTs, requests: hit, toolCount: hit.reduce((n, r) => n + r.tools.length, 0), logSince };
}
// ── Gerade im Trend: ladbare GGUF-Modelle von HuggingFace ───────────
// Fuer das Fenster "Bestenlisten" im Modell-Katalog. "Trend" ist nicht
// "das Beste" — viele Community-Mischungen. Darum standardmaessig nur
@@ -2388,6 +2434,17 @@ const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message, models: loadLlmCatalog() }));
});
} else if (req.url.startsWith("/api/aria-proof") && req.method === "GET") {
// Belege zu einer ARIA-Antwort: alle Werkzeug-Aufrufe (Bash/ssh, Read, …),
// die ARIA im Proxy zwischen Stefans Frage und dieser Antwort gemacht hat.
// Quelle: /shared/logs/aria-tools/<tag>.jsonl (proxy-patches/routes.js).
const u = new URL(req.url, "http://x");
const to = Number(u.searchParams.get("ts")) || 0;
const project = u.searchParams.get("project") || "";
res.writeHead(200, { "Content-Type": "application/json" });
if (!to) { res.end(JSON.stringify({ ok: false, error: "ts fehlt" })); return; }
try { res.end(JSON.stringify({ ok: true, ...ariaProof(to, project) })); }
catch (e) { res.end(JSON.stringify({ ok: false, error: e.message })); }
} else if (req.url.startsWith("/api/llm-discover") && req.method === "GET") {
const u = new URL(req.url, "http://x");
discoverGguf(u.searchParams.get("cat") || "chat", u.searchParams.get("trusted") !== "0")
+45 -1
View File
@@ -118,7 +118,51 @@ function _emitToolEvent(toolName, projectId) {
* kind: "start" | "text" | "tool_use" | "tool_result" | "end"
*/
function _emitStreamEvent(requestId, kind, fields) {
_postJson(STREAM_HOOK_URL, { requestId, kind, ts: Date.now(), ...fields });
const ev = { requestId, kind, ts: Date.now(), ...fields };
_postJson(STREAM_HOOK_URL, ev);
_persistToolEvent(ev);
}
// ── Werkzeug-Protokoll (Beleg) ────────────────────────────────
// Was ARIA SELBST im Proxy ausfuehrt (Bash/ssh, Read, Edit, …) war bisher
// nur live sichtbar. Jetzt zusaetzlich dauerhaft: eine JSONL-Datei pro Tag
// unter /shared/logs/aria-tools/. Die Diagnostic zeigt pro ARIA-Antwort die
// zugehoerigen Aufrufe ("Belege"). Nur start/tool_use/tool_result/end —
// keine Text-Deltas. Fail-open: ein Schreibfehler stoert ARIA nie.
const TOOL_LOG_DIR = process.env.ARIA_TOOL_LOG_DIR || "/shared/logs/aria-tools";
const TOOL_LOG_KINDS = new Set(["start", "tool_use", "tool_result", "end"]);
const TOOL_LOG_KEEP_DAYS = 30;
let _toolLogCleaned = "";
// Offensichtliche Geheimnisse nicht dauerhaft ablegen (Passwoerter, Tokens,
// API-Keys, private Schluessel) — Ausgaben wie "cat .env" landen sonst im Log.
const SECRET_PATTERNS = [
[/(-----BEGIN [A-Z ]*PRIVATE KEY-----)[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----)/g, "$1 *** $2"],
[/((?:pass(?:word|wd)?|token|secret|api[_-]?key|auth|credential)[\w-]*["']?\s*[=:]\s*["']?)[^\s"',;]{4,}/gi, "$1***"],
[/\b(hf_|ghp_|gho_|github_pat_|sk-|xox[bp]-|glpat-)[A-Za-z0-9_-]{8,}/g, "$1***"],
[/(https?:\/\/[^\s:@\/]+:)[^\s@\/]+@/g, "$1***@"],
];
function _redact(v) {
if (typeof v !== "string") return v;
for (const [re, rep] of SECRET_PATTERNS) v = v.replace(re, rep);
return v;
}
function _persistToolEvent(ev) {
if (!TOOL_LOG_KINDS.has(ev.kind)) return;
try {
ev = { ...ev, input: _redact(ev.input), content: _redact(ev.content) };
const day = new Date(ev.ts).toISOString().slice(0, 10);
fs.mkdirSync(TOOL_LOG_DIR, { recursive: true });
fs.appendFile(`${TOOL_LOG_DIR}/${day}.jsonl`, JSON.stringify(ev) + "\n", () => {});
if (_toolLogCleaned !== day) { // einmal am Tag: alte Tage weg
_toolLogCleaned = day;
const cutoff = new Date(Date.now() - TOOL_LOG_KEEP_DAYS * 86400000).toISOString().slice(0, 10);
for (const f of fs.readdirSync(TOOL_LOG_DIR)) {
if (/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) < cutoff) {
try { fs.unlinkSync(`${TOOL_LOG_DIR}/${f}`); } catch (_) { }
}
}
}
} catch (_) { /* fail-open */ }
}
function _truncate(str, max) {