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
+12
View File
@@ -32,6 +32,18 @@ services:
db:
condition: service_healthy
test:
profiles: ["tools"]
image: node:22-alpine
working_dir: /app
environment:
CI: "true"
volumes:
- .:/app
- test_node_modules:/app/node_modules
command: sh -c "npm ci && npm test"
volumes:
pgdata:
uploads:
test_node_modules:
-1
View File
@@ -5,7 +5,6 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Meeting {
+130
View File
@@ -0,0 +1,130 @@
import { describe, it, expect, vi } from 'vitest'
import { generateLiveSummary } from '@/lib/live-summary'
describe('generateLiveSummary', () => {
it('Gemini API 응답을 받아 중간 요약 마크다운을 반환한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [
{
content: {
parts: [
{
text: '## 요약\n진행 중인 회의 내용 요약\n\n## 액션 아이템\n- [ ] QA 진행',
},
],
},
},
],
}),
})
const result = await generateLiveSummary('안녕하세요 회의 시작합니다', {
apiKey: 'test-key',
fetchFn: mockFetch,
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.markdown).toContain('요약')
expect(result.markdown).toContain('액션 아이템')
}
})
it('API 키가 비어있으면 에러를 반환한다', async () => {
const result = await generateLiveSummary('텍스트', {
apiKey: '',
fetchFn: vi.fn(),
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('API 키')
}
})
it('transcript가 비어있으면 에러를 반환한다', async () => {
const result = await generateLiveSummary(' ', {
apiKey: 'test-key',
fetchFn: vi.fn(),
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('비어')
}
})
it('429 응답은 rateLimited 플래그와 안내 메시지를 반환한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
statusText: 'Too Many Requests',
})
const result = await generateLiveSummary('회의 내용', {
apiKey: 'test-key',
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.rateLimited).toBe(true)
expect(result.error).toContain('한도 초과')
}
})
it('429 이외의 실패는 상태 코드를 포함한 에러를 반환한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
})
const result = await generateLiveSummary('회의 내용', {
apiKey: 'test-key',
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('500')
expect(result.rateLimited).toBeUndefined()
}
})
it('API가 빈 응답을 반환하면 에러로 처리한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ candidates: [] }),
})
const result = await generateLiveSummary('회의 내용', {
apiKey: 'test-key',
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
})
it('x-goog-api-key 헤더로 인증한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [{ content: { parts: [{ text: '요약' }] } }],
}),
})
await generateLiveSummary('회의 내용', {
apiKey: 'secret-key',
fetchFn: mockFetch,
})
const [url, init] = mockFetch.mock.calls[0]
expect(url).not.toContain('secret-key')
expect(init.headers['x-goog-api-key']).toBe('secret-key')
})
})
+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 && (
+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>
+167
View File
@@ -0,0 +1,167 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { TranscriptChunk } from '@/lib/transcript-formatter'
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
interface UseLiveSummaryOptions {
enabled: boolean
pollIntervalMs?: number
minWords?: number
incrementWords?: number
}
interface LiveSummaryState {
summary: string
isSummarizing: boolean
error: string | null
lastUpdatedAt: number | null
wordCount: number
wordsUntilNext: number
minWords: number
incrementWords: number
cooldownUntil: number | null
}
function countWords(text: string): number {
return text.trim().split(/\s+/).filter(Boolean).length
}
export function useLiveSummary(
chunks: readonly TranscriptChunk[],
options: UseLiveSummaryOptions,
): LiveSummaryState {
const {
enabled,
pollIntervalMs = 30_000,
minWords = 25,
incrementWords = 40,
} = options
const [summary, setSummary] = useState('')
const [isSummarizing, setIsSummarizing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
const [cooldownUntil, setCooldownUntil] = useState<number | null>(null)
const chunksRef = useRef<readonly TranscriptChunk[]>(chunks)
const lastWordCountRef = useRef(0)
const abortRef = useRef<AbortController | null>(null)
const inFlightRef = useRef(false)
const cooldownUntilRef = useRef<number | null>(null)
const consecutiveFailuresRef = useRef(0)
useEffect(() => {
chunksRef.current = chunks
}, [chunks])
const wordCount = useMemo(
() => countWords(formatTranscriptChunks(chunks)),
[chunks],
)
const wordsUntilNext = useMemo(() => {
if (lastUpdatedAt === null) {
return Math.max(0, minWords - wordCount)
}
return Math.max(0, incrementWords - (wordCount - lastWordCountRef.current))
}, [wordCount, lastUpdatedAt, minWords, incrementWords])
useEffect(() => {
if (!enabled) {
abortRef.current?.abort()
abortRef.current = null
inFlightRef.current = false
return
}
async function maybeSummarize() {
if (inFlightRef.current) return
if (
cooldownUntilRef.current !== null &&
Date.now() < cooldownUntilRef.current
) {
return
}
const transcript = formatTranscriptChunks(chunksRef.current)
const currentWordCount = countWords(transcript)
if (currentWordCount < minWords) return
if (currentWordCount - lastWordCountRef.current < incrementWords) return
const controller = new AbortController()
abortRef.current = controller
inFlightRef.current = true
setIsSummarizing(true)
setError(null)
try {
const res = await fetch('/api/summarize-live', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transcript }),
signal: controller.signal,
})
if (!res.ok) {
const data = await res
.json()
.catch(() => ({ error: '실시간 요약 실패' }))
consecutiveFailuresRef.current += 1
const backoffMs =
res.status === 429
? 60_000
: Math.min(120_000, 15_000 * consecutiveFailuresRef.current)
const until = Date.now() + backoffMs
cooldownUntilRef.current = until
setCooldownUntil(until)
setError(data.error ?? '실시간 요약 실패')
return
}
const data = await res.json()
setSummary(data.markdown)
setLastUpdatedAt(Date.now())
lastWordCountRef.current = currentWordCount
consecutiveFailuresRef.current = 0
cooldownUntilRef.current = null
setCooldownUntil(null)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : '알 수 없는 오류')
} finally {
inFlightRef.current = false
if (abortRef.current === controller) {
abortRef.current = null
}
setIsSummarizing(false)
}
}
const interval = setInterval(maybeSummarize, pollIntervalMs)
return () => {
clearInterval(interval)
}
}, [enabled, pollIntervalMs, minWords, incrementWords])
useEffect(() => {
return () => {
abortRef.current?.abort()
}
}, [])
return {
summary,
isSummarizing,
error,
lastUpdatedAt,
wordCount,
wordsUntilNext,
minWords,
incrementWords,
cooldownUntil,
}
}
+90 -15
View File
@@ -1,31 +1,60 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import { useState, useRef, useCallback, useEffect } from 'react'
import type { TranscriptChunk } from '@/lib/transcript-formatter'
interface SpeechRecognitionHook {
isListening: boolean
isSupported: boolean
isSupported: boolean | null
chunks: TranscriptChunk[]
interimText: string
error: string | null
startListening: () => void
stopListening: () => void
resetChunks: () => void
}
function describeError(code: string): string {
switch (code) {
case 'not-allowed':
case 'service-not-allowed':
return '마이크 권한이 거부되었습니다. 브라우저 주소창 왼쪽 자물쇠 아이콘에서 마이크를 허용해주세요.'
case 'audio-capture':
return '마이크를 찾을 수 없습니다. 장치가 연결되어 있는지 확인해주세요.'
case 'network':
return '네트워크 오류로 음성 인식을 사용할 수 없습니다.'
case 'language-not-supported':
return '해당 언어가 지원되지 않습니다.'
default:
return `음성 인식 오류: ${code}`
}
}
export function useSpeechRecognition(): SpeechRecognitionHook {
const [isListening, setIsListening] = useState(false)
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
const [interimText, setInterimText] = useState('')
const [error, setError] = useState<string | null>(null)
const [isSupported, setIsSupported] = useState<boolean | null>(null)
const recognitionRef = useRef<SpeechRecognition | null>(null)
const startTimeRef = useRef<number>(0)
const networkRetryRef = useRef<number>(0)
const isSupported =
typeof window !== 'undefined' &&
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
const MAX_NETWORK_RETRIES = 8
useEffect(() => {
setIsSupported(
typeof window !== 'undefined' &&
('SpeechRecognition' in window ||
'webkitSpeechRecognition' in window),
)
}, [])
const startListening = useCallback(() => {
if (!isSupported) return
if (isSupported !== true) {
setError('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome을 사용해주세요.')
return
}
const SpeechRecognitionAPI =
window.SpeechRecognition || window.webkitSpeechRecognition
@@ -36,8 +65,15 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
recognition.interimResults = true
startTimeRef.current = Date.now()
networkRetryRef.current = 0
setError(null)
recognition.onresult = (event: SpeechRecognitionEvent) => {
if (networkRetryRef.current > 0) {
networkRetryRef.current = 0
setError(null)
}
const now = (Date.now() - startTimeRef.current) / 1000
for (let i = event.resultIndex; i < event.results.length; i++) {
@@ -62,22 +98,59 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
}
recognition.onend = () => {
if (recognitionRef.current) {
recognition.start()
}
if (recognitionRef.current !== recognition) return
const delay = networkRetryRef.current > 0 ? 1500 : 0
setTimeout(() => {
if (recognitionRef.current !== recognition) return
try {
recognition.start()
} catch {
setIsListening(false)
recognitionRef.current = null
setError('음성 인식이 중단되었습니다. 다시 시작해주세요.')
}
}, delay)
}
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
if (event.error !== 'no-speech' && event.error !== 'aborted') {
console.error('Speech recognition error:', event.error)
setIsListening(false)
recognitionRef.current = null
if (event.error === 'no-speech' || event.error === 'aborted') {
return
}
if (event.error === 'network') {
networkRetryRef.current += 1
if (networkRetryRef.current > MAX_NETWORK_RETRIES) {
setError(
`네트워크 오류가 ${MAX_NETWORK_RETRIES}회 반복되어 녹음을 중단합니다. 지금까지의 텍스트는 보존되어 있습니다.`,
)
setIsListening(false)
recognitionRef.current = null
return
}
setError(
`네트워크 일시 단절 — 자동 재연결 중 (${networkRetryRef.current}/${MAX_NETWORK_RETRIES})`,
)
return
}
setError(describeError(event.error))
setIsListening(false)
recognitionRef.current = null
}
recognitionRef.current = recognition
recognition.start()
setIsListening(true)
try {
recognition.start()
setIsListening(true)
} catch (err) {
setError(
err instanceof Error
? `음성 인식을 시작할 수 없습니다: ${err.message}`
: '음성 인식을 시작할 수 없습니다.',
)
recognitionRef.current = null
}
}, [isSupported])
const stopListening = useCallback(() => {
@@ -93,6 +166,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
const resetChunks = useCallback(() => {
setChunks([])
setInterimText('')
setError(null)
}, [])
return {
@@ -100,6 +174,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
isSupported,
chunks,
interimText,
error,
startListening,
stopListening,
resetChunks,
+93
View File
@@ -0,0 +1,93 @@
type LiveSummaryResult =
| { success: true; markdown: string }
| { success: false; error: string; rateLimited?: boolean }
interface LiveSummaryOptions {
apiKey: string
fetchFn?: typeof fetch
}
const LIVE_PROMPT = `회의가 아직 진행 중입니다. 지금까지의 발화 내용을 기반으로 짧고 구조화된 중간 요약을 작성하세요.
형식 (마크다운):
## 요약
(핵심 내용 3줄 이내)
## 주요 논의 사항
1. ...
2. ...
## 액션 아이템
- [ ] ...
규칙:
- 확정되지 않은 결정은 "(논의 중)"으로 표시
- 액션 아이템은 담당자/기한이 명확한 것만 포함
- 한국어로, 간결하게
---
음성 인식 텍스트:
`
export async function generateLiveSummary(
transcript: string,
options: LiveSummaryOptions,
): Promise<LiveSummaryResult> {
const { apiKey, fetchFn = fetch } = options
if (!apiKey || apiKey.trim().length === 0) {
return { success: false, error: 'Gemini API 키가 필요합니다.' }
}
const trimmed = transcript.trim()
if (trimmed.length === 0) {
return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
}
const url =
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
try {
const response = await fetchFn(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [
{
parts: [{ text: `${LIVE_PROMPT}${trimmed}` }],
},
],
}),
})
if (!response.ok) {
if (response.status === 429) {
return {
success: false,
error: 'Gemini 요청 한도 초과. 잠시 후 자동 재시도됩니다.',
rateLimited: true,
}
}
return {
success: false,
error: `Gemini API 호출 실패: ${response.status}`,
}
}
const data = await response.json()
const markdown: string =
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
if (markdown.trim().length === 0) {
return { success: false, error: '요약 결과가 비어 있습니다.' }
}
return { success: true, markdown }
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'
return { success: false, error: `실시간 요약 중 오류: ${message}` }
}
}
+6 -2
View File
@@ -63,12 +63,16 @@ export async function generateGeminiMinutes(
return { success: false, error: 'Gemini API 키가 필요합니다.' }
}
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`
const url =
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
try {
const response = await fetchFn(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [
{