From 8597fea02a33525891b6d8c26a843f50f175c5ed Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Mon, 16 Jun 2025 13:56:54 -0400 Subject: [PATCH 01/10] update the native log viewer to incorporate improvements from the ObservableHQ version --- frontend/src/logs/index.tsx | 258 +++++++++++++++++++++++++++--------- 1 file changed, 194 insertions(+), 64 deletions(-) diff --git a/frontend/src/logs/index.tsx b/frontend/src/logs/index.tsx index d8cc3986..fe48c267 100644 --- a/frontend/src/logs/index.tsx +++ b/frontend/src/logs/index.tsx @@ -1,5 +1,5 @@ import * as ReactDOM from 'react-dom'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { SERVER_URL } from '@/api'; @@ -11,78 +11,208 @@ interface Log { prompt: string; result: string; completion: string; - timestamp: string; + timestamp: number; // Use number for easier calculations + isBackend?: boolean; + generationType?: string; + isPlayground?: boolean; } -function App() { - const [logs, updateLogs] = useState([]); +// Collapsible component for prompt/result/completion +function Collapsible({ text, maxWidth = 200 }: { text: any, maxWidth?: number }) { + // If text is an object, render as JSON + let displayText: string; + if (typeof text === 'object' && text !== null) { + displayText = JSON.stringify(text, null, 2); + } else { + displayText = String(text ?? ''); + } + return ( +
+ {displayText.length > 100 ? displayText.slice(0, 100) + '…' : displayText} +
{displayText}
+
+ ); +} + +function secondsToHMS(seconds: number) { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + const pad = (x: number) => x.toString().padStart(2, '0'); + return `${pad(m)}m${pad(s)}`; +} + +function getInteractionColor(interaction: string) { + if (interaction.includes('Backend')) return '#BBE9FF'; + if (interaction.includes('Frontend')) return '#FFEADD'; + if (interaction.includes('Sensitivity')) return '#FFFED3'; + return '#FFFFFF'; +} + +function deduplicateLogs(logs: Log[]): Log[] { + const seen = new Set(); + return logs.filter(log => { + const key = `${log.timestamp}|${log.interaction}|${log.username}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function EntriesTable({ entries }: { entries: Log[] }) { + let lastTimestamp: number | null = null; + const annotatedEntries = entries.map((entry) => { + const newEntry = { ...entry } as any; + if (lastTimestamp !== null) { + newEntry.secondsSinceLast = (entry.timestamp - lastTimestamp) / 1000; + } + newEntry.secondsSinceStart = (entry.timestamp - entries[0].timestamp) / 1000; + lastTimestamp = entry.timestamp; + return newEntry; + }).reverse(); + + return ( + + + + + + + + + + + + {annotatedEntries.map((entry: any, i: number) => ( + + + + + + + + ))} + +
TimestampInteractionPromptResultCompletion
{secondsToHMS(entry.secondsSinceStart)}{entry.interaction}
+ ); +} - const [usernameFilter, updateUsernameFilter] = useState(''); +function App() { + const [logs, setLogs] = useState([]); + const [username, setUsername] = useState(''); + const [includePlayground, setIncludePlayground] = useState(false); + const [logSecret, setLogSecret] = useState(() => localStorage.getItem('logSecret') || ''); + const logsRef = useRef([]); + // Fetch logs via SSE useEffect(() => { - (async function() { - const streamer = new EventSource(`${SERVER_URL}/logs`); - - streamer.onmessage = (event) => { - const parsedLogs = JSON.parse(event.data); - - const newLogs = parsedLogs.map((log: {logs: Log[]}) => log.logs).flat(); - updateLogs([...logs, ...newLogs]); - }; - })(); - }, []); + if (!logSecret) return; + const streamer = new EventSource(`${SERVER_URL}/logs?secret=${encodeURIComponent(logSecret)}`); + streamer.onmessage = (event) => { + const parsedLogs = JSON.parse(event.data); + let newLogs: Log[] = parsedLogs.map((log: { logs: Log[] }) => log.logs).flat(); + // Convert timestamp to number (seconds) + newLogs = newLogs.map((x) => { + const ts = typeof x.timestamp === 'string' ? new Date(x.timestamp).getTime() / 1000 : x.timestamp; + const isBackend = x.interaction.endsWith('_Backend'); + return { + ...x, + timestamp: ts, + isBackend, + generationType: isBackend ? x.interaction.replace('_Backend', '') : undefined, + isPlayground: ((x.prompt || '').trim().startsWith('From the Wikipedia page on Calvin University')), + }; + }); + // Deduplicate + const allLogs = deduplicateLogs([...logsRef.current, ...newLogs]); + logsRef.current = allLogs; + setLogs(allLogs); + }; + return () => streamer.close(); + }, [logSecret]); + + // Username datalist + const availableUsernames = useMemo(() => { + return Array.from(new Set(logs.map(x => x.username))).sort(); + }, [logs]); + + // Filtered logs + const desiredEntries = useMemo(() => { + return logs.filter(x => + (!username || x.username === username) && + (includePlayground || !x.isPlayground) + ); + }, [logs, username, includePlayground]); + + // Generation type counts + const generationTypeCounts = useMemo(() => { + return Object.entries( + desiredEntries.reduce((acc: Record, x) => { + if (x.isBackend && x.generationType) { + acc[x.generationType] = (acc[x.generationType] || 0) + 1; + } + return acc; + }, {} as Record) + ).sort((a, b) => (b[1] as number) - (a[1] as number)).map(([k, v]) => ({ generationType: k, count: v as number })); + }, [desiredEntries]); return ( - <> -
- updateUsernameFilter(e.target.value) } /> +
+
+ +
- - { - ( - function() { - const groupedLogs = logs.reduce((acc: any, log) => { - if (!acc[log.username]) { - acc[log.username] = []; - } - - acc[log.username].push(log); - - return acc; - }, {}); - - // const filteredLogs = logs.filter((log) => log.username.includes(usernameFilter)); - - return ( - Object.keys(groupedLogs).filter( - username => username.includes(usernameFilter) - ).map( - username => ( -
-

{ username }

- -
    - { - groupedLogs[username].map((log: Log) => ( -
  • -

    Interaction: { log.interaction }

    -

    Prompt: { log.prompt }

    -

    Result: { log.result }

    -

    Completion: { log.completion }

    -

    Timestamp: { log.timestamp }

    -
  • - )) - } -
-
- ) - ) - ); - }() - ) - } - +
+ {desiredEntries.length} entries selected of {logs.length} total entries.
+ Last entry: {desiredEntries.length > 0 ? new Date(desiredEntries[desiredEntries.length - 1].timestamp * 1000).toLocaleString() : 'No entries'} +
+
+ Generation Type Counts: +
    + {generationTypeCounts.map(({ generationType, count }: { generationType: string, count: number }) => ( +
  • {generationType}: {count}
  • + ))} +
+
+
+ +
+ +
); } ReactDOM.render(, document.getElementById('container')); + +// Add styles for collapsible From fe9d8a28232f22756e5ba2b8aefc704f704f8bd8 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Mon, 16 Jun 2025 13:57:05 -0400 Subject: [PATCH 02/10] Reinstate the native log viewer endpoint --- frontend/webpack.config.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 7e32e376..18050a7d 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -163,6 +163,11 @@ module.exports = async (env, options) => { template: './src/editor/editor.html', chunks: ['editor', 'react'] }), + new HtmlWebpackPlugin({ + filename: 'logs.html', + template: './src/logs/logs.html', + chunks: ['logs', 'react'] + }), new HtmlWebpackPlugin({ filename: 'popup.html', template: './src/popup.html', From 037529bafa90f3dbb6b6825f6b5cd2d5ee8d7178 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Mon, 16 Jun 2025 13:57:42 -0400 Subject: [PATCH 03/10] Show an explanation for 422 validation errors. --- backend/server.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/server.py b/backend/server.py index 21649f90..9218d3fa 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1,3 +1,5 @@ +from fastapi.exception_handlers import request_validation_exception_handler +from fastapi.exceptions import RequestValidationError from pydantic import BaseModel, Field, ConfigDict, AfterValidator import os import json @@ -35,8 +37,6 @@ LOG_SECRET = os.getenv("LOG_SECRET", "").strip() print(f"Log secret: {LOG_SECRET!r}") -# Flag for whether to include any document text in the logs. -# In the future we'll enable this for developers and study participants who have consented. def should_log(username: str) -> bool: """ @@ -120,6 +120,13 @@ class ReflectionLog(Log): ) +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + print(f"The client sent invalid data!: {exc}") + return await request_validation_exception_handler(request, exc) + + + # Routes @app.post("/api/generation") async def generation(payload: GenerationRequestPayload, background_tasks: BackgroundTasks) -> nlp.GenerationResult: From b9157b120a7d1fd7ca9614e790b4d8ef261a2b27 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Mon, 16 Jun 2025 14:27:09 -0400 Subject: [PATCH 04/10] styling --- frontend/src/logs/index.tsx | 52 ++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/frontend/src/logs/index.tsx b/frontend/src/logs/index.tsx index fe48c267..6d04cb8a 100644 --- a/frontend/src/logs/index.tsx +++ b/frontend/src/logs/index.tsx @@ -3,8 +3,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { SERVER_URL } from '@/api'; -import classes from './styles.module.css'; - interface Log { username: string; interaction: string; @@ -27,9 +25,9 @@ function Collapsible({ text, maxWidth = 200 }: { text: any, maxWidth?: number }) displayText = String(text ?? ''); } return ( -
- {displayText.length > 100 ? displayText.slice(0, 100) + '…' : displayText} -
{displayText}
+
+ {displayText.length > 100 ? displayText.slice(0, 100) + '…' : displayText} +
{displayText}
); } @@ -71,24 +69,24 @@ function EntriesTable({ entries }: { entries: Log[] }) { }).reverse(); return ( - +
- - - - - + + + + + {annotatedEntries.map((entry: any, i: number) => ( - - - - - + + + + + ))} @@ -156,36 +154,36 @@ function App() { }, [desiredEntries]); return ( -
-
-
TimestampInteractionPromptResultCompletionTimestampInteractionPromptResultCompletion
{secondsToHMS(entry.secondsSinceStart)}{entry.interaction}{secondsToHMS(entry.secondsSinceStart)}{entry.interaction}