// --------------------------------------------------------------------------- // 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, '"'); // 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 = `
diora kann mit Claude (Anthropic) Vorschläge zum Ordnen machen – Bücher, Podcasts, Nachrichten, Projekte. Du entscheidest bei jedem Vorschlag, ob er übernommen wird.
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 console.anthropic.com an.
${esc(text)}
`; return body; } function showError(title, e) { const body = sheet(title); body.innerHTML = `${esc(explain(e))}
`; 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 = `${esc(intro || '')}
Claude hat nichts gefunden, das sich zu ändern lohnt.
`; return; } let h = intro ? `${esc(intro)}
` : ''; h += `