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 { 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);
|
||||||
|
|
|
||||||
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'] },
|
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'] },
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
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: {},
|
topicWeights: {},
|
||||||
sourceWeights: {},
|
sourceWeights: {},
|
||||||
onboardingCompleted: false,
|
onboardingCompleted: false,
|
||||||
|
customFeeds: [],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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> = {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
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(
|
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>`
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue