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:
parent
b9da198e1f
commit
8e550b9785
152 changed files with 19227 additions and 19463 deletions
2851
svelte/package-lock.json
generated
Normal file
2851
svelte/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
svelte/package.json
Normal file
32
svelte/package.json
Normal 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
6
svelte/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
333
svelte/src/app.css
Normal file
333
svelte/src/app.css
Normal 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
20
svelte/src/app.html
Normal 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>
|
||||
16
svelte/src/hooks.server.ts
Normal file
16
svelte/src/hooks.server.ts
Normal 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
225
svelte/src/lib/api.ts
Normal 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 || [];
|
||||
}
|
||||
32
svelte/src/lib/auth.server.ts
Normal file
32
svelte/src/lib/auth.server.ts
Normal 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());
|
||||
}
|
||||
10
svelte/src/lib/backend.server.ts
Normal file
10
svelte/src/lib/backend.server.ts
Normal 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);
|
||||
}
|
||||
101
svelte/src/lib/chat-formatters.ts
Normal file
101
svelte/src/lib/chat-formatters.ts
Normal 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' });
|
||||
}
|
||||
242
svelte/src/lib/chat-utils.ts
Normal file
242
svelte/src/lib/chat-utils.ts
Normal 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 };
|
||||
}
|
||||
84
svelte/src/lib/components/ChartCanvas.svelte
Normal file
84
svelte/src/lib/components/ChartCanvas.svelte
Normal 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>
|
||||
104
svelte/src/lib/components/ChatMessage.svelte
Normal file
104
svelte/src/lib/components/ChatMessage.svelte
Normal 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}
|
||||
68
svelte/src/lib/components/ChatOutsideBlock.svelte
Normal file
68
svelte/src/lib/components/ChatOutsideBlock.svelte
Normal 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>
|
||||
444
svelte/src/lib/components/ChatRequestDetail.svelte
Normal file
444
svelte/src/lib/components/ChatRequestDetail.svelte
Normal 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">·</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">·</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>
|
||||
140
svelte/src/lib/components/ChatSidebar.svelte
Normal file
140
svelte/src/lib/components/ChatSidebar.svelte
Normal 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>
|
||||
182
svelte/src/lib/components/ChatToolBlock.svelte
Normal file
182
svelte/src/lib/components/ChatToolBlock.svelte
Normal 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">→</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>
|
||||
65
svelte/src/lib/components/CodeDiff.svelte
Normal file
65
svelte/src/lib/components/CodeDiff.svelte
Normal 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>
|
||||
169
svelte/src/lib/components/CodeViewer.svelte
Normal file
169
svelte/src/lib/components/CodeViewer.svelte
Normal 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}
|
||||
138
svelte/src/lib/components/ConversationThread.svelte
Normal file
138
svelte/src/lib/components/ConversationThread.svelte
Normal 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 • {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 • {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}
|
||||
90
svelte/src/lib/components/ImageContent.svelte
Normal file
90
svelte/src/lib/components/ImageContent.svelte
Normal 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}
|
||||
226
svelte/src/lib/components/MessageContent.svelte
Normal file
226
svelte/src/lib/components/MessageContent.svelte
Normal 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}
|
||||
182
svelte/src/lib/components/MessageFlow.svelte
Normal file
182
svelte/src/lib/components/MessageFlow.svelte
Normal 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>
|
||||
232
svelte/src/lib/components/Nav.svelte
Normal file
232
svelte/src/lib/components/Nav.svelte
Normal 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 > Models > 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}
|
||||
687
svelte/src/lib/components/RequestDetailContent.svelte
Normal file
687
svelte/src/lib/components/RequestDetailContent.svelte
Normal 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>
|
||||
105
svelte/src/lib/components/RichText.svelte
Normal file
105
svelte/src/lib/components/RichText.svelte
Normal 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>
|
||||
79
svelte/src/lib/components/RichTextInline.svelte
Normal file
79
svelte/src/lib/components/RichTextInline.svelte
Normal 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}
|
||||
20
svelte/src/lib/components/ThemeToggle.svelte
Normal file
20
svelte/src/lib/components/ThemeToggle.svelte
Normal 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>
|
||||
92
svelte/src/lib/components/TodoList.svelte
Normal file
92
svelte/src/lib/components/TodoList.svelte
Normal 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}
|
||||
182
svelte/src/lib/components/ToolResult.svelte
Normal file
182
svelte/src/lib/components/ToolResult.svelte
Normal 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>
|
||||
173
svelte/src/lib/components/ToolUse.svelte
Normal file
173
svelte/src/lib/components/ToolUse.svelte
Normal 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>
|
||||
87
svelte/src/lib/components/XmlBlock.svelte
Normal file
87
svelte/src/lib/components/XmlBlock.svelte
Normal 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"><{tag}></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>
|
||||
1
svelte/src/lib/formatters.ts
Normal file
1
svelte/src/lib/formatters.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '../../../shared/frontend/formatters';
|
||||
29
svelte/src/lib/models.ts
Normal file
29
svelte/src/lib/models.ts
Normal 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
118
svelte/src/lib/pricing.ts
Normal 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
170
svelte/src/lib/rich-text.ts
Normal 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;
|
||||
}
|
||||
45
svelte/src/lib/theme.svelte.ts
Normal file
45
svelte/src/lib/theme.svelte.ts
Normal 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
1
svelte/src/lib/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '../../../shared/frontend/types';
|
||||
19
svelte/src/routes/+error.svelte
Normal file
19
svelte/src/routes/+error.svelte
Normal 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>
|
||||
25
svelte/src/routes/+layout.server.ts
Normal file
25
svelte/src/routes/+layout.server.ts
Normal 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` };
|
||||
}
|
||||
18
svelte/src/routes/+layout.svelte
Normal file
18
svelte/src/routes/+layout.svelte
Normal 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()}
|
||||
1
svelte/src/routes/+page.server.ts
Normal file
1
svelte/src/routes/+page.server.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
// proxyUrl is now provided by +layout.server.ts
|
||||
318
svelte/src/routes/+page.svelte
Normal file
318
svelte/src/routes/+page.svelte
Normal 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>
|
||||
980
svelte/src/routes/analytics/+page.svelte
Normal file
980
svelte/src/routes/analytics/+page.svelte
Normal 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, Mon–Sun)
|
||||
{: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 ·
|
||||
{((item.inputTokens + item.outputTokens) / 1000).toFixed(0)}k tokens
|
||||
{#if item.cacheTokens > 0}
|
||||
· {(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>
|
||||
13
svelte/src/routes/api/conversations/+server.ts
Normal file
13
svelte/src/routes/api/conversations/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
14
svelte/src/routes/api/conversations/[id]/+server.ts
Normal file
14
svelte/src/routes/api/conversations/[id]/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
19
svelte/src/routes/api/grade-prompt/+server.ts
Normal file
19
svelte/src/routes/api/grade-prompt/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
24
svelte/src/routes/api/requests/+server.ts
Normal file
24
svelte/src/routes/api/requests/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
13
svelte/src/routes/api/requests/[id]/+server.ts
Normal file
13
svelte/src/routes/api/requests/[id]/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
13
svelte/src/routes/api/requests/latest-date/+server.ts
Normal file
13
svelte/src/routes/api/requests/latest-date/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
13
svelte/src/routes/api/requests/summary/+server.ts
Normal file
13
svelte/src/routes/api/requests/summary/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
25
svelte/src/routes/api/settings/+server.ts
Normal file
25
svelte/src/routes/api/settings/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
13
svelte/src/routes/api/stats/+server.ts
Normal file
13
svelte/src/routes/api/stats/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
13
svelte/src/routes/api/stats/dashboard/+server.ts
Normal file
13
svelte/src/routes/api/stats/dashboard/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
13
svelte/src/routes/api/stats/hourly/+server.ts
Normal file
13
svelte/src/routes/api/stats/hourly/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
13
svelte/src/routes/api/stats/models/+server.ts
Normal file
13
svelte/src/routes/api/stats/models/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
13
svelte/src/routes/api/stats/organizations/+server.ts
Normal file
13
svelte/src/routes/api/stats/organizations/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
217
svelte/src/routes/chat/+page.svelte
Normal file
217
svelte/src/routes/chat/+page.svelte
Normal 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>
|
||||
194
svelte/src/routes/conversations/+page.svelte
Normal file
194
svelte/src/routes/conversations/+page.svelte
Normal 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>
|
||||
274
svelte/src/routes/settings/+page.svelte
Normal file
274
svelte/src/routes/settings/+page.svelte
Normal 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">→</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">→</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 & 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
BIN
svelte/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
svelte/static/fonts/inter-latin-ext.woff2
Normal file
BIN
svelte/static/fonts/inter-latin-ext.woff2
Normal file
Binary file not shown.
BIN
svelte/static/fonts/inter-latin.woff2
Normal file
BIN
svelte/static/fonts/inter-latin.woff2
Normal file
Binary file not shown.
12
svelte/svelte.config.js
Normal file
12
svelte/svelte.config.js
Normal 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
23
svelte/tailwind.config.ts
Normal 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
14
svelte/tsconfig.json
Normal 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
32
svelte/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue