Files
sam-manage/resources/views/juil/meeting-minutes.blade.php
김보곤 7f1f7165a5 feat:최고 품질 음성 녹음 시스템 구축 (STT V2 + Chirp 2 + Web Audio)
- 프론트엔드: Web Audio API 전처리 파이프라인 (GainNode + DynamicsCompressor + AnalyserNode)
- 프론트엔드: VU 미터 실시간 레벨 표시 + 마이크 감도 슬라이더 (0.5x~3.0x)
- 프론트엔드: getUserMedia constraints 강화 + MediaRecorder 128kbps Opus
- 백엔드: Google STT V2 API + Chirp 2 모델 batchRecognize 메서드 추가
- 백엔드: V2→V1 자동 폴백 래퍼 (speechToTextWithDiarizationAuto)
- 백엔드: Speech Adaptation 도메인 용어 힌트 (블라인드/스크린 등 22개)
- 백엔드: V2 SentencePiece 토큰 자동 감지 분기 처리
- 설정: config/services.php에 google.location 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 21:01:37 +09:00

1316 lines
66 KiB
PHP

@extends('layouts.app')
@section('title', '회의록 작성')
@section('content')
<div id="root"></div>
@endsection
@push('scripts')
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
@verbatim
const { useState, useEffect, useCallback, useRef, useMemo } = React;
const API = {
list: '/juil/meeting-minutes/list',
store: '/juil/meeting-minutes',
show: (id) => `/juil/meeting-minutes/${id}`,
update: (id) => `/juil/meeting-minutes/${id}`,
destroy: (id) => `/juil/meeting-minutes/${id}`,
saveSegments: (id) => `/juil/meeting-minutes/${id}/segments`,
uploadAudio: (id) => `/juil/meeting-minutes/${id}/upload-audio`,
summarize: (id) => `/juil/meeting-minutes/${id}/summarize`,
downloadAudio: (id) => `/juil/meeting-minutes/${id}/download-audio`,
diarize: (id) => `/juil/meeting-minutes/${id}/diarize`,
logSttUsage: '/juil/meeting-minutes/log-stt-usage',
};
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content || '';
const SPEAKER_COLORS = [
{ name: '화자 1', bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', badge: 'bg-blue-100 text-blue-800', dot: 'bg-blue-500' },
{ name: '화자 2', bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', badge: 'bg-green-100 text-green-800', dot: 'bg-green-500' },
{ name: '화자 3', bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700', badge: 'bg-purple-100 text-purple-800', dot: 'bg-purple-500' },
{ name: '화자 4', bg: 'bg-orange-50', border: 'border-orange-200', text: 'text-orange-700', badge: 'bg-orange-100 text-orange-800', dot: 'bg-orange-500' },
];
const STATUS_LABELS = {
DRAFT: { label: '초안', color: 'bg-gray-100 text-gray-800' },
RECORDING: { label: '녹음중', color: 'bg-red-100 text-red-800' },
PROCESSING: { label: '처리중', color: 'bg-yellow-100 text-yellow-800' },
COMPLETED: { label: '완료', color: 'bg-green-100 text-green-800' },
FAILED: { label: '실패', color: 'bg-red-100 text-red-800' },
};
const LANGUAGES = [
{ code: 'ko-KR', label: '한국어' },
{ code: 'en-US', label: 'English' },
{ code: 'ja-JP', label: '日本語' },
{ code: 'zh-CN', label: '中文' },
];
async function apiFetch(url, options = {}) {
const { headers: optHeaders, ...restOptions } = options;
const res = await fetch(url, {
...restOptions,
headers: {
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json',
...optHeaders,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: '요청 처리 중 오류가 발생했습니다.' }));
throw new Error(err.message || `HTTP ${res.status}`);
}
return res.json();
}
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
function formatMs(ms) {
const totalSec = Math.floor(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
// ========== Toast ==========
function ToastNotification({ toast, onClose }) {
useEffect(() => {
if (toast) {
const t = setTimeout(onClose, 3000);
return () => clearTimeout(t);
}
}, [toast]);
if (!toast) return null;
const colors = toast.type === 'error' ? 'bg-red-500' : toast.type === 'warning' ? 'bg-yellow-500' : 'bg-green-500';
return (
<div className={`fixed top-4 right-4 z-50 ${colors} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-2 max-w-sm`}>
<span className="flex-1 text-sm">{toast.message}</span>
<button onClick={onClose} className="text-white/80 hover:text-white">&times;</button>
</div>
);
}
// ========== AlertModal ==========
function AlertModal({ show, title, message, icon, onClose }) {
if (!show) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-xl shadow-2xl max-w-sm w-full mx-4 p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex flex-col items-center text-center">
{icon === 'warning' && (
<div className="w-14 h-14 rounded-full bg-yellow-100 flex items-center justify-center mb-4">
<svg className="w-7 h-7 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.072 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
</div>
)}
{icon === 'info' && (
<div className="w-14 h-14 rounded-full bg-blue-100 flex items-center justify-center mb-4">
<svg className="w-7 h-7 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
)}
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-sm text-gray-500 leading-relaxed">{message}</p>
<button onClick={onClose} className="mt-5 w-full bg-blue-600 text-white py-2.5 rounded-lg hover:bg-blue-700 transition text-sm font-medium">확인</button>
</div>
</div>
</div>
);
}
// ========== MeetingCard ==========
function MeetingCard({ meeting, onClick }) {
const st = STATUS_LABELS[meeting.status] || STATUS_LABELS.DRAFT;
const date = meeting.meeting_date ? new Date(meeting.meeting_date).toLocaleDateString('ko-KR') : '';
const participants = meeting.participants || [];
return (
<div onClick={() => onClick(meeting.id)} className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md hover:border-blue-300 transition-all cursor-pointer">
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium text-gray-900 truncate flex-1">{meeting.title}</h3>
<span className={`ml-2 px-2 py-0.5 rounded-full text-xs font-medium ${st.color}`}>{st.label}</span>
</div>
<div className="text-sm text-gray-500 space-y-1">
<div className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span>{date}</span>
{meeting.duration_seconds > 0 && <span className="text-gray-400">| {formatDuration(meeting.duration_seconds)}</span>}
</div>
{participants.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{participants.slice(0, 3).map((p, i) => (
<span key={i} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-gray-100 text-gray-600">{p}</span>
))}
{participants.length > 3 && <span className="text-xs text-gray-400">+{participants.length - 3}</span>}
</div>
)}
{meeting.folder && <div className="text-xs text-gray-400 mt-1">📁 {meeting.folder}</div>}
</div>
</div>
);
}
// ========== MeetingList ==========
function MeetingList({ onSelect, onNew, showToast }) {
const [meetings, setMeetings] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [pagination, setPagination] = useState(null);
const loadMeetings = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ per_page: 12, page });
if (search) params.set('search', search);
const res = await apiFetch(`${API.list}?${params}`);
setMeetings(res.data.data || []);
setPagination({ current_page: res.data.current_page, last_page: res.data.last_page, total: res.data.total });
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading(false);
}
}, [search]);
useEffect(() => { loadMeetings(); }, [loadMeetings]);
const handleCreate = async () => {
try {
const res = await apiFetch(API.store, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: '무제 회의록', meeting_date: new Date().toISOString().split('T')[0] }),
});
showToast('새 회의록이 생성되었습니다.');
onSelect(res.data.id);
} catch (e) {
showToast(e.message, 'error');
}
};
return (
<div className="p-4">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">회의록</h1>
<p className="text-sm text-gray-500 mt-1">AI 음성 인식 + 자동 요약 회의록</p>
</div>
<button onClick={handleCreate} className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition flex items-center gap-2 text-sm font-medium">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
회의록
</button>
</div>
<div className="mb-4">
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="회의록 검색..." className="w-full max-w-md px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm" />
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : meetings.length === 0 ? (
<div className="text-center py-20 text-gray-500">
<svg className="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /></svg>
<p className="text-lg font-medium">회의록이 없습니다</p>
<p className="text-sm mt-1"> 회의록을 만들어 음성 녹음을 시작하세요.</p>
<button onClick={handleCreate} className="mt-4 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 text-sm"> 회의록 만들기</button>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{meetings.map(m => <MeetingCard key={m.id} meeting={m} onClick={onSelect} />)}
</div>
{pagination && pagination.last_page > 1 && (
<div className="flex justify-center gap-2 mt-6">
{Array.from({ length: pagination.last_page }, (_, i) => i + 1).map(p => (
<button key={p} onClick={() => loadMeetings(p)} className={`px-3 py-1 rounded text-sm ${p === pagination.current_page ? 'bg-blue-600 text-white' : 'bg-white border text-gray-700 hover:bg-gray-50'}`}>{p}</button>
))}
</div>
)}
</>
)}
</div>
);
}
// ========== MeetingDetail ==========
function MeetingDetail({ meetingId, onBack, showToast }) {
const [meeting, setMeeting] = useState(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('conversation');
const [showSummary, setShowSummary] = useState(true);
// 녹음 상태
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [localSegments, setLocalSegments] = useState([]);
const [interimText, setInterimText] = useState('');
const [currentSpeakerIdx, setCurrentSpeakerIdx] = useState(0);
const [speakers, setSpeakers] = useState([{ name: '화자 1' }, { name: '화자 2' }]);
const [sttLanguage, setSttLanguage] = useState('ko-KR');
// 편집 상태
const [editingTitle, setEditingTitle] = useState(false);
const [titleValue, setTitleValue] = useState('');
const [saving, setSaving] = useState(false);
const [summarizing, setSummarizing] = useState(false);
const [diarizing, setDiarizing] = useState(false);
const [alertModal, setAlertModal] = useState(null);
// 마이크 감도
const [micGain, setMicGain] = useState(1.5);
// 세그먼트 편집 상태
const [editingSegments, setEditingSegments] = useState(false);
const [editBackup, setEditBackup] = useState([]);
// refs
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
const recognitionRef = useRef(null);
const streamRef = useRef(null);
const timerRef = useRef(null);
const transcriptRef = useRef(null);
const startTimeRef = useRef(null);
const audioContextRef = useRef(null);
const analyserRef = useRef(null);
const gainNodeRef = useRef(null);
const loadMeeting = useCallback(async () => {
try {
const res = await apiFetch(API.show(meetingId));
const data = res.data;
setMeeting(data);
setTitleValue(data.title);
setSttLanguage(data.stt_language || 'ko-KR');
if (data.segments && data.segments.length > 0) {
setLocalSegments(data.segments.map(s => ({
speaker_name: s.speaker_name,
text: s.text,
start_time_ms: s.start_time_ms,
end_time_ms: s.end_time_ms,
is_final: true,
})));
// 화자 목록 복원
const uniqueSpeakers = [...new Set(data.segments.map(s => s.speaker_name))];
if (uniqueSpeakers.length > 0) {
setSpeakers(uniqueSpeakers.map(n => ({ name: n })));
}
}
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading(false);
}
}, [meetingId]);
useEffect(() => { loadMeeting(); }, [loadMeeting]);
// 타이머
useEffect(() => {
if (isRecording) {
timerRef.current = setInterval(() => setRecordingTime(prev => prev + 1), 1000);
} else if (timerRef.current) {
clearInterval(timerRef.current);
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [isRecording]);
// 자동 스크롤
useEffect(() => {
if (transcriptRef.current) {
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
}
}, [localSegments, interimText]);
const currentSpeaker = speakers[currentSpeakerIdx] || speakers[0];
const speakerColor = SPEAKER_COLORS[currentSpeakerIdx % SPEAKER_COLORS.length];
const getSpeakerColor = (name) => {
const idx = speakers.findIndex(s => s.name === name);
return SPEAKER_COLORS[idx >= 0 ? idx % SPEAKER_COLORS.length : 0];
};
// ===== 녹음 시작 =====
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
sampleRate: 48000,
}
});
streamRef.current = stream;
// Web Audio API 전처리 체인
const audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000 });
audioContextRef.current = audioCtx;
const source = audioCtx.createMediaStreamSource(stream);
// GainNode: 감도 조절 (기본 1.5x)
const gainNode = audioCtx.createGain();
gainNode.gain.value = micGain;
gainNodeRef.current = gainNode;
// DynamicsCompressor: 작은 소리 증폭 + 큰 소리 억제
const compressor = audioCtx.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.knee.value = 40;
compressor.ratio.value = 12;
compressor.attack.value = 0;
compressor.release.value = 0.25;
// AnalyserNode: VU 미터용
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
analyserRef.current = analyser;
// MediaStreamDestination: 처리된 스트림
const destination = audioCtx.createMediaStreamDestination();
// 체인 연결: source → gain → compressor → analyser → destination
source.connect(gainNode);
gainNode.connect(compressor);
compressor.connect(analyser);
analyser.connect(destination);
const processedStream = destination.stream;
// MediaRecorder (처리된 스트림 사용)
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus' : 'audio/webm';
const recorder = new MediaRecorder(processedStream, { mimeType, audioBitsPerSecond: 128000 });
audioChunksRef.current = [];
recorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunksRef.current.push(e.data); };
recorder.start();
mediaRecorderRef.current = recorder;
// Web Speech API
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SpeechRecognition) {
const recognition = new SpeechRecognition();
recognition.lang = sttLanguage;
recognition.continuous = true;
recognition.interimResults = true;
recognition.maxAlternatives = 1;
recognition.onresult = (event) => {
let currentInterim = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const text = event.results[i][0].transcript;
if (event.results[i].isFinal) {
const now = Date.now();
const startMs = startTimeRef.current ? now - startTimeRef.current : 0;
setLocalSegments(prev => [...prev, {
speaker_name: currentSpeaker.name,
text: text.trim(),
start_time_ms: startMs,
end_time_ms: null,
is_final: true,
}]);
currentInterim = '';
} else {
currentInterim = text;
}
}
setInterimText(currentInterim);
};
recognition.onerror = (event) => {
if (event.error === 'no-speech' || event.error === 'aborted') return;
console.warn('STT error:', event.error);
};
recognition.onend = () => {
if (isRecordingRef.current && recognitionRef.current) {
try { recognitionRef.current.start(); } catch (e) {}
}
};
recognition.start();
recognitionRef.current = recognition;
}
startTimeRef.current = Date.now();
setRecordingTime(0);
setIsRecording(true);
} catch (e) {
showToast('마이크 접근 권한이 필요합니다.', 'error');
}
};
// isRecording ref (onend에서 접근)
const isRecordingRef = useRef(false);
useEffect(() => { isRecordingRef.current = isRecording; }, [isRecording]);
// ===== 녹음 중지 =====
const stopRecording = async () => {
setIsRecording(false);
if (recognitionRef.current) {
recognitionRef.current.onend = null;
recognitionRef.current.stop();
recognitionRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop());
streamRef.current = null;
}
// AudioContext 정리
if (audioContextRef.current) {
audioContextRef.current.close().catch(() => {});
audioContextRef.current = null;
analyserRef.current = null;
gainNodeRef.current = null;
}
// MediaRecorder 중지 → blob 생성
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== 'inactive') {
await new Promise(resolve => {
recorder.onstop = resolve;
recorder.stop();
});
}
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const duration = recordingTime;
setSaving(true);
try {
// 1. 세그먼트 임시 저장 (Web Speech API 결과)
const segmentsToSave = localSegments
.filter(s => s.is_final && s.text && s.text.trim())
.map((s, i) => ({
speaker_name: s.speaker_name || '화자 1',
text: s.text.trim(),
start_time_ms: Math.round(s.start_time_ms || 0),
end_time_ms: s.end_time_ms ? Math.round(s.end_time_ms) : null,
is_manual_speaker: true,
}));
if (segmentsToSave.length > 0) {
await apiFetch(API.saveSegments(meetingId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ segments: segmentsToSave }),
});
}
// 2. 오디오 업로드
let audioUploaded = false;
if (audioBlob.size > 0 && duration > 0) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
formData.append('duration_seconds', duration);
await apiFetch(API.uploadAudio(meetingId), {
method: 'POST',
body: formData,
});
audioUploaded = true;
}
// 3. STT 사용량 로깅
if (duration > 0) {
await apiFetch(API.logSttUsage, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duration_seconds: duration }),
}).catch(() => {});
}
showToast('녹음이 저장되었습니다.');
setSaving(false);
// 4. 자동 화자 분리 (오디오 업로드 완료 시)
if (audioUploaded) {
setDiarizing(true);
try {
const diarizeRes = await apiFetch(API.diarize(meetingId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ min_speakers: 2, max_speakers: Math.max(speakers.length, 2) }),
});
if (diarizeRes.data && diarizeRes.data.segments && diarizeRes.data.segments.length > 0) {
setLocalSegments(diarizeRes.data.segments.map(s => ({
speaker_name: s.speaker_name,
text: s.text,
start_time_ms: s.start_time_ms,
end_time_ms: s.end_time_ms,
is_final: true,
})));
const uniqueSpeakers = [...new Set(diarizeRes.data.segments.map(s => s.speaker_name))];
if (uniqueSpeakers.length > 0) {
setSpeakers(uniqueSpeakers.map(n => ({ name: n })));
}
}
showToast(`화자 분리 완료 (${diarizeRes.speaker_count || 1}명 감지)`);
} catch (e) {
showToast('자동 화자 분리 실패: ' + e.message, 'warning');
} finally {
setDiarizing(false);
}
}
// 5. 자동 AI 요약
setSummarizing(true);
try {
await apiFetch(API.summarize(meetingId), { method: 'POST' });
showToast('AI 요약이 완료되었습니다.');
} catch (e) {
showToast('AI 요약 실패: ' + e.message, 'warning');
} finally {
setSummarizing(false);
}
// 새로고침
await loadMeeting();
} catch (e) {
showToast('저장 실패: ' + e.message, 'error');
setSaving(false);
}
};
// ===== 화자 전환 =====
const switchSpeaker = (idx) => {
setCurrentSpeakerIdx(idx);
};
const addSpeaker = () => {
if (speakers.length >= 4) return;
const newIdx = speakers.length;
setSpeakers(prev => [...prev, { name: `화자 ${newIdx + 1}` }]);
};
// ===== 제목 인라인 편집 =====
const saveTitle = async () => {
if (!titleValue.trim()) return;
try {
await apiFetch(API.update(meetingId), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: titleValue.trim() }),
});
setMeeting(prev => ({ ...prev, title: titleValue.trim() }));
setEditingTitle(false);
} catch (e) {
showToast(e.message, 'error');
}
};
// ===== 삭제 =====
const handleDelete = async () => {
if (!confirm('이 회의록을 삭제하시겠습니까?')) return;
try {
await apiFetch(API.destroy(meetingId), { method: 'DELETE' });
showToast('회의록이 삭제되었습니다.');
onBack();
} catch (e) {
showToast(e.message, 'error');
}
};
// ===== 노트 복사 =====
const copyNotes = () => {
const lines = localSegments.filter(s => s.is_final).map(s => `[${s.speaker_name}] ${s.text}`);
const text = lines.join('\n');
navigator.clipboard.writeText(text).then(() => showToast('클립보드에 복사되었습니다.'));
};
// ===== 세그먼트 편집 =====
const cleanSegmentText = (text) => text.replace(/[_\u2581]/g, ' ').replace(/\s{2,}/g, ' ').trim();
const startEditingSegments = () => {
setEditBackup(localSegments.map(s => ({ ...s })));
setLocalSegments(prev => prev.map(s => ({ ...s, text: cleanSegmentText(s.text) })));
setEditingSegments(true);
};
const cancelEditingSegments = () => {
setLocalSegments(editBackup);
setEditingSegments(false);
setEditBackup([]);
};
const saveEditedSegments = async () => {
setSaving(true);
try {
const segmentsToSave = localSegments
.filter(s => s.is_final && s.text && s.text.trim())
.map(s => ({
speaker_name: s.speaker_name || '화자 1',
text: s.text.trim(),
start_time_ms: Math.round(s.start_time_ms || 0),
end_time_ms: s.end_time_ms ? Math.round(s.end_time_ms) : null,
is_manual_speaker: true,
}));
await apiFetch(API.saveSegments(meetingId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ segments: segmentsToSave }),
});
showToast('대화 기록이 저장되었습니다.');
setEditingSegments(false);
setEditBackup([]);
await loadMeeting();
} catch (e) {
showToast('저장 실패: ' + e.message, 'error');
} finally {
setSaving(false);
}
};
const handleEditText = (segIdx, newText) => {
setLocalSegments(prev => prev.map((s, i) => i === segIdx ? { ...s, text: newText } : s));
};
const handleEditSpeaker = (segIdx, newSpeaker) => {
setLocalSegments(prev => prev.map((s, i) => i === segIdx ? { ...s, speaker_name: newSpeaker } : s));
};
const handleDeleteSegment = (segIdx) => {
setLocalSegments(prev => prev.filter((_, i) => i !== segIdx));
};
// ===== 수동 요약 =====
const handleSummarize = async () => {
const finalSegments = localSegments.filter(s => s.is_final && s.text && s.text.trim());
const hasContent = finalSegments.length > 0 || (meeting && meeting.full_transcript);
if (!hasContent) {
setAlertModal({
title: '대화 내용이 없습니다',
message: '요약을 실행하려면 먼저 녹음을 진행하여 대화 내용을 기록해주세요.',
icon: 'warning',
});
return;
}
setSummarizing(true);
try {
// 로컬 세그먼트가 있으면 먼저 서버에 저장 (full_transcript 업데이트)
if (finalSegments.length > 0) {
const segmentsToSave = finalSegments.map((s, i) => ({
speaker_name: s.speaker_name || '화자 1',
text: s.text.trim(),
start_time_ms: Math.round(s.start_time_ms || 0),
end_time_ms: s.end_time_ms ? Math.round(s.end_time_ms) : null,
is_manual_speaker: true,
}));
await apiFetch(API.saveSegments(meetingId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ segments: segmentsToSave }),
});
}
await apiFetch(API.summarize(meetingId), { method: 'POST' });
showToast('AI 요약이 완료되었습니다.');
await loadMeeting();
} catch (e) {
showToast(e.message, 'error');
} finally {
setSummarizing(false);
}
};
// ===== 자동 화자 분리 =====
const handleDiarize = async () => {
if (!meeting || !meeting.audio_gcs_uri) {
setAlertModal({
title: '오디오 파일이 없습니다',
message: '자동 화자 분리를 실행하려면 먼저 녹음을 진행하여 오디오를 저장해주세요.',
icon: 'warning',
});
return;
}
if (!confirm('자동 화자 분리를 실행하면 기존 대화 기록이 새로운 결과로 교체됩니다. 계속하시겠습니까?')) {
return;
}
setDiarizing(true);
try {
const res = await apiFetch(API.diarize(meetingId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ min_speakers: 2, max_speakers: speakers.length || 4 }),
});
showToast(res.message || '자동 화자 분리가 완료되었습니다.');
await loadMeeting();
} catch (e) {
showToast('화자 분리 실패: ' + e.message, 'error');
} finally {
setDiarizing(false);
}
};
if (loading) {
return <div className="flex items-center justify-center h-96"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>;
}
if (!meeting) {
return <div className="text-center py-20 text-gray-500">회의록을 찾을 없습니다.</div>;
}
const segments = localSegments
.map((s, i) => ({ ...s, __origIdx: i }))
.filter(s => s.is_final);
return (
<div className="flex flex-col" style={{height: 'calc(100vh - 112px)', margin: '-24px'}}>
{/* Alert Modal */}
<AlertModal show={!!alertModal} title={alertModal?.title} message={alertModal?.message} icon={alertModal?.icon} onClose={() => setAlertModal(null)} />
{/* Header */}
<div className="bg-white border-b px-4 py-3 flex-shrink-0">
<div className="flex items-center gap-3">
<button onClick={onBack} className="text-gray-500 hover:text-gray-700 p-1">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
</button>
{editingTitle ? (
<input value={titleValue} onChange={(e) => setTitleValue(e.target.value)} onBlur={saveTitle} onKeyDown={(e) => e.key === 'Enter' && saveTitle()} autoFocus className="text-lg font-bold border-b-2 border-blue-500 outline-none bg-transparent flex-1" />
) : (
<h1 onClick={() => setEditingTitle(true)} className="text-lg font-bold text-gray-900 cursor-pointer hover:text-blue-600 flex-1 truncate">{meeting.title}</h1>
)}
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={copyNotes} className="text-gray-500 hover:text-gray-700 text-sm flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100" title="노트 복사">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>
복사
</button>
{meeting.audio_file_path && (
<a href={API.downloadAudio(meetingId)} className="text-gray-500 hover:text-gray-700 text-sm flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100" title="음성 다운로드">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
</a>
)}
<button onClick={handleDelete} className="text-red-400 hover:text-red-600 p-1" title="삭제">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
</div>
<div className="flex items-center gap-3 mt-2 text-sm text-gray-500 flex-wrap">
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
{meeting.meeting_date ? new Date(meeting.meeting_date).toLocaleDateString('ko-KR') : ''}
</span>
{meeting.duration_seconds > 0 && (
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{formatDuration(meeting.duration_seconds)}
</span>
)}
{meeting.participants && meeting.participants.length > 0 && (
<div className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
{meeting.participants.join(', ')}
</div>
)}
{meeting.status && <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${(STATUS_LABELS[meeting.status] || STATUS_LABELS.DRAFT).color}`}>{(STATUS_LABELS[meeting.status] || STATUS_LABELS.DRAFT).label}</span>}
</div>
</div>
{/* Tab Bar */}
<div className="bg-white border-b px-4 flex items-center gap-4 flex-shrink-0">
<button onClick={() => setActiveTab('conversation')} className={`py-2.5 text-sm font-medium border-b-2 transition ${activeTab === 'conversation' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>대화 기록</button>
<button onClick={() => { if (editingSegments) cancelEditingSegments(); setActiveTab('script'); }} className={`py-2.5 text-sm font-medium border-b-2 transition ${activeTab === 'script' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>스크립트</button>
<div className="flex-1"></div>
{activeTab === 'conversation' && !isRecording && segments.length > 0 && !editingSegments && (
<button onClick={startEditingSegments} className="py-2 px-3 text-sm font-medium text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded transition flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
편집
</button>
)}
{editingSegments && (
<>
<button onClick={cancelEditingSegments} className="py-2 px-3 text-sm font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded transition">
취소
</button>
<button onClick={saveEditedSegments} disabled={saving} className="py-2 px-3 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded transition disabled:opacity-50 flex items-center gap-1">
{saving ? <div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white"></div> : <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>}
저장
</button>
</>
)}
<button onClick={() => setShowSummary(!showSummary)} className={`py-2.5 text-sm font-medium flex items-center gap-1 ${showSummary ? 'text-blue-600' : 'text-gray-500'}`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
AI 요약
</button>
</div>
{/* Main Content */}
<div className="flex flex-1 overflow-hidden">
{/* Left: Transcript */}
<div className="flex-1 overflow-y-auto p-4" ref={transcriptRef}>
{activeTab === 'conversation' ? (
<ConversationView segments={segments} interimText={interimText} isRecording={isRecording} currentSpeaker={currentSpeaker} getSpeakerColor={getSpeakerColor} editing={editingSegments} onEditText={handleEditText} onEditSpeaker={handleEditSpeaker} onDeleteSegment={handleDeleteSegment} speakers={speakers} />
) : (
<ScriptView segments={segments} interimText={interimText} isRecording={isRecording} />
)}
</div>
{/* Right: Summary Panel */}
{showSummary && (
<div className="w-80 border-l bg-gray-50 overflow-y-auto flex-shrink-0">
<SummaryPanel meeting={meeting} onSummarize={handleSummarize} summarizing={summarizing} />
</div>
)}
</div>
{/* Bottom: Recording Control Bar */}
<RecordingControlBar
isRecording={isRecording}
recordingTime={recordingTime}
currentSpeakerIdx={currentSpeakerIdx}
speakers={speakers}
sttLanguage={sttLanguage}
onStart={startRecording}
onStop={stopRecording}
onSwitchSpeaker={switchSpeaker}
onAddSpeaker={addSpeaker}
onLanguageChange={setSttLanguage}
onSummarize={handleSummarize}
onDiarize={handleDiarize}
saving={saving}
summarizing={summarizing}
diarizing={diarizing}
hasSegments={segments.length > 0}
hasAudio={!!meeting?.audio_gcs_uri}
analyserRef={analyserRef}
micGain={micGain}
onMicGainChange={setMicGain}
gainNodeRef={gainNodeRef}
/>
</div>
);
}
// ========== AutoResizeTextarea ==========
function AutoResizeTextarea({ value, onChange, className }) {
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
ref.current.style.height = 'auto';
ref.current.style.height = ref.current.scrollHeight + 'px';
}
}, [value]);
return (
<textarea
ref={ref}
value={value}
onChange={onChange}
rows={1}
className={className}
style={{ resize: 'none', overflow: 'hidden' }}
/>
);
}
// ========== ConversationView ==========
function ConversationView({ segments, interimText, isRecording, currentSpeaker, getSpeakerColor, editing, onEditText, onEditSpeaker, onDeleteSegment, speakers }) {
if (segments.length === 0 && !interimText) {
return (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /></svg>
<p className="text-lg">녹음 버튼을 눌러 시작하세요</p>
<p className="text-sm mt-1">실시간 음성 인식이 대화를 기록합니다</p>
</div>
);
}
// 편집 모드: 개별 세그먼트 렌더링
if (editing) {
// segments는 filter된 것이므로 원본 인덱스를 찾기 위해 전체 localSegments의 is_final 기준 인덱스 사용
// 부모에서 segments = localSegments.filter(s => s.is_final)로 전달하므로, segIdx는 filter된 배열이 아닌 원본 인덱스여야 함
// 하지만 onEditText 등은 localSegments 전체 배열의 인덱스를 사용하므로, 실제 인덱스를 매핑해야 함
// segments 배열은 is_final만 필터된 것이므로 원본 localSegments에서의 인덱스를 별도로 추적
return (
<div className="space-y-2">
<div className="text-xs text-gray-400 mb-2 flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
텍스트, 화자를 수정하거나 세그먼트를 삭제할 있습니다.
</div>
{segments.map((seg, filteredIdx) => {
const color = getSpeakerColor(seg.speaker_name);
// 원본 localSegments에서의 실제 인덱스 계산
// segments는 부모에서 localSegments.filter(s => s.is_final)로 전달됨
// filteredIdx → 원본 인덱스 매핑 필요
// seg 객체 자체가 localSegments의 참조이므로, 원본에서 찾기
const origIdx = seg.__origIdx !== undefined ? seg.__origIdx : filteredIdx;
return (
<div key={filteredIdx} className={`rounded-lg border-2 p-3 ${color.bg} ${color.border}`}>
<div className="flex items-center gap-2 mb-2">
<select
value={seg.speaker_name}
onChange={(e) => onEditSpeaker(origIdx, e.target.value)}
className={`text-xs font-medium px-2 py-1 rounded-full border ${color.badge} cursor-pointer`}
>
{(speakers || []).map((sp, si) => (
<option key={si} value={sp.name}>{sp.name}</option>
))}
</select>
{seg.start_time_ms > 0 && (
<span className="text-xs text-gray-400">{formatMs(seg.start_time_ms)}</span>
)}
<div className="flex-1"></div>
<button
onClick={() => onDeleteSegment(origIdx)}
className="text-red-400 hover:text-red-600 hover:bg-red-50 p-1 rounded transition"
title="세그먼트 삭제"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<AutoResizeTextarea
value={seg.text}
onChange={(e) => onEditText(origIdx, e.target.value)}
className="w-full text-sm text-gray-800 leading-relaxed bg-white/70 border border-gray-200 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:border-blue-400 outline-none"
/>
</div>
);
})}
</div>
);
}
// 읽기 모드: 연속 같은 화자 세그먼트 그룹화
const groups = [];
segments.forEach((seg, i) => {
if (i === 0 || seg.speaker_name !== segments[i - 1].speaker_name) {
groups.push({ speaker_name: seg.speaker_name, texts: [seg] });
} else {
groups[groups.length - 1].texts.push(seg);
}
});
return (
<div className="space-y-3">
{groups.map((group, gi) => {
const color = getSpeakerColor(group.speaker_name);
return (
<div key={gi} className={`rounded-lg border p-3 ${color.bg} ${color.border}`}>
<div className="flex items-center gap-2 mb-1.5">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${color.badge}`}>
<span className={`w-2 h-2 rounded-full ${color.dot} mr-1`}></span>
{group.speaker_name}
</span>
{group.texts[0].start_time_ms > 0 && (
<span className="text-xs text-gray-400">{formatMs(group.texts[0].start_time_ms)}</span>
)}
</div>
<div className="text-sm text-gray-800 leading-relaxed">
{group.texts.map((t, ti) => <span key={ti}>{ti > 0 ? ' ' : ''}{t.text.replace(/[_\u2581]/g, ' ').replace(/\s{2,}/g, ' ').trim()}</span>)}
</div>
</div>
);
})}
{/* Interim (미확정) */}
{isRecording && interimText && (
<div className="rounded-lg border-2 border-dashed border-gray-300 p-3 bg-white">
<div className="flex items-center gap-2 mb-1.5">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getSpeakerColor(currentSpeaker.name).badge}`}>
<span className={`w-2 h-2 rounded-full bg-red-500 mr-1 animate-pulse`}></span>
{currentSpeaker.name}
</span>
<span className="text-xs text-gray-400">인식 ...</span>
</div>
<div className="text-sm text-gray-400 italic leading-relaxed">{interimText}</div>
</div>
)}
</div>
);
}
// ========== ScriptView ==========
function ScriptView({ segments, interimText, isRecording }) {
if (segments.length === 0 && !interimText) {
return <div className="text-center py-20 text-gray-400">스크립트가 없습니다.</div>;
}
return (
<div className="prose prose-sm max-w-none">
<div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
{segments.map((s, i) => <span key={i}>{s.text.replace(/[_\u2581]/g, ' ').replace(/\s{2,}/g, ' ').trim()}{i < segments.length - 1 ? ' ' : ''}</span>)}
{isRecording && interimText && <span className="text-gray-400 italic"> {interimText}</span>}
</div>
</div>
);
}
// ========== SummaryPanel ==========
function SummaryPanel({ meeting, onSummarize, summarizing }) {
const hasSummary = meeting.summary || (meeting.decisions && meeting.decisions.length > 0) || (meeting.action_items && meeting.action_items.length > 0);
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-900 text-sm">AI 요약</h3>
<button onClick={onSummarize} disabled={summarizing} className="text-xs text-blue-600 hover:text-blue-700 disabled:text-gray-400 flex items-center gap-1">
{summarizing ? (
<><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-600"></div> 요약 ...</>
) : (
<><svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg> 요약 실행</>
)}
</button>
</div>
{!hasSummary ? (
<div className="text-center py-8 text-gray-400">
<svg className="w-10 h-10 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
<p className="text-sm">녹음 완료 AI가 자동으로 요약합니다</p>
</div>
) : (
<div className="space-y-4">
{meeting.summary && (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">회의 요약</h4>
<p className="text-sm text-gray-700 leading-relaxed">{meeting.summary}</p>
</div>
)}
{meeting.decisions && meeting.decisions.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">결정사항</h4>
<ul className="space-y-1">
{meeting.decisions.map((d, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
<span className="text-green-500 mt-0.5">&#10003;</span>
<span>{d}</span>
</li>
))}
</ul>
</div>
)}
{meeting.action_items && meeting.action_items.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">액션 아이템</h4>
<ul className="space-y-2">
{meeting.action_items.map((item, i) => (
<li key={i} className="bg-white rounded border p-2">
<div className="text-sm text-gray-800">{item.task || item}</div>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
{item.assignee && <span className="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">{item.assignee}</span>}
{item.deadline && <span className="bg-orange-50 text-orange-700 px-1.5 py-0.5 rounded">{item.deadline}</span>}
</div>
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
}
// ========== AudioLevelMeter ==========
function AudioLevelMeter({ analyserRef, isRecording }) {
const canvasRef = useRef(null);
const animFrameRef = useRef(null);
useEffect(() => {
if (!isRecording || !analyserRef?.current) {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
// 녹음 중지 시 캔버스 클리어
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
return;
}
const analyser = analyserRef.current;
const dataArray = new Uint8Array(analyser.fftSize);
const draw = () => {
analyser.getByteTimeDomainData(dataArray);
// RMS 계산
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
const v = (dataArray[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / dataArray.length);
const level = Math.min(1, rms * 3); // 0~1 정규화
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
// 바 그리기
const barWidth = Math.round(w * level);
const gradient = ctx.createLinearGradient(0, 0, w, 0);
gradient.addColorStop(0, '#22c55e');
gradient.addColorStop(0.6, '#eab308');
gradient.addColorStop(1, '#ef4444');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, barWidth, h);
// 배경 바
ctx.fillStyle = 'rgba(0,0,0,0.06)';
ctx.fillRect(barWidth, 0, w - barWidth, h);
animFrameRef.current = requestAnimationFrame(draw);
};
draw();
return () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, [isRecording, analyserRef?.current]);
return <canvas ref={canvasRef} width={120} height={12} className="rounded-full" style={{ display: 'block' }} />;
}
// ========== RecordingControlBar ==========
function RecordingControlBar({ isRecording, recordingTime, currentSpeakerIdx, speakers, sttLanguage, onStart, onStop, onSwitchSpeaker, onAddSpeaker, onLanguageChange, onSummarize, onDiarize, saving, summarizing, diarizing, hasSegments, hasAudio, analyserRef, micGain, onMicGainChange, gainNodeRef }) {
return (
<div className="bg-white border-t shadow-lg px-4 py-3 flex-shrink-0">
<div className="flex items-center justify-between gap-3">
{/* Language */}
<div className="flex items-center gap-2">
<select value={sttLanguage} onChange={(e) => onLanguageChange(e.target.value)} disabled={isRecording} className="text-sm border rounded px-2 py-1.5 bg-white disabled:bg-gray-100 disabled:text-gray-400">
{LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
</select>
</div>
{/* Speaker Selection */}
<div className="flex items-center gap-1">
{speakers.map((sp, idx) => {
const c = SPEAKER_COLORS[idx % SPEAKER_COLORS.length];
return (
<button key={idx} onClick={() => onSwitchSpeaker(idx)} className={`px-3 py-1.5 rounded-full text-xs font-medium transition ${currentSpeakerIdx === idx ? `${c.badge} ring-2 ring-offset-1 ring-blue-400` : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{sp.name}
</button>
);
})}
{speakers.length < 4 && (
<button onClick={onAddSpeaker} disabled={isRecording} className="px-2 py-1.5 rounded-full text-xs text-gray-400 hover:text-gray-600 hover:bg-gray-100 disabled:opacity-50" title="화자 추가">+</button>
)}
</div>
{/* Mic Gain Slider */}
{isRecording && (
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /></svg>
<input
type="range"
min="0.5"
max="3.0"
step="0.1"
value={micGain}
onChange={(e) => {
const val = parseFloat(e.target.value);
onMicGainChange(val);
if (gainNodeRef?.current) gainNodeRef.current.gain.value = val;
}}
className="w-16 h-1 accent-blue-500"
title={`감도 ${micGain.toFixed(1)}x`}
/>
<span className="text-xs text-gray-400 min-w-[28px]">{micGain.toFixed(1)}x</span>
</div>
)}
{/* Record Button + VU Meter */}
<div className="flex items-center gap-3">
{isRecording ? (
<button onClick={onStop} disabled={saving} className="bg-red-600 text-white px-5 py-2 rounded-full hover:bg-red-700 transition flex items-center gap-2 text-sm font-medium shadow-lg disabled:opacity-50">
<span className="w-3 h-3 bg-white rounded-sm"></span>
중지
</button>
) : (
<button onClick={onStart} disabled={saving || diarizing} className="bg-red-500 text-white px-5 py-2 rounded-full hover:bg-red-600 transition flex items-center gap-2 text-sm font-medium shadow-lg disabled:opacity-50">
<span className="w-3 h-3 bg-white rounded-full"></span>
녹음
</button>
)}
{isRecording && <AudioLevelMeter analyserRef={analyserRef} isRecording={isRecording} />}
<span className="text-sm font-mono text-gray-600 min-w-[60px]">{formatDuration(recordingTime)}</span>
{isRecording && <span className="flex items-center gap-1 text-xs text-red-500"><span className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>REC</span>}
</div>
{/* Status indicators */}
<div className="flex items-center gap-2">
{saving && <span className="text-xs text-blue-500 flex items-center gap-1"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500"></div>저장 ...</span>}
{summarizing && <span className="text-xs text-purple-500 flex items-center gap-1"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-purple-500"></div>요약 ...</span>}
{diarizing && <span className="text-xs text-indigo-500 flex items-center gap-1"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-indigo-500"></div>화자 분리 ...</span>}
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
{/* 자동 화자 분리 버튼 */}
<button onClick={onDiarize} disabled={diarizing || summarizing || isRecording || !hasAudio} className="bg-indigo-600 text-white px-3 py-1.5 rounded-lg hover:bg-indigo-700 transition text-xs font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1" title="업로드된 오디오에서 AI로 자동 화자 구분">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
화자 분리
</button>
{/* AI 요약 버튼 */}
<button onClick={onSummarize} disabled={summarizing || diarizing || isRecording || !hasSegments} className="bg-purple-600 text-white px-3 py-1.5 rounded-lg hover:bg-purple-700 transition text-xs font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
AI 요약
</button>
</div>
</div>
</div>
);
}
// ========== App ==========
function App() {
const [currentView, setCurrentView] = useState('list');
const [selectedId, setSelectedId] = useState(null);
const [toast, setToast] = useState(null);
const showToast = useCallback((message, type = 'success') => {
setToast({ message, type });
}, []);
const handleSelect = (id) => {
setSelectedId(id);
setCurrentView('detail');
};
const handleBack = () => {
setSelectedId(null);
setCurrentView('list');
};
return (
<>
<ToastNotification toast={toast} onClose={() => setToast(null)} />
{currentView === 'list' ? (
<MeetingList onSelect={handleSelect} showToast={showToast} />
) : (
<MeetingDetail meetingId={selectedId} onBack={handleBack} showToast={showToast} />
)}
</>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
@endverbatim
</script>
@endpush