// --------------------------------------------------------------------------- // The "KI" features (docs/ai.md): Claude suggests, the user decides. // // The key (Anthropic's or OpenRouter's) 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, effort, maxTokens, onProgress, signal}), review(…)} // --------------------------------------------------------------------------- import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs'; // Two ways to Claude, chosen in the settings (docs/ai.md): // - anthropic: the Anthropic API with the official SDK (structured output, fallbacks on refusal); // - openrouter: OpenRouter's chat completions (its Anthropic-compatible endpoint refuses the // headers a browser call needs), the same Claude model, structured output by response_format. const PROVIDERS = { anthropic: {label: 'Anthropic', model: 'claude-opus-5-5', help: 'https://console.anthropic.com/settings/keys', helpLabel: 'console.anthropic.com', placeholder: 'sk-ant-…'}, openrouter: {label: 'OpenRouter', model: 'anthropic/claude-opus-5.5', help: 'https://openrouter.ai/settings/keys', helpLabel: 'openrouter.ai', placeholder: 'sk-or-…'}, }; const OPENROUTER = 'https://openrouter.ai/api/v1'; const VAULT = '/accounts/vault/ai/'; // Decrypted, in memory only: {provider, anthropic_key, openrouter_key}. let vault = null; 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'; } const providerOf = v => (v && PROVIDERS[v.provider] ? v.provider : 'anthropic'); const keyOf = v => (v ? v[providerOf(v) + '_key'] || null : null); async function loadVault(fresh) { if (loaded && !fresh) return vault; if (!available()) return null; try { const r = await fetch(VAULT, {cache: 'no-store'}); if (!r.ok) return vault; const d = await r.json(); vault = null; if (d.ct) { const plain = await decryptBytes(await getOrCreateEncKey(), d.iv, d.ct); vault = JSON.parse(new TextDecoder().decode(plain)) || null; } loaded = true; } catch (e) { // Offline, or encrypted with a key this device doesn't have (the password changed): ask again. loaded = false; } return vault; } async function loadKey(fresh) { return keyOf(await loadVault(fresh)); } async function saveVault(v) { const enc = await encryptBytes(await getOrCreateEncKey(), new TextEncoder().encode(JSON.stringify(v))); 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 + ')'); vault = v; loaded = true; } async function removeKeys() { await fetch(VAULT, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ct: ''})}); vault = 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 || '?') + ').'; if (e instanceof TypeError) return navigator.onLine ? 'Der KI-Anbieter war nicht erreichbar.' : 'Keine Verbindung.'; return (e && e.message) || 'Unbekannter Fehler.'; } function fail(message) { const e = new Error(message); e.diora = true; return e; } function parseAnswer(text) { try { return JSON.parse(text); } catch (e) { throw fail('Die Antwort ließ sich nicht lesen.'); } } // Words in a text so far (for onProgress). const countWords = t => { const m = String(t || '').match(/\S+/g); return m ? m.length : 0; }; // One request with a JSON answer of `schema` (structured outputs). // Optional: onProgress(words) while the answer is written (throttled; OpenRouter is not streamed, // it reports once at the end), signal (an AbortSignal) to give up. async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000, onProgress, signal}) { const v = await loadVault(); const key = keyOf(v); if (!key) throw fail('Kein API-Key hinterlegt.'); if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal}); // Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model. const stream = client(key).beta.messages.stream({ model: PROVIDERS.anthropic.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}}, }, signal ? {signal} : undefined); if (onProgress) { let last = 0; stream.on('text', (delta, snapshot) => { const now = Date.now(); if (now - last < 400) return; last = now; try { onProgress(countWords(snapshot)); } catch (e) { /* the caller's display must not break the call */ } }); } const msg = await stream.finalMessage(); if (onProgress) { try { onProgress(countWords(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''))); } catch (e) { /* see above */ } } 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.'); return parseAnswer(msg.content.filter(b => b.type === 'text').map(b => b.text).join('')); } async function openRouterFetch(key, path, init) { const r = await fetch(OPENROUTER + path, Object.assign({}, init, {headers: Object.assign({ 'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json', 'X-Title': 'diora', }, (init && init.headers) || {})})); if (r.status === 401 || r.status === 403) throw fail('Der OpenRouter-Key wird nicht angenommen – in den KI-Einstellungen prüfen.'); if (r.status === 402) throw fail('Das OpenRouter-Guthaben reicht nicht.'); if (r.status === 429) throw fail('Gerade zu viele Anfragen – bitte gleich noch einmal.'); if (!r.ok) throw fail('OpenRouter antwortete mit einem Fehler (' + r.status + ').'); return r.json(); } async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal}) { const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', signal, body: JSON.stringify({ model: PROVIDERS.openrouter.model, max_tokens: maxTokens, reasoning: {effort}, messages: [{role: 'system', content: system}, {role: 'user', content: prompt}], response_format: {type: 'json_schema', json_schema: {name: 'antwort', strict: true, schema}}, })}); const choice = d && d.choices && d.choices[0]; if (!choice) throw fail('Die Antwort ließ sich nicht lesen.'); if (choice.message && choice.message.refusal) throw fail('Claude hat diese Anfrage abgelehnt.'); if (choice.finish_reason === 'length') throw fail('Die Antwort wurde zu lang – bitte einen kleineren Teil ordnen.'); const text = (choice.message && choice.message.content) || ''; if (onProgress) { try { onProgress(countWords(text)); } catch (e) { /* the caller's display must not break the call */ } } return parseAnswer(text); } // Is this key accepted? (Costs nothing: Anthropic's model info, OpenRouter's key info.) async function checkKey(provider, key) { if (provider === 'openrouter') await openRouterFetch(key, '/key', {method: 'GET'}); else await client(key).models.retrieve(PROVIDERS.anthropic.model); } // ---- a sheet of our own (settings, progress, review) -------------------------------------- function sheet(title, noGear) { close(); const wrap = document.createElement('div'); wrap.id = 'dai-sheet'; wrap.innerHTML = ``; 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 v = (await loadVault(true)) || {}; let provider = providerOf(v); const draw = () => { const p = PROVIDERS[provider]; const has = !!v[provider + '_key']; body.innerHTML = `

diora kann mit Claude Vorschläge zum Ordnen machen – Bücher, Podcasts, Nachrichten, Projekte. Du entscheidest bei jedem Vorschlag, ob er übernommen wird.

${Object.entries(PROVIDERS).map(([id, x]) => ``).join('')}

Dein eigener Key, mit deinem Buchschlüssel verschlüsselt gespeichert – der diora-Server kann ihn nicht lesen. Deine Geräte fragen ${esc(p.label)} direkt; ${provider === 'openrouter' ? 'OpenRouter und Anthropic sehen' : 'Anthropic sieht'} dabei, was eine Funktion braucht (z. B. Titel und Autoren), der diora-Server nichts davon. Die Kosten rechnet ${esc(p.label)} mit dir ab. Einen Key legst du unter ${esc(p.helpLabel)} an.

${v.anthropic_key || v.openrouter_key ? '' : ''}
`; body.querySelectorAll('input[name="dai-provider"]').forEach(r => r.addEventListener('change', async () => { provider = r.value; // A provider whose key is there is used at once; otherwise once its key is saved. if (v[provider + '_key'] && v.provider !== provider) { v.provider = provider; try { await saveVault(Object.assign({}, v)); } catch (e) { /* stays for this visit */ } } draw(); })); const status = body.querySelector('.dai-status'); body.querySelector('.dai-save').addEventListener('click', async () => { const key = body.querySelector('.dai-key').value.trim(); if (!key && has) { v.provider = provider; try { await saveVault(Object.assign({}, v)); } catch (e) { status.textContent = e.message; return; } close(); if (after) after(); return; } if (!key) { status.textContent = 'Bitte einen Key eingeben.'; return; } status.textContent = 'Wird geprüft …'; try { await checkKey(provider, key); } catch (e) { status.textContent = explain(e); return; } const next = Object.assign({}, v, {provider, [provider + '_key']: key}); try { await saveVault(next); } catch (e) { status.textContent = e.message; return; } Object.assign(v, next); status.textContent = '✓ Gespeichert'; setTimeout(() => { close(); if (after) after(); }, 600); }); const rm = body.querySelector('.dai-remove'); if (rm) rm.addEventListener('click', async () => { await removeKeys(); close(); }); }; draw(); } // 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 = `

${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 += `
${items.length} Vorschläge
`; let group = null; items.forEach((it, i) => { if (it.group && it.group !== group) { group = it.group; h += `
${esc(group)}
`; } h += ``; }); h += `
`; 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; }; const modelName = () => PROVIDERS[providerOf(vault)].model; window.DioraAI = {available, hasKey: async () => !!(await loadKey()), modelName, settings, withKey, ask, review, progress, showError, close, toast, explain}; window.dispatchEvent(new Event('diora-ai'));