feat: implement basic web app

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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