mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[Haik]: refactor: restructure OpenSwarmThread component hierarchy — move AssistantMessage from flat file into AssistantMessage/ directory with colocated MarkdownText/ (MarkdownText, DEFAULT_COMPONENTS, CodeHeader) and ToolFallback/ (ToolFallback, ToolFallbackRoot, ToolFallbackTrigger, ToolFallbackContent, ToolFallbackArgs, ToolFallbackResult, ToolFallbackError); move UserMessage into UserMessage/ directory extracting UserMessageAttachments; relocate TooltipIconButton from global components/assistant-ui/tooltip-icon-button into OpenSwarmThread/components/; delete shared modules attachment.tsx, markdown-text.tsx, tool-fallback.tsx, and tooltip-icon-button.tsx from components/assistant-ui/; update import paths in OpenSwarmThread.tsx, BranchPicker.tsx, and MessageActions.tsx
This commit is contained in:
@@ -8,9 +8,9 @@ import {
|
||||
import { ArrowDownIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button';
|
||||
import { UserMessage } from './components/UserMessage';
|
||||
import { AssistantMessage } from './components/AssistantMessage';
|
||||
import { TooltipIconButton } from './components/TooltipIconButton';
|
||||
import { UserMessage } from './components/UserMessage/UserMessage';
|
||||
import { AssistantMessage } from './components/AssistantMessage/AssistantMessage';
|
||||
import { SessionIdContext, BranchChatContext } from './utils';
|
||||
|
||||
interface OpenSwarmThreadProps {
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { type FC } from 'react';
|
||||
import { MessagePrimitive, ErrorPrimitive } from '@assistant-ui/react';
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text';
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback';
|
||||
import { AssistantActionBar } from './MessageActions';
|
||||
import { BranchPicker } from './BranchPicker';
|
||||
import { MarkdownText } from './MarkdownText/MarkdownText';
|
||||
import { ToolFallback } from './ToolFallback/ToolFallback';
|
||||
import { AssistantActionBar } from '../MessageActions';
|
||||
import { BranchPicker } from '../BranchPicker';
|
||||
|
||||
export const AssistantMessage: FC = () => {
|
||||
return (
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import "@assistant-ui/react-markdown/styles/dot.css";
|
||||
|
||||
import {
|
||||
type CodeHeaderProps,
|
||||
} from "@assistant-ui/react-markdown";
|
||||
import { type FC, useState } from "react";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
|
||||
import { TooltipIconButton } from "@/app/pages/AgentChat/OpenSwarmThread/components/TooltipIconButton";
|
||||
|
||||
|
||||
|
||||
const useCopyToClipboard = ({
|
||||
copiedDuration = 3000,
|
||||
}: {
|
||||
copiedDuration?: number;
|
||||
} = {}) => {
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||
|
||||
const copyToClipboard = (value: string) => {
|
||||
if (!value) return;
|
||||
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), copiedDuration);
|
||||
});
|
||||
};
|
||||
|
||||
return { isCopied, copyToClipboard };
|
||||
};
|
||||
|
||||
export const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard();
|
||||
const onCopy = () => {
|
||||
if (!code || isCopied) return;
|
||||
copyToClipboard(code);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="aui-code-header-root mt-2.5 flex items-center justify-between rounded-t-lg border border-border/50 border-b-0 bg-muted/50 px-3 py-1.5 text-xs">
|
||||
<span className="aui-code-header-language font-medium text-muted-foreground lowercase">
|
||||
{language}
|
||||
</span>
|
||||
<TooltipIconButton tooltip="Copy" onClick={onCopy}>
|
||||
{!isCopied && <CopyIcon />}
|
||||
{isCopied && <CheckIcon />}
|
||||
</TooltipIconButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+2
-58
@@ -3,70 +3,14 @@
|
||||
import "@assistant-ui/react-markdown/styles/dot.css";
|
||||
|
||||
import {
|
||||
type CodeHeaderProps,
|
||||
MarkdownTextPrimitive,
|
||||
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
|
||||
useIsMarkdownCodeBlock,
|
||||
} from "@assistant-ui/react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { type FC, memo, useState } from "react";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CodeHeader } from "./CodeHeader";
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
return (
|
||||
<MarkdownTextPrimitive
|
||||
remarkPlugins={[remarkGfm]}
|
||||
className="aui-md"
|
||||
components={defaultComponents}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkdownText = memo(MarkdownTextImpl);
|
||||
|
||||
const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard();
|
||||
const onCopy = () => {
|
||||
if (!code || isCopied) return;
|
||||
copyToClipboard(code);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="aui-code-header-root mt-2.5 flex items-center justify-between rounded-t-lg border border-border/50 border-b-0 bg-muted/50 px-3 py-1.5 text-xs">
|
||||
<span className="aui-code-header-language font-medium text-muted-foreground lowercase">
|
||||
{language}
|
||||
</span>
|
||||
<TooltipIconButton tooltip="Copy" onClick={onCopy}>
|
||||
{!isCopied && <CopyIcon />}
|
||||
{isCopied && <CheckIcon />}
|
||||
</TooltipIconButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useCopyToClipboard = ({
|
||||
copiedDuration = 3000,
|
||||
}: {
|
||||
copiedDuration?: number;
|
||||
} = {}) => {
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||
|
||||
const copyToClipboard = (value: string) => {
|
||||
if (!value) return;
|
||||
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), copiedDuration);
|
||||
});
|
||||
};
|
||||
|
||||
return { isCopied, copyToClipboard };
|
||||
};
|
||||
|
||||
const defaultComponents = memoizeMarkdownComponents({
|
||||
export const DEFAULT_COMPONENTS = memoizeMarkdownComponents({
|
||||
h1: ({ className, ...props }) => (
|
||||
<h1
|
||||
className={cn(
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import "@assistant-ui/react-markdown/styles/dot.css";
|
||||
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { DEFAULT_COMPONENTS } from "./DEFAULT_COMPONENTS/DEFAULT_COMPONENTS";
|
||||
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
|
||||
|
||||
export const MarkdownText = () => (
|
||||
<MarkdownTextPrimitive
|
||||
remarkPlugins={[remarkGfm]}
|
||||
className="aui-md"
|
||||
components={DEFAULT_COMPONENTS}
|
||||
/>
|
||||
);
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import {
|
||||
type ToolCallMessagePartComponent,
|
||||
} from "@assistant-ui/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ToolFallbackRoot } from "./components/ToolFallbackRoot";
|
||||
import { ToolFallbackTrigger } from "./components/ToolFallbackTrigger";
|
||||
import { ToolFallbackContent } from "./components/ToolFallbackContent";
|
||||
import { ToolFallbackError } from "./components/ToolFallbackError";
|
||||
import { ToolFallbackArgs } from "./components/ToolFallbackArgs";
|
||||
import { ToolFallbackResult } from "./components/ToolFallbackResult";
|
||||
|
||||
|
||||
|
||||
const ToolFallbackImpl: ToolCallMessagePartComponent = ({
|
||||
toolName,
|
||||
argsText,
|
||||
result,
|
||||
status,
|
||||
}) => {
|
||||
const isCancelled =
|
||||
status?.type === "incomplete" && status.reason === "cancelled";
|
||||
|
||||
return (
|
||||
<ToolFallbackRoot
|
||||
className={cn(isCancelled && "border-muted-foreground/30 bg-muted/30")}
|
||||
>
|
||||
<ToolFallbackTrigger toolName={toolName} status={status} />
|
||||
<ToolFallbackContent>
|
||||
<ToolFallbackError status={status} />
|
||||
<ToolFallbackArgs
|
||||
argsText={argsText}
|
||||
className={cn(isCancelled && "opacity-60")}
|
||||
/>
|
||||
{!isCancelled && <ToolFallbackResult result={result} />}
|
||||
</ToolFallbackContent>
|
||||
</ToolFallbackRoot>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolFallback = memo(
|
||||
ToolFallbackImpl,
|
||||
) as unknown as ToolCallMessagePartComponent & {
|
||||
Root: typeof ToolFallbackRoot;
|
||||
Trigger: typeof ToolFallbackTrigger;
|
||||
Content: typeof ToolFallbackContent;
|
||||
Args: typeof ToolFallbackArgs;
|
||||
Result: typeof ToolFallbackResult;
|
||||
Error: typeof ToolFallbackError;
|
||||
};
|
||||
|
||||
ToolFallback.displayName = "ToolFallback";
|
||||
ToolFallback.Root = ToolFallbackRoot;
|
||||
ToolFallback.Trigger = ToolFallbackTrigger;
|
||||
ToolFallback.Content = ToolFallbackContent;
|
||||
ToolFallback.Args = ToolFallbackArgs;
|
||||
ToolFallback.Result = ToolFallbackResult;
|
||||
ToolFallback.Error = ToolFallbackError;
|
||||
|
||||
export {
|
||||
ToolFallback,
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ToolFallbackArgs({
|
||||
argsText,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
argsText?: string;
|
||||
}) {
|
||||
if (!argsText) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="tool-fallback-args"
|
||||
className={cn("aui-tool-fallback-args px-4", className)}
|
||||
{...props}
|
||||
>
|
||||
<pre className="aui-tool-fallback-args-value whitespace-pre-wrap">
|
||||
{argsText}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
||||
export function ToolFallbackContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsibleContent
|
||||
data-slot="tool-fallback-content"
|
||||
className={cn(
|
||||
"aui-tool-fallback-content relative overflow-hidden text-sm outline-none",
|
||||
"group/collapsible-content ease-out",
|
||||
"data-[state=closed]:animate-collapsible-up",
|
||||
"data-[state=open]:animate-collapsible-down",
|
||||
"data-[state=closed]:fill-mode-forwards",
|
||||
"data-[state=closed]:pointer-events-none",
|
||||
"data-[state=open]:duration-(--animation-duration)",
|
||||
"data-[state=closed]:duration-(--animation-duration)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mt-3 flex flex-col gap-2 border-t pt-2">{children}</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { type ToolCallMessagePartStatus } from "@assistant-ui/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
||||
|
||||
export function ToolFallbackError({
|
||||
status,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
status?: ToolCallMessagePartStatus;
|
||||
}) {
|
||||
if (status?.type !== "incomplete") return null;
|
||||
|
||||
const error = status.error;
|
||||
const errorText = error
|
||||
? typeof error === "string"
|
||||
? error
|
||||
: JSON.stringify(error)
|
||||
: null;
|
||||
|
||||
if (!errorText) return null;
|
||||
|
||||
const isCancelled = status.reason === "cancelled";
|
||||
const headerText = isCancelled ? "Cancelled reason:" : "Error:";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="tool-fallback-error"
|
||||
className={cn("aui-tool-fallback-error px-4", className)}
|
||||
{...props}
|
||||
>
|
||||
<p className="aui-tool-fallback-error-header font-semibold text-muted-foreground">
|
||||
{headerText}
|
||||
</p>
|
||||
<p className="aui-tool-fallback-error-reason text-muted-foreground">
|
||||
{errorText}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ToolFallbackResult({
|
||||
result,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
result?: unknown;
|
||||
}) {
|
||||
if (result === undefined) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="tool-fallback-result"
|
||||
className={cn(
|
||||
"aui-tool-fallback-result border-t border-dashed px-4 pt-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<p className="aui-tool-fallback-result-header font-semibold">Result:</p>
|
||||
<pre className="aui-tool-fallback-result-content whitespace-pre-wrap">
|
||||
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
useScrollLock,
|
||||
} from "@assistant-ui/react";
|
||||
import { Collapsible } from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ANIMATION_DURATION = 200;
|
||||
|
||||
type ToolFallbackRootProps = Omit<
|
||||
React.ComponentProps<typeof Collapsible>,
|
||||
"open" | "onOpenChange"
|
||||
> & {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
export function ToolFallbackRoot({
|
||||
className,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
...props
|
||||
}: ToolFallbackRootProps) {
|
||||
const collapsibleRef = useRef<HTMLDivElement>(null);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
||||
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
lockScroll();
|
||||
}
|
||||
if (!isControlled) {
|
||||
setUncontrolledOpen(open);
|
||||
}
|
||||
controlledOnOpenChange?.(open);
|
||||
},
|
||||
[lockScroll, isControlled, controlledOnOpenChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
ref={collapsibleRef}
|
||||
data-slot="tool-fallback-root"
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
className={cn(
|
||||
"aui-tool-fallback-root group/tool-fallback-root w-full rounded-lg border py-3",
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--animation-duration": `${ANIMATION_DURATION}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
LoaderIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { type ToolCallMessagePartStatus } from "@assistant-ui/react";
|
||||
import { CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ToolStatus = ToolCallMessagePartStatus["type"];
|
||||
|
||||
const statusIconMap: Record<ToolStatus, React.ElementType> = {
|
||||
running: LoaderIcon,
|
||||
complete: CheckIcon,
|
||||
incomplete: XCircleIcon,
|
||||
"requires-action": AlertCircleIcon,
|
||||
};
|
||||
|
||||
export function ToolFallbackTrigger({
|
||||
toolName,
|
||||
status,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsibleTrigger> & {
|
||||
toolName: string;
|
||||
status?: ToolCallMessagePartStatus;
|
||||
}) {
|
||||
const statusType = status?.type ?? "complete";
|
||||
const isRunning = statusType === "running";
|
||||
const isCancelled =
|
||||
status?.type === "incomplete" && status.reason === "cancelled";
|
||||
|
||||
const Icon = statusIconMap[statusType];
|
||||
const label = isCancelled ? "Cancelled tool" : "Used tool";
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
data-slot="tool-fallback-trigger"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 px-4 text-sm transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
data-slot="tool-fallback-trigger-icon"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger-icon size-4 shrink-0",
|
||||
isCancelled && "text-muted-foreground",
|
||||
isRunning && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
data-slot="tool-fallback-trigger-label"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger-label-wrapper relative inline-block grow text-left leading-none",
|
||||
isCancelled && "text-muted-foreground line-through",
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{label}: <b>{toolName}</b>
|
||||
</span>
|
||||
{isRunning && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="tool-fallback-trigger-shimmer"
|
||||
className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
|
||||
>
|
||||
{label}: <b>{toolName}</b>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
data-slot="tool-fallback-trigger-chevron"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger-chevron size-4 shrink-0",
|
||||
"transition-transform duration-(--animation-duration) ease-out",
|
||||
"group-data-[state=closed]/trigger:-rotate-90",
|
||||
"group-data-[state=open]/trigger:rotate-0",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type FC } from 'react';
|
||||
import { BranchPickerPrimitive } from '@assistant-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button';
|
||||
import { TooltipIconButton } from './TooltipIconButton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
} from 'lucide-react';
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button';
|
||||
import { TooltipIconButton } from './TooltipIconButton';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import {
|
||||
setActiveSession,
|
||||
|
||||
+4
-4
@@ -4,12 +4,12 @@ import {
|
||||
useAui,
|
||||
useMessagePartText,
|
||||
} from '@assistant-ui/react';
|
||||
import { UserMessageAttachments } from '@/components/assistant-ui/attachment';
|
||||
import { UserMessageAttachments } from './UserMessageAttachments';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import type { AgentMessage } from '@/shared/state/agentsSlice';
|
||||
import { useSessionId } from '../utils';
|
||||
import { UserActionBar } from './MessageActions';
|
||||
import { BranchPicker } from './BranchPicker';
|
||||
import { useSessionId } from '../../utils';
|
||||
import { UserActionBar } from '../MessageActions';
|
||||
import { BranchPicker } from '../BranchPicker';
|
||||
|
||||
const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n';
|
||||
const SKILL_PILL_RE = /\{\{skill:([^}]+)\}\}/g;
|
||||
+1
-1
@@ -21,7 +21,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { TooltipIconButton } from "../TooltipIconButton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const useFileSrc = (file: File | undefined) => {
|
||||
@@ -1,318 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
LoaderIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useScrollLock,
|
||||
type ToolCallMessagePartStatus,
|
||||
type ToolCallMessagePartComponent,
|
||||
} from "@assistant-ui/react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ANIMATION_DURATION = 200;
|
||||
|
||||
type ToolFallbackRootProps = Omit<
|
||||
React.ComponentProps<typeof Collapsible>,
|
||||
"open" | "onOpenChange"
|
||||
> & {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
function ToolFallbackRoot({
|
||||
className,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
...props
|
||||
}: ToolFallbackRootProps) {
|
||||
const collapsibleRef = useRef<HTMLDivElement>(null);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
||||
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
lockScroll();
|
||||
}
|
||||
if (!isControlled) {
|
||||
setUncontrolledOpen(open);
|
||||
}
|
||||
controlledOnOpenChange?.(open);
|
||||
},
|
||||
[lockScroll, isControlled, controlledOnOpenChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
ref={collapsibleRef}
|
||||
data-slot="tool-fallback-root"
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
className={cn(
|
||||
"aui-tool-fallback-root group/tool-fallback-root w-full rounded-lg border py-3",
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--animation-duration": `${ANIMATION_DURATION}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
type ToolStatus = ToolCallMessagePartStatus["type"];
|
||||
|
||||
const statusIconMap: Record<ToolStatus, React.ElementType> = {
|
||||
running: LoaderIcon,
|
||||
complete: CheckIcon,
|
||||
incomplete: XCircleIcon,
|
||||
"requires-action": AlertCircleIcon,
|
||||
};
|
||||
|
||||
function ToolFallbackTrigger({
|
||||
toolName,
|
||||
status,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsibleTrigger> & {
|
||||
toolName: string;
|
||||
status?: ToolCallMessagePartStatus;
|
||||
}) {
|
||||
const statusType = status?.type ?? "complete";
|
||||
const isRunning = statusType === "running";
|
||||
const isCancelled =
|
||||
status?.type === "incomplete" && status.reason === "cancelled";
|
||||
|
||||
const Icon = statusIconMap[statusType];
|
||||
const label = isCancelled ? "Cancelled tool" : "Used tool";
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
data-slot="tool-fallback-trigger"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 px-4 text-sm transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
data-slot="tool-fallback-trigger-icon"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger-icon size-4 shrink-0",
|
||||
isCancelled && "text-muted-foreground",
|
||||
isRunning && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
data-slot="tool-fallback-trigger-label"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger-label-wrapper relative inline-block grow text-left leading-none",
|
||||
isCancelled && "text-muted-foreground line-through",
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{label}: <b>{toolName}</b>
|
||||
</span>
|
||||
{isRunning && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="tool-fallback-trigger-shimmer"
|
||||
className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
|
||||
>
|
||||
{label}: <b>{toolName}</b>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
data-slot="tool-fallback-trigger-chevron"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger-chevron size-4 shrink-0",
|
||||
"transition-transform duration-(--animation-duration) ease-out",
|
||||
"group-data-[state=closed]/trigger:-rotate-90",
|
||||
"group-data-[state=open]/trigger:rotate-0",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolFallbackContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsibleContent
|
||||
data-slot="tool-fallback-content"
|
||||
className={cn(
|
||||
"aui-tool-fallback-content relative overflow-hidden text-sm outline-none",
|
||||
"group/collapsible-content ease-out",
|
||||
"data-[state=closed]:animate-collapsible-up",
|
||||
"data-[state=open]:animate-collapsible-down",
|
||||
"data-[state=closed]:fill-mode-forwards",
|
||||
"data-[state=closed]:pointer-events-none",
|
||||
"data-[state=open]:duration-(--animation-duration)",
|
||||
"data-[state=closed]:duration-(--animation-duration)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mt-3 flex flex-col gap-2 border-t pt-2">{children}</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolFallbackArgs({
|
||||
argsText,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
argsText?: string;
|
||||
}) {
|
||||
if (!argsText) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="tool-fallback-args"
|
||||
className={cn("aui-tool-fallback-args px-4", className)}
|
||||
{...props}
|
||||
>
|
||||
<pre className="aui-tool-fallback-args-value whitespace-pre-wrap">
|
||||
{argsText}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolFallbackResult({
|
||||
result,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
result?: unknown;
|
||||
}) {
|
||||
if (result === undefined) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="tool-fallback-result"
|
||||
className={cn(
|
||||
"aui-tool-fallback-result border-t border-dashed px-4 pt-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<p className="aui-tool-fallback-result-header font-semibold">Result:</p>
|
||||
<pre className="aui-tool-fallback-result-content whitespace-pre-wrap">
|
||||
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolFallbackError({
|
||||
status,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
status?: ToolCallMessagePartStatus;
|
||||
}) {
|
||||
if (status?.type !== "incomplete") return null;
|
||||
|
||||
const error = status.error;
|
||||
const errorText = error
|
||||
? typeof error === "string"
|
||||
? error
|
||||
: JSON.stringify(error)
|
||||
: null;
|
||||
|
||||
if (!errorText) return null;
|
||||
|
||||
const isCancelled = status.reason === "cancelled";
|
||||
const headerText = isCancelled ? "Cancelled reason:" : "Error:";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="tool-fallback-error"
|
||||
className={cn("aui-tool-fallback-error px-4", className)}
|
||||
{...props}
|
||||
>
|
||||
<p className="aui-tool-fallback-error-header font-semibold text-muted-foreground">
|
||||
{headerText}
|
||||
</p>
|
||||
<p className="aui-tool-fallback-error-reason text-muted-foreground">
|
||||
{errorText}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ToolFallbackImpl: ToolCallMessagePartComponent = ({
|
||||
toolName,
|
||||
argsText,
|
||||
result,
|
||||
status,
|
||||
}) => {
|
||||
const isCancelled =
|
||||
status?.type === "incomplete" && status.reason === "cancelled";
|
||||
|
||||
return (
|
||||
<ToolFallbackRoot
|
||||
className={cn(isCancelled && "border-muted-foreground/30 bg-muted/30")}
|
||||
>
|
||||
<ToolFallbackTrigger toolName={toolName} status={status} />
|
||||
<ToolFallbackContent>
|
||||
<ToolFallbackError status={status} />
|
||||
<ToolFallbackArgs
|
||||
argsText={argsText}
|
||||
className={cn(isCancelled && "opacity-60")}
|
||||
/>
|
||||
{!isCancelled && <ToolFallbackResult result={result} />}
|
||||
</ToolFallbackContent>
|
||||
</ToolFallbackRoot>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolFallback = memo(
|
||||
ToolFallbackImpl,
|
||||
) as unknown as ToolCallMessagePartComponent & {
|
||||
Root: typeof ToolFallbackRoot;
|
||||
Trigger: typeof ToolFallbackTrigger;
|
||||
Content: typeof ToolFallbackContent;
|
||||
Args: typeof ToolFallbackArgs;
|
||||
Result: typeof ToolFallbackResult;
|
||||
Error: typeof ToolFallbackError;
|
||||
};
|
||||
|
||||
ToolFallback.displayName = "ToolFallback";
|
||||
ToolFallback.Root = ToolFallbackRoot;
|
||||
ToolFallback.Trigger = ToolFallbackTrigger;
|
||||
ToolFallback.Content = ToolFallbackContent;
|
||||
ToolFallback.Args = ToolFallbackArgs;
|
||||
ToolFallback.Result = ToolFallbackResult;
|
||||
ToolFallback.Error = ToolFallbackError;
|
||||
|
||||
export {
|
||||
ToolFallback,
|
||||
};
|
||||
Reference in New Issue
Block a user