diff --git a/src/app/(route)/chat/ChatPage.tsx b/src/app/(route)/chat/ChatPage.tsx index 6569c3f2..c54964ea 100644 --- a/src/app/(route)/chat/ChatPage.tsx +++ b/src/app/(route)/chat/ChatPage.tsx @@ -1,13 +1,192 @@ 'use client'; -import QueryBox from '@/app/(route)/home/_components/HomeQueryBox/HomeQueryBox'; +import HomeQueryBox from '@/app/(route)/home/_components/HomeQueryBox/HomeQueryBox'; +import CardList from '@/components/basics/CardList/CardList'; +import LinkCard from '@/components/basics/LinkCard/LinkCard'; +import Tab from '@/components/basics/Tab/Tab'; +import UserChatBox from '@/components/wrappers/UserChatBox/UserChatBox'; +import { chatHistoryById, chatLinksById, chatReasoningById, mockChats } from '@/mocks'; +import { useChatRightPanelStore } from '@/stores/chatRightPanelStore'; +import { useChatStore } from '@/stores/chatStore'; +import { useSearchParams } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; export default function Chat() { + const params = useSearchParams(); + const { setChats, setActiveChat } = useChatStore(); + const { setSelectedLink } = useChatRightPanelStore(); + const [message, setMessage] = useState(''); + const useMockData = process.env.NEXT_PUBLIC_USE_MOCKS === 'true'; + + const activeId = useMemo(() => { + const param = params.get('chatId'); + const parsed = param ? Number(param) : NaN; + if (!Number.isNaN(parsed)) return parsed; + return useMockData ? (mockChats[0]?.id ?? null) : null; + }, [params, useMockData]); + + useEffect(() => { + setChats(useMockData ? mockChats : []); + }, [setChats, useMockData]); + + useEffect(() => { + if (activeId !== null) { + setActiveChat(activeId); + } + }, [activeId, setActiveChat]); + + const messages = useMemo(() => { + if (!useMockData || activeId === null) return []; + return chatHistoryById[activeId] ?? []; + }, [activeId, useMockData]); + const chatPairs = useMemo(() => { + const pairs: { + user: (typeof messages)[number]; + assistant?: (typeof messages)[number]; + }[] = []; + let pendingUser: (typeof messages)[number] | null = null; + + messages.forEach(message => { + if (message.role === 'user') { + if (pendingUser) { + pairs.push({ user: pendingUser }); + } + pendingUser = message; + } else if (message.role === 'assistant') { + if (pendingUser) { + pairs.push({ user: pendingUser, assistant: message }); + pendingUser = null; + } + } + }); + + if (pendingUser) { + pairs.push({ user: pendingUser }); + } + + return pairs; + }, [messages]); + const linkCards = activeId && useMockData ? (chatLinksById[activeId] ?? []) : []; + const reasoning = activeId && useMockData ? chatReasoningById[activeId] : undefined; return ( -
-
- -
+
+
+
+ {chatPairs.length === 0 ? ( +

요청을 입력해 보세요.

+ ) : ( +
+ {chatPairs.map(({ user, assistant }) => ( +
+
+
+ {user.content} +
+
+ +
+

+ {reasoning?.answer ?? assistant?.content ?? '응답을 생성 중입니다.'} +

+
+
+ {linkCards.length === 0 ? ( +

표시할 링크가 없습니다.

+ ) : ( + + {linkCards.map(link => ( + setSelectedLink(link)} + /> + ))} + + )} +
+
+ ), + 링크: ( +
+ {linkCards.length === 0 ? ( +

표시할 링크가 없습니다.

+ ) : ( + + {linkCards.map(link => ( + setSelectedLink(link)} + /> + ))} + + )} +
+ ), + 단계: ( +
    + {reasoning?.reasoningSteps?.length + ? reasoning.reasoningSteps.map((step, index) => { + const relatedLinks = linkCards.filter(link => + step.linkIds.includes(link.id) + ); + + return ( +
  1. +

    + {index + 1}. {step.step} +

    + {relatedLinks.length > 0 && ( +
    + {relatedLinks.map(link => ( + + ))} +
    + )} +
  2. + ); + }) + : null} +
+ ), + }} + /> +
+ ))} +
+ )} +
+ +
+
+ +
+
+ setMessage(e.target.value)} + onSubmit={() => setMessage('')} + /> +
+
+
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 45f6a66a..7dc99683 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import ReactQueryProvider from '@/components/ReactQueryProvider'; import ToastContainer from '@/components/basics/Toast/ToastContainer'; import SideNavigation from '@/components/layout/SideNavigation/SideNavigation'; +import ChatRightSidebar from '@/components/layout/SideNavigation/components/ChatRightSidebar/ChatRightSidebar'; import '@/styles/globals.css'; import type { Metadata } from 'next'; @@ -60,6 +61,7 @@ export default function RootLayout({
{children}
+ diff --git a/src/components/layout/SideNavigation/SideNavigation.tsx b/src/components/layout/SideNavigation/SideNavigation.tsx index 8ca0195a..f6390d44 100644 --- a/src/components/layout/SideNavigation/SideNavigation.tsx +++ b/src/components/layout/SideNavigation/SideNavigation.tsx @@ -1,5 +1,7 @@ 'use client'; +import { mockChats } from '@/mocks'; +import { useChatStore } from '@/stores/chatStore'; import { useModalStore } from '@/stores/modalStore'; import { useSideNavStore } from '@/stores/sideNavStore'; import { motion } from 'framer-motion'; @@ -7,18 +9,28 @@ import { motion } from 'framer-motion'; import AddLinkButton from './components/AddLink/AddLinkButton'; import AddLinkModal from './components/AddLinkModal/AddLinkModal'; import AllLinkButton from './components/AllLink/AllLinkButton'; +import ChatRoomList from './components/ChatRoomList/ChatRoomList'; import NewChatButton from './components/NewChat/NewChatButton'; import SideNavHeaderIconButton from './components/SideNavToggle/SideNavToggle'; export default function SideNavigation() { const { type, open } = useModalStore(); const { isOpen, toggle } = useSideNavStore(); + const { chats, activeChatId } = useChatStore(); + const useMockData = process.env.NEXT_PUBLIC_USE_MOCKS === 'true'; const MENU_ITEMS = [ { id: 'new-chat', item: }, { id: 'add-link', item: open('ADD_LINK')} /> }, { id: 'all-link', item: }, ]; + + const resolvedChats = useMockData && chats.length === 0 ? mockChats : chats; + const chatRooms = resolvedChats.map(chat => ({ + id: chat.id, + title: chat.title, + href: `/chat?chatId=${chat.id}`, + })); return ( <> ))} + + {type === 'ADD_LINK' && } diff --git a/src/components/layout/SideNavigation/components/ChatRightSidebar/ChatRightSidebar.tsx b/src/components/layout/SideNavigation/components/ChatRightSidebar/ChatRightSidebar.tsx new file mode 100644 index 00000000..be6ec49a --- /dev/null +++ b/src/components/layout/SideNavigation/components/ChatRightSidebar/ChatRightSidebar.tsx @@ -0,0 +1,27 @@ +'use client'; + +import LinkCardDetailPanel from '@/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel'; +import { useChatRightPanelStore } from '@/stores/chatRightPanelStore'; +import { usePathname } from 'next/navigation'; + +export default function ChatRightSidebar() { + const pathname = usePathname(); + const { selectedLink, clearSelectedLink } = useChatRightPanelStore(); + + if (pathname !== '/chat') return null; + + if (!selectedLink) return null; + + return ( + + ); +} diff --git a/src/components/layout/SideNavigation/components/ChatRoomList/ChatRoomList.tsx b/src/components/layout/SideNavigation/components/ChatRoomList/ChatRoomList.tsx new file mode 100644 index 00000000..9f7e7703 --- /dev/null +++ b/src/components/layout/SideNavigation/components/ChatRoomList/ChatRoomList.tsx @@ -0,0 +1,47 @@ +import LinkButton from '@/components/wrappers/LinkButton/LinkButton'; +import { useSideNavStore } from '@/stores/sideNavStore'; + +type ChatRoomItem = { + id: number; + title: string; + href: string; +}; + +type ChatRoomListProps = { + items: ChatRoomItem[]; + activeId?: number | null; +}; + +export default function ChatRoomList({ items, activeId }: ChatRoomListProps) { + const { isOpen } = useSideNavStore(); + + if (!isOpen) return null; + + return ( +
+

채팅방

+ {items.length === 0 ? ( +

채팅방이 없습니다.

+ ) : ( +
    + {items.map(item => { + const isActive = activeId === item.id; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/mocks/fixtures/chatLinks.ts b/src/mocks/fixtures/chatLinks.ts new file mode 100644 index 00000000..9dcbae2b --- /dev/null +++ b/src/mocks/fixtures/chatLinks.ts @@ -0,0 +1,65 @@ +export type ChatLinkCard = { + id: number; + title: string; + url: string; + summary: string; + imageUrl?: string; +}; + +export const chatLinksById: Record = { + 201: [ + { + id: 1101, + title: '웹 접근성 가이드 요약', + url: 'https://examples.design/a11y-guide', + summary: '접근성 핵심 원칙과 실무 체크리스트를 한 번에 정리한 가이드입니다.', + imageUrl: 'https://images.unsplash.com/photo-1454165804606-c3d57bc86b40', + }, + { + id: 1102, + title: '키보드 네비게이션 패턴', + url: 'https://examples.design/keyboard-nav', + summary: '포커스 이동 규칙과 예외 처리 패턴을 모아둔 자료입니다.', + imageUrl: 'https://images.unsplash.com/photo-1487014679447-9f8336841d58', + }, + ], + 202: [ + { + id: 1201, + title: '팀을 위한 AI 로드맵', + url: 'https://tech.example.com/ai-roadmap', + summary: 'AI 도입 단계별 체크리스트와 조직 준비 항목을 제공합니다.', + imageUrl: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085', + }, + { + id: 1202, + title: 'AI 트렌드 요약', + url: 'https://examples.design/ai-trends', + summary: '올해 주요 AI 트렌드와 제품 적용 사례를 요약했습니다.', + imageUrl: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4', + }, + ], + 203: [ + { + id: 1301, + title: '제품 타이포그래피 시스템', + url: 'https://design.example.com/typography', + summary: '가독성을 높이는 타입 시스템 구성 방법을 소개합니다.', + imageUrl: 'https://images.unsplash.com/photo-1487014679447-9f8336841d58', + }, + { + id: 1302, + title: '정보 밀도 높은 레이아웃', + url: 'https://examples.design/white-space', + summary: '여백을 활용해 읽기 흐름을 유지하는 구성 팁입니다.', + imageUrl: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085', + }, + { + id: 1303, + title: '브랜드 톤 구축 가이드', + url: 'https://examples.design/brand-voice', + summary: '브랜드 보이스를 일관되게 유지하는 작성 기준입니다.', + imageUrl: 'https://images.unsplash.com/photo-1529333166437-7750a6dd5a70', + }, + ], +}; diff --git a/src/mocks/fixtures/chatMessages.ts b/src/mocks/fixtures/chatMessages.ts index ce88a540..f2822c06 100644 --- a/src/mocks/fixtures/chatMessages.ts +++ b/src/mocks/fixtures/chatMessages.ts @@ -26,9 +26,24 @@ export const chatHistoryById: Record = { chatId: 201, role: 'assistant', content: - '웹 접근성은 누구나 웹을 사용할 수 있도록 만드는 기준입니다. 인지/운용/이해/견고성의 4원칙을 기준으로 대비, 키보드 접근성, 대체 텍스트 등을 확인합니다.', + '웹 접근성은 누구나 웹을 사용할 수 있도록 만드는 기준입니다. 인지/운용/이해/견고성의 4원칙을 기준으로 대비, 키보드 접근성, 대체 텍스트 등을 확인합니다. 예를 들어 버튼/링크에는 의미 있는 라벨이 있어야 하고, 키보드만으로도 모든 기능을 사용할 수 있어야 합니다. 또한 색 대비는 최소 기준을 만족해야 하며, 상태 변화는 시각 외에도 텍스트로 전달되어야 합니다. 마지막으로 다양한 브라우저와 보조기기에서 일관되게 동작하는지 점검하는 것이 중요합니다. 특히 폼 오류 메시지는 입력 필드와 명확히 연결되어야 하고, 자동 재생되는 콘텐츠는 사용자가 멈출 수 있어야 합니다. 내비게이션은 반복되는 링크를 건너뛸 수 있는 방법을 제공하고, 동작이 긴 작업은 진행 상태를 알려주어야 합니다. 이런 항목을 체크리스트로 정리해 QA 단계에서 반복 점검하면 품질이 안정적으로 유지됩니다.', createdAt: iso(-89), }, + { + id: '201-3', + chatId: 201, + role: 'user', + content: '그럼 우리 서비스에서 우선순위로 볼 항목은 뭐야?', + createdAt: iso(-80), + }, + { + id: '201-4', + chatId: 201, + role: 'assistant', + content: + '우선순위로는 1) 키보드 전용 탐색 가능 여부, 2) 이미지/아이콘의 대체 텍스트, 3) 색 대비와 포커스 표시, 4) 폼 에러 메시지의 명확한 안내 순서로 점검하는 것을 추천합니다. 특히 핵심 플로우(로그인/검색/저장)는 모든 상태에서 접근 가능해야 합니다. 추가로 모달이나 드로어 같은 레이어 UI는 포커스 트랩이 잘 걸리는지, 닫을 때 포커스가 원래 위치로 복귀하는지 확인하세요. 스크린 리더 사용자 입장에서는 버튼 텍스트가 컨텍스트를 포함해야 하므로, “저장”보다는 “링크 저장”처럼 의미가 분명한 레이블이 더 좋습니다.', + createdAt: iso(-79), + }, ], 202: [ { @@ -59,7 +74,7 @@ export const chatHistoryById: Record = { chatId: 203, role: 'assistant', content: - 'AI 도입 속도 증가, 보안/프라이버시 강화, 개인화 UX 확대, 그리고 개발 생산성 도구의 확산이 주요 흐름으로 보입니다.', + 'AI 도입 속도 증가, 보안/프라이버시 강화, 개인화 UX 확대, 그리고 개발 생산성 도구의 확산이 주요 흐름으로 보입니다. 기업들은 AI를 단순 자동화가 아니라 의사결정 보조와 콘텐츠 생성에 적극적으로 활용하고 있으며, 그에 따라 거버넌스와 데이터 품질 관리가 중요해졌습니다. 또한 사용자 경험은 맥락 기반 개인화가 강화되고, 개발 조직은 코파일럿류 도구로 생산성을 높이는 방향으로 움직이고 있습니다. 동시에 규제 대응과 데이터 주권 이슈로 인해 온프레미스/프라이빗 AI 수요가 증가하고, 평가/모니터링 체계까지 포함한 MLOps가 핵심 과제로 떠오르고 있습니다. 제품 전략 관점에서는 AI 기능의 ROI를 명확히 보여주는 지표 설계가 경쟁력의 핵심이 되고 있습니다.', createdAt: iso(-49), }, ], diff --git a/src/mocks/fixtures/chatReasoning.ts b/src/mocks/fixtures/chatReasoning.ts new file mode 100644 index 00000000..b286546d --- /dev/null +++ b/src/mocks/fixtures/chatReasoning.ts @@ -0,0 +1,70 @@ +export type ChatReasoningStep = { + step: string; + linkIds: number[]; +}; + +export type ChatReasoningResponse = { + answer: string; + linkIds: number[]; + reasoningSteps: ChatReasoningStep[]; + relatedLinks: number[]; + isFallback: boolean; +}; + +export const chatReasoningById: Record = { + 201: { + answer: + '웹 접근성 관련 핵심 내용을 저장된 링크에서 찾아 요약했습니다. 키보드 접근성, 대체 텍스트, 대비 기준을 중심으로 정리했어요. 아래 링크에서 자세한 가이드와 패턴을 확인할 수 있습니다.', + linkIds: [1101, 1102], + reasoningSteps: [ + { + step: '접근성 기준이 요약된 가이드 링크를 선택', + linkIds: [1101], + }, + { + step: '키보드 네비게이션 패턴을 포함한 링크를 추가로 매핑', + linkIds: [1102], + }, + ], + relatedLinks: [1101, 1102], + isFallback: false, + }, + 202: { + answer: + 'Gemini 관련 링크 두 개를 기반으로 CLI 사용법과 모델 활성화 절차를 정리했습니다. 구독 조건과 실행 흐름이 포함된 문서를 우선 노출합니다.', + linkIds: [1201, 1202], + reasoningSteps: [ + { + step: 'AI 로드맵 문서에서 Gemini Pro 사용 절차를 확인', + linkIds: [1201], + }, + { + step: 'AI 트렌드 요약 링크에서 추가 설명을 연결', + linkIds: [1202], + }, + ], + relatedLinks: [1201, 1202], + isFallback: false, + }, + 203: { + answer: + '최근 IT 트렌드 요약을 위해 타입 시스템, 레이아웃, 브랜드 톤 관련 링크를 참고했습니다. 핵심 포인트만 추려 짧게 정리했어요.', + linkIds: [1301, 1302, 1303], + reasoningSteps: [ + { + step: '타이포그래피 시스템 링크에서 UX 흐름 관련 포인트 추출', + linkIds: [1301], + }, + { + step: '레이아웃 구성 링크를 통해 정보 밀도 관련 기준 보완', + linkIds: [1302], + }, + { + step: '브랜드 톤 구축 링크로 요약 톤을 정리', + linkIds: [1303], + }, + ], + relatedLinks: [1301, 1302, 1303], + isFallback: false, + }, +}; diff --git a/src/mocks/index.ts b/src/mocks/index.ts index bf9111c4..eea101da 100644 --- a/src/mocks/index.ts +++ b/src/mocks/index.ts @@ -1,5 +1,4 @@ export * from './fixtures/chats'; export * from './fixtures/chatMessages'; -export * from './fixtures/links'; -export * from './fixtures/summaryStatus'; -export * from './response'; +export * from './fixtures/chatLinks'; +export * from './fixtures/chatReasoning'; diff --git a/src/stores/chatRightPanelStore.ts b/src/stores/chatRightPanelStore.ts new file mode 100644 index 00000000..e533dfbe --- /dev/null +++ b/src/stores/chatRightPanelStore.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand'; + +export type ChatRightPanelLink = { + id: number; + title: string; + url: string; + summary: string; + imageUrl?: string; +}; + +type ChatRightPanelState = { + selectedLink: ChatRightPanelLink | null; + setSelectedLink: (link: ChatRightPanelLink) => void; + clearSelectedLink: () => void; +}; + +export const useChatRightPanelStore = create(set => ({ + selectedLink: null, + setSelectedLink: link => set({ selectedLink: link }), + clearSelectedLink: () => set({ selectedLink: null }), +})); diff --git a/src/stores/linkStore.ts b/src/stores/linkStore.ts index 6a01b847..86081fe2 100644 --- a/src/stores/linkStore.ts +++ b/src/stores/linkStore.ts @@ -1,4 +1,3 @@ -import { mockLinks } from '@/mocks'; import type { LinkApiData } from '@/types/api/linkApi'; import { create } from 'zustand'; @@ -10,10 +9,8 @@ type LinkStoreState = { updateLink: (id: number, updates: Partial) => void; }; -const useMockData = process.env.NEXT_PUBLIC_USE_MOCKS === 'true'; - export const useLinkStore = create(set => ({ - links: useMockData ? mockLinks : [], + links: [], selectedLinkId: null, setLinks: links => set({ links }), selectLink: id => set({ selectedLinkId: id }), diff --git a/src/stories/SideNavigation.stories.tsx b/src/stories/SideNavigation.stories.tsx index ce0a48e4..a91e8ef8 100644 --- a/src/stories/SideNavigation.stories.tsx +++ b/src/stories/SideNavigation.stories.tsx @@ -1,5 +1,8 @@ import SideNavigation from '@/components/layout/SideNavigation/SideNavigation'; +import { mockChats } from '@/mocks'; +import { useChatStore } from '@/stores/chatStore'; import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useEffect } from 'react'; const meta: Meta = { title: 'Components/Layout/SideNavigation', @@ -12,3 +15,23 @@ export default meta; type Story = StoryObj; export const Default: Story = {}; + +export const WithChatRooms: Story = { + render: () => { + useEffect(() => { + useChatStore.setState({ + chats: mockChats, + activeChatId: mockChats[0]?.id ?? null, + }); + + return () => { + useChatStore.setState({ + chats: [], + activeChatId: null, + }); + }; + }, []); + + return ; + }, +};