diff --git a/docs/ai.md b/docs/ai.md index d4757a8..3ed9ece 100644 --- a/docs/ai.md +++ b/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 -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). diff --git a/static/js/ai.js b/static/js/ai.js index 994d901..e18a56f 100644 --- a/static/js/ai.js +++ b/static/js/ai.js @@ -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 = ` -

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

-

Dein eigener API-Key: Er wird mit deinem Buchschlüssel verschlüsselt gespeichert – der diora-Server - kann ihn nicht lesen. Deine Geräte fragen Claude direkt; Anthropic sieht dabei, was eine Funktion braucht - (z. B. Titel und Autoren), der diora-Server nichts davon. Die Kosten rechnet Anthropic mit dir ab. - Einen Key legst du unter console.anthropic.com an.

- -
- - ${has ? '' : ''} - -
`; - 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 = ` +

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. diff --git a/static/js/feeds.js b/static/js/feeds.js index 0c66dab..1c787a0 100644 --- a/static/js/feeds.js +++ b/static/js/feeds.js @@ -159,6 +159,9 @@ '' + '
' + '' + + // Ordering with Claude (docs/ai.md): Ressorts for the news, folders for the blogs. + (window.DioraAI && DioraAI.available() && sources().length + ? '' : '') + '' + '
') + '' + @@ -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; diff --git a/static/js/sw.js b/static/js/sw.js index 020a4e7..d3b7dcc 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -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', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index b995f72..07df488 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.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 // 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)));