From 8548708c5db56caff19fb5d2c73e1313b15b4c6a Mon Sep 17 00:00:00 2001 From: kevinkim-ogp Date: Thu, 6 Nov 2025 10:05:23 +0800 Subject: [PATCH 01/16] feat: add chat interface --- packages/frontend/src/assets/plumber-logo.svg | 5 + packages/frontend/src/hooks/useChatStream.ts | 118 ++++++++++++ .../src/pages/AiBuilder/AiBuilderContext.tsx | 2 +- .../ChatInterface/ChatMessageToolbar.tsx | 62 +++++++ .../components/ChatInterface/ChatMessages.tsx | 120 +++++++++++++ .../components/ChatInterface/Loader.tsx | 30 ++++ .../components/ChatInterface/PromptInput.tsx | 169 ++++++++++++++++++ .../components/ChatInterface/index.tsx | 168 +++++++++++++++++ .../AiBuilder/components/IdeaButtons.tsx | 37 ++++ .../frontend/src/pages/AiBuilder/index.tsx | 26 +-- .../frontend/src/pages/Flows/constants.ts | 34 ++++ .../src/theme/components/Streamdown.tsx | 36 ++++ 12 files changed, 783 insertions(+), 24 deletions(-) create mode 100644 packages/frontend/src/assets/plumber-logo.svg create mode 100644 packages/frontend/src/hooks/useChatStream.ts create mode 100644 packages/frontend/src/pages/AiBuilder/components/ChatInterface/ChatMessageToolbar.tsx create mode 100644 packages/frontend/src/pages/AiBuilder/components/ChatInterface/ChatMessages.tsx create mode 100644 packages/frontend/src/pages/AiBuilder/components/ChatInterface/Loader.tsx create mode 100644 packages/frontend/src/pages/AiBuilder/components/ChatInterface/PromptInput.tsx create mode 100644 packages/frontend/src/pages/AiBuilder/components/ChatInterface/index.tsx create mode 100644 packages/frontend/src/pages/AiBuilder/components/IdeaButtons.tsx create mode 100644 packages/frontend/src/theme/components/Streamdown.tsx diff --git a/packages/frontend/src/assets/plumber-logo.svg b/packages/frontend/src/assets/plumber-logo.svg new file mode 100644 index 0000000000..23a7aab094 --- /dev/null +++ b/packages/frontend/src/assets/plumber-logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/frontend/src/hooks/useChatStream.ts b/packages/frontend/src/hooks/useChatStream.ts new file mode 100644 index 0000000000..ee685c437b --- /dev/null +++ b/packages/frontend/src/hooks/useChatStream.ts @@ -0,0 +1,118 @@ +import { useCallback, useMemo } from 'react' +import type { UIMessage as AIMessage } from '@ai-sdk/react' +import { useChat } from '@ai-sdk/react' +import { useToast } from '@opengovsg/design-system-react' +import { DefaultChatTransport } from 'ai' + +export interface Message { + text: string + traceId?: string + generationId?: string + isUser: boolean +} + +export function useChatStream() { + const toast = useToast() + const { + messages: aiMessages, + sendMessage, + status, + error: aiError, + stop, + } = useChat({ + transport: new DefaultChatTransport({ + api: '/api/chat', + credentials: 'include', + prepareSendMessagesRequest: ({ messages }) => { + // Send all messages to maintain conversation context + const body = { + messages: messages, + userId: 'user-123', + sessionId: 'session-456', + } + return { body } + }, + }), + onError: (error: Error) => { + toast({ + title: 'Error: ' + error.message, + status: 'error', + duration: 3000, + isClosable: true, + position: 'top', + }) + }, + }) + + // Helper function to extract text content from UIMessage + const extractTextContent = useCallback((msg: AIMessage): string => { + return msg.parts + .filter((part) => part.type === 'text') + .map((part) => (part as any).text) + .join('') + }, []) + + // Transform AI SDK messages to our Message format + const messages = useMemo(() => { + const isActivelyStreaming = status === 'streaming' || status === 'submitted' + + // Filter user and assistant messages + let messagesToTransform = aiMessages.filter( + (msg) => msg.role === 'user' || msg.role === 'assistant', + ) + + // If streaming, exclude the last assistant message (shown via currentResponse) + if (isActivelyStreaming && messagesToTransform.length > 0) { + const lastMsg = messagesToTransform[messagesToTransform.length - 1] + if (lastMsg.role === 'assistant') { + messagesToTransform = messagesToTransform.slice(0, -1) + } + } + + return messagesToTransform.map((msg: AIMessage) => ({ + text: extractTextContent(msg), + isUser: msg.role === 'user', + traceId: undefined, + generationId: undefined, + })) + }, [aiMessages, extractTextContent, status]) + + // Get the current streaming response (last assistant message that's still being streamed) + const currentResponse = useMemo(() => { + const isActivelyStreaming = status === 'streaming' || status === 'submitted' + + if (!isActivelyStreaming) { + return '' + } + + const lastMessage = aiMessages[aiMessages.length - 1] + if (lastMessage && lastMessage.role === 'assistant') { + return extractTextContent(lastMessage) + } + return '' + }, [aiMessages, status, extractTextContent]) + + // Wrapper for sendMessage that matches the expected signature + const sendMessageWrapper = useCallback( + (userPrompt: string) => { + sendMessage({ + role: 'user', + parts: [{ type: 'text', text: userPrompt }], + }) + }, + [sendMessage], + ) + + const cancelStream = useCallback(() => { + stop() + }, [stop]) + + return { + messages, + currentResponse, + isStreaming: status === 'submitted' || status === 'streaming', + error: aiError?.message || null, + sendMessage: sendMessageWrapper, + cancelStream, + } +} diff --git a/packages/frontend/src/pages/AiBuilder/AiBuilderContext.tsx b/packages/frontend/src/pages/AiBuilder/AiBuilderContext.tsx index 910d9882c4..2a0ff5ed8b 100644 --- a/packages/frontend/src/pages/AiBuilder/AiBuilderContext.tsx +++ b/packages/frontend/src/pages/AiBuilder/AiBuilderContext.tsx @@ -30,9 +30,9 @@ interface AiBuilderStep extends IStep { interface AIBuilderContextValue extends AIBuilderSharedProps { allApps: IApp[] - isMobile: boolean triggerStep: IStep | null steps: AiBuilderStep[] + isMobile: boolean actionSteps: IStep[] stepsBeforeGroup: IStep[] groupedSteps: IStep[][] diff --git a/packages/frontend/src/pages/AiBuilder/components/ChatInterface/ChatMessageToolbar.tsx b/packages/frontend/src/pages/AiBuilder/components/ChatInterface/ChatMessageToolbar.tsx new file mode 100644 index 0000000000..05ab8fe876 --- /dev/null +++ b/packages/frontend/src/pages/AiBuilder/components/ChatInterface/ChatMessageToolbar.tsx @@ -0,0 +1,62 @@ +import { FaRegCopy, FaRegThumbsDown } from 'react-icons/fa' +import { FaCheck } from 'react-icons/fa6' +import { Flex, Icon, IconButton, Tooltip } from '@chakra-ui/react' + +import { Message } from '@/hooks/useChatStream' + +interface ChatMessageToolbarProps { + message: Message + index: number + copiedIndex: number | null + setCopiedIndex: (index: number | null) => void + onOpenDrawer: () => void +} + +const DEFAULT_BUTTON_PROPS = { + size: 'xs', + variant: 'clear', + color: 'gray.500', + _hover: { color: 'gray.700', bg: 'gray.100' }, +} + +export default function ChatMessageToolbar({ + message, + index, + copiedIndex, + setCopiedIndex, +}: ChatMessageToolbarProps) { + const handleCopy = async (text: string, index: number) => { + await navigator.clipboard.writeText(text) + setCopiedIndex(index) + setTimeout(() => setCopiedIndex(null), 2000) + } + + const handleThumbsDown = () => { + // TODO: Implement feedback functionality + } + + return ( + + + } + onClick={handleThumbsDown} + /> + + + } + onClick={() => handleCopy(message.text, index)} + /> + + {/* TODO: Add preview button */} + + ) +} diff --git a/packages/frontend/src/pages/AiBuilder/components/ChatInterface/ChatMessages.tsx b/packages/frontend/src/pages/AiBuilder/components/ChatInterface/ChatMessages.tsx new file mode 100644 index 0000000000..67b5fa32c4 --- /dev/null +++ b/packages/frontend/src/pages/AiBuilder/components/ChatInterface/ChatMessages.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react' +import { Box, Flex, Image, ImageProps, Text, VStack } from '@chakra-ui/react' + +import plumberLogo from '@/assets/plumber-logo.svg' +import { Message } from '@/hooks/useChatStream' +import { ChakraStreamdown } from '@/theme/components/Streamdown' + +import ChatMessageToolbar from './ChatMessageToolbar' +import Loader from './Loader' + +interface ChatMessagesProps { + messages: Message[] + currentResponse: string + isStreaming: boolean + messagesEndRef: React.RefObject + messagesContainerRef: React.RefObject + hasMessages: boolean + onOpenDrawer: () => void +} + +interface StreamingMessageProps { + currentResponse: string +} + +const PlumberAvatar = (props: ImageProps) => { + return ( + Plumber + ) +} + +const StreamingMessage = ({ currentResponse }: StreamingMessageProps) => ( + + + {currentResponse ? ( + + + {currentResponse} + + + ) : ( + + )} + +) + +export default function ChatMessages({ + messages, + currentResponse, + isStreaming, + messagesEndRef, + messagesContainerRef, + onOpenDrawer, +}: ChatMessagesProps) { + const [copiedIndex, setCopiedIndex] = useState(null) + + return ( + + + + {messages.map((message, index) => ( + + {message.isUser ? ( + + + + {message.text} + + + + ) : ( + + + + + {message.text || ''} + + + + + )} + + ))} + + {/* Streaming response */} + {isStreaming && ( + + )} + +
+ + + + ) +} diff --git a/packages/frontend/src/pages/AiBuilder/components/ChatInterface/Loader.tsx b/packages/frontend/src/pages/AiBuilder/components/ChatInterface/Loader.tsx new file mode 100644 index 0000000000..f35a3db898 --- /dev/null +++ b/packages/frontend/src/pages/AiBuilder/components/ChatInterface/Loader.tsx @@ -0,0 +1,30 @@ +import { Box, Flex, keyframes } from '@chakra-ui/react' + +// Animated dots keyframes +const dotFlashing = keyframes` + 0%, 80%, 100% { opacity: 0.3; } + 40% { opacity: 1; } +` + +function Dot({ animation }: { animation: string }) { + return ( + + ) +} + +export default function Loader() { + return ( + + + + + + ) +} diff --git a/packages/frontend/src/pages/AiBuilder/components/ChatInterface/PromptInput.tsx b/packages/frontend/src/pages/AiBuilder/components/ChatInterface/PromptInput.tsx new file mode 100644 index 0000000000..481ec69777 --- /dev/null +++ b/packages/frontend/src/pages/AiBuilder/components/ChatInterface/PromptInput.tsx @@ -0,0 +1,169 @@ +import { + type FormEvent, + type KeyboardEvent, + type SyntheticEvent, + useRef, + useState, +} from 'react' +import { FaArrowCircleRight } from 'react-icons/fa' +import { FaCircleStop } from 'react-icons/fa6' +import { Box, Flex, Icon, Text, Textarea } from '@chakra-ui/react' + +import pairLogo from '@/assets/pair-logo.svg' +import { ImageBox } from '@/components/FlowStepConfigurationModal/ChooseAndAddConnection/ConfigureExcelConnection' +import { AI_CHAT_IDEAS, AiChatIdea, AiFormIdea } from '@/pages/Flows/constants' + +import IdeaButtons from '../IdeaButtons' + +interface PromptInputProps { + isStreaming: boolean + showIdeas?: boolean + sendMessage: (message: string) => void + cancelStream: () => void +} + +export default function PromptInput({ + isStreaming, + showIdeas = false, + sendMessage, + cancelStream, +}: PromptInputProps) { + const [input, setInput] = useState('') + const textareaRef = useRef(null) + + const handleSubmit = async (e: SyntheticEvent) => { + e.preventDefault() + if (input?.trim()) { + sendMessage(input) + setInput('') + if (textareaRef.current) { + textareaRef.current.style.height = 'auto' + } + } + } + + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit(e) + } + } + + const handleResize = (e?: FormEvent) => { + const target = e?.currentTarget || textareaRef.current + if (!target) { + return + } + const maxHeight = window.innerHeight * 0.4 - 100 // 40vh minus padding/margins + target.style.height = 'auto' + target.style.height = Math.min(target.scrollHeight, maxHeight) + 'px' + } + + return ( + + +