mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 02:21:27 +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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import ProviderPicker from './components/ProviderPicker.svelte';
|
||||
import CompareColumn from './components/CompareColumn.svelte';
|
||||
import { researchLabStore } from './stores/session.svelte';
|
||||
|
|
@ -164,7 +165,11 @@
|
|||
</div>
|
||||
<div class="grid">
|
||||
{#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}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -177,16 +182,22 @@
|
|||
{: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>
|
||||
<button
|
||||
type="button"
|
||||
class="run run-button"
|
||||
onclick={() => void goto(`/research-lab/runs/${run.id}`)}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
|
@ -379,6 +390,19 @@
|
|||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-surface));
|
||||
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 {
|
||||
padding: 0.125rem 0.375rem;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
Renders the appropriate card content based on category.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import * as api from '../api';
|
||||
import type {
|
||||
AgentAnswer,
|
||||
CompareEntry,
|
||||
|
|
@ -14,9 +15,26 @@
|
|||
interface Props {
|
||||
category: ResearchCategory;
|
||||
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 {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
|
|
@ -135,6 +153,29 @@
|
|||
{:else}
|
||||
<p class="empty">Keine Daten.</p>
|
||||
{/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>
|
||||
|
||||
<style>
|
||||
|
|
@ -335,4 +376,41 @@
|
|||
font-size: 0.6875rem;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ export type CompareEntry<T> = {
|
|||
success: boolean;
|
||||
data?: T;
|
||||
meta: ProviderMeta;
|
||||
resultId?: string;
|
||||
userRating?: number | null;
|
||||
};
|
||||
|
||||
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue