basic-tool.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
  2. import { animate, type AnimationPlaybackControls } from "motion"
  3. import { useI18n } from "../context/i18n"
  4. import { createStore } from "solid-js/store"
  5. import { Collapsible } from "./collapsible"
  6. import type { IconProps } from "./icon"
  7. import { TextShimmer } from "./text-shimmer"
  8. export type TriggerTitle = {
  9. title: string
  10. titleClass?: string
  11. subtitle?: string
  12. subtitleClass?: string
  13. args?: string[]
  14. argsClass?: string
  15. action?: JSX.Element
  16. }
  17. const isTriggerTitle = (val: any): val is TriggerTitle => {
  18. return (
  19. typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node))
  20. )
  21. }
  22. export interface BasicToolProps {
  23. icon: IconProps["name"]
  24. trigger: TriggerTitle | JSX.Element
  25. children?: JSX.Element
  26. status?: string
  27. hideDetails?: boolean
  28. defaultOpen?: boolean
  29. forceOpen?: boolean
  30. defer?: boolean
  31. locked?: boolean
  32. animated?: boolean
  33. onSubtitleClick?: () => void
  34. }
  35. const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
  36. export function BasicTool(props: BasicToolProps) {
  37. const [state, setState] = createStore({
  38. open: props.defaultOpen ?? false,
  39. ready: props.defaultOpen ?? false,
  40. })
  41. const open = () => state.open
  42. const ready = () => state.ready
  43. const pending = () => props.status === "pending" || props.status === "running"
  44. let frame: number | undefined
  45. const cancel = () => {
  46. if (frame === undefined) return
  47. cancelAnimationFrame(frame)
  48. frame = undefined
  49. }
  50. onCleanup(cancel)
  51. createEffect(() => {
  52. if (props.forceOpen) setState("open", true)
  53. })
  54. createEffect(
  55. on(
  56. open,
  57. (value) => {
  58. if (!props.defer) return
  59. if (!value) {
  60. cancel()
  61. setState("ready", false)
  62. return
  63. }
  64. cancel()
  65. frame = requestAnimationFrame(() => {
  66. frame = undefined
  67. if (!open()) return
  68. setState("ready", true)
  69. })
  70. },
  71. { defer: true },
  72. ),
  73. )
  74. // Animated height for collapsible open/close
  75. let contentRef: HTMLDivElement | undefined
  76. let heightAnim: AnimationPlaybackControls | undefined
  77. const initialOpen = open()
  78. createEffect(
  79. on(
  80. open,
  81. (isOpen) => {
  82. if (!props.animated || !contentRef) return
  83. heightAnim?.stop()
  84. if (isOpen) {
  85. contentRef.style.overflow = "hidden"
  86. heightAnim = animate(contentRef, { height: "auto" }, SPRING)
  87. heightAnim.finished.then(() => {
  88. if (!contentRef || !open()) return
  89. contentRef.style.overflow = "visible"
  90. contentRef.style.height = "auto"
  91. })
  92. } else {
  93. contentRef.style.overflow = "hidden"
  94. heightAnim = animate(contentRef, { height: "0px" }, SPRING)
  95. }
  96. },
  97. { defer: true },
  98. ),
  99. )
  100. onCleanup(() => {
  101. heightAnim?.stop()
  102. })
  103. const handleOpenChange = (value: boolean) => {
  104. if (pending()) return
  105. if (props.locked && !value) return
  106. setState("open", value)
  107. }
  108. return (
  109. <Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
  110. <Collapsible.Trigger>
  111. <div data-component="tool-trigger">
  112. <div data-slot="basic-tool-tool-trigger-content">
  113. <div data-slot="basic-tool-tool-info">
  114. <Switch>
  115. <Match when={isTriggerTitle(props.trigger) && props.trigger}>
  116. {(trigger) => (
  117. <div data-slot="basic-tool-tool-info-structured">
  118. <div data-slot="basic-tool-tool-info-main">
  119. <span
  120. data-slot="basic-tool-tool-title"
  121. classList={{
  122. [trigger().titleClass ?? ""]: !!trigger().titleClass,
  123. }}
  124. >
  125. <TextShimmer text={trigger().title} active={pending()} />
  126. </span>
  127. <Show when={!pending()}>
  128. <Show when={trigger().subtitle}>
  129. <span
  130. data-slot="basic-tool-tool-subtitle"
  131. classList={{
  132. [trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
  133. clickable: !!props.onSubtitleClick,
  134. }}
  135. onClick={(e) => {
  136. if (props.onSubtitleClick) {
  137. e.stopPropagation()
  138. props.onSubtitleClick()
  139. }
  140. }}
  141. >
  142. {trigger().subtitle}
  143. </span>
  144. </Show>
  145. <Show when={trigger().args?.length}>
  146. <For each={trigger().args}>
  147. {(arg) => (
  148. <span
  149. data-slot="basic-tool-tool-arg"
  150. classList={{
  151. [trigger().argsClass ?? ""]: !!trigger().argsClass,
  152. }}
  153. >
  154. {arg}
  155. </span>
  156. )}
  157. </For>
  158. </Show>
  159. </Show>
  160. </div>
  161. <Show when={!pending() && trigger().action}>
  162. <span data-slot="basic-tool-tool-action">{trigger().action}</span>
  163. </Show>
  164. </div>
  165. )}
  166. </Match>
  167. <Match when={true}>{props.trigger as JSX.Element}</Match>
  168. </Switch>
  169. </div>
  170. </div>
  171. <Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
  172. <Collapsible.Arrow />
  173. </Show>
  174. </div>
  175. </Collapsible.Trigger>
  176. <Show when={props.animated && props.children && !props.hideDetails}>
  177. <div
  178. ref={contentRef}
  179. data-slot="collapsible-content"
  180. data-animated
  181. style={{
  182. height: initialOpen ? "auto" : "0px",
  183. overflow: initialOpen ? "visible" : "hidden",
  184. }}
  185. >
  186. {props.children}
  187. </div>
  188. </Show>
  189. <Show when={!props.animated && props.children && !props.hideDetails}>
  190. <Collapsible.Content>
  191. <Show when={!props.defer || ready()}>{props.children}</Show>
  192. </Collapsible.Content>
  193. </Show>
  194. </Collapsible>
  195. )
  196. }
  197. function label(input: Record<string, unknown> | undefined) {
  198. const keys = ["description", "query", "url", "filePath", "path", "pattern", "name"]
  199. return keys.map((key) => input?.[key]).find((value): value is string => typeof value === "string" && value.length > 0)
  200. }
  201. function args(input: Record<string, unknown> | undefined) {
  202. if (!input) return []
  203. const skip = new Set(["description", "query", "url", "filePath", "path", "pattern", "name"])
  204. return Object.entries(input)
  205. .filter(([key]) => !skip.has(key))
  206. .flatMap(([key, value]) => {
  207. if (typeof value === "string") return [`${key}=${value}`]
  208. if (typeof value === "number") return [`${key}=${value}`]
  209. if (typeof value === "boolean") return [`${key}=${value}`]
  210. return []
  211. })
  212. .slice(0, 3)
  213. }
  214. export function GenericTool(props: {
  215. tool: string
  216. status?: string
  217. hideDetails?: boolean
  218. input?: Record<string, unknown>
  219. }) {
  220. const i18n = useI18n()
  221. return (
  222. <BasicTool
  223. icon="mcp"
  224. status={props.status}
  225. trigger={{
  226. title: i18n.t("ui.basicTool.called", { tool: props.tool }),
  227. subtitle: label(props.input),
  228. args: args(props.input),
  229. }}
  230. hideDetails={props.hideDetails}
  231. />
  232. )
  233. }