- /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>
284 lines
14 KiB
JavaScript
284 lines
14 KiB
JavaScript
// ---------------------------------------------------------------------------
|
||
// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
|
||
// 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'));
|