mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-08-12 22:33:25 +09:00
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:
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user