add file upload code from main thread/index

This commit is contained in:
starmorph
2025-05-19 21:23:58 -07:00
parent f29b5054b4
commit d3e5534621

View File

@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from "uuid";
import { ReactNode, useEffect, useRef, ChangeEvent } from "react";
import { ReactNode, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { useStreamContext } from "@/providers/Stream";
@@ -20,9 +20,9 @@ import {
PanelRightOpen,
PanelRightClose,
SquarePen,
XIcon,
Plus,
CircleX,
XIcon,
} from "lucide-react";
import { useQueryState, parseAsBoolean } from "nuqs";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
@@ -113,10 +113,10 @@ function OpenGitHubRepo() {
}
export function Thread() {
const [_threadId, _setThreadId] = useQueryState("threadId");
const [artifactContext, setArtifactContext] = useArtifactContext();
const [artifactOpen, closeArtifact] = useArtifactOpen();
const [threadId, _setThreadId] = useQueryState("threadId");
const [chatHistoryOpen, setChatHistoryOpen] = useQueryState(
"chatHistoryOpen",
parseAsBoolean.withDefault(false),
@@ -143,6 +143,14 @@ export function Thread() {
const lastError = useRef<string | undefined>(undefined);
const setThreadId = (id: string | null) => {
_setThreadId(id);
// close artifact and reset artifact context
closeArtifact();
setArtifactContext({});
};
useEffect(() => {
if (!stream.error) {
lastError.current = undefined;
@@ -154,6 +162,7 @@ export function Thread() {
// Message has already been logged. do not modify ref, return early.
return;
}
// Message is defined, and it has not been logged yet. Save it, and send the error
lastError.current = message;
toast.error("An error occurred. Please try again.", {
@@ -200,16 +209,17 @@ export function Thread() {
};
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
const context =
Object.keys(artifactContext).length > 0 ? artifactContext : undefined;
stream.submit(
{
messages: [...toolMessages, newHumanMessage],
context: artifactContext,
},
{ messages: [...toolMessages, newHumanMessage], context },
{
streamMode: ["values"],
optimisticValues: (prev) => ({
...prev,
context: artifactContext,
context,
messages: [
...(prev.messages ?? []),
...toolMessages,
@@ -223,7 +233,6 @@ export function Thread() {
setContentBlocks([]);
};
// Restore handleRegenerate
const handleRegenerate = (
parentCheckpoint: Checkpoint | null | undefined,
) => {
@@ -236,13 +245,6 @@ export function Thread() {
});
};
const setThreadId = (id: string | null) => {
_setThreadId(id);
// close artifact and reset artifact context
closeArtifact();
setArtifactContext({});
};
const threadId = _threadId;
const chatStarted = !!threadId || !!messages.length;
const hasNoAIOrToolMessages = !messages.find(
(m) => m.type === "ai" || m.type === "tool",
@@ -274,52 +276,36 @@ export function Thread() {
</div>
</motion.div>
</div>
<motion.div
<div
className={cn(
"relative flex min-w-0 flex-1 flex-col overflow-hidden",
!chatStarted && "grid-rows-[1fr]",
"grid w-full grid-cols-[1fr_0fr] transition-all duration-500",
artifactOpen && "grid-cols-[3fr_2fr]",
)}
layout={isLargeScreen}
animate={{
marginLeft: chatHistoryOpen ? (isLargeScreen ? 300 : 0) : 0,
width: chatHistoryOpen
? isLargeScreen
? "calc(100% - 300px)"
: "100%"
: "100%",
}}
transition={
isLargeScreen
? { type: "spring", stiffness: 300, damping: 30 }
: { duration: 0 }
}
>
{!chatStarted && (
<div className="absolute top-0 left-0 z-10 flex w-full items-center justify-between gap-3 p-2 pl-4">
<div>
{(!chatHistoryOpen || !isLargeScreen) && (
<Button
className="hover:bg-gray-100"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
<PanelRightOpen className="size-5" />
) : (
<PanelRightClose className="size-5" />
)}
</Button>
)}
</div>
<div className="absolute top-2 right-4 flex items-center">
<OpenGitHubRepo />
</div>
</div>
)}
{chatStarted && (
<div className="relative z-10 flex items-center justify-between gap-3 p-2">
<div className="relative flex items-center justify-start gap-2">
<div className="absolute left-0 z-10">
<motion.div
className={cn(
"relative flex min-w-0 flex-1 flex-col overflow-hidden",
!chatStarted && "grid-rows-[1fr]",
)}
layout={isLargeScreen}
animate={{
marginLeft: chatHistoryOpen ? (isLargeScreen ? 300 : 0) : 0,
width: chatHistoryOpen
? isLargeScreen
? "calc(100% - 300px)"
: "100%"
: "100%",
}}
transition={
isLargeScreen
? { type: "spring", stiffness: 300, damping: 30 }
: { duration: 0 }
}
>
{!chatStarted && (
<div className="absolute top-0 left-0 z-10 flex w-full items-center justify-between gap-3 p-2 pl-4">
<div>
{(!chatHistoryOpen || !isLargeScreen) && (
<Button
className="hover:bg-gray-100"
@@ -334,137 +320,171 @@ export function Thread() {
</Button>
)}
</div>
<motion.button
className="flex cursor-pointer items-center gap-2"
onClick={() => setThreadId(null)}
animate={{
marginLeft: !chatHistoryOpen ? 48 : 0,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
>
<LangGraphLogoSVG
width={32}
height={32}
/>
<span className="text-xl font-semibold tracking-tight">
Agent Chat
</span>
</motion.button>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center">
<div className="absolute top-2 right-4 flex items-center">
<OpenGitHubRepo />
</div>
<TooltipIconButton
size="lg"
className="p-4"
tooltip="New thread"
variant="ghost"
onClick={() => setThreadId(null)}
>
<SquarePen className="size-5" />
</TooltipIconButton>
</div>
<div className="from-background to-background/0 absolute inset-x-0 top-full h-5 bg-gradient-to-b" />
</div>
)}
<StickToBottom className="relative flex-1 overflow-hidden">
<StickyToBottomContent
className={cn(
"absolute inset-0 overflow-y-scroll px-4 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
!chatStarted && "mt-[25vh] flex flex-col items-stretch",
chatStarted && "grid grid-rows-[1fr_auto]",
)}
contentClassName="pt-8 pb-16 max-w-3xl mx-auto flex flex-col gap-4 w-full"
content={
<>
{messages
.filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX))
.map((message, index) =>
message.type === "human" ? (
<HumanMessage
key={message.id || `${message.type}-${index}`}
message={message}
isLoading={isLoading}
/>
) : (
<AssistantMessage
key={message.id || `${message.type}-${index}`}
message={message}
isLoading={isLoading}
handleRegenerate={handleRegenerate}
/>
),
)}
{chatStarted && (
<div className="relative z-10 flex items-center justify-between gap-3 p-2">
<div className="relative flex items-center justify-start gap-2">
<div className="absolute left-0 z-10">
{(!chatHistoryOpen || !isLargeScreen) && (
<Button
className="hover:bg-gray-100"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
<PanelRightOpen className="size-5" />
) : (
<PanelRightClose className="size-5" />
)}
</Button>
)}
{/* Special rendering case where there are no AI/tool messages, but there is an interrupt.
We need to render it outside of the messages list, since there are no messages to render */}
{hasNoAIOrToolMessages && !!stream.interrupt && (
<AssistantMessage
key="interrupt-msg"
message={undefined}
isLoading={isLoading}
handleRegenerate={handleRegenerate}
/>
)}
{isLoading && !firstTokenReceived && (
<AssistantMessageLoading />
)}
</>
}
footer={
<div className="sticky bottom-0 flex flex-col items-center gap-8 bg-white">
{!chatStarted && (
<div className="flex items-center gap-3">
<LangGraphLogoSVG className="h-8 flex-shrink-0" />
<h1 className="text-2xl font-semibold tracking-tight">
Agent Chat
</h1>
</div>
)}
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" />
<div
ref={dropRef}
className="bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl border shadow-xs"
</div>
<motion.button
className="flex cursor-pointer items-center gap-2"
onClick={() => setThreadId(null)}
animate={{
marginLeft: !chatHistoryOpen ? 48 : 0,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
>
<form
onSubmit={handleSubmit}
className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2"
>
<ContentBlocksPreview
<LangGraphLogoSVG
width={32}
height={32}
/>
<span className="text-xl font-semibold tracking-tight">
Agent Chat
</span>
</motion.button>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center">
<OpenGitHubRepo />
</div>
<TooltipIconButton
size="lg"
className="p-4"
tooltip="New thread"
variant="ghost"
onClick={() => setThreadId(null)}
>
<SquarePen className="size-5" />
</TooltipIconButton>
</div>
<div className="from-background to-background/0 absolute inset-x-0 top-full h-5 bg-gradient-to-b" />
</div>
)}
<StickToBottom className="relative flex-1 overflow-hidden">
<StickyToBottomContent
className={cn(
"absolute inset-0 overflow-y-scroll px-4 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
!chatStarted && "mt-[25vh] flex flex-col items-stretch",
chatStarted && "grid grid-rows-[1fr_auto]",
)}
contentClassName="pt-8 pb-16 max-w-3xl mx-auto flex flex-col gap-4 w-full"
content={
<>
{messages
.filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX))
.map((message, index) =>
message.type === "human" ? (
<HumanMessage
key={message.id || `${message.type}-${index}`}
message={message}
isLoading={isLoading}
/>
) : (
<AssistantMessage
key={message.id || `${message.type}-${index}`}
message={message}
isLoading={isLoading}
handleRegenerate={handleRegenerate}
/>
),
)}
{/* Special rendering case where there are no AI/tool messages, but there is an interrupt.
We need to render it outside of the messages list, since there are no messages to render */}
{hasNoAIOrToolMessages && !!stream.interrupt && (
<AssistantMessage
key="interrupt-msg"
message={undefined}
isLoading={isLoading}
handleRegenerate={handleRegenerate}
/>
)}
{isLoading && !firstTokenReceived && (
<AssistantMessageLoading />
)}
</>
}
footer={
<div className="sticky bottom-0 flex flex-col items-center gap-8 bg-white">
{!chatStarted && (
<div className="flex items-center gap-3">
<LangGraphLogoSVG className="h-8 flex-shrink-0" />
<h1 className="text-2xl font-semibold tracking-tight">
Agent Chat
</h1>
</div>
)}
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" />
<div ref={dropRef} className="bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl border shadow-xs">
<form
onSubmit={handleSubmit}
className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2"
>
<ContentBlocksPreview
blocks={contentBlocks}
onRemove={removeBlock}
/>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.metaKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
const el = e.target as HTMLElement | undefined;
const form = el?.closest("form");
form?.requestSubmit();
}
}}
placeholder="Type your message..."
className="field-sizing-content resize-none border-none bg-transparent p-3.5 pb-0 shadow-none ring-0 outline-none focus:ring-0 focus:outline-none"
/>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.metaKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
const el = e.target as HTMLElement | undefined;
const form = el?.closest("form");
form?.requestSubmit();
}
}}
placeholder="Type your message..."
className="field-sizing-content resize-none border-none bg-transparent p-3.5 pb-0 shadow-none ring-0 outline-none focus:ring-0 focus:outline-none"
/>
<div className="flex items-center justify-between p-2 pt-4">
<div className="flex items-center gap-2">
<div className="flex items-center gap-6 p-2 pt-4">
<div>
<div className="flex items-center space-x-2">
<Switch
id="render-tool-calls"
checked={hideToolCalls ?? false}
onCheckedChange={setHideToolCalls}
/>
<Label
htmlFor="render-tool-calls"
className="text-sm text-gray-600"
>
Hide Tool Calls
</Label>
</div>
</div>
<Label
htmlFor="file-input"
className="flex cursor-pointer items-center gap-2"
@@ -482,62 +502,50 @@ export function Thread() {
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
className="hidden"
/>
<div className="flex items-center space-x-2">
<Switch
id="render-tool-calls"
checked={hideToolCalls ?? false}
onCheckedChange={setHideToolCalls}
/>
<Label
htmlFor="render-tool-calls"
className="text-sm text-gray-600"
{stream.isLoading ? (
<Button
key="stop"
onClick={() => stream.stop()}
className="ml-auto"
>
Hide Tool Calls
</Label>
</div>
<LoaderCircle className="h-4 w-4 animate-spin" />
Cancel
</Button>
) : (
<Button
type="submit"
className="shadow-md transition-all ml-auto"
disabled={
isLoading ||
(!input.trim() && contentBlocks.length === 0)
}
>
Send
</Button>
)}
</div>
{stream.isLoading ? (
<Button
key="stop"
onClick={() => stream.stop()}
>
<LoaderCircle className="h-4 w-4 animate-spin" />
Cancel
</Button>
) : (
<Button
type="submit"
className="shadow-md transition-all"
disabled={
isLoading ||
(!input.trim() && contentBlocks.length === 0)
}
>
Send
</Button>
)}
</div>
</form>
</form>
</div>
</div>
</div>
}
/>
</StickToBottom>
</motion.div>
<div className="relative flex flex-col border-l">
<div className="absolute inset-0 flex min-w-[30vw] flex-col">
<div className="grid grid-cols-[1fr_auto] border-b p-4">
<ArtifactTitle className="truncate overflow-hidden" />
<button
onClick={closeArtifact}
className="cursor-pointer"
>
<XIcon className="size-5" />
</button>
}
/>
</StickToBottom>
</motion.div>
<div className="relative flex flex-col border-l">
<div className="absolute inset-0 flex min-w-[30vw] flex-col">
<div className="grid grid-cols-[1fr_auto] border-b p-4">
<ArtifactTitle className="truncate overflow-hidden" />
<button
onClick={closeArtifact}
className="cursor-pointer"
>
<XIcon className="size-5" />
</button>
</div>
<ArtifactContent className="relative flex-grow" />
</div>
<ArtifactContent className="relative flex-grow" />
</div>
</div>
</div>
);
}
}