feat: drop assistant ui, use custom chat ui
This commit is contained in:
66
src/components/thread/messages/ai.tsx
Normal file
66
src/components/thread/messages/ai.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useStreamContext } from "@/providers/Stream";
|
||||
import { Message } from "@langchain/langgraph-sdk";
|
||||
import { getContentString } from "../utils";
|
||||
import { BranchSwitcher, CommandBar } from "./shared";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { MarkdownText } from "../markdown-text";
|
||||
|
||||
export function AssistantMessage({
|
||||
message,
|
||||
isLoading,
|
||||
}: {
|
||||
message: Message;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const thread = useStreamContext();
|
||||
const meta = thread.getMessagesMetadata(message);
|
||||
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
|
||||
|
||||
const contentString = getContentString(message.content);
|
||||
|
||||
const handleRegenerate = () => {
|
||||
thread.submit(undefined, { checkpoint: parentCheckpoint });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start mr-auto gap-2 group">
|
||||
<Avatar>
|
||||
<AvatarFallback>A</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="rounded-2xl bg-muted px-4 py-2">
|
||||
<MarkdownText>{contentString}</MarkdownText>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center mr-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<BranchSwitcher
|
||||
branch={meta?.branch}
|
||||
branchOptions={meta?.branchOptions}
|
||||
onSelect={(branch) => thread.setBranch(branch)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<CommandBar
|
||||
content={contentString}
|
||||
isLoading={isLoading}
|
||||
isAiMessage={true}
|
||||
handleRegenerate={handleRegenerate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssistantMessageLoading() {
|
||||
return (
|
||||
<div className="flex items-start mr-auto gap-2">
|
||||
<Avatar>
|
||||
<AvatarFallback>A</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex items-center gap-1 rounded-2xl bg-muted px-4 py-2 h-8">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-foreground/50 animate-[pulse_1.5s_ease-in-out_infinite]"></div>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-foreground/50 animate-[pulse_1.5s_ease-in-out_0.5s_infinite]"></div>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-foreground/50 animate-[pulse_1.5s_ease-in-out_1s_infinite]"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/components/thread/messages/human.tsx
Normal file
107
src/components/thread/messages/human.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useStreamContext } from "@/providers/Stream";
|
||||
import { Message } from "@langchain/langgraph-sdk";
|
||||
import { useState } from "react";
|
||||
import { getContentString } from "../utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { BranchSwitcher, CommandBar } from "./shared";
|
||||
|
||||
function EditableContent({
|
||||
value,
|
||||
setValue,
|
||||
onSubmit,
|
||||
}: {
|
||||
value: string;
|
||||
setValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HumanMessage({
|
||||
message,
|
||||
isLoading,
|
||||
}: {
|
||||
message: Message;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const thread = useStreamContext();
|
||||
const meta = thread.getMessagesMetadata(message);
|
||||
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const contentString = getContentString(message.content);
|
||||
|
||||
const handleSubmitEdit = () => {
|
||||
setIsEditing(false);
|
||||
thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
...message,
|
||||
content: value,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
checkpoint: parentCheckpoint,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center ml-auto gap-2 px-4 py-2 group",
|
||||
isEditing && "w-full max-w-xl",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-col gap-2", isEditing && "w-full")}>
|
||||
{isEditing ? (
|
||||
<EditableContent
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
onSubmit={handleSubmitEdit}
|
||||
/>
|
||||
) : (
|
||||
<p>{contentString}</p>
|
||||
)}
|
||||
<div className="flex gap-2 items-center ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<BranchSwitcher
|
||||
branch={meta?.branch}
|
||||
branchOptions={meta?.branchOptions}
|
||||
onSelect={(branch) => thread.setBranch(branch)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<CommandBar
|
||||
isLoading={isLoading}
|
||||
content={contentString}
|
||||
isEditing={isEditing}
|
||||
setIsEditing={(c) => {
|
||||
if (c) {
|
||||
setValue(contentString);
|
||||
}
|
||||
setIsEditing(c);
|
||||
}}
|
||||
handleSubmitEdit={handleSubmitEdit}
|
||||
isHumanMessage={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
src/components/thread/messages/shared.tsx
Normal file
213
src/components/thread/messages/shared.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
XIcon,
|
||||
SendHorizontal,
|
||||
RefreshCcw,
|
||||
Pencil,
|
||||
Copy,
|
||||
CopyCheck,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { TooltipIconButton } from "../tooltip-icon-button";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function ContentCopyable({
|
||||
content,
|
||||
disabled,
|
||||
}: {
|
||||
content: string;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipIconButton
|
||||
onClick={(e: any) => handleCopy(e)}
|
||||
variant="ghost"
|
||||
tooltip="Copy content"
|
||||
disabled={disabled}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<CopyCheck className="text-green-500" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Copy />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TooltipIconButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchSwitcher({
|
||||
branch,
|
||||
branchOptions,
|
||||
onSelect,
|
||||
isLoading,
|
||||
}: {
|
||||
branch: string | undefined;
|
||||
branchOptions: string[] | undefined;
|
||||
onSelect: (branch: string) => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (!branchOptions || !branch) return null;
|
||||
const index = branchOptions.indexOf(branch);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const prevBranch = branchOptions[index - 1];
|
||||
if (!prevBranch) return;
|
||||
onSelect(prevBranch);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
{index + 1} / {branchOptions.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const nextBranch = branchOptions[index + 1];
|
||||
if (!nextBranch) return;
|
||||
onSelect(nextBranch);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandBar({
|
||||
content,
|
||||
isHumanMessage,
|
||||
isAiMessage,
|
||||
isEditing,
|
||||
setIsEditing,
|
||||
handleSubmitEdit,
|
||||
handleRegenerate,
|
||||
isLoading,
|
||||
}: {
|
||||
content: string;
|
||||
isHumanMessage?: boolean;
|
||||
isAiMessage?: boolean;
|
||||
isEditing?: boolean;
|
||||
setIsEditing?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleSubmitEdit?: () => void;
|
||||
handleRegenerate?: () => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (isHumanMessage && isAiMessage) {
|
||||
throw new Error(
|
||||
"Can only set one of isHumanMessage or isAiMessage to true, not both.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!isHumanMessage && !isAiMessage) {
|
||||
throw new Error(
|
||||
"One of isHumanMessage or isAiMessage must be set to true.",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isHumanMessage &&
|
||||
(isEditing === undefined ||
|
||||
setIsEditing === undefined ||
|
||||
handleSubmitEdit === undefined)
|
||||
) {
|
||||
throw new Error(
|
||||
"If isHumanMessage is true, all of isEditing, setIsEditing, and handleSubmitEdit must be set.",
|
||||
);
|
||||
}
|
||||
|
||||
const showEdit =
|
||||
isHumanMessage &&
|
||||
isEditing !== undefined &&
|
||||
!!setIsEditing &&
|
||||
!!handleSubmitEdit;
|
||||
|
||||
if (isHumanMessage && isEditing && !!setIsEditing && !!handleSubmitEdit) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipIconButton
|
||||
disabled={isLoading}
|
||||
tooltip="Cancel edit"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</TooltipIconButton>
|
||||
<TooltipIconButton
|
||||
disabled={isLoading}
|
||||
tooltip="Submit"
|
||||
variant="secondary"
|
||||
onClick={handleSubmitEdit}
|
||||
>
|
||||
<SendHorizontal />
|
||||
</TooltipIconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ContentCopyable content={content} disabled={isLoading} />
|
||||
{isAiMessage && !!handleRegenerate && (
|
||||
<TooltipIconButton
|
||||
disabled={isLoading}
|
||||
tooltip="Refresh"
|
||||
variant="ghost"
|
||||
onClick={handleRegenerate}
|
||||
>
|
||||
<RefreshCcw />
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
{showEdit && (
|
||||
<TooltipIconButton
|
||||
disabled={isLoading}
|
||||
tooltip="Edit"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsEditing?.(true);
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user