mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-03 06:12:14 +08:00
feat: implement basic web app
This commit is contained in:
@@ -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
1801
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
98
frontend/src/app/api/langgraph/[...path]/route.ts
Normal file
98
frontend/src/app/api/langgraph/[...path]/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
115
frontend/src/app/workspace/chats/[thread_id]/page.tsx
Normal file
115
frontend/src/app/workspace/chats/[thread_id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/app/workspace/chats/page.tsx
Normal file
18
frontend/src/app/workspace/chats/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
frontend/src/app/workspace/layout.tsx
Normal file
29
frontend/src/app/workspace/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/workspace/page.tsx
Normal file
5
frontend/src/app/workspace/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function WorkspacePage() {
|
||||
return redirect("/workspace/chats/new");
|
||||
}
|
||||
26
frontend/src/app/workspace/threads/[thread_id]/page.tsx
Normal file
26
frontend/src/app/workspace/threads/[thread_id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
10
frontend/src/components/theme-provider.tsx
Normal file
10
frontend/src/components/theme-provider.tsx
Normal 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>;
|
||||
}
|
||||
109
frontend/src/components/ui/breadcrumb.tsx
Normal file
109
frontend/src/components/ui/breadcrumb.tsx
Normal 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,
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
139
frontend/src/components/ui/sheet.tsx
Normal file
139
frontend/src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
726
frontend/src/components/ui/sidebar.tsx
Normal file
726
frontend/src/components/ui/sidebar.tsx
Normal 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,
|
||||
};
|
||||
13
frontend/src/components/ui/skeleton.tsx
Normal file
13
frontend/src/components/ui/skeleton.tsx
Normal 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 }
|
||||
14
frontend/src/components/workspace/github-icon.tsx
Normal file
14
frontend/src/components/workspace/github-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
frontend/src/components/workspace/input-box.tsx
Normal file
71
frontend/src/components/workspace/input-box.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/components/workspace/message-list/index.ts
Normal file
1
frontend/src/components/workspace/message-list/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./message-list";
|
||||
329
frontend/src/components/workspace/message-list/message-group.tsx
Normal file
329
frontend/src/components/workspace/message-list/message-group.tsx
Normal 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">"{args.query}"</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 };
|
||||
}
|
||||
}
|
||||
@@ -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_);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function MessageListSkeleton() {
|
||||
// TODO: Add a loading state
|
||||
return null;
|
||||
}
|
||||
157
frontend/src/components/workspace/message-list/utils.ts
Normal file
157
frontend/src/components/workspace/message-list/utils.ts
Normal 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 ``;
|
||||
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;
|
||||
}
|
||||
17
frontend/src/components/workspace/overscroll.tsx
Normal file
17
frontend/src/components/workspace/overscroll.tsx
Normal 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;
|
||||
}
|
||||
107
frontend/src/components/workspace/recent-chat-list.tsx
Normal file
107
frontend/src/components/workspace/recent-chat-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/workspace/streaming-indicator.tsx
Normal file
34
frontend/src/components/workspace/streaming-indicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/workspace/tooltip.tsx
Normal file
23
frontend/src/components/workspace/tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
frontend/src/components/workspace/workspace-container.tsx
Normal file
146
frontend/src/components/workspace/workspace-container.tsx
Normal 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);
|
||||
}
|
||||
30
frontend/src/components/workspace/workspace-header.tsx
Normal file
30
frontend/src/components/workspace/workspace-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/workspace/workspace-nav-menu.tsx
Normal file
41
frontend/src/components/workspace/workspace-nav-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/workspace/workspace-sidebar.tsx
Normal file
29
frontend/src/components/workspace/workspace-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/core/api/client.ts
Normal file
17
frontend/src/core/api/client.ts
Normal 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;
|
||||
}
|
||||
45
frontend/src/core/api/hooks.ts
Normal file
45
frontend/src/core/api/hooks.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
2
frontend/src/core/api/index.ts
Normal file
2
frontend/src/core/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./client";
|
||||
export * from "./hooks";
|
||||
49
frontend/src/core/rehype/index.ts
Normal file
49
frontend/src/core/rehype/index.ts
Normal 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;
|
||||
}
|
||||
1
frontend/src/core/thread/index.ts
Normal file
1
frontend/src/core/thread/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./types";
|
||||
8
frontend/src/core/thread/types.ts
Normal file
8
frontend/src/core/thread/types.ts
Normal 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> {}
|
||||
27
frontend/src/core/thread/utils.ts
Normal file
27
frontend/src/core/thread/utils.ts
Normal 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";
|
||||
}
|
||||
10
frontend/src/core/utils/json.ts
Normal file
10
frontend/src/core/utils/json.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
frontend/src/core/utils/markdown.ts
Normal file
10
frontend/src/core/utils/markdown.ts
Normal 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;
|
||||
}
|
||||
1
frontend/src/core/utils/uuid.ts
Normal file
1
frontend/src/core/utils/uuid.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { v4 as uuid } from "uuid";
|
||||
19
frontend/src/hooks/use-mobile.ts
Normal file
19
frontend/src/hooks/use-mobile.ts
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
Reference in New Issue
Block a user