KI: OpenRouter als zweiter Anbieter (wählbar, chat completions mit json_schema); ✦ Mit KI ordnen für Podcasts (Ordner, Stufen) und Nachrichten/Blogs (Ressort, Ordner, Art); sw v149
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0ba16a40ff
commit
58003057e3
5 changed files with 373 additions and 64 deletions
13
docs/ai.md
13
docs/ai.md
|
|
@ -5,7 +5,16 @@ first; podcasts, news/blogs and projects follow (same building blocks).
|
||||||
|
|
||||||
## The key: the user's own, end-to-end encrypted, only on the devices
|
## The key: the user's own, end-to-end encrypted, only on the devices
|
||||||
|
|
||||||
Decided with the user: the Anthropic API key is entered in the app ("KI-Assistent", the ✦ of a
|
Two providers, chosen in the settings (decided 2026-10-25): **Anthropic** (the official SDK) or
|
||||||
|
**OpenRouter** (the same Claude model, `anthropic/claude-opus-5.5`). OpenRouter's
|
||||||
|
Anthropic-compatible endpoint refuses the headers a browser call needs (`anthropic-version` …,
|
||||||
|
checked by a CORS preflight), so diora talks to OpenRouter's chat completions instead: plain
|
||||||
|
`fetch`, `response_format` json_schema (strict), `reasoning.effort`; no fallback on refusals, and
|
||||||
|
OpenRouter sees what is sent too. The vault keeps `{provider, anthropic_key, openrouter_key}`
|
||||||
|
(an old `{anthropic_key}` still reads as Anthropic); a key is checked before it is kept
|
||||||
|
(Anthropic: the model's info; OpenRouter: `GET /api/v1/key`).
|
||||||
|
|
||||||
|
Decided with the user: the API key is entered in the app ("KI-Assistent", the ✦ of a
|
||||||
feature or the app menu) and stored like the books — encrypted with the book key on the device,
|
feature or the app menu) and stored like the books — encrypted with the book key on the device,
|
||||||
kept by the server as a blob it can't read:
|
kept by the server as a blob it can't read:
|
||||||
|
|
||||||
|
|
@ -42,5 +51,7 @@ kept by the server as a blob it can't read:
|
||||||
| area | where | what Claude gets | what it suggests |
|
| area | where | what Claude gets | what it suggests |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Bücher, Zeitschriften | ✦ in the library's head (`static/js/app/55-library-tools.js`) | per book: id, title, author, folders, series, magazine mark; the existing folders | folders to add (existing first; new ones only for ≥ 3 books), series with volume, cleaned title/author where broken, magazine mark |
|
| Bücher, Zeitschriften | ✦ in the library's head (`static/js/app/55-library-tools.js`) | per book: id, title, author, folders, series, magazine mark; the existing folders | folders to add (existing first; new ones only for ≥ 3 books), series with volume, cleaned title/author where broken, magazine mark |
|
||||||
|
| Podcasts | ✦ in Abos (diora-android `web-native/static/native-podcast.js`, copied to `static/podcasts/`) | per subscription: title, author, folders, level, heard in 12 months, publishing interval, three newest episode titles | folders to add (≤ 2), level Stamm / Strom / Auf Abruf (only with a clear reason) |
|
||||||
|
| Nachrichten, Blogs | ✦ in the feeds' bar (`static/js/feeds.js`; the apps' section bar presses it) | per source: title, address, kind, Ressort or folders, recent titles shown | Ressort for news, folders for blogs, news ↔ blog when clearly wrong |
|
||||||
|
|
||||||
Applied through `_updateBookMeta` (the encrypted meta, as when editing by hand).
|
Applied through `_updateBookMeta` (the encrypted meta, as when editing by hand).
|
||||||
|
|
|
||||||
204
static/js/ai.js
204
static/js/ai.js
|
|
@ -1,7 +1,7 @@
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// The "KI" features (docs/ai.md): Claude suggests, the user decides.
|
// 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
|
// 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
|
// (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
|
// 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,
|
// server sees neither the key nor what is sent; Anthropic sees what a feature needs (titles,
|
||||||
|
|
@ -12,11 +12,21 @@
|
||||||
|
|
||||||
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
|
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
|
||||||
|
|
||||||
const MODEL = 'claude-opus-5-5';
|
// 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/';
|
const VAULT = '/accounts/vault/ai/';
|
||||||
const KEY_HELP = 'https://console.anthropic.com/settings/keys';
|
|
||||||
|
|
||||||
let apiKey = null; // decrypted, in memory only
|
// Decrypted, in memory only: {provider, anthropic_key, openrouter_key}.
|
||||||
|
let vault = null;
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
|
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
|
|
@ -32,38 +42,45 @@ function available() {
|
||||||
bookKeyThere() && typeof getOrCreateEncKey === 'function';
|
bookKeyThere() && typeof getOrCreateEncKey === 'function';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadKey(fresh) {
|
const providerOf = v => (v && PROVIDERS[v.provider] ? v.provider : 'anthropic');
|
||||||
if (loaded && !fresh) return apiKey;
|
const keyOf = v => (v ? v[providerOf(v) + '_key'] || null : null);
|
||||||
|
|
||||||
|
async function loadVault(fresh) {
|
||||||
|
if (loaded && !fresh) return vault;
|
||||||
if (!available()) return null;
|
if (!available()) return null;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(VAULT, {cache: 'no-store'});
|
const r = await fetch(VAULT, {cache: 'no-store'});
|
||||||
if (!r.ok) return apiKey;
|
if (!r.ok) return vault;
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
apiKey = null;
|
vault = null;
|
||||||
if (d.ct) {
|
if (d.ct) {
|
||||||
const plain = await decryptBytes(await getOrCreateEncKey(), d.iv, d.ct);
|
const plain = await decryptBytes(await getOrCreateEncKey(), d.iv, d.ct);
|
||||||
apiKey = JSON.parse(new TextDecoder().decode(plain)).anthropic_key || null;
|
vault = JSON.parse(new TextDecoder().decode(plain)) || null;
|
||||||
}
|
}
|
||||||
loaded = true;
|
loaded = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Offline, or encrypted with a key this device doesn't have (the password changed): ask again.
|
// Offline, or encrypted with a key this device doesn't have (the password changed): ask again.
|
||||||
loaded = false;
|
loaded = false;
|
||||||
}
|
}
|
||||||
return apiKey;
|
return vault;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveKey(key) {
|
async function loadKey(fresh) {
|
||||||
const enc = await encryptBytes(await getOrCreateEncKey(), new TextEncoder().encode(JSON.stringify({anthropic_key: key})));
|
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'},
|
const r = await fetch(VAULT, {method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({ct: enc.ciphertext, iv: enc.iv})});
|
body: JSON.stringify({ct: enc.ciphertext, iv: enc.iv})});
|
||||||
if (!r.ok) throw new Error('Speichern ging nicht (' + r.status + ')');
|
if (!r.ok) throw new Error('Speichern ging nicht (' + r.status + ')');
|
||||||
apiKey = key;
|
vault = v;
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeKey() {
|
async function removeKeys() {
|
||||||
await fetch(VAULT, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ct: ''})});
|
await fetch(VAULT, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ct: ''})});
|
||||||
apiKey = null;
|
vault = null;
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,6 +98,7 @@ function explain(e) {
|
||||||
if (e instanceof Anthropic.RateLimitError) return 'Gerade zu viele Anfragen – bitte gleich noch einmal.';
|
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.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 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.';
|
return (e && e.message) || 'Unbekannter Fehler.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,12 +108,23 @@ function fail(message) {
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One request with a JSON answer of `schema` (structured outputs). Long lists stream.
|
function parseAnswer(text) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
throw fail('Die Antwort ließ sich nicht lesen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One request with a JSON answer of `schema` (structured outputs).
|
||||||
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000}) {
|
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000}) {
|
||||||
const key = await loadKey();
|
const v = await loadVault();
|
||||||
|
const key = keyOf(v);
|
||||||
if (!key) throw fail('Kein API-Key hinterlegt.');
|
if (!key) throw fail('Kein API-Key hinterlegt.');
|
||||||
|
if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens});
|
||||||
|
// Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model.
|
||||||
const stream = client(key).beta.messages.stream({
|
const stream = client(key).beta.messages.stream({
|
||||||
model: MODEL,
|
model: PROVIDERS.anthropic.model,
|
||||||
max_tokens: maxTokens,
|
max_tokens: maxTokens,
|
||||||
betas: ['server-side-fallback-2026-07-01'],
|
betas: ['server-side-fallback-2026-07-01'],
|
||||||
fallbacks: 'default',
|
fallbacks: 'default',
|
||||||
|
|
@ -106,12 +135,39 @@ async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000})
|
||||||
const msg = await stream.finalMessage();
|
const msg = await stream.finalMessage();
|
||||||
if (msg.stop_reason === 'refusal') throw fail('Claude hat diese Anfrage abgelehnt.');
|
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.');
|
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('');
|
return parseAnswer(msg.content.filter(b => b.type === 'text').map(b => b.text).join(''));
|
||||||
try {
|
}
|
||||||
return JSON.parse(text);
|
|
||||||
} catch (e) {
|
async function openRouterFetch(key, path, init) {
|
||||||
throw fail('Die Antwort ließ sich nicht lesen.');
|
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}) {
|
||||||
|
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', 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.');
|
||||||
|
return parseAnswer((choice.message && choice.message.content) || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) --------------------------------------
|
// ---- a sheet of our own (settings, progress, review) --------------------------------------
|
||||||
|
|
@ -139,43 +195,69 @@ function close() {
|
||||||
|
|
||||||
async function settings(after) {
|
async function settings(after) {
|
||||||
const body = sheet('KI-Assistent', true);
|
const body = sheet('KI-Assistent', true);
|
||||||
const has = !!(await loadKey(true));
|
const v = (await loadVault(true)) || {};
|
||||||
body.innerHTML = `
|
let provider = providerOf(v);
|
||||||
<p>diora kann mit Claude (Anthropic) Vorschläge zum Ordnen machen – Bücher, Podcasts, Nachrichten, Projekte.
|
const draw = () => {
|
||||||
Du entscheidest bei jedem Vorschlag, ob er übernommen wird.</p>
|
const p = PROVIDERS[provider];
|
||||||
<p class="dai-muted">Dein eigener API-Key: Er wird mit deinem Buchschlüssel verschlüsselt gespeichert – der diora-Server
|
const has = !!v[provider + '_key'];
|
||||||
kann ihn nicht lesen. Deine Geräte fragen Claude direkt; Anthropic sieht dabei, was eine Funktion braucht
|
body.innerHTML = `
|
||||||
(z. B. Titel und Autoren), der diora-Server nichts davon. Die Kosten rechnet Anthropic mit dir ab.
|
<p>diora kann mit Claude Vorschläge zum Ordnen machen – Bücher, Podcasts, Nachrichten, Projekte.
|
||||||
Einen Key legst du unter <a href="${KEY_HELP}" target="_blank" rel="noopener">console.anthropic.com</a> an.</p>
|
Du entscheidest bei jedem Vorschlag, ob er übernommen wird.</p>
|
||||||
<label class="dai-field"><span>${has ? 'Key ersetzen' : 'API-Key'}</span>
|
<div class="dai-row dai-providers">${Object.entries(PROVIDERS).map(([id, x]) =>
|
||||||
<input type="password" class="dai-key" autocomplete="off" placeholder="${has ? '•••• hinterlegt' : 'sk-ant-…'}"></label>
|
`<label><input type="radio" name="dai-provider" value="${id}"${id === provider ? ' checked' : ''}> ${esc(x.label)}${v[id + '_key'] ? ' ✓' : ''}</label>`).join('')}</div>
|
||||||
<div class="dai-row">
|
<p class="dai-muted">Dein eigener Key, mit deinem Buchschlüssel verschlüsselt gespeichert – der diora-Server kann ihn nicht
|
||||||
<button type="button" class="btn btn-primary dai-save">Speichern</button>
|
lesen. Deine Geräte fragen ${esc(p.label)} direkt; ${provider === 'openrouter' ? 'OpenRouter und Anthropic sehen' : 'Anthropic sieht'}
|
||||||
${has ? '<button type="button" class="btn dai-remove">Key entfernen</button>' : ''}
|
dabei, was eine Funktion braucht (z. B. Titel und Autoren), der diora-Server nichts davon. Die Kosten rechnet
|
||||||
<span class="dai-status dai-muted"></span>
|
${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>
|
||||||
</div>`;
|
<label class="dai-field"><span>${esc(p.label)}-Key${has ? ' ersetzen' : ''}</span>
|
||||||
const status = body.querySelector('.dai-status');
|
<input type="password" class="dai-key" autocomplete="off" placeholder="${has ? '•••• hinterlegt' : p.placeholder}"></label>
|
||||||
body.querySelector('.dai-save').addEventListener('click', async () => {
|
<div class="dai-row">
|
||||||
const key = body.querySelector('.dai-key').value.trim();
|
<button type="button" class="btn btn-primary dai-save">${has ? 'Speichern' : 'Speichern und nutzen'}</button>
|
||||||
if (!key) { status.textContent = 'Bitte einen Key eingeben.'; return; }
|
${v.anthropic_key || v.openrouter_key ? '<button type="button" class="btn dai-remove">Alle Keys entfernen</button>' : ''}
|
||||||
status.textContent = 'Wird geprüft …';
|
<span class="dai-status dai-muted"></span>
|
||||||
try {
|
</div>`;
|
||||||
await client(key).models.retrieve(MODEL); // checks the key, costs nothing
|
body.querySelectorAll('input[name="dai-provider"]').forEach(r => r.addEventListener('change', async () => {
|
||||||
} catch (e) {
|
provider = r.value;
|
||||||
status.textContent = explain(e);
|
// A provider whose key is there is used at once; otherwise once its key is saved.
|
||||||
return;
|
if (v[provider + '_key'] && v.provider !== provider) {
|
||||||
}
|
v.provider = provider;
|
||||||
try {
|
try { await saveVault(Object.assign({}, v)); } catch (e) { /* stays for this visit */ }
|
||||||
await saveKey(key);
|
}
|
||||||
} catch (e) {
|
draw();
|
||||||
status.textContent = e.message;
|
}));
|
||||||
return;
|
const status = body.querySelector('.dai-status');
|
||||||
}
|
body.querySelector('.dai-save').addEventListener('click', async () => {
|
||||||
status.textContent = '✓ Gespeichert';
|
const key = body.querySelector('.dai-key').value.trim();
|
||||||
setTimeout(() => { close(); if (after) after(); }, 600);
|
if (!key && has) {
|
||||||
});
|
v.provider = provider;
|
||||||
const rm = body.querySelector('.dai-remove');
|
try { await saveVault(Object.assign({}, v)); } catch (e) { status.textContent = e.message; return; }
|
||||||
if (rm) rm.addEventListener('click', async () => { await removeKey(); close(); });
|
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.
|
// The key first; then fn.
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,9 @@
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="fd-actions">' +
|
'<div class="fd-actions">' +
|
||||||
'<button type="button" class="fd-icon" data-act="refresh" title="Jetzt abrufen" aria-label="Jetzt abrufen">' + (st.loading ? '…' : '↻') + '</button>' +
|
'<button type="button" class="fd-icon" data-act="refresh" title="Jetzt abrufen" aria-label="Jetzt abrufen">' + (st.loading ? '…' : '↻') + '</button>' +
|
||||||
|
// Ordering with Claude (docs/ai.md): Ressorts for the news, folders for the blogs.
|
||||||
|
(window.DioraAI && DioraAI.available() && sources().length
|
||||||
|
? '<button type="button" class="fd-icon" data-act="ai-organize" title="Mit KI ordnen" aria-label="Mit KI ordnen">✦</button>' : '') +
|
||||||
'<button type="button" class="fd-icon" data-act="manage" title="Feeds verwalten" aria-label="Feeds verwalten">⚙</button>' +
|
'<button type="button" class="fd-icon" data-act="manage" title="Feeds verwalten" aria-label="Feeds verwalten">⚙</button>' +
|
||||||
'</div>') +
|
'</div>') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
|
@ -841,6 +844,108 @@
|
||||||
document.body.appendChild(sheet);
|
document.body.appendChild(sheet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ✦ Ordnen with Claude (docs/ai.md) ---------------------------------------------
|
||||||
|
|
||||||
|
const FEEDS_AI_SYSTEM = `Du hilfst, die Feed-Quellen eines Lesers zu ordnen: Nachrichtenquellen (kind "news") haben
|
||||||
|
genau ein Ressort (category), Blogs (kind "blog") haben Ordner wie Schlagwörter (mehrere möglich). Du bekommst die
|
||||||
|
Quellen als JSON (id, Titel, Adresse, kind, Ressort bzw. Ordner, Titel der letzten Beiträge) und die vorhandenen Ressorts
|
||||||
|
und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne sinnvolle Änderung lässt du weg.
|
||||||
|
|
||||||
|
- Ressort (nur news): eines der vorhandenen, wenn es passt; ein neues nur, wenn mehrere Quellen hineinpassen. Kurze
|
||||||
|
deutsche Namen (Deutschland, International, Wirtschaft, Technik, Kultur …). Nur setzen, wenn es fehlt oder klar falsch ist.
|
||||||
|
- Ordner (nur blog): vorhandene bevorzugt, neue nur für mindestens drei Blogs; höchstens zwei vorschlagen, nur hinzufügen.
|
||||||
|
- kind: nur wenn eine Quelle offensichtlich falsch eingeordnet ist (tägliche Meldungen → news, Essays/persönliche
|
||||||
|
Beiträge → blog), sonst null.
|
||||||
|
- warum: ein kurzer deutscher Satz.`;
|
||||||
|
|
||||||
|
const FEEDS_AI_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
vorschlaege: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {type: 'integer'},
|
||||||
|
ressort: {type: ['string', 'null']},
|
||||||
|
ordner_hinzu: {type: ['array', 'null'], items: {type: 'string'}},
|
||||||
|
kind: {type: ['string', 'null']},
|
||||||
|
warum: {type: 'string'},
|
||||||
|
},
|
||||||
|
required: ['id', 'ressort', 'ordner_hinzu', 'kind', 'warum'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['vorschlaege'],
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function feedsOrganize() {
|
||||||
|
DioraAI.withKey(async () => {
|
||||||
|
const list = sources();
|
||||||
|
if (!list.length) return;
|
||||||
|
DioraAI.progress('Mit KI ordnen', `Claude sieht sich ${list.length} Quellen an …`);
|
||||||
|
// A few recent titles per source say more than a name (what is loaded already).
|
||||||
|
const recent = new Map();
|
||||||
|
const note = (id, title) => { const a = recent.get(id) || []; if (a.length < 3 && title) { a.push(title); recent.set(id, a); } };
|
||||||
|
st.items.forEach(i => note(i.source, i.title));
|
||||||
|
st.stories.forEach(x => (x.items || []).forEach(i => note(i.source, i.title)));
|
||||||
|
const data = list.map(x => ({id: x.id, titel: x.title, adresse: x.site_url || x.url, kind: x.kind,
|
||||||
|
ressort: x.kind === 'news' ? (x.category || '') : undefined, ordner: x.kind === 'blog' ? (x.folders || []) : undefined,
|
||||||
|
letzte: recent.get(x.id) || []}));
|
||||||
|
const cats = st.state.categories || [];
|
||||||
|
const folders = st.state.folders || [];
|
||||||
|
let answer;
|
||||||
|
try {
|
||||||
|
answer = await DioraAI.ask({
|
||||||
|
system: FEEDS_AI_SYSTEM,
|
||||||
|
prompt: `Vorhandene Ressorts: ${JSON.stringify(cats)}\nVorhandene Blog-Ordner: ${JSON.stringify(folders)}\n\nQuellen:\n${JSON.stringify(data)}`,
|
||||||
|
schema: FEEDS_AI_SCHEMA,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
DioraAI.showError('Mit KI ordnen', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const byId = new Map(list.map(x => [x.id, x]));
|
||||||
|
const items = [];
|
||||||
|
for (const v of answer.vorschlaege || []) {
|
||||||
|
const x = byId.get(v.id);
|
||||||
|
if (!x) continue;
|
||||||
|
const change = {};
|
||||||
|
const lines = [];
|
||||||
|
const kind = v.kind === 'news' || v.kind === 'blog' ? v.kind : x.kind;
|
||||||
|
if (kind !== x.kind) { change.kind = kind; lines.push(kind === 'news' ? 'als Nachrichtenquelle führen' : 'als Blog führen'); }
|
||||||
|
if (kind === 'news' && v.ressort && v.ressort.trim() && v.ressort.trim() !== (x.category || '')) {
|
||||||
|
change.category = v.ressort.trim();
|
||||||
|
lines.push('Ressort: ' + change.category);
|
||||||
|
}
|
||||||
|
if (kind === 'blog') {
|
||||||
|
const have = x.folders || [];
|
||||||
|
const add = (v.ordner_hinzu || []).map(n => String(n).trim()).filter(n => n && !have.some(h => h.toLowerCase() === n.toLowerCase()));
|
||||||
|
if (add.length) { change.folders = have.concat(add); lines.push('Ordner + ' + add.join(', ')); }
|
||||||
|
}
|
||||||
|
if (!lines.length) continue;
|
||||||
|
if (v.warum) lines.push('– ' + v.warum);
|
||||||
|
items.push({id: x.id, title: x.title, group: x.kind === 'news' ? 'Nachrichten' : 'Blogs', lines, change});
|
||||||
|
}
|
||||||
|
items.sort((a, b) => a.group.localeCompare(b.group));
|
||||||
|
DioraAI.review({
|
||||||
|
title: 'Mit KI ordnen',
|
||||||
|
intro: 'Vorschläge von Claude. Was angehakt ist, wird übernommen.',
|
||||||
|
items,
|
||||||
|
onApply: async ids => {
|
||||||
|
for (const id of ids) {
|
||||||
|
const it = items.find(i => i.id === id);
|
||||||
|
if (it) await api('/feeds/sources/' + id + '/', it.change);
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
return `${ids.length} ${ids.length === 1 ? 'Quelle' : 'Quellen'} geordnet`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- events
|
// ---- events
|
||||||
|
|
||||||
tab.addEventListener('click', async e => {
|
tab.addEventListener('click', async e => {
|
||||||
|
|
@ -937,6 +1042,7 @@
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (act === 'ai-organize') return feedsOrganize();
|
||||||
if (act === 'manage') {
|
if (act === 'manage') {
|
||||||
// Managing every subscription has its place (Abos & Quellen, docs/sources.md).
|
// Managing every subscription has its place (Abos & Quellen, docs/sources.md).
|
||||||
const nav = window.DioraNav;
|
const nav = window.DioraNav;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v148';
|
const CACHE = 'diora-v149';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app/01-core.js',
|
'/static/js/app/01-core.js',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copied from diora-android 4f514cd 2026-10-19: web-native/static/native-podcast.js
|
// Copied from diora-android 9a6c059 2026-10-25: web-native/static/native-podcast.js
|
||||||
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
|
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
|
||||||
// and copy again. The browser host is static/js/podcasts-web.js.
|
// and copy again. The browser host is static/js/podcasts-web.js.
|
||||||
|
|
||||||
|
|
@ -2645,6 +2645,115 @@
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ✦ Ordnen with Claude (diora-web docs/ai.md) ------------------------------------------
|
||||||
|
// Folders and (with the two lists) levels for the subscriptions, from their titles, authors,
|
||||||
|
// newest episode titles and how much they are heard. The user ticks what to take.
|
||||||
|
|
||||||
|
const POD_AI_SYSTEM = `Du hilfst, die Podcast-Abos eines Hörers zu ordnen. Du bekommst die Abos als JSON (id, Titel,
|
||||||
|
Autor, Ordner, Stufe, wie viele Folgen in 12 Monaten gehört, mittlerer Abstand zwischen Folgen in Tagen, Titel der
|
||||||
|
neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wirklich helfen; ein Abo ohne sinnvolle
|
||||||
|
Änderung lässt du weg.
|
||||||
|
|
||||||
|
- Ordner sind Kategorien wie Schlagwörter (Politik, Geschichte, Wissenschaft …), ein Abo kann in mehreren sein.
|
||||||
|
Nutze vorhandene Ordner bevorzugt; einen neuen nur, wenn mindestens drei Abos hineinpassen, kurzer deutscher Name.
|
||||||
|
Höchstens zwei Ordner pro Abo vorschlagen, nur hinzufügen, nichts entfernen.
|
||||||
|
- Stufe (nur wenn angegeben): "queue" = Stamm (neue Folgen auf die Merkliste und geladen) – für Podcasts, die
|
||||||
|
regelmäßig gehört werden; "inbox" = Strom (neue Folgen nur im Strom) – der Normalfall; "ignore" = Auf Abruf (nicht im
|
||||||
|
Strom) – für Abos, die kaum gehört werden und deren Folgen im Strom nur stören (z. B. sehr häufig erscheinend und
|
||||||
|
nie gehört, abgeschlossene Archive). Ändere eine Stufe nur mit klarem Grund aus den Hörzahlen; sonst null.
|
||||||
|
- warum: ein kurzer deutscher Satz.`;
|
||||||
|
|
||||||
|
function podAiSchema() {
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
vorschlaege: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {type: 'integer'},
|
||||||
|
ordner_hinzu: {type: ['array', 'null'], items: {type: 'string'}},
|
||||||
|
stufe: {type: ['string', 'null']},
|
||||||
|
warum: {type: 'string'},
|
||||||
|
},
|
||||||
|
required: ['id', 'ordner_hinzu', 'stufe', 'warum'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['vorschlaege'],
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function podcastOrganize() {
|
||||||
|
DioraAI.withKey(async () => {
|
||||||
|
const list = podcastFeeds();
|
||||||
|
if (!list.length) return;
|
||||||
|
DioraAI.progress('Mit KI ordnen', `Claude sieht sich ${list.length} Abos an – das kann eine Minute dauern …`);
|
||||||
|
// The newest episode titles of each (one request; what the feeds are about).
|
||||||
|
const titles = new Map();
|
||||||
|
try {
|
||||||
|
const d = await getJson('/podcasts/episodes/?limit=500');
|
||||||
|
for (const e of d.episodes || []) {
|
||||||
|
const a = titles.get(e.feed__id) || [];
|
||||||
|
if (a.length < 3) { a.push(plain(e.title)); titles.set(e.feed__id, a); }
|
||||||
|
}
|
||||||
|
} catch (e) { /* titles and authors alone then */ }
|
||||||
|
const levels = v2() && list.some(f => 'new_episodes' in f);
|
||||||
|
const data = list.map(f => ({
|
||||||
|
id: f.id, titel: plain(f.title), autor: plain(f.author || ''), ordner: foldersOf(f),
|
||||||
|
stufe: levels ? (f.new_episodes || 'inbox') : undefined, gehoert_12m: f.heard_365 || 0,
|
||||||
|
abstand_tage: typeof f.interval_days === 'number' ? Math.round(f.interval_days * 10) / 10 : null,
|
||||||
|
neueste: titles.get(f.id) || [],
|
||||||
|
}));
|
||||||
|
let answer;
|
||||||
|
try {
|
||||||
|
answer = await DioraAI.ask({
|
||||||
|
system: POD_AI_SYSTEM,
|
||||||
|
prompt: `Vorhandene Ordner: ${JSON.stringify(namesOf(list))}\n\nAbos:\n${JSON.stringify(data)}`,
|
||||||
|
schema: podAiSchema(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
DioraAI.showError('Mit KI ordnen', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const byId = new Map(list.map(f => [f.id, f]));
|
||||||
|
const levelName = v => (LEVELS.find(l => l.value === v) || {}).label || v;
|
||||||
|
const items = [];
|
||||||
|
for (const x of answer.vorschlaege || []) {
|
||||||
|
const f = byId.get(x.id);
|
||||||
|
if (!f) continue;
|
||||||
|
const have = foldersOf(f);
|
||||||
|
const add = (x.ordner_hinzu || []).map(n => String(n).trim()).filter(n => n && !have.some(h => sameName(h, n)));
|
||||||
|
const change = {};
|
||||||
|
const lines = [];
|
||||||
|
if (add.length) { change.folders = have.concat(add); lines.push('Ordner + ' + add.join(', ')); }
|
||||||
|
if (levels && ['queue', 'inbox', 'ignore'].includes(x.stufe) && x.stufe !== (f.new_episodes || 'inbox')) {
|
||||||
|
change.new_episodes = x.stufe;
|
||||||
|
lines.push(`Stufe: ${levelName(f.new_episodes || 'inbox')} → ${levelName(x.stufe)}`);
|
||||||
|
}
|
||||||
|
if (!lines.length) continue;
|
||||||
|
if (x.warum) lines.push('– ' + x.warum);
|
||||||
|
items.push({id: f.id, title: plain(f.title), lines, change});
|
||||||
|
}
|
||||||
|
DioraAI.review({
|
||||||
|
title: 'Mit KI ordnen',
|
||||||
|
intro: 'Vorschläge von Claude. Was angehakt ist, wird übernommen; vorhandene Ordner bleiben.',
|
||||||
|
items,
|
||||||
|
onApply: async ids => {
|
||||||
|
for (const id of ids) {
|
||||||
|
const it = items.find(i => i.id === id);
|
||||||
|
if (it) N.podcastFeedSettings(id, JSON.stringify(it.change));
|
||||||
|
}
|
||||||
|
setTimeout(render, 200);
|
||||||
|
return `${ids.length} ${ids.length === 1 ? 'Abo' : 'Abos'} geordnet`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderFeeds() {
|
function renderFeeds() {
|
||||||
const sort = FEED_SORTS.find(s => s.id === feedSort) || FEED_SORTS[0];
|
const sort = FEED_SORTS.find(s => s.id === feedSort) || FEED_SORTS[0];
|
||||||
const items = podcastFeeds().filter(f => inFolder(f.id) && (!podFaves || !v2() || f.favorite))
|
const items = podcastFeeds().filter(f => inFolder(f.id) && (!podFaves || !v2() || f.favorite))
|
||||||
|
|
@ -2667,6 +2776,7 @@
|
||||||
head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`),
|
head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`),
|
||||||
listTools([
|
listTools([
|
||||||
hasSources() && button('⚙', 'Verwalten: Abos & Quellen', toSources),
|
hasSources() && button('⚙', 'Verwalten: Abos & Quellen', toSources),
|
||||||
|
window.DioraAI && DioraAI.available() && button('✦', 'Mit KI ordnen: Ordner und Stufen vorschlagen', podcastOrganize),
|
||||||
button('⇅', 'Sortieren und Zahl wählen', feedSortMenu),
|
button('⇅', 'Sortieren und Zahl wählen', feedSortMenu),
|
||||||
hasFolders() && N.podcastFeedSettings && button('☐', 'Mehrere auswählen (in Ordner)', () => startSelecting()),
|
hasFolders() && N.podcastFeedSettings && button('☐', 'Mehrere auswählen (in Ordner)', () => startSelecting()),
|
||||||
].filter(Boolean)));
|
].filter(Boolean)));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue