mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
b768a0ffce
commit
fdd643f4b4
16 changed files with 1586 additions and 2 deletions
|
|
@ -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);
|
||||
|
|
|
|||
219
apps/api/src/modules/news-research/routes.ts
Normal file
219
apps/api/src/modules/news-research/routes.ts
Normal 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 };
|
||||
|
|
@ -335,7 +335,7 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
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'] },
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
430
apps/mana/apps/web/src/lib/modules/news-research/ListView.svelte
Normal file
430
apps/mana/apps/web/src/lib/modules/news-research/ListView.svelte
Normal 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>
|
||||
71
apps/mana/apps/web/src/lib/modules/news-research/api.ts
Normal file
71
apps/mana/apps/web/src/lib/modules/news-research/api.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
102
apps/mana/apps/web/src/lib/modules/news-research/tools.ts
Normal file
102
apps/mana/apps/web/src/lib/modules/news-research/tools.ts
Normal 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 },
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
59
apps/mana/apps/web/src/lib/modules/news-research/types.ts
Normal file
59
apps/mana/apps/web/src/lib/modules/news-research/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -31,4 +31,5 @@ export const DEFAULT_PREFERENCES: LocalPreferences = {
|
|||
topicWeights: {},
|
||||
sourceWeights: {},
|
||||
onboardingCompleted: false,
|
||||
customFeeds: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 : [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<LocalPreferences> {
|
||||
|
|
@ -97,6 +97,41 @@ export const preferencesStore = {
|
|||
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> {
|
||||
await ensureRow();
|
||||
const diff: Partial<LocalPreferences> = {
|
||||
|
|
|
|||
|
|
@ -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<string, number>;
|
||||
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<string, number>;
|
||||
sourceWeights: Record<string, number>;
|
||||
onboardingCompleted: boolean;
|
||||
customFeeds: CustomFeed[];
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
|
|
|
|||
439
apps/mana/apps/web/src/routes/(app)/news-research/+page.svelte
Normal file
439
apps/mana/apps/web/src/routes/(app)/news-research/+page.svelte
Normal 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>
|
||||
|
|
@ -113,6 +113,9 @@ export const APP_ICONS = {
|
|||
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>`
|
||||
),
|
||||
'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(
|
||||
`<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>`
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue