part.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783
  1. import map from "lang-map"
  2. import { DateTime } from "luxon"
  3. import { For, Show, Match, Switch, type JSX, createMemo, createSignal, type ParentProps } from "solid-js"
  4. import {
  5. IconHashtag,
  6. IconSparkles,
  7. IconGlobeAlt,
  8. IconDocument,
  9. IconPaperClip,
  10. IconQueueList,
  11. IconUserCircle,
  12. IconCommandLine,
  13. IconCheckCircle,
  14. IconChevronDown,
  15. IconChevronRight,
  16. IconDocumentPlus,
  17. IconPencilSquare,
  18. IconRectangleStack,
  19. IconMagnifyingGlass,
  20. IconDocumentMagnifyingGlass,
  21. } from "../icons"
  22. import { IconMeta, IconRobot, IconOpenAI, IconGemini, IconAnthropic, IconBrain } from "../icons/custom"
  23. import { ContentCode } from "./content-code"
  24. import { ContentDiff } from "./content-diff"
  25. import { ContentText } from "./content-text"
  26. import { ContentBash } from "./content-bash"
  27. import { ContentError } from "./content-error"
  28. import { formatDuration } from "../share/common"
  29. import { ContentMarkdown } from "./content-markdown"
  30. import type { MessageV2 } from "opencode/session/message-v2"
  31. import type { Diagnostic } from "vscode-languageserver-types"
  32. import styles from "./part.module.css"
  33. const MIN_DURATION = 2000
  34. export interface PartProps {
  35. index: number
  36. message: MessageV2.Info
  37. part: MessageV2.Part
  38. last: boolean
  39. }
  40. export function Part(props: PartProps) {
  41. const [copied, setCopied] = createSignal(false)
  42. const id = createMemo(() => props.message.id + "-" + props.index)
  43. return (
  44. <div
  45. class={styles.root}
  46. id={id()}
  47. data-component="part"
  48. data-type={props.part.type}
  49. data-role={props.message.role}
  50. data-copied={copied() ? true : undefined}
  51. >
  52. <div data-component="decoration">
  53. <div data-slot="anchor" title="Link to this message">
  54. <a
  55. href={`#${id()}`}
  56. onClick={(e) => {
  57. e.preventDefault()
  58. const anchor = e.currentTarget
  59. const hash = anchor.getAttribute("href") || ""
  60. const { origin, pathname, search } = window.location
  61. navigator.clipboard
  62. .writeText(`${origin}${pathname}${search}${hash}`)
  63. .catch((err) => console.error("Copy failed", err))
  64. setCopied(true)
  65. setTimeout(() => setCopied(false), 3000)
  66. }}
  67. >
  68. <Switch>
  69. <Match when={props.message.role === "user" && props.part.type === "text"}>
  70. <IconUserCircle width={18} height={18} />
  71. </Match>
  72. <Match when={props.message.role === "user" && props.part.type === "file"}>
  73. <IconPaperClip width={18} height={18} />
  74. </Match>
  75. <Match
  76. when={props.part.type === "step-start" && props.message.role === "assistant" && props.message.modelID}
  77. >
  78. {(model) => <ProviderIcon model={model()} size={18} />}
  79. </Match>
  80. <Match when={props.part.type === "reasoning" && props.message.role === "assistant"}>
  81. <IconBrain width={18} height={18} />
  82. </Match>
  83. <Match when={props.part.type === "tool" && props.part.tool === "todowrite"}>
  84. <IconQueueList width={18} height={18} />
  85. </Match>
  86. <Match when={props.part.type === "tool" && props.part.tool === "todoread"}>
  87. <IconQueueList width={18} height={18} />
  88. </Match>
  89. <Match when={props.part.type === "tool" && props.part.tool === "bash"}>
  90. <IconCommandLine width={18} height={18} />
  91. </Match>
  92. <Match when={props.part.type === "tool" && props.part.tool === "edit"}>
  93. <IconPencilSquare width={18} height={18} />
  94. </Match>
  95. <Match when={props.part.type === "tool" && props.part.tool === "write"}>
  96. <IconDocumentPlus width={18} height={18} />
  97. </Match>
  98. <Match when={props.part.type === "tool" && props.part.tool === "read"}>
  99. <IconDocument width={18} height={18} />
  100. </Match>
  101. <Match when={props.part.type === "tool" && props.part.tool === "grep"}>
  102. <IconDocumentMagnifyingGlass width={18} height={18} />
  103. </Match>
  104. <Match when={props.part.type === "tool" && props.part.tool === "list"}>
  105. <IconRectangleStack width={18} height={18} />
  106. </Match>
  107. <Match when={props.part.type === "tool" && props.part.tool === "glob"}>
  108. <IconMagnifyingGlass width={18} height={18} />
  109. </Match>
  110. <Match when={props.part.type === "tool" && props.part.tool === "webfetch"}>
  111. <IconGlobeAlt width={18} height={18} />
  112. </Match>
  113. <Match when={props.part.type === "tool" && props.part.tool === "task"}>
  114. <IconRobot width={18} height={18} />
  115. </Match>
  116. <Match when={true}>
  117. <IconSparkles width={18} height={18} />
  118. </Match>
  119. </Switch>
  120. <IconHashtag width={18} height={18} />
  121. <IconCheckCircle width={18} height={18} />
  122. </a>
  123. <span data-slot="tooltip">Copied!</span>
  124. </div>
  125. <div data-slot="bar"></div>
  126. </div>
  127. <div data-component="content">
  128. {props.message.role === "user" && props.part.type === "text" && (
  129. <div data-component="user-text">
  130. <ContentText text={props.part.text} expand={props.last} />
  131. </div>
  132. )}
  133. {props.message.role === "assistant" && props.part.type === "text" && (
  134. <div data-component="assistant-text">
  135. <div data-component="assistant-text-markdown">
  136. <ContentMarkdown expand={props.last} text={props.part.text} />
  137. </div>
  138. {props.last && props.message.role === "assistant" && props.message.time.completed && (
  139. <Footer
  140. title={DateTime.fromMillis(props.message.time.completed).toLocaleString(
  141. DateTime.DATETIME_FULL_WITH_SECONDS,
  142. )}
  143. >
  144. {DateTime.fromMillis(props.message.time.completed || props.message.time.created).toLocaleString(
  145. DateTime.DATETIME_MED,
  146. )}
  147. {` | ${props.message.modelID}`}
  148. {props.message.mode && (
  149. <span style={{ "font-weight": "bold", color: "var(--sl-color-accent)" }}>
  150. {` | ${props.message.mode}`}
  151. </span>
  152. )}
  153. </Footer>
  154. )}
  155. </div>
  156. )}
  157. {props.message.role === "assistant" && props.part.type === "reasoning" && (
  158. <div data-component="assistant-reasoning">
  159. <div data-component="assistant-reasoning-markdown">
  160. <ContentMarkdown expand={props.last} text={props.part.text || "Thinking..."} />
  161. </div>
  162. </div>
  163. )}
  164. {props.message.role === "user" && props.part.type === "file" && (
  165. <div data-component="attachment">
  166. <div data-slot="copy">Attachment</div>
  167. <div data-slot="filename">{props.part.filename}</div>
  168. </div>
  169. )}
  170. {props.part.type === "step-start" && props.message.role === "assistant" && (
  171. <div data-component="step-start">
  172. <div data-slot="provider">{props.message.providerID}</div>
  173. <div data-slot="model">
  174. {DateTime.fromMillis(props.message.time.completed || props.message.time.created).toLocaleString(
  175. DateTime.DATETIME_MED,
  176. )}
  177. {` | ${props.message.modelID}`}
  178. {props.message.mode && (
  179. <span style={{ "font-weight": "bold", color: "var(--sl-color-accent)" }}>
  180. {` | ${props.message.mode}`}
  181. </span>
  182. )}
  183. </div>
  184. </div>
  185. )}
  186. {props.part.type === "tool" && props.part.state.status === "error" && (
  187. <div data-component="tool" data-tool="error">
  188. <ContentError>{formatErrorString(props.part.state.error)}</ContentError>
  189. <Spacer />
  190. </div>
  191. )}
  192. {props.part.type === "tool" &&
  193. props.part.state.status === "completed" &&
  194. props.message.role === "assistant" && (
  195. <>
  196. <div data-component="tool" data-tool={props.part.tool}>
  197. <Switch>
  198. <Match when={props.part.tool === "grep"}>
  199. <GrepTool
  200. message={props.message}
  201. id={props.part.id}
  202. tool={props.part.tool}
  203. state={props.part.state}
  204. />
  205. </Match>
  206. <Match when={props.part.tool === "glob"}>
  207. <GlobTool
  208. message={props.message}
  209. id={props.part.id}
  210. tool={props.part.tool}
  211. state={props.part.state}
  212. />
  213. </Match>
  214. <Match when={props.part.tool === "list"}>
  215. <ListTool
  216. message={props.message}
  217. id={props.part.id}
  218. tool={props.part.tool}
  219. state={props.part.state}
  220. />
  221. </Match>
  222. <Match when={props.part.tool === "read"}>
  223. <ReadTool
  224. message={props.message}
  225. id={props.part.id}
  226. tool={props.part.tool}
  227. state={props.part.state}
  228. />
  229. </Match>
  230. <Match when={props.part.tool === "write"}>
  231. <WriteTool
  232. message={props.message}
  233. id={props.part.id}
  234. tool={props.part.tool}
  235. state={props.part.state}
  236. />
  237. </Match>
  238. <Match when={props.part.tool === "edit"}>
  239. <EditTool
  240. message={props.message}
  241. id={props.part.id}
  242. tool={props.part.tool}
  243. state={props.part.state}
  244. />
  245. </Match>
  246. <Match when={props.part.tool === "bash"}>
  247. <BashTool
  248. id={props.part.id}
  249. tool={props.part.tool}
  250. state={props.part.state}
  251. message={props.message}
  252. />
  253. </Match>
  254. <Match when={props.part.tool === "todowrite"}>
  255. <TodoWriteTool
  256. message={props.message}
  257. id={props.part.id}
  258. tool={props.part.tool}
  259. state={props.part.state}
  260. />
  261. </Match>
  262. <Match when={props.part.tool === "webfetch"}>
  263. <WebFetchTool
  264. message={props.message}
  265. id={props.part.id}
  266. tool={props.part.tool}
  267. state={props.part.state}
  268. />
  269. </Match>
  270. <Match when={props.part.tool === "task"}>
  271. <TaskTool
  272. id={props.part.id}
  273. tool={props.part.tool}
  274. message={props.message}
  275. state={props.part.state}
  276. />
  277. </Match>
  278. <Match when={true}>
  279. <FallbackTool
  280. message={props.message}
  281. id={props.part.id}
  282. tool={props.part.tool}
  283. state={props.part.state}
  284. />
  285. </Match>
  286. </Switch>
  287. </div>
  288. <ToolFooter
  289. time={DateTime.fromMillis(props.part.state.time.end)
  290. .diff(DateTime.fromMillis(props.part.state.time.start))
  291. .toMillis()}
  292. />
  293. </>
  294. )}
  295. </div>
  296. </div>
  297. )
  298. }
  299. type ToolProps = {
  300. id: MessageV2.ToolPart["id"]
  301. tool: MessageV2.ToolPart["tool"]
  302. state: MessageV2.ToolStateCompleted
  303. message: MessageV2.Assistant
  304. isLastPart?: boolean
  305. }
  306. interface Todo {
  307. id: string
  308. content: string
  309. status: "pending" | "in_progress" | "completed"
  310. priority: "low" | "medium" | "high"
  311. }
  312. function stripWorkingDirectory(filePath?: string, workingDir?: string) {
  313. if (filePath === undefined || workingDir === undefined) return filePath
  314. const prefix = workingDir.endsWith("/") ? workingDir : workingDir + "/"
  315. if (filePath === workingDir) {
  316. return ""
  317. }
  318. if (filePath.startsWith(prefix)) {
  319. return filePath.slice(prefix.length)
  320. }
  321. return filePath
  322. }
  323. function getShikiLang(filename: string) {
  324. const ext = filename.split(".").pop()?.toLowerCase() ?? ""
  325. const langs = map.languages(ext)
  326. const type = langs?.[0]?.toLowerCase()
  327. const overrides: Record<string, string> = {
  328. conf: "shellscript",
  329. }
  330. return type ? (overrides[type] ?? type) : "plaintext"
  331. }
  332. function getDiagnostics(diagnosticsByFile: Record<string, Diagnostic[]>, currentFile: string): JSX.Element[] {
  333. const result: JSX.Element[] = []
  334. if (diagnosticsByFile === undefined || diagnosticsByFile[currentFile] === undefined) return result
  335. for (const diags of Object.values(diagnosticsByFile)) {
  336. for (const d of diags) {
  337. if (d.severity !== 1) continue
  338. const line = d.range.start.line + 1
  339. const column = d.range.start.character + 1
  340. result.push(
  341. <pre>
  342. <span data-color="red" data-marker="label">
  343. Error
  344. </span>
  345. <span data-color="dimmed" data-separator>
  346. [{line}:{column}]
  347. </span>
  348. <span>{d.message}</span>
  349. </pre>,
  350. )
  351. }
  352. }
  353. return result
  354. }
  355. function formatErrorString(error: string): JSX.Element {
  356. const errorMarker = "Error: "
  357. const startsWithError = error.startsWith(errorMarker)
  358. return startsWithError ? (
  359. <pre>
  360. <span data-color="red" data-marker="label" data-separator>
  361. Error
  362. </span>
  363. <span>{error.slice(errorMarker.length)}</span>
  364. </pre>
  365. ) : (
  366. <pre>
  367. <span data-color="dimmed">{error}</span>
  368. </pre>
  369. )
  370. }
  371. export function TodoWriteTool(props: ToolProps) {
  372. const priority: Record<Todo["status"], number> = {
  373. in_progress: 0,
  374. pending: 1,
  375. completed: 2,
  376. }
  377. const todos = createMemo(() =>
  378. ((props.state.input?.todos ?? []) as Todo[]).slice().sort((a, b) => priority[a.status] - priority[b.status]),
  379. )
  380. const starting = () => todos().every((t: Todo) => t.status === "pending")
  381. const finished = () => todos().every((t: Todo) => t.status === "completed")
  382. return (
  383. <>
  384. <div data-component="tool-title">
  385. <span data-slot="name">
  386. <Switch fallback="Updating plan">
  387. <Match when={starting()}>Creating plan</Match>
  388. <Match when={finished()}>Completing plan</Match>
  389. </Switch>
  390. </span>
  391. </div>
  392. <Show when={todos().length > 0}>
  393. <ul data-component="todos">
  394. <For each={todos()}>
  395. {(todo) => (
  396. <li data-slot="item" data-status={todo.status}>
  397. <span></span>
  398. {todo.content}
  399. </li>
  400. )}
  401. </For>
  402. </ul>
  403. </Show>
  404. </>
  405. )
  406. }
  407. export function GrepTool(props: ToolProps) {
  408. return (
  409. <>
  410. <div data-component="tool-title">
  411. <span data-slot="name">Grep</span>
  412. <span data-slot="target">&ldquo;{props.state.input.pattern}&rdquo;</span>
  413. </div>
  414. <div data-component="tool-result">
  415. <Switch>
  416. <Match when={props.state.metadata?.matches && props.state.metadata?.matches > 0}>
  417. <ResultsButton
  418. showCopy={props.state.metadata?.matches === 1 ? "1 match" : `${props.state.metadata?.matches} matches`}
  419. >
  420. <ContentText expand compact text={props.state.output} />
  421. </ResultsButton>
  422. </Match>
  423. <Match when={props.state.output}>
  424. <ContentText expand compact text={props.state.output} data-size="sm" data-color="dimmed" />
  425. </Match>
  426. </Switch>
  427. </div>
  428. </>
  429. )
  430. }
  431. export function ListTool(props: ToolProps) {
  432. const path = createMemo(() =>
  433. props.state.input?.path !== props.message.path.cwd
  434. ? stripWorkingDirectory(props.state.input?.path, props.message.path.cwd)
  435. : props.state.input?.path,
  436. )
  437. return (
  438. <>
  439. <div data-component="tool-title">
  440. <span data-slot="name">LS</span>
  441. <span data-slot="target" title={props.state.input?.path}>
  442. {path()}
  443. </span>
  444. </div>
  445. <div data-component="tool-result">
  446. <Switch>
  447. <Match when={props.state.output}>
  448. <ResultsButton>
  449. <ContentText expand compact text={props.state.output} />
  450. </ResultsButton>
  451. </Match>
  452. </Switch>
  453. </div>
  454. </>
  455. )
  456. }
  457. export function WebFetchTool(props: ToolProps) {
  458. return (
  459. <>
  460. <div data-component="tool-title">
  461. <span data-slot="name">Fetch</span>
  462. <span data-slot="target">{props.state.input.url}</span>
  463. </div>
  464. <div data-component="tool-result">
  465. <Switch>
  466. <Match when={props.state.metadata?.error}>
  467. <ContentError>{formatErrorString(props.state.output)}</ContentError>
  468. </Match>
  469. <Match when={props.state.output}>
  470. <ResultsButton>
  471. <ContentCode lang={props.state.input.format || "text"} code={props.state.output} />
  472. </ResultsButton>
  473. </Match>
  474. </Switch>
  475. </div>
  476. </>
  477. )
  478. }
  479. export function ReadTool(props: ToolProps) {
  480. const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd))
  481. return (
  482. <>
  483. <div data-component="tool-title">
  484. <span data-slot="name">Read</span>
  485. <span data-slot="target" title={props.state.input?.filePath}>
  486. {filePath()}
  487. </span>
  488. </div>
  489. <div data-component="tool-result">
  490. <Switch>
  491. <Match when={props.state.metadata?.error}>
  492. <ContentError>{formatErrorString(props.state.output)}</ContentError>
  493. </Match>
  494. <Match when={typeof props.state.metadata?.preview === "string"}>
  495. <ResultsButton showCopy="Show preview" hideCopy="Hide preview">
  496. <ContentCode lang={getShikiLang(filePath() || "")} code={props.state.metadata?.preview} />
  497. </ResultsButton>
  498. </Match>
  499. <Match when={typeof props.state.metadata?.preview !== "string" && props.state.output}>
  500. <ResultsButton>
  501. <ContentText expand compact text={props.state.output} />
  502. </ResultsButton>
  503. </Match>
  504. </Switch>
  505. </div>
  506. </>
  507. )
  508. }
  509. export function WriteTool(props: ToolProps) {
  510. const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd))
  511. const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath))
  512. return (
  513. <>
  514. <div data-component="tool-title">
  515. <span data-slot="name">Write</span>
  516. <span data-slot="target" title={props.state.input?.filePath}>
  517. {filePath()}
  518. </span>
  519. </div>
  520. <Show when={diagnostics().length > 0}>
  521. <ContentError>{diagnostics()}</ContentError>
  522. </Show>
  523. <div data-component="tool-result">
  524. <Switch>
  525. <Match when={props.state.metadata?.error}>
  526. <ContentError>{formatErrorString(props.state.output)}</ContentError>
  527. </Match>
  528. <Match when={props.state.input?.content}>
  529. <ResultsButton showCopy="Show contents" hideCopy="Hide contents">
  530. <ContentCode lang={getShikiLang(filePath() || "")} code={props.state.input?.content} />
  531. </ResultsButton>
  532. </Match>
  533. </Switch>
  534. </div>
  535. </>
  536. )
  537. }
  538. export function EditTool(props: ToolProps) {
  539. const filePath = createMemo(() => stripWorkingDirectory(props.state.input.filePath, props.message.path.cwd))
  540. const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath))
  541. return (
  542. <>
  543. <div data-component="tool-title">
  544. <span data-slot="name">Edit</span>
  545. <span data-slot="target" title={props.state.input?.filePath}>
  546. {filePath()}
  547. </span>
  548. </div>
  549. <div data-component="tool-result">
  550. <Switch>
  551. <Match when={props.state.metadata?.error}>
  552. <ContentError>{formatErrorString(props.state.metadata?.message || "")}</ContentError>
  553. </Match>
  554. <Match when={props.state.metadata?.diff}>
  555. <div data-component="diff">
  556. <ContentDiff diff={props.state.metadata?.diff} lang={getShikiLang(filePath() || "")} />
  557. </div>
  558. </Match>
  559. </Switch>
  560. </div>
  561. <Show when={diagnostics().length > 0}>
  562. <ContentError>{diagnostics()}</ContentError>
  563. </Show>
  564. </>
  565. )
  566. }
  567. export function BashTool(props: ToolProps) {
  568. return (
  569. <ContentBash
  570. command={props.state.input.command}
  571. output={props.state.metadata?.stdout || ""}
  572. description={props.state.metadata.description}
  573. />
  574. )
  575. }
  576. export function GlobTool(props: ToolProps) {
  577. return (
  578. <>
  579. <div data-component="tool-title">
  580. <span data-slot="name">Glob</span>
  581. <span data-slot="target">&ldquo;{props.state.input.pattern}&rdquo;</span>
  582. </div>
  583. <Switch>
  584. <Match when={props.state.metadata?.count && props.state.metadata?.count > 0}>
  585. <div data-component="tool-result">
  586. <ResultsButton
  587. showCopy={props.state.metadata?.count === 1 ? "1 result" : `${props.state.metadata?.count} results`}
  588. >
  589. <ContentText expand compact text={props.state.output} />
  590. </ResultsButton>
  591. </div>
  592. </Match>
  593. <Match when={props.state.output}>
  594. <ContentText expand text={props.state.output} data-size="sm" data-color="dimmed" />
  595. </Match>
  596. </Switch>
  597. </>
  598. )
  599. }
  600. interface ResultsButtonProps extends ParentProps {
  601. showCopy?: string
  602. hideCopy?: string
  603. }
  604. function ResultsButton(props: ResultsButtonProps) {
  605. const [show, setShow] = createSignal(false)
  606. return (
  607. <>
  608. <button type="button" data-component="button-text" data-more onClick={() => setShow((e) => !e)}>
  609. <span>{show() ? props.hideCopy || "Hide results" : props.showCopy || "Show results"}</span>
  610. <span data-slot="icon">
  611. <Show when={show()} fallback={<IconChevronRight width={11} height={11} />}>
  612. <IconChevronDown width={11} height={11} />
  613. </Show>
  614. </span>
  615. </button>
  616. <Show when={show()}>{props.children}</Show>
  617. </>
  618. )
  619. }
  620. export function Spacer() {
  621. return <div data-component="spacer"></div>
  622. }
  623. function Footer(props: ParentProps<{ title: string }>) {
  624. return (
  625. <div data-component="content-footer" title={props.title}>
  626. {props.children}
  627. </div>
  628. )
  629. }
  630. function ToolFooter(props: { time: number }) {
  631. return props.time > MIN_DURATION && <Footer title={`${props.time}ms`}>{formatDuration(props.time)}</Footer>
  632. }
  633. function TaskTool(props: ToolProps) {
  634. return (
  635. <>
  636. <div data-component="tool-title">
  637. <span data-slot="name">Task</span>
  638. <span data-slot="target">{props.state.input.description}</span>
  639. </div>
  640. <div data-component="tool-input">&ldquo;{props.state.input.prompt}&rdquo;</div>
  641. <ResultsButton showCopy="Show output" hideCopy="Hide output">
  642. <div data-component="tool-output">
  643. <ContentMarkdown expand text={props.state.output} />
  644. </div>
  645. </ResultsButton>
  646. </>
  647. )
  648. }
  649. export function FallbackTool(props: ToolProps) {
  650. return (
  651. <>
  652. <div data-component="tool-title">
  653. <span data-slot="name">{props.tool}</span>
  654. </div>
  655. <div data-component="tool-args">
  656. <For each={flattenToolArgs(props.state.input)}>
  657. {(arg) => (
  658. <>
  659. <div></div>
  660. <div>{arg[0]}</div>
  661. <div>{arg[1]}</div>
  662. </>
  663. )}
  664. </For>
  665. </div>
  666. <Switch>
  667. <Match when={props.state.output}>
  668. <div data-component="tool-result">
  669. <ResultsButton>
  670. <ContentText expand compact text={props.state.output} data-size="sm" data-color="dimmed" />
  671. </ResultsButton>
  672. </div>
  673. </Match>
  674. </Switch>
  675. </>
  676. )
  677. }
  678. // Converts nested objects/arrays into [path, value] pairs.
  679. // E.g. {a:{b:{c:1}}, d:[{e:2}, 3]} => [["a.b.c",1], ["d[0].e",2], ["d[1]",3]]
  680. function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> {
  681. const entries: Array<[string, any]> = []
  682. for (const [key, value] of Object.entries(obj)) {
  683. const path = prefix ? `${prefix}.${key}` : key
  684. if (value !== null && typeof value === "object") {
  685. if (Array.isArray(value)) {
  686. value.forEach((item, index) => {
  687. const arrayPath = `${path}[${index}]`
  688. if (item !== null && typeof item === "object") {
  689. entries.push(...flattenToolArgs(item, arrayPath))
  690. } else {
  691. entries.push([arrayPath, item])
  692. }
  693. })
  694. } else {
  695. entries.push(...flattenToolArgs(value, path))
  696. }
  697. } else {
  698. entries.push([path, value])
  699. }
  700. }
  701. return entries
  702. }
  703. function getProvider(model: string) {
  704. const lowerModel = model.toLowerCase()
  705. if (/claude|anthropic/.test(lowerModel)) return "anthropic"
  706. if (/gpt|o[1-4]|codex|openai/.test(lowerModel)) return "openai"
  707. if (/gemini|palm|bard|google/.test(lowerModel)) return "gemini"
  708. if (/llama|meta/.test(lowerModel)) return "meta"
  709. return "any"
  710. }
  711. export function ProviderIcon(props: { model: string; size?: number }) {
  712. const provider = getProvider(props.model)
  713. const size = props.size || 16
  714. return (
  715. <Switch fallback={<IconSparkles width={size} height={size} />}>
  716. <Match when={provider === "openai"}>
  717. <IconOpenAI width={size} height={size} />
  718. </Match>
  719. <Match when={provider === "anthropic"}>
  720. <IconAnthropic width={size} height={size} />
  721. </Match>
  722. <Match when={provider === "gemini"}>
  723. <IconGemini width={size} height={size} />
  724. </Match>
  725. <Match when={provider === "meta"}>
  726. <IconMeta width={size} height={size} />
  727. </Match>
  728. </Switch>
  729. )
  730. }