mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-22 13:44:46 +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>
|
||||
);
|
||||
}
|
||||
62
web/src/components/ui/button.tsx
Normal file
62
web/src/components/ui/button.tsx
Normal 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 };
|
||||
92
web/src/components/ui/card.tsx
Normal file
92
web/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
66
web/src/components/ui/tabs.tsx
Normal file
66
web/src/components/ui/tabs.tsx
Normal 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 }
|
||||
61
web/src/components/ui/tooltip.tsx
Normal file
61
web/src/components/ui/tooltip.tsx
Normal 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
71
web/src/core/api/chat.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
web/src/core/api/index.ts
Normal file
2
web/src/core/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./chat";
|
||||
export * from "./types";
|
||||
81
web/src/core/api/types.ts
Normal file
81
web/src/core/api/types.ts
Normal 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;
|
||||
2
web/src/core/messages/index.ts
Normal file
2
web/src/core/messages/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./merge-message";
|
||||
93
web/src/core/messages/merge-message.ts
Normal file
93
web/src/core/messages/merge-message.ts
Normal 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;
|
||||
}
|
||||
28
web/src/core/messages/types.ts
Normal file
28
web/src/core/messages/types.ts
Normal 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;
|
||||
}
|
||||
1
web/src/core/rehype/index.ts
Normal file
1
web/src/core/rehype/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./rehype-split-words-into-spans";
|
||||
40
web/src/core/rehype/rehype-split-words-into-spans.ts
Normal file
40
web/src/core/rehype/rehype-split-words-into-spans.ts
Normal 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">);
|
||||
};
|
||||
}
|
||||
4
web/src/core/sse/StreamEvent.ts
Normal file
4
web/src/core/sse/StreamEvent.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface StreamEvent {
|
||||
type: string;
|
||||
data: object;
|
||||
}
|
||||
70
web/src/core/sse/fetch-stream.ts
Normal file
70
web/src/core/sse/fetch-stream.ts
Normal 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;
|
||||
}
|
||||
2
web/src/core/sse/index.ts
Normal file
2
web/src/core/sse/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./fetch-stream";
|
||||
export * from "./StreamEvent";
|
||||
1
web/src/core/store/index.ts
Normal file
1
web/src/core/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./store";
|
||||
239
web/src/core/store/store.ts
Normal file
239
web/src/core/store/store.ts
Normal 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?",
|
||||
// );
|
||||
3
web/src/core/utils/deep-clone.ts
Normal file
3
web/src/core/utils/deep-clone.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function deepClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
1
web/src/core/utils/index.ts
Normal file
1
web/src/core/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./time";
|
||||
3
web/src/core/utils/time.ts
Normal file
3
web/src/core/utils/time.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
40
web/src/env.js
Normal file
40
web/src/env.js
Normal 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
6
web/src/lib/utils.ts
Normal 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
208
web/src/styles/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user