waypoi 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/instructions/ui.instructions.md +42 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +71 -0
- package/.github/workflows/release.yml +48 -0
- package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
- package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
- package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
- package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
- package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
- package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
- package/AGENTS.md +48 -0
- package/CHANGELOG.md +131 -0
- package/README.md +552 -0
- package/assets/agent-mode.png +0 -0
- package/assets/categorize.png +0 -0
- package/assets/dashboard.png +0 -0
- package/assets/endpoint-proxy.png +0 -0
- package/assets/icon.png +0 -0
- package/assets/mcp-generate-image.png +0 -0
- package/assets/mcp-understand-image.png +0 -0
- package/assets/peek-token-flow.png +0 -0
- package/assets/playground.png +0 -0
- package/assets/sankey.png +0 -0
- package/cli/index.ts +2805 -0
- package/cli/legacyRewrite.ts +108 -0
- package/cli/modelRef.ts +24 -0
- package/dist/cli/index.js +2536 -0
- package/dist/cli/legacyRewrite.js +92 -0
- package/dist/cli/modelRef.js +20 -0
- package/dist/src/benchmark/artifacts.js +131 -0
- package/dist/src/benchmark/capabilityClassifier.js +81 -0
- package/dist/src/benchmark/capabilityStore.js +144 -0
- package/dist/src/benchmark/config.js +238 -0
- package/dist/src/benchmark/gates.js +118 -0
- package/dist/src/benchmark/jobs.js +252 -0
- package/dist/src/benchmark/runner.js +1847 -0
- package/dist/src/benchmark/schema.js +353 -0
- package/dist/src/benchmark/suites.js +314 -0
- package/dist/src/benchmark/tinyQaDataset.js +422 -0
- package/dist/src/benchmark/types.js +25 -0
- package/dist/src/config.js +47 -0
- package/dist/src/index.js +178 -0
- package/dist/src/mcp/client.js +215 -0
- package/dist/src/mcp/discovery.js +226 -0
- package/dist/src/mcp/policy.js +65 -0
- package/dist/src/mcp/registry.js +129 -0
- package/dist/src/mcp/service.js +460 -0
- package/dist/src/middleware/auth.js +179 -0
- package/dist/src/middleware/requestCapture.js +192 -0
- package/dist/src/middleware/requestStats.js +118 -0
- package/dist/src/pools/builder.js +132 -0
- package/dist/src/pools/repository.js +69 -0
- package/dist/src/pools/scheduler.js +360 -0
- package/dist/src/pools/types.js +2 -0
- package/dist/src/protocols/adapters/dashscope.js +267 -0
- package/dist/src/protocols/adapters/inferenceV2.js +346 -0
- package/dist/src/protocols/adapters/openai.js +27 -0
- package/dist/src/protocols/registry.js +99 -0
- package/dist/src/protocols/types.js +2 -0
- package/dist/src/providers/health.js +153 -0
- package/dist/src/providers/importer.js +289 -0
- package/dist/src/providers/modelRegistry.js +313 -0
- package/dist/src/providers/repository.js +361 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/routes/admin.js +531 -0
- package/dist/src/routes/audio.js +295 -0
- package/dist/src/routes/chat.js +240 -0
- package/dist/src/routes/embeddings.js +157 -0
- package/dist/src/routes/images.js +288 -0
- package/dist/src/routes/mcp.js +256 -0
- package/dist/src/routes/mcpService.js +100 -0
- package/dist/src/routes/models.js +48 -0
- package/dist/src/routes/responses.js +711 -0
- package/dist/src/routes/sessions.js +450 -0
- package/dist/src/routes/stats.js +270 -0
- package/dist/src/routes/ui.js +97 -0
- package/dist/src/routes/videos.js +107 -0
- package/dist/src/routing/router.js +338 -0
- package/dist/src/services/imageGeneration.js +280 -0
- package/dist/src/services/imageUnderstanding.js +352 -0
- package/dist/src/services/videoGeneration.js +79 -0
- package/dist/src/storage/captureRepository.js +1591 -0
- package/dist/src/storage/files.js +157 -0
- package/dist/src/storage/imageCache.js +346 -0
- package/dist/src/storage/repositories.js +388 -0
- package/dist/src/storage/sessionRepository.js +370 -0
- package/dist/src/storage/statsRepository.js +204 -0
- package/dist/src/transport/httpClient.js +126 -0
- package/dist/src/types.js +2 -0
- package/dist/src/utils/messageMedia.js +285 -0
- package/dist/src/utils/modelCapabilities.js +108 -0
- package/dist/src/utils/modelDiscovery.js +170 -0
- package/dist/src/version.js +5 -0
- package/dist/src/workers/captureRetention.js +25 -0
- package/dist/src/workers/configWatcher.js +91 -0
- package/dist/src/workers/healthChecker.js +21 -0
- package/dist/src/workers/statsRotation.js +41 -0
- package/docs/LLM/output_schema.md +312 -0
- package/docs/benchmark.md +208 -0
- package/docs/mcp-guidelines.md +125 -0
- package/docs/mcp-service.md +178 -0
- package/docs/opencode.md +86 -0
- package/docs/providers.md +79 -0
- package/examples/benchmark.config.yaml +28 -0
- package/examples/providers/alibaba-dashscope.yaml +88 -0
- package/examples/providers/alibaba-llm.yaml +64 -0
- package/examples/providers/alibaba-registry.yaml +7 -0
- package/examples/providers/inference-v2-ray.yaml +29 -0
- package/examples/scenarios/assets/omni-call-sample.wav +0 -0
- package/examples/scenarios/custom.jsonl +5 -0
- package/examples/scenarios/custom.yaml +40 -0
- package/model-form-v2.png +0 -0
- package/package.json +66 -0
- package/provider-form-v2.png +0 -0
- package/provider-form.png +0 -0
- package/scripts/manual-test.sh +11 -0
- package/scripts/version-from-git.js +23 -0
- package/src/benchmark/artifacts.ts +149 -0
- package/src/benchmark/capabilityClassifier.ts +99 -0
- package/src/benchmark/capabilityStore.ts +174 -0
- package/src/benchmark/config.ts +337 -0
- package/src/benchmark/gates.ts +164 -0
- package/src/benchmark/jobs.ts +312 -0
- package/src/benchmark/runner.ts +2519 -0
- package/src/benchmark/schema.ts +443 -0
- package/src/benchmark/suites.ts +323 -0
- package/src/benchmark/tinyQaDataset.ts +428 -0
- package/src/benchmark/types.ts +442 -0
- package/src/config.ts +44 -0
- package/src/index.ts +195 -0
- package/src/mcp/client.ts +305 -0
- package/src/mcp/discovery.ts +266 -0
- package/src/mcp/policy.ts +105 -0
- package/src/mcp/registry.ts +164 -0
- package/src/mcp/service.ts +611 -0
- package/src/middleware/auth.ts +251 -0
- package/src/middleware/requestCapture.ts +245 -0
- package/src/middleware/requestStats.ts +163 -0
- package/src/pools/builder.ts +159 -0
- package/src/pools/repository.ts +71 -0
- package/src/pools/scheduler.ts +425 -0
- package/src/pools/types.ts +117 -0
- package/src/protocols/adapters/dashscope.ts +335 -0
- package/src/protocols/adapters/inferenceV2.ts +428 -0
- package/src/protocols/adapters/openai.ts +32 -0
- package/src/protocols/registry.ts +117 -0
- package/src/protocols/types.ts +81 -0
- package/src/providers/health.ts +207 -0
- package/src/providers/importer.ts +402 -0
- package/src/providers/modelRegistry.ts +415 -0
- package/src/providers/repository.ts +439 -0
- package/src/providers/types.ts +113 -0
- package/src/routes/admin.ts +666 -0
- package/src/routes/audio.ts +372 -0
- package/src/routes/chat.ts +301 -0
- package/src/routes/embeddings.ts +197 -0
- package/src/routes/images.ts +356 -0
- package/src/routes/mcp.ts +320 -0
- package/src/routes/mcpService.ts +114 -0
- package/src/routes/models.ts +50 -0
- package/src/routes/responses.ts +872 -0
- package/src/routes/sessions.ts +558 -0
- package/src/routes/stats.ts +312 -0
- package/src/routes/ui.ts +96 -0
- package/src/routes/videos.ts +132 -0
- package/src/routing/router.ts +501 -0
- package/src/services/imageGeneration.ts +396 -0
- package/src/services/imageUnderstanding.ts +449 -0
- package/src/services/videoGeneration.ts +127 -0
- package/src/storage/captureRepository.ts +1835 -0
- package/src/storage/files.ts +178 -0
- package/src/storage/imageCache.ts +405 -0
- package/src/storage/repositories.ts +494 -0
- package/src/storage/sessionRepository.ts +419 -0
- package/src/storage/statsRepository.ts +238 -0
- package/src/transport/httpClient.ts +145 -0
- package/src/types.ts +322 -0
- package/src/utils/messageMedia.ts +293 -0
- package/src/utils/modelCapabilities.ts +161 -0
- package/src/utils/modelDiscovery.ts +203 -0
- package/src/workers/captureRetention.ts +25 -0
- package/src/workers/configWatcher.ts +115 -0
- package/src/workers/healthChecker.ts +22 -0
- package/src/workers/statsRotation.ts +49 -0
- package/tests/benchmarkAdminRoutes.test.ts +82 -0
- package/tests/benchmarkBasics.test.ts +116 -0
- package/tests/captureAdminRoutes.test.ts +420 -0
- package/tests/captureRepository.test.ts +797 -0
- package/tests/cliLegacyRewrite.test.ts +45 -0
- package/tests/imageGeneration.service.test.ts +107 -0
- package/tests/imageUnderstanding.service.test.ts +123 -0
- package/tests/mcpPolicy.test.ts +105 -0
- package/tests/mcpService.test.ts +1245 -0
- package/tests/modelRef.test.ts +23 -0
- package/tests/modelsRoutes.test.ts +154 -0
- package/tests/sessionMediaCache.test.ts +167 -0
- package/tests/statsRoutes.test.ts +323 -0
- package/tsconfig.json +15 -0
- package/ui/index.html +16 -0
- package/ui/package-lock.json +8521 -0
- package/ui/package.json +52 -0
- package/ui/postcss.config.js +6 -0
- package/ui/public/assets/apple-touch-icon.png +0 -0
- package/ui/public/assets/favicon-16.png +0 -0
- package/ui/public/assets/favicon-32.png +0 -0
- package/ui/public/assets/icon-192.png +0 -0
- package/ui/public/assets/icon-512.png +0 -0
- package/ui/src/App.tsx +27 -0
- package/ui/src/api/client.ts +1503 -0
- package/ui/src/components/EndpointUsageGuide.tsx +361 -0
- package/ui/src/components/Layout.tsx +124 -0
- package/ui/src/components/MessageContent.tsx +365 -0
- package/ui/src/components/ToolCallMessage.tsx +179 -0
- package/ui/src/components/ToolPicker.tsx +442 -0
- package/ui/src/components/messageContentParser.test.ts +41 -0
- package/ui/src/components/messageContentParser.ts +73 -0
- package/ui/src/components/thinkingPreview.test.ts +27 -0
- package/ui/src/components/thinkingPreview.ts +15 -0
- package/ui/src/components/toMermaidSankey.test.ts +78 -0
- package/ui/src/components/toMermaidSankey.ts +56 -0
- package/ui/src/components/ui/button.tsx +58 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/textarea.tsx +21 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +9 -0
- package/ui/src/pages/AgentPlayground.tsx +2010 -0
- package/ui/src/pages/Benchmark.tsx +988 -0
- package/ui/src/pages/Dashboard.tsx +581 -0
- package/ui/src/pages/Peek.tsx +962 -0
- package/ui/src/pages/Settings.tsx +2013 -0
- package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
- package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
- package/ui/src/pages/agentThinkingContent.test.ts +50 -0
- package/ui/src/pages/agentThinkingContent.ts +57 -0
- package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
- package/ui/src/pages/dashboardTokenUsage.ts +36 -0
- package/ui/src/pages/imageUpload.test.ts +39 -0
- package/ui/src/pages/imageUpload.ts +71 -0
- package/ui/src/pages/peekFilters.test.ts +29 -0
- package/ui/src/pages/peekFilters.ts +13 -0
- package/ui/src/pages/peekMedia.test.ts +58 -0
- package/ui/src/pages/peekMedia.ts +148 -0
- package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
- package/ui/src/pages/sessionAutoTitle.ts +106 -0
- package/ui/src/stores/settings.ts +58 -0
- package/ui/src/styles/globals.css +223 -0
- package/ui/src/vite-env.d.ts +8 -0
- package/ui/tailwind.config.js +106 -0
- package/ui/tsconfig.json +32 -0
- package/ui/vite.config.ts +37 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
2
|
+
import * as Dialog from '@radix-ui/react-dialog'
|
|
3
|
+
import { sankey, sankeyLinkHorizontal } from 'd3-sankey'
|
|
4
|
+
import { Copy } from 'lucide-react'
|
|
5
|
+
import { toMermaidSankey } from '@/components/toMermaidSankey'
|
|
6
|
+
// Simple copy button for Sankey mermaid code
|
|
7
|
+
function CopyMermaidButton({ code }: { code: string }) {
|
|
8
|
+
const [copied, setCopied] = useState(false)
|
|
9
|
+
const handleCopy = async () => {
|
|
10
|
+
try {
|
|
11
|
+
await navigator.clipboard.writeText(code)
|
|
12
|
+
setCopied(true)
|
|
13
|
+
setTimeout(() => setCopied(false), 2000)
|
|
14
|
+
} catch (err) {
|
|
15
|
+
setCopied(false)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return (
|
|
19
|
+
<button
|
|
20
|
+
onClick={handleCopy}
|
|
21
|
+
className="absolute top-2 right-2 z-10 p-1.5 rounded bg-background/80 hover:bg-secondary border border-border transition-colors"
|
|
22
|
+
title={copied ? 'Copied!' : 'Copy mermaid code'}
|
|
23
|
+
aria-label="Copy mermaid code"
|
|
24
|
+
>
|
|
25
|
+
<Copy className={copied ? 'text-green-500' : 'text-muted-foreground'} size={16} />
|
|
26
|
+
</button>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
import {
|
|
30
|
+
getCaptureCalendar,
|
|
31
|
+
getCaptureConfig,
|
|
32
|
+
getCaptureRecord,
|
|
33
|
+
listCaptureRecords,
|
|
34
|
+
updateCaptureConfig,
|
|
35
|
+
type CaptureCalendarDaySummary,
|
|
36
|
+
type CaptureRecordDetail,
|
|
37
|
+
type CaptureRecordSummary,
|
|
38
|
+
type CaptureTimelineEntry,
|
|
39
|
+
} from '@/api/client'
|
|
40
|
+
import { Button } from '@/components/ui/button'
|
|
41
|
+
import { cn } from '@/lib/utils'
|
|
42
|
+
import { extractEmbeddedMedia, redactEmbeddedMedia, type PeekEmbeddedMedia } from './peekMedia'
|
|
43
|
+
import { filterCaptureRecords } from './peekFilters'
|
|
44
|
+
|
|
45
|
+
type PeekTab = 'overview' | 'timeline' | 'request' | 'response' | 'media'
|
|
46
|
+
|
|
47
|
+
const DAY_PAGE_SIZE = 50
|
|
48
|
+
const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
49
|
+
|
|
50
|
+
export function Peek() {
|
|
51
|
+
const browserTimeZone = getBrowserTimeZone()
|
|
52
|
+
const [config, setConfig] = useState<{ enabled: boolean; retentionDays: number; maxBytes: number } | null>(null)
|
|
53
|
+
const [dayRecords, setDayRecords] = useState<CaptureRecordSummary[]>([])
|
|
54
|
+
const [dayTotal, setDayTotal] = useState(0)
|
|
55
|
+
const [calendarDays, setCalendarDays] = useState<CaptureCalendarDaySummary[]>([])
|
|
56
|
+
const [month, setMonth] = useState<string>('')
|
|
57
|
+
const [selectedDate, setSelectedDate] = useState<string>('')
|
|
58
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
59
|
+
const [detail, setDetail] = useState<CaptureRecordDetail | null>(null)
|
|
60
|
+
const [dayOffset, setDayOffset] = useState(0)
|
|
61
|
+
const [loading, setLoading] = useState(false)
|
|
62
|
+
const [tab, setTab] = useState<PeekTab>('overview')
|
|
63
|
+
const [error, setError] = useState<string | null>(null)
|
|
64
|
+
const [tokenFlowOpen, setTokenFlowOpen] = useState(false)
|
|
65
|
+
const [ignoreModels, setIgnoreModels] = useState<boolean>(false)
|
|
66
|
+
const [showRawResponseBody, setShowRawResponseBody] = useState(false)
|
|
67
|
+
|
|
68
|
+
const refreshOverview = async () => {
|
|
69
|
+
setLoading(true)
|
|
70
|
+
setError(null)
|
|
71
|
+
try {
|
|
72
|
+
const cfg = await getCaptureConfig()
|
|
73
|
+
setConfig(cfg)
|
|
74
|
+
const fallbackDate = formatDateForTimeZone(new Date(), browserTimeZone)
|
|
75
|
+
const fallbackMonth = fallbackDate.slice(0, 7)
|
|
76
|
+
setMonth((current) => current || fallbackMonth)
|
|
77
|
+
setSelectedDate((current) => current || fallbackDate)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
setError((err as Error).message)
|
|
80
|
+
} finally {
|
|
81
|
+
setLoading(false)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
void refreshOverview()
|
|
87
|
+
}, [])
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!month) return
|
|
91
|
+
void getCaptureCalendar(month, { timeZone: browserTimeZone })
|
|
92
|
+
.then((calendar) => setCalendarDays(calendar.days))
|
|
93
|
+
.catch((err) => setError((err as Error).message))
|
|
94
|
+
}, [month, browserTimeZone])
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!selectedDate) return
|
|
98
|
+
void listCaptureRecords({ date: selectedDate, limit: DAY_PAGE_SIZE, offset: dayOffset, timeZone: browserTimeZone })
|
|
99
|
+
.then((result) => {
|
|
100
|
+
setDayRecords(result.data)
|
|
101
|
+
setDayTotal(result.total)
|
|
102
|
+
setSelectedId((current) => {
|
|
103
|
+
if (current && result.data.some((item) => item.id === current)) return current
|
|
104
|
+
return result.data[0]?.id ?? null
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
.catch((err) => setError((err as Error).message))
|
|
108
|
+
}, [selectedDate, dayOffset, browserTimeZone])
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!selectedId) {
|
|
112
|
+
setDetail(null)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
void getCaptureRecord(selectedId)
|
|
116
|
+
.then(setDetail)
|
|
117
|
+
.catch((err) => setError((err as Error).message))
|
|
118
|
+
}, [selectedId])
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
setTokenFlowOpen(false)
|
|
122
|
+
}, [selectedId])
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
setShowRawResponseBody(false)
|
|
126
|
+
}, [selectedId])
|
|
127
|
+
|
|
128
|
+
const mediaArtifacts = useMemo(() => detail?.artifacts ?? [], [detail])
|
|
129
|
+
const requestEmbeddedMedia = useMemo(
|
|
130
|
+
() => extractEmbeddedMedia('request', detail?.request.body ?? null),
|
|
131
|
+
[detail],
|
|
132
|
+
)
|
|
133
|
+
const responseEmbeddedMedia = useMemo(
|
|
134
|
+
() => extractEmbeddedMedia('response', detail?.response.body ?? null),
|
|
135
|
+
[detail],
|
|
136
|
+
)
|
|
137
|
+
const embeddedMedia = useMemo(
|
|
138
|
+
() => [...requestEmbeddedMedia, ...responseEmbeddedMedia],
|
|
139
|
+
[requestEmbeddedMedia, responseEmbeddedMedia],
|
|
140
|
+
)
|
|
141
|
+
const requestTimeline = useMemo(() => detail?.analysis.requestTimeline ?? [], [detail])
|
|
142
|
+
const responseTimeline = useMemo(() => detail?.analysis.responseTimeline ?? [], [detail])
|
|
143
|
+
const calendarGrid = useMemo(() => buildCalendarGrid(month, calendarDays), [month, calendarDays])
|
|
144
|
+
const tokenFlow = detail?.analysis.tokenFlow
|
|
145
|
+
const responseBodyView = useMemo(() => {
|
|
146
|
+
if (!detail) return null
|
|
147
|
+
const body = showRawResponseBody ? detail.response.body ?? null : buildAggregatedResponseBody(detail)
|
|
148
|
+
return redactEmbeddedMedia(body)
|
|
149
|
+
}, [detail, showRawResponseBody])
|
|
150
|
+
|
|
151
|
+
const toggleCapture = async () => {
|
|
152
|
+
if (!config) return
|
|
153
|
+
const next = await updateCaptureConfig({ enabled: !config.enabled })
|
|
154
|
+
setConfig(next)
|
|
155
|
+
await refreshOverview()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
try {
|
|
160
|
+
const stored = localStorage.getItem('peek.ignoreModels')
|
|
161
|
+
setIgnoreModels(stored === '1')
|
|
162
|
+
} catch {
|
|
163
|
+
// ignore
|
|
164
|
+
}
|
|
165
|
+
}, [])
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
try {
|
|
169
|
+
localStorage.setItem('peek.ignoreModels', ignoreModels ? '1' : '0')
|
|
170
|
+
} catch {
|
|
171
|
+
// ignore
|
|
172
|
+
}
|
|
173
|
+
}, [ignoreModels])
|
|
174
|
+
|
|
175
|
+
const visibleDayRecords = useMemo(() => {
|
|
176
|
+
return filterCaptureRecords(dayRecords, ignoreModels)
|
|
177
|
+
}, [dayRecords, ignoreModels])
|
|
178
|
+
|
|
179
|
+
// Ensure selectedId stays valid when filters change
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!selectedId) return
|
|
182
|
+
const existsInDay = visibleDayRecords.some((r) => r.id === selectedId)
|
|
183
|
+
if (!existsInDay) {
|
|
184
|
+
setSelectedId(visibleDayRecords[0]?.id ?? null)
|
|
185
|
+
}
|
|
186
|
+
}, [ignoreModels, visibleDayRecords, selectedId])
|
|
187
|
+
|
|
188
|
+
const selectDate = (date: string) => {
|
|
189
|
+
setSelectedDate(date)
|
|
190
|
+
setDayOffset(0)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<>
|
|
195
|
+
<div className="flex-1 flex flex-col h-full min-h-0">
|
|
196
|
+
<header className="sticky top-0 z-20 shrink-0 min-h-14 border-b border-border bg-background/95 backdrop-blur px-4 py-2 flex items-center gap-3">
|
|
197
|
+
<h2 className="font-mono font-semibold text-sm uppercase tracking-wider">Peek</h2>
|
|
198
|
+
<span className="min-w-0 max-w-[34ch] truncate text-xs text-muted-foreground font-mono">Calendar browse + ordered capture timelines ({browserTimeZone})</span>
|
|
199
|
+
<div className="flex-1" />
|
|
200
|
+
<Button size="sm" variant={config?.enabled ? 'default' : 'outline'} onClick={toggleCapture} disabled={!config}>
|
|
201
|
+
Capture {config?.enabled ? 'On' : 'Off'}
|
|
202
|
+
</Button>
|
|
203
|
+
<Button size="sm" variant="outline" onClick={() => void refreshOverview()} disabled={loading}>
|
|
204
|
+
Refresh
|
|
205
|
+
</Button>
|
|
206
|
+
<label className="shrink-0 flex items-center gap-2 rounded border border-border px-2 py-1 text-xs font-mono">
|
|
207
|
+
<input
|
|
208
|
+
type="checkbox"
|
|
209
|
+
checked={ignoreModels}
|
|
210
|
+
onChange={(e) => setIgnoreModels(e.target.checked)}
|
|
211
|
+
className="h-4 w-4 shrink-0 accent-primary"
|
|
212
|
+
/>
|
|
213
|
+
<span className="text-muted-foreground">Hide GET /v1/models</span>
|
|
214
|
+
</label>
|
|
215
|
+
</header>
|
|
216
|
+
|
|
217
|
+
<div className="flex-1 min-h-0 overflow-hidden flex">
|
|
218
|
+
<aside className="w-[26rem] shrink-0 border-r border-border overflow-y-auto p-3 space-y-4">
|
|
219
|
+
<Section title="Calendar">
|
|
220
|
+
<div className="flex items-center justify-between gap-2">
|
|
221
|
+
<Button size="sm" variant="outline" onClick={() => setMonth(prevMonth(month))} disabled={!month}>Prev</Button>
|
|
222
|
+
<div className="text-sm font-mono">{month || '---- --'}</div>
|
|
223
|
+
<Button size="sm" variant="outline" onClick={() => setMonth(nextMonth(month))} disabled={!month}>Next</Button>
|
|
224
|
+
</div>
|
|
225
|
+
<div className="grid grid-cols-7 gap-1 text-[11px] font-mono text-muted-foreground">
|
|
226
|
+
{WEEKDAY_LABELS.map((label) => (
|
|
227
|
+
<div key={label} className="px-1 py-1 text-center">{label}</div>
|
|
228
|
+
))}
|
|
229
|
+
</div>
|
|
230
|
+
<div className="grid grid-cols-7 gap-1">
|
|
231
|
+
{calendarGrid.map((cell) => (
|
|
232
|
+
<button
|
|
233
|
+
key={cell.key}
|
|
234
|
+
type="button"
|
|
235
|
+
disabled={!cell.date}
|
|
236
|
+
onClick={() => cell.date && selectDate(cell.date)}
|
|
237
|
+
className={cn(
|
|
238
|
+
'min-h-[3.5rem] rounded border px-1 py-1 text-left',
|
|
239
|
+
!cell.inMonth && 'opacity-40',
|
|
240
|
+
cell.date === selectedDate ? 'border-primary bg-primary/10' : 'border-border hover:bg-secondary/40',
|
|
241
|
+
!cell.date && 'cursor-default opacity-0'
|
|
242
|
+
)}
|
|
243
|
+
>
|
|
244
|
+
{cell.date && (
|
|
245
|
+
<>
|
|
246
|
+
<div className="text-[11px] font-mono">{cell.day}</div>
|
|
247
|
+
<div className="text-sm font-semibold leading-none mt-2">{cell.count}</div>
|
|
248
|
+
</>
|
|
249
|
+
)}
|
|
250
|
+
</button>
|
|
251
|
+
))}
|
|
252
|
+
</div>
|
|
253
|
+
</Section>
|
|
254
|
+
|
|
255
|
+
<Section title={selectedDate ? `Selected Day ${selectedDate}` : 'Selected Day'}>
|
|
256
|
+
<div className="flex items-center justify-between text-xs font-mono text-muted-foreground">
|
|
257
|
+
<span>{ignoreModels ? visibleDayRecords.length : dayTotal} captures</span>
|
|
258
|
+
<span>{dayOffset + 1}-{Math.min(dayOffset + DAY_PAGE_SIZE, dayTotal || 0)}</span>
|
|
259
|
+
</div>
|
|
260
|
+
<div className="flex items-center justify-between gap-2">
|
|
261
|
+
<Button size="sm" variant="outline" onClick={() => setDayOffset((current) => Math.max(0, current - DAY_PAGE_SIZE))} disabled={dayOffset === 0}>
|
|
262
|
+
Newer
|
|
263
|
+
</Button>
|
|
264
|
+
<Button size="sm" variant="outline" onClick={() => setDayOffset((current) => current + DAY_PAGE_SIZE)} disabled={dayOffset + DAY_PAGE_SIZE >= dayTotal}>
|
|
265
|
+
Older
|
|
266
|
+
</Button>
|
|
267
|
+
</div>
|
|
268
|
+
{visibleDayRecords.length === 0 && dayRecords.length > 0 && <EmptyState label="No captures for this day match filter." />}
|
|
269
|
+
{visibleDayRecords.map((record) => (
|
|
270
|
+
<RecordButton key={record.id} record={record} selected={selectedId === record.id} onSelect={setSelectedId} />
|
|
271
|
+
))}
|
|
272
|
+
</Section>
|
|
273
|
+
</aside>
|
|
274
|
+
|
|
275
|
+
<section className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
|
276
|
+
<div className="border-b border-border px-4 py-2 flex gap-2">
|
|
277
|
+
{(['overview', 'timeline', 'request', 'response', 'media'] as PeekTab[]).map((item) => (
|
|
278
|
+
<button
|
|
279
|
+
key={item}
|
|
280
|
+
className={cn(
|
|
281
|
+
'px-2 py-1 text-xs font-mono rounded border',
|
|
282
|
+
tab === item ? 'border-primary text-primary' : 'border-border text-muted-foreground'
|
|
283
|
+
)}
|
|
284
|
+
onClick={() => setTab(item)}
|
|
285
|
+
>
|
|
286
|
+
{item}
|
|
287
|
+
</button>
|
|
288
|
+
))}
|
|
289
|
+
</div>
|
|
290
|
+
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
|
291
|
+
{error && <div className="text-sm text-destructive mb-3">{error}</div>}
|
|
292
|
+
{!detail && <div className="text-sm text-muted-foreground">Select a capture to inspect.</div>}
|
|
293
|
+
{detail && tab === 'overview' && (
|
|
294
|
+
<div className="space-y-4 text-sm font-mono">
|
|
295
|
+
<div>ID: {detail.id}</div>
|
|
296
|
+
<div>Route: {detail.method} {detail.route}</div>
|
|
297
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
298
|
+
<span>Status:</span>
|
|
299
|
+
<Badge className={statusTone(detail.statusCode)}>{detail.statusCode}</Badge>
|
|
300
|
+
<Badge className={routeTone(detail.route)}>{routeType(detail.route)}</Badge>
|
|
301
|
+
<Badge className={responseTone(detail)}>{responseType(detail)}</Badge>
|
|
302
|
+
</div>
|
|
303
|
+
<div>Latency: {detail.latencyMs}ms</div>
|
|
304
|
+
<div>Model: {detail.routing.publicModel || 'unknown'}</div>
|
|
305
|
+
<div>Endpoint: {detail.routing.endpointName || detail.routing.endpointId || 'n/a'}</div>
|
|
306
|
+
<div>Upstream: {detail.routing.upstreamModel || 'n/a'}</div>
|
|
307
|
+
<Section title="Token Flow">
|
|
308
|
+
{tokenFlow ? (
|
|
309
|
+
<div className="space-y-2">
|
|
310
|
+
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
311
|
+
<span className="font-mono">Input: {formatCount(tokenFlow.totals.inputTokens)}</span>
|
|
312
|
+
<span className="font-mono">Output: {formatCount(tokenFlow.totals.outputTokens)}</span>
|
|
313
|
+
<span className="font-mono">Total: {formatCount(tokenFlow.totals.totalTokens)}</span>
|
|
314
|
+
<Badge className={tokenFlow.method === 'exact_totals_estimated_categories' ? 'border-emerald-400/40 text-emerald-300 bg-emerald-500/10' : 'border-amber-400/40 text-amber-300 bg-amber-500/10'}>
|
|
315
|
+
{tokenFlow.method === 'exact_totals_estimated_categories' ? 'Exact totals' : tokenFlow.method === 'estimated_only' ? 'Estimated' : 'Unavailable'}
|
|
316
|
+
</Badge>
|
|
317
|
+
{tokenFlow.eligible && (
|
|
318
|
+
<Button size="sm" variant="outline" onClick={() => setTokenFlowOpen(true)}>
|
|
319
|
+
Open Sankey
|
|
320
|
+
</Button>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
{!tokenFlow.eligible && (
|
|
324
|
+
<div className="text-xs text-muted-foreground">{tokenFlow.reason || 'Token flow unavailable for this capture.'}</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
) : (
|
|
328
|
+
<EmptyState label="Token flow analysis unavailable." />
|
|
329
|
+
)}
|
|
330
|
+
</Section>
|
|
331
|
+
<Section title="Secondary Metadata">
|
|
332
|
+
<div className="space-y-2">
|
|
333
|
+
<MetadataList title="AGENTS / Guardrail Hints" items={detail.analysis.agentsMdHints} />
|
|
334
|
+
<MetadataList title="MCP Tool Descriptions" items={detail.analysis.mcpToolDescriptions} />
|
|
335
|
+
<MetadataList title="Raw Sections" items={detail.analysis.rawSections} mono />
|
|
336
|
+
</div>
|
|
337
|
+
</Section>
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
{detail && tab === 'timeline' && (
|
|
341
|
+
<div className="space-y-4">
|
|
342
|
+
<Section title="Request Timeline">
|
|
343
|
+
{requestTimeline.length === 0 && <EmptyState label="No request timeline items." />}
|
|
344
|
+
{requestTimeline.map((entry) => <TimelineEntryCard key={`req-${entry.index}`} entry={entry} />)}
|
|
345
|
+
</Section>
|
|
346
|
+
<Section title="Response Timeline">
|
|
347
|
+
{responseTimeline.length === 0 && (
|
|
348
|
+
<EmptyState
|
|
349
|
+
label={isMissingLegacyStreamCapture(detail) ? 'Stream response missing from capture.' : 'No response timeline items.'}
|
|
350
|
+
/>
|
|
351
|
+
)}
|
|
352
|
+
{responseTimeline.map((entry) => <TimelineEntryCard key={`res-${entry.index}`} entry={entry} />)}
|
|
353
|
+
</Section>
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
356
|
+
{detail && tab === 'request' && (
|
|
357
|
+
<div className="space-y-4">
|
|
358
|
+
<Section title="Detected Media">
|
|
359
|
+
<EmbeddedMediaList items={requestEmbeddedMedia} emptyLabel="No inline media detected in request body." />
|
|
360
|
+
</Section>
|
|
361
|
+
<Section title="Raw Request Body">
|
|
362
|
+
<pre className="text-xs overflow-auto">{JSON.stringify(redactEmbeddedMedia(detail.request.body ?? null), null, 2)}</pre>
|
|
363
|
+
</Section>
|
|
364
|
+
<Section title="Derived (normalized/preview)">
|
|
365
|
+
<pre className="text-xs overflow-auto">{JSON.stringify(detail.request.derived ?? null, null, 2)}</pre>
|
|
366
|
+
</Section>
|
|
367
|
+
<Section title="Headers">
|
|
368
|
+
<pre className="text-xs overflow-auto">{JSON.stringify(detail.request.headers ?? {}, null, 2)}</pre>
|
|
369
|
+
</Section>
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
{detail && tab === 'response' && (
|
|
373
|
+
<div className="space-y-4">
|
|
374
|
+
<Section title="Detected Media">
|
|
375
|
+
<EmbeddedMediaList items={responseEmbeddedMedia} emptyLabel="No inline media detected in response body." />
|
|
376
|
+
</Section>
|
|
377
|
+
<Section title="Response Body">
|
|
378
|
+
<div className="flex items-center justify-end">
|
|
379
|
+
<label className="inline-flex items-center gap-2 text-xs font-mono text-muted-foreground">
|
|
380
|
+
<input
|
|
381
|
+
type="checkbox"
|
|
382
|
+
checked={showRawResponseBody}
|
|
383
|
+
onChange={(e) => setShowRawResponseBody(e.target.checked)}
|
|
384
|
+
className="h-4 w-4 shrink-0 accent-primary"
|
|
385
|
+
/>
|
|
386
|
+
<span>Raw</span>
|
|
387
|
+
</label>
|
|
388
|
+
</div>
|
|
389
|
+
<pre className="text-xs overflow-auto">{JSON.stringify(responseBodyView, null, 2)}</pre>
|
|
390
|
+
</Section>
|
|
391
|
+
<Section title="Headers">
|
|
392
|
+
<pre className="text-xs overflow-auto">{JSON.stringify(detail.response.headers ?? {}, null, 2)}</pre>
|
|
393
|
+
</Section>
|
|
394
|
+
<Section title="Error">
|
|
395
|
+
<pre className="text-xs overflow-auto">{JSON.stringify(detail.response.error ?? null, null, 2)}</pre>
|
|
396
|
+
</Section>
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
{detail && tab === 'media' && (
|
|
400
|
+
<div className="space-y-3">
|
|
401
|
+
{mediaArtifacts.length === 0 && embeddedMedia.length === 0 && (
|
|
402
|
+
<div className="text-sm text-muted-foreground">No media artifacts.</div>
|
|
403
|
+
)}
|
|
404
|
+
{mediaArtifacts.map((artifact) => (
|
|
405
|
+
<div key={artifact.hash} className="rounded border border-border p-2">
|
|
406
|
+
<div className="text-xs font-mono mb-2">{artifact.kind} • {artifact.mime} • {artifact.bytes} bytes</div>
|
|
407
|
+
{artifact.kind === 'image' ? (
|
|
408
|
+
<img
|
|
409
|
+
src={artifact.blobRef}
|
|
410
|
+
alt={artifact.hash}
|
|
411
|
+
className="max-h-72 rounded border border-border"
|
|
412
|
+
/>
|
|
413
|
+
) : (
|
|
414
|
+
<a href={artifact.blobRef} target="_blank" rel="noreferrer" className="text-xs text-primary underline">
|
|
415
|
+
Open blob
|
|
416
|
+
</a>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
))}
|
|
420
|
+
{embeddedMedia.length > 0 && (
|
|
421
|
+
<Section title="Embedded Base64 / Data URLs">
|
|
422
|
+
<EmbeddedMediaList items={embeddedMedia} emptyLabel="No embedded media detected." />
|
|
423
|
+
</Section>
|
|
424
|
+
)}
|
|
425
|
+
</div>
|
|
426
|
+
)}
|
|
427
|
+
</div>
|
|
428
|
+
</section>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
<TokenFlowDialog
|
|
432
|
+
open={tokenFlowOpen}
|
|
433
|
+
onOpenChange={setTokenFlowOpen}
|
|
434
|
+
captureId={detail?.id}
|
|
435
|
+
model={detail?.routing.publicModel}
|
|
436
|
+
tokenFlow={tokenFlow}
|
|
437
|
+
/>
|
|
438
|
+
</>
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
type TokenFlowData = NonNullable<CaptureRecordDetail['analysis']['tokenFlow']>
|
|
443
|
+
|
|
444
|
+
function TokenFlowDialog(props: {
|
|
445
|
+
open: boolean
|
|
446
|
+
onOpenChange: (open: boolean) => void
|
|
447
|
+
captureId?: string
|
|
448
|
+
model?: string
|
|
449
|
+
tokenFlow?: TokenFlowData
|
|
450
|
+
}) {
|
|
451
|
+
const { tokenFlow } = props
|
|
452
|
+
return (
|
|
453
|
+
<Dialog.Root open={props.open} onOpenChange={props.onOpenChange}>
|
|
454
|
+
<Dialog.Portal>
|
|
455
|
+
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
|
|
456
|
+
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(96vw,1100px)] max-h-[88vh] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-4 shadow-2xl overflow-y-auto">
|
|
457
|
+
<div className="flex items-start justify-between gap-3 mb-3">
|
|
458
|
+
<div className="space-y-1">
|
|
459
|
+
<Dialog.Title className="text-sm font-mono uppercase tracking-wider">Token Flow Sankey</Dialog.Title>
|
|
460
|
+
<div className="text-xs text-muted-foreground font-mono">
|
|
461
|
+
Capture: {props.captureId || 'n/a'} {props.model ? `• ${props.model}` : ''}
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
<Dialog.Close asChild>
|
|
465
|
+
<Button variant="outline" size="sm">Close</Button>
|
|
466
|
+
</Dialog.Close>
|
|
467
|
+
</div>
|
|
468
|
+
{!tokenFlow && <EmptyState label="Token flow analysis unavailable." />}
|
|
469
|
+
{tokenFlow && !tokenFlow.eligible && (
|
|
470
|
+
<div className="rounded border border-border p-3 text-sm text-muted-foreground">
|
|
471
|
+
{tokenFlow.reason || 'Token flow unavailable for this capture.'}
|
|
472
|
+
</div>
|
|
473
|
+
)}
|
|
474
|
+
{tokenFlow?.eligible && (
|
|
475
|
+
<div className="relative">
|
|
476
|
+
<CopyMermaidButton code={toMermaidSankey(tokenFlow)} />
|
|
477
|
+
<TokenFlowSankey tokenFlow={tokenFlow} />
|
|
478
|
+
</div>
|
|
479
|
+
)}
|
|
480
|
+
</Dialog.Content>
|
|
481
|
+
</Dialog.Portal>
|
|
482
|
+
</Dialog.Root>
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function TokenFlowSankey(props: { tokenFlow: TokenFlowData }) {
|
|
487
|
+
const width = 980
|
|
488
|
+
const height = 420
|
|
489
|
+
const graph = useMemo(() => {
|
|
490
|
+
const tokenFlow = props.tokenFlow
|
|
491
|
+
const nodes: Array<{ id: string; name: string; side: 'neutral' | 'input' | 'output' }> = [
|
|
492
|
+
{ id: 'total', name: 'Total Tokens', side: 'neutral' },
|
|
493
|
+
{ id: 'input', name: 'Input Tokens', side: 'input' },
|
|
494
|
+
{ id: 'output', name: 'Output Tokens', side: 'output' },
|
|
495
|
+
]
|
|
496
|
+
const links: Array<{ source: string; target: string; value: number }> = []
|
|
497
|
+
const inputTotal = Math.max(0, tokenFlow.totals.inputTokens ?? 0)
|
|
498
|
+
const outputTotal = Math.max(0, tokenFlow.totals.outputTokens ?? 0)
|
|
499
|
+
links.push({ source: 'total', target: 'input', value: inputTotal })
|
|
500
|
+
links.push({ source: 'total', target: 'output', value: outputTotal })
|
|
501
|
+
|
|
502
|
+
for (const category of tokenFlow.input) {
|
|
503
|
+
if (category.tokens <= 0) continue
|
|
504
|
+
const id = `input:${category.key}`
|
|
505
|
+
nodes.push({ id, name: category.label, side: 'input' })
|
|
506
|
+
links.push({ source: 'input', target: id, value: category.tokens })
|
|
507
|
+
}
|
|
508
|
+
for (const category of tokenFlow.output) {
|
|
509
|
+
if (category.tokens <= 0) continue
|
|
510
|
+
const id = `output:${category.key}`
|
|
511
|
+
nodes.push({ id, name: category.label, side: 'output' })
|
|
512
|
+
links.push({ source: 'output', target: id, value: category.tokens })
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const layout = sankey()
|
|
516
|
+
.nodeId((node: { id: string }) => node.id)
|
|
517
|
+
.nodeWidth(16)
|
|
518
|
+
.nodePadding(12)
|
|
519
|
+
.extent([[12, 12], [width - 12, height - 12]])
|
|
520
|
+
return layout({
|
|
521
|
+
nodes: nodes.map((node) => ({ ...node })),
|
|
522
|
+
links: links.map((link) => ({ ...link })),
|
|
523
|
+
})
|
|
524
|
+
}, [props.tokenFlow])
|
|
525
|
+
|
|
526
|
+
const inputTotal = Math.max(0, props.tokenFlow.totals.inputTokens ?? 0)
|
|
527
|
+
const outputTotal = Math.max(0, props.tokenFlow.totals.outputTokens ?? 0)
|
|
528
|
+
return (
|
|
529
|
+
<div className="space-y-3">
|
|
530
|
+
<div className="flex flex-wrap items-center gap-2 text-xs font-mono">
|
|
531
|
+
<Badge className={props.tokenFlow.method === 'exact_totals_estimated_categories' ? 'border-emerald-400/40 text-emerald-300 bg-emerald-500/10' : 'border-amber-400/40 text-amber-300 bg-amber-500/10'}>
|
|
532
|
+
{props.tokenFlow.method === 'exact_totals_estimated_categories' ? 'Exact totals' : 'Estimated totals'}
|
|
533
|
+
</Badge>
|
|
534
|
+
<span>Input: {formatCount(props.tokenFlow.totals.inputTokens)}</span>
|
|
535
|
+
<span>Output: {formatCount(props.tokenFlow.totals.outputTokens)}</span>
|
|
536
|
+
<span>Total: {formatCount(props.tokenFlow.totals.totalTokens)}</span>
|
|
537
|
+
</div>
|
|
538
|
+
<div className="overflow-x-auto rounded border border-border p-2">
|
|
539
|
+
<svg width={width} height={height} role="img" aria-label="Token flow sankey chart">
|
|
540
|
+
<g fill="none" strokeOpacity={0.35}>
|
|
541
|
+
{graph.links.map((link: any, index: number) => {
|
|
542
|
+
const sourceId = String((link.source as { id?: string }).id ?? '')
|
|
543
|
+
const sourceTotal = sourceId === 'input' ? inputTotal : sourceId === 'output' ? outputTotal : Math.max(0, props.tokenFlow.totals.totalTokens ?? 0)
|
|
544
|
+
const percentage = sourceTotal > 0 ? (link.value / sourceTotal) * 100 : 0
|
|
545
|
+
return (
|
|
546
|
+
<path
|
|
547
|
+
key={`link-${index}`}
|
|
548
|
+
d={sankeyLinkHorizontal()(link) ?? ''}
|
|
549
|
+
stroke={sourceId.startsWith('input') ? '#38bdf8' : sourceId.startsWith('output') ? '#f97316' : '#94a3b8'}
|
|
550
|
+
strokeWidth={Math.max(1, link.width ?? 1)}
|
|
551
|
+
>
|
|
552
|
+
<title>{`${sourceId || 'source'} -> ${String((link.target as { id?: string }).id ?? 'target')}: ${link.value.toLocaleString()} tokens (${percentage.toFixed(1)}%)`}</title>
|
|
553
|
+
</path>
|
|
554
|
+
)
|
|
555
|
+
})}
|
|
556
|
+
</g>
|
|
557
|
+
<g>
|
|
558
|
+
{graph.nodes.map((node: any, index: number) => {
|
|
559
|
+
const nodeHeight = Math.max(1, (node.y1 ?? 0) - (node.y0 ?? 0))
|
|
560
|
+
const tokens = Math.round(Number(node.value ?? 0))
|
|
561
|
+
const sideTotal = node.id.startsWith('input:') ? inputTotal : node.id.startsWith('output:') ? outputTotal : Math.max(0, props.tokenFlow.totals.totalTokens ?? 0)
|
|
562
|
+
const percentage = sideTotal > 0 ? (tokens / sideTotal) * 100 : 0
|
|
563
|
+
const isRightColumn = (node.x1 ?? 0) > width * 0.72
|
|
564
|
+
const labelX = isRightColumn ? (node.x0 ?? 0) - 6 : (node.x1 ?? 0) + 6
|
|
565
|
+
const labelAnchor = isRightColumn ? 'end' : 'start'
|
|
566
|
+
return (
|
|
567
|
+
<g key={`node-${index}`}>
|
|
568
|
+
<rect
|
|
569
|
+
x={node.x0}
|
|
570
|
+
y={node.y0}
|
|
571
|
+
width={Math.max(1, (node.x1 ?? 0) - (node.x0 ?? 0))}
|
|
572
|
+
height={nodeHeight}
|
|
573
|
+
fill={node.id.startsWith('input') ? '#0ea5e9' : node.id.startsWith('output') ? '#f97316' : '#64748b'}
|
|
574
|
+
rx={2}
|
|
575
|
+
ry={2}
|
|
576
|
+
>
|
|
577
|
+
<title>{`${node.name}: ${tokens.toLocaleString()} tokens (${percentage.toFixed(1)}%)`}</title>
|
|
578
|
+
</rect>
|
|
579
|
+
<text
|
|
580
|
+
x={labelX}
|
|
581
|
+
y={((node.y0 ?? 0) + (node.y1 ?? 0)) / 2}
|
|
582
|
+
dominantBaseline="middle"
|
|
583
|
+
textAnchor={labelAnchor}
|
|
584
|
+
fontSize={11}
|
|
585
|
+
fill="hsl(var(--foreground))"
|
|
586
|
+
>
|
|
587
|
+
{node.name} ({tokens.toLocaleString()})
|
|
588
|
+
</text>
|
|
589
|
+
</g>
|
|
590
|
+
)
|
|
591
|
+
})}
|
|
592
|
+
</g>
|
|
593
|
+
</svg>
|
|
594
|
+
</div>
|
|
595
|
+
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
596
|
+
<div className="rounded border border-border p-2">
|
|
597
|
+
<div className="font-mono uppercase tracking-wider text-muted-foreground mb-1">Input Categories</div>
|
|
598
|
+
{props.tokenFlow.input.map((item) => (
|
|
599
|
+
<div key={item.key} className="flex justify-between gap-2 font-mono">
|
|
600
|
+
<span>{item.label}</span>
|
|
601
|
+
<span>{item.tokens.toLocaleString()}</span>
|
|
602
|
+
</div>
|
|
603
|
+
))}
|
|
604
|
+
</div>
|
|
605
|
+
<div className="rounded border border-border p-2">
|
|
606
|
+
<div className="font-mono uppercase tracking-wider text-muted-foreground mb-1">Output Categories</div>
|
|
607
|
+
{props.tokenFlow.output.map((item) => (
|
|
608
|
+
<div key={item.key} className="flex justify-between gap-2 font-mono">
|
|
609
|
+
<span>{item.label}</span>
|
|
610
|
+
<span>{item.tokens.toLocaleString()}</span>
|
|
611
|
+
</div>
|
|
612
|
+
))}
|
|
613
|
+
</div>
|
|
614
|
+
</div>
|
|
615
|
+
<div className="rounded border border-border p-2 text-xs text-muted-foreground">
|
|
616
|
+
<div>Unattributed categories include rounding residue, multimodal payload portions, and structures without direct text attribution.</div>
|
|
617
|
+
{(props.tokenFlow.notes ?? []).map((note, idx) => (
|
|
618
|
+
<div key={idx}>{note}</div>
|
|
619
|
+
))}
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function RecordButton(props: {
|
|
626
|
+
record: CaptureRecordSummary
|
|
627
|
+
selected: boolean
|
|
628
|
+
onSelect: (id: string) => void
|
|
629
|
+
}) {
|
|
630
|
+
const { record, selected, onSelect } = props
|
|
631
|
+
return (
|
|
632
|
+
<button
|
|
633
|
+
className={cn(
|
|
634
|
+
'w-full text-left rounded border px-3 py-2 space-y-1',
|
|
635
|
+
selected ? 'border-primary bg-primary/5' : 'border-border hover:bg-secondary/40'
|
|
636
|
+
)}
|
|
637
|
+
onClick={() => onSelect(record.id)}
|
|
638
|
+
>
|
|
639
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
640
|
+
<Badge className={statusTone(record.statusCode)}>{record.statusCode}</Badge>
|
|
641
|
+
<span className="text-xs font-mono text-muted-foreground">{new Date(record.timestamp).toLocaleTimeString()}</span>
|
|
642
|
+
</div>
|
|
643
|
+
<div className="text-xs font-mono text-muted-foreground truncate">{record.method} {record.route}</div>
|
|
644
|
+
<div className="text-sm font-mono truncate">{record.model || 'unknown-model'}</div>
|
|
645
|
+
<div className="text-xs text-muted-foreground">{record.latencyMs}ms</div>
|
|
646
|
+
</button>
|
|
647
|
+
)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function TimelineEntryCard(props: { entry: CaptureTimelineEntry }) {
|
|
651
|
+
const { entry } = props
|
|
652
|
+
return (
|
|
653
|
+
<div className={cn('rounded border p-2 space-y-2', roleCardTone(entry.role), kindCardTone(entry.kind))}>
|
|
654
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
655
|
+
<Badge className={directionTone(entry.direction)}>{entry.direction}</Badge>
|
|
656
|
+
<Badge className={kindTone(entry.kind)}>{entry.kind}</Badge>
|
|
657
|
+
{entry.role && <Badge className={roleBadgeTone(entry.role)}>{entry.role}</Badge>}
|
|
658
|
+
<span className="text-[11px] font-mono text-muted-foreground">#{entry.index}</span>
|
|
659
|
+
<span className="text-[11px] font-mono text-muted-foreground">{entry.sourcePath}</span>
|
|
660
|
+
</div>
|
|
661
|
+
{entry.name && <div className="text-xs font-mono">{entry.name}</div>}
|
|
662
|
+
{entry.content && <ExpandableText content={entry.content} />}
|
|
663
|
+
{entry.arguments && (
|
|
664
|
+
<div className="rounded border border-sky-400/30 bg-sky-500/10 p-2">
|
|
665
|
+
<div className="text-[11px] uppercase tracking-wider font-mono text-sky-200 mb-1">Arguments</div>
|
|
666
|
+
<ExpandableText content={entry.arguments} />
|
|
667
|
+
</div>
|
|
668
|
+
)}
|
|
669
|
+
{entry.toolCallId && <div className="text-[11px] font-mono text-muted-foreground">tool_call_id: {entry.toolCallId}</div>}
|
|
670
|
+
{entry.metadata && !entry.arguments && !entry.content && (
|
|
671
|
+
<ExpandableText content={JSON.stringify(entry.metadata, null, 2)} />
|
|
672
|
+
)}
|
|
673
|
+
</div>
|
|
674
|
+
)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function MetadataList(props: { title: string; items: string[]; mono?: boolean }) {
|
|
678
|
+
return (
|
|
679
|
+
<div>
|
|
680
|
+
<div className="text-[11px] uppercase tracking-wider font-mono text-muted-foreground mb-1">{props.title}</div>
|
|
681
|
+
{props.items.length === 0 ? (
|
|
682
|
+
<EmptyState label={`No ${props.title.toLowerCase()}.`} />
|
|
683
|
+
) : (
|
|
684
|
+
<div className="space-y-2">
|
|
685
|
+
{props.items.map((item, index) => (
|
|
686
|
+
<pre key={index} className={cn('whitespace-pre-wrap text-xs', props.mono && 'font-mono')}>
|
|
687
|
+
{item}
|
|
688
|
+
</pre>
|
|
689
|
+
))}
|
|
690
|
+
</div>
|
|
691
|
+
)}
|
|
692
|
+
</div>
|
|
693
|
+
)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function Section(props: { title: string; children: ReactNode }) {
|
|
697
|
+
return (
|
|
698
|
+
<div>
|
|
699
|
+
<h3 className="text-xs uppercase tracking-wider font-mono text-muted-foreground mb-1">{props.title}</h3>
|
|
700
|
+
<div className="rounded border border-border p-2 space-y-2">{props.children}</div>
|
|
701
|
+
</div>
|
|
702
|
+
)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function Badge(props: { className: string; children: ReactNode }) {
|
|
706
|
+
return <span className={cn('inline-flex items-center rounded border px-2 py-0.5 text-xs font-mono', props.className)}>{props.children}</span>
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function EmptyState(props: { label: string }) {
|
|
710
|
+
return <div className="text-xs text-muted-foreground">{props.label}</div>
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function ExpandableText(props: { content: string }) {
|
|
714
|
+
const isLong = props.content.length > 700 || props.content.includes('\n')
|
|
715
|
+
if (!isLong) {
|
|
716
|
+
return <pre className="whitespace-pre-wrap text-xs">{props.content}</pre>
|
|
717
|
+
}
|
|
718
|
+
return (
|
|
719
|
+
<details className="group">
|
|
720
|
+
<summary className="cursor-pointer text-xs font-mono text-muted-foreground">
|
|
721
|
+
Show full text ({props.content.length} chars)
|
|
722
|
+
</summary>
|
|
723
|
+
<pre className="whitespace-pre-wrap text-xs mt-2">{props.content}</pre>
|
|
724
|
+
</details>
|
|
725
|
+
)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function EmbeddedMediaList(props: { items: PeekEmbeddedMedia[]; emptyLabel: string }) {
|
|
729
|
+
if (props.items.length === 0) {
|
|
730
|
+
return <EmptyState label={props.emptyLabel} />
|
|
731
|
+
}
|
|
732
|
+
return (
|
|
733
|
+
<div className="space-y-3">
|
|
734
|
+
{props.items.map((item) => (
|
|
735
|
+
<EmbeddedMediaCard key={`${item.source}:${item.path}:${item.url.slice(0, 32)}`} item={item} />
|
|
736
|
+
))}
|
|
737
|
+
</div>
|
|
738
|
+
)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function EmbeddedMediaCard(props: { item: PeekEmbeddedMedia }) {
|
|
742
|
+
const { item } = props
|
|
743
|
+
return (
|
|
744
|
+
<div className="rounded border border-border p-2 space-y-2">
|
|
745
|
+
<div className="flex flex-wrap items-center gap-2 text-[11px] font-mono text-muted-foreground">
|
|
746
|
+
<Badge className={item.source === 'request' ? 'border-blue-400/40 text-blue-300 bg-blue-500/10' : 'border-teal-400/40 text-teal-300 bg-teal-500/10'}>
|
|
747
|
+
{item.source}
|
|
748
|
+
</Badge>
|
|
749
|
+
<span>{item.path}</span>
|
|
750
|
+
<span>{item.origin}</span>
|
|
751
|
+
<span>{item.mime}</span>
|
|
752
|
+
<span>{item.sizeHint} chars</span>
|
|
753
|
+
</div>
|
|
754
|
+
{item.kind === 'image' ? (
|
|
755
|
+
<img src={item.url} alt={item.path} className="max-h-72 rounded border border-border" />
|
|
756
|
+
) : item.kind === 'audio' ? (
|
|
757
|
+
<audio controls src={item.url} className="w-full" />
|
|
758
|
+
) : (
|
|
759
|
+
<a href={item.url} target="_blank" rel="noreferrer" className="text-xs text-primary underline">
|
|
760
|
+
Open embedded media
|
|
761
|
+
</a>
|
|
762
|
+
)}
|
|
763
|
+
</div>
|
|
764
|
+
)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function buildCalendarGrid(month: string, days: CaptureCalendarDaySummary[]) {
|
|
768
|
+
if (!month) return [] as Array<{ key: string; date: string | null; day: number | null; count: number; inMonth: boolean }>
|
|
769
|
+
const [year, monthIndex] = month.split('-').map(Number)
|
|
770
|
+
const first = new Date(Date.UTC(year, monthIndex - 1, 1))
|
|
771
|
+
const startWeekday = first.getUTCDay()
|
|
772
|
+
const totalDays = new Date(Date.UTC(year, monthIndex, 0)).getUTCDate()
|
|
773
|
+
const countByDate = new Map(days.map((day) => [day.date, day.count]))
|
|
774
|
+
const cells: Array<{ key: string; date: string | null; day: number | null; count: number; inMonth: boolean }> = []
|
|
775
|
+
|
|
776
|
+
for (let i = 0; i < startWeekday; i += 1) {
|
|
777
|
+
cells.push({ key: `empty-start-${i}`, date: null, day: null, count: 0, inMonth: false })
|
|
778
|
+
}
|
|
779
|
+
for (let day = 1; day <= totalDays; day += 1) {
|
|
780
|
+
const date = `${month}-${String(day).padStart(2, '0')}`
|
|
781
|
+
cells.push({
|
|
782
|
+
key: date,
|
|
783
|
+
date,
|
|
784
|
+
day,
|
|
785
|
+
count: countByDate.get(date) ?? 0,
|
|
786
|
+
inMonth: true,
|
|
787
|
+
})
|
|
788
|
+
}
|
|
789
|
+
while (cells.length % 7 !== 0) {
|
|
790
|
+
cells.push({ key: `empty-end-${cells.length}`, date: null, day: null, count: 0, inMonth: false })
|
|
791
|
+
}
|
|
792
|
+
return cells
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function buildAggregatedResponseBody(detail: CaptureRecordDetail): unknown {
|
|
796
|
+
const body = detail.response.body ?? null
|
|
797
|
+
if (!body || typeof body !== 'object') return body
|
|
798
|
+
const responseRecord = body as Record<string, unknown>
|
|
799
|
+
if (responseRecord.$type !== 'stream') {
|
|
800
|
+
return body
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const timeline = detail.analysis.responseTimeline ?? []
|
|
804
|
+
const streamPreview = timeline.find((entry) => entry.kind === 'stream_preview')?.content ?? ''
|
|
805
|
+
const toolCalls = timeline
|
|
806
|
+
.filter((entry) => entry.kind === 'tool_call')
|
|
807
|
+
.map((entry) => ({
|
|
808
|
+
id: entry.toolCallId,
|
|
809
|
+
type: String(entry.metadata?.type ?? 'function'),
|
|
810
|
+
function: {
|
|
811
|
+
name: entry.name ?? '',
|
|
812
|
+
arguments: entry.arguments ?? '',
|
|
813
|
+
},
|
|
814
|
+
}))
|
|
815
|
+
const errors = timeline
|
|
816
|
+
.filter((entry) => entry.kind === 'error')
|
|
817
|
+
.map((entry) => ({
|
|
818
|
+
content: entry.content ?? '',
|
|
819
|
+
metadata: entry.metadata ?? null,
|
|
820
|
+
}))
|
|
821
|
+
|
|
822
|
+
return {
|
|
823
|
+
$type: 'stream_aggregated',
|
|
824
|
+
content: streamPreview,
|
|
825
|
+
tool_calls: toolCalls,
|
|
826
|
+
errors,
|
|
827
|
+
stream: {
|
|
828
|
+
contentType: responseRecord.contentType ?? null,
|
|
829
|
+
bytes: responseRecord.bytes ?? null,
|
|
830
|
+
},
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function prevMonth(month: string): string {
|
|
835
|
+
if (!month) return new Date().toISOString().slice(0, 7)
|
|
836
|
+
const [year, mon] = month.split('-').map(Number)
|
|
837
|
+
const date = new Date(Date.UTC(year, mon - 2, 1))
|
|
838
|
+
return date.toISOString().slice(0, 7)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function nextMonth(month: string): string {
|
|
842
|
+
if (!month) return new Date().toISOString().slice(0, 7)
|
|
843
|
+
const [year, mon] = month.split('-').map(Number)
|
|
844
|
+
const date = new Date(Date.UTC(year, mon, 1))
|
|
845
|
+
return date.toISOString().slice(0, 7)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function formatCount(value: number | null | undefined): string {
|
|
849
|
+
if (value === null || value === undefined) return 'n/a'
|
|
850
|
+
return value.toLocaleString()
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function routeType(route: string): string {
|
|
854
|
+
if (route.includes('/chat/completions')) return 'chat'
|
|
855
|
+
if (route.includes('/responses')) return 'responses'
|
|
856
|
+
if (route.includes('/embeddings')) return 'embeddings'
|
|
857
|
+
if (route.includes('/images/')) return 'images'
|
|
858
|
+
if (route.includes('/audio/')) return 'audio'
|
|
859
|
+
return 'other'
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function getBrowserTimeZone(): string {
|
|
863
|
+
try {
|
|
864
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
865
|
+
} catch {
|
|
866
|
+
return 'UTC'
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function formatDateForTimeZone(value: Date, timeZone: string): string {
|
|
871
|
+
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
872
|
+
timeZone,
|
|
873
|
+
year: 'numeric',
|
|
874
|
+
month: '2-digit',
|
|
875
|
+
day: '2-digit',
|
|
876
|
+
})
|
|
877
|
+
const parts = formatter.formatToParts(value)
|
|
878
|
+
const year = parts.find((part) => part.type === 'year')?.value ?? '0000'
|
|
879
|
+
const month = parts.find((part) => part.type === 'month')?.value ?? '00'
|
|
880
|
+
const day = parts.find((part) => part.type === 'day')?.value ?? '00'
|
|
881
|
+
return `${year}-${month}-${day}`
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function routeTone(route: string): string {
|
|
885
|
+
const type = routeType(route)
|
|
886
|
+
if (type === 'chat') return 'border-blue-400/40 text-blue-300 bg-blue-500/10'
|
|
887
|
+
if (type === 'responses') return 'border-cyan-400/40 text-cyan-300 bg-cyan-500/10'
|
|
888
|
+
if (type === 'embeddings') return 'border-emerald-400/40 text-emerald-300 bg-emerald-500/10'
|
|
889
|
+
if (type === 'images') return 'border-amber-400/40 text-amber-300 bg-amber-500/10'
|
|
890
|
+
if (type === 'audio') return 'border-violet-400/40 text-violet-300 bg-violet-500/10'
|
|
891
|
+
return 'border-border text-muted-foreground'
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function statusTone(statusCode: number): string {
|
|
895
|
+
if (statusCode >= 500) return 'border-red-400/40 text-red-300 bg-red-500/10'
|
|
896
|
+
if (statusCode >= 400) return 'border-orange-400/40 text-orange-300 bg-orange-500/10'
|
|
897
|
+
if (statusCode >= 300) return 'border-yellow-400/40 text-yellow-300 bg-yellow-500/10'
|
|
898
|
+
return 'border-green-400/40 text-green-300 bg-green-500/10'
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function responseType(detail: CaptureRecordDetail): string {
|
|
902
|
+
const body = detail.response.body as Record<string, unknown> | null
|
|
903
|
+
if (detail.statusCode >= 400) return 'error'
|
|
904
|
+
if (body && typeof body === 'object' && body.$type === 'stream') return 'stream'
|
|
905
|
+
return 'json'
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function responseTone(detail: CaptureRecordDetail): string {
|
|
909
|
+
const type = responseType(detail)
|
|
910
|
+
if (type === 'error') return 'border-red-400/40 text-red-300 bg-red-500/10'
|
|
911
|
+
if (type === 'stream') return 'border-sky-400/40 text-sky-300 bg-sky-500/10'
|
|
912
|
+
return 'border-teal-400/40 text-teal-300 bg-teal-500/10'
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function isMissingLegacyStreamCapture(detail: CaptureRecordDetail): boolean {
|
|
916
|
+
const request = detail.request.body as Record<string, unknown> | null
|
|
917
|
+
const response = detail.response.body as Record<string, unknown> | null
|
|
918
|
+
return request?.stream === true && (detail.analysis.responseTimeline?.length ?? 0) === 0 && !response
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function roleBadgeTone(role: CaptureTimelineEntry['role']) {
|
|
922
|
+
if (role === 'system') return 'border-amber-400/40 text-amber-300 bg-amber-500/10'
|
|
923
|
+
if (role === 'user') return 'border-blue-400/40 text-blue-300 bg-blue-500/10'
|
|
924
|
+
if (role === 'assistant') return 'border-emerald-400/40 text-emerald-300 bg-emerald-500/10'
|
|
925
|
+
if (role === 'tool') return 'border-violet-400/40 text-violet-300 bg-violet-500/10'
|
|
926
|
+
if (role === 'developer') return 'border-fuchsia-400/40 text-fuchsia-300 bg-fuchsia-500/10'
|
|
927
|
+
return 'border-border text-muted-foreground'
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function roleCardTone(role: CaptureTimelineEntry['role']) {
|
|
931
|
+
if (role === 'system') return 'border-amber-400/30 bg-amber-500/5'
|
|
932
|
+
if (role === 'user') return 'border-blue-400/30 bg-blue-500/5'
|
|
933
|
+
if (role === 'assistant') return 'border-emerald-400/30 bg-emerald-500/5'
|
|
934
|
+
if (role === 'tool') return 'border-violet-400/30 bg-violet-500/5'
|
|
935
|
+
if (role === 'developer') return 'border-fuchsia-400/30 bg-fuchsia-500/5'
|
|
936
|
+
return ''
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function kindTone(kind: CaptureTimelineEntry['kind']) {
|
|
940
|
+
if (kind === 'tool_call') return 'border-sky-400/40 text-sky-300 bg-sky-500/10'
|
|
941
|
+
if (kind === 'tool_result') return 'border-violet-400/40 text-violet-300 bg-violet-500/10'
|
|
942
|
+
if (kind === 'reasoning') return 'border-indigo-400/40 text-indigo-300 bg-indigo-500/10'
|
|
943
|
+
if (kind === 'instructions') return 'border-amber-400/40 text-amber-300 bg-amber-500/10'
|
|
944
|
+
if (kind === 'stream_preview') return 'border-cyan-400/40 text-cyan-300 bg-cyan-500/10'
|
|
945
|
+
if (kind === 'error') return 'border-red-400/40 text-red-300 bg-red-500/10'
|
|
946
|
+
if (kind === 'tool_definition') return 'border-orange-400/40 text-orange-300 bg-orange-500/10'
|
|
947
|
+
return 'border-border text-muted-foreground'
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function kindCardTone(kind: CaptureTimelineEntry['kind']) {
|
|
951
|
+
if (kind === 'reasoning') return 'bg-indigo-500/5'
|
|
952
|
+
if (kind === 'tool_call') return 'bg-sky-500/5'
|
|
953
|
+
if (kind === 'tool_result') return 'bg-violet-500/5'
|
|
954
|
+
if (kind === 'error') return 'bg-red-500/5'
|
|
955
|
+
return ''
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function directionTone(direction: CaptureTimelineEntry['direction']) {
|
|
959
|
+
return direction === 'request'
|
|
960
|
+
? 'border-slate-400/40 text-slate-200 bg-slate-500/10'
|
|
961
|
+
: 'border-teal-400/40 text-teal-300 bg-teal-500/10'
|
|
962
|
+
}
|