mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
* feat: OpenAI 호환 프로바이더 지원 (OrcaRouter · OpenAI · 로컬 모델)
Gemini 직접 호출만 가능하던 구조를 프로바이더 레이어로 분리.
base URL과 모델 ID만 지정하면 OpenAI Chat Completions 형식을 따르는
엔드포인트는 모두 연결된다 (OrcaRouter, OpenAI, Ollama, LM Studio, vLLM).
- src/lib/providers/ 신설 (gemini / openai-compatible 어댑터 + 프리셋)
- /settings에 프로바이더 선택 UI 추가, 기존 Gemini 키는 자동 승계
- LLM_PROVIDER / LLM_BASE_URL / LLM_MODEL / LLM_API_KEY 환경변수 지원
- base URL은 http/https만 허용 (서버가 대신 fetch하므로 SSRF 방어)
- SECURITY.md에 프로바이더별 전송 경로와 SSRF 주의사항 문서화
- 테스트 102 → 141
DB에 저장되는 summaryMode 값('gemini')은 기존 레코드 호환을 위해 유지하고
UI 라벨만 "AI 요약"으로 변경했다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: 기본 Gemini 모델을 3.5 Flash Lite로 갱신
gemini-2.5-flash-lite → gemini-3.5-flash-lite. 같은 저비용·고속 티어의
최신 세대이며, 무료 등급 중심의 사용 프로필을 그대로 유지한다.
- providers/gemini.ts: DEFAULT_GEMINI_MODEL
- presets.ts: OrcaRouter 기본 모델과 모델 힌트 문구
- .env.example, README 참조 갱신
참고: 화자 분리를 지원하는 gemini-3.5-transcribe는 오디오 입력 전용
음성인식 모델이라 이 자리(텍스트 → 회의록 요약)에 넣을 수 없다.
오디오 캡처가 들어오는 시점에 STT 경로로 별도 추가해야 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: csbae <csbae@RP-002.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { AudioUploader } from '@/components/upload/AudioUploader'
|
|
import { LiveRecorder } from '@/components/recorder/LiveRecorder'
|
|
import { MinutesViewer } from '@/components/minutes/MinutesViewer'
|
|
import { getProviderRequestPayload } from '@/lib/api-key-storage'
|
|
import {
|
|
TEMPLATES,
|
|
DEFAULT_TEMPLATE_ID,
|
|
DEFAULT_DEPTH,
|
|
depthAdjustableFor,
|
|
liveEnabledFor,
|
|
type SummaryDepth,
|
|
type TemplateId,
|
|
} from '@/lib/templates'
|
|
|
|
type Tab = 'upload' | 'record'
|
|
type SummaryMode = 'simple' | 'gemini'
|
|
|
|
interface MinutesResult {
|
|
markdown: string
|
|
mode: SummaryMode
|
|
warning?: string
|
|
}
|
|
|
|
const DEPTH_LABELS: Record<SummaryDepth, string> = {
|
|
concise: '간결',
|
|
standard: '표준',
|
|
detailed: '상세',
|
|
}
|
|
|
|
export default function HomePage() {
|
|
const [tab, setTab] = useState<Tab>('record')
|
|
const [title, setTitle] = useState('')
|
|
const [transcript, setTranscript] = useState('')
|
|
const [summaryMode, setSummaryMode] = useState<SummaryMode>('gemini')
|
|
const [template, setTemplate] = useState<TemplateId>(DEFAULT_TEMPLATE_ID)
|
|
const [depth, setDepth] = useState<SummaryDepth>(DEFAULT_DEPTH)
|
|
const [customPrompt, setCustomPrompt] = useState('')
|
|
const [result, setResult] = useState<MinutesResult | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
|
|
|
const templateList = useMemo(
|
|
() => [
|
|
...Object.values(TEMPLATES),
|
|
{
|
|
id: 'custom' as const,
|
|
name: '커스텀',
|
|
icon: '⚙️',
|
|
description: '직접 프롬프트 작성',
|
|
},
|
|
],
|
|
[],
|
|
)
|
|
|
|
const activeTemplateMeta = useMemo(() => {
|
|
if (template === 'custom') {
|
|
return { adjustable: false, live: true }
|
|
}
|
|
return {
|
|
adjustable: depthAdjustableFor(template),
|
|
live: liveEnabledFor(template),
|
|
}
|
|
}, [template])
|
|
|
|
function handleTemplateChange(id: TemplateId) {
|
|
setTemplate(id)
|
|
if (id !== 'custom') {
|
|
const tpl = TEMPLATES[id]
|
|
setDepth(tpl.defaultDepth)
|
|
}
|
|
}
|
|
|
|
async function handleUpload(file: File) {
|
|
setUploadedFile(file)
|
|
setError(null)
|
|
|
|
const formData = new FormData()
|
|
formData.append('audio', file)
|
|
formData.append('title', title || file.name.replace(/\.[^.]+$/, ''))
|
|
|
|
try {
|
|
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setError(data.error)
|
|
return
|
|
}
|
|
|
|
if (!title) setTitle(data.title)
|
|
} catch {
|
|
setError('파일 업로드에 실패했습니다.')
|
|
}
|
|
}
|
|
|
|
async function generateMinutes(sourceTranscript: string, mode: SummaryMode) {
|
|
const text = sourceTranscript.trim()
|
|
if (!text) {
|
|
setError('변환할 텍스트가 없습니다. 녹음하거나 파일을 업로드해주세요.')
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const res = await fetch('/api/summarize', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: title || '무제 회의',
|
|
transcript: text,
|
|
mode,
|
|
template,
|
|
depth,
|
|
customPrompt: template === 'custom' ? customPrompt : undefined,
|
|
...getProviderRequestPayload(),
|
|
}),
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setError(data.error)
|
|
return
|
|
}
|
|
|
|
setResult({
|
|
markdown: data.markdown,
|
|
mode: data.mode,
|
|
warning: data.warning,
|
|
})
|
|
} catch {
|
|
setError('회의록 생성에 실패했습니다.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
function handleTranscriptReady(text: string) {
|
|
setTranscript(text)
|
|
generateMinutes(text, summaryMode)
|
|
}
|
|
|
|
const liveSummaryActive =
|
|
summaryMode === 'gemini' && activeTemplateMeta.live && tab === 'record'
|
|
|
|
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">
|
|
<header className="mb-10 text-center relative">
|
|
<h1 className="text-4xl font-bold tracking-tight text-neutral-900">
|
|
Meeting Minutes
|
|
</h1>
|
|
<p className="mt-2 text-neutral-500">
|
|
음성을 텍스트로, 용도에 맞는 템플릿으로 정리
|
|
</p>
|
|
<div className="absolute right-0 top-1 flex gap-3 text-sm text-neutral-500">
|
|
<Link
|
|
href="/meetings"
|
|
className="hover:text-neutral-800 transition-colors"
|
|
>
|
|
📚 회의록
|
|
</Link>
|
|
<Link
|
|
href="/settings"
|
|
className="hover:text-neutral-800 transition-colors"
|
|
>
|
|
⚙️ 설정
|
|
</Link>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="mb-8">
|
|
<label className="block text-sm font-medium text-neutral-600 mb-2">
|
|
제목
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="예: 주간 스프린트 회의"
|
|
className="w-full rounded-xl border border-neutral-300 px-4 py-3 text-neutral-800 placeholder:text-neutral-400 focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100 transition-all"
|
|
/>
|
|
</section>
|
|
|
|
<section className="mb-8 space-y-5">
|
|
<div className="flex items-center gap-4">
|
|
<label className="text-sm font-medium text-neutral-600 min-w-20">
|
|
변환 모드
|
|
</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'
|
|
}`}
|
|
>
|
|
AI 요약
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{summaryMode === 'gemini' && (
|
|
<>
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-600 mb-2">
|
|
템플릿
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{templateList.map((tpl) => (
|
|
<button
|
|
key={tpl.id}
|
|
onClick={() => handleTemplateChange(tpl.id)}
|
|
title={tpl.description}
|
|
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-all ${
|
|
template === tpl.id
|
|
? 'border-purple-400 bg-purple-50 text-purple-700'
|
|
: 'border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300 hover:bg-neutral-50'
|
|
}`}
|
|
>
|
|
<span className="mr-1">{tpl.icon}</span>
|
|
{tpl.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="mt-2 text-xs text-neutral-500">
|
|
{template === 'custom'
|
|
? '직접 프롬프트 작성'
|
|
: TEMPLATES[template]?.description}
|
|
</p>
|
|
</div>
|
|
|
|
{activeTemplateMeta.adjustable && (
|
|
<div className="flex items-center gap-4">
|
|
<label className="text-sm font-medium text-neutral-600 min-w-20">
|
|
작성 강도
|
|
</label>
|
|
<div className="flex gap-2">
|
|
{(['concise', 'standard', 'detailed'] as const).map(
|
|
(d) => (
|
|
<button
|
|
key={d}
|
|
onClick={() => setDepth(d)}
|
|
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
|
|
depth === d
|
|
? 'bg-purple-100 text-purple-700 ring-1 ring-purple-300'
|
|
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
|
|
}`}
|
|
>
|
|
{DEPTH_LABELS[d]}
|
|
</button>
|
|
),
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{template === 'custom' && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-600 mb-2">
|
|
커스텀 프롬프트
|
|
</label>
|
|
<textarea
|
|
value={customPrompt}
|
|
onChange={(e) => setCustomPrompt(e.target.value)}
|
|
placeholder="예: 당신은 기술 문서 작성자입니다. 아래 내용을 개발자 가이드 형식으로..."
|
|
rows={4}
|
|
className="w-full rounded-xl border border-neutral-300 px-4 py-3 text-sm text-neutral-700 placeholder:text-neutral-400 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all resize-y"
|
|
/>
|
|
<p className="mt-1 text-xs text-neutral-500">
|
|
비워두면 회의록 템플릿이 적용됩니다.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'record' && (
|
|
<p className="text-xs text-purple-600">
|
|
{liveSummaryActive
|
|
? '💡 녹음 중 30초마다 중간 정리가 자동 갱신되고, 종료 시 최종 문서가 생성됩니다.'
|
|
: '💡 이 템플릿은 녹음 종료 시 한 번만 생성됩니다.'}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
<section className="mb-8">
|
|
<div className="flex gap-1 rounded-xl bg-neutral-100 p-1 mb-6">
|
|
<button
|
|
onClick={() => setTab('record')}
|
|
className={`flex-1 rounded-lg py-2.5 text-sm font-medium transition-all ${
|
|
tab === 'record'
|
|
? 'bg-white text-neutral-900 shadow-sm'
|
|
: 'text-neutral-500 hover:text-neutral-700'
|
|
}`}
|
|
>
|
|
실시간 녹음
|
|
</button>
|
|
<button
|
|
onClick={() => setTab('upload')}
|
|
className={`flex-1 rounded-lg py-2.5 text-sm font-medium transition-all ${
|
|
tab === 'upload'
|
|
? 'bg-white text-neutral-900 shadow-sm'
|
|
: 'text-neutral-500 hover:text-neutral-700'
|
|
}`}
|
|
>
|
|
파일 업로드
|
|
</button>
|
|
</div>
|
|
|
|
{tab === 'record' ? (
|
|
<LiveRecorder
|
|
onTranscriptReady={handleTranscriptReady}
|
|
liveSummaryEnabled={liveSummaryActive}
|
|
template={template}
|
|
depth={depth}
|
|
customPrompt={template === 'custom' ? customPrompt : undefined}
|
|
/>
|
|
) : (
|
|
<AudioUploader onFileSelected={handleUpload} />
|
|
)}
|
|
</section>
|
|
|
|
{transcript && (
|
|
<section className="mb-8">
|
|
<label className="block text-sm font-medium text-neutral-600 mb-2">
|
|
인식된 텍스트
|
|
</label>
|
|
<textarea
|
|
value={transcript}
|
|
onChange={(e) => setTranscript(e.target.value)}
|
|
rows={6}
|
|
className="w-full rounded-xl border border-neutral-300 px-4 py-3 text-sm font-mono text-neutral-700 focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100 transition-all resize-y"
|
|
/>
|
|
</section>
|
|
)}
|
|
|
|
{uploadedFile && !transcript && (
|
|
<section className="mb-8">
|
|
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-700">
|
|
<strong>{uploadedFile.name}</strong> 업로드 완료.
|
|
실시간 녹음 탭에서 음성 인식을 시작하거나, 텍스트를 직접 입력해주세요.
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<button
|
|
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 ? '생성 중...' : result ? '재생성' : '생성'}
|
|
</button>
|
|
|
|
{error && (
|
|
<div className="mb-8 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{result?.warning && (
|
|
<div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
|
|
{result.warning}
|
|
</div>
|
|
)}
|
|
|
|
{result && (
|
|
<MinutesViewer
|
|
markdown={result.markdown}
|
|
title={title || '무제 회의'}
|
|
mode={result.mode}
|
|
transcript={transcript}
|
|
template={template}
|
|
depth={depth}
|
|
customPrompt={template === 'custom' ? customPrompt : undefined}
|
|
summaryMode={summaryMode}
|
|
/>
|
|
)}
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|