mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(research-lab): tier gate (beta+), 1–5 star ratings, run detail route
- Branding: research-lab registered in @mana/shared-branding with requiredTier: 'beta' + a custom flask-on-purple icon, so guest/public users are filtered out of the workbench picker. - Backend: compare routes now return resultId alongside each CompareEntry so the frontend can wire ratings to the eval_results rows in research.*. - Frontend: click-to-rate stars in CompareColumn (persists via POST /v1/runs/:runId/results/:resultId/rate), recent-run list rows are now buttons that navigate to /research-lab/runs/[id], and the detail route reconstructs CompareEntry shapes from eval_results + reuses CompareColumn for a full read-only view of any past run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bd1e273f60
commit
8f0a74b2e7
10 changed files with 358 additions and 16 deletions
|
|
@ -6,6 +6,7 @@
|
||||||
is a thin orchestrator over the mana-research service.
|
is a thin orchestrator over the mana-research service.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import ProviderPicker from './components/ProviderPicker.svelte';
|
import ProviderPicker from './components/ProviderPicker.svelte';
|
||||||
import CompareColumn from './components/CompareColumn.svelte';
|
import CompareColumn from './components/CompareColumn.svelte';
|
||||||
import { researchLabStore } from './stores/session.svelte';
|
import { researchLabStore } from './stores/session.svelte';
|
||||||
|
|
@ -164,7 +165,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each session.lastRun.entries as entry (entry.provider)}
|
{#each session.lastRun.entries as entry (entry.provider)}
|
||||||
<CompareColumn category={session.lastRun.category} {entry} />
|
<CompareColumn
|
||||||
|
category={session.lastRun.category}
|
||||||
|
{entry}
|
||||||
|
runId={session.lastRun.runId}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -177,16 +182,22 @@
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="runs-list">
|
<ul class="runs-list">
|
||||||
{#each recentRuns.slice(0, 10) as run (run.id)}
|
{#each recentRuns.slice(0, 10) as run (run.id)}
|
||||||
<li class="run">
|
<li>
|
||||||
<span class="run-badge badge-{run.category}">{run.category}</span>
|
<button
|
||||||
<span class="run-query">{run.query}</span>
|
type="button"
|
||||||
<span class="run-providers">
|
class="run run-button"
|
||||||
{run.providersRequested.join(', ')}
|
onclick={() => void goto(`/research-lab/runs/${run.id}`)}
|
||||||
</span>
|
>
|
||||||
<span class="run-meta">
|
<span class="run-badge badge-{run.category}">{run.category}</span>
|
||||||
{run.mode}
|
<span class="run-query">{run.query}</span>
|
||||||
{run.totalCostCredits > 0 ? ` · ${run.totalCostCredits}¢` : ''}
|
<span class="run-providers">
|
||||||
</span>
|
{run.providersRequested.join(', ')}
|
||||||
|
</span>
|
||||||
|
<span class="run-meta">
|
||||||
|
{run.mode}
|
||||||
|
{run.totalCostCredits > 0 ? ` · ${run.totalCostCredits}¢` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -379,6 +390,19 @@
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
background: hsl(var(--color-surface));
|
background: hsl(var(--color-surface));
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.run-button {
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
.run-button:hover {
|
||||||
|
background: hsl(var(--color-surface-hover));
|
||||||
|
border-color: hsl(var(--color-border-strong));
|
||||||
}
|
}
|
||||||
.run-badge {
|
.run-badge {
|
||||||
padding: 0.125rem 0.375rem;
|
padding: 0.125rem 0.375rem;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
Renders the appropriate card content based on category.
|
Renders the appropriate card content based on category.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import * as api from '../api';
|
||||||
import type {
|
import type {
|
||||||
AgentAnswer,
|
AgentAnswer,
|
||||||
CompareEntry,
|
CompareEntry,
|
||||||
|
|
@ -14,9 +15,26 @@
|
||||||
interface Props {
|
interface Props {
|
||||||
category: ResearchCategory;
|
category: ResearchCategory;
|
||||||
entry: CompareEntry<unknown>;
|
entry: CompareEntry<unknown>;
|
||||||
|
runId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { category, entry }: Props = $props();
|
const { category, entry, runId }: Props = $props();
|
||||||
|
|
||||||
|
let rating = $state(entry.userRating ?? 0);
|
||||||
|
let ratingError = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function setRating(value: number) {
|
||||||
|
if (!runId || !entry.resultId) return;
|
||||||
|
ratingError = null;
|
||||||
|
const prev = rating;
|
||||||
|
rating = value;
|
||||||
|
try {
|
||||||
|
await api.rateResult(runId, entry.resultId, value);
|
||||||
|
} catch (err) {
|
||||||
|
rating = prev;
|
||||||
|
ratingError = err instanceof Error ? err.message : 'Bewertung fehlgeschlagen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fmtLatency(ms: number): string {
|
function fmtLatency(ms: number): string {
|
||||||
if (ms < 1000) return `${ms}ms`;
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
|
@ -135,6 +153,29 @@
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty">Keine Daten.</p>
|
<p class="empty">Keine Daten.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if runId && entry.resultId && entry.success}
|
||||||
|
<footer class="rate">
|
||||||
|
<span class="rate-label">Bewertung:</span>
|
||||||
|
<div class="stars" role="radiogroup" aria-label="Bewertung">
|
||||||
|
{#each [1, 2, 3, 4, 5] as n}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={rating === n}
|
||||||
|
aria-label={`${n} Stern${n > 1 ? 'e' : ''}`}
|
||||||
|
class:filled={rating >= n}
|
||||||
|
onclick={() => void setRating(n)}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if ratingError}
|
||||||
|
<span class="rate-error" title={ratingError}>⚠</span>
|
||||||
|
{/if}
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -335,4 +376,41 @@
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
color: hsl(var(--color-muted-foreground));
|
color: hsl(var(--color-muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px dashed hsl(var(--color-border));
|
||||||
|
}
|
||||||
|
.rate-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.stars {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
.stars button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0.125rem 0.1875rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: hsl(var(--color-muted-foreground) / 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.stars button:hover {
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
.stars button.filled {
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
.rate-error {
|
||||||
|
color: hsl(var(--color-error, 0 84% 40%));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ export type CompareEntry<T> = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: T;
|
data?: T;
|
||||||
meta: ProviderMeta;
|
meta: ProviderMeta;
|
||||||
|
resultId?: string;
|
||||||
|
userRating?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface SearchCompareResponse {
|
export interface SearchCompareResponse {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
<!--
|
||||||
|
/research-lab/runs/[id] — read-only detail view of a past comparison run.
|
||||||
|
Reconstructs CompareEntry shapes from eval_results and reuses CompareColumn.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import * as api from '$lib/modules/research-lab/api';
|
||||||
|
import CompareColumn from '$lib/modules/research-lab/components/CompareColumn.svelte';
|
||||||
|
import type { CompareEntry, RunSummary } from '$lib/modules/research-lab/types';
|
||||||
|
|
||||||
|
const runId = $derived($page.params.id);
|
||||||
|
|
||||||
|
let run = $state<RunSummary | null>(null);
|
||||||
|
let entries = $state<CompareEntry<unknown>[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!runId) return;
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
api
|
||||||
|
.getRun(runId)
|
||||||
|
.then((res) => {
|
||||||
|
run = res.run as RunSummary;
|
||||||
|
entries = (
|
||||||
|
res.results as Array<{
|
||||||
|
id: string;
|
||||||
|
providerId: string;
|
||||||
|
success: boolean;
|
||||||
|
latencyMs: number;
|
||||||
|
costCredits: number;
|
||||||
|
billingMode: string;
|
||||||
|
cacheHit: boolean;
|
||||||
|
normalizedResult: unknown;
|
||||||
|
errorCode: string | null;
|
||||||
|
userRating: number | null;
|
||||||
|
}>
|
||||||
|
).map((r) => ({
|
||||||
|
provider: r.providerId,
|
||||||
|
success: r.success,
|
||||||
|
data: r.normalizedResult,
|
||||||
|
resultId: r.id,
|
||||||
|
userRating: r.userRating,
|
||||||
|
meta: {
|
||||||
|
provider: r.providerId,
|
||||||
|
category: (run?.category ?? 'search') as 'search' | 'extract' | 'agent',
|
||||||
|
latencyMs: r.latencyMs,
|
||||||
|
costCredits: r.costCredits,
|
||||||
|
cacheHit: r.cacheHit,
|
||||||
|
billingMode: r.billingMode as 'server-key' | 'byo-key' | 'free' | 'mixed',
|
||||||
|
errorCode: r.errorCode ?? undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
error = err.message;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Research Run · Mana</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<header class="header">
|
||||||
|
<button type="button" class="back" onclick={() => void goto('/research-lab')}>
|
||||||
|
← Zurück zum Lab
|
||||||
|
</button>
|
||||||
|
{#if run}
|
||||||
|
<div class="meta">
|
||||||
|
<span class="badge badge-{run.category}">{run.category}</span>
|
||||||
|
<span class="mode">{run.mode}</span>
|
||||||
|
{#if run.totalCostCredits > 0}
|
||||||
|
<span class="cost">{run.totalCostCredits}¢</span>
|
||||||
|
{/if}
|
||||||
|
<span class="time">{formatDate(run.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="loading">Lade Run …</p>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{:else if run}
|
||||||
|
<h1 class="query">{run.query}</h1>
|
||||||
|
<p class="providers-line">
|
||||||
|
{run.providersRequested.length} Anbieter · Run <code>{run.id.slice(0, 8)}</code>
|
||||||
|
</p>
|
||||||
|
<div class="grid">
|
||||||
|
{#each entries as entry (entry.resultId)}
|
||||||
|
<CompareColumn category={run.category} {entry} {runId} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
background: hsl(var(--color-background));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: hsl(var(--color-surface));
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
background: hsl(var(--color-surface-hover));
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
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%);
|
||||||
|
}
|
||||||
|
.cost {
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.query {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
.providers-line {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
.providers-line code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -116,6 +116,9 @@ export const APP_ICONS = {
|
||||||
'news-research': svgToDataUrl(
|
'news-research': svgToDataUrl(
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="nr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0891b2"/><stop offset="100%" style="stop-color:#22d3ee"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#nr)"/><path d="M30 30a6 6 0 0 0-6 6v6M30 30a6 6 0 0 1 6 6v0M30 30v0" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/><circle cx="30" cy="30" r="3" fill="white"/><circle cx="52" cy="52" r="18" stroke="white" stroke-width="4" fill="none"/><line x1="65" y1="65" x2="78" y2="78" stroke="white" stroke-width="5" stroke-linecap="round"/><line x1="44" y1="50" x2="58" y2="50" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="44" y1="56" x2="54" y2="56" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="nr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0891b2"/><stop offset="100%" style="stop-color:#22d3ee"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#nr)"/><path d="M30 30a6 6 0 0 0-6 6v6M30 30a6 6 0 0 1 6 6v0M30 30v0" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/><circle cx="30" cy="30" r="3" fill="white"/><circle cx="52" cy="52" r="18" stroke="white" stroke-width="4" fill="none"/><line x1="65" y1="65" x2="78" y2="78" stroke="white" stroke-width="5" stroke-linecap="round"/><line x1="44" y1="50" x2="58" y2="50" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="44" y1="56" x2="54" y2="56" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
|
||||||
),
|
),
|
||||||
|
'research-lab': svgToDataUrl(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="rl" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#8b5cf6"/><stop offset="100%" style="stop-color:#a78bfa"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#rl)"/><path d="M42 22h16v14l12 26a10 10 0 0 1-9 14H39a10 10 0 0 1-9-14l12-26V22z" fill="white" fill-opacity="0.2" stroke="white" stroke-width="3" stroke-linejoin="round"/><line x1="38" y1="22" x2="62" y2="22" stroke="white" stroke-width="4" stroke-linecap="round"/><circle cx="42" cy="58" r="2.5" fill="white"/><circle cx="55" cy="64" r="2" fill="white" fill-opacity="0.7"/><circle cx="48" cy="70" r="2.5" fill="white" fill-opacity="0.9"/></svg>`
|
||||||
|
),
|
||||||
guides: svgToDataUrl(
|
guides: svgToDataUrl(
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="gg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0d9488"/><stop offset="100%" style="stop-color:#0f766e"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#gg)"/><rect x="18" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="54" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="46" y="25" width="8" height="50" fill="white" fill-opacity="0.25"/><circle cx="27" cy="40" r="4" fill="white" fill-opacity="0.9"/><rect x="34" y="37" width="11" height="3" rx="1.5" fill="white" fill-opacity="0.6"/><circle cx="27" cy="52" r="4" fill="white" fill-opacity="0.55"/><rect x="34" y="49" width="9" height="3" rx="1.5" fill="white" fill-opacity="0.4"/><circle cx="27" cy="64" r="4" fill="white" fill-opacity="0.3"/><rect x="34" y="61" width="10" height="3" rx="1.5" fill="white" fill-opacity="0.25"/><path d="M60 52l6 7 12-14" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="gg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0d9488"/><stop offset="100%" style="stop-color:#0f766e"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#gg)"/><rect x="18" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="54" y="25" width="28" height="50" rx="3" fill="white" fill-opacity="0.15"/><rect x="46" y="25" width="8" height="50" fill="white" fill-opacity="0.25"/><circle cx="27" cy="40" r="4" fill="white" fill-opacity="0.9"/><rect x="34" y="37" width="11" height="3" rx="1.5" fill="white" fill-opacity="0.6"/><circle cx="27" cy="52" r="4" fill="white" fill-opacity="0.55"/><rect x="34" y="49" width="9" height="3" rx="1.5" fill="white" fill-opacity="0.4"/><circle cx="27" cy="64" r="4" fill="white" fill-opacity="0.3"/><rect x="34" y="61" width="10" height="3" rx="1.5" fill="white" fill-opacity="0.25"/><path d="M60 52l6 7 12-14" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,23 @@ export const MANA_APPS: ManaApp[] = [
|
||||||
status: 'development',
|
status: 'development',
|
||||||
requiredTier: 'guest',
|
requiredTier: 'guest',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'research-lab',
|
||||||
|
name: 'Research Lab',
|
||||||
|
description: {
|
||||||
|
de: 'Web-Research Anbieter Seite-an-Seite vergleichen',
|
||||||
|
en: 'Compare web-research providers side-by-side',
|
||||||
|
},
|
||||||
|
longDescription: {
|
||||||
|
de: 'Schick dieselbe Anfrage parallel an bis zu fünf Anbieter (Brave, Tavily, Exa, Perplexity, Claude, Gemini, OpenAI …) und vergleich Antworten, Latenzen und Kosten in einer Ansicht. Alle Runs werden serverseitig persistiert für spätere Auswertung.',
|
||||||
|
en: 'Send the same query to up to five providers in parallel (Brave, Tavily, Exa, Perplexity, Claude, Gemini, OpenAI …) and compare answers, latency, and cost side-by-side. All runs are persisted server-side for later review.',
|
||||||
|
},
|
||||||
|
icon: APP_ICONS['research-lab'],
|
||||||
|
color: '#8b5cf6',
|
||||||
|
comingSoon: false,
|
||||||
|
status: 'beta',
|
||||||
|
requiredTier: 'beta',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'calc',
|
id: 'calc',
|
||||||
name: 'Calc',
|
name: 'Calc',
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ export interface CompareResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: T;
|
data?: T;
|
||||||
meta: ProviderMeta;
|
meta: ProviderMeta;
|
||||||
|
resultId?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,10 +126,11 @@ export function createExtractRoutes(
|
||||||
);
|
);
|
||||||
|
|
||||||
let totalCost = 0;
|
let totalCost = 0;
|
||||||
|
const resultIds: string[] = [];
|
||||||
for (let i = 0; i < providers.length; i++) {
|
for (let i = 0; i < providers.length; i++) {
|
||||||
const out = settled[i];
|
const out = settled[i];
|
||||||
totalCost += out.meta.costCredits;
|
totalCost += out.meta.costCredits;
|
||||||
await storage.addResult({
|
const row = await storage.addResult({
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
providerId: providers[i].id,
|
providerId: providers[i].id,
|
||||||
success: out.success,
|
success: out.success,
|
||||||
|
|
@ -140,6 +141,7 @@ export function createExtractRoutes(
|
||||||
normalizedResult: out.data ?? null,
|
normalizedResult: out.data ?? null,
|
||||||
errorCode: out.meta.errorCode ?? null,
|
errorCode: out.meta.errorCode ?? null,
|
||||||
});
|
});
|
||||||
|
resultIds.push(row.id);
|
||||||
}
|
}
|
||||||
if (totalCost > 0) await storage.finalizeRunCost(run.id, totalCost);
|
if (totalCost > 0) await storage.finalizeRunCost(run.id, totalCost);
|
||||||
|
|
||||||
|
|
@ -151,6 +153,7 @@ export function createExtractRoutes(
|
||||||
success: settled[i].success,
|
success: settled[i].success,
|
||||||
data: settled[i].data as { content: ExtractedContent } | undefined,
|
data: settled[i].data as { content: ExtractedContent } | undefined,
|
||||||
meta: settled[i].meta,
|
meta: settled[i].meta,
|
||||||
|
resultId: resultIds[i],
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -130,10 +130,11 @@ export function createResearchRoutes(
|
||||||
);
|
);
|
||||||
|
|
||||||
let totalCost = 0;
|
let totalCost = 0;
|
||||||
|
const resultIds: string[] = [];
|
||||||
for (let i = 0; i < providers.length; i++) {
|
for (let i = 0; i < providers.length; i++) {
|
||||||
const out = settled[i];
|
const out = settled[i];
|
||||||
totalCost += out.meta.costCredits;
|
totalCost += out.meta.costCredits;
|
||||||
await storage.addResult({
|
const row = await storage.addResult({
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
providerId: providers[i].id,
|
providerId: providers[i].id,
|
||||||
success: out.success,
|
success: out.success,
|
||||||
|
|
@ -144,6 +145,7 @@ export function createResearchRoutes(
|
||||||
normalizedResult: out.data ?? null,
|
normalizedResult: out.data ?? null,
|
||||||
errorCode: out.meta.errorCode ?? null,
|
errorCode: out.meta.errorCode ?? null,
|
||||||
});
|
});
|
||||||
|
resultIds.push(row.id);
|
||||||
}
|
}
|
||||||
if (totalCost > 0) await storage.finalizeRunCost(run.id, totalCost);
|
if (totalCost > 0) await storage.finalizeRunCost(run.id, totalCost);
|
||||||
|
|
||||||
|
|
@ -155,6 +157,7 @@ export function createResearchRoutes(
|
||||||
success: settled[i].success,
|
success: settled[i].success,
|
||||||
data: settled[i].data as { answer: AgentAnswer } | undefined,
|
data: settled[i].data as { answer: AgentAnswer } | undefined,
|
||||||
meta: settled[i].meta,
|
meta: settled[i].meta,
|
||||||
|
resultId: resultIds[i],
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ export function createSearchRoutes(
|
||||||
deps
|
deps
|
||||||
);
|
);
|
||||||
|
|
||||||
await storage.addResult({
|
const resultRow = await storage.addResult({
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
providerId,
|
providerId,
|
||||||
success: out.success,
|
success: out.success,
|
||||||
|
|
@ -110,6 +110,7 @@ export function createSearchRoutes(
|
||||||
success: out.success,
|
success: out.success,
|
||||||
data: out.data,
|
data: out.data,
|
||||||
meta: out.meta,
|
meta: out.meta,
|
||||||
|
resultId: resultRow.id,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.post('/compare', async (c) => {
|
.post('/compare', async (c) => {
|
||||||
|
|
@ -147,10 +148,11 @@ export function createSearchRoutes(
|
||||||
);
|
);
|
||||||
|
|
||||||
let totalCost = 0;
|
let totalCost = 0;
|
||||||
|
const resultIds: string[] = [];
|
||||||
for (let i = 0; i < providers.length; i++) {
|
for (let i = 0; i < providers.length; i++) {
|
||||||
const out = settled[i];
|
const out = settled[i];
|
||||||
totalCost += out.meta.costCredits;
|
totalCost += out.meta.costCredits;
|
||||||
await storage.addResult({
|
const row = await storage.addResult({
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
providerId: providers[i].id,
|
providerId: providers[i].id,
|
||||||
success: out.success,
|
success: out.success,
|
||||||
|
|
@ -161,6 +163,7 @@ export function createSearchRoutes(
|
||||||
normalizedResult: out.data ?? null,
|
normalizedResult: out.data ?? null,
|
||||||
errorCode: out.meta.errorCode ?? null,
|
errorCode: out.meta.errorCode ?? null,
|
||||||
});
|
});
|
||||||
|
resultIds.push(row.id);
|
||||||
}
|
}
|
||||||
if (totalCost > 0) await storage.finalizeRunCost(run.id, totalCost);
|
if (totalCost > 0) await storage.finalizeRunCost(run.id, totalCost);
|
||||||
|
|
||||||
|
|
@ -172,6 +175,7 @@ export function createSearchRoutes(
|
||||||
success: settled[i].success,
|
success: settled[i].success,
|
||||||
data: settled[i].data as { results: SearchHit[] } | undefined,
|
data: settled[i].data as { results: SearchHit[] } | undefined,
|
||||||
meta: settled[i].meta,
|
meta: settled[i].meta,
|
||||||
|
resultId: resultIds[i],
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue