feat: integrated with artifacts in states

This commit is contained in:
Henry Li
2026-01-17 17:21:37 +08:00
parent 384353d613
commit f1c6991194
8 changed files with 118 additions and 70 deletions

View File

@@ -14,6 +14,7 @@ import {
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { import {
ArtifactFileDetail, ArtifactFileDetail,
ArtifactFileList,
useArtifacts, useArtifacts,
} from "@/components/workspace/artifacts"; } from "@/components/workspace/artifacts";
import { FlipDisplay } from "@/components/workspace/flip-display"; import { FlipDisplay } from "@/components/workspace/flip-display";
@@ -32,8 +33,10 @@ export default function ChatPage() {
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const { setOpen: setSidebarOpen } = useSidebar(); const { setOpen: setSidebarOpen } = useSidebar();
const { const {
artifacts,
open: artifactsOpen, open: artifactsOpen,
setOpen: setArtifactsOpen, setOpen: setArtifactsOpen,
setArtifacts,
selectedArtifact, selectedArtifact,
} = useArtifacts(); } = useArtifacts();
@@ -65,6 +68,10 @@ export default function ChatPage() {
return result; return result;
}, [thread, isNewThread]); }, [thread, isNewThread]);
useEffect(() => {
setArtifacts(thread.values.artifacts);
}, [setArtifacts, thread.values.artifacts]);
const handleSubmit = useSubmitThread({ const handleSubmit = useSubmitThread({
isNewThread, isNewThread,
threadId, threadId,
@@ -96,17 +103,17 @@ export default function ChatPage() {
</FlipDisplay> </FlipDisplay>
</div> </div>
<div> <div>
{!artifactsOpen && ( {artifacts?.length && !artifactsOpen && (
<Tooltip content="Show artifacts"> <Tooltip content="Show artifacts of this conversation">
<Button <Button
variant="ghost" variant="ghost"
size="icon"
onClick={() => { onClick={() => {
setArtifactsOpen(true); setArtifactsOpen(true);
setSidebarOpen(false); setSidebarOpen(false);
}} }}
> >
<FilesIcon /> <FilesIcon />
Artifacts
</Button> </Button>
</Tooltip> </Tooltip>
)} )}
@@ -161,7 +168,7 @@ export default function ChatPage() {
threadId={threadId!} threadId={threadId!}
/> />
) : ( ) : (
<div className="relative flex size-full items-center justify-center"> <div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30"> <div className="absolute top-1 right-1 z-30">
<Button <Button
size="icon-sm" size="icon-sm"
@@ -173,11 +180,26 @@ export default function ChatPage() {
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
{thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState <ConversationEmptyState
icon={<FilesIcon />} icon={<FilesIcon />}
title="No artifact selected" title="No artifact selected"
description="Select an artifact to view its details" description="Select an artifact to view its details"
/> />
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId!}
/>
</main>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -16,8 +16,8 @@ export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => ( export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div <div
className={cn( className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm", "bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
className className,
)} )}
{...props} {...props}
/> />
@@ -31,8 +31,8 @@ export const ArtifactHeader = ({
}: ArtifactHeaderProps) => ( }: ArtifactHeaderProps) => (
<div <div
className={cn( className={cn(
"flex items-center justify-between border-b bg-muted/50 px-4 py-3", "bg-muted/50 flex items-center justify-between border-b px-4 py-3",
className className,
)} )}
{...props} {...props}
/> />
@@ -49,8 +49,8 @@ export const ArtifactClose = ({
}: ArtifactCloseProps) => ( }: ArtifactCloseProps) => (
<Button <Button
className={cn( className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground", "text-muted-foreground hover:text-foreground size-8 p-0",
className className,
)} )}
size={size} size={size}
type="button" type="button"
@@ -65,8 +65,8 @@ export const ArtifactClose = ({
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>; export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => ( export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
<p <div
className={cn("font-medium text-foreground text-sm", className)} className={cn("text-foreground text-sm font-medium", className)}
{...props} {...props}
/> />
); );
@@ -108,8 +108,8 @@ export const ArtifactAction = ({
const button = ( const button = (
<Button <Button
className={cn( className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground", "text-muted-foreground hover:text-foreground size-8 p-0",
className className,
)} )}
size={size} size={size}
type="button" type="button"

View File

@@ -1,27 +1,27 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) { }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} /> return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
function SelectTrigger({ function SelectTrigger({
@@ -30,15 +30,15 @@ function SelectTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default";
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@@ -47,7 +47,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
@@ -65,7 +65,7 @@ function SelectContent({
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className,
)} )}
position={position} position={position}
align={align} align={align}
@@ -76,7 +76,7 @@ function SelectContent({
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)} )}
> >
{children} {children}
@@ -84,7 +84,7 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({
@@ -97,7 +97,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({
@@ -109,8 +109,8 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className,
)} )}
{...props} {...props}
> >
@@ -124,7 +124,7 @@ function SelectItem({
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
@@ -137,7 +137,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
@@ -149,13 +149,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@@ -167,13 +167,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@@ -187,4 +187,4 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} };

View File

@@ -12,17 +12,19 @@ import {
ArtifactAction, ArtifactAction,
ArtifactActions, ArtifactActions,
ArtifactContent, ArtifactContent,
ArtifactDescription,
ArtifactHeader, ArtifactHeader,
ArtifactTitle, ArtifactTitle,
} from "@/components/ai-elements/artifact"; } from "@/components/ai-elements/artifact";
import { Select, SelectItem } from "@/components/ui/select";
import {
SelectContent,
SelectGroup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useArtifactContent } from "@/core/artifacts/hooks"; import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import { import { checkCodeFile, getFileName } from "@/core/utils/files";
checkCodeFile,
getFileExtensionDisplayName,
getFileName,
} from "@/core/utils/files";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useArtifacts } from "./context"; import { useArtifacts } from "./context";
@@ -37,7 +39,7 @@ export function ArtifactFileDetail({
filepath: string; filepath: string;
threadId: string; threadId: string;
}) { }) {
const { setOpen } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
const { isCodeFile } = useMemo(() => checkCodeFile(filepath), [filepath]); const { isCodeFile } = useMemo(() => checkCodeFile(filepath), [filepath]);
const { content } = useArtifactContent({ const { content } = useArtifactContent({
threadId, threadId,
@@ -46,12 +48,24 @@ export function ArtifactFileDetail({
}); });
return ( return (
<Artifact className={cn("rounded-none", className)}> <Artifact className={cn("rounded-none", className)}>
<ArtifactHeader> <ArtifactHeader className="px-2">
<div> <div>
<ArtifactTitle>{getFileName(filepath)}</ArtifactTitle> <ArtifactTitle>
<ArtifactDescription className="mt-1 text-xs"> <Select value={filepath} onValueChange={select}>
{getFileExtensionDisplayName(filepath)} file <SelectTrigger className="border-none bg-transparent! select-none focus:outline-0 active:outline-0">
</ArtifactDescription> <SelectValue placeholder="Select a file" />
</SelectTrigger>
<SelectContent className="select-none">
<SelectGroup>
{(artifacts ?? []).map((filepath) => (
<SelectItem key={filepath} value={filepath}>
{getFileName(filepath)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</ArtifactTitle>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ArtifactActions> <ArtifactActions>

View File

@@ -24,12 +24,13 @@ export function ArtifactFileList({
files: string[]; files: string[];
threadId: string; threadId: string;
}) { }) {
const { openArtifact } = useArtifacts(); const { select: selectArtifact, setOpen } = useArtifacts();
const handleClick = useCallback( const handleClick = useCallback(
(filepath: string) => { (filepath: string) => {
openArtifact(filepath); selectArtifact(filepath);
setOpen(true);
}, },
[openArtifact], [selectArtifact, setOpen],
); );
return ( return (
<ul className={cn("flex w-full flex-col gap-4", className)}> <ul className={cn("flex w-full flex-col gap-4", className)}>

View File

@@ -4,13 +4,15 @@ import { useSidebar } from "@/components/ui/sidebar";
export interface ArtifactsContextType { export interface ArtifactsContextType {
artifacts: string[]; artifacts: string[];
setArtifacts: (artifacts: string[]) => void;
selectedArtifact: string | null; selectedArtifact: string | null;
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
deselect: () => void;
addArtifacts: (artifacts: string[]) => void; select: (artifact: string) => void;
openArtifact: (artifact: string) => void;
} }
const ArtifactsContext = createContext<ArtifactsContextType | undefined>( const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
@@ -27,23 +29,25 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { setOpen: setSidebarOpen } = useSidebar(); const { setOpen: setSidebarOpen } = useSidebar();
const addArtifacts = (newArtifacts: string[]) => { const select = (artifact: string) => {
setArtifacts((prev) => [...prev, ...newArtifacts]); setSelectedArtifact(artifact);
setSidebarOpen(false);
}; };
const openArtifact = (artifact: string) => { const deselect = () => {
setSelectedArtifact(artifact); setSelectedArtifact(null);
setOpen(true);
setSidebarOpen(false);
}; };
const value: ArtifactsContextType = { const value: ArtifactsContextType = {
artifacts, artifacts,
selectedArtifact, setArtifacts,
open, open,
setOpen, setOpen,
addArtifacts,
openArtifact, selectedArtifact,
select,
deselect,
}; };
return ( return (

View File

@@ -1,3 +1,5 @@
import type { AgentThread } from "../threads";
export function urlOfArtifact({ export function urlOfArtifact({
filepath, filepath,
threadId, threadId,
@@ -9,3 +11,7 @@ export function urlOfArtifact({
}) { }) {
return `http://localhost:8000/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`; return `http://localhost:8000/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
} }
export function extractArtifactsFromThread(thread: AgentThread) {
return thread.values.artifacts ?? [];
}

View File

@@ -4,6 +4,7 @@ import type { Thread } from "@langchain/langgraph-sdk";
export interface AgentThreadState extends Record<string, unknown> { export interface AgentThreadState extends Record<string, unknown> {
title: string; title: string;
messages: BaseMessage[]; messages: BaseMessage[];
artifacts: string[];
} }
export interface AgentThread extends Thread<AgentThreadState> {} export interface AgentThread extends Thread<AgentThreadState> {}