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
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 18s
Test / test (push) Has been cancelled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-25 12:23:54 +01:00
parent 0ba16a40ff
commit 58003057e3
5 changed files with 373 additions and 64 deletions

View file

@ -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
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,
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 |
|---|---|---|---|
| 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).

View file

@ -1,7 +1,7 @@
// ---------------------------------------------------------------------------
// 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
// 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,
@ -12,11 +12,21 @@
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 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;
const html = document.documentElement;
@ -32,38 +42,45 @@ function available() {
bookKeyThere() && typeof getOrCreateEncKey === 'function';
}
async function loadKey(fresh) {
if (loaded && !fresh) return apiKey;
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 apiKey;
if (!r.ok) return vault;
const d = await r.json();
apiKey = null;
vault = null;
if (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;
} catch (e) {
// Offline, or encrypted with a key this device doesn't have (the password changed): ask again.
loaded = false;
}
return apiKey;
return vault;
}
async function saveKey(key) {
const enc = await encryptBytes(await getOrCreateEncKey(), new TextEncoder().encode(JSON.stringify({anthropic_key: key})));
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 + ')');
apiKey = key;
vault = v;
loaded = true;
}
async function removeKey() {
async function removeKeys() {
await fetch(VAULT, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ct: ''})});
apiKey = null;
vault = null;
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.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.';
}
@ -90,12 +108,23 @@ function fail(message) {
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}) {
const key = await loadKey();
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});
// Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model.
const stream = client(key).beta.messages.stream({
model: MODEL,
model: PROVIDERS.anthropic.model,
max_tokens: maxTokens,
betas: ['server-side-fallback-2026-07-01'],
fallbacks: 'default',
@ -106,12 +135,39 @@ async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000})
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.');
}
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}) {
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) --------------------------------------
@ -139,43 +195,69 @@ function close() {
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(); });
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.

View file

@ -159,6 +159,9 @@
'</div>' +
'<div class="fd-actions">' +
'<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>' +
'</div>') +
'</div>' +
@ -841,6 +844,108 @@
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
tab.addEventListener('click', async e => {
@ -937,6 +1042,7 @@
});
return;
}
if (act === 'ai-organize') return feedsOrganize();
if (act === 'manage') {
// Managing every subscription has its place (Abos & Quellen, docs/sources.md).
const nav = window.DioraNav;

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v148';
const CACHE = 'diora-v149';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',

View file

@ -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
// and copy again. The browser host is static/js/podcasts-web.js.
@ -2645,6 +2645,115 @@
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() {
const sort = FEED_SORTS.find(s => s.id === feedSort) || FEED_SORTS[0];
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()}`),
listTools([
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),
hasFolders() && N.podcastFeedSettings && button('☐', 'Mehrere auswählen (in Ordner)', () => startSelecting()),
].filter(Boolean)));