Skip to content
Open
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
85 changes: 56 additions & 29 deletions src/features/chat/components/rightpanel/ChatMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -50,7 +49,7 @@ const FinalResponseLoadingBar = () => {
}, []);

return (
<div className="flex min-h-[30px] w-full items-center gap-[8px] overflow-hidden rounded-[12px] border-[0.5px] border-[#D1D6DE] bg-[#E3E7ED] p-[10px]">
<div className={`${STATUS_BAR_BASE_CLASS} p-[16px]`}>
<SparkleIcon
size={24}
color={isBlue ? "#2A6AFF" : "#6B7280"}
Expand Down Expand Up @@ -80,21 +79,15 @@ const UserMessage = ({ message }: { message: ChatMessage }) => (
<MessageAttachments attachments={message.attachments} />
)}
{/* 텍스트 메시지 */}
<div className="flex items-start justify-end gap-[12px]">
<div className="max-w-[400px] overflow-hidden rounded-[12px] border-[0.5px] border-[#D1D6DE] bg-white p-[10px]">
<div className="flex items-start justify-end">
<div className="max-w-[400px] overflow-hidden rounded-[12px] border-[0.5px] border-[#D1D6DE] bg-white p-[16px]">
<div className="text-[14px] leading-[20px] font-medium break-words whitespace-pre-wrap text-black">
<MessageContent
content={message.content}
enableFileMentionChip={true}
/>
</div>
</div>
<div className="shrink-0">
<ProfileIcon
size={40}
color="#2A6AFF"
/>
</div>
</div>
</div>
);
Expand All @@ -104,29 +97,54 @@ const AssistantMessage = ({
content,
isStreaming,
statusText,
toolStatuses,
}: {
content: string;
isStreaming?: boolean;
statusText?: string;
toolStatuses?: ChatMessage["toolStatuses"];
}) => (
<div className="flex items-start justify-start gap-[12px]">
<div className="flex h-[50px] w-[50px] shrink-0 items-center justify-center rounded-[40px] border-[0.5px] border-[#D1D6DE] bg-white">
<div className="flex items-start justify-start gap-[24px]">
<div className="flex h-[30px] w-[30px] shrink-0 items-start justify-start">
<SubscriptionIcon
size={40}
size={30}
isActive={true}
/>
</div>
{isStreaming && !content ? (
statusText ? (
<ThinkingBar statusText={statusText} />
) : (
<FinalResponseLoadingBar />
)
{isStreaming && !content && toolStatuses?.length ? (
<div className="flex min-w-0 flex-1 flex-col gap-[8px] md:max-w-[540px]">
{toolStatuses.map((status) => (
<ToolStatusBar
key={status.id}
icon={status.icon}
label={status.label}
/>
))}
</div>
) : isStreaming && !content ? (
<div className="flex min-w-0 flex-1 flex-col md:max-w-[540px]">
{statusText ? (
<ThinkingBar statusText={statusText} />
) : (
<FinalResponseLoadingBar />
)}
</div>
) : (
<div className="w-full overflow-hidden rounded-[12px] border-[0.5px] border-[#D1D6DE] bg-white p-[10px]">
<div className="text-sm leading-5 break-words text-gray-900">
<MessageContent content={content} />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-[8px] md:max-w-[540px]">
{content.trim().length > 0 && (
<div className="overflow-hidden rounded-[12px] border-[0.5px] border-[#D1D6DE] bg-white p-[16px]">
<div className="text-sm leading-5 break-words text-gray-900">
<MessageContent content={content} />
</div>
</div>
)}
{toolStatuses?.map((status) => (
<ToolStatusBar
key={status.id}
icon={status.icon}
label={status.label}
/>
))}
</div>
)}
</div>
Expand Down Expand Up @@ -250,16 +268,24 @@ export const ChatMessages = ({ messages }: ChatMessagesProps) => {
return (
<div
ref={scrollRef}
className="relative flex h-full justify-center overflow-x-hidden overflow-y-auto"
className="relative flex h-full justify-center overflow-x-hidden overflow-y-auto px-[30px]"
>
{/* 가운데 정렬 컨테이너 - ChatInput과 동일한 max-width */}
<div className="w-full max-w-[660px] min-w-[270px] px-[16px] pt-[40px]">
<div className="w-full max-w-[660px] min-w-[270px] pt-[40px]">
{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 (
<div
Expand All @@ -275,6 +301,7 @@ export const ChatMessages = ({ messages }: ChatMessagesProps) => {
content={message.content}
isStreaming={message.isStreaming}
statusText={message.statusText}
toolStatuses={message.toolStatuses}
/>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/features/chat/components/rightpanel/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const RightPanel = ({
</div>

{/* 입력 영역 */}
<div className="flex shrink-0 justify-center px-[16px] pb-[20px]">
<div className="flex shrink-0 justify-center px-[30px] pb-[20px]">
<ChatInput
noteId={noteId}
onSend={onSend}
Expand Down
3 changes: 2 additions & 1 deletion src/features/chat/components/rightpanel/ThinkingBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { SparkleIcon } from "@/shared/components/icons/SparkleIcon";
import { STATUS_BAR_BASE_CLASS } from "./status_bar_styles";

interface ThinkingBarProps {
/** 서버에서 전달받은 진행 상황 텍스트 (없으면 기본 메시지) */
Expand All @@ -26,7 +27,7 @@ export const ThinkingBar = ({ statusText }: ThinkingBarProps) => {
const iconColor = isBlue ? "#2A6AFF" : "#6B7280";

return (
<div className="flex min-h-[30px] w-full items-center gap-[8px] overflow-hidden rounded-[12px] border-[0.5px] border-[#D1D6DE] bg-[#E3E7ED] p-[10px]">
<div className={`${STATUS_BAR_BASE_CLASS} p-[16px]`}>
<SparkleIcon
size={24}
color={iconColor}
Expand Down
111 changes: 111 additions & 0 deletions src/features/chat/components/rightpanel/ToolStatusBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useId, type SVGProps } from "react";
import type { ToolStatusIconType } from "../../types/chat_types";
import { STATUS_BAR_BASE_CLASS } from "./status_bar_styles";

interface ToolStatusBarProps {
icon: ToolStatusIconType;
label: string;
className?: string;
}

const PythonIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
{...props}
>
<path
d="M11.9953 20.4718C11.8636 20.4718 11.7224 20.4718 11.5813 20.4718C8.74007 20.3683 8.19441 19.2393 8.03447 18.2703C7.94039 17.687 8.00624 15.7583 8.02506 15.3726V13.8297C8.20381 11.7787 9.69029 11.2425 10.4523 11.2425H13.7922C14.4037 10.9696 14.432 10.5463 14.432 10.4616V5.96452C14.432 5.81399 14.2908 5.61643 14.0462 5.41886C13.7922 5.22129 12.7573 4.92964 11.8259 4.92964C10.8381 4.93904 9.72792 5.40004 9.42686 5.78577C9.20107 6.07742 9.27633 6.65131 9.32337 6.83007C9.41746 7.20639 9.19166 7.59212 8.81534 7.6862C8.43902 7.78028 8.05328 7.5639 7.9592 7.18757C7.92157 7.04645 7.62992 5.81399 8.31671 4.92964C8.98468 4.06409 10.6217 3.53724 11.8259 3.52783C12.8232 3.52783 14.3002 3.81007 14.94 4.31811C15.6926 4.92023 15.8526 5.54116 15.8526 5.96452V10.424C15.8902 10.9602 15.5892 12.0892 14.1873 12.6066L13.9427 12.6537H10.4712C10.2642 12.6537 9.54917 12.776 9.44568 13.8955V15.4008C9.41746 16.3228 9.38923 17.7152 9.44568 18.0351C9.51154 18.4208 9.59621 18.9853 11.6566 19.07C13.764 19.1452 14.3191 18.5243 14.3285 18.5149L14.4696 18.3738C14.4696 18.3738 14.6766 18.1574 14.6483 17.6399C14.6295 17.2542 14.9306 16.9249 15.3163 16.9061C15.7115 16.8967 16.0313 17.1884 16.0501 17.5741C16.0972 18.6372 15.6268 19.2111 15.4104 19.4181C15.1564 19.7285 14.2908 20.4906 12.0141 20.4906L11.9953 20.4718Z"
fill="#2A6AFF"
/>
<path
d="M8.73068 16.0974H6.33162C5.07094 16.0786 4.33711 15.2131 4.11131 14.6956C3.22695 12.7387 3.55624 10.5655 4.11131 9.30478C4.60053 8.18522 5.59779 7.9312 6.09642 7.96883H12.1176C12.5033 7.96883 12.8232 8.2793 12.8232 8.67444C12.8232 9.06958 12.5033 9.38004 12.1176 9.38004H6.04938C5.94589 9.38004 5.59779 9.40827 5.40022 9.86926C5.05212 10.6689 4.65698 12.4753 5.40022 14.1123C5.42844 14.1593 5.69187 14.6674 6.35043 14.6768H8.74008C9.12582 14.6768 9.44569 14.9967 9.44569 15.3824C9.44569 15.7681 9.12582 16.088 8.74008 16.088L8.73068 16.0974Z"
fill="#2A6AFF"
/>
<path
d="M17.9883 15.9279C17.9883 15.9279 17.9318 15.9279 17.9036 15.9279H11.8824C11.4967 15.9279 11.1768 15.6174 11.1768 15.2223C11.1768 14.8271 11.4967 14.5167 11.8824 14.5167H17.9412C18.0259 14.5167 18.3928 14.4979 18.5904 14.0275C18.9385 13.2278 19.3336 11.4214 18.5904 9.78442C18.5622 9.73738 18.2987 9.21994 17.6402 9.21053H15.2505C14.8648 9.21053 14.5449 8.89065 14.5449 8.50492C14.5449 8.11919 14.8648 7.79932 15.2505 7.79932H17.6496C18.9197 7.81813 19.6441 8.68368 19.8699 9.20112C20.7542 11.158 20.425 13.3313 19.8699 14.5919C19.4089 15.6551 18.4869 15.9279 17.96 15.9279H17.9883Z"
fill="#2A6AFF"
/>
<path
d="M10.65 7.18775C10.9461 7.18775 11.1862 6.94766 11.1862 6.65149C11.1862 6.35533 10.9461 6.11523 10.65 6.11523C10.3538 6.11523 10.1137 6.35533 10.1137 6.65149C10.1137 6.94766 10.3538 7.18775 10.65 7.18775Z"
fill="#2A6AFF"
/>
<path
d="M13.3971 17.7625C13.6933 17.7625 13.9334 17.5224 13.9334 17.2262C13.9334 16.93 13.6933 16.6899 13.3971 16.6899C13.1009 16.6899 12.8608 16.93 12.8608 17.2262C12.8608 17.5224 13.1009 17.7625 13.3971 17.7625Z"
fill="#2A6AFF"
/>
</svg>
);

const TransformIcon = (props: SVGProps<SVGSVGElement>) => {
// 같은 아이콘이 여러 개 렌더링돼도 gradient id가 충돌하지 않도록 인스턴스마다 고유 id 생성
const gradientId = useId();

return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
{...props}
>
<path
d="M8.55191 18.835C8.33553 18.8256 8.0721 18.7033 7.94039 18.4869L5.91765 15.0623C5.72008 14.7236 5.83298 14.2909 6.17167 14.0933C6.51036 13.8957 6.94313 14.0086 7.1407 14.3473L8.56132 16.7464L11.5719 11.7131C11.7036 11.4967 11.9294 11.365 12.174 11.365H17.452L14.5072 6.57625H11.0074C10.6217 6.57625 10.3018 6.25638 10.3018 5.87064C10.3018 5.48491 10.6217 5.16504 11.0074 5.16504H14.9024C15.147 5.16504 15.3728 5.29675 15.5045 5.50373L19.3148 11.7037C19.4465 11.92 19.4559 12.1929 19.3336 12.4187C19.2113 12.6445 18.9761 12.7762 18.722 12.7762H12.5786L9.17285 18.4869C9.04113 18.7033 8.81534 18.835 8.57073 18.835H8.55191Z"
fill="#2A6AFF"
/>
<path
d="M15.0435 18.8348H8.54254V17.4236H14.639L18.0917 11.7129L19.296 12.4467L15.6362 18.5055C15.5045 18.7125 15.2787 18.8442 15.0341 18.8442L15.0435 18.8348Z"
fill="#2A6AFF"
/>
<path
d="M7.3062 4.70411L3.76324 8.24707L7.51858 12.0024L11.0615 8.45945L7.3062 4.70411Z"
fill={`url(#${gradientId})`}
/>
<defs>
<linearGradient
id={gradientId}
x1="5.54562"
y1="6.48431"
x2="9.30207"
y2="10.2408"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" />
<stop
offset="1"
stopColor="#2A6AFF"
stopOpacity="0.3"
/>
</linearGradient>
</defs>
</svg>
);
};

const ICON_BY_TYPE: Record<ToolStatusIconType, typeof PythonIcon> = {
python: PythonIcon,
transform: TransformIcon,
};

export const ToolStatusBar = ({
icon,
label,
className = "",
}: ToolStatusBarProps) => {
const Icon = ICON_BY_TYPE[icon];

return (
<div className={`${STATUS_BAR_BASE_CLASS} px-[20px] py-[8px] ${className}`}>
<Icon className="size-[24px] shrink-0" />
<p className="min-w-0 truncate text-[14px] leading-[20px] font-medium text-[#6B7280]">
{label}
</p>
</div>
);
};
1 change: 1 addition & 0 deletions src/features/chat/components/rightpanel/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { RightPanel } from "./RightPanel";
export { ChatMessages } from "./ChatMessages";
export { ToolStatusBar } from "./ToolStatusBar";
8 changes: 8 additions & 0 deletions src/features/chat/components/rightpanel/status_bar_styles.ts
Original file line number Diff line number Diff line change
@@ -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]";
42 changes: 42 additions & 0 deletions src/features/chat/hooks/tool_status_mock.ts
Original file line number Diff line number Diff line change
@@ -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<ChatMessage["toolStatuses"]> = [
{
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,
);
};
Loading