mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-21 13:24:44 +08:00
chore: merge with web UI project
This commit is contained in:
53
web/src/app/_components/conversation-starter.tsx
Normal file
53
web/src/app/_components/conversation-starter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
web/src/app/_components/fav-icon.tsx
Normal file
15
web/src/app/_components/fav-icon.tsx
Normal 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";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
167
web/src/app/_components/input-box.tsx
Normal file
167
web/src/app/_components/input-box.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
web/src/app/_components/loading-animation.module.css
Normal file
34
web/src/app/_components/loading-animation.module.css
Normal 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;
|
||||
}
|
||||
25
web/src/app/_components/loading-animation.tsx
Normal file
25
web/src/app/_components/loading-animation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
web/src/app/_components/logo.tsx
Normal file
20
web/src/app/_components/logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
web/src/app/_components/markdown.tsx
Normal file
124
web/src/app/_components/markdown.tsx
Normal 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, "");
|
||||
}
|
||||
347
web/src/app/_components/message-list-view.tsx
Normal file
347
web/src/app/_components/message-list-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
web/src/app/_components/messages-block.tsx
Normal file
70
web/src/app/_components/messages-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
web/src/app/_components/rainbow-text.module.css
Normal file
24
web/src/app/_components/rainbow-text.module.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
19
web/src/app/_components/rainbow-text.tsx
Normal file
19
web/src/app/_components/rainbow-text.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
web/src/app/_components/research-activities-block.tsx
Normal file
204
web/src/app/_components/research-activities-block.tsx
Normal 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 </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 </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>
|
||||
);
|
||||
}
|
||||
102
web/src/app/_components/research-block.tsx
Normal file
102
web/src/app/_components/research-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
web/src/app/_components/research-report-block.tsx
Normal file
21
web/src/app/_components/research-report-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
web/src/app/_components/rolling-text.tsx
Normal file
33
web/src/app/_components/rolling-text.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
web/src/app/_components/scroll-container.tsx
Normal file
54
web/src/app/_components/scroll-container.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
web/src/app/_components/welcome.tsx
Normal file
31
web/src/app/_components/welcome.tsx
Normal 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
30
web/src/app/layout.tsx
Normal 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
61
web/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user