my harness for niri
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 870 lines 27 kB view raw
1import { useCallback, useEffect, useMemo, useState } from "react" 2import { MarkdownBlock } from "./MarkdownBlock" 3 4type Usage = { 5 prompt_tokens?: number 6 completion_tokens?: number 7 total_tokens?: number 8} 9 10type BaseMetric = { 11 id: number 12 sourceType: string 13 timestamp: string 14 detailPath: string 15} 16 17type MemoryMetric = BaseMetric & { 18 type: "memory" 19 queryPreview?: string 20 resultCount?: number 21} 22 23type PromptMetric = BaseMetric & { 24 type: "prompt" 25 messageCount?: number 26 lastUserMessage?: string 27} 28 29type ResponseMetric = BaseMetric & { 30 type: "response" 31 promptMetricId?: number 32 model?: string 33 toolChoice?: string 34 messageCount?: number 35 lastUserMessage?: string 36 responsePreview?: string 37 toolCallCount?: number 38 usage?: Usage 39} 40 41type UsageMetric = BaseMetric & { 42 type: "usage" 43 usage?: Usage 44} 45 46type SummarizationMetric = BaseMetric & { 47 type: "summarization" 48 method?: string 49 before?: number 50 after?: number 51 savedTokens?: number 52 summaryPreview?: string 53 summaryChars?: number 54} 55 56type DiscordMetric = { 57 id: string 58 type: "discord" 59 sourceType: "discord" 60 timestamp: string 61 detailPath: string 62 messageId: string 63 channelId: string 64 guildId?: string 65 authorUsername?: string 66 contentPreview?: string 67 isDm: boolean 68 mentionsBot: boolean 69 isFromBot: boolean 70} 71 72type MetricItem = MemoryMetric | PromptMetric | ResponseMetric | UsageMetric | SummarizationMetric 73 74type MetricsPage = { 75 memories: MemoryMetric[] 76 summarization: SummarizationMetric[] 77 response: ResponseMetric[] 78 prompt: PromptMetric[] 79 usage: UsageMetric[] 80 discord: DiscordMetric[] 81 limit: number 82 nextCursor: Record<string, string | number | undefined> 83 hasMore: Record<string, boolean | undefined> 84} 85 86type MetricsPageInput = Partial<MetricsPage> 87 88type MemorySearchResult = { 89 chunkId: number 90 kind: string 91 path: string 92 source: string 93 title: string 94 headingPath: string | null 95 preview: string 96} 97 98type MemoryDetail = { 99 id: number 100 type: "memory" 101 timestamp: string 102 query: string 103 results: MemorySearchResult[] 104} 105 106type Message = { 107 role?: string 108 content?: unknown 109 tool_call_id?: string 110 tool_calls?: unknown 111} 112 113type PromptDetail = { 114 id: number 115 type: "prompt" | "prompt_response" 116 timestamp: string 117 messages?: Message[] 118 response?: Message 119} 120 121type TurnDetail = { 122 id: number 123 timestamp: string 124 model?: string 125 usage?: Usage 126 promptText: string 127 responseText?: string 128 toolTraces: ToolTrace[] 129} 130 131type DetailState = 132 | { kind: "idle" } 133 | { kind: "loading"; label: string } 134 | { kind: "error"; text: string } 135 | { kind: "memory"; memory: MemoryDetail; prompt?: PromptDetail } 136 | { kind: "turn"; turn: TurnDetail } 137 | { kind: "metric"; metric: unknown } 138 139type MemoryPair = { 140 memory: MemoryMetric 141 prompt?: PromptMetric | ResponseMetric 142 secondsApart?: number 143 overlap: number 144 shared: string[] 145 issue: "ok" | "loose" | "missing" 146} 147 148type ToolTrace = { 149 id: string 150 name: string 151 args: string 152 result?: string 153} 154 155const baseUrl = import.meta.env.VITE_NIRI_BASE_URL ?? "" 156const METRICS_POLL_INTERVAL_MS = 5_000 157 158const stopWords = new Set([ 159 "a", 160 "an", 161 "and", 162 "are", 163 "as", 164 "at", 165 "be", 166 "but", 167 "by", 168 "for", 169 "from", 170 "have", 171 "i", 172 "in", 173 "is", 174 "it", 175 "me", 176 "my", 177 "of", 178 "on", 179 "or", 180 "that", 181 "the", 182 "this", 183 "to", 184 "was", 185 "with", 186 "you", 187 "your", 188]) 189 190const formatNumber = (value: number | undefined): string => 191 typeof value === "number" && Number.isFinite(value) ? value.toLocaleString() : "0" 192 193const timeLabel = (iso: string): string => { 194 const date = new Date(iso) 195 if (Number.isNaN(date.getTime())) return iso 196 return new Intl.DateTimeFormat(undefined, { 197 month: "short", 198 day: "2-digit", 199 hour: "2-digit", 200 minute: "2-digit", 201 }).format(date) 202} 203 204const shortTime = (iso: string): string => { 205 const date = new Date(iso) 206 if (Number.isNaN(date.getTime())) return iso 207 return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(date) 208} 209 210const textContent = (content: unknown): string => { 211 if (typeof content === "string") return content 212 if (!Array.isArray(content)) return "" 213 return content 214 .flatMap((part) => { 215 if (!part || typeof part !== "object") return [] 216 const record = part as Record<string, unknown> 217 return typeof record.text === "string" ? [record.text] : [] 218 }) 219 .join("\n") 220} 221 222const toolResultMarkdown = (result: string): string => { 223 const trimmed = result.trim() 224 if (!trimmed) return "```text\n(no output)\n```" 225 if (/^(```|#{1,6}\s|- |\* |\d+\. |> |\|)/m.test(trimmed)) return trimmed 226 return `\`\`\`text\n${trimmed}\n\`\`\`` 227} 228 229const extractToolTraces = (prompt: PromptDetail | undefined): ToolTrace[] => { 230 const messages = [...(prompt?.messages ?? []), ...(prompt?.response ? [prompt.response] : [])] 231 const traces: ToolTrace[] = [] 232 const byId = new Map<string, ToolTrace>() 233 234 for (const message of messages) { 235 if (Array.isArray(message.tool_calls)) { 236 for (const rawCall of message.tool_calls) { 237 if (!rawCall || typeof rawCall !== "object") continue 238 const call = rawCall as Record<string, unknown> 239 const fn = call.function && typeof call.function === "object" ? (call.function as Record<string, unknown>) : {} 240 const id = typeof call.id === "string" ? call.id : `tool-${traces.length + 1}` 241 const trace: ToolTrace = { 242 id, 243 name: typeof fn.name === "string" ? fn.name : "tool", 244 args: typeof fn.arguments === "string" ? fn.arguments : "", 245 } 246 traces.push(trace) 247 byId.set(id, trace) 248 } 249 } 250 251 if (message.role === "tool") { 252 const id = typeof message.tool_call_id === "string" ? message.tool_call_id : "" 253 const result = textContent(message.content) 254 const existing = byId.get(id) 255 if (existing) { 256 existing.result = result 257 } else { 258 traces.push({ 259 id: id || `tool-result-${traces.length + 1}`, 260 name: "tool result", 261 args: "", 262 result, 263 }) 264 } 265 } 266 } 267 268 return traces 269} 270 271const lastUserMessage = (messages: Message[] | undefined): string => { 272 if (!messages) return "" 273 for (let i = messages.length - 1; i >= 0; i--) { 274 const message = messages[i] 275 if (message?.role === "user") { 276 const text = textContent(message.content).trim() 277 if (text) return text 278 } 279 } 280 return "" 281} 282 283const tokens = (value: string | undefined): string[] => { 284 if (!value) return [] 285 return value 286 .toLowerCase() 287 .replace(/https?:\/\/\S+/g, " ") 288 .replace(/[^a-z0-9\s'-]+/g, " ") 289 .split(/\s+/) 290 .map((token) => token.replace(/^['-]+|['-]+$/g, "")) 291 .filter((token) => token.length > 2 && !stopWords.has(token)) 292} 293 294const overlapFor = (left: string | undefined, right: string | undefined): { score: number; shared: string[] } => { 295 const leftTokens = new Set(tokens(left)) 296 const rightTokens = new Set(tokens(right)) 297 if (leftTokens.size === 0 || rightTokens.size === 0) return { score: 0, shared: [] } 298 299 const shared = [...leftTokens].filter((token) => rightTokens.has(token)) 300 return { 301 score: shared.length / Math.max(1, Math.min(leftTokens.size, rightTokens.size)), 302 shared: shared.slice(0, 8), 303 } 304} 305 306const fetchJson = async <T,>(path: string, signal?: AbortSignal): Promise<T> => { 307 const res = await fetch(`${baseUrl}${path}`, { signal }) 308 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()) 309 return (await res.json()) as T 310} 311 312const normalizeMetricsPage = (page: MetricsPageInput): MetricsPage => ({ 313 memories: Array.isArray(page.memories) ? page.memories : [], 314 summarization: Array.isArray(page.summarization) ? page.summarization : [], 315 response: Array.isArray(page.response) ? page.response : [], 316 prompt: Array.isArray(page.prompt) ? page.prompt : [], 317 usage: Array.isArray(page.usage) ? page.usage : [], 318 discord: Array.isArray(page.discord) ? page.discord : [], 319 limit: typeof page.limit === "number" ? page.limit : 100, 320 nextCursor: page.nextCursor ?? {}, 321 hasMore: page.hasMore ?? {}, 322}) 323 324function buildMetricsUrl(search: string): string { 325 const params = new URLSearchParams() 326 params.set("limit", "100") 327 if (search.trim()) params.set("q", search.trim()) 328 return `/metrics?${params.toString()}` 329} 330 331function closestResponsePath(timestamp: string, responses: ResponseMetric[]): string | undefined { 332 const usageTime = new Date(timestamp).getTime() 333 if (!Number.isFinite(usageTime)) return undefined 334 335 let best: ResponseMetric | undefined 336 let bestDelta = Number.POSITIVE_INFINITY 337 for (const r of responses) { 338 const t = new Date(r.timestamp).getTime() 339 if (!Number.isFinite(t) || t > usageTime) continue 340 const delta = usageTime - t 341 if (delta < bestDelta) { 342 best = r 343 bestDelta = delta 344 } 345 } 346 return best?.detailPath 347} 348 349function TokenTrace({ 350 usage, 351 responses, 352 onOpenTurn, 353 latestPromptText, 354}: { 355 usage: UsageMetric[] 356 responses: ResponseMetric[] 357 onOpenTurn: (path: string) => void 358 latestPromptText?: string 359}) { 360 const points = useMemo(() => { 361 const usagePoints = usage.map((item) => ({ 362 id: `u-${item.id}`, 363 timestamp: item.timestamp, 364 usage: item.usage, 365 model: undefined as string | undefined, 366 detailPath: closestResponsePath(item.timestamp, responses) ?? item.detailPath, 367 })) 368 const responsePoints = responses 369 .filter((item) => item.usage) 370 .map((item) => ({ 371 id: `r-${item.id}`, 372 timestamp: item.timestamp, 373 usage: item.usage, 374 model: item.model, 375 detailPath: item.detailPath, 376 })) 377 return [...usagePoints, ...responsePoints] 378 .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) 379 .slice(-80) 380 }, [responses, usage]) 381 382 const maxTotal = Math.max(1, ...points.map((point) => point.usage?.total_tokens ?? 0)) 383 const latest = points[points.length - 1] 384 const totals = points.reduce( 385 (sum, point) => ({ 386 prompt: sum.prompt + (point.usage?.prompt_tokens ?? 0), 387 completion: sum.completion + (point.usage?.completion_tokens ?? 0), 388 total: sum.total + (point.usage?.total_tokens ?? 0), 389 }), 390 { prompt: 0, completion: 0, total: 0 }, 391 ) 392 const average = points.length ? Math.round(totals.total / points.length) : 0 393 394 return ( 395 <section className="metric-panel metric-token-panel" aria-label="token usage"> 396 <div className="panel-head"> 397 <div> 398 <h2>Token Usage</h2> 399 <p>{points.length ? `${points.length} recent completions` : "No usage rows yet"}</p> 400 </div> 401 <div className="token-readout"> 402 <span>{formatNumber(latest?.usage?.total_tokens)}</span> 403 <small>latest total</small> 404 </div> 405 </div> 406 407 <div className="token-strip" aria-label="recent token totals"> 408 {points.map((point) => { 409 const prompt = point.usage?.prompt_tokens ?? 0 410 const completion = point.usage?.completion_tokens ?? 0 411 const total = point.usage?.total_tokens ?? prompt + completion 412 return ( 413 <button 414 key={point.id} 415 className="token-bar" 416 type="button" 417 onClick={() => onOpenTurn(point.detailPath)} 418 title={`${timeLabel(point.timestamp)} total ${formatNumber(total)} prompt ${formatNumber(prompt)} completion ${formatNumber(completion)}`} 419 style={{ height: `${Math.max(8, Math.round((total / maxTotal) * 100))}%` }} 420 > 421 <span className="token-bar-prompt" style={{ height: `${total ? (prompt / total) * 100 : 0}%` }} /> 422 <span className="token-bar-completion" style={{ height: `${total ? (completion / total) * 100 : 0}%` }} /> 423 </button> 424 ) 425 })} 426 </div> 427 428 <div className="token-ledger"> 429 <div> 430 <span>{formatNumber(totals.prompt)}</span> 431 <small>prompt</small> 432 </div> 433 <div> 434 <span>{formatNumber(totals.completion)}</span> 435 <small>completion</small> 436 </div> 437 <div> 438 <span>{formatNumber(average)}</span> 439 <small>avg total</small> 440 </div> 441 </div> 442 443 {latestPromptText && ( 444 <div className="latest-prompt"> 445 <small>latest prompt</small> 446 <p>{latestPromptText}</p> 447 </div> 448 )} 449 </section> 450 ) 451} 452 453function MemoryReview({ 454 pairs, 455 selectedId, 456 reviewOnly, 457 onReviewOnlyChange, 458 onSelect, 459}: { 460 pairs: MemoryPair[] 461 selectedId?: number 462 reviewOnly: boolean 463 onReviewOnlyChange: (value: boolean) => void 464 onSelect: (pair: MemoryPair) => void 465}) { 466 const visible = reviewOnly ? pairs.filter((pair) => pair.issue !== "ok") : pairs 467 468 return ( 469 <section className="metric-panel memory-panel" aria-label="memory prompt alignment"> 470 <div className="panel-head"> 471 <div> 472 <h2>Memory Fit</h2> 473 <p>{visible.length} recalls matched against nearby prompts</p> 474 </div> 475 <label className="switch-row"> 476 <input type="checkbox" checked={reviewOnly} onChange={(event) => onReviewOnlyChange(event.target.checked)} /> 477 review only 478 </label> 479 </div> 480 481 <div className="memory-table" role="table"> 482 <div className="memory-row memory-row-head" role="row"> 483 <span>time</span> 484 <span>fit</span> 485 <span>memory query</span> 486 <span>near prompt</span> 487 </div> 488 {visible.map((pair) => ( 489 <button 490 key={pair.memory.id} 491 type="button" 492 className={`memory-row ${selectedId === pair.memory.id ? "is-selected" : ""} issue-${pair.issue}`} 493 onClick={() => onSelect(pair)} 494 role="row" 495 > 496 <span>{shortTime(pair.memory.timestamp)}</span> 497 <span> 498 {pair.issue === "missing" ? "no prompt" : `${Math.round(pair.overlap * 100)}%`} 499 {pair.secondsApart != null ? <small>{Math.abs(pair.secondsApart)}s</small> : null} 500 </span> 501 <span>{pair.memory.queryPreview ?? "empty memory query"}</span> 502 <span>{pair.prompt?.lastUserMessage ?? "no matching prompt"}</span> 503 </button> 504 ))} 505 </div> 506 </section> 507 ) 508} 509 510function DetailPane({ detail }: { detail: DetailState }) { 511 if (detail.kind === "idle") { 512 return ( 513 <aside className="detail-pane"> 514 <h2>Turn Detail</h2> 515 <p>Click a bar in the chart or a response in the rail to inspect the turn.</p> 516 </aside> 517 ) 518 } 519 520 if (detail.kind === "loading") { 521 return ( 522 <aside className="detail-pane"> 523 <h2>{detail.label}</h2> 524 <p>Loading.</p> 525 </aside> 526 ) 527 } 528 529 if (detail.kind === "error") { 530 return ( 531 <aside className="detail-pane detail-error"> 532 <h2>Error</h2> 533 <p>{detail.text}</p> 534 </aside> 535 ) 536 } 537 538 if (detail.kind === "turn") { 539 const { turn } = detail 540 return ( 541 <aside className="detail-pane"> 542 <h2>Turn #{turn.id}</h2> 543 <dl className="detail-meta"> 544 {turn.model ? <div><dt>model</dt><dd>{turn.model}</dd></div> : null} 545 <div><dt>time</dt><dd>{timeLabel(turn.timestamp)}</dd></div> 546 {turn.usage ? ( 547 <> 548 <div><dt>prompt</dt><dd>{formatNumber(turn.usage.prompt_tokens)} tok</dd></div> 549 <div><dt>completion</dt><dd>{formatNumber(turn.usage.completion_tokens)} tok</dd></div> 550 </> 551 ) : null} 552 </dl> 553 554 <section className="detail-section"> 555 <h3>Prompt</h3> 556 <MarkdownBlock content={turn.promptText || "(no prompt)"} /> 557 </section> 558 559 {turn.toolTraces.length > 0 ? ( 560 <section className="detail-section"> 561 <h3>Tool Calls ({turn.toolTraces.length})</h3> 562 <div className="tool-trace-list"> 563 {turn.toolTraces.map((tool) => ( 564 <article key={tool.id} className="tool-trace"> 565 <details> 566 <summary> 567 <span>{tool.name}</span> 568 <small>{tool.id}</small> 569 </summary> 570 {tool.args ? <pre className="tool-args">{tool.args}</pre> : null} 571 {tool.result !== undefined ? ( 572 <div className="tool-result"> 573 <MarkdownBlock content={toolResultMarkdown(tool.result)} /> 574 </div> 575 ) : null} 576 </details> 577 </article> 578 ))} 579 </div> 580 </section> 581 ) : null} 582 583 {turn.responseText ? ( 584 <section className="detail-section"> 585 <h3>Response</h3> 586 <MarkdownBlock content={turn.responseText} /> 587 </section> 588 ) : null} 589 </aside> 590 ) 591 } 592 593 if (detail.kind === "metric") { 594 return ( 595 <aside className="detail-pane"> 596 <h2>Metric Detail</h2> 597 <pre>{JSON.stringify(detail.metric, null, 2)}</pre> 598 </aside> 599 ) 600 } 601 602 const promptText = lastUserMessage(detail.prompt?.messages) 603 604 return ( 605 <aside className="detail-pane"> 606 <h2>Recall #{detail.memory.id}</h2> 607 <dl className="detail-meta"> 608 <div> 609 <dt>time</dt> 610 <dd>{timeLabel(detail.memory.timestamp)}</dd> 611 </div> 612 <div> 613 <dt>chunks</dt> 614 <dd>{detail.memory.results.length}</dd> 615 </div> 616 </dl> 617 618 <section className="detail-section"> 619 <h3>Prompt</h3> 620 <MarkdownBlock content={promptText || detail.memory.query} /> 621 </section> 622 623 <section className="detail-section"> 624 <h3>Memory Query</h3> 625 <MarkdownBlock content={detail.memory.query} /> 626 </section> 627 628 <section className="detail-section"> 629 <h3>Retrieved Chunks</h3> 630 {detail.memory.results.map((result) => ( 631 <article key={result.chunkId} className="memory-hit"> 632 <div> 633 <strong>{result.title}</strong> 634 <span>{result.kind} / {result.source}</span> 635 </div> 636 <p>{result.preview}</p> 637 </article> 638 ))} 639 </section> 640 </aside> 641 ) 642} 643 644function BucketRail({ 645 metrics, 646 onOpenMetric, 647}: { 648 metrics: MetricsPage 649 onOpenMetric: (path: string) => void 650}) { 651 const rows: Array<{ label: string; count: number; items: Array<MetricItem | DiscordMetric> }> = [ 652 { label: "response", count: metrics.response.length, items: metrics.response.slice(0, 6) }, 653 { label: "summarization", count: metrics.summarization.length, items: metrics.summarization.slice(0, 6) }, 654 { label: "discord", count: metrics.discord.length, items: metrics.discord.slice(0, 6) }, 655 ] 656 657 return ( 658 <section className="bucket-rail" aria-label="raw metric buckets"> 659 {rows.map((bucket) => ( 660 <section key={bucket.label}> 661 <header> 662 <h3>{bucket.label}</h3> 663 <span>{bucket.count}</span> 664 </header> 665 <div className="bucket-list"> 666 {bucket.items.map((item) => ( 667 <button key={`${item.type}-${item.id}`} type="button" onClick={() => onOpenMetric(item.detailPath)}> 668 <span>{shortTime(item.timestamp)}</span> 669 <strong> 670 {item.type === "response" 671 ? item.responsePreview || `${item.model ?? "model"}` 672 : item.type === "summarization" 673 ? item.summaryPreview || item.method || "summary" 674 : item.type === "discord" 675 ? item.contentPreview || item.authorUsername || "discord" 676 : item.type} 677 </strong> 678 </button> 679 ))} 680 </div> 681 </section> 682 ))} 683 </section> 684 ) 685} 686 687export function MetricsWorkbench() { 688 const [metrics, setMetrics] = useState<MetricsPage | null>(null) 689 const [error, setError] = useState<string | null>(null) 690 const [loading, setLoading] = useState(true) 691 const [search, setSearch] = useState("") 692 const [query, setQuery] = useState("") 693 const [reviewOnly, setReviewOnly] = useState(false) 694 const [detail, setDetail] = useState<DetailState>({ kind: "idle" }) 695 const [live, setLive] = useState(true) 696 const [lastUpdated, setLastUpdated] = useState<string | null>(null) 697 698 const loadMetrics = useCallback((signal?: AbortSignal, options?: { silent?: boolean }) => { 699 if (!options?.silent) setLoading(true) 700 setError(null) 701 fetchJson<MetricsPageInput>(buildMetricsUrl(query), signal) 702 .then((page) => { 703 setMetrics(normalizeMetricsPage(page)) 704 setLastUpdated(new Date().toISOString()) 705 }) 706 .catch((err) => { 707 if (signal?.aborted) return 708 setError(err instanceof Error ? err.message : String(err)) 709 }) 710 .finally(() => { 711 if (!signal?.aborted) setLoading(false) 712 }) 713 }, [query]) 714 715 useEffect(() => { 716 const controller = new AbortController() 717 loadMetrics(controller.signal) 718 let interval: ReturnType<typeof setInterval> | undefined 719 let pollController: AbortController | null = null 720 721 if (live) { 722 interval = setInterval(() => { 723 pollController?.abort() 724 pollController = new AbortController() 725 loadMetrics(pollController.signal, { silent: true }) 726 }, METRICS_POLL_INTERVAL_MS) 727 } 728 729 return () => { 730 controller.abort() 731 pollController?.abort() 732 if (interval) clearInterval(interval) 733 } 734 }, [live, loadMetrics]) 735 736 const pairs = useMemo<MemoryPair[]>(() => { 737 if (!metrics) return [] 738 const prompts = [...metrics.response, ...metrics.prompt] 739 .filter((prompt) => prompt.lastUserMessage) 740 .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) 741 742 return metrics.memories.map((memory) => { 743 const memoryTime = new Date(memory.timestamp).getTime() 744 const prompt = prompts.find((item) => new Date(item.timestamp).getTime() >= memoryTime) ?? prompts[prompts.length - 1] 745 const promptTime = prompt ? new Date(prompt.timestamp).getTime() : Number.NaN 746 const secondsApart = prompt && Number.isFinite(promptTime) ? Math.round((promptTime - memoryTime) / 1000) : undefined 747 const overlap = overlapFor(memory.queryPreview, prompt?.lastUserMessage) 748 const issue = !prompt ? "missing" : overlap.score < 0.16 ? "loose" : "ok" 749 return { memory, prompt, secondsApart, overlap: overlap.score, shared: overlap.shared, issue } 750 }) 751 }, [metrics]) 752 753 const latestPromptText = useMemo(() => { 754 return metrics?.response[0]?.lastUserMessage ?? undefined 755 }, [metrics]) 756 757 const selectedMemoryId = detail.kind === "memory" ? detail.memory.id : undefined 758 759 const selectPair = useCallback(async (pair: MemoryPair) => { 760 setDetail({ kind: "loading", label: `Recall #${pair.memory.id}` }) 761 try { 762 const [memory, prompt] = await Promise.all([ 763 fetchJson<MemoryDetail>(pair.memory.detailPath), 764 pair.prompt ? fetchJson<PromptDetail>(pair.prompt.detailPath) : Promise.resolve(undefined), 765 ]) 766 setDetail({ kind: "memory", memory, prompt }) 767 } catch (err) { 768 setDetail({ kind: "error", text: err instanceof Error ? err.message : String(err) }) 769 } 770 }, []) 771 772 const openMetric = useCallback(async (path: string) => { 773 setDetail({ kind: "loading", label: "Turn detail" }) 774 try { 775 const raw = await fetchJson<Record<string, unknown>>(path) 776 if (raw?.type === "prompt_response") { 777 const msgs = Array.isArray(raw.messages) ? (raw.messages as Message[]) : [] 778 const response = raw.response as Message | undefined 779 const promptText = lastUserMessage(msgs) 780 const responseText = textContent(response?.content) || undefined 781 const fakeDetail: PromptDetail = { 782 id: raw.id as number, 783 type: "prompt_response", 784 timestamp: raw.timestamp as string, 785 messages: msgs, 786 response, 787 } 788 setDetail({ 789 kind: "turn", 790 turn: { 791 id: raw.id as number, 792 timestamp: raw.timestamp as string, 793 model: typeof raw.model === "string" ? raw.model : undefined, 794 usage: raw.usage as Usage | undefined, 795 promptText: promptText || "(no prompt)", 796 responseText, 797 toolTraces: extractToolTraces(fakeDetail), 798 }, 799 }) 800 } else { 801 setDetail({ kind: "metric", metric: raw }) 802 } 803 } catch (err) { 804 setDetail({ kind: "error", text: err instanceof Error ? err.message : String(err) }) 805 } 806 }, []) 807 808 return ( 809 <main className="metrics-app"> 810 <header className="metrics-header"> 811 <div> 812 <h1>Metrics</h1> 813 <p>Token pressure and memory retrieval checks.</p> 814 </div> 815 <form 816 className="metrics-search" 817 onSubmit={(event) => { 818 event.preventDefault() 819 setQuery(search.trim()) 820 }} 821 > 822 <input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="filter metrics" aria-label="filter metrics" /> 823 <button type="submit">filter</button> 824 <button type="button" onClick={() => loadMetrics()}> 825 refresh 826 </button> 827 <button type="button" onClick={() => { 828 setSearch("") 829 setQuery("") 830 }}> 831 clear 832 </button> 833 </form> 834 <div className="live-controls"> 835 <button type="button" className={live ? "is-live" : ""} onClick={() => setLive((value) => !value)}> 836 {live ? "live" : "paused"} 837 </button> 838 <span>{lastUpdated ? `updated ${shortTime(lastUpdated)}` : "not updated yet"}</span> 839 </div> 840 </header> 841 842 {error ? <p className="metrics-error">metrics unavailable: {error}</p> : null} 843 {loading && !metrics ? <p className="metrics-loading">loading metrics</p> : null} 844 845 {metrics ? ( 846 <div className="metrics-grid"> 847 <div className="metrics-main"> 848 <TokenTrace 849 usage={metrics.usage} 850 responses={metrics.response} 851 onOpenTurn={openMetric} 852 latestPromptText={latestPromptText} 853 /> 854 <MemoryReview 855 pairs={pairs} 856 selectedId={selectedMemoryId} 857 reviewOnly={reviewOnly} 858 onReviewOnlyChange={setReviewOnly} 859 onSelect={selectPair} 860 /> 861 </div> 862 <div className="metrics-side"> 863 <DetailPane detail={detail} /> 864 <BucketRail metrics={metrics} onOpenMetric={openMetric} /> 865 </div> 866 </div> 867 ) : null} 868 </main> 869 ) 870}