feat: implement MCP UIs

This commit is contained in:
Li Xin
2025-04-24 15:41:33 +08:00
parent d9ffb19950
commit 10b1d63834
32 changed files with 1419 additions and 321 deletions

View File

@@ -30,7 +30,7 @@ export function InputBox({
size?: "large" | "normal";
responding?: boolean;
feedback?: { option: Option } | null;
onSend?: (message: string, feedback: { option: Option } | null) => void;
onSend?: (message: string, options?: { interruptFeedback?: string }) => void;
onCancel?: () => void;
onRemoveFeedback?: () => void;
}) {
@@ -63,7 +63,9 @@ export function InputBox({
return;
}
if (onSend) {
onSend(message, feedback ?? null);
onSend(message, {
interruptFeedback: feedback?.option.value,
});
setMessage("");
onRemoveFeedback?.();
}

View File

@@ -17,7 +17,6 @@ import {
import type { Message, Option } from "~/core/messages";
import {
openResearch,
sendMessage,
useMessage,
useResearchTitle,
useStore,
@@ -35,9 +34,14 @@ import { Tooltip } from "./tooltip";
export function MessageListView({
className,
onFeedback,
onSendMessage,
}: {
className?: string;
onFeedback?: (feedback: { option: Option }) => void;
onSendMessage?: (
message: string,
options?: { interruptFeedback?: string },
) => void;
}) {
const messageIds = useStore((state) => state.messageIds);
const interruptMessage = useStore((state) => {
@@ -81,6 +85,7 @@ export function MessageListView({
waitForFeedback={waitingForFeedbackMessageId === messageId}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
onSendMessage={onSendMessage}
/>
))}
<div className="flex h-8 w-full shrink-0"></div>
@@ -96,14 +101,19 @@ function MessageListItem({
className,
messageId,
waitForFeedback,
onFeedback,
interruptMessage,
onFeedback,
onSendMessage,
}: {
className?: string;
messageId: string;
waitForFeedback?: boolean;
onFeedback?: (feedback: { option: Option }) => void;
interruptMessage?: Message | null;
onSendMessage?: (
message: string,
options?: { interruptFeedback?: string },
) => void;
}) {
const message = useMessage(messageId);
const startOfResearch = useStore((state) =>
@@ -126,6 +136,7 @@ function MessageListItem({
waitForFeedback={waitForFeedback}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
onSendMessage={onSendMessage}
/>
</div>
);
@@ -269,11 +280,16 @@ function PlanCard({
interruptMessage,
onFeedback,
waitForFeedback,
onSendMessage,
}: {
className?: string;
message: Message;
interruptMessage?: Message | null;
onFeedback?: (feedback: { option: Option }) => void;
onSendMessage?: (
message: string,
options?: { interruptFeedback?: string },
) => void;
waitForFeedback?: boolean;
}) {
const plan = useMemo<{
@@ -284,13 +300,15 @@ function PlanCard({
return parseJSON(message.content ?? "", {});
}, [message.content]);
const handleAccept = useCallback(async () => {
await sendMessage(
`${GREETINGS[Math.floor(Math.random() * GREETINGS.length)]}! ${Math.random() > 0.5 ? "Let's get started." : "Let's start."}`,
{
interruptFeedback: "accepted",
},
);
}, []);
if (onSendMessage) {
onSendMessage(
`${GREETINGS[Math.floor(Math.random() * GREETINGS.length)]}! ${Math.random() > 0.5 ? "Let's get started." : "Let's start."}`,
{
interruptFeedback: "accepted",
},
);
}
}, [onSendMessage]);
return (
<Card className={cn("w-full", className)}>
<CardHeader>

View File

@@ -17,16 +17,15 @@ export function MessagesBlock({ className }: { className?: string }) {
const abortControllerRef = useRef<AbortController | null>(null);
const [feedback, setFeedback] = useState<{ option: Option } | null>(null);
const handleSend = useCallback(
async (message: string) => {
async (message: string, options?: { interruptFeedback?: string }) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
await sendMessage(
message,
{
maxPlanIterations: 1,
maxStepNum: 3,
interruptFeedback: feedback?.option.value,
interruptFeedback:
options?.interruptFeedback ?? feedback?.option.value,
},
{
abortSignal: abortController.signal,
@@ -37,6 +36,7 @@ export function MessagesBlock({ className }: { className?: string }) {
[feedback],
);
const handleCancel = useCallback(() => {
console.info("cancel");
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
@@ -51,7 +51,11 @@ export function MessagesBlock({ className }: { className?: string }) {
}, [setFeedback]);
return (
<div className={cn("flex h-full flex-col", className)}>
<MessageListView className="flex flex-grow" onFeedback={handleFeedback} />
<MessageListView
className="flex flex-grow"
onFeedback={handleFeedback}
onSendMessage={handleSend}
/>
<div className="relative flex h-42 shrink-0 pb-4">
{!responding && messageCount === 0 && (
<ConversationStarter

View File

@@ -4,12 +4,19 @@
import { PythonOutlined } from "@ant-design/icons";
import { motion } from "framer-motion";
import { LRUCache } from "lru-cache";
import { BookOpenText, Search } from "lucide-react";
import { BookOpenText, PencilRuler, Search } from "lucide-react";
import { useMemo } from "react";
import SyntaxHighlighter from "react-syntax-highlighter";
import { docco } from "react-syntax-highlighter/dist/esm/styles/hljs";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "~/components/ui/accordion";
import { Skeleton } from "~/components/ui/skeleton";
import { findMCPTool } from "~/core/mcp";
import type { ToolCallRuntime } from "~/core/messages";
import { useMessage, useStore } from "~/core/store";
import { parseJSON } from "~/core/utils";
@@ -20,6 +27,7 @@ import Image from "./image";
import { LoadingAnimation } from "./loading-animation";
import { Markdown } from "./markdown";
import { RainbowText } from "./rainbow-text";
import { Tooltip } from "./tooltip";
export function ResearchActivitiesBlock({
className,
@@ -85,6 +93,8 @@ function ActivityListItem({ messageId }: { messageId: string }) {
return <CrawlToolCall key={toolCall.id} toolCall={toolCall} />;
} else if (toolCall.name === "python_repl_tool") {
return <PythonToolCall key={toolCall.id} toolCall={toolCall} />;
} else {
return <MCPToolCall key={toolCall.id} toolCall={toolCall} />;
}
}
}
@@ -142,7 +152,7 @@ function WebSearchToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
className="flex items-center"
animated={searchResults === undefined}
>
<Search className={"mr-2"} />
<Search size={16} className={"mr-2"} />
<span>Searching for&nbsp;</span>
<span className="max-w-[500px] overflow-hidden text-ellipsis whitespace-nowrap">
{(toolCall.args as { query: string }).query}
@@ -229,12 +239,12 @@ function CrawlToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const title = useMemo(() => __pageCache.get(url), [url]);
return (
<section className="mt-4 pl-4">
<div className="font-medium italic">
<div>
<RainbowText
className="flex items-center"
className="flex items-center text-base font-medium italic"
animated={toolCall.result === undefined}
>
<BookOpenText className={"mr-2"} />
<BookOpenText size={16} className={"mr-2"} />
<span>Reading</span>
</RainbowText>
</div>
@@ -264,15 +274,22 @@ function PythonToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
}, [toolCall.args]);
return (
<section className="mt-4 pl-4">
<div className="font-medium italic">
<div className="flex items-center">
<PythonOutlined className={"mr-2"} />
<RainbowText animated={toolCall.result === undefined}>
<RainbowText
className="text-base font-medium italic"
animated={toolCall.result === undefined}
>
Running Python code
</RainbowText>
</div>
<div className="px-5">
<div className="bg-accent mt-2 rounded-md p-2 text-sm">
<SyntaxHighlighter language="python" style={docco}>
<div className="bg-accent mt-2 max-h-[400px] w-[800px] overflow-y-auto rounded-md p-2 text-sm">
<SyntaxHighlighter
customStyle={{ background: "transparent" }}
language="python"
style={docco}
>
{code}
</SyntaxHighlighter>
</div>
@@ -280,3 +297,43 @@ function PythonToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
</section>
);
}
function MCPToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const tool = useMemo(() => findMCPTool(toolCall.name), [toolCall.name]);
return (
<section className="mt-4 pl-4">
<div className="w-fit overflow-y-auto rounded-md py-0">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger>
<Tooltip title={tool?.description}>
<div className="flex items-center font-medium italic">
<PencilRuler size={16} className={"mr-2"} />
<RainbowText
className="pr-0.5 text-base font-medium italic"
animated={toolCall.result === undefined}
>
Running {toolCall.name ? toolCall.name + "()" : "MCP tool"}
</RainbowText>
</div>
</Tooltip>
</AccordionTrigger>
<AccordionContent>
{toolCall.result && (
<div className="bg-accent max-h-[400px] w-[800px] overflow-y-auto rounded-md text-sm">
<SyntaxHighlighter
customStyle={{ background: "transparent" }}
language="json"
style={docco}
>
{toolCall.result}
</SyntaxHighlighter>
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</section>
);
}

View File

@@ -17,7 +17,7 @@ export function Tooltip({
title?: React.ReactNode;
}) {
return (
<ShadcnTooltip>
<ShadcnTooltip delayDuration={750}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent className={className}>{title}</TooltipContent>
</ShadcnTooltip>