Local fork: hardening + ops improvements (timeout knob, demotion, /livez, drain)

This commit captures both the prior accumulated work-in-progress
(framework migration web/→svelte/, postgres storage, conversation
viewer, dashboard auth, OpenAPI spec, integration tests) AND today's
operational improvements layered on top. History wasn't checkpointed
incrementally; happy to split it via interactive rebase if a reviewer
wants smaller commits.

Today's changes (in addition to the older WIP):

1. Configurable upstream response-header timeout
   - ANTHROPIC_RESPONSE_HEADER_TIMEOUT env (default 300s)
   - Replaces hardcoded 300s in provider/anthropic.go that was firing
     on opus + 1M-context + extended thinking non-streaming requests
   - Files: internal/config/config.go, internal/provider/anthropic.go

2. Structured forward-error diagnostic logging
   - When a forward to Anthropic fails, log a single key=value line
     with request_id, model, stream, body_bytes, has_thinking,
     anthropic_beta, query, elapsed, ctx_err — alongside the existing
     human-readable error line for back-compat
   - Files: internal/handler/handlers.go (logForwardFailure)

3. Full SSE protocol passthrough + Flusher fix
   - handler/handlers.go: forward all SSE lines verbatim (event:, id:,
     retry:, : comments, blank-line terminators), not only data:.
     Previous code produced malformed SSE for strict parsers.
   - middleware/logging.go: explicit Flush() method on responseWriter.
     Embedding http.ResponseWriter (interface) does not auto-promote
     Flush(), so every w.(http.Flusher) check in the streaming
     handler was returning ok=false and SSE writes buffered in net/http
     until the body closed.

4. Non-streaming → streaming demotion (feature-flagged)
   - ANTHROPIC_DEMOTE_NONSTREAMING env (default false)
   - When enabled and the routed provider is anthropic, force stream=true
     upstream for clients that asked for stream=false. Receive SSE,
     accumulate via accumulateSSEToMessage (handles text, tool_use with
     partial_json reassembly, thinking, signature, citations_delta,
     usage merge), and synthesize a single non-streaming JSON response.
   - Eliminates the ResponseHeaderTimeout class of failure entirely.
   - Body rewrite uses json.Decoder + UseNumber() to preserve integer
     precision in unknown nested fields (tool inputs from prior turns).
   - Files: internal/config/config.go, internal/handler/handlers.go,
     cmd/proxy/main.go, cmd/proxy/main_test.go

5. Live operational state: /livez gauge + graceful drain
   - New internal/runtime package: atomic in-flight counter + draining flag
   - New middleware/inflight.go: increments runtime gauge, applied to
     /v1/* subrouter so Messages, ChatCompletions, and ProxyPassthrough
     are all counted
   - /v1/* moved to a gorilla/mux subrouter so the InFlight middleware
     applies surgically; /health, /livez, /openapi.* remain on parent
     router (unauthenticated, uncounted)
   - Health handler returns 503 draining when runtime.IsDraining() is
     true, so Traefik stops routing to a slot before drain begins
   - New /livez handler returns {status, in_flight, draining, timestamp}
   - SIGTERM handler in main.go: SetDraining(true), poll for in_flight==0
     with 32-min ceiling and 1s tick (logs every 10s), then srv.Shutdown
   - Auth bypass list extended with /livez
   - Files: internal/runtime/runtime.go (new),
     internal/middleware/inflight.go (new),
     internal/middleware/auth.go,
     internal/handler/handlers.go (Health, Livez, runtime import),
     cmd/proxy/main.go (subrouter, drain loop)

6. OpenAPI spec updates
   - Document Health 503 response and new DrainingResponse schema
   - Add /livez path with LivezResponse schema
   - Files: internal/handler/openapi.go

Verified: go build ./... clean, go test ./... all pass, go vet clean.
Three rounds of codex peer review across changes 1-5; all feedback
addressed (citations_delta, json.Number precision, drain-loop logging
via lastLog timestamp, PathPrefix tightened to "/v1/").
This commit is contained in:
sid 2026-05-02 15:15:58 -06:00
parent b9da198e1f
commit 8e550b9785
152 changed files with 19227 additions and 19463 deletions

2851
svelte/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
svelte/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "claude-code-proxy-svelte",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "svelte-kit sync && vite dev",
"build": "svelte-kit sync && vite build",
"preview": "svelte-kit sync && vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.21.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/node": "^25.5.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"svelte": "^5.33.0",
"svelte-check": "^4.2.1",
"tailwindcss": "^3.4.4",
"typescript": "^5.1.6",
"vite": "^6.0.0"
},
"dependencies": {
"chart.js": "^4.5.1",
"lucide-svelte": "^0.522.0"
},
"type": "module",
"engines": {
"node": ">=20.0.0"
}
}

6
svelte/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

333
svelte/src/app.css Normal file
View file

@ -0,0 +1,333 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
* {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
background-color: #fafafa;
color: #111;
}
html.dark body {
background-color: #0f172a;
color: #e2e8f0;
}
@layer utilities {
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
.code-block {
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
font-size: 0.75rem;
line-height: 1.5;
background: #f5f5f5;
border: 1px solid #e5e5e5;
border-radius: 4px;
}
html.dark .code-block {
background: #1e293b;
border-color: #334155;
}
.scrollbar-custom {
scrollbar-width: thin;
scrollbar-color: #ddd #f5f5f5;
}
html.dark .scrollbar-custom {
scrollbar-color: #475569 #1e293b;
}
.scrollbar-custom::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-custom::-webkit-scrollbar-track {
background: #f5f5f5;
border-radius: 3px;
}
html.dark .scrollbar-custom::-webkit-scrollbar-track {
background: #1e293b;
}
.scrollbar-custom::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 3px;
}
html.dark .scrollbar-custom::-webkit-scrollbar-thumb {
background: #475569;
}
.scrollbar-custom::-webkit-scrollbar-thumb:hover {
background: #ccc;
}
html.dark .scrollbar-custom::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
/* --- Nav active/inactive states (immune to dark mode CSS overrides) --- */
.nav-active {
background-color: #111827; /* gray-900 */
color: #fff;
}
html.dark .nav-active {
background-color: #e5e7eb; /* gray-200 */
color: #111827;
}
.nav-inactive {
color: #4b5563; /* gray-600 */
}
.nav-inactive:hover {
background-color: #f3f4f6; /* gray-100 */
}
html.dark .nav-inactive {
color: #9ca3af; /* gray-400 */
}
html.dark .nav-inactive:hover {
background-color: #1f2937; /* gray-800 */
}
/* ============================================================================
Dark Mode Overrides
These override Tailwind utility classes when html.dark is present.
This approach avoids adding dark: variants to every component.
============================================================================ */
/* --- Background colors --- */
html.dark .bg-white { background-color: #1e293b; }
html.dark .bg-gray-50 { background-color: #0f172a; }
html.dark .bg-gray-100 { background-color: #1e293b; }
html.dark .bg-gray-200 { background-color: #334155; }
/* Accent backgrounds - subtle dark variants */
html.dark .bg-blue-50 { background-color: rgba(59, 130, 246, 0.1); }
html.dark .bg-blue-100 { background-color: rgba(59, 130, 246, 0.2); }
html.dark .bg-indigo-50 { background-color: rgba(99, 102, 241, 0.1); }
html.dark .bg-indigo-100 { background-color: rgba(99, 102, 241, 0.15); }
html.dark .bg-purple-50 { background-color: rgba(147, 51, 234, 0.1); }
html.dark .bg-purple-100 { background-color: rgba(147, 51, 234, 0.15); }
html.dark .bg-green-50 { background-color: rgba(34, 197, 94, 0.1); }
html.dark .bg-green-100 { background-color: rgba(34, 197, 94, 0.15); }
html.dark .bg-emerald-50 { background-color: rgba(16, 185, 129, 0.1); }
html.dark .bg-emerald-100 { background-color: rgba(16, 185, 129, 0.15); }
html.dark .bg-red-50 { background-color: rgba(239, 68, 68, 0.1); }
html.dark .bg-red-100 { background-color: rgba(239, 68, 68, 0.15); }
html.dark .bg-yellow-50 { background-color: rgba(234, 179, 8, 0.1); }
html.dark .bg-yellow-100 { background-color: rgba(234, 179, 8, 0.15); }
html.dark .bg-amber-50 { background-color: rgba(245, 158, 11, 0.1); }
html.dark .bg-amber-100 { background-color: rgba(245, 158, 11, 0.15); }
html.dark .bg-orange-100 { background-color: rgba(249, 115, 22, 0.15); }
html.dark .bg-teal-50 { background-color: rgba(20, 184, 166, 0.1); }
html.dark .bg-slate-50 { background-color: rgba(100, 116, 139, 0.1); }
/* Gradient backgrounds */
html.dark .bg-gradient-to-r.from-blue-50 { --tw-gradient-from: rgba(59, 130, 246, 0.1); }
html.dark .bg-gradient-to-r.to-indigo-50 { --tw-gradient-to: rgba(99, 102, 241, 0.1); }
html.dark .bg-gradient-to-r.from-indigo-50 { --tw-gradient-from: rgba(99, 102, 241, 0.1); }
html.dark .bg-gradient-to-r.to-purple-50 { --tw-gradient-to: rgba(147, 51, 234, 0.1); }
html.dark .bg-gradient-to-r.from-purple-50 { --tw-gradient-from: rgba(147, 51, 234, 0.1); }
html.dark .bg-gradient-to-r.to-blue-50 { --tw-gradient-to: rgba(59, 130, 246, 0.1); }
html.dark .bg-gradient-to-r.from-emerald-50 { --tw-gradient-from: rgba(16, 185, 129, 0.1); }
html.dark .bg-gradient-to-r.to-green-50 { --tw-gradient-to: rgba(34, 197, 94, 0.1); }
html.dark .bg-gradient-to-r.from-red-50 { --tw-gradient-from: rgba(239, 68, 68, 0.1); }
html.dark .bg-gradient-to-r.to-pink-50 { --tw-gradient-to: rgba(236, 72, 153, 0.1); }
/* --- Text colors --- */
html.dark .text-gray-900 { color: #f1f5f9; }
html.dark .text-gray-800 { color: #e2e8f0; }
html.dark .text-gray-700 { color: #cbd5e1; }
html.dark .text-gray-600 { color: #94a3b8; }
html.dark .text-gray-500 { color: #64748b; }
html.dark .text-gray-400 { color: #64748b; }
/* Accent text colors - brighten for dark backgrounds */
html.dark .text-blue-700 { color: #93c5fd; }
html.dark .text-blue-600 { color: #60a5fa; }
html.dark .text-indigo-900 { color: #c7d2fe; }
html.dark .text-indigo-700 { color: #a5b4fc; }
html.dark .text-indigo-600 { color: #818cf8; }
html.dark .text-purple-900 { color: #e9d5ff; }
html.dark .text-purple-700 { color: #c084fc; }
html.dark .text-purple-600 { color: #a855f7; }
html.dark .text-green-900 { color: #bbf7d0; }
html.dark .text-green-800 { color: #86efac; }
html.dark .text-green-700 { color: #4ade80; }
html.dark .text-green-600 { color: #22c55e; }
html.dark .text-emerald-900 { color: #a7f3d0; }
html.dark .text-emerald-700 { color: #34d399; }
html.dark .text-emerald-600 { color: #10b981; }
html.dark .text-red-900 { color: #fecaca; }
html.dark .text-red-800 { color: #fca5a5; }
html.dark .text-red-700 { color: #f87171; }
html.dark .text-red-600 { color: #ef4444; }
html.dark .text-yellow-900 { color: #fef08a; }
html.dark .text-yellow-700 { color: #facc15; }
html.dark .text-yellow-600 { color: #eab308; }
html.dark .text-amber-900 { color: #fde68a; }
html.dark .text-amber-800 { color: #fbbf24; }
html.dark .text-amber-700 { color: #f59e0b; }
html.dark .text-amber-600 { color: #d97706; }
html.dark .text-teal-600 { color: #2dd4bf; }
html.dark .text-blue-900 { color: #bfdbfe; }
html.dark .text-slate-800 { color: #e2e8f0; }
html.dark .text-slate-700 { color: #cbd5e1; }
html.dark .text-slate-600 { color: #94a3b8; }
/* --- Border colors --- */
html.dark .border-gray-200 { border-color: #334155; }
html.dark .border-gray-100 { border-color: #1e293b; }
html.dark .border-gray-300 { border-color: #475569; }
html.dark .border-blue-200 { border-color: rgba(59, 130, 246, 0.3); }
html.dark .border-indigo-200 { border-color: rgba(99, 102, 241, 0.3); }
html.dark .border-purple-200 { border-color: rgba(147, 51, 234, 0.3); }
html.dark .border-green-200 { border-color: rgba(34, 197, 94, 0.3); }
html.dark .border-green-100 { border-color: rgba(34, 197, 94, 0.15); }
html.dark .border-emerald-200 { border-color: rgba(16, 185, 129, 0.3); }
html.dark .border-emerald-100 { border-color: rgba(16, 185, 129, 0.15); }
html.dark .border-red-200 { border-color: rgba(239, 68, 68, 0.3); }
html.dark .border-yellow-200 { border-color: rgba(234, 179, 8, 0.3); }
html.dark .border-amber-200 { border-color: rgba(245, 158, 11, 0.3); }
html.dark .border-orange-200 { border-color: rgba(249, 115, 22, 0.3); }
html.dark .border-slate-200 { border-color: #334155; }
html.dark .border-slate-100 { border-color: #1e293b; }
html.dark .border-blue-100 { border-color: rgba(59, 130, 246, 0.15); }
html.dark .border-gray-700 { border-color: #475569; }
/* Left-accent borders */
html.dark .border-l-blue-500 { border-left-color: #3b82f6; }
html.dark .border-l-gray-500 { border-left-color: #64748b; }
html.dark .border-l-amber-500 { border-left-color: #f59e0b; }
html.dark .border-l-emerald-500 { border-left-color: #10b981; }
html.dark .border-l-red-500 { border-left-color: #ef4444; }
html.dark .border-l-blue-500 { border-left-color: #3b82f6; }
/* --- Divide colors --- */
html.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: #334155; }
html.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #1e293b; }
html.dark .divide-slate-100 > :not([hidden]) ~ :not([hidden]) { border-color: #1e293b; }
/* --- Hover overrides --- */
html.dark .hover\:bg-gray-50:hover { background-color: #1e293b; }
html.dark .hover\:bg-gray-100:hover { background-color: #334155; }
html.dark .hover\:bg-gray-200:hover { background-color: #475569; }
html.dark .hover\:bg-blue-50:hover { background-color: rgba(59, 130, 246, 0.15); }
html.dark .hover\:bg-red-50:hover { background-color: rgba(239, 68, 68, 0.15); }
html.dark .hover\:bg-purple-50:hover { background-color: rgba(147, 51, 234, 0.15); }
html.dark .hover\:bg-white\/50:hover { background-color: rgba(30, 41, 59, 0.5); }
html.dark .hover\:bg-slate-100\/50:hover { background-color: rgba(30, 41, 59, 0.5); }
html.dark .hover\:bg-amber-100\/50:hover { background-color: rgba(245, 158, 11, 0.15); }
html.dark .hover\:bg-emerald-100\/50:hover { background-color: rgba(16, 185, 129, 0.15); }
html.dark .hover\:text-gray-600:hover { color: #94a3b8; }
html.dark .hover\:text-gray-700:hover { color: #cbd5e1; }
html.dark .hover\:text-gray-800:hover { color: #e2e8f0; }
html.dark .hover\:text-gray-900:hover { color: #f1f5f9; }
html.dark .hover\:text-blue-800:hover { color: #93c5fd; }
html.dark .hover\:text-indigo-800:hover { color: #a5b4fc; }
html.dark .hover\:text-amber-800:hover { color: #fcd34d; }
html.dark .hover\:text-emerald-800:hover { color: #6ee7b7; }
html.dark .hover\:text-red-800:hover { color: #fca5a5; }
html.dark .hover\:shadow-md:hover { --tw-shadow-color: rgba(0, 0, 0, 0.3); }
/* --- Ring overrides --- */
html.dark .ring-blue-200\/30 { --tw-ring-color: rgba(59, 130, 246, 0.15); }
/* --- Focus overrides --- */
html.dark .focus\:ring-blue-500:focus { --tw-ring-color: #3b82f6; }
html.dark .focus\:ring-emerald-500:focus { --tw-ring-color: #10b981; }
/* --- Shadow adjustments --- */
html.dark .shadow-sm { --tw-shadow-color: rgba(0, 0, 0, 0.2); }
html.dark .shadow-md { --tw-shadow-color: rgba(0, 0, 0, 0.3); }
html.dark .shadow-2xl { --tw-shadow-color: rgba(0, 0, 0, 0.5); }
/* --- Modal backdrops --- */
html.dark .bg-gray-900\/70 { background-color: rgba(0, 0, 0, 0.8); }
/* --- Chat page specific --- */
html.dark .bg-blue-500 { background-color: #2563eb; }
html.dark .bg-gray-200.text-gray-900 { background-color: #334155; color: #e2e8f0; }
/* Chat bubble colors */
html.dark .bg-blue-100 { background-color: rgba(59, 130, 246, 0.2); }
/* --- Prose / formatted content --- */
html.dark .prose { color: #cbd5e1; }
html.dark .prose pre { background-color: #0f172a; border-color: #334155; }
/* --- Form elements --- */
html.dark input,
html.dark select,
html.dark textarea {
background-color: #1e293b;
border-color: #475569;
color: #e2e8f0;
}
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #64748b;
}
html.dark option {
background-color: #1e293b;
color: #e2e8f0;
}
/* --- Code blocks (already dark, keep them) --- */
html.dark .bg-gray-900 { background-color: #0f172a; }
html.dark .bg-gray-800 { background-color: #1e293b; }
/* --- Misc --- */
html.dark .bg-black { background-color: #000; }
html.dark .brightness-95 { filter: brightness(1.05); }
/* Hover on table rows */
html.dark .hover\:bg-gray-800\/50:hover { background-color: rgba(30, 41, 59, 0.5); }
html.dark .hover\:bg-gray-100:hover { background-color: #334155; }
html.dark .hover\:bg-gray-50:hover { background-color: #1e293b; }
/* Sticky headers */
html.dark .bg-gray-50.sticky { background-color: #0f172a; }
/* Active/selected states for model filter buttons */
html.dark .bg-transparent { background-color: transparent; }

20
svelte/src/app.html Normal file
View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<script>
(function() {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,16 @@
import type { Handle } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { dashboardUnauthorizedResponse, isDashboardAuthorized } from '../../shared/server/dashboard_auth';
export const handle: Handle = async ({ event, resolve }) => {
const password = env.DASHBOARD_PASSWORD || '';
if (password) {
const authHeader = event.request.headers.get('Authorization') || '';
if (!isDashboardAuthorized(authHeader, password)) {
return dashboardUnauthorizedResponse();
}
}
return resolve(event);
};

225
svelte/src/lib/api.ts Normal file
View file

@ -0,0 +1,225 @@
import type {
Request, ConversationSummary, Conversation,
RequestSummary, DashboardStats, HourlyStatsResponse,
ModelStatsResponse, UsageStats, ProxySettings,
PromptGrade, RequestMessage, SystemMessage
} from './types';
const API_BASE = '/api';
type RequestListItem = Omit<Request, 'id'> & { id?: string; requestId?: string };
type RequestsResponse = { requests?: RequestListItem[]; total?: number };
type ConversationsResponse = { conversations: ConversationSummary[]; hasMore?: boolean; total?: number };
type RequestSummaryResponse = { requests: RequestSummary[]; total: number };
type RequestDetailResponse = { request: Request; fullId: string };
type LatestRequestDateResponse = { latestDate: string | null };
type OrganizationsResponse = { organizations?: string[] };
export async function fetchRequests(
page: number = 1,
limit: number = 50,
model: string = 'all'
): Promise<{ requests: Request[]; hasMore: boolean; total: number }> {
const url = new URL(`${API_BASE}/requests`, window.location.origin);
url.searchParams.append('page', page.toString());
url.searchParams.append('limit', limit.toString());
if (model !== 'all') {
url.searchParams.append('model', model);
}
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data: RequestsResponse = await response.json();
const fetchedRequests = data.requests || [];
const mappedRequests: Request[] = fetchedRequests.map((req) => ({
...req,
id: req.id || req.requestId || req.timestamp
}));
return { requests: mappedRequests, hasMore: mappedRequests.length === limit, total: data.total || 0 };
}
export async function fetchConversations(
page: number = 1,
limit: number = 50,
model: string = 'all'
): Promise<{ conversations: ConversationSummary[]; hasMore: boolean; total?: number }> {
const url = new URL(`${API_BASE}/conversations`, window.location.origin);
url.searchParams.append('page', page.toString());
url.searchParams.append('limit', limit.toString());
if (model !== 'all') {
url.searchParams.append('model', model);
}
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data: ConversationsResponse = await response.json();
return {
conversations: data.conversations,
hasMore: typeof data.hasMore === 'boolean' ? data.hasMore : data.conversations.length === limit,
total: typeof data.total === 'number' ? data.total : undefined
};
}
export async function fetchConversationDetail(
conversationId: string,
projectPath: string
): Promise<Conversation> {
const response = await fetch(
`${API_BASE}/conversations/${encodeURIComponent(conversationId)}?project=${encodeURIComponent(projectPath)}`
);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
export async function deleteRequests(): Promise<void> {
const response = await fetch(`${API_BASE}/requests`, { method: 'DELETE' });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
}
export async function gradePrompt(
messages: RequestMessage[],
systemMessages: SystemMessage[],
requestId: string
): Promise<PromptGrade> {
const response = await fetch(`${API_BASE}/grade-prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, systemMessages, requestId })
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
// New summary endpoint — lightweight request list for fast rendering
export async function fetchRequestsSummary(
model: string = 'all',
startTime?: string,
endTime?: string,
offset: number = 0,
limit: number = 0
): Promise<{ requests: RequestSummary[]; total: number }> {
const url = new URL(`${API_BASE}/requests/summary`, window.location.origin);
if (model !== 'all') url.searchParams.append('model', model);
if (startTime) url.searchParams.append('start', startTime);
if (endTime) url.searchParams.append('end', endTime);
if (offset > 0) url.searchParams.append('offset', offset.toString());
if (limit > 0) url.searchParams.append('limit', limit.toString());
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json() as Promise<RequestSummaryResponse>;
}
// Fetch a single request by ID (full detail)
export async function fetchRequestById(
requestId: string
): Promise<{ request: Request; fullId: string }> {
const response = await fetch(`${API_BASE}/requests/${encodeURIComponent(requestId)}`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json() as Promise<RequestDetailResponse>;
}
// Get the latest request date
export async function fetchLatestRequestDate(): Promise<{ latestDate: string | null }> {
const response = await fetch(`${API_BASE}/requests/latest-date`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json() as Promise<LatestRequestDateResponse>;
}
// Usage stats with date range and model filter
export async function fetchUsageStats(
startDate?: string,
endDate?: string,
model?: string,
org?: string
): Promise<UsageStats> {
const url = new URL(`${API_BASE}/stats`, window.location.origin);
if (startDate) url.searchParams.append('start_date', startDate);
if (endDate) url.searchParams.append('end_date', endDate);
if (model && model !== 'all') url.searchParams.append('model', model);
if (org) url.searchParams.append('org', org);
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
// Dashboard stats — daily token usage
export async function fetchDashboardStats(
startTime?: string,
endTime?: string,
org?: string
): Promise<DashboardStats> {
const url = new URL(`${API_BASE}/stats/dashboard`, window.location.origin);
if (startTime) url.searchParams.append('start', startTime);
if (endTime) url.searchParams.append('end', endTime);
if (org) url.searchParams.append('org', org);
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
// Hourly stats for a date range
export async function fetchHourlyStats(
startTime: string,
endTime: string,
bucketMinutes: number = 60,
org?: string
): Promise<HourlyStatsResponse> {
const url = new URL(`${API_BASE}/stats/hourly`, window.location.origin);
url.searchParams.append('start', startTime);
url.searchParams.append('end', endTime);
if (bucketMinutes !== 60) {
url.searchParams.append('bucket', bucketMinutes.toString());
}
if (org) url.searchParams.append('org', org);
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
// Settings
export async function fetchSettings(): Promise<ProxySettings> {
const response = await fetch(`${API_BASE}/settings`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
export async function saveSettings(settings: ProxySettings): Promise<ProxySettings> {
const response = await fetch(`${API_BASE}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
// Model breakdown for a date range
export async function fetchModelStats(
startTime: string,
endTime: string,
org?: string
): Promise<ModelStatsResponse> {
const url = new URL(`${API_BASE}/stats/models`, window.location.origin);
url.searchParams.append('start', startTime);
url.searchParams.append('end', endTime);
if (org) url.searchParams.append('org', org);
const response = await fetch(url.toString());
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
// Get distinct organization IDs
export async function fetchOrganizations(): Promise<string[]> {
const response = await fetch(`${API_BASE}/stats/organizations`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data: OrganizationsResponse = await response.json();
return data.organizations || [];
}

View file

@ -0,0 +1,32 @@
import { env } from '$env/dynamic/private';
import { error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import {
backendAuthHeaders as buildSharedBackendAuthHeaders,
isDashboardAuthorized
} from '../../../shared/server/dashboard_auth';
function getDashboardPassword(): string {
return env.DASHBOARD_PASSWORD || '';
}
/**
* Check basic auth on the incoming request.
* Throws a 401 error if auth is required and invalid.
*/
export function requireDashboardAuth(event: RequestEvent): void {
const password = getDashboardPassword();
const authHeader = event.request.headers.get('Authorization') || '';
if (isDashboardAuthorized(authHeader, password)) {
return;
}
throw error(401, 'Unauthorized');
}
/**
* Returns headers to forward basic auth to the Go backend.
*/
export function backendAuthHeaders(): Record<string, string> {
return buildSharedBackendAuthHeaders(getDashboardPassword());
}

View file

@ -0,0 +1,10 @@
import { env } from '$env/dynamic/private';
import { buildBackendURL as buildSharedBackendURL, resolveBackendOrigin } from '../../../shared/frontend/backend';
export function getBackendOrigin(): string {
return resolveBackendOrigin(env);
}
export function buildBackendURL(path: string, searchParams?: URLSearchParams): string {
return buildSharedBackendURL(getBackendOrigin(), path, searchParams);
}

View file

@ -0,0 +1,101 @@
/**
* Date/time formatting utilities for the chat view.
*
* These provide iMessage-style and relative timestamp formatting used by the
* chat page and its sub-components. The shared `$lib/formatters` module has
* generic helpers (`formatTimestamp`, `formatTime`, etc.) but nothing that
* matches the specific "Today 3:42 PM" / "Yesterday 10:15 AM" / "Mon 2:30 PM"
* style needed here, so we keep these separate.
*/
import type { RequestMessage } from '$lib/types';
// ---------------------------------------------------------------------------
// Core helpers
// ---------------------------------------------------------------------------
/** Calendar-day difference (accounts for date boundaries, not raw 24h). */
export function calendarDayDiff(date: Date, now: Date): number {
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const n = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return Math.round((n.getTime() - d.getTime()) / 86400000);
}
// ---------------------------------------------------------------------------
// iMessage-style timestamp
// ---------------------------------------------------------------------------
export function formatImessageTimestamp(ts: string): string {
const date = new Date(ts);
const now = new Date();
const diffDays = calendarDayDiff(date, now);
const timeStr = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (diffDays === 0) return `Today ${timeStr}`;
if (diffDays === 1) return `Yesterday ${timeStr}`;
if (diffDays < 7) {
const day = date.toLocaleDateString(undefined, { weekday: 'long' });
return `${day} ${timeStr}`;
}
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: diffDays > 365 ? 'numeric' : undefined }) + ' ' + timeStr;
}
// ---------------------------------------------------------------------------
// Relative timestamp ("2 min ago", "Yesterday 3:42 PM")
// ---------------------------------------------------------------------------
export function formatRelativeTimestamp(ts: string): string {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDays = calendarDayDiff(date, now);
const timeStr = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin} min ago`;
if (diffDays === 0 && diffHr < 2) return `${diffHr} hr ago`;
if (diffDays === 0) return `Today ${timeStr}`;
if (diffDays === 1) return `Yesterday ${timeStr}`;
if (diffDays < 7) {
return date.toLocaleDateString(undefined, { weekday: 'short' }) + ' ' + timeStr;
}
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + timeStr;
}
// ---------------------------------------------------------------------------
// Turn/timestamp helpers for message arrays
// ---------------------------------------------------------------------------
/** Whether a timestamp separator should be shown before message at index idx. */
export function shouldShowTimestamp(messages: RequestMessage[], idx: number): boolean {
if (idx === 0) return true;
const prev = messages[idx - 1];
const curr = messages[idx];
if (prev.role === 'assistant' && curr.role === 'user') return true;
return false;
}
/** Estimate a 1-based turn number for a message based on user->assistant pairs. */
export function getTurnNumber(messages: RequestMessage[], idx: number): number {
let turn = 0;
for (let i = 0; i <= idx; i++) {
if (messages[i].role === 'user' && (i === 0 || messages[i - 1]?.role === 'assistant')) {
turn++;
}
}
return turn;
}
/** Total turns in the message array. */
export function getTotalTurns(messages: RequestMessage[]): number {
return getTurnNumber(messages, messages.length - 1);
}
/** Format a short time string (HH:MM:SS). */
export function formatTimeFull(ts: string): string {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}

View file

@ -0,0 +1,242 @@
/**
* Shared utilities for the chat view components.
*
* Contains type-guards, content-splitting logic, and label/icon/color helpers
* that are used across ChatMessage, ChatToolBlock, and the main chat page.
*/
import { parseXmlBlocks, getXmlTagStyle } from '$lib/formatters';
import type {
MessageContent as RenderableMessageContent,
RequestMessage,
ToolUseContentBlock,
ToolResultContentBlock,
TextContentBlock,
ContentBlock,
GenericContentBlock
} from '$lib/types';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface XmlOutsideBlock {
type: 'xml-block';
tag: string;
content: string;
raw: string;
}
export type OutsideItem = Exclude<ContentBlock, TextContentBlock> | XmlOutsideBlock | GenericContentBlock;
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function isTextBlock(value: unknown): value is TextContentBlock {
return isRecord(value) && value.type === 'text' && typeof value.text === 'string';
}
export function isToolUseBlock(value: unknown): value is ToolUseContentBlock {
return isRecord(value) && value.type === 'tool_use';
}
export function isToolResultBlock(value: unknown): value is ToolResultContentBlock {
return isRecord(value) && value.type === 'tool_result';
}
export function isXmlOutsideBlock(value: unknown): value is XmlOutsideBlock {
return isRecord(value) && value.type === 'xml-block' && typeof value.tag === 'string' && typeof value.content === 'string';
}
// ---------------------------------------------------------------------------
// Content splitting
// ---------------------------------------------------------------------------
/**
* Split content: plain text goes in chat bubble, everything else outside.
* Parses XML-like blocks out of text so they render as outside items.
*/
export function splitContent(content: RenderableMessageContent | undefined): { chat: string | null; outside: OutsideItem[] } {
if (typeof content === 'string') {
return splitTextContent(content);
}
if (!Array.isArray(content)) {
if (isTextBlock(content)) {
return splitTextContent(content.text || '');
}
return content ? { chat: null, outside: [content as OutsideItem] } : { chat: null, outside: [] };
}
let chatParts: string[] = [];
const outside: OutsideItem[] = [];
for (const item of content) {
if (isTextBlock(item)) {
const result = splitTextContent(item.text);
outside.push(...result.outside);
if (result.chat) chatParts.push(result.chat);
} else {
outside.push(item as OutsideItem);
}
}
const chatText = chatParts.join('\n\n').trim();
return { chat: chatText || null, outside };
}
function splitTextContent(text: string): { chat: string | null; outside: OutsideItem[] } {
const outside: OutsideItem[] = [];
const segments = parseXmlBlocks(text);
if (segments.length === 0 || (segments.length === 1 && segments[0].type === 'text')) {
return { chat: text.trim() || null, outside: [] };
}
const textParts: string[] = [];
for (const seg of segments) {
if (seg.type === 'text') {
const trimmed = seg.content.trim();
if (trimmed) textParts.push(trimmed);
} else if (seg.type === 'xml' && seg.tag) {
outside.push({
type: 'xml-block',
tag: seg.tag,
content: seg.innerContent || '',
raw: seg.content
});
}
}
return { chat: textParts.join('\n\n').trim() || null, outside };
}
// ---------------------------------------------------------------------------
// Labels / icons / colors for outside blocks
// ---------------------------------------------------------------------------
export function outsideLabel(item: OutsideItem): string {
if (isXmlOutsideBlock(item)) return item.tag || 'block';
if (isToolUseBlock(item)) return typeof item.name === 'string' ? item.name : 'tool call';
if (isToolResultBlock(item)) return item.is_error ? 'error' : 'result';
if (item.type === 'thinking') return `thought (${(typeof item.thinking === 'string' ? item.thinking.length : 0).toLocaleString()} chars)`;
return typeof item.type === 'string' ? item.type : 'block';
}
export function outsideIconName(item: OutsideItem): string {
if (isXmlOutsideBlock(item) && item.tag) {
const style = getXmlTagStyle(item.tag);
return style.icon;
}
switch (item.type) {
case 'thinking': return 'brain';
case 'tool_use': return 'terminal';
case 'tool_result': return item.is_error ? 'alert-circle' : 'check-circle';
default: return 'code';
}
}
export function outsideColor(item: OutsideItem): string {
if (isXmlOutsideBlock(item) && item.tag) {
const style = getXmlTagStyle(item.tag);
return style.text.replace('text-', 'text-').replace('-800', '-400') + ' hover:' + style.text;
}
switch (item.type) {
case 'thinking': return 'text-amber-400 hover:text-amber-600';
case 'tool_use': return 'text-indigo-400 hover:text-indigo-600';
case 'tool_result': return item.is_error ? 'text-red-400 hover:text-red-600' : 'text-emerald-400 hover:text-emerald-600';
default: return 'text-gray-400 hover:text-gray-600';
}
}
// ---------------------------------------------------------------------------
// Model helpers
// ---------------------------------------------------------------------------
export function getModelLabel(model?: string): { label: string; color: string } {
if (!model) return { label: 'API', color: 'text-gray-600' };
if (model.includes('opus')) return { label: 'Opus', color: 'text-purple-600' };
if (model.includes('sonnet')) return { label: 'Sonnet', color: 'text-indigo-600' };
if (model.includes('haiku')) return { label: 'Haiku', color: 'text-teal-600' };
if (model.includes('gpt')) return { label: 'GPT', color: 'text-green-600' };
return { label: model.split('-')[0], color: 'text-gray-700' };
}
export function getStatusBadge(code?: number): string {
if (!code) return 'bg-gray-100 text-gray-500';
if (code >= 200 && code < 300) return 'bg-green-100 text-green-700';
if (code >= 400) return 'bg-red-100 text-red-700';
return 'bg-yellow-100 text-yellow-700';
}
// ---------------------------------------------------------------------------
// Tool helpers
// ---------------------------------------------------------------------------
export function buildToolResultMap(messages: RequestMessage[]): Map<string, ToolResultContentBlock> {
const map = new Map<string, ToolResultContentBlock>();
if (!messages) return map;
for (const msg of messages) {
const content = msg.content;
if (!Array.isArray(content)) continue;
for (const item of content) {
if (isToolResultBlock(item) && item.tool_use_id) {
map.set(item.tool_use_id, item);
}
}
}
return map;
}
export function toolInputSummary(item: ToolUseContentBlock): string {
if (!item?.input) return '';
const input = item.input;
switch (item.name) {
case 'Read': return input.file_path || '';
case 'Edit': return input.file_path || '';
case 'Write': return input.file_path || '';
case 'Bash': return (input.command || '').slice(0, 80);
case 'Grep': return input.pattern ? `"${input.pattern}"` : '';
case 'Glob': return input.pattern || '';
case 'Agent': return (input.prompt || '').slice(0, 80);
default: {
const first = Object.values(input).find((v) => typeof v === 'string');
return typeof first === 'string' ? first.slice(0, 80) : '';
}
}
}
export function getToolResultContent(result: ToolResultContentBlock | undefined): string {
if (!result) return '';
let content = result.content ?? result.text ?? '';
if (Array.isArray(content)) {
return content
.map((c) => {
if (typeof c === 'string') return c;
if (isRecord(c) && typeof c.text === 'string') return c.text;
if (isRecord(c) && typeof c.content === 'string') return c.content;
return '';
})
.filter(Boolean)
.join('\n');
}
if (isRecord(content)) {
if (typeof content.text === 'string') return content.text;
if (typeof content.content === 'string') return content.content;
return JSON.stringify(content, null, 2);
}
return String(content);
}
export function toolResultBrief(result: ToolResultContentBlock | undefined): { text: string; isError: boolean; chars: number } {
const isError = result?.is_error || false;
const content = getToolResultContent(result);
const chars = content.length;
if (isError) return { text: `error (${chars.toLocaleString()} chars)`, isError, chars };
return { text: `${chars.toLocaleString()} chars`, isError, chars };
}

View file

@ -0,0 +1,84 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables, type ChartData, type ChartOptions, type ChartType } from 'chart.js';
Chart.register(...registerables);
interface Props {
type: ChartType;
data: ChartData;
options?: ChartOptions;
height?: string;
/** Enable horizontal scrolling when data is too dense to fit comfortably */
scrollable?: boolean;
}
let { type, data, options = {}, height = '300px', scrollable = false }: Props = $props();
// Only scroll when there are enough points that they'd be unreadably dense.
// Target ~6px per point as the threshold where scrolling kicks in,
// and give ~8px per point when it does.
let containerEl: HTMLDivElement;
let scrollMinWidth = $derived.by(() => {
if (!scrollable || !data?.labels?.length) return '100%';
const count = data.labels.length;
// Assume ~500px available width; only scroll if points would be <6px apart
if (count <= 80) return '100%';
return `${count * 8}px`;
});
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
function isDark() {
return typeof document !== 'undefined' && document.documentElement.classList.contains('dark');
}
function createChart() {
if (chart) chart.destroy();
if (!canvas) return;
const dark = isDark();
const darkOverrides = dark ? {
scales: {
...options?.scales,
x: { ...options?.scales?.x, ticks: { ...options?.scales?.x?.ticks, color: '#94a3b8' }, grid: { color: 'rgba(51, 65, 85, 0.5)' } },
y: { ...options?.scales?.y, ticks: { ...options?.scales?.y?.ticks, color: '#94a3b8' }, grid: { color: 'rgba(51, 65, 85, 0.5)' } },
},
plugins: {
...options?.plugins,
legend: { ...options?.plugins?.legend, labels: { ...options?.plugins?.legend?.labels, color: '#94a3b8' } },
},
} : {};
chart = new Chart(canvas, {
type,
data,
options: {
responsive: true,
maintainAspectRatio: false,
...options,
...darkOverrides,
},
});
}
onMount(() => {
createChart();
});
$effect(() => {
// Re-create chart when data or type changes
void data;
void type;
if (canvas) createChart();
});
onDestroy(() => {
if (chart) chart.destroy();
});
</script>
<div style="height: {height}; overflow-x: auto; overflow-y: hidden; position: relative;">
<div style="min-width: {scrollMinWidth}; height: 100%; position: relative;">
<canvas bind:this={canvas}></canvas>
</div>
</div>

View file

@ -0,0 +1,104 @@
<script lang="ts">
import {
User, Bot, Settings, Eye, EyeOff, Code
} from 'lucide-svelte';
import RichText from '$lib/components/RichText.svelte';
import ChatToolBlock from '$lib/components/ChatToolBlock.svelte';
import ChatOutsideBlock from '$lib/components/ChatOutsideBlock.svelte';
import { formatJSON } from '$lib/formatters';
import {
splitContent,
isToolResultBlock,
isToolUseBlock,
type OutsideItem
} from '$lib/chat-utils';
import type { RequestMessage, ToolResultContentBlock } from '$lib/types';
interface Props {
/** The message to render. */
message: RequestMessage;
/** Index of this message in the messages array. */
idx: number;
/** The full messages array (needed to check next message for "Delivered"). */
messages: RequestMessage[];
/** Map of tool_use_id -> tool_result for pairing. */
toolResultMap: Map<string, ToolResultContentBlock>;
/** Expanded raw sections state. */
expandedRawSections: Record<string, boolean>;
/** Toggle a raw section. */
onToggleRaw: (key: string) => void;
}
let { message, idx, messages, toolResultMap, expandedRawSections, onToggleRaw }: Props = $props();
let isUser = $derived(message.role === 'user');
let isAssistant = $derived(message.role === 'assistant');
let split = $derived(splitContent(message.content));
</script>
<!-- Outside-bubble blocks: everything that isn't plain text -->
{#each split.outside as item, oi}
{#if isToolResultBlock(item) && item.tool_use_id && toolResultMap.has(item.tool_use_id)}
<!-- Paired tool result -- already rendered with its tool_use -->
{:else if isToolUseBlock(item) && item.id && toolResultMap.has(item.id)}
<ChatToolBlock
{item}
result={toolResultMap.get(item.id)}
rawKey={`out-${idx}-${oi}`}
{expandedRawSections}
{onToggleRaw}
/>
{:else}
<ChatOutsideBlock
{item}
rawKey={`out-${idx}-${oi}`}
{expandedRawSections}
{onToggleRaw}
/>
{/if}
{/each}
<!-- Chat bubble -- only plain text content -->
{#if split.chat}
<div class="flex {isUser ? 'justify-end' : 'justify-start'}">
<div class="max-w-[85%]">
<div class="flex items-start space-x-2 {isUser ? 'flex-row-reverse space-x-reverse' : ''}">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center {isUser ? 'bg-blue-100' : isAssistant ? 'bg-gray-100' : 'bg-amber-100'}">
{#if isUser}
<User class="w-4 h-4 text-blue-600" />
{:else if isAssistant}
<Bot class="w-4 h-4 text-gray-600" />
{:else}
<Settings class="w-4 h-4 text-amber-600" />
{/if}
</div>
<div class="min-w-0">
<div class="{isUser ? 'bg-blue-500 text-white' : isAssistant ? 'bg-gray-200 text-gray-900' : 'bg-amber-50 border border-amber-200 text-gray-900'} rounded-2xl {isUser ? 'rounded-tr-sm' : 'rounded-tl-sm'} px-4 py-3 shadow-sm">
<RichText text={split.chat} variant={isUser ? 'inverse' : 'default'} />
</div>
{#if isUser && idx < messages.length - 1 && messages[idx + 1]?.role === 'assistant'}
<div class="flex justify-end mt-0.5 px-1">
<span class="text-[10px] text-gray-400">Delivered</span>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Raw view -- rendered outside the bubble layout so it doesn't resize it -->
<div class="px-10 {isUser ? 'text-right' : ''}">
<button
onclick={() => onToggleRaw(`msg-${idx}`)}
class="text-[10px] text-gray-400 hover:text-gray-600 inline-flex items-center space-x-1 transition-colors mt-0.5"
>
{#if expandedRawSections[`msg-${idx}`]}
<EyeOff class="w-3 h-3" /><span>Hide raw</span>
{:else}
<Eye class="w-3 h-3" /><span>View raw</span>
{/if}
</button>
{#if expandedRawSections[`msg-${idx}`]}
<pre class="mt-1 text-[10px] bg-gray-900 text-gray-300 rounded-lg p-3 overflow-x-auto max-h-48 font-mono text-left">{formatJSON(message, 0)}</pre>
{/if}
</div>
{/if}

View file

@ -0,0 +1,68 @@
<script lang="ts">
import {
Brain, Terminal, Code, Settings, Wrench, FileText,
CheckCircle, AlertCircle, EyeOff
} from 'lucide-svelte';
import { formatJSON } from '$lib/formatters';
import MessageContent from '$lib/components/MessageContent.svelte';
import RichText from '$lib/components/RichText.svelte';
import {
outsideLabel,
outsideIconName,
outsideColor,
isXmlOutsideBlock,
type OutsideItem
} from '$lib/chat-utils';
interface Props {
item: OutsideItem;
rawKey: string;
expandedRawSections: Record<string, boolean>;
onToggleRaw: (key: string) => void;
}
let { item, rawKey, expandedRawSections, onToggleRaw }: Props = $props();
const iconMap: Record<string, typeof Code> = {
brain: Brain,
terminal: Terminal,
code: Code,
settings: Settings,
wrench: Wrench,
database: FileText,
'check-circle': CheckCircle,
'alert-circle': AlertCircle,
};
let ItemIcon = $derived(iconMap[outsideIconName(item)] || Code);
</script>
<div class="px-10">
<details class="group cursor-pointer">
<summary class="inline-flex items-center space-x-1.5 text-[10px] {outsideColor(item)} transition-colors select-none">
<ItemIcon class="w-3 h-3" />
<span>{outsideLabel(item)}</span>
</summary>
<div class="mt-1 bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
<div class="p-3 text-xs text-gray-700 leading-relaxed max-h-64 overflow-y-auto">
{#if isXmlOutsideBlock(item)}
<RichText text={item.content} size="xs" />
{:else}
<MessageContent content={item} />
{/if}
</div>
<div class="border-t border-gray-200 px-3 py-1 bg-gray-100/50">
<button onclick={() => onToggleRaw(rawKey)} class="text-[10px] text-gray-400 hover:text-gray-600 inline-flex items-center space-x-1">
{#if expandedRawSections[rawKey]}
<EyeOff class="w-3 h-3" /><span>Hide raw</span>
{:else}
<Code class="w-3 h-3" /><span>View raw</span>
{/if}
</button>
</div>
{#if expandedRawSections[rawKey]}
<pre class="p-3 text-[10px] bg-gray-900 text-gray-300 font-mono overflow-x-auto max-h-48 whitespace-pre-wrap">{isXmlOutsideBlock(item) ? item.raw : formatJSON(item, 0)}</pre>
{/if}
</div>
</details>
</div>

View file

@ -0,0 +1,444 @@
<script lang="ts">
import {
Brain, Sparkles, Zap, Hash,
Activity, ChevronRight, ChevronDown,
Layers, Settings, Wrench, Code, Eye, EyeOff
} from 'lucide-svelte';
import RichText from '$lib/components/RichText.svelte';
import MessageContent from '$lib/components/MessageContent.svelte';
import ChatMessage from '$lib/components/ChatMessage.svelte';
import ChatOutsideBlock from '$lib/components/ChatOutsideBlock.svelte';
import { formatJSON } from '$lib/formatters';
import { formatImessageTimestamp, shouldShowTimestamp, getTurnNumber, getTotalTurns } from '$lib/chat-formatters';
import {
getModelLabel, getStatusBadge, buildToolResultMap, splitContent
} from '$lib/chat-utils';
import type { Request, RequestSummary } from '$lib/types';
interface Props {
/** The fully-loaded request object. */
request: Request;
/** The currently selected request id. */
selectedId: string;
/** Turn ids for the selected conversation group (oldest first). */
selectedGroupTurnIds: string[];
/** Map of requestId -> summary for timestamps. */
summaryMap: Map<string, RequestSummary>;
/** Expanded raw sections state. */
expandedRawSections: Record<string, boolean>;
/** Toggle a raw section by key. */
onToggleRaw: (key: string) => void;
/** Navigate to a different request. */
onSelectRequest: (id: string) => void;
}
let {
request: req,
selectedId,
selectedGroupTurnIds,
summaryMap,
expandedRawSections,
onToggleRaw,
onSelectRequest,
}: Props = $props();
function getModelIcon(model?: string) {
if (!model) return Hash;
if (model.includes('opus')) return Brain;
if (model.includes('sonnet')) return Sparkles;
if (model.includes('haiku')) return Zap;
return Hash;
}
function getRequestIdForTurn(turnNum: number): string | null {
if (!selectedGroupTurnIds.length) return null;
return selectedGroupTurnIds[turnNum - 1] || null;
}
function getTimestampForTurn(turnNum: number): string | null {
const reqId = getRequestIdForTurn(turnNum);
if (!reqId) return null;
return summaryMap.get(reqId)?.timestamp || null;
}
let model = $derived(req.routedModel || req.body?.model || req.originalModel || '');
let ml = $derived(getModelLabel(model));
let ModelIcon = $derived(getModelIcon(model));
</script>
<div class="max-w-4xl mx-auto px-6 py-6 space-y-4">
<!-- Request metadata bar -->
<div class="bg-white border border-gray-200 rounded-xl p-4 shadow-sm">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
<ModelIcon class="w-4 h-4 text-white" />
</div>
<div>
<div class="flex items-center space-x-2">
<span class="font-semibold {ml.color}">{ml.label}</span>
{#if model}
<span class="text-xs text-gray-400 font-mono">{model}</span>
{/if}
</div>
<div class="text-xs text-gray-500 flex items-center space-x-3">
<span>{new Date(req.timestamp).toLocaleString()}</span>
{#if req.response?.responseTime}
<span class="flex items-center space-x-1">
<Activity class="w-3 h-3" />
<span>{(req.response.responseTime / 1000).toFixed(2)}s</span>
</span>
{/if}
{#if req.response?.statusCode}
<span class="px-1.5 py-0.5 rounded text-[10px] font-medium {getStatusBadge(req.response.statusCode)}">{req.response.statusCode}</span>
{/if}
</div>
</div>
</div>
<div class="text-right text-xs text-gray-500">
{#if req.response?.body?.usage}
{@const u = req.response.body.usage}
<div class="space-y-0.5">
<div><span class="text-gray-400">In:</span> <span class="font-medium text-gray-700">{(u.input_tokens || 0).toLocaleString()}</span></div>
<div><span class="text-gray-400">Out:</span> <span class="font-medium text-gray-700">{(u.output_tokens || 0).toLocaleString()}</span></div>
{#if u.cache_read_input_tokens}
<div><span class="text-gray-400">Cache:</span> <span class="font-medium text-green-600">{u.cache_read_input_tokens.toLocaleString()}</span></div>
{/if}
</div>
{/if}
</div>
</div>
</div>
<!-- Request & Response Headers (collapsed) -->
{#if req.headers || req.response?.headers}
<div class="bg-slate-50 border border-slate-200 rounded-xl overflow-hidden">
<button
onclick={() => onToggleRaw('headers')}
class="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-100/50 transition-colors"
>
<div class="flex items-center space-x-2">
<Layers class="w-4 h-4 text-slate-600" />
<span class="text-sm font-medium text-slate-800">Headers</span>
</div>
{#if expandedRawSections['headers']}
<ChevronDown class="w-4 h-4 text-slate-500" />
{:else}
<ChevronRight class="w-4 h-4 text-slate-500" />
{/if}
</button>
{#if expandedRawSections['headers']}
<div class="px-4 pb-4 space-y-3">
<!-- Request Headers -->
{#if req.headers && Object.keys(req.headers).length > 0}
<div>
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-slate-600 uppercase">Request Headers</span>
<button onclick={() => onToggleRaw('headers-req-raw')} class="text-[10px] text-slate-400 hover:text-slate-600 inline-flex items-center space-x-1 transition-colors">
{#if expandedRawSections['headers-req-raw']}
<Code class="w-3 h-3" /><span>Formatted</span>
{:else}
<Code class="w-3 h-3" /><span>Raw</span>
{/if}
</button>
</div>
{#if expandedRawSections['headers-req-raw']}
<pre class="text-[10px] bg-gray-900 text-gray-300 rounded-lg p-3 overflow-x-auto max-h-64 font-mono">{formatJSON(req.headers, 0)}</pre>
{:else}
<div class="bg-white rounded-lg border border-slate-200 divide-y divide-slate-100 max-h-64 overflow-y-auto">
{#each Object.entries(req.headers) as [key, values]}
<div class="px-3 py-1.5 flex items-start gap-2">
<span class="text-[11px] font-mono font-medium text-slate-700 flex-shrink-0">{key}</span>
<span class="text-[11px] font-mono text-slate-500 break-all">{Array.isArray(values) ? values.join(', ') : values}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Response Headers -->
{#if req.response?.headers && Object.keys(req.response.headers).length > 0}
<div>
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-slate-600 uppercase">Response Headers</span>
<button onclick={() => onToggleRaw('headers-res-raw')} class="text-[10px] text-slate-400 hover:text-slate-600 inline-flex items-center space-x-1 transition-colors">
{#if expandedRawSections['headers-res-raw']}
<Code class="w-3 h-3" /><span>Formatted</span>
{:else}
<Code class="w-3 h-3" /><span>Raw</span>
{/if}
</button>
</div>
{#if expandedRawSections['headers-res-raw']}
<pre class="text-[10px] bg-gray-900 text-gray-300 rounded-lg p-3 overflow-x-auto max-h-64 font-mono">{formatJSON(req.response.headers, 0)}</pre>
{:else}
<div class="bg-white rounded-lg border border-slate-200 divide-y divide-slate-100 max-h-64 overflow-y-auto">
{#each Object.entries(req.response.headers) as [key, values]}
<div class="px-3 py-1.5 flex items-start gap-2">
<span class="text-[11px] font-mono font-medium text-slate-700 flex-shrink-0">{key}</span>
<span class="text-[11px] font-mono text-slate-500 break-all">{Array.isArray(values) ? values.join(', ') : values}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- System prompt (collapsed) -->
{#if req.body?.system && req.body.system.length > 0}
<div class="bg-amber-50 border border-amber-200 rounded-xl overflow-hidden">
<button
onclick={() => onToggleRaw('system')}
class="w-full px-4 py-3 flex items-center justify-between hover:bg-amber-100/50 transition-colors"
>
<div class="flex items-center space-x-2">
<Settings class="w-4 h-4 text-amber-600" />
<span class="text-sm font-medium text-amber-800">System Prompt</span>
<span class="text-xs text-amber-600 bg-amber-100 px-1.5 py-0.5 rounded-full">{req.body.system.length} block{req.body.system.length > 1 ? 's' : ''}</span>
</div>
{#if expandedRawSections['system']}
<ChevronDown class="w-4 h-4 text-amber-500" />
{:else}
<ChevronRight class="w-4 h-4 text-amber-500" />
{/if}
</button>
{#if expandedRawSections['system']}
<div class="px-4 pb-4 space-y-2">
{#each req.body.system as sys, si}
<div class="bg-white rounded-lg border border-amber-200 overflow-hidden">
<div class="p-4 text-sm text-gray-700 leading-relaxed max-h-96 overflow-y-auto">
<RichText text={sys.text || ''} />
</div>
<div class="border-t border-amber-200 px-3 py-1.5 bg-amber-50 flex items-center justify-between">
<button onclick={() => onToggleRaw(`sys-raw-${si}`)} class="text-[10px] text-amber-500 hover:text-amber-700 inline-flex items-center space-x-1">
{#if expandedRawSections[`sys-raw-${si}`]}
<EyeOff class="w-3 h-3" /><span>Hide raw</span>
{:else}
<Code class="w-3 h-3" /><span>View raw</span>
{/if}
</button>
{#if sys.cache_control}
<span class="text-[10px] text-amber-500">cache: {sys.cache_control.type}</span>
{/if}
</div>
{#if expandedRawSections[`sys-raw-${si}`]}
<pre class="p-3 text-[10px] bg-gray-900 text-gray-300 font-mono overflow-x-auto max-h-64 whitespace-pre-wrap">{formatJSON(sys, 0)}</pre>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Tools (collapsed) -->
{#if req.body?.tools && req.body.tools.length > 0}
<div class="bg-emerald-50 border border-emerald-200 rounded-xl overflow-hidden">
<button
onclick={() => onToggleRaw('tools')}
class="w-full px-4 py-3 flex items-center justify-between hover:bg-emerald-100/50 transition-colors"
>
<div class="flex items-center space-x-2">
<Wrench class="w-4 h-4 text-emerald-600" />
<span class="text-sm font-medium text-emerald-800">Tools</span>
<span class="text-xs text-emerald-600 bg-emerald-100 px-1.5 py-0.5 rounded-full">{req.body.tools.length} available</span>
</div>
{#if expandedRawSections['tools']}
<ChevronDown class="w-4 h-4 text-emerald-500" />
{:else}
<ChevronRight class="w-4 h-4 text-emerald-500" />
{/if}
</button>
{#if expandedRawSections['tools']}
<div class="px-4 pb-4">
<div class="space-y-2 max-h-96 overflow-y-auto">
{#each req.body.tools as tool}
<details class="bg-white rounded-lg border border-emerald-200 group">
<summary class="px-3 py-2 flex items-center justify-between cursor-pointer hover:bg-emerald-50/50 transition-colors">
<div class="flex items-center space-x-2">
<Wrench class="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" />
<span class="font-mono text-xs font-semibold text-emerald-700">{tool.name}</span>
</div>
{#if tool.input_schema?.properties}
<span class="text-[10px] text-gray-400">{Object.keys(tool.input_schema.properties).length} params</span>
{/if}
</summary>
<div class="px-3 pb-2.5 pt-1 border-t border-emerald-100">
{#if tool.description}
<RichText text={tool.description} size="xs" variant="muted" />
{/if}
{#if tool.input_schema?.properties}
<div class="mt-2 flex flex-wrap gap-1">
{#each Object.entries(tool.input_schema.properties) as [name, prop]}
<span class="text-[10px] font-mono bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded border border-gray-200">
{name}{#if tool.input_schema?.required?.includes(name)}<span class="text-red-400">*</span>{/if}
</span>
{/each}
</div>
{/if}
</div>
</details>
{/each}
</div>
<div class="mt-2 pt-2 border-t border-emerald-200">
<button onclick={() => onToggleRaw('tools-raw')} class="text-[10px] text-emerald-500 hover:text-emerald-700 inline-flex items-center space-x-1">
{#if expandedRawSections['tools-raw']}
<EyeOff class="w-3 h-3" /><span>Hide raw</span>
{:else}
<Code class="w-3 h-3" /><span>View raw</span>
{/if}
</button>
</div>
{#if expandedRawSections['tools-raw']}
<pre class="mt-2 text-[10px] bg-gray-900 text-gray-300 rounded-lg p-3 overflow-x-auto max-h-64 font-mono">{formatJSON(req.body.tools, 0)}</pre>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Chat messages -->
{#if req.body?.messages}
{@const msgs = req.body.messages}
{@const totalTurns = getTotalTurns(msgs)}
{@const trMap = buildToolResultMap(msgs)}
<div class="space-y-3">
{#each msgs as message, idx}
<!-- iMessage-style timestamp separator -->
{#if shouldShowTimestamp(msgs, idx)}
{@const turn = getTurnNumber(msgs, idx)}
<div class="flex justify-center py-2">
{#if idx === 0}
<span class="text-[11px] text-gray-400 font-medium">
{formatImessageTimestamp(req.timestamp)}
</span>
{:else}
{@const turnReqId = getRequestIdForTurn(turn)}
{@const turnTs = getTimestampForTurn(turn)}
{@const prevTurnReqId = getRequestIdForTurn(turn - 1)}
{@const prevTurnResponseTime = prevTurnReqId ? summaryMap.get(prevTurnReqId)?.responseTime : null}
<div class="flex flex-col items-center">
{#if turnTs}
<span class="text-[11px] text-gray-400 font-medium">
{formatImessageTimestamp(turnTs)}
{#if prevTurnResponseTime}
<span class="text-gray-300 mx-1">&middot;</span>
<span>{(prevTurnResponseTime / 1000).toFixed(1)}s</span>
{/if}
</span>
{/if}
{#if turnReqId && turnReqId !== selectedId}
<button
onclick={() => onSelectRequest(turnReqId)}
class="text-[10px] text-blue-400 hover:text-blue-600 font-medium transition-colors cursor-pointer"
title="View turn {turn} request"
>
Turn {turn} of {totalTurns}
</button>
{:else}
<span class="text-[10px] text-gray-400 font-medium">
Turn {turn} of {totalTurns}
</span>
{/if}
</div>
{/if}
</div>
{/if}
<ChatMessage
{message}
{idx}
messages={msgs}
toolResultMap={trMap}
{expandedRawSections}
onToggleRaw={onToggleRaw}
/>
{/each}
</div>
{/if}
<!-- Response timestamp -->
{#if req.response?.completedAt}
<div class="flex justify-center py-2">
<span class="text-[11px] text-gray-400 font-medium">
{formatImessageTimestamp(req.response.completedAt)}
{#if req.response.responseTime}
<span class="text-gray-300 mx-1">&middot;</span>
<span>{(req.response.responseTime / 1000).toFixed(1)}s</span>
{/if}
</span>
</div>
{/if}
<!-- Response content -->
{#if req.response?.body?.content}
{@const respSplit = splitContent(req.response.body.content)}
<!-- Outside-bubble response blocks -->
{#each respSplit.outside as item, oi}
<ChatOutsideBlock
{item}
rawKey={`resp-out-${oi}`}
{expandedRawSections}
onToggleRaw={onToggleRaw}
/>
{/each}
<!-- Response chat bubble -->
{#if respSplit.chat}
<div class="flex justify-start">
<div class="max-w-[85%]">
<div class="flex items-start space-x-2">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center bg-gradient-to-br from-purple-100 to-indigo-100">
<ModelIcon class="w-4 h-4 text-purple-600" />
</div>
<div class="min-w-0">
<div class="bg-gray-200 text-gray-900 rounded-2xl rounded-tl-sm px-4 py-3 shadow-sm">
<RichText text={respSplit.chat} />
</div>
{#if req.response.isStreaming}
<div class="flex mt-0.5 px-1">
<span class="text-[10px] text-gray-400">Streamed</span>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Raw view for the full response body -->
<div class="px-10">
<button
onclick={() => onToggleRaw('response')}
class="text-[10px] text-gray-400 hover:text-gray-600 inline-flex items-center space-x-1 transition-colors mt-0.5"
>
{#if expandedRawSections['response']}
<EyeOff class="w-3 h-3" /><span>Hide raw</span>
{:else}
<Eye class="w-3 h-3" /><span>View raw</span>
{/if}
</button>
{#if expandedRawSections['response']}
<pre class="mt-1 text-[10px] bg-gray-900 text-gray-300 rounded-lg p-3 overflow-x-auto max-h-64 font-mono">{formatJSON(req.response.body, 0)}</pre>
{/if}
</div>
{/if}
{/if}
<!-- Error response -->
{#if req.response?.bodyText && !req.response?.body?.content}
<div class="bg-red-50 border border-red-200 rounded-xl p-4">
<div class="flex items-center space-x-2 mb-2">
<Code class="w-4 h-4 text-red-600" />
<span class="text-sm font-medium text-red-700">Error Response</span>
</div>
<pre class="text-xs text-red-800 bg-white rounded p-3 overflow-x-auto border border-red-200">{req.response.bodyText}</pre>
</div>
{/if}
<!-- Bottom spacer -->
<div class="h-8"></div>
</div>

View file

@ -0,0 +1,140 @@
<script lang="ts">
import {
ChevronRight, ChevronDown, Loader2, Layers
} from 'lucide-svelte';
import { formatRelativeTimestamp, formatTimeFull } from '$lib/chat-formatters';
import { getModelLabel, getStatusBadge } from '$lib/chat-utils';
import type { ConversationGroup, RequestSummary } from '$lib/types';
interface Props {
/** Conversations grouped by display date label (e.g. "Mon, Mar 20"). */
groupedByDate: Record<string, ConversationGroup[]>;
/** Map from requestId to its summary for quick lookup. */
summaryMap: Map<string, RequestSummary>;
/** Currently selected request id (if any). */
selectedId: string | null;
/** Set of conversation hashes whose turn-lists are expanded. */
expandedGroups: Set<string>;
/** Whether the initial list is still loading. */
isLoading: boolean;
/** Total number of conversation groups (for the empty-state check). */
totalGroups: number;
/** Called when the user clicks a conversation / turn. */
onSelectRequest: (id: string) => void;
/** Called when the user toggles the expand chevron on a multi-turn group. */
onToggleGroup: (hash: string) => void;
}
let {
groupedByDate,
summaryMap,
selectedId,
expandedGroups,
isLoading,
totalGroups,
onSelectRequest,
onToggleGroup,
}: Props = $props();
</script>
<aside class="w-80 flex-shrink-0 bg-white border-r border-gray-200 flex flex-col min-h-0">
<div class="flex-1 overflow-y-auto">
{#if isLoading}
<div class="flex items-center justify-center py-12">
<Loader2 class="w-5 h-5 animate-spin text-gray-400" />
</div>
{:else if totalGroups === 0}
<div class="p-6 text-center text-gray-500 text-sm">No requests yet</div>
{:else}
{#each Object.entries(groupedByDate) as [date, groups]}
<div class="sticky top-0 bg-gray-50 px-3 py-1.5 border-b border-gray-100 z-[1]">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider">{date}</span>
</div>
{#each groups as group}
{@const s = group.latestRequest}
{@const ml = getModelLabel(s.model)}
{@const isSelected = group.requestIds.includes(selectedId || '')}
{@const isExpanded = expandedGroups.has(group.conversationHash)}
<div class="border-b border-gray-100">
<div class="flex">
<button
onclick={() => onSelectRequest(s.requestId)}
class="flex-1 text-left px-3 py-2.5 transition-colors hover:bg-gray-50 {isSelected ? 'bg-blue-50 border-l-2 border-l-blue-500' : ''}"
>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center space-x-1.5">
<span class="text-xs font-semibold {ml.color}">{ml.label}</span>
{#if s.statusCode}
<span class="text-[10px] font-medium px-1 py-0.5 rounded {getStatusBadge(s.statusCode)}">{s.statusCode}</span>
{/if}
{#if group.turnCount > 1}
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-700 flex items-center space-x-0.5">
<Layers class="w-2.5 h-2.5" />
<span>{group.turnCount} turns</span>
</span>
{/if}
</div>
<span class="text-[10px] text-gray-400">{formatTimeFull(s.timestamp)}</span>
</div>
<div class="flex items-center justify-between text-[11px] text-gray-500">
<div class="flex items-center space-x-2">
{#if group.totalTokens > 0}
<span>{group.totalTokens.toLocaleString()} tok</span>
{/if}
{#if s.messageCount}
<span>{s.messageCount} msgs</span>
{/if}
{#if s.responseTime}
<span>{(s.responseTime / 1000).toFixed(1)}s</span>
{/if}
</div>
<span class="text-gray-400 font-mono text-[10px]">#{s.requestId.slice(-6)}</span>
</div>
</button>
{#if group.turnCount > 1}
<button
onclick={() => onToggleGroup(group.conversationHash)}
class="flex-shrink-0 px-2 flex items-center hover:bg-gray-100 transition-colors border-l border-gray-100"
title="{isExpanded ? 'Collapse' : 'Expand'} turns"
>
{#if isExpanded}
<ChevronDown class="w-3.5 h-3.5 text-gray-400" />
{:else}
<ChevronRight class="w-3.5 h-3.5 text-gray-400" />
{/if}
</button>
{/if}
</div>
{#if isExpanded && group.turnCount > 1}
<div class="bg-gray-50 border-t border-gray-100">
{#each group.requestIds as turnId, ti}
{@const turnSummary = summaryMap.get(turnId)}
{@const isTurnSelected = turnId === selectedId}
{#if turnSummary}
<button
onclick={() => onSelectRequest(turnId)}
class="w-full text-left pl-8 pr-3 py-1.5 flex items-center justify-between transition-colors hover:bg-blue-50 {isTurnSelected ? 'bg-blue-100 text-blue-700' : 'text-gray-500'}"
>
<div class="flex items-center space-x-2">
<span class="text-[10px] font-medium {isTurnSelected ? 'text-blue-700' : 'text-gray-500'}">Turn {group.turnCount - ti}</span>
<span class="text-[10px] {isTurnSelected ? 'text-blue-500' : 'text-gray-400'}">{formatRelativeTimestamp(turnSummary.timestamp)}</span>
</div>
<div class="flex items-center space-x-2 text-[10px] {isTurnSelected ? 'text-blue-500' : 'text-gray-400'}">
{#if turnSummary.usage}
<span>{((turnSummary.usage.input_tokens || 0) + (turnSummary.usage.output_tokens || 0)).toLocaleString()} tok</span>
{/if}
{#if turnSummary.responseTime}
<span>{(turnSummary.responseTime / 1000).toFixed(1)}s</span>
{/if}
</div>
</button>
{/if}
{/each}
</div>
{/if}
</div>
{/each}
{/each}
{/if}
</div>
</aside>

View file

@ -0,0 +1,182 @@
<script lang="ts">
import {
Terminal, FileText, Code, EyeOff, CheckCircle, AlertCircle
} from 'lucide-svelte';
import { formatJSON, truncateText } from '$lib/formatters';
import {
toolInputSummary,
toolResultBrief,
getToolResultContent,
type OutsideItem
} from '$lib/chat-utils';
import type { ToolUseContentBlock, ToolResultContentBlock } from '$lib/types';
interface Props {
/** The tool_use content block. */
item: ToolUseContentBlock;
/** The matching tool_result (if found via tool_use_id). */
result: ToolResultContentBlock | undefined;
/** Unique key for raw-section toggling, e.g. `out-${idx}-${oi}`. */
rawKey: string;
/** Current expanded-raw state record. */
expandedRawSections: Record<string, boolean>;
/** Callback to toggle a raw section. */
onToggleRaw: (key: string) => void;
}
let { item, result, rawKey, expandedRawSections, onToggleRaw }: Props = $props();
let summary = $derived(toolInputSummary(item));
let brief = $derived(toolResultBrief(result));
let resultContent = $derived(getToolResultContent(result));
</script>
<div class="px-10">
<details class="group cursor-pointer">
<summary class="inline-flex items-center gap-1.5 text-[10px] transition-colors select-none">
<Terminal class="w-3 h-3 text-indigo-400" />
<span class="font-mono font-medium text-indigo-500">{item.name}</span>
{#if summary}
<span class="text-gray-400 font-mono truncate max-w-xs" title={summary}>{summary.length > 50 ? summary.slice(0, 50) + '\u2026' : summary}</span>
{/if}
<span class="text-gray-300">&rarr;</span>
{#if brief.isError}
<AlertCircle class="w-2.5 h-2.5 text-red-400" />
<span class="text-red-400">{brief.text}</span>
{:else}
<CheckCircle class="w-2.5 h-2.5 text-emerald-400" />
<span class="text-emerald-400">{brief.text}</span>
{/if}
</summary>
<div class="mt-1 rounded-lg overflow-hidden border border-gray-700 bg-gray-900">
{#if item.name === 'Bash'}
<!-- Terminal-style rendering for Bash -->
{#if item.input?.description}
<div class="px-3 py-1.5 bg-gray-800 border-b border-gray-700 text-[10px] text-gray-400 italic">{item.input.description}</div>
{/if}
<div class="px-3 py-2 border-b border-gray-800">
<div class="flex items-start gap-1.5">
<span class="text-green-400 text-[11px] font-mono font-bold select-none flex-shrink-0">$</span>
<pre class="text-[11px] text-gray-100 font-mono whitespace-pre-wrap break-all">{item.input?.command || ''}</pre>
</div>
</div>
{#if resultContent}
<div class="max-h-64 overflow-auto">
<pre class="px-3 py-2 text-[10px] font-mono whitespace-pre-wrap break-all {brief.isError ? 'text-red-300' : 'text-gray-400'}">{truncateText(resultContent, 8000)}</pre>
</div>
{/if}
{:else if item.name === 'Read'}
<!-- File read rendering -->
<div class="px-3 py-1.5 bg-gray-800 border-b border-gray-700 flex items-center gap-2">
<FileText class="w-3 h-3 text-blue-400 flex-shrink-0" />
<span class="text-[11px] text-gray-200 font-mono truncate">{item.input?.file_path || 'file'}</span>
{#if item.input?.offset}
<span class="text-[9px] text-gray-500">L{item.input.offset}{item.input.limit ? `-${item.input.offset + item.input.limit}` : ''}</span>
{/if}
</div>
{#if resultContent}
<div class="max-h-64 overflow-auto">
<pre class="px-3 py-2 text-[10px] text-gray-300 font-mono whitespace-pre-wrap">{truncateText(resultContent, 8000)}</pre>
</div>
{/if}
{:else if item.name === 'Edit'}
<!-- Edit rendering -->
<div class="px-3 py-1.5 bg-gray-800 border-b border-gray-700 flex items-center gap-2">
<FileText class="w-3 h-3 text-amber-400 flex-shrink-0" />
<span class="text-[11px] text-gray-200 font-mono truncate">{item.input?.file_path || 'file'}</span>
{#if item.input?.replace_all}
<span class="text-[9px] text-amber-500 bg-amber-900/30 px-1 py-0.5 rounded">replace all</span>
{/if}
</div>
{#if item.input?.old_string !== undefined && item.input?.new_string !== undefined}
<div class="max-h-48 overflow-auto border-b border-gray-800">
<div class="px-3 py-1.5">
<div class="text-[9px] text-red-400 font-medium mb-1 select-none">- old</div>
<pre class="text-[10px] text-red-300/80 font-mono whitespace-pre-wrap">{truncateText(item.input.old_string, 2000)}</pre>
</div>
<div class="px-3 py-1.5 border-t border-gray-800">
<div class="text-[9px] text-green-400 font-medium mb-1 select-none">+ new</div>
<pre class="text-[10px] text-green-300/80 font-mono whitespace-pre-wrap">{truncateText(item.input.new_string, 2000)}</pre>
</div>
</div>
{/if}
{#if resultContent}
<pre class="px-3 py-1.5 text-[10px] text-gray-400 font-mono">{truncateText(resultContent, 1000)}</pre>
{/if}
{:else if item.name === 'Write'}
<!-- Write rendering -->
<div class="px-3 py-1.5 bg-gray-800 border-b border-gray-700 flex items-center gap-2">
<FileText class="w-3 h-3 text-green-400 flex-shrink-0" />
<span class="text-[11px] text-gray-200 font-mono truncate">{item.input?.file_path || 'file'}</span>
{#if item.input?.content}
<span class="text-[9px] text-gray-500">{item.input.content.split('\n').length} lines</span>
{/if}
</div>
{#if item.input?.content}
<div class="max-h-48 overflow-auto">
<pre class="px-3 py-2 text-[10px] text-gray-300 font-mono whitespace-pre-wrap">{truncateText(item.input.content, 5000)}</pre>
</div>
{/if}
{#if resultContent}
<pre class="px-3 py-1.5 text-[10px] text-gray-400 font-mono border-t border-gray-800">{truncateText(resultContent, 500)}</pre>
{/if}
{:else if item.name === 'Grep'}
<!-- Grep rendering -->
<div class="px-3 py-1.5 bg-gray-800 border-b border-gray-700">
<div class="flex items-center gap-1.5">
<span class="text-green-400 text-[11px] font-mono font-bold select-none flex-shrink-0">$</span>
<span class="text-[11px] text-gray-100 font-mono">rg {item.input?.pattern ? `"${item.input.pattern}"` : ''}{item.input?.glob ? ` --glob "${item.input.glob}"` : ''}{item.input?.path ? ` ${item.input.path}` : ''}</span>
</div>
</div>
{#if resultContent}
<div class="max-h-64 overflow-auto">
<pre class="px-3 py-2 text-[10px] text-gray-400 font-mono whitespace-pre-wrap">{truncateText(resultContent, 8000)}</pre>
</div>
{/if}
{:else if item.name === 'Glob'}
<!-- Glob rendering -->
<div class="px-3 py-1.5 bg-gray-800 border-b border-gray-700">
<div class="flex items-center gap-1.5">
<span class="text-green-400 text-[11px] font-mono font-bold select-none flex-shrink-0">$</span>
<span class="text-[11px] text-gray-100 font-mono">find {item.input?.pattern || ''}{item.input?.path ? ` in ${item.input.path}` : ''}</span>
</div>
</div>
{#if resultContent}
<div class="max-h-64 overflow-auto">
<pre class="px-3 py-2 text-[10px] text-gray-400 font-mono whitespace-pre-wrap">{truncateText(resultContent, 8000)}</pre>
</div>
{/if}
{:else}
<!-- Generic tool rendering -->
{#if item.input && Object.keys(item.input).length > 0}
<div class="px-3 py-2 border-b border-gray-800 max-h-48 overflow-auto">
{#each Object.entries(item.input) as [key, val]}
<div class="flex gap-2 py-0.5">
<span class="text-[10px] text-indigo-400 font-mono flex-shrink-0">{key}:</span>
<pre class="text-[10px] text-gray-300 font-mono whitespace-pre-wrap break-all">{typeof val === 'string' ? truncateText(val, 500) : JSON.stringify(val)}</pre>
</div>
{/each}
</div>
{/if}
{#if resultContent}
<div class="max-h-64 overflow-auto">
<pre class="px-3 py-2 text-[10px] {brief.isError ? 'text-red-300' : 'text-gray-400'} font-mono whitespace-pre-wrap">{truncateText(resultContent, 5000)}</pre>
</div>
{/if}
{/if}
<!-- Raw toggle -->
<div class="px-3 py-1 bg-gray-800/50 border-t border-gray-800">
<button onclick={() => onToggleRaw(rawKey)} class="text-[10px] text-gray-500 hover:text-gray-300 inline-flex items-center space-x-1">
{#if expandedRawSections[rawKey]}
<EyeOff class="w-3 h-3" /><span>Hide raw</span>
{:else}
<Code class="w-3 h-3" /><span>View raw</span>
{/if}
</button>
</div>
{#if expandedRawSections[rawKey]}
<pre class="p-3 text-[10px] bg-black text-gray-400 font-mono overflow-x-auto max-h-48 whitespace-pre-wrap">{formatJSON({ tool_use: item, tool_result: result }, 0)}</pre>
{/if}
</div>
</details>
</div>

View file

@ -0,0 +1,65 @@
<script lang="ts">
interface Props {
oldCode: string;
newCode: string;
fileName?: string;
}
let { oldCode, newCode, fileName }: Props = $props();
let diffLines = $derived.by(() => {
const oldLines = oldCode.split('\n');
const newLines = newCode.split('\n');
let start = 0;
let oldEnd = oldLines.length - 1;
let newEnd = newLines.length - 1;
while (start <= oldEnd && start <= newEnd && oldLines[start] === newLines[start]) start++;
while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
oldEnd--;
newEnd--;
}
const lines: Array<{ type: 'unchanged' | 'removed' | 'added'; content: string; lineNum?: number }> = [];
for (let i = 0; i < start; i++) lines.push({ type: 'unchanged', content: oldLines[i], lineNum: i + 1 });
for (let i = start; i <= oldEnd; i++) lines.push({ type: 'removed', content: oldLines[i] });
for (let i = start; i <= newEnd; i++) lines.push({ type: 'added', content: newLines[i], lineNum: i + 1 });
for (let i = oldEnd + 1; i < oldLines.length; i++) lines.push({ type: 'unchanged', content: oldLines[i], lineNum: i + 1 + (newEnd - oldEnd) });
return lines;
});
</script>
<div class="rounded-lg border border-gray-700 bg-gray-900 overflow-hidden">
{#if fileName}
<div class="px-4 py-2 bg-gray-800 border-b border-gray-700 text-sm text-gray-300">{fileName}</div>
{/if}
<div class="overflow-x-auto">
<table class="w-full text-sm font-mono">
<tbody>
{#each diffLines as line, idx}
<tr class={line.type === 'removed' ? 'bg-red-900/20' : line.type === 'added' ? 'bg-green-900/20' : ''}>
<td class="px-2 py-0.5 text-right text-gray-500 select-none w-12">
{line.type === 'removed' ? '-' : line.lineNum || ''}
</td>
<td class="px-2 py-0.5 text-right text-gray-500 select-none w-12">
{line.type === 'added' ? '+' : line.type === 'unchanged' ? line.lineNum || '' : ''}
</td>
<td class="px-1 py-0.5 select-none w-6 text-center">
<span class={line.type === 'removed' ? 'text-red-400' : line.type === 'added' ? 'text-green-400' : 'text-gray-600'}>
{line.type === 'removed' ? '-' : line.type === 'added' ? '+' : ' '}
</span>
</td>
<td class="px-2 py-0.5 whitespace-pre overflow-x-auto">
<span class={line.type === 'removed' ? 'text-red-300' : line.type === 'added' ? 'text-green-300' : 'text-gray-300'}>
{line.content}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,169 @@
<script lang="ts">
import { Copy, Check, FileCode, Download, Maximize2, X } from 'lucide-svelte';
interface Props {
code: string;
fileName?: string;
language?: string;
}
let { code, fileName, language }: Props = $props();
let copied = $state(false);
let isFullscreen = $state(false);
const languageMap: Record<string, string> = {
js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java',
cpp: 'cpp', c: 'c', h: 'c', hpp: 'cpp', cs: 'csharp', php: 'php',
swift: 'swift', kt: 'kotlin', scala: 'scala', r: 'r',
sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', ps1: 'powershell',
sql: 'sql', html: 'html', htm: 'html', xml: 'xml', css: 'css',
scss: 'scss', sass: 'sass', less: 'less', json: 'json',
yaml: 'yaml', yml: 'yaml', toml: 'toml', md: 'markdown', mdx: 'markdown',
tex: 'latex', dockerfile: 'dockerfile', makefile: 'makefile',
lua: 'lua', dart: 'dart', elixir: 'elixir', elm: 'elm',
haskell: 'haskell', julia: 'julia', perl: 'perl', ocaml: 'ocaml',
clj: 'clojure', cljs: 'clojure', cljc: 'clojure'
};
let detectedLanguage = $derived(
language || (() => {
if (!fileName) return 'text';
const ext = fileName.split('.').pop()?.toLowerCase() || '';
return languageMap[ext] || 'text';
})()
);
let lines = $derived(code.split('\n'));
let lineCount = $derived(lines.length);
function highlightCode(line: string): Array<{ text: string; className?: string }> {
const segments: Array<{ text: string; className?: string }> = [];
const tokenPatterns: Array<{ regex: RegExp; className: string }> = [
{ regex: /(["'`])(?:(?=(\\?))\2.)*?\1/, className: 'text-green-400' },
{ regex: /\/\/.*$/, className: 'text-gray-500 italic' },
{ regex: /\/\*[\s\S]*?\*\//, className: 'text-gray-500 italic' },
{ regex: /#.*$/, className: 'text-gray-500 italic' },
{ regex: /\b(?:function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default)\b/, className: 'text-blue-400' },
{ regex: /\b(?:true|false|null|undefined|nil|None|True|False)\b/, className: 'text-orange-400' },
{ regex: /\b\d+\.?\d*\b/, className: 'text-purple-400' },
];
let remaining = line;
while (remaining.length > 0) {
let earliest: { index: number; length: number; className: string; matched: string } | null = null;
for (const { regex, className } of tokenPatterns) {
const m = remaining.match(regex);
if (m && m.index !== undefined) {
if (earliest === null || m.index < earliest.index) {
earliest = { index: m.index, length: m[0].length, className, matched: m[0] };
}
}
}
if (earliest === null) {
segments.push({ text: remaining });
break;
}
if (earliest.index > 0) {
segments.push({ text: remaining.substring(0, earliest.index) });
}
segments.push({ text: earliest.matched, className: earliest.className });
remaining = remaining.substring(earliest.index + earliest.length);
}
return segments;
}
async function handleCopy() {
try {
await navigator.clipboard.writeText(code);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
}
function handleDownload() {
const blob = new Blob([code], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName || 'code.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
{#snippet codeDisplay(inModal: boolean)}
<div class="rounded-lg border border-gray-700 bg-gray-900 overflow-hidden {inModal ? '' : 'max-h-[600px]'}">
<div class="px-4 py-2 bg-gray-800 border-b border-gray-700 flex items-center justify-between">
<div class="flex items-center space-x-3">
<FileCode class="w-4 h-4 text-blue-400" />
<span class="text-sm text-gray-300 font-mono">{fileName || 'Untitled'}</span>
<span class="text-xs text-gray-500 bg-gray-700 px-2 py-1 rounded">{detectedLanguage}</span>
<span class="text-xs text-gray-500">{lineCount} lines</span>
</div>
<div class="flex items-center space-x-2">
<button onclick={handleDownload} class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors" title="Download file">
<Download class="w-4 h-4" />
</button>
{#if !inModal}
<button onclick={() => (isFullscreen = true)} class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors" title="View fullscreen">
<Maximize2 class="w-4 h-4" />
</button>
{/if}
<button onclick={handleCopy} class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors" title="Copy code">
{#if copied}
<Check class="w-4 h-4 text-green-400" />
{:else}
<Copy class="w-4 h-4" />
{/if}
</button>
</div>
</div>
<div class="overflow-auto {inModal ? 'max-h-[80vh]' : 'max-h-[500px]'}">
<table class="w-full text-sm font-mono">
<tbody>
{#each lines as line, idx (`line-${idx}`)}
<tr class="hover:bg-gray-800/50">
<td class="px-4 py-0.5 text-right text-gray-500 select-none w-12 align-top">{idx + 1}</td>
<td class="px-4 py-0.5 whitespace-pre text-gray-300">
{#each highlightCode(line) as segment, segmentIndex (`${idx}-${segmentIndex}`)}
{#if segment.className}
<span class={segment.className}>{segment.text}</span>
{:else}
<span>{segment.text}</span>
{/if}
{/each}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/snippet}
{@render codeDisplay(false)}
{#if isFullscreen}
<div class="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-label="Code viewer">
<button type="button" class="absolute inset-0 w-full h-full bg-transparent border-0 cursor-default" onclick={() => (isFullscreen = false)} aria-label="Close fullscreen">
</button>
<div class="relative max-w-[90vw] w-full max-h-[90vh]">
<button onclick={() => (isFullscreen = false)} class="absolute -top-10 right-0 p-2 text-white hover:text-gray-300 transition-colors" title="Close">
<X class="w-6 h-6" />
</button>
{@render codeDisplay(true)}
</div>
</div>
{/if}

View file

@ -0,0 +1,138 @@
<script lang="ts">
import { MessageCircle, Clock, Sparkles, ChevronDown, ChevronRight, GitBranch } from 'lucide-svelte';
import MessageFlow from './MessageFlow.svelte';
import type { Conversation, MessageContent } from '$lib/types';
interface ConversationMessage {
role: 'user' | 'assistant' | 'system';
content: MessageContent;
timestamp: string;
turnNumber?: number;
isNewInTurn?: boolean;
isDuplicate?: boolean;
}
interface Props {
conversation: Conversation;
}
let { conversation }: Props = $props();
let expandedSections = $state(new Set(['flow']));
function toggleSection(section: string) {
const newSet = new Set(expandedSections);
if (newSet.has(section)) newSet.delete(section);
else newSet.add(section);
expandedSections = newSet;
}
let messages: ConversationMessage[] = $derived.by(() => {
const all: ConversationMessage[] = [];
if (!conversation.messages || !Array.isArray(conversation.messages)) return all;
for (const msg of conversation.messages) {
let parsedMessage: unknown;
try {
parsedMessage = typeof msg.message === 'string' ? JSON.parse(msg.message) : msg.message;
} catch (error) { console.error('Failed to parse conversation message:', error); parsedMessage = msg.message; }
let role: 'user' | 'assistant' | 'system' = 'user';
if (msg.type === 'assistant') role = 'assistant';
else if (msg.type === 'system') role = 'system';
let content: MessageContent | null = null;
if (parsedMessage && typeof parsedMessage === 'object') {
if ('content' in parsedMessage) {
const raw = (parsedMessage as Record<string, unknown>).content;
if (typeof raw === 'string' || Array.isArray(raw) || (raw && typeof raw === 'object')) {
content = raw as MessageContent;
}
} else if ('text' in parsedMessage && typeof (parsedMessage as Record<string, unknown>).text === 'string') {
content = (parsedMessage as Record<string, unknown>).text as string;
} else if (Array.isArray(parsedMessage)) {
content = parsedMessage;
} else {
content = parsedMessage as Record<string, unknown>;
}
} else if (typeof parsedMessage === 'string') {
content = parsedMessage;
}
if (content) {
all.push({ role, content, timestamp: msg.timestamp, isNewInTurn: true });
}
}
return all;
});
</script>
{#if messages.length === 0}
<div class="text-center py-12">
<div class="w-20 h-20 bg-gray-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<MessageCircle class="w-10 h-10 text-gray-400" />
</div>
<h3 class="text-lg font-medium text-gray-600 mb-2">No messages found</h3>
<p class="text-sm text-gray-500">This conversation appears to be empty</p>
</div>
{:else}
<div class="space-y-6">
<!-- Header -->
<div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<div class="flex items-center justify-between cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('flow')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('flow'); } }}>
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
<GitBranch class="w-5 h-5 text-white" />
</div>
<div>
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-2">
<span>Conversation Flow</span>
<div class="flex items-center space-x-2 text-sm">
<Sparkles class="w-4 h-4 text-purple-500" />
<span class="text-gray-600">Conversation processed - <span class="font-semibold text-purple-700">{messages.length}</span> messages</span>
</div>
</h4>
<p class="text-sm text-gray-600">{messages.length} messages &bull; {conversation.messageCount} total</p>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">{new Date(messages[messages.length - 1]?.timestamp).toLocaleTimeString()}</span>
{#if expandedSections.has('flow')}
<ChevronDown class="w-5 h-5 text-gray-400" />
{:else}
<ChevronRight class="w-5 h-5 text-gray-400" />
{/if}
</div>
</div>
</div>
<!-- Messages -->
{#if expandedSections.has('flow')}
<div class="space-y-1">
{#each messages as message, index}
<MessageFlow {message} {index} isLast={index === messages.length - 1} totalMessages={messages.length} />
{/each}
<div class="mt-8 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<Sparkles class="w-4 h-4 text-blue-600" />
</div>
<div>
<div class="text-sm font-medium text-blue-900">Conversation Summary</div>
<div class="text-xs text-blue-700">{messages.length} messages &bull; {conversation.messageCount} total messages</div>
</div>
</div>
<div class="text-right text-xs text-blue-700">
<div class="flex items-center space-x-1">
<Clock class="w-3 h-3" />
<span>Latest: {new Date(messages[messages.length - 1]?.timestamp).toLocaleTimeString()}</span>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { Image as ImageIcon, Download, Maximize2, X } from 'lucide-svelte';
interface Props {
content: {
source?: { type: string; media_type: string; data: string };
data?: string;
media_type?: string;
};
}
let { content }: Props = $props();
let isFullscreen = $state(false);
let imageError = $state(false);
let imageData = $derived(content.source?.data ?? content.data);
let mediaType = $derived(content.source?.media_type ?? content.media_type ?? 'image/png');
let dataUri = $derived(
imageData
? imageData.startsWith('data:')
? imageData
: `data:${mediaType};base64,${imageData}`
: ''
);
function handleDownload() {
const link = document.createElement('a');
link.href = dataUri;
link.download = `image-${Date.now()}.${mediaType?.split('/')[1] || 'png'}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
</script>
{#if !imageData}
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div class="flex items-center space-x-2">
<ImageIcon class="w-4 h-4 text-amber-600" />
<span class="text-amber-700 font-medium text-sm">No image data available</span>
</div>
</div>
{:else if imageError}
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex items-center space-x-2">
<ImageIcon class="w-4 h-4 text-red-600" />
<span class="text-red-700 font-medium text-sm">Failed to load image</span>
</div>
<details class="mt-2 cursor-pointer">
<summary class="text-xs text-red-600 hover:text-red-800 underline transition-colors">Show raw data</summary>
<pre class="mt-2 text-xs overflow-x-auto bg-white rounded p-3 border border-red-200 font-mono">{JSON.stringify(content, null, 2)}</pre>
</details>
</div>
{:else}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-2">
<ImageIcon class="w-4 h-4 text-blue-600" />
<span class="text-gray-700 font-medium text-sm">Image ({mediaType || 'unknown type'})</span>
</div>
<div class="flex items-center space-x-2">
<button onclick={handleDownload} class="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded transition-colors" title="Download image">
<Download class="w-4 h-4" />
</button>
<button onclick={() => (isFullscreen = true)} class="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded transition-colors" title="View fullscreen">
<Maximize2 class="w-4 h-4" />
</button>
</div>
</div>
<div class="bg-white rounded border border-gray-200 p-2">
<button type="button" class="block w-full p-0 border-0 bg-transparent cursor-pointer" onclick={() => (isFullscreen = true)}>
<img src={dataUri} alt="Content" class="max-w-full h-auto rounded" onerror={() => (imageError = true)} />
</button>
</div>
</div>
{#if isFullscreen}
<div class="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-label="Fullscreen image">
<button type="button" class="absolute inset-0 w-full h-full bg-transparent border-0 cursor-default" onclick={() => (isFullscreen = false)} aria-label="Close fullscreen">
</button>
<div class="relative max-w-[90vw] max-h-[90vh]">
<button onclick={(e) => { e.stopPropagation(); isFullscreen = false; }} class="absolute -top-10 right-0 p-2 text-white hover:text-gray-300 transition-colors" title="Close">
<X class="w-6 h-6" />
</button>
<img src={dataUri} alt="Content (fullscreen)" class="max-w-full max-h-full object-contain" />
</div>
</div>
{/if}
{/if}

View file

@ -0,0 +1,226 @@
<script lang="ts">
import { Wrench, Code, FileText, Database, Brain } from 'lucide-svelte';
import ToolResult from './ToolResult.svelte';
import ToolUse from './ToolUse.svelte';
import ImageContent from './ImageContent.svelte';
import MessageContent from './MessageContent.svelte';
import RichText from './RichText.svelte';
import XmlBlock from './XmlBlock.svelte';
import { formatJSON, parseXmlBlocks, hasCustomXmlBlocks } from '$lib/formatters';
import type {
MessageContent as RenderableMessageContent,
TextContentBlock,
ToolUseContentBlock,
ToolResultContentBlock,
ThinkingContentBlock,
ImageContentBlock,
ToolDefinition
} from '$lib/types';
interface Props {
content: RenderableMessageContent | unknown;
}
let { content }: Props = $props();
function parseSystemReminders(text: string) {
const result: Array<{ type: 'text' | 'reminder'; content: string }> = [];
const regex = /<system-reminder>([\s\S]*?)<\/system-reminder>/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
const textPart = text.substring(lastIndex, match.index).trim();
if (textPart) result.push({ type: 'text', content: textPart });
}
result.push({ type: 'reminder', content: match[1].trim() });
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
const textPart = text.substring(lastIndex).trim();
if (textPart) result.push({ type: 'text', content: textPart });
}
return result;
}
function isTextBlock(value: unknown): value is TextContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'text' && 'text' in value && typeof value.text === 'string';
}
function isToolUseBlock(value: unknown): value is ToolUseContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'tool_use';
}
function isToolResultBlock(value: unknown): value is ToolResultContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'tool_result';
}
function isImageBlock(value: unknown): value is ImageContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'image';
}
function isThinkingBlock(value: unknown): value is ThinkingContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'thinking';
}
function parseFunctions(text: string): { beforeFunctions: string; afterFunctions: string; tools: Array<ToolDefinition | null> } | null {
const functionsMatch = text.match(/<functions>([\s\S]*?)<\/functions>/);
if (!functionsMatch) return null;
const beforeFunctions = text.substring(0, functionsMatch.index!);
const afterFunctions = text.substring(functionsMatch.index! + functionsMatch[0].length);
const functionMatches = [...functionsMatch[1].matchAll(/<function>([\s\S]*?)<\/function>/g)];
const tools = functionMatches.map((m) => {
try { return JSON.parse(m[1]) as ToolDefinition; } catch (error) { console.error('Failed to parse tool definition:', error); return null; }
});
return { beforeFunctions, afterFunctions, tools };
}
</script>
{#if typeof content === 'string'}
{#if hasCustomXmlBlocks(content)}
{@const segments = parseXmlBlocks(content)}
<div class="space-y-2">
{#each segments as segment, index (`${segment.type}-${segment.tag ?? 'text'}-${index}`)}
{#if segment.type === 'xml' && segment.tag && segment.innerContent !== undefined}
<XmlBlock tag={segment.tag} innerContent={segment.innerContent} startCollapsed={true} />
{:else if segment.type === 'text' && segment.content.trim()}
<div class="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<RichText text={segment.content} />
</div>
{/if}
{/each}
</div>
{:else}
<div class="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<RichText text={content} />
</div>
{/if}
{:else if Array.isArray(content)}
<div class="space-y-4">
{#each content as item, index (`${typeof item === 'object' && item && 'type' in item && typeof item.type === 'string' ? item.type : 'item'}-${index}`)}
<div class="content-block">
<MessageContent content={item} />
</div>
{/each}
</div>
{:else if content && typeof content === 'object'}
{#if isTextBlock(content)}
{#if content.text && content.text.includes('<functions>')}
{@const parsed = parseFunctions(content.text)}
{#if parsed}
<div class="space-y-4">
{#if parsed.beforeFunctions.trim()}
<div class="max-h-64 overflow-y-auto bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<RichText text={parsed.beforeFunctions} />
</div>
{/if}
<details class="bg-gradient-to-r from-emerald-50 to-green-50 border border-emerald-200 rounded-xl p-5 shadow-sm cursor-pointer">
<summary class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-br from-emerald-500 to-green-600 rounded-xl flex items-center justify-center shadow-sm">
<Wrench class="w-5 h-5 text-white" />
</div>
<div>
<div class="flex items-center space-x-2">
<span class="text-emerald-900 font-semibold text-base">Available Tools</span>
<Database class="w-4 h-4 text-emerald-600" />
</div>
<div class="text-sm text-emerald-700">{parsed.tools.length} tools defined for this conversation</div>
</div>
</div>
</summary>
<div class="space-y-3 max-h-96 overflow-y-auto mt-4">
{#each parsed.tools as toolDef, index (toolDef?.name ?? `invalid-${index}`)}
{#if toolDef}
{@const paramCount = toolDef.parameters?.properties ? Object.keys(toolDef.parameters.properties).length : 0}
{@const requiredParams = toolDef.parameters?.required || []}
<div class="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div class="flex items-center space-x-3 mb-3">
<div class="w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center">
<Wrench class="w-4 h-4 text-emerald-600" />
</div>
<div>
<span class="text-emerald-700 font-mono text-sm font-semibold">{toolDef.name}</span>
<div class="flex items-center space-x-2 mt-1">
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full border border-gray-200">{paramCount} params</span>
{#if requiredParams.length > 0}
<span class="text-xs bg-orange-100 text-orange-700 px-2 py-1 rounded-full border border-orange-200">{requiredParams.length} required</span>
{/if}
</div>
</div>
</div>
<div class="text-gray-600 text-sm mb-3 leading-relaxed">{toolDef.description || 'No description available'}</div>
<details class="cursor-pointer pt-3 border-t border-gray-200">
<summary class="text-xs text-gray-600 hover:text-gray-800 underline transition-colors">Show raw definition</summary>
<pre class="mt-2 p-3 bg-gray-50 border border-gray-200 rounded text-xs overflow-x-auto font-mono">{JSON.stringify(toolDef, null, 2)}</pre>
</details>
</div>
{:else}
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2">
<Code class="w-4 h-4 text-red-600" />
<span class="text-red-700 font-medium text-sm">Invalid Tool Definition #{index + 1}</span>
</div>
</div>
{/if}
{/each}
</div>
</details>
{#if parsed.afterFunctions.trim()}
<div class="max-h-64 overflow-y-auto bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<RichText text={parsed.afterFunctions} />
</div>
{/if}
</div>
{:else}
<div class="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<RichText text={content.text} />
</div>
{/if}
{:else if content.text && hasCustomXmlBlocks(content.text)}
<MessageContent content={content.text} />
{:else}
<div class="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<RichText text={content.text || ''} />
</div>
{/if}
{:else if isToolUseBlock(content)}
<ToolUse name={content.name || 'Unknown Tool'} id={content.id || 'unknown'} input={content.input || {}} text={content.text} />
{:else if isToolResultBlock(content)}
<ToolResult content={content.text || content.content || content} toolId={content.tool_call_id || content.id} isError={content.is_error || false} />
{:else if isImageBlock(content)}
<ImageContent content={content} />
{:else if isThinkingBlock(content) && content.thinking && content.thinking.trim()}
<details class="group cursor-pointer inline-block">
<summary class="inline-flex items-center space-x-1 text-[11px] text-gray-400 hover:text-amber-600 transition-colors select-none">
<Brain class="w-3 h-3" />
<span>thought for {Math.ceil(content.thinking.length / 300)}s</span>
</summary>
<pre class="mt-2 whitespace-pre-wrap text-xs text-gray-600 leading-relaxed max-h-[400px] overflow-y-auto bg-gray-50 rounded-lg p-3 border border-gray-200">{content.thinking}</pre>
</details>
{:else if isThinkingBlock(content)}
<div class="hidden" aria-hidden="true"></div>
{:else}
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2">
<Code class="w-4 h-4 text-amber-600" />
<span class="text-amber-700 font-medium text-sm">Unknown content type: {'type' in content && typeof content.type === 'string' ? content.type : 'unknown'}</span>
</div>
<details class="cursor-pointer">
<summary class="text-xs text-amber-600 hover:text-amber-800 underline transition-colors">Show raw content</summary>
<pre class="mt-2 text-xs overflow-x-auto bg-white rounded p-3 border border-amber-200 font-mono">{JSON.stringify(content, null, 2)}</pre>
</details>
</div>
{/if}
{:else}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2">
<FileText class="w-4 h-4 text-gray-500" />
<span class="text-gray-600 font-medium text-sm">Unable to render content</span>
</div>
<details class="cursor-pointer">
<summary class="text-xs text-blue-600 hover:text-blue-800 underline transition-colors">Show raw content</summary>
<pre class="mt-2 text-xs overflow-x-auto bg-white rounded p-3 border border-gray-200 font-mono">{JSON.stringify(content, null, 2)}</pre>
</details>
</div>
{/if}

View file

@ -0,0 +1,182 @@
<script lang="ts">
import { User, Bot, Settings, ChevronDown, ChevronRight, Clock, ArrowDown } from 'lucide-svelte';
import MessageContent from './MessageContent.svelte';
import { formatTime } from '$lib/formatters';
import type { MessageContent as RenderableMessageContent, TextContentBlock } from '$lib/types';
interface ConversationMessage {
role: 'user' | 'assistant' | 'system';
content: RenderableMessageContent;
timestamp: string;
turnNumber?: number;
isNewInTurn?: boolean;
isDuplicate?: boolean;
}
interface Props {
message: ConversationMessage;
index: number;
isLast: boolean;
totalMessages: number;
}
let { message, index, isLast, totalMessages }: Props = $props();
let isExpanded = $state(false);
let roleConfig = $derived((() => {
switch (message.role) {
case 'user':
return { bgColor: 'bg-blue-50', borderColor: 'border-blue-200', accentColor: 'border-l-blue-500', titleColor: 'text-blue-700', name: 'User', iconColor: 'text-blue-600' };
case 'assistant':
return { bgColor: 'bg-gray-50', borderColor: 'border-gray-200', accentColor: 'border-l-gray-500', titleColor: 'text-gray-700', name: 'Assistant', iconColor: 'text-gray-600' };
case 'system':
return { bgColor: 'bg-amber-50', borderColor: 'border-amber-200', accentColor: 'border-l-amber-500', titleColor: 'text-amber-700', name: 'System', iconColor: 'text-amber-600' };
default:
return { bgColor: 'bg-gray-50', borderColor: 'border-gray-200', accentColor: 'border-l-gray-500', titleColor: 'text-gray-700', name: 'Unknown', iconColor: 'text-gray-600' };
}
})());
function isSystemReminder(text: string) {
return text.includes('<system-reminder>') || text.includes('</system-reminder>');
}
function extractNonSystemContent(c: string) {
return c.split(/<system-reminder>[\s\S]*?<\/system-reminder>/g).filter((part) => part.trim()).join(' ').trim();
}
function isTextBlock(block: unknown): block is TextContentBlock {
return !!block && typeof block === 'object' && 'type' in block && block.type === 'text' && 'text' in block && typeof block.text === 'string';
}
let contentPreview = $derived((() => {
if (typeof message.content === 'string') {
const nonSystem = extractNonSystemContent(message.content);
if (!nonSystem && isSystemReminder(message.content)) return '[System reminder]';
return nonSystem.length > 300 ? nonSystem.substring(0, 300) + '...' : nonSystem;
}
if (Array.isArray(message.content)) {
const allText = message.content
.filter(isTextBlock)
.map((c) => extractNonSystemContent(c.text))
.filter(Boolean)
.join('\n');
if (!allText) {
if (message.content.some((c) => !!c && typeof c === 'object' && 'type' in c && c.type === 'tool_use')) return '[Tool call]';
if (message.content.some((c) => isTextBlock(c) && isSystemReminder(c.text))) return '[System reminder]';
return '[Context message]';
}
return allText.length > 300 ? allText.substring(0, 300) + '...' : allText;
}
if (message.content && typeof message.content === 'object' && 'type' in message.content && typeof message.content.type === 'string') {
return `[${message.content.type.replace('_', ' ')}]`;
}
try {
const str = JSON.stringify(message.content, null, 2);
return str.length > 300 ? str.substring(0, 300) + '...' : str;
} catch (error) { console.error('Failed to serialize message content:', error); return '[Complex content]'; }
})());
let shouldShowExpander = $derived((() => {
if (typeof message.content === 'string') return message.content.length > 300 || isSystemReminder(message.content);
if (Array.isArray(message.content)) {
const allText = message.content.filter(isTextBlock).map((c) => c.text).join('\n');
return allText.length > 300 || message.content.length > 1;
}
return true;
})());
</script>
<div class="relative">
{#if !isLast}
<div class="absolute left-5 top-16 w-0.5 h-8 bg-gray-200"></div>
{/if}
<div class="relative {message.isNewInTurn ? 'animate-in slide-in-from-left-2' : ''}">
{#if message.isNewInTurn}
<div class="absolute -left-2 top-0 w-1 h-full bg-gradient-to-b from-blue-500 to-transparent rounded-full opacity-60"></div>
{/if}
<div class="{roleConfig.bgColor} {roleConfig.borderColor} {roleConfig.accentColor} border border-l-4 rounded-xl p-5 {message.isNewInTurn ? 'ring-2 ring-blue-200/30 shadow-md' : 'shadow-sm'} transition-all duration-200 hover:shadow-md">
<div class="flex items-start space-x-4">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-white rounded-xl flex items-center justify-center border-2 border-gray-200 shadow-sm">
{#if message.role === 'user'}
<User class="w-5 h-5 {roleConfig.iconColor}" />
{:else if message.role === 'system'}
<Settings class="w-5 h-5 {roleConfig.iconColor}" />
{:else}
<Bot class="w-5 h-5 {roleConfig.iconColor}" />
{/if}
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<span class="font-semibold text-lg {roleConfig.titleColor}">{roleConfig.name}</span>
{#if message.isNewInTurn}
<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full border border-green-200 font-medium">NEW</span>
{/if}
<span class="text-xs text-gray-500 bg-white px-2 py-1 rounded-full border border-gray-200">#{index + 1}</span>
{#if message.turnNumber}
<span class="text-xs text-purple-600 bg-purple-50 px-2 py-1 rounded-full border border-purple-200">Turn {message.turnNumber}</span>
{/if}
</div>
<div class="flex items-center space-x-1 text-xs text-gray-500">
<Clock class="w-3 h-3" />
<span>{formatTime(message.timestamp)}</span>
</div>
</div>
<div class="space-y-4">
{#if shouldShowExpander && !isExpanded}
<div class="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<div class="text-sm text-gray-700 leading-relaxed">
{#if typeof message.content === 'string'}
<div class="whitespace-pre-wrap">{contentPreview}</div>
{:else}
<div class="space-y-2">
<div class="text-gray-600 font-medium">
{Array.isArray(message.content) ? `Message contains ${message.content.length} content blocks` : 'Complex content'}
</div>
{#if Array.isArray(message.content)}
<div class="text-xs text-gray-500 pl-2 border-l-2 border-gray-200">
{message.content.map((item) => typeof item === 'object' && item && 'type' in item && typeof item.type === 'string' ? item.type : 'unknown').join(' → ')}
</div>
{/if}
<div class="text-xs text-gray-500 mt-1 italic">{contentPreview}</div>
</div>
{/if}
</div>
<button onclick={() => (isExpanded = true)} class="mt-3 flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-800 transition-colors">
<ChevronRight class="w-4 h-4" />
<span>Show full content</span>
</button>
</div>
{:else}
<div class="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
{#if shouldShowExpander && isExpanded}
<div class="mb-3 pb-3 border-b border-gray-200">
<button onclick={() => (isExpanded = false)} class="flex items-center space-x-2 text-sm text-gray-600 hover:text-gray-800 transition-colors">
<ChevronDown class="w-4 h-4" />
<span>Collapse</span>
</button>
</div>
{/if}
<MessageContent content={message.content} />
</div>
{/if}
</div>
</div>
</div>
</div>
{#if !isLast}
<div class="flex items-center justify-center py-2">
<ArrowDown class="w-4 h-4 text-gray-400" />
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,232 @@
<script lang="ts">
import { page } from '$app/stores';
import {
BarChart3, MessageCircle, List, Settings, HelpCircle,
X, Copy, Check, Brain, Zap, Sparkles, Wrench, FileText
} from 'lucide-svelte';
import ThemeToggle from './ThemeToggle.svelte';
let currentPath = $derived($page.url.pathname);
let proxyUrl = $derived($page.data.proxyUrl);
let showSetupModal = $state(false);
let copiedSetup: Record<string, boolean> = $state({});
async function copySetupCommand(text: string, key: string) {
try {
await navigator.clipboard.writeText(text);
copiedSetup = { ...copiedSetup, [key]: true };
setTimeout(() => { copiedSetup = { ...copiedSetup, [key]: false }; }, 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
}
</script>
<header class="sticky top-0 z-40 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-6 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<a href="/" class="text-lg font-semibold text-gray-900 dark:text-gray-100 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">Claude Code Proxy</a>
</div>
<nav class="flex items-center space-x-1">
<a
href="/"
class="px-3 py-1.5 text-xs font-medium rounded transition-colors flex items-center space-x-1.5
{currentPath === '/' ? 'nav-active' : 'nav-inactive'}"
>
<List class="w-3.5 h-3.5" />
<span>Requests</span>
</a>
<a
href="/conversations"
class="px-3 py-1.5 text-xs font-medium rounded transition-colors flex items-center space-x-1.5
{currentPath === '/conversations' ? 'nav-active' : 'nav-inactive'}"
>
<MessageCircle class="w-3.5 h-3.5" />
<span>Conversations</span>
</a>
<a
href="/analytics"
class="px-3 py-1.5 text-xs font-medium rounded transition-colors flex items-center space-x-1.5
{currentPath === '/analytics' ? 'bg-indigo-600 text-white' : 'nav-inactive'}"
>
<BarChart3 class="w-3.5 h-3.5" />
<span>Analytics</span>
</a>
<span class="w-px h-5 bg-gray-200 dark:bg-gray-700 mx-1"></span>
<a
href="/chat"
class="px-3 py-1.5 text-xs font-medium rounded transition-colors flex items-center space-x-1.5
{currentPath === '/chat' ? 'bg-purple-600 text-white' : 'text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/30'}"
>
<MessageCircle class="w-3.5 h-3.5" />
<span>Chat</span>
</a>
<a
href="/settings"
class="px-3 py-1.5 text-xs font-medium rounded transition-colors flex items-center space-x-1.5
{currentPath === '/settings' ? 'nav-active' : 'nav-inactive'}"
>
<Settings class="w-3.5 h-3.5" />
<span>Settings</span>
</a>
<button onclick={() => (showSetupModal = true)} class="px-3 py-1.5 text-xs font-medium rounded transition-colors flex items-center space-x-1.5 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30" title="Setup Instructions">
<HelpCircle class="w-3.5 h-3.5" />
<span>Setup</span>
</button>
<ThemeToggle />
</nav>
</div>
</div>
</header>
<!-- Setup Instructions Modal -->
{#if showSetupModal}
<div class="fixed inset-0 bg-gray-900/70 backdrop-blur-sm z-50 flex items-center justify-center p-6" role="dialog" aria-modal="true" aria-label="Setup instructions">
<div class="bg-white rounded-xl max-w-3xl w-full max-h-[90vh] overflow-hidden shadow-2xl">
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 px-6 py-4 border-b border-blue-200">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<Settings class="w-5 h-5 text-blue-600" />
<h3 class="text-lg font-semibold text-gray-900">Proxy Setup Instructions</h3>
</div>
<button onclick={() => (showSetupModal = false)} class="text-gray-400 hover:text-gray-600 transition-colors p-2 hover:bg-white/50 rounded-lg">
<X class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 overflow-y-auto max-h-[calc(90vh-100px)] space-y-6">
<!-- Proxy URL -->
<div class="bg-gray-50 border border-gray-200 rounded-xl p-4">
<h4 class="text-sm font-semibold text-gray-900 mb-2">Your Proxy URL</h4>
<div class="flex items-center space-x-2">
<code class="flex-1 bg-white px-3 py-2 rounded-lg border border-gray-300 font-mono text-sm text-blue-700">{proxyUrl}</code>
<button onclick={() => copySetupCommand(proxyUrl, 'proxyUrl')} class="p-2 text-gray-500 hover:text-gray-700 bg-white rounded-lg border border-gray-300 transition-colors">
{#if copiedSetup.proxyUrl}<Check class="w-4 h-4 text-green-600" />{:else}<Copy class="w-4 h-4" />{/if}
</button>
</div>
</div>
<!-- Claude Code CLI -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div class="bg-purple-50 px-4 py-3 border-b border-purple-200">
<h4 class="text-sm font-semibold text-purple-900 flex items-center space-x-2">
<Brain class="w-4 h-4" />
<span>Claude Code CLI</span>
</h4>
</div>
<div class="p-4 space-y-3">
<p class="text-sm text-gray-600">Set the environment variable before running Claude Code:</p>
<div class="relative">
<pre class="bg-gray-900 text-gray-100 rounded-lg p-3 text-sm font-mono overflow-x-auto">export ANTHROPIC_BASE_URL={proxyUrl}</pre>
<button onclick={() => copySetupCommand(`export ANTHROPIC_BASE_URL=${proxyUrl}`, 'claudeCli')} class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white bg-gray-800 rounded transition-colors">
{#if copiedSetup.claudeCli}<Check class="w-3.5 h-3.5 text-green-400" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
</div>
<p class="text-xs text-gray-500">Or add to your <code class="bg-gray-100 px-1 rounded">~/.bashrc</code> / <code class="bg-gray-100 px-1 rounded">~/.zshrc</code> for persistence.</p>
</div>
</div>
<!-- Cursor IDE -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div class="bg-blue-50 px-4 py-3 border-b border-blue-200">
<h4 class="text-sm font-semibold text-blue-900 flex items-center space-x-2">
<Zap class="w-4 h-4" />
<span>Cursor IDE</span>
</h4>
</div>
<div class="p-4 space-y-3">
<p class="text-sm text-gray-600">Add to Cursor settings (<code class="bg-gray-100 px-1 rounded">Settings &gt; Models &gt; OpenAI API Key</code>):</p>
<ol class="text-sm text-gray-600 list-decimal list-inside space-y-1">
<li>Open Settings (<code class="bg-gray-100 px-1 rounded">Cmd/Ctrl + ,</code>)</li>
<li>Search for "OpenAI Base URL"</li>
<li>Set the base URL to: <code class="bg-gray-100 px-1 rounded">{proxyUrl}/v1</code></li>
</ol>
</div>
</div>
<!-- Continue.dev -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div class="bg-green-50 px-4 py-3 border-b border-green-200">
<h4 class="text-sm font-semibold text-green-900 flex items-center space-x-2">
<Sparkles class="w-4 h-4" />
<span>Continue.dev (VS Code)</span>
</h4>
</div>
<div class="p-4 space-y-3">
<p class="text-sm text-gray-600">Add to your <code class="bg-gray-100 px-1 rounded">~/.continue/config.json</code>:</p>
<div class="relative">
<pre class="bg-gray-900 text-gray-100 rounded-lg p-3 text-sm font-mono overflow-x-auto">{`{
"models": [{
"provider": "anthropic",
"model": "claude-sonnet-4-20250514",
"apiBase": "${proxyUrl}"
}]
}`}</pre>
<button onclick={() => copySetupCommand(`{\n "models": [{\n "provider": "anthropic",\n "model": "claude-sonnet-4-20250514",\n "apiBase": "${proxyUrl}"\n }]\n}`, 'continue')} class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white bg-gray-800 rounded transition-colors">
{#if copiedSetup.continue}<Check class="w-3.5 h-3.5 text-green-400" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
</div>
</div>
</div>
<!-- Python SDK -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div class="bg-yellow-50 px-4 py-3 border-b border-yellow-200">
<h4 class="text-sm font-semibold text-yellow-900 flex items-center space-x-2">
<FileText class="w-4 h-4" />
<span>Python (anthropic SDK)</span>
</h4>
</div>
<div class="p-4 space-y-3">
<div class="relative">
<pre class="bg-gray-900 text-gray-100 rounded-lg p-3 text-sm font-mono overflow-x-auto">{`import anthropic
client = anthropic.Anthropic(
base_url="${proxyUrl}"
)`}</pre>
<button onclick={() => copySetupCommand(`import anthropic\n\nclient = anthropic.Anthropic(\n base_url="${proxyUrl}"\n)`, 'python')} class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white bg-gray-800 rounded transition-colors">
{#if copiedSetup.python}<Check class="w-3.5 h-3.5 text-green-400" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
</div>
</div>
</div>
<!-- cURL -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div class="bg-gray-100 px-4 py-3 border-b border-gray-200">
<h4 class="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<Wrench class="w-4 h-4" />
<span>cURL / Direct API</span>
</h4>
</div>
<div class="p-4 space-y-3">
<div class="relative">
<pre class="bg-gray-900 text-gray-100 rounded-lg p-3 text-sm font-mono overflow-x-auto whitespace-pre-wrap">{`curl ${proxyUrl}/v1/messages \\
-H "Content-Type: application/json" \\
-H "x-api-key: $ANTHROPIC_API_KEY" \\
-H "anthropic-version: 2023-06-01" \\
-d '{"model": "claude-sonnet-4-20250514", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello!"}]}'`}</pre>
<button onclick={() => copySetupCommand(`curl ${proxyUrl}/v1/messages \\\n -H "Content-Type: application/json" \\\n -H "x-api-key: $ANTHROPIC_API_KEY" \\\n -H "anthropic-version: 2023-06-01" \\\n -d '{"model": "claude-sonnet-4-20250514", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello!"}]}'`, 'curl')} class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white bg-gray-800 rounded transition-colors">
{#if copiedSetup.curl}<Check class="w-3.5 h-3.5 text-green-400" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
</div>
</div>
</div>
<!-- Health Check -->
<div class="bg-emerald-50 border border-emerald-200 rounded-xl p-4">
<h4 class="text-sm font-semibold text-emerald-900 mb-2">Verify Connection</h4>
<p class="text-sm text-emerald-700">Test that the proxy is working:</p>
<div class="mt-2 relative">
<pre class="bg-emerald-900 text-emerald-100 rounded-lg p-3 text-sm font-mono">curl {proxyUrl}/health</pre>
<button onclick={() => copySetupCommand(`curl ${proxyUrl}/health`, 'health')} class="absolute top-2 right-2 p-1.5 text-emerald-300 hover:text-white bg-emerald-800 rounded transition-colors">
{#if copiedSetup.health}<Check class="w-3.5 h-3.5 text-green-400" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
</div>
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,687 @@
<script lang="ts">
import {
ChevronDown, Info, Settings, Cpu, MessageCircle, Brain,
User, Bot, Copy, Check, ArrowLeftRight, Activity, Clock,
Wifi, Calendar, List, FileText, Wrench
} from 'lucide-svelte';
import MessageContent from './MessageContent.svelte';
import { formatJSON, formatJSONFull } from '$lib/formatters';
import { getChatCompletionsEndpoint, getProviderName } from '$lib/models';
import type { Request } from '$lib/types';
interface Props {
request: Request;
onGrade: () => void;
}
let { request, onGrade }: Props = $props();
let expandedSections: Record<string, boolean> = $state({ overview: true });
let copied: Record<string, boolean> = $state({});
let headerViewMode: Record<string, 'pretty' | 'raw'> = $state({ request: 'pretty', response: 'pretty' });
function formatHeadersRaw(headers: Record<string, string[]>): string {
return Object.entries(headers)
.map(([key, values]) => values.map(v => `${key}: ${v}`).join('\n'))
.join('\n');
}
function toggleSection(section: string) {
expandedSections = { ...expandedSections, [section]: !expandedSections[section] };
}
async function handleCopy(content: string, key: string) {
try {
await navigator.clipboard.writeText(content);
copied = { ...copied, [key]: true };
setTimeout(() => { copied = { ...copied, [key]: false }; }, 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
}
function getMethodColor(method: string) {
const colors: Record<string, string> = {
GET: 'bg-green-50 text-green-700 border border-green-200',
POST: 'bg-blue-50 text-blue-700 border border-blue-200',
PUT: 'bg-yellow-50 text-yellow-700 border border-yellow-200',
DELETE: 'bg-red-50 text-red-700 border border-red-200'
};
return colors[method] || 'bg-gray-50 text-gray-700 border border-gray-200';
}
function getStatusColor(statusCode: number) {
if (statusCode >= 200 && statusCode < 300) return { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', icon: 'text-green-600' };
if (statusCode >= 400 && statusCode < 500) return { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-700', icon: 'text-yellow-600' };
if (statusCode >= 500) return { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700', icon: 'text-red-600' };
return { bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-700', icon: 'text-gray-600' };
}
function parseStreamingResponse(chunks: string[]) {
let assembledText = '';
let rawData = chunks.join('');
try {
const lines = rawData.split('\n').filter((line) => line.trim());
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.substring(6).trim();
if (!jsonStr.startsWith('{')) continue;
try {
const eventData = JSON.parse(jsonStr);
if (eventData.type === 'content_block_delta' && eventData.delta?.type === 'text_delta' && typeof eventData.delta.text === 'string') {
assembledText += eventData.delta.text;
}
} catch { continue; }
}
}
if (assembledText.trim().length > 0) return { finalText: assembledText, isFormatted: true, rawData };
const textMatches = rawData.match(/"text":"([^"]+)"/g);
if (textMatches) {
let fallbackText = '';
for (const match of textMatches) {
const text = match.match(/"text":"([^"]+)"/)?.[1];
if (text) fallbackText += text.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
if (fallbackText.trim()) return { finalText: fallbackText, isFormatted: true, rawData };
}
} catch (error) {
console.warn('Error parsing streaming response:', error);
}
return { finalText: rawData, isFormatted: false, rawData };
}
</script>
<div class="space-y-6">
<!-- Request Overview -->
<div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Info class="w-5 h-5 text-blue-600" />
<span>Request Overview</span>
</h4>
</div>
<div class="grid grid-cols-2 gap-6 text-sm">
<div class="space-y-3">
<div class="flex items-center space-x-3">
<span class="text-gray-500 font-medium min-w-[80px]">Method:</span>
<span class="px-3 py-1.5 rounded-lg text-xs font-semibold uppercase tracking-wide {getMethodColor(request.method)}">{request.method}</span>
</div>
<div class="flex items-center space-x-3">
<span class="text-gray-500 font-medium min-w-[80px]">Endpoint:</span>
<code class="text-blue-600 bg-blue-50 px-2 py-1 rounded font-mono text-xs border border-blue-200">{getChatCompletionsEndpoint(request.routedModel, request.endpoint)}</code>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center space-x-3">
<span class="text-gray-500 font-medium min-w-[80px]">Timestamp:</span>
<span class="text-gray-900">{new Date(request.timestamp).toLocaleString()}</span>
</div>
<div class="flex items-center space-x-3">
<span class="text-gray-500 font-medium min-w-[80px]">User Agent:</span>
<span class="text-gray-600 text-xs">{request.headers['User-Agent']?.[0] || 'N/A'}</span>
</div>
</div>
</div>
</div>
<!-- Headers -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('headers')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('headers'); } }}>
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Settings class="w-5 h-5 text-blue-600" />
<span>Request Headers</span>
<span class="text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded-full">{Object.keys(request.headers).length}</span>
</h4>
<ChevronDown class="w-5 h-5 text-gray-500 transition-transform {expandedSections.headers ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.headers}
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-2">
<button
onclick={() => headerViewMode = { ...headerViewMode, request: 'pretty' }}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors {headerViewMode.request === 'pretty' ? 'bg-blue-100 text-blue-700 border border-blue-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'}"
>Pretty</button>
<button
onclick={() => headerViewMode = { ...headerViewMode, request: 'raw' }}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors {headerViewMode.request === 'raw' ? 'bg-blue-100 text-blue-700 border border-blue-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'}"
>Raw HTTP</button>
</div>
<button onclick={() => handleCopy(headerViewMode.request === 'raw' ? formatHeadersRaw(request.headers) : formatJSON(request.headers), 'headers')} class="p-1.5 text-gray-500 hover:text-gray-700 transition-colors rounded border border-gray-200 bg-white" title="Copy headers">
{#if copied.headers}<Check class="w-4 h-4 text-green-600" />{:else}<Copy class="w-4 h-4" />{/if}
</button>
</div>
{#if headerViewMode.request === 'pretty'}
<div class="bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-100 border-b border-gray-200">
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide w-1/3">Header</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide">Value</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each Object.entries(request.headers) as [key, values]}
<tr class="hover:bg-gray-100 transition-colors">
<td class="px-4 py-2 font-mono text-xs text-blue-700 font-medium align-top">{key}</td>
<td class="px-4 py-2 font-mono text-xs text-gray-700 break-all">
{#each values as value, i}
<div class={i > 0 ? 'mt-1 pt-1 border-t border-gray-100' : ''}>{value}</div>
{/each}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<pre class="text-sm text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">{formatHeadersRaw(request.headers)}</pre>
</div>
{/if}
</div>
{/if}
</div>
{#if request.body}
<!-- System Messages -->
{#if request.body.system}
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('system')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('system'); } }}>
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Cpu class="w-5 h-5 text-yellow-600" />
<span>System Instructions</span>
<span class="text-xs bg-yellow-50 text-yellow-700 px-2 py-1 rounded-full border border-yellow-200">{request.body.system.length} items</span>
</h4>
<ChevronDown class="w-5 h-5 text-gray-500 transition-transform {expandedSections.system ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.system}
<div class="p-6 space-y-4">
{#each request.body.system as sys, index}
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-yellow-700 font-medium text-sm">System Message #{index + 1}</span>
{#if sys.cache_control}
<span class="text-xs bg-orange-100 text-orange-700 px-2 py-1 rounded-full border border-orange-200">Cache: {sys.cache_control.type}</span>
{/if}
</div>
<div class="bg-white rounded p-3 border border-gray-200">
<MessageContent content={{ type: 'text', text: sys.text }} />
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Tools -->
{#if request.body.tools && request.body.tools.length > 0}
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('tools')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('tools'); } }}>
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Wrench class="w-5 h-5 text-indigo-600" />
<span>Available Tools</span>
<span class="text-xs bg-indigo-50 text-indigo-700 px-2 py-1 rounded-full border border-indigo-200">{request.body.tools.length} tools</span>
</h4>
<ChevronDown class="w-5 h-5 text-gray-500 transition-transform {expandedSections.tools ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.tools}
<div class="p-6 space-y-4">
{#each request.body.tools as tool, index}
{@const isLongDesc = tool.description.length > 300}
<div class="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div class="p-5">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-white rounded-lg flex items-center justify-center border border-gray-200 shadow-sm">
<Wrench class="w-5 h-5 text-gray-600" />
</div>
<div>
<h5 class="text-lg font-bold text-gray-900">{tool.name}</h5>
<span class="text-xs text-gray-500">Tool #{index + 1}</span>
</div>
</div>
</div>
{#if isLongDesc}
<details class="cursor-pointer">
<summary class="text-sm text-gray-700 leading-relaxed">{tool.description.slice(0, 300)}...</summary>
<div class="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap mt-2">{tool.description}</div>
</details>
{:else}
<div class="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">{tool.description}</div>
{/if}
{#if tool.input_schema}
<details class="mt-4 cursor-pointer">
<summary class="text-xs font-semibold text-gray-700 flex items-center space-x-2">
<Settings class="w-3.5 h-3.5" />
<span>Input Schema</span>
</summary>
<div class="mt-2 bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="p-3">
<pre class="text-xs text-gray-700 overflow-x-auto font-mono">{formatJSON(tool.input_schema)}</pre>
</div>
</div>
</details>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Conversation -->
{#if request.body.messages}
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('conversation')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('conversation'); } }}>
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<MessageCircle class="w-5 h-5 text-blue-600" />
<span>Conversation</span>
<span class="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded-full border border-blue-200">{request.body.messages.length} messages</span>
</h4>
<ChevronDown class="w-5 h-5 text-gray-500 transition-transform {expandedSections.conversation ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.conversation}
<div class="p-6 space-y-4 max-h-[600px] overflow-y-auto">
{#each request.body.messages as message, index}
{@const roleColors: Record<string, string> = { user: 'bg-blue-50 border border-blue-200', assistant: 'bg-gray-50 border border-gray-200', system: 'bg-yellow-50 border border-yellow-200' }}
{@const roleIconColors: Record<string, string> = { user: 'text-blue-600', assistant: 'text-gray-600', system: 'text-yellow-600' }}
<div class="rounded-lg p-4 {roleColors[message.role] || 'bg-gray-50 border border-gray-200'}">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center border border-gray-200">
{#if message.role === 'user'}
<User class="w-4 h-4 {roleIconColors[message.role] || 'text-gray-600'}" />
{:else if message.role === 'system'}
<Settings class="w-4 h-4 {roleIconColors[message.role] || 'text-gray-600'}" />
{:else}
<Bot class="w-4 h-4 {roleIconColors[message.role] || 'text-gray-600'}" />
{/if}
</div>
<span class="font-medium capitalize text-gray-900">{message.role}</span>
<span class="text-xs text-gray-500 bg-white px-2 py-1 rounded-full border border-gray-200">#{index + 1}</span>
</div>
</div>
<div>
<MessageContent content={message.content} />
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Model Configuration -->
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('model')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('model'); } }}>
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Brain class="w-5 h-5 text-purple-600" />
<span>Model Configuration</span>
</h4>
<ChevronDown class="w-5 h-5 text-gray-500 transition-transform {expandedSections.model ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.model}
<div class="p-6 space-y-4">
{#if request.routedModel && request.routedModel !== request.originalModel}
<div class="bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-200 rounded-xl p-4">
<div class="flex items-center space-x-4">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-2">
<span class="text-sm font-semibold text-purple-700">Requested Model</span>
<code class="text-xs bg-white px-2 py-1 rounded font-mono border border-purple-200">{request.originalModel || request.body.model}</code>
</div>
<div class="flex items-center space-x-3">
<div class="flex items-center space-x-2">
<ArrowLeftRight class="w-4 h-4 text-purple-600" />
<span class="text-xs text-purple-600 font-medium">Routed to</span>
</div>
<code class="text-sm bg-white px-3 py-1.5 rounded font-mono font-semibold border border-blue-200 text-blue-700">{request.routedModel}</code>
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full border border-blue-200">{getProviderName(request.routedModel)}</span>
</div>
</div>
<div class="text-right">
<div class="text-xs text-gray-500 mb-1">Target Endpoint</div>
<code class="text-xs bg-white px-2 py-1 rounded font-mono border border-gray-200">{getChatCompletionsEndpoint(request.routedModel)}</code>
</div>
</div>
</div>
{/if}
<div class="grid grid-cols-2 gap-4">
{#if !request.routedModel || request.routedModel === request.originalModel}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div class="text-xs text-gray-500 mb-1">Model</div>
<div class="text-sm font-medium text-gray-900">{request.originalModel || request.body.model || 'N/A'}</div>
</div>
{/if}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div class="text-xs text-gray-500 mb-1">Max Tokens</div>
<div class="text-sm font-medium text-gray-900">{request.body.max_tokens?.toLocaleString() || 'N/A'}</div>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div class="text-xs text-gray-500 mb-1">Temperature</div>
<div class="text-sm font-medium text-gray-900">{request.body.temperature ?? 'N/A'}</div>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div class="text-xs text-gray-500 mb-1">Stream</div>
<div class="text-sm font-medium text-gray-900">{request.body.stream ? 'Yes' : 'No'}</div>
</div>
</div>
</div>
{/if}
</div>
{/if}
<!-- API Response -->
{#if request.response}
{@const response = request.response}
{@const statusColors = getStatusColor(response.statusCode)}
{@const completedAt = response.completedAt ? new Date(response.completedAt).toLocaleString() : 'Unknown'}
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm border-l-4 border-l-blue-500">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('responseOverview')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('responseOverview'); } }}>
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<ArrowLeftRight class="w-5 h-5 text-blue-600" />
<span>API Response</span>
<span class="text-xs px-2 py-1 rounded-full border {statusColors.bg} {statusColors.text} {statusColors.border}">{response.statusCode}</span>
</h4>
<ChevronDown class="w-5 h-5 text-gray-500 transition-transform {expandedSections.responseOverview ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.responseOverview}
<div class="p-6 space-y-6">
<!-- Response overview grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="{statusColors.bg} border {statusColors.border} rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Activity class="w-4 h-4 {statusColors.icon}" /><span class="text-xs font-medium {statusColors.text}">Status</span></div>
<div class="text-lg font-bold {statusColors.text}">{response.statusCode}</div>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Clock class="w-4 h-4 text-blue-600" /><span class="text-xs font-medium text-blue-700">Response Time</span></div>
<div class="text-lg font-bold text-blue-700">{response.responseTime}ms</div>
</div>
<div class="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Wifi class="w-4 h-4 text-purple-600" /><span class="text-xs font-medium text-purple-700">Type</span></div>
<div class="text-lg font-bold text-purple-700">{response.isStreaming ? 'Stream' : 'Single'}</div>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Calendar class="w-4 h-4 text-gray-600" /><span class="text-xs font-medium text-gray-700">Completed</span></div>
<div class="text-sm font-bold text-gray-700">{completedAt.split(' ')[1] || 'N/A'}</div>
</div>
</div>
<!-- Token Usage -->
{#if response.body?.usage}
{@const usage = response.body.usage}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Brain class="w-4 h-4 text-indigo-600" /><span class="text-xs font-medium text-indigo-700">Input Tokens</span></div>
<div class="text-lg font-bold text-indigo-700">{usage.input_tokens?.toLocaleString() || '0'}</div>
</div>
<div class="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><MessageCircle class="w-4 h-4 text-emerald-600" /><span class="text-xs font-medium text-emerald-700">Output Tokens</span></div>
<div class="text-lg font-bold text-emerald-700">{usage.output_tokens?.toLocaleString() || '0'}</div>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Cpu class="w-4 h-4 text-amber-600" /><span class="text-xs font-medium text-amber-700">Total Tokens</span></div>
<div class="text-lg font-bold text-amber-700">{((usage.input_tokens || 0) + (usage.output_tokens || 0)).toLocaleString()}</div>
</div>
{#if usage.cache_read_input_tokens}
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-center space-x-2 mb-2"><Bot class="w-4 h-4 text-green-600" /><span class="text-xs font-medium text-green-700">Cached Tokens</span></div>
<div class="text-lg font-bold text-green-700">{usage.cache_read_input_tokens.toLocaleString()}</div>
</div>
{/if}
</div>
{/if}
<!-- Response Headers -->
{#if response.headers}
<div class="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('responseHeaders')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('responseHeaders'); } }}>
<div class="flex items-center justify-between">
<h5 class="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<List class="w-4 h-4 text-gray-600" />
<span>Response Headers</span>
<span class="text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded-full">{Object.keys(response.headers).length}</span>
</h5>
<ChevronDown class="w-4 h-4 text-gray-500 transition-transform {expandedSections.responseHeaders ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.responseHeaders}
<div class="px-4 py-4 space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<button
onclick={() => headerViewMode = { ...headerViewMode, response: 'pretty' }}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors {headerViewMode.response === 'pretty' ? 'bg-blue-100 text-blue-700 border border-blue-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'}"
>Pretty</button>
<button
onclick={() => headerViewMode = { ...headerViewMode, response: 'raw' }}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors {headerViewMode.response === 'raw' ? 'bg-blue-100 text-blue-700 border border-blue-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'}"
>Raw HTTP</button>
</div>
<button onclick={() => handleCopy(headerViewMode.response === 'raw' ? formatHeadersRaw(response.headers) : formatJSON(response.headers), 'responseHeaders')} class="p-1.5 text-gray-500 hover:text-gray-700 transition-colors rounded border border-gray-200 bg-white" title="Copy headers">
{#if copied.responseHeaders}<Check class="w-4 h-4 text-green-600" />{:else}<Copy class="w-4 h-4" />{/if}
</button>
</div>
{#if headerViewMode.response === 'pretty'}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-100 border-b border-gray-200">
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide w-1/3">Header</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide">Value</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each Object.entries(response.headers) as [key, values]}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-2 font-mono text-xs text-blue-700 font-medium align-top">{key}</td>
<td class="px-4 py-2 font-mono text-xs text-gray-700 break-all">
{#if Array.isArray(values)}
{#each values as value, i}
<div class={i > 0 ? 'mt-1 pt-1 border-t border-gray-100' : ''}>{value}</div>
{/each}
{:else}
{values}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
<pre class="text-xs text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">{formatHeadersRaw(response.headers)}</pre>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Response Body - Structured Display -->
{#if response.body || response.bodyText}
<div class="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('responseBody')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('responseBody'); } }}>
<div class="flex items-center justify-between">
<h5 class="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<FileText class="w-4 h-4 text-gray-600" />
<span>Response Content</span>
{#if response.body?.content && Array.isArray(response.body.content)}
<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full border border-green-200">{response.body.content.length} blocks</span>
{/if}
{#if response.body?.stop_reason}
<span class="text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded-full">{response.body.stop_reason}</span>
{/if}
</h5>
<ChevronDown class="w-4 h-4 text-gray-500 transition-transform {expandedSections.responseBody ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.responseBody}
<div class="px-4 pb-4 space-y-4">
{#if response.body}
<!-- Response Metadata -->
{#if response.body.id || response.body.model}
<div class="flex flex-wrap gap-2 text-xs">
{#if response.body.id}
<span class="bg-gray-200 text-gray-700 px-2 py-1 rounded font-mono">{response.body.id}</span>
{/if}
{#if response.body.model}
<span class="bg-purple-100 text-purple-700 px-2 py-1 rounded border border-purple-200">{response.body.model}</span>
{/if}
{#if response.body.role}
<span class="bg-blue-100 text-blue-700 px-2 py-1 rounded border border-blue-200">{response.body.role}</span>
{/if}
</div>
{/if}
<!-- Content Blocks -->
{#if response.body.content && Array.isArray(response.body.content)}
<div class="space-y-3">
{#each response.body.content as block, idx}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-3 py-2 bg-gray-100 border-b border-gray-200 flex items-center justify-between">
<div class="flex items-center space-x-2">
<span class="text-xs font-medium text-gray-600">#{idx + 1}</span>
<span class="text-xs font-semibold px-2 py-0.5 rounded {block.type === 'text' ? 'bg-blue-100 text-blue-700' : block.type === 'tool_use' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-gray-700'}">{block.type}</span>
{#if block.name}
<span class="text-xs font-mono text-gray-600">{block.name}</span>
{/if}
</div>
{#if block.id}
<span class="text-xs font-mono text-gray-400">{block.id}</span>
{/if}
</div>
<div class="p-3">
{#if block.type === 'text' && block.text}
<div class="prose prose-sm max-w-none">
<pre class="whitespace-pre-wrap text-sm text-gray-800 font-sans leading-relaxed bg-gray-50 rounded p-3 border border-gray-100 max-h-[500px] overflow-y-auto">{block.text}</pre>
</div>
{:else if block.type === 'tool_use'}
<div class="space-y-2">
{#if block.input}
<details class="cursor-pointer" open>
<summary class="text-xs font-medium text-gray-600 mb-1">Tool Input</summary>
<pre class="text-xs text-gray-700 bg-gray-50 rounded p-2 border border-gray-200 overflow-x-auto max-h-64 overflow-y-auto font-mono">{formatJSON(block.input)}</pre>
</details>
{/if}
</div>
{:else if block.type === 'thinking' && block.thinking}
<div class="bg-amber-50 rounded p-3 border border-amber-200">
<div class="flex items-center space-x-2 mb-2">
<Brain class="w-4 h-4 text-amber-600" />
<span class="text-xs font-semibold text-amber-700">Thinking</span>
</div>
<pre class="whitespace-pre-wrap text-sm text-amber-900 leading-relaxed max-h-[400px] overflow-y-auto">{block.thinking}</pre>
</div>
{:else}
<pre class="text-xs text-gray-700 overflow-x-auto font-mono">{formatJSON(block)}</pre>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<!-- Fallback to raw JSON for non-standard responses -->
<details class="cursor-pointer">
<summary class="text-sm font-medium text-gray-700">Raw Response Body</summary>
<pre class="text-xs text-gray-700 overflow-x-auto max-h-96 overflow-y-auto mt-2 bg-white rounded p-3 border border-gray-200 font-mono">{formatJSON(response.body)}</pre>
</details>
{/if}
<!-- Raw JSON toggle -->
<details class="cursor-pointer mt-4">
<summary class="text-xs font-medium text-gray-500 hover:text-gray-700">View Raw JSON</summary>
<div class="mt-2 relative">
<button
onclick={() => handleCopy(formatJSONFull(response.body), 'responseBodyRaw')}
class="absolute top-2 right-2 p-1.5 bg-white rounded border border-gray-300 text-gray-500 hover:text-gray-700 transition-colors z-10"
title="Copy JSON"
>
{#if copied.responseBodyRaw}<Check class="w-3.5 h-3.5 text-green-600" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
</button>
<pre class="text-xs text-gray-700 overflow-x-auto max-h-96 overflow-y-auto bg-white rounded p-3 border border-gray-200 font-mono">{formatJSON(response.body)}</pre>
</div>
</details>
{:else if response.bodyText}
<pre class="text-sm text-gray-700 whitespace-pre-wrap bg-white rounded p-3 border border-gray-200">{response.bodyText}</pre>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Streaming Response -->
{#if response.isStreaming && response.streamingChunks && response.streamingChunks.length > 0}
{@const parsed = parseStreamingResponse(response.streamingChunks)}
<div class="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 cursor-pointer" role="button" tabindex="0" onclick={() => toggleSection('streamingResponse')} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection('streamingResponse'); } }}>
<div class="flex items-center justify-between">
<h5 class="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<Wifi class="w-4 h-4 text-gray-600" />
<span>Streaming Response</span>
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full border border-blue-200">{response.streamingChunks.length} chunks</span>
{#if parsed.isFormatted}
<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full border border-green-200">Parsed</span>
{/if}
</h5>
<ChevronDown class="w-4 h-4 text-gray-500 transition-transform {expandedSections.streamingResponse ? 'rotate-180' : ''}" />
</div>
</div>
{#if expandedSections.streamingResponse}
<div class="px-4 pb-4 space-y-3">
{#if parsed.isFormatted}
<div class="bg-white rounded-lg p-4 border border-green-200">
<h6 class="text-sm font-semibold text-green-900 flex items-center space-x-2 mb-3">
<Check class="w-4 h-4" />
<span>Final Response (Clean)</span>
</h6>
<pre class="text-sm text-gray-900 whitespace-pre-wrap leading-relaxed bg-gray-50 rounded p-3 border border-gray-200">{parsed.finalText}</pre>
</div>
{/if}
<details class="bg-gray-50 rounded-lg border border-gray-200">
<summary class="px-3 py-2 cursor-pointer text-sm font-medium text-gray-700">Raw Streaming Data</summary>
<div class="px-3 pb-3">
<pre class="text-xs text-gray-600 overflow-x-auto max-h-64 overflow-y-auto bg-gray-100 rounded p-2 font-mono">{parsed.rawData}</pre>
</div>
</details>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Prompt Grading Results -->
{#if request.promptGrade}
<div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<h4 class="text-lg font-semibold text-gray-900 mb-4">Prompt Quality Analysis</h4>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-gray-700">Overall Score:</span>
<span class="text-2xl font-bold text-blue-600">{request.promptGrade.score}/5</span>
</div>
<div class="text-sm text-gray-600"><p>{request.promptGrade.feedback}</p></div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,105 @@
<script lang="ts">
import RichTextInline from './RichTextInline.svelte';
import { parseRichText } from '$lib/rich-text';
interface Props {
text: string;
variant?: 'default' | 'inverse' | 'muted';
size?: 'xs' | 'sm';
className?: string;
}
let { text, variant = 'default', size = 'sm', className = '' }: Props = $props();
let blocks = $derived(parseRichText(text));
function textClass() {
const sizeClass = size === 'xs' ? 'text-xs' : 'text-sm';
switch (variant) {
case 'inverse':
return `${sizeClass} text-white`;
case 'muted':
return `${sizeClass} text-gray-600`;
default:
return `${sizeClass} text-gray-700`;
}
}
function headingClass(level: number) {
const tone =
variant === 'inverse'
? 'text-white'
: variant === 'muted'
? 'text-gray-700'
: 'text-gray-900';
switch (level) {
case 1:
return `text-xl font-bold ${tone}`;
case 2:
return `text-lg font-bold ${tone}`;
case 3:
return `text-base font-semibold ${tone}`;
case 4:
return `text-sm font-semibold ${tone}`;
default:
return `text-xs font-semibold ${tone}`;
}
}
function codeBlockClass() {
if (variant === 'inverse') {
return 'bg-blue-600/40 text-white border border-blue-300/30';
}
return 'bg-gray-900 text-gray-100 border border-gray-700';
}
function listClass(type: 'ul' | 'ol') {
return `${textClass()} ${type === 'ul' ? 'list-disc' : 'list-decimal'} list-inside space-y-1 pl-1`;
}
function headingTag(level: number): keyof HTMLElementTagNameMap {
switch (level) {
case 1:
return 'h1';
case 2:
return 'h2';
case 3:
return 'h3';
case 4:
return 'h4';
case 5:
return 'h5';
default:
return 'h6';
}
}
</script>
<div class={`space-y-3 ${className}`.trim()}>
{#each blocks as block, index (`${block.type}-${index}`)}
{#if block.type === 'paragraph'}
<p class={`${textClass()} leading-relaxed break-words`}>
<RichTextInline segments={block.content} variant={variant} />
</p>
{:else if block.type === 'heading'}
<svelte:element this={headingTag(block.level)} class={headingClass(block.level)}>
<RichTextInline segments={block.content} variant={variant} />
</svelte:element>
{:else if block.type === 'ul' || block.type === 'ol'}
<svelte:element this={block.type} class={listClass(block.type)}>
{#each block.items as item, itemIndex (`item-${itemIndex}`)}
<li class="leading-relaxed">
<RichTextInline segments={item} variant={variant} />
</li>
{/each}
</svelte:element>
{:else if block.type === 'code_block'}
<pre class={`${codeBlockClass()} rounded-lg p-4 overflow-x-auto font-mono ${size === 'xs' ? 'text-xs' : 'text-sm'}`}><code>{block.code}</code></pre>
{:else if block.type === 'hr'}
<hr class={variant === 'inverse' ? 'border-blue-200/30' : 'border-gray-300'} />
{:else}
<div class={size === 'xs' ? 'h-2' : 'h-3'}></div>
{/if}
{/each}
</div>

View file

@ -0,0 +1,79 @@
<script lang="ts">
import type { RichTextInline } from '$lib/rich-text';
interface Props {
segments: RichTextInline[];
variant?: 'default' | 'inverse' | 'muted';
}
let { segments, variant = 'default' }: Props = $props();
function textClass() {
switch (variant) {
case 'inverse':
return 'text-white';
case 'muted':
return 'text-gray-600';
default:
return 'text-gray-700';
}
}
function strongClass() {
switch (variant) {
case 'inverse':
return 'font-semibold text-white';
case 'muted':
return 'font-semibold text-gray-700';
default:
return 'font-semibold text-gray-900';
}
}
function emClass() {
switch (variant) {
case 'inverse':
return 'italic text-blue-100';
case 'muted':
return 'italic text-gray-600';
default:
return 'italic text-gray-700';
}
}
function codeClass() {
switch (variant) {
case 'inverse':
return 'bg-blue-400/30 border border-blue-300/30 text-white px-1 py-0.5 rounded text-[0.85em] font-mono';
case 'muted':
return 'bg-gray-100 text-gray-700 px-1 py-0.5 rounded text-[0.85em] font-mono border border-gray-200';
default:
return 'bg-gray-100 text-gray-800 px-1 py-0.5 rounded text-[0.85em] font-mono border border-gray-200';
}
}
function linkClass() {
switch (variant) {
case 'inverse':
return 'text-blue-200 hover:text-white underline underline-offset-2';
case 'muted':
return 'text-blue-600 hover:text-blue-800 underline underline-offset-2';
default:
return 'text-blue-600 hover:text-blue-800 underline underline-offset-2';
}
}
</script>
{#each segments as segment, index (`${segment.type}-${index}`)}
{#if segment.type === 'text'}
<span class={textClass()}>{segment.text}</span>
{:else if segment.type === 'strong'}
<strong class={strongClass()}>{segment.text}</strong>
{:else if segment.type === 'em'}
<em class={emClass()}>{segment.text}</em>
{:else if segment.type === 'code'}
<code class={codeClass()}>{segment.text}</code>
{:else}
<a href={segment.href} target="_blank" rel="noopener noreferrer" class={linkClass()}>{segment.text}</a>
{/if}
{/each}

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { Sun, Moon, Monitor } from 'lucide-svelte';
import { getTheme, cycleTheme } from '$lib/theme.svelte';
let current = $derived(getTheme());
</script>
<button
onclick={cycleTheme}
class="p-1.5 rounded transition-colors text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
title="Theme: {current}"
>
{#if current === 'light'}
<Sun class="w-3.5 h-3.5" />
{:else if current === 'dark'}
<Moon class="w-3.5 h-3.5" />
{:else}
<Monitor class="w-3.5 h-3.5" />
{/if}
</button>

View file

@ -0,0 +1,92 @@
<script lang="ts">
import { CheckSquare, Square, Clock, AlertCircle, ListTodo } from 'lucide-svelte';
import type { TodoItem } from '$lib/types';
interface Props {
todos: TodoItem[];
}
let { todos }: Props = $props();
let groupedTodos = $derived({
in_progress: todos.filter((t) => t.status === 'in_progress'),
pending: todos.filter((t) => t.status === 'pending'),
completed: todos.filter((t) => t.status === 'completed')
});
function getTaskText(todo: TodoItem): string {
return (
todo.task || todo.description || todo.content || todo.title || todo.text ||
(Object.entries(todo).find(([key, value]) => typeof value === 'string' && !['priority', 'status'].includes(key))?.[1] as string | undefined) ||
'No task description'
);
}
function getPriorityColor(priority: string) {
switch (priority) {
case 'high': return 'text-red-600 bg-red-50 border-red-200';
case 'medium': return 'text-yellow-600 bg-yellow-50 border-yellow-200';
case 'low': return 'text-green-600 bg-green-50 border-green-200';
default: return 'text-gray-600 bg-gray-50 border-gray-200';
}
}
function getStatusColor(status: string) {
switch (status) {
case 'completed': return 'bg-green-50 border-green-200';
case 'in_progress': return 'bg-blue-50 border-blue-200';
default: return 'bg-gray-50 border-gray-200';
}
}
</script>
{#if !todos || todos.length === 0}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
<ListTodo class="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p class="text-sm text-gray-600">No tasks in the todo list</p>
</div>
{:else}
<div class="space-y-3">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-2">
<ListTodo class="w-4 h-4 text-indigo-600" />
<span class="text-sm font-semibold text-gray-900">Todo List</span>
</div>
<div class="flex items-center space-x-2 text-xs">
{#if groupedTodos.in_progress.length > 0}
<span class="px-2 py-1 bg-blue-100 text-blue-700 rounded-full border border-blue-200">{groupedTodos.in_progress.length} in progress</span>
{/if}
{#if groupedTodos.pending.length > 0}
<span class="px-2 py-1 bg-gray-100 text-gray-700 rounded-full border border-gray-200">{groupedTodos.pending.length} pending</span>
{/if}
{#if groupedTodos.completed.length > 0}
<span class="px-2 py-1 bg-green-100 text-green-700 rounded-full border border-green-200">{groupedTodos.completed.length} completed</span>
{/if}
</div>
</div>
<div class="space-y-2">
{#each groupedTodos.in_progress as todo}
<div class="flex items-start space-x-3 p-3 rounded-lg border {getStatusColor(todo.status)} transition-all duration-200">
<div class="flex-shrink-0 mt-0.5"><Clock class="w-4 h-4 text-blue-600 animate-pulse" /></div>
<div class="flex-1 min-w-0"><p class="text-sm text-gray-900">{getTaskText(todo)}</p></div>
<div class="flex-shrink-0"><span class="text-xs px-2 py-1 rounded-full border font-medium {getPriorityColor(todo.priority)}">{todo.priority}</span></div>
</div>
{/each}
{#each groupedTodos.pending as todo}
<div class="flex items-start space-x-3 p-3 rounded-lg border {getStatusColor(todo.status)} transition-all duration-200">
<div class="flex-shrink-0 mt-0.5"><Square class="w-4 h-4 text-gray-400" /></div>
<div class="flex-1 min-w-0"><p class="text-sm text-gray-900">{getTaskText(todo)}</p></div>
<div class="flex-shrink-0"><span class="text-xs px-2 py-1 rounded-full border font-medium {getPriorityColor(todo.priority)}">{todo.priority}</span></div>
</div>
{/each}
{#each groupedTodos.completed as todo}
<div class="flex items-start space-x-3 p-3 rounded-lg border {getStatusColor(todo.status)} transition-all duration-200">
<div class="flex-shrink-0 mt-0.5"><CheckSquare class="w-4 h-4 text-green-600" /></div>
<div class="flex-1 min-w-0"><p class="text-sm line-through text-gray-500">{getTaskText(todo)}</p></div>
<div class="flex-shrink-0"><span class="text-xs px-2 py-1 rounded-full border font-medium {getPriorityColor(todo.priority)}">{todo.priority}</span></div>
</div>
{/each}
</div>
</div>
{/if}

View file

@ -0,0 +1,182 @@
<script lang="ts">
import { ChevronDown, ChevronRight, CheckCircle, AlertCircle, FileText, Database, Clock } from 'lucide-svelte';
import { formatValue, formatJSON, isComplexObject, truncateText } from '$lib/formatters';
import CodeViewer from './CodeViewer.svelte';
interface Props {
content: unknown;
toolId?: string;
isError?: boolean;
}
let { content, toolId, isError = false }: Props = $props();
let isExpanded = $state(false);
function isCodeContent(c: string): boolean {
if (typeof c !== 'string') return false;
const hasLineNumbers = /^\s*\d+→/m.test(c);
const hasCodePatterns =
c.includes('function') || c.includes('const ') || c.includes('let ') ||
c.includes('var ') || c.includes('import ') || c.includes('export ') ||
c.includes('class ') || c.includes('interface ') || c.includes('type ') ||
c.includes('def ') || c.includes('if (') || c.includes('for (') ||
c.includes('while (') || (c.includes('{') && c.includes('}'));
return hasLineNumbers || (hasCodePatterns && c.length > 100);
}
function extractCodeFromCatN(c: string): { code: string; fileName?: string } {
if (typeof c !== 'string') return { code: c };
if (!/^\s*\d+→/m.test(c)) return { code: c };
const lines = c.split('\n');
const codeLines = lines.map((line) => {
const match = line.match(/^\s*\d+→(.*)$/);
return match ? match[1] : line;
});
return { code: codeLines.join('\n') };
}
function getDisplayContent(): string {
if (typeof content === 'string') return content;
if (content && typeof content === 'object') {
const obj = content as Record<string, unknown>;
if (typeof obj.text === 'string') return obj.text;
if (typeof obj.content === 'string') return obj.content;
}
if (Array.isArray(content)) return content.map((item) => formatValue(item)).join('\n');
if (isComplexObject(content)) return formatJSON(content);
return formatValue(content);
}
let displayContent = $derived(getDisplayContent());
let isLargeContent = $derived(displayContent.length > 2000);
let shouldTruncate = $derived(isLargeContent && !isExpanded);
let truncatedContent = $derived(shouldTruncate ? truncateText(displayContent, 2000) : displayContent);
let isJSONContent = $derived(isComplexObject(content) || (typeof content === 'string' && content.startsWith('{')));
let isCode = $derived(isCodeContent(displayContent));
let extractedCode = $derived(isCode ? extractCodeFromCatN(displayContent).code : displayContent);
let config = $derived(
isError
? {
bgColor: 'bg-gradient-to-r from-red-50 to-pink-50',
borderColor: 'border-red-200',
accentColor: 'border-l-red-500',
iconBg: 'bg-red-100',
iconColor: 'text-red-600',
titleColor: 'text-red-900',
title: 'Tool Error',
dotColor: 'bg-red-500',
statusText: 'Execution failed'
}
: {
bgColor: 'bg-gradient-to-r from-emerald-50 to-green-50',
borderColor: 'border-emerald-200',
accentColor: 'border-l-emerald-500',
iconBg: 'bg-emerald-100',
iconColor: 'text-emerald-600',
titleColor: 'text-emerald-900',
title: 'Tool Result',
dotColor: 'bg-emerald-500',
statusText: 'Execution completed'
}
);
</script>
<div class="{config.bgColor} {config.borderColor} {config.accentColor} border border-l-4 rounded-xl p-5 shadow-sm hover:shadow-md transition-all duration-200">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 {config.iconBg} rounded-xl flex items-center justify-center shadow-sm">
<div class={config.iconColor}>
{#if isError}
<AlertCircle class="w-5 h-5" />
{:else}
<CheckCircle class="w-5 h-5" />
{/if}
</div>
</div>
<div>
<div class="flex items-center space-x-2">
<span class="font-semibold text-base {config.titleColor}">{config.title}</span>
<Database class="w-4 h-4 text-gray-500" />
</div>
{#if toolId}
<div class="flex items-center space-x-2 mt-1">
<FileText class="w-3 h-3 text-gray-500" />
<span class="text-xs text-gray-500 font-mono bg-white px-2 py-1 rounded-md border border-gray-200">{toolId}</span>
</div>
{/if}
</div>
</div>
{#if isLargeContent}
<button onclick={() => (isExpanded = !isExpanded)} class="flex items-center space-x-2 text-xs text-gray-600 hover:text-gray-800 bg-white hover:bg-gray-50 px-3 py-2 rounded-lg border border-gray-200 transition-all duration-200">
{#if isExpanded}
<ChevronDown class="w-3 h-3" />
{:else}
<ChevronRight class="w-3 h-3" />
{/if}
<span>{isExpanded ? 'Collapse' : 'Expand'}</span>
</button>
{/if}
</div>
<!-- Content -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm">
<div class="p-4">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-gray-100">
<div class="flex items-center space-x-2 text-xs text-gray-600">
<Clock class="w-3 h-3" />
<span>Result received</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
{isCode ? 'Code' : isJSONContent ? 'JSON' : 'Text'}
</span>
{#if !isCode}
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">{displayContent.length} chars</span>
{/if}
</div>
</div>
{#if isCode}
<CodeViewer code={extractedCode} fileName={content && typeof content === 'object' && 'fileName' in content && typeof content.fileName === 'string' ? content.fileName : undefined} />
{:else if isJSONContent}
<pre class="text-sm text-gray-700 whitespace-pre-wrap font-mono overflow-x-auto bg-gray-50 rounded-lg p-3 border border-gray-200">{truncatedContent}</pre>
{:else}
<pre class="text-sm text-gray-700 whitespace-pre-wrap break-words leading-relaxed">{truncatedContent}</pre>
{/if}
{#if shouldTruncate && !isCode}
<div class="mt-3 pt-3 border-t border-gray-200">
<button onclick={() => (isExpanded = true)} class="text-xs text-blue-600 hover:text-blue-800 underline transition-colors">
Show full content ({displayContent.length.toLocaleString()} characters)
</button>
</div>
{/if}
</div>
</div>
<!-- Metadata -->
{#if content && typeof content === 'object' && Object.keys(content).length > 1}
<div class="mt-3">
<details class="cursor-pointer group">
<summary class="text-xs text-gray-600 hover:text-gray-800 transition-colors flex items-center space-x-1">
<ChevronRight class="w-3 h-3 group-open:rotate-90 transition-transform" />
<span>Show raw data structure</span>
</summary>
<div class="mt-2 bg-white rounded-lg border border-gray-200 p-3">
<pre class="text-xs overflow-x-auto font-mono text-gray-700 bg-gray-50 rounded p-2">{formatJSON(content)}</pre>
</div>
</details>
</div>
{/if}
<!-- Result indicator -->
<div class="mt-4 pt-3 border-t border-gray-200">
<div class="flex items-center space-x-2 text-xs {config.titleColor}">
<div class="w-2 h-2 rounded-full {config.dotColor}"></div>
<span>{config.statusText}</span>
</div>
</div>
</div>

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { Wrench, ChevronDown, ChevronRight, Copy, Check, Terminal, Zap } from 'lucide-svelte';
import { formatValue, formatJSON, isComplexObject } from '$lib/formatters';
import type { ToolInput } from '$lib/types';
import CodeDiff from './CodeDiff.svelte';
import TodoList from './TodoList.svelte';
interface Props {
name: string;
id: string;
input?: ToolInput;
text?: string;
}
let { name, id, input = {}, text }: Props = $props();
let isParamsExpanded = $state(false);
let copied = $state(false);
async function handleCopy() {
try {
await navigator.clipboard.writeText(formatJSON({ name, id, input }));
copied = true;
setTimeout(() => (copied = false), 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
}
function objectKeyCount(value: unknown): number {
return value && typeof value === 'object' ? Object.keys(value).length : 0;
}
let inputKeys = $derived(Object.keys(input));
</script>
<div class="bg-gradient-to-r from-indigo-50 to-blue-50 border border-indigo-200 rounded-xl p-5 shadow-sm hover:shadow-md transition-all duration-200">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-blue-600 rounded-xl flex items-center justify-center shadow-sm">
<Wrench class="w-5 h-5 text-white" />
</div>
<div>
<div class="flex items-center space-x-2">
<span class="text-indigo-900 font-semibold text-base">Tool Execution</span>
<Zap class="w-4 h-4 text-indigo-600" />
</div>
<div class="flex items-center space-x-2 mt-1">
<Terminal class="w-3 h-3 text-indigo-600" />
<span class="font-mono text-sm text-indigo-700 bg-white px-2 py-1 rounded-md border border-indigo-200 font-medium">{name}</span>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500 font-mono bg-white px-2 py-1 rounded-md border border-gray-200">{id}</span>
<button onclick={handleCopy} class="p-2 text-gray-500 hover:text-indigo-600 hover:bg-white transition-all duration-200 rounded-lg border border-transparent hover:border-indigo-200" title="Copy tool call details">
{#if copied}
<Check class="w-4 h-4 text-green-600" />
{:else}
<Copy class="w-4 h-4" />
{/if}
</button>
</div>
</div>
<!-- Edit tool - code diff -->
{#if name === 'Edit' && input.old_string && input.new_string}
<div class="mb-4">
<div class="text-sm font-semibold text-indigo-900 mb-3">Code Changes</div>
<CodeDiff oldCode={input.old_string} newCode={input.new_string} fileName={input.file_path} />
</div>
{/if}
<!-- Read tool -->
{#if name === 'Read' && input.file_path}
<div class="mb-4">
<div class="text-sm font-semibold text-indigo-900 mb-3">File Contents</div>
<div class="text-xs text-gray-600 mb-2">Reading: <span class="font-mono">{input.file_path}</span></div>
</div>
{/if}
<!-- TodoWrite tool -->
{#if name === 'TodoWrite' && input.todos && Array.isArray(input.todos)}
<div class="mb-4">
<div class="text-sm font-semibold text-indigo-900 mb-3">Task Management</div>
<TodoList todos={input.todos} />
</div>
{/if}
<!-- Parameters -->
{#if inputKeys.length > 0}
<div class="mb-4">
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-semibold text-indigo-900 flex items-center space-x-2">
<span>Parameters</span>
<span class="text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full border border-indigo-200">{inputKeys.length}</span>
</div>
{#if inputKeys.length > 2}
<button onclick={() => (isParamsExpanded = !isParamsExpanded)} class="flex items-center space-x-1 text-xs text-indigo-600 hover:text-indigo-800 transition-colors">
{#if isParamsExpanded}
<ChevronDown class="w-3 h-3" />
{:else}
<ChevronRight class="w-3 h-3" />
{/if}
<span>{isParamsExpanded ? 'Collapse' : 'Expand'}</span>
</button>
{/if}
</div>
{#if name !== 'Edit' && name !== 'TodoWrite'}
<div class="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div class="space-y-3 {!isParamsExpanded && inputKeys.length > 2 ? 'max-h-32 overflow-hidden' : ''}">
{#each Object.entries(input) as [key, value] (key)}
<div class="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg border border-gray-100">
<span class="font-mono text-sm text-indigo-600 pt-0.5 min-w-0 flex-shrink-0 font-medium">{key}:</span>
<div class="flex-1 min-w-0">
{#if typeof value === 'string'}
{#if value.length > 200 || value.includes('\n')}
<details class="cursor-pointer">
<summary class="text-xs text-indigo-600 hover:text-indigo-800 underline transition-colors">Show large parameter</summary>
<pre class="mt-2 bg-gray-50 border border-gray-200 p-3 rounded-lg text-xs max-h-64 overflow-auto font-mono whitespace-pre-wrap">{value}</pre>
</details>
{:else}
<span class="text-gray-700 text-sm break-all font-mono">{value}</span>
{/if}
{:else if Array.isArray(value)}
<details class="cursor-pointer">
<summary class="text-xs text-indigo-600 hover:text-indigo-800 underline transition-colors">Show array ({value.length} items)</summary>
<pre class="mt-2 bg-gray-50 border border-gray-200 p-3 rounded-lg text-xs overflow-auto font-mono">{formatJSON(value)}</pre>
</details>
{:else if isComplexObject(value)}
<details class="cursor-pointer">
<summary class="text-xs text-indigo-600 hover:text-indigo-800 underline transition-colors">Show object ({objectKeyCount(value)} properties)</summary>
<pre class="mt-2 bg-gray-50 border border-gray-200 p-3 rounded-lg text-xs overflow-auto font-mono">{formatJSON(value)}</pre>
</details>
{:else}
<span class="text-gray-700 text-sm font-mono">{formatValue(value)}</span>
{/if}
</div>
</div>
{/each}
</div>
{#if !isParamsExpanded && inputKeys.length > 2}
<div class="mt-3 pt-3 border-t border-gray-200">
<button onclick={() => (isParamsExpanded = true)} class="text-xs text-indigo-600 hover:text-indigo-800 underline transition-colors">
Show all {inputKeys.length} parameters
</button>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Additional text -->
{#if text}
<div class="bg-white rounded-lg p-3 border border-gray-200 shadow-sm">
<div class="text-xs text-gray-600 mb-1 font-medium">Additional Information:</div>
<div class="text-sm text-gray-700">{text}</div>
</div>
{/if}
<!-- Tool execution indicator -->
<div class="mt-4 pt-3 border-t border-indigo-200">
<div class="flex items-center space-x-2 text-xs text-indigo-700">
<div class="w-2 h-2 bg-indigo-500 rounded-full animate-pulse"></div>
<span>Tool execution initiated</span>
</div>
</div>
</div>

View file

@ -0,0 +1,87 @@
<script lang="ts">
import { Settings, Wrench, Terminal, Database, Code, ChevronRight, ChevronDown } from 'lucide-svelte';
import { parseXmlBlocks, getXmlTagStyle, type XmlSegment } from '$lib/formatters';
import XmlBlock from './XmlBlock.svelte';
import RichText from './RichText.svelte';
interface Props {
tag: string;
innerContent: string;
startCollapsed?: boolean;
}
let { tag, innerContent, startCollapsed = true }: Props = $props();
let manualExpanded = $state<boolean | null>(null);
let isExpanded = $derived(manualExpanded ?? !startCollapsed);
const iconMap: Record<string, typeof Settings> = {
settings: Settings,
wrench: Wrench,
terminal: Terminal,
database: Database,
code: Code
};
let style = $derived.by(() => getXmlTagStyle(tag));
let IconComponent = $derived.by(() => iconMap[style.icon] || Code);
// Parse inner content for nested XML blocks
let innerSegments = $derived(parseXmlBlocks(innerContent));
let hasNestedXml = $derived(innerSegments.some((s: XmlSegment) => s.type === 'xml'));
// For short content without nested XML, show inline
let isShortContent = $derived(!hasNestedXml && innerContent.trim().length < 200);
function formatTagName(name: string): string {
return name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function toggleExpanded() {
manualExpanded = !isExpanded;
}
</script>
<div class="{style.bg} {style.border} border rounded-lg overflow-hidden">
<button
onclick={toggleExpanded}
class="w-full px-3 py-2 flex items-center justify-between hover:brightness-95 transition-all {style.headerBg}"
>
<div class="flex items-center space-x-2 min-w-0">
<IconComponent class="w-3.5 h-3.5 {style.text} flex-shrink-0" />
<span class="text-xs font-semibold {style.text} truncate">{formatTagName(tag)}</span>
<code class="text-[10px] text-gray-400 font-mono hidden sm:inline">&lt;{tag}&gt;</code>
{#if isShortContent && !isExpanded}
<span class="text-[11px] text-gray-500 truncate max-w-[300px]">{innerContent.trim()}</span>
{/if}
</div>
<div class="flex items-center space-x-1 flex-shrink-0">
{#if innerContent.trim().length > 0}
<span class="text-[10px] text-gray-400">{innerContent.trim().length.toLocaleString()} chars</span>
{/if}
{#if isExpanded}
<ChevronDown class="w-3.5 h-3.5 text-gray-400" />
{:else}
<ChevronRight class="w-3.5 h-3.5 text-gray-400" />
{/if}
</div>
</button>
{#if isExpanded}
<div class="px-3 py-2.5 border-t {style.border}">
{#if hasNestedXml}
<div class="space-y-2">
{#each innerSegments as segment, index (`${segment.type}-${segment.tag ?? 'text'}-${index}`)}
{#if segment.type === 'xml' && segment.tag && segment.innerContent !== undefined}
<XmlBlock tag={segment.tag} innerContent={segment.innerContent} startCollapsed={true} />
{:else if segment.type === 'text' && segment.content.trim()}
<RichText text={segment.content} size="xs" />
{/if}
{/each}
</div>
{:else}
<div class="text-xs text-gray-700 leading-relaxed overflow-x-auto whitespace-pre-wrap font-mono">{innerContent.trim()}</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1 @@
export * from '../../../shared/frontend/formatters';

29
svelte/src/lib/models.ts Normal file
View file

@ -0,0 +1,29 @@
/**
* Utility functions for model-related operations
*/
export function isOpenAIModel(model: string | null | undefined): boolean {
if (!model) return false;
return model.startsWith('gpt-') || /^o[0-9]/.test(model);
}
export function getProviderName(model: string | null | undefined): 'OpenAI' | 'Anthropic' {
return isOpenAIModel(model) ? 'OpenAI' : 'Anthropic';
}
export function getChatCompletionsEndpoint(model: string | null | undefined, defaultEndpoint?: string): string {
return isOpenAIModel(model) ? '/v1/chat/completions' : (defaultEndpoint || '/v1/messages');
}
/**
* Get a short display label and color class for a model string.
*/
export function getModelDisplay(model: string): { label: string; colorClass: string } {
if (!model) return { label: 'API', colorClass: 'text-gray-900' };
const m = model.toLowerCase();
if (m.includes('opus')) return { label: 'Opus', colorClass: 'text-purple-600' };
if (m.includes('sonnet')) return { label: 'Sonnet', colorClass: 'text-indigo-600' };
if (m.includes('haiku')) return { label: 'Haiku', colorClass: 'text-teal-600' };
if (isOpenAIModel(model)) return { label: model.includes('gpt-4o') ? 'GPT-4o' : model.split('-')[0].toUpperCase(), colorClass: 'text-green-600' };
return { label: model.split('-')[0], colorClass: 'text-gray-900' };
}

118
svelte/src/lib/pricing.ts Normal file
View file

@ -0,0 +1,118 @@
import { isOpenAIModel } from './models';
/**
* Anthropic API pricing (per million tokens) as of March 2026
* https://docs.anthropic.com/en/docs/about-claude/pricing
*/
export interface ModelPricing {
inputPerMTok: number;
outputPerMTok: number;
cacheReadPerMTok: number;
cacheWritePerMTok: number;
label: string;
tier: 'opus' | 'sonnet' | 'haiku' | 'unknown';
}
const PRICING: Record<string, ModelPricing> = {
// Opus 4 family
'claude-opus-4': { inputPerMTok: 15, outputPerMTok: 75, cacheReadPerMTok: 1.50, cacheWritePerMTok: 18.75, label: 'Opus 4', tier: 'opus' },
// Sonnet 4 family
'claude-sonnet-4': { inputPerMTok: 3, outputPerMTok: 15, cacheReadPerMTok: 0.30, cacheWritePerMTok: 3.75, label: 'Sonnet 4', tier: 'sonnet' },
// Haiku 3.5
'claude-haiku-3': { inputPerMTok: 0.80, outputPerMTok: 4, cacheReadPerMTok: 0.08, cacheWritePerMTok: 1, label: 'Haiku 3.5', tier: 'haiku' },
};
// Subscription plans for comparison
export interface SubscriptionPlan {
name: string;
monthlyPrice: number;
description: string;
}
export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [
{ name: 'Claude Pro', monthlyPrice: 20, description: 'Standard usage limits' },
{ name: 'Claude Max 5x', monthlyPrice: 100, description: '5x Pro usage' },
{ name: 'Claude Max 20x', monthlyPrice: 200, description: '20x Pro usage' },
];
/**
* Match a model string to its pricing tier
*/
export function getModelPricing(model: string): ModelPricing {
if (isOpenAIModel(model)) {
return { ...PRICING['claude-sonnet-4'], label: model.split('-').slice(0, 2).join('-'), tier: 'unknown' };
}
const m = model.toLowerCase();
if (m.includes('opus')) return PRICING['claude-opus-4'];
if (m.includes('sonnet')) return PRICING['claude-sonnet-4'];
if (m.includes('haiku')) return PRICING['claude-haiku-3'];
// Default to sonnet pricing for unknown models
return { ...PRICING['claude-sonnet-4'], label: model.split('-').slice(0, 2).join('-'), tier: 'unknown' };
}
/**
* Calculate cost for a given token count and model
*/
export function calculateCost(
inputTokens: number,
outputTokens: number,
cacheReadTokens: number,
cacheWriteTokens: number,
model: string
): number {
const pricing = getModelPricing(model);
return (
(inputTokens / 1_000_000) * pricing.inputPerMTok +
(outputTokens / 1_000_000) * pricing.outputPerMTok +
(cacheReadTokens / 1_000_000) * pricing.cacheReadPerMTok +
(cacheWriteTokens / 1_000_000) * pricing.cacheWritePerMTok
);
}
/**
* Calculate costs from usage stats broken down by model
*/
export function calculateTotalCostFromStats(
requestsByModel: Record<string, { request_count: number; input_tokens: number; output_tokens: number; cache_tokens: number }>
): { totalCost: number; costByModel: Array<{ model: string; label: string; cost: number; inputTokens: number; outputTokens: number; cacheTokens: number; requests: number }> } {
let totalCost = 0;
const costByModel: Array<{ model: string; label: string; cost: number; inputTokens: number; outputTokens: number; cacheTokens: number; requests: number }> = [];
for (const [model, stats] of Object.entries(requestsByModel)) {
const pricing = getModelPricing(model);
// Treat cache_tokens as cache reads (most common case)
const cost = calculateCost(stats.input_tokens, stats.output_tokens, stats.cache_tokens, 0, model);
totalCost += cost;
costByModel.push({
model,
label: pricing.label,
cost,
inputTokens: stats.input_tokens,
outputTokens: stats.output_tokens,
cacheTokens: stats.cache_tokens,
requests: stats.request_count,
});
}
// Sort by cost descending
costByModel.sort((a, b) => b.cost - a.cost);
return { totalCost, costByModel };
}
/**
* Format a dollar amount for display
*/
export function formatCost(cost: number): string {
if (cost < 0.01) return `$${cost.toFixed(4)}`;
if (cost < 1) return `$${cost.toFixed(3)}`;
return `$${cost.toFixed(2)}`;
}
/**
* Project monthly cost from a daily rate
*/
export function projectMonthlyCost(dailyCost: number): number {
return dailyCost * 30;
}

170
svelte/src/lib/rich-text.ts Normal file
View file

@ -0,0 +1,170 @@
export type RichTextInline =
| { type: 'text'; text: string }
| { type: 'strong'; text: string }
| { type: 'em'; text: string }
| { type: 'code'; text: string }
| { type: 'link'; text: string; href: string };
export type RichTextBlock =
| { type: 'paragraph'; content: RichTextInline[] }
| { type: 'heading'; level: number; content: RichTextInline[] }
| { type: 'ul'; items: RichTextInline[][] }
| { type: 'ol'; items: RichTextInline[][] }
| { type: 'code_block'; code: string }
| { type: 'hr' }
| { type: 'spacer' };
const INLINE_TOKEN_PATTERN = /(https?:\/\/[^\s<]+|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g;
function pushText(segments: RichTextInline[], text: string) {
if (!text) return;
segments.push({ type: 'text', text });
}
function splitTrailingPunctuation(url: string): { href: string; trailing: string } {
const trailingMatch = url.match(/[),.!?;:]+$/);
if (!trailingMatch) return { href: url, trailing: '' };
const trailing = trailingMatch[0];
return {
href: url.slice(0, url.length - trailing.length),
trailing
};
}
export function parseInlineRichText(text: string): RichTextInline[] {
if (!text) return [];
const segments: RichTextInline[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = INLINE_TOKEN_PATTERN.exec(text)) !== null) {
if (match.index > lastIndex) {
pushText(segments, text.slice(lastIndex, match.index));
}
const token = match[0];
if (token.startsWith('**') && token.endsWith('**')) {
segments.push({ type: 'strong', text: token.slice(2, -2) });
} else if (token.startsWith('*') && token.endsWith('*')) {
segments.push({ type: 'em', text: token.slice(1, -1) });
} else if (token.startsWith('`') && token.endsWith('`')) {
segments.push({ type: 'code', text: token.slice(1, -1) });
} else {
const { href, trailing } = splitTrailingPunctuation(token);
segments.push({ type: 'link', text: href, href });
pushText(segments, trailing);
}
lastIndex = match.index + token.length;
}
if (lastIndex < text.length) {
pushText(segments, text.slice(lastIndex));
}
return segments;
}
export function parseRichText(text: string): RichTextBlock[] {
if (!text) return [];
const lines = text.split('\n');
const blocks: RichTextBlock[] = [];
let index = 0;
let activeListType: 'ul' | 'ol' | null = null;
let activeListItems: RichTextInline[][] = [];
function flushList() {
if (!activeListType || activeListItems.length === 0) return;
blocks.push({
type: activeListType,
items: activeListItems
});
activeListType = null;
activeListItems = [];
}
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
if (/^```/.test(trimmed)) {
flushList();
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !/^```/.test(lines[index].trim())) {
codeLines.push(lines[index]);
index += 1;
}
blocks.push({ type: 'code_block', code: codeLines.join('\n') });
if (index < lines.length) index += 1;
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
flushList();
blocks.push({ type: 'hr' });
index += 1;
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
flushList();
blocks.push({
type: 'heading',
level: headingMatch[1].length,
content: parseInlineRichText(headingMatch[2])
});
index += 1;
continue;
}
const bulletMatch = line.match(/^\s*[-*+]\s+(.+)$/);
if (bulletMatch) {
if (activeListType !== 'ul') {
flushList();
activeListType = 'ul';
}
activeListItems.push(parseInlineRichText(bulletMatch[1]));
index += 1;
continue;
}
const numberMatch = line.match(/^\s*\d+[.)]\s+(.+)$/);
if (numberMatch) {
if (activeListType !== 'ol') {
flushList();
activeListType = 'ol';
}
activeListItems.push(parseInlineRichText(numberMatch[1]));
index += 1;
continue;
}
if (trimmed === '') {
flushList();
if (blocks[blocks.length - 1]?.type !== 'spacer') {
blocks.push({ type: 'spacer' });
}
index += 1;
continue;
}
flushList();
blocks.push({
type: 'paragraph',
content: parseInlineRichText(line)
});
index += 1;
}
flushList();
while (blocks[0]?.type === 'spacer') blocks.shift();
while (blocks[blocks.length - 1]?.type === 'spacer') blocks.pop();
return blocks;
}

View file

@ -0,0 +1,45 @@
export type ThemeMode = 'light' | 'dark' | 'system';
let mode = $state<ThemeMode>('system');
function getSystemPreference(): boolean {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function applyTheme(m: ThemeMode) {
if (typeof document === 'undefined') return;
const isDark = m === 'dark' || (m === 'system' && getSystemPreference());
document.documentElement.classList.toggle('dark', isDark);
}
export function initTheme() {
if (typeof window === 'undefined') return;
const stored = localStorage.getItem('theme') as ThemeMode | null;
if (stored && ['light', 'dark', 'system'].includes(stored)) {
mode = stored;
}
applyTheme(mode);
// Listen for system theme changes
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', () => {
if (mode === 'system') applyTheme('system');
});
}
export function getTheme(): ThemeMode {
return mode;
}
export function setTheme(m: ThemeMode) {
mode = m;
localStorage.setItem('theme', m);
applyTheme(m);
}
export function cycleTheme() {
const order: ThemeMode[] = ['system', 'light', 'dark'];
const next = order[(order.indexOf(mode) + 1) % order.length];
setTheme(next);
}

1
svelte/src/lib/types.ts Normal file
View file

@ -0,0 +1 @@
export * from '../../../shared/frontend/types';

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { page } from '$app/stores';
import { AlertTriangle, RefreshCw } from 'lucide-svelte';
</script>
<div class="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center p-6">
<div class="max-w-md w-full bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-8 text-center space-y-4">
<AlertTriangle class="w-8 h-8 mx-auto text-amber-500" />
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{$page.status}</h1>
<p class="text-sm text-gray-600 dark:text-gray-400">{$page.error?.message}</p>
<button
onclick={() => location.reload()}
class="mt-4 inline-flex items-center space-x-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
<RefreshCw class="w-4 h-4" />
<span>Reload Page</span>
</button>
</div>
</div>

View file

@ -0,0 +1,25 @@
import type { ServerLoadEvent } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export function load({ request }: ServerLoadEvent) {
// Explicit override takes priority
if (env.PROXY_PUBLIC_URL) {
return { proxyUrl: env.PROXY_PUBLIC_URL.replace(/\/$/, '') };
}
// Derive from reverse-proxy forwarded headers (Traefik sets these)
const forwardedProto = request.headers.get('x-forwarded-proto') || 'https';
const forwardedHost = request.headers.get('x-forwarded-host') || '';
if (forwardedHost) {
const proxyHost = forwardedHost
.replace(/^claude-code-proxy-svelte\./, 'claude-code-proxy.')
.replace(/^claude-code-proxy-web\./, 'claude-code-proxy.');
return { proxyUrl: `${forwardedProto}://${proxyHost}` };
}
// Local dev fallback
const host = request.headers.get('host') || 'localhost:3001';
const hostname = host.split(':')[0];
return { proxyUrl: `http://${hostname}:3001` };
}

View file

@ -0,0 +1,18 @@
<script>
import '../app.css';
import { onMount } from 'svelte';
import { initTheme } from '$lib/theme.svelte';
let { children } = $props();
onMount(() => {
initTheme();
});
</script>
<svelte:head>
<title>Claude Code Proxy</title>
<meta name="description" content="Claude Code Proxy - Real-time API request visualization" />
</svelte:head>
{@render children()}

View file

@ -0,0 +1 @@
// proxyUrl is now provided by +layout.server.ts

View file

@ -0,0 +1,318 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
RefreshCw, Trash2, FileText, X, ArrowLeftRight,
Zap, Brain,
Sparkles, Loader2
} from 'lucide-svelte';
import Nav from '$lib/components/Nav.svelte';
import RequestDetailContent from '$lib/components/RequestDetailContent.svelte';
import { getChatCompletionsEndpoint, getProviderName, getModelDisplay } from '$lib/models';
import {
fetchRequests, deleteRequests, gradePrompt, fetchRequestById, fetchUsageStats
} from '$lib/api';
import { formatDate, formatTimeOfDay } from '$lib/formatters';
import type { Request, UsageStats } from '$lib/types';
let requests: Request[] = $state([]);
let selectedRequest: Request | null = $state(null);
let filter = $state('all');
let isModalOpen = $state(false);
let modelFilter = $state('all');
let isFetching = $state(false);
let requestsCurrentPage = $state(1);
let hasMoreRequests = $state(true);
let totalRequests = $state(0);
let usageStats: UsageStats | null = $state(null);
const itemsPerPage = 50;
async function loadRequests(currentModelFilter?: string, loadMore = false, currentPage = 1) {
isFetching = true;
const pageToFetch = loadMore ? currentPage + 1 : 1;
try {
const filterToUse = currentModelFilter ?? modelFilter;
const result = await fetchRequests(pageToFetch, itemsPerPage, filterToUse);
if (loadMore) {
requests = [...requests, ...result.requests];
} else {
requests = result.requests;
}
requestsCurrentPage = pageToFetch;
hasMoreRequests = result.hasMore;
totalRequests = result.total;
} catch (error) {
console.error('Failed to load requests:', error);
requests = [];
totalRequests = 0;
hasMoreRequests = false;
} finally {
isFetching = false;
}
}
async function loadUsageStats(currentModelFilter?: string) {
try {
usageStats = await fetchUsageStats(undefined, undefined, currentModelFilter ?? modelFilter);
} catch (error) {
console.error('Failed to load usage stats:', error);
usageStats = null;
}
}
async function refreshRequestsAndStats(currentModelFilter: string = modelFilter) {
await Promise.all([
loadRequests(currentModelFilter),
loadUsageStats(currentModelFilter)
]);
}
async function clearRequests() {
try {
await deleteRequests();
requests = [];
requestsCurrentPage = 1;
hasMoreRequests = true;
totalRequests = 0;
usageStats = null;
selectedRequest = null;
isModalOpen = false;
} catch (error) {
console.error('Failed to clear requests:', error);
}
}
function filterRequests(f: string) {
if (f === 'all') return requests;
return requests.filter((req) => {
switch (f) {
case 'messages': return req.endpoint.includes('/messages');
case 'completions': return req.endpoint.includes('/completions');
case 'models': return req.endpoint.includes('/models');
default: return true;
}
});
}
async function showRequestDetails(requestId: string) {
const request = requests.find((r) => r.id === requestId);
if (request) {
selectedRequest = request;
isModalOpen = true;
return;
}
try {
const result = await fetchRequestById(requestId);
if (result.request) {
selectedRequest = { ...result.request, id: requestId };
isModalOpen = true;
}
} catch (error) {
console.error('Failed to load request details:', error);
}
}
function closeModal() {
isModalOpen = false;
selectedRequest = null;
}
async function gradeRequest(requestId: string) {
const request = requests.find((r) => r.id === requestId);
if (!request || !request.body?.messages?.some((msg) => msg.role === 'user') || !request.endpoint.includes('/messages')) return;
try {
const promptGradeResult = await gradePrompt(request.body!.messages, request.body!.system || [], request.timestamp);
requests = requests.map((r) => (r.id === requestId ? { ...r, promptGrade: promptGradeResult } : r));
} catch (error) {
console.error('Failed to grade prompt:', error);
}
}
function handleModelFilterChange(newFilter: string) {
modelFilter = newFilter;
refreshRequestsAndStats(newFilter);
}
let filteredRequests = $derived(filterRequests(filter));
onMount(() => {
refreshRequestsAndStats(modelFilter);
function handleEscapeKey(event: KeyboardEvent) {
if (event.key === 'Escape' && isModalOpen) closeModal();
}
window.addEventListener('keydown', handleEscapeKey);
return () => window.removeEventListener('keydown', handleEscapeKey);
});
</script>
<div class="min-h-screen bg-gray-50">
<Nav />
<!-- Sub-header: actions + model filter -->
<div class="bg-white border-b border-gray-100">
<div class="max-w-7xl mx-auto px-6 py-2 flex items-center justify-between">
<div class="flex items-center space-x-2">
<button onclick={() => refreshRequestsAndStats(modelFilter)} class="p-1.5 text-gray-600 hover:bg-gray-100 rounded transition-colors" title="Refresh">
<RefreshCw class="w-4 h-4" />
</button>
<button onclick={clearRequests} class="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors" title="Clear all requests">
<Trash2 class="w-4 h-4" />
</button>
</div>
<div class="inline-flex items-center bg-gray-100 rounded p-0.5 space-x-0.5">
<button onclick={() => handleModelFilterChange('all')} class="px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 {modelFilter === 'all' ? 'bg-white text-gray-900 shadow-sm' : 'bg-transparent text-gray-600 hover:text-gray-900'}">
All Models
</button>
<button onclick={() => handleModelFilterChange('opus')} class="px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 flex items-center space-x-1 {modelFilter === 'opus' ? 'bg-white text-purple-600 shadow-sm' : 'bg-transparent text-gray-600 hover:text-gray-900'}">
<Brain class="w-3 h-3" /><span>Opus</span>
</button>
<button onclick={() => handleModelFilterChange('sonnet')} class="px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 flex items-center space-x-1 {modelFilter === 'sonnet' ? 'bg-white text-indigo-600 shadow-sm' : 'bg-transparent text-gray-600 hover:text-gray-900'}">
<Sparkles class="w-3 h-3" /><span>Sonnet</span>
</button>
<button onclick={() => handleModelFilterChange('haiku')} class="px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 flex items-center space-x-1 {modelFilter === 'haiku' ? 'bg-white text-teal-600 shadow-sm' : 'bg-transparent text-gray-600 hover:text-gray-900'}">
<Zap class="w-3 h-3" /><span>Haiku</span>
</button>
</div>
</div>
</div>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-6 py-8 space-y-8">
<!-- Stats Grid -->
<div class="mb-6">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Total Requests</p>
<p class="text-2xl font-semibold text-gray-900 mt-1">{totalRequests > 0 ? totalRequests : requests.length}</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Input Tokens</p>
<p class="text-2xl font-semibold text-indigo-600 mt-1">{usageStats?.total_input_tokens?.toLocaleString() || '0'}</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Output Tokens</p>
<p class="text-2xl font-semibold text-emerald-600 mt-1">{usageStats?.total_output_tokens?.toLocaleString() || '0'}</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Cached Tokens</p>
<p class="text-2xl font-semibold text-green-600 mt-1">{usageStats?.total_cache_tokens?.toLocaleString() || '0'}</p>
</div>
</div>
{#if usageStats?.requests_by_model && Object.keys(usageStats.requests_by_model).length > 0}
<div class="mt-4 bg-white border border-gray-200 rounded-lg p-4">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Usage by Model</p>
<div class="grid grid-cols-2 lg:grid-cols-3 gap-3">
{#each Object.entries(usageStats.requests_by_model) as [model, stats]}
{@const provider = getProviderName(model)}
<div class="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div class="text-xs font-medium text-gray-700 truncate" title={model}>
{model.includes('opus') ? 'Opus' : model.includes('sonnet') ? 'Sonnet' : model.includes('haiku') ? 'Haiku' : provider === 'OpenAI' ? model.split('-').slice(0, 2).join('-') : model.split('-').slice(0, 2).join('-')}
</div>
<div class="text-sm font-semibold text-gray-900 mt-1">{stats.request_count} requests</div>
<div class="text-xs text-gray-500">{((stats.input_tokens + stats.output_tokens) || 0).toLocaleString()} tokens</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<!-- Request History -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-900 uppercase tracking-wider">Request History</h2>
</div>
<div class="divide-y divide-gray-200">
{#if isFetching && requestsCurrentPage === 1}
<div class="p-8 text-center">
<Loader2 class="w-6 h-6 mx-auto animate-spin text-gray-400" />
<p class="mt-2 text-xs text-gray-500">Loading requests...</p>
</div>
{:else if filteredRequests.length === 0}
<div class="p-8 text-center text-gray-500">
<h3 class="text-sm font-medium text-gray-600 mb-1">No requests found</h3>
<p class="text-xs text-gray-500">Make sure you have set <code class="font-mono bg-gray-100 px-1 py-0.5 rounded">ANTHROPIC_BASE_URL</code> to point at the proxy</p>
</div>
{:else}
{#each filteredRequests as request}
{@const model = request.routedModel || request.body?.model || ''}
{@const modelDisplay = getModelDisplay(model)}
<div class="px-4 py-3 hover:bg-gray-50 transition-colors cursor-pointer border-b border-gray-100 last:border-b-0" role="button" tabindex="0" onclick={() => showRequestDetails(request.id)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showRequestDetails(request.id); } }}>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0 mr-4">
<div class="flex items-center space-x-3 mb-1">
<h3 class="text-sm font-medium">
<span class="{modelDisplay.colorClass} font-semibold">{modelDisplay.label}</span>
</h3>
{#if request.routedModel && request.routedModel !== request.originalModel}
<span class="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded font-medium flex items-center space-x-1">
<ArrowLeftRight class="w-3 h-3" /><span>routed</span>
</span>
{/if}
{#if request.response?.statusCode}
<span class="text-xs font-medium px-1.5 py-0.5 rounded {request.response.statusCode >= 200 && request.response.statusCode < 300 ? 'bg-green-100 text-green-700' : request.response.statusCode >= 300 && request.response.statusCode < 400 ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'}">
{request.response.statusCode}
</span>
{/if}
{#if request.conversationId}
<span class="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded font-medium">Turn {request.turnNumber}</span>
{/if}
</div>
<div class="text-xs text-gray-600 font-mono mb-1">{getChatCompletionsEndpoint(request.routedModel, request.endpoint)}</div>
<div class="flex items-center space-x-3 text-xs">
{#if request.response?.body?.usage}
<span class="font-mono text-gray-600">
<span class="font-medium text-gray-900">{((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.output_tokens || 0)).toLocaleString()}</span> tokens
</span>
{#if request.response.body.usage.cache_read_input_tokens}
<span class="font-mono bg-green-50 text-green-700 px-1.5 py-0.5 rounded">{request.response.body.usage.cache_read_input_tokens.toLocaleString()} cached</span>
{/if}
{/if}
{#if request.response?.responseTime}
<span class="font-mono text-gray-600"><span class="font-medium text-gray-900">{(request.response.responseTime / 1000).toFixed(2)}</span>s</span>
{/if}
</div>
</div>
<div class="flex-shrink-0 text-right">
<div class="text-xs text-gray-500">{formatDate(request.timestamp)}</div>
<div class="text-xs text-gray-400">{formatTimeOfDay(request.timestamp)}</div>
</div>
</div>
</div>
{/each}
{#if hasMoreRequests}
<div class="p-3 text-center border-t border-gray-100">
<button onclick={() => loadRequests(modelFilter, true, requestsCurrentPage)} disabled={isFetching} class="px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 rounded hover:bg-gray-200 disabled:opacity-50 transition-colors">
{isFetching ? 'Loading...' : 'Load More'}
</button>
</div>
{/if}
{/if}
</div>
</div>
</main>
<!-- Request Detail Modal -->
{#if isModalOpen && selectedRequest}
<div class="fixed inset-0 bg-gray-900/70 backdrop-blur-sm z-50 flex items-center justify-center p-6" role="dialog" aria-modal="true" aria-label="Request details">
<div class="bg-white rounded-xl max-w-6xl w-full max-h-[90vh] overflow-hidden shadow-2xl">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<FileText class="w-5 h-5 text-blue-600" />
<h3 class="text-lg font-semibold text-gray-900">Request Details</h3>
</div>
<button onclick={closeModal} class="text-gray-400 hover:text-gray-600 transition-colors p-2 hover:bg-gray-100 rounded-lg">
<X class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 overflow-y-auto max-h-[calc(90vh-100px)]">
<RequestDetailContent request={selectedRequest} onGrade={() => gradeRequest(selectedRequest!.id)} />
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,980 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
BarChart3, TrendingUp, DollarSign, Clock, Zap, Brain, Sparkles,
Loader2, Calendar, Activity, AlertCircle, CheckCircle, Square, Wrench
} from 'lucide-svelte';
import Nav from '$lib/components/Nav.svelte';
import ChartCanvas from '$lib/components/ChartCanvas.svelte';
import {
fetchDashboardStats, fetchHourlyStats, fetchModelStats,
fetchUsageStats, fetchRequestsSummary, fetchOrganizations
} from '$lib/api';
import {
calculateTotalCostFromStats, formatCost, projectMonthlyCost,
SUBSCRIPTION_PLANS, getModelPricing
} from '$lib/pricing';
import type {
DashboardStats, HourlyStatsResponse, ModelStatsResponse,
UsageStats, RequestSummary
} from '$lib/types';
let dashboardStats = $state<DashboardStats | null>(null);
let hourlyStats = $state<HourlyStatsResponse | null>(null);
let modelStats = $state<ModelStatsResponse | null>(null);
let usageStats = $state<UsageStats | null>(null);
let recentSummaries = $state<RequestSummary[]>([]);
let isLoading = $state(true);
let isRefreshing = $state(false);
let dateRange = $state(7);
let bucketMinutes = $state(60);
let orgFilter = $state('');
let organizations = $state<string[]>([]);
let loadSequence = 0;
let startTime = $derived(new Date(Date.now() - dateRange * 86400000).toISOString());
let endTime = $derived(new Date().toISOString());
async function loadAllStats() {
const loadId = ++loadSequence;
const isInitial = isLoading;
if (!isInitial) isRefreshing = true;
try {
const org = orgFilter || undefined;
const summariesPromise = org
? Promise.resolve({ requests: [] as RequestSummary[] })
: fetchRequestsSummary('all', startTime, endTime, 0, 500);
const [dashboard, hourly, models, usage, summaries] = await Promise.all([
fetchDashboardStats(startTime, endTime, org),
fetchHourlyStats(startTime, endTime, bucketMinutes, org),
fetchModelStats(startTime, endTime, org),
fetchUsageStats(startTime, endTime, undefined, org),
summariesPromise,
]);
if (loadId !== loadSequence) return;
dashboardStats = dashboard;
hourlyStats = hourly;
modelStats = models;
usageStats = usage;
recentSummaries = summaries.requests;
} catch (error) {
if (loadId !== loadSequence) return;
console.error('Failed to load analytics:', error);
} finally {
if (loadId !== loadSequence) return;
isLoading = false;
isRefreshing = false;
}
}
$effect(() => {
void dateRange;
void bucketMinutes;
void orgFilter;
loadAllStats();
});
onMount(async () => {
try {
organizations = await fetchOrganizations();
} catch (error) {
console.error('Failed to load organizations:', error);
}
});
// Cost calculations
let costData = $derived(
usageStats?.requests_by_model
? calculateTotalCostFromStats(usageStats.requests_by_model)
: null
);
let dailyAvgCost = $derived(
costData && dashboardStats?.dailyStats?.length
? costData.totalCost / Math.max(dashboardStats.dailyStats.length, 1)
: 0
);
let projectedMonthly = $derived(projectMonthlyCost(dailyAvgCost));
// --- Response metadata analytics ---
// Stop reason distribution
let stopReasonDistribution = $derived.by(() => {
const counts: Record<string, number> = {};
for (const s of recentSummaries) {
const reason = s.stopReason || 'unknown';
counts[reason] = (counts[reason] || 0) + 1;
}
return Object.entries(counts).sort((a, b) => b[1] - a[1]);
});
// Service tier distribution
let serviceTierDistribution = $derived.by(() => {
const counts: Record<string, number> = {};
for (const s of recentSummaries) {
const tier = s.usage?.service_tier || 'unknown';
counts[tier] = (counts[tier] || 0) + 1;
}
return Object.entries(counts).sort((a, b) => b[1] - a[1]);
});
// Cache token breakdown
let cacheBreakdown = $derived.by(() => {
let totalCacheRead = 0;
let totalCacheCreation = 0;
let totalInput = 0;
let totalOutput = 0;
let requestsWithCache = 0;
for (const s of recentSummaries) {
if (!s.usage) continue;
totalInput += s.usage.input_tokens || 0;
totalOutput += s.usage.output_tokens || 0;
if (s.usage.cache_read_input_tokens) {
totalCacheRead += s.usage.cache_read_input_tokens;
requestsWithCache++;
}
if (s.usage.cache_creation_input_tokens) {
totalCacheCreation += s.usage.cache_creation_input_tokens;
}
}
const cacheHitRate = totalInput > 0
? ((totalCacheRead / (totalInput + totalCacheCreation)) * 100)
: 0;
return { totalCacheRead, totalCacheCreation, totalInput, totalOutput, requestsWithCache, cacheHitRate };
});
// Response time percentiles
let responseTimeStats = $derived.by(() => {
const times = recentSummaries
.filter(s => s.responseTime && s.responseTime > 0)
.map(s => s.responseTime!);
if (times.length === 0) return null;
times.sort((a, b) => a - b);
const p50 = times[Math.floor(times.length * 0.5)];
const p90 = times[Math.floor(times.length * 0.9)];
const p99 = times[Math.floor(times.length * 0.99)];
const avg = times.reduce((a, b) => a + b, 0) / times.length;
return { p50, p90, p99, avg, count: times.length };
});
// Stop reason chart data
let stopReasonChartData = $derived((() => {
if (stopReasonDistribution.length === 0) return null;
const colorMap: Record<string, string> = {
'end_turn': 'rgba(16, 185, 129, 0.7)',
'max_tokens': 'rgba(245, 158, 11, 0.7)',
'tool_use': 'rgba(99, 102, 241, 0.7)',
'stop_sequence': 'rgba(147, 51, 234, 0.7)',
'unknown': 'rgba(156, 163, 175, 0.7)',
};
return {
labels: stopReasonDistribution.map(([reason]) => reason),
datasets: [{
data: stopReasonDistribution.map(([, count]) => count),
backgroundColor: stopReasonDistribution.map(([reason]) => colorMap[reason] || 'rgba(156, 163, 175, 0.7)'),
borderWidth: 2,
borderColor: '#fff',
}],
};
})());
// Chart data
let dailyChartData = $derived((() => {
if (!dashboardStats?.dailyStats?.length) return null;
const stats = dashboardStats.dailyStats;
return {
labels: stats.map(d => new Date(d.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
datasets: [{
label: 'Tokens',
data: stats.map(d => d.tokens),
backgroundColor: 'rgba(99, 102, 241, 0.5)',
borderColor: 'rgb(99, 102, 241)',
borderWidth: 1,
borderRadius: 4,
}],
};
})());
let dailyRequestsChartData = $derived((() => {
if (!dashboardStats?.dailyStats?.length) return null;
const stats = dashboardStats.dailyStats;
return {
labels: stats.map(d => new Date(d.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
datasets: [{
label: 'Requests',
data: stats.map(d => d.requests),
backgroundColor: 'rgba(16, 185, 129, 0.5)',
borderColor: 'rgb(16, 185, 129)',
borderWidth: 2,
tension: 0.3,
fill: true,
}],
};
})());
// Short time label: "12a", "9a", "3p", "11p" for hours; "9:15a", "2:30p" for sub-hour
function shortTime(d: Date): string {
const h = d.getHours();
const m = d.getMinutes();
const suffix = h < 12 ? 'a' : 'p';
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return m === 0 ? `${h12}${suffix}` : `${h12}:${m.toString().padStart(2, '0')}${suffix}`;
}
let hourlyChartData = $derived((() => {
if (!hourlyStats) return null;
const stats = hourlyStats.hourlyStats || [];
// Build a lookup of existing data by backend label key (format: "Jan 2 15:04")
const dataByKey = new Map<string, number>();
for (const h of stats) {
const key = h.label || `${h.hour.toString().padStart(2, '0')}:00`;
dataByKey.set(key, (dataByKey.get(key) || 0) + h.tokens);
}
const labels: string[] = [];
const data: (number | null)[] = [];
const now = new Date();
// Generate every N-minute slot across the full range
const start = new Date(startTime);
const end = new Date(endTime);
const cursor = new Date(start);
const minuteOfDay = cursor.getHours() * 60 + cursor.getMinutes();
const bs = Math.floor(minuteOfDay / bucketMinutes) * bucketMinutes;
cursor.setHours(Math.floor(bs / 60), bs % 60, 0, 0);
let lastDate = '';
while (cursor <= end) {
// Backend key format: "Jan 2 15:04"
const backendKey = cursor.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
+ ' ' + cursor.getHours().toString().padStart(2, '0')
+ ':' + cursor.getMinutes().toString().padStart(2, '0');
// Short time label, prepend date at day boundaries for multi-day ranges
const dateStr = cursor.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
const timeStr = shortTime(cursor);
labels.push(dateStr !== lastDate && dateRange > 1 ? `${dateStr} ${timeStr}` : timeStr);
lastDate = dateStr;
// Future slots get null (renders as gap)
if (cursor > now) {
data.push(null);
} else {
data.push(dataByKey.get(backendKey) || 0);
}
cursor.setTime(cursor.getTime() + bucketMinutes * 60000);
}
return {
labels,
datasets: [{
label: 'Tokens',
data,
backgroundColor: 'rgba(245, 158, 11, 0.1)',
borderColor: 'rgb(245, 158, 11)',
borderWidth: 2,
tension: 0.3,
fill: true,
spanGaps: false,
pointRadius: data.length > 48 ? 0 : 3,
pointHoverRadius: 5,
}],
};
})());
// --- Patterns & Comparisons ---
let comparisonTab = $state<'dow' | 'wow' | 'hod'>('dow');
const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const DOW_COLORS = [
'rgba(156,163,175,0.6)', 'rgba(99,102,241,0.6)', 'rgba(16,185,129,0.6)',
'rgba(245,158,11,0.6)', 'rgba(147,51,234,0.6)', 'rgba(236,72,153,0.6)', 'rgba(156,163,175,0.6)'
];
// Day-of-week aggregation from dailyStats
let dowChartData = $derived.by(() => {
if (!dashboardStats?.dailyStats?.length) return null;
const totals = Array(7).fill(0);
const counts = Array(7).fill(0);
for (const d of dashboardStats.dailyStats) {
const dow = new Date(d.date + 'T12:00:00').getDay();
totals[dow] += d.tokens;
counts[dow]++;
}
const avgTokens = totals.map((t, i) => counts[i] > 0 ? Math.round(t / counts[i]) : 0);
return {
labels: DOW_LABELS,
datasets: [{
label: 'Avg Tokens',
data: avgTokens,
backgroundColor: DOW_COLORS,
borderColor: DOW_COLORS.map(c => c.replace('0.6', '1')),
borderWidth: 2,
borderRadius: 4,
}],
};
});
let dowRequestsChartData = $derived.by(() => {
if (!dashboardStats?.dailyStats?.length) return null;
const totals = Array(7).fill(0);
const counts = Array(7).fill(0);
for (const d of dashboardStats.dailyStats) {
const dow = new Date(d.date + 'T12:00:00').getDay();
totals[dow] += d.requests;
counts[dow]++;
}
const avgRequests = totals.map((t, i) => counts[i] > 0 ? Math.round(t / counts[i]) : 0);
return {
labels: DOW_LABELS,
datasets: [{
label: 'Avg Requests',
data: avgRequests,
backgroundColor: DOW_COLORS,
borderColor: DOW_COLORS.map(c => c.replace('0.6', '1')),
borderWidth: 2,
borderRadius: 4,
}],
};
});
// Week-over-week comparison from dailyStats
const WOW_PALETTE = [
{ bg: 'rgba(99,102,241,0.15)', border: 'rgb(99,102,241)' },
{ bg: 'rgba(16,185,129,0.15)', border: 'rgb(16,185,129)' },
{ bg: 'rgba(245,158,11,0.15)', border: 'rgb(245,158,11)' },
{ bg: 'rgba(147,51,234,0.15)', border: 'rgb(147,51,234)' },
{ bg: 'rgba(236,72,153,0.15)', border: 'rgb(236,72,153)' },
];
let wowChartData = $derived.by(() => {
if (!dashboardStats?.dailyStats?.length) return null;
// Group daily stats into calendar weeks (Mon-Sun)
const weekMap = new Map<string, { tokens: number[]; labels: string[] }>();
for (const d of dashboardStats.dailyStats) {
const date = new Date(d.date + 'T12:00:00');
// ISO week start (Monday)
const dayOfWeek = (date.getDay() + 6) % 7; // 0=Mon, 6=Sun
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - dayOfWeek);
const weekKey = weekStart.toISOString().slice(0, 10);
if (!weekMap.has(weekKey)) {
weekMap.set(weekKey, { tokens: Array(7).fill(0), labels: [] });
}
const week = weekMap.get(weekKey)!;
week.tokens[dayOfWeek] = d.tokens;
}
const weeks = [...weekMap.entries()].sort((a, b) => b[0].localeCompare(a[0]));
if (weeks.length === 0) return null;
const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const datasets = weeks.slice(0, 5).map(([weekKey, week], i) => {
const weekDate = new Date(weekKey + 'T12:00:00');
const label = weekDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
const color = WOW_PALETTE[i % WOW_PALETTE.length];
return {
label: `Wk of ${label}`,
data: week.tokens,
borderColor: color.border,
backgroundColor: i === 0 ? color.bg : 'transparent',
borderWidth: i === 0 ? 2.5 : 1.5,
borderDash: i === 0 ? [] : [4, 3],
tension: 0.3,
fill: i === 0,
pointRadius: 3,
pointHoverRadius: 5,
};
});
return { labels: dayLabels, datasets };
});
// Hour-of-day aggregation from hourlyStats
let hodChartData = $derived.by(() => {
if (!hourlyStats?.hourlyStats?.length) return null;
const totals = Array(24).fill(0);
const counts = Array(24).fill(0);
for (const h of hourlyStats.hourlyStats) {
// Parse the label to get the hour (backend label format: "Jan 2 15:04")
const match = h.label?.match(/(\d{2}):\d{2}$/);
const hour = match ? parseInt(match[1], 10) : h.hour;
if (hour >= 0 && hour < 24) {
totals[hour] += h.tokens;
counts[hour]++;
}
}
const daysInRange = Math.max(dateRange, 1);
const avgTokens = totals.map(t => Math.round(t / daysInRange));
const labels = Array.from({ length: 24 }, (_, i) => {
const d = new Date(); d.setHours(i, 0, 0, 0);
return shortTime(d);
});
return {
labels,
datasets: [{
label: 'Avg Tokens',
data: avgTokens,
backgroundColor: 'rgba(147, 51, 234, 0.1)',
borderColor: 'rgb(147, 51, 234)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 3,
pointHoverRadius: 5,
}],
};
});
let modelChartData = $derived((() => {
if (!modelStats?.modelStats?.length) return null;
const stats = modelStats.modelStats;
const colors = stats.map(m => {
const p = getModelPricing(m.model);
if (p.tier === 'opus') return { bg: 'rgba(147, 51, 234, 0.7)', border: 'rgb(147, 51, 234)' };
if (p.tier === 'sonnet') return { bg: 'rgba(99, 102, 241, 0.7)', border: 'rgb(99, 102, 241)' };
if (p.tier === 'haiku') return { bg: 'rgba(20, 184, 166, 0.7)', border: 'rgb(20, 184, 166)' };
return { bg: 'rgba(156, 163, 175, 0.7)', border: 'rgb(156, 163, 175)' };
});
return {
labels: stats.map(m => getModelPricing(m.model).label),
datasets: [{
data: stats.map(m => m.tokens),
backgroundColor: colors.map(c => c.bg),
borderColor: colors.map(c => c.border),
borderWidth: 2,
}],
};
})());
let costChartData = $derived((() => {
if (!costData?.costByModel?.length) return null;
const colors = costData.costByModel.map(m => {
const p = getModelPricing(m.model);
if (p.tier === 'opus') return 'rgba(147, 51, 234, 0.7)';
if (p.tier === 'sonnet') return 'rgba(99, 102, 241, 0.7)';
if (p.tier === 'haiku') return 'rgba(20, 184, 166, 0.7)';
return 'rgba(156, 163, 175, 0.7)';
});
return {
labels: costData.costByModel.map(m => m.label),
datasets: [{
label: 'Cost ($)',
data: costData.costByModel.map(m => m.cost),
backgroundColor: colors,
borderRadius: 4,
}],
};
})());
function stopReasonIcon(reason: string) {
if (reason === 'end_turn') return CheckCircle;
if (reason === 'max_tokens') return AlertCircle;
if (reason === 'tool_use') return Wrench;
if (reason === 'stop_sequence') return Square;
return Activity;
}
function stopReasonColor(reason: string) {
if (reason === 'end_turn') return 'text-emerald-600';
if (reason === 'max_tokens') return 'text-amber-600';
if (reason === 'tool_use') return 'text-indigo-600';
if (reason === 'stop_sequence') return 'text-purple-600';
return 'text-gray-500';
}
</script>
<svelte:head>
<title>Analytics - Claude Code Proxy</title>
</svelte:head>
<div class="min-h-screen bg-gray-50">
<Nav />
<main class="max-w-7xl mx-auto px-6 py-8 space-y-6">
<!-- Date Range Selector -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 flex items-center space-x-2">
<BarChart3 class="w-5 h-5 text-indigo-600" />
<span>Analytics</span>
{#if recentSummaries.length > 0}
<span class="text-xs text-gray-400 font-normal ml-2">{recentSummaries.length} requests in period</span>
{/if}
</h2>
<div class="flex items-center space-x-4">
{#if organizations.length > 0}
<select
bind:value={orgFilter}
class="text-xs border border-gray-200 rounded px-2 py-1.5 bg-white text-gray-700"
>
<option value="">All Orgs</option>
{#each organizations as org}
<option value={org}>{org.slice(0, 12)}...</option>
{/each}
</select>
{/if}
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 text-gray-400" />
{#each [{ d: 1, l: '24h' }, { d: 7, l: '7d' }, { d: 14, l: '14d' }, { d: 30, l: '30d' }] as { d, l }}
<button
onclick={() => (dateRange = d)}
class="px-3 py-1.5 text-xs font-medium rounded transition-all {dateRange === d ? 'bg-indigo-600 text-white shadow-sm' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
{l}
</button>
{/each}
</div>
</div>
</div>
{#if isLoading}
<div class="flex items-center justify-center py-20">
<Loader2 class="w-8 h-8 animate-spin text-indigo-400" />
</div>
{:else}
<!-- Cost Overview Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div class="flex items-center space-x-2 mb-2">
<DollarSign class="w-4 h-4 text-green-600" />
<span class="text-xs font-medium text-gray-500 uppercase">Total API Cost</span>
</div>
<div class="text-2xl font-bold text-gray-900">{costData ? formatCost(costData.totalCost) : '$0.00'}</div>
<div class="text-xs text-gray-500 mt-1">all time</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div class="flex items-center space-x-2 mb-2">
<TrendingUp class="w-4 h-4 text-blue-600" />
<span class="text-xs font-medium text-gray-500 uppercase">Daily Average</span>
</div>
<div class="text-2xl font-bold text-gray-900">{formatCost(dailyAvgCost)}</div>
<div class="text-xs text-gray-500 mt-1">/day</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div class="flex items-center space-x-2 mb-2">
<TrendingUp class="w-4 h-4 text-purple-600" />
<span class="text-xs font-medium text-gray-500 uppercase">Projected Monthly</span>
</div>
<div class="text-2xl font-bold text-gray-900">{formatCost(projectedMonthly)}</div>
<div class="text-xs text-gray-500 mt-1">/month (30d projection)</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div class="flex items-center space-x-2 mb-2">
<Clock class="w-4 h-4 text-amber-600" />
<span class="text-xs font-medium text-gray-500 uppercase">Avg Response</span>
</div>
<div class="text-2xl font-bold text-gray-900">
{responseTimeStats ? `${(responseTimeStats.avg / 1000).toFixed(1)}s` : 'N/A'}
</div>
<div class="text-xs text-gray-500 mt-1">response time</div>
</div>
</div>
<!-- Response Metadata Cards -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Stop Reason Distribution -->
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center space-x-2">
<Activity class="w-4 h-4 text-indigo-600" />
<span>Stop Reasons</span>
</h3>
{#if stopReasonDistribution.length > 0}
<div class="space-y-2.5">
{#each stopReasonDistribution as [reason, count]}
{@const pct = recentSummaries.length > 0 ? ((count / recentSummaries.length) * 100).toFixed(1) : '0'}
{@const Icon = stopReasonIcon(reason)}
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<Icon class="w-3.5 h-3.5 {stopReasonColor(reason)}" />
<span class="text-sm font-mono text-gray-700">{reason}</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-24 bg-gray-100 rounded-full h-1.5">
<div class="h-1.5 rounded-full bg-indigo-500" style="width: {pct}%"></div>
</div>
<span class="text-xs text-gray-500 w-16 text-right">{count} ({pct}%)</span>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-sm text-gray-400 text-center py-4">No data</div>
{/if}
</div>
<!-- Service Tier Distribution -->
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center space-x-2">
<Zap class="w-4 h-4 text-amber-600" />
<span>Service Tier</span>
</h3>
{#if serviceTierDistribution.length > 0}
<div class="space-y-2.5">
{#each serviceTierDistribution as [tier, count]}
{@const pct = recentSummaries.length > 0 ? ((count / recentSummaries.length) * 100).toFixed(1) : '0'}
<div class="flex items-center justify-between">
<span class="text-sm font-mono text-gray-700">{tier}</span>
<div class="flex items-center space-x-2">
<div class="w-24 bg-gray-100 rounded-full h-1.5">
<div class="h-1.5 rounded-full bg-amber-500" style="width: {pct}%"></div>
</div>
<span class="text-xs text-gray-500 w-16 text-right">{count} ({pct}%)</span>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-sm text-gray-400 text-center py-4">No data</div>
{/if}
</div>
<!-- Cache Performance -->
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center space-x-2">
<Activity class="w-4 h-4 text-emerald-600" />
<span>Cache Performance</span>
</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 uppercase">Hit Rate</span>
<span class="text-lg font-bold text-emerald-600">{cacheBreakdown.cacheHitRate.toFixed(1)}%</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2">
<div class="h-2 rounded-full bg-emerald-500" style="width: {Math.min(cacheBreakdown.cacheHitRate, 100)}%"></div>
</div>
<div class="grid grid-cols-2 gap-3 pt-2">
<div class="bg-green-50 rounded-lg p-3 border border-green-100">
<div class="text-xs text-green-600 font-medium">Cache Read</div>
<div class="text-sm font-semibold text-green-800">{(cacheBreakdown.totalCacheRead / 1000).toFixed(0)}k</div>
</div>
<div class="bg-blue-50 rounded-lg p-3 border border-blue-100">
<div class="text-xs text-blue-600 font-medium">Cache Write</div>
<div class="text-sm font-semibold text-blue-800">{(cacheBreakdown.totalCacheCreation / 1000).toFixed(0)}k</div>
</div>
<div class="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div class="text-xs text-gray-500 font-medium">Input Tokens</div>
<div class="text-sm font-semibold text-gray-800">{(cacheBreakdown.totalInput / 1000).toFixed(0)}k</div>
</div>
<div class="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div class="text-xs text-gray-500 font-medium">Output Tokens</div>
<div class="text-sm font-semibold text-gray-800">{(cacheBreakdown.totalOutput / 1000).toFixed(0)}k</div>
</div>
</div>
<div class="text-xs text-gray-400 text-center">{cacheBreakdown.requestsWithCache} requests used cache</div>
</div>
</div>
</div>
<!-- Response Time Percentiles -->
{#if responseTimeStats}
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center space-x-2">
<Clock class="w-4 h-4 text-blue-600" />
<span>Response Time Distribution</span>
<span class="text-xs text-gray-400 font-normal">({responseTimeStats.count} requests)</span>
</h3>
<div class="grid grid-cols-4 gap-4">
<div class="text-center">
<div class="text-xs text-gray-500 uppercase mb-1">Average</div>
<div class="text-xl font-bold text-gray-900">{(responseTimeStats.avg / 1000).toFixed(1)}s</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 uppercase mb-1">P50</div>
<div class="text-xl font-bold text-blue-600">{(responseTimeStats.p50 / 1000).toFixed(1)}s</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 uppercase mb-1">P90</div>
<div class="text-xl font-bold text-amber-600">{(responseTimeStats.p90 / 1000).toFixed(1)}s</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 uppercase mb-1">P99</div>
<div class="text-xl font-bold text-red-600">{(responseTimeStats.p99 / 1000).toFixed(1)}s</div>
</div>
</div>
</div>
{/if}
<!-- Subscription Comparison -->
{#if costData && costData.totalCost > 0}
<div class="bg-gradient-to-r from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-6 shadow-sm">
<h3 class="text-sm font-semibold text-indigo-900 mb-4 flex items-center space-x-2">
<DollarSign class="w-4 h-4" />
<span>API vs Subscription Comparison</span>
</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-white rounded-lg p-4 border border-indigo-200">
<div class="text-xs font-medium text-indigo-600 uppercase mb-1">Your API Usage</div>
<div class="text-xl font-bold text-indigo-700">{formatCost(projectedMonthly)}<span class="text-sm font-normal">/mo</span></div>
<div class="text-xs text-gray-500 mt-1">Pay-as-you-go</div>
</div>
{#each SUBSCRIPTION_PLANS as plan}
{@const isCheaper = projectedMonthly < plan.monthlyPrice}
<div class="bg-white rounded-lg p-4 border {isCheaper ? 'border-green-200' : 'border-red-200'}">
<div class="text-xs font-medium {isCheaper ? 'text-green-600' : 'text-red-600'} uppercase mb-1">{plan.name}</div>
<div class="text-xl font-bold {isCheaper ? 'text-green-700' : 'text-red-700'}">${plan.monthlyPrice}<span class="text-sm font-normal">/mo</span></div>
<div class="text-xs mt-1 {isCheaper ? 'text-green-600' : 'text-red-600'}">
{isCheaper
? `API saves you ${formatCost(Math.abs(plan.monthlyPrice - projectedMonthly))}/mo`
: `Sub saves you ${formatCost(Math.abs(plan.monthlyPrice - projectedMonthly))}/mo`}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Charts Row 1: Daily Usage + Stop Reasons -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Daily Token Usage</h3>
{#if dailyChartData}
<ChartCanvas type="bar" data={dailyChartData} height="250px" options={{
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { callback: (v: string | number) => { const n = Number(v); return n >= 1000000 ? `${(n / 1000000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(0)}k` : n; } } }
}
}} />
{:else}
<div class="flex items-center justify-center h-[250px] text-gray-400 text-sm">No data for this period</div>
{/if}
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Stop Reason Distribution</h3>
{#if stopReasonChartData}
<ChartCanvas type="doughnut" data={stopReasonChartData} height="250px" options={{
plugins: {
legend: { position: 'right', labels: { usePointStyle: true, padding: 16, font: { size: 12 } } }
}
}} />
{:else}
<div class="flex items-center justify-center h-[250px] text-gray-400 text-sm">No data for this period</div>
{/if}
</div>
</div>
<!-- Charts Row 2: Daily Requests + Hourly Activity -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Daily Requests</h3>
{#if dailyRequestsChartData}
<ChartCanvas type="line" data={dailyRequestsChartData} height="250px" options={{
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } }
}} />
{:else}
<div class="flex items-center justify-center h-[250px] text-gray-400 text-sm">No data for this period</div>
{/if}
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-semibold text-gray-900">Activity</h3>
<div class="flex items-center space-x-1">
{#each [{ m: 5, l: '5m' }, { m: 15, l: '15m' }, { m: 30, l: '30m' }, { m: 60, l: '1h' }] as { m, l }}
<button
onclick={() => { bucketMinutes = m; }}
class="px-2 py-0.5 text-xs font-medium rounded transition-all {bucketMinutes === m ? 'bg-amber-500 text-white shadow-sm' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}"
>
{l}
</button>
{/each}
</div>
</div>
<p class="text-xs text-gray-400 mb-3">
{new Date(startTime).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} {new Date(endTime).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
{#if hourlyChartData}
<ChartCanvas type="line" data={hourlyChartData} height="250px"
scrollable
options={{
plugins: { legend: { display: false } },
scales: {
x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 24 } },
y: { beginAtZero: true, ticks: { callback: (v: string | number) => { const n = Number(v); return n >= 1000 ? `${(n / 1000).toFixed(0)}k` : n; } } }
}
}} />
{:else}
<div class="flex items-center justify-center h-[250px] text-gray-400 text-sm">No data for this period</div>
{/if}
</div>
</div>
<!-- Patterns & Comparisons -->
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-semibold text-gray-900">Patterns</h3>
<div class="flex items-center space-x-1">
{#each [
{ key: 'dow' as const, label: 'By Day' },
{ key: 'wow' as const, label: 'Week / Week' },
{ key: 'hod' as const, label: 'By Hour' },
] as { key, label }}
<button
onclick={() => { comparisonTab = key; }}
class="px-2.5 py-1 text-xs font-medium rounded transition-all {comparisonTab === key ? 'bg-indigo-600 text-white shadow-sm' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}"
>
{label}
</button>
{/each}
</div>
</div>
<p class="text-xs text-gray-400 mb-3">
{#if comparisonTab === 'dow'}
Average tokens & requests per day of week over the selected period
{:else if comparisonTab === 'wow'}
Token usage overlaid by week (most recent weeks, MonSun)
{:else}
Average tokens per hour of day over the selected period
{/if}
</p>
{#if comparisonTab === 'dow'}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{#if dowChartData}
<div>
<p class="text-xs text-gray-500 mb-2 font-medium">Tokens</p>
<ChartCanvas type="bar" data={dowChartData} height="220px" options={{
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { callback: (v: string | number) => { const n = Number(v); return n >= 1000 ? `${(n / 1000).toFixed(0)}k` : n; } } }
}
}} />
</div>
{/if}
{#if dowRequestsChartData}
<div>
<p class="text-xs text-gray-500 mb-2 font-medium">Requests</p>
<ChartCanvas type="bar" data={dowRequestsChartData} height="220px" options={{
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } }
}} />
</div>
{/if}
{#if !dowChartData}
<div class="flex items-center justify-center h-[220px] text-gray-400 text-sm col-span-2">No data for this period</div>
{/if}
</div>
{:else if comparisonTab === 'wow'}
{#if wowChartData}
<ChartCanvas type="line" data={wowChartData} height="280px" options={{
plugins: { legend: { position: 'bottom', labels: { usePointStyle: true, padding: 12, font: { size: 11 } } } },
scales: {
y: { beginAtZero: true, ticks: { callback: (v: string | number) => { const n = Number(v); return n >= 1000 ? `${(n / 1000).toFixed(0)}k` : n; } } }
}
}} />
{:else}
<div class="flex items-center justify-center h-[280px] text-gray-400 text-sm">Need at least 2 weeks of data</div>
{/if}
{:else}
{#if hodChartData}
<ChartCanvas type="line" data={hodChartData} height="280px" options={{
plugins: { legend: { display: false } },
scales: {
x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 } },
y: { beginAtZero: true, ticks: { callback: (v: string | number) => { const n = Number(v); return n >= 1000 ? `${(n / 1000).toFixed(0)}k` : n; } } }
}
}} />
{:else}
<div class="flex items-center justify-center h-[280px] text-gray-400 text-sm">No data for this period</div>
{/if}
{/if}
</div>
<!-- Charts Row 3: Model Distribution -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Model Distribution</h3>
{#if modelChartData}
<ChartCanvas type="doughnut" data={modelChartData} height="250px" options={{
plugins: {
legend: { position: 'right', labels: { usePointStyle: true, padding: 16, font: { size: 12 } } }
}
}} />
{:else}
<div class="flex items-center justify-center h-[250px] text-gray-400 text-sm">No data for this period</div>
{/if}
</div>
<!-- Cost by Model -->
{#if costChartData}
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Cost by Model</h3>
<ChartCanvas type="bar" data={costChartData} height="250px" options={{
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true, ticks: { callback: (v: string | number) => `$${Number(v).toFixed(2)}` } } }
}} />
</div>
{/if}
</div>
<!-- Detailed Cost Breakdown -->
{#if costData && costData.costByModel.length > 0}
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Detailed Cost Breakdown</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{#each costData.costByModel as item}
{@const pricing = getModelPricing(item.model)}
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-100">
<div>
<div class="text-sm font-medium text-gray-900 flex items-center space-x-2">
{#if pricing.tier === 'opus'}
<Brain class="w-3.5 h-3.5 text-purple-600" />
{:else if pricing.tier === 'sonnet'}
<Sparkles class="w-3.5 h-3.5 text-indigo-600" />
{:else if pricing.tier === 'haiku'}
<Zap class="w-3.5 h-3.5 text-teal-600" />
{/if}
<span>{item.label}</span>
</div>
<div class="text-xs text-gray-500 mt-0.5">
{item.requests} requests &middot;
{((item.inputTokens + item.outputTokens) / 1000).toFixed(0)}k tokens
{#if item.cacheTokens > 0}
&middot; {(item.cacheTokens / 1000).toFixed(0)}k cached
{/if}
</div>
</div>
<div class="text-right">
<div class="text-sm font-semibold text-gray-900">{formatCost(item.cost)}</div>
<div class="text-xs text-gray-500">${pricing.inputPerMTok}/${pricing.outputPerMTok} per MTok</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Today's Stats -->
{#if hourlyStats}
<div class="grid grid-cols-3 gap-4">
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm text-center">
<div class="text-xs font-medium text-gray-500 uppercase mb-1">Today's Tokens</div>
<div class="text-2xl font-bold text-indigo-600">{hourlyStats.todayTokens?.toLocaleString() || '0'}</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm text-center">
<div class="text-xs font-medium text-gray-500 uppercase mb-1">Today's Requests</div>
<div class="text-2xl font-bold text-emerald-600">{hourlyStats.todayRequests || 0}</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 shadow-sm text-center">
<div class="text-xs font-medium text-gray-500 uppercase mb-1">Avg Response Time</div>
<div class="text-2xl font-bold text-amber-600">{hourlyStats.avgResponseTime ? `${(hourlyStats.avgResponseTime / 1000).toFixed(1)}s` : 'N/A'}</div>
</div>
</div>
{/if}
{/if}
</main>
</div>

View file

@ -0,0 +1,13 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const GET: RequestHandler = async ({ url }) => {
const res = await fetch(buildBackendURL('/api/conversations', url.searchParams), {
headers: backendAuthHeaders()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,14 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const GET: RequestHandler = async ({ params, url }) => {
const res = await fetch(
buildBackendURL(`/api/conversations/${encodeURIComponent(params.id)}`, url.searchParams),
{ headers: backendAuthHeaders() }
);
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,19 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const POST: RequestHandler = async ({ request }) => {
const body = await request.text();
const res = await fetch(buildBackendURL('/api/grade-prompt'), {
method: 'POST',
headers: {
'content-type': 'application/json',
...backendAuthHeaders()
},
body
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,24 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const GET: RequestHandler = async ({ url }) => {
const res = await fetch(buildBackendURL('/api/requests', url.searchParams), {
headers: backendAuthHeaders()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};
export const DELETE: RequestHandler = async () => {
const res = await fetch(buildBackendURL('/api/requests'), {
method: 'DELETE',
headers: backendAuthHeaders()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,13 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const GET: RequestHandler = async ({ params }) => {
const res = await fetch(buildBackendURL(`/api/requests/${encodeURIComponent(params.id)}`), {
headers: backendAuthHeaders()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,13 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const GET: RequestHandler = async () => {
const res = await fetch(buildBackendURL('/api/requests/latest-date'), {
headers: backendAuthHeaders()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,13 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const GET: RequestHandler = async ({ url }) => {
const res = await fetch(buildBackendURL('/api/requests/summary', url.searchParams), {
headers: backendAuthHeaders()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,25 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const GET: RequestHandler = async () => {
const res = await fetch(buildBackendURL('/api/settings'), {
headers: backendAuthHeaders()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};
export const PUT: RequestHandler = async ({ request }) => {
const res = await fetch(buildBackendURL('/api/settings'), {
method: 'PUT',
headers: { ...backendAuthHeaders(), 'content-type': 'application/json' },
body: await request.text()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,13 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const GET: RequestHandler = async ({ url }) => {
const res = await fetch(buildBackendURL('/api/stats', url.searchParams), {
headers: backendAuthHeaders()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,13 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const GET: RequestHandler = async ({ url }) => {
const res = await fetch(buildBackendURL('/api/stats/dashboard', url.searchParams), {
headers: backendAuthHeaders()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,13 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const GET: RequestHandler = async ({ url }) => {
const res = await fetch(buildBackendURL('/api/stats/hourly', url.searchParams), {
headers: backendAuthHeaders()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,13 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const GET: RequestHandler = async ({ url }) => {
const res = await fetch(buildBackendURL('/api/stats/models', url.searchParams), {
headers: backendAuthHeaders()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,13 @@
import type { RequestHandler } from './$types';
import { backendAuthHeaders } from '$lib/auth.server';
import { buildBackendURL } from '$lib/backend.server';
export const GET: RequestHandler = async ({ url }) => {
const res = await fetch(buildBackendURL('/api/stats/organizations', url.searchParams), {
headers: backendAuthHeaders()
});
return new Response(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') || 'application/json' }
});
};

View file

@ -0,0 +1,217 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { replaceState } from '$app/navigation';
import { MessageCircle, Loader2 } from 'lucide-svelte';
import Nav from '$lib/components/Nav.svelte';
import ChatSidebar from '$lib/components/ChatSidebar.svelte';
import ChatRequestDetail from '$lib/components/ChatRequestDetail.svelte';
import { fetchRequestsSummary, fetchRequestById } from '$lib/api';
import type { RequestSummary, Request, ConversationGroup } from '$lib/types';
// -----------------------------------------------------------------------
// State
// -----------------------------------------------------------------------
let summaries: RequestSummary[] = $state([]);
let selectedRequest: Request | null = $state(null);
let selectedId: string | null = $state(null);
let isLoadingList = $state(true);
let isLoadingDetail = $state(false);
let expandedRawSections: Record<string, boolean> = $state({});
let expandedGroups: Set<string> = $state(new Set());
let chatContainer: HTMLElement | undefined = $state(undefined);
const initialSummaryLimit = 500;
// -----------------------------------------------------------------------
// Derived data
// -----------------------------------------------------------------------
/** Map requestId -> summary for quick lookup. */
let summaryMap = $derived.by(() => {
const map = new Map<string, RequestSummary>();
for (const s of summaries) map.set(s.requestId, s);
return map;
});
/** Group summaries by conversationHash, showing only the latest per conversation. */
let conversationGroups = $derived.by(() => {
const hashMap = new Map<string, RequestSummary[]>();
for (const s of summaries) {
const key = s.conversationHash || s.requestId;
if (!hashMap.has(key)) hashMap.set(key, []);
hashMap.get(key)!.push(s);
}
const groups: ConversationGroup[] = [];
for (const [hash, requests] of hashMap) {
const latest = requests[0];
const oldest = requests[requests.length - 1];
let totalTokens = 0;
let totalResponseTime = 0;
for (const r of requests) {
if (r.usage) totalTokens += (r.usage.input_tokens || 0) + (r.usage.output_tokens || 0);
if (r.responseTime) totalResponseTime += r.responseTime;
}
groups.push({
conversationHash: hash,
latestRequest: latest,
turnCount: requests.length,
totalTokens,
totalResponseTime,
firstTimestamp: oldest.timestamp,
lastTimestamp: latest.timestamp,
requestIds: requests.map(r => r.requestId),
});
}
return groups;
});
/** Group conversations by date for sidebar display (newest first). */
let groupedByDate = $derived.by(() => {
const groups: Record<string, ConversationGroup[]> = {};
for (const g of conversationGroups) {
const date = new Date(g.lastTimestamp).toLocaleDateString(undefined, {
weekday: 'short', month: 'short', day: 'numeric'
});
if (!groups[date]) groups[date] = [];
groups[date].push(g);
}
return groups;
});
/** The conversation group containing the currently selected request. */
let selectedGroup = $derived.by(() => {
if (!selectedId) return null;
return conversationGroups.find(g => g.requestIds.includes(selectedId!)) || null;
});
/** For the selected group, requestIds in turn order (oldest first). */
let selectedGroupTurnIds = $derived.by(() => {
if (!selectedGroup) return [];
return [...selectedGroup.requestIds].reverse();
});
// -----------------------------------------------------------------------
// Actions
// -----------------------------------------------------------------------
async function selectRequest(id: string, pushUrl = true) {
if (selectedId === id) return;
selectedId = id;
isLoadingDetail = true;
expandedRawSections = {};
// Auto-expand the sidebar group when viewing an older turn
const group = conversationGroups.find(g => g.requestIds.includes(id));
if (group && group.turnCount > 1 && id !== group.latestRequest.requestId && !expandedGroups.has(group.conversationHash)) {
expandedGroups = new Set([...expandedGroups, group.conversationHash]);
}
// Update URL without full navigation
if (pushUrl && typeof window !== 'undefined') {
const url = new URL(window.location.href);
url.searchParams.set('id', id);
replaceState(url.toString(), {});
}
try {
const result = await fetchRequestById(id);
selectedRequest = { ...result.request, id };
} catch (e) {
console.error('Failed to load request:', e);
selectedRequest = null;
} finally {
isLoadingDetail = false;
await tick();
if (chatContainer) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
}
function toggleRawSection(key: string) {
expandedRawSections = { ...expandedRawSections, [key]: !expandedRawSections[key] };
}
function toggleGroupExpanded(hash: string) {
const next = new Set(expandedGroups);
if (next.has(hash)) next.delete(hash);
else next.add(hash);
expandedGroups = next;
}
// -----------------------------------------------------------------------
// Lifecycle
// -----------------------------------------------------------------------
onMount(async () => {
try {
const result = await fetchRequestsSummary('all', undefined, undefined, 0, initialSummaryLimit);
summaries = result.requests;
const urlId = new URL(window.location.href).searchParams.get('id');
if (urlId) {
await selectRequest(urlId, false);
} else if (conversationGroups.length > 0) {
await selectRequest(conversationGroups[0].latestRequest.requestId);
}
} catch (e) {
console.error('Failed to load summaries:', e);
} finally {
isLoadingList = false;
}
});
</script>
<div class="h-screen flex flex-col bg-gray-50">
<Nav />
<!-- Sub-bar -->
<div class="flex-shrink-0 bg-white border-b border-gray-100 px-4 py-1.5 flex items-center space-x-3 z-10">
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">{conversationGroups.length} conversations</span>
<span class="text-xs text-gray-400 bg-gray-50 px-2 py-0.5 rounded-full">{summaries.length} requests</span>
</div>
<!-- Main content: sidebar + chat -->
<div class="flex-1 flex min-h-0">
<ChatSidebar
{groupedByDate}
{summaryMap}
{selectedId}
{expandedGroups}
isLoading={isLoadingList}
totalGroups={conversationGroups.length}
onSelectRequest={(id) => selectRequest(id)}
onToggleGroup={toggleGroupExpanded}
/>
<!-- Main chat area (auto-scrolls to bottom) -->
<main bind:this={chatContainer} class="flex-1 min-w-0 overflow-y-auto">
{#if isLoadingDetail}
<div class="flex items-center justify-center h-full">
<Loader2 class="w-6 h-6 animate-spin text-gray-400" />
</div>
{:else if !selectedRequest}
<div class="flex items-center justify-center h-full text-gray-400">
<div class="text-center">
<MessageCircle class="w-12 h-12 mx-auto mb-3 opacity-30" />
<p class="text-sm">Select a conversation to view</p>
</div>
</div>
{:else}
<ChatRequestDetail
request={selectedRequest}
selectedId={selectedId!}
{selectedGroupTurnIds}
{summaryMap}
{expandedRawSections}
onToggleRaw={toggleRawSection}
onSelectRequest={(id) => selectRequest(id)}
/>
{/if}
</main>
</div>
</div>

View file

@ -0,0 +1,194 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Loader2, Brain, Sparkles, Zap, MessageCircle, X
} from 'lucide-svelte';
import Nav from '$lib/components/Nav.svelte';
import ConversationThread from '$lib/components/ConversationThread.svelte';
import { fetchConversations, fetchConversationDetail } from '$lib/api';
import { formatDuration, formatDate, formatTimeOfDay } from '$lib/formatters';
import type { ConversationSummary, Conversation } from '$lib/types';
let conversations: ConversationSummary[] = $state([]);
let selectedConversation: Conversation | null = $state(null);
let isConversationModalOpen = $state(false);
let modelFilter = $state('all');
let isFetching = $state(false);
let currentPage = $state(1);
let hasMore = $state(true);
const itemsPerPage = 50;
async function loadConversations(currentModelFilter = 'all', loadMore = false) {
isFetching = true;
const pageToFetch = loadMore ? currentPage + 1 : 1;
try {
const result = await fetchConversations(pageToFetch, itemsPerPage, currentModelFilter);
if (loadMore) {
conversations = [...conversations, ...result.conversations];
} else {
conversations = result.conversations;
}
currentPage = pageToFetch;
hasMore = result.hasMore;
} catch (error) {
console.error('Failed to load conversations:', error);
conversations = [];
} finally {
isFetching = false;
}
}
async function loadConversationDetails(conversationId: string, projectPath: string) {
try {
selectedConversation = await fetchConversationDetail(conversationId, projectPath);
isConversationModalOpen = true;
} catch (error) {
console.error('Failed to load conversation details:', error);
}
}
function handleModelFilterChange(newFilter: string) {
modelFilter = newFilter;
loadConversations(newFilter);
}
onMount(() => {
loadConversations(modelFilter);
function handleEscapeKey(event: KeyboardEvent) {
if (event.key === 'Escape' && isConversationModalOpen) {
isConversationModalOpen = false;
selectedConversation = null;
}
}
window.addEventListener('keydown', handleEscapeKey);
return () => window.removeEventListener('keydown', handleEscapeKey);
});
</script>
<svelte:head>
<title>Conversations - Claude Code Proxy</title>
</svelte:head>
<div class="min-h-screen bg-gray-50">
<Nav />
<!-- Model filter -->
<div class="mb-4 flex justify-center pt-4">
<div class="inline-flex items-center bg-gray-100 rounded p-0.5 space-x-0.5">
<button onclick={() => handleModelFilterChange('all')} class="px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 {modelFilter === 'all' ? 'bg-white text-gray-900 shadow-sm' : 'bg-transparent text-gray-600 hover:text-gray-900'}">
All Models
</button>
<button onclick={() => handleModelFilterChange('opus')} class="px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 flex items-center space-x-1 {modelFilter === 'opus' ? 'bg-white text-purple-600 shadow-sm' : 'bg-transparent text-gray-600 hover:text-gray-900'}">
<Brain class="w-3 h-3" /><span>Opus</span>
</button>
<button onclick={() => handleModelFilterChange('sonnet')} class="px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 flex items-center space-x-1 {modelFilter === 'sonnet' ? 'bg-white text-indigo-600 shadow-sm' : 'bg-transparent text-gray-600 hover:text-gray-900'}">
<Sparkles class="w-3 h-3" /><span>Sonnet</span>
</button>
<button onclick={() => handleModelFilterChange('haiku')} class="px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 flex items-center space-x-1 {modelFilter === 'haiku' ? 'bg-white text-teal-600 shadow-sm' : 'bg-transparent text-gray-600 hover:text-gray-900'}">
<Zap class="w-3 h-3" /><span>Haiku</span>
</button>
</div>
</div>
<main class="max-w-7xl mx-auto px-6 py-8 space-y-8">
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-900 uppercase tracking-wider">Conversations</h2>
</div>
<div class="divide-y divide-gray-200">
{#if isFetching && currentPage === 1}
<div class="p-8 text-center">
<Loader2 class="w-6 h-6 mx-auto animate-spin text-gray-400" />
<p class="mt-2 text-xs text-gray-500">Loading conversations...</p>
</div>
{:else if conversations.length === 0}
<div class="p-8 text-center text-gray-500">
<h3 class="text-sm font-medium text-gray-600 mb-1">No conversations found</h3>
<p class="text-xs text-gray-500">Start a conversation to see it appear here</p>
</div>
{:else}
{#each conversations as conversation}
<div class="px-4 py-4 hover:bg-gray-50 transition-colors cursor-pointer border-b border-gray-100 last:border-b-0" role="button" tabindex="0" onclick={() => loadConversationDetails(conversation.id, conversation.projectPath)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadConversationDetails(conversation.id, conversation.projectPath); } }}>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0 mr-4">
<div class="flex items-center space-x-2 mb-2">
<span class="text-sm font-semibold text-gray-900 font-mono">#{conversation.id.slice(-8)}</span>
<span class="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full font-medium">{conversation.requestCount} turns</span>
<span class="text-xs px-2 py-0.5 bg-gray-100 text-gray-700 rounded-full">{formatDuration(conversation.duration)}</span>
{#if conversation.projectName}
<span class="text-xs px-2 py-0.5 bg-purple-100 text-purple-700 rounded-full font-medium">{conversation.projectName}</span>
{/if}
</div>
<div class="space-y-2">
<div class="bg-gray-50 rounded p-2 border border-gray-200">
<div class="text-xs font-medium text-gray-600 mb-0.5">First Message</div>
<div class="text-xs text-gray-700 line-clamp-2">{conversation.firstMessage || 'No content'}</div>
</div>
{#if conversation.lastMessage && conversation.lastMessage !== conversation.firstMessage}
<div class="bg-blue-50 rounded p-2 border border-blue-200">
<div class="text-xs font-medium text-blue-600 mb-0.5">Latest Message</div>
<div class="text-xs text-gray-700 line-clamp-2">{conversation.lastMessage}</div>
</div>
{/if}
</div>
</div>
<div class="flex-shrink-0 text-right">
<div class="text-xs text-gray-500">{formatDate(conversation.startTime)}</div>
<div class="text-xs text-gray-400">{formatTimeOfDay(conversation.startTime)}</div>
</div>
</div>
</div>
{/each}
{#if hasMore}
<div class="p-3 text-center border-t border-gray-100">
<button onclick={() => loadConversations(modelFilter, true)} disabled={isFetching} class="px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 rounded hover:bg-gray-200 disabled:opacity-50 transition-colors">
{isFetching ? 'Loading...' : 'Load More'}
</button>
</div>
{/if}
{/if}
</div>
</div>
</main>
<!-- Conversation Detail Modal -->
{#if isConversationModalOpen && selectedConversation}
<div class="fixed inset-0 bg-gray-900/70 backdrop-blur-sm z-50 flex items-center justify-center p-6" role="dialog" aria-modal="true" aria-label="Conversation details">
<div class="bg-white rounded-xl max-w-6xl w-full max-h-[90vh] overflow-hidden shadow-2xl">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<MessageCircle class="w-5 h-5 text-blue-600" />
<h3 class="text-lg font-semibold text-gray-900">Conversation {selectedConversation.sessionId.slice(-8)}</h3>
<span class="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-2 py-1 rounded-full">{selectedConversation.messageCount} messages</span>
</div>
<button onclick={() => { isConversationModalOpen = false; selectedConversation = null; }} class="text-gray-400 hover:text-gray-600 transition-colors p-2 hover:bg-gray-100 rounded-lg">
<X class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 overflow-y-auto max-h-[calc(90vh-100px)]">
<div class="space-y-6">
<div class="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-xl p-6">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">{selectedConversation.messageCount}</div>
<div class="text-sm text-gray-600">Messages</div>
</div>
<div class="text-center">
<div class="text-sm font-medium text-gray-700">{formatDate(selectedConversation.startTime)}</div>
<div class="text-sm text-gray-600">Started</div>
</div>
<div class="text-center">
<div class="text-sm font-medium text-gray-700">{formatDate(selectedConversation.endTime)}</div>
<div class="text-sm text-gray-600">Last Activity</div>
</div>
</div>
</div>
<ConversationThread conversation={selectedConversation} />
</div>
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,274 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
ArrowLeft, Plus, Trash2, Save, Loader2, Settings,
Shield, ArrowDownUp, ToggleLeft, ToggleRight, Code, Eye, EyeOff
} from 'lucide-svelte';
import Nav from '$lib/components/Nav.svelte';
import { fetchSettings, saveSettings } from '$lib/api';
import type { HeaderRule, ProxySettings } from '$lib/types';
let settings: ProxySettings = $state({
requestHeaderRules: [],
responseHeaderRules: []
});
let isLoading = $state(true);
let isSaving = $state(false);
let saveMessage = $state('');
let showRawJson = $state(false);
function newRule(): HeaderRule {
return { header: '', action: 'block', value: '', find: '', enabled: true };
}
function addRequestRule() {
settings.requestHeaderRules = [...settings.requestHeaderRules, newRule()];
}
function addResponseRule() {
settings.responseHeaderRules = [...settings.responseHeaderRules, newRule()];
}
function removeRequestRule(idx: number) {
settings.requestHeaderRules = settings.requestHeaderRules.filter((_, i) => i !== idx);
}
function removeResponseRule(idx: number) {
settings.responseHeaderRules = settings.responseHeaderRules.filter((_, i) => i !== idx);
}
function toggleRule(rule: HeaderRule) {
rule.enabled = !rule.enabled;
settings = settings; // trigger reactivity
}
async function handleSave() {
isSaving = true;
saveMessage = '';
try {
await saveSettings(settings);
saveMessage = 'Settings saved';
setTimeout(() => saveMessage = '', 3000);
} catch (error) {
saveMessage = 'Failed to save';
console.error('Failed to save settings:', error);
} finally {
isSaving = false;
}
}
onMount(async () => {
try {
const data = await fetchSettings();
settings = {
requestHeaderRules: data.requestHeaderRules || [],
responseHeaderRules: data.responseHeaderRules || [],
};
} catch (error) {
console.error('Failed to load settings:', error);
} finally {
isLoading = false;
}
});
</script>
<div class="min-h-screen bg-gray-50">
<Nav />
<header class="sticky top-[53px] z-10 bg-white border-b border-gray-100 px-6 py-2">
<div class="max-w-4xl mx-auto flex items-center justify-end">
<div class="flex items-center space-x-3">
{#if saveMessage}
<span class="text-xs text-green-600 font-medium">{saveMessage}</span>
{/if}
<button
onclick={() => showRawJson = !showRawJson}
class="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="View raw JSON"
>
{#if showRawJson}<EyeOff class="w-4 h-4" />{:else}<Code class="w-4 h-4" />{/if}
</button>
<button
onclick={handleSave}
disabled={isSaving}
class="flex items-center space-x-1.5 px-3 py-1.5 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{#if isSaving}
<Loader2 class="w-3.5 h-3.5 animate-spin" />
{:else}
<Save class="w-3.5 h-3.5" />
{/if}
<span>Save</span>
</button>
</div>
</div>
</header>
<main class="max-w-4xl mx-auto px-6 py-8 space-y-8">
{#if isLoading}
<div class="flex items-center justify-center py-20">
<Loader2 class="w-6 h-6 animate-spin text-gray-400" />
</div>
{:else}
{#if showRawJson}
<div class="bg-gray-900 rounded-xl p-4 overflow-x-auto">
<pre class="text-xs text-gray-300 font-mono">{JSON.stringify(settings, null, 2)}</pre>
</div>
{/if}
<!-- Request Header Rules -->
<section>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-2">
<ArrowDownUp class="w-4 h-4 text-blue-600" />
<h2 class="text-sm font-semibold text-gray-900 uppercase tracking-wider">Request Header Rules</h2>
<span class="text-xs text-gray-400">Applied before forwarding upstream</span>
</div>
<button onclick={addRequestRule} class="flex items-center space-x-1 text-xs text-blue-600 hover:text-blue-800 font-medium transition-colors">
<Plus class="w-3.5 h-3.5" />
<span>Add Rule</span>
</button>
</div>
{#if settings.requestHeaderRules.length === 0}
<div class="bg-white border border-gray-200 rounded-lg p-6 text-center text-sm text-gray-500">
No request header rules. Requests are forwarded with all headers intact.
</div>
{:else}
<div class="space-y-2">
{#each settings.requestHeaderRules as rule, idx}
<div class="bg-white border border-gray-200 rounded-lg p-3 {rule.enabled ? '' : 'opacity-50'}">
<div class="flex items-center space-x-2">
<button onclick={() => toggleRule(rule)} class="flex-shrink-0 text-gray-400 hover:text-blue-600 transition-colors" title="{rule.enabled ? 'Disable' : 'Enable'}">
{#if rule.enabled}
<ToggleRight class="w-5 h-5 text-blue-600" />
{:else}
<ToggleLeft class="w-5 h-5" />
{/if}
</button>
<input
type="text"
bind:value={rule.header}
placeholder="Header name"
class="flex-1 text-sm font-mono border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<select bind:value={rule.action} class="text-sm border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white">
<option value="block">Block</option>
<option value="set">Set</option>
<option value="replace">Replace</option>
</select>
{#if rule.action === 'set'}
<input
type="text"
bind:value={rule.value}
placeholder="Value"
class="flex-1 text-sm font-mono border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{:else if rule.action === 'replace'}
<input
type="text"
bind:value={rule.find}
placeholder="Find"
class="w-32 text-sm font-mono border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<span class="text-xs text-gray-400">&rarr;</span>
<input
type="text"
bind:value={rule.value}
placeholder="Replace with"
class="w-32 text-sm font-mono border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{/if}
<button onclick={() => removeRequestRule(idx)} class="flex-shrink-0 text-gray-400 hover:text-red-600 transition-colors p-1">
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
{/each}
</div>
{/if}
</section>
<!-- Response Header Rules -->
<section>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-2">
<Shield class="w-4 h-4 text-emerald-600" />
<h2 class="text-sm font-semibold text-gray-900 uppercase tracking-wider">Response Header Rules</h2>
<span class="text-xs text-gray-400">Applied before forwarding to client</span>
</div>
<button onclick={addResponseRule} class="flex items-center space-x-1 text-xs text-emerald-600 hover:text-emerald-800 font-medium transition-colors">
<Plus class="w-3.5 h-3.5" />
<span>Add Rule</span>
</button>
</div>
{#if settings.responseHeaderRules.length === 0}
<div class="bg-white border border-gray-200 rounded-lg p-6 text-center text-sm text-gray-500">
No response header rules. Responses are forwarded with all headers intact.
</div>
{:else}
<div class="space-y-2">
{#each settings.responseHeaderRules as rule, idx}
<div class="bg-white border border-gray-200 rounded-lg p-3 {rule.enabled ? '' : 'opacity-50'}">
<div class="flex items-center space-x-2">
<button onclick={() => toggleRule(rule)} class="flex-shrink-0 text-gray-400 hover:text-emerald-600 transition-colors" title="{rule.enabled ? 'Disable' : 'Enable'}">
{#if rule.enabled}
<ToggleRight class="w-5 h-5 text-emerald-600" />
{:else}
<ToggleLeft class="w-5 h-5" />
{/if}
</button>
<input
type="text"
bind:value={rule.header}
placeholder="Header name"
class="flex-1 text-sm font-mono border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
<select bind:value={rule.action} class="text-sm border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-white">
<option value="block">Block</option>
<option value="set">Set</option>
<option value="replace">Replace</option>
</select>
{#if rule.action === 'set'}
<input
type="text"
bind:value={rule.value}
placeholder="Value"
class="flex-1 text-sm font-mono border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
{:else if rule.action === 'replace'}
<input
type="text"
bind:value={rule.find}
placeholder="Find"
class="w-32 text-sm font-mono border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
<span class="text-xs text-gray-400">&rarr;</span>
<input
type="text"
bind:value={rule.value}
placeholder="Replace with"
class="w-32 text-sm font-mono border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
{/if}
<button onclick={() => removeResponseRule(idx)} class="flex-shrink-0 text-gray-400 hover:text-red-600 transition-colors p-1">
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
{/each}
</div>
{/if}
</section>
<!-- Help -->
<section class="bg-gray-100 rounded-xl p-5 text-sm text-gray-600 space-y-2">
<h3 class="font-semibold text-gray-800">How rules work</h3>
<ul class="space-y-1 text-xs">
<li><strong class="text-gray-800">Block</strong> — removes the header entirely</li>
<li><strong class="text-gray-800">Set</strong> — overrides the header with the specified value</li>
<li><strong class="text-gray-800">Replace</strong> — find &amp; replace within the header value (e.g. remove a specific beta flag)</li>
</ul>
<p class="text-xs text-gray-500 mt-2">Header names are matched case-insensitively. Rules are applied in order. Changes take effect immediately on save — no restart needed.</p>
</section>
{/if}
</main>
</div>

BIN
svelte/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Binary file not shown.

12
svelte/svelte.config.js Normal file
View file

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;

23
svelte/tailwind.config.ts Normal file
View file

@ -0,0 +1,23 @@
import type { Config } from 'tailwindcss';
export default {
darkMode: 'class',
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
fontFamily: {
sans: [
'Inter',
'ui-sans-serif',
'system-ui',
'sans-serif',
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
'Noto Color Emoji'
]
}
}
},
plugins: []
} satisfies Config;

14
svelte/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

32
svelte/vite.config.ts Normal file
View file

@ -0,0 +1,32 @@
import path from 'node:path';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig, loadEnv, searchForWorkspaceRoot } from 'vite';
import { resolveBackendOrigin } from '../shared/frontend/backend';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [sveltekit()],
server: {
allowedHosts: true,
fs: {
allow: [
searchForWorkspaceRoot(process.cwd()),
path.resolve(__dirname, '../shared')
]
},
hmr: {
// When behind a reverse proxy (Traefik), use the client's host for WebSocket
clientPort: 443,
protocol: 'wss'
},
proxy: {
'/api': {
target: resolveBackendOrigin(env),
changeOrigin: true
}
}
}
};
});