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 @@
+
+
+
+
+
+
+
+
+
+
+ {#if !catalog}
+ Lade Anbieter-Katalog …
+ {:else}
+ store.toggleProvider(session.mode, id)}
+ />
+ {/if}
+
+
+ {#if error}
+
{error}
+ {/if}
+
+ {#if session.lastRun}
+
+
+
+ {#each session.lastRun.entries as entry (entry.provider)}
+
+ {/each}
+
+
+ {/if}
+
+
+ Letzte Runs
+ {#if recentRuns.length === 0}
+ Noch keine Runs in dieser Session.
+ {:else}
+
+ {#each recentRuns.slice(0, 10) as run (run.id)}
+ -
+ {run.category}
+ {run.query}
+
+ {run.providersRequested.join(', ')}
+
+
+ {run.mode}
+ {run.totalCostCredits > 0 ? ` · ${run.totalCostCredits}¢` : ''}
+
+
+ {/each}
+
+ {/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)}
+ -
+
+ {hit.title || hit.url}
+
+
{hit.url}
+ {#if hit.snippet}
+ {hit.snippet}
+ {/if}
+
+
+ {/each}
+
+ {/if}
+ {:else if category === 'extract' && extractContent}
+
+ {:else if category === 'agent' && agentAnswer}
+
+
{agentAnswer.answer}
+ {#if agentAnswer.citations.length > 0}
+
+ {agentAnswer.citations.length} Quellen
+
+ {#each agentAnswer.citations as cit (cit.url)}
+ -
+ {cit.title || cit.url}
+
+ {/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
+
+
+