mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-27 15:54:48 +08:00
feat: implement basic web app
This commit is contained in:
@@ -16,6 +16,8 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@langchain/core": "^1.1.15",
|
||||||
|
"@langchain/langgraph-sdk": "^1.5.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -28,25 +30,31 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
|
"@tanstack/react-query": "^5.90.17",
|
||||||
|
"@types/hast": "^3.0.4",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"ai": "^6.0.33",
|
"ai": "^6.0.33",
|
||||||
|
"best-effort-json-parser": "^1.2.1",
|
||||||
"better-auth": "^1.3",
|
"better-auth": "^1.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"hast": "^1.0.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.26.2",
|
"motion": "^12.26.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next": "^16.1.1",
|
"next": "^15.2.8",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"shiki": "^3.21.0",
|
"shiki": "^3.21.0",
|
||||||
"streamdown": "^2.0.1",
|
"streamdown": "1.5.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tokenlens": "^1.3.1",
|
"tokenlens": "^1.3.1",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
"use-stick-to-bottom": "^1.1.1",
|
"use-stick-to-bottom": "^1.1.1",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 { type Metadata } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
|
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "DeerFlow",
|
title: "Welcome to DeerFlow",
|
||||||
description: "A LangChain-based framework for building super agents.",
|
description: "A LangChain-based framework for building super agents.",
|
||||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||||
};
|
};
|
||||||
@@ -18,8 +20,21 @@ export default function RootLayout({
|
|||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${geist.variable}`}>
|
<html
|
||||||
<body>{children}</body>
|
className={geist.variable}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<body>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="dark"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
export default async function LandingPage() {
|
export default async function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center">
|
<main className="flex min-h-screen flex-col">DeerFlow Landing Page</main>
|
||||||
Welcome to DeerFlow 2
|
|
||||||
</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>(
|
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
|
||||||
null
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const useChainOfThought = () => {
|
const useChainOfThought = () => {
|
||||||
const context = useContext(ChainOfThoughtContext);
|
const context = useContext(ChainOfThoughtContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"ChainOfThought components must be used within ChainOfThought"
|
"ChainOfThought components must be used within ChainOfThought",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
@@ -59,7 +59,7 @@ export const ChainOfThought = memo(
|
|||||||
|
|
||||||
const chainOfThoughtContext = useMemo(
|
const chainOfThoughtContext = useMemo(
|
||||||
() => ({ isOpen, setIsOpen }),
|
() => ({ isOpen, setIsOpen }),
|
||||||
[isOpen, setIsOpen]
|
[isOpen, setIsOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,40 +72,42 @@ export const ChainOfThought = memo(
|
|||||||
</div>
|
</div>
|
||||||
</ChainOfThoughtContext.Provider>
|
</ChainOfThoughtContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ChainOfThoughtHeaderProps = ComponentProps<
|
export type ChainOfThoughtHeaderProps = ComponentProps<
|
||||||
typeof CollapsibleTrigger
|
typeof CollapsibleTrigger
|
||||||
>;
|
> & {
|
||||||
|
icon?: React.ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
export const ChainOfThoughtHeader = memo(
|
export const ChainOfThoughtHeader = memo(
|
||||||
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
|
({ className, children, icon, ...props }: ChainOfThoughtHeaderProps) => {
|
||||||
const { isOpen, setIsOpen } = useChainOfThought();
|
const { isOpen, setIsOpen } = useChainOfThought();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||||
<CollapsibleTrigger
|
<CollapsibleTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<BrainIcon className="size-4" />
|
{icon ?? <BrainIcon className="size-4" />}
|
||||||
<span className="flex-1 text-left">
|
<span className="flex-1 text-left">
|
||||||
{children ?? "Chain of Thought"}
|
{children ?? "Chain of Thought"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4 transition-transform",
|
"size-4 transition-transform",
|
||||||
isOpen ? "rotate-180" : "rotate-0"
|
isOpen ? "rotate-180" : "rotate-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||||
@@ -137,13 +139,13 @@ export const ChainOfThoughtStep = memo(
|
|||||||
"flex gap-2 text-sm",
|
"flex gap-2 text-sm",
|
||||||
statusStyles[status],
|
statusStyles[status],
|
||||||
"fade-in-0 slide-in-from-top-2 animate-in",
|
"fade-in-0 slide-in-from-top-2 animate-in",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="relative mt-0.5">
|
<div className="relative mt-0.5">
|
||||||
<Icon className="size-4" />
|
<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>
|
||||||
<div className="flex-1 space-y-2 overflow-hidden">
|
<div className="flex-1 space-y-2 overflow-hidden">
|
||||||
<div>{label}</div>
|
<div>{label}</div>
|
||||||
@@ -154,7 +156,7 @@ export const ChainOfThoughtStep = memo(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
|
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
|
||||||
@@ -165,7 +167,7 @@ export const ChainOfThoughtSearchResults = memo(
|
|||||||
className={cn("flex flex-wrap items-center gap-2", className)}
|
className={cn("flex flex-wrap items-center gap-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
|
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
|
||||||
@@ -173,13 +175,13 @@ export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
|
|||||||
export const ChainOfThoughtSearchResult = memo(
|
export const ChainOfThoughtSearchResult = memo(
|
||||||
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
|
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
|
||||||
<Badge
|
<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"
|
variant="secondary"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ChainOfThoughtContentProps = ComponentProps<
|
export type ChainOfThoughtContentProps = ComponentProps<
|
||||||
@@ -195,8 +197,8 @@ export const ChainOfThoughtContent = memo(
|
|||||||
<CollapsibleContent
|
<CollapsibleContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-2 space-y-3",
|
"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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -204,7 +206,7 @@ export const ChainOfThoughtContent = memo(
|
|||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
|
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
|
||||||
@@ -214,12 +216,12 @@ export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
|
|||||||
export const ChainOfThoughtImage = memo(
|
export const ChainOfThoughtImage = memo(
|
||||||
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
|
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
|
||||||
<div className={cn("mt-2 space-y-2", className)} {...props}>
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
|
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
ChainOfThought.displayName = "ChainOfThought";
|
ChainOfThought.displayName = "ChainOfThought";
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
|
||||||
ButtonGroup,
|
|
||||||
ButtonGroupText,
|
|
||||||
} from "@/components/ui/button-group";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -30,9 +27,9 @@ export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
|||||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -47,10 +44,10 @@ export const MessageContent = ({
|
|||||||
}: MessageContentProps) => (
|
}: MessageContentProps) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm",
|
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden",
|
||||||
"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",
|
"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",
|
"group-[.is-assistant]:text-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -116,7 +113,7 @@ type MessageBranchContextType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||||
null
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const useMessageBranch = () => {
|
const useMessageBranch = () => {
|
||||||
@@ -124,7 +121,7 @@ const useMessageBranch = () => {
|
|||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error(
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||||
index === currentBranch ? "block" : "hidden"
|
index === currentBranch ? "block" : "hidden",
|
||||||
)}
|
)}
|
||||||
key={branch.key}
|
key={branch.key}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -294,8 +291,8 @@ export const MessageBranchPage = ({
|
|||||||
return (
|
return (
|
||||||
<ButtonGroupText
|
<ButtonGroupText
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
"text-muted-foreground border-none bg-transparent shadow-none",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -311,12 +308,12 @@ export const MessageResponse = memo(
|
|||||||
<Streamdown
|
<Streamdown
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
(prevProps, nextProps) => prevProps.children === nextProps.children,
|
||||||
);
|
);
|
||||||
|
|
||||||
MessageResponse.displayName = "MessageResponse";
|
MessageResponse.displayName = "MessageResponse";
|
||||||
@@ -343,7 +340,7 @@ export function MessageAttachment({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative size-24 overflow-hidden rounded-lg",
|
"group relative size-24 overflow-hidden rounded-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -359,7 +356,7 @@ export function MessageAttachment({
|
|||||||
{onRemove && (
|
{onRemove && (
|
||||||
<Button
|
<Button
|
||||||
aria-label="Remove attachment"
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove();
|
onRemove();
|
||||||
@@ -376,7 +373,7 @@ export function MessageAttachment({
|
|||||||
<>
|
<>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<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" />
|
<PaperclipIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -387,7 +384,7 @@ export function MessageAttachment({
|
|||||||
{onRemove && (
|
{onRemove && (
|
||||||
<Button
|
<Button
|
||||||
aria-label="Remove attachment"
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove();
|
onRemove();
|
||||||
@@ -420,7 +417,7 @@ export function MessageAttachments({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto flex w-fit flex-wrap items-start gap-2",
|
"ml-auto flex w-fit flex-wrap items-start gap-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -439,7 +436,7 @@ export const MessageToolbar = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-4 flex w-full items-center justify-between gap-4",
|
"mt-4 flex w-full items-center justify-between gap-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatStatus, FileUIPart } from "ai";
|
import type { ChatStatus, FileUIPart } from "ai";
|
||||||
import {
|
import {
|
||||||
CornerDownLeftIcon,
|
ArrowUpIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
MicIcon,
|
MicIcon,
|
||||||
@@ -95,22 +95,22 @@ export type PromptInputControllerProps = {
|
|||||||
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
|
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
|
||||||
__registerFileInput: (
|
__registerFileInput: (
|
||||||
ref: RefObject<HTMLInputElement | null>,
|
ref: RefObject<HTMLInputElement | null>,
|
||||||
open: () => void
|
open: () => void,
|
||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PromptInputController = createContext<PromptInputControllerProps | null>(
|
const PromptInputController = createContext<PromptInputControllerProps | null>(
|
||||||
null
|
null,
|
||||||
);
|
);
|
||||||
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
|
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
|
||||||
null
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const usePromptInputController = () => {
|
export const usePromptInputController = () => {
|
||||||
const ctx = useContext(PromptInputController);
|
const ctx = useContext(PromptInputController);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Wrap your component inside <PromptInputProvider> to use usePromptInputController()."
|
"Wrap your component inside <PromptInputProvider> to use usePromptInputController().",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
@@ -124,7 +124,7 @@ export const useProviderAttachments = () => {
|
|||||||
const ctx = useContext(ProviderAttachmentsContext);
|
const ctx = useContext(ProviderAttachmentsContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Wrap your component inside <PromptInputProvider> to use useProviderAttachments()."
|
"Wrap your component inside <PromptInputProvider> to use useProviderAttachments().",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
@@ -170,8 +170,8 @@ export function PromptInputProvider({
|
|||||||
url: URL.createObjectURL(file),
|
url: URL.createObjectURL(file),
|
||||||
mediaType: file.type,
|
mediaType: file.type,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
}))
|
})),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ export function PromptInputProvider({
|
|||||||
openFileDialog,
|
openFileDialog,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
}),
|
}),
|
||||||
[attachmentFiles, add, remove, clear, openFileDialog]
|
[attachmentFiles, add, remove, clear, openFileDialog],
|
||||||
);
|
);
|
||||||
|
|
||||||
const __registerFileInput = useCallback(
|
const __registerFileInput = useCallback(
|
||||||
@@ -232,7 +232,7 @@ export function PromptInputProvider({
|
|||||||
fileInputRef.current = ref.current;
|
fileInputRef.current = ref.current;
|
||||||
openRef.current = open;
|
openRef.current = open;
|
||||||
},
|
},
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const controller = useMemo<PromptInputControllerProps>(
|
const controller = useMemo<PromptInputControllerProps>(
|
||||||
@@ -245,7 +245,7 @@ export function PromptInputProvider({
|
|||||||
attachments,
|
attachments,
|
||||||
__registerFileInput,
|
__registerFileInput,
|
||||||
}),
|
}),
|
||||||
[textInput, clearInput, attachments, __registerFileInput]
|
[textInput, clearInput, attachments, __registerFileInput],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -270,7 +270,7 @@ export const usePromptInputAttachments = () => {
|
|||||||
const context = provider ?? local;
|
const context = provider ?? local;
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
|
"usePromptInputAttachments must be used within a PromptInput or PromptInputProvider",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
@@ -301,14 +301,14 @@ export function PromptInputAttachment({
|
|||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
key={data.id}
|
key={data.id}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="relative size-5 shrink-0">
|
<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 ? (
|
{isImage ? (
|
||||||
<img
|
<img
|
||||||
alt={filename || "attachment"}
|
alt={filename || "attachment"}
|
||||||
@@ -318,7 +318,7 @@ export function PromptInputAttachment({
|
|||||||
width={20}
|
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" />
|
<PaperclipIcon className="size-3" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -356,11 +356,11 @@ export function PromptInputAttachment({
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="min-w-0 flex-1 space-y-1 px-0.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")}
|
{filename || (isImage ? "Image" : "Attachment")}
|
||||||
</h4>
|
</h4>
|
||||||
{data.mediaType && (
|
{data.mediaType && (
|
||||||
<p className="truncate font-mono text-muted-foreground text-xs">
|
<p className="text-muted-foreground truncate font-mono text-xs">
|
||||||
{data.mediaType}
|
{data.mediaType}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -392,7 +392,7 @@ export function PromptInputAttachments({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
{...props}
|
||||||
>
|
>
|
||||||
{attachments.files.map((file) => (
|
{attachments.files.map((file) => (
|
||||||
@@ -451,7 +451,7 @@ export type PromptInputProps = Omit<
|
|||||||
}) => void;
|
}) => void;
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
message: PromptInputMessage,
|
message: PromptInputMessage,
|
||||||
event: FormEvent<HTMLFormElement>
|
event: FormEvent<HTMLFormElement>,
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -507,7 +507,7 @@ export const PromptInput = ({
|
|||||||
return f.type === pattern;
|
return f.type === pattern;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[accept]
|
[accept],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addLocal = useCallback(
|
const addLocal = useCallback(
|
||||||
@@ -558,7 +558,7 @@ export const PromptInput = ({
|
|||||||
return prev.concat(next);
|
return prev.concat(next);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[matchesAccept, maxFiles, maxFileSize, onError]
|
[matchesAccept, maxFiles, maxFileSize, onError],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeLocal = useCallback(
|
const removeLocal = useCallback(
|
||||||
@@ -570,7 +570,7 @@ export const PromptInput = ({
|
|||||||
}
|
}
|
||||||
return prev.filter((file) => file.id !== id);
|
return prev.filter((file) => file.id !== id);
|
||||||
}),
|
}),
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearLocal = useCallback(
|
const clearLocal = useCallback(
|
||||||
@@ -583,7 +583,7 @@ export const PromptInput = ({
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}),
|
}),
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const add = usingProvider ? controller.attachments.add : addLocal;
|
const add = usingProvider ? controller.attachments.add : addLocal;
|
||||||
@@ -611,7 +611,7 @@ export const PromptInput = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const form = formRef.current;
|
const form = formRef.current;
|
||||||
if (!form) return;
|
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) => {
|
const onDragOver = (e: DragEvent) => {
|
||||||
if (e.dataTransfer?.types?.includes("Files")) {
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current
|
||||||
[usingProvider]
|
[usingProvider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||||
@@ -679,7 +679,7 @@ export const PromptInput = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const convertBlobUrlToDataUrl = async (
|
const convertBlobUrlToDataUrl = async (
|
||||||
url: string
|
url: string,
|
||||||
): Promise<string | null> => {
|
): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
@@ -704,7 +704,7 @@ export const PromptInput = ({
|
|||||||
openFileDialog,
|
openFileDialog,
|
||||||
fileInputRef: inputRef,
|
fileInputRef: inputRef,
|
||||||
}),
|
}),
|
||||||
[files, add, remove, clear, openFileDialog]
|
[files, add, remove, clear, openFileDialog],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
|
||||||
@@ -736,7 +736,7 @@ export const PromptInput = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.then((convertedFiles: FileUIPart[]) => {
|
.then((convertedFiles: FileUIPart[]) => {
|
||||||
try {
|
try {
|
||||||
@@ -839,7 +839,7 @@ export const PromptInputTextarea = ({
|
|||||||
// Check if the submit button is disabled before submitting
|
// Check if the submit button is disabled before submitting
|
||||||
const form = e.currentTarget.form;
|
const form = e.currentTarget.form;
|
||||||
const submitButton = form?.querySelector(
|
const submitButton = form?.querySelector(
|
||||||
'button[type="submit"]'
|
'button[type="submit"]',
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
if (submitButton?.disabled) {
|
if (submitButton?.disabled) {
|
||||||
return;
|
return;
|
||||||
@@ -1030,7 +1030,7 @@ export const PromptInputSubmit = ({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: PromptInputSubmitProps) => {
|
}: PromptInputSubmitProps) => {
|
||||||
let Icon = <CornerDownLeftIcon className="size-4" />;
|
let Icon = <ArrowUpIcon className="size-4" />;
|
||||||
|
|
||||||
if (status === "submitted") {
|
if (status === "submitted") {
|
||||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||||||
@@ -1123,7 +1123,7 @@ export const PromptInputSpeechButton = ({
|
|||||||
}: PromptInputSpeechButtonProps) => {
|
}: PromptInputSpeechButtonProps) => {
|
||||||
const [isListening, setIsListening] = useState(false);
|
const [isListening, setIsListening] = useState(false);
|
||||||
const [recognition, setRecognition] = useState<SpeechRecognition | null>(
|
const [recognition, setRecognition] = useState<SpeechRecognition | null>(
|
||||||
null
|
null,
|
||||||
);
|
);
|
||||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||||||
|
|
||||||
@@ -1202,8 +1202,8 @@ export const PromptInputSpeechButton = ({
|
|||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative transition-all duration-200",
|
"relative transition-all duration-200",
|
||||||
isListening && "animate-pulse bg-accent text-accent-foreground",
|
isListening && "bg-accent text-accent-foreground animate-pulse",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
disabled={!recognition}
|
disabled={!recognition}
|
||||||
onClick={toggleListening}
|
onClick={toggleListening}
|
||||||
@@ -1230,9 +1230,9 @@ export const PromptInputSelectTrigger = ({
|
|||||||
}: PromptInputSelectTriggerProps) => (
|
}: PromptInputSelectTriggerProps) => (
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className={cn(
|
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",
|
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -1282,7 +1282,7 @@ export type PromptInputHoverCardTriggerProps = ComponentProps<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export const PromptInputHoverCardTrigger = (
|
export const PromptInputHoverCardTrigger = (
|
||||||
props: PromptInputHoverCardTriggerProps
|
props: PromptInputHoverCardTriggerProps,
|
||||||
) => <HoverCardTrigger {...props} />;
|
) => <HoverCardTrigger {...props} />;
|
||||||
|
|
||||||
export type PromptInputHoverCardContentProps = ComponentProps<
|
export type PromptInputHoverCardContentProps = ComponentProps<
|
||||||
@@ -1318,8 +1318,8 @@ export const PromptInputTabLabel = ({
|
|||||||
}: PromptInputTabLabelProps) => (
|
}: PromptInputTabLabelProps) => (
|
||||||
<h3
|
<h3
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-2 px-3 font-medium text-muted-foreground text-xs",
|
"text-muted-foreground mb-2 px-3 text-xs font-medium",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -1342,8 +1342,8 @@ export const PromptInputTabItem = ({
|
|||||||
}: PromptInputTabItemProps) => (
|
}: PromptInputTabItemProps) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent",
|
"hover:bg-accent flex items-center gap-2 px-3 py-2 text-xs",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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 * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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",
|
"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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default:
|
||||||
|
"cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive:
|
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:
|
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:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"cursor-pointer bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
"cursor-pointer hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "cursor-pointer text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
@@ -33,8 +34,8 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
@@ -44,9 +45,9 @@ function Button({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -56,7 +57,7 @@ function Button({
|
|||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...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({
|
function Collapsible({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleTrigger({
|
function CollapsibleTrigger({
|
||||||
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
return (
|
return (
|
||||||
<CollapsiblePrimitive.CollapsibleTrigger
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
data-slot="collapsible-trigger"
|
data-slot="collapsible-trigger"
|
||||||
|
className={cn("cursor-pointer", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleContent({
|
function CollapsibleContent({
|
||||||
@@ -27,7 +31,7 @@ function CollapsibleContent({
|
|||||||
data-slot="collapsible-content"
|
data-slot="collapsible-content"
|
||||||
{...props}
|
{...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 "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@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 *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@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";
|
"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 {
|
@theme inline {
|
||||||
@@ -116,6 +202,7 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -126,3 +213,10 @@
|
|||||||
@apply bg-background text-foreground;
|
@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,
|
"noEmit": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"jsx": "react-jsx",
|
"jsx": "preserve",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
|
|||||||
Reference in New Issue
Block a user