diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 553108ded..e6ada0194 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -29,6 +29,7 @@ import { foodRoutes } from './modules/food/routes'; import { guidesRoutes } from './modules/guides/routes'; import { moodlitRoutes } from './modules/moodlit/routes'; import { newsRoutes } from './modules/news/routes'; +import { newsResearchRoutes } from './modules/news-research/routes'; import { tracesRoutes } from './modules/traces/routes'; import { presiRoutes } from './modules/presi/routes'; import { researchRoutes } from './modules/research/routes'; @@ -61,6 +62,7 @@ app.route('/api/v1/food', foodRoutes); app.route('/api/v1/guides', guidesRoutes); app.route('/api/v1/moodlit', moodlitRoutes); app.route('/api/v1/news', newsRoutes); +app.route('/api/v1/news-research', newsResearchRoutes); app.route('/api/v1/traces', tracesRoutes); app.route('/api/v1/presi', presiRoutes); app.route('/api/v1/research', researchRoutes); diff --git a/apps/api/src/modules/news-research/routes.ts b/apps/api/src/modules/news-research/routes.ts new file mode 100644 index 000000000..4da8d0b3c --- /dev/null +++ b/apps/api/src/modules/news-research/routes.ts @@ -0,0 +1,219 @@ +/** + * News Research module — feed discovery, validation, and topic-scoped + * article search over user-selected feeds. Stateless: every request is a + * fresh fetch. Consumers are the /news-research UI and the `research_news` + * LLM tool. + */ + +import { Hono } from 'hono'; +import { + discoverFeeds, + validateFeed, + parseFeedUrl, + extractFromUrl, + type NormalizedFeedItem, +} from '@mana/shared-rss'; +import { webSearch } from '../../lib/search'; + +const routes = new Hono(); + +const MAX_FEEDS_PER_SEARCH = 12; +const MAX_ARTICLES_PER_FEED = 40; + +// ─── POST /discover ───────────────────────────────────────── +// Input shapes: { siteUrl } or { query } (with optional language). +// Returns a list of discovered feeds. For query mode we run a web +// search and then attempt feed discovery on each top result. + +routes.post('/discover', async (c) => { + const body = await c.req.json<{ + siteUrl?: string; + query?: string; + language?: string; + limit?: number; + }>(); + + const limit = Math.min(body.limit ?? 10, 20); + + if (body.siteUrl) { + const feeds = await discoverFeeds(body.siteUrl); + return c.json({ feeds }); + } + + if (body.query) { + let hits: Awaited>; + try { + hits = await webSearch({ + query: `${body.query} rss feed`, + limit, + language: body.language, + categories: ['general', 'news'], + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn('[news-research] webSearch failed:', message); + return c.json( + { + error: 'web-search-unavailable', + message: `Websuche fehlgeschlagen: ${message}. Läuft mana-search (Port 3021)?`, + }, + 502 + ); + } + + const siteUrls = Array.from(new Set(hits.map((h) => h.url).filter(Boolean))).slice(0, limit); + + const perSite = await Promise.all( + siteUrls.map(async (url) => { + const feeds = await discoverFeeds(url).catch(() => []); + return feeds.map((f) => ({ ...f, sourceHit: url })); + }) + ); + + const seen = new Set(); + const feeds = perSite + .flat() + .filter((f) => { + if (seen.has(f.url)) return false; + seen.add(f.url); + return true; + }) + .slice(0, MAX_FEEDS_PER_SEARCH); + + return c.json({ feeds, searched: siteUrls.length }); + } + + return c.json({ error: 'Provide either siteUrl or query' }, 400); +}); + +// ─── POST /validate ───────────────────────────────────────── + +routes.post('/validate', async (c) => { + const { url } = await c.req.json<{ url: string }>(); + if (!url) return c.json({ error: 'url required' }, 400); + const result = await validateFeed(url); + return c.json(result); +}); + +// ─── POST /search ─────────────────────────────────────────── +// Fetch selected feeds in parallel, score their items against the +// query, return top N. Scoring is plain keyword frequency for v1; +// BM25/embeddings can replace `scoreItem` later. + +interface ScoredArticle extends NormalizedFeedItem { + feedUrl: string; + score: number; +} + +const STOPWORDS = new Set([ + 'der', + 'die', + 'das', + 'und', + 'oder', + 'aber', + 'the', + 'a', + 'an', + 'of', + 'to', + 'in', + 'for', + 'on', + 'with', +]); + +function tokenize(text: string): string[] { + return text + .toLowerCase() + .normalize('NFKD') + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .split(/\s+/) + .filter((t) => t.length > 2 && !STOPWORDS.has(t)); +} + +function scoreItem(item: NormalizedFeedItem, queryTokens: string[]): number { + if (queryTokens.length === 0) return 0; + const haystack = `${item.title} ${item.excerpt ?? ''} ${item.content ?? ''}`.toLowerCase(); + let score = 0; + for (const q of queryTokens) { + let from = 0; + while ((from = haystack.indexOf(q, from)) !== -1) { + score += 1; + from += q.length; + } + } + // Title matches count extra. + const title = item.title.toLowerCase(); + for (const q of queryTokens) { + if (title.includes(q)) score += 3; + } + // Recency boost. + if (item.publishedAt) { + const ageDays = (Date.now() - item.publishedAt.getTime()) / 86_400_000; + if (ageDays < 1) score += 2; + else if (ageDays < 7) score += 1; + } + return score; +} + +routes.post('/search', async (c) => { + const body = await c.req.json<{ + feeds: string[]; + query: string; + limit?: number; + sinceIso?: string; + }>(); + + if (!Array.isArray(body.feeds) || body.feeds.length === 0) { + return c.json({ error: 'feeds[] required' }, 400); + } + if (!body.query || typeof body.query !== 'string') { + return c.json({ error: 'query required' }, 400); + } + + const queryTokens = tokenize(body.query); + const limit = Math.min(body.limit ?? 25, 100); + const since = body.sinceIso ? new Date(body.sinceIso) : null; + + const feeds = body.feeds.slice(0, MAX_FEEDS_PER_SEARCH); + + const perFeed = await Promise.all( + feeds.map(async (url) => { + try { + const items = await parseFeedUrl(url); + return items.slice(0, MAX_ARTICLES_PER_FEED).map((item) => ({ url, item })); + } catch { + return []; + } + }) + ); + + const scored: ScoredArticle[] = perFeed + .flat() + .filter(({ item }) => !since || (item.publishedAt && item.publishedAt >= since)) + .map(({ url, item }) => ({ + ...item, + feedUrl: url, + score: scoreItem(item, queryTokens), + })) + .filter((a) => a.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit); + + return c.json({ articles: scored, feedCount: feeds.length }); +}); + +// ─── POST /extract ────────────────────────────────────────── +// Readability for a single URL. Thin wrapper so the news-research +// client doesn't need to hit the legacy /news/extract/save path. + +routes.post('/extract', async (c) => { + const { url } = await c.req.json<{ url: string }>(); + if (!url) return c.json({ error: 'url required' }, 400); + const article = await extractFromUrl(url); + if (!article) return c.json({ error: 'Extraction failed' }, 502); + return c.json({ url, ...article }); +}); + +export { routes as newsResearchRoutes }; diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index af39e0168..f35763076 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -335,7 +335,7 @@ export const ENCRYPTION_REGISTRY: Record = { newsCategories: { enabled: true, fields: ['name'] }, newsPreferences: { enabled: true, - fields: ['selectedTopics', 'blockedSources', 'topicWeights', 'sourceWeights'], + fields: ['selectedTopics', 'blockedSources', 'topicWeights', 'sourceWeights', 'customFeeds'], }, newsReactions: { enabled: true, fields: ['reaction', 'sourceSlug', 'topic'] }, 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 7e7b6821e..aef2446eb 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -30,6 +30,7 @@ import { guidesTools } from '$lib/modules/guides/tools'; 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 { recipesTools } from '$lib/modules/recipes/tools'; import { questionsTools } from '$lib/modules/questions/tools'; import { meditateTools } from '$lib/modules/meditate/tools'; @@ -65,6 +66,7 @@ export function initTools(): void { registerTools(inventoryTools); registerTools(plantsTools); registerTools(newsTools); + registerTools(newsResearchTools); registerTools(recipesTools); registerTools(questionsTools); registerTools(meditateTools); diff --git a/apps/mana/apps/web/src/lib/modules/news-research/ListView.svelte b/apps/mana/apps/web/src/lib/modules/news-research/ListView.svelte new file mode 100644 index 000000000..ea7e0dde2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news-research/ListView.svelte @@ -0,0 +1,430 @@ + + + +
+
+
+ + + +
+ +
+ {#if mode === 'query'} + + + {:else} + + + {/if} +
+ {#if store.error}
{store.error}
{/if} +
+ + {#if store.session.discoveredFeeds.length > 0} +
+ + {#if feedsOpen} +
    + {#each store.session.discoveredFeeds as feed (feed.url)} +
  • + + +
  • + {/each} +
+ {/if} + +
+ + +
+
+ {/if} + + {#if store.session.results.length > 0} +
+
+ Treffer ({store.session.results.length}) + +
+ {#if saveError}
{saveError}
{/if} +
    + {#each store.session.results as a (a.url)} +
  • + {a.title} +
    + {formatDate(a.publishedAt)} + · + Score {a.score} + +
    +
  • + {/each} +
+
+ {/if} + + {#if store.session.discoveredFeeds.length === 0 && !store.discovering} +
+ {#if store.session.hasDiscovered && !store.error} + Keine passenden Feeds gefunden. Versuche andere Stichworte oder den „Website"-Modus. + {:else if !store.session.hasDiscovered} + Entdecke zum Thema passende RSS-Feeds, filtere Artikel und übergib sie deiner KI als + Kontext. + {/if} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/news-research/api.ts b/apps/mana/apps/web/src/lib/modules/news-research/api.ts new file mode 100644 index 000000000..ee0860a58 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news-research/api.ts @@ -0,0 +1,71 @@ +/** + * News Research API client — talks to apps/api `/api/v1/news-research/*`. + * Mirrors the auth-header pattern of `news/api.ts`. + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaApiUrl } from '$lib/api/config'; +import type { + DiscoveredFeedDto, + FeedValidationDto, + ScoredArticleDto, + ExtractedArticleDto, +} from './types'; + +async function authHeader(): Promise> { + const token = await authStore.getValidToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function post(path: string, body: unknown, fetchImpl: typeof fetch = fetch): Promise { + const res = await fetchImpl(`${getManaApiUrl()}/api/v1/news-research${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(await authHeader()) }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + let message = text; + try { + const parsed = JSON.parse(text) as { message?: string; error?: string }; + message = parsed.message ?? parsed.error ?? text; + } catch { + // text was not JSON — keep raw + } + throw new Error(message || `news-research ${path} failed (${res.status})`); + } + return (await res.json()) as T; +} + +export function discoverBySite(siteUrl: string, fetchImpl?: typeof fetch) { + return post<{ feeds: DiscoveredFeedDto[] }>('/discover', { siteUrl }, fetchImpl); +} + +export function discoverByQuery(query: string, language?: string, fetchImpl?: typeof fetch) { + return post<{ feeds: DiscoveredFeedDto[]; searched: number }>( + '/discover', + { query, language }, + fetchImpl + ); +} + +export function validateFeedUrl(url: string, fetchImpl?: typeof fetch) { + return post('/validate', { url }, fetchImpl); +} + +export function searchFeeds( + feeds: string[], + query: string, + options: { limit?: number; sinceIso?: string } = {}, + fetchImpl?: typeof fetch +) { + return post<{ articles: ScoredArticleDto[]; feedCount: number }>( + '/search', + { feeds, query, ...options }, + fetchImpl + ); +} + +export function extractArticle(url: string, fetchImpl?: typeof fetch) { + return post('/extract', { url }, fetchImpl); +} diff --git a/apps/mana/apps/web/src/lib/modules/news-research/stores/session.svelte.ts b/apps/mana/apps/web/src/lib/modules/news-research/stores/session.svelte.ts new file mode 100644 index 000000000..131a3a1e0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news-research/stores/session.svelte.ts @@ -0,0 +1,186 @@ +/** + * Ephemeral research session. Lives in memory + sessionStorage so the + * tab survives refreshes; nothing touches Dexie or mana-sync. + * + * One active session at a time. UI calls the store's actions to run + * discovery / search; results stream into `session`. + */ + +import * as api from '../api'; +import type { DiscoveredFeedDto, ResearchSession, ScoredArticleDto } from '../types'; + +const STORAGE_KEY = 'news-research-session-v1'; + +function emptySession(): ResearchSession { + return { + id: crypto.randomUUID(), + query: '', + siteUrl: null, + discoveredFeeds: [], + selectedFeeds: [], + results: [], + createdAt: Date.now(), + hasDiscovered: false, + hasSearched: false, + }; +} + +function loadInitial(): ResearchSession { + if (typeof sessionStorage === 'undefined') return emptySession(); + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return emptySession(); + return JSON.parse(raw) as ResearchSession; + } catch { + return emptySession(); + } +} + +function persist(session: ResearchSession) { + if (typeof sessionStorage === 'undefined') return; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + } catch { + // sessionStorage full or blocked — non-fatal. + } +} + +function createStore() { + let session = $state(loadInitial()); + let discovering = $state(false); + let searching = $state(false); + let error = $state(null); + + return { + get session() { + return session; + }, + get discovering() { + return discovering; + }, + get searching() { + return searching; + }, + get error() { + return error; + }, + + reset() { + session = emptySession(); + error = null; + persist(session); + }, + + async discoverByQuery(query: string, language?: string) { + error = null; + discovering = true; + try { + const res = await api.discoverByQuery(query, language); + session = { + ...session, + query, + siteUrl: null, + discoveredFeeds: res.feeds, + selectedFeeds: res.feeds.slice(0, 5).map((f) => f.url), + results: [], + hasDiscovered: true, + hasSearched: false, + }; + persist(session); + } catch (e) { + error = e instanceof Error ? e.message : 'discovery failed'; + session = { ...session, hasDiscovered: true }; + persist(session); + } finally { + discovering = false; + } + }, + + async discoverBySite(siteUrl: string) { + error = null; + discovering = true; + try { + const res = await api.discoverBySite(siteUrl); + session = { + ...session, + siteUrl, + discoveredFeeds: res.feeds, + selectedFeeds: res.feeds.map((f) => f.url), + results: [], + hasDiscovered: true, + hasSearched: false, + }; + persist(session); + } catch (e) { + error = e instanceof Error ? e.message : 'discovery failed'; + session = { ...session, hasDiscovered: true }; + persist(session); + } finally { + discovering = false; + } + }, + + toggleFeed(url: string) { + const selected = new Set(session.selectedFeeds); + if (selected.has(url)) selected.delete(url); + else selected.add(url); + session = { ...session, selectedFeeds: Array.from(selected) }; + persist(session); + }, + + addDiscoveredFeed(feed: DiscoveredFeedDto) { + if (session.discoveredFeeds.some((f) => f.url === feed.url)) return; + session = { + ...session, + discoveredFeeds: [...session.discoveredFeeds, feed], + selectedFeeds: [...session.selectedFeeds, feed.url], + }; + persist(session); + }, + + async runSearch(query: string, sinceIso?: string) { + if (session.selectedFeeds.length === 0) { + error = 'No feeds selected'; + return; + } + error = null; + searching = true; + try { + const res = await api.searchFeeds(session.selectedFeeds, query, { sinceIso }); + session = { ...session, query, results: res.articles, hasSearched: true }; + persist(session); + } catch (e) { + error = e instanceof Error ? e.message : 'search failed'; + session = { ...session, hasSearched: true }; + persist(session); + } finally { + searching = false; + } + }, + + buildAiContext(): string { + const lines = [ + `# News Research — Query: ${session.query || '(none)'}`, + `Feeds: ${session.selectedFeeds.length}`, + '', + ]; + for (const a of session.results.slice(0, 20)) { + lines.push( + `## ${a.title}`, + `Source: ${a.feedUrl}`, + `Published: ${a.publishedAt ?? 'unknown'}`, + `URL: ${a.url}`, + a.excerpt ? `\n${a.excerpt}` : '', + '' + ); + } + return lines.join('\n'); + }, + + pickTopResults(limit = 10): ScoredArticleDto[] { + return session.results.slice(0, limit); + }, + }; +} + +export const researchSessionStore = createStore(); diff --git a/apps/mana/apps/web/src/lib/modules/news-research/tools.ts b/apps/mana/apps/web/src/lib/modules/news-research/tools.ts new file mode 100644 index 000000000..ce9d9c0a2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news-research/tools.ts @@ -0,0 +1,102 @@ +/** + * News Research tools — expose the discovery + search pipeline to the + * AI companion. Non-destructive: these fetch from public RSS feeds and + * return structured context. They don't mutate anything on the user's + * side, so `propose` is overkill — they run auto. + * + * Companion flow: `research_news({query})` → the LLM gets a curated + * context block it can cite from. No IndexedDB writes happen inside + * this tool; if the AI wants to keep an article, it follows up with + * `save_news_article` from the news module. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { discoverByQuery, searchFeeds } from './api'; + +const MAX_RESULTS = 15; + +function formatContext( + query: string, + feedCount: number, + articles: Array<{ + url: string; + title: string; + excerpt: string | null; + publishedAt: string | null; + feedUrl: string; + }> +): string { + const lines = [ + `# News Research — Query: ${query}`, + `Feeds consulted: ${feedCount}`, + `Hits: ${articles.length}`, + '', + ]; + for (const a of articles) { + lines.push( + `## ${a.title}`, + `Published: ${a.publishedAt ?? 'unknown'}`, + `Source feed: ${a.feedUrl}`, + `URL: ${a.url}`, + a.excerpt ? `\n${a.excerpt}` : '', + '' + ); + } + return lines.join('\n'); +} + +export const newsResearchTools: ModuleTool[] = [ + { + name: 'research_news', + module: 'news-research', + description: + 'Entdeckt zum Thema passende RSS-Feeds, durchsucht deren Artikel nach Stichworten und liefert ein strukturiertes Kontext-Paket für die KI. Nur lesend.', + parameters: [ + { + name: 'query', + type: 'string', + description: 'Thema oder Stichworte (z.B. "KI-Regulierung EU")', + required: true, + }, + { + name: 'language', + type: 'string', + description: 'Sprache der Feeds (de/en); optional', + required: false, + }, + { + name: 'limit', + type: 'number', + description: `Maximale Anzahl Treffer (Standard ${MAX_RESULTS})`, + required: false, + }, + ], + async execute(params) { + const query = String(params.query ?? '').trim(); + if (!query) { + return { success: false, message: 'query is required' }; + } + const language = typeof params.language === 'string' ? params.language : undefined; + const limit = Math.min(Math.max(Number(params.limit) || MAX_RESULTS, 1), 50); + + const discovered = await discoverByQuery(query, language); + const feedUrls = discovered.feeds.slice(0, 10).map((f) => f.url); + if (feedUrls.length === 0) { + return { + success: true, + message: 'Keine passenden Feeds gefunden.', + data: { context: '', feedCount: 0, articles: [] }, + }; + } + + const { articles } = await searchFeeds(feedUrls, query, { limit }); + const context = formatContext(query, feedUrls.length, articles); + + return { + success: true, + message: `${articles.length} Artikel aus ${feedUrls.length} Feeds.`, + data: { context, feedCount: feedUrls.length, articles }, + }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/modules/news-research/types.ts b/apps/mana/apps/web/src/lib/modules/news-research/types.ts new file mode 100644 index 000000000..a47d79386 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/news-research/types.ts @@ -0,0 +1,59 @@ +export interface DiscoveredFeedDto { + url: string; + title: string | null; + type: 'rss' | 'atom' | 'unknown'; + siteUrl: string | null; + sourceHit?: string; +} + +export interface FeedValidationDto { + ok: boolean; + itemCount: number; + title: string | null; + sample: Array<{ + url: string; + title: string; + excerpt: string | null; + publishedAt: string | null; + }>; + error?: string; +} + +export interface ScoredArticleDto { + url: string; + title: string; + excerpt: string | null; + content: string | null; + htmlContent: string | null; + author: string | null; + imageUrl: string | null; + publishedAt: string | null; + feedUrl: string; + score: number; +} + +export interface ExtractedArticleDto { + url: string; + title: string | null; + content: string; + htmlContent: string; + excerpt: string; + byline: string | null; + siteName: string | null; + wordCount: number; + readingTimeMinutes: number; +} + +export interface ResearchSession { + id: string; + query: string; + siteUrl: string | null; + discoveredFeeds: DiscoveredFeedDto[]; + selectedFeeds: string[]; + results: ScoredArticleDto[]; + createdAt: number; + /** True once a discovery run has completed (success or empty). */ + hasDiscovered: boolean; + /** True once a search run has completed. */ + hasSearched: boolean; +} diff --git a/apps/mana/apps/web/src/lib/modules/news/collections.ts b/apps/mana/apps/web/src/lib/modules/news/collections.ts index 15399d242..6dbbba4b0 100644 --- a/apps/mana/apps/web/src/lib/modules/news/collections.ts +++ b/apps/mana/apps/web/src/lib/modules/news/collections.ts @@ -31,4 +31,5 @@ export const DEFAULT_PREFERENCES: LocalPreferences = { topicWeights: {}, sourceWeights: {}, onboardingCompleted: false, + customFeeds: [], }; diff --git a/apps/mana/apps/web/src/lib/modules/news/queries.ts b/apps/mana/apps/web/src/lib/modules/news/queries.ts index 9b689a8f0..d62bdc50d 100644 --- a/apps/mana/apps/web/src/lib/modules/news/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/news/queries.ts @@ -85,6 +85,7 @@ export function toPreferences(local: LocalPreferences): Preferences { sourceWeights: local.sourceWeights && typeof local.sourceWeights === 'object' ? local.sourceWeights : {}, onboardingCompleted: local.onboardingCompleted ?? false, + customFeeds: Array.isArray(local.customFeeds) ? local.customFeeds : [], }; } diff --git a/apps/mana/apps/web/src/lib/modules/news/stores/preferences.svelte.ts b/apps/mana/apps/web/src/lib/modules/news/stores/preferences.svelte.ts index 83d4f1f96..f63db1ba2 100644 --- a/apps/mana/apps/web/src/lib/modules/news/stores/preferences.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/news/stores/preferences.svelte.ts @@ -9,7 +9,7 @@ import { encryptRecord } from '$lib/data/crypto'; import { preferencesTable, DEFAULT_PREFERENCES } from '../collections'; import { toPreferences } from '../queries'; -import type { LocalPreferences, Preferences, Topic, Language } from '../types'; +import type { CustomFeed, LocalPreferences, Preferences, Topic, Language } from '../types'; import { PREFERENCES_ID } from '../types'; async function ensureRow(): Promise { @@ -97,6 +97,41 @@ export const preferencesStore = { await preferencesTable.update(PREFERENCES_ID, update); }, + async pinCustomFeed(feed: { url: string; title: string; topic?: Topic }): Promise { + const row = await ensureRow(); + const existing = Array.isArray(row.customFeeds) ? row.customFeeds : []; + if (existing.some((f) => f.url === feed.url)) return; + const next: CustomFeed[] = [ + ...existing, + { + id: crypto.randomUUID(), + url: feed.url, + title: feed.title, + topic: feed.topic, + pinnedAt: Date.now(), + }, + ]; + const diff: Partial = { + customFeeds: next, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('newsPreferences', diff); + await preferencesTable.update(PREFERENCES_ID, diff); + }, + + async unpinCustomFeed(id: string): Promise { + const row = await ensureRow(); + const existing = Array.isArray(row.customFeeds) ? row.customFeeds : []; + const next = existing.filter((f) => f.id !== id); + if (next.length === existing.length) return; + const diff: Partial = { + customFeeds: next, + updatedAt: new Date().toISOString(), + }; + await encryptRecord('newsPreferences', diff); + await preferencesTable.update(PREFERENCES_ID, diff); + }, + async resetWeights(): Promise { await ensureRow(); const diff: Partial = { diff --git a/apps/mana/apps/web/src/lib/modules/news/types.ts b/apps/mana/apps/web/src/lib/modules/news/types.ts index 23d949b42..472e652d0 100644 --- a/apps/mana/apps/web/src/lib/modules/news/types.ts +++ b/apps/mana/apps/web/src/lib/modules/news/types.ts @@ -78,6 +78,16 @@ export interface LocalCategory extends BaseRecord { */ export const PREFERENCES_ID = 'singleton'; +export interface CustomFeed { + id: string; + url: string; + title: string; + /** Optional topic tag from the standard taxonomy. */ + topic?: Topic; + /** Epoch ms when the user pinned this feed. */ + pinnedAt: number; +} + export interface LocalPreferences extends BaseRecord { id: string; selectedTopics: Topic[]; @@ -88,6 +98,12 @@ export interface LocalPreferences extends BaseRecord { /** source slug → weight (default 1.0, range ~0.1 to 3.0). */ sourceWeights: Record; onboardingCompleted: boolean; + /** + * User-subscribed RSS feeds, populated from the News Research module's + * "Pin feed" action. Not ingested centrally — the client fetches these + * on its own schedule (see feed-cache). + */ + customFeeds?: CustomFeed[]; } // ─── Reactions ───────────────────────────────────────────── @@ -169,6 +185,7 @@ export interface Preferences { topicWeights: Record; sourceWeights: Record; onboardingCompleted: boolean; + customFeeds: CustomFeed[]; } export interface Reaction { diff --git a/apps/mana/apps/web/src/routes/(app)/news-research/+page.svelte b/apps/mana/apps/web/src/routes/(app)/news-research/+page.svelte new file mode 100644 index 000000000..a9e3359d3 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/news-research/+page.svelte @@ -0,0 +1,439 @@ + + + + + News Research — Mana + + +
+
+

News Research

+

+ Finde RSS-Feeds zu deinem Thema, filtere Artikel und übergib das Ergebnis deiner KI als + Kontext. +

+
+ +
+
+ + +
+ +
+ {#if mode === 'query'} + + + {:else} + + + {/if} +
+ + {#if store.error} +
{store.error}
+ {/if} + + {#if store.session.hasDiscovered && store.session.discoveredFeeds.length === 0 && !store.discovering && !store.error} +
+ Keine passenden Feeds gefunden. Versuche andere Stichworte oder den „Von Website"-Modus. +
+ {/if} +
+ + {#if store.session.discoveredFeeds.length > 0} +
+
+

Gefundene Feeds ({store.session.discoveredFeeds.length})

+ {store.session.selectedFeeds.length} ausgewählt +
+
    + {#each store.session.discoveredFeeds as feed (feed.url)} +
  • + +
  • + {/each} +
+ +
+ + +
+
+ {/if} + + {#if store.session.results.length > 0} +
+
+

Ergebnisse ({store.session.results.length})

+ +
+ {#if saveError} +
{saveError}
+ {/if} +
    + {#each store.session.results as article (article.url)} +
  • + {article.title} +
    + {formatDate(article.publishedAt)} + · + Score {article.score} + · + {article.feedUrl} +
    + {#if article.excerpt} +

    {article.excerpt}

    + {/if} + +
  • + {/each} +
+
+ {/if} +
+ + diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 5fe16677a..26001c0a1 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -113,6 +113,9 @@ export const APP_ICONS = { news: svgToDataUrl( `` ), + 'news-research': svgToDataUrl( + `` + ), guides: svgToDataUrl( `` ), diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 6e005c463..dc595052b 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -479,6 +479,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', requiredTier: 'guest', }, + { + id: 'news-research', + name: 'News Research', + description: { + de: 'RSS-Feeds finden & durchsuchen', + en: 'Find & search RSS feeds', + }, + longDescription: { + de: 'Entdecke zum Thema passende RSS-Feeds, filtere die Artikel nach Stichworten und exportiere die Treffer als KI-Kontext oder in deine Leseliste.', + en: 'Discover topic-matched RSS feeds, filter articles by keyword, and export hits as AI context or into your reading list.', + }, + icon: APP_ICONS['news-research'], + color: '#0891b2', + comingSoon: false, + status: 'development', + requiredTier: 'guest', + }, { id: 'calc', name: 'Calc',