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:
@@ -32,6 +32,18 @@ services:
|
|||||||
db:
|
db:
|
||||||
condition: service_healthy
|
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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
uploads:
|
uploads:
|
||||||
|
test_node_modules:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ generator client {
|
|||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Meeting {
|
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 { NextRequest } from 'next/server'
|
||||||
import { generateGeminiMinutes, generateSimpleMinutes } from '@/lib/minutes-generator'
|
import {
|
||||||
|
generateGeminiMinutes,
|
||||||
|
generateSimpleMinutes,
|
||||||
|
} from '@/lib/minutes-generator'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -7,7 +10,10 @@ export async function POST(request: NextRequest) {
|
|||||||
const { title, transcript, mode, date } = body
|
const { title, transcript, mode, date } = body
|
||||||
|
|
||||||
if (!transcript || typeof transcript !== 'string') {
|
if (!transcript || typeof transcript !== 'string') {
|
||||||
return Response.json({ error: '변환할 텍스트가 필요합니다.' }, { status: 400 })
|
return Response.json(
|
||||||
|
{ error: '변환할 텍스트가 필요합니다.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = {
|
const input = {
|
||||||
@@ -21,7 +27,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const result = await generateGeminiMinutes(input, { apiKey })
|
const result = await generateGeminiMinutes(input, { apiKey })
|
||||||
|
|
||||||
if (!result.success) {
|
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' })
|
return Response.json({ markdown: result.markdown, mode: 'gemini' })
|
||||||
@@ -31,6 +42,9 @@ export async function POST(request: NextRequest) {
|
|||||||
return Response.json({ markdown, mode: 'simple' })
|
return Response.json({ markdown, mode: 'simple' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
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 [tab, setTab] = useState<Tab>('record')
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [transcript, setTranscript] = 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 [result, setResult] = useState<MinutesResult | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -46,12 +46,9 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTranscriptReady(text: string) {
|
async function generateMinutes(sourceTranscript: string, mode: SummaryMode) {
|
||||||
setTranscript(text)
|
const text = sourceTranscript.trim()
|
||||||
}
|
if (!text) {
|
||||||
|
|
||||||
async function generateMinutes() {
|
|
||||||
if (!transcript.trim()) {
|
|
||||||
setError('변환할 텍스트가 없습니다. 녹음하거나 파일을 업로드해주세요.')
|
setError('변환할 텍스트가 없습니다. 녹음하거나 파일을 업로드해주세요.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -65,8 +62,8 @@ export default function HomePage() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: title || '무제 회의',
|
title: title || '무제 회의',
|
||||||
transcript,
|
transcript: text,
|
||||||
mode: summaryMode,
|
mode,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -84,6 +81,11 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTranscriptReady(text: string) {
|
||||||
|
setTranscript(text)
|
||||||
|
generateMinutes(text, summaryMode)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 bg-gradient-to-b from-neutral-50 to-white">
|
<main className="flex-1 bg-gradient-to-b from-neutral-50 to-white">
|
||||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||||
@@ -109,6 +111,41 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</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">
|
<section className="mb-8">
|
||||||
<div className="flex gap-1 rounded-xl bg-neutral-100 p-1 mb-6">
|
<div className="flex gap-1 rounded-xl bg-neutral-100 p-1 mb-6">
|
||||||
<button
|
<button
|
||||||
@@ -134,7 +171,10 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'record' ? (
|
{tab === 'record' ? (
|
||||||
<LiveRecorder onTranscriptReady={handleTranscriptReady} />
|
<LiveRecorder
|
||||||
|
onTranscriptReady={handleTranscriptReady}
|
||||||
|
liveSummaryEnabled={summaryMode === 'gemini'}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AudioUploader onFileSelected={handleUpload} />
|
<AudioUploader onFileSelected={handleUpload} />
|
||||||
)}
|
)}
|
||||||
@@ -163,42 +203,12 @@ export default function HomePage() {
|
|||||||
</section>
|
</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
|
<button
|
||||||
onClick={generateMinutes}
|
onClick={() => generateMinutes(transcript, summaryMode)}
|
||||||
disabled={loading || !transcript.trim()}
|
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"
|
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>
|
</button>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -1,23 +1,62 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||||
|
import { useLiveSummary } from '@/hooks/useLiveSummary'
|
||||||
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
||||||
|
|
||||||
interface LiveRecorderProps {
|
interface LiveRecorderProps {
|
||||||
onTranscriptReady: (transcript: string) => void
|
onTranscriptReady: (transcript: string) => void
|
||||||
|
liveSummaryEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LiveRecorder({ onTranscriptReady }: LiveRecorderProps) {
|
export function LiveRecorder({
|
||||||
|
onTranscriptReady,
|
||||||
|
liveSummaryEnabled,
|
||||||
|
}: LiveRecorderProps) {
|
||||||
const {
|
const {
|
||||||
isListening,
|
isListening,
|
||||||
isSupported,
|
isSupported,
|
||||||
chunks,
|
chunks,
|
||||||
interimText,
|
interimText,
|
||||||
|
error: recognitionError,
|
||||||
startListening,
|
startListening,
|
||||||
stopListening,
|
stopListening,
|
||||||
resetChunks,
|
resetChunks,
|
||||||
} = useSpeechRecognition()
|
} = 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) {
|
if (!isSupported) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-center">
|
<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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{isListening ? (
|
{isListening ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
@@ -69,21 +142,140 @@ export function LiveRecorder({ onTranscriptReady }: LiveRecorderProps) {
|
|||||||
초기화
|
초기화
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{(chunks.length > 0 || interimText) && (
|
{recognitionError && (
|
||||||
<div className="rounded-2xl border border-neutral-200 bg-neutral-50 p-6 max-h-64 overflow-y-auto">
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
<h3 className="text-sm font-semibold text-neutral-500 mb-3 uppercase tracking-wider">
|
<strong>음성 인식 오류:</strong> {recognitionError}
|
||||||
실시간 텍스트
|
</div>
|
||||||
</h3>
|
)}
|
||||||
<div className="space-y-1 text-sm font-mono text-neutral-700">
|
|
||||||
{chunks.map((chunk, i) => (
|
{isListening && !hasTranscript && (
|
||||||
<p key={i}>{chunk.text}</p>
|
<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">
|
||||||
{interimText && (
|
🎙️ 마이크가 활성화되었습니다. 말씀해주세요...
|
||||||
<p className="text-neutral-400 italic">{interimText}...</p>
|
</p>
|
||||||
)}
|
<p className="mt-1 text-xs text-blue-500">
|
||||||
</div>
|
아무 반응이 없다면 브라우저 주소창 왼쪽에서 마이크 권한을 확인해주세요.
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
</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'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef, useCallback } from 'react'
|
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
import type { TranscriptChunk } from '@/lib/transcript-formatter'
|
import type { TranscriptChunk } from '@/lib/transcript-formatter'
|
||||||
|
|
||||||
interface SpeechRecognitionHook {
|
interface SpeechRecognitionHook {
|
||||||
isListening: boolean
|
isListening: boolean
|
||||||
isSupported: boolean
|
isSupported: boolean | null
|
||||||
chunks: TranscriptChunk[]
|
chunks: TranscriptChunk[]
|
||||||
interimText: string
|
interimText: string
|
||||||
|
error: string | null
|
||||||
startListening: () => void
|
startListening: () => void
|
||||||
stopListening: () => void
|
stopListening: () => void
|
||||||
resetChunks: () => 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 {
|
export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||||
const [isListening, setIsListening] = useState(false)
|
const [isListening, setIsListening] = useState(false)
|
||||||
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
||||||
const [interimText, setInterimText] = useState('')
|
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 recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||||
const startTimeRef = useRef<number>(0)
|
const startTimeRef = useRef<number>(0)
|
||||||
|
const networkRetryRef = useRef<number>(0)
|
||||||
|
|
||||||
const isSupported =
|
const MAX_NETWORK_RETRIES = 8
|
||||||
typeof window !== 'undefined' &&
|
|
||||||
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
|
useEffect(() => {
|
||||||
|
setIsSupported(
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
('SpeechRecognition' in window ||
|
||||||
|
'webkitSpeechRecognition' in window),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const startListening = useCallback(() => {
|
const startListening = useCallback(() => {
|
||||||
if (!isSupported) return
|
if (isSupported !== true) {
|
||||||
|
setError('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome을 사용해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const SpeechRecognitionAPI =
|
const SpeechRecognitionAPI =
|
||||||
window.SpeechRecognition || window.webkitSpeechRecognition
|
window.SpeechRecognition || window.webkitSpeechRecognition
|
||||||
@@ -36,8 +65,15 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
recognition.interimResults = true
|
recognition.interimResults = true
|
||||||
|
|
||||||
startTimeRef.current = Date.now()
|
startTimeRef.current = Date.now()
|
||||||
|
networkRetryRef.current = 0
|
||||||
|
setError(null)
|
||||||
|
|
||||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||||
|
if (networkRetryRef.current > 0) {
|
||||||
|
networkRetryRef.current = 0
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
const now = (Date.now() - startTimeRef.current) / 1000
|
const now = (Date.now() - startTimeRef.current) / 1000
|
||||||
|
|
||||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
@@ -62,22 +98,59 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
recognition.onend = () => {
|
recognition.onend = () => {
|
||||||
if (recognitionRef.current) {
|
if (recognitionRef.current !== recognition) return
|
||||||
recognition.start()
|
|
||||||
}
|
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) => {
|
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||||
if (event.error !== 'no-speech' && event.error !== 'aborted') {
|
if (event.error === 'no-speech' || event.error === 'aborted') {
|
||||||
console.error('Speech recognition error:', event.error)
|
return
|
||||||
setIsListening(false)
|
|
||||||
recognitionRef.current = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
recognitionRef.current = recognition
|
||||||
recognition.start()
|
try {
|
||||||
setIsListening(true)
|
recognition.start()
|
||||||
|
setIsListening(true)
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? `음성 인식을 시작할 수 없습니다: ${err.message}`
|
||||||
|
: '음성 인식을 시작할 수 없습니다.',
|
||||||
|
)
|
||||||
|
recognitionRef.current = null
|
||||||
|
}
|
||||||
}, [isSupported])
|
}, [isSupported])
|
||||||
|
|
||||||
const stopListening = useCallback(() => {
|
const stopListening = useCallback(() => {
|
||||||
@@ -93,6 +166,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
const resetChunks = useCallback(() => {
|
const resetChunks = useCallback(() => {
|
||||||
setChunks([])
|
setChunks([])
|
||||||
setInterimText('')
|
setInterimText('')
|
||||||
|
setError(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -100,6 +174,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
isSupported,
|
isSupported,
|
||||||
chunks,
|
chunks,
|
||||||
interimText,
|
interimText,
|
||||||
|
error,
|
||||||
startListening,
|
startListening,
|
||||||
stopListening,
|
stopListening,
|
||||||
resetChunks,
|
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 키가 필요합니다.' }
|
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 {
|
try {
|
||||||
const response = await fetchFn(url, {
|
const response = await fetchFn(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-goog-api-key': apiKey,
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
contents: [
|
contents: [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user