feat: implement basic web app

This commit is contained in:
Henry Li
2026-01-15 23:40:21 +08:00
parent c7d68c6d3f
commit cecc684de1
49 changed files with 4142 additions and 626 deletions

View File

@@ -16,6 +16,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@langchain/core": "^1.1.15",
"@langchain/langgraph-sdk": "^1.5.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -28,25 +30,31 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.90.17",
"@types/hast": "^3.0.4",
"@xyflow/react": "^12.10.0",
"ai": "^6.0.33",
"best-effort-json-parser": "^1.2.1",
"better-auth": "^1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
"hast": "^1.0.0",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next": "^16.1.1",
"next": "^15.2.8",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"shiki": "^3.21.0",
"streamdown": "^2.0.1",
"streamdown": "1.5.1",
"tailwind-merge": "^3.4.0",
"tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1",
"uuid": "^13.0.0",
"zod": "^3.24.2"
},
"devDependencies": {

1801
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
import { type NextRequest } from "next/server";
export const runtime = "nodejs";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return handleProxy(request, (await params).path);
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return handleProxy(request, (await params).path);
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return handleProxy(request, (await params).path);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return handleProxy(request, (await params).path);
}
async function handleProxy(request: NextRequest, pathSegments: string[]) {
const targetUrl = `http://localhost:2024/${pathSegments.join("/")}`;
// Preserve query parameters
const searchParams = request.nextUrl.searchParams.toString();
const fullUrl = searchParams ? `${targetUrl}?${searchParams}` : targetUrl;
// Prepare headers
const headers = new Headers();
request.headers.forEach((value, key) => {
// Skip Next.js specific headers
if (!key.startsWith("x-") && key !== "host" && key !== "connection") {
headers.set(key, value);
}
});
// Prepare fetch options
const fetchOptions: RequestInit = {
method: request.method,
headers,
};
// Add body for non-GET requests
if (request.method !== "GET" && request.method !== "HEAD") {
fetchOptions.body = await request.text();
}
try {
const response = await fetch(fullUrl, fetchOptions);
// Check if response is SSE
const contentType = response.headers.get("content-type");
const isSSE = contentType?.includes("text/event-stream");
if (isSSE && response.body) {
// Handle SSE streaming
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// Handle regular responses
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
responseHeaders.set(key, value);
});
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
} catch (error) {
console.error("Proxy error:", error);
return new Response(JSON.stringify({ error: "Proxy request failed" }), {
status: 502,
headers: { "Content-Type": "application/json" },
});
}
}

View File

@@ -3,8 +3,10 @@ import "@/styles/globals.css";
import { type Metadata } from "next";
import { Geist } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
export const metadata: Metadata = {
title: "DeerFlow",
title: "Welcome to DeerFlow",
description: "A LangChain-based framework for building super agents.",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
@@ -18,8 +20,21 @@ export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${geist.variable}`}>
<body>{children}</body>
<html
className={geist.variable}
suppressContentEditableWarning
suppressHydrationWarning
>
<body>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -1,7 +1,5 @@
export default async function LandingPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center">
Welcome to DeerFlow 2
</main>
<main className="flex min-h-screen flex-col">DeerFlow Landing Page</main>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { type HumanMessage } from "@langchain/core/messages";
import { useStream } from "@langchain/langgraph-sdk/react";
import { useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { BreadcrumbItem } from "@/components/ui/breadcrumb";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/message-list/message-list";
import {
WorkspaceContainer,
WorkspaceBody,
WorkspaceHeader,
WorkspaceFooter,
} from "@/components/workspace/workspace-container";
import { getLangGraphClient } from "@/core/api";
import type { MessageThread, MessageThreadState } from "@/core/thread";
import { titleOfThread } from "@/core/thread/utils";
import { uuid } from "@/core/utils/uuid";
const langGraphClient = getLangGraphClient();
export default function ChatPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const isNewThread = useMemo(
() => threadIdFromPath === "new",
[threadIdFromPath],
);
const [threadId, setThreadId] = useState<string | null>(null);
useEffect(() => {
if (threadIdFromPath !== "new") {
setThreadId(threadIdFromPath);
} else {
setThreadId(uuid());
}
}, [threadIdFromPath]);
const thread = useStream<MessageThreadState>({
client: langGraphClient,
assistantId: "lead_agent",
threadId: !isNewThread ? threadId : undefined,
reconnectOnMount: true,
fetchStateHistory: true,
onFinish() {
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
},
});
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
const text = message.text.trim();
if (isNewThread) {
router.replace(`/workspace/chats/${threadId}`);
}
await thread.submit(
{
messages: [
{
type: "human",
content: [
{
type: "text",
text,
},
],
},
] as HumanMessage[],
},
{
threadId: isNewThread ? threadId! : undefined,
streamSubgraphs: true,
streamResumable: true,
context: {
thread_id: threadId!,
model: "deepseek-v3.2",
thinking_enabled: true,
},
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
},
[isNewThread, queryClient, router, thread, threadId],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
return (
<WorkspaceContainer>
<WorkspaceHeader>
<BreadcrumbItem className="hidden md:block">
{isNewThread
? "New"
: titleOfThread(thread as unknown as MessageThread)}
</BreadcrumbItem>
</WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full justify-center">
<MessageList className="size-full" thread={thread} />
</div>
</WorkspaceBody>
<WorkspaceFooter>
<InputBox
className="max-w-(--container-width-md)"
autoFocus={isNewThread}
status={thread.isLoading ? "streaming" : "ready"}
onSubmit={handleSubmit}
onStop={handleStop}
/>
</WorkspaceFooter>
</WorkspaceContainer>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
export default function ChatsPage() {
return (
<WorkspaceContainer>
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full justify-center"></div>
</WorkspaceBody>
</WorkspaceContainer>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Overscroll } from "@/components/workspace/overscroll";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
const queryClient = new QueryClient();
export default function WorkspaceLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<QueryClientProvider client={queryClient}>
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
} as React.CSSProperties
}
>
<Overscroll behavior="none" overflow="hidden" />
<WorkspaceSidebar />
<SidebarInset>{children}</SidebarInset>
</SidebarProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function WorkspacePage() {
return redirect("/workspace/chats/new");
}

View File

@@ -0,0 +1,26 @@
"use client";
import { useStream } from "@langchain/langgraph-sdk/react";
import { useParams } from "next/navigation";
import { getLangGraphClient } from "@/core/api";
import type { MessageThreadState } from "@/core/thread";
const apiClient = getLangGraphClient();
export default function TestPage() {
const { thread_id: threadId } = useParams<{ thread_id: string }>();
const thread = useStream<MessageThreadState>({
client: apiClient,
assistantId: "lead_agent",
threadId,
reconnectOnMount: true,
fetchStateHistory: true,
});
return (
<div className="p-4">
<div>{threadId}</div>
<div>{thread.isLoading ? "loading" : "not loading"}</div>
</div>
);
}

View File

@@ -23,14 +23,14 @@ type ChainOfThoughtContextValue = {
};
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
null
null,
);
const useChainOfThought = () => {
const context = useContext(ChainOfThoughtContext);
if (!context) {
throw new Error(
"ChainOfThought components must be used within ChainOfThought"
"ChainOfThought components must be used within ChainOfThought",
);
}
return context;
@@ -59,7 +59,7 @@ export const ChainOfThought = memo(
const chainOfThoughtContext = useMemo(
() => ({ isOpen, setIsOpen }),
[isOpen, setIsOpen]
[isOpen, setIsOpen],
);
return (
@@ -72,40 +72,42 @@ export const ChainOfThought = memo(
</div>
</ChainOfThoughtContext.Provider>
);
}
},
);
export type ChainOfThoughtHeaderProps = ComponentProps<
typeof CollapsibleTrigger
>;
> & {
icon?: React.ReactElement;
};
export const ChainOfThoughtHeader = memo(
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
({ className, children, icon, ...props }: ChainOfThoughtHeaderProps) => {
const { isOpen, setIsOpen } = useChainOfThought();
return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
className,
)}
{...props}
>
<BrainIcon className="size-4" />
{icon ?? <BrainIcon className="size-4" />}
<span className="flex-1 text-left">
{children ?? "Chain of Thought"}
</span>
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
isOpen ? "rotate-180" : "rotate-0",
)}
/>
</CollapsibleTrigger>
</Collapsible>
);
}
},
);
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
@@ -137,13 +139,13 @@ export const ChainOfThoughtStep = memo(
"flex gap-2 text-sm",
statusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in",
className
className,
)}
{...props}
>
<div className="relative mt-0.5">
<Icon className="size-4" />
<div className="-mx-px absolute top-7 bottom-0 left-1/2 w-px bg-border" />
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
</div>
<div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div>
@@ -154,7 +156,7 @@ export const ChainOfThoughtStep = memo(
</div>
</div>
);
}
},
);
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
@@ -165,7 +167,7 @@ export const ChainOfThoughtSearchResults = memo(
className={cn("flex flex-wrap items-center gap-2", className)}
{...props}
/>
)
),
);
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
@@ -173,13 +175,13 @@ export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
export const ChainOfThoughtSearchResult = memo(
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
<Badge
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
className={cn("gap-1 px-2 py-0.5 text-xs font-normal", className)}
variant="secondary"
{...props}
>
{children}
</Badge>
)
),
);
export type ChainOfThoughtContentProps = ComponentProps<
@@ -195,8 +197,8 @@ export const ChainOfThoughtContent = memo(
<CollapsibleContent
className={cn(
"mt-2 space-y-3",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className,
)}
{...props}
>
@@ -204,7 +206,7 @@ export const ChainOfThoughtContent = memo(
</CollapsibleContent>
</Collapsible>
);
}
},
);
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
@@ -214,12 +216,12 @@ export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
export const ChainOfThoughtImage = memo(
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
<div className={cn("mt-2 space-y-2", className)} {...props}>
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
<div className="bg-muted relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg p-3">
{children}
</div>
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
</div>
)
),
);
ChainOfThought.displayName = "ChainOfThought";

View File

@@ -1,10 +1,7 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
@@ -30,9 +27,9 @@ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
"group flex w-full flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
className,
)}
{...props}
/>
@@ -47,10 +44,10 @@ export const MessageContent = ({
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden",
"group-[.is-user]:bg-secondary group-[.is-user]:text-foreground group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:px-4 group-[.is-user]:py-3",
"group-[.is-assistant]:text-foreground",
className
className,
)}
{...props}
>
@@ -116,7 +113,7 @@ type MessageBranchContextType = {
};
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
null,
);
const useMessageBranch = () => {
@@ -124,7 +121,7 @@ const useMessageBranch = () => {
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
"MessageBranch components must be used within MessageBranch",
);
}
@@ -201,7 +198,7 @@ export const MessageBranchContent = ({
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
index === currentBranch ? "block" : "hidden",
)}
key={branch.key}
{...props}
@@ -294,8 +291,8 @@ export const MessageBranchPage = ({
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
"text-muted-foreground border-none bg-transparent shadow-none",
className,
)}
{...props}
>
@@ -311,12 +308,12 @@ export const MessageResponse = memo(
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
className,
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
(prevProps, nextProps) => prevProps.children === nextProps.children,
);
MessageResponse.displayName = "MessageResponse";
@@ -343,7 +340,7 @@ export function MessageAttachment({
<div
className={cn(
"group relative size-24 overflow-hidden rounded-lg",
className
className,
)}
{...props}
>
@@ -359,7 +356,7 @@ export function MessageAttachment({
{onRemove && (
<Button
aria-label="Remove attachment"
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
className="bg-background/80 hover:bg-background absolute top-2 right-2 size-6 rounded-full p-0 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
@@ -376,7 +373,7 @@ export function MessageAttachment({
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<div className="bg-muted text-muted-foreground flex size-full shrink-0 items-center justify-center rounded-lg">
<PaperclipIcon className="size-4" />
</div>
</TooltipTrigger>
@@ -387,7 +384,7 @@ export function MessageAttachment({
{onRemove && (
<Button
aria-label="Remove attachment"
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
className="hover:bg-accent size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
@@ -420,7 +417,7 @@ export function MessageAttachments({
<div
className={cn(
"ml-auto flex w-fit flex-wrap items-start gap-2",
className
className,
)}
{...props}
>
@@ -439,7 +436,7 @@ export const MessageToolbar = ({
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
className,
)}
{...props}
>

View File

@@ -37,7 +37,7 @@ import {
import { cn } from "@/lib/utils";
import type { ChatStatus, FileUIPart } from "ai";
import {
CornerDownLeftIcon,
ArrowUpIcon,
ImageIcon,
Loader2Icon,
MicIcon,
@@ -95,22 +95,22 @@ export type PromptInputControllerProps = {
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
__registerFileInput: (
ref: RefObject<HTMLInputElement | null>,
open: () => void
open: () => void,
) => void;
};
const PromptInputController = createContext<PromptInputControllerProps | null>(
null
null,
);
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
null
null,
);
export const usePromptInputController = () => {
const ctx = useContext(PromptInputController);
if (!ctx) {
throw new Error(
"Wrap your component inside <PromptInputProvider> to use usePromptInputController()."
"Wrap your component inside <PromptInputProvider> to use usePromptInputController().",
);
}
return ctx;
@@ -124,7 +124,7 @@ export const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext);
if (!ctx) {
throw new Error(
"Wrap your component inside <PromptInputProvider> to use useProviderAttachments()."
"Wrap your component inside <PromptInputProvider> to use useProviderAttachments().",
);
}
return ctx;
@@ -170,8 +170,8 @@ export function PromptInputProvider({
url: URL.createObjectURL(file),
mediaType: file.type,
filename: file.name,
}))
)
})),
),
);
}, []);
@@ -224,7 +224,7 @@ export function PromptInputProvider({
openFileDialog,
fileInputRef,
}),
[attachmentFiles, add, remove, clear, openFileDialog]
[attachmentFiles, add, remove, clear, openFileDialog],
);
const __registerFileInput = useCallback(
@@ -232,7 +232,7 @@ export function PromptInputProvider({
fileInputRef.current = ref.current;
openRef.current = open;
},
[]
[],
);
const controller = useMemo<PromptInputControllerProps>(
@@ -245,7 +245,7 @@ export function PromptInputProvider({
attachments,
__registerFileInput,
}),
[textInput, clearInput, attachments, __registerFileInput]
[textInput, clearInput, attachments, __registerFileInput],
);
return (
@@ -270,7 +270,7 @@ export const usePromptInputAttachments = () => {
const context = provider ?? local;
if (!context) {
throw new Error(
"usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
"usePromptInputAttachments must be used within a PromptInput or PromptInputProvider",
);
}
return context;
@@ -301,14 +301,14 @@ export function PromptInputAttachment({
<HoverCardTrigger asChild>
<div
className={cn(
"group relative flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-md border border-border px-1.5 font-medium text-sm transition-all hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
className
"group border-border hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md border px-1.5 text-sm font-medium transition-all select-none",
className,
)}
key={data.id}
{...props}
>
<div className="relative size-5 shrink-0">
<div className="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0">
<div className="bg-background absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded transition-opacity group-hover:opacity-0">
{isImage ? (
<img
alt={filename || "attachment"}
@@ -318,7 +318,7 @@ export function PromptInputAttachment({
width={20}
/>
) : (
<div className="flex size-5 items-center justify-center text-muted-foreground">
<div className="text-muted-foreground flex size-5 items-center justify-center">
<PaperclipIcon className="size-3" />
</div>
)}
@@ -356,11 +356,11 @@ export function PromptInputAttachment({
)}
<div className="flex items-center gap-2.5">
<div className="min-w-0 flex-1 space-y-1 px-0.5">
<h4 className="truncate font-semibold text-sm leading-none">
<h4 className="truncate text-sm leading-none font-semibold">
{filename || (isImage ? "Image" : "Attachment")}
</h4>
{data.mediaType && (
<p className="truncate font-mono text-muted-foreground text-xs">
<p className="text-muted-foreground truncate font-mono text-xs">
{data.mediaType}
</p>
)}
@@ -392,7 +392,7 @@ export function PromptInputAttachments({
return (
<div
className={cn("flex flex-wrap items-center gap-2 p-3 w-full", className)}
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
{...props}
>
{attachments.files.map((file) => (
@@ -451,7 +451,7 @@ export type PromptInputProps = Omit<
}) => void;
onSubmit: (
message: PromptInputMessage,
event: FormEvent<HTMLFormElement>
event: FormEvent<HTMLFormElement>,
) => void | Promise<void>;
};
@@ -507,7 +507,7 @@ export const PromptInput = ({
return f.type === pattern;
});
},
[accept]
[accept],
);
const addLocal = useCallback(
@@ -558,7 +558,7 @@ export const PromptInput = ({
return prev.concat(next);
});
},
[matchesAccept, maxFiles, maxFileSize, onError]
[matchesAccept, maxFiles, maxFileSize, onError],
);
const removeLocal = useCallback(
@@ -570,7 +570,7 @@ export const PromptInput = ({
}
return prev.filter((file) => file.id !== id);
}),
[]
[],
);
const clearLocal = useCallback(
@@ -583,7 +583,7 @@ export const PromptInput = ({
}
return [];
}),
[]
[],
);
const add = usingProvider ? controller.attachments.add : addLocal;
@@ -611,7 +611,7 @@ export const PromptInput = ({
useEffect(() => {
const form = formRef.current;
if (!form) return;
if (globalDrop) return // when global drop is on, let the document-level handler own drops
if (globalDrop) return; // when global drop is on, let the document-level handler own drops
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
@@ -667,7 +667,7 @@ export const PromptInput = ({
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current
[usingProvider]
[usingProvider],
);
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
@@ -679,7 +679,7 @@ export const PromptInput = ({
};
const convertBlobUrlToDataUrl = async (
url: string
url: string,
): Promise<string | null> => {
try {
const response = await fetch(url);
@@ -704,7 +704,7 @@ export const PromptInput = ({
openFileDialog,
fileInputRef: inputRef,
}),
[files, add, remove, clear, openFileDialog]
[files, add, remove, clear, openFileDialog],
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
@@ -736,7 +736,7 @@ export const PromptInput = ({
};
}
return item;
})
}),
)
.then((convertedFiles: FileUIPart[]) => {
try {
@@ -839,7 +839,7 @@ export const PromptInputTextarea = ({
// Check if the submit button is disabled before submitting
const form = e.currentTarget.form;
const submitButton = form?.querySelector(
'button[type="submit"]'
'button[type="submit"]',
) as HTMLButtonElement | null;
if (submitButton?.disabled) {
return;
@@ -1030,7 +1030,7 @@ export const PromptInputSubmit = ({
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <CornerDownLeftIcon className="size-4" />;
let Icon = <ArrowUpIcon className="size-4" />;
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
@@ -1123,7 +1123,7 @@ export const PromptInputSpeechButton = ({
}: PromptInputSpeechButtonProps) => {
const [isListening, setIsListening] = useState(false);
const [recognition, setRecognition] = useState<SpeechRecognition | null>(
null
null,
);
const recognitionRef = useRef<SpeechRecognition | null>(null);
@@ -1202,8 +1202,8 @@ export const PromptInputSpeechButton = ({
<PromptInputButton
className={cn(
"relative transition-all duration-200",
isListening && "animate-pulse bg-accent text-accent-foreground",
className
isListening && "bg-accent text-accent-foreground animate-pulse",
className,
)}
disabled={!recognition}
onClick={toggleListening}
@@ -1230,9 +1230,9 @@ export const PromptInputSelectTrigger = ({
}: PromptInputSelectTriggerProps) => (
<SelectTrigger
className={cn(
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
"text-muted-foreground border-none bg-transparent font-medium shadow-none transition-colors",
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
className
className,
)}
{...props}
/>
@@ -1282,7 +1282,7 @@ export type PromptInputHoverCardTriggerProps = ComponentProps<
>;
export const PromptInputHoverCardTrigger = (
props: PromptInputHoverCardTriggerProps
props: PromptInputHoverCardTriggerProps,
) => <HoverCardTrigger {...props} />;
export type PromptInputHoverCardContentProps = ComponentProps<
@@ -1318,8 +1318,8 @@ export const PromptInputTabLabel = ({
}: PromptInputTabLabelProps) => (
<h3
className={cn(
"mb-2 px-3 font-medium text-muted-foreground text-xs",
className
"text-muted-foreground mb-2 px-3 text-xs font-medium",
className,
)}
{...props}
/>
@@ -1342,8 +1342,8 @@ export const PromptInputTabItem = ({
}: PromptInputTabItemProps) => (
<div
className={cn(
"flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent",
className
"hover:bg-accent flex items-center gap-2 px-3 py-2 text-xs",
className,
)}
{...props}
/>

View File

@@ -0,0 +1,10 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -1,24 +1,25 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
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 hover:bg-primary/90",
default:
"cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"cursor-pointer bg-destructive text-white 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",
"cursor-pointer 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 hover:bg-secondary/80",
"cursor-pointer bg-secondary text-secondary-foreground 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",
"cursor-pointer hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "cursor-pointer text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
@@ -33,8 +34,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Button({
className,
@@ -44,9 +45,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -56,7 +57,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };

View File

@@ -1,22 +1,26 @@
"use client"
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { cn } from "@/lib/utils";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
className,
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
className={cn("cursor-pointer", className)}
{...props}
/>
)
);
}
function CollapsibleContent({
@@ -27,7 +31,7 @@ function CollapsibleContent({
data-slot="collapsible-content"
{...props}
/>
)
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,726 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { open, toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7 opacity-50 hover:opacity-100", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
{open ? <PanelLeftCloseIcon /> : <PanelLeftOpenIcon />}
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,14 @@
export function GithubIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
height={32}
aria-hidden="true"
viewBox="0 0 24 24"
width={32}
fill="currentColor"
{...props}
>
<path d="M12 1C5.923 1 1 5.923 1 12c0 4.867 3.149 8.979 7.521 10.436.55.096.756-.233.756-.522 0-.262-.013-1.128-.013-2.049-2.764.509-3.479-.674-3.699-1.292-.124-.317-.66-1.293-1.127-1.554-.385-.207-.936-.715-.014-.729.866-.014 1.485.797 1.691 1.128.99 1.663 2.571 1.196 3.204.907.096-.715.385-1.196.701-1.471-2.448-.275-5.005-1.224-5.005-5.432 0-1.196.426-2.186 1.128-2.956-.111-.275-.496-1.402.11-2.915 0 0 .921-.288 3.024 1.128a10.193 10.193 0 0 1 2.75-.371c.936 0 1.871.123 2.75.371 2.104-1.43 3.025-1.128 3.025-1.128.605 1.513.221 2.64.111 2.915.701.77 1.127 1.747 1.127 2.956 0 4.222-2.571 5.157-5.019 5.432.399.344.743 1.004.743 2.035 0 1.471-.014 2.654-.014 3.025 0 .289.206.632.756.522C19.851 20.979 23 16.854 23 12c0-6.077-4.922-11-11-11Z"></path>
</svg>
);
}

View File

@@ -0,0 +1,71 @@
import type { ChatStatus } from "ai";
import { useCallback, type ComponentProps } from "react";
import {
PromptInput,
PromptInputBody,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import { cn } from "@/lib/utils";
export function InputBox({
className,
autoFocus,
status = "ready",
onSubmit,
onStop,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
status?: ChatStatus;
onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void;
}) {
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (status === "streaming") {
onStop?.();
return;
}
if (!message.text) {
return;
}
onSubmit?.(message);
},
[onSubmit, onStop, status],
);
return (
<PromptInput
className={cn(
"bg-background/50 rounded-2xl drop-shadow-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
"focus-within:bg-background/85 h-48 translate-y-14 overflow-hidden",
className,
)}
globalDrop
multiple
onSubmit={handleSubmit}
{...props}
>
<PromptInputBody>
<PromptInputTextarea
className={cn("size-full")}
placeholder="How can I assist you today?"
autoFocus={autoFocus}
/>
</PromptInputBody>
<PromptInputFooter className="flex">
<div></div>
<div className="flex items-center gap-2">
<PromptInputSubmit
className="rounded-full"
variant="outline"
status={status}
/>
</div>
</PromptInputFooter>
</PromptInput>
);
}

View File

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

View File

@@ -0,0 +1,329 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
ListTreeIcon,
NotebookPenIcon,
SearchIcon,
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
ChainOfThoughtSearchResult,
ChainOfThoughtSearchResults,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { MessageResponse } from "@/components/ai-elements/message";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "./utils";
export function MessageGroup({
className,
messages,
isLoading = false,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
}) {
const steps = useMemo(() => convertToSteps(messages), [messages]);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const [open, setOpen] = useState(false);
const lastStep = steps[steps.length - 1]!;
const { label, icon } = describeStep(lastStep);
return (
<ChainOfThought
className={cn("w-full rounded-lg border px-4 pt-4", className)}
defaultOpen={false}
open={open}
onOpenChange={setOpen}
>
<ChainOfThoughtHeader
className="min-h-6"
icon={
open && steps.length > 1 ? <ListTreeIcon className="size-4" /> : icon
}
>
<div className="flex w-full items-center justify-between">
<div>
<div>
{open && steps.length > 1 ? (
<div>{steps.length} steps</div>
) : (
<MessageResponse rehypePlugins={rehypePlugins}>
{label}
</MessageResponse>
)}
</div>
</div>
<div>
{!open && steps.length > 1 && (
<div className="tet-xs opacity-60">
{steps.length - 1} more step
{steps.length - 1 > 1 ? "s" : ""}
</div>
)}
</div>
</div>
</ChainOfThoughtHeader>
<ChainOfThoughtContent className="pb-4">
{steps.map((step) =>
step.type === "reasoning" ? (
<ChainOfThoughtStep
key={step.id}
label={
<MessageResponse rehypePlugins={rehypePlugins}>
{step.reasoning ?? ""}
</MessageResponse>
}
/>
) : (
<ToolCall key={step.id} {...step} />
),
)}
</ChainOfThoughtContent>
</ChainOfThought>
);
}
function ToolCall({
id,
name,
args,
result,
}: {
id?: string;
name: string;
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
}) {
if (name === "web_search") {
let label: React.ReactNode = "Search for related information";
if (typeof args.query === "string") {
label = (
<div>
Search on the web for{" "}
<span className="font-bold">&quot;{args.query}&quot;</span>
</div>
);
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
{Array.isArray(result) && (
<ChainOfThoughtSearchResults>
{result.map((item) => (
<ChainOfThoughtSearchResult key={item.url}>
<a href={item.url} target="_blank" rel="noreferrer">
{item.title}
</a>
</ChainOfThoughtSearchResult>
))}
</ChainOfThoughtSearchResults>
)}
</ChainOfThoughtStep>
);
} else if (name === "web_fetch") {
const url = (args as { url: string })?.url;
let title = url;
if (typeof result === "string") {
const potentialTitle = extractTitleFromMarkdown(result);
if (potentialTitle && potentialTitle.toLowerCase() !== "untitled") {
title = potentialTitle;
}
}
return (
<ChainOfThoughtStep key={id} label="View web page" icon={GlobeIcon}>
<ChainOfThoughtSearchResult>
{url && (
<a href={url} target="_blank" rel="noreferrer">
{title}
</a>
)}
</ChainOfThoughtSearchResult>
</ChainOfThoughtStep>
);
} else if (name === "ls") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = "List folder";
}
const path: string | undefined = (args as { path: string })?.path;
return (
<ChainOfThoughtStep key={id} label={description} icon={FolderOpenIcon}>
{path && (
<ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
} else if (name === "read_file") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = "Read file";
}
const path: string | undefined = (args as { path: string })?.path;
return (
<ChainOfThoughtStep key={id} label={description} icon={BookOpenTextIcon}>
{path && (
<ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
} else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = "Write file";
}
const path: string | undefined = (args as { path: string })?.path;
return (
<ChainOfThoughtStep key={id} label={description} icon={NotebookPenIcon}>
{path && (
<ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
} else if (name === "bash") {
const description: string | undefined = (args as { description: string })
?.description;
if (!description) {
return "Execute command";
}
const command: string | undefined = (args as { command: string })?.command;
return (
<ChainOfThoughtStep
key={id}
label={description}
icon={SquareTerminalIcon}
>
{command && (
<ChainOfThoughtSearchResult>{command}</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
} else {
const description: string | undefined = (args as { description: string })
?.description;
return (
<ChainOfThoughtStep
key={id}
label={
description ?? (
<div>
Use tool <b>{name}</b>
</div>
)
}
icon={WrenchIcon}
></ChainOfThoughtStep>
);
}
}
interface GenericCoTStep<T extends string = string> {
id?: string;
type: T;
}
interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
reasoning: string | null;
}
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
name: string;
args: Record<string, unknown>;
result?: string;
}
type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[]): CoTStep[] {
const steps: CoTStep[] = [];
for (const message of messages) {
if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message);
if (reasoning) {
const step: CoTReasoningStep = {
id: message.id,
type: "reasoning",
reasoning: extractReasoningContentFromMessage(message),
};
steps.push(step);
}
for (const tool_call of message.tool_calls ?? []) {
const step: CoTToolCallStep = {
id: tool_call.id,
type: "toolCall",
name: tool_call.name,
args: tool_call.args,
};
const toolCallId = tool_call.id;
if (toolCallId) {
const toolCallResult = findToolCallResult(toolCallId, messages);
if (toolCallResult) {
try {
const json = JSON.parse(toolCallResult);
step.result = json;
} catch {
step.result = toolCallResult;
}
}
}
steps.push(step);
}
}
}
return steps;
}
function describeStep(step: CoTStep): {
label: string;
icon: React.ReactElement;
} {
if (step.type === "reasoning") {
return { label: "Thinking", icon: <LightbulbIcon className="size-4" /> };
} else {
let label: string;
let icon: React.ReactElement = <WrenchIcon className="size-4" />;
if (step.name === "web_search") {
label = "Search on the web";
icon = <SearchIcon className="size-4" />;
} else if (step.name === "web_fetch") {
label = "View web page";
icon = <GlobeIcon className="size-4" />;
} else if (step.name === "ls") {
label = "List folder";
icon = <FolderOpenIcon className="size-4" />;
} else if (step.name === "read_file") {
label = "Read file";
icon = <BookOpenTextIcon className="size-4" />;
} else if (step.name === "write_file" || step.name === "str_replace") {
label = "Write file";
icon = <NotebookPenIcon className="size-4" />;
} else if (step.name === "bash") {
label = "Execute command";
icon = <SquareTerminalIcon className="size-4" />;
} else {
label = `Call tool "${step.name}"`;
icon = <WrenchIcon className="size-4" />;
}
if (typeof step.args.description === "string") {
label = step.args.description;
}
return { label, icon };
}
}

View File

@@ -0,0 +1,71 @@
import type { Message } from "@langchain/langgraph-sdk";
import { memo } from "react";
import {
Message as AIElementMessage,
MessageContent as AIElementMessageContent,
MessageResponse as AIElementMessageResponse,
} from "@/components/ai-elements/message";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { cn } from "@/lib/utils";
import { MessageGroup } from "./message-group";
import { extractContentFromMessage, hasReasoning, hasToolCalls } from "./utils";
export function MessageListItem({
className,
message,
messagesInGroup,
isLoading,
}: {
className?: string;
message: Message;
messagesInGroup: Message[];
isLoading?: boolean;
}) {
return (
<AIElementMessage
className={cn("relative", "group/conversation-message", className)}
from={message.type === "human" ? "user" : "assistant"}
>
<MessageContent
className={className}
message={message}
messagesInGroup={messagesInGroup}
isLoading={isLoading}
/>
</AIElementMessage>
);
}
function MessageContent_({
className,
message,
messagesInGroup,
isLoading = false,
}: {
className?: string;
message: Message;
messagesInGroup: Message[];
isLoading?: boolean;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
return (
<AIElementMessageContent className={className}>
{hasReasoning(message) && (
<MessageGroup
className="mb-2"
messages={[message]}
isLoading={isLoading}
/>
)}
<AIElementMessageResponse rehypePlugins={rehypePlugins}>
{extractContentFromMessage(message)}
</AIElementMessageResponse>
{hasToolCalls(message) && (
<MessageGroup messages={messagesInGroup} isLoading={isLoading} />
)}
</AIElementMessageContent>
);
}
const MessageContent = memo(MessageContent_);

View File

@@ -0,0 +1,63 @@
import type { UseStream } from "@langchain/langgraph-sdk/react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import type { MessageThreadState } from "@/core/thread";
import { cn } from "@/lib/utils";
import { StreamingIndicator } from "../streaming-indicator";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import { MessageListSkeleton } from "./skeleton";
import { groupMessages, hasContent } from "./utils";
export function MessageList({
className,
thread,
}: {
className?: string;
thread: UseStream<MessageThreadState>;
}) {
if (thread.isThreadLoading) {
return <MessageListSkeleton />;
}
return (
<Conversation
className={cn("flex size-full flex-col justify-center pt-2", className)}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-md)">
{groupMessages(
thread.messages,
(groupedMessages, groupIndex, isLastGroup) => {
if (groupedMessages[0] && hasContent(groupedMessages[0])) {
const message = groupedMessages[0];
return (
<MessageListItem
key={message.id}
message={message}
messagesInGroup={groupedMessages}
isLoading={thread.isLoading}
/>
);
}
return (
<MessageGroup
key={groupedMessages[0]!.id}
messages={groupedMessages}
isLoading={thread.isLoading}
/>
);
},
thread.isLoading,
)}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div className="h-40" />
</ConversationContent>
<ConversationScrollButton className="-translate-y-16 backdrop-blur-xs" />
</Conversation>
);
}

View File

@@ -0,0 +1,4 @@
export function MessageListSkeleton() {
// TODO: Add a loading state
return null;
}

View File

@@ -0,0 +1,157 @@
import type { Message } from "@langchain/langgraph-sdk";
export function groupMessages<T>(
messages: Message[],
mapper: (
groupedMessages: Message[],
groupIndex: number,
isLastGroup: boolean,
) => T,
isLoading = false,
): T[] {
if (messages.length === 0) {
return [];
}
const resultsOfGroups: T[] = [];
let currentGroup: Message[] = [];
const lastMessage = messages[messages.length - 1]!;
const yieldCurrentGroup = () => {
if (currentGroup.length > 0) {
const resultOfGroup = mapper(
currentGroup,
resultsOfGroups.length,
currentGroup.includes(lastMessage),
);
if (resultOfGroup !== undefined && resultOfGroup !== null) {
resultsOfGroups.push(resultOfGroup);
}
currentGroup = [];
}
};
let messageIndex = 0;
for (const message of messages) {
if (message.type === "human") {
// Human messages are always shown as a individual group
yieldCurrentGroup();
currentGroup.push(message);
yieldCurrentGroup();
} else if (message.type === "tool") {
// Tool messages are always shown with the assistant messages that contains the tool calls
currentGroup.push(message);
} else if (message.type === "ai") {
if (
hasToolCalls(message) ||
(extractTextFromMessage(message) === "" &&
extractReasoningContentFromMessage(message) !== "" &&
messageIndex === messages.length - 1 &&
isLoading)
) {
// Assistant messages without any content are folded into the previous group
// Normally, these are tool calls (with or without thinking)
currentGroup.push(message);
} else {
// Assistant messages with content (text or images) are shown as a group if they have content
// No matter whether it has tool calls or not
yieldCurrentGroup();
currentGroup.push(message);
}
}
messageIndex++;
}
yieldCurrentGroup();
return resultsOfGroups;
}
export function extractTextFromMessage(message: Message) {
if (typeof message.content === "string") {
return message.content.trim();
}
if (Array.isArray(message.content)) {
return message.content
.map((content) => (content.type === "text" ? content.text : ""))
.join("\n")
.trim();
}
return "";
}
export function extractContentFromMessage(message: Message) {
if (typeof message.content === "string") {
return message.content.trim();
}
if (Array.isArray(message.content)) {
return message.content
.map((content) => {
switch (content.type) {
case "text":
return content.text;
case "image_url":
const imageURL = extractURLFromImageURLContent(content.image_url);
return `![image](${imageURL})`;
default:
return "";
}
})
.join("\n")
.trim();
}
return "";
}
export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai" || !message.additional_kwargs) {
return null;
}
if ("reasoning_content" in message.additional_kwargs) {
return message.additional_kwargs.reasoning_content as string | null;
}
return null;
}
export function extractURLFromImageURLContent(
content:
| string
| {
url: string;
},
) {
if (typeof content === "string") {
return content;
}
return content.url;
}
export function hasContent(message: Message) {
if (typeof message.content === "string") {
return message.content.trim().length > 0;
}
if (Array.isArray(message.content)) {
return message.content.length > 0;
}
return false;
}
export function hasReasoning(message: Message) {
return (
message.type === "ai" &&
typeof message.additional_kwargs?.reasoning_content === "string"
);
}
export function hasToolCalls(message: Message) {
return (
message.type === "ai" && message.tool_calls && message.tool_calls.length > 0
);
}
export function findToolCallResult(toolCallId: string, messages: Message[]) {
for (const message of messages) {
if (message.type === "tool" && message.tool_call_id === toolCallId) {
const content = extractTextFromMessage(message);
if (content) {
return content;
}
}
}
return undefined;
}

View File

@@ -0,0 +1,17 @@
"use client";
import { useEffect } from "react";
export function Overscroll({
behavior,
overflow = "hidden",
}: {
behavior: "none" | "contain" | "auto";
overflow?: "hidden" | "auto" | "scroll";
}) {
useEffect(() => {
document.documentElement.style.overflow = overflow;
document.documentElement.style.overscrollBehavior = behavior;
}, [behavior, overflow]);
return null;
}

View File

@@ -0,0 +1,107 @@
"use client";
import { MoreHorizontal, Trash2 } from "lucide-react";
import Link from "next/link";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useCallback } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useDeleteThread, useThreads } from "@/core/api";
import { pathOfThread, titleOfThread } from "@/core/thread/utils";
export function RecentChatList() {
const router = useRouter();
const pathname = usePathname();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread();
const handleDelete = useCallback(
(threadId: string) => {
deleteThread({ threadId });
if (threadId === threadIdFromPath) {
const threadIndex = threads.findIndex((t) => t.thread_id === threadId);
let nextThreadId = "new";
if (threadIndex > -1) {
if (threads[threadIndex + 1]) {
nextThreadId = threads[threadIndex + 1]!.thread_id;
} else if (threads[threadIndex - 1]) {
nextThreadId = threads[threadIndex - 1]!.thread_id;
}
}
void router.push(`/workspace/chats/${nextThreadId}`);
}
},
[deleteThread, router, threadIdFromPath, threads],
);
if (threads.length === 0) {
return null;
}
return (
<SidebarGroup>
<SidebarGroupLabel>Recents</SidebarGroupLabel>
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
<SidebarMenu>
<div className="flex w-full flex-col gap-1">
{threads.map((thread) => {
const isActive = pathOfThread(thread, false) === pathname;
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<div>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -0,0 +1,34 @@
import { cn } from "@/lib/utils";
export function StreamingIndicator({
className,
size = "normal",
}: {
className?: string;
size?: "normal" | "sm";
}) {
const dotSize = size === "sm" ? "w-1.5 h-1.5 mx-0.5" : "w-2 h-2 mx-1";
return (
<div className={cn("flex", className)}>
<div
className={cn(
dotSize,
"animate-bouncing rounded-full bg-[#a3a1a1] opacity-100",
)}
/>
<div
className={cn(
dotSize,
"animate-bouncing rounded-full bg-[#a3a1a1] opacity-100 [animation-delay:0.2s]",
)}
/>
<div
className={cn(
dotSize,
"animate-bouncing rounded-full bg-[#a3a1a1] opacity-100 [animation-delay:0.4s]",
)}
/>
</div>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import {
Tooltip as TooltipPrimitive,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function Tooltip({
children,
content,
...props
}: {
children: React.ReactNode;
content?: React.ReactNode;
}) {
return (
<TooltipPrimitive {...props}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent className="shadow">{content}</TooltipContent>
</TooltipPrimitive>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { cn } from "@/lib/utils";
import { GithubIcon } from "./github-icon";
import { Tooltip } from "./tooltip";
export function WorkspaceContainer({
className,
children,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex h-screen w-full flex-col", className)} {...props}>
{children}
</div>
);
}
export function WorkspaceHeader({
className,
children,
...props
}: React.ComponentProps<"header">) {
const pathname = usePathname();
const segments = useMemo(() => {
const parts = pathname?.split("/") || [];
if (parts.length > 0) {
return parts.slice(1, 3);
}
}, [pathname]);
return (
<header
className={cn(
"top-0 right-0 left-0 z-20 flex h-16 shrink-0 items-center justify-between gap-2 border-b backdrop-blur-sm transition-[width,height] ease-out group-has-data-[collapsible=icon]/sidebar-wrapper:h-12",
className,
)}
{...props}
>
<div className="flex items-center gap-2 px-4">
<Breadcrumb>
<BreadcrumbList>
{segments?.[0] && (
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink asChild>
<Link href={`/${segments[0]}`}>
{nameOfSegment(segments[0])}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
)}
{segments?.[1] && (
<>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
{segments.length >= 2 ? (
<BreadcrumbLink asChild>
<Link href={`/${segments[0]}/${segments[1]}`}>
{nameOfSegment(segments[1])}
</Link>
</BreadcrumbLink>
) : (
<BreadcrumbPage>
{nameOfSegment(segments[1])}
</BreadcrumbPage>
)}
</BreadcrumbItem>
</>
)}
{children && (
<>
<BreadcrumbSeparator />
{children}
</>
)}
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="pr-4">
<Tooltip content="DeerFlow on Github">
<a
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
className="opacity-75 transition hover:opacity-100"
>
<GithubIcon className="size-6" />
</a>
</Tooltip>
</div>
</header>
);
}
export function WorkspaceBody({
className,
children,
...props
}: React.ComponentProps<"main">) {
return (
<main
className={cn(
"relative flex min-h-0 w-full flex-1 flex-col items-center",
className,
)}
{...props}
>
<div className="flex h-full w-full flex-col items-center">{children}</div>
</main>
);
}
export function WorkspaceFooter({
className,
children,
...props
}: React.ComponentProps<"footer">) {
return (
<footer
className={cn(
"absolute right-0 bottom-0 left-0 z-30 flex justify-center",
className,
)}
{...props}
>
{children}
</footer>
);
}
function nameOfSegment(segment: string | undefined) {
if (!segment) return "Home";
return segment[0]?.toUpperCase() + segment.slice(1);
}

View File

@@ -0,0 +1,30 @@
"use client";
import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
export function WorkspaceHeader({ className }: { className?: string }) {
const { state } = useSidebar();
return (
<div
className={cn(
"group/workspace-header flex h-15 flex-col justify-center",
className,
)}
>
{state === "collapsed" ? (
<div className="flex w-full cursor-pointer items-center justify-center group-has-data-[collapsible=icon]/sidebar-wrapper:-translate-y-[6px]">
<h1 className="text-primary block font-serif group-hover/workspace-header:hidden">
DF
</h1>
<SidebarTrigger className="hidden pl-2 group-hover/workspace-header:block" />
</div>
) : (
<div className="flex items-center justify-between gap-2">
<h1 className="text-primary ml-2 font-serif">DeerFlow</h1>
<SidebarTrigger />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { MessageSquarePlus, MessagesSquare } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function WorkspaceNavMenu() {
const pathname = usePathname();
return (
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/workspace/chats/new"}
asChild
>
<Link className="text-muted-foreground" href="/workspace/chats/new">
<MessageSquarePlus size={16} />
<span>New chat</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton isActive={pathname === "/workspace/chats"} asChild>
<Link className="text-muted-foreground" href="/workspace/chats">
<MessagesSquare />
<span>Chats</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}

View File

@@ -0,0 +1,29 @@
import {
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarRail,
} from "@/components/ui/sidebar";
import { RecentChatList } from "./recent-chat-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({
...props
}: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar variant="sidebar" collapsible="icon" {...props}>
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
<SidebarContent>
<WorkspaceNavMenu />
<RecentChatList />
</SidebarContent>
<SidebarFooter></SidebarFooter>
<SidebarRail />
</Sidebar>
);
}

View File

@@ -0,0 +1,17 @@
"use client";
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
let _singleton: LangGraphClient | null = null;
export function getLangGraphClient(): LangGraphClient {
let url: URL | null = null;
if (typeof window === "undefined") {
url = new URL("/api/langgraph", "http://localhost:3000");
} else {
url = new URL("/api/langgraph", window.location.origin);
}
_singleton ??= new LangGraphClient({
apiUrl: "http://localhost:2024",
});
return _singleton;
}

View File

@@ -0,0 +1,45 @@
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { MessageThread, MessageThreadState } from "../thread";
import { getLangGraphClient } from "./client";
export function useThreads(
params: Parameters<ThreadsClient["search"]>[0] = {
limit: 50,
sortBy: "updated_at",
sortOrder: "desc",
},
) {
const langGraphClient = getLangGraphClient();
return useQuery<MessageThread[]>({
queryKey: ["threads", "search", params],
queryFn: async () => {
const response =
await langGraphClient.threads.search<MessageThreadState>(params);
return response as MessageThread[];
},
});
}
export function useDeleteThread() {
const queryClient = useQueryClient();
const langGraphClient = getLangGraphClient();
return useMutation({
mutationFn: async ({ threadId }: { threadId: string }) => {
await langGraphClient.threads.delete(threadId);
},
onSuccess(_, { threadId }) {
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<MessageThread>) => {
return oldData.filter((t) => t.thread_id !== threadId);
},
);
},
});
}

View File

@@ -0,0 +1,2 @@
export * from "./client";
export * from "./hooks";

View File

@@ -0,0 +1,49 @@
import type { Element, Root, ElementContent } from "hast";
import { useMemo } from "react";
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">);
};
}
export function useRehypeSplitWordsIntoSpans(enabled = true) {
const rehypePlugins = useMemo(
() => (enabled ? [rehypeSplitWordsIntoSpans] : []),
[enabled],
);
return rehypePlugins;
}

View File

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

View File

@@ -0,0 +1,8 @@
import { type BaseMessage } from "@langchain/core/messages";
import type { Thread } from "@langchain/langgraph-sdk";
export interface MessageThreadState extends Record<string, unknown> {
messages: BaseMessage[];
}
export interface MessageThread extends Thread<MessageThreadState> {}

View File

@@ -0,0 +1,27 @@
import type { BaseMessage } from "@langchain/core/messages";
import type { MessageThread } from "./types";
export function pathOfThread(thread: MessageThread, includeAssistantId = true) {
if (includeAssistantId) {
return `/workspace/chats/${thread.thread_id}`;
}
return `/workspace/chats/${thread.thread_id}`;
}
export function textOfMessage(message: BaseMessage) {
if (typeof message.content === "string") {
return message.content;
} else if (Array.isArray(message.content)) {
return message.content.find((part) => part.type === "text" && part.text)
?.text as string;
}
return null;
}
export function titleOfThread(thread: MessageThread) {
if (thread.values && "title" in thread.values) {
return thread.values.title as string;
}
return "Untitled";
}

View File

@@ -0,0 +1,10 @@
import { parse } from "best-effort-json-parser";
export function tryParseJSON(json: string) {
try {
const object = parse(json);
return object;
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,10 @@
export function extractTitleFromMarkdown(markdown: string) {
if (markdown.startsWith("# ")) {
let title = markdown.split("\n")[0]!.trim();
if (title.startsWith("# ")) {
title = title.slice(2).trim();
}
return title;
}
return undefined;
}

View File

@@ -0,0 +1 @@
export { v4 as uuid } from "uuid";

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -1,11 +1,97 @@
@import "tailwindcss";
@import "tw-animate-css";
@source "../node_modules/streamdown/dist/index.js";
/* Heading */
@source inline("text-{xs,sm,base,lg,xl,2xl,3xl,4xl,5xl,6xl}");
@source inline("font-{normal,medium,semibold,bold,extrabold}");
@source inline("leading-{none,tight,snug,normal,relaxed,loose}");
/* Spacing */
@source inline("m{t,b,l,r,x,y}-{0,1,2,3,4,5,6,8,10,12,16,20,24}");
@source inline("p{t,b,l,r,x,y}-{0,1,2,3,4,5,6,8,10,12,16,20,24}");
@source inline("space-{x,y}-{1,2,3,4,5,6,8}");
/* List */
@source inline("list-{disc,decimal,none,inside,outside}");
/* Text */
@source inline("text-{left,center,right,justify}");
@source inline("text-{slate,gray,zinc,neutral,stone}-{50,100,200,300,400,500,600,700,800,900,950}");
@source inline("italic");
@source inline("underline");
@source inline("line-through");
/* Code */
@source inline("font-mono");
@source inline("bg-{slate,gray,zinc,muted}-{50,100,200}");
@source inline("rounded{,-sm,-md,-lg,-xl}");
@source inline("border{,-2,-4}");
@source inline("border-{slate,gray,zinc,border}-{200,300}");
/* Blockquote */
@source inline("border-l-{2,4}");
@source inline("border-l-{slate,gray,primary}-{300,400,500}");
/* Link */
@source inline("text-{blue,primary,accent}-{500,600,700}");
@source inline("hover:text-{blue,primary,accent}-{600,700,800}");
@source inline("hover:underline");
/* Table */
@source inline("border-collapse");
@source inline("table-auto");
@source inline("w-full");
@source inline("divide-{x,y}");
@source inline("divide-{slate,gray,border}-{200,300}");
/* Image */
@source inline("max-w-{xs,sm,md,lg,xl,2xl,full}");
@source inline("h-auto");
@source inline("object-cover");
/* Horizontal Rule */
@source inline("border-t");
@source inline("border-{slate,gray,border}-{200,300}");
/* General */
@source inline("block");
@source inline("inline");
@source inline("inline-block");
@source inline("break-words");
@source inline("overflow-{auto,hidden,x-auto}");
@source inline("whitespace-pre-wrap");
/* Shadcn Colors */
@source inline("text-{foreground,muted-foreground,primary,secondary,accent}");
@source inline("bg-{background,muted,primary,secondary,accent}");
@source inline("border-{border,input}");
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
--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 1.1s;
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
--animate-bouncing: bouncing 0.5s infinite alternate;
@keyframes bouncing {
to {
opacity: 0.1;
transform: translateY(-8px);
}
}
}
@theme inline {
@@ -116,6 +202,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
font-weight: 300;
}
@layer base {
@@ -126,3 +213,10 @@
@apply bg-background text-foreground;
}
}
:root {
--container-width-xs: calc(var(--spacing) * 72);
--container-width-sm: calc(var(--spacing) * 144);
--container-width-md: calc(var(--spacing) * 204);
--container-width-lg: calc(var(--spacing) * 256);
}

View File

@@ -22,7 +22,7 @@
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"jsx": "preserve",
"plugins": [
{
"name": "next"