feat(CC-058): add AI Context & Sources page — per-request context manifest viewer
This commit is contained in:
parent
3410f3e4f8
commit
e9f83d0980
1 changed files with 178 additions and 0 deletions
178
src/app/dashboard/ai/context/page.tsx
Normal file
178
src/app/dashboard/ai/context/page.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiFetch } from '../../../../lib/api';
|
||||||
|
import { useSession } from '../../../../components/session-provider';
|
||||||
|
|
||||||
|
interface AiRequest {
|
||||||
|
id: string;
|
||||||
|
purpose: string;
|
||||||
|
actionClass: string;
|
||||||
|
model: string;
|
||||||
|
templateVersion: string | null;
|
||||||
|
resultStatus: string;
|
||||||
|
costUsd: string | null;
|
||||||
|
contextManifest?: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CFG: Record<string, { label: string; cls: string }> = {
|
||||||
|
pending: { label: 'Pending', cls: 'bg-amber-500/10 text-amber-700 dark:text-amber-300' },
|
||||||
|
completed: { label: 'Completat', cls: 'bg-signal-ok/10 text-signal-ok' },
|
||||||
|
failed: { label: 'Eșuat', cls: 'bg-signal-danger/10 text-signal-danger' },
|
||||||
|
cancelled: { label: 'Anulat', cls: 'bg-muted text-ink-faint' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AiContextPage() {
|
||||||
|
const { activeTenant } = useSession();
|
||||||
|
const tenantId = activeTenant?.tenantId ?? '';
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: requests = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['ai-requests', tenantId],
|
||||||
|
queryFn: () => apiFetch<AiRequest[]>('/v1/ai-requests?limit=100', { tenantId }),
|
||||||
|
enabled: Boolean(tenantId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: selected } = useQuery({
|
||||||
|
queryKey: ['ai-requests', tenantId, selectedId],
|
||||||
|
queryFn: () => apiFetch<AiRequest>(`/v1/ai-requests/${selectedId}`, { tenantId }),
|
||||||
|
enabled: Boolean(selectedId && tenantId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-0" style={{ height: 'calc(100vh - 7rem)' }}>
|
||||||
|
{/* Sidebar: request list */}
|
||||||
|
<aside className="w-72 shrink-0 border-r border-border/50 flex flex-col overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-border/50 shrink-0">
|
||||||
|
<h1 className="font-semibold text-sm text-ink">Context AI & Surse</h1>
|
||||||
|
<p className="text-xs text-ink-faint mt-0.5">
|
||||||
|
{requests.length} cereri AI înregistrate
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoading && (
|
||||||
|
<p className="p-4 text-xs text-ink-faint text-center">Se încarcă…</p>
|
||||||
|
)}
|
||||||
|
{!isLoading && requests.length === 0 && (
|
||||||
|
<p className="p-4 text-xs text-ink-faint text-center">
|
||||||
|
Nicio cerere AI înregistrată.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{requests.map((req) => {
|
||||||
|
const cfg = STATUS_CFG[req.resultStatus] ?? STATUS_CFG.pending;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={req.id}
|
||||||
|
onClick={() => setSelectedId(req.id)}
|
||||||
|
className={`w-full text-left px-4 py-3 border-b border-border/30 transition-colors ${
|
||||||
|
selectedId === req.id ? 'bg-primary/5' : 'hover:bg-muted/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-xs font-medium text-ink line-clamp-1">{req.purpose}</p>
|
||||||
|
<span className={`shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full ${cfg.cls}`}>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-[10px] text-ink-faint">{req.model.split('/').pop()}</span>
|
||||||
|
{req.costUsd && parseFloat(req.costUsd) > 0 && (
|
||||||
|
<span className="text-[10px] text-ink-faint">
|
||||||
|
${parseFloat(req.costUsd).toFixed(5)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-ink-faint ml-auto">
|
||||||
|
{new Date(req.createdAt).toLocaleTimeString('ro-RO', {
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main: context manifest viewer */}
|
||||||
|
{!selectedId ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<p className="text-3xl">🔍</p>
|
||||||
|
<p className="text-sm text-ink-faint max-w-sm">
|
||||||
|
Selectează o cerere AI din stânga pentru a vedea contextul complet — ce date au fost permise și ce model a primit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !selected ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-ink-faint">Se încarcă contextul…</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<main className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="font-display text-lg font-semibold text-ink">{selected.purpose}</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-ink-faint">
|
||||||
|
<span className="font-mono bg-muted px-2 py-0.5 rounded">{selected.actionClass}</span>
|
||||||
|
<span>{selected.model}</span>
|
||||||
|
{selected.templateVersion && (
|
||||||
|
<span>template v{selected.templateVersion}</span>
|
||||||
|
)}
|
||||||
|
{selected.costUsd && parseFloat(selected.costUsd) > 0 && (
|
||||||
|
<span className="text-ink">${parseFloat(selected.costUsd).toFixed(6)}</span>
|
||||||
|
)}
|
||||||
|
<span>{new Date(selected.createdAt).toLocaleString('ro-RO')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div>
|
||||||
|
<span className={`text-xs font-medium px-2 py-1 rounded-full ${
|
||||||
|
(STATUS_CFG[selected.resultStatus] ?? STATUS_CFG.pending).cls
|
||||||
|
}`}>
|
||||||
|
{(STATUS_CFG[selected.resultStatus] ?? STATUS_CFG.pending).label}
|
||||||
|
</span>
|
||||||
|
{selected.completedAt && (
|
||||||
|
<span className="ml-2 text-xs text-ink-faint">
|
||||||
|
completat la {new Date(selected.completedAt).toLocaleTimeString('ro-RO')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context Manifest */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||||
|
Context Manifest
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-ink-faint">
|
||||||
|
Datele permise pentru această cerere AI — conform politicii de acces a platformei.
|
||||||
|
</p>
|
||||||
|
{selected.contextManifest ? (
|
||||||
|
<pre className="bg-muted/50 border rounded-xl p-4 text-xs font-mono text-ink overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(selected.contextManifest, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="bg-muted/30 border border-dashed rounded-xl p-4 text-center">
|
||||||
|
<p className="text-xs text-ink-faint">
|
||||||
|
Context manifest nu este disponibil pentru această cerere.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hash */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||||
|
Hash Manifest
|
||||||
|
</h3>
|
||||||
|
<p className="font-mono text-xs text-ink-faint bg-muted/30 px-3 py-2 rounded-lg break-all">
|
||||||
|
{(selected as unknown as { contextManifestHash: string }).contextManifestHash ?? '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue