diff --git a/ASK_SESSION_INTEGRATION.md b/ASK_SESSION_INTEGRATION.md new file mode 100644 index 0000000..085b1a2 --- /dev/null +++ b/ASK_SESSION_INTEGRATION.md @@ -0,0 +1,253 @@ +# ASK Session Integration Guide + +이 문서는 프론트엔드가 새 ASK 세션/히스토리 기능을 활용하기 위해 필요한 API 계약과 구현 예시를 정리한 자료다. `/ai/ask`, `/ai/v2/ask` 스트림 요청부터 세션 REST 엔드포인트, 무한 스크롤 메시지 페이징까지 한 흐름으로 설명한다. + +--- + +## 1. ASK 요청 흐름 + +### 1.1 세션 ID 확보/생성 +1. 기존 세션을 재사용할 때는 세션 목록 API(`GET /ai/v2/sessions`)로 ID를 조회한 뒤 선택한다. +2. 새 세션을 만들려면 ASK 요청의 `session_id`를 `null`이거나 생략하고, `user_id`(챗봇 주인 ID)를 반드시 포함한다. + - 서버가 자동으로 세션을 생성하고 다음을 반환한다. + - HTTP 헤더 `session-id: ` + - SSE `event: session` → `{ session_id, owner_user_id, requester_user_id }` +3. 새 ID를 받으면 프론트에서 상태에 저장하고 이후 요청에서는 `session_id`만 전달하면 된다. 이때 `user_id`는 optional이며, 보낸 경우 DB owner와 일치해야 한다. + +### 1.2 `/ai/ask` / `/ai/v2/ask` SSE 요청 샘플 + +```http +POST /ai/v2/ask HTTP/1.1 +Authorization: Bearer +Content-Type: application/json + +{ + "question": "최근 인프라 포스트 요약해줘", + "user_id": "blog-owner-123", // 새 세션일 때 필수 + "session_id": null, // null 또는 생략 → 세션 자동 생성 + "category_id": 42, + "speech_tone": -2 +} +``` + +스트림 이벤트 순서(상황에 따라 일부 생략): +1. `event: session` *(신규 세션인 경우)* +2. `event: search_plan` +3. `event: rewrite` / `event: keywords` *(하이브리드 일 때)* +4. `event: search_result`, `event: search_result_meta`, `event: exist_in_post_status`, `event: context` +5. `event: hybrid_result`, `event: hybrid_result_meta` *(필요 시)* +6. `event: answer` (LLM 토큰이 들어있는 SSE) +7. `event: session_saved` → `{ session_id, owner_user_id, requester_user_id, cached }` + - `cached: true`는 질문이 캐시 적중되어 기존 답변을 재사용했음을 의미 +8. 오류 시 `event: session_error`(reason 포함) + 기존 `event: error` + +프론트에서는 `session_saved`/`session_error`를 기준으로 UI 상태(“보관 완료” 배지 등)를 갱신할 수 있다. + +### 1.3 캐시 히트 처리 +동일한 질문(같은 사용자와 필터)일 경우 서버가 자동으로 캐시를 재생한다. +- SSE로 `search_plan`/`search_result`/`answer`가 즉시 도착하고, `session_saved` 이벤트의 `cached`가 `true`. +- **프론트 액션은 일반 답변과 동일**: SSE 순서/값이 동일하게 재생되므로 별도 분기를 둘 필요는 없지만, `cached`를 활용해 “이전 답변을 재사용했습니다” 같은 안내를 띄울 수 있다. + +--- + +## 2. 세션 REST API + +### 2.1 목록 조회 `GET /ai/v2/sessions` +- 쿼리 파라미터 + - `limit`: 기본 20, 최대 50 + - `cursor`: Base64(`created_at|id`) 문자열 + - `owner_user_id`: 특정 블로그/챗봇만 필터링할 때 사용 +- 응답 +```json +{ + "sessions": [ + { + "session_id": 123, + "owner_user_id": "blog-owner-123", + "requester_user_id": "viewer-999", + "title": "인프라 정리 질문", + "metadata": {}, + "last_question_at": "2025-01-19T10:05:12.123Z", + "created_at": "2025-01-19T09:55:00.000Z", + "updated_at": "2025-01-19T10:05:12.123Z", + "message_count": 4 + } + ], + "paging": { + "cursor": "MjAyNS0wMS0xOVQxMDowNToxMi4xMjNa|123", + "has_more": true + } +} +``` +- 페이징 구현 예시 + 1. 최초 호출: `GET /ai/v2/sessions?limit=20` + 2. 응답 `paging.cursor`가 존재하면 “더 보기” 클릭 시 `GET ...?cursor=` + 3. `has_more=false`일 때까지 반복 + +### 2.2 단일 세션 메타 `GET /ai/v2/sessions/:id` +- 자신이 만든 세션이 아니면 404. +- `message_count`를 추가로 주므로 목록에서 선택한 뒤 최신 상태를 다시 확인할 수 있다. + +### 2.3 메시지 페이지네이션 `GET /ai/v2/sessions/:id/messages` +- 쿼리 + - `limit` (default 20, max 50) + - `cursor` + - `direction`: `'backward'`(기본) 또는 `'forward'` +- 응답 +```json +{ + "session_id": 123, + "owner_user_id": "blog-owner-123", + "requester_user_id": "viewer-999", + "messages": [ + { + "id": 456, + "role": "user", + "content": "최근 인프라 글을 알려줘", + "search_plan": {...}, + "retrieval_meta": null, + "created_at": "2025-01-19T10:05:12.123Z" + }, + { + "id": 457, + "role": "assistant", + "content": "인프라 관련 최신 글은 ...", + "search_plan": null, + "retrieval_meta": {...}, + "created_at": "2025-01-19T10:05:20.000Z" + } + ], + "paging": { + "direction": "backward", + "has_more": true, + "next_cursor": "MjAyNS0wMS0xOVQxMDowNToxMi4xMjNa|456" + } +} +``` +- **무한 스크롤 구현 팁** + 1. 최신 메시지를 불러오려면 `direction=backward`, `cursor` 생략으로 시작. UI에서는 리스트 끝에 붙인다. + 2. 위로 스크롤하여 과거 메시지를 계속 불러오고 싶다면 응답의 `next_cursor`를 사용해 `GET ...?cursor=&direction=backward`. + 3. 대화 중간으로 점프해 이후 메시지를 로드하려면 동일 cursor를 `direction=forward`로 호출하면 된다. + 4. 응답 메시지는 API에서 시간순으로 이미 정렬되어 있으므로 바로 렌더링하면 된다. + +**무한 스크롤 의사 코드** +```ts +type PagingState = { + prevCursor: string | null; + nextCursor: string | null; + hasMorePrev: boolean; +}; + +const state: PagingState = { prevCursor: null, nextCursor: null, hasMorePrev: true }; + +// 최신(아래쪽) 메시지 로드 +const loadLatest = async () => { + const params = new URLSearchParams({ limit: '20', direction: 'backward' }); + if (state.prevCursor) params.set('cursor', state.prevCursor); + const res = await fetch(`/ai/v2/sessions/${sessionId}/messages?${params}`, { headers }); + const body = await res.json(); + renderPrepend(body.messages); // 위쪽에 추가 + state.prevCursor = body.paging?.next_cursor ?? null; + state.hasMorePrev = Boolean(body.paging?.has_more); +}; + +// 사용자가 아래로 내려간 뒤 이후 메시지를 보고 싶을 때 +const loadForward = async () => { + if (!state.nextCursor) return; + const params = new URLSearchParams({ limit: '20', direction: 'forward', cursor: state.nextCursor }); + const res = await fetch(`/ai/v2/sessions/${sessionId}/messages?${params}`, { headers }); + const body = await res.json(); + renderAppend(body.messages); // 아래쪽에 추가 + state.nextCursor = body.paging?.next_cursor ?? null; +}; +``` + +### 2.4 PATCH / DELETE +- `PATCH /ai/v2/sessions/:id` + - Body: `{ "title": "...", "metadata": { ... } }` (둘 중 하나 이상 필수) + - 성공 시 최신 메타를 반환. +- `DELETE /ai/v2/sessions/:id` + - `{ "session_id": 123, "deleted": true }` + - 세션/메시지/임베딩이 모두 cascade로 제거되므로 프론트에서 제거 후 새로고침 필요 없음. + +--- + +## 3. 프론트엔드 구현 참고 + +### 3.1 ASK 스트림 핸들러 의사 코드 +```ts +const sse = new EventSourcePolyfill('/ai/v2/ask', { headers: { Authorization: `Bearer ${token}` }, payload }); +const state = { sessionId: null, chunks: [] }; + +sse.addEventListener('session', (evt) => { + const data = JSON.parse(evt.data); + state.sessionId = data.session_id; + // 새 세션 ID를 저장해 다음 질문에 사용 +}); + +sse.addEventListener('search_plan', (evt) => { ... }); +sse.addEventListener('context', (evt) => { ... }); +sse.addEventListener('answer', (evt) => { + state.chunks.push(JSON.parse(evt.data)); + renderStreamingAnswer(state.chunks.join('')); +}); + +sse.addEventListener('session_saved', (evt) => { + const data = JSON.parse(evt.data); + showToast(data.cached ? '기존 답변을 재사용했어요.' : '대화가 저장되었습니다.'); +}); + +sse.addEventListener('session_error', (evt) => { + console.warn('세션 저장 실패', evt.data); +}); + +sse.onerror = () => { + sse.close(); +}; +``` + +### 3.2 대화 목록/상세 UI 시나리오 +1. **좌측 패널**: `/ai/v2/sessions?limit=20`으로 최근 대화 조회 → 커서 기반 “더 보기” 버튼. +2. **메시지 영역**: 세션을 선택하면 `GET /ai/v2/sessions/:id/messages`로 최신 메시지 불러오기 → `direction=backward`. +3. **무한 스크롤**: 맨 위로 스크롤되면 `cursor=previous.next_cursor`로 과거 메시지 로드. +4. **실시간 갱신**: SSE에서 받은 user/assistant 메시지를 메모리에 쌓고, 스트림 종료 후 `session_saved` 이벤트가 오면 REST API 결과와 동기화 가능. + +### 3.3 세션 ID 전파 +- 새 ASK 요청 → 응답 헤더 `session-id`와 `event: session`을 받으면, 프론트의 현재 대화 객체에 그 ID를 기록한다. +- 이후 폼 전송 시 `session_id`만 바디에 넣어서 이어서 질문할 수 있다. +- 다른 블로그로 이동하면 기존 세션 ID를 버리고 `user_id`를 새 값으로 넣어 다시 질문하면 된다(서버가 다른 owner와 세션을 매칭하지 않도록 검증함). + +--- + +## 4. 오류 및 예외 처리 + +### 4.1 주요 SSE 이벤트 & UI 매핑 + +| 이벤트 | 예시 payload | 권장 UI 처리 | +|--------|--------------|--------------| +| `session` | `{ session_id, owner_user_id, requester_user_id }` | 새 세션 카드 추가, 현재 대화 헤더 업데이트 | +| `search_plan` | `{ mode: 'rag', ... }` | 디버그 패널, “검색 계획 준비 중…” 표시 | +| `rewrite` / `keywords` | `["재작성1", ...]` / `["키워드1", ...]` | 검색 과정 시각화(선택 사항) | +| `search_result` / `hybrid_result` | `[ { postId, postTitle }, ... ]` | 참고 컨텍스트 목록 표시 | +| `search_result_meta` / `hybrid_result_meta` | 추가 메타 정보 | 고급 모드 또는 디버그 뷰 | +| `exist_in_post_status` | `true/false` | “관련 글을 찾음/찾지 못함” 안내 뱃지 | +| `context` | `[ { postId, postTitle }, ... ]` | UI 우측 “참조 글 목록” 섹션 | +| `answer` | `"…LLM 청크…"` | 채팅 말풍선 실시간 갱신 | +| `session_saved` | `{ session_id, cached }` | 저장 완료/캐시 재사용 토스트, 상태 뱃지 | +| `session_error` | `{ reason }` | 오류 토스트, 재시도 버튼 노출 | +| `error` | `{ message }` | 스트림 종료 + 에러 메시지 | + +### 4.2 오류 대응 요약 + +| 상황 | 응답/이벤트 | 대응 방법 | +|------|-------------|-----------| +| `session_id`가 유효하지 않음 | 400 + `{ message: 'Invalid session_id' }` | 프론트 세션 상태 초기화, 새 세션 생성 | +| 세션 owner 불일치 | 409 + `{ message: 'Session owner mismatch' }` | 다른 블로그로 전환 후 새 세션 시작 | +| 세션 접근 권한 없음 | 404 (`존재하지 않는다고 응답`) | 리스트를 다시 로드해 실제로 존재하는지 확인 | +| 포스트가 삭제/비공개 | SSE `event: error` + `session_error(reason=post_not_found/forbidden_post)` | 사용자에게 안내 후 대화 중단 | +| 저장 실패 | SSE `event: session_error` + reason | 로그/토스트로 사용자에게 “대화 저장에 실패했습니다” 알림 | +| LLM 오류/스트림 예외 | SSE `event: error` + `session_error(reason=llm_error/stream_error)` | 스트림 종료 후 재시도 UI | + +--- + +이 가이드를 토대로 세션 기반 ASK UX를 구현하면, 신규 세션 생성에서 히스토리 로딩까지 백엔드와 일관된 동작을 보장할 수 있다. 추가 질문은 `docs/history-tasks/ASK_SESSION_MANAGEMENT` 시리즈나 최근 커밋을 참고한다. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9a21ac6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,428 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +**Primary Role: UI/UX Design & Front-end Enhancement Specialist** + +이 프로젝트에서 Claude는 주로 **디자인 개선과 사용자 경험 향상**을 담당합니다. 기존 기능을 유지하면서 시각적 완성도를 높이고, 인터랙션을 세밀하게 다듬는 역할입니다. 모든 변경사항은 반응형, 접근성, 성능을 고려하여 점진적으로 적용됩니다. + +## Project Overview + +Bubblog is a Next.js blog platform with an AI-powered chatbot feature. The frontend interacts with two backend services: a main API server for blog/user data and an AI server for chat functionality with RAG (Retrieval-Augmented Generation). + +**Tech Stack:** +- Next.js 15 (App Router) with TypeScript +- Tailwind CSS v4 for styling +- Zustand for state management +- Markdown editing with `@uiw/react-md-editor` and syntax highlighting +- Server-Sent Events (SSE) for AI streaming responses +- Framer Motion & GSAP for animations +- HeadlessUI & Radix UI for accessible components +- Lucide React for icons + +**Environment Variables:** +- `NEXT_PUBLIC_API_URL` - Main API server base URL +- `NEXT_PUBLIC_AI_API_URL` - AI server base URL +- Configure these in `.env.local` (never commit this file) + +## Development Commands + +```bash +# Development server (http://localhost:3000) +npm run dev + +# Production build +npm run build + +# Run production build locally +npm start + +# Lint check +npm run lint +``` + +## Architecture + +### Directory Structure + +- `src/app/` - Next.js App Router pages and routes + - Each route folder contains `page.tsx` (e.g., `blog/`, `chatbot/`, `write/`) + - `layout.tsx` defines the root layout with auth initialization + - `globals.css` contains Tailwind base styles and custom CSS +- `src/components/` - React components organized by feature + - `Chat/` - AI chatbot UI (messages, inspector, session panel) + - `Blog/`, `Post/`, `Comment/` - Blog content components + - `Common/` - Shared UI components + - `Layout/` - Layout-related components +- `src/apis/` - API client layer + - `apiClient.ts` - Core fetch wrappers with auth/retry logic + - `*Api.ts` - Domain-specific API clients (blog, auth, user, AI, etc.) +- `src/store/` - Zustand global state stores + - `AuthStore.tsx` - Authentication state and token management + - `ChatSessionStore.ts` - AI chat session and message state + - `ProfileStore.tsx` - User profile state +- `src/hooks/` - Custom React hooks +- `src/utils/` - Type definitions and utility functions + +### API Client Pattern + +All API requests go through centralized client functions in `src/apis/apiClient.ts`: + +1. **`apiClientNoAuth(path, options)`** - For public endpoints (login, signup, public posts) + - Includes cookies for server-side session handling + - Returns typed `APIResponse` with `{ success, code, message, data }` + +2. **`apiClientWithAuth(path, options)`** - For authenticated endpoints + - Automatically adds `Authorization: Bearer ` header from `AuthStore` + - On 401 error: attempts token refresh via `reissue()`, then retries once + - On refresh failure: triggers safe logout + - Includes cookies for refresh token + +3. **`aiFetch(path, options)`** - For AI server requests + - Same auth/retry logic as `apiClientWithAuth` + - Returns raw `Response` object (needed for SSE stream handling) + - Used by `askChatAPI` and `askChatAPIV2` for streaming responses + +### Authentication Flow + +- Tokens managed by Zustand store (`src/store/AuthStore.tsx`) +- Access token stored in memory, refresh token in HTTP-only cookie +- Token refresh is automatic and transparent via `apiClientWithAuth` +- Protected routes should check `useAuthStore` for `isAuthenticated` state +- `ClientAuthInit` component in root layout initializes auth state on mount + +### AI Chat Architecture + +The chatbot uses a **session-based conversation system** with SSE streaming: + +1. **Session Management** (`ChatSessionStore.ts`) + - Sessions track conversation history between user and AI + - Each session belongs to an owner (blog user) and requester (visitor) + - `currentSessionId: null` creates a new session on next question + - Sessions are fetched with cursor-based pagination + +2. **SSE Stream Events** (from `/ai/v2/ask`) + - `event: session` - Provides `session_id` for new conversations + - `event: search_plan` - Shows RAG search strategy + - `event: context` - Related blog posts found + - `event: answer` - LLM response chunks (streamed) + - `event: session_saved` - Confirms message persistence (with `cached` flag) + - `event: session_error` - Session save failures + +3. **Message History** + - Infinite scroll loading (bidirectional: backward for older, forward for newer) + - Messages are cached in `messagesBySession` keyed by `session_id` + - Streaming messages are tracked separately during generation + +4. **UI Components** (`src/components/Chat/`) + - `SessionListPanel` - Session browser with infinite scroll + - `ChatMessages` - Message list with streaming support + - `InspectorPanel` - Shows RAG search plan and context + - `ChatInput` - Question submission form + +### Image Uploads + +Images use presigned S3 URLs (see `src/apis/uploadApi.ts`): +1. Request presigned URL from API: `getPresignedUrl(fileName, contentType)` +2. Upload file directly to S3: `uploadToS3(presignedUrl, file)` +3. Use returned `fileUrl` in post content or profile + +S3 domains must be allowlisted in `next.config.ts` under `images.remotePatterns`. + +### Markdown Editing + +- Editor: `@uiw/react-md-editor` with custom toolbar commands +- Preview: `react-markdown` with `rehype-highlight` for syntax highlighting +- Custom image upload command integrated in `MarkdownEditor.tsx` +- Supports GFM (tables, strikethrough, task lists) via `remark-gfm` + +## UI/UX Design Guidelines + +**Role: Design & Front-end Enhancement Specialist** + +This project prioritizes **visual refinement and user experience improvements** without breaking existing functionality. When making design changes: + +### Core Principles + +1. **Preserve Functionality** - Never modify logic, API calls, or state management when improving UI +2. **Progressive Enhancement** - Add visual polish incrementally, testing after each change +3. **Consistency** - Maintain design language across all components +4. **Performance** - Animations should be smooth (60fps), use CSS transforms and opacity when possible +5. **Accessibility First** - All interactive elements must be keyboard navigable and screen-reader friendly + +### Design System + +**Color Palette:** +- Use Tailwind's semantic color classes consistently +- Dark mode support via Tailwind's `dark:` variants +- Maintain proper contrast ratios (WCAG AA minimum) + +**Typography:** +- Geist font family (loaded via `next/font`) +- Establish clear hierarchy: headings, body, captions +- Line height: 1.5-1.7 for body text, tighter for headings +- Letter spacing: Use Tailwind's tracking utilities sparingly + +**Spacing:** +- Follow 4px/8px grid system (Tailwind's default spacing scale) +- Consistent padding/margin: prefer `p-4`, `p-6`, `p-8` over arbitrary values +- Use `space-y-*` and `space-x-*` for consistent gaps + +**Borders & Shadows:** +- Border radius: `rounded-lg` (8px) for cards, `rounded-xl` (12px) for modals +- Shadows: Use Tailwind's shadow scale, prefer subtle `shadow-sm` and `shadow-md` +- Border colors: Use opacity-based borders like `border-gray-200 dark:border-gray-800` + +### Animation Guidelines + +**Framer Motion** (preferred for React components): +```tsx +// Smooth entrance animations + +``` + +**Common animation patterns:** +- Page transitions: fade + slide (y: 10-20px) +- Hover states: scale 1.02-1.05, transition duration 150-200ms +- Loading states: Skeleton screens with shimmer effect +- Modal/drawer: backdrop blur + slide from edge +- List items: stagger children with `staggerChildren: 0.05` + +**GSAP** (for complex scroll animations): +- Use for parallax, scroll-triggered sequences +- Always clean up with `ScrollTrigger.kill()` in useEffect cleanup + +**Performance:** +- Avoid animating `width`, `height`, `top`, `left` - use `transform` instead +- Use `will-change` sparingly and remove after animation +- Prefer CSS transitions for simple hover/focus states + +### Component Design Patterns + +**Buttons:** +- Primary: Bold color, prominent shadow +- Secondary: Outline or ghost style +- Sizes: `sm` (32px), `md` (40px), `lg` (48px) height +- States: default, hover, active, disabled, loading +- Icon buttons should have `aria-label` and minimum 44x44px tap target + +**Cards:** +- Subtle border or shadow for depth +- Hover state: Lift effect with increased shadow +- Internal padding: `p-6` for desktop, `p-4` for mobile +- Rounded corners: `rounded-lg` or `rounded-xl` + +**Forms:** +- Input height: minimum 44px for touch targets +- Focus rings: Tailwind's `focus:ring-2 focus:ring-blue-500` +- Error states: Red border + error message below with icon +- Labels: Always present, use `text-sm font-medium` above inputs + +**Loading States:** +- Skeleton screens matching content layout +- Spinner for async actions (button loading) +- Shimmer effect for placeholder content +- Use `aria-busy="true"` and `aria-live="polite"` + +**Modals & Drawers:** +- Backdrop: `bg-black/50` with backdrop blur +- Animation: Slide + fade (from bottom on mobile, center on desktop) +- Close button: Always top-right, accessible via Escape key +- Focus trap: Use HeadlessUI Dialog or Radix Dialog + +**Chat Interface:** +- Messages: Clear visual distinction between user/assistant +- Streaming: Show typing indicator, smooth text reveal +- Timestamps: Subtle, relative time format +- Actions: Copy, regenerate buttons on hover +- Code blocks: Syntax highlighting, copy button top-right + +### Responsive Design + +**Breakpoints** (Tailwind defaults): +- `sm: 640px` - Small tablets +- `md: 768px` - Tablets +- `lg: 1024px` - Small desktops +- `xl: 1280px` - Desktops +- `2xl: 1536px` - Large desktops + +**Mobile-first approach:** +```tsx +// Base styles for mobile, then scale up +
+``` + +**Touch targets:** +- Minimum 44x44px for all interactive elements +- Increase spacing between clickable items on mobile +- Use larger font sizes on mobile for readability + +**Navigation:** +- Mobile: Hamburger menu with slide-out drawer +- Desktop: Horizontal nav or sidebar +- Always show current page indicator + +### Accessibility Checklist + +- [ ] All images have `alt` text (or `alt=""` for decorative) +- [ ] Interactive elements are keyboard accessible (Tab, Enter, Space) +- [ ] Focus indicators are visible and clear +- [ ] Color is not the only means of conveying information +- [ ] Form inputs have associated labels +- [ ] ARIA labels on icon-only buttons +- [ ] Proper heading hierarchy (h1 → h2 → h3) +- [ ] Skip navigation links for keyboard users +- [ ] Modals trap focus and close on Escape +- [ ] Loading/error states announced to screen readers + +### Common UI Improvements + +**Micro-interactions to add:** +- Button press effect (scale 0.98 on click) +- Smooth color transitions on hover (150-200ms) +- Icon animations (rotate, bounce) on state changes +- Success checkmarks with animated stroke +- Number counters with animated increments +- Ripple effects on touch (Material Design pattern) + +**Visual feedback:** +- Toast notifications for actions (success/error/info) +- Optimistic UI updates (show change immediately, rollback on error) +- Progress indicators for multi-step processes +- Disabled state styling (reduced opacity, cursor-not-allowed) + +**Layout enhancements:** +- Card hover effects (lift + shadow increase) +- Grid/list view toggles with smooth transitions +- Sticky headers that shrink on scroll +- Infinite scroll with "load more" sentinel +- Empty states with illustrations and CTAs + +### Design Review Process + +Before committing design changes: + +1. **Visual QA** - Test on multiple screen sizes (mobile, tablet, desktop) +2. **Interaction QA** - Verify all hover, focus, active states +3. **Keyboard Navigation** - Tab through entire flow without mouse +4. **Performance Check** - Ensure 60fps animations, no layout shift +5. **Dark Mode** - If applicable, test dark theme variants +6. **Cross-browser** - Test on Chrome, Safari, Firefox at minimum + +### Tools & Resources + +**Available UI libraries:** +- HeadlessUI: `Transition`, `Dialog`, `Menu`, `Listbox`, `Popover` +- Radix UI: `Tooltip`, `DropdownMenu` (already installed) +- Lucide React: Consistent icon set, tree-shakeable + +**Animation timing functions:** +- `ease: [0.4, 0, 0.2, 1]` - Default smooth (similar to ease-in-out) +- `ease: [0, 0, 0.2, 1]` - Ease out (good for entrances) +- `ease: [0.4, 0, 1, 1]` - Ease in (good for exits) +- `type: "spring"` - Natural, bouncy feel + +**When to use which animation library:** +- Simple transitions: CSS (`transition-all duration-200`) +- Component animations: Framer Motion +- Complex scroll effects: GSAP with ScrollTrigger +- SVG animations: Framer Motion or GSAP + +## Important Patterns + +### Type Safety +- All API responses are typed with `APIResponse` wrapper +- Component props should have explicit TypeScript interfaces +- Avoid `any` where possible; use `unknown` for truly dynamic data + +### State Management +- Use Zustand stores for global state (auth, chat sessions, profile) +- Local component state with `useState` for UI-only state +- Avoid prop drilling - prefer Zustand selectors for deep component trees + +### Error Handling +- API clients throw errors on `!success` response +- Components should use try-catch with user-friendly error messages +- 401 errors are handled automatically by `apiClientWithAuth` + +### Styling +- Tailwind utility classes for all styling +- Avoid inline styles or CSS modules +- Custom global styles only in `globals.css` +- Component-specific styles should be colocated in same file with Tailwind + +### SSR vs CSR +- Use SSR for SEO-critical content (post detail pages) +- Use CSR (Client Components) for interactive features (chat, editing) +- Mark client components with `'use client'` directive + +## Key Files Reference + +- `src/app/layout.tsx` - Root layout with `ClientAuthInit` for auth state +- `src/apis/apiClient.ts` - Auth retry logic and token refresh +- `src/store/ChatSessionStore.ts` - Chat session state and SSE integration +- `src/components/Chat/ChatWindow.tsx` - Main chat UI container +- `src/apis/aiApi.ts` - SSE stream parsing and event handlers +- `AGENTS.md` - Detailed API reference for all endpoints +- `ASK_SESSION_INTEGRATION.md` - AI session/history feature specification + +## Testing Notes + +No test framework is currently configured. When adding tests: +- Use Jest + React Testing Library +- Place tests adjacent to source files: `Component.test.tsx` +- Mock API calls using `apiClient` function spies +- Focus on critical paths: auth flow, chat session management, post CRUD + +## Common Tasks + +**Add a new API endpoint:** +1. Add function to appropriate `src/apis/*Api.ts` file +2. Use `apiClientWithAuth` or `apiClientNoAuth` based on auth requirement +3. Define TypeScript interface for request/response in `src/utils/types.ts` +4. Update `AGENTS.md` API reference section + +**Create a new page:** +1. Add folder under `src/app/` with `page.tsx` +2. Use `'use client'` if interactive or needs hooks +3. Import layout components from `src/components/Layout/` + +**Add chat event handler:** +1. Update event parsing in `askChatAPIV2` (src/apis/aiApi.ts) +2. Add callback to event handler interface +3. Wire callback in `ChatWindow` or consuming component +4. Update `ChatSessionStore` if state changes needed + +**Improve component visual design:** +1. Identify the component file in `src/components/` +2. Review current Tailwind classes and layout +3. Apply design system principles (spacing, colors, shadows) +4. Add Framer Motion animations if appropriate +5. Test responsiveness at all breakpoints (mobile, tablet, desktop) +6. Verify accessibility (keyboard nav, focus states, ARIA labels) +7. Check dark mode if applicable + +**Add micro-interactions:** +1. Choose animation library: CSS transitions (simple) or Framer Motion (complex) +2. Define animation variants for states (hover, active, loading) +3. Use appropriate easing curves and durations (150-300ms typical) +4. Test performance - ensure 60fps on low-end devices +5. Add reduced-motion media query support: `motion-reduce:transition-none` + +**Refine existing UI without breaking functionality:** +1. Read component to understand props, state, and event handlers +2. Identify visual-only changes (colors, spacing, shadows, animations) +3. Make incremental changes, testing in browser after each +4. Preserve all `onClick`, `onChange`, state updates, and API calls +5. Test complete user flow to ensure nothing broke + +## Documentation + +- `AGENTS.md` - Complete API endpoint reference +- `ASK_SESSION_INTEGRATION.md` - AI chat session system design +- `TASK.md` - Project task tracking +- `springAPI.md` - Backend API documentation diff --git a/TASK.md b/TASK.md index 60d5025..3ae3a4c 100644 --- a/TASK.md +++ b/TASK.md @@ -1,10 +1,90 @@ -### 목표 -- src/app/not-found.tsx 개선 -## 구현 범위 -src/app/not-found.tsx +# ASK 세션 기능 초기 설계 -### 구현 단계 -1) src/app/not-found.tsx 는 페이지를 찾을 수 없을 때 나오는 페이지야. 왼쪽편 적당한 곳에 "readingBookRobot.png" 그림을 넣고, 그와 나란하게 "페이지를 찾을 수 없어요 -요청하신 주소가 변경되었거나 존재하지 않습니다." 텍스트를 배치해줘. -2) "홈으로" 버튼과 "글쓰러 가기" 버튼을 삭제하고, 이전 페이지로 돌아가는 "이전 페이지로 돌아가기" 하이퍼링크 텍스트를 추가해줘. +## 1. 현황 정리 +- `src/app/chatbot/[userId]/page.tsx` 는 로컬 상태(`messages`)만으로 챗 UI를 구성하고 있어, 새로고침 시 기록이 사라지고 세션 ID 개념이 없음. +- `src/apis/aiApi.ts` 의 `askChatAPIV2` 는 질문/카테고리/페르소나만 전송하며, SSE 이벤트도 검색 관련 정보만 처리한다. `session`, `session_saved`, `session_error` 등 새 이벤트와 REST 세션 API가 빠져 있음. +- 세션/히스토리 REST 요청을 위한 API 클라이언트(`GET /ai/v2/sessions`, `/sessions/:id/messages`) 및 타입이 존재하지 않으며, UI에서도 세션 목록/관리 탭이 없다. + +## 2. 요구 기능 요약 +1. ASK 요청 시 세션 ID를 `null`로 넘겨 신규 세션을 만들고, SSE에서 받은 `session_id`를 이후 질문에 재사용. +2. 내 세션 리스트를 불러와 좌측 “세션 관리” 탭(또는 패널)로 보여주고, 커서 기반 무한 로딩을 지원. +3. 특정 세션을 선택하면 해당 세션의 메시지를 최신 순으로 불러오고, 스크롤 최상단 진입 시 과거 메시지를 추가 로딩(무한 스크롤). +4. SSE 스트림으로 들어오는 메시지를 현재 세션의 UI에 반영하고, `session_saved`, `session_error`에 따라 상태 및 토스트를 갱신. + +## 3. 구현 계획 + +### 3.1 API/타입 레이어 확장 +1. `src/apis/aiApi.ts` + - `askChatAPIV2(question, userId, sessionId, categoryId, personaId, handlers, options)` 처럼 시그니처를 바꿔 `sessionId`를 명시적으로 받는다(새 세션일 땐 `null`). `requester_user_id`는 서버에서 세션 생성 시 검증하므로 body에 항상 넣는다. + - SSE 파서에 `session`, `session_saved`, `session_error`, `session_timeout` 등을 추가하고, 핸들러 타입에 `onSession`, `onSessionSaved`, `onSessionError` 콜백을 포함한다. `session-id` 헤더는 fallback 으로만 쓰고, 기본은 `session` 이벤트 payload를 신뢰하도록 명시. + - Context/answer 이외의 메시지(`event: message_user`, `event: message_assistant` 등) 발생 가능성에 대비해 공통 `onStreamMessage(role, chunk)` 시그니처를 추가하고, 추후 히스토리 append 로직이 같은 포맷을 쓰도록 한다. +2. 세션 REST API 클라이언트 + - `src/apis/aiSessionApi.ts` 파일을 새로 만들고 `getChatSessions`, `getChatSession`, `getChatSessionMessages` API를 정의한다. 파일 내부에서 `aiFetch`를 재사용해 베이스 URL/에러 처리를 일관되게 유지한다. + - `src/utils/types.ts` 에 `ChatSession`, `ChatSessionMessage`, `ChatSessionPaging` 타입을 추가해 API/스토어/UI가 동일한 구조를 바라보도록 한다(예: `ChatSessionMessage['role']` 는 `'user' | 'assistant'`). + +### 3.2 상태 관리 & 훅 +1. 공통 zustand 스토어 + - `src/store/ChatSessionStore.ts` 를 만들고, `sessions`, `sessionsPaging`, `currentSessionId`, `messagesBySession`, `messagesPagingBySession`, `isSessionPanelOpen`, `isStreaming` 등을 한 곳에서 관리한다. + - 주요 액션: `fetchInitialSessions`, `fetchMoreSessions`, `selectSession(sessionId | null)`, `resetSession(userId)`, `fetchMessages(sessionId, { cursor, direction })`, `prependMessages`, `appendMessages`, `upsertSessionFromStream`, `appendStreamingChunk`, `setPanelOpen`. + - SSE 이벤트 핸들러는 이 스토어 액션을 직접 호출해 전역 상태를 갱신하고, 페이지 컴포넌트는 `useChatSessionStore(selector)` 조합으로 필요한 조각만 구독한다. +2. 훅 레이어 + - `useChatSessions(userId)` 훅은 zustand 액션을 감싼 얇은 커스텀 훅으로, 초기 로딩/추가 로딩 호출과 MEMOized 파생값(`sortedSessions`, `selectedSession`)을 제공한다. + - `useSessionMessages(sessionId)` 훅 역시 zustand 상태를 구독하면서 스크롤 로딩/스트리밍 append 를 담당한다. UI가 훅만 사용하도록 만들어 컴포넌트 복잡도를 낮춘다. + +### 3.3 UI 구조 개편 +1. 레이아웃 + - 챗봇 페이지를 좌측 `세션 패널` + 우측 `대화 영역` 으로 나누는 컨테이너 컴포넌트 생성. + - 상단 햄버거 버튼으로 세션 패널을 열고 닫을 수 있도록 제어 상태(`isSessionPanelOpen`)를 두고, 모바일/태블릿에서는 전체 화면 Drawer(뒤 배경 dim 처리), 데스크톱에서는 기본 오픈 + width 축소 애니메이션을 적용한다. 햄버거 버튼은 `ProfileHeader` 우측에 배치해 항상 접근 가능하게 한다. +2. 세션 패널 + - 세션 카드에는 `title`(없으면 첫 user 메시지 앞부분), `updated_at`, `message_count`를 표시하고 선택 상태를 강조. + - “새 세션 시작” 버튼: 클릭 시 현재 `currentSessionId` 를 `null`로 초기화하고 다음 질문을 새 세션으로 전송하도록 상태 변경. + - 하단 “더 보기” 또는 스크롤 감지 시 `loadMore()` 호출. +3. 메시지 영역 + - `ChatMessages` 를 그대로 쓰되 prop 구조를 `messages: Array` 로 확장하고, 서버 메시지에는 `inspector`가 없을 수 있음을 허용한다(4단계에서 관련 호출부 전체를 한 번에 업데이트할 예정). + - 스크롤 영역 상단에 `div` sentinel을 두고 `IntersectionObserver` 로 진입 감지 → `prependOlder()` 호출, 응답 도착 후에는 기존 scrollHeight 차이를 이용해 스크롤 위치를 유지(React 18에서도 안정적). loading 중에는 skeleton bubble을 잠시 노출. + - Inspector 패널은 스트리밍 메시지(실시간 질문)에만 붙이고, 과거 히스토리는 collapse 된 `ContextSummary` 정도만 노출하는 식으로 경량화한다. + +### 3.4 ASK 전송 흐름 업데이트 +1. `handleSubmit` + - zustand에서 `currentSessionId` 를 읽어 `askChatAPIV2` 에 전달하고, `null`일 경우 body에 `session_id: null`, `user_id: ownerUserId`, `requester_user_id: viewerId`를 명시한다. `viewerId` 는 `useAuthStore` 의 `userId` 값(로그인 사용자)이며, 비로그인일 때는 추후 정의할 게스트 ID(or null) 정책을 따른다. + - SSE `onSession` 콜백이 오면 즉시 `selectSession(newId)` + `upsertSessionFromStream(payload)`를 호출해 패널 리스트에도 새 항목을 넣는다. 헤더 값만 들어오고 이벤트가 없을 경우 대비하여 fallback 로직도 추가. + - 전송 직후엔 로컬 메시지를 `messagesBySession[tempSessionKey]` 에 push 해 UI가 즉시 반응하도록 하고, SSE chunk 가 오면 `appendStreamingChunk` 액션이 현재 세션 메시지를 업데이트한다. +2. 스트림 종료 처리 + - `session_saved` 이벤트에서 `cached` 여부와 최종 `message_count` 를 받아 해당 세션 카드 메타 정보를 갱신하고, 필요하면 `fetchMessages(sessionId, { direction: 'forward' })` 로 최신화한다. + - `session_error` 수신 시 현재 스트림 메시지를 에러 상태로 표시하고, `selectSession(null)` + 토스트 노출을 한 번에 처리한다(실패 세션은 리스트에서 제거하거나 `failed` 구분값을 두어 재시도 UX 제공). + +### 3.5 무한 스크롤 & UX 세부사항 +1. 메시지 히스토리 로딩 동작 정의 + - 최초 세션 선택 시 `direction=backward` 로 20개 로드 후 리스트 하단으로 스크롤, 이후 새 메시지가 오면 append. + - 상단 sentinel 이 viewport 에 들어오면 `cursor=messagesPagingBySession[sessionId].prevCursor` 로 과거 데이터를 불러와 `prependMessages` 한다. prepend 직후 `(scrollHeight_after - scrollHeight_before)` 만큼 `scrollTop` 을 보정해 위치가 튀지 않도록 한다. +2. 에러/빈 상태 처리 + - 세션이 없을 때는 “첫 질문을 해보세요” CTA 제공. + - 목록/메시지 로딩이 실패하면 zustand 에 `lastError` 를 기록하고, 패널/메시지 영역 각각에 재시도 버튼을 노출한다. 에러 상태가 풀리면 자동으로 토스트를 닫는다. + +## 4. 다음 단계 +1. API 클라이언트/타입 작성 → 유닛(또는 간단한 런타임 테스트)으로 JSON 형태 검증. +2. zustand 스토어 + 훅 구현 → Fake API 응답(Mock Service Worker 또는 임시 JSON)으로 세션 리스트/메시지 로직을 검증. +3. UI/흐름 통합 → 실제 SSE 없이도 dev 에서 동작하도록 `askChatAPIV2` 를 주입형으로 감싸고, 필요 시 `ChatbotPage.stories.tsx` 같은 문서화도 고려. + +## 5. 커밋 단위 구현 계획 +1. **[feat(api)] ai 세션 타입/클라이언트 추가** + - `src/utils/types.ts` 에 `ChatSession*` 타입 정의. + - `src/apis/aiSessionApi.ts` 신설, 세션 목록/상세/메시지 API 함수 구현. + - `askChatAPIV2` 시그니처 초안만 정의(아직 호출부 수정 X) 및 새로운 SSE 이벤트 핸들러 타입 선언. +2. **[feat(store)] ChatSessionStore 및 훅 도입** + - `src/store/ChatSessionStore.ts` 생성, 상태/액션/초기값 구현. + - `src/hooks/useChatSessions.ts`, `src/hooks/useSessionMessages.ts` 작성해 zustand 액션을 래핑. + - 임시 mock 함수나 dev-only util로 스토어 로직을 간단히 검증. +3. **[feat/ui] 세션 패널/햄버거 레이아웃 구축** + - `src/app/chatbot/[userId]/page.tsx` 레이아웃을 세션 패널 + 대화 영역으로 분리. + - 햄버거 버튼, Drawer/축소 UI, 세션 리스트 컴포넌트(`SessionListPanel.tsx`) 추가. + - 패널과 store를 연결, 기본 세션 로딩/빈 상태 UI 구현. +4. **[feat(chat)] 메시지 영역 무한 스크롤 + SSE 연동** + - `ChatMessages` 컴포넌트 prop 확장 및 sentinel 기반 무한 스크롤 구현. + - `askChatAPIV2` 본격 수정: `session_id` 전달, `onSession`/`onSessionSaved`/`onSessionError` 등 호출 시 zustand 액션 연동. 해당 커밋에서 기존 호출부 전부(챗봇 페이지, 기타 컴포넌트)를 새 시그니처로 업데이트. + - `handleSubmit` 로직을 훅/store 중심으로 재작성하고 임시 메시지/스트리밍 업데이트 처리. +5. **[feat/polish] 오류 처리·토스트·UX 마감** + - `session_saved` 캐시 배지, `session_error` 토스트, 재시도 버튼, loading skeleton 등 UX 디테일 반영. + - API 실패/네트워크 해제 대응, 패널/메시지 에러 상태 컴포넌트. + - 문서(TASK.md or ASK_SESSION_INTEGRATION.md subsection) 및 TODO 코멘트 정리, 린트 확인. diff --git a/TOAST_USAGE.md b/TOAST_USAGE.md new file mode 100644 index 0000000..1f4727e --- /dev/null +++ b/TOAST_USAGE.md @@ -0,0 +1,208 @@ +# Toast 사용 가이드 + +프로젝트에 Toast 알림 시스템이 추가되었습니다. 사용자에게 성공, 오류, 정보, 경고 메시지를 우아하게 표시할 수 있습니다. + +## 기본 사용법 + +```tsx +'use client' + +import { useToast } from '@/contexts/ToastContext' + +export function MyComponent() { + const toast = useToast() + + const handleSuccess = () => { + toast.success('저장되었습니다!') + } + + const handleError = () => { + toast.error('오류가 발생했습니다') + } + + const handleInfo = () => { + toast.info('알림: 새로운 메시지가 있습니다') + } + + const handleWarning = () => { + toast.warning('주의: 이 작업은 되돌릴 수 없습니다') + } + + return ( +
+ + + + +
+ ) +} +``` + +## API + +### `useToast()` Hook + +컴포넌트에서 Toast를 사용하려면 `useToast()` hook을 import 합니다. + +```tsx +const toast = useToast() +``` + +### 메서드 + +#### `toast.success(message, duration?)` +성공 메시지를 표시합니다 (초록색 아이콘). + +```tsx +toast.success('게시글이 저장되었습니다') +toast.success('업로드 완료!', 3000) // 3초 후 자동 닫힘 +``` + +#### `toast.error(message, duration?)` +에러 메시지를 표시합니다 (빨간색 아이콘). + +```tsx +toast.error('로그인에 실패했습니다') +toast.error('네트워크 오류가 발생했습니다', 5000) // 5초 후 자동 닫힘 +``` + +#### `toast.info(message, duration?)` +정보 메시지를 표시합니다 (파란색 아이콘). + +```tsx +toast.info('로그인이 필요한 서비스입니다') +toast.info('새로운 버전이 출시되었습니다', 4000) +``` + +#### `toast.warning(message, duration?)` +경고 메시지를 표시합니다 (노란색 아이콘). + +```tsx +toast.warning('변경사항이 저장되지 않았습니다') +toast.warning('세션이 곧 만료됩니다', 6000) +``` + +#### `toast.showToast(message, type, duration?)` +커스텀 Toast를 표시합니다. + +```tsx +toast.showToast('커스텀 메시지', 'success', 3000) +``` + +### 매개변수 + +- **message** (string, 필수): 표시할 메시지 +- **duration** (number, 선택): 자동으로 닫히기까지의 시간 (밀리초). 기본값: 4000ms (4초) + - `0`으로 설정하면 자동으로 닫히지 않음 + +## 실제 사용 예시 + +### 1. 폼 제출 성공/실패 + +```tsx +const handleSubmit = async (data: FormData) => { + try { + await savePost(data) + toast.success('게시글이 저장되었습니다') + router.push('/posts') + } catch (error) { + toast.error('저장 중 오류가 발생했습니다') + } +} +``` + +### 2. 로그인 상태 확인 + +```tsx +const handleProtectedAction = () => { + if (!isLogin) { + toast.info('로그인이 필요한 서비스입니다') + router.push('/login') + return + } + + // 로그인된 경우 작업 수행 + performAction() +} +``` + +### 3. AI Chat 세션 저장 + +```tsx +sse.addEventListener('session_saved', (evt) => { + const data = JSON.parse(evt.data) + if (data.cached) { + toast.info('이전 답변을 재사용했습니다') + } else { + toast.success('대화가 저장되었습니다') + } +}) + +sse.addEventListener('session_error', (evt) => { + toast.error('대화 저장에 실패했습니다') +}) +``` + +### 4. 파일 업로드 + +```tsx +const handleFileUpload = async (file: File) => { + try { + const { presignedUrl, fileUrl } = await getPresignedUrl(file.name, file.type) + await uploadToS3(presignedUrl, file) + toast.success('이미지가 업로드되었습니다') + return fileUrl + } catch (error) { + toast.error('업로드에 실패했습니다') + throw error + } +} +``` + +### 5. 복사하기 기능 + +```tsx +const handleCopy = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + toast.success('클립보드에 복사되었습니다', 2000) + } catch { + toast.error('복사에 실패했습니다') + } +} +``` + +## 디자인 특징 + +- ✨ Framer Motion을 사용한 부드러운 entrance/exit 애니메이션 +- 🎨 4가지 타입별 색상 구분 (성공/에러/정보/경고) +- 🌙 다크모드 지원 (준비됨) +- 📱 반응형 디자인 +- ♿ 접근성 고려 (aria-live, aria-atomic) +- 👆 닫기 버튼 제공 +- ⏱️ 자동 닫힘 기능 (커스터마이징 가능) +- 📚 여러 Toast 스택 가능 (동시에 표시) + +## 주의사항 + +- `useToast()`는 Client Component에서만 사용 가능합니다 (`'use client'` 필요) +- ToastProvider는 이미 `src/app/layout.tsx`에 설정되어 있습니다 +- Toast는 화면 우측 상단에 표시됩니다 +- 여러 Toast가 동시에 표시될 경우 위에서 아래로 쌓입니다 + +## 스타일 커스터마이징 + +Toast 컴포넌트는 `src/components/Common/Toast.tsx`에 있으며, 필요에 따라 색상이나 스타일을 수정할 수 있습니다. + +```tsx +// Toast 색상 커스터마이징 예시 +const toastConfig = { + success: { + bgColor: 'bg-green-50 dark:bg-green-900/20', + borderColor: 'border-green-500', + // ... + }, + // ... +} +``` diff --git a/src/apis/aiApi.ts b/src/apis/aiApi.ts index d60239c..9f151f4 100644 --- a/src/apis/aiApi.ts +++ b/src/apis/aiApi.ts @@ -11,6 +11,24 @@ export interface ChatMessage { content: string; } +export interface AskSessionEventPayload { + session_id: number; + owner_user_id: string; + requester_user_id: string; +} + +export interface AskSessionSavedPayload extends AskSessionEventPayload { + cached?: boolean; +} + +export interface AskSessionErrorPayload { + session_id?: number; + owner_user_id?: string; + requester_user_id?: string; + reason?: string; + message?: string; +} + // v2 검색 계획 타입(최소 서브셋, 유연성 유지) export type SearchMode = 'rag' | 'post'; export interface SearchPlan { @@ -62,6 +80,12 @@ function normalizeContextArray(raw: any): ContextItem[] { /** * AI 서버에 질문을 보내고 SSE 스트림을 처리합니다. (v1) */ +export interface AskChatHandlers { + onSession?: (payload: AskSessionEventPayload) => void; + onSessionSaved?: (payload: AskSessionSavedPayload) => void; + onSessionError?: (payload: AskSessionErrorPayload) => void; +} + export async function askChatAPI( question: string, userId: string, @@ -69,7 +93,13 @@ export async function askChatAPI( personaId: number | -1, onContext: (items: ContextItem[]) => void, onAnswerChunk: (chunk: string) => void, - options?: { postId?: number; llm?: LLMRequest; onExistInPostStatus?: (exists: boolean) => void } + options?: { + postId?: number; + llm?: LLMRequest; + sessionId?: number | null; + requesterUserId?: string | null; + onExistInPostStatus?: (exists: boolean) => void; + } & AskChatHandlers ): Promise { const body: any = { question, @@ -79,6 +109,8 @@ export async function askChatAPI( }; if (options?.postId != null) body.post_id = options.postId; if (options?.llm) body.llm = options.llm; + if (options?.sessionId !== undefined) body.session_id = options.sessionId; + if (options?.requesterUserId !== undefined) body.requester_user_id = options.requesterUserId; const res = await aiFetch('/ai/ask', { method: 'POST', @@ -150,6 +182,22 @@ export async function askChatAPI( // Ignore non-JSON-string chunks } } + if (eventName === 'session') { + try { + const payload = JSON.parse(raw) as AskSessionEventPayload; + if (payload?.session_id != null) options?.onSession?.(payload); + } catch {} + } else if (eventName === 'session_saved') { + try { + const payload = JSON.parse(raw) as AskSessionSavedPayload; + if (payload?.session_id != null) options?.onSessionSaved?.(payload); + } catch {} + } else if (eventName === 'session_error') { + try { + const payload = JSON.parse(raw) as AskSessionErrorPayload; + options?.onSessionError?.(payload); + } catch {} + } eventName = ''; } } @@ -165,9 +213,19 @@ export interface AskV2Handlers { onExistInPostStatus?: (exists: boolean) => void; onContext?: (items: ContextItem[]) => void; onAnswerChunk?: (chunk: string) => void; + onSession?: (payload: AskSessionEventPayload) => void; + onSessionSaved?: (payload: AskSessionSavedPayload) => void; + onSessionError?: (payload: AskSessionErrorPayload) => void; onError?: (message: string, code?: number) => void; } +export interface AskChatV2Options { + postId?: number; + llm?: LLMRequest; + sessionId?: number | null; + requesterUserId?: string | null; +} + /** * AI v2 서버에 질문을 보내고(\n/ai/v2/ask) 추가 이벤트를 처리합니다. */ @@ -177,7 +235,7 @@ export async function askChatAPIV2( categoryId: number | null, personaId: number | -1, handlers: AskV2Handlers, - options?: { postId?: number; llm?: LLMRequest } + options?: AskChatV2Options ): Promise { const body: any = { question, @@ -187,6 +245,8 @@ export async function askChatAPIV2( }; if (options?.postId != null) body.post_id = options.postId; if (options?.llm) body.llm = options.llm; + if (options?.sessionId !== undefined) body.session_id = options.sessionId; + if (options?.requesterUserId !== undefined) body.requester_user_id = options.requesterUserId; const res = await aiFetch('/ai/v2/ask', { method: 'POST', @@ -279,6 +339,21 @@ export async function askChatAPIV2( if (typeof s === 'string' && s.length > 0) handlers.onAnswerChunk?.(s); break; } + case 'session': { + const payload = JSON.parse(raw) as AskSessionEventPayload; + if (payload?.session_id != null) handlers.onSession?.(payload); + break; + } + case 'session_saved': { + const payload = JSON.parse(raw) as AskSessionSavedPayload; + if (payload?.session_id != null) handlers.onSessionSaved?.(payload); + break; + } + case 'session_error': { + const payload = JSON.parse(raw) as AskSessionErrorPayload; + handlers.onSessionError?.(payload); + break; + } case 'error': { try { const err = JSON.parse(raw); @@ -303,3 +378,52 @@ export async function askChatAPIV2( } } } + +// ============================================================ +// Session Management API +// ============================================================ + +/** + * 세션 제목 또는 메타데이터를 수정합니다. + * @param sessionId 세션 ID + * @param updates title과 metadata 중 하나 이상 필수 + */ +export async function updateSession( + sessionId: number, + updates: { title?: string; metadata?: Record } +): Promise { + const { aiFetch } = await import('@/apis/apiClient'); + + const res = await aiFetch(`/ai/v2/sessions/${sessionId}`, { + method: 'PATCH', + body: JSON.stringify(updates), + }); + + if (!res.ok) { + const error = await res.json().catch(() => ({ message: 'Failed to update session' })); + throw new Error(error.message || 'Failed to update session'); + } + + return res.json(); +} + +/** + * 세션을 삭제합니다 (cascade: 메시지, 임베딩 포함). + * @param sessionId 세션 ID + */ +export async function deleteSession( + sessionId: number +): Promise<{ session_id: number; deleted: boolean }> { + const { aiFetch } = await import('@/apis/apiClient'); + + const res = await aiFetch(`/ai/v2/sessions/${sessionId}`, { + method: 'DELETE', + }); + + if (!res.ok) { + const error = await res.json().catch(() => ({ message: 'Failed to delete session' })); + throw new Error(error.message || 'Failed to delete session'); + } + + return res.json(); +} diff --git a/src/apis/aiSessionApi.ts b/src/apis/aiSessionApi.ts new file mode 100644 index 0000000..a16ed27 --- /dev/null +++ b/src/apis/aiSessionApi.ts @@ -0,0 +1,74 @@ +import { aiFetch } from '@/apis/apiClient'; +import { + ChatSession, + ChatSessionListResponse, + ChatSessionMessagesResponse, +} from '@/utils/types'; + +const DEFAULT_ERROR = 'AI 세션 API 호출 중 오류가 발생했습니다.'; + +function buildParams(source: Record) { + return Object.fromEntries( + Object.entries(source).filter(([, value]) => value !== undefined && value !== null) + ) as Record; +} + +async function parseJson(res: Response, fallbackMessage: string): Promise { + const text = await res.text(); + const data = text ? JSON.parse(text) : null; + if (!res.ok) { + const message = (data && (data.message || data.error || data.reason)) || fallbackMessage; + throw new Error(message); + } + return data as T; +} + +export interface GetChatSessionsParams { + limit?: number; + cursor?: string | null; + ownerUserId?: string; +} + +export async function getChatSessions(params: GetChatSessionsParams = {}): Promise { + const query = buildParams({ + limit: params.limit, + cursor: params.cursor, + owner_user_id: params.ownerUserId, + }); + + const res = await aiFetch('/ai/v2/sessions', { + method: 'GET', + params: query, + }); + return parseJson(res, DEFAULT_ERROR); +} + +export async function getChatSession(sessionId: number): Promise { + const res = await aiFetch(`/ai/v2/sessions/${sessionId}`, { + method: 'GET', + }); + return parseJson(res, DEFAULT_ERROR); +} + +export interface GetChatSessionMessagesParams { + limit?: number; + cursor?: string | null; + direction?: 'forward' | 'backward'; +} + +export async function getChatSessionMessages( + sessionId: number, + params: GetChatSessionMessagesParams = {} +): Promise { + const query = buildParams({ + limit: params.limit, + cursor: params.cursor, + direction: params.direction, + }); + + const res = await aiFetch(`/ai/v2/sessions/${sessionId}/messages`, { + method: 'GET', + params: query, + }); + return parseJson(res, DEFAULT_ERROR); +} diff --git a/src/app/chatbot/[userId]/page.tsx b/src/app/chatbot/[userId]/page.tsx index c378d41..63f4854 100644 --- a/src/app/chatbot/[userId]/page.tsx +++ b/src/app/chatbot/[userId]/page.tsx @@ -1,10 +1,12 @@ 'use client' -import { useState, useRef, useEffect, FormEvent } from 'react' +import { useState, useRef, useEffect, FormEvent, useCallback, useMemo } from 'react' import { useParams, useSearchParams } from 'next/navigation' +import { motion, AnimatePresence } from 'framer-motion' import { getBlogById } from '@/apis/blogApi' import { getUserProfile, UserProfile } from '@/apis/userApi' -import { askChatAPI, askChatAPIV2 } from '@/apis/aiApi' +import { askChatAPI, askChatAPIV2, type SearchPlan, type ContextItem, updateSession, deleteSession } from '@/apis/aiApi' +import type { ChatSessionMessage } from '@/utils/types' import { ProfileHeader } from '@/components/Chat/ProfileHeader' import { CategoryFilterButton } from '@/components/Category/CategoryFilterButton' import { ChatMessages, type ChatMessage as UIChatMessage, type InspectorData } from '@/components/Chat/ChatMessages' @@ -17,6 +19,105 @@ import { PersonaFilterButton } from '@/components/Persona/PersonaFilterButton' import { Persona } from '@/apis/personaApi' import { CategoryNode } from '@/apis/categoryApi' import { VersionToggle } from '@/components/Chat/VersionToggle' +import { SessionListPanel } from '@/components/Chat/SessionListPanel' +import { useChatSessions } from '@/hooks/useChatSessions' +import { useSessionMessages } from '@/hooks/useSessionMessages' +import { useAuthStore } from '@/store/AuthStore' +import { useChatSessionStore } from '@/store/ChatSessionStore' +import { ThreeDotsLoader } from '@/components/Common/ThreeDotsLoader' +import { useIsMobile } from '@/hooks/useIsMobile' + +const BANNER_STYLES: Record<'info' | 'success' | 'error', string> = { + info: 'border-blue-200 bg-blue-50 text-blue-700', + success: 'border-green-200 bg-green-50 text-green-700', + error: 'border-red-200 bg-red-50 text-red-700', +} + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +const toStringArray = (value: unknown): string[] => { + if (typeof value === 'string') return [value] + if (Array.isArray(value)) { + return value + .map(item => (typeof item === 'string' ? item : null)) + .filter((item): item is string => !!item) + } + return [] +} + +const normalizeContextItems = (raw: unknown): ContextItem[] => { + if (!Array.isArray(raw)) return [] + const pairs = raw + .map((entry: any) => { + const id = entry?.postId ?? entry?.post_id ?? entry?.id + const title = entry?.postTitle ?? entry?.post_title ?? entry?.title + if (id == null || title == null) return null + return { post_id: String(id), post_title: String(title) } + }) + .filter((item): item is ContextItem => !!item) + return pairs +} + +const pickMetaValue = (meta: Record | null, keys: string[]) => { + if (!meta) return undefined + for (const key of keys) { + if (meta[key] != null) return meta[key] + } + return undefined +} + +const buildInspectorFromHistoryMessage = ( + message: ChatSessionMessage, + planOverride: SearchPlan | null +): InspectorData | undefined => { + const meta = isPlainObject(message.retrieval_meta) ? message.retrieval_meta : null + + // retrieval_meta에서 먼저 찾고, 없으면 search_plan에서 가져오기 + const rewritesFromMeta = toStringArray(pickMetaValue(meta, ['rewrites', 'rewrite_list', 'rewriteList'])) + const keywordsFromMeta = toStringArray(pickMetaValue(meta, ['keywords', 'keyword_list', 'keywordList'])) + + const rewrites = rewritesFromMeta.length > 0 ? rewritesFromMeta : toStringArray(planOverride?.rewrites) + const keywords = keywordsFromMeta.length > 0 ? keywordsFromMeta : toStringArray(planOverride?.keywords) + + const hybridResult = normalizeContextItems(pickMetaValue(meta, ['hybrid_result', 'hybridResult'])) + const searchResult = normalizeContextItems(pickMetaValue(meta, ['search_result', 'searchResult'])) + const contextItems = normalizeContextItems(pickMetaValue(meta, ['context', 'contexts'])) + + // 실제 데이터가 있을 때만 인스펙션 표시 + const hasData = Boolean(rewrites.length || keywords.length || hybridResult.length || searchResult.length || contextItems.length) + if (!hasData) return undefined + + return { + version: 'v2', + open: false, + v2Plan: planOverride, + v2PlanReceived: Boolean(planOverride), + v2Rewrites: rewrites, + v2RewritesReceived: true, // History에서 로드되었으므로 항상 true + v2Keywords: keywords, + v2KeywordsReceived: true, // History에서 로드되었으므로 항상 true + v2HybridResult: hybridResult, + v2HybridResultReceived: true, // History에서 로드되었으므로 항상 true + v2SearchResult: searchResult, + v2SearchResultReceived: true, // History에서 로드되었으므로 항상 true + v2Context: contextItems, + v2ContextReceived: true, // History에서 로드되었으므로 항상 true + pending: false, + } +} + +const updateSessionQueryParam = (sessionId: number | null) => { + if (typeof window === 'undefined') return + const url = new URL(window.location.href) + if (sessionId != null) { + url.searchParams.set('sessionId', String(sessionId)) + } else { + url.searchParams.delete('sessionId') + } + const newPath = `${url.pathname}${url.search ? url.search : ''}` + window.history.replaceState({}, '', newPath) +} export default function ChatPage() { const { userId } = useParams<{ userId: string }>() @@ -29,9 +130,10 @@ export default function ChatPage() { const [loadingUser, setLoadingUser] = useState(true) const [errorUser, setErrorUser] = useState(null) - const [messages, setMessages] = useState([]) + const [liveMessages, setLiveMessages] = useState([]) const [input, setInput] = useState('') const [askVersion, setAskVersion] = useState<'v1' | 'v2'>('v1') + const [inspectorOpenMap, setInspectorOpenMap] = useState>(new Map()) const [isCatOpen, setIsCatOpen] = useState(false) const [selectedCategory, setSelectedCategory] = useState(null) @@ -42,8 +144,182 @@ export default function ChatPage() { const [isSending, setIsSending] = useState(false) const [modalPostId, setModalPostId] = useState(null) + const [banner, setBanner] = useState<{ type: 'info' | 'success' | 'error'; message: string } | null>(null) const chatEndRef = useRef(null) + const tempIdRef = useRef(-1) + const bannerTimerRef = useRef | null>(null) + const activeSessionIdRef = useRef(null) + const viewerId = useAuthStore(state => state.userId) + + const isMobile = useIsMobile(); + + const PANEL_TOP_OFFSET = isMobile ? 72 : 96 // px + + const { + sessions, + sessionsLoading, + sessionsLoadingMore, + sessionsError, + sessionsPaging, + currentSessionId, + isSessionPanelOpen, + selectSession, + setPanelOpen, + loadMore, + fetchInitialSessions, + resetSessions, + } = useChatSessions(userId, { limit: 20 }) + const { + messages: historyMessages, + paging: historyPaging, + isLoading: historyLoading, + error: historyError, + isFetchingOlder, + loadOlder, + } = useSessionMessages(currentSessionId, { limit: 20 }) + const upsertSessionFromStream = useChatSessionStore(state => state.upsertSessionFromStream) + const updateSessionMeta = useChatSessionStore(state => state.updateSessionMeta) + const fetchSessionMessages = useChatSessionStore(state => state.fetchMessages) + const historyUiMessages = useMemo(() => { + const transformed: UIChatMessage[] = [] + let pendingPlan: SearchPlan | null = null + for (const msg of historyMessages) { + const rawPlan = (msg.search_plan as SearchPlan | null) ?? null + if (msg.role === 'user') { + if (rawPlan) pendingPlan = rawPlan + transformed.push({ + id: msg.id, + role: 'user', + content: msg.content, + }) + continue + } + + const planForMessage = rawPlan ?? pendingPlan + if (rawPlan || pendingPlan) pendingPlan = null + const inspector = buildInspectorFromHistoryMessage(msg, planForMessage) + transformed.push({ + id: msg.id, + role: 'bot', + content: msg.content, + ...(inspector ? { inspector } : {}), + }) + } + return transformed + }, [historyMessages]) + const combinedMessages = useMemo(() => { + const applyOpenState = (msg: UIChatMessage): UIChatMessage => ({ + ...msg, + inspector: msg.inspector ? { ...msg.inspector, open: inspectorOpenMap.get(msg.id) ?? false } : undefined + }) + return [...historyUiMessages.map(applyOpenState), ...liveMessages.map(applyOpenState)] + }, [historyUiMessages, liveMessages, inspectorOpenMap]) + const showBanner = useCallback((type: 'info' | 'success' | 'error', message: string) => { + if (bannerTimerRef.current) clearTimeout(bannerTimerRef.current) + setBanner({ type, message }) + bannerTimerRef.current = setTimeout(() => setBanner(null), 4000) + }, []) + + type SessionSelectionOptions = { + keepPanel?: boolean + preserveLiveMessages?: boolean + force?: boolean + } + + const syncSessionSelection = useCallback( + (sessionId: number | null, options?: SessionSelectionOptions) => { + const shouldSkip = !options?.force && sessionId === currentSessionId + if (!options?.keepPanel) setPanelOpen(false) + if (shouldSkip) return + activeSessionIdRef.current = sessionId + selectSession(sessionId) + if (!options?.preserveLiveMessages) setLiveMessages([]) + }, + [currentSessionId, selectSession, setPanelOpen, setLiveMessages] + ) + + const handleSelectSession = useCallback( + (sessionId: number | null) => { + syncSessionSelection(sessionId) + }, + [syncSessionSelection] + ) + + const handleSessionUpdate = useCallback(async (sessionId: number, title: string) => { + try { + await updateSession(sessionId, { title }) + updateSessionMeta(sessionId, { title }) + showBanner('success', '세션 이름이 변경되었습니다.') + } catch (error) { + console.error('Failed to update session:', error) + showBanner('error', '세션 이름 변경에 실패했습니다.') + throw error + } + }, [updateSessionMeta, showBanner]) + + const handleSessionDelete = useCallback(async (sessionId: number) => { + try { + await deleteSession(sessionId) + + // 세션 목록 새로고침 + resetSessions() + if (userId) { + fetchInitialSessions({ ownerUserId: userId, limit: 20 }) + } + + // 현재 세션이 삭제된 경우 새 세션으로 전환 + if (sessionId === currentSessionId) { + syncSessionSelection(null, { preserveLiveMessages: true }) + showBanner('info', '삭제된 세션입니다. 새 대화를 시작하세요.') + } else { + showBanner('success', '세션이 삭제되었습니다.') + } + } catch (error) { + console.error('Failed to delete session:', error) + showBanner('error', '세션 삭제에 실패했습니다.') + throw error + } + }, [currentSessionId, userId, updateSessionMeta, resetSessions, fetchInitialSessions, syncSessionSelection, showBanner]) + + useEffect(() => { + activeSessionIdRef.current = currentSessionId + updateSessionQueryParam(currentSessionId) + }, [currentSessionId]) + + const handledSessionParamRef = useRef(null) + useEffect(() => { + const param = searchParams.get('sessionId') + if (handledSessionParamRef.current === param) return + handledSessionParamRef.current = param + if (!param) return + const parsed = Number(param) + if (!Number.isNaN(parsed)) { + syncSessionSelection(parsed, { keepPanel: true, preserveLiveMessages: true, force: true }) + } + }, [searchParams, syncSessionSelection]) + + useEffect(() => () => { + if (bannerTimerRef.current) clearTimeout(bannerTimerRef.current) + }, []) + + useEffect(() => { + activeSessionIdRef.current = null + setLiveMessages([]) + resetSessions() + updateSessionQueryParam(null) + }, [userId, resetSessions]) + + const handleOpenPanel = () => setPanelOpen(true) + const handleClosePanel = () => setPanelOpen(false) + const retrySessions = useCallback(() => { + if (!userId) return + fetchInitialSessions({ ownerUserId: userId, limit: 20 }) + }, [userId, fetchInitialSessions]) + const retryHistory = useCallback(() => { + if (!currentSessionId) return + fetchSessionMessages(currentSessionId, { direction: 'backward', mode: 'replace' }) + }, [currentSessionId, fetchSessionMessages]) useEffect(() => { if (!userId) return @@ -71,7 +347,15 @@ export default function ChatPage() { useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) + }, [combinedMessages]) + + useEffect(() => { + if (sessionsError) showBanner('error', sessionsError) + }, [sessionsError, showBanner]) + + useEffect(() => { + if (historyError) showBanner('error', historyError) + }, [historyError, showBanner]) const handleSubmit = async (e: FormEvent) => { e.preventDefault() @@ -79,12 +363,13 @@ export default function ChatPage() { setIsSending(true) const question = input.trim() + const userMsgId = tempIdRef.current-- + const botId = tempIdRef.current-- const userMsg: UIChatMessage = { - id: messages.length + 1, + id: userMsgId, role: 'user', content: question, } - const botId = userMsg.id + 1 const initialInspector: InspectorData = askVersion === 'v1' @@ -107,21 +392,24 @@ export default function ChatPage() { pending: true, } - setMessages(prev => [ + setLiveMessages(prev => [ ...prev, userMsg, { id: botId, role: 'bot', content: '', inspector: initialInspector }, ]) + const resolvedCategoryId = postId != null ? null : (selectedCategory?.id ?? null) + const resolvedSessionId = activeSessionIdRef.current ?? null + try { if (askVersion === 'v1') { await askChatAPI( question, userId!, - postId != null ? null : (selectedCategory?.id ?? null), + resolvedCategoryId, selectedPersona?.id ?? -1, items => { - setMessages(prev => { + setLiveMessages(prev => { const next = [...prev] const msg = next.find(m => m.id === botId) if (msg && msg.inspector) { @@ -133,23 +421,49 @@ export default function ChatPage() { }) }, chunk => { - setMessages(prev => { + setLiveMessages(prev => { const next = [...prev] const msg = next.find(m => m.id === botId) if (msg) msg.content += chunk return next }) }, - { postId } + { + postId, + sessionId: resolvedSessionId, + requesterUserId: viewerId ?? null, + onSession: payload => { + upsertSessionFromStream(payload) + syncSessionSelection(payload.session_id, { keepPanel: true, preserveLiveMessages: true }) + showBanner('info', '새 대화 세션을 시작했어요.') + }, + onSessionSaved: payload => { + updateSessionMeta(payload.session_id, { updated_at: new Date().toISOString() }) + fetchSessionMessages(payload.session_id, { direction: 'backward', mode: 'replace' }).finally(() => { + setLiveMessages([]) + }) + showBanner('success', payload.cached ? '이전 답변을 재사용했어요.' : '대화가 저장되었어요.') + }, + onSessionError: payload => { + const message = payload.reason || payload.message || '세션 저장 중 오류가 발생했습니다.' + setLiveMessages(prev => { + const next = [...prev] + const msg = next.find(m => m.id === botId) + if (msg) msg.content = message + return next + }) + showBanner('error', message) + }, + } ) } else { await askChatAPIV2( question, userId!, - postId != null ? null : (selectedCategory?.id ?? null), + resolvedCategoryId, selectedPersona?.id ?? -1, { - onSearchPlan: p => setMessages(prev => { + onSearchPlan: p => setLiveMessages(prev => { const next = [...prev] const msg = next.find(m => m.id === botId) if (msg?.inspector) { @@ -159,7 +473,7 @@ export default function ChatPage() { } return next }), - onRewrites: r => setMessages(prev => { + onRewrites: r => setLiveMessages(prev => { const next = [...prev] const msg = next.find(m => m.id === botId) if (msg?.inspector) { @@ -169,7 +483,7 @@ export default function ChatPage() { } return next }), - onKeywords: k => setMessages(prev => { + onKeywords: k => setLiveMessages(prev => { const next = [...prev] const msg = next.find(m => m.id === botId) if (msg?.inspector) { @@ -179,7 +493,7 @@ export default function ChatPage() { } return next }), - onHybridResult: items => setMessages(prev => { + onHybridResult: items => setLiveMessages(prev => { const next = [...prev] const msg = next.find(m => m.id === botId) if (msg?.inspector) { @@ -189,7 +503,7 @@ export default function ChatPage() { } return next }), - onSearchResult: items => setMessages(prev => { + onSearchResult: items => setLiveMessages(prev => { const next = [...prev] const msg = next.find(m => m.id === botId) if (msg?.inspector) { @@ -199,7 +513,7 @@ export default function ChatPage() { } return next }), - onContext: items => setMessages(prev => { + onContext: items => setLiveMessages(prev => { const next = [...prev] const msg = next.find(m => m.id === botId) if (msg?.inspector) { @@ -210,32 +524,60 @@ export default function ChatPage() { return next }), onAnswerChunk: chunk => { - setMessages(prev => { + setLiveMessages(prev => { const next = [...prev] const msg = next.find(m => m.id === botId) if (msg) msg.content += chunk return next }) }, + onSession: payload => { + upsertSessionFromStream(payload) + syncSessionSelection(payload.session_id, { keepPanel: true, preserveLiveMessages: true }) + showBanner('info', '새 대화 세션을 시작했어요.') + }, + onSessionSaved: payload => { + updateSessionMeta(payload.session_id, { updated_at: new Date().toISOString() }) + fetchSessionMessages(payload.session_id, { direction: 'backward', mode: 'replace' }).finally(() => { + setLiveMessages([]) + }) + showBanner('success', payload.cached ? '이전 답변을 재사용했어요.' : '대화가 저장되었어요.') + }, + onSessionError: payload => { + const message = payload.reason || payload.message || '세션 저장 중 오류가 발생했습니다.' + setLiveMessages(prev => { + const next = [...prev] + const msg = next.find(m => m.id === botId) + if (msg) msg.content = message + return next + }) + showBanner('error', message) + }, onError: (message) => { - setMessages(prev => { + setLiveMessages(prev => { const next = [...prev] const msg = next.find(m => m.id === botId) if (msg) msg.content = message || '서버 요청 중 오류가 발생했습니다.' return next }) + showBanner('error', message || '서버 요청 중 오류가 발생했습니다.') } }, - { postId } + { + postId, + sessionId: resolvedSessionId, + requesterUserId: viewerId ?? null, + } ) } } catch { - setMessages(prev => { + setLiveMessages(prev => { const next = [...prev] const msg = next.find(m => m.id === botId) if (msg) msg.content = '서버 요청 중 오류가 발생했습니다.' return next }) + showBanner('error', '서버 요청 중 오류가 발생했습니다.') } finally { setIsSending(false) setInput('') @@ -248,82 +590,169 @@ export default function ChatPage() { return (
-
-
- - {postId != null && ( -
- - {postTitle ? `"${postTitle}" 글에 대해 질문 중` : '이 글 범위로 질문 중'} - +
+ + {isSessionPanelOpen && ( +
+ +
+ +
)} -
- -
- {postId == null && ( - setIsCatOpen(false)} - selectedCategory={selectedCategory} - setSelectedCategory={setSelectedCategory} - /> - )} + - setSelectedPersona(p)} - onClose={() => setIsPersonaOpen(false)} - /> - - {/* 채팅 메시지 구간 (스크롤) */} - { - setMessages(prev => prev.map(m => (m.id === id && m.inspector) ? { ...m, inspector: { ...m.inspector, open: !m.inspector.open } } : m)) - }} - onInspectorItemClick={(_id, item) => setModalPostId(item.post_id)} - /> - {messages.length === 0 && ( -
- {postId != null ? ( - 이 글에 대해 물어보세요 - ) : ( - 블로그에 대해 물어보세요 - )} +
+
+
+ + +
+ {postId != null && ( +
+ + {postTitle ? `"${postTitle}" 글에 대해 질문 중` : '이 글 범위로 질문 중'} + +
+ )} +
+ {banner && ( +
+ {banner.message}
)} - - {modalPostId && ( - setModalPostId(null)}> - setModalPostId(null)} /> - - )} +
+ {postId == null && ( + setIsCatOpen(false)} + selectedCategory={selectedCategory} + setSelectedCategory={setSelectedCategory} + /> + )} + + setSelectedPersona(p)} + onClose={() => setIsPersonaOpen(false)} + selectedPersona={selectedPersona} + /> - -
- - {postId == null && ( - setIsCatOpen(true)} + {historyError && ( +
+

{historyError}

+ {currentSessionId && ( + + )} +
+ )} + + {/* 채팅 메시지 구간 (스크롤) */} + { + setInspectorOpenMap(prev => { + const next = new Map(prev) + next.set(id, !prev.get(id)) + return next + }) + }} + onInspectorItemClick={(_id, item) => setModalPostId(item.post_id)} + /> + {historyLoading && combinedMessages.length === 0 && ( +
+ +
+ )} + {combinedMessages.length === 0 && !historyLoading && ( +
+ {postId != null ? ( + 이 글에 대해 물어보세요 + ) : ( + 블로그에 대해 물어보세요 + )} +
+ )} + + {modalPostId && ( + setModalPostId(null)}> + setModalPostId(null)} /> + + )} + + +
+ + {postId == null && ( + setIsCatOpen(true)} + /> + )} + setIsPersonaOpen(true)} /> - )} - setIsPersonaOpen(true)} - /> -
-
+
+
+
diff --git a/src/app/globals.css b/src/app/globals.css index b116120..de697e3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -32,6 +32,24 @@ body { color: var(--foreground); } +/* Animation Performance Optimization */ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* GPU Acceleration for animated elements */ +[data-motion] { + transform: translateZ(0); + backface-visibility: hidden; + perspective: 1000px; +} + +/* Smooth scrolling */ +* { + scroll-behavior: smooth; +} + /* Markdown Editor Toolbar Size Customization */ .w-md-editor-toolbar { padding: 8px 10px !important; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 12981a7..2b339d6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -34,6 +34,7 @@ export const metadata: Metadata = { }; import TextSizeAdjuster from '@/components/Common/TextSizeAdjuster'; +import { ToastProvider } from '@/contexts/ToastContext'; export default function RootLayout({ children, @@ -73,17 +74,19 @@ export default function RootLayout({ }), }} /> - {/* 클라이언트에서만 init() 실행 (SSR 레이아웃 유지) */} - -
- -
- {children} + + {/* 클라이언트에서만 init() 실행 (SSR 레이아웃 유지) */} + +
+ +
+ {children} +
-
- - - + + + + ); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 84b7732..2a7231c 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation' import { Button } from '@/components/Common/Button' import BubbleBackgroundCursor from '@/components/BackGround/BubbleBackgroundCursor' import BubbleBackground from '@/components/BackGround/BubbleBackground' +import { useToast } from '@/contexts/ToastContext' // 아이콘 SVG 컴포넌트 const MailIcon = () => ( @@ -36,8 +37,9 @@ export default function LoginPage() { const login = useAuthStore(selectLogin); const isAuthenticated = useAuthStore(selectIsLogin); const router = useRouter(); + const toast = useToast(); const [form, setForm] = useState({ email: '', password: '' }); - const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { if (isAuthenticated) { @@ -51,12 +53,15 @@ export default function LoginPage() { const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setError(''); + setIsLoading(true); try { await login(form); + toast.success('로그인되었습니다'); router.push('/'); } catch (err: any) { - setError(err.message || '로그인 중 오류가 발생했습니다.'); + toast.error(err.message || '로그인 중 오류가 발생했습니다'); + } finally { + setIsLoading(false); } }; @@ -76,10 +81,6 @@ export default function LoginPage() {
- {error && ( -

{error}

- )} -
@@ -118,9 +119,10 @@ export default function LoginPage() {
diff --git a/src/app/settings/[userId]/page.tsx b/src/app/settings/[userId]/page.tsx index a3e0ef0..439bdf0 100644 --- a/src/app/settings/[userId]/page.tsx +++ b/src/app/settings/[userId]/page.tsx @@ -14,11 +14,13 @@ import ImageUploader from '@/components/Common/ImageUploader' import { Button } from '@/components/Common/Button' import Image from 'next/image' import { useRouter } from 'next/navigation' +import { useToast } from '@/contexts/ToastContext' export default function SettingsPage() { const userId = useAuthStore(selectUserId); const logout = useAuthStore(selectLogout); const router = useRouter() + const toast = useToast() // 프로필 상태 const [profile, setProfile] = useState(null) @@ -41,9 +43,12 @@ export default function SettingsPage() { setProfileImageUrl(p.profileImageUrl ?? '') setErrorProfile(null) }) - .catch(e => setErrorProfile(e.message)) + .catch(e => { + setErrorProfile(e.message) + toast.error('프로필을 불러오는데 실패했습니다') + }) .finally(() => setLoadingProfile(false)) - }, [userId]) + }, [userId, toast]) // 프로필 저장 const saveProfile = async () => { @@ -52,8 +57,10 @@ export default function SettingsPage() { const updated = await updateUserProfile({ nickname, profileImageUrl }) setProfile(updated) setErrorProfile(null) + toast.success('프로필이 저장되었습니다') } catch (e: any) { setErrorProfile(e.message) + toast.error(e.message || '프로필 저장에 실패했습니다') } } @@ -61,10 +68,12 @@ export default function SettingsPage() { const handleWithdraw = async () => { try { await deleteUserAccount() + toast.success('계정이 삭제되었습니다') await logout() router.replace('/') } catch (e: any) { setErrorProfile(e.message) + toast.error(e.message || '계정 삭제에 실패했습니다') } } diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index f6c4343..dbd9a4d 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/Common/Button' import ImageUploader from '@/components/Common/ImageUploader' import BubbleBackground from '@/components/BackGround/BubbleBackground' import BubbleBackgroundCursor from '@/components/BackGround/BubbleBackgroundCursor' +import { useToast } from '@/contexts/ToastContext' // --- 아이콘 SVG 컴포넌트 --- const MailIcon = () => ( @@ -46,8 +47,9 @@ const BubblogLogoIcon = () => ( export default function SignupPage() { const router = useRouter() + const toast = useToast() const [form, setForm] = useState({ email: '', password: '', nickname: '', profileImageUrl: '' }) - const [error, setError] = useState('') + const [isLoading, setIsLoading] = useState(false) const onChange = (e: React.ChangeEvent) => { setForm({ ...form, [e.target.name]: e.target.value }) @@ -55,12 +57,15 @@ export default function SignupPage() { const onSubmit = async (e: React.FormEvent) => { e.preventDefault() - setError('') + setIsLoading(true) try { await signup(form) + toast.success('회원가입이 완료되었습니다. 로그인해주세요') router.push('/login') } catch (err: any) { - setError(err.message || '회원가입 중 오류가 발생했습니다.') + toast.error(err.message || '회원가입 중 오류가 발생했습니다') + } finally { + setIsLoading(false) } } @@ -82,10 +87,6 @@ export default function SignupPage() {
- {error && ( -

{error}

- )} - {/* 프로필 이미지 업로드 - 인라인으로 간소화 */}
@@ -170,9 +171,10 @@ export default function SignupPage() {
diff --git a/src/app/write/WritePostClient.tsx b/src/app/write/WritePostClient.tsx index cfb5e1b..8cee335 100644 --- a/src/app/write/WritePostClient.tsx +++ b/src/app/write/WritePostClient.tsx @@ -13,6 +13,7 @@ import { import ThumbnailUploader from '@/components/Post/ThumbnailUploader'; import { CategoryNode } from '@/apis/categoryApi'; import MarkdownEditor from '@/components/Post/MarkdownEditor'; +import { useToast } from '@/contexts/ToastContext'; interface Props { postId?: string; @@ -23,6 +24,7 @@ interface Props { export default function WritePostClient({ postId, initialData }: Props) { const router = useRouter(); const userId = useAuthStore(selectUserId); + const toast = useToast(); const isEdit = Boolean(postId); const [title, setTitle] = useState(initialData?.title ?? ''); @@ -58,14 +60,14 @@ export default function WritePostClient({ postId, initialData }: Props) { setPublicVisible(data.publicVisible); }) .catch(() => { - alert('게시글을 불러오는 데 실패했습니다.'); + toast.error('게시글을 불러오는 데 실패했습니다'); router.back(); }); - }, [isEdit, postId, initialData, router]); + }, [isEdit, postId, initialData, router, toast]); const handleSave = async () => { if (!title || !summary || !selectedCategory?.id) { - alert('제목, 요약, 카테고리는 필수입니다.'); + toast.warning('제목, 요약, 카테고리는 필수입니다'); return; } @@ -81,15 +83,15 @@ export default function WritePostClient({ postId, initialData }: Props) { try { if (isEdit) { await updateBlog(Number(postId), payload); - alert('수정되었습니다.'); + toast.success('게시글이 수정되었습니다'); router.push(`/post/${postId}`); } else { const created = await createBlog(payload); - alert('작성되었습니다.'); + toast.success('게시글이 작성되었습니다'); router.push(`/post/${created.id}`); } } catch (e: any) { - alert(`저장에 실패했습니다: ${e.message}`); + toast.error(e.message || '저장에 실패했습니다'); } }; diff --git a/src/components/Blog/UserProfileHeader.tsx b/src/components/Blog/UserProfileHeader.tsx index 8acf2db..fdaf489 100644 --- a/src/components/Blog/UserProfileHeader.tsx +++ b/src/components/Blog/UserProfileHeader.tsx @@ -1,7 +1,7 @@ 'use client' -import Image from 'next/image' import { UserProfile } from '@/apis/userApi' +import UserInitialAvatar from '@/components/Common/UserInitialAvatar' interface Props { profile: UserProfile @@ -11,22 +11,15 @@ interface Props { export function UserProfileHeader({ profile, isMyBlog }: Props) { return (
- {profile.profileImageUrl ? ( - {profile.nickname} - ) : ( -
- ? -
- )} +

{isMyBlog ? '내 블로그' : `${profile.nickname}님의 블로그`}

) -} \ No newline at end of file +} diff --git a/src/components/Category/CategorySelector.tsx b/src/components/Category/CategorySelector.tsx index 16a7512..f16f8fb 100644 --- a/src/components/Category/CategorySelector.tsx +++ b/src/components/Category/CategorySelector.tsx @@ -21,6 +21,7 @@ import { MinusSmallIcon, CheckIcon, ArrowPathIcon, + CheckCircleIcon, } from '@heroicons/react/24/outline' import { useAuthStore } from '@/store/AuthStore'; import { ConfirmModal } from '@/components/Common/ConfirmModal' @@ -309,8 +310,13 @@ export function CategorySelector({ : {} // 공통 컨테이너 스타일: 테두리, 배경, 패딩, rounded - const baseClass = 'flex items-center gap-2 mb-1 rounded-md px-2 py-2 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-indigo-500' - const highlightClass = hoverTargetId === node.id ? 'ring-2 ring-indigo-300' : '' + const isSelected = selectedCategory?.id === node.id + const baseClass = `flex items-center gap-2 mb-1 rounded-lg px-3 py-2.5 transition-all duration-200 ${ + isSelected + ? 'bg-blue-50 border-2 border-blue-500 shadow-sm' + : 'bg-white border-2 border-gray-200 hover:border-blue-300 hover:shadow-sm' + }` + const highlightClass = hoverTargetId === node.id ? 'ring-2 ring-blue-300' : '' const containerClass = `${baseClass} ${highlightClass}` const line: JSX.Element[] = [] @@ -391,9 +397,9 @@ export function CategorySelector({ } }} className={`flex-1 text-left text-sm py-1 outline-none ${ - selectedCategory?.id === node.id - ? 'font-semibold text-indigo-700' - : 'hover:underline text-gray-800' + isSelected + ? 'font-semibold text-blue-900' + : 'text-gray-800' }`} data-cat-id={node.id} > @@ -511,12 +517,15 @@ export function CategorySelector({
{loading && ( -
-
- 로딩 중… +
+
+
+ )} + {error && ( +
+ {error}
)} - {error &&

{error}

} {!loading && !error && (
@@ -524,18 +533,29 @@ export function CategorySelector({
{isOwner && ( @@ -618,10 +638,10 @@ export function CategorySelector({ )} {/* Footer */} -
+
diff --git a/src/components/Chat/ChatBubble.tsx b/src/components/Chat/ChatBubble.tsx index 88c39e6..c42222f 100644 --- a/src/components/Chat/ChatBubble.tsx +++ b/src/components/Chat/ChatBubble.tsx @@ -1,5 +1,6 @@ 'use client' +import { motion } from 'framer-motion' import { ThreeDotsLoader } from '@/components/Common/ThreeDotsLoader' interface Props { @@ -13,39 +14,59 @@ export function ChatBubble({ content, role, loading = false }: Props) { // 조건부 클래스 분리 const alignment = isUser ? 'justify-end' : 'justify-start' - const bgColor = isUser ? 'bg-purple-600' : 'bg-gray-200' - const textColor = isUser ? 'text-white' : 'text-gray-900' - const bubbleCorners = isUser - ? 'rounded-tl-lg rounded-tr-lg rounded-bl-lg rounded-br-none' - : 'rounded-tl-none rounded-tr-lg rounded-br-lg rounded-bl-lg' - const trianglePos = isUser - ? 'right-0 translate-x-1/2' - : 'left-0 -translate-x-1/2' + const bgColor = isUser ? 'bg-white' : 'bg-gray-50' + const borderColor = isUser ? 'border-gray-300' : 'border-gray-200' + const textColor = 'text-gray-900' return ( -
-
+ {loading && !content ? ( -
+
) : ( -

{content}

+
+

{content}

+ {/* 타임스탬프 */} +
+ + {new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })} + + {!isUser && ( + navigator.clipboard.writeText(content)} + > + 복사 + + )} +
+
)} - {/* 꼬리 삼각형 */} -
-
-
+ + ) } diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index 37a63ca..28db39e 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -1,6 +1,7 @@ 'use client' import { FormEvent } from 'react' +import { motion } from 'framer-motion' import { PaperAirplaneIcon, ArrowPathIcon } from '@heroicons/react/24/outline' interface Props { @@ -18,15 +19,22 @@ export function ChatInput({ disabled, children }: Props) { + const hasInput = input.trim().length > 0 + return ( -
-
-
+
+ onChange(e.target.value)} disabled={disabled} - placeholder="메시지를 입력하세요" + placeholder="무엇이든 물어보세요..." + aria-label="채팅 메시지 입력" className=" - flex-1 bg-transparent text-gray-700 placeholder-gray-400 - outline-none border-none py-2 px-3 - pr-12 + flex-1 bg-transparent text-gray-800 placeholder-gray-400 + outline-none border-none py-3 px-3 + text-base + disabled:opacity-50 " />
@@ -47,25 +57,33 @@ export function ChatInput({ {children}
)} - +
-
-
+ +
) } \ No newline at end of file diff --git a/src/components/Chat/ChatMessages.tsx b/src/components/Chat/ChatMessages.tsx index e588890..144c345 100644 --- a/src/components/Chat/ChatMessages.tsx +++ b/src/components/Chat/ChatMessages.tsx @@ -1,5 +1,7 @@ 'use client' +import { useEffect, useRef } from 'react' +import { AnimatePresence } from 'framer-motion' import { ChatBubble } from './ChatBubble' import { InspectorPanel } from '@/components/Chat/InspectorPanel' import { ThreeDotsLoader } from '@/components/Common/ThreeDotsLoader' @@ -38,49 +40,105 @@ export interface ChatMessage { interface Props { messages: ChatMessage[] chatEndRef: React.RefObject + loadingMoreTop?: boolean + hasMoreTop?: boolean + onLoadMoreTop?: () => void onToggleInspector?: (id: number) => void onInspectorItemClick?: (messageId: number, item: ContextItem) => void } -export function ChatMessages({ messages, chatEndRef, onToggleInspector, onInspectorItemClick }: Props) { - return ( -
- {messages.map(msg => ( -
- {msg.role === 'bot' && msg.inspector && (() => { - const ins = msg.inspector - const hasAnyData = ins.version === 'v1' - ? !!ins.v1ContextReceived - : !!(ins.v2PlanReceived || ins.v2RewritesReceived || ins.v2KeywordsReceived || ins.v2HybridResultReceived || ins.v2SearchResultReceived || ins.v2ContextReceived) +export function ChatMessages({ + messages, + chatEndRef, + loadingMoreTop, + hasMoreTop, + onLoadMoreTop, + onToggleInspector, + onInspectorItemClick, +}: Props) { + const containerRef = useRef(null) + const topSentinelRef = useRef(null) + const prevScrollHeightRef = useRef(null) + + useEffect(() => { + if (!onLoadMoreTop || !hasMoreTop) return + const sentinel = topSentinelRef.current + const container = containerRef.current + if (!sentinel || !container) return + + const observer = new IntersectionObserver( + entries => { + const entry = entries[0] + if (entry?.isIntersecting && !loadingMoreTop) { + prevScrollHeightRef.current = container.scrollHeight + onLoadMoreTop() + } + }, + { root: container, threshold: 0.1 } + ) + observer.observe(sentinel) + return () => observer.disconnect() + }, [hasMoreTop, loadingMoreTop, onLoadMoreTop]) - if (!hasAnyData) return null + useEffect(() => { + const container = containerRef.current + if (!container) return + if (!loadingMoreTop && prevScrollHeightRef.current != null) { + const delta = container.scrollHeight - prevScrollHeightRef.current + container.scrollTop += delta + prevScrollHeightRef.current = null + } + }, [loadingMoreTop, messages]) - return ( - onToggleInspector?.(msg.id)} - v1Context={ins.v1Context} - v1ContextReceived={ins.v1ContextReceived} - v2Plan={ins.v2Plan} - v2PlanReceived={ins.v2PlanReceived} - v2Rewrites={ins.v2Rewrites} - v2RewritesReceived={ins.v2RewritesReceived} - v2Keywords={ins.v2Keywords} - v2KeywordsReceived={ins.v2KeywordsReceived} - v2HybridResult={ins.v2HybridResult} - v2HybridResultReceived={ins.v2HybridResultReceived} - v2SearchResult={ins.v2SearchResult} - v2SearchResultReceived={ins.v2SearchResultReceived} - v2Context={ins.v2Context} - v2ContextReceived={ins.v2ContextReceived} - onItemClick={onInspectorItemClick ? (item) => onInspectorItemClick(msg.id, item) : undefined} - /> - ) - })()} - + return ( +
+
+ {loadingMoreTop && ( +
+
- ))} + )} + + {messages.map(msg => ( +
+ {msg.role === 'bot' && msg.inspector && (() => { + const ins = msg.inspector + const hasAnyData = ins.version === 'v1' + ? !!ins.v1ContextReceived + : !!(ins.v2PlanReceived || ins.v2RewritesReceived || ins.v2KeywordsReceived || ins.v2HybridResultReceived || ins.v2SearchResultReceived || ins.v2ContextReceived) + + if (!hasAnyData) return null + + return ( + onToggleInspector?.(msg.id)} + v1Context={ins.v1Context} + v1ContextReceived={ins.v1ContextReceived} + v2Plan={ins.v2Plan} + v2PlanReceived={ins.v2PlanReceived} + v2Rewrites={ins.v2Rewrites} + v2RewritesReceived={ins.v2RewritesReceived} + v2Keywords={ins.v2Keywords} + v2KeywordsReceived={ins.v2KeywordsReceived} + v2HybridResult={ins.v2HybridResult} + v2HybridResultReceived={ins.v2HybridResultReceived} + v2SearchResult={ins.v2SearchResult} + v2SearchResultReceived={ins.v2SearchResultReceived} + v2Context={ins.v2Context} + v2ContextReceived={ins.v2ContextReceived} + onItemClick={onInspectorItemClick ? (item) => onInspectorItemClick(msg.id, item) : undefined} + /> + ) + })()} + +
+ ))} +
) diff --git a/src/components/Chat/ChatViewButton.tsx b/src/components/Chat/ChatViewButton.tsx index 64d3beb..c3378e6 100644 --- a/src/components/Chat/ChatViewButton.tsx +++ b/src/components/Chat/ChatViewButton.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { Button } from '@/components/Common/Button' import { useAuthStore, selectIsLogin } from '@/store/AuthStore' import { useIsMobile } from '@/hooks/useIsMobile' +import { useToast } from '@/contexts/ToastContext' interface Props { userId: string @@ -15,23 +16,43 @@ interface Props { export function ChatViewButton({ userId, onClick, postId, variant = 'post' }: Props) { const isMobile = useIsMobile() const isLogin = useAuthStore(selectIsLogin) + const toast = useToast() + const buildHref = () => `/chatbot/${userId}${postId != null ? `?postId=${postId}` : ''}` + + const handleClick = () => { + if (!isLogin) { + toast.info('로그인이 필요한 서비스입니다') + } + onClick() + } + + if (postId == null) { + const href = buildHref() + return isLogin ? ( + + + + ) : ( + + ) + } if (isMobile) { - const href = `/chatbot/${userId}${postId != null ? `?postId=${postId}` : ''}` + const href = buildHref() return ( isLogin ? ( ) : ( - + ) ) } return ( - ) diff --git a/src/components/Chat/ChatWindow.tsx b/src/components/Chat/ChatWindow.tsx index ab09ad4..a9dff91 100644 --- a/src/components/Chat/ChatWindow.tsx +++ b/src/components/Chat/ChatWindow.tsx @@ -315,6 +315,7 @@ export function ChatWindow({ userId, postId, postTitle }: Props) { isOpen={isPersonaOpen} onSelect={p => setSelectedPersona(p)} onClose={() => setIsPersonaOpen(false)} + selectedPersona={selectedPersona} />
) diff --git a/src/components/Chat/DeleteSessionDialog.tsx b/src/components/Chat/DeleteSessionDialog.tsx new file mode 100644 index 0000000..4cc472b --- /dev/null +++ b/src/components/Chat/DeleteSessionDialog.tsx @@ -0,0 +1,109 @@ +'use client' + +import { motion, AnimatePresence } from 'framer-motion' +import { AlertTriangle, X } from 'lucide-react' + +interface DeleteSessionDialogProps { + isOpen: boolean + sessionTitle: string + onClose: () => void + onConfirm: () => void + loading?: boolean +} + +export function DeleteSessionDialog({ + isOpen, + sessionTitle, + onClose, + onConfirm, + loading = false, +}: DeleteSessionDialogProps) { + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Dialog */} +
+ e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+

세션 삭제

+
+ + + +
+ + {/* Body */} +
+

+ "{sessionTitle}" 세션을 정말 삭제하시겠습니까? +

+

+ 이 작업은 되돌릴 수 없으며, 모든 대화 내용이 영구적으로 삭제됩니다. +

+ + {/* Actions */} +
+ + 취소 + + + {loading ? ( + +
+ 삭제 중... + + ) : ( + '삭제하기' + )} + +
+
+ +
+ + )} + + ) +} diff --git a/src/components/Chat/InspectorPanel.tsx b/src/components/Chat/InspectorPanel.tsx index 4034326..ed05fbf 100644 --- a/src/components/Chat/InspectorPanel.tsx +++ b/src/components/Chat/InspectorPanel.tsx @@ -2,6 +2,8 @@ import Link from 'next/link' import { useState, useMemo } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { ChevronDown } from 'lucide-react' import type { ContextItem, SearchPlan } from '@/apis/aiApi' import { ThreeDotsLoader } from '@/components/Common/ThreeDotsLoader' @@ -55,26 +57,47 @@ export function InspectorPanel({ const [searchOpen, setSearchOpen] = useState(false) const [contextOpen, setContextOpen] = useState(false) const Section = ({ title, children }: { title: string; children: React.ReactNode }) => ( -
-
{title}
+
+
+ {title} +
{children}
) const renderList = (items: ContextItem[]) => ( -
    - {items.map((it) => ( -
  • +
      + {items.map((it, i) => ( +
    • + {onItemClick ? ( ) : ( - + {it.post_title} )} @@ -131,17 +154,43 @@ export function InspectorPanel({ return (
      - + - {visible && ( -
      + + {visible && ( + {version === 'v1' ? (
      {v1ContextReceived ? ( @@ -154,154 +203,288 @@ export function InspectorPanel({
      ) : ( <> -
      -
      -
      - {v2PlanReceived ? (v2Summary || '계획 요약 없음') : } + {v2Plan && ( +
      +
      +
      + {v2PlanReceived ? (v2Summary || '계획 요약 없음') : } +
      + setPlanOpen((v) => !v)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + transition={{ duration: 0.15 }} + > + + + + {planOpen ? '세부 접기' : '세부 보기'} + +
      +
      + {timeFilterView}
      - -
      -
      - {timeFilterView} -
      - {planOpen && ( - v2PlanReceived ? ( - v2Plan ? ( -
      -                        {JSON.stringify(v2Plan, null, 2)}
      -                      
      - ) : ( -
      계획 없음
      - ) - ) : ( -
      - ) - )} -
      -
      -
      -
      - {v2RewritesReceived ? ( - v2Rewrites && v2Rewrites.length > 0 ? `재작성 ${v2Rewrites.length}개` : '재작성 없음' - ) : ( - + + {planOpen && ( + + {v2PlanReceived ? ( + v2Plan ? ( +
      +                              {JSON.stringify(v2Plan, null, 2)}
      +                            
      + ) : ( +
      계획 없음
      + ) + ) : ( +
      + )} +
      )} +
      +
      + )} + {v2Rewrites && v2Rewrites.length > 0 && ( +
      +
      +
      + 재작성 {v2Rewrites.length}개 +
      + setRewritesOpen(v => !v)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + transition={{ duration: 0.15 }} + > + + + + {rewritesOpen ? '세부 접기' : '세부 보기'} +
      - -
      - {rewritesOpen && v2RewritesReceived && v2Rewrites && v2Rewrites.length > 0 && ( -
        - {v2Rewrites.map((s, i) =>
      • {s}
      • )} -
      - )} - -
      -
      -
      - {v2KeywordsReceived ? ( - v2Keywords && v2Keywords.length > 0 ? `키워드 ${v2Keywords.length}개` : '키워드 없음' - ) : ( - + + {rewritesOpen && ( + + {v2Rewrites.map((s, i) =>
    • {s}
    • )} +
      )} +
      +
      + )} + {v2Keywords && v2Keywords.length > 0 && ( +
      +
      +
      + 키워드 {v2Keywords.length}개 +
      + setKeywordsOpen(v => !v)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + transition={{ duration: 0.15 }} + > + + + + {keywordsOpen ? '세부 접기' : '세부 보기'} +
      - -
      - {keywordsOpen && v2KeywordsReceived && v2Keywords && v2Keywords.length > 0 && ( -
        - {v2Keywords.map((s, i) =>
      • {s}
      • )} -
      - )} - -
      -
      -
      - {v2HybridResultReceived ? ( - v2HybridResult && v2HybridResult.length > 0 ? `후보 ${v2HybridResult.length}건` : '하이브리드 결과 없음' - ) : ( - + + {keywordsOpen && ( + + {v2Keywords.map((s, i) =>
    • {s}
    • )} +
      )} +
      +
      + )} + {v2HybridResult && v2HybridResult.length > 0 && ( +
      +
      +
      + 후보 {v2HybridResult.length}건 +
      + setHybridOpen(v => !v)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + transition={{ duration: 0.15 }} + > + + + + {hybridOpen ? '세부 접기' : '세부 보기'} +
      - -
- {hybridOpen && v2HybridResultReceived && v2HybridResult && v2HybridResult.length > 0 && ( -
{renderList(v2HybridResult)}
- )} - -
-
-
- {v2SearchResultReceived ? ( - v2SearchResult && v2SearchResult.length > 0 ? `결과 ${v2SearchResult.length}건` : '검색 결과 없음' - ) : ( - + + {hybridOpen && ( + + {renderList(v2HybridResult)} + )} + +
+ )} + {v2SearchResult && v2SearchResult.length > 0 && ( +
+
+
+ 결과 {v2SearchResult.length}건 +
+ setSearchOpen(v => !v)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + transition={{ duration: 0.15 }} + > + + + + {searchOpen ? '세부 접기' : '세부 보기'} +
- -
- {searchOpen && v2SearchResultReceived && v2SearchResult && v2SearchResult.length > 0 && ( -
{renderList(v2SearchResult)}
- )} - -
-
-
- {v2ContextReceived ? ( - v2Context && v2Context.length > 0 ? `컨텍스트 ${v2Context.length}건` : '컨텍스트 없음' - ) : ( - + + {searchOpen && ( + + {renderList(v2SearchResult)} + )} + +
+ )} + {v2Context && v2Context.length > 0 && ( +
+
+
+ 컨텍스트 {v2Context.length}건 +
+ setContextOpen(v => !v)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + transition={{ duration: 0.15 }} + > + + + + {contextOpen ? '세부 접기' : '세부 보기'} +
- -
- {contextOpen && v2ContextReceived && v2Context && v2Context.length > 0 && ( -
{renderList(v2Context)}
- )} - + + {contextOpen && ( + + {renderList(v2Context)} + + )} + + + )} )} -
- )} + + )} +
) } diff --git a/src/components/Chat/ProfileHeader.tsx b/src/components/Chat/ProfileHeader.tsx index d352b44..063d5fe 100644 --- a/src/components/Chat/ProfileHeader.tsx +++ b/src/components/Chat/ProfileHeader.tsx @@ -1,5 +1,6 @@ 'use client' +import { motion } from 'framer-motion' import { UserProfile } from '@/apis/userApi' interface Props { @@ -8,10 +9,20 @@ interface Props { export function ProfileHeader({ profile }: Props) { return ( -
-

+ + {profile.nickname}의 챗봇 -

-
+ + ) } \ No newline at end of file diff --git a/src/components/Chat/RenameSessionModal.tsx b/src/components/Chat/RenameSessionModal.tsx new file mode 100644 index 0000000..d578f4a --- /dev/null +++ b/src/components/Chat/RenameSessionModal.tsx @@ -0,0 +1,126 @@ +'use client' + +import { useState, useEffect, FormEvent } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X } from 'lucide-react' + +interface RenameSessionModalProps { + isOpen: boolean + currentTitle: string + onClose: () => void + onConfirm: (newTitle: string) => void + loading?: boolean +} + +export function RenameSessionModal({ + isOpen, + currentTitle, + onClose, + onConfirm, + loading = false, +}: RenameSessionModalProps) { + const [title, setTitle] = useState(currentTitle) + + useEffect(() => { + if (isOpen) { + setTitle(currentTitle) + } + }, [isOpen, currentTitle]) + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + if (title.trim() && title !== currentTitle) { + onConfirm(title.trim()) + } + } + + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Modal */} +
+ e.stopPropagation()} + > + {/* Header */} +
+

세션 이름 변경

+ + + +
+ + {/* Body */} +
+ + setTitle(e.target.value)} + placeholder="세션 이름을 입력하세요" + className="w-full rounded-lg border-2 border-gray-200 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none transition-colors" + autoFocus + disabled={loading} + /> + + {/* Actions */} +
+ + 취소 + + + {loading ? ( + +
+ 변경 중... + + ) : ( + '변경하기' + )} + +
+ + +
+ + )} + + ) +} diff --git a/src/components/Chat/SessionContextMenu.tsx b/src/components/Chat/SessionContextMenu.tsx new file mode 100644 index 0000000..4142ac6 --- /dev/null +++ b/src/components/Chat/SessionContextMenu.tsx @@ -0,0 +1,99 @@ +'use client' + +import { motion, AnimatePresence } from 'framer-motion' +import { Edit3, Trash2 } from 'lucide-react' +import { useEffect, useRef } from 'react' + +interface SessionContextMenuProps { + isOpen: boolean + onClose: () => void + onRename: () => void + onDelete: () => void + position?: { x: number; y: number } +} + +export function SessionContextMenu({ + isOpen, + onClose, + onRename, + onDelete, + position, +}: SessionContextMenuProps) { + const menuRef = useRef(null) + + // 외부 클릭 감지 + useEffect(() => { + if (!isOpen) return + + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose() + } + } + + // ESC 키로 닫기 + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleEscape) + } + }, [isOpen, onClose]) + + return ( + + {isOpen && ( + e.stopPropagation()} + initial={{ opacity: 0, scale: 0.95, y: -10 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.95, y: -10 }} + transition={{ duration: 0.15 }} + > +
+ { + onRename() + onClose() + }} + className="w-full flex items-center gap-3 px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-100 transition-colors" + whileHover={{ x: 4 }} + transition={{ duration: 0.15 }} + > + + 이름 변경 + + +
+ + { + onDelete() + onClose() + }} + className="w-full flex items-center gap-3 px-4 py-2.5 text-left text-sm text-red-600 hover:bg-red-50 transition-colors" + whileHover={{ x: 4 }} + transition={{ duration: 0.15 }} + > + + 삭제 + +
+ + )} + + ) +} diff --git a/src/components/Chat/SessionListPanel.tsx b/src/components/Chat/SessionListPanel.tsx new file mode 100644 index 0000000..01f76c6 --- /dev/null +++ b/src/components/Chat/SessionListPanel.tsx @@ -0,0 +1,320 @@ +import { useState } from 'react' +import { motion } from 'framer-motion' +import { X, MessageSquare, Clock, MoreVertical } from 'lucide-react' +import { ChatSession } from '@/utils/types' +import { SessionContextMenu } from './SessionContextMenu' +import { RenameSessionModal } from './RenameSessionModal' +import { DeleteSessionDialog } from './DeleteSessionDialog' + +interface SessionListPanelProps { + sessions: ChatSession[] + loading: boolean + loadingMore?: boolean + error: string | null + isOpen?: boolean + selectedSessionId: number | null + onSelect: (sessionId: number | null) => void + onLoadMore?: () => void + hasMore?: boolean + className?: string + onClose?: () => void + onRetry?: () => void + onSessionUpdate?: (sessionId: number, title: string) => Promise + onSessionDelete?: (sessionId: number) => Promise +} + +function formatTimestamp(iso: string | null) { + if (!iso) return '방금 생성됨' + try { + const date = new Date(iso) + return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` + } catch { + return iso + } +} + +function getSessionTitle(session: ChatSession) { + if (session.title && session.title.trim().length > 0) return session.title + return `세션 #${session.session_id}` +} + +export function SessionListPanel({ + sessions, + loading, + loadingMore, + error, + selectedSessionId, + onSelect, + onLoadMore, + hasMore, + className = '', + onClose, + onRetry, + onSessionUpdate, + onSessionDelete, +}: SessionListPanelProps) { + const [contextMenuOpen, setContextMenuOpen] = useState(null) + const [renameModalOpen, setRenameModalOpen] = useState(null) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(null) + const [actionLoading, setActionLoading] = useState(false) + + const openRenameModal = (sessionId: number) => { + setTimeout(() => setRenameModalOpen(sessionId), 0) + } + + const openDeleteDialog = (sessionId: number) => { + setTimeout(() => setDeleteDialogOpen(sessionId), 0) + } + + const handleRename = async (sessionId: number, newTitle: string) => { + if (!onSessionUpdate) return + + try { + setActionLoading(true) + await onSessionUpdate(sessionId, newTitle) + setRenameModalOpen(null) + } catch (error) { + console.error('Failed to rename session:', error) + alert('세션 이름 변경에 실패했습니다.') + } finally { + setActionLoading(false) + } + } + + const handleDelete = async (sessionId: number) => { + if (!onSessionDelete) return + + try { + setActionLoading(true) + await onSessionDelete(sessionId) + setDeleteDialogOpen(null) + } catch (error) { + console.error('Failed to delete session:', error) + alert('세션 삭제에 실패했습니다.') + } finally { + setActionLoading(false) + } + } + + return ( + <> + +
+
+ +

대화 세션

+
+ {onClose && ( + + + + )} +
+ +
+ onSelect(null)} + className="w-full rounded-lg border-2 border-dashed border-blue-300 bg-blue-50/50 py-3 text-sm font-semibold text-blue-600 hover:bg-blue-100 hover:border-blue-400 transition-colors" + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + + + 새 세션 시작 + + +
+ +
+ {loading && sessions.length === 0 && ( +
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {!loading && sessions.length === 0 && ( + + +

아직 저장된 세션이 없습니다

+

첫 질문을 해보세요!

+
+ )} + + {sessions.map(session => { + const isActive = selectedSessionId === session.session_id + const isContextMenuOpen = contextMenuOpen === session.session_id + return ( +
+ onSelect(session.session_id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect(session.session_id) + } + }} + role="button" + tabIndex={0} + aria-label={`세션 선택: ${getSessionTitle(session)}`} + className={`w-full rounded-xl border-2 px-4 py-3.5 text-left transition-all cursor-pointer ${ + isActive + ? 'border-blue-500 bg-gradient-to-r from-blue-50 to-blue-100 shadow-md' + : 'border-gray-100 bg-white hover:bg-gray-50 hover:border-gray-200 hover:shadow-sm' + }`} + whileHover={{ scale: 1.01, y: -2 }} + whileTap={{ scale: 0.99 }} + transition={{ duration: 0.2 }} + > +
+
+
+ +

+ {getSessionTitle(session)} +

+
+
+ + + {formatTimestamp(session.updated_at)} + + + + {session.message_count}개 + +
+
+ + {/* 세션 관리 버튼 */} +
+ { + e.stopPropagation() + setContextMenuOpen(contextMenuOpen === session.session_id ? null : session.session_id) + }} + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + > + + + + setContextMenuOpen(null)} + onRename={() => openRenameModal(session.session_id)} + onDelete={() => openDeleteDialog(session.session_id)} + /> +
+
+
+
+ ) + })} + + {error && ( +
+

{error}

+ {onRetry && ( + + )} +
+ )} +
+ + {hasMore && ( +
+ + {loadingMore ? ( + +
+ 불러오는 중… + + ) : ( + '더 보기' + )} + +
+ )} + + + {/* Modals */} + {renameModalOpen !== null && (() => { + const session = sessions.find(s => s.session_id === renameModalOpen) + if (!session) return null + return ( + setRenameModalOpen(null)} + onConfirm={(newTitle) => handleRename(session.session_id, newTitle)} + loading={actionLoading} + /> + ) + })()} + + {deleteDialogOpen !== null && (() => { + const session = sessions.find(s => s.session_id === deleteDialogOpen) + if (!session) return null + return ( + setDeleteDialogOpen(null)} + onConfirm={() => handleDelete(session.session_id)} + loading={actionLoading} + /> + ) + })()} + + ) +} diff --git a/src/components/Chat/VersionToggle.tsx b/src/components/Chat/VersionToggle.tsx index 35848af..4e930ad 100644 --- a/src/components/Chat/VersionToggle.tsx +++ b/src/components/Chat/VersionToggle.tsx @@ -1,5 +1,7 @@ 'use client' +import { motion } from 'framer-motion' + interface Props { value: 'v1' | 'v2' onChange: (v: 'v1' | 'v2') => void @@ -7,30 +9,52 @@ interface Props { } export function VersionToggle({ value, onChange, disabled }: Props) { - const base = 'px-3 py-1.5 text-sm rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2' - const active = 'bg-gray-900 text-white hover:bg-gray-800' - const inactive = 'bg-gray-100 text-gray-700 hover:bg-gray-200' - return ( -
- - +
) } diff --git a/src/components/Comment/CommentItem.tsx b/src/components/Comment/CommentItem.tsx index 4149fea..4b4a6cc 100644 --- a/src/components/Comment/CommentItem.tsx +++ b/src/components/Comment/CommentItem.tsx @@ -1,11 +1,11 @@ 'use client'; import { useState } from 'react' -import Image from 'next/image' import { Comment } from '@/utils/types' import { useProfileStore } from '@/store/ProfileStore' import { updateComment, deleteComment, toggleCommentLike, getChildComments } from '@/apis/commentApi' import CommentForm from './CommentForm' +import UserInitialAvatar from '@/components/Common/UserInitialAvatar' // Helper function for relative time function timeAgo(date: string): string { @@ -29,7 +29,7 @@ interface CommentItemProps { postId: string comment: Comment onCommentUpdate: (updatedComment: Comment) => void - onCommentDelete: (commentId: number, replyCount: number) => void + onCommentDelete: (commentId: number) => void onReplyCreated: () => void isReply?: boolean // 대댓글 여부를 나타내는 prop } @@ -48,6 +48,7 @@ export default function CommentItem({ const [likeCount, setLikeCount] = useState(comment.likeCount) const replyCount = comment.replyCount ?? 0 + const isDeleted = comment.deleted // useState의 lazy initializer를 사용하여 클라이언트에서만 localStorage에 접근 const [isLiked, setIsLiked] = useState(() => { if (typeof window === 'undefined') { @@ -79,7 +80,7 @@ export default function CommentItem({ if (window.confirm('정말로 이 댓글을 삭제하시겠습니까?')) { try { await deleteComment(comment.id); - onCommentDelete(comment.id, replyCount); + onCommentDelete(comment.id); } catch (error) { console.error('Error deleting comment:', error); } @@ -87,6 +88,9 @@ export default function CommentItem({ }; const handleLike = async () => { + if (isDeleted) { + return; + } if (!nickname) { // isLogin 대신 nickname 존재 여부로 확인 alert('로그인이 필요합니다.'); return; @@ -126,19 +130,20 @@ export default function CommentItem({ } const handleReplySuccess = (newReply: Comment) => { - setChildComments([...childComments, newReply]); + setChildComments(prev => [...prev, newReply]); setShowReplyForm(false); + setShowChildren(true); + const updatedReplyCount = (comment.replyCount ?? 0) + 1; + onCommentUpdate({ ...comment, replyCount: updatedReplyCount }); onReplyCreated(); // 부모에게 답글 생성 알림 }; return (
- {comment.writerNickname}
@@ -161,12 +166,16 @@ export default function CommentItem({
) : (
-

{comment.content}

-
- +

{comment.content}

+
+ {!isReply && ( )} diff --git a/src/components/Comment/CommentList.tsx b/src/components/Comment/CommentList.tsx index eb8ec18..1bd99c7 100644 --- a/src/components/Comment/CommentList.tsx +++ b/src/components/Comment/CommentList.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { getComments, getCommentsCount } from '@/apis/commentApi' import { Comment } from '@/utils/types' import { useAuthStore, selectIsLogin } from '@/store/AuthStore' @@ -16,39 +16,46 @@ export default function CommentList({ postId }: CommentListProps) { const [count, setCount] = useState(0); const isLogin = useAuthStore(selectIsLogin) + const refreshCommentCount = useCallback(async () => { + try { + const countResponse = await getCommentsCount(postId) + if (countResponse.success) { + setCount(countResponse.data || 0); + } + } catch (error) { + console.error('Error refreshing comment count:', error) + } + }, [postId]) + useEffect(() => { const fetchInitialData = async () => { try { - const [commentsResponse, countResponse] = await Promise.all([ - getComments(postId), - getCommentsCount(postId) - ]); + const commentsResponse = await getComments(postId) if (commentsResponse.success) { setComments(commentsResponse.data || []) } - if (countResponse.success) { - setCount(countResponse.data || 0); - } + + await refreshCommentCount() } catch (error) { console.error('Error fetching comments data:', error) } } fetchInitialData() - }, [postId]) + }, [postId, refreshCommentCount]) const handleNewComment = (newComment: Comment) => { - setComments([...comments, newComment]); - setCount(c => c + 1); + setComments(prev => [...prev, newComment]); + refreshCommentCount() } const onCommentUpdate = (updatedComment: Comment) => { setComments( - comments.map(c => (c.id === updatedComment.id ? updatedComment : c)), + prev => prev.map(c => (c.id === updatedComment.id ? updatedComment : c)), ) } - const onCommentDelete = (commentId: number, replyCount: number) => { + const onCommentDelete = (commentId: number) => { const removeComment = (list: Comment[], id: number): Comment[] => { return list.filter(comment => { if (comment.children) { @@ -58,11 +65,11 @@ export default function CommentList({ postId }: CommentListProps) { }); }; setComments(prevComments => removeComment(prevComments, commentId)); - setCount(c => c - (1 + replyCount)); + refreshCommentCount() } const onReplyCreated = () => { - setCount(c => c + 1); + refreshCommentCount() } return ( @@ -88,4 +95,4 @@ export default function CommentList({ postId }: CommentListProps) {
) -} \ No newline at end of file +} diff --git a/src/components/Common/Toast.tsx b/src/components/Common/Toast.tsx new file mode 100644 index 0000000..594537a --- /dev/null +++ b/src/components/Common/Toast.tsx @@ -0,0 +1,100 @@ +'use client' + +import { motion, AnimatePresence } from 'framer-motion' +import { CheckCircle2, XCircle, Info, AlertTriangle, X } from 'lucide-react' + +export type ToastType = 'success' | 'error' | 'info' | 'warning' + +export interface ToastProps { + id: string + message: string + type: ToastType + onClose: () => void +} + +const toastConfig = { + success: { + icon: CheckCircle2, + bgColor: 'bg-green-50 dark:bg-green-900/20', + borderColor: 'border-green-500', + iconColor: 'text-green-600 dark:text-green-400', + textColor: 'text-green-900 dark:text-green-100', + }, + error: { + icon: XCircle, + bgColor: 'bg-red-50 dark:bg-red-900/20', + borderColor: 'border-red-500', + iconColor: 'text-red-600 dark:text-red-400', + textColor: 'text-red-900 dark:text-red-100', + }, + info: { + icon: Info, + bgColor: 'bg-blue-50 dark:bg-blue-900/20', + borderColor: 'border-blue-500', + iconColor: 'text-blue-600 dark:text-blue-400', + textColor: 'text-blue-900 dark:text-blue-100', + }, + warning: { + icon: AlertTriangle, + bgColor: 'bg-yellow-50 dark:bg-yellow-900/20', + borderColor: 'border-yellow-500', + iconColor: 'text-yellow-600 dark:text-yellow-400', + textColor: 'text-yellow-900 dark:text-yellow-100', + }, +} + +export function Toast({ message, type, onClose }: ToastProps) { + const config = toastConfig[type] + const Icon = config.icon + + return ( + + +

+ {message} +

+ +
+ ) +} + +interface ToastContainerProps { + toasts: Array +} + +export function ToastContainer({ toasts }: ToastContainerProps) { + return ( +
+ + {toasts.map((toast) => ( +
+ +
+ ))} +
+
+ ) +} diff --git a/src/components/Common/UserInitialAvatar.tsx b/src/components/Common/UserInitialAvatar.tsx new file mode 100644 index 0000000..b6cea58 --- /dev/null +++ b/src/components/Common/UserInitialAvatar.tsx @@ -0,0 +1,48 @@ +'use client' + +import Image from 'next/image' + +interface UserInitialAvatarProps { + name?: string | null + imageUrl?: string | null + size?: number // px + className?: string +} + +const getInitial = (name?: string | null) => { + if (!name) return '?' + const trimmed = name.trim() + if (!trimmed) return '?' + return trimmed[0]?.toUpperCase() ?? '?' +} + +export default function UserInitialAvatar({ + name, + imageUrl, + size = 40, + className = '', +}: UserInitialAvatarProps) { + if (imageUrl) { + return ( + {name + ) + } + + const fontSize = Math.max(12, Math.floor(size / 2)) + + return ( +
+ {getInitial(name)} +
+ ) +} diff --git a/src/components/Layout/NavBar.tsx b/src/components/Layout/NavBar.tsx index 35b8fc6..21d97d4 100644 --- a/src/components/Layout/NavBar.tsx +++ b/src/components/Layout/NavBar.tsx @@ -3,7 +3,6 @@ import { useState, useEffect } from 'react' import Link from 'next/link' -import Image from 'next/image' import { useAuthStore, selectIsLogin, selectUserId, selectLogout } from '@/store/AuthStore' import SearchBar from './SearchBar' import { useRouter } from 'next/navigation' @@ -18,6 +17,7 @@ import { Cog6ToothIcon, } from '@heroicons/react/24/outline' import { getUserProfile, UserProfile } from '@/apis/userApi' +import UserInitialAvatar from '@/components/Common/UserInitialAvatar' export default function NavBar() { const isAuthenticated = useAuthStore(selectIsLogin); @@ -69,23 +69,16 @@ export default function NavBar() { className="flex items-center gap-3 rounded-full px-3 py-2 hover:bg-gray-200 transition-colors flex-shrink-0 whitespace-nowrap" > - {profile ? ( - <> - {profile.nickname} - - {profile.nickname} - - - ) : ( -
- -
+ + {profile && ( + + {profile.nickname} + )} ) -} \ No newline at end of file +} diff --git a/src/components/Persona/PersonaSelectorModal.tsx b/src/components/Persona/PersonaSelectorModal.tsx index c98107e..f5caf4b 100644 --- a/src/components/Persona/PersonaSelectorModal.tsx +++ b/src/components/Persona/PersonaSelectorModal.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { Dialog } from '@headlessui/react' -import { XMarkIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline' +import { XMarkIcon, PencilIcon, TrashIcon, CheckCircleIcon } from '@heroicons/react/24/outline' import { useAuthStore, selectUserId } from '@/store/AuthStore' import { getPersonasByUser, @@ -14,15 +14,17 @@ import { interface Props { userId: string isOpen: boolean - onSelect: (persona: Persona) => void + onSelect: (persona: Persona | null) => void onClose: () => void + selectedPersona?: Persona | null } export function PersonaSelectorModal({ userId, isOpen, onSelect, - onClose + onClose, + selectedPersona }: Props) { const authUserId = useAuthStore(selectUserId) const isOwner = authUserId === userId @@ -97,60 +99,136 @@ export function PersonaSelectorModal({
- {loading &&

로딩 중…

} - {error &&

{error}

} + {loading && ( +
+
+
+ )} + + {error && ( +
+ {error} +
+ )} {!loading && !error && ( -
    - {list.map(p => ( -
  • { - onSelect(p) - onClose() - }} - className="flex items-center space-x-2 mb-1 border border-gray-300 rounded-lg px-1 py-3 bg-white hover:bg-gray-50 cursor-pointer" - > -
    -

    {p.name}

    -

    - {p.description} -

    +
    + {/* 기본 옵션 (페르소나 없음) */} + + + {/* 페르소나 리스트 */} + {list.map(p => { + const isSelected = selectedPersona?.id === p.id + return ( +
    + + + {isOwner && ( +
    + + +
    + )}
    + ) + })} + + {list.length === 0 && ( +
    +

    등록된 페르소나가 없습니다

    {isOwner && ( -
    - - -
    +

    설정 페이지에서 페르소나를 추가할 수 있습니다

    )} -
  • - ))} -
+
+ )} +
)} -
+
diff --git a/src/components/PostDetail/Action.tsx b/src/components/PostDetail/Action.tsx index def0fdd..c1914bc 100644 --- a/src/components/PostDetail/Action.tsx +++ b/src/components/PostDetail/Action.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation' import Link from 'next/link' import { deleteBlog } from '@/apis/blogApi' import { Pencil, Trash2 } from 'lucide-react' +import { useToast } from '@/contexts/ToastContext' interface Props { postId: number @@ -11,14 +12,16 @@ interface Props { export function PostDetailActions({ postId }: Props) { const router = useRouter() + const toast = useToast() const onDelete = async () => { if (!confirm('정말 삭제하시겠습니까?')) return try { await deleteBlog(postId) + toast.success('게시글이 삭제되었습니다') router.push('/') - } catch { - alert('삭제에 실패했습니다.') + } catch (err: any) { + toast.error(err.message || '삭제에 실패했습니다') } } diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx new file mode 100644 index 0000000..563d201 --- /dev/null +++ b/src/contexts/ToastContext.tsx @@ -0,0 +1,87 @@ +'use client' + +import { createContext, useContext, useState, useCallback, ReactNode } from 'react' +import { ToastContainer } from '@/components/Common/Toast' +import type { ToastType, ToastProps } from '@/components/Common/Toast' + +interface Toast { + id: string + message: string + type: ToastType + duration?: number +} + +interface ToastContextValue { + showToast: (message: string, type?: ToastType, duration?: number) => void + success: (message: string, duration?: number) => void + error: (message: string, duration?: number) => void + info: (message: string, duration?: number) => void + warning: (message: string, duration?: number) => void +} + +const ToastContext = createContext(undefined) + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)) + }, []) + + const showToast = useCallback( + (message: string, type: ToastType = 'info', duration: number = 4000) => { + const id = Math.random().toString(36).substring(2, 9) + const newToast: Toast = { id, message, type, duration } + + setToasts((prev) => [...prev, newToast]) + + // Auto-dismiss after duration + if (duration > 0) { + setTimeout(() => { + removeToast(id) + }, duration) + } + }, + [removeToast] + ) + + const success = useCallback( + (message: string, duration?: number) => showToast(message, 'success', duration), + [showToast] + ) + + const error = useCallback( + (message: string, duration?: number) => showToast(message, 'error', duration), + [showToast] + ) + + const info = useCallback( + (message: string, duration?: number) => showToast(message, 'info', duration), + [showToast] + ) + + const warning = useCallback( + (message: string, duration?: number) => showToast(message, 'warning', duration), + [showToast] + ) + + const toastProps: ToastProps[] = toasts.map((toast) => ({ + ...toast, + onClose: () => removeToast(toast.id), + })) + + return ( + + {children} + + + ) +} + +export function useToast() { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within ToastProvider') + } + return context +} diff --git a/src/hooks/useChatSessions.ts b/src/hooks/useChatSessions.ts new file mode 100644 index 0000000..4f0b0c6 --- /dev/null +++ b/src/hooks/useChatSessions.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect } from 'react' +import { useChatSessionStore } from '@/store/ChatSessionStore' + +interface UseChatSessionsOptions { + autoFetch?: boolean + limit?: number +} + +export function useChatSessions(ownerUserId: string | null | undefined, options: UseChatSessionsOptions = {}) { + const sessions = useChatSessionStore(state => state.sessions) + const sessionsPaging = useChatSessionStore(state => state.sessionsPaging) + const sessionsLoading = useChatSessionStore(state => state.sessionsLoading) + const sessionsLoadingMore = useChatSessionStore(state => state.sessionsLoadingMore) + const sessionsError = useChatSessionStore(state => state.sessionsError) + const currentSessionId = useChatSessionStore(state => state.currentSessionId) + const isSessionPanelOpen = useChatSessionStore(state => state.isSessionPanelOpen) + + const fetchInitialSessions = useChatSessionStore(state => state.fetchInitialSessions) + const fetchMoreSessions = useChatSessionStore(state => state.fetchMoreSessions) + const selectSession = useChatSessionStore(state => state.selectSession) + const setPanelOpen = useChatSessionStore(state => state.setPanelOpen) + const resetSessions = useChatSessionStore(state => state.resetSessions) + + const autoFetch = options.autoFetch ?? true + + useEffect(() => { + if (!autoFetch) return + if (!ownerUserId) { + resetSessions() + return + } + fetchInitialSessions({ ownerUserId, limit: options.limit }) + }, [ownerUserId, options.limit, autoFetch, fetchInitialSessions, resetSessions]) + + const hasMoreSessions = Boolean(sessionsPaging?.has_more) + + const loadMore = useCallback(() => { + if (!hasMoreSessions) return + fetchMoreSessions() + }, [hasMoreSessions, fetchMoreSessions]) + + return { + sessions, + sessionsPaging, + sessionsLoading, + sessionsLoadingMore, + sessionsError, + currentSessionId, + isSessionPanelOpen, + selectSession, + setPanelOpen, + fetchInitialSessions, + resetSessions, + loadMore, + } +} diff --git a/src/hooks/useSessionMessages.ts b/src/hooks/useSessionMessages.ts new file mode 100644 index 0000000..76b9339 --- /dev/null +++ b/src/hooks/useSessionMessages.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from 'react' +import { useChatSessionStore } from '@/store/ChatSessionStore' +import type { ChatSessionMessage } from '@/utils/types' + +interface UseSessionMessagesOptions { + autoFetch?: boolean + limit?: number +} + +const EMPTY_MESSAGES: ChatSessionMessage[] = [] + +export function useSessionMessages(sessionId: number | null | undefined, options: UseSessionMessagesOptions = {}) { + const storeMessages = useChatSessionStore(state => + sessionId != null ? state.messagesBySession[sessionId] : undefined + ) + const paging = useChatSessionStore(state => + sessionId != null ? state.messagesPagingBySession[sessionId] ?? null : null + ) + const isLoading = useChatSessionStore(state => + sessionId != null ? state.messagesLoadingBySession[sessionId] ?? false : false + ) + const error = useChatSessionStore(state => + sessionId != null ? state.messagesErrorBySession[sessionId] ?? null : null + ) + const messages = storeMessages ?? EMPTY_MESSAGES + + const fetchMessages = useChatSessionStore(state => state.fetchMessages) + const [isFetchingOlder, setIsFetchingOlder] = useState(false) + useEffect(() => { + setIsFetchingOlder(false) + }, [sessionId]) + + const autoFetch = options.autoFetch ?? true + + useEffect(() => { + if (!autoFetch || sessionId == null) return + fetchMessages(sessionId, { limit: options.limit, direction: 'backward', mode: 'replace' }) + }, [sessionId, autoFetch, options.limit, fetchMessages]) + + const loadOlder = useCallback(() => { + if (sessionId == null || !paging?.has_more || !paging.next_cursor || isFetchingOlder) return + setIsFetchingOlder(true) + fetchMessages(sessionId, { + cursor: paging.next_cursor, + direction: paging.direction ?? 'backward', + mode: 'prepend', + }).finally(() => setIsFetchingOlder(false)) + }, [sessionId, paging, fetchMessages, isFetchingOlder]) + + const reload = useCallback(() => { + if (sessionId == null) return Promise.resolve() + return fetchMessages(sessionId, { direction: 'backward', mode: 'replace' }) + }, [sessionId, fetchMessages]) + + return { + messages, + paging, + isLoading, + error, + isFetchingOlder, + loadOlder, + reload, + } +} diff --git a/src/store/ChatSessionStore.ts b/src/store/ChatSessionStore.ts new file mode 100644 index 0000000..eb76c8c --- /dev/null +++ b/src/store/ChatSessionStore.ts @@ -0,0 +1,317 @@ +import { create } from 'zustand' +import { + ChatSession, + ChatSessionListResponse, + ChatSessionMessage, + ChatSessionMessagesResponse, + ChatSessionPaging, +} from '@/utils/types' +import { + getChatSessions, + getChatSessionMessages, + GetChatSessionsParams, + GetChatSessionMessagesParams, +} from '@/apis/aiSessionApi' +import { AskSessionEventPayload } from '@/apis/aiApi' + +type MessageMergeMode = 'replace' | 'append' | 'prepend' + +interface MessagesPagingState { + direction: 'forward' | 'backward' + has_more: boolean + next_cursor: string | null +} + +interface FetchMessagesOptions extends GetChatSessionMessagesParams { + mode?: MessageMergeMode + replace?: boolean +} + +const DEFAULT_SESSION_ERROR = '세션을 불러오지 못했습니다.' +const DEFAULT_MESSAGE_ERROR = '메시지를 불러오지 못했습니다.' + +function mergeMessages( + current: ChatSessionMessage[], + incoming: ChatSessionMessage[], + mode: MessageMergeMode +) { + let combined: ChatSessionMessage[] + if (mode === 'replace') { + combined = incoming + } else if (mode === 'append') { + combined = [...current, ...incoming] + } else { + combined = [...incoming, ...current] + } + + const map = new Map() + combined.forEach(msg => map.set(msg.id, msg)) + + return Array.from(map.values()).sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ) +} + +export interface ChatSessionStoreState { + sessions: ChatSession[] + sessionsPaging: ChatSessionPaging | null + sessionsQuery: GetChatSessionsParams | null + sessionsLoading: boolean + sessionsLoadingMore: boolean + sessionsError: string | null + + currentSessionId: number | null + isSessionPanelOpen: boolean + + messagesBySession: Record + messagesPagingBySession: Record + messagesLoadingBySession: Record + messagesErrorBySession: Record + + isStreaming: boolean + streamingSessionId: number | null + streamingBuffer: string + + fetchInitialSessions: (params: GetChatSessionsParams) => Promise + fetchMoreSessions: () => Promise + selectSession: (sessionId: number | null) => void + resetSessions: () => void + + fetchMessages: (sessionId: number, options?: FetchMessagesOptions) => Promise + prependMessages: (sessionId: number, messages: ChatSessionMessage[]) => void + appendMessages: (sessionId: number, messages: ChatSessionMessage[]) => void + + upsertSessionFromStream: (payload: AskSessionEventPayload, partial?: Partial) => void + updateSessionMeta: (sessionId: number, partial: Partial) => void + setPanelOpen: (open: boolean) => void + + beginStreaming: (sessionId: number, initial?: string) => void + appendStreamingChunk: (sessionId: number, chunk: string) => void + endStreaming: (sessionId?: number) => void + + /** dev-only helper */ + __devHydrateSessions?: (response: ChatSessionListResponse) => void +} + +export const useChatSessionStore = create((set, get) => ({ + sessions: [], + sessionsPaging: null, + sessionsQuery: null, + sessionsLoading: false, + sessionsLoadingMore: false, + sessionsError: null, + + currentSessionId: null, + isSessionPanelOpen: false, + + messagesBySession: {}, + messagesPagingBySession: {}, + messagesLoadingBySession: {}, + messagesErrorBySession: {}, + + isStreaming: false, + streamingSessionId: null, + streamingBuffer: '', + + async fetchInitialSessions(params) { + set({ sessionsLoading: true, sessionsError: null }) + try { + const res = await getChatSessions(params) + set({ + sessions: res.sessions, + sessionsPaging: res.paging, + sessionsQuery: params ?? null, + sessionsLoading: false, + sessionsError: null, + }) + } catch (error) { + set({ + sessionsLoading: false, + sessionsError: error instanceof Error ? error.message : DEFAULT_SESSION_ERROR, + }) + } + }, + + async fetchMoreSessions() { + const { sessionsPaging, sessionsLoadingMore, sessionsQuery } = get() + if (sessionsLoadingMore || !sessionsPaging?.has_more || !sessionsPaging.cursor) return + set({ sessionsLoadingMore: true }) + try { + const res = await getChatSessions({ + ...sessionsQuery, + cursor: sessionsPaging.cursor, + }) + set(state => ({ + sessions: [...state.sessions, ...res.sessions], + sessionsPaging: res.paging, + sessionsLoadingMore: false, + })) + } catch (error) { + set({ + sessionsLoadingMore: false, + sessionsError: error instanceof Error ? error.message : DEFAULT_SESSION_ERROR, + }) + } + }, + + selectSession(sessionId) { + set({ currentSessionId: sessionId }) + }, + + resetSessions() { + set({ + sessions: [], + sessionsPaging: null, + sessionsQuery: null, + sessionsError: null, + currentSessionId: null, + messagesBySession: {}, + messagesPagingBySession: {}, + messagesLoadingBySession: {}, + messagesErrorBySession: {}, + }) + }, + + async fetchMessages(sessionId, options = {}) { + const direction = options.direction ?? 'backward' + const mode = + options.mode ?? + (options.cursor ? (direction === 'backward' ? 'prepend' : 'append') : 'replace') + set(state => ({ + messagesLoadingBySession: { ...state.messagesLoadingBySession, [sessionId]: true }, + messagesErrorBySession: { ...state.messagesErrorBySession, [sessionId]: null }, + })) + try { + const res: ChatSessionMessagesResponse = await getChatSessionMessages(sessionId, { + direction, + cursor: options.cursor, + limit: options.limit, + }) + set(state => { + const current = state.messagesBySession[sessionId] ?? [] + const nextMessages = mergeMessages(current, res.messages, mode) + return { + messagesBySession: { ...state.messagesBySession, [sessionId]: nextMessages }, + messagesPagingBySession: { ...state.messagesPagingBySession, [sessionId]: res.paging }, + messagesLoadingBySession: { ...state.messagesLoadingBySession, [sessionId]: false }, + } + }) + } catch (error) { + set(state => ({ + messagesLoadingBySession: { ...state.messagesLoadingBySession, [sessionId]: false }, + messagesErrorBySession: { + ...state.messagesErrorBySession, + [sessionId]: error instanceof Error ? error.message : DEFAULT_MESSAGE_ERROR, + }, + })) + } + }, + + prependMessages(sessionId, messages) { + set(state => { + const list = state.messagesBySession[sessionId] ?? [] + return { + messagesBySession: { + ...state.messagesBySession, + [sessionId]: mergeMessages(list, messages, 'prepend'), + }, + } + }) + }, + + appendMessages(sessionId, messages) { + set(state => { + const list = state.messagesBySession[sessionId] ?? [] + return { + messagesBySession: { + ...state.messagesBySession, + [sessionId]: mergeMessages(list, messages, 'append'), + }, + } + }) + }, + + upsertSessionFromStream(payload, partial) { + set(state => { + const now = new Date().toISOString() + const idx = state.sessions.findIndex(s => s.session_id === payload.session_id) + if (idx >= 0) { + const next = [...state.sessions] + next[idx] = { + ...next[idx], + ...partial, + last_question_at: partial?.last_question_at ?? next[idx].last_question_at ?? now, + updated_at: partial?.updated_at ?? now, + } + return { sessions: next } + } + const stub: ChatSession = { + session_id: payload.session_id, + owner_user_id: payload.owner_user_id, + requester_user_id: payload.requester_user_id, + title: partial?.title ?? null, + metadata: partial?.metadata ?? null, + last_question_at: partial?.last_question_at ?? now, + created_at: partial?.created_at ?? now, + updated_at: partial?.updated_at ?? now, + message_count: partial?.message_count ?? 0, + } + + return { + sessions: [stub, ...state.sessions], + sessionsPaging: state.sessionsPaging + ? { ...state.sessionsPaging, has_more: true } + : state.sessionsPaging, + } + }) + }, + + updateSessionMeta(sessionId, partial) { + set(state => { + const idx = state.sessions.findIndex(s => s.session_id === sessionId) + if (idx === -1) return state + const next = [...state.sessions] + next[idx] = { ...next[idx], ...partial } + return { sessions: next } + }) + }, + + setPanelOpen(open) { + set({ isSessionPanelOpen: open }) + }, + + beginStreaming(sessionId, initial = '') { + set({ + isStreaming: true, + streamingSessionId: sessionId, + streamingBuffer: initial, + }) + }, + + appendStreamingChunk(sessionId, chunk) { + set(state => { + if (state.streamingSessionId !== sessionId) return state + return { streamingBuffer: state.streamingBuffer + chunk } + }) + }, + + endStreaming(sessionId) { + const state = get() + if (sessionId != null && state.streamingSessionId !== sessionId) return + set({ + isStreaming: false, + streamingSessionId: null, + streamingBuffer: '', + }) + }, + + __devHydrateSessions: + process.env.NODE_ENV !== 'production' + ? res => { + set({ + sessions: res.sessions, + sessionsPaging: res.paging, + }) + } + : undefined, +})) diff --git a/src/utils/types.ts b/src/utils/types.ts index d12360b..6b74edb 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -40,4 +40,53 @@ export interface Comment { parentId: number | null replyCount: number | null children?: Comment[] // 대댓글을 저장하기 위한 선택적 필드 -} \ No newline at end of file +} + +// ASK 세션 및 메시지 타입 +export interface ChatSession { + session_id: number + owner_user_id: string + requester_user_id: string + title: string | null + metadata?: Record | null + last_question_at: string | null + created_at: string + updated_at: string + message_count: number +} + +export interface ChatSessionPaging { + cursor: string | null + has_more: boolean + direction?: 'forward' | 'backward' + next_cursor?: string | null +} + +export interface ChatSessionListResponse { + sessions: ChatSession[] + paging: ChatSessionPaging +} + +export type ChatSessionMessageRole = 'user' | 'assistant' + +export interface ChatSessionMessage { + id: number + session_id: number + role: ChatSessionMessageRole + content: string + search_plan?: Record | null + retrieval_meta?: Record | null + created_at: string +} + +export interface ChatSessionMessagesResponse { + session_id: number + owner_user_id: string + requester_user_id: string + messages: ChatSessionMessage[] + paging: { + direction: 'forward' | 'backward' + has_more: boolean + next_cursor: string | null + } +}