mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-03 23:53:46 +00:00
252 lines
7.8 KiB
TypeScript
252 lines
7.8 KiB
TypeScript
import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
|
|
import { animate, type AnimationPlaybackControls } from "motion"
|
|
import { useI18n } from "../context/i18n"
|
|
import { createStore } from "solid-js/store"
|
|
import { Collapsible } from "./collapsible"
|
|
import type { IconProps } from "./icon"
|
|
import { TextShimmer } from "./text-shimmer"
|
|
|
|
export type TriggerTitle = {
|
|
title: string
|
|
titleClass?: string
|
|
subtitle?: string
|
|
subtitleClass?: string
|
|
args?: string[]
|
|
argsClass?: string
|
|
action?: JSX.Element
|
|
}
|
|
|
|
const isTriggerTitle = (val: any): val is TriggerTitle => {
|
|
return (
|
|
typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node))
|
|
)
|
|
}
|
|
|
|
export interface BasicToolProps {
|
|
icon: IconProps["name"]
|
|
trigger: TriggerTitle | JSX.Element
|
|
children?: JSX.Element
|
|
status?: string
|
|
hideDetails?: boolean
|
|
defaultOpen?: boolean
|
|
forceOpen?: boolean
|
|
defer?: boolean
|
|
locked?: boolean
|
|
animated?: boolean
|
|
onSubtitleClick?: () => void
|
|
}
|
|
|
|
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
|
|
|
|
export function BasicTool(props: BasicToolProps) {
|
|
const [state, setState] = createStore({
|
|
open: props.defaultOpen ?? false,
|
|
ready: props.defaultOpen ?? false,
|
|
})
|
|
const open = () => state.open
|
|
const ready = () => state.ready
|
|
const pending = () => props.status === "pending" || props.status === "running"
|
|
|
|
let frame: number | undefined
|
|
|
|
const cancel = () => {
|
|
if (frame === undefined) return
|
|
cancelAnimationFrame(frame)
|
|
frame = undefined
|
|
}
|
|
|
|
onCleanup(cancel)
|
|
|
|
createEffect(() => {
|
|
if (props.forceOpen) setState("open", true)
|
|
})
|
|
|
|
createEffect(
|
|
on(
|
|
open,
|
|
(value) => {
|
|
if (!props.defer) return
|
|
if (!value) {
|
|
cancel()
|
|
setState("ready", false)
|
|
return
|
|
}
|
|
|
|
cancel()
|
|
frame = requestAnimationFrame(() => {
|
|
frame = undefined
|
|
if (!open()) return
|
|
setState("ready", true)
|
|
})
|
|
},
|
|
{ defer: true },
|
|
),
|
|
)
|
|
|
|
// Animated height for collapsible open/close
|
|
let contentRef: HTMLDivElement | undefined
|
|
let heightAnim: AnimationPlaybackControls | undefined
|
|
const initialOpen = open()
|
|
|
|
createEffect(
|
|
on(
|
|
open,
|
|
(isOpen) => {
|
|
if (!props.animated || !contentRef) return
|
|
heightAnim?.stop()
|
|
if (isOpen) {
|
|
contentRef.style.overflow = "hidden"
|
|
heightAnim = animate(contentRef, { height: "auto" }, SPRING)
|
|
heightAnim.finished.then(() => {
|
|
if (!contentRef || !open()) return
|
|
contentRef.style.overflow = "visible"
|
|
contentRef.style.height = "auto"
|
|
})
|
|
} else {
|
|
contentRef.style.overflow = "hidden"
|
|
heightAnim = animate(contentRef, { height: "0px" }, SPRING)
|
|
}
|
|
},
|
|
{ defer: true },
|
|
),
|
|
)
|
|
|
|
onCleanup(() => {
|
|
heightAnim?.stop()
|
|
})
|
|
|
|
const handleOpenChange = (value: boolean) => {
|
|
if (pending()) return
|
|
if (props.locked && !value) return
|
|
setState("open", value)
|
|
}
|
|
|
|
return (
|
|
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
|
|
<Collapsible.Trigger>
|
|
<div data-component="tool-trigger">
|
|
<div data-slot="basic-tool-tool-trigger-content">
|
|
<div data-slot="basic-tool-tool-info">
|
|
<Switch>
|
|
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
|
{(trigger) => (
|
|
<div data-slot="basic-tool-tool-info-structured">
|
|
<div data-slot="basic-tool-tool-info-main">
|
|
<span
|
|
data-slot="basic-tool-tool-title"
|
|
classList={{
|
|
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
|
}}
|
|
>
|
|
<TextShimmer text={trigger().title} active={pending()} />
|
|
</span>
|
|
<Show when={!pending()}>
|
|
<Show when={trigger().subtitle}>
|
|
<span
|
|
data-slot="basic-tool-tool-subtitle"
|
|
classList={{
|
|
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
|
|
clickable: !!props.onSubtitleClick,
|
|
}}
|
|
onClick={(e) => {
|
|
if (props.onSubtitleClick) {
|
|
e.stopPropagation()
|
|
props.onSubtitleClick()
|
|
}
|
|
}}
|
|
>
|
|
{trigger().subtitle}
|
|
</span>
|
|
</Show>
|
|
<Show when={trigger().args?.length}>
|
|
<For each={trigger().args}>
|
|
{(arg) => (
|
|
<span
|
|
data-slot="basic-tool-tool-arg"
|
|
classList={{
|
|
[trigger().argsClass ?? ""]: !!trigger().argsClass,
|
|
}}
|
|
>
|
|
{arg}
|
|
</span>
|
|
)}
|
|
</For>
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
|
|
</div>
|
|
)}
|
|
</Match>
|
|
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
|
</Switch>
|
|
</div>
|
|
</div>
|
|
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
|
|
<Collapsible.Arrow />
|
|
</Show>
|
|
</div>
|
|
</Collapsible.Trigger>
|
|
<Show when={props.animated && props.children && !props.hideDetails}>
|
|
<div
|
|
ref={contentRef}
|
|
data-slot="collapsible-content"
|
|
data-animated
|
|
style={{
|
|
height: initialOpen ? "auto" : "0px",
|
|
overflow: initialOpen ? "visible" : "hidden",
|
|
}}
|
|
>
|
|
{props.children}
|
|
</div>
|
|
</Show>
|
|
<Show when={!props.animated && props.children && !props.hideDetails}>
|
|
<Collapsible.Content>
|
|
<Show when={!props.defer || ready()}>{props.children}</Show>
|
|
</Collapsible.Content>
|
|
</Show>
|
|
</Collapsible>
|
|
)
|
|
}
|
|
|
|
function label(input: Record<string, unknown> | undefined) {
|
|
const keys = ["description", "query", "url", "filePath", "path", "pattern", "name"]
|
|
return keys.map((key) => input?.[key]).find((value): value is string => typeof value === "string" && value.length > 0)
|
|
}
|
|
|
|
function args(input: Record<string, unknown> | undefined) {
|
|
if (!input) return []
|
|
const skip = new Set(["description", "query", "url", "filePath", "path", "pattern", "name"])
|
|
return Object.entries(input)
|
|
.filter(([key]) => !skip.has(key))
|
|
.flatMap(([key, value]) => {
|
|
if (typeof value === "string") return [`${key}=${value}`]
|
|
if (typeof value === "number") return [`${key}=${value}`]
|
|
if (typeof value === "boolean") return [`${key}=${value}`]
|
|
return []
|
|
})
|
|
.slice(0, 3)
|
|
}
|
|
|
|
export function GenericTool(props: {
|
|
tool: string
|
|
status?: string
|
|
hideDetails?: boolean
|
|
input?: Record<string, unknown>
|
|
}) {
|
|
const i18n = useI18n()
|
|
|
|
return (
|
|
<BasicTool
|
|
icon="mcp"
|
|
status={props.status}
|
|
trigger={{
|
|
title: i18n.t("ui.basicTool.called", { tool: props.tool }),
|
|
subtitle: label(props.input),
|
|
args: args(props.input),
|
|
}}
|
|
hideDetails={props.hideDetails}
|
|
/>
|
|
)
|
|
}
|