Files
sam-manage/resources/views/lab/ai/web-recording.blade.php

554 lines
19 KiB
PHP

@extends('layouts.app')
@section('title', '웹 녹음 AI 요약')
@push('styles')
<style>
.recording-container {
max-width: 900px;
margin: 0 auto;
}
.record-button {
width: 100px;
height: 100px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 24px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.record-button:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.record-button.recording {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
animation: pulse 1.5s ease-in-out infinite;
}
.record-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4); }
50% { box-shadow: 0 4px 30px rgba(245, 87, 108, 0.8); }
}
.timer {
font-size: 2.5rem;
font-weight: bold;
font-family: 'Courier New', monospace;
min-height: 50px;
color: #374151;
}
.timer.active { color: #f5576c; }
.waveform {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
}
.waveform-bar {
width: 4px;
background: #667eea;
border-radius: 2px;
animation: wave 0.5s ease-in-out infinite;
}
@keyframes wave {
0%, 100% { height: 10px; }
50% { height: 40px; }
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
.status-waiting { background: #f1f5f9; color: #64748b; }
.status-recording { background: #fee2e2; color: #dc2626; }
.status-processing { background: #dbeafe; color: #1d4ed8; }
.status-completed { background: #dcfce7; color: #16a34a; }
.status-error { background: #fee2e2; color: #dc2626; }
.meeting-card {
transition: all 0.2s ease;
cursor: pointer;
}
.meeting-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.accordion-header {
cursor: pointer;
user-select: none;
}
.accordion-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.accordion-content.open {
max-height: 500px;
}
.accordion-icon {
transition: transform 0.3s ease;
}
.accordion-icon.open {
transform: rotate(180deg);
}
</style>
@endpush
@section('content')
<div class="recording-container">
{{-- 헤더 --}}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-800 mb-2"> 녹음 AI 요약</h1>
<p class="text-gray-500">브라우저에서 녹음하고 AI가 자동으로 회의록을 작성합니다</p>
</div>
{{-- 녹음 섹션 --}}
<div class="bg-white rounded-xl shadow-md mb-8 p-8">
<div class="flex flex-col items-center text-center">
{{-- 타이머 --}}
<div class="timer" id="timer">00:00</div>
{{-- 파형 (녹음 중에만 표시) --}}
<div class="waveform hidden mt-4" id="waveform">
@for($i = 0; $i < 20; $i++)
<div class="waveform-bar" style="animation-delay: {{ $i * 0.05 }}s"></div>
@endfor
</div>
{{-- 상태 표시 --}}
<div class="my-4">
<span class="status-badge status-waiting" id="statusBadge">대기 </span>
</div>
{{-- 녹음 버튼 --}}
<div class="flex gap-4 items-center">
<button class="record-button" id="recordBtn">
<svg id="micIcon" class="w-10 h-10 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
<svg id="stopIcon" class="w-10 h-10 mx-auto hidden" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
</div>
{{-- 안내 텍스트 --}}
<p class="text-sm text-gray-500 mt-4" id="helpText">
버튼을 클릭하여 녹음을 시작하세요
</p>
</div>
</div>
{{-- 처리 상태 (숨김) --}}
<div class="bg-white rounded-xl shadow-md mb-8 p-8 hidden" id="processingCard">
<div class="flex flex-col items-center text-center">
<div class="spinner"></div>
<h3 class="text-lg font-semibold mt-4 text-gray-800">AI가 회의록을 작성하고 있습니다</h3>
<p class="text-sm text-gray-500 mt-2" id="processingStatus">음성을 텍스트로 변환 ...</p>
<div class="w-56 h-2 bg-gray-200 rounded-full mt-4 overflow-hidden">
<div class="h-full bg-blue-500 rounded-full transition-all" id="progressBar" style="width: 0%"></div>
</div>
</div>
</div>
{{-- 결과 섹션 (숨김) --}}
<div class="hidden" id="resultSection">
<div class="bg-white rounded-xl shadow-md mb-6 p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
AI 요약 결과
</h2>
<button class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" onclick="newRecording()"> 녹음</button>
</div>
{{-- 제목 입력 --}}
<div class="mb-4">
<input type="text" id="meetingTitle" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="회의록 제목을 입력하세요" />
</div>
{{-- 요약 내용 --}}
<div class="bg-gray-50 rounded-lg p-4 mb-4">
<h3 class="font-semibold text-gray-800 mb-2">요약</h3>
<div id="summaryContent" class="text-gray-700 prose max-w-none"></div>
</div>
{{-- 원본 텍스트 (아코디언) --}}
<div class="bg-gray-50 rounded-lg overflow-hidden">
<div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
<span class="font-medium text-gray-800">원본 텍스트 보기</span>
<svg class="accordion-icon w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<div class="accordion-content">
<div id="transcriptContent" class="px-4 pb-4 text-sm text-gray-600 whitespace-pre-wrap"></div>
</div>
</div>
</div>
</div>
{{-- 최근 회의록 목록 --}}
<div class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
최근 회의록
</h2>
<div id="meetingList" hx-get="{{ route('api.admin.meeting-logs.index') }}" hx-trigger="load" hx-swap="innerHTML">
<div class="flex justify-center py-8">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
let mediaRecorder = null;
let audioChunks = [];
let timerInterval = null;
let startTime = null;
let currentMeetingId = null;
// 아코디언 토글
function toggleAccordion(header) {
const content = header.nextElementSibling;
const icon = header.querySelector('.accordion-icon');
content.classList.toggle('open');
icon.classList.toggle('open');
}
// 녹음 토글
async function toggleRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
stopRecording();
} else {
await startRecording();
}
}
// 녹음 시작
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
await processAudio(audioBlob);
stream.getTracks().forEach(track => track.stop());
};
// 회의록 생성
const response = await fetch('{{ route("api.admin.meeting-logs.store") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ title: '무제 회의록' })
});
const result = await response.json();
if (result.success) {
currentMeetingId = result.data.id;
}
mediaRecorder.start(1000);
startTime = Date.now();
updateUI('recording');
startTimer();
} catch (error) {
console.error('녹음 시작 실패:', error);
showToast('마이크 접근 권한이 필요합니다.', 'error');
}
}
// 녹음 중지
function stopRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
stopTimer();
updateUI('processing');
}
}
// 오디오 처리
async function processAudio(audioBlob) {
const duration = Math.floor((Date.now() - startTime) / 1000);
// Base64 변환
const reader = new FileReader();
reader.onloadend = async () => {
const base64Audio = reader.result;
try {
updateProgress(10);
document.getElementById('processingStatus').textContent = '서버에 업로드 중...';
const response = await fetch(`/api/meeting-logs/${currentMeetingId}/process`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
audio: base64Audio,
duration: duration
})
});
updateProgress(100);
const result = await response.json();
if (result.success) {
showResult(result.data);
} else {
throw new Error(result.message || '처리 실패');
}
} catch (error) {
console.error('처리 실패:', error);
showToast('처리 중 오류가 발생했습니다: ' + error.message, 'error');
updateUI('ready');
}
};
reader.readAsDataURL(audioBlob);
}
// 진행률 업데이트
function updateProgress(percent) {
document.getElementById('progressBar').style.width = percent + '%';
}
// 결과 표시
function showResult(meeting) {
document.getElementById('meetingTitle').value = meeting.title || '';
document.getElementById('summaryContent').innerHTML = marked.parse(meeting.summary_text || '요약 내용이 없습니다.');
document.getElementById('transcriptContent').textContent = meeting.transcript_text || '';
updateUI('result');
// 목록 새로고침
refreshMeetingList();
}
// UI 업데이트
function updateUI(state) {
const recordBtn = document.getElementById('recordBtn');
const micIcon = document.getElementById('micIcon');
const stopIcon = document.getElementById('stopIcon');
const waveform = document.getElementById('waveform');
const statusBadge = document.getElementById('statusBadge');
const helpText = document.getElementById('helpText');
const processingCard = document.getElementById('processingCard');
const resultSection = document.getElementById('resultSection');
switch (state) {
case 'recording':
recordBtn.classList.add('recording');
micIcon.classList.add('hidden');
stopIcon.classList.remove('hidden');
waveform.classList.remove('hidden');
statusBadge.textContent = '녹음 중';
statusBadge.className = 'status-badge status-recording';
helpText.textContent = '버튼을 클릭하여 녹음을 종료하세요';
processingCard.classList.add('hidden');
resultSection.classList.add('hidden');
break;
case 'processing':
recordBtn.classList.remove('recording');
recordBtn.disabled = true;
micIcon.classList.remove('hidden');
stopIcon.classList.add('hidden');
waveform.classList.add('hidden');
statusBadge.textContent = '처리 중';
statusBadge.className = 'status-badge status-processing';
helpText.textContent = 'AI가 회의록을 작성하고 있습니다...';
processingCard.classList.remove('hidden');
resultSection.classList.add('hidden');
updateProgress(0);
break;
case 'result':
recordBtn.classList.remove('recording');
recordBtn.disabled = false;
micIcon.classList.remove('hidden');
stopIcon.classList.add('hidden');
waveform.classList.add('hidden');
statusBadge.textContent = '완료';
statusBadge.className = 'status-badge status-completed';
helpText.textContent = '버튼을 클릭하여 새로운 녹음을 시작하세요';
processingCard.classList.add('hidden');
resultSection.classList.remove('hidden');
break;
default: // ready
recordBtn.classList.remove('recording');
recordBtn.disabled = false;
micIcon.classList.remove('hidden');
stopIcon.classList.add('hidden');
waveform.classList.add('hidden');
statusBadge.textContent = '대기 중';
statusBadge.className = 'status-badge status-waiting';
helpText.textContent = '버튼을 클릭하여 녹음을 시작하세요';
processingCard.classList.add('hidden');
resultSection.classList.add('hidden');
}
}
// 타이머 시작
function startTimer() {
const timerEl = document.getElementById('timer');
timerEl.classList.add('active');
timerInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
const seconds = (elapsed % 60).toString().padStart(2, '0');
timerEl.textContent = `${minutes}:${seconds}`;
}, 1000);
}
// 타이머 중지
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
document.getElementById('timer').classList.remove('active');
}
// 새 녹음
function newRecording() {
currentMeetingId = null;
document.getElementById('timer').textContent = '00:00';
updateUI('ready');
}
// 회의록 삭제
async function deleteMeeting(id) {
showDeleteConfirm('이 회의록', async () => {
try {
const response = await fetch(`/api/meeting-logs/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
const result = await response.json();
if (result.success) {
showToast('삭제되었습니다.', 'success');
refreshMeetingList();
} else {
showToast(result.message || '삭제 실패', 'error');
}
} catch (error) {
console.error('삭제 실패:', error);
showToast('삭제 중 오류가 발생했습니다.', 'error');
}
});
}
// 목록 새로고침
async function refreshMeetingList() {
try {
const response = await fetch('{{ route("api.admin.meeting-logs.index") }}', {
headers: {
'HX-Request': 'true',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
const html = await response.text();
document.getElementById('meetingList').innerHTML = html;
} catch (error) {
console.error('목록 새로고침 실패:', error);
}
}
// 회의록 상세 보기
async function viewMeeting(id) {
try {
const response = await fetch(`/api/meeting-logs/${id}/summary`, {
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
const result = await response.json();
if (result.success) {
document.getElementById('meetingTitle').value = result.data.transcript ? '회의록' : '';
document.getElementById('summaryContent').innerHTML = marked.parse(result.data.summary || '요약 내용이 없습니다.');
document.getElementById('transcriptContent').textContent = result.data.transcript || '';
updateUI('result');
}
} catch (error) {
console.error('조회 실패:', error);
showToast('조회 중 오류가 발생했습니다.', 'error');
}
}
// 녹음 버튼 이벤트 리스너
document.getElementById('recordBtn')?.addEventListener('click', toggleRecording);
</script>
{{-- Markdown 파서 --}}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
@endpush