diff options
Diffstat (limited to 'packages/web/src/components/Share.tsx')
| -rw-r--r-- | packages/web/src/components/Share.tsx | 772 |
1 files changed, 772 insertions, 0 deletions
diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx new file mode 100644 index 000000000..ac75a3cf7 --- /dev/null +++ b/packages/web/src/components/Share.tsx @@ -0,0 +1,772 @@ +import { type JSX } from "solid-js" +import { + For, + Show, + Match, + Switch, + onMount, + onCleanup, + splitProps, + createMemo, + createEffect, + createSignal, +} from "solid-js" +import { DateTime } from "luxon" +import { + IconOpenAI, + IconGemini, + IconAnthropic, +} from "./icons/custom" +import { + IconCpuChip, + IconSparkles, + IconUserCircle, + IconChevronDown, + IconChevronRight, + IconPencilSquare, + IconWrenchScrewdriver, +} from "./icons" +import DiffView from "./DiffView" +import styles from "./share.module.css" +import { type UIMessage } from "ai" +import { createStore, reconcile } from "solid-js/store" + +type Status = "disconnected" | "connecting" | "connected" | "error" | "reconnecting" + + +type SessionMessage = UIMessage<{ + time: { + created: number + completed?: number + } + assistant?: { + modelID: string; + providerID: string; + cost: number; + tokens: { + input: number; + output: number; + reasoning: number; + }; + }; + sessionID: string + tool: Record<string, { + properties: Record<string, any> + time: { + start: number + end: number + } + }> +}> + +type SessionInfo = { + title: string + cost?: number +} + +function getFileType(path: string) { + return path.split('.').pop() +} + +// Converts `{a:{b:{c:1}}` to `[['a.b.c', 1]]` +function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> { + const entries: Array<[string, any]> = []; + + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key; + + if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) + ) { + entries.push(...flattenToolArgs(value, path)); + } + else { + entries.push([path, value]); + } + } + + return entries; +} + +function getStatusText(status: [Status, string?]): string { + switch (status[0]) { + case "connected": return "Connected" + case "connecting": return "Connecting..." + case "disconnected": return "Disconnected" + case "reconnecting": return "Reconnecting..." + case "error": return status[1] || "Error" + default: return "Unknown" + } +} + +function ProviderIcon(props: { provider: string, size?: number }) { + const size = props.size || 16 + return ( + <Switch fallback={ + <IconSparkles width={size} height={size} /> + }> + <Match when={props.provider === "openai"}> + <IconOpenAI width={size} height={size} /> + </Match> + <Match when={props.provider === "anthropic"}> + <IconAnthropic width={size} height={size} /> + </Match> + <Match when={props.provider === "gemini"}> + <IconGemini width={size} height={size} /> + </Match> + </Switch> + ) +} + +interface ResultsButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> { + results: boolean +} +function ResultsButton(props: ResultsButtonProps) { + const [local, rest] = splitProps(props, ["results"]) + return ( + <button + type="button" + data-element-button-text + data-element-button-more + {...rest} + > + <span> + {local.results ? "Hide results" : "Show results"} + </span> + <span data-button-icon> + <Show + when={local.results} + fallback={ + <IconChevronRight width={10} height={10} /> + } + > + <IconChevronDown width={10} height={10} /> + </Show> + </span> + </button> + ) +} + +interface TextPartProps extends JSX.HTMLAttributes<HTMLDivElement> { + text: string + expand?: boolean + highlight?: boolean +} +function TextPart(props: TextPartProps) { + const [local, rest] = splitProps(props, ["text", "expand", "highlight"]) + const [expanded, setExpanded] = createSignal(false) + const [overflowed, setOverflowed] = createSignal(false) + let preEl: HTMLPreElement | undefined + + function checkOverflow() { + if (preEl && !local.expand) { + setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1) + } + } + + onMount(() => { + checkOverflow() + window.addEventListener("resize", checkOverflow) + }) + + createEffect(() => { + local.text + setTimeout(checkOverflow, 0) + }) + + onCleanup(() => { + window.removeEventListener("resize", checkOverflow) + }) + + return ( + <div + data-element-message-text + data-highlight={local.highlight} + data-expanded={expanded() || local.expand === true} + {...rest} + > + <pre ref={el => (preEl = el)}>{local.text}</pre> + {overflowed() && + <button + type="button" + data-element-button-text + onClick={() => setExpanded(e => !e)} + > + {expanded() ? "Show less" : "Show more"} + </button> + } + </div> + ) +} + +function PartFooter(props: { time: number }) { + return ( + <span + data-part-footer + title={ + DateTime.fromMillis(props.time).toLocaleString( + DateTime.DATETIME_FULL_WITH_SECONDS + ) + } + > + {DateTime.fromMillis(props.time).toLocaleString(DateTime.TIME_WITH_SECONDS)} + </span> + ) +} + +export default function Share(props: { api: string }) { + let params = new URLSearchParams(document.location.search) + const id = params.get("id") + + const [store, setStore] = createStore<{ + info?: SessionInfo + messages: Record<string, SessionMessage> + }>({ + messages: {}, + }) + const messages = createMemo(() => Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id))) + const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected", "Disconnected"]) + + onMount(() => { + const apiUrl = props.api + + if (!id) { + setConnectionStatus(["error", "id not found"]) + return + } + + if (!apiUrl) { + console.error("API URL not found in environment variables") + setConnectionStatus(["error", "API URL not found"]) + return + } + + let reconnectTimer: number | undefined + let socket: WebSocket | null = null + + // Function to create and set up WebSocket with auto-reconnect + const setupWebSocket = () => { + // Close any existing connection + if (socket) { + socket.close() + } + + setConnectionStatus(["connecting"]) + + // Always use secure WebSocket protocol (wss) + const wsBaseUrl = apiUrl.replace(/^https?:\/\//, "wss://") + const wsUrl = `${wsBaseUrl}/share_poll?id=${id}` + console.log("Connecting to WebSocket URL:", wsUrl) + + // Create WebSocket connection + socket = new WebSocket(wsUrl) + + // Handle connection opening + socket.onopen = () => { + setConnectionStatus(["connected"]) + console.log("WebSocket connection established") + } + + // Handle incoming messages + socket.onmessage = (event) => { + console.log("WebSocket message received") + try { + const data = JSON.parse(event.data) + const [root, type, ...splits] = data.key.split("/") + if (root !== "session") return + if (type === "info") { + setStore("info", reconcile(data.content)) + return + } + if (type === "message") { + const [, messageID] = splits + setStore("messages", messageID, reconcile(data.content)) + } + } catch (error) { + console.error("Error parsing WebSocket message:", error) + } + } + + // Handle errors + socket.onerror = (error) => { + console.error("WebSocket error:", error) + setConnectionStatus(["error", "Connection failed"]) + } + + // Handle connection close and reconnection + socket.onclose = (event) => { + console.log(`WebSocket closed: ${event.code} ${event.reason}`) + setConnectionStatus(["reconnecting"]) + + // Try to reconnect after 2 seconds + clearTimeout(reconnectTimer) + reconnectTimer = window.setTimeout( + setupWebSocket, + 2000, + ) as unknown as number + } + } + + // Initial connection + setupWebSocket() + + // Clean up on component unmount + onCleanup(() => { + console.log("Cleaning up WebSocket connection") + if (socket) { + socket.close() + } + clearTimeout(reconnectTimer) + }) + }) + + const models = createMemo(() => { + const result: string[][] = [] + for (const msg of messages()) { + if (msg.role === "assistant" && msg.metadata?.assistant) { + result.push([msg.metadata.assistant.providerID, msg.metadata.assistant.modelID]) + } + } + return result + }) + + const metrics = createMemo(() => { + const result = { + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + } + } + for (const msg of messages()) { + const assistant = msg.metadata?.assistant + if (!assistant) continue + result.cost += assistant.cost + result.tokens.input += assistant.tokens.input + result.tokens.output += assistant.tokens.output + result.tokens.reasoning += assistant.tokens.reasoning + } + return result + }) + + return ( + <main class={`${styles.root} not-content`}> + <div class={styles.header}> + <div data-section="title"> + <h1>{store.info?.title}</h1> + <p> + <span data-status={connectionStatus()[0]}>●</span> + <span data-element-label>{getStatusText(connectionStatus())}</span> + </p> + </div> + <div data-section="row"> + <ul data-section="stats"> + <li> + <span data-element-label>Cost</span> + {metrics().cost !== undefined ? + <span>${metrics().cost.toFixed(2)}</span> + : + <span data-placeholder>—</span> + } + </li> + <li> + <span data-element-label>Input Tokens</span> + {metrics().tokens.input ? + <span>{metrics().tokens.input}</span> + : + <span data-placeholder>—</span> + } + </li> + <li> + <span data-element-label>Output Tokens</span> + {metrics().tokens.output ? + <span>{metrics().tokens.output}</span> + : + <span data-placeholder>—</span> + } + </li> + <li> + <span data-element-label>Reasoning Tokens</span> + {metrics().tokens.reasoning ? + <span>{metrics().tokens.reasoning}</span> + : + <span data-placeholder>—</span> + } + </li> + </ul> + <ul data-section="stats" data-section-models> + {models().length > 0 ? + <For each={Array.from(models())}> + {([provider, model]) => ( + <li> + <div data-stat-model-icon title={provider}> + <ProviderIcon provider={provider} /> + </div> + <span data-stat-model>{model}</span> + </li> + )} + </For> + : + <li> + <span data-element-label>Models</span> + <span data-placeholder>—</span> + </li> + } + </ul> + <div data-section="date"> + {messages().length > 0 && messages()[0].metadata?.time.created ? + <span title={ + DateTime.fromMillis( + messages()[0].metadata?.time.created || 0 + ).toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS) + }> + {DateTime.fromMillis( + messages()[0].metadata?.time.created || 0 + ).toLocaleString(DateTime.DATE_MED)} + </span> + : + <span data-element-label data-placeholder>Started at —</span> + } + </div> + </div> + </div> + + <div> + <Show + when={messages().length > 0} + fallback={<p>Waiting for messages...</p>} + > + <div class={styles.parts}> + <For each={messages()}> + {(msg, msgIndex) => ( + <For each={msg.parts}> + {(part, partIndex) => { + if (part.type === "step-start" && (partIndex() > 0 || !msg.metadata?.assistant)) return null + + const [results, showResults] = createSignal(false) + const isLastPart = createMemo(() => + (messages().length === msgIndex() + 1) + && (msg.parts.length === partIndex() + 1) + ) + const time = msg.metadata?.time.completed + || msg.metadata?.time.created + || 0 + return ( + <Switch> + { /* User text */} + <Match when={ + msg.role === "user" && part.type === "text" && part + }> + {part => + <div + data-section="part" + data-part-type="user-text" + > + <div data-section="decoration"> + <div> + <IconUserCircle width={18} height={18} /> + </div> + <div></div> + </div> + <div data-section="content"> + <TextPart + highlight + text={part().text} + expand={isLastPart()} + /> + <PartFooter time={time} /> + </div> + </div> + } + </Match> + { /* AI text */} + <Match when={ + msg.role === "assistant" + && part.type === "text" + && part + }> + {part => + <div + data-section="part" + data-part-type="ai-text" + > + <div data-section="decoration"> + <div><IconSparkles width={18} height={18} /></div> + <div></div> + </div> + <div data-section="content"> + <TextPart + text={part().text} + expand={isLastPart()} + /> + <PartFooter time={time} /> + </div> + </div> + } + </Match> + { /* AI model */} + <Match when={ + msg.role === "assistant" + && part.type === "step-start" + && msg.metadata?.assistant + }> + {assistant => + <div + data-section="part" + data-part-type="ai-model" + > + <div data-section="decoration"> + <div> + <ProviderIcon + size={18} + provider={assistant().providerID} + /> + </div> + <div></div> + </div> + <div data-section="content"> + <div data-part-tool-body> + <span + data-size="md" + data-part-title + data-element-label + > + {assistant().providerID} + </span> + <span data-part-model> + {assistant().modelID} + </span> + </div> + </div> + </div> + } + </Match> + { /* System text */} + <Match when={ + msg.role === "system" + && part.type === "text" + && part + }> + {part => + <div + data-section="part" + data-part-type="system-text" + > + <div data-section="decoration"> + <div> + <IconCpuChip width={18} height={18} /> + </div> + <div></div> + </div> + <div data-section="content"> + <div data-part-tool-body> + <span data-element-label data-part-title> + System + </span> + <TextPart + data-size="sm" + text={part().text} + data-color="dimmed" + /> + </div> + <PartFooter time={time} /> + </div> + </div> + } + </Match> + { /* Edit tool */} + <Match when={ + msg.role === "assistant" + && part.type === "tool-invocation" + && part.toolInvocation.toolName === "edit" + && part + }> + {part => { + const args = part().toolInvocation.args + const filePath = args.filePath + return ( + <div + data-section="part" + data-part-type="tool-edit" + > + <div data-section="decoration"> + <div> + <IconPencilSquare width={18} height={18} /> + </div> + <div></div> + </div> + <div data-section="content"> + <div data-part-tool-body> + <span data-part-title data-size="md"> + Edit {filePath} + </span> + <div data-part-tool-edit> + <DiffView + class={styles["code-block"]} + oldCode={args.oldString} + newCode={args.newString} + lang={getFileType(filePath)} + /> + </div> + </div> + <PartFooter time={time} /> + </div> + </div> + ) + }} + </Match> + { /* Tool call */} + <Match when={ + msg.role === "assistant" + && part.type === "tool-invocation" + && part + }> + {part => + <div + data-section="part" + data-part-type="tool-fallback" + > + <div data-section="decoration"> + <div> + <IconWrenchScrewdriver width={18} height={18} /> + </div> + <div></div> + </div> + <div data-section="content"> + <div data-part-tool-body> + <span data-part-title data-size="md"> + {part().toolInvocation.toolName} + </span> + <div data-part-tool-args> + <For each={ + flattenToolArgs(part().toolInvocation.args) + }> + {([name, value]) => + <> + <div></div> + <div>{name}</div> + <div>{value}</div> + </> + } + </For> + </div> + <Switch> + <Match when={ + part().toolInvocation.state === "result" + && part().toolInvocation.result + }> + <div data-part-tool-result> + <ResultsButton + results={results()} + onClick={() => showResults(e => !e)} + /> + <Show when={results()}> + <TextPart + expand + data-size="sm" + data-color="dimmed" + text={part().toolInvocation.result} + /> + </Show> + </div> + </Match> + <Match when={ + part().toolInvocation.state === "call" + }> + <TextPart + data-size="sm" + data-color="dimmed" + text="Calling..." + /> + </Match> + </Switch> + </div> + <PartFooter time={time} /> + </div> + </div> + } + </Match> + { /* Fallback */} + <Match when={true}> + <div + data-section="part" + data-part-type="fallback" + > + <div data-section="decoration"> + <div> + <Switch fallback={ + <IconWrenchScrewdriver width={16} height={16} /> + }> + <Match when={msg.role === "assistant" && part.type !== "tool-invocation"}> + <IconSparkles width={18} height={18} /> + </Match> + <Match when={msg.role === "system"}> + <IconCpuChip width={18} height={18} /> + </Match> + <Match when={msg.role === "user"}> + <IconUserCircle width={18} height={18} /> + </Match> + </Switch> + </div> + <div></div> + </div> + <div data-section="content"> + <div data-part-tool-body> + <span data-element-label data-part-title> + {part.type} + </span> + <TextPart text={JSON.stringify(part, null, 2)} /> + </div> + <PartFooter time={time} /> + </div> + </div> + </Match> + </Switch> + ) + }} + </For> + )} + </For> + </div> + </Show> + </div> + + <div style={{ margin: "2rem 0" }}> + <div + style={{ + border: "1px solid #ccc", + padding: "1rem", + "overflow-y": "auto", + }} + > + <Show + when={messages().length > 0} + fallback={<p>Waiting for messages...</p>} + > + <ul style={{ "list-style-type": "none", padding: 0 }}> + <For each={messages()}> + {(msg) => ( + <li + style={{ + padding: "0.75rem", + margin: "0.75rem 0", + "box-shadow": "0 1px 3px rgba(0,0,0,0.1)", + }} + > + <div> + <strong>Key:</strong> {msg.id} + </div> + <pre>{JSON.stringify(msg, null, 2)}</pre> + </li> + )} + </For> + </ul> + </Show> + </div> + </div> + </main > + ) +} |
