diff --git a/src/features/chat/components/rightpanel/ChatMessages.tsx b/src/features/chat/components/rightpanel/ChatMessages.tsx index df6c6e37..eab4fa5b 100644 --- a/src/features/chat/components/rightpanel/ChatMessages.tsx +++ b/src/features/chat/components/rightpanel/ChatMessages.tsx @@ -5,14 +5,13 @@ import { useCallback, useState, } from "react"; -import { - ProfileIcon, - SubscriptionIcon, -} from "@/shared/components/icons/SettingsIcons"; +import { SubscriptionIcon } from "@/shared/components/icons/SettingsIcons"; import { SparkleIcon } from "@/shared/components/icons/SparkleIcon"; import { MessageContent } from "./MessageContent"; import { MessageAttachments } from "./MessageAttachments"; import { ThinkingBar } from "./ThinkingBar"; +import { ToolStatusBar } from "./ToolStatusBar"; +import { STATUS_BAR_BASE_CLASS } from "./status_bar_styles"; import type { ChatMessage } from "../../types/chat_types"; interface ChatMessagesProps { @@ -50,7 +49,7 @@ const FinalResponseLoadingBar = () => { }, []); return ( -
+
( )} {/* 텍스트 메시지 */} -
-
+
+
( />
-
- -
); @@ -104,29 +97,54 @@ const AssistantMessage = ({ content, isStreaming, statusText, + toolStatuses, }: { content: string; isStreaming?: boolean; statusText?: string; + toolStatuses?: ChatMessage["toolStatuses"]; }) => ( -
-
+
+
- {isStreaming && !content ? ( - statusText ? ( - - ) : ( - - ) + {isStreaming && !content && toolStatuses?.length ? ( +
+ {toolStatuses.map((status) => ( + + ))} +
+ ) : isStreaming && !content ? ( +
+ {statusText ? ( + + ) : ( + + )} +
) : ( -
-
- -
+
+ {content.trim().length > 0 && ( +
+
+ +
+
+ )} + {toolStatuses?.map((status) => ( + + ))}
)}
@@ -250,16 +268,24 @@ export const ChatMessages = ({ messages }: ChatMessagesProps) => { return (
{/* 가운데 정렬 컨테이너 - ChatInput과 동일한 max-width */} -
+
{messages.map((message, index) => { const prevMessage = messages[index - 1]; const isNewGroup = prevMessage?.role === "assistant" && message.role === "user"; + const isAnswerAfterQuestion = + prevMessage?.role === "user" && message.role === "assistant"; const marginTop = - index === 0 ? "" : isNewGroup ? "mt-[40px]" : "mt-[12px]"; + index === 0 + ? "" + : isNewGroup + ? "mt-[40px]" + : isAnswerAfterQuestion + ? "mt-[24px]" + : "mt-[12px]"; return (
{ content={message.content} isStreaming={message.isStreaming} statusText={message.statusText} + toolStatuses={message.toolStatuses} /> )}
diff --git a/src/features/chat/components/rightpanel/RightPanel.tsx b/src/features/chat/components/rightpanel/RightPanel.tsx index c47cc71f..f00e82f1 100644 --- a/src/features/chat/components/rightpanel/RightPanel.tsx +++ b/src/features/chat/components/rightpanel/RightPanel.tsx @@ -37,7 +37,7 @@ export const RightPanel = ({
{/* 입력 영역 */} -
+
{ const iconColor = isBlue ? "#2A6AFF" : "#6B7280"; return ( -
+
) => ( + +); + +const TransformIcon = (props: SVGProps) => { + // 같은 아이콘이 여러 개 렌더링돼도 gradient id가 충돌하지 않도록 인스턴스마다 고유 id 생성 + const gradientId = useId(); + + return ( + + ); +}; + +const ICON_BY_TYPE: Record = { + python: PythonIcon, + transform: TransformIcon, +}; + +export const ToolStatusBar = ({ + icon, + label, + className = "", +}: ToolStatusBarProps) => { + const Icon = ICON_BY_TYPE[icon]; + + return ( +
+ +

+ {label} +

+
+ ); +}; diff --git a/src/features/chat/components/rightpanel/index.ts b/src/features/chat/components/rightpanel/index.ts index 73a93b39..b61559ed 100644 --- a/src/features/chat/components/rightpanel/index.ts +++ b/src/features/chat/components/rightpanel/index.ts @@ -1,2 +1,3 @@ export { RightPanel } from "./RightPanel"; export { ChatMessages } from "./ChatMessages"; +export { ToolStatusBar } from "./ToolStatusBar"; diff --git a/src/features/chat/components/rightpanel/status_bar_styles.ts b/src/features/chat/components/rightpanel/status_bar_styles.ts new file mode 100644 index 00000000..a42a7246 --- /dev/null +++ b/src/features/chat/components/rightpanel/status_bar_styles.ts @@ -0,0 +1,8 @@ +/** + * AI 상태바 공통 컨테이너 스타일 + * + * ThinkingBar / FinalResponseLoadingBar / ToolStatusBar가 공유하는 + * 둥근 회색 바 컨테이너의 기본 클래스. 패딩은 각 컴포넌트에서 덧붙인다. + */ +export const STATUS_BAR_BASE_CLASS = + "flex min-h-[30px] w-full items-center gap-[8px] overflow-hidden rounded-[12px] border-[0.5px] border-[#D1D6DE] bg-[#E3E7ED]"; diff --git a/src/features/chat/hooks/tool_status_mock.ts b/src/features/chat/hooks/tool_status_mock.ts new file mode 100644 index 00000000..b0d42edb --- /dev/null +++ b/src/features/chat/hooks/tool_status_mock.ts @@ -0,0 +1,42 @@ +import type { ChatMessage } from "../types/chat_types"; + +/** + * 도구 상태바(ToolStatusBar) UI 확인용 목업 데이터 + * + * 개발 환경(`import.meta.env.DEV`)에서 `?toolStatusMock=true` 쿼리 파라미터가 + * 있을 때만 사용된다. 실제 SSE 이벤트 연동 시 제거 예정. + */ +export const MOCK_TOOL_STATUSES: NonNullable = [ + { + id: "mock-python-create", + icon: "python", + label: "파이썬 코드 생성 중...", + }, + { + id: "mock-python-calculate", + icon: "python", + label: "파이썬 코드로 문제의 정답 계산 중...", + }, + { + id: "mock-transform-create", + icon: "transform", + label: "변형 문제가 생성되는 중...", + }, +]; + +/** 첫 번째 assistant 메시지에 목업 도구 상태바를 주입한다. */ +export const withMockToolStatuses = ( + messages: ChatMessage[], +): ChatMessage[] => { + const firstAssistantIndex = messages.findIndex( + (message) => message.role === "assistant", + ); + + if (firstAssistantIndex === -1) return messages; + + return messages.map((message, index) => + index === firstAssistantIndex + ? { ...message, toolStatuses: MOCK_TOOL_STATUSES } + : message, + ); +}; diff --git a/src/features/chat/hooks/useChatMessages.ts b/src/features/chat/hooks/useChatMessages.ts index 30a06b97..f6b33c59 100644 --- a/src/features/chat/hooks/useChatMessages.ts +++ b/src/features/chat/hooks/useChatMessages.ts @@ -25,6 +25,7 @@ import { creditKeys } from "@/features/settings/hooks/useCredit"; import { userKeys } from "@/features/settings/hooks/useUser"; import { assetKeys } from "@/features/storage/hooks/useAssets"; import { showErrorToast } from "@/shared/lib/toast"; +import { MOCK_TOOL_STATUSES, withMockToolStatuses } from "./tool_status_mock"; type PendingAttachment = ChatSendData["attachments"][number]; @@ -116,6 +117,8 @@ export const useChatMessages = () => { const chatEntrySource = (initialStateRef.current as { chatEntrySource?: string } | null) ?.chatEntrySource ?? "unknown"; + const shouldUseToolStatusMock = + import.meta.env.DEV && searchParams.get("toolStatusMock") === "true"; const viewerFile = firstMessageData?.viewerFile; @@ -206,10 +209,13 @@ export const useChatMessages = () => { setMessages((prev) => { const serverIds = new Set(serverMessages.map((m) => m.id)); const localOnly = prev.filter((m) => !serverIds.has(m.id)); - return [...serverMessages, ...localOnly]; + const mergedMessages = [...serverMessages, ...localOnly]; + return shouldUseToolStatusMock + ? withMockToolStatuses(mergedMessages) + : mergedMessages; }); } - }, [noteDetail, hasFirstMessage]); + }, [noteDetail, hasFirstMessage, shouldUseToolStatusMock]); // ─── 스트리밍 상태 관리 ─── const [isUploading, setIsUploading] = useState(false); @@ -249,12 +255,14 @@ export const useChatMessages = () => { // 컴포넌트 언마운트 시 진행 중인 스트리밍 + 미리보기 URL 정리 useEffect(() => { + const managedPreviewUrls = managedPreviewUrlsRef.current; + return () => { abortControllerRef.current?.abort(); - managedPreviewUrlsRef.current.forEach((url) => { + managedPreviewUrls.forEach((url) => { URL.revokeObjectURL(url); }); - managedPreviewUrlsRef.current.clear(); + managedPreviewUrls.clear(); }; }, []); @@ -501,6 +509,7 @@ export const useChatMessages = () => { role: "assistant", content: "", isStreaming: true, + toolStatuses: shouldUseToolStatusMock ? MOCK_TOOL_STATUSES : undefined, }, ]); @@ -659,6 +668,7 @@ export const useChatMessages = () => { registerPreviewUrl, searchParams, setSearchParams, + shouldUseToolStatusMock, ]); // ─── 후속 대화 전송 핸들러 ─── @@ -728,6 +738,9 @@ export const useChatMessages = () => { role: "assistant", content: "", isStreaming: true, + toolStatuses: shouldUseToolStatusMock + ? MOCK_TOOL_STATUSES + : undefined, }, ]); @@ -787,7 +800,13 @@ export const useChatMessages = () => { setIsStreamingResponse(false); } }, - [noteId, processStream, queryClient, toMessageAttachment], + [ + noteId, + processStream, + queryClient, + shouldUseToolStatusMock, + toMessageAttachment, + ], ); // ─── 파생 데이터 ─── diff --git a/src/features/chat/hooks/useChatPanel.ts b/src/features/chat/hooks/useChatPanel.ts index fd93f30d..4252aa7c 100644 --- a/src/features/chat/hooks/useChatPanel.ts +++ b/src/features/chat/hooks/useChatPanel.ts @@ -42,7 +42,7 @@ export const useChatPanel = ({ } = useResizable({ initialWidth: 50, leftMinPx: 382, - rightMinPx: 302, + rightMinPx: 330, }); const handleTabChange = useCallback( diff --git a/src/features/chat/types/chat_types.ts b/src/features/chat/types/chat_types.ts index 137a2545..bb5e4b8b 100644 --- a/src/features/chat/types/chat_types.ts +++ b/src/features/chat/types/chat_types.ts @@ -10,6 +10,16 @@ export interface MessageAttachment { previewUrl?: string; } +/** AI 도구 실행 상태바 아이콘 종류 */ +export type ToolStatusIconType = "python" | "transform"; + +/** AI 도구 실행 상태바 */ +export interface ToolStatus { + id: string; + icon: ToolStatusIconType; + label: string; +} + /** 채팅 메시지 */ export interface ChatMessage { id: string; @@ -21,4 +31,6 @@ export interface ChatMessage { isStreaming?: boolean; /** AI 진행 상황 텍스트 — message(custom) 이벤트의 status (assistant 메시지에만 사용) */ statusText?: string; + /** AI 도구 실행 상태바 목록 (assistant 메시지에만 사용) */ + toolStatuses?: ToolStatus[]; } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 5748fc70..1209ec5d 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -67,7 +67,7 @@ const ChatPageContent = () => { return (