feat(news-research): RSS feed discovery, filter, and AI-context export

New sibling module to news/. Discovers topic-matched RSS feeds via
SearXNG (mana-search) or rel="alternate" probing of a site URL,
filters articles by keyword with a recency + title-match boost,
and exports the top hits as a markdown context block for the AI.

- API: /api/v1/news-research/{discover,validate,search,extract}
- Frontend: /news-research route + workbench ListView (compact card)
- Tool: research_news LLM tool (read-only, runs auto)
- Pin feeds → newsPreferences.customFeeds (encrypted) — covers the
  long-missing custom-RSS subscription gap; reading-list saves still
  go through articlesStore.saveFromUrl into the existing newsArticles
- shared-branding: new news-research entry + binoculars icon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 22:31:07 +02:00
parent b768a0ffce
commit fdd643f4b4
16 changed files with 1586 additions and 2 deletions

View file

@ -29,6 +29,7 @@ import { foodRoutes } from './modules/food/routes';
import { guidesRoutes } from './modules/guides/routes'; import { guidesRoutes } from './modules/guides/routes';
import { moodlitRoutes } from './modules/moodlit/routes'; import { moodlitRoutes } from './modules/moodlit/routes';
import { newsRoutes } from './modules/news/routes'; import { newsRoutes } from './modules/news/routes';
import { newsResearchRoutes } from './modules/news-research/routes';
import { tracesRoutes } from './modules/traces/routes'; import { tracesRoutes } from './modules/traces/routes';
import { presiRoutes } from './modules/presi/routes'; import { presiRoutes } from './modules/presi/routes';
import { researchRoutes } from './modules/research/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/guides', guidesRoutes);
app.route('/api/v1/moodlit', moodlitRoutes); app.route('/api/v1/moodlit', moodlitRoutes);
app.route('/api/v1/news', newsRoutes); app.route('/api/v1/news', newsRoutes);
app.route('/api/v1/news-research', newsResearchRoutes);
app.route('/api/v1/traces', tracesRoutes); app.route('/api/v1/traces', tracesRoutes);
app.route('/api/v1/presi', presiRoutes); app.route('/api/v1/presi', presiRoutes);
app.route('/api/v1/research', researchRoutes); app.route('/api/v1/research', researchRoutes);

View file

@ -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<ReturnType<typeof webSearch>>;
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<string>();
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 };

View file

@ -335,7 +335,7 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
newsCategories: { enabled: true, fields: ['name'] }, newsCategories: { enabled: true, fields: ['name'] },
newsPreferences: { newsPreferences: {
enabled: true, enabled: true,
fields: ['selectedTopics', 'blockedSources', 'topicWeights', 'sourceWeights'], fields: ['selectedTopics', 'blockedSources', 'topicWeights', 'sourceWeights', 'customFeeds'],
}, },
newsReactions: { enabled: true, fields: ['reaction', 'sourceSlug', 'topic'] }, newsReactions: { enabled: true, fields: ['reaction', 'sourceSlug', 'topic'] },

View file

@ -30,6 +30,7 @@ import { guidesTools } from '$lib/modules/guides/tools';
import { inventoryTools } from '$lib/modules/inventory/tools'; import { inventoryTools } from '$lib/modules/inventory/tools';
import { plantsTools } from '$lib/modules/plants/tools'; import { plantsTools } from '$lib/modules/plants/tools';
import { newsTools } from '$lib/modules/news/tools'; import { newsTools } from '$lib/modules/news/tools';
import { newsResearchTools } from '$lib/modules/news-research/tools';
import { recipesTools } from '$lib/modules/recipes/tools'; import { recipesTools } from '$lib/modules/recipes/tools';
import { questionsTools } from '$lib/modules/questions/tools'; import { questionsTools } from '$lib/modules/questions/tools';
import { meditateTools } from '$lib/modules/meditate/tools'; import { meditateTools } from '$lib/modules/meditate/tools';
@ -65,6 +66,7 @@ export function initTools(): void {
registerTools(inventoryTools); registerTools(inventoryTools);
registerTools(plantsTools); registerTools(plantsTools);
registerTools(newsTools); registerTools(newsTools);
registerTools(newsResearchTools);
registerTools(recipesTools); registerTools(recipesTools);
registerTools(questionsTools); registerTools(questionsTools);
registerTools(meditateTools); registerTools(meditateTools);

View file

@ -0,0 +1,430 @@
<!--
News Research — Workbench ListView.
Compact inline variant of /news-research. One card, scrolled; the user
runs discovery, picks feeds, searches articles, pins/saves/exports
without leaving the workbench.
Stateful session is shared with the /news-research route via
`researchSessionStore` (sessionStorage-backed), so moving between
workbench card and full page keeps results.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import type { ViewProps } from '$lib/app-registry';
import { researchSessionStore } from '$lib/modules/news-research/stores/session.svelte';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
import { usePreferences } from '$lib/modules/news/queries';
const {}: ViewProps = $props();
const store = researchSessionStore;
const prefs$ = usePreferences();
const pinnedUrls = $derived(new Set((prefs$.value?.customFeeds ?? []).map((f) => f.url)));
let mode = $state<'query' | 'site'>('query');
let query = $state('');
let siteUrl = $state('');
let searchQuery = $state('');
let savingUrl = $state<string | null>(null);
let saveError = $state<string | null>(null);
let copyLabel = $state('Kopieren');
let feedsOpen = $state(true);
function isUrl(s: string): boolean {
try {
const u = new URL(s.trim());
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
async function onDiscover(e: Event) {
e.preventDefault();
if (mode === 'query' && query.trim().length > 2) {
await store.discoverByQuery(query.trim());
if (!searchQuery) searchQuery = query.trim();
} else if (mode === 'site' && isUrl(siteUrl)) {
await store.discoverBySite(siteUrl.trim());
}
}
async function onSearch(e: Event) {
e.preventDefault();
if (!searchQuery.trim()) return;
await store.runSearch(searchQuery.trim());
feedsOpen = false;
}
async function togglePin(feed: { url: string; title: string | null }) {
if (pinnedUrls.has(feed.url)) {
const existing = (prefs$.value?.customFeeds ?? []).find((f) => f.url === feed.url);
if (existing) await preferencesStore.unpinCustomFeed(existing.id);
} else {
await preferencesStore.pinCustomFeed({ url: feed.url, title: feed.title ?? feed.url });
}
}
async function onSave(articleUrl: string) {
savingUrl = articleUrl;
saveError = null;
try {
const article = await articlesStore.saveFromUrl(articleUrl);
goto(`/news/${article.id}`);
} catch (err) {
saveError = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
savingUrl = null;
}
}
async function onCopy() {
try {
await navigator.clipboard.writeText(store.buildAiContext());
copyLabel = '✓';
setTimeout(() => (copyLabel = 'Kopieren'), 1200);
} catch {
copyLabel = 'Fehler';
}
}
function formatDate(iso: string | null): string {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString('de-DE', { month: 'short', day: 'numeric' });
} catch {
return '';
}
}
</script>
<div class="wrap">
<section class="slot">
<div class="mode-switch">
<button type="button" class:active={mode === 'query'} onclick={() => (mode = 'query')}
>Thema</button
>
<button type="button" class:active={mode === 'site'} onclick={() => (mode = 'site')}
>Website</button
>
<a class="open-full" href="/news-research" title="Als volle Seite öffnen"></a>
</div>
<form onsubmit={onDiscover} class="row">
{#if mode === 'query'}
<input
type="text"
placeholder="z.B. KI-Regulierung, Klimawandel"
bind:value={query}
disabled={store.discovering}
/>
<button type="submit" disabled={store.discovering || query.trim().length < 3}>
{store.discovering ? '…' : 'Finden'}
</button>
{:else}
<input
type="url"
placeholder="https://…"
bind:value={siteUrl}
disabled={store.discovering}
/>
<button type="submit" disabled={store.discovering || !isUrl(siteUrl)}>
{store.discovering ? '…' : 'Entdecken'}
</button>
{/if}
</form>
{#if store.error}<div class="error">{store.error}</div>{/if}
</section>
{#if store.session.discoveredFeeds.length > 0}
<section class="slot">
<button type="button" class="collapse" onclick={() => (feedsOpen = !feedsOpen)}>
<span
>Feeds ({store.session.selectedFeeds.length}/{store.session.discoveredFeeds.length})</span
>
<span>{feedsOpen ? '▾' : '▸'}</span>
</button>
{#if feedsOpen}
<ul class="feeds">
{#each store.session.discoveredFeeds as feed (feed.url)}
<li>
<label>
<input
type="checkbox"
checked={store.session.selectedFeeds.includes(feed.url)}
onchange={() => store.toggleFeed(feed.url)}
/>
<span class="ft">{feed.title ?? feed.url}</span>
</label>
<button
type="button"
class="pin"
class:pinned={pinnedUrls.has(feed.url)}
onclick={() => togglePin(feed)}
title={pinnedUrls.has(feed.url) ? 'Abo entfernen' : 'Abonnieren'}
>
{pinnedUrls.has(feed.url) ? '★' : '☆'}
</button>
</li>
{/each}
</ul>
{/if}
<form onsubmit={onSearch} class="row">
<input
type="text"
placeholder="Artikel filtern"
bind:value={searchQuery}
disabled={store.searching}
/>
<button
type="submit"
disabled={store.searching ||
!searchQuery.trim() ||
store.session.selectedFeeds.length === 0}
>
{store.searching ? '…' : 'Suchen'}
</button>
</form>
</section>
{/if}
{#if store.session.results.length > 0}
<section class="slot">
<div class="results-head">
<span>Treffer ({store.session.results.length})</span>
<button type="button" class="ctx" onclick={onCopy}>KI-Kontext: {copyLabel}</button>
</div>
{#if saveError}<div class="error">{saveError}</div>{/if}
<ul class="results">
{#each store.session.results as a (a.url)}
<li>
<a href={a.url} target="_blank" rel="noreferrer" class="rt">{a.title}</a>
<div class="rm">
<span>{formatDate(a.publishedAt)}</span>
<span>·</span>
<span>Score {a.score}</span>
<button
type="button"
class="save"
disabled={savingUrl === a.url}
onclick={() => onSave(a.url)}
>
{savingUrl === a.url ? '…' : 'Speichern'}
</button>
</div>
</li>
{/each}
</ul>
</section>
{/if}
{#if store.session.discoveredFeeds.length === 0 && !store.discovering}
<section class="empty">
{#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}
</section>
{/if}
</div>
<style>
.wrap {
display: flex;
flex-direction: column;
gap: 0.65rem;
padding: 0.65rem;
height: 100%;
overflow-y: auto;
}
.slot {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.55rem;
border: 1px solid var(--border, #e5e5e5);
border-radius: 0.5rem;
background: var(--surface, #fff);
}
.empty {
color: var(--text-muted, #888);
font-size: 0.85rem;
padding: 0.75rem;
text-align: center;
}
.mode-switch {
display: flex;
gap: 0.2rem;
align-items: center;
background: var(--surface-alt, #f4f4f4);
padding: 0.2rem;
border-radius: 0.4rem;
}
.mode-switch button {
flex: 1;
background: transparent;
border: none;
padding: 0.3rem 0.5rem;
border-radius: 0.3rem;
cursor: pointer;
font-size: 0.8rem;
color: var(--text, #333);
}
.mode-switch button.active {
background: var(--surface, #fff);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.open-full {
padding: 0 0.4rem;
text-decoration: none;
color: var(--text-muted, #888);
font-size: 0.9rem;
}
.row {
display: flex;
gap: 0.35rem;
}
.row input {
flex: 1;
min-width: 0;
padding: 0.35rem 0.5rem;
border: 1px solid var(--border, #e5e5e5);
border-radius: 0.35rem;
background: var(--surface, #fff);
color: var(--text, #333);
font-size: 0.85rem;
}
.row button {
padding: 0.35rem 0.7rem;
border: none;
border-radius: 0.35rem;
background: var(--accent, #0891b2);
color: white;
cursor: pointer;
font-size: 0.8rem;
white-space: nowrap;
}
.row button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.collapse {
display: flex;
justify-content: space-between;
background: transparent;
border: none;
padding: 0.2rem 0;
cursor: pointer;
font-size: 0.85rem;
color: var(--text, #333);
font-weight: 500;
}
.feeds,
.results {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: 180px;
overflow-y: auto;
}
.feeds li {
display: flex;
gap: 0.35rem;
align-items: center;
}
.feeds li label {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 1;
min-width: 0;
cursor: pointer;
font-size: 0.8rem;
}
.ft {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pin {
background: transparent;
border: none;
cursor: pointer;
font-size: 0.95rem;
color: var(--text-muted, #888);
padding: 0 0.25rem;
}
.pin.pinned {
color: var(--accent, #0891b2);
}
.results-head {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
font-weight: 500;
color: var(--text, #333);
}
.ctx {
background: var(--surface-alt, #f4f4f4);
border: 1px solid var(--border, #e5e5e5);
border-radius: 0.3rem;
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
cursor: pointer;
color: var(--text, #333);
}
.results li {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.35rem;
border: 1px solid var(--border, #eee);
border-radius: 0.35rem;
}
.rt {
font-size: 0.85rem;
font-weight: 600;
color: var(--text, #333);
text-decoration: none;
line-height: 1.25;
}
.rt:hover {
text-decoration: underline;
}
.rm {
display: flex;
gap: 0.3rem;
align-items: center;
font-size: 0.7rem;
color: var(--text-muted, #888);
}
.save {
margin-left: auto;
background: transparent;
border: 1px solid var(--border, #e5e5e5);
border-radius: 0.25rem;
padding: 0.1rem 0.4rem;
font-size: 0.7rem;
cursor: pointer;
color: var(--text, #333);
}
.save:disabled {
opacity: 0.5;
}
.error {
background: #fee;
border: 1px solid #fcc;
color: #900;
padding: 0.35rem 0.5rem;
border-radius: 0.35rem;
font-size: 0.8rem;
}
</style>

View file

@ -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<Record<string, string>> {
const token = await authStore.getValidToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function post<T>(path: string, body: unknown, fetchImpl: typeof fetch = fetch): Promise<T> {
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<FeedValidationDto>('/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<ExtractedArticleDto>('/extract', { url }, fetchImpl);
}

View file

@ -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<ResearchSession>(loadInitial());
let discovering = $state(false);
let searching = $state(false);
let error = $state<string | null>(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();

View file

@ -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 },
};
},
},
];

View file

@ -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;
}

View file

@ -31,4 +31,5 @@ export const DEFAULT_PREFERENCES: LocalPreferences = {
topicWeights: {}, topicWeights: {},
sourceWeights: {}, sourceWeights: {},
onboardingCompleted: false, onboardingCompleted: false,
customFeeds: [],
}; };

View file

@ -85,6 +85,7 @@ export function toPreferences(local: LocalPreferences): Preferences {
sourceWeights: sourceWeights:
local.sourceWeights && typeof local.sourceWeights === 'object' ? local.sourceWeights : {}, local.sourceWeights && typeof local.sourceWeights === 'object' ? local.sourceWeights : {},
onboardingCompleted: local.onboardingCompleted ?? false, onboardingCompleted: local.onboardingCompleted ?? false,
customFeeds: Array.isArray(local.customFeeds) ? local.customFeeds : [],
}; };
} }

View file

@ -9,7 +9,7 @@
import { encryptRecord } from '$lib/data/crypto'; import { encryptRecord } from '$lib/data/crypto';
import { preferencesTable, DEFAULT_PREFERENCES } from '../collections'; import { preferencesTable, DEFAULT_PREFERENCES } from '../collections';
import { toPreferences } from '../queries'; 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'; import { PREFERENCES_ID } from '../types';
async function ensureRow(): Promise<LocalPreferences> { async function ensureRow(): Promise<LocalPreferences> {
@ -97,6 +97,41 @@ export const preferencesStore = {
await preferencesTable.update(PREFERENCES_ID, update); await preferencesTable.update(PREFERENCES_ID, update);
}, },
async pinCustomFeed(feed: { url: string; title: string; topic?: Topic }): Promise<void> {
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<LocalPreferences> = {
customFeeds: next,
updatedAt: new Date().toISOString(),
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
async unpinCustomFeed(id: string): Promise<void> {
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<LocalPreferences> = {
customFeeds: next,
updatedAt: new Date().toISOString(),
};
await encryptRecord('newsPreferences', diff);
await preferencesTable.update(PREFERENCES_ID, diff);
},
async resetWeights(): Promise<void> { async resetWeights(): Promise<void> {
await ensureRow(); await ensureRow();
const diff: Partial<LocalPreferences> = { const diff: Partial<LocalPreferences> = {

View file

@ -78,6 +78,16 @@ export interface LocalCategory extends BaseRecord {
*/ */
export const PREFERENCES_ID = 'singleton'; 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 { export interface LocalPreferences extends BaseRecord {
id: string; id: string;
selectedTopics: Topic[]; selectedTopics: Topic[];
@ -88,6 +98,12 @@ export interface LocalPreferences extends BaseRecord {
/** source slug → weight (default 1.0, range ~0.1 to 3.0). */ /** source slug → weight (default 1.0, range ~0.1 to 3.0). */
sourceWeights: Record<string, number>; sourceWeights: Record<string, number>;
onboardingCompleted: boolean; 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 ───────────────────────────────────────────── // ─── Reactions ─────────────────────────────────────────────
@ -169,6 +185,7 @@ export interface Preferences {
topicWeights: Record<string, number>; topicWeights: Record<string, number>;
sourceWeights: Record<string, number>; sourceWeights: Record<string, number>;
onboardingCompleted: boolean; onboardingCompleted: boolean;
customFeeds: CustomFeed[];
} }
export interface Reaction { export interface Reaction {

View file

@ -0,0 +1,439 @@
<!--
/news-research — discover RSS feeds by query or site, select them,
search their articles by keyword, and export the top results as an
AI context block or save any to the reading list.
Ephemeral session — lives in sessionStorage, never touches Dexie.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { researchSessionStore } from '$lib/modules/news-research/stores/session.svelte';
import { articlesStore } from '$lib/modules/news/stores/articles.svelte';
import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte';
import { usePreferences } from '$lib/modules/news/queries';
let mode = $state<'query' | 'site'>('query');
let query = $state('');
let siteUrl = $state('');
let searchQuery = $state('');
let copyLabel = $state('Als KI-Kontext kopieren');
let savingUrl = $state<string | null>(null);
let saveError = $state<string | null>(null);
const store = researchSessionStore;
const prefs$ = usePreferences();
const pinnedUrls = $derived(new Set((prefs$.value?.customFeeds ?? []).map((f) => f.url)));
async function togglePin(feed: { url: string; title: string | null }) {
if (pinnedUrls.has(feed.url)) {
const existing = (prefs$.value?.customFeeds ?? []).find((f) => f.url === feed.url);
if (existing) await preferencesStore.unpinCustomFeed(existing.id);
} else {
await preferencesStore.pinCustomFeed({
url: feed.url,
title: feed.title ?? feed.url,
});
}
}
function isUrl(s: string): boolean {
try {
const u = new URL(s.trim());
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
async function onDiscover(e: Event) {
e.preventDefault();
if (mode === 'query' && query.trim().length > 2) {
await store.discoverByQuery(query.trim());
if (!searchQuery) searchQuery = query.trim();
} else if (mode === 'site' && isUrl(siteUrl)) {
await store.discoverBySite(siteUrl.trim());
}
}
async function onSearch(e: Event) {
e.preventDefault();
if (!searchQuery.trim()) return;
await store.runSearch(searchQuery.trim());
}
async function onCopy() {
try {
await navigator.clipboard.writeText(store.buildAiContext());
copyLabel = 'Kopiert ✓';
setTimeout(() => (copyLabel = 'Als KI-Kontext kopieren'), 1500);
} catch {
copyLabel = 'Kopieren fehlgeschlagen';
}
}
async function onSave(articleUrl: string) {
savingUrl = articleUrl;
saveError = null;
try {
const article = await articlesStore.saveFromUrl(articleUrl);
goto(`/news/${article.id}`);
} catch (err) {
saveError = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
savingUrl = null;
}
}
function formatDate(iso: string | null): string {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return '';
}
}
</script>
<svelte:head>
<title>News Research — Mana</title>
</svelte:head>
<div class="page">
<header class="header">
<h1>News Research</h1>
<p class="hint">
Finde RSS-Feeds zu deinem Thema, filtere Artikel und übergib das Ergebnis deiner KI als
Kontext.
</p>
</header>
<section class="block">
<div class="mode-switch">
<button type="button" class:active={mode === 'query'} onclick={() => (mode = 'query')}
>Thema-Suche</button
>
<button type="button" class:active={mode === 'site'} onclick={() => (mode = 'site')}
>Von Website</button
>
</div>
<form onsubmit={onDiscover} class="discover-form">
{#if mode === 'query'}
<input
type="text"
placeholder="z.B. Klimawandel, KI-Regulierung, Mars-Missionen"
bind:value={query}
disabled={store.discovering}
/>
<button type="submit" disabled={store.discovering || query.trim().length < 3}>
{store.discovering ? 'Suche…' : 'Feeds finden'}
</button>
{:else}
<input
type="url"
placeholder="https://example.com"
bind:value={siteUrl}
disabled={store.discovering}
/>
<button type="submit" disabled={store.discovering || !isUrl(siteUrl)}>
{store.discovering ? 'Suche…' : 'Feeds entdecken'}
</button>
{/if}
</form>
{#if store.error}
<div class="error">{store.error}</div>
{/if}
{#if store.session.hasDiscovered && store.session.discoveredFeeds.length === 0 && !store.discovering && !store.error}
<div class="empty-hint">
Keine passenden Feeds gefunden. Versuche andere Stichworte oder den „Von Website"-Modus.
</div>
{/if}
</section>
{#if store.session.discoveredFeeds.length > 0}
<section class="block">
<div class="block-head">
<h2>Gefundene Feeds ({store.session.discoveredFeeds.length})</h2>
<span class="sub">{store.session.selectedFeeds.length} ausgewählt</span>
</div>
<ul class="feed-list">
{#each store.session.discoveredFeeds as feed (feed.url)}
<li>
<label>
<input
type="checkbox"
checked={store.session.selectedFeeds.includes(feed.url)}
onchange={() => store.toggleFeed(feed.url)}
/>
<span class="feed-title">{feed.title ?? feed.url}</span>
<span class="feed-type">{feed.type}</span>
{#if feed.sourceHit}<span class="feed-src">{feed.sourceHit}</span>{/if}
<button
type="button"
class="pin"
class:pinned={pinnedUrls.has(feed.url)}
onclick={(e) => {
e.preventDefault();
togglePin(feed);
}}
title={pinnedUrls.has(feed.url) ? 'Abo entfernen' : 'Als Abo speichern'}
>
{pinnedUrls.has(feed.url) ? '★ Abonniert' : '☆ Abonnieren'}
</button>
</label>
</li>
{/each}
</ul>
<form onsubmit={onSearch} class="search-form">
<input
type="text"
placeholder="Artikel nach Stichworten filtern"
bind:value={searchQuery}
disabled={store.searching}
/>
<button
type="submit"
disabled={store.searching ||
!searchQuery.trim() ||
store.session.selectedFeeds.length === 0}
>
{store.searching ? 'Suche…' : 'Artikel suchen'}
</button>
</form>
</section>
{/if}
{#if store.session.results.length > 0}
<section class="block">
<div class="block-head">
<h2>Ergebnisse ({store.session.results.length})</h2>
<button type="button" class="secondary" onclick={onCopy}>{copyLabel}</button>
</div>
{#if saveError}
<div class="error">{saveError}</div>
{/if}
<ul class="result-list">
{#each store.session.results as article (article.url)}
<li class="result">
<a href={article.url} target="_blank" rel="noreferrer" class="r-title"
>{article.title}</a
>
<div class="r-meta">
<span>{formatDate(article.publishedAt)}</span>
<span>·</span>
<span class="r-score">Score {article.score}</span>
<span>·</span>
<span class="r-feed">{article.feedUrl}</span>
</div>
{#if article.excerpt}
<p class="r-excerpt">{article.excerpt}</p>
{/if}
<button
type="button"
class="save"
disabled={savingUrl === article.url}
onclick={() => onSave(article.url)}
>
{savingUrl === article.url ? 'Speichere…' : 'In Leseliste speichern'}
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
<style>
.page {
max-width: 840px;
margin: 0 auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.header h1 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
}
.hint {
color: var(--text-muted, #888);
margin: 0;
}
.block {
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e5e5);
border-radius: 0.75rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.block-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.block-head h2 {
margin: 0;
font-size: 1.1rem;
}
.sub {
color: var(--text-muted, #888);
font-size: 0.85rem;
}
.mode-switch {
display: inline-flex;
gap: 0.25rem;
background: var(--surface-alt, #f4f4f4);
padding: 0.25rem;
border-radius: 0.5rem;
width: fit-content;
}
.mode-switch button {
background: transparent;
border: none;
padding: 0.35rem 0.75rem;
border-radius: 0.35rem;
cursor: pointer;
color: var(--text, #333);
}
.mode-switch button.active {
background: var(--surface, #fff);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.discover-form,
.search-form {
display: flex;
gap: 0.5rem;
}
.discover-form input,
.search-form input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border, #e5e5e5);
border-radius: 0.5rem;
background: var(--surface, #fff);
color: var(--text, #333);
}
button[type='submit'] {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
background: var(--accent, #10b981);
color: white;
cursor: pointer;
}
button[type='submit']:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.secondary {
background: var(--surface-alt, #f4f4f4);
color: var(--text, #333);
border: 1px solid var(--border, #e5e5e5);
border-radius: 0.5rem;
padding: 0.35rem 0.75rem;
cursor: pointer;
}
.feed-list,
.result-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.feed-list li label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.feed-title {
font-weight: 500;
}
.feed-type,
.feed-src {
font-size: 0.75rem;
color: var(--text-muted, #888);
}
.pin {
margin-left: auto;
background: transparent;
border: 1px solid var(--border, #e5e5e5);
border-radius: 0.35rem;
padding: 0.15rem 0.55rem;
font-size: 0.75rem;
cursor: pointer;
color: var(--text, #333);
}
.pin.pinned {
background: var(--accent, #0891b2);
color: white;
border-color: transparent;
}
.result {
padding: 0.75rem;
border: 1px solid var(--border, #eee);
border-radius: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.r-title {
font-weight: 600;
color: var(--text, #333);
text-decoration: none;
}
.r-title:hover {
text-decoration: underline;
}
.r-meta {
display: flex;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--text-muted, #888);
flex-wrap: wrap;
}
.r-excerpt {
margin: 0.25rem 0 0;
color: var(--text, #333);
font-size: 0.9rem;
}
.save {
align-self: flex-start;
background: transparent;
border: 1px solid var(--border, #e5e5e5);
border-radius: 0.35rem;
padding: 0.25rem 0.65rem;
font-size: 0.8rem;
cursor: pointer;
color: var(--text, #333);
}
.save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
background: #fee;
border: 1px solid #fcc;
color: #900;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.9rem;
}
.empty-hint {
color: var(--text-muted, #888);
font-size: 0.9rem;
padding: 0.5rem 0;
}
</style>

View file

@ -113,6 +113,9 @@ export const APP_ICONS = {
news: svgToDataUrl( news: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ng" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#10b981"/><stop offset="100%" style="stop-color:#34d399"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ng)"/><rect x="22" y="25" width="56" height="50" rx="4" stroke="white" stroke-width="4" fill="none"/><line x1="30" y1="38" x2="55" y2="38" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="30" y1="48" x2="70" y2="48" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="30" y1="58" x2="65" y2="58" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>` `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ng" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#10b981"/><stop offset="100%" style="stop-color:#34d399"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ng)"/><rect x="22" y="25" width="56" height="50" rx="4" stroke="white" stroke-width="4" fill="none"/><line x1="30" y1="38" x2="55" y2="38" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="30" y1="48" x2="70" y2="48" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="30" y1="58" x2="65" y2="58" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
), ),
'news-research': svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="nr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0891b2"/><stop offset="100%" style="stop-color:#22d3ee"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#nr)"/><path d="M30 30a6 6 0 0 0-6 6v6M30 30a6 6 0 0 1 6 6v0M30 30v0" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/><circle cx="30" cy="30" r="3" fill="white"/><circle cx="52" cy="52" r="18" stroke="white" stroke-width="4" fill="none"/><line x1="65" y1="65" x2="78" y2="78" stroke="white" stroke-width="5" stroke-linecap="round"/><line x1="44" y1="50" x2="58" y2="50" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="44" y1="56" x2="54" y2="56" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),
guides: svgToDataUrl( guides: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="gg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0d9488"/><stop offset="100%" style="stop-color:#0f766e"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#gg)"/><rect x="18" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="54" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="46" y="25" width="8" height="50" fill="white" fill-opacity="0.25"/><circle cx="27" cy="40" r="4" fill="white" fill-opacity="0.9"/><rect x="34" y="37" width="11" height="3" rx="1.5" fill="white" fill-opacity="0.6"/><circle cx="27" cy="52" r="4" fill="white" fill-opacity="0.55"/><rect x="34" y="49" width="9" height="3" rx="1.5" fill="white" fill-opacity="0.4"/><circle cx="27" cy="64" r="4" fill="white" fill-opacity="0.3"/><rect x="34" y="61" width="10" height="3" rx="1.5" fill="white" fill-opacity="0.25"/><path d="M60 52l6 7 12-14" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>` `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="gg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0d9488"/><stop offset="100%" style="stop-color:#0f766e"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#gg)"/><rect x="18" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="54" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="46" y="25" width="8" height="50" fill="white" fill-opacity="0.25"/><circle cx="27" cy="40" r="4" fill="white" fill-opacity="0.9"/><rect x="34" y="37" width="11" height="3" rx="1.5" fill="white" fill-opacity="0.6"/><circle cx="27" cy="52" r="4" fill="white" fill-opacity="0.55"/><rect x="34" y="49" width="9" height="3" rx="1.5" fill="white" fill-opacity="0.4"/><circle cx="27" cy="64" r="4" fill="white" fill-opacity="0.3"/><rect x="34" y="61" width="10" height="3" rx="1.5" fill="white" fill-opacity="0.25"/><path d="M60 52l6 7 12-14" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
), ),

View file

@ -479,6 +479,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development', status: 'development',
requiredTier: 'guest', 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', id: 'calc',
name: 'Calc', name: 'Calc',