feat: LLM logs viewer page + log button on Home synthesis list

- Add LlmLogs page with collapsible prompts/response sections, call-type
  colored badges, and duration display
- Wire /llm-logs/:jobId route in App.tsx (lazy-loaded)
- Expose job_id in backend SynthesisListItem and frontend SynthesisListItem
  type; update test fixture accordingly
- Add log-icon link next to delete button on each Home synthesis card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
oabrivard 3 months ago
parent cbe1cd6507
commit f9023cff7e

@ -82,6 +82,7 @@ pub struct SynthesisListItem {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub first_section_title: Option<String>, pub first_section_title: Option<String>,
pub first_section_item_count: usize, pub first_section_item_count: usize,
pub job_id: Option<Uuid>,
} }
impl TryFrom<Synthesis> for SynthesisListItem { impl TryFrom<Synthesis> for SynthesisListItem {
@ -102,6 +103,7 @@ impl TryFrom<Synthesis> for SynthesisListItem {
created_at: s.created_at, created_at: s.created_at,
first_section_title, first_section_title,
first_section_item_count, first_section_item_count,
job_id: s.job_id,
}) })
} }
} }

@ -17,6 +17,7 @@ const Sources = lazy(() => import('~/pages/Sources'));
const GenerateSynthesis = lazy(() => import('~/pages/GenerateSynthesis')); const GenerateSynthesis = lazy(() => import('~/pages/GenerateSynthesis'));
const SynthesisDetail = lazy(() => import('~/pages/SynthesisDetail')); const SynthesisDetail = lazy(() => import('~/pages/SynthesisDetail'));
const ArticleHistory = lazy(() => import('~/pages/ArticleHistory')); const ArticleHistory = lazy(() => import('~/pages/ArticleHistory'));
const LlmLogs = lazy(() => import('~/pages/LlmLogs'));
const AdminProviders = lazy(() => import('~/pages/admin/Providers')); const AdminProviders = lazy(() => import('~/pages/admin/Providers'));
const AdminRateLimits = lazy(() => import('~/pages/admin/RateLimits')); const AdminRateLimits = lazy(() => import('~/pages/admin/RateLimits'));
const AdminUsers = lazy(() => import('~/pages/admin/Users')); const AdminUsers = lazy(() => import('~/pages/admin/Users'));
@ -65,6 +66,7 @@ const App: Component = () => {
<Route path="/generate" component={GenerateSynthesis} /> <Route path="/generate" component={GenerateSynthesis} />
<Route path="/synthesis/:id" component={SynthesisDetail} /> <Route path="/synthesis/:id" component={SynthesisDetail} />
<Route path="/article-history" component={ArticleHistory} /> <Route path="/article-history" component={ArticleHistory} />
<Route path="/llm-logs/:jobId" component={LlmLogs} />
</Route> </Route>
{/* Admin routes with admin layout wrapper */} {/* Admin routes with admin layout wrapper */}

@ -17,6 +17,7 @@ export const MOCK_SYNTHESIS_LIST_ITEM: SynthesisListItem = {
created_at: '2026-03-21T10:00:00Z', created_at: '2026-03-21T10:00:00Z',
first_section_title: 'Annonces majeures', first_section_title: 'Annonces majeures',
first_section_item_count: 3, first_section_item_count: 3,
job_id: 'job-test-1',
}; };
export const MOCK_SYNTHESIS_LIST: SynthesisListItem[] = [ export const MOCK_SYNTHESIS_LIST: SynthesisListItem[] = [

@ -192,6 +192,19 @@ const Home: Component = () => {
<span class="text-sm font-medium text-indigo-600 hover:text-indigo-500"> <span class="text-sm font-medium text-indigo-600 hover:text-indigo-500">
{t('home.readLink')} &rarr; {t('home.readLink')} &rarr;
</span> </span>
<div class="flex items-center gap-1">
{synth.job_id && (
<A
href={`/llm-logs/${synth.job_id}`}
title={t('llmLogs.viewLogs')}
class="p-1 text-gray-400 hover:text-indigo-600"
onClick={(e: MouseEvent) => e.stopPropagation()}
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</A>
)}
<button <button
onClick={(e) => handleDelete(e, synth.id)} onClick={(e) => handleDelete(e, synth.id)}
class={`inline-flex items-center p-1.5 rounded-md transition-colors ${ class={`inline-flex items-center p-1.5 rounded-md transition-colors ${
@ -213,6 +226,7 @@ const Home: Component = () => {
<Trash2 class="h-4 w-4" /> <Trash2 class="h-4 w-4" />
</button> </button>
</div> </div>
</div>
</A> </A>
)} )}
</For> </For>

@ -0,0 +1,144 @@
import { type Component, createSignal, onMount, For, Show } from 'solid-js';
import { useParams, A } from '@solidjs/router';
import { useI18n } from '~/i18n';
import { llmLogsApi } from '~/api/llmLogs';
import type { LlmCallLogEntry } from '~/types';
import LoadingSpinner from '~/components/ui/LoadingSpinner';
const CALL_TYPE_BADGE: Record<string, string> = {
search: 'bg-blue-100 text-blue-800',
classification_phase1: 'bg-purple-100 text-purple-800',
classification_phase2: 'bg-purple-100 text-purple-800',
rewrite: 'bg-green-100 text-green-800',
link_extraction: 'bg-orange-100 text-orange-800',
article_extraction: 'bg-orange-100 text-orange-800',
};
function badgeClass(callType: string): string {
return CALL_TYPE_BADGE[callType] ?? 'bg-gray-100 text-gray-800';
}
function formatDuration(ms: number): string {
return (ms / 1000).toFixed(1) + 's';
}
function prettyResponse(raw: string): string {
try {
const parsed = JSON.parse(raw);
return JSON.stringify(parsed, null, 2);
} catch {
return raw;
}
}
const LlmLogs: Component = () => {
const { t } = useI18n();
const params = useParams<{ jobId: string }>();
const [logs, setLogs] = createSignal<LlmCallLogEntry[]>([]);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
onMount(async () => {
try {
const data = await llmLogsApi.getByJobId(params.jobId);
setLogs(data);
} catch {
setError(t('common.error'));
} finally {
setLoading(false);
}
});
return (
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<A
href="/"
class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800"
>
&larr; {t('llmLogs.back')}
</A>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">{t('llmLogs.title')}</h1>
<Show when={!loading()} fallback={<LoadingSpinner />}>
<Show when={error()}>
<div class="bg-red-50 border border-red-200 rounded-md p-4 text-sm text-red-800">
{error()}
</div>
</Show>
<Show
when={logs().length > 0}
fallback={
<Show when={!error()}>
<p class="text-gray-500 text-sm">{t('llmLogs.empty')}</p>
</Show>
}
>
<div class="space-y-4">
<For each={logs()}>
{(entry) => (
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="flex items-center gap-3 px-5 py-3 border-b border-gray-100 bg-gray-50">
<span
class={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${badgeClass(entry.call_type)}`}
>
{entry.call_type}
</span>
<span class="text-sm font-medium text-gray-700">{entry.model}</span>
<span class="ml-auto text-sm text-gray-500">
{formatDuration(entry.duration_ms)}
</span>
</div>
<div class="divide-y divide-gray-100">
<details class="group">
<summary class="cursor-pointer px-5 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 select-none list-none flex items-center gap-2">
<span class="transition-transform group-open:rotate-90">&#9654;</span>
{t('llmLogs.systemPrompt')}
</summary>
<div class="px-5 pb-4">
<pre class="whitespace-pre-wrap font-mono text-xs text-gray-700 max-h-64 overflow-y-auto bg-gray-50 rounded p-3">
{entry.system_prompt}
</pre>
</div>
</details>
<details class="group">
<summary class="cursor-pointer px-5 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 select-none list-none flex items-center gap-2">
<span class="transition-transform group-open:rotate-90">&#9654;</span>
{t('llmLogs.userPrompt')}
</summary>
<div class="px-5 pb-4">
<pre class="whitespace-pre-wrap font-mono text-xs text-gray-700 max-h-64 overflow-y-auto bg-gray-50 rounded p-3">
{entry.user_prompt}
</pre>
</div>
</details>
<details class="group">
<summary class="cursor-pointer px-5 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 select-none list-none flex items-center gap-2">
<span class="transition-transform group-open:rotate-90">&#9654;</span>
{t('llmLogs.response')}
</summary>
<div class="px-5 pb-4">
<pre class="whitespace-pre-wrap font-mono text-xs text-gray-700 max-h-64 overflow-y-auto bg-gray-50 rounded p-3">
{prettyResponse(entry.response_body)}
</pre>
</div>
</details>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
);
};
export default LlmLogs;

@ -136,6 +136,7 @@ export interface SynthesisListItem {
created_at: string; created_at: string;
first_section_title: string | null; first_section_title: string | null;
first_section_item_count: number; first_section_item_count: number;
job_id: string | null;
} }
export interface GenerateResponse { export interface GenerateResponse {

Loading…
Cancel
Save