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:
nad4 committed 2026-04-21 17:24:25 +09:00
1 parent 77af89aed7
commit 3b9692e857
11 files changed
+824 -79

No files matched your search

+52 -42
View File
@@ -17,7 +17,7 @@ export default function HomePage() {
const [tab, setTab] = useState<Tab>('record')
const [title, setTitle] = useState('')
const [transcript, setTranscript] = useState('')
const [summaryMode, setSummaryMode] = useState<SummaryMode>('simple')
const [summaryMode, setSummaryMode] = useState<SummaryMode>('gemini')
const [result, setResult] = useState<MinutesResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -46,12 +46,9 @@ export default function HomePage() {
}
}
function handleTranscriptReady(text: string) {
setTranscript(text)
}
async function generateMinutes() {
if (!transcript.trim()) {
async function generateMinutes(sourceTranscript: string, mode: SummaryMode) {
const text = sourceTranscript.trim()
if (!text) {
setError('변환할 텍스트가 없습니다. 녹음하거나 파일을 업로드해주세요.')
return
}
@@ -65,8 +62,8 @@ export default function HomePage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title || '무제 회의',
transcript,
mode: summaryMode,
transcript: text,
mode,
}),
})
const data = await res.json()
@@ -84,6 +81,11 @@ export default function HomePage() {
}
}
function handleTranscriptReady(text: string) {
setTranscript(text)
generateMinutes(text, summaryMode)
}
return (
<main className="flex-1 bg-gradient-to-b from-neutral-50 to-white">
<div className="mx-auto max-w-3xl px-6 py-12">
@@ -109,6 +111,41 @@ export default function HomePage() {
/>
</section>
<section className="mb-8">
<div className="flex items-center gap-4 mb-6">
<label className="text-sm font-medium text-neutral-600">
변환 모드:
</label>
<div className="flex gap-2">
<button
onClick={() => setSummaryMode('simple')}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
summaryMode === 'simple'
? 'bg-green-100 text-green-700 ring-1 ring-green-300'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
}`}
>
단순 변환
</button>
<button
onClick={() => setSummaryMode('gemini')}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
summaryMode === 'gemini'
? 'bg-purple-100 text-purple-700 ring-1 ring-purple-300'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
}`}
>
Gemini AI 요약
</button>
</div>
</div>
{summaryMode === 'gemini' && tab === 'record' && (
<p className="text-xs text-purple-600">
💡 녹음 중 30초마다 중간 요약이 자동 갱신되고, 종료 시 최종 회의록이 생성됩니다.
</p>
)}
</section>
<section className="mb-8">
<div className="flex gap-1 rounded-xl bg-neutral-100 p-1 mb-6">
<button
@@ -134,7 +171,10 @@ export default function HomePage() {
</div>
{tab === 'record' ? (
<LiveRecorder onTranscriptReady={handleTranscriptReady} />
<LiveRecorder
onTranscriptReady={handleTranscriptReady}
liveSummaryEnabled={summaryMode === 'gemini'}
/>
) : (
<AudioUploader onFileSelected={handleUpload} />
)}
@@ -163,42 +203,12 @@ export default function HomePage() {
</section>
)}
<section className="mb-8">
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-neutral-600">
변환 모드:
</label>
<div className="flex gap-2">
<button
onClick={() => setSummaryMode('simple')}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
summaryMode === 'simple'
? 'bg-green-100 text-green-700 ring-1 ring-green-300'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
}`}
>
단순 변환
</button>
<button
onClick={() => setSummaryMode('gemini')}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
summaryMode === 'gemini'
? 'bg-purple-100 text-purple-700 ring-1 ring-purple-300'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
}`}
>
Gemini AI 요약
</button>
</div>
</div>
</section>
<button
onClick={generateMinutes}
onClick={() => generateMinutes(transcript, summaryMode)}
disabled={loading || !transcript.trim()}
className="w-full rounded-xl bg-neutral-900 px-6 py-3.5 text-white font-medium shadow-lg shadow-neutral-900/10 hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-all mb-8"
>
{loading ? '생성 중...' : '회의록 생성'}
{loading ? '생성 중...' : result ? '회의록 재생성' : '회의록 생성'}
</button>
{error && (