|
| 1 | +import React, { useState, useRef } from 'react'; |
| 2 | +import { Button, Tooltip } from '@nextui-org/react'; |
| 3 | +import type { CallFrame } from '@gptscript-ai/gptscript'; |
| 4 | +import { FaChevronUp, FaChevronDown } from 'react-icons/fa'; |
| 5 | +import { AiOutlineLoading3Quarters } from 'react-icons/ai'; |
| 6 | +import { JSONTree } from 'react-json-tree'; |
| 7 | + |
| 8 | +const CallFrames = ({ calls }: { calls: Record<string, CallFrame> | null }) => { |
| 9 | + // TODO Move this to before the final return so that we aren't disobeying the rules of hooks |
| 10 | + if (!calls) return null; |
| 11 | + |
| 12 | + // eslint-disable-next-line react-hooks/rules-of-hooks |
| 13 | + const logsContainerRef = useRef<HTMLDivElement>(null); |
| 14 | + // eslint-disable-next-line react-hooks/rules-of-hooks |
| 15 | + const [allOpen, setAllOpen] = useState(false); |
| 16 | + |
| 17 | + const EmptyLogs = () => { |
| 18 | + return ( |
| 19 | + <div className=""> |
| 20 | + <p>Waiting for the first event from GPTScript...</p> |
| 21 | + </div> |
| 22 | + ); |
| 23 | + }; |
| 24 | + |
| 25 | + // Build tree structure |
| 26 | + const buildTree = (calls: Record<string, CallFrame>) => { |
| 27 | + const tree: Record<string, any> = {}; |
| 28 | + const rootNodes: string[] = []; |
| 29 | + |
| 30 | + // Sort calls by start timestamp |
| 31 | + const sortedCalls = Object.entries(calls).sort( |
| 32 | + (a, b) => new Date(a[1].start).getTime() - new Date(b[1].start).getTime() |
| 33 | + ); |
| 34 | + |
| 35 | + sortedCalls.forEach(([id, call]) => { |
| 36 | + // Skip "GPTScript Gateway Provider" calls |
| 37 | + if (call.tool?.name === 'GPTScript Gateway Provider') { |
| 38 | + return; |
| 39 | + } |
| 40 | + |
| 41 | + const parentId = call.parentID || ''; |
| 42 | + if (!parentId) { |
| 43 | + rootNodes.push(id); |
| 44 | + } else { |
| 45 | + if (!tree[parentId]) { |
| 46 | + tree[parentId] = []; |
| 47 | + } |
| 48 | + tree[parentId].push(id); |
| 49 | + } |
| 50 | + }); |
| 51 | + |
| 52 | + return { tree, rootNodes }; |
| 53 | + }; |
| 54 | + |
| 55 | + // Render input (JSON or text) |
| 56 | + const renderInput = (input: any) => { |
| 57 | + if (typeof input === 'string') { |
| 58 | + try { |
| 59 | + const jsonInput = JSON.parse(input); |
| 60 | + return ( |
| 61 | + <JSONTree |
| 62 | + data={jsonInput} |
| 63 | + theme={{ |
| 64 | + base00: 'transparent', // Set the background to transparent |
| 65 | + }} |
| 66 | + invertTheme={false} |
| 67 | + shouldExpandNodeInitially={() => !allOpen} |
| 68 | + /> |
| 69 | + ); |
| 70 | + } catch (_) { |
| 71 | + // If parsing fails, render as text |
| 72 | + return <p className="ml-5 whitespace-pre-wrap">{input}</p>; |
| 73 | + } |
| 74 | + } |
| 75 | + // If input is already an object, render as JSON |
| 76 | + return ( |
| 77 | + <JSONTree |
| 78 | + data={input} |
| 79 | + theme={{ |
| 80 | + base00: 'transparent', // Set the background to transparent |
| 81 | + }} |
| 82 | + invertTheme={false} |
| 83 | + shouldExpandNodeInitially={() => !allOpen} |
| 84 | + /> |
| 85 | + ); |
| 86 | + }; |
| 87 | + |
| 88 | + // Helper function to truncate and stringify input |
| 89 | + const truncateInput = (input: any): string => { |
| 90 | + const stringified = |
| 91 | + typeof input === 'string' ? input : JSON.stringify(input); |
| 92 | + return stringified.length > 100 |
| 93 | + ? stringified.slice(0, 100) + '...' |
| 94 | + : stringified; |
| 95 | + }; |
| 96 | + |
| 97 | + // Render tree recursively |
| 98 | + const renderTree = (nodeId: string, depth: number = 0) => { |
| 99 | + const call = calls[nodeId]; |
| 100 | + const children = tree[nodeId] || []; |
| 101 | + |
| 102 | + return ( |
| 103 | + <div key={nodeId} style={{ marginLeft: `${depth * 20}px` }}> |
| 104 | + <details open={depth === 0 || allOpen}> |
| 105 | + <Summary call={call} /> |
| 106 | + <div className="ml-5"> |
| 107 | + {call.tool?.source?.location && |
| 108 | + call.tool.source.location !== 'inline' && ( |
| 109 | + <div className="mb-2 text-xs text-gray-400"> |
| 110 | + Source:{' '} |
| 111 | + <a |
| 112 | + href={call.tool.source.location} |
| 113 | + target="_blank" |
| 114 | + rel="noopener noreferrer" |
| 115 | + className="underline hover:text-gray-300" |
| 116 | + > |
| 117 | + {call.tool.source.location} |
| 118 | + </a> |
| 119 | + </div> |
| 120 | + )} |
| 121 | + <details open={allOpen}> |
| 122 | + <summary className="cursor-pointer"> |
| 123 | + Input Message: {truncateInput(call?.input)} |
| 124 | + </summary> |
| 125 | + <div className="ml-5">{renderInput(call?.input)}</div> |
| 126 | + </details> |
| 127 | + <details open={allOpen}> |
| 128 | + <summary className="cursor-pointer">Output Messages</summary> |
| 129 | + <ul className="ml-5 list-none"> |
| 130 | + {call.output && call.output.length > 0 ? ( |
| 131 | + call.output.flatMap((output, key) => { |
| 132 | + if (output.content) { |
| 133 | + return [ |
| 134 | + <li key={`content-${key}`} className="mb-2"> |
| 135 | + <details open={allOpen}> |
| 136 | + <summary className="cursor-pointer"> |
| 137 | + {truncateInput(output.content)} |
| 138 | + </summary> |
| 139 | + <p className="ml-5 whitespace-pre-wrap"> |
| 140 | + {output.content} |
| 141 | + </p> |
| 142 | + </details> |
| 143 | + </li>, |
| 144 | + ]; |
| 145 | + } else if (output.subCalls) { |
| 146 | + return Object.entries(output.subCalls).map( |
| 147 | + ([subCallKey, subCall]) => ( |
| 148 | + <li |
| 149 | + key={`subcall-${key}-${subCallKey}`} |
| 150 | + className="mb-2" |
| 151 | + > |
| 152 | + <details open={allOpen}> |
| 153 | + <summary className="cursor-pointer"> |
| 154 | + Tool call: {truncateInput(subCallKey)} |
| 155 | + </summary> |
| 156 | + <p className="ml-5 whitespace-pre-wrap"> |
| 157 | + Tool Call ID: {subCallKey} |
| 158 | + </p> |
| 159 | + <p className="ml-5 whitespace-pre-wrap"> |
| 160 | + Tool ID: {subCall.toolID} |
| 161 | + </p> |
| 162 | + <p className="ml-5 whitespace-pre-wrap"> |
| 163 | + Input: {subCall.input} |
| 164 | + </p> |
| 165 | + </details> |
| 166 | + </li> |
| 167 | + ) |
| 168 | + ); |
| 169 | + } |
| 170 | + return []; |
| 171 | + }) |
| 172 | + ) : ( |
| 173 | + <li> |
| 174 | + <p className="ml-5">No output available</p> |
| 175 | + </li> |
| 176 | + )} |
| 177 | + </ul> |
| 178 | + </details> |
| 179 | + {children.length > 0 && ( |
| 180 | + <details open={allOpen}> |
| 181 | + <summary className="cursor-pointer">Subcalls</summary> |
| 182 | + <div className="ml-5"> |
| 183 | + {children.map((childId: string) => |
| 184 | + renderTree(childId, depth + 1) |
| 185 | + )} |
| 186 | + </div> |
| 187 | + </details> |
| 188 | + )} |
| 189 | + {(call.llmRequest || call.llmResponse) && ( |
| 190 | + <details open={allOpen}> |
| 191 | + <summary className="cursor-pointer"> |
| 192 | + {call.llmRequest && 'messages' in call.llmRequest |
| 193 | + ? 'LLM Request & Response' |
| 194 | + : 'Tool Command and Output'} |
| 195 | + </summary> |
| 196 | + <div className="ml-5"> |
| 197 | + {call.llmRequest && ( |
| 198 | + <details open={allOpen}> |
| 199 | + <summary className="cursor-pointer"> |
| 200 | + {call.llmRequest && 'messages' in call.llmRequest |
| 201 | + ? 'Request' |
| 202 | + : 'Command'} |
| 203 | + </summary> |
| 204 | + <div className="ml-5">{renderInput(call.llmRequest)}</div> |
| 205 | + </details> |
| 206 | + )} |
| 207 | + {call.llmResponse && ( |
| 208 | + <details open={allOpen}> |
| 209 | + <summary className="cursor-pointer"> |
| 210 | + {call.llmRequest && 'messages' in call.llmRequest |
| 211 | + ? 'Response' |
| 212 | + : 'Output'} |
| 213 | + </summary> |
| 214 | + <div className="ml-5"> |
| 215 | + {renderInput(call.llmResponse)} |
| 216 | + </div> |
| 217 | + </details> |
| 218 | + )} |
| 219 | + </div> |
| 220 | + </details> |
| 221 | + )} |
| 222 | + {call.tool?.toolMapping && ( |
| 223 | + <details open={allOpen}> |
| 224 | + <summary className="cursor-pointer">Tools</summary> |
| 225 | + <div className="ml-5"> |
| 226 | + {renderToolMapping(call.tool.toolMapping)} |
| 227 | + </div> |
| 228 | + </details> |
| 229 | + )} |
| 230 | + {call.tool?.export && ( |
| 231 | + <details open={allOpen}> |
| 232 | + <summary className="cursor-pointer">Shared Tools</summary> |
| 233 | + <div className="ml-5">{renderExports(call.tool.export)}</div> |
| 234 | + </details> |
| 235 | + )} |
| 236 | + </div> |
| 237 | + </details> |
| 238 | + </div> |
| 239 | + ); |
| 240 | + }; |
| 241 | + |
| 242 | + const renderToolMapping = (toolMapping: Record<string, any>) => { |
| 243 | + return Object.entries(toolMapping).map(([key, value]) => ( |
| 244 | + <div key={key} className="mb-2"> |
| 245 | + {value.some((item: any) => item.toolID !== key) ? ( |
| 246 | + <> |
| 247 | + {key}: |
| 248 | + <ul className="list-none ml-5"> |
| 249 | + {value.map((item: any, index: number) => ( |
| 250 | + <li key={index} className="mb-2"> |
| 251 | + <p className="ml-5 whitespace-pre-wrap">{item.reference}</p> |
| 252 | + <p className="ml-5 whitespace-pre-wrap">{item.toolID}</p> |
| 253 | + </li> |
| 254 | + ))} |
| 255 | + </ul> |
| 256 | + </> |
| 257 | + ) : ( |
| 258 | + <p className="whitespace-pre-wrap">{key}</p> |
| 259 | + )} |
| 260 | + </div> |
| 261 | + )); |
| 262 | + }; |
| 263 | + |
| 264 | + const renderExports = (exports: string[]) => { |
| 265 | + return ( |
| 266 | + <ul className="list-none ml-5"> |
| 267 | + {exports.map((item, index) => ( |
| 268 | + <li key={index} className="mb-2"> |
| 269 | + <p className="whitespace-pre-wrap">{item}</p> |
| 270 | + </li> |
| 271 | + ))} |
| 272 | + </ul> |
| 273 | + ); |
| 274 | + }; |
| 275 | + |
| 276 | + const { tree, rootNodes } = buildTree(calls); |
| 277 | + |
| 278 | + return ( |
| 279 | + <div |
| 280 | + className="h-full overflow-scroll p-4 rounded-2xl border-2 shadow-lg border-primary border-lg bg-black text-white" |
| 281 | + ref={logsContainerRef} |
| 282 | + > |
| 283 | + <Tooltip content={allOpen ? 'Collapse all' : 'Expand all'} closeDelay={0}> |
| 284 | + <Button |
| 285 | + onPress={() => setAllOpen(!allOpen)} |
| 286 | + className="absolute right-8" |
| 287 | + isIconOnly |
| 288 | + radius="full" |
| 289 | + color="primary" |
| 290 | + > |
| 291 | + {allOpen ? <FaChevronUp /> : <FaChevronDown />} |
| 292 | + </Button> |
| 293 | + </Tooltip> |
| 294 | + {rootNodes.length > 0 ? ( |
| 295 | + rootNodes.map((rootId) => renderTree(rootId)) |
| 296 | + ) : ( |
| 297 | + <EmptyLogs /> |
| 298 | + )} |
| 299 | + </div> |
| 300 | + ); |
| 301 | +}; |
| 302 | + |
| 303 | +const Summary = ({ call }: { call: CallFrame }) => { |
| 304 | + const name = |
| 305 | + call.tool?.name || |
| 306 | + call.tool?.source?.repo || |
| 307 | + call.tool?.source?.location || |
| 308 | + 'main'; |
| 309 | + const category = call.toolCategory; |
| 310 | + |
| 311 | + const startTime = new Date(call.start).toLocaleTimeString(); |
| 312 | + const endTime = call.end |
| 313 | + ? new Date(call.end).toLocaleTimeString() |
| 314 | + : 'In progress'; |
| 315 | + const duration = call.end |
| 316 | + ? `${((new Date(call.end).getTime() - new Date(call.start).getTime()) / 1000).toFixed(2)}s` |
| 317 | + : 'N/A'; |
| 318 | + |
| 319 | + const timeInfo = `[ID: ${call.id}] [${startTime} - ${endTime}, ${duration}]`; |
| 320 | + |
| 321 | + let summaryContent; |
| 322 | + if (call.tool?.chat) { |
| 323 | + summaryContent = |
| 324 | + call.type !== 'callFinish' |
| 325 | + ? `Chat open with ${name}` |
| 326 | + : `Chatted with ${name}`; |
| 327 | + } else { |
| 328 | + summaryContent = |
| 329 | + call.type !== 'callFinish' |
| 330 | + ? category |
| 331 | + ? `Loading ${category} from ${name}` + '...' |
| 332 | + : `Running ${name}` |
| 333 | + : category |
| 334 | + ? `Loaded ${category} from ${name}` |
| 335 | + : `Ran ${name}`; |
| 336 | + } |
| 337 | + |
| 338 | + return ( |
| 339 | + <summary className="cursor-pointer"> |
| 340 | + {summaryContent} <span className="text-xs text-gray-400">{timeInfo}</span> |
| 341 | + {call?.type !== 'callFinish' && ( |
| 342 | + <AiOutlineLoading3Quarters className="ml-2 animate-spin inline" /> |
| 343 | + )} |
| 344 | + </summary> |
| 345 | + ); |
| 346 | +}; |
| 347 | + |
| 348 | +export default CallFrames; |
0 commit comments