diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts index b288cc124..188aa30ef 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -31,6 +31,7 @@ import { inventoryTools } from '$lib/modules/inventory/tools'; import { plantsTools } from '$lib/modules/plants/tools'; import { newsTools } from '$lib/modules/news/tools'; import { newsResearchTools } from '$lib/modules/news-research/tools'; +import { articlesTools } from '$lib/modules/articles/tools'; import { recipesTools } from '$lib/modules/recipes/tools'; import { questionsTools } from '$lib/modules/questions/tools'; import { meditateTools } from '$lib/modules/meditate/tools'; @@ -76,6 +77,7 @@ export function initTools(): void { registerTools(plantsTools); registerTools(newsTools); registerTools(newsResearchTools); + registerTools(articlesTools); registerTools(recipesTools); registerTools(questionsTools); registerTools(meditateTools); diff --git a/apps/mana/apps/web/src/lib/modules/articles/tools.ts b/apps/mana/apps/web/src/lib/modules/articles/tools.ts new file mode 100644 index 000000000..69c7a8927 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/articles/tools.ts @@ -0,0 +1,308 @@ +/** + * Articles AI Tools — LLM-accessible operations for the articles module. + * + * Catalog entries live in `@mana/shared-ai/src/tools/schemas.ts` and drive + * the policy layer + server planner automatically; this file contributes + * the execute-side glue. + * + * list_articles auto Read-only listing for agent context. + * save_article propose URL → Readability → encrypted save. + * Legacy `save_news_article` kept as + * alias in `modules/news/tools.ts`. + * archive_article propose Flips status → 'archived'. + * tag_article propose Creates (or reuses) a global tag by + * name and links it to the article. + * add_article_highlight propose Persists a highlight anchored to the + * first verbatim occurrence of `text` + * in the article's plain content. Fails + * gracefully if the snippet isn't found. + */ + +import { decryptRecords } from '$lib/data/crypto'; +import { scopedForModule, scopedGet } from '$lib/data/scope'; +import { tagMutations, useAllTags } from '@mana/shared-stores'; +import type { ModuleTool } from '$lib/data/tools/types'; +import { articlesStore } from './stores/articles.svelte'; +import { highlightsStore } from './stores/highlights.svelte'; +import { articleTagOps } from './stores/tags.svelte'; +import { toArticle } from './queries'; +import type { HighlightColor, LocalArticle, ArticleStatus } from './types'; + +const DEFAULT_LIST_LIMIT = 30; +const MAX_LIST_LIMIT = 100; +const MIN_HIGHLIGHT_TEXT = 10; +const MAX_HIGHLIGHT_TEXT = 500; + +export const articlesTools: ModuleTool[] = [ + { + name: 'list_articles', + module: 'articles', + description: + 'Listet gespeicherte Artikel (id, title, siteName, status, readingTime). Optional nach Status filtern.', + parameters: [ + { + name: 'status', + type: 'string', + description: + 'Nur Artikel mit diesem Status. Default: ohne Filter (archivierte werden nur bei "archived"/"all" eingeschlossen).', + required: false, + enum: ['unread', 'reading', 'finished', 'archived', 'all'], + }, + { + name: 'limit', + type: 'number', + description: `Maximale Anzahl (Standard ${DEFAULT_LIST_LIMIT}, max ${MAX_LIST_LIMIT})`, + required: false, + }, + { + name: 'query', + type: 'string', + description: 'Case-insensitive Substring-Filter auf Titel / Autor / Quelle', + required: false, + }, + ], + async execute(params) { + const limit = Math.min( + Math.max(Number(params.limit) || DEFAULT_LIST_LIMIT, 1), + MAX_LIST_LIMIT + ); + const statusFilter = typeof params.status === 'string' ? params.status : ''; + const query = typeof params.query === 'string' ? params.query.toLowerCase().trim() : ''; + + const locals = await scopedForModule('articles', 'articles').toArray(); + const visible = locals.filter((a) => { + if (a.deletedAt) return false; + if (statusFilter === 'all') return true; + if (statusFilter === '' || !statusFilter) return a.status !== 'archived'; + return a.status === statusFilter; + }); + const decrypted = await decryptRecords('articles', visible); + + const matches = query + ? decrypted.filter( + (a) => + a.title.toLowerCase().includes(query) || + (a.author?.toLowerCase().includes(query) ?? false) || + (a.siteName?.toLowerCase().includes(query) ?? false) + ) + : decrypted; + + const rows = matches + .sort((a, b) => (b.savedAt ?? '').localeCompare(a.savedAt ?? '')) + .slice(0, limit) + .map((a) => ({ + id: a.id, + title: a.title, + siteName: a.siteName, + status: a.status, + readingTimeMinutes: a.readingTimeMinutes, + url: a.originalUrl, + savedAt: a.savedAt, + })); + + return { + success: true, + message: `${rows.length} Artikel gefunden`, + data: { articles: rows, total: matches.length }, + }; + }, + }, + + { + name: 'save_article', + module: 'articles', + description: + 'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.', + parameters: [ + { name: 'url', type: 'string', description: 'Die Artikel-URL', required: true }, + { + name: 'title', + type: 'string', + description: 'Anzeigetitel für den Approval-Dialog (informativ)', + required: false, + }, + { + name: 'reason', + type: 'string', + description: 'Kurze Begründung warum der Artikel für den Nutzer relevant ist', + required: false, + }, + ], + async execute(params) { + const url = String(params.url ?? '').trim(); + if (!url) return { success: false, message: 'URL fehlt' }; + const { article, duplicate } = await articlesStore.saveFromUrl(url); + return { + success: true, + message: duplicate + ? `Artikel bereits gespeichert: ${article.title}` + : `Artikel gespeichert: ${article.title}`, + data: { articleId: article.id, title: article.title, duplicate }, + }; + }, + }, + + { + name: 'archive_article', + module: 'articles', + description: 'Verschiebt einen Artikel ins Archiv.', + parameters: [ + { + name: 'articleId', + type: 'string', + description: 'ID des Artikels (aus list_articles)', + required: true, + }, + ], + async execute(params) { + const id = String(params.articleId ?? '').trim(); + if (!id) return { success: false, message: 'articleId fehlt' }; + const existing = await scopedGet('articles', id); + if (!existing || existing.deletedAt) { + return { success: false, message: `Kein Artikel mit id ${id}` }; + } + await articlesStore.setStatus(id, 'archived' satisfies ArticleStatus); + return { success: true, message: 'Artikel archiviert', data: { articleId: id } }; + }, + }, + + { + name: 'tag_article', + module: 'articles', + description: + 'Vergibt einen Tag auf einen Artikel. Tag wird angelegt falls er noch nicht existiert.', + parameters: [ + { + name: 'articleId', + type: 'string', + description: 'ID des Artikels (aus list_articles)', + required: true, + }, + { + name: 'tagName', + type: 'string', + description: 'Tag-Name (z.B. "KI", "lesen bald")', + required: true, + }, + ], + async execute(params) { + const id = String(params.articleId ?? '').trim(); + const rawName = String(params.tagName ?? '').trim(); + if (!id) return { success: false, message: 'articleId fehlt' }; + if (!rawName) return { success: false, message: 'tagName fehlt' }; + const name = rawName.slice(0, 60); + + const existing = await scopedGet('articles', id); + if (!existing || existing.deletedAt) { + return { success: false, message: `Kein Artikel mit id ${id}` }; + } + + // useAllTags().value works even outside a Svelte reactive scope — + // it returns the current in-memory snapshot. Match by lower-case + // name so "KI" and "ki" dedupe. + const pool = useAllTags().value; + const needle = name.toLowerCase(); + let tag = pool.find((t) => t.name.toLowerCase() === needle); + if (!tag) { + tag = await tagMutations.createTag({ name }); + } + + await articleTagOps.addTag(id, tag.id); + return { + success: true, + message: `Tag „${tag.name}" gesetzt`, + data: { + articleId: id, + tagId: tag.id, + tagName: tag.name, + created: !pool.some((t) => t.id === tag!.id), + }, + }; + }, + }, + + { + name: 'add_article_highlight', + module: 'articles', + description: + 'Markiert eine Textstelle in einem Artikel als Highlight. Der Text muss wörtlich im Artikel vorkommen.', + parameters: [ + { + name: 'articleId', + type: 'string', + description: 'ID des Artikels (aus list_articles)', + required: true, + }, + { + name: 'text', + type: 'string', + description: 'Wörtliche Textstelle die markiert werden soll (10–500 Zeichen)', + required: true, + }, + { + name: 'color', + type: 'string', + description: 'Highlight-Farbe', + required: false, + enum: ['yellow', 'green', 'blue', 'pink'], + }, + { + name: 'note', + type: 'string', + description: 'Optionale Notiz zum Highlight', + required: false, + }, + ], + async execute(params) { + const id = String(params.articleId ?? '').trim(); + const text = String(params.text ?? '').trim(); + const color = (params.color as HighlightColor | undefined) ?? 'yellow'; + const note = typeof params.note === 'string' ? params.note.trim() || null : null; + + if (!id) return { success: false, message: 'articleId fehlt' }; + if (text.length < MIN_HIGHLIGHT_TEXT) { + return { success: false, message: `Text zu kurz (min ${MIN_HIGHLIGHT_TEXT} Zeichen)` }; + } + if (text.length > MAX_HIGHLIGHT_TEXT) { + return { success: false, message: `Text zu lang (max ${MAX_HIGHLIGHT_TEXT} Zeichen)` }; + } + + const existing = await scopedGet('articles', id); + if (!existing || existing.deletedAt) { + return { success: false, message: `Kein Artikel mit id ${id}` }; + } + const [decrypted] = await decryptRecords('articles', [existing]); + if (!decrypted) return { success: false, message: 'Entschlüsselung fehlgeschlagen' }; + const article = toArticle(decrypted); + + // Snap to the first verbatim occurrence of the snippet in the + // Readability-extracted plain content. If the AI is hallucinating + // (or the article was re-extracted and the text shifted) we bail + // instead of persisting an orphan highlight. + const startOffset = article.content.indexOf(text); + if (startOffset < 0) { + return { success: false, message: 'Textstelle nicht im Artikel gefunden' }; + } + const endOffset = startOffset + text.length; + const contextBefore = + article.content.slice(Math.max(0, startOffset - 40), startOffset) || null; + const contextAfter = article.content.slice(endOffset, endOffset + 40) || null; + + const highlight = await highlightsStore.addHighlight({ + articleId: id, + text, + color, + note, + startOffset, + endOffset, + contextBefore, + contextAfter, + }); + return { + success: true, + message: 'Highlight gesetzt', + data: { highlightId: highlight.id, articleId: id }, + }; + }, + }, +]; diff --git a/docs/plans/articles-module.md b/docs/plans/articles-module.md index da2cdba06..bdc1e4ac0 100644 --- a/docs/plans/articles-module.md +++ b/docs/plans/articles-module.md @@ -2,7 +2,21 @@ ## Status (2026-04-21) -Proposed. Noch nichts gebaut. +**M1 Skelett: DONE** (commit `3357e88a1`) — Modul registriert, Dexie v33, Encryption-Registry-Einträge (articles + articleHighlights verschlüsselt, articleTags auf Plaintext-Allowlist als FK-Junction ins globale Tag-System), App-Registry + orangefarbenes Icon, Route mountet, Empty-State ListView. + +**M2 URL-Save + Reader: DONE** (commit `3357e88a1`) — `POST /api/v1/articles/extract` (ein Endpoint statt zwei — Client cached die Preview und vermeidet doppelten Server-Fetch), AddUrlForm mit scope-aware Dedupe, DetailView mit ReaderView (Serif/Sans, Light/Sepia/Dark, Size-Slider), auto-getrackter Reading-Progress mit Scroll-Restore. + +**M3 Highlights: DONE** (commit `3357e88a1`) — TreeWalker-basierte Plain-Text-Offset-Resolution (`lib/offsets.ts`), `highlightsStore`, Floating `HighlightMenu` mit Create/Edit-Modi, `HighlightLayer`-Orchestrator der Overlays bei jedem Highlights- oder HTML-Change unwrap+re-applies (Range.surroundContents pro Text-Node-Slice). Vier Farben mit Dark-Mode-angepassten Alpha-Werten. + +**M4 Tags + Filter: DONE** (commit `04293ed5e`) — `useArticleTagIds` / `useArticleTagMap` Live-Queries gegen `articleTagOps`. DetailView bekommt `` aus `@mana/shared-ui` mit globalem Tag-Pool; `onChange` → `articleTagOps.setTags(id, ids)`. ListView: 6 Filter-Chips (Alle / Ungelesen / In Arbeit / Gelesen / Favoriten / Archiv) mit Live-Counts, `` auf jeder Karte, neue `.status-reading`-Pill, Favoriten-Stern. + +**M5 Migration von news:type='saved': DONE** (commit `04293ed5e`) — Boot-gated Migration in `modules/articles/migrations/from-news.ts` (localStorage-Sentinel `mana:articles:from-news-migration:v1`), decrypt→re-encrypt zwischen den beiden Field-Allowlists, Status-Mapping `isArchived→archived` / `isRead→finished` / sonst `unread`, Source-Rows werden soft-deletet. News-Code deprecated: `saveFromUrl` + `extractFromUrl` entfernt, `save_news_article` AI-Tool behält seinen Namen (wegen Mission-History) und leitet intern aufs `articles`-Modul um. `/news/add` + `/news/saved` sind Redirects. `news-research` „Speichern"-Buttons routen auf `/articles/[id]`. + +**M6 AI-Tools: DONE** (commit pending) — 5 neue Einträge im `AI_TOOL_CATALOG` (`shared-ai/src/tools/schemas.ts`): `list_articles` (auto), `save_article` / `archive_article` / `tag_article` / `add_article_highlight` (alle propose). `modules/articles/tools.ts` enthält die `execute`-Funktionen, registriert in `data/tools/init.ts`. `tag_article` dedupliziert case-insensitive über den globalen Pool und legt Tags via `tagMutations.createTag` an falls nötig. `add_article_highlight` snappt auf die erste wörtliche Fundstelle in `article.content` und lehnt den Call ab wenn der Text nicht exakt vorkommt (kein Orphan-Highlight). Policy/Executor/Server-Planner leiten sich automatisch aus dem Katalog ab. + +**Hinweis AiProposalInbox:** Der apps/mana/CLAUDE.md-Abschnitt erwähnt `` als Inline-Mount, aber die Komponente existiert im aktuellen Codebase nicht — nach dem `pendingProposals`-Table-Drop in Dexie v29 wurde die Proposal-Darstellung auf `server-iteration-staging` + den Cross-Module-Inbox im Mission-Detail-View umgestellt. Articles-Proposals tauchen dort automatisch auf. Falls die Inline-Komponente wieder reaktiviert wird, muss nur der Mount in `ListView.svelte` ergänzt werden. + +Nächster Schritt: M7 (Share-Target + Bookmarklet) oder M8 (HighlightsView + Stats + Dashboard-Widget). ## Ziel diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index c70bd24cc..8df6e47df 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -384,6 +384,128 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ ], }, + // ── Articles (Pocket-style read-it-later) ─────────────── + { + name: 'list_articles', + module: 'articles', + description: + 'Listet gespeicherte Artikel (id, title, siteName, status, readingTime). Optional nach Status filtern.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'status', + type: 'string', + description: + 'Nur Artikel mit diesem Status. Default: ohne Filter (archivierte werden nur bei "archived"/"all" eingeschlossen).', + required: false, + enum: ['unread', 'reading', 'finished', 'archived', 'all'], + }, + { + name: 'limit', + type: 'number', + description: 'Maximale Anzahl (Standard 30, max 100)', + required: false, + }, + { + name: 'query', + type: 'string', + description: 'Case-insensitive Substring-Filter auf Titel / Autor / Quelle', + required: false, + }, + ], + }, + { + name: 'save_article', + module: 'articles', + description: + 'Speichert einen Artikel von einer URL in die Leseliste. URL wird serverseitig per Readability extrahiert.', + defaultPolicy: 'propose', + parameters: [ + { name: 'url', type: 'string', description: 'Die Artikel-URL', required: true }, + { + name: 'title', + type: 'string', + description: 'Anzeigetitel für den Approval-Dialog (informativ)', + required: false, + }, + { + name: 'reason', + type: 'string', + description: 'Kurze Begründung warum der Artikel für den Nutzer relevant ist', + required: false, + }, + ], + }, + { + name: 'archive_article', + module: 'articles', + description: 'Verschiebt einen Artikel ins Archiv.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'articleId', + type: 'string', + description: 'ID des Artikels (aus list_articles)', + required: true, + }, + ], + }, + { + name: 'tag_article', + module: 'articles', + description: + 'Vergibt einen Tag auf einen Artikel. Tag wird angelegt falls er noch nicht existiert.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'articleId', + type: 'string', + description: 'ID des Artikels (aus list_articles)', + required: true, + }, + { + name: 'tagName', + type: 'string', + description: 'Tag-Name (z.B. "KI", "lesen bald")', + required: true, + }, + ], + }, + { + name: 'add_article_highlight', + module: 'articles', + description: + 'Markiert eine Textstelle in einem Artikel als Highlight. Der Text muss wörtlich im Artikel vorkommen — sonst wird der Call abgelehnt.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'articleId', + type: 'string', + description: 'ID des Artikels (aus list_articles)', + required: true, + }, + { + name: 'text', + type: 'string', + description: 'Wörtliche Textstelle die markiert werden soll (10–500 Zeichen)', + required: true, + }, + { + name: 'color', + type: 'string', + description: 'Highlight-Farbe', + required: false, + enum: ['yellow', 'green', 'blue', 'pink'], + }, + { + name: 'note', + type: 'string', + description: 'Optionale Notiz zum Highlight', + required: false, + }, + ], + }, + // ── News-Research ───────────────────────────────────────── { name: 'research_news',