diff --git a/docs/event-stream.md b/docs/event-stream.md index 0ab34bb..f2293b8 100644 --- a/docs/event-stream.md +++ b/docs/event-stream.md @@ -125,7 +125,7 @@ WS(RT1)는 같은 내용을 JSON 한 줄 프레임으로: `{ "id": , "e ``` - `messageId`: Core 가 답변 직후 선INSERT 한 INTERVIEWER **placeholder** 메시지 id(content=`"(생성 중)"`, status=`CREATED`). 생성 시 Core 가 `SESSION_MESSAGE(placeholderId)` 를 1회 발행하므로 프론트 목록에 placeholder 버블이 먼저 뜬다. -- `seq`: 0부터 단조 증가. `text`: 이번 델타에서 **추가된 조각**(누적 아님). 프론트는 placeholder 버블에 append. +- `seq`: 0부터 단조 증가. `text`: 이번 델타에서 **추가된 조각**(누적 아님). 발행은 순서대로지만 브로커→WS fan-out 경로가 프레임 순서·전달을 보장하지는 않으므로, 프론트는 도착 순서가 아닌 `seq` 로 재조립한다(연속 prefix 만 표시, 중복은 멱등 — `streamingBuffer.ts`). 유실분은 종료 reconcile 이 정본으로 덮는다. - 흐름: 답변 → (Core) placeholder INSERT + `SESSION_MESSAGE` → (AI) `astream` 으로 `SESSION_MESSAGE_DELTA` 연속 발행 → (AI) `callback.questions(FOLLOWUP, followupMessageId)` → (Core) placeholder UPDATE(content/COMPLETED) + `SESSION_MESSAGE`(종료) → 프론트 `GET …/messages` 재조회로 정본 reconcile. - `answer_intent=DONT_KNOW` 면 AI 가 델타를 **발행하지 않고**, Core 가 placeholder 삭제 후 다음 일반질문으로 진행한다. 이때 프론트는 placeholder 를 "생각 중"으로만 표시하다 일반질문으로 교체. diff --git a/frontend/src/features/interview/lib/useTypewriter.test.ts b/frontend/src/features/interview/lib/useTypewriter.test.ts deleted file mode 100644 index a40c4e1..0000000 --- a/frontend/src/features/interview/lib/useTypewriter.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { renderHook, act } from '@testing-library/react' -import { useTypewriter } from './useTypewriter' - -describe('useTypewriter', () => { - beforeEach(() => vi.useFakeTimers()) - afterEach(() => vi.useRealTimers()) - - it('enabled=false 면 전체 텍스트 즉시', () => { - const { result } = renderHook(() => useTypewriter('안녕하세요', false)) - expect(result.current).toBe('안녕하세요') - }) - - it('enabled 면 점진적으로 드러나 결국 전체에 도달', () => { - const { result } = renderHook(() => useTypewriter('가나다라마바사아자차', true)) - // 처음엔 일부만(또는 비어있음) - const initial = result.current.length - act(() => { vi.advanceTimersByTime(35 * 12) }) - expect(result.current).toBe('가나다라마바사아자차') - expect(initial).toBeLessThan('가나다라마바사아자차'.length) - }) -}) diff --git a/frontend/src/features/interview/lib/useTypewriter.ts b/frontend/src/features/interview/lib/useTypewriter.ts deleted file mode 100644 index 792b272..0000000 --- a/frontend/src/features/interview/lib/useTypewriter.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect, useRef, useState } from 'react' - -// 스트리밍 텍스트를 일정 속도로 점진 표시(타자기). enabled=false면 전체 즉시 반환. -// fullText 가 늘어나면 따라가고, 남은 양에 비례해 step 을 키워 길고 빠른 스트림은 지연 없이 따라잡는다. -export function useTypewriter(fullText: string, enabled: boolean): string { - const [count, setCount] = useState(enabled ? 0 : fullText.length) - const targetRef = useRef(fullText) - // 렌더 중 ref 대입은 react-hooks/refs 위반이라 커밋 후 이펙트에서 갱신한다. - // 아래 타자기 이펙트보다 먼저 선언해, 같은 커밋에서 ref 가 항상 먼저 최신화되게 한다. - useEffect(() => { - targetRef.current = fullText - }, [fullText]) - - useEffect(() => { - if (!enabled) { - setCount(targetRef.current.length) - return - } - const id = setInterval(() => { - setCount((c) => { - const target = targetRef.current.length - if (c >= target) return c - const remaining = target - c - const step = Math.max(2, Math.ceil(remaining / 6)) - return Math.min(target, c + step) - }) - }, 35) - return () => clearInterval(id) - }, [enabled]) - - const shown = enabled ? Math.min(count, fullText.length) : fullText.length - return fullText.slice(0, shown) -} diff --git a/frontend/src/features/interview/model/streamingBuffer.test.ts b/frontend/src/features/interview/model/streamingBuffer.test.ts index 38e9232..e353661 100644 --- a/frontend/src/features/interview/model/streamingBuffer.test.ts +++ b/frontend/src/features/interview/model/streamingBuffer.test.ts @@ -1,13 +1,65 @@ import { describe, it, expect } from 'vitest' -import { applyDelta, isStreamingMessage, FOLLOWUP_GENERATING_TEXT } from './streamingBuffer' +import type { DeltaBuffer } from './streamingBuffer' +import { + applyDelta, + bufferedText, + isStreamingMessage, + FOLLOWUP_GENERATING_TEXT, +} from './streamingBuffer' describe('streamingBuffer', () => { - it('applyDelta 는 messageId별로 누적한다', () => { - let buf: Record = {} + it('정상 순서 프레임을 누적한다', () => { + let buf: DeltaBuffer = {} buf = applyDelta(buf, { messageId: 5, seq: 0, text: '안녕' }) buf = applyDelta(buf, { messageId: 5, seq: 1, text: '하세요' }) - expect(buf[5]).toBe('안녕하세요') + expect(bufferedText(buf, 5)).toBe('안녕하세요') }) + + it('순서가 뒤바뀐 프레임은 seq 로 재조립한다', () => { + let buf: DeltaBuffer = {} + buf = applyDelta(buf, { messageId: 7, seq: 0, text: '동시성 ' }) + buf = applyDelta(buf, { messageId: 7, seq: 2, text: '어떻게 해결했나요?' }) + buf = applyDelta(buf, { messageId: 7, seq: 1, text: '문제를 ' }) + expect(bufferedText(buf, 7)).toBe('동시성 문제를 어떻게 해결했나요?') + }) + + it('중복 재전달 프레임은 멱등 — 한 번만 반영된다', () => { + let buf: DeltaBuffer = {} + buf = applyDelta(buf, { messageId: 5, seq: 0, text: '안녕' }) + buf = applyDelta(buf, { messageId: 5, seq: 0, text: '안녕' }) + expect(bufferedText(buf, 5)).toBe('안녕') + }) + + it('갭 뒤 프레임은 갭이 채워질 때까지 표시하지 않는다', () => { + let buf: DeltaBuffer = {} + buf = applyDelta(buf, { messageId: 5, seq: 0, text: 'A' }) + buf = applyDelta(buf, { messageId: 5, seq: 2, text: 'C' }) + expect(bufferedText(buf, 5)).toBe('A') + }) + + it('갭이 채워지면 보류된 프레임까지 이어서 표시한다', () => { + let buf: DeltaBuffer = {} + buf = applyDelta(buf, { messageId: 5, seq: 0, text: 'A' }) + buf = applyDelta(buf, { messageId: 5, seq: 2, text: 'C' }) + buf = applyDelta(buf, { messageId: 5, seq: 1, text: 'B' }) + expect(bufferedText(buf, 5)).toBe('ABC') + }) + + it('seq 0 이 도착하기 전에는 undefined — placeholder 표시를 유지한다', () => { + let buf: DeltaBuffer = {} + buf = applyDelta(buf, { messageId: 5, seq: 1, text: 'B' }) + expect(bufferedText(buf, 5)).toBeUndefined() + }) + + it('메시지별로 격리 누적한다', () => { + let buf: DeltaBuffer = {} + buf = applyDelta(buf, { messageId: 1, seq: 0, text: '하나' }) + buf = applyDelta(buf, { messageId: 2, seq: 0, text: '둘' }) + expect(bufferedText(buf, 1)).toBe('하나') + expect(bufferedText(buf, 2)).toBe('둘') + expect(bufferedText(buf, 3)).toBeUndefined() + }) + it('isStreamingMessage 는 sentinel content + 버퍼 유무로 판별', () => { expect(isStreamingMessage({ content: FOLLOWUP_GENERATING_TEXT }, undefined)).toBe(true) expect(isStreamingMessage({ content: FOLLOWUP_GENERATING_TEXT }, '안녕')).toBe(true) diff --git a/frontend/src/features/interview/model/streamingBuffer.ts b/frontend/src/features/interview/model/streamingBuffer.ts index 6bc9906..124fd63 100644 --- a/frontend/src/features/interview/model/streamingBuffer.ts +++ b/frontend/src/features/interview/model/streamingBuffer.ts @@ -3,13 +3,29 @@ export { FOLLOWUP_GENERATING_TEXT } export type DeltaPayload = { messageId: number; seq: number; text: string } -// messageId -> 누적 텍스트. seq 는 순서 보조(현재는 단순 append, gap 은 종료 reconcile 로 자기치유). -export function applyDelta( - buffer: Record, - delta: DeltaPayload, -): Record { - const prev = buffer[delta.messageId] ?? '' - return { ...buffer, [delta.messageId]: prev + delta.text } +// messageId -> (seq -> 조각). 발행측(AI followup consumer)은 seq 를 0부터 1씩 증가시키지만, +// WS 경로는 프레임 순서를 보장하지 않으므로 도착 순서가 아닌 seq 로 재조립한다. +export type DeltaBuffer = Record> + +// 중복 seq(재전달)는 멱등 — 첫 조각을 유지한다. +export function applyDelta(buffer: DeltaBuffer, delta: DeltaPayload): DeltaBuffer { + const parts = buffer[delta.messageId] + if (parts?.[delta.seq] !== undefined) return buffer + return { ...buffer, [delta.messageId]: { ...parts, [delta.seq]: delta.text } } +} + +// seq 0부터 연속으로 이어진 구간만 join 한다. 갭 뒤에 도착한 조각은 갭이 채워질 때까지 +// 표시하지 않는다 — 프레임이 정말 유실되면 종료 시 콜백 정본(reconcile)이 덮는다. +// seq 0 이 아직 없으면 undefined — 호출부가 placeholder 표시를 유지하게 한다. +export function bufferedText( + buffer: DeltaBuffer, + messageId: number | undefined, +): string | undefined { + const parts = buffer[messageId ?? -1] + if (!parts || parts[0] === undefined) return undefined + let text = '' + for (let seq = 0; parts[seq] !== undefined; seq++) text += parts[seq] + return text } export function isStreamingMessage( diff --git a/frontend/src/features/interview/model/useLiveInterview.ts b/frontend/src/features/interview/model/useLiveInterview.ts index 34ed1f0..031ea53 100644 --- a/frontend/src/features/interview/model/useLiveInterview.ts +++ b/frontend/src/features/interview/model/useLiveInterview.ts @@ -13,8 +13,8 @@ import { useInterviewSocket } from './useInterviewSocket' import { interviewEventAction } from './interviewEvent' import { pendingAnswers, toOptimisticMessage } from './optimistic' import type { OptimisticAnswer } from './optimistic' -import { applyDelta, isStreamingMessage, FOLLOWUP_GENERATING_TEXT } from './streamingBuffer' -import type { DeltaPayload } from './streamingBuffer' +import { applyDelta, bufferedText, isStreamingMessage, FOLLOWUP_GENERATING_TEXT } from './streamingBuffer' +import type { DeltaBuffer, DeltaPayload } from './streamingBuffer' import type { DeliveryMode } from './useDeliveryMode' export type ThreadItem = Message & { key: string; streaming?: boolean } @@ -38,7 +38,7 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode = const [optimistic, setOptimistic] = useState([]) // 전송 실패로 롤백된 답변 본문 — 컴포저가 입력창을 복원하는 데 사용(nonce 로 매 실패마다 트리거). const [restoreDraft, setRestoreDraft] = useState<{ content: string; nonce: number } | null>(null) - const [deltaBuffer, setDeltaBuffer] = useState>({}) + const [deltaBuffer, setDeltaBuffer] = useState({}) // 라이브 세그먼트 오디오가 지금 재생 중인 메시지(아바타·질문 카드의 '말하는 중' 표시용). const [speakingAudio, setSpeakingAudio] = useState<{ msgId: number | null; playing: boolean }>({ msgId: null, @@ -81,9 +81,9 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode = ) const pending = pendingAnswers(optimistic, serverMessages) - // 스트리밍 중인 메시지는 deltaBuffer의 누적 텍스트로 content를 오버라이드한다. + // 스트리밍 중인 메시지는 deltaBuffer의 seq 재조립 텍스트로 content를 오버라이드한다. const mergedMessages = serverMessages.map((m) => { - const buffered = deltaBuffer[m.id ?? -1] + const buffered = bufferedText(deltaBuffer, m.id) if (buffered !== undefined && isStreamingMessage(m, buffered)) { return { ...m, content: buffered, streaming: true as const } } @@ -194,7 +194,8 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode = navigate(`/sessions/${sessionId}/feedback`) } else if (action.kind === 'append-delta') { const payload = (frame.data as { data?: DeltaPayload } | undefined)?.data - if (payload && typeof payload.messageId === 'number') { + // seq 재조립은 비정상 seq 조각을 조용히 영구 드롭하므로 seq 타입까지 방어 검증한다. + if (payload && typeof payload.messageId === 'number' && typeof payload.seq === 'number') { setDeltaBuffer((b) => applyDelta(b, payload)) } } else if (action.kind === 'queue-audio') { diff --git a/frontend/src/features/interview/ui/live/ConversationThread.tsx b/frontend/src/features/interview/ui/live/ConversationThread.tsx index 1efb29a..e6655bd 100644 --- a/frontend/src/features/interview/ui/live/ConversationThread.tsx +++ b/frontend/src/features/interview/ui/live/ConversationThread.tsx @@ -30,7 +30,7 @@ export function ConversationThread({
{items.map((item) => isQuestion(item) ? ( - + ) : ( ), diff --git a/frontend/src/features/interview/ui/live/QuestionBubble.tsx b/frontend/src/features/interview/ui/live/QuestionBubble.tsx index 2ce7a99..9d373d8 100644 --- a/frontend/src/features/interview/ui/live/QuestionBubble.tsx +++ b/frontend/src/features/interview/ui/live/QuestionBubble.tsx @@ -3,7 +3,6 @@ import { StatusBadge } from '@/shared/ui/StatusBadge' import { categoryLabel } from '../../lib/categoryLabel' import { useTtsPlayback } from '../../lib/media/useTtsPlayback' import { FOLLOWUP_GENERATING_TEXT } from '../../model/streamingBuffer' -import { useTypewriter } from '../../lib/useTypewriter' import { useSetQuestionBookmark } from '../../model/useBookmarks' function StarIcon({ filled }: { filled: boolean }) { @@ -34,12 +33,10 @@ function PlayIcon({ playing }: { playing: boolean }) { export function QuestionBubble({ message, autoPlay = false, - streaming = false, bookmarkable = false, }: { message: Message autoPlay?: boolean - streaming?: boolean /** 오답노트 표시 버튼 노출. 라이브 중엔 끄고(집중 방해) 종료 세션 기록에서만 켠다. */ bookmarkable?: boolean }) { @@ -48,7 +45,8 @@ export function QuestionBubble({ const hasMeta = Boolean(label || message.targetEvidence) const ttsReady = message.ttsStatus === 'SUCCEEDED' const isSentinel = message.content === FOLLOWUP_GENERATING_TEXT - const shownText = useTypewriter(message.content ?? '', !!streaming && !isSentinel) + // 델타가 이미 토큰 단위로 도착하므로 재애니메이션 없이 그대로 표시한다(실스트림 = 애니메이션). + const shownText = message.content ?? '' const { playing, toggle, audioNode } = useTtsPlayback({ sessionId: message.sessionId, diff --git a/frontend/src/features/interview/ui/live/StageQuestion.tsx b/frontend/src/features/interview/ui/live/StageQuestion.tsx index aedb49a..0c22f28 100644 --- a/frontend/src/features/interview/ui/live/StageQuestion.tsx +++ b/frontend/src/features/interview/ui/live/StageQuestion.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import type { Message } from '@/domain/session' import { categoryLabel } from '../../lib/categoryLabel' import { useTtsPlayback } from '../../lib/media/useTtsPlayback' -import { useTypewriter } from '../../lib/useTypewriter' import type { DeliveryMode } from '../../model/useDeliveryMode' function PlayIcon({ playing }: { playing: boolean }) { @@ -49,7 +48,8 @@ export function StageQuestion({ const ttsPending = ttsStatus === 'PENDING' const ttsFailed = ttsStatus === 'FAILED' const voiceMode = mode === 'voice' - const shownText = useTypewriter(question.content ?? '', !!streaming) + // 델타가 이미 토큰 단위로 도착하므로 재애니메이션 없이 그대로 표시한다(실스트림 = 애니메이션). + const shownText = question.content ?? '' // 음성 모드여도 TTS 가 실패했으면 텍스트로 폴백한다. const listenOnly = voiceMode && !ttsFailed @@ -67,7 +67,7 @@ export function StageQuestion({ return ( // 질문은 WebSocket 으로 비동기 도착한다. live region 이 없으면 스크린리더 사용자는 // 새 질문이 왔다는 사실 자체를 모른 채 기다리게 된다 — 면접의 핵심 흐름이라 - // 알림이 필수다. 타이핑 효과로 글자가 이어 붙는 동안 계속 읽지 않도록, + // 알림이 필수다. 델타 스트리밍으로 글자가 이어 붙는 동안 계속 읽지 않도록, // 스트리밍이 끝난 뒤에만 한 번 알리게 aria-busy 로 묶는다.