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
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest } from 'next/server'
import { generateLiveSummary } from '@/lib/live-summary'
const MAX_TRANSCRIPT_CHARS = 40_000
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { transcript } = body
if (!transcript || typeof transcript !== 'string') {
return Response.json(
{ error: '요약할 텍스트가 필요합니다.' },
{ status: 400 },
)
}
const apiKey = process.env.GEMINI_API_KEY ?? ''
if (!apiKey) {
return Response.json(
{ error: 'Gemini API 키가 설정되지 않았습니다.' },
{ status: 503 },
)
}
const truncated =
transcript.length > MAX_TRANSCRIPT_CHARS
? transcript.slice(-MAX_TRANSCRIPT_CHARS)
: transcript
const result = await generateLiveSummary(truncated, { apiKey })
if (!result.success) {
const status = result.rateLimited ? 429 : 502
return Response.json(
{ error: result.error, rateLimited: !!result.rateLimited },
{ status },
)
}
return Response.json({ markdown: result.markdown })
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'
return Response.json(
{ error: `실시간 요약 실패: ${message}` },
{ status: 500 },
)
}
}
+18 -4
View File
@@ -1,5 +1,8 @@
import { NextRequest } from 'next/server'
import { generateGeminiMinutes, generateSimpleMinutes } from '@/lib/minutes-generator'
import {
generateGeminiMinutes,
generateSimpleMinutes,
} from '@/lib/minutes-generator'
export async function POST(request: NextRequest) {
try {
@@ -7,7 +10,10 @@ export async function POST(request: NextRequest) {
const { title, transcript, mode, date } = body
if (!transcript || typeof transcript !== 'string') {
return Response.json({ error: '변환할 텍스트가 필요합니다.' }, { status: 400 })
return Response.json(
{ error: '변환할 텍스트가 필요합니다.' },
{ status: 400 },
)
}
const input = {
@@ -21,7 +27,12 @@ export async function POST(request: NextRequest) {
const result = await generateGeminiMinutes(input, { apiKey })
if (!result.success) {
return Response.json({ error: result.error }, { status: 502 })
const fallback = generateSimpleMinutes(input)
return Response.json({
markdown: fallback,
mode: 'simple',
warning: `Gemini 요약에 실패하여 단순 변환으로 대체되었습니다: ${result.error}`,
})
}
return Response.json({ markdown: result.markdown, mode: 'gemini' })
@@ -31,6 +42,9 @@ export async function POST(request: NextRequest) {
return Response.json({ markdown, mode: 'simple' })
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'
return Response.json({ error: `요약 처리 실패: ${message}` }, { status: 500 })
return Response.json(
{ error: `요약 처리 실패: ${message}` },
{ status: 500 },
)
}
}
+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 && (