forked from bai/curriculum-project-hub
4e7b158ff9
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy /admin/org/:slug/* bookmarks redirect, login lands on /admin.
239 lines
7.7 KiB
Svelte
239 lines
7.7 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import { api, type SessionDetail, type SessionRunRow, type UsageFactRow } from '$lib/api';
|
|
import { session } from '$lib/session';
|
|
import { resolveOrg } from '$lib/org';
|
|
import {
|
|
fmtCost,
|
|
fmtDate,
|
|
fmtNum,
|
|
fmtQuantity,
|
|
fmtTokens,
|
|
runStatusLabel,
|
|
usageKindLabel,
|
|
} from '$lib/format';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
import Icon from '$lib/components/Icon.svelte';
|
|
|
|
const org = $derived(resolveOrg($session.me, page.url.search));
|
|
const slug = $derived(org?.slug ?? '');
|
|
const sessionId = $derived(page.params.sessionId ?? '');
|
|
|
|
let detail = $state<SessionDetail | null>(null);
|
|
let loading = $state(true);
|
|
let error = $state<string | null>(null);
|
|
let expandedRunId = $state<string | null>(null);
|
|
|
|
async function load() {
|
|
if (!slug || !sessionId) return;
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
detail = await api.session(slug, sessionId);
|
|
if (detail.runs.length > 0) {
|
|
expandedRunId = detail.runs[0]!.id;
|
|
}
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function statusClass(status: string): string {
|
|
const key = status.toUpperCase();
|
|
if (key === 'COMPLETED') return 'saas-badge-success';
|
|
if (key === 'FAILED' || key === 'TIMED_OUT' || key === 'CANCELED') return 'saas-badge-error';
|
|
return 'saas-badge-primary';
|
|
}
|
|
|
|
function factMeter(f: UsageFactRow): string {
|
|
if (f.unit) return fmtQuantity(f.quantity, f.unit);
|
|
if (f.inputTokens !== null || f.outputTokens !== null) {
|
|
return fmtTokens(f.inputTokens, f.outputTokens);
|
|
}
|
|
return '—';
|
|
}
|
|
|
|
function factSource(f: UsageFactRow): string {
|
|
if (f.capabilityId) return f.capabilityId;
|
|
if (f.model) return f.model;
|
|
return '—';
|
|
}
|
|
|
|
function runCostHint(run: SessionRunRow): string {
|
|
const factCost = run.usageFacts.reduce<number | null>((acc, f) => {
|
|
if (f.costUsd === null) return acc;
|
|
return (acc ?? 0) + f.costUsd;
|
|
}, null);
|
|
const cache = run.costUsd;
|
|
if (factCost !== null && cache !== null && Math.abs(factCost - cache) > 1e-9) {
|
|
return `运行缓存 ${fmtCost(cache)};事实合计 ${fmtCost(factCost)}(缓存可能未含外部能力)`;
|
|
}
|
|
if (factCost !== null) return `事实合计 ${fmtCost(factCost)}`;
|
|
if (cache !== null) return `运行缓存 ${fmtCost(cache)}`;
|
|
return '成本未知';
|
|
}
|
|
|
|
function toggleRun(id: string) {
|
|
expandedRunId = expandedRunId === id ? null : id;
|
|
}
|
|
|
|
$effect(() => {
|
|
if (slug && sessionId) void load();
|
|
});
|
|
</script>
|
|
|
|
{#if loading}
|
|
<LoadingState />
|
|
{:else if error}
|
|
<ErrorBanner message={error} onretry={load} />
|
|
{:else if detail}
|
|
<div class="mb-2">
|
|
<a
|
|
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-700"
|
|
href={`/admin/projects/${detail.project.id}`}
|
|
>
|
|
<Icon name="arrow-left" class="h-4 w-4" />
|
|
返回项目 {detail.project.name}
|
|
</a>
|
|
</div>
|
|
|
|
<PageHeader
|
|
title={detail.title?.trim() || '未命名会话'}
|
|
description={`${detail.provider} · ${detail.roleId} · ${detail.model}`}
|
|
/>
|
|
|
|
<div class="saas-card-pad mb-6">
|
|
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
|
<div>
|
|
<dt class="text-surface-600">会话 ID</dt>
|
|
<dd class="mt-0.5 break-all font-mono text-xs">{detail.id}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-surface-600">项目</dt>
|
|
<dd class="mt-0.5">
|
|
<a class="text-primary-700 hover:underline" href={`/admin/projects/${detail.project.id}`}>
|
|
{detail.project.name}
|
|
</a>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-surface-600">创建 / 更新</dt>
|
|
<dd class="mt-0.5 text-surface-800">{fmtDate(detail.createdAt)} · {fmtDate(detail.updatedAt)}</dd>
|
|
</div>
|
|
{#if detail.archivedAt}
|
|
<div>
|
|
<dt class="text-surface-600">已归档</dt>
|
|
<dd class="mt-0.5">{fmtDate(detail.archivedAt)}</dd>
|
|
</div>
|
|
{/if}
|
|
<div>
|
|
<dt class="text-surface-600">运行数</dt>
|
|
<dd class="mt-0.5 tabular-nums">{fmtNum(detail.runs.length)}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<h2 class="saas-section-title">运行与计费事实</h2>
|
|
<p class="saas-muted">每条 UsageFact 是一次可计费消费;外部能力与模型完成分开列出。</p>
|
|
</div>
|
|
|
|
{#if detail.runs.length === 0}
|
|
<div class="saas-card">
|
|
<EmptyState title="尚无运行" description="此会话还没有 Agent run。" />
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-3">
|
|
{#each detail.runs as run (run.id)}
|
|
{@const open = expandedRunId === run.id}
|
|
<div class="saas-card overflow-hidden">
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-start justify-between gap-3 px-5 py-4 text-left hover:bg-surface-50"
|
|
onclick={() => toggleRun(run.id)}
|
|
>
|
|
<div class="min-w-0 space-y-1">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class={statusClass(run.status)}>{runStatusLabel(run.status)}</span>
|
|
<span class="font-mono text-xs text-surface-700">{run.provider} / {run.model}</span>
|
|
<span class="font-mono text-[11px] text-surface-500">{run.id}</span>
|
|
</div>
|
|
<div class="text-xs text-surface-700">
|
|
{fmtDate(run.startedAt)}
|
|
{#if run.finishedAt}
|
|
→ {fmtDate(run.finishedAt)}
|
|
{/if}
|
|
</div>
|
|
<div class="text-xs text-surface-600">{runCostHint(run)}</div>
|
|
{#if run.error}
|
|
<div class="text-xs text-error-700">{run.error}</div>
|
|
{/if}
|
|
</div>
|
|
<div class="shrink-0 text-right text-sm">
|
|
<div class="tabular-nums text-surface-800">{fmtTokens(run.inputTokens, run.outputTokens)}</div>
|
|
<div class="tabular-nums font-medium">{fmtCost(run.costUsd)}</div>
|
|
<div class="mt-1 text-[11px] text-surface-600">{open ? '收起事实' : `${run.usageFacts.length} 条事实`}</div>
|
|
</div>
|
|
</button>
|
|
|
|
{#if open}
|
|
<div class="border-t border-surface-200">
|
|
{#if run.usageFacts.length === 0}
|
|
<div class="px-5 py-4">
|
|
<p class="text-sm text-surface-600">此 run 没有 UsageFact(可能尚未结束或未记费)。</p>
|
|
</div>
|
|
{:else}
|
|
<div class="overflow-x-auto">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>时间</th>
|
|
<th>类型</th>
|
|
<th>供应方</th>
|
|
<th>模型 / 能力</th>
|
|
<th>计量</th>
|
|
<th>成本</th>
|
|
<th>来源</th>
|
|
<th>关联 ID</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each run.usageFacts as fact}
|
|
<tr>
|
|
<td class="whitespace-nowrap text-xs text-surface-700">{fmtDate(fact.occurredAt)}</td>
|
|
<td>
|
|
<span
|
|
class={fact.kind === 'external_capability'
|
|
? 'saas-badge-primary'
|
|
: 'saas-badge-success'}
|
|
>
|
|
{usageKindLabel(fact.kind)}
|
|
</span>
|
|
</td>
|
|
<td class="font-mono text-xs">{fact.provider}</td>
|
|
<td class="font-mono text-xs">{factSource(fact)}</td>
|
|
<td class="tabular-nums text-xs">{factMeter(fact)}</td>
|
|
<td class="tabular-nums">{fmtCost(fact.costUsd)}</td>
|
|
<td class="font-mono text-[11px] text-surface-600">{fact.costSource}</td>
|
|
<td class="max-w-[10rem] truncate font-mono text-[11px] text-surface-500" title={fact.correlationId ?? ''}>
|
|
{fact.correlationId ?? '—'}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/if}
|