chore: merge with web UI project

This commit is contained in:
Li Xin
2025-04-17 12:02:23 +08:00
parent 3aebb67e2b
commit fd7a803753
58 changed files with 10290 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
import { motion } from "framer-motion";
import { cn } from "~/lib/utils";
import { Welcome } from "./welcome";
const questions = [
"How many times taller is the Eiffel Tower than the tallest building in the world?",
"How many years does an average Tesla battery last compared to a gasoline engine?",
"How many liters of water are required to produce 1 kg of beef?",
"How many times faster is the speed of light compared to the speed of sound?",
];
export function ConversationStarter({
className,
onSend,
}: {
className?: string;
onSend?: (message: string) => void;
}) {
return (
<div className={cn("flex flex-col items-center", className)}>
<div className="pointer-events-none fixed inset-0 flex items-center justify-center">
<Welcome className="pointer-events-auto mb-15 w-[75%] -translate-y-24" />
</div>
<ul className="flex flex-wrap">
{questions.map((question, index) => (
<motion.li
key={question}
className="flex w-1/2 shrink-0 p-2 active:scale-105"
style={{ transition: "all 0.2s ease-out" }}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.2,
delay: index * 0.1 + 0.5,
ease: "easeOut",
}}
>
<div
className="cursor-pointer rounded-2xl border bg-[rgba(255,255,255,0.5)] px-4 py-4 text-gray-500 transition-all duration-300 hover:bg-[rgba(255,255,255,1)] hover:text-gray-900 hover:shadow-md"
onClick={() => {
onSend?.(question);
}}
>
{question}
</div>
</motion.li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,15 @@
export function FavIcon({ url, title }: { url: string; title?: string }) {
return (
<img
className="h-4 w-4 rounded-full bg-slate-100 shadow-sm"
width={16}
height={16}
src={new URL(url).origin + "/favicon.ico"}
alt={title}
onError={(e) => {
e.currentTarget.src =
"https://perishablepress.com/wp/wp-content/images/2021/favicon-standard.png";
}}
/>
);
}

View File

@@ -0,0 +1,167 @@
import { ArrowUpOutlined, CloseOutlined } from "@ant-design/icons";
import { AnimatePresence, motion } from "framer-motion";
import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Button } from "~/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import type { Option } from "~/core/messages";
import { cn } from "~/lib/utils";
export function InputBox({
className,
size,
responding,
feedback,
onSend,
onCancel,
onRemoveFeedback,
}: {
className?: string;
size?: "large" | "normal";
responding?: boolean;
feedback?: { option: Option } | null;
onSend?: (message: string, feedback: { option: Option } | null) => void;
onCancel?: () => void;
onRemoveFeedback?: () => void;
}) {
const [message, setMessage] = useState("");
const [imeStatus, setImeStatus] = useState<"active" | "inactive">("inactive");
const [indent, setIndent] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const feedbackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (feedback) {
setMessage("");
setTimeout(() => {
if (feedbackRef.current) {
setIndent(feedbackRef.current.offsetWidth);
}
}, 200);
}
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
}, [feedback]);
const handleSendMessage = useCallback(() => {
if (responding) {
onCancel?.();
} else {
if (message.trim() === "") {
return;
}
if (onSend) {
onSend(message, feedback ?? null);
setMessage("");
onRemoveFeedback?.();
}
}
}, [responding, onCancel, message, onSend, feedback, onRemoveFeedback]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (responding) {
return;
}
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
imeStatus === "inactive"
) {
event.preventDefault();
handleSendMessage();
}
},
[responding, imeStatus, handleSendMessage],
);
return (
<div className={cn("relative rounded-[24px] border bg-white", className)}>
<div className="w-full">
<AnimatePresence>
{feedback && (
<motion.div
ref={feedbackRef}
className="absolute top-0 left-0 mt-3 ml-2 flex items-center justify-center gap-1 rounded-2xl border border-[#007aff] bg-white px-2 py-0.5"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<div className="flex h-full w-full items-center justify-center text-sm text-[#007aff] opacity-90">
{feedback.option.text}
</div>
<CloseOutlined
className="cursor-pointer text-[9px]"
onClick={onRemoveFeedback}
/>
</motion.div>
)}
</AnimatePresence>
<textarea
ref={textareaRef}
className={cn(
"m-0 w-full resize-none border-none px-4 py-3 text-lg",
size === "large" ? "min-h-32" : "min-h-4",
)}
style={{ textIndent: feedback ? `${indent}px` : 0 }}
placeholder={
feedback
? `Describe how you ${feedback.option.text.toLocaleLowerCase()}?`
: "What can I do for you?"
}
value={message}
onCompositionStart={() => setImeStatus("active")}
onCompositionEnd={() => setImeStatus("inactive")}
onKeyDown={handleKeyDown}
onChange={(event) => {
setMessage(event.target.value);
}}
/>
</div>
<div className="flex items-center px-4 py-2">
<div className="flex grow"></div>
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className={cn(
"h-10 w-10 rounded-full",
responding ? "bg-button-hover" : "bg-button",
)}
onClick={handleSendMessage}
>
{responding ? (
<div className="flex h-10 w-10 items-center justify-center">
<div className="h-4 w-4 rounded-sm bg-red-300" />
</div>
) : (
<ArrowUpOutlined />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{responding ? "Stop" : "Send"}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
@keyframes bouncing-animation {
to {
opacity: 0.1;
transform: translateY(-8px);
}
}
.loadingAnimation {
display: flex;
}
.loadingAnimation > div {
width: 8px;
height: 8px;
margin: 2px 4px;
border-radius: 50%;
background-color: #a3a1a1;
opacity: 1;
animation: bouncing-animation 0.5s infinite alternate;
}
.loadingAnimation.sm > div {
width: 6px;
height: 6px;
margin: 1px 2px;
}
.loadingAnimation > div:nth-child(2) {
animation-delay: 0.2s;
}
.loadingAnimation > div:nth-child(3) {
animation-delay: 0.4s;
}

View File

@@ -0,0 +1,25 @@
import { cn } from "~/lib/utils";
import styles from "./loading-animation.module.css";
export function LoadingAnimation({
className,
size = "normal",
}: {
className?: string;
size?: "normal" | "sm";
}) {
return (
<div
className={cn(
styles.loadingAnimation,
size === "sm" && styles.sm,
className,
)}
>
<div></div>
<div></div>
<div></div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { useState } from "react";
import { Markdown } from "./markdown";
export function Logo() {
const [text, setText] = useState("🦌 Deer");
return (
<a
className="text-sm opacity-70 transition-opacity duration-300 hover:opacity-100"
target="_blank"
href="https://github.com/bytedance/deer"
onMouseEnter={() =>
setText("🦌 **D**eep **E**xploration and **E**fficient **R**esearch")
}
onMouseLeave={() => setText("🦌 Deer")}
>
<Markdown animate>{text}</Markdown>
</a>
);
}

View File

@@ -0,0 +1,124 @@
import { CheckOutlined, CopyOutlined } from "@ant-design/icons";
import { useMemo, useState } from "react";
import ReactMarkdown, {
type Options as ReactMarkdownOptions,
} from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import "katex/dist/katex.min.css";
import { Button } from "~/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { rehypeSplitWordsIntoSpans } from "~/core/rehype";
import { cn } from "~/lib/utils";
export function Markdown({
className,
children,
style,
enableCopy,
animate = false,
...props
}: ReactMarkdownOptions & {
className?: string;
enableCopy?: boolean;
style?: React.CSSProperties;
animate?: boolean;
}) {
const rehypePlugins = useMemo(() => {
if (animate) {
return [rehypeKatex, rehypeSplitWordsIntoSpans];
}
return [rehypeKatex];
}, [animate]);
return (
<div
className={cn(className, "markdown flex flex-col gap-4")}
style={style}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={rehypePlugins}
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
}}
{...props}
>
{dropMarkdownQuote(processKatexInMarkdown(children))}
</ReactMarkdown>
{enableCopy && typeof children === "string" && (
<div className="flex">
<CopyButton content={children} />
</div>
)}
</div>
);
}
function CopyButton({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="rounded-full"
onClick={async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
} catch (error) {
console.error(error);
}
}}
>
{copied ? (
<CheckOutlined className="h-4 w-4" />
) : (
<CopyOutlined className="h-4 w-4" />
)}{" "}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy</p>
</TooltipContent>
</Tooltip>
);
}
function processKatexInMarkdown(markdown?: string | null) {
if (!markdown) return markdown;
const markdownWithKatexSyntax = markdown
.replace(/\\\\\[/g, "$$$$") // Replace '\\[' with '$$'
.replace(/\\\\\]/g, "$$$$") // Replace '\\]' with '$$'
.replace(/\\\\\(/g, "$$$$") // Replace '\\(' with '$$'
.replace(/\\\\\)/g, "$$$$") // Replace '\\)' with '$$'
.replace(/\\\[/g, "$$$$") // Replace '\[' with '$$'
.replace(/\\\]/g, "$$$$") // Replace '\]' with '$$'
.replace(/\\\(/g, "$$$$") // Replace '\(' with '$$'
.replace(/\\\)/g, "$$$$"); // Replace '\)' with '$$';
return markdownWithKatexSyntax;
}
function dropMarkdownQuote(markdown?: string | null) {
if (!markdown) return markdown;
return markdown
.replace(/^```markdown\n/gm, "")
.replace(/^```text\n/gm, "")
.replace(/^```\n/gm, "")
.replace(/\n```$/gm, "");
}

View File

@@ -0,0 +1,347 @@
import { parse } from "best-effort-json-parser";
import { motion } from "framer-motion";
import { useCallback, useMemo } from "react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import type { Message, Option } from "~/core/messages";
import {
openResearch,
sendMessage,
useMessage,
useResearchTitle,
useStore,
} from "~/core/store";
import { cn } from "~/lib/utils";
import { LoadingAnimation } from "./loading-animation";
import { Markdown } from "./markdown";
import { RainbowText } from "./rainbow-text";
import { RollingText } from "./rolling-text";
import { ScrollContainer } from "./scroll-container";
export function MessageListView({
className,
onFeedback,
}: {
className?: string;
onFeedback?: (feedback: { option: Option }) => void;
}) {
const messageIds = useStore((state) => state.messageIds);
const interruptMessage = useStore((state) => {
if (messageIds.length >= 2) {
const lastMessage = state.messages.get(
messageIds[messageIds.length - 1]!,
);
return lastMessage?.finishReason === "interrupt" ? lastMessage : null;
}
return null;
});
const waitingForFeedbackMessageId = useStore((state) => {
if (messageIds.length >= 2) {
const lastMessage = state.messages.get(
messageIds[messageIds.length - 1]!,
);
if (lastMessage && lastMessage.finishReason === "interrupt") {
return state.messageIds[state.messageIds.length - 2];
}
}
return null;
});
const responding = useStore((state) => state.responding);
const noOngoingResearch = useStore(
(state) => state.ongoingResearchId === null,
);
const ongoingResearchIsOpen = useStore(
(state) => state.ongoingResearchId === state.openResearchId,
);
return (
<ScrollContainer
className={cn(
"flex h-full w-full flex-col overflow-y-auto pt-4",
className,
)}
scrollShadowColor="#f7f5f3"
>
<ul className="flex flex-col">
{messageIds.map((messageId) => (
<MessageListItem
key={messageId}
messageId={messageId}
waitForFeedback={waitingForFeedbackMessageId === messageId}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
/>
))}
<div className="flex h-8 w-full shrink-0"></div>
</ul>
{responding && (noOngoingResearch || !ongoingResearchIsOpen) && (
<LoadingAnimation className="ml-4" />
)}
</ScrollContainer>
);
}
function MessageListItem({
className,
messageId,
waitForFeedback,
onFeedback,
interruptMessage,
}: {
className?: string;
messageId: string;
waitForFeedback?: boolean;
onFeedback?: (feedback: { option: Option }) => void;
interruptMessage?: Message | null;
}) {
const message = useMessage(messageId);
const startOfResearch = useStore((state) =>
state.researchIds.includes(messageId),
);
if (message) {
if (
message.role === "user" ||
message.agent === "coordinator" ||
message.agent === "planner" ||
startOfResearch
) {
let content: React.ReactNode;
if (message.agent === "planner") {
content = (
<div className="w-full px-4">
<PlanCard
message={message}
waitForFeedback={waitForFeedback}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
/>
</div>
);
} else if (startOfResearch) {
content = (
<div className="w-full px-4">
<ResearchCard researchId={message.id} />
</div>
);
} else {
content = message.content ? (
<div
className={cn(
"flex w-full px-4",
message.role === "user" && "justify-end",
className,
)}
>
<MessageBubble message={message}>
<div className="flex w-full flex-col">
<Markdown
animate={message.role !== "user" && message.isStreaming}
>
{message?.content}
</Markdown>
</div>
</MessageBubble>
</div>
) : null;
}
if (content) {
return (
<motion.li
className="mt-10"
key={messageId}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
style={{ transition: "all 0.2s ease-out" }}
transition={{
duration: 0.2,
ease: "easeOut",
}}
>
{content}
</motion.li>
);
}
}
return null;
}
function MessageBubble({
className,
message,
children,
}: {
className?: string;
message: Message;
children: React.ReactNode;
}) {
return (
<div
className={cn(
`flex w-fit max-w-[85%] flex-col rounded-2xl px-4 py-3 shadow-xs`,
message.role === "user" &&
"text-primary-foreground rounded-ee-none bg-[#007aff]",
message.role === "assistant" && "rounded-es-none bg-white",
className,
)}
>
{children}
</div>
);
}
function ResearchCard({
className,
researchId,
}: {
className?: string;
researchId: string;
}) {
const reportId = useStore((state) =>
state.researchReportIds.get(researchId),
);
const hasReport = useStore((state) =>
state.researchReportIds.has(researchId),
);
const reportGenerating = useStore(
(state) => hasReport && state.messages.get(reportId!)!.isStreaming,
);
const openResearchId = useStore((state) => state.openResearchId);
const state = useMemo(() => {
if (hasReport) {
return reportGenerating ? "Generating report..." : "Report generated";
}
return "Researching...";
}, [hasReport, reportGenerating]);
const title = useResearchTitle(researchId);
const handleOpen = useCallback(() => {
if (openResearchId === researchId) {
openResearch(null);
} else {
openResearch(researchId);
}
}, [openResearchId, researchId]);
return (
<Card className={cn("w-full bg-white", className)}>
<CardHeader>
<CardTitle>
<RainbowText animated={state !== "Report generated"}>
{title !== undefined && title !== "" ? title : "Deep Research"}
</RainbowText>
</CardTitle>
</CardHeader>
<CardFooter>
<div className="flex w-full">
<RollingText className="flex-grow text-sm opacity-50">
{state}
</RollingText>
<Button onClick={handleOpen}>
{!openResearchId ? "Open" : "Close"}
</Button>
</div>
</CardFooter>
</Card>
);
}
}
const GREETINGS = ["Cool", "Sounds great", "Looks good", "Great", "Awesome"];
function PlanCard({
className,
message,
interruptMessage,
onFeedback,
waitForFeedback,
}: {
className?: string;
message: Message;
interruptMessage?: Message | null;
onFeedback?: (feedback: { option: Option }) => void;
waitForFeedback?: boolean;
}) {
const plan = useMemo<{
title?: string;
thought?: string;
steps?: { title?: string; description?: string }[];
}>(() => {
return parse(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",
},
);
}, []);
return (
<Card className={cn("w-full bg-white", className)}>
<CardHeader>
<CardTitle>
<h1 className="text-xl font-medium">
<Markdown animate>
{plan.title !== undefined && plan.title !== ""
? plan.title
: "Deep Research"}
</Markdown>
</h1>
</CardTitle>
</CardHeader>
<CardContent>
<Markdown className="opacity-80" animate>
{plan.thought}
</Markdown>
{plan.steps && (
<ul className="my-2 flex list-decimal flex-col gap-4 border-l-[2px] pl-8">
{plan.steps.map((step, i) => (
<li key={`step-${i}`}>
<h3 className="mb text-lg font-medium">
<Markdown animate>{step.title}</Markdown>
</h3>
<div className="text-muted-foreground text-sm">
<Markdown animate>{step.description}</Markdown>
</div>
</li>
))}
</ul>
)}
</CardContent>
<CardFooter className="flex justify-end">
{!message.isStreaming && interruptMessage?.options?.length && (
<motion.div
className="flex gap-2"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
{interruptMessage?.options.map((option) => (
<Button
key={option.value}
variant={option.value === "accepted" ? "default" : "outline"}
disabled={!waitForFeedback}
onClick={() => {
if (option.value === "accepted") {
void handleAccept();
} else {
onFeedback?.({
option,
});
}
}}
>
{option.text}
</Button>
))}
</motion.div>
)}
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,70 @@
import { useCallback, useRef, useState } from "react";
import type { Option } from "~/core/messages";
import { sendMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { ConversationStarter } from "./conversation-starter";
import { InputBox } from "./input-box";
import { MessageListView } from "./message-list-view";
export function MessagesBlock({ className }: { className?: string }) {
const messageCount = useStore((state) => state.messageIds.length);
const responding = useStore((state) => state.responding);
const abortControllerRef = useRef<AbortController | null>(null);
const [feedback, setFeedback] = useState<{ option: Option } | null>(null);
const handleSend = useCallback(
async (message: string) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
await sendMessage(
message,
{
maxPlanIterations: 1,
maxStepNum: 3,
interruptFeedback: feedback?.option.value,
},
{
abortSignal: abortController.signal,
},
);
} catch {}
},
[feedback],
);
const handleCancel = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
const handleFeedback = useCallback(
(feedback: { option: Option }) => {
setFeedback(feedback);
},
[setFeedback],
);
const handleRemoveFeedback = useCallback(() => {
setFeedback(null);
}, [setFeedback]);
return (
<div className={cn("flex h-full flex-col", className)}>
<MessageListView className="flex flex-grow" onFeedback={handleFeedback} />
<div className="relative flex h-42 shrink-0 pb-4">
{!responding && messageCount === 0 && (
<ConversationStarter
className="absolute top-[-218px] left-0"
onSend={handleSend}
/>
)}
<InputBox
className="h-full w-full"
responding={responding}
feedback={feedback}
onSend={handleSend}
onCancel={handleCancel}
onRemoveFeedback={handleRemoveFeedback}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
.animated {
background: linear-gradient(
to right,
rgba(0, 0, 0, 0.3) 15%,
rgba(0, 0, 0, 0.7) 35%,
rgba(0, 0, 0, 0.7) 65%,
rgba(0, 0, 0, 0.3) 85%
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-fill-color: transparent;
background-size: 500% auto;
animation: textShine 2s ease-in-out infinite alternate;
}
@keyframes textShine {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}

View File

@@ -0,0 +1,19 @@
import { cn } from "~/lib/utils";
import styles from "./rainbow-text.module.css";
export function RainbowText({
animated,
className,
children,
}: {
animated?: boolean;
className?: string;
children?: React.ReactNode;
}) {
return (
<span className={cn(animated && styles.animated, className)}>
{children}
</span>
);
}

View File

@@ -0,0 +1,204 @@
import {
BookOutlined,
PythonOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { parse } from "best-effort-json-parser";
import { motion } from "framer-motion";
import { LRUCache } from "lru-cache";
import { useMemo } from "react";
import SyntaxHighlighter from "react-syntax-highlighter";
import { docco } from "react-syntax-highlighter/dist/esm/styles/hljs";
import type { ToolCallRuntime } from "~/core/messages";
import { useMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { FavIcon } from "./fav-icon";
import { LoadingAnimation } from "./loading-animation";
import { Markdown } from "./markdown";
import { RainbowText } from "./rainbow-text";
export function ResearchActivitiesBlock({
className,
researchId,
}: {
className?: string;
researchId: string;
}) {
const activityIds = useStore((state) =>
state.researchActivityIds.get(researchId),
)!;
const ongoing = useStore((state) => state.ongoingResearchId === researchId);
return (
<>
<ul className={cn("flex flex-col py-4", className)}>
{activityIds.map(
(activityId, i) =>
i !== 0 && (
<motion.li
key={activityId}
style={{ transition: "all 0.4s ease-out" }}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: "easeOut",
}}
>
<ActivityMessage messageId={activityId} />
<ActivityListItem messageId={activityId} />
{i !== activityIds.length - 1 && <hr className="my-8" />}
</motion.li>
),
)}
</ul>
{ongoing && <LoadingAnimation className="mx-4 my-12" />}
</>
);
}
function ActivityMessage({ messageId }: { messageId: string }) {
const message = useMessage(messageId);
if (message?.agent && message.content) {
if (message.agent !== "reporter" && message.agent !== "planner") {
return (
<div className="px-4 py-2">
<Markdown animate>{message.content}</Markdown>
</div>
);
}
}
return null;
}
function ActivityListItem({ messageId }: { messageId: string }) {
const message = useMessage(messageId);
if (message) {
if (!message.isStreaming && message.toolCalls?.length) {
for (const toolCall of message.toolCalls) {
if (toolCall.name === "web_search") {
return <WebSearchToolCall key={toolCall.id} toolCall={toolCall} />;
} else if (toolCall.name === "crawl_tool") {
return <CrawlToolCall key={toolCall.id} toolCall={toolCall} />;
} else if (toolCall.name === "python_repl_tool") {
return <PythonToolCall key={toolCall.id} toolCall={toolCall} />;
}
}
}
}
return null;
}
const __pageCache = new LRUCache<string, string>({ max: 100 });
function WebSearchToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const searchResults = useMemo<
{ title: string; url: string; content: string }[]
>(() => {
let results: { title: string; url: string; content: string }[] | undefined =
undefined;
try {
results = toolCall.result ? parse(toolCall.result) : undefined;
} catch {
results = undefined;
}
if (Array.isArray(results)) {
results.forEach((result: { url: string; title: string }) => {
__pageCache.set(result.url, result.title);
});
} else {
results = [];
}
return results;
}, [toolCall.result]);
return (
<section>
<div className="font-medium italic">
<RainbowText
className="flex items-center"
animated={searchResults === undefined}
>
<SearchOutlined 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}
</span>
</RainbowText>
</div>
{searchResults && (
<div className="px-5">
<ul className="mt-2 flex flex-wrap gap-4">
{searchResults.map((searchResult, i) => (
<motion.li
key={`search-result-${i}`}
className="text-muted-foreground flex max-w-40 gap-2 rounded-md bg-slate-100 px-2 py-1 text-sm"
initial={{ opacity: 0, y: 10, scale: 0.66 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.2,
delay: i * 0.1,
ease: "easeOut",
}}
>
<FavIcon url={searchResult.url} title={searchResult.title} />
<a href={searchResult.url} target="_blank">
{searchResult.title}
</a>
</motion.li>
))}
</ul>
</div>
)}
</section>
);
}
function CrawlToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const url = useMemo(
() => (toolCall.args as { url: string }).url,
[toolCall.args],
);
const title = useMemo(() => __pageCache.get(url), [url]);
return (
<section>
<div className="font-medium italic">
<RainbowText
className="flex items-center"
animated={toolCall.result === undefined}
>
<BookOutlined className={"mr-2"} />
<span>Reading&nbsp;</span>
<li className="flex w-fit gap-1 px-2 py-1 text-sm">
<FavIcon url={url} title={title} />
<a className="hover:underline" href={url} target="_blank">
{title}
</a>
</li>
</RainbowText>
</div>
</section>
);
}
function PythonToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const code = useMemo<string>(() => {
return (toolCall.args as { code: string }).code;
}, [toolCall.args]);
return (
<section>
<div className="font-medium italic">
<PythonOutlined className={"mr-2"} />
<RainbowText animated={toolCall.result === undefined}>
Running Python code
</RainbowText>
</div>
<div className="px-5">
<div className="mt-2 rounded-md bg-slate-50 p-2 text-sm">
<SyntaxHighlighter language="python" style={docco}>
{code}
</SyntaxHighlighter>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,102 @@
import { CloseOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { openResearch, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { ResearchActivitiesBlock } from "./research-activities-block";
import { ResearchReportBlock } from "./research-report-block";
import { ScrollContainer } from "./scroll-container";
export function ResearchBlock({
className,
researchId = null,
}: {
className?: string;
researchId: string | null;
}) {
const reportId = useStore((state) =>
researchId ? state.researchReportIds.get(researchId) : undefined,
);
const [activeTab, setActiveTab] = useState("activities");
const hasReport = useStore((state) =>
researchId ? state.researchReportIds.has(researchId) : false,
);
useEffect(() => {
if (hasReport) {
setActiveTab("report");
}
}, [hasReport]);
return (
<div className={cn("h-full w-full", className)}>
<Card className={cn("relative h-full w-full pt-4", className)}>
<div className="absolute right-4 flex h-9 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
className="text-gray-400"
size="sm"
variant="ghost"
onClick={() => {
openResearch(null);
}}
>
<CloseOutlined />
</Button>
</TooltipTrigger>
<TooltipContent>Close</TooltipContent>
</Tooltip>
</div>
<Tabs
className="flex h-full w-full flex-col"
value={activeTab}
onValueChange={(value) => setActiveTab(value)}
>
<div className="flex w-full justify-center">
<TabsList className="">
<TabsTrigger
className="px-8"
value="report"
disabled={!hasReport}
>
Report
</TabsTrigger>
<TabsTrigger className="px-8" value="activities">
Activities
</TabsTrigger>
</TabsList>
</div>
<TabsContent className="h-full min-h-0 flex-grow px-8" value="report">
<ScrollContainer className="px-5pb-20 h-full">
{reportId && (
<ResearchReportBlock className="mt-4" messageId={reportId} />
)}
</ScrollContainer>
</TabsContent>
<TabsContent
className="h-full min-h-0 flex-grow px-8"
value="activities"
>
<ScrollContainer className="h-full">
{researchId && (
<ResearchActivitiesBlock
className="mt-4"
researchId={researchId}
/>
)}
</ScrollContainer>
</TabsContent>
</Tabs>
</Card>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { useMessage } from "~/core/store";
import { cn } from "~/lib/utils";
import { LoadingAnimation } from "./loading-animation";
import { Markdown } from "./markdown";
export function ResearchReportBlock({
className,
messageId,
}: {
className?: string;
messageId: string;
}) {
const message = useMessage(messageId);
return (
<div className={cn("flex flex-col pb-8", className)}>
<Markdown animate>{message?.content}</Markdown>
{message?.isStreaming && <LoadingAnimation className="my-12" />}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "~/lib/utils";
export function RollingText({
className,
children,
}: {
className?: string;
children?: string | string[];
}) {
return (
<span
className={cn(
"relative flex h-[2em] items-center overflow-hidden",
className,
)}
>
<AnimatePresence mode="popLayout">
<motion.div
className="absolute w-fit"
style={{ transition: "all 0.3s ease-in-out" }}
initial={{ y: "100%", opacity: 0 }}
animate={{ y: "0%", opacity: 1 }}
exit={{ y: "-100%", opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
{children}
</motion.div>
</AnimatePresence>
</span>
);
}

View File

@@ -0,0 +1,54 @@
import { useStickToBottom } from "use-stick-to-bottom";
import { cn } from "~/lib/utils";
export function ScrollContainer({
className,
children,
scrollShadow = true,
scrollShadowColor = "white",
}: {
className?: string;
children: React.ReactNode;
scrollShadow?: boolean;
scrollShadowColor?: string;
}) {
const { scrollRef, contentRef } = useStickToBottom({
initial: "instant",
});
return (
<div className={cn("relative", className)}>
{scrollShadow && (
<>
<div
className={cn(
"absolute top-0 right-0 left-0 z-10 h-10 bg-gradient-to-b",
`from-[var(--scroll-shadow-color)] to-transparent`,
)}
style={
{
"--scroll-shadow-color": scrollShadowColor,
} as React.CSSProperties
}
></div>
<div
className={cn(
"absolute right-0 bottom-0 left-0 z-10 h-10 bg-gradient-to-b",
`from-transparent to-[var(--scroll-shadow-color)]`,
)}
style={
{
"--scroll-shadow-color": scrollShadowColor,
} as React.CSSProperties
}
></div>
</>
)}
<div ref={scrollRef} className={"h-full w-full overflow-y-scroll"}>
<div className="h-fit w-full" ref={contentRef}>
{children}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { motion } from "framer-motion";
import { cn } from "~/lib/utils";
export function Welcome({ className }: { className?: string }) {
return (
<motion.div
className={cn("flex flex-col", className)}
style={{ transition: "all 0.2s ease-out" }}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
>
<h3 className="mb-2 text-center text-3xl font-medium">
👋 Hello, there!
</h3>
<div className="px-4 text-center text-lg text-gray-400">
Welcome to{" "}
<a
href="https://github.com/bytedance/deer"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
🦌 Deer
</a>
, a research tool built on cutting-edge language models, helps you
search on web, browse information, and handle complex tasks.
</div>
</motion.div>
);
}

30
web/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,30 @@
import "~/styles/globals.css";
import { type Metadata } from "next";
import { Geist } from "next/font/google";
import { TooltipProvider } from "~/components/ui/tooltip";
export const metadata: Metadata = {
title: "🦌 Deer",
description:
"Deep Exploration and Efficient Research, an AI tool that combines language models with specialized tools for research tasks.",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${geist.variable}`}>
<body className="h-screen w-screen overflow-hidden overscroll-none bg-[#f7f5f3]">
<TooltipProvider>{children}</TooltipProvider>
</body>
</html>
);
}

61
web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,61 @@
"use client";
import { GithubOutlined } from "@ant-design/icons";
import Link from "next/link";
import { useMemo } from "react";
import { Button } from "~/components/ui/button";
import { useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { Logo } from "./_components/logo";
import { MessagesBlock } from "./_components/messages-block";
import { ResearchBlock } from "./_components/research-block";
export default function HomePage() {
const openResearchId = useStore((state) => state.openResearchId);
const doubleColumnMode = useMemo(
() => openResearchId !== null,
[openResearchId],
);
return (
<div className="flex h-full w-full justify-center">
<header className="fixed top-0 left-0 flex h-12 w-full w-screen items-center justify-between px-4">
<Logo />
<Button
className="opacity-70 transition-opacity duration-300 hover:opacity-100"
variant="ghost"
size="icon"
asChild
>
<Link href="https://github.com/bytedance/deer" target="_blank">
<GithubOutlined />
</Link>
</Button>
</header>
<div
className={cn(
"flex h-full w-full justify-center px-4 pt-12",
doubleColumnMode && "gap-8",
)}
>
<MessagesBlock
className={cn(
"shrink-0 transition-all duration-300 ease-out",
!doubleColumnMode &&
`w-[768px] translate-x-[min(calc((100vw-538px)*0.75/2),960px/2)]`,
doubleColumnMode && `w-[538px]`,
)}
/>
<ResearchBlock
className={cn(
"w-[min(calc((100vw-538px)*0.75),960px)] pb-4 transition-all duration-300 ease-out",
!doubleColumnMode && "scale-0",
doubleColumnMode && "",
)}
researchId={openResearchId}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "~/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(
buttonVariants({ variant, size, className }),
"cursor-pointer active:scale-105",
)}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "~/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,61 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

71
web/src/core/api/chat.ts Normal file
View File

@@ -0,0 +1,71 @@
import { env } from "~/env";
import { fetchStream } from "../sse";
import { sleep } from "../utils";
import type { ChatEvent } from "./types";
export function chatStream(
userMessage: string,
params: {
thread_id: string;
max_plan_iterations: number;
max_step_num: number;
interrupt_feedback?: string;
},
options: { abortSignal?: AbortSignal } = {},
) {
if (location.search.includes("mock")) {
return chatStreamMock(userMessage, params, options);
}
return fetchStream<ChatEvent>(
(env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api") + "/chat/stream",
{
body: JSON.stringify({
messages: [{ role: "user", content: userMessage }],
auto_accepted_plan: false,
...params,
}),
signal: options.abortSignal,
},
);
}
async function* chatStreamMock(
userMessage: string,
_: {
thread_id: string;
max_plan_iterations: number;
max_step_num: number;
} = {
thread_id: "__mock__",
max_plan_iterations: 3,
max_step_num: 1,
},
options: { abortSignal?: AbortSignal } = {},
): AsyncIterable<ChatEvent> {
const res = await fetch("/mock.txt", {
signal: options.abortSignal,
});
await sleep(800);
const text = await res.text();
const chunks = text.split("\n\n");
for (const chunk of chunks) {
const [eventRaw, dataRaw] = chunk.split("\n") as [string, string];
const [, event] = eventRaw.split("event: ", 2) as [string, string];
const [, data] = dataRaw.split("data: ", 2) as [string, string];
if (event === "message_chunk") {
await sleep(0);
} else if (event === "tool_call_result") {
await sleep(1500);
}
try {
yield {
type: event,
data: JSON.parse(data),
} as ChatEvent;
} catch (e) {
console.error(e);
}
}
}

View File

@@ -0,0 +1,2 @@
export * from "./chat";
export * from "./types";

81
web/src/core/api/types.ts Normal file
View File

@@ -0,0 +1,81 @@
import type { Option } from "../messages";
import type { StreamEvent } from "../sse";
// Tool Calls
export interface ToolCall {
type: "tool_call";
id: string;
name: string;
args: Record<string, unknown>;
}
export interface ToolCallChunk {
type: "tool_call_chunk";
index: number;
id: string;
name: string;
args: string;
}
// Events
interface GenericEvent<T extends string, D extends object> extends StreamEvent {
type: T;
data: {
id: string;
thread_id: string;
agent: "coordinator" | "planner" | "researcher" | "coder" | "reporter";
role: "user" | "assistant" | "tool";
finish_reason?: "stop" | "tool_calls" | "interrupt";
} & D;
}
export interface MessageChunkEvent
extends GenericEvent<
"message_chunk",
{
content?: string;
}
> {}
export interface ToolCallsEvent
extends GenericEvent<
"tool_calls",
{
tool_calls: ToolCall[];
tool_call_chunks: ToolCallChunk[];
}
> {}
export interface ToolCallChunksEvent
extends GenericEvent<
"tool_call_chunks",
{
tool_call_chunks: ToolCallChunk[];
}
> {}
export interface ToolCallResultEvent
extends GenericEvent<
"tool_call_result",
{
tool_call_id: string;
content?: string;
}
> {}
export interface InterruptEvent
extends GenericEvent<
"interrupt",
{
options: Option[];
}
> {}
export type ChatEvent =
| MessageChunkEvent
| ToolCallsEvent
| ToolCallChunksEvent
| ToolCallResultEvent
| InterruptEvent;

View File

@@ -0,0 +1,2 @@
export * from "./types";
export * from "./merge-message";

View File

@@ -0,0 +1,93 @@
import type {
ChatEvent,
InterruptEvent,
MessageChunkEvent,
ToolCallChunksEvent,
ToolCallResultEvent,
ToolCallsEvent,
} from "../api";
import { deepClone } from "../utils/deep-clone";
import type { Message } from "./types";
export function mergeMessage(message: Message, event: ChatEvent) {
if (event.type === "message_chunk") {
mergeTextMessage(message, event);
} else if (event.type === "tool_calls" || event.type === "tool_call_chunks") {
mergeToolCallMessage(message, event);
} else if (event.type === "tool_call_result") {
mergeToolCallResultMessage(message, event);
} else if (event.type === "interrupt") {
mergeInterruptMessage(message, event);
}
if (event.data.finish_reason) {
message.finishReason = event.data.finish_reason;
message.isStreaming = false;
if (message.toolCalls) {
message.toolCalls.forEach((toolCall) => {
if (toolCall.argsChunks?.length) {
toolCall.args = JSON.parse(toolCall.argsChunks.join(""));
delete toolCall.argsChunks;
}
});
}
}
return deepClone(message);
}
function mergeTextMessage(message: Message, event: MessageChunkEvent) {
if (event.data.content) {
message.content += event.data.content;
message.contentChunks.push(event.data.content);
}
}
function mergeToolCallMessage(
message: Message,
event: ToolCallsEvent | ToolCallChunksEvent,
) {
if (event.type === "tool_calls" && event.data.tool_calls[0]?.name) {
message.toolCalls = event.data.tool_calls.map((raw) => ({
id: raw.id,
name: raw.name,
args: raw.args,
result: undefined,
}));
}
message.toolCalls ??= [];
for (const chunk of event.data.tool_call_chunks) {
if (chunk.id) {
const toolCall = message.toolCalls.find(
(toolCall) => toolCall.id === chunk.id,
);
if (toolCall) {
toolCall.argsChunks = [chunk.args];
}
} else {
const streamingToolCall = message.toolCalls.find(
(toolCall) => toolCall.argsChunks?.length,
);
if (streamingToolCall) {
streamingToolCall.argsChunks!.push(chunk.args);
}
}
}
}
function mergeToolCallResultMessage(
message: Message,
event: ToolCallResultEvent,
) {
const toolCall = message.toolCalls?.find(
(toolCall) => toolCall.id === event.data.tool_call_id,
);
if (toolCall) {
toolCall.result = event.data.content;
}
}
function mergeInterruptMessage(message: Message, event: InterruptEvent) {
message.isStreaming = false;
message.options = event.data.options;
}

View File

@@ -0,0 +1,28 @@
export type MessageRole = "user" | "assistant" | "tool";
export interface Message {
id: string;
threadId: string;
agent?: "coordinator" | "planner" | "researcher" | "coder" | "reporter";
role: MessageRole;
isStreaming?: boolean;
content: string;
contentChunks: string[];
toolCalls?: ToolCallRuntime[];
options?: Option[];
finishReason?: "stop" | "interrupt" | "tool_calls";
interruptFeedback?: string;
}
export interface Option {
text: string;
value: string;
}
export interface ToolCallRuntime {
id: string;
name: string;
args: Record<string, unknown>;
argsChunks?: string[];
result?: string;
}

View File

@@ -0,0 +1 @@
export * from "./rehype-split-words-into-spans";

View File

@@ -0,0 +1,40 @@
import type { Element, Root, ElementContent } from "hast";
import { visit } from "unist-util-visit";
import type { BuildVisitor } from "unist-util-visit";
export function rehypeSplitWordsIntoSpans() {
return (tree: Root) => {
visit(tree, "element", ((node: Element) => {
if (
["p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "strong"].includes(
node.tagName,
) &&
node.children
) {
const newChildren: Array<ElementContent> = [];
node.children.forEach((child) => {
if (child.type === "text") {
const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
const segments = segmenter.segment(child.value);
const words = Array.from(segments)
.map((segment) => segment.segment)
.filter(Boolean);
words.forEach((word: string) => {
newChildren.push({
type: "element",
tagName: "span",
properties: {
className: "animate-fade-in",
},
children: [{ type: "text", value: word }],
});
});
} else {
newChildren.push(child);
}
});
node.children = newChildren;
}
}) as BuildVisitor<Root, "element">);
};
}

View File

@@ -0,0 +1,4 @@
export interface StreamEvent {
type: string;
data: object;
}

View File

@@ -0,0 +1,70 @@
import { type StreamEvent } from "./StreamEvent";
export async function* fetchStream<T extends StreamEvent>(
url: string,
init: RequestInit,
): AsyncIterable<T> {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
...init,
});
if (response.status !== 200) {
throw new Error(`Failed to fetch from ${url}: ${response.status}`);
}
// Read from response body, event by event. An event always ends with a '\n\n'.
const reader = response.body
?.pipeThrough(new TextDecoderStream())
.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += value;
while (true) {
const index = buffer.indexOf("\n\n");
if (index === -1) {
break;
}
const chunk = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const event = parseEvent<T>(chunk);
if (event) {
yield event;
}
}
}
}
function parseEvent<T extends StreamEvent>(chunk: string) {
let resultType = "message";
let resultData: object | null = null;
for (const line of chunk.split("\n")) {
const pos = line.indexOf(": ");
if (pos === -1) {
continue;
}
const key = line.slice(0, pos);
const value = line.slice(pos + 2);
if (key === "event") {
resultType = value;
} else if (key === "data") {
resultData = JSON.parse(value);
}
}
if (resultType === "message" && resultData === null) {
return undefined;
}
return {
type: resultType,
data: resultData,
} as T;
}

View File

@@ -0,0 +1,2 @@
export * from "./fetch-stream";
export * from "./StreamEvent";

View File

@@ -0,0 +1 @@
export * from "./store";

239
web/src/core/store/store.ts Normal file
View File

@@ -0,0 +1,239 @@
import { parse } from "best-effort-json-parser";
import { nanoid } from "nanoid";
import { create } from "zustand";
import { chatStream } from "../api";
import type { Message } from "../messages";
import { mergeMessage } from "../messages";
const THREAD_ID = nanoid();
export const useStore = create<{
responding: boolean;
threadId: string | undefined;
messageIds: string[];
messages: Map<string, Message>;
researchIds: string[];
researchPlanIds: Map<string, string>;
researchReportIds: Map<string, string>;
researchActivityIds: Map<string, string[]>;
ongoingResearchId: string | null;
openResearchId: string | null;
}>(() => ({
responding: false,
threadId: THREAD_ID,
messageIds: [],
messages: new Map<string, Message>(),
researchIds: [],
researchPlanIds: new Map<string, string>(),
researchReportIds: new Map<string, string>(),
researchActivityIds: new Map<string, string[]>(),
ongoingResearchId: null,
openResearchId: null,
}));
export async function sendMessage(
content: string,
{
maxPlanIterations = 1,
maxStepNum = 3,
interruptFeedback,
}: {
maxPlanIterations?: number;
maxStepNum?: number;
interruptFeedback?: string;
} = {},
options: { abortSignal?: AbortSignal } = {},
) {
appendMessage({
id: nanoid(),
threadId: THREAD_ID,
role: "user",
content: content,
contentChunks: [content],
});
setResponding(true);
try {
const stream = chatStream(
content,
{
thread_id: THREAD_ID,
max_plan_iterations: maxPlanIterations,
max_step_num: maxStepNum,
interrupt_feedback: interruptFeedback,
},
options,
);
for await (const event of stream) {
const { type, data } = event;
const messageId = data.id;
let message: Message | undefined;
if (type === "tool_call_result") {
message = findMessageByToolCallId(data.tool_call_id);
} else if (!existsMessage(messageId)) {
message = {
id: messageId,
threadId: data.thread_id,
agent: data.agent,
role: data.role,
content: "",
contentChunks: [],
isStreaming: true,
interruptFeedback,
};
appendMessage(message);
}
message ??= findMessage(messageId);
if (message) {
message = mergeMessage(message, event);
updateMessage(message);
}
}
} finally {
setResponding(false);
}
}
function setResponding(value: boolean) {
useStore.setState({ responding: value });
}
function existsMessage(id: string) {
return useStore.getState().messageIds.includes(id);
}
function findMessage(id: string) {
return useStore.getState().messages.get(id);
}
function findMessageByToolCallId(toolCallId: string) {
return Array.from(useStore.getState().messages.values())
.reverse()
.find((message) => {
if (message.toolCalls) {
return message.toolCalls.some((toolCall) => toolCall.id === toolCallId);
}
return false;
});
}
function appendMessage(message: Message) {
if (
message.agent === "coder" ||
message.agent === "reporter" ||
message.agent === "researcher"
) {
appendResearchActivity(message);
}
useStore.setState({
messageIds: [...useStore.getState().messageIds, message.id],
messages: new Map(useStore.getState().messages).set(message.id, message),
});
}
function updateMessage(message: Message) {
if (
message.agent === "researcher" ||
message.agent === "coder" ||
message.agent === "reporter"
) {
const id = message.id;
if (!getOngoingResearchId()) {
appendResearch(id);
openResearch(id);
}
}
if (
getOngoingResearchId() &&
message.agent === "reporter" &&
!message.isStreaming
) {
setOngoingResearchId(null);
}
useStore.setState({
messages: new Map(useStore.getState().messages).set(message.id, message),
});
}
function getOngoingResearchId() {
return useStore.getState().ongoingResearchId;
}
function setOngoingResearchId(value: string | null) {
return useStore.setState({
ongoingResearchId: value,
});
}
function appendResearch(researchId: string) {
let planMessage: Message | undefined;
const reversedMessageIds = [...useStore.getState().messageIds].reverse();
for (const messageId of reversedMessageIds) {
const message = findMessage(messageId);
if (message?.agent === "planner") {
planMessage = message;
break;
}
}
const messageIds = [researchId];
messageIds.unshift(planMessage!.id);
useStore.setState({
ongoingResearchId: researchId,
researchIds: [...useStore.getState().researchIds, researchId],
researchPlanIds: new Map(useStore.getState().researchPlanIds).set(
researchId,
planMessage!.id,
),
researchActivityIds: new Map(useStore.getState().researchActivityIds).set(
researchId,
messageIds,
),
});
}
function appendResearchActivity(message: Message) {
const researchId = getOngoingResearchId();
if (researchId) {
const researchActivityIds = useStore.getState().researchActivityIds;
useStore.setState({
researchActivityIds: new Map(researchActivityIds).set(researchId, [
...researchActivityIds.get(researchId)!,
message.id,
]),
});
if (message.agent === "reporter") {
useStore.setState({
researchReportIds: new Map(useStore.getState().researchReportIds).set(
researchId,
message.id,
),
});
}
}
}
export function openResearch(researchId: string | null) {
useStore.setState({
openResearchId: researchId,
});
}
export function useResearchTitle(researchId: string) {
const planMessage = useMessage(
useStore.getState().researchPlanIds.get(researchId),
);
return planMessage ? parse(planMessage.content).title : undefined;
}
export function useMessage(messageId: string | null | undefined) {
return useStore((state) =>
messageId ? state.messages.get(messageId) : undefined,
);
}
// void sendMessage(
// "How many times taller is the Eiffel Tower than the tallest building in the world?",
// );

View File

@@ -0,0 +1,3 @@
export function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}

View File

@@ -0,0 +1 @@
export * from "./time";

View File

@@ -0,0 +1,3 @@
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

40
web/src/env.js Normal file
View File

@@ -0,0 +1,40 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
NODE_ENV: z.enum(["development", "test", "production"]),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
NEXT_PUBLIC_API_URL: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

6
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

208
web/src/styles/globals.css Normal file
View File

@@ -0,0 +1,208 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans:
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--animate-fade-in: fade-in 1s;
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: rgba(0, 0, 0, 0.72);
--card: oklch(1 0 0);
--card-foreground: var(--foreground);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
input,
textarea {
outline: none;
}
.markdown {
line-height: 1.75;
a {
color: blue;
&:hover {
text-decoration: underline;
}
}
h1 {
@apply text-2xl font-bold;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
h2 {
@apply text-xl font-bold;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
h3 {
@apply text-lg font-bold;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
h4 {
@apply text-base font-bold;
margin-bottom: 0.5rem;
}
h5 {
@apply text-sm font-bold;
}
h6 {
@apply text-xs font-bold;
}
ul {
@apply list-disc pl-4;
}
ol {
@apply list-decimal pl-4;
}
table {
@apply w-full;
table-layout: fixed;
border-collapse: collapse;
th,
td {
padding: 4px 8px;
border: 1px solid var(--border);
}
th {
@apply bg-muted;
}
}
}