406 lines
19 KiB
JavaScript
406 lines
19 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);
|
|
});
|
|
});
|
|
|
|
// <button data-sidebar-toggle>: Seitenleiste ein-/ausblenden (auch Strg/Cmd+B). Der Zustand liegt in einem Cookie,
|
|
// damit der Server die Seite gleich richtig ausliefert (kein Flackern beim Laden).
|
|
(function () {
|
|
function toggle() {
|
|
var root = document.documentElement;
|
|
var collapsed = root.dataset.sidebar !== 'collapsed';
|
|
if (collapsed) root.dataset.sidebar = 'collapsed'; else delete root.dataset.sidebar;
|
|
document.cookie = 'aiw_sidebar=' + (collapsed ? 'collapsed' : 'open') + '; path=/; max-age=31536000; SameSite=Lax';
|
|
document.querySelectorAll('[data-sidebar-toggle]').forEach(function (b) {
|
|
b.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
|
});
|
|
}
|
|
document.addEventListener('click', function (e) {
|
|
if (e.target.closest('[data-sidebar-toggle]')) toggle();
|
|
});
|
|
document.addEventListener('keydown', function (e) {
|
|
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'b'
|
|
&& window.matchMedia('(min-width: 64.0625rem)').matches) {
|
|
e.preventDefault();
|
|
toggle();
|
|
}
|
|
});
|
|
})();
|
|
|
|
// <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); });
|
|
})();
|