diora-web/static/js/ai.js

385 lines
19 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 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, '&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';
}
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 = `<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 v = (await loadVault(true)) || {};
let provider = providerOf(v);
const draw = () => {
const p = PROVIDERS[provider];
const has = !!v[provider + '_key'];
body.innerHTML = `
<p>diora kann mit Claude Vorschläge zum Ordnen machen – Bücher, Podcasts, Nachrichten, Projekte.
Du entscheidest bei jedem Vorschlag, ob er übernommen wird.</p>
<div class="dai-row dai-providers">${Object.entries(PROVIDERS).map(([id, x]) =>
`<label><input type="radio" name="dai-provider" value="${id}"${id === provider ? ' checked' : ''}> ${esc(x.label)}${v[id + '_key'] ? ' ✓' : ''}</label>`).join('')}</div>
<p class="dai-muted">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 <a href="${p.help}" target="_blank" rel="noopener">${esc(p.helpLabel)}</a> an.</p>
<label class="dai-field"><span>${esc(p.label)}-Key${has ? ' ersetzen' : ''}</span>
<input type="password" class="dai-key" autocomplete="off" placeholder="${has ? '•••• hinterlegt' : p.placeholder}"></label>
<div class="dai-row">
<button type="button" class="btn btn-primary dai-save">${has ? 'Speichern' : 'Speichern und nutzen'}</button>
${v.anthropic_key || v.openrouter_key ? '<button type="button" class="btn dai-remove">Alle Keys entfernen</button>' : ''}
<span class="dai-status dai-muted"></span>
</div>`;
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 = `<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;
};
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'));