159 lines
8.2 KiB
Markdown
159 lines
8.2 KiB
Markdown
# Snap 백엔드 연결 설계
|
|
|
|
> snap 목업을 걷어내고 base-backend(000)의 세션 기반 chat 계약에 real 연결.
|
|
> 이전 spec(`2026-07-16-snap-mate-react-client-design.md`)의 "2차로 미룸 — real 백엔드 부착"을 실행.
|
|
> 최종 목적지: .NET 윈도우 애플리케이션의 웹뷰(WebView2).
|
|
|
|
작성일: 2026-07-18
|
|
|
|
---
|
|
|
|
## 1. 목표 & 범위
|
|
|
|
snap 은 이미 **교체 seam 이 다 잡혀 있다.** 새 구조를 만드는 게 아니라, 기존 seam 에
|
|
real 구현을 끼우고 목업을 제거하는 작업이다. UI/store/컴포넌트는 **건드리지 않는다**
|
|
(반환 타입만 유지하면 화면 코드는 안 바뀐다).
|
|
|
|
### 이번 범위 (세로 슬라이스)
|
|
|
|
| # | 대상 | 붙일 곳 |
|
|
|---|---|---|
|
|
| 1 | `useSessionList` | `GET /chat/sessions` (`apiList`) |
|
|
| 2 | `useSessionMessages` | `GET /chat/sessions/{id}/messages` (`apiGet`) |
|
|
| 3 | `useCreateSession` | `POST /chat/sessions` (`apiPost`) |
|
|
| 4 | `snap.stream` (`USE_MOCK=false`) | `POST /chat/stream` SSE (token/done/error/**title**) |
|
|
| 5 | 목업 3파일 제거 | `mock/sessions.ts` · `mock/conversations.ts` · `mock/stream.ts` + import 정리 |
|
|
| 6 | Bearer 토큰 provider seam | `client.ts` 요청 인터셉터 + `sse.ts` 헤더 |
|
|
|
|
### 2차로 미룸 (범위 밖)
|
|
|
|
- rename(PATCH) / delete / search 엔드포인트
|
|
- `subagent_start` / `subagent_done` 진행 UI
|
|
- `usage` 토큰 카운터
|
|
- Bearer 모드 refresh(호스트 위임) 실제 배선 — .NET 호스트 생기면
|
|
|
|
---
|
|
|
|
## 2. 핵심 결정 (확정됨)
|
|
|
|
| 항목 | 결정 |
|
|
|---|---|
|
|
| 방침 | 기존 seam 에 real 구현 주입 + 목업 제거. **UI/store/컴포넌트 0 변경** |
|
|
| 범위 | **세로 슬라이스** — list/get/create/stream 까지. rename/delete/search 는 2차 |
|
|
| envelope | 비스트림 응답은 `Envelope[T]` → `apiGet`/`apiPost`/`apiList` 헬퍼가 `.data` 자동 언랩 |
|
|
| title 이벤트 | **살린다** — 첫 메시지 후 백엔드가 쏘는 `title{title}` 로 세션 제목 자동 갱신 |
|
|
| subagent/usage | **무시** — `streamLLM` 이 unknown 이벤트를 흘려서 안 살려도 안 깨짐. 2차 |
|
|
| 409 | `isGenerating` 이면 send 막고, stream open 이 409(`CHAT_GENERATION_IN_PROGRESS`)면 toast |
|
|
| 인증 | **쿠키 now + Bearer 주입 seam.** 토큰 소스를 `getAccessToken()` 한 곳으로. 있으면 `Authorization: Bearer`, 없으면(=오늘) 쿠키 폴백 |
|
|
| refresh | 쿠키 모드는 지금대로. Bearer 모드 refresh 는 .NET 호스트 책임 → 지금은 TODO 만 |
|
|
| dev 환경 | Vite proxy(`/api`→`localhost:8001`)로 same-origin → 쿠키/CORS 이슈 없음. 건드릴 것 없음 |
|
|
|
|
---
|
|
|
|
## 3. 백엔드 계약 (base-backend `modules/chat`)
|
|
|
|
베이스 URL: `http://localhost:8001` · 프리픽스 `/api/v1` · dev 는 Vite proxy 로 `/api/v1/...`.
|
|
|
|
### 3.1 비스트림 엔드포인트 (Envelope 래핑, camelCase)
|
|
|
|
| Method + Path | 용도 | 반환 `data` |
|
|
|---|---|---|
|
|
| `POST /chat/sessions` | 세션 생성 | `ChatSessionResponse` |
|
|
| `GET /chat/sessions` | 세션 목록(페이지네이션) | `ChatSessionResponse[]` |
|
|
| `GET /chat/sessions/{id}/messages` | 세션 + 메시지 | `ChatSessionDetailResponse` |
|
|
|
|
- `ChatSessionResponse`: `id, title, titleLlm, isGenerating, createdAt, updatedAt`
|
|
- `ChatMessageResponse`: `sessionId, role, content, createdAt`
|
|
- `ChatSessionDetailResponse`: 세션 필드 + `messages: ChatMessageResponse[]`
|
|
|
|
프론트 `contract/types.ts` 의 `SnapSession`/`SnapMessage`/`SnapSessionDetail` 와 1:1
|
|
(단, `SnapSession` 의 UI-only 필드 `tag?`/`snippet?`/`tokens?` 는 백엔드에 없음 — 옵셔널이라
|
|
없어도 안 깨지고, 화면에서 안 쓰면 그만).
|
|
|
|
### 3.2 스트림 엔드포인트
|
|
|
|
`POST /chat/stream` — `sse_starlette.EventSourceResponse` (POST + JSON body 라 브라우저
|
|
네이티브 `EventSource` 불가 → `@microsoft/fetch-event-source` 사용, 이미 `sse.ts` 가 그럼).
|
|
|
|
요청 바디 `ChatStreamRequest`: `{ sessionId, content, forcedSkill? }`.
|
|
|
|
이벤트 (named `event:` + JSON `data:`):
|
|
|
|
| event | data | 이번 처리 |
|
|
|---|---|---|
|
|
| `token` | `{delta}` | `appendChunk(delta)` |
|
|
| `title` | `{title}` | **세션 제목 갱신** (react-query 캐시 + 필요 시 store) |
|
|
| `done` | `{}` / `{traceId?}` | 스트림 종료 |
|
|
| `error` | `{message, code, detail?}` | toast + 마지막 assistant 메시지 frozen |
|
|
| `result` | `{items}` | (이번엔 pass — snap 은 안 씀) |
|
|
| `subagent_start`/`subagent_done` | `{name,...}` | 무시 (2차) |
|
|
| `usage` | `{used,limit,ratio,elapsed_ms}` | 무시 (2차) |
|
|
|
|
순서: (optional `title`/`result`/`subagent_*`) → 다수 `token` → `usage` → `done`,
|
|
또는 `error` 종결. SSE 는 200 으로 열려서 중간에 상태코드 못 바꿈 → LLM 설정/생성 실패는
|
|
HTTP 에러가 아니라 `error` **이벤트**로 옴. code: `LLM_NOT_CONFIGURED` /
|
|
`LLM_RATE_LIMITED` / `LLM_ERROR` / `UNKNOWN_SKILL`.
|
|
|
|
---
|
|
|
|
## 4. 유닛별 변경
|
|
|
|
### 4.1 `features/snap/api/snap.api.ts` — 목업 바디 → real 호출
|
|
세 함수의 `queryFn`/`mutationFn` 바디만 교체. 시그니처·반환타입·queryKey 유지.
|
|
- `useSessionList` → `apiList<SnapSession>("/chat/sessions")` 의 `items`
|
|
- `useSessionMessages(id)` → `apiGet<SnapSessionDetail>(\`/chat/sessions/${id}/messages\`)`
|
|
- `useCreateSession` → `apiPost<SnapSession>("/chat/sessions")`
|
|
- `MOCK_SESSIONS`/`MOCK_CONVERSATIONS` import 제거.
|
|
|
|
### 4.2 `features/snap/api/snap.stream.ts` — 플래그 + title 핸들러
|
|
- `USE_MOCK = false`.
|
|
- `SnapStreamHandlers` 에 `onTitle?(title: string)` 추가.
|
|
- real 경로: `streamLLM({ path: "/chat/stream", body: req, signal, handlers })` —
|
|
`streamLLM` 에도 `onTitle` 배선 필요(4.4).
|
|
- `mockStream` import 제거.
|
|
|
|
### 4.3 `features/snap/hooks/useSnapChat.ts` — title 반영 + 409
|
|
- `onTitle` → react-query 세션 캐시(`["snap","sessions"]`, `["snap","session",id]`)의
|
|
`title` 패치(`queryClient.setQueryData`).
|
|
- send 진입 가드: 스토어 `isStreaming` 이거나 세션 `isGenerating` 이면 막음.
|
|
- stream open 이 409 면 `onError` 로 흘러오니 code 보고 "이미 생성 중" toast.
|
|
|
|
### 4.4 `lib/streaming/streamLLM.ts` — title 이벤트 파싱
|
|
- 이벤트 스위치에 `title` → `handlers.onTitle?.(payload.title)` 추가.
|
|
- `LLMStreamHandlers` 타입에 `onTitle?` 추가. 기존 소비자(다른 chat)는 옵셔널이라 영향 없음.
|
|
|
|
### 4.5 인증 seam — `getAccessToken()` provider
|
|
- 새 파일 `lib/auth/tokenProvider.ts`: `getAccessToken(): string | null` (기본 `null`) +
|
|
`setAccessToken(t: string | null)`. .NET 호스트가 나중에 `window.chrome.webview` 로
|
|
주입하면 여기만 채움.
|
|
- `lib/api/client.ts`: **요청 인터셉터** 추가 — 토큰 있으면 `Authorization: Bearer` 세팅
|
|
(없으면 아무것도 안 함 → 쿠키 그대로). 응답 인터셉터는 유지.
|
|
- `lib/streaming/sse.ts`: `open()` 헤더에 토큰 있으면 `Authorization` 추가.
|
|
- refresh: 쿠키 모드 유지. Bearer 모드는 `// TODO(host): .NET 호스트 refresh 위임` 주석만.
|
|
|
|
### 4.6 목업 제거
|
|
`mock/sessions.ts`·`mock/conversations.ts`·`mock/stream.ts` 삭제. 남은 import 없는지 확인
|
|
(4.1/4.2 에서 이미 끊음).
|
|
|
|
---
|
|
|
|
## 5. 검증
|
|
|
|
1. **백엔드 로컬 8001 기동 + LLM(NVIDIA NIM) 설정** → 프론트 로그인 → `/snap` 세션 목록이
|
|
실제 DB 세션으로 뜸.
|
|
2. **새 대화** → 첫 메시지 전송 → 실제 LLM 토큰이 타이핑되듯 스트리밍 → 완료 후
|
|
사이드바 제목이 LLM 이 지은 제목으로 자동 변경(title 이벤트).
|
|
3. **기존 세션 재진입** → 과거 메시지 렌더 정상.
|
|
4. **생성 중 재전송** → 막힘 + toast.
|
|
5. 기존 프론트 테스트(`snap.api`/`snap.stream`/`sse`/`streamLLM`) 통과 + real 경로 유닛
|
|
테스트 추가(핸들러 매핑·title·409·Bearer 헤더 주입).
|
|
|
|
---
|
|
|
|
## 6. 리스크 / 열린 항목
|
|
|
|
- **title 갱신 위치**: react-query 캐시만 패치할지, snap 스토어에도 반영할지 —
|
|
사이드바가 `useSessionList` 캐시를 보면 캐시 패치로 충분. 구현 때 확정.
|
|
- **Bearer refresh**: .NET 호스트 부재로 이번엔 TODO. 호스트 붙을 때 별도 phase.
|
|
- **usage/subagent**: 지금 무시. 진행 UI 는 후속 spec 에서.
|