diff --git a/apps/mana/apps/web/src/hooks.server.ts b/apps/mana/apps/web/src/hooks.server.ts index 85aeb096e..be3a295ed 100644 --- a/apps/mana/apps/web/src/hooks.server.ts +++ b/apps/mana/apps/web/src/hooks.server.ts @@ -49,6 +49,8 @@ const PUBLIC_MANA_CREDITS_URL_CLIENT = process.env.PUBLIC_MANA_CREDITS_URL_CLIENT || process.env.PUBLIC_MANA_CREDITS_URL || ''; const PUBLIC_MANA_AI_URL_CLIENT = process.env.PUBLIC_MANA_AI_URL_CLIENT || process.env.PUBLIC_MANA_AI_URL || ''; +const PUBLIC_MANA_RESEARCH_URL_CLIENT = + process.env.PUBLIC_MANA_RESEARCH_URL_CLIENT || process.env.PUBLIC_MANA_RESEARCH_URL || ''; // Feature flag for the Mission Key-Grant UI (server-side execution of // encrypted missions). Default off — flip to 'true' per deployment once // the MANA_AI_PUBLIC/PRIVATE_KEY_PEM pair is provisioned on both services. @@ -133,6 +135,7 @@ window.__PUBLIC_MANA_EVENTS_URL__ = ${JSON.stringify(PUBLIC_MANA_EVENTS_URL_CLIE window.__PUBLIC_MANA_API_URL__ = ${JSON.stringify(PUBLIC_MANA_API_URL_CLIENT)}; window.__PUBLIC_MANA_CREDITS_URL__ = ${JSON.stringify(PUBLIC_MANA_CREDITS_URL_CLIENT)}; window.__PUBLIC_MANA_AI_URL__ = ${JSON.stringify(PUBLIC_MANA_AI_URL_CLIENT)}; +window.__PUBLIC_MANA_RESEARCH_URL__ = ${JSON.stringify(PUBLIC_MANA_RESEARCH_URL_CLIENT)}; window.__PUBLIC_AI_MISSION_GRANTS__ = ${JSON.stringify(PUBLIC_AI_MISSION_GRANTS)}; window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)}; `; diff --git a/apps/mana/apps/web/src/lib/api/config.ts b/apps/mana/apps/web/src/lib/api/config.ts index f195599c9..68c81b83a 100644 --- a/apps/mana/apps/web/src/lib/api/config.ts +++ b/apps/mana/apps/web/src/lib/api/config.ts @@ -74,6 +74,20 @@ export function getManaAiUrl(): string { return process.env.PUBLIC_MANA_AI_URL || 'http://localhost:3067'; } +/** + * Get the mana-research service URL (Bun/Hono, port 3068 in dev). + * Hosts the unified web-research provider orchestration — search, extract, + * and research-agent endpoints with side-by-side comparison support. + */ +export function getManaResearchUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_MANA_RESEARCH_URL__?: string }) + .__PUBLIC_MANA_RESEARCH_URL__; + return injected || 'http://localhost:3068'; + } + return process.env.PUBLIC_MANA_RESEARCH_URL || 'http://localhost:3068'; +} + /** * Feature flag for the AI Mission Key-Grant UI. When false, the consent * dialog + "Server-Zugriff" box are hidden even on missions with diff --git a/apps/mana/apps/web/src/lib/app-registry/apps.ts b/apps/mana/apps/web/src/lib/app-registry/apps.ts index 24804d624..bb6a8833c 100644 --- a/apps/mana/apps/web/src/lib/app-registry/apps.ts +++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts @@ -75,6 +75,7 @@ import { CloudSun, Stack, ArrowClockwise, + Flask, } from '@mana/shared-icons'; // ── Apps with entity capabilities ─────────────────────────── @@ -847,6 +848,16 @@ registerApp({ }, }); +registerApp({ + id: 'research-lab', + name: 'Research Lab', + color: '#8B5CF6', + icon: Flask, + views: { + list: { load: () => import('$lib/modules/research-lab/ListView.svelte') }, + }, +}); + registerApp({ id: 'firsts', name: 'Firsts', diff --git a/apps/mana/apps/web/src/lib/modules/research-lab/ListView.svelte b/apps/mana/apps/web/src/lib/modules/research-lab/ListView.svelte new file mode 100644 index 000000000..0fbe6a9f2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/research-lab/ListView.svelte @@ -0,0 +1,421 @@ + + + +
+
+
+

Research Lab

+

+ Gleiche Anfrage parallel an mehrere Anbieter schicken, Antworten nebeneinander vergleichen, + persistent speichern. +

+
+
+ {#each ['search', 'extract', 'agent'] as const as m} + + {/each} +
+
+ +
+ {#if session.mode === 'extract'} + store.setUrl((e.currentTarget as HTMLInputElement).value)} + onkeydown={onKeyDown} + class="query-input" + /> + {:else} + store.setQuery((e.currentTarget as HTMLInputElement).value)} + onkeydown={onKeyDown} + class="query-input" + /> + {/if} + + +
+ +
+
+ Anbieter +
+ {selected.length} ausgewählt + {#if estimatedCost > 0} + ~ {estimatedCost}¢ Kosten + {/if} + Historie ↓ +
+
+ {#if !catalog} +

Lade Anbieter-Katalog …

+ {:else} + store.toggleProvider(session.mode, id)} + /> + {/if} +
+ + {#if error} +
{error}
+ {/if} + + {#if session.lastRun} +
+
+

Ergebnisse

+ + Run {session.lastRun.runId.slice(0, 8)} · {session.lastRun.entries.length} + Anbieter + +
+
+ {#each session.lastRun.entries as entry (entry.provider)} + + {/each} +
+
+ {/if} + +
+

Letzte Runs

+ {#if recentRuns.length === 0} +

Noch keine Runs in dieser Session.

+ {:else} + + {/if} +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/research-lab/api.ts b/apps/mana/apps/web/src/lib/modules/research-lab/api.ts new file mode 100644 index 000000000..6257cba25 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/research-lab/api.ts @@ -0,0 +1,115 @@ +/** + * Research Lab API client — talks directly to `mana-research` on port 3068. + * + * Auth header is the same EdDSA JWT that mana-auth issues; mana-research + * verifies against the same JWKS, so no extra setup needed. + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaResearchUrl } from '$lib/api/config'; +import type { + ExtractCompareResponse, + ProvidersCatalog, + ProvidersHealth, + ResearchCompareResponse, + RunSummary, + SearchCompareResponse, +} from './types'; + +async function authHeader(): Promise> { + const token = await authStore.getValidToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function request(path: string, init: RequestInit = {}): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...(await authHeader()), + ...((init.headers as Record) ?? {}), + }; + + const res = await fetch(`${getManaResearchUrl()}${path}`, { ...init, headers }); + 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 { + /* raw text fallback */ + } + throw new Error(message || `mana-research ${path} failed (${res.status})`); + } + return (await res.json()) as T; +} + +// ─── Providers catalog + health ───────────────────────────── + +export function getProviders(): Promise { + return request('/api/v1/providers'); +} + +export function getProvidersHealth(): Promise { + return request('/api/v1/providers/health'); +} + +// ─── Search compare ───────────────────────────────────────── + +export function compareSearch( + query: string, + providers: string[], + options: { limit?: number; language?: string; categories?: string[] } = {} +): Promise { + return request('/api/v1/search/compare', { + method: 'POST', + body: JSON.stringify({ query, providers, options }), + }); +} + +// ─── Extract compare ──────────────────────────────────────── + +export function compareExtract( + url: string, + providers: string[], + options: { maxLength?: number } = {} +): Promise { + return request('/api/v1/extract/compare', { + method: 'POST', + body: JSON.stringify({ url, providers, options }), + }); +} + +// ─── Research (agent) compare ─────────────────────────────── + +export function compareResearch( + query: string, + providers: string[], + options: { model?: string; maxTokens?: number } = {} +): Promise { + return request('/api/v1/research/compare', { + method: 'POST', + body: JSON.stringify({ query, providers, options }), + }); +} + +// ─── Runs history ─────────────────────────────────────────── + +export function listRuns(limit = 50, offset = 0): Promise<{ runs: RunSummary[] }> { + return request<{ runs: RunSummary[] }>(`/api/v1/runs?limit=${limit}&offset=${offset}`); +} + +export function getRun(id: string): Promise<{ run: RunSummary; results: unknown[] }> { + return request<{ run: RunSummary; results: unknown[] }>(`/api/v1/runs/${id}`); +} + +export function rateResult( + runId: string, + resultId: string, + rating: number, + notes?: string +): Promise<{ success: boolean }> { + return request<{ success: boolean }>(`/api/v1/runs/${runId}/results/${resultId}/rate`, { + method: 'POST', + body: JSON.stringify({ rating, notes }), + }); +} diff --git a/apps/mana/apps/web/src/lib/modules/research-lab/components/CompareColumn.svelte b/apps/mana/apps/web/src/lib/modules/research-lab/components/CompareColumn.svelte new file mode 100644 index 000000000..d0384075b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/research-lab/components/CompareColumn.svelte @@ -0,0 +1,338 @@ + + + +
+
+
+ {entry.provider} + {#if entry.meta.cacheHit} + cached + {/if} + {#if entry.meta.billingMode === 'byo-key'} + BYO + {:else if entry.meta.billingMode === 'free'} + free + {/if} +
+
+ {fmtLatency(entry.meta.latencyMs)} + {#if entry.meta.costCredits > 0} + {entry.meta.costCredits}¢ + {/if} +
+
+ + {#if !entry.success} +
+ Fehler: + {entry.meta.errorCode ?? 'UNKNOWN'} +
+ {:else if category === 'search'} + {#if searchResults.length === 0} +

Keine Ergebnisse.

+ {:else} +
    + {#each searchResults as hit, i (hit.url + i)} +
  1. + + {hit.title || hit.url} + +
    {hit.url}
    + {#if hit.snippet} +

    {hit.snippet}

    + {/if} +
    + {#if hit.publishedAt}{hit.publishedAt.slice(0, 10)}{/if} + {#if hit.score !== undefined}score {hit.score.toFixed(2)}{/if} +
    +
  2. + {/each} +
+ {/if} + {:else if category === 'extract' && extractContent} +
+

{extractContent.title || extractContent.url}

+ {#if extractContent.siteName || extractContent.author} +
+ {extractContent.siteName ?? ''} + {extractContent.author ? `· ${extractContent.author}` : ''} +
+ {/if} +
+ {extractContent.wordCount} Wörter + {#if extractContent.publishedAt} + · {extractContent.publishedAt.slice(0, 10)} + {/if} +
+ {#if extractContent.excerpt} +

{extractContent.excerpt}

+ {/if} +
{extractContent.content.slice(0, 2000)}{extractContent.content
+					.length > 2000
+					? '…'
+					: ''}
+
+ {:else if category === 'agent' && agentAnswer} +
+
{agentAnswer.answer}
+ {#if agentAnswer.citations.length > 0} +
+ {agentAnswer.citations.length} Quellen +
    + {#each agentAnswer.citations as cit (cit.url)} +
  1. + {cit.title || cit.url} +
  2. + {/each} +
+
+ {/if} + {#if agentAnswer.tokenUsage} +
+ {agentAnswer.tokenUsage.input} in / {agentAnswer.tokenUsage.output} out tokens +
+ {/if} +
+ {:else} +

Keine Daten.

+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/research-lab/components/ProviderPicker.svelte b/apps/mana/apps/web/src/lib/modules/research-lab/components/ProviderPicker.svelte new file mode 100644 index 000000000..c53d2b997 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/research-lab/components/ProviderPicker.svelte @@ -0,0 +1,126 @@ + + + +
+ {#each providers as p (p.id)} + {@const ready = isReady(p.id)} + {@const price = priceOf(p)} + {@const isSelected = selected.includes(p.id)} + + {/each} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/research-lab/index.ts b/apps/mana/apps/web/src/lib/modules/research-lab/index.ts new file mode 100644 index 000000000..f325be2bf --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/research-lab/index.ts @@ -0,0 +1,3 @@ +export * from './api'; +export * from './types'; +export { researchLabStore } from './stores/session.svelte'; diff --git a/apps/mana/apps/web/src/lib/modules/research-lab/stores/session.svelte.ts b/apps/mana/apps/web/src/lib/modules/research-lab/stores/session.svelte.ts new file mode 100644 index 000000000..04913d533 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/research-lab/stores/session.svelte.ts @@ -0,0 +1,234 @@ +/** + * Ephemeral research-lab session — in-memory + sessionStorage. + * + * Each research run (search/extract/agent comparison) appends to the + * current session; refresh-safe but doesn't touch Dexie or mana-sync. + */ + +import * as api from '../api'; +import type { + CompareEntry, + ExtractCompareResponse, + ProvidersCatalog, + ProvidersHealth, + ResearchCategory, + ResearchCompareResponse, + RunSummary, + SearchCompareResponse, +} from '../types'; + +const STORAGE_KEY = 'research-lab-session-v2'; + +export interface LabSession { + id: string; + mode: ResearchCategory; + query: string; + url: string; + selected: { search: string[]; extract: string[]; agent: string[] }; + lastRun: { + runId: string; + category: ResearchCategory; + query: string; + entries: CompareEntry[]; + } | null; + createdAt: number; +} + +function emptySession(): LabSession { + return { + id: crypto.randomUUID(), + mode: 'search', + query: '', + url: '', + selected: { + search: ['searxng', 'brave', 'tavily'], + extract: ['readability', 'jina-reader'], + agent: ['perplexity-sonar', 'gemini-grounding'], + }, + lastRun: null, + createdAt: Date.now(), + }; +} + +function loadInitial(): LabSession { + if (typeof sessionStorage === 'undefined') return emptySession(); + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return emptySession(); + return { ...emptySession(), ...(JSON.parse(raw) as Partial) }; + } catch { + return emptySession(); + } +} + +function persist(session: LabSession) { + if (typeof sessionStorage === 'undefined') return; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + } catch { + /* storage full / blocked — non-fatal */ + } +} + +function createStore() { + let session = $state(loadInitial()); + let isRunning = $state(false); + let error = $state(null); + let catalog = $state(null); + let health = $state(null); + let recentRuns = $state([]); + + return { + get session() { + return session; + }, + get isRunning() { + return isRunning; + }, + get error() { + return error; + }, + get catalog() { + return catalog; + }, + get health() { + return health; + }, + get recentRuns() { + return recentRuns; + }, + + setMode(mode: ResearchCategory) { + session = { ...session, mode }; + persist(session); + }, + + setQuery(query: string) { + session = { ...session, query }; + persist(session); + }, + + setUrl(url: string) { + session = { ...session, url }; + persist(session); + }, + + toggleProvider(category: ResearchCategory, providerId: string) { + const list = session.selected[category]; + const next = list.includes(providerId) + ? list.filter((id) => id !== providerId) + : [...list, providerId]; + session = { ...session, selected: { ...session.selected, [category]: next } }; + persist(session); + }, + + reset() { + session = emptySession(); + error = null; + persist(session); + }, + + async loadCatalog() { + try { + const [c, h] = await Promise.all([api.getProviders(), api.getProvidersHealth()]); + catalog = c; + health = h; + } catch (err) { + console.warn('[research-lab] catalog load failed:', err); + } + }, + + async loadRecentRuns(limit = 25) { + try { + const { runs } = await api.listRuns(limit); + recentRuns = runs; + } catch (err) { + console.warn('[research-lab] runs load failed:', err); + } + }, + + async runSearchCompare() { + if (!session.query.trim() || session.selected.search.length === 0) return; + error = null; + isRunning = true; + try { + const res: SearchCompareResponse = await api.compareSearch( + session.query.trim(), + session.selected.search, + { limit: 10 } + ); + session = { + ...session, + lastRun: { + runId: res.runId, + category: 'search', + query: res.query, + entries: res.results as CompareEntry[], + }, + }; + persist(session); + this.loadRecentRuns().catch(() => {}); + } catch (err) { + error = err instanceof Error ? err.message : 'Search-Vergleich fehlgeschlagen'; + } finally { + isRunning = false; + } + }, + + async runExtractCompare() { + if (!session.url.trim() || session.selected.extract.length === 0) return; + error = null; + isRunning = true; + try { + const res: ExtractCompareResponse = await api.compareExtract( + session.url.trim(), + session.selected.extract + ); + session = { + ...session, + lastRun: { + runId: res.runId, + category: 'extract', + query: res.url, + entries: res.results as CompareEntry[], + }, + }; + persist(session); + this.loadRecentRuns().catch(() => {}); + } catch (err) { + error = err instanceof Error ? err.message : 'Extract-Vergleich fehlgeschlagen'; + } finally { + isRunning = false; + } + }, + + async runAgentCompare() { + if (!session.query.trim() || session.selected.agent.length === 0) return; + error = null; + isRunning = true; + try { + const res: ResearchCompareResponse = await api.compareResearch( + session.query.trim(), + session.selected.agent + ); + session = { + ...session, + lastRun: { + runId: res.runId, + category: 'agent', + query: res.query, + entries: res.results as CompareEntry[], + }, + }; + persist(session); + this.loadRecentRuns().catch(() => {}); + } catch (err) { + error = err instanceof Error ? err.message : 'Agent-Vergleich fehlgeschlagen'; + } finally { + isRunning = false; + } + }, + }; +} + +export const researchLabStore = createStore(); diff --git a/apps/mana/apps/web/src/lib/modules/research-lab/types.ts b/apps/mana/apps/web/src/lib/modules/research-lab/types.ts new file mode 100644 index 000000000..fdee85ee0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/research-lab/types.ts @@ -0,0 +1,118 @@ +/** + * Research Lab — DTOs matching mana-research service responses. + * Kept intentionally narrow: we only model what the UI consumes. + */ + +export type ResearchCategory = 'search' | 'extract' | 'agent'; + +export type BillingMode = 'server-key' | 'byo-key' | 'free' | 'mixed'; + +export interface ProviderMeta { + provider: string; + category: ResearchCategory; + latencyMs: number; + costCredits: number; + cacheHit: boolean; + billingMode: BillingMode; + errorCode?: string; +} + +export interface SearchHit { + url: string; + title: string; + snippet: string; + publishedAt?: string; + author?: string; + score?: number; + content?: string; +} + +export interface ExtractedContent { + url: string; + title: string; + content: string; + excerpt?: string; + author?: string; + siteName?: string; + publishedAt?: string; + wordCount: number; +} + +export interface Citation { + url: string; + title: string; + snippet?: string; +} + +export interface AgentAnswer { + query: string; + answer: string; + citations: Citation[]; + followUpQueries?: string[]; + tokenUsage?: { input: number; output: number }; +} + +export type CompareEntry = { + provider: string; + success: boolean; + data?: T; + meta: ProviderMeta; +}; + +export interface SearchCompareResponse { + runId: string; + query: string; + results: CompareEntry<{ results: SearchHit[] }>[]; +} + +export interface ExtractCompareResponse { + runId: string; + url: string; + results: CompareEntry<{ content: ExtractedContent }>[]; +} + +export interface ResearchCompareResponse { + runId: string; + query: string; + results: CompareEntry<{ answer: AgentAnswer }>[]; +} + +export interface ProviderInfo { + id: string; + category: ResearchCategory; + requiresApiKey: boolean; + capabilities: Record; + pricing?: { search?: number; extract?: number; research?: number }; +} + +export interface ProvidersCatalog { + search: ProviderInfo[]; + extract: ProviderInfo[]; + agent: ProviderInfo[]; +} + +export interface ProviderHealthEntry { + id: string; + category: ResearchCategory; + requiresApiKey: boolean; + serverKeyAvailable: boolean; + status: 'ready' | 'free' | 'needs-key'; +} + +export interface ProvidersHealth { + providers: ProviderHealthEntry[]; + summary: { ready: number; total: number }; +} + +export interface RunSummary { + id: string; + userId: string | null; + query: string; + queryType: string | null; + mode: 'single' | 'compare' | 'auto'; + category: ResearchCategory; + providersRequested: string[]; + billingMode: BillingMode; + totalCostCredits: number; + createdAt: string; +} diff --git a/apps/mana/apps/web/src/routes/(app)/research-lab/+page.svelte b/apps/mana/apps/web/src/routes/(app)/research-lab/+page.svelte new file mode 100644 index 000000000..00150bff5 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/research-lab/+page.svelte @@ -0,0 +1,9 @@ + + + + Research Lab · Mana + + +