From 3b9692e8570bb1ed4cb7182d86b2ee9b999f848a Mon Sep 17 00:00:00 2001 From: nad4-su Date: Tue, 21 Apr 2026 17:24:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EB=A1=A4?= =?UTF-8?q?=EB=A7=81=20=EC=9A=94=EC=95=BD=20+=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=B5=9C=EC=A2=85=20=ED=9A=8C=EC=9D=98=EB=A1=9D=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 녹음 중 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) --- docker-compose.yml | 12 ++ prisma/schema.prisma | 1 - src/__tests__/live-summary.test.ts | 130 +++++++++++++ src/app/api/summarize-live/route.ts | 49 +++++ src/app/api/summarize/route.ts | 22 ++- src/app/page.tsx | 94 +++++----- src/components/recorder/LiveRecorder.tsx | 222 +++++++++++++++++++++-- src/hooks/useLiveSummary.ts | 167 +++++++++++++++++ src/hooks/useSpeechRecognition.ts | 105 +++++++++-- src/lib/live-summary.ts | 93 ++++++++++ src/lib/minutes-generator.ts | 8 +- 11 files changed, 824 insertions(+), 79 deletions(-) create mode 100644 src/__tests__/live-summary.test.ts create mode 100644 src/app/api/summarize-live/route.ts create mode 100644 src/hooks/useLiveSummary.ts create mode 100644 src/lib/live-summary.ts diff --git a/docker-compose.yml b/docker-compose.yml index 8da6830..8d206da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 640e7f1..10f8515 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,7 +5,6 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") } model Meeting { diff --git a/src/__tests__/live-summary.test.ts b/src/__tests__/live-summary.test.ts new file mode 100644 index 0000000..98de65f --- /dev/null +++ b/src/__tests__/live-summary.test.ts @@ -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') + }) +}) diff --git a/src/app/api/summarize-live/route.ts b/src/app/api/summarize-live/route.ts new file mode 100644 index 0000000..08696da --- /dev/null +++ b/src/app/api/summarize-live/route.ts @@ -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 }, + ) + } +} diff --git a/src/app/api/summarize/route.ts b/src/app/api/summarize/route.ts index bc3e7bf..796da64 100644 --- a/src/app/api/summarize/route.ts +++ b/src/app/api/summarize/route.ts @@ -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 }, + ) } } diff --git a/src/app/page.tsx b/src/app/page.tsx index 7fedfaa..1ad39c3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -17,7 +17,7 @@ export default function HomePage() { const [tab, setTab] = useState('record') const [title, setTitle] = useState('') const [transcript, setTranscript] = useState('') - const [summaryMode, setSummaryMode] = useState('simple') + const [summaryMode, setSummaryMode] = useState('gemini') const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(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 (
@@ -109,6 +111,41 @@ export default function HomePage() { /> +
+
+ +
+ + +
+
+ {summaryMode === 'gemini' && tab === 'record' && ( +

+ 💡 녹음 중 30초마다 중간 요약이 자동 갱신되고, 종료 시 최종 회의록이 생성됩니다. +

+ )} +
+
)} -
-
- -
- - -
-
-
- {error && ( diff --git a/src/components/recorder/LiveRecorder.tsx b/src/components/recorder/LiveRecorder.tsx index c3b0ba0..b378cc0 100644 --- a/src/components/recorder/LiveRecorder.tsx +++ b/src/components/recorder/LiveRecorder.tsx @@ -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(null) + const summaryEndRef = useRef(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 ( +
+ 브라우저 기능 확인 중... +
+ ) + } + if (!isSupported) { return (
@@ -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 (
-
+
{isListening ? (
- {(chunks.length > 0 || interimText) && ( -
-

- 실시간 텍스트 -

-
- {chunks.map((chunk, i) => ( -

{chunk.text}

- ))} - {interimText && ( -

{interimText}...

- )} -
+ {recognitionError && ( +
+ 음성 인식 오류: {recognitionError} +
+ )} + + {isListening && !hasTranscript && ( +
+

+ 🎙️ 마이크가 활성화되었습니다. 말씀해주세요... +

+

+ 아무 반응이 없다면 브라우저 주소창 왼쪽에서 마이크 권한을 확인해주세요. +

+
+ )} + + {showPanels && ( +
+
+
+
+ +

+ 실시간 텍스트 +

+
+ + {chunks.length}개 구간 · {wordCount}단어 + +
+
+ {hasTranscript ? ( +
+ {chunks.map((chunk, i) => ( +

{chunk.text}

+ ))} + {interimText && ( +

+ {interimText} + +

+ )} +
+
+ ) : ( +

+ {isListening + ? '발화를 기다리는 중...' + : '녹음을 시작하면 여기에 텍스트가 나타납니다.'} +

+ )} +
+
+ + {liveSummaryEnabled && ( +
+
+
+ +

+ 실시간 회의록 +

+
+
+ {summaryProgress && {summaryProgress}} + {lastUpdatedLabel && · {lastUpdatedLabel}} +
+
+
+ {summaryError ? ( +

{summaryError}

+ ) : hasSummary ? ( + <> +
+                      {summary}
+                    
+
+ + ) : ( +
+

+ 발화가 쌓이면 30초 간격으로 Gemini가 중간 회의록을 + 작성합니다. +

+
+
+ 진행률 + + {Math.min(wordCount, minWords)}/{minWords}단어 + +
+
+
+
+
+
+ )} +
+
+ )}
)}
diff --git a/src/hooks/useLiveSummary.ts b/src/hooks/useLiveSummary.ts new file mode 100644 index 0000000..9eda117 --- /dev/null +++ b/src/hooks/useLiveSummary.ts @@ -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(null) + const [lastUpdatedAt, setLastUpdatedAt] = useState(null) + const [cooldownUntil, setCooldownUntil] = useState(null) + + const chunksRef = useRef(chunks) + const lastWordCountRef = useRef(0) + const abortRef = useRef(null) + const inFlightRef = useRef(false) + const cooldownUntilRef = useRef(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, + } +} diff --git a/src/hooks/useSpeechRecognition.ts b/src/hooks/useSpeechRecognition.ts index 5f51fb4..9e45cd7 100644 --- a/src/hooks/useSpeechRecognition.ts +++ b/src/hooks/useSpeechRecognition.ts @@ -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([]) const [interimText, setInterimText] = useState('') + const [error, setError] = useState(null) + const [isSupported, setIsSupported] = useState(null) const recognitionRef = useRef(null) const startTimeRef = useRef(0) + const networkRetryRef = useRef(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, diff --git a/src/lib/live-summary.ts b/src/lib/live-summary.ts new file mode 100644 index 0000000..1ddf1ae --- /dev/null +++ b/src/lib/live-summary.ts @@ -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 { + 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}` } + } +} diff --git a/src/lib/minutes-generator.ts b/src/lib/minutes-generator.ts index 9cc82d3..aaeec75 100644 --- a/src/lib/minutes-generator.ts +++ b/src/lib/minutes-generator.ts @@ -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: [ {