| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909 |
- 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,
- IconCommandLine,
- IconChevronRight,
- IconPencilSquare,
- IconWrenchScrewdriver,
- } from "./icons"
- import DiffView from "./DiffView"
- import CodeBlock from "./CodeBlock"
- 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,
- {
- [key: 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>
- {((!local.expand && overflowed()) || expanded()) && (
- <button
- type="button"
- data-element-button-text
- onClick={() => setExpanded((e) => !e)}
- >
- {expanded() ? "Show less" : "Show more"}
- </button>
- )}
- </div>
- )
- }
- interface TerminalPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
- text: string
- expand?: boolean
- }
- function TerminalPart(props: TerminalPartProps) {
- const [local, rest] = splitProps(props, ["text", "expand"])
- const [expanded, setExpanded] = createSignal(false)
- const [overflowed, setOverflowed] = createSignal(false)
- let preEl: HTMLElement | undefined
- function checkOverflow() {
- if (!preEl) return
- const code = preEl.getElementsByTagName("code")[0]
- if (code && !local.expand) {
- console.log(preEl.clientHeight, code.offsetHeight)
- setOverflowed(preEl.clientHeight < code.offsetHeight)
- }
- }
- onMount(() => {
- window.addEventListener("resize", checkOverflow)
- })
- onCleanup(() => {
- window.removeEventListener("resize", checkOverflow)
- })
- return (
- <div
- data-element-message-terminal
- data-expanded={expanded() || local.expand === true}
- {...rest}
- >
- <div data-section="body">
- <div data-section="header"></div>
- <div data-section="content">
- <CodeBlock
- lang="ansi"
- onRendered={checkOverflow}
- ref={(el) => (preEl = el)}
- code={`\x1b[90m>\x1b[0m ${local.text}`}
- />
- </div>
- </div>
- {((!local.expand && overflowed()) || expanded()) && (
- <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 title="Message">
- <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 title="AI response">
- <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 title="System message">
- <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 === "opencode_edit" &&
- part
- }
- >
- {(part) => {
- const metadata = createMemo(() => msg.metadata?.tool[part().toolInvocation.toolCallId])
- const args = part().toolInvocation.args
- const filePath = args.filePath
- return (
- <div
- data-section="part"
- data-part-type="tool-edit"
- >
- <div data-section="decoration">
- <div title="Edit file">
- <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">
- <span data-element-label>Edit</span>
- <b>{filePath}</b>
- </span>
- <div data-part-tool-edit>
- <DiffView
- class={styles["diff-code-block"]}
- changes={metadata()?.changes || []}
- lang={getFileType(filePath)}
- />
- </div>
- </div>
- <PartFooter time={time} />
- </div>
- </div>
- )
- }}
- </Match>
- {/* Bash tool */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName === "opencode_bash" &&
- part
- }
- >
- {(part) => {
- const id = part().toolInvocation.toolCallId
- const command = part().toolInvocation.args.command
- const stdout = msg.metadata?.tool[id]?.stdout
- const result = stdout || (part().toolInvocation.state === "result" && part().toolInvocation.result)
- return (
- <div
- data-section="part"
- data-part-type="tool-edit"
- >
- <div data-section="decoration">
- <div title="Bash command">
- <IconCommandLine width={18} height={18} />
- </div>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <TerminalPart
- data-size="sm"
- text={command + (result ? `\n${result}` : "")}
- />
- </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 title="Tool call">
- <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>
- )
- }
|