feat: 실시간 롤링 요약 + 자동 최종 회의록 생성

녹음 중 30초 간격으로 Gemini가 중간 회의록을 갱신하고, 녹음 중지 시
최종 회의록을 자동 생성한다. Gemini 실패 시 단순 변환 마크다운으로
폴백하여 사용자는 항상 결과물을 받는다.

- 실시간 요약 폴링 (25단어 시작, 40단어 증분 게이트)
- 429 쿼터 초과 60초 쿨다운 + 일반 실패 지수 백오프
- Web Speech API 네트워크 단절 자동 재연결 (최대 8회)
- Hydration mismatch (React #418) 수정 — isSupported를 mount 후 결정
- gemini-2.5-flash-lite 모델로 통일 (무료 등급 15 RPM / 1000 RPD)
- Gemini 호출 URL → x-goog-api-key 헤더 인증으로 전환
- 좌우 분할 뷰 (실시간 텍스트 / 실시간 회의록) + 자동 스크롤
- 진행률 바, 쿨다운 카운터, interim 텍스트 커서
- Prisma 7 호환 위해 schema.prisma의 datasource url 제거
  (prisma.config.ts로 이동됨)
- Docker Compose에 test 프로필 서비스 추가 (vitest 38 tests)
This commit is contained in:
2026-04-21 17:24:25 +09:00
parent 77af89aed7
commit 3b9692e857
11 changed files with 824 additions and 79 deletions
+207 -15
View File
@@ -1,23 +1,62 @@
'use client'
import { useEffect, useRef } from 'react'
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
import { useLiveSummary } from '@/hooks/useLiveSummary'
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
interface LiveRecorderProps {
onTranscriptReady: (transcript: string) => void
liveSummaryEnabled: boolean
}
export function LiveRecorder({ onTranscriptReady }: LiveRecorderProps) {
export function LiveRecorder({
onTranscriptReady,
liveSummaryEnabled,
}: LiveRecorderProps) {
const {
isListening,
isSupported,
chunks,
interimText,
error: recognitionError,
startListening,
stopListening,
resetChunks,
} = useSpeechRecognition()
const {
summary,
isSummarizing,
error: summaryError,
lastUpdatedAt,
wordCount,
wordsUntilNext,
minWords,
cooldownUntil,
} = useLiveSummary(chunks, {
enabled: liveSummaryEnabled && isListening,
})
const transcriptEndRef = useRef<HTMLDivElement>(null)
const summaryEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
transcriptEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [chunks.length, interimText])
useEffect(() => {
summaryEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [summary])
if (isSupported === null) {
return (
<div className="rounded-2xl border border-neutral-200 bg-neutral-50 p-6 text-center text-sm text-neutral-400">
...
</div>
)
}
if (!isSupported) {
return (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-center">
@@ -37,9 +76,43 @@ export function LiveRecorder({ onTranscriptReady }: LiveRecorderProps) {
}
}
const lastUpdatedLabel = lastUpdatedAt
? new Date(lastUpdatedAt).toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
: null
const hasTranscript = chunks.length > 0 || interimText.length > 0
const hasSummary = summary.length > 0
const showPanels = isListening || hasTranscript || hasSummary
const cooldownSeconds =
cooldownUntil && cooldownUntil > Date.now()
? Math.ceil((cooldownUntil - Date.now()) / 1000)
: 0
const summaryProgress = (() => {
if (!liveSummaryEnabled) return null
if (!isListening && !hasSummary) return null
if (cooldownSeconds > 0) return `⏸️ API 쿼터 초과 — ${cooldownSeconds}초 후 재시도`
if (isSummarizing) return '요약 생성 중...'
if (!hasSummary) {
if (wordCount < minWords) {
return `첫 요약까지 ${wordsUntilNext}단어 (${wordCount}/${minWords})`
}
return '곧 첫 요약이 생성됩니다...'
}
if (wordsUntilNext > 0 && isListening) {
return `다음 갱신까지 ${wordsUntilNext}단어`
}
return isListening ? '다음 갱신 대기 중...' : '녹음 종료됨'
})()
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
{isListening ? (
<button
onClick={handleStop}
@@ -69,21 +142,140 @@ export function LiveRecorder({ onTranscriptReady }: LiveRecorderProps) {
</button>
)}
{isListening && (
<div className="flex items-center gap-2 rounded-full bg-red-50 px-4 py-1.5 text-xs text-red-600">
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
· {wordCount} · {chunks.length}
</div>
)}
</div>
{(chunks.length > 0 || interimText) && (
<div className="rounded-2xl border border-neutral-200 bg-neutral-50 p-6 max-h-64 overflow-y-auto">
<h3 className="text-sm font-semibold text-neutral-500 mb-3 uppercase tracking-wider">
</h3>
<div className="space-y-1 text-sm font-mono text-neutral-700">
{chunks.map((chunk, i) => (
<p key={i}>{chunk.text}</p>
))}
{interimText && (
<p className="text-neutral-400 italic">{interimText}...</p>
)}
</div>
{recognitionError && (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<strong> :</strong> {recognitionError}
</div>
)}
{isListening && !hasTranscript && (
<div className="rounded-2xl border border-dashed border-blue-300 bg-blue-50/50 p-6 text-center">
<p className="text-sm text-blue-700">
🎙 . ...
</p>
<p className="mt-1 text-xs text-blue-500">
.
</p>
</div>
)}
{showPanels && (
<div
className={`grid gap-4 ${
liveSummaryEnabled ? 'lg:grid-cols-2' : 'grid-cols-1'
}`}
>
<section className="flex flex-col rounded-2xl border border-neutral-200 bg-neutral-50 overflow-hidden">
<header className="flex items-center justify-between border-b border-neutral-200 bg-white/60 px-5 py-3">
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${
isListening ? 'bg-red-500 animate-pulse' : 'bg-neutral-300'
}`}
/>
<h3 className="text-xs font-semibold text-neutral-600 uppercase tracking-wider">
</h3>
</div>
<span className="text-xs text-neutral-500">
{chunks.length} · {wordCount}
</span>
</header>
<div className="h-80 overflow-y-auto px-5 py-4">
{hasTranscript ? (
<div className="space-y-1 text-sm font-mono leading-relaxed text-neutral-700">
{chunks.map((chunk, i) => (
<p key={i}>{chunk.text}</p>
))}
{interimText && (
<p className="text-neutral-400 italic">
{interimText}
<span className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-neutral-400 align-middle" />
</p>
)}
<div ref={transcriptEndRef} />
</div>
) : (
<p className="text-sm text-neutral-400">
{isListening
? '발화를 기다리는 중...'
: '녹음을 시작하면 여기에 텍스트가 나타납니다.'}
</p>
)}
</div>
</section>
{liveSummaryEnabled && (
<section className="flex flex-col rounded-2xl border border-purple-200 bg-purple-50/50 overflow-hidden">
<header className="flex items-center justify-between border-b border-purple-200 bg-white/60 px-5 py-3">
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${
isSummarizing
? 'bg-purple-500 animate-pulse'
: hasSummary
? 'bg-purple-400'
: 'bg-purple-200'
}`}
/>
<h3 className="text-xs font-semibold text-purple-700 uppercase tracking-wider">
</h3>
</div>
<div className="flex items-center gap-2 text-xs text-purple-500">
{summaryProgress && <span>{summaryProgress}</span>}
{lastUpdatedLabel && <span>· {lastUpdatedLabel}</span>}
</div>
</header>
<div className="h-80 overflow-y-auto px-5 py-4">
{summaryError ? (
<p className="text-sm text-red-600">{summaryError}</p>
) : hasSummary ? (
<>
<pre className="whitespace-pre-wrap text-sm text-neutral-700 font-mono leading-relaxed">
{summary}
</pre>
<div ref={summaryEndRef} />
</>
) : (
<div className="flex flex-col gap-3 text-sm text-purple-600">
<p>
30 Gemini가
.
</p>
<div>
<div className="mb-1.5 flex justify-between text-xs text-purple-500">
<span></span>
<span>
{Math.min(wordCount, minWords)}/{minWords}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-purple-100">
<div
className="h-full bg-purple-400 transition-all duration-500"
style={{
width: `${Math.min(
100,
(wordCount / minWords) * 100,
)}%`,
}}
/>
</div>
</div>
</div>
)}
</div>
</section>
)}
</div>
)}
</div>