Files
desktop-sdk/ChromiumBasedEditors/plugins/ai-agent/src/components/tool-fallback/index.tsx
2025-11-21 17:05:59 +08:00

287 lines
11 KiB
TypeScript

import { useEffect, useState, useCallback } from "react";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { ReactSVG } from "react-svg";
import { useTranslation } from "react-i18next";
import ToolCalledIconUrl from "@/assets/tool.called.svg?url";
import CodeIconUrl from "@/assets/code.svg?url";
import ArrowBottomIconUrl from "@/assets/arrow.bottom.svg?url";
import ArrowRightIconUrl from "@/assets/arrow.right.svg?url";
import CopyIconUrl from "@/assets/btn-copy.svg?url";
import CheckedIconUrl from "@/assets/checked.svg?url";
import SearchIconUrl from "@/assets/btn-web-search.svg?url";
import ExternalIconUrl from "@/assets/btn-external.svg?url";
import server from "@/servers";
import { Loader } from "../loader";
import { IconButton } from "../icon-button";
export const ToolFallback: ToolCallMessagePartComponent = ({
toolName,
argsText,
result,
}) => {
const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(true);
const [isArgsCopied, setIsArgsCopied] = useState(false);
const [isResultCopied, setIsResultCopied] = useState(false);
useEffect(() => {
if (isArgsCopied) {
setTimeout(() => {
setIsArgsCopied(false);
}, 2000);
}
}, [isArgsCopied]);
useEffect(() => {
if (isResultCopied) {
setTimeout(() => {
setIsResultCopied(false);
}, 2000);
}
}, [isResultCopied]);
const type = server.getServerType(toolName);
const name = toolName.replace(type + "_", "");
const isLoading = result === undefined;
const isWebSearch = name === "web_search";
const isWebCrawling = name === "web_crawling";
let webSearchName = "";
let argsTextFinal = argsText;
try {
const parsedArgs = JSON.parse(argsTextFinal);
if (parsedArgs.args) {
argsTextFinal = JSON.stringify(parsedArgs.args);
}
webSearchName = argsTextFinal
? isWebSearch
? JSON.parse(argsTextFinal).query
: isWebCrawling
? JSON.parse(argsTextFinal).urls[0]
: ""
: "";
} catch {
//ignore
}
const handleBeforeInjectionFill = useCallback((svg: SVGSVGElement) => {
const paths = svg.querySelectorAll("path");
paths.forEach((path) => {
path.setAttribute("fill", "var(--chat-message-tool-call-name-color)");
});
const circles = svg.querySelectorAll("circle");
circles.forEach((circle) => {
circle.setAttribute("fill", "var(--chat-message-tool-call-name-color)");
});
}, []);
const handleBeforeInjection = useCallback((svg: SVGSVGElement) => {
const paths = svg.querySelectorAll("path");
paths.forEach((path) => {
path.setAttribute("stroke", "var(--chat-message-tool-call-name-color)");
});
const circles = svg.querySelectorAll("circle");
circles.forEach((circle) => {
circle.setAttribute("stroke", "var(--chat-message-tool-call-name-color)");
});
}, []);
return (
<div className="my-[16px] flex w-full flex-col gap-3">
<div
className="flex items-center gap-[10px] cursor-pointer"
onClick={() => {
if (isWebCrawling) {
window.open(webSearchName, "_blank");
return;
}
if (isWebSearch && result === undefined) {
return;
}
setIsCollapsed(!isCollapsed);
}}
>
{!isLoading ? (
<ReactSVG src={ToolCalledIconUrl} />
) : (
<Loader size={16} />
)}
{isLoading && !isWebSearch && !isWebCrawling ? (
<p className="text-[var(--chat-message-tool-call-header-color)] text-[14px] font-normal leading-[16px]">
{t("ToolExecuted")}
</p>
) : null}
<span className="flex items-center gap-[8px] rounded-[4px] ps-[4px] pe-[8px] text-[14px] leading-[20px] font-normal text-[var(--chat-message-tool-call-name-color)] bg-[var(--chat-message-tool-call-name-background-color)] min-w-0 w-fit">
{isWebSearch ? (
<ReactSVG
src={SearchIconUrl}
beforeInjection={handleBeforeInjectionFill}
/>
) : !isWebCrawling ? (
<ReactSVG
src={CodeIconUrl}
beforeInjection={handleBeforeInjection}
/>
) : null}
<span className="truncate">
{isWebSearch
? webSearchName
: isWebCrawling
? `${name} | ${webSearchName}`
: name}
</span>
</span>
{isWebCrawling ? (
<ReactSVG
src={ExternalIconUrl}
beforeInjection={handleBeforeInjection}
/>
) : isWebSearch && result === undefined ? null : (
<ReactSVG
src={!isCollapsed ? ArrowBottomIconUrl : ArrowRightIconUrl}
beforeInjection={handleBeforeInjection}
/>
)}
</div>
{!isCollapsed ? (
<div className="flex flex-col gap-[24px] mt-[8px] p-[12px] bg-[var(--chat-message-tool-call-body-background-color)] rounded-[12px]">
{isWebSearch ? null : (
<div className="">
<p className="flex flex-row items-center justify-between text-[var(--chat-message-tool-call-header-color)] text-[14px] font-bold leading-[20px]">
{t("ToolCallArguments")}
<ReactSVG
src={isArgsCopied ? CheckedIconUrl : CopyIconUrl}
onClick={() => setIsArgsCopied(true)}
beforeInjection={
!isArgsCopied
? handleBeforeInjectionFill
: handleBeforeInjection
}
/>
</p>
<pre className="max-h-[200px] overflow-y-auto whitespace-pre-wrap text-[var(--chat-message-tool-call-pre-color)] border border-[var(--chat-message-tool-call-pre-border-color)] bg-[var(--chat-message-tool-call-pre-background-color)] px-[8px] py-[2px] rounded-[4px]">
{argsTextFinal ? argsTextFinal : "{}"}
</pre>
</div>
)}
{result !== undefined && (
<div className="">
{isWebSearch ? (
<div>
{(() => {
try {
const parsedResult =
typeof result === "string"
? JSON.parse(result)
: result;
// Check if there's an error in the result
if (parsedResult?.error) {
return (
<pre className="max-h-[200px] overflow-y-auto whitespace-pre-wrap text-[var(--chat-message-tool-call-pre-color)] border border-[var(--chat-message-tool-call-pre-border-color)] bg-[var(--chat-message-tool-call-pre-background-color)] px-[8px] py-[2px] rounded-[4px]">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
);
}
const searchResults = parsedResult?.data || [];
return searchResults.length > 0 ? (
<div className="flex flex-col gap-[10px]">
{searchResults.map(
(
item: {
id: string;
title: string;
url: string;
publishedDate?: string;
author?: string;
},
index: number
) => (
<div
key={index}
className="group h-[36px] px-[8px] rounded-[4px] flex flex-row items-center justify-between cursor-pointer hover:bg-[var(--drop-down-menu-item-hover-color)] transition-colors"
onClick={() => window.open(item.url, "_blank")}
>
<div className="flex flex-row items-center gap-[8px] min-w-0 flex-1">
<IconButton
iconName={SearchIconUrl}
size={24}
disableHover
/>
<h4 className="text-[14px] font-normal text-[var(--chat-message-tool-call-pre-color)] truncate">
{item.title}
</h4>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<IconButton
iconName={ExternalIconUrl}
size={24}
disableHover
/>
</div>
</div>
)
)}
</div>
) : (
<pre className="max-h-[200px] overflow-y-auto whitespace-pre-wrap text-[var(--chat-message-tool-call-pre-color)] border border-[var(--chat-message-tool-call-pre-border-color)] bg-[var(--chat-message-tool-call-pre-background-color)] px-[8px] py-[2px] rounded-[4px]">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
);
} catch {
return (
<pre className="max-h-[200px] overflow-y-auto whitespace-pre-wrap text-[var(--chat-message-tool-call-pre-color)] border border-[var(--chat-message-tool-call-pre-border-color)] bg-[var(--chat-message-tool-call-pre-background-color)] px-[8px] py-[2px] rounded-[4px]">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
);
}
})()}
</div>
) : (
<>
<p className="flex flex-row items-center justify-between text-[var(--chat-message-tool-call-header-color)] text-[14px] font-bold leading-[20px]">
{t("ToolCallResult")}
<ReactSVG
src={isResultCopied ? CheckedIconUrl : CopyIconUrl}
onClick={() => setIsResultCopied(true)}
beforeInjection={
!isResultCopied
? handleBeforeInjectionFill
: handleBeforeInjection
}
/>
</p>
<pre className="max-h-[200px] overflow-y-auto whitespace-pre-wrap text-[var(--chat-message-tool-call-pre-color)] border border-[var(--chat-message-tool-call-pre-border-color)] bg-[var(--chat-message-tool-call-pre-background-color)] px-[8px] py-[2px] rounded-[4px]">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
</>
)}
</div>
)}
</div>
) : null}
</div>
);
};