diora-web/static/js/ai.js
mrwnslz 0ba16a40ff
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 10m46s
Test / e2e (push) Failing after 1m27s
KI-Assistent (eigener Anthropic-Key, Ende-zu-Ende verschlüsselt, Aufrufe direkt vom Gerät) mit „Mit KI ordnen“ für Bücher; Bibliothek als XML exportieren; sw v148
- /accounts/vault/<name>/: kleine verschlüsselte Geräte-Einstellungen (UserVault)
- static/js/ai.js: DioraAI (SDK 0.133.0 gebündelt in static/vendor), Vorschläge zum Abhaken
- static/js/app/55-library-tools.js: ✦ Ordnen (Ordner, Serien, Titel/Autor, Zeitschrift) und ⇩ XML-Export
- docs/ai.md, docs/library.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:34:49 +02:00

284 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ---------------------------------------------------------------------------
// The "KI" features (docs/ai.md): Claude suggests, the user decides.
//
// The Anthropic API key is the user's own. It lives end-to-end encrypted with the book key
// (POST /accounts/vault/ai/, like the books: the server keeps a blob it can't read) and is
// decrypted on the device into memory only. The device calls Claude directly — the diora
// server sees neither the key nor what is sent; Anthropic sees what a feature needs (titles,
// authors, folder names …). Nothing is changed without the user ticking it in review().
//
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema}), review(…)}
// ---------------------------------------------------------------------------
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
const MODEL = 'claude-opus-5-5';
const VAULT = '/accounts/vault/ai/';
const KEY_HELP = 'https://console.anthropic.com/settings/keys';
let apiKey = null; // decrypted, in memory only
let loaded = false;
const html = document.documentElement;
const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// The book key of this device: never make one (getOrCreateEncKey would), the vault needs the real one.
function bookKeyThere() {
try { return !!localStorage.getItem(`diora_enc_key_${window.USER_ID || 0}`); } catch (e) { return false; }
}
function available() {
return typeof IS_AUTHENTICATED !== 'undefined' && !!IS_AUTHENTICATED && !html.classList.contains('nui-reader-app') &&
bookKeyThere() && typeof getOrCreateEncKey === 'function';
}
async function loadKey(fresh) {
if (loaded && !fresh) return apiKey;
if (!available()) return null;
try {
const r = await fetch(VAULT, {cache: 'no-store'});
if (!r.ok) return apiKey;
const d = await r.json();
apiKey = null;
if (d.ct) {
const plain = await decryptBytes(await getOrCreateEncKey(), d.iv, d.ct);
apiKey = JSON.parse(new TextDecoder().decode(plain)).anthropic_key || null;
}
loaded = true;
} catch (e) {
// Offline, or encrypted with a key this device doesn't have (the password changed): ask again.
loaded = false;
}
return apiKey;
}
async function saveKey(key) {
const enc = await encryptBytes(await getOrCreateEncKey(), new TextEncoder().encode(JSON.stringify({anthropic_key: key})));
const r = await fetch(VAULT, {method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ct: enc.ciphertext, iv: enc.iv})});
if (!r.ok) throw new Error('Speichern ging nicht (' + r.status + ')');
apiKey = key;
loaded = true;
}
async function removeKey() {
await fetch(VAULT, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ct: ''})});
apiKey = null;
loaded = true;
}
function client(key) {
// In the page (browser, the apps' WebView): the key is the user's own and stays on the device.
return new Anthropic({apiKey: key, dangerouslyAllowBrowser: true});
}
// A plain German message for what went wrong.
function explain(e) {
if (e && e.diora) return e.message;
if (e instanceof Anthropic.AuthenticationError || e instanceof Anthropic.PermissionDeniedError) {
return 'Der API-Key wird nicht angenommen – in den KI-Einstellungen prüfen.';
}
if (e instanceof Anthropic.RateLimitError) return 'Gerade zu viele Anfragen – bitte gleich noch einmal.';
if (e instanceof Anthropic.APIConnectionError) return navigator.onLine ? 'Claude war nicht erreichbar.' : 'Keine Verbindung.';
if (e instanceof Anthropic.APIError) return 'Claude antwortete mit einem Fehler (' + (e.status || '?') + ').';
return (e && e.message) || 'Unbekannter Fehler.';
}
function fail(message) {
const e = new Error(message);
e.diora = true;
return e;
}
// One request with a JSON answer of `schema` (structured outputs). Long lists stream.
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000}) {
const key = await loadKey();
if (!key) throw fail('Kein API-Key hinterlegt.');
const stream = client(key).beta.messages.stream({
model: MODEL,
max_tokens: maxTokens,
betas: ['server-side-fallback-2026-07-01'],
fallbacks: 'default',
system,
messages: [{role: 'user', content: prompt}],
output_config: {effort, format: {type: 'json_schema', schema}},
});
const msg = await stream.finalMessage();
if (msg.stop_reason === 'refusal') throw fail('Claude hat diese Anfrage abgelehnt.');
if (msg.stop_reason === 'max_tokens') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.');
const text = msg.content.filter(b => b.type === 'text').map(b => b.text).join('');
try {
return JSON.parse(text);
} catch (e) {
throw fail('Die Antwort ließ sich nicht lesen.');
}
}
// ---- a sheet of our own (settings, progress, review) --------------------------------------
function sheet(title, noGear) {
close();
const wrap = document.createElement('div');
wrap.id = 'dai-sheet';
wrap.innerHTML = `<div class="dai-panel" role="dialog" aria-label="${esc(title)}">
<div class="dai-top"><strong>${esc(title)}</strong><span>${noGear ? '' : '<button type="button" class="dai-x dai-gear" aria-label="KI-Einstellungen" title="KI-Einstellungen">⚙</button>'}<button type="button" class="dai-x dai-close" aria-label="Schließen">✕</button></span></div>
<div class="dai-body"></div></div>`;
wrap.addEventListener('click', e => {
if (e.target.closest('.dai-gear')) { settings(); return; }
if (e.target === wrap || e.target.closest('.dai-close')) close();
});
document.body.append(wrap);
return wrap.querySelector('.dai-body');
}
function close() {
const s = document.getElementById('dai-sheet');
if (s) s.remove();
return !!s;
}
async function settings(after) {
const body = sheet('KI-Assistent', true);
const has = !!(await loadKey(true));
body.innerHTML = `
<p>diora kann mit Claude (Anthropic) Vorschläge zum Ordnen machen – Bücher, Podcasts, Nachrichten, Projekte.
Du entscheidest bei jedem Vorschlag, ob er übernommen wird.</p>
<p class="dai-muted">Dein eigener API-Key: Er wird mit deinem Buchschlüssel verschlüsselt gespeichert – der diora-Server
kann ihn nicht lesen. Deine Geräte fragen Claude direkt; Anthropic sieht dabei, was eine Funktion braucht
(z. B. Titel und Autoren), der diora-Server nichts davon. Die Kosten rechnet Anthropic mit dir ab.
Einen Key legst du unter <a href="${KEY_HELP}" target="_blank" rel="noopener">console.anthropic.com</a> an.</p>
<label class="dai-field"><span>${has ? 'Key ersetzen' : 'API-Key'}</span>
<input type="password" class="dai-key" autocomplete="off" placeholder="${has ? '•••• hinterlegt' : 'sk-ant-…'}"></label>
<div class="dai-row">
<button type="button" class="btn btn-primary dai-save">Speichern</button>
${has ? '<button type="button" class="btn dai-remove">Key entfernen</button>' : ''}
<span class="dai-status dai-muted"></span>
</div>`;
const status = body.querySelector('.dai-status');
body.querySelector('.dai-save').addEventListener('click', async () => {
const key = body.querySelector('.dai-key').value.trim();
if (!key) { status.textContent = 'Bitte einen Key eingeben.'; return; }
status.textContent = 'Wird geprüft …';
try {
await client(key).models.retrieve(MODEL); // checks the key, costs nothing
} catch (e) {
status.textContent = explain(e);
return;
}
try {
await saveKey(key);
} catch (e) {
status.textContent = e.message;
return;
}
status.textContent = '✓ Gespeichert';
setTimeout(() => { close(); if (after) after(); }, 600);
});
const rm = body.querySelector('.dai-remove');
if (rm) rm.addEventListener('click', async () => { await removeKey(); close(); });
}
// The key first; then fn.
async function withKey(fn) {
if (!available()) return;
if (await loadKey()) fn();
else settings(fn);
}
function progress(title, text) {
const body = sheet(title);
body.innerHTML = `<p class="dai-wait"><span class="dai-spin"></span> ${esc(text)}</p>`;
return body;
}
function showError(title, e) {
const body = sheet(title);
body.innerHTML = `<p>${esc(explain(e))}</p><div class="dai-row"><button type="button" class="btn dai-keys">KI-Einstellungen</button></div>`;
body.querySelector('.dai-keys').addEventListener('click', () => settings());
}
// Suggestions to tick: items [{id, title, lines: [text], checked}], groups optional (item.group).
// onApply(ids) does the work and returns a short message.
function review({title, intro, items, applyLabel = 'Übernehmen', onApply}) {
const body = sheet(title);
if (!items.length) {
body.innerHTML = `<p>${esc(intro || '')}</p><p>Claude hat nichts gefunden, das sich zu ändern lohnt.</p>`;
return;
}
let h = intro ? `<p class="dai-muted">${esc(intro)}</p>` : '';
h += `<div class="dai-row dai-all"><label><input type="checkbox" class="dai-toggle" checked> Alle</label>
<span class="dai-muted">${items.length} Vorschläge</span></div><div class="dai-list">`;
let group = null;
items.forEach((it, i) => {
if (it.group && it.group !== group) { group = it.group; h += `<div class="dai-group">${esc(group)}</div>`; }
h += `<label class="dai-item"><input type="checkbox" data-i="${i}"${it.checked === false ? '' : ' checked'}>
<span><strong>${esc(it.title)}</strong>${(it.lines || []).map(l => `<span class="dai-line">${esc(l)}</span>`).join('')}</span></label>`;
});
h += `</div><div class="dai-row dai-foot"><button type="button" class="btn btn-primary dai-apply">${esc(applyLabel)}</button>
<span class="dai-status dai-muted"></span></div>`;
body.innerHTML = h;
const boxes = () => [...body.querySelectorAll('.dai-item input')];
body.querySelector('.dai-toggle').addEventListener('change', e => boxes().forEach(b => { b.checked = e.target.checked; }));
body.querySelector('.dai-apply').addEventListener('click', async e => {
const ids = boxes().filter(b => b.checked).map(b => items[Number(b.dataset.i)].id);
if (!ids.length) { close(); return; }
e.target.disabled = true;
const status = body.querySelector('.dai-status');
status.textContent = 'Wird übernommen …';
try {
const msg = await onApply(ids);
close();
if (msg && window.DioraAI.toast) window.DioraAI.toast(msg);
} catch (err) {
status.textContent = explain(err);
e.target.disabled = false;
}
});
}
function toast(text) {
let t = document.getElementById('dai-toast');
if (!t) { t = document.createElement('div'); t.id = 'dai-toast'; document.body.append(t); }
t.textContent = text;
t.classList.add('on');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('on'), 3500);
}
const style = document.createElement('style');
style.textContent = [
'#dai-sheet { position: fixed; inset: 0; z-index: 3200; background: rgba(0,0,0,.45); display: flex; align-items: flex-end; justify-content: center; }',
'@media (min-width: 720px) { #dai-sheet { align-items: center; } }',
'#dai-sheet .dai-panel { background: var(--panel, #111); color: var(--fg, #eee); width: min(640px, 100%); max-height: 88vh; display: flex; flex-direction: column; border-radius: 12px 12px 0 0; box-shadow: 0 -4px 24px rgba(0,0,0,.4); }',
'@media (min-width: 720px) { #dai-sheet .dai-panel { border-radius: 12px; } }',
'#dai-sheet .dai-top { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 6px; }',
'#dai-sheet .dai-x { background: none; border: none; color: inherit; font-size: 18px; min-width: 40px; min-height: 40px; cursor: pointer; }',
'#dai-sheet .dai-body { padding: 0 16px 16px; overflow-y: auto; line-height: 1.45; }',
'#dai-sheet .dai-muted { color: var(--muted, #999); font-size: 13px; }',
'#dai-sheet .dai-field { display: flex; flex-direction: column; gap: 4px; margin: 12px 0; }',
'#dai-sheet .dai-field input { padding: 9px 10px; border-radius: 6px; border: 1px solid var(--border-strong, #444); background: var(--raised, #1a1a1a); color: inherit; font: inherit; }',
'#dai-sheet .dai-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0; }',
'#dai-sheet .dai-list { display: flex; flex-direction: column; }',
'#dai-sheet .dai-item { display: flex; gap: 10px; align-items: flex-start; padding: 8px 0; border-bottom: 1px solid var(--border, #2a2a2a); cursor: pointer; }',
'#dai-sheet .dai-item input { margin-top: 3px; flex: none; }',
'#dai-sheet .dai-line { display: block; font-size: 13px; color: var(--muted, #999); }',
'#dai-sheet .dai-group { margin: 14px 0 2px; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted, #999); }',
'#dai-sheet .dai-foot { position: sticky; bottom: 0; background: var(--panel, #111); padding: 10px 0 0; margin: 0; }',
'#dai-sheet .dai-spin { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--muted, #999); border-top-color: transparent; border-radius: 50%; animation: dai-spin 1s linear infinite; vertical-align: -2px; margin-right: 6px; }',
'@keyframes dai-spin { to { transform: rotate(360deg); } }',
'#dai-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 80px); transform: translateX(-50%); z-index: 3300; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }',
'#dai-toast.on { opacity: 1; }',
'.dai-btn { font-size: 16px; }',
].join('\n');
document.head.append(style);
// Android's back key closes the sheet first.
const back = window.__dioraBack;
window.__dioraBack = function () {
if (close()) return true;
return back ? back.apply(this, arguments) : false;
};
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), settings, withKey, ask, review, progress, showError,
close, toast, explain};
window.dispatchEvent(new Event('diora-ai'));