Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
node_modules
dist

.idea
*.log
.DS_Store
.eslintcache
6 changes: 5 additions & 1 deletion playground/src/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ export function Chat() {
if (!backend) {
throw new Error("Backend is not initialized");
}
const { messages, input, setMessages } = useChat(backend, initialMessages);
const { messages, input, setMessages, isPending } = useChat(
backend,
initialMessages,
);

const onClear = () => {
setPrompt("");
Expand All @@ -57,6 +60,7 @@ export function Chat() {
className="px-4 w-full max-w-full"
messages={messages}
background="right-solid"
isPending={isPending}
footer={
<Button
onClick={onClear}
Expand Down
154 changes: 113 additions & 41 deletions src/bubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ export interface BubbleProps
* @default "solid"
*/
background?: "transparent" | "solid";
/**
* Custom pending content to display when pending is true.
* @description If not provided, will use default dots animation.
*/
pending?: React.ReactNode;
/**
* Whether the bubble is in pending state.
* @default false
*/
isPending?: boolean;
}

export function Bubble({
Expand All @@ -97,10 +107,26 @@ export function Bubble({
size,
align,
background = "solid",
pending,
isPending = false,
...props
}: BubbleProps) {
const { isDark } = useTheme();

const defaultPending = (
<div className="flex items-center space-x-1 py-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: "0.1s" }}
/>
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: "0.2s" }}
/>
</div>
);

return (
<div
data-slot="bubble"
Expand All @@ -112,50 +138,55 @@ export function Bubble({
align,
background,
}),
pending && "flex items-center",
),
)}
{...props}
>
<Markdown
remarkPlugins={[remarkGfm, remarkMath]}
components={{
code(props) {
const { children, className, ref: _ref, ...rest } = props;
const match = /language-(\w+)/.exec(className || "");
return match ? (
<div className="w-full overflow-x-auto border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 rounded-lg">
<SyntaxHighlighter
{...rest}
PreTag="div"
language={match[1]}
style={isDark ? vscDarkPlus : oneLight}
customStyle={{
background: "transparent",
margin: 0,
padding: "1rem",
borderRadius: "0.5rem",
overflowX: "auto",
}}
codeTagProps={{
style: {
fontFamily: "monospace",
fontSize: "0.875rem",
},
}}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
</div>
) : (
<code {...rest} className={className}>
{children}
</code>
);
},
}}
>
{text}
</Markdown>
{isPending ? (
pending || defaultPending
) : (
<Markdown
remarkPlugins={[remarkGfm, remarkMath]}
components={{
code(props) {
const { children, className, ref: _ref, ...rest } = props;
const match = /language-(\w+)/.exec(className || "");
return match ? (
<div className="w-full overflow-x-auto border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 rounded-lg">
<SyntaxHighlighter
{...rest}
PreTag="div"
language={match[1]}
style={isDark ? vscDarkPlus : oneLight}
customStyle={{
background: "transparent",
margin: 0,
padding: "1rem",
borderRadius: "0.5rem",
overflowX: "auto",
}}
codeTagProps={{
style: {
fontFamily: "monospace",
fontSize: "0.875rem",
},
}}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
</div>
) : (
<code {...rest} className={className}>
{children}
</code>
);
},
}}
>
{text}
</Markdown>
)}
</div>
);
}
Expand Down Expand Up @@ -202,13 +233,27 @@ export interface BubbleListProps extends React.ComponentProps<"div"> {
* @default "right-solid"
*/
background?: "transparent" | "solid" | "left-solid" | "right-solid";
isPending?: boolean;
assistant?: {
avatar?: AvatarProps;
align?: "left" | "right";
};
footer?: React.ReactNode;
pending?: React.ReactNode;
}

export function BubbleList({
className,
background = "right-solid",
footer,
pending,
assistant = {
avatar: {
text: "A",
},
align: "left",
},
isPending = true,
...props
}: BubbleListProps) {
const { messages } = props;
Expand All @@ -222,7 +267,7 @@ export function BubbleList({
block: "end",
});
}
}, [messages]);
}, [messages, isPending]);

return (
<div
Expand Down Expand Up @@ -269,6 +314,33 @@ export function BubbleList({
/>
</div>
))}
{isPending && (
<div
key="pending"
data-slot="bubble-item"
className={twMerge(
clsx(assistant?.align === "right" && "flex-row-reverse"),
"flex items-start gap-2 w-full",
)}
>
<Avatar className="flex-shrink-0" {...(assistant?.avatar || {})} />
<Bubble
isPending={isPending}
pending={pending}
text=""
align={assistant?.align || "left"}
background={
(background === "left-solid" &&
(assistant?.align || "left") === "left") ||
(background === "right-solid" &&
(assistant?.align || "left") === "right") ||
background === "solid"
? "solid"
: "transparent"
}
/>
</div>
)}
</div>
{footer && (
<div
Expand Down
10 changes: 9 additions & 1 deletion src/utils/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ export function useChat(
input: (prompt: string, options?: InputOptions) => Promise<void>;
on: <K extends EventTypes["type"]>(type: K, handler: Events[K]) => () => void;
setMessages: React.Dispatch<React.SetStateAction<MessageParam[]>>;
isPending: boolean;
} {
const [messages, setMessages] = useState<MessageParam[]>(initialMessages);
const [isPending, setIsPending] = useState(false);

const input = async (prompt: string, options?: InputOptions) => {
setIsPending(true);
return backend.input(prompt, {
messages,
...options,
Expand Down Expand Up @@ -47,6 +50,7 @@ export function useChat(
setMessages((prevMessages) => [...prevMessages, event.payload]);
}),
backend.on("error", (event) => {
setIsPending(false);
console.error("Error from backend:", event.payload.error);
setMessages((prevMessages) => [
...prevMessages,
Expand All @@ -58,7 +62,11 @@ export function useChat(
},
]);
}),
backend.on("finish", (event) => {
setIsPending(false);
}),
backend.on("chunk", (event) => {
setIsPending(false);
setMessages((prev) => {
const lastMessage = prev[prev.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
Expand Down Expand Up @@ -92,5 +100,5 @@ export function useChat(
};
}, [backend]);

return { messages, input, on, setMessages };
return { messages, input, on, setMessages, isPending };
}
Loading