mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-08-12 14:23:24 +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:
@@ -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:
|
||||
|
||||
@@ -5,7 +5,6 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Meeting {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}` }
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user