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.
Files changed (260) hide show
  1. package/.github/instructions/ui.instructions.md +42 -0
  2. package/.github/workflows/ci.yml +35 -0
  3. package/.github/workflows/publish.yml +71 -0
  4. package/.github/workflows/release.yml +48 -0
  5. package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
  6. package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
  7. package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
  8. package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
  10. package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
  11. package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
  12. package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
  13. package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
  14. package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
  15. package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
  16. package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
  17. package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
  18. package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
  19. package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
  20. package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
  21. package/AGENTS.md +48 -0
  22. package/CHANGELOG.md +131 -0
  23. package/README.md +552 -0
  24. package/assets/agent-mode.png +0 -0
  25. package/assets/categorize.png +0 -0
  26. package/assets/dashboard.png +0 -0
  27. package/assets/endpoint-proxy.png +0 -0
  28. package/assets/icon.png +0 -0
  29. package/assets/mcp-generate-image.png +0 -0
  30. package/assets/mcp-understand-image.png +0 -0
  31. package/assets/peek-token-flow.png +0 -0
  32. package/assets/playground.png +0 -0
  33. package/assets/sankey.png +0 -0
  34. package/cli/index.ts +2805 -0
  35. package/cli/legacyRewrite.ts +108 -0
  36. package/cli/modelRef.ts +24 -0
  37. package/dist/cli/index.js +2536 -0
  38. package/dist/cli/legacyRewrite.js +92 -0
  39. package/dist/cli/modelRef.js +20 -0
  40. package/dist/src/benchmark/artifacts.js +131 -0
  41. package/dist/src/benchmark/capabilityClassifier.js +81 -0
  42. package/dist/src/benchmark/capabilityStore.js +144 -0
  43. package/dist/src/benchmark/config.js +238 -0
  44. package/dist/src/benchmark/gates.js +118 -0
  45. package/dist/src/benchmark/jobs.js +252 -0
  46. package/dist/src/benchmark/runner.js +1847 -0
  47. package/dist/src/benchmark/schema.js +353 -0
  48. package/dist/src/benchmark/suites.js +314 -0
  49. package/dist/src/benchmark/tinyQaDataset.js +422 -0
  50. package/dist/src/benchmark/types.js +25 -0
  51. package/dist/src/config.js +47 -0
  52. package/dist/src/index.js +178 -0
  53. package/dist/src/mcp/client.js +215 -0
  54. package/dist/src/mcp/discovery.js +226 -0
  55. package/dist/src/mcp/policy.js +65 -0
  56. package/dist/src/mcp/registry.js +129 -0
  57. package/dist/src/mcp/service.js +460 -0
  58. package/dist/src/middleware/auth.js +179 -0
  59. package/dist/src/middleware/requestCapture.js +192 -0
  60. package/dist/src/middleware/requestStats.js +118 -0
  61. package/dist/src/pools/builder.js +132 -0
  62. package/dist/src/pools/repository.js +69 -0
  63. package/dist/src/pools/scheduler.js +360 -0
  64. package/dist/src/pools/types.js +2 -0
  65. package/dist/src/protocols/adapters/dashscope.js +267 -0
  66. package/dist/src/protocols/adapters/inferenceV2.js +346 -0
  67. package/dist/src/protocols/adapters/openai.js +27 -0
  68. package/dist/src/protocols/registry.js +99 -0
  69. package/dist/src/protocols/types.js +2 -0
  70. package/dist/src/providers/health.js +153 -0
  71. package/dist/src/providers/importer.js +289 -0
  72. package/dist/src/providers/modelRegistry.js +313 -0
  73. package/dist/src/providers/repository.js +361 -0
  74. package/dist/src/providers/types.js +2 -0
  75. package/dist/src/routes/admin.js +531 -0
  76. package/dist/src/routes/audio.js +295 -0
  77. package/dist/src/routes/chat.js +240 -0
  78. package/dist/src/routes/embeddings.js +157 -0
  79. package/dist/src/routes/images.js +288 -0
  80. package/dist/src/routes/mcp.js +256 -0
  81. package/dist/src/routes/mcpService.js +100 -0
  82. package/dist/src/routes/models.js +48 -0
  83. package/dist/src/routes/responses.js +711 -0
  84. package/dist/src/routes/sessions.js +450 -0
  85. package/dist/src/routes/stats.js +270 -0
  86. package/dist/src/routes/ui.js +97 -0
  87. package/dist/src/routes/videos.js +107 -0
  88. package/dist/src/routing/router.js +338 -0
  89. package/dist/src/services/imageGeneration.js +280 -0
  90. package/dist/src/services/imageUnderstanding.js +352 -0
  91. package/dist/src/services/videoGeneration.js +79 -0
  92. package/dist/src/storage/captureRepository.js +1591 -0
  93. package/dist/src/storage/files.js +157 -0
  94. package/dist/src/storage/imageCache.js +346 -0
  95. package/dist/src/storage/repositories.js +388 -0
  96. package/dist/src/storage/sessionRepository.js +370 -0
  97. package/dist/src/storage/statsRepository.js +204 -0
  98. package/dist/src/transport/httpClient.js +126 -0
  99. package/dist/src/types.js +2 -0
  100. package/dist/src/utils/messageMedia.js +285 -0
  101. package/dist/src/utils/modelCapabilities.js +108 -0
  102. package/dist/src/utils/modelDiscovery.js +170 -0
  103. package/dist/src/version.js +5 -0
  104. package/dist/src/workers/captureRetention.js +25 -0
  105. package/dist/src/workers/configWatcher.js +91 -0
  106. package/dist/src/workers/healthChecker.js +21 -0
  107. package/dist/src/workers/statsRotation.js +41 -0
  108. package/docs/LLM/output_schema.md +312 -0
  109. package/docs/benchmark.md +208 -0
  110. package/docs/mcp-guidelines.md +125 -0
  111. package/docs/mcp-service.md +178 -0
  112. package/docs/opencode.md +86 -0
  113. package/docs/providers.md +79 -0
  114. package/examples/benchmark.config.yaml +28 -0
  115. package/examples/providers/alibaba-dashscope.yaml +88 -0
  116. package/examples/providers/alibaba-llm.yaml +64 -0
  117. package/examples/providers/alibaba-registry.yaml +7 -0
  118. package/examples/providers/inference-v2-ray.yaml +29 -0
  119. package/examples/scenarios/assets/omni-call-sample.wav +0 -0
  120. package/examples/scenarios/custom.jsonl +5 -0
  121. package/examples/scenarios/custom.yaml +40 -0
  122. package/model-form-v2.png +0 -0
  123. package/package.json +66 -0
  124. package/provider-form-v2.png +0 -0
  125. package/provider-form.png +0 -0
  126. package/scripts/manual-test.sh +11 -0
  127. package/scripts/version-from-git.js +23 -0
  128. package/src/benchmark/artifacts.ts +149 -0
  129. package/src/benchmark/capabilityClassifier.ts +99 -0
  130. package/src/benchmark/capabilityStore.ts +174 -0
  131. package/src/benchmark/config.ts +337 -0
  132. package/src/benchmark/gates.ts +164 -0
  133. package/src/benchmark/jobs.ts +312 -0
  134. package/src/benchmark/runner.ts +2519 -0
  135. package/src/benchmark/schema.ts +443 -0
  136. package/src/benchmark/suites.ts +323 -0
  137. package/src/benchmark/tinyQaDataset.ts +428 -0
  138. package/src/benchmark/types.ts +442 -0
  139. package/src/config.ts +44 -0
  140. package/src/index.ts +195 -0
  141. package/src/mcp/client.ts +305 -0
  142. package/src/mcp/discovery.ts +266 -0
  143. package/src/mcp/policy.ts +105 -0
  144. package/src/mcp/registry.ts +164 -0
  145. package/src/mcp/service.ts +611 -0
  146. package/src/middleware/auth.ts +251 -0
  147. package/src/middleware/requestCapture.ts +245 -0
  148. package/src/middleware/requestStats.ts +163 -0
  149. package/src/pools/builder.ts +159 -0
  150. package/src/pools/repository.ts +71 -0
  151. package/src/pools/scheduler.ts +425 -0
  152. package/src/pools/types.ts +117 -0
  153. package/src/protocols/adapters/dashscope.ts +335 -0
  154. package/src/protocols/adapters/inferenceV2.ts +428 -0
  155. package/src/protocols/adapters/openai.ts +32 -0
  156. package/src/protocols/registry.ts +117 -0
  157. package/src/protocols/types.ts +81 -0
  158. package/src/providers/health.ts +207 -0
  159. package/src/providers/importer.ts +402 -0
  160. package/src/providers/modelRegistry.ts +415 -0
  161. package/src/providers/repository.ts +439 -0
  162. package/src/providers/types.ts +113 -0
  163. package/src/routes/admin.ts +666 -0
  164. package/src/routes/audio.ts +372 -0
  165. package/src/routes/chat.ts +301 -0
  166. package/src/routes/embeddings.ts +197 -0
  167. package/src/routes/images.ts +356 -0
  168. package/src/routes/mcp.ts +320 -0
  169. package/src/routes/mcpService.ts +114 -0
  170. package/src/routes/models.ts +50 -0
  171. package/src/routes/responses.ts +872 -0
  172. package/src/routes/sessions.ts +558 -0
  173. package/src/routes/stats.ts +312 -0
  174. package/src/routes/ui.ts +96 -0
  175. package/src/routes/videos.ts +132 -0
  176. package/src/routing/router.ts +501 -0
  177. package/src/services/imageGeneration.ts +396 -0
  178. package/src/services/imageUnderstanding.ts +449 -0
  179. package/src/services/videoGeneration.ts +127 -0
  180. package/src/storage/captureRepository.ts +1835 -0
  181. package/src/storage/files.ts +178 -0
  182. package/src/storage/imageCache.ts +405 -0
  183. package/src/storage/repositories.ts +494 -0
  184. package/src/storage/sessionRepository.ts +419 -0
  185. package/src/storage/statsRepository.ts +238 -0
  186. package/src/transport/httpClient.ts +145 -0
  187. package/src/types.ts +322 -0
  188. package/src/utils/messageMedia.ts +293 -0
  189. package/src/utils/modelCapabilities.ts +161 -0
  190. package/src/utils/modelDiscovery.ts +203 -0
  191. package/src/workers/captureRetention.ts +25 -0
  192. package/src/workers/configWatcher.ts +115 -0
  193. package/src/workers/healthChecker.ts +22 -0
  194. package/src/workers/statsRotation.ts +49 -0
  195. package/tests/benchmarkAdminRoutes.test.ts +82 -0
  196. package/tests/benchmarkBasics.test.ts +116 -0
  197. package/tests/captureAdminRoutes.test.ts +420 -0
  198. package/tests/captureRepository.test.ts +797 -0
  199. package/tests/cliLegacyRewrite.test.ts +45 -0
  200. package/tests/imageGeneration.service.test.ts +107 -0
  201. package/tests/imageUnderstanding.service.test.ts +123 -0
  202. package/tests/mcpPolicy.test.ts +105 -0
  203. package/tests/mcpService.test.ts +1245 -0
  204. package/tests/modelRef.test.ts +23 -0
  205. package/tests/modelsRoutes.test.ts +154 -0
  206. package/tests/sessionMediaCache.test.ts +167 -0
  207. package/tests/statsRoutes.test.ts +323 -0
  208. package/tsconfig.json +15 -0
  209. package/ui/index.html +16 -0
  210. package/ui/package-lock.json +8521 -0
  211. package/ui/package.json +52 -0
  212. package/ui/postcss.config.js +6 -0
  213. package/ui/public/assets/apple-touch-icon.png +0 -0
  214. package/ui/public/assets/favicon-16.png +0 -0
  215. package/ui/public/assets/favicon-32.png +0 -0
  216. package/ui/public/assets/icon-192.png +0 -0
  217. package/ui/public/assets/icon-512.png +0 -0
  218. package/ui/src/App.tsx +27 -0
  219. package/ui/src/api/client.ts +1503 -0
  220. package/ui/src/components/EndpointUsageGuide.tsx +361 -0
  221. package/ui/src/components/Layout.tsx +124 -0
  222. package/ui/src/components/MessageContent.tsx +365 -0
  223. package/ui/src/components/ToolCallMessage.tsx +179 -0
  224. package/ui/src/components/ToolPicker.tsx +442 -0
  225. package/ui/src/components/messageContentParser.test.ts +41 -0
  226. package/ui/src/components/messageContentParser.ts +73 -0
  227. package/ui/src/components/thinkingPreview.test.ts +27 -0
  228. package/ui/src/components/thinkingPreview.ts +15 -0
  229. package/ui/src/components/toMermaidSankey.test.ts +78 -0
  230. package/ui/src/components/toMermaidSankey.ts +56 -0
  231. package/ui/src/components/ui/button.tsx +58 -0
  232. package/ui/src/components/ui/input.tsx +21 -0
  233. package/ui/src/components/ui/textarea.tsx +21 -0
  234. package/ui/src/lib/utils.ts +6 -0
  235. package/ui/src/main.tsx +9 -0
  236. package/ui/src/pages/AgentPlayground.tsx +2010 -0
  237. package/ui/src/pages/Benchmark.tsx +988 -0
  238. package/ui/src/pages/Dashboard.tsx +581 -0
  239. package/ui/src/pages/Peek.tsx +962 -0
  240. package/ui/src/pages/Settings.tsx +2013 -0
  241. package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
  242. package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
  243. package/ui/src/pages/agentThinkingContent.test.ts +50 -0
  244. package/ui/src/pages/agentThinkingContent.ts +57 -0
  245. package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
  246. package/ui/src/pages/dashboardTokenUsage.ts +36 -0
  247. package/ui/src/pages/imageUpload.test.ts +39 -0
  248. package/ui/src/pages/imageUpload.ts +71 -0
  249. package/ui/src/pages/peekFilters.test.ts +29 -0
  250. package/ui/src/pages/peekFilters.ts +13 -0
  251. package/ui/src/pages/peekMedia.test.ts +58 -0
  252. package/ui/src/pages/peekMedia.ts +148 -0
  253. package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
  254. package/ui/src/pages/sessionAutoTitle.ts +106 -0
  255. package/ui/src/stores/settings.ts +58 -0
  256. package/ui/src/styles/globals.css +223 -0
  257. package/ui/src/vite-env.d.ts +8 -0
  258. package/ui/tailwind.config.js +106 -0
  259. package/ui/tsconfig.json +32 -0
  260. 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
+ }