From 786ffd771b9812427d540272cbe4a2feae355d43 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 17 Apr 2026 15:21:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(research-lab):=20Phase=204=20=E2=80=94=20U?= =?UTF-8?q?I=20for=20side-by-side=20provider=20comparison?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New SvelteKit module that consumes mana-research directly on port 3068 (JWT auth + CORS are already wired). Three modes — search, extract, agent — with a shared ephemeral session store (sessionStorage, no Dexie) so tab-refreshes survive but nothing leaks into the local-first data layer. Components: - ProviderPicker — chip multi-select per category. Shows free/ready/ needs-key status + per-call pricing from the providers catalog. - CompareColumn — one provider's result: search hits (ordered list + snippets + scores), extract (title + excerpt + body preview + stats), or agent answer (text + citations list + token usage). - ListView — mode toggle, query/URL input, providers row with cost estimate, results grid, recent-runs history list. Data flow: store calls api.ts → fetch(`${getManaResearchUrl()}/...`) with Bearer JWT. Cost, latency, cache-hit, and billing-mode come back in each result's meta and are rendered inline per column. The module registers itself as "Research Lab" (Flask icon, purple brand color). No collection, no IndexedDB table, no sync — this is purely a live query interface over the comparison backend. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mana/apps/web/src/hooks.server.ts | 3 + apps/mana/apps/web/src/lib/api/config.ts | 14 + .../apps/web/src/lib/app-registry/apps.ts | 11 + .../lib/modules/research-lab/ListView.svelte | 421 ++++++++++++++++++ .../web/src/lib/modules/research-lab/api.ts | 115 +++++ .../components/CompareColumn.svelte | 338 ++++++++++++++ .../components/ProviderPicker.svelte | 126 ++++++ .../web/src/lib/modules/research-lab/index.ts | 3 + .../research-lab/stores/session.svelte.ts | 234 ++++++++++ .../web/src/lib/modules/research-lab/types.ts | 118 +++++ .../routes/(app)/research-lab/+page.svelte | 9 + 11 files changed, 1392 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/modules/research-lab/ListView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/research-lab/api.ts create mode 100644 apps/mana/apps/web/src/lib/modules/research-lab/components/CompareColumn.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/research-lab/components/ProviderPicker.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/research-lab/index.ts create mode 100644 apps/mana/apps/web/src/lib/modules/research-lab/stores/session.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/research-lab/types.ts create mode 100644 apps/mana/apps/web/src/routes/(app)/research-lab/+page.svelte 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} +
    + {#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)} +
  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 + + +