feat(research-lab): Phase 4 — UI for side-by-side provider comparison

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-17 15:21:21 +02:00
parent 4aafbf6f6d
commit 786ffd771b
11 changed files with 1392 additions and 0 deletions

View file

@ -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)};
</script>`;

View file

@ -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

View file

@ -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',

View file

@ -0,0 +1,421 @@
<!--
Research Lab — ListView.
Pick a category (search/extract/agent), choose providers, run a side-by-side
comparison. Eval runs persist server-side in research.eval_runs; this view
is a thin orchestrator over the mana-research service.
-->
<script lang="ts">
import ProviderPicker from './components/ProviderPicker.svelte';
import CompareColumn from './components/CompareColumn.svelte';
import { researchLabStore } from './stores/session.svelte';
import type { ResearchCategory } from './types';
const store = researchLabStore;
$effect(() => {
void store.loadCatalog();
void store.loadRecentRuns();
});
const session = $derived(store.session);
const catalog = $derived(store.catalog);
const health = $derived(store.health);
const error = $derived(store.error);
const isRunning = $derived(store.isRunning);
const recentRuns = $derived(store.recentRuns);
const activeProviders = $derived(
!catalog
? []
: session.mode === 'search'
? catalog.search
: session.mode === 'extract'
? catalog.extract
: catalog.agent
);
const activeHealth = $derived(health?.providers.filter((p) => p.category === session.mode) ?? []);
const selected = $derived(session.selected[session.mode]);
const estimatedCost = $derived.by(() => {
return selected.reduce((sum, id) => {
const info = activeProviders.find((p) => p.id === id);
if (!info) return sum;
const price =
session.mode === 'search'
? info.pricing?.search
: session.mode === 'extract'
? info.pricing?.extract
: info.pricing?.research;
return sum + (price ?? 0);
}, 0);
});
async function runCompare() {
if (session.mode === 'search') await store.runSearchCompare();
else if (session.mode === 'extract') await store.runExtractCompare();
else await store.runAgentCompare();
}
function onKeyDown(ev: KeyboardEvent) {
if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey)) {
ev.preventDefault();
void runCompare();
}
}
const canRun = $derived.by(() => {
if (isRunning) return false;
if (selected.length === 0) return false;
if (session.mode === 'extract') return session.url.trim().length > 0;
return session.query.trim().length > 0;
});
</script>
<div class="lab">
<header class="lab-header">
<div>
<h2>Research Lab</h2>
<p class="subtitle">
Gleiche Anfrage parallel an mehrere Anbieter schicken, Antworten nebeneinander vergleichen,
persistent speichern.
</p>
</div>
<div class="mode-toggle" role="tablist">
{#each ['search', 'extract', 'agent'] as const as m}
<button
role="tab"
aria-selected={session.mode === m}
class:active={session.mode === m}
onclick={() => store.setMode(m as ResearchCategory)}
>
{m === 'search' ? 'Suche' : m === 'extract' ? 'Extrakt' : 'Agent'}
</button>
{/each}
</div>
</header>
<section class="query-bar">
{#if session.mode === 'extract'}
<input
type="url"
placeholder="https://example.com/article"
value={session.url}
oninput={(e) => store.setUrl((e.currentTarget as HTMLInputElement).value)}
onkeydown={onKeyDown}
class="query-input"
/>
{:else}
<input
type="text"
placeholder={session.mode === 'agent'
? 'Frage stellen — z.B. "Was sind aktuelle Meinungen zu X?"'
: 'Suchanfrage …'}
value={session.query}
oninput={(e) => store.setQuery((e.currentTarget as HTMLInputElement).value)}
onkeydown={onKeyDown}
class="query-input"
/>
{/if}
<button type="button" class="primary" disabled={!canRun} onclick={() => void runCompare()}>
{#if isRunning}Läuft…{:else}Vergleichen ({selected.length}){/if}
</button>
</section>
<section class="providers">
<div class="providers-row">
<span class="label">Anbieter</span>
<div class="providers-meta">
<span>{selected.length} ausgewählt</span>
{#if estimatedCost > 0}
<span>~ {estimatedCost}¢ Kosten</span>
{/if}
<a href="#recent-runs" class="history-link">Historie ↓</a>
</div>
</div>
{#if !catalog}
<p class="loading">Lade Anbieter-Katalog …</p>
{:else}
<ProviderPicker
category={session.mode}
providers={activeProviders}
health={activeHealth}
{selected}
onToggle={(id) => store.toggleProvider(session.mode, id)}
/>
{/if}
</section>
{#if error}
<div class="error-banner">{error}</div>
{/if}
{#if session.lastRun}
<section class="results">
<div class="results-header">
<h3>Ergebnisse</h3>
<span class="results-meta">
Run <code>{session.lastRun.runId.slice(0, 8)}</code> · {session.lastRun.entries.length}
Anbieter
</span>
</div>
<div class="grid">
{#each session.lastRun.entries as entry (entry.provider)}
<CompareColumn category={session.lastRun.category} {entry} />
{/each}
</div>
</section>
{/if}
<section class="recent" id="recent-runs">
<h3>Letzte Runs</h3>
{#if recentRuns.length === 0}
<p class="loading">Noch keine Runs in dieser Session.</p>
{:else}
<ul class="runs-list">
{#each recentRuns.slice(0, 10) as run (run.id)}
<li class="run">
<span class="run-badge badge-{run.category}">{run.category}</span>
<span class="run-query">{run.query}</span>
<span class="run-providers">
{run.providersRequested.join(', ')}
</span>
<span class="run-meta">
{run.mode}
{run.totalCostCredits > 0 ? ` · ${run.totalCostCredits}¢` : ''}
</span>
</li>
{/each}
</ul>
{/if}
</section>
</div>
<style>
.lab {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.25rem;
max-width: 100%;
min-height: 100%;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
}
.lab-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.lab-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.subtitle {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
max-width: 40rem;
}
.mode-toggle {
display: inline-flex;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
overflow: hidden;
}
.mode-toggle button {
padding: 0.375rem 0.875rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
border: none;
border-right: 1px solid hsl(var(--color-border));
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s;
}
.mode-toggle button:last-child {
border-right: none;
}
.mode-toggle button:hover {
background: hsl(var(--color-surface-hover));
}
.mode-toggle button.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 10%));
}
.query-bar {
display: flex;
gap: 0.5rem;
}
.query-input {
flex: 1;
padding: 0.625rem 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font-size: 0.9375rem;
transition: border-color 0.15s;
}
.query-input:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
button.primary {
padding: 0.625rem 1.25rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 10%));
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
button.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.providers {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.providers-row {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.label {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.04em;
}
.providers-meta {
display: flex;
gap: 0.875rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.history-link {
color: hsl(var(--color-primary));
text-decoration: none;
}
.loading {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.error-banner {
padding: 0.625rem 0.875rem;
background: hsl(var(--color-error, 0 84% 60%) / 0.1);
border: 1px solid hsl(var(--color-error, 0 84% 60%) / 0.4);
color: hsl(var(--color-error, 0 84% 40%));
border-radius: 0.375rem;
font-size: 0.875rem;
}
.results-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.75rem;
}
.results-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.results-meta {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.results-meta code {
font-family: ui-monospace, SFMono-Regular, monospace;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 0.875rem;
}
.recent {
border-top: 1px solid hsl(var(--color-border));
padding-top: 1rem;
}
.recent h3 {
margin: 0 0 0.625rem;
font-size: 1rem;
font-weight: 600;
}
.runs-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.run {
display: grid;
grid-template-columns: 5rem 1fr auto auto;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
font-size: 0.8125rem;
}
.run-badge {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
text-transform: uppercase;
text-align: center;
letter-spacing: 0.04em;
}
.badge-search {
background: hsl(200 80% 50% / 0.15);
color: hsl(200 80% 40%);
}
.badge-extract {
background: hsl(270 60% 55% / 0.15);
color: hsl(270 60% 45%);
}
.badge-agent {
background: hsl(30 90% 55% / 0.15);
color: hsl(30 90% 40%);
}
.run-query {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-providers {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
max-width: 16rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-meta {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -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<Record<string, string>> {
const token = await authStore.getValidToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(await authHeader()),
...((init.headers as Record<string, string>) ?? {}),
};
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<ProvidersCatalog> {
return request<ProvidersCatalog>('/api/v1/providers');
}
export function getProvidersHealth(): Promise<ProvidersHealth> {
return request<ProvidersHealth>('/api/v1/providers/health');
}
// ─── Search compare ─────────────────────────────────────────
export function compareSearch(
query: string,
providers: string[],
options: { limit?: number; language?: string; categories?: string[] } = {}
): Promise<SearchCompareResponse> {
return request<SearchCompareResponse>('/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<ExtractCompareResponse> {
return request<ExtractCompareResponse>('/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<ResearchCompareResponse> {
return request<ResearchCompareResponse>('/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 }),
});
}

View file

@ -0,0 +1,338 @@
<!--
CompareColumn — one provider's result in the comparison grid.
Renders the appropriate card content based on category.
-->
<script lang="ts">
import type {
AgentAnswer,
CompareEntry,
ExtractedContent,
ResearchCategory,
SearchHit,
} from '../types';
interface Props {
category: ResearchCategory;
entry: CompareEntry<unknown>;
}
const { category, entry }: Props = $props();
function fmtLatency(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
const searchResults = $derived(
category === 'search'
? ((entry.data as { results: SearchHit[] } | undefined)?.results ?? [])
: []
);
const extractContent = $derived(
category === 'extract'
? ((entry.data as { content: ExtractedContent } | undefined)?.content ?? null)
: null
);
const agentAnswer = $derived(
category === 'agent'
? ((entry.data as { answer: AgentAnswer } | undefined)?.answer ?? null)
: null
);
</script>
<article class="column" class:failed={!entry.success}>
<header class="column-header">
<div class="provider">
<span class="provider-name">{entry.provider}</span>
{#if entry.meta.cacheHit}
<span class="tag cache" title="Cache hit">cached</span>
{/if}
{#if entry.meta.billingMode === 'byo-key'}
<span class="tag byo" title="Your own API key">BYO</span>
{:else if entry.meta.billingMode === 'free'}
<span class="tag free">free</span>
{/if}
</div>
<div class="metrics">
<span class="latency">{fmtLatency(entry.meta.latencyMs)}</span>
{#if entry.meta.costCredits > 0}
<span class="cost">{entry.meta.costCredits}¢</span>
{/if}
</div>
</header>
{#if !entry.success}
<div class="error">
<strong>Fehler:</strong>
<code>{entry.meta.errorCode ?? 'UNKNOWN'}</code>
</div>
{:else if category === 'search'}
{#if searchResults.length === 0}
<p class="empty">Keine Ergebnisse.</p>
{:else}
<ol class="hits">
{#each searchResults as hit, i (hit.url + i)}
<li class="hit">
<a href={hit.url} target="_blank" rel="noreferrer" class="hit-title">
{hit.title || hit.url}
</a>
<div class="hit-url">{hit.url}</div>
{#if hit.snippet}
<p class="hit-snippet">{hit.snippet}</p>
{/if}
<footer class="hit-meta">
{#if hit.publishedAt}<span>{hit.publishedAt.slice(0, 10)}</span>{/if}
{#if hit.score !== undefined}<span>score {hit.score.toFixed(2)}</span>{/if}
</footer>
</li>
{/each}
</ol>
{/if}
{:else if category === 'extract' && extractContent}
<div class="extract">
<h4 class="extract-title">{extractContent.title || extractContent.url}</h4>
{#if extractContent.siteName || extractContent.author}
<div class="extract-meta">
{extractContent.siteName ?? ''}
{extractContent.author ? `· ${extractContent.author}` : ''}
</div>
{/if}
<div class="extract-stats">
<span>{extractContent.wordCount} Wörter</span>
{#if extractContent.publishedAt}
<span>· {extractContent.publishedAt.slice(0, 10)}</span>
{/if}
</div>
{#if extractContent.excerpt}
<p class="extract-excerpt">{extractContent.excerpt}</p>
{/if}
<pre class="extract-body">{extractContent.content.slice(0, 2000)}{extractContent.content
.length > 2000
? '…'
: ''}</pre>
</div>
{:else if category === 'agent' && agentAnswer}
<div class="agent">
<div class="agent-answer">{agentAnswer.answer}</div>
{#if agentAnswer.citations.length > 0}
<details class="citations" open>
<summary>{agentAnswer.citations.length} Quellen</summary>
<ol>
{#each agentAnswer.citations as cit (cit.url)}
<li>
<a href={cit.url} target="_blank" rel="noreferrer">{cit.title || cit.url}</a>
</li>
{/each}
</ol>
</details>
{/if}
{#if agentAnswer.tokenUsage}
<div class="agent-tokens">
{agentAnswer.tokenUsage.input} in / {agentAnswer.tokenUsage.output} out tokens
</div>
{/if}
</div>
{:else}
<p class="empty">Keine Daten.</p>
{/if}
</article>
<style>
.column {
display: flex;
flex-direction: column;
min-width: 0;
padding: 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
}
.column.failed {
border-color: hsl(var(--color-error, 0 84% 60%) / 0.4);
background: hsl(var(--color-error, 0 84% 60%) / 0.04);
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid hsl(var(--color-border));
gap: 0.5rem;
}
.provider {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
}
.provider-name {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tag.cache {
background: hsl(var(--color-muted-foreground) / 0.15);
color: hsl(var(--color-muted-foreground));
}
.tag.byo {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
.tag.free {
background: hsl(142 71% 45% / 0.15);
color: hsl(142 71% 35%);
}
.metrics {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
.cost {
color: hsl(var(--color-foreground));
font-weight: 500;
}
.error {
padding: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-error, 0 84% 40%));
}
.empty {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
padding: 0.5rem 0;
}
.hits {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.hit-title {
font-weight: 500;
color: hsl(var(--color-foreground));
text-decoration: none;
}
.hit-title:hover {
text-decoration: underline;
}
.hit-url {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 0.125rem;
}
.hit-snippet {
font-size: 0.8125rem;
color: hsl(var(--color-foreground) / 0.85);
line-height: 1.45;
margin: 0.375rem 0 0.25rem;
}
.hit-meta {
display: flex;
gap: 0.75rem;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.extract-title {
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0 0 0.25rem;
}
.extract-meta,
.extract-stats {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.extract-excerpt {
font-size: 0.8125rem;
font-style: italic;
color: hsl(var(--color-foreground) / 0.85);
margin: 0.5rem 0;
}
.extract-body {
margin-top: 0.5rem;
padding: 0.5rem;
background: hsl(var(--color-background));
border-radius: 0.25rem;
font-size: 0.75rem;
line-height: 1.5;
color: hsl(var(--color-foreground) / 0.85);
white-space: pre-wrap;
word-break: break-word;
max-height: 18rem;
overflow-y: auto;
font-family: ui-monospace, SFMono-Regular, monospace;
}
.agent-answer {
font-size: 0.875rem;
line-height: 1.55;
color: hsl(var(--color-foreground));
white-space: pre-wrap;
max-height: 28rem;
overflow-y: auto;
}
.citations {
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px dashed hsl(var(--color-border));
font-size: 0.8125rem;
}
.citations summary {
cursor: pointer;
color: hsl(var(--color-muted-foreground));
user-select: none;
}
.citations ol {
margin: 0.375rem 0 0 1.125rem;
padding: 0;
}
.citations a {
color: hsl(var(--color-primary));
text-decoration: none;
}
.citations a:hover {
text-decoration: underline;
}
.agent-tokens {
margin-top: 0.5rem;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,126 @@
<!--
ProviderPicker — multi-select chips for one research category.
Shows key-status (free/ready/needs-key) + pricing per call.
-->
<script lang="ts">
import type { ProviderHealthEntry, ProviderInfo, ResearchCategory } from '../types';
interface Props {
category: ResearchCategory;
providers: ProviderInfo[];
health: ProviderHealthEntry[];
selected: string[];
onToggle: (id: string) => void;
}
const { category, providers, health, selected, onToggle }: Props = $props();
function status(id: string): ProviderHealthEntry['status'] {
return health.find((h) => h.id === id)?.status ?? 'needs-key';
}
function priceOf(p: ProviderInfo): number | undefined {
if (category === 'search') return p.pricing?.search;
if (category === 'extract') return p.pricing?.extract;
return p.pricing?.research;
}
function isReady(id: string): boolean {
const s = status(id);
return s === 'ready' || s === 'free';
}
</script>
<div class="picker">
{#each providers as p (p.id)}
{@const ready = isReady(p.id)}
{@const price = priceOf(p)}
{@const isSelected = selected.includes(p.id)}
<button
type="button"
class="chip"
class:selected={isSelected}
class:disabled={!ready}
disabled={!ready}
onclick={() => onToggle(p.id)}
title={ready ? `${p.id}` : `${p.id} API-Key fehlt`}
>
<span class="chip-name">{p.id}</span>
{#if price !== undefined}
<span class="chip-price">{price === 0 ? 'free' : `${price}¢`}</span>
{/if}
<span class="chip-status status-{status(p.id)}">
{#if status(p.id) === 'ready'}{:else if status(p.id) === 'free'}{:else}{/if}
</span>
</button>
{/each}
</div>
<style>
.picker {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border-radius: 999px;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s,
color 0.15s;
}
.chip:hover:not(.disabled) {
background: hsl(var(--color-surface-hover));
border-color: hsl(var(--color-border-strong));
}
.chip.selected {
background: hsl(var(--color-primary) / 0.15);
border-color: hsl(var(--color-primary) / 0.6);
color: hsl(var(--color-primary));
}
.chip.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.chip-name {
font-family: ui-monospace, SFMono-Regular, monospace;
}
.chip-price {
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
}
.chip-status {
font-size: 0.625rem;
line-height: 1;
}
.chip.selected .chip-price {
color: hsl(var(--color-primary) / 0.7);
}
.status-ready {
color: hsl(142 71% 45%);
}
.status-free {
color: hsl(var(--color-muted-foreground));
}
.status-needs-key {
color: hsl(var(--color-error, 0 84% 60%));
}
</style>

View file

@ -0,0 +1,3 @@
export * from './api';
export * from './types';
export { researchLabStore } from './stores/session.svelte';

View file

@ -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<unknown>[];
} | 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<LabSession>) };
} 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<LabSession>(loadInitial());
let isRunning = $state(false);
let error = $state<string | null>(null);
let catalog = $state<ProvidersCatalog | null>(null);
let health = $state<ProvidersHealth | null>(null);
let recentRuns = $state<RunSummary[]>([]);
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<unknown>[],
},
};
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<unknown>[],
},
};
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<unknown>[],
},
};
persist(session);
this.loadRecentRuns().catch(() => {});
} catch (err) {
error = err instanceof Error ? err.message : 'Agent-Vergleich fehlgeschlagen';
} finally {
isRunning = false;
}
},
};
}
export const researchLabStore = createStore();

View file

@ -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<T> = {
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<string, boolean>;
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;
}

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/research-lab/ListView.svelte';
</script>
<svelte:head>
<title>Research Lab · Mana</title>
</svelte:head>
<ListView />