bifroest/app/static/js/app.js
2026-10-27 08:34:17 +01:00

382 lines
18 KiB
JavaScript

/* Was nur Bifröst braucht: Streaming der Antworten per SSE (→ Konzept 4.4).
Kein Inline-Skript (strenge CSP); Texte nur über textContent, nie als HTML. */
var STATUS_TEXT = {
queued: 'wartet', running: 'läuft', completed: 'fertig', failed: 'fehlgeschlagen',
cancelled: 'abgebrochen', waiting_approval: 'wartet auf Freigabe', waiting_input: 'wartet auf Antwort',
blocked_by_guardrail: 'vom Gateway blockiert', needs_reconciliation: 'Abgleich nötig'
};
// <article data-stream="/runs/…/events" data-answer="/runs/…/answer">: Antwort live anzeigen.
// EventSource verbindet sich selbst neu und schickt dabei Last-Event-ID; der Server liefert ab dort.
function attachStreams(root) {
root.querySelectorAll('[data-stream]:not([data-attached])').forEach(function (card) {
card.setAttribute('data-attached', '');
var text = card.querySelector('[data-text]');
var badge = card.querySelector('[data-status]');
var source = new EventSource(card.dataset.stream);
function payload(e) { return JSON.parse(e.data); }
function showStatus(status) { badge.textContent = STATUS_TEXT[status] || status; }
function done() {
source.close();
htmx.ajax('GET', card.dataset.answer, { target: card, swap: 'outerHTML' });
// z. B. die Ansicht „Änderungen“ nach neuen Commits aktualisieren
document.body.dispatchEvent(new CustomEvent('aiw:run-done'));
}
var toolList = card.querySelector('[data-tools]');
var TOOL_STATUS = { running: '…', ok: '✓', error: 'Fehler', denied: 'abgewiesen', pending: 'wartet auf Freigabe',
approved: 'freigegeben' };
source.addEventListener('token', function (e) { text.textContent += payload(e).text; });
// Tool-Schritte: Name und Kurzform der Argumente, Status wird aktualisiert
source.addEventListener('tool', function (e) {
var data = payload(e);
var item = toolList.querySelector('[data-call="' + CSS.escape(data.call_id) + '"]');
if (!item) {
item = document.createElement('li');
item.setAttribute('data-call', data.call_id);
var name = document.createElement('code');
name.textContent = data.name;
var state = document.createElement('span');
state.className = 'aiw-tool-state';
item.append(name, ' ', document.createTextNode(data.args || ''), ' ', state);
toolList.append(item);
toolList.hidden = false;
}
if (data.name && !item.querySelector('code').textContent) item.querySelector('code').textContent = data.name;
item.setAttribute('data-status', data.status);
item.querySelector('.aiw-tool-state').textContent = TOOL_STATUS[data.status] || data.status;
});
source.addEventListener('status', function (e) {
var data = payload(e);
showStatus(data.status);
// Neuer Versuch nach einem Fehler: die Teilantwort des alten Versuchs verwerfen
if (data.status === 'queued' && data.reason) {
text.textContent = '';
toolList.replaceChildren();
toolList.hidden = true;
}
});
source.addEventListener('message', done);
source.addEventListener('end', function (e) {
showStatus(payload(e).status);
done();
});
});
}
document.addEventListener('DOMContentLoaded', function () { attachStreams(document); });
document.addEventListener('htmx:afterSettle', function (e) { attachStreams(e.target); });
// Chat: Verlauf beim Öffnen unten zeigen; neue Inhalte mitscrollen, solange man unten ist
function nearBottom() {
return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 160;
}
function toBottom() { window.scrollTo(0, document.documentElement.scrollHeight); }
document.addEventListener('DOMContentLoaded', function () {
var chat = document.querySelector('[data-chat]');
if (!chat) return;
if (!location.hash || location.hash === '#messages-end') toBottom();
var follow = true;
window.addEventListener('scroll', function () { follow = nearBottom(); }, { passive: true });
new MutationObserver(function () { if (follow) toBottom(); })
.observe(document.getElementById('messages'), { childList: true, subtree: true, characterData: true });
});
// <form data-reset-on-success>: nach erfolgreichem Absenden leeren
document.addEventListener('htmx:afterRequest', function (e) {
var form = e.detail.elt;
if (form.matches && form.matches('form[data-reset-on-success]') && e.detail.successful &&
e.detail.xhr.status < 300) {
form.reset();
}
});
// <form data-confirm="Frage"> bzw. <button data-confirm="Frage">: erst nach Bestätigung absenden
document.addEventListener('submit', function (e) {
var question = (e.submitter && e.submitter.dataset.confirm) || e.target.dataset.confirm;
if (question && !window.confirm(question)) e.preventDefault();
});
// <button data-copy>: Text der Antwort (data-copy-source in derselben Karte) in die Zwischenablage
document.addEventListener('click', function (e) {
var button = e.target.closest && e.target.closest('[data-copy]');
if (!button) return;
var source = button.closest('article').querySelector('[data-copy-source]');
if (!source || !navigator.clipboard) return;
navigator.clipboard.writeText(source.innerText).then(function () {
var label = button.textContent;
button.textContent = 'Kopiert';
setTimeout(function () { button.textContent = label; }, 1500);
});
});
// <textarea data-submit-on-enter>: Strg/Cmd+Enter sendet das Formular
document.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && e.target.matches('textarea[data-submit-on-enter]')) {
e.preventDefault();
e.target.form.requestSubmit();
}
});
// <textarea data-commands='[{"name", "args", "description"}]'>: Befehle aus Hugin beim Tippen von / vorschlagen.
// Pfeiltasten wählen, Tab oder Enter übernimmt, Escape schließt; nach dem Namen zeigt ein Hinweis die Argumente.
(function () {
function setup(area) {
var box = document.getElementById(area.getAttribute('aria-controls'));
if (!box || area.dataset.commandsReady) return;
area.dataset.commandsReady = '1';
var commands = [];
try { commands = JSON.parse(area.dataset.commands); } catch (e) { return; }
var shown = [], active = 0;
function close() { box.hidden = true; box.textContent = ''; shown = []; }
function choose(i) {
var c = shown[i];
if (!c) return;
area.value = '/' + c.name + ' ';
area.focus();
area.setSelectionRange(area.value.length, area.value.length);
update();
}
function item(text, cls) {
var el = document.createElement('div');
el.className = cls;
el.textContent = text;
return el;
}
function update() {
var value = area.value, match = /^\/([a-z0-9-]*)$/.exec(value);
box.textContent = '';
if (match) {
shown = commands.filter(function (c) { return c.name.indexOf(match[1]) === 0; });
if (!shown.length) { close(); return; }
active = Math.min(active, shown.length - 1);
shown.forEach(function (c, i) {
var el = item('', 'aiw-command-option' + (i === active ? ' active' : ''));
el.setAttribute('role', 'option');
el.setAttribute('aria-selected', i === active ? 'true' : 'false');
el.appendChild(item('/' + c.name + c.args.map(function (a) { return ' <' + a + '>'; }).join(''), 'aiw-command-name'));
if (c.description) el.appendChild(item(c.description, 'text-xs text-muted'));
el.addEventListener('mousedown', function (e) { e.preventDefault(); choose(i); });
box.appendChild(el);
});
box.hidden = false;
return;
}
shown = [];
// Hinweis auf die Argumente, solange die erste Zeile ein bekannter Befehl ist
var head = /^\/([a-z0-9-]+)\s(.*)$/.exec(value.split('\n')[0]);
var command = head && commands.filter(function (c) { return c.name === head[1]; })[0];
if (command && command.args.length) {
var given = (head[2].match(/"[^"]*"|\S+/g) || []).length;
var rest = command.args.slice(given);
box.appendChild(item(rest.length ? 'Noch: ' + rest.map(function (a) { return '<' + a + '>'; }).join(' ')
+ ' (mit Leerzeichen in Anführungszeichen)' : 'Alle Argumente angegeben. Strg+Enter sendet.', 'text-xs text-muted aiw-command-hint'));
box.hidden = false;
} else {
close();
}
}
area.addEventListener('input', function () { active = 0; update(); });
area.addEventListener('blur', function () { if (shown.length) close(); });
area.addEventListener('keydown', function (e) {
if (!shown.length || box.hidden) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
active = (active + (e.key === 'ArrowDown' ? 1 : shown.length - 1)) % shown.length;
update();
} else if ((e.key === 'Tab' || e.key === 'Enter') && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
choose(active);
} else if (e.key === 'Escape') {
close();
}
});
area.form && area.form.addEventListener('submit', close);
}
function scan(root) { (root || document).querySelectorAll('textarea[data-commands]').forEach(setup); }
document.addEventListener('DOMContentLoaded', function () { scan(); });
document.addEventListener('htmx:afterSwap', function (e) { scan(e.target); });
})();
// Abgeglichene Ordner: lokalen Ordner mit Bifröst abgleichen (nur Neues und Geändertes hochladen, Fehlendes entfernen).
// Chrome/Edge: Ordner per showDirectoryPicker, Handle in IndexedDB gemerkt; sonst <input webkitdirectory> je Abgleich.
(function () {
var DB = 'aiw-folders', STORE = 'handles';
function idb() {
return new Promise(function (resolve, reject) {
var request = indexedDB.open(DB, 1);
request.onupgradeneeded = function () { request.result.createObjectStore(STORE); };
request.onsuccess = function () { resolve(request.result); };
request.onerror = function () { reject(request.error); };
});
}
function stored(key, value) {
return idb().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(STORE, value === undefined ? 'readonly' : 'readwrite');
var request = value === undefined ? tx.objectStore(STORE).get(key) : tx.objectStore(STORE).put(value, key);
request.onsuccess = function () { resolve(request.result); };
request.onerror = function () { reject(request.error); };
});
}).catch(function () { return undefined; });
}
function hidden(name) { return name.charAt(0) === '.' || name.indexOf('~$') === 0; }
async function walk(handle, prefix, out, limit) {
for await (var entry of handle.values()) {
if (hidden(entry.name) || out.length > limit) continue;
if (entry.kind === 'directory') await walk(entry, prefix + entry.name + '/', out, limit);
else out.push({ path: prefix + entry.name, file: await entry.getFile() });
}
return out;
}
function setup(box) {
if (box.dataset.ready) return;
box.dataset.ready = '1';
var id = box.dataset.docFolder, csrf = box.dataset.csrf;
var suffixes = box.dataset.suffixes.split(','), maxBytes = Number(box.dataset.maxMb) * 1048576;
var maxFiles = Number(box.dataset.maxFiles);
var status = box.querySelector('[data-doc-status]'), syncButton = box.querySelector('[data-doc-sync]');
var pick = box.querySelector('[data-doc-pick]'), input = box.querySelector('[data-doc-input]');
var busy = false;
function say(text) { status.textContent = text; }
function wanted(entry) {
var name = entry.path.split('/').pop(), dot = name.lastIndexOf('.');
return dot > 0 && suffixes.indexOf(name.slice(dot).toLowerCase()) >= 0 && !entry.path.split('/').some(hidden);
}
async function sha256(file) {
var digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer());
return Array.prototype.map.call(new Uint8Array(digest), function (b) { return b.toString(16).padStart(2, '0'); }).join('');
}
// verify: Inhalt per Prüfsumme vergleichen, wenn Größe und Änderungszeit gleich sind (ohne gemerkten Ordner,
// weil Safari beim erneuten Wählen alte Dateiangaben liefern kann); nur in sicherem Kontext (HTTPS, localhost)
async function sync(entries, verify) {
if (busy) return;
busy = true;
try {
entries = entries.filter(wanted);
if (entries.length > maxFiles) { say('Der Ordner hat mehr als ' + maxFiles + ' passende Dateien; bitte einen kleineren wählen.'); return; }
var response = await fetch('/folders/' + id + '/manifest', { credentials: 'same-origin' });
if (!response.ok) { say('Abgleich nicht möglich (' + response.status + ').'); return; }
var known = (await response.json()).files;
var paths = entries.map(function (e) { return e.path; });
var gone = Object.keys(known).filter(function (p) { return paths.indexOf(p) < 0; });
if (gone.length && gone.length >= Object.keys(known).length / 2 && Object.keys(known).length > 3
&& !window.confirm(gone.length + ' von ' + Object.keys(known).length + ' Dateien fehlen im gewählten Ordner und würden entfernt. Richtiger Ordner?')) {
say('Abgebrochen.'); return;
}
var changed = [];
verify = verify && window.crypto && crypto.subtle;
for (var n = 0; n < entries.length; n++) {
var entry = entries[n], k = known[entry.path];
if (!k || k[0] !== entry.file.size || k[1] !== entry.file.lastModified) {
changed.push(entry);
} else if (verify && entry.file.size <= maxBytes) {
say('Prüfe Inhalte: ' + (n + 1) + ' von ' + entries.length);
if (await sha256(entry.file) !== k[2]) changed.push(entry);
}
}
var errors = [], done = 0;
for (var i = 0; i < changed.length; i++) {
var e = changed[i];
say('Lade hoch: ' + (i + 1) + ' von ' + changed.length + ' (' + e.path + ')');
if (e.file.size > maxBytes) { errors.push(e.path + ': zu groß'); paths.splice(paths.indexOf(e.path), 1); continue; }
var form = new FormData();
form.append('file', e.file, e.file.name);
form.append('path', e.path);
form.append('modified', String(e.file.lastModified));
var put = await fetch('/folders/' + id + '/files', { method: 'POST', body: form, credentials: 'same-origin', headers: { 'X-CSRF-Token': csrf } });
if (put.ok) { done++; } else {
var body = await put.json().catch(function () { return {}; });
errors.push(body.error || (e.path + ': Fehler ' + put.status));
if (!known[e.path]) paths.splice(paths.indexOf(e.path), 1);
}
}
var prune = await fetch('/folders/' + id + '/prune', { method: 'POST', credentials: 'same-origin',
headers: { 'X-CSRF-Token': csrf, 'Content-Type': 'application/json' }, body: JSON.stringify({ paths: paths }) });
var removed = prune.ok ? (await prune.json()).removed : 0;
say('Abgeglichen: ' + done + ' neu oder geändert, ' + removed + ' entfernt, ' + (entries.length - changed.length) + ' unverändert'
+ (verify ? ' (Inhalte per Prüfsumme verglichen).' : '.')
+ (errors.length ? ' Nicht übernommen: ' + errors.join('; ') : ''));
if (done || removed) window.setTimeout(function () { window.location.reload(); }, errors.length ? 6000 : 1500);
} catch (err) {
say('Abgleich abgebrochen: ' + (err && err.message ? err.message : err));
} finally {
busy = false;
}
}
async function fromHandle(handle, ask) {
var mode = { mode: 'read' };
var state = await handle.queryPermission(mode);
if (state !== 'granted' && ask) state = await handle.requestPermission(mode);
if (state !== 'granted') return false;
say('Lese Ordner „' + handle.name + '“ …');
await sync(await walk(handle, '', [], maxFiles));
return true;
}
// Gemerkter Ordner (nur mit showDirectoryPicker); sonst wählt man bei jedem Abgleich neu
var current = null;
function remember(handle) {
current = handle;
pick.textContent = 'Anderen Ordner wählen …';
say('Gemerkter Ordner: „' + handle.name + '“.');
}
function failed(err) { if (err && err.name !== 'AbortError') say('Ordner nicht lesbar: ' + (err.message || err)); }
async function choose() {
if (!window.showDirectoryPicker) {
// Jedes Mal ein frisches Feld: Safari liefert beim erneuten Wählen im selben Feld sonst alte Dateien
var fresh = input.cloneNode(false);
input.replaceWith(fresh);
input = fresh;
input.addEventListener('change', fromInput);
input.click();
return;
}
var handle = await window.showDirectoryPicker({ id: 'aiw-folder-' + id, mode: 'read' });
await stored('folder-' + id, handle);
remember(handle);
await fromHandle(handle, true);
}
syncButton.hidden = false;
syncButton.addEventListener('click', function () {
(current ? fromHandle(current, true) : choose()).catch(failed);
});
pick.addEventListener('click', function () { choose().catch(failed); });
function fromInput() {
var entries = Array.prototype.map.call(input.files, function (file) {
return { path: file.webkitRelativePath.split('/').slice(1).join('/') || file.name, file: file };
});
sync(entries, true);
}
if (window.showDirectoryPicker) {
stored('folder-' + id).then(function (handle) {
if (!handle) return;
remember(handle);
// Seite offen und Zugriff noch erlaubt: gleich abgleichen
fromHandle(handle, false).catch(function () {});
});
}
}
document.addEventListener('DOMContentLoaded', function () { document.querySelectorAll('[data-doc-folder]').forEach(setup); });
})();