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
|
||||
|
||||
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).
|
||||
|
|
|
|||
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 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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue