Files
sam-kd/voice_ai_cnslt/index.php

2771 lines
96 KiB
PHP

<?php
// 출력 버퍼링 시작 (경고 메시지 방지)
ob_start();
@error_reporting(0);
@ini_set('display_errors', 0);
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
@require_once($_SERVER['DOCUMENT_ROOT'] . "/load_header.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 권한 체크
if ($level > 5) {
echo "<script>alert('접근 권한이 없습니다.'); history.back();</script>";
exit;
}
// 업무협의록 리스트 조회 (최근 10개)
$tenant_id = $user_id; // session.php에서 user_id를 tenant_id로 사용
$consult_list = [];
try {
$pdo = db_connect();
if (!$pdo) {
error_log('업무협의록 조회 실패: DB 연결 실패');
} else {
// MVP 단계: 모든 사용자가 모든 데이터를 볼 수 있도록 tenant_id 조건 제거
$sql = "SELECT id, title, created_at, summary_text, audio_file_path
FROM consult_logs
ORDER BY created_at DESC
LIMIT 10";
$stmt = $pdo->prepare($sql);
if (!$stmt) {
error_log('업무협의록 조회 실패: SQL 준비 실패 - ' . print_r($pdo->errorInfo(), true));
} else {
$executeResult = $stmt->execute();
if (!$executeResult) {
error_log('업무협의록 조회 실패: SQL 실행 실패 - ' . print_r($stmt->errorInfo(), true));
} else {
$consult_list = $stmt->fetchAll(PDO::FETCH_ASSOC);
error_log('업무협의록 조회 성공: ' . count($consult_list) . '개 조회됨 (모든 사용자 공개)');
}
}
}
} catch (Exception $e) {
// DB 조회 실패 시 빈 배열
error_log('업무협의록 조회 예외 발생: ' . $e->getMessage());
$consult_list = [];
}
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>AI 스마트 업무협의록 (SAM Project)</title>
<style>
* {
box-sizing: border-box;
}
body {
overflow-x: hidden; /* 가로 스크롤 방지 */
width: 100%;
}
.voice-container {
max-width: 900px;
margin: 40px auto;
padding: 30px;
width: 100%; /* 모바일 대응 */
}
.header-section {
text-align: center;
margin-bottom: 40px;
}
.header-section h3 {
margin-bottom: 10px;
color: #333;
}
.header-section p {
color: #6c757d;
font-size: 14px;
}
.recording-section {
background: #fff;
border-radius: 12px;
padding: 40px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
.record-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.record-button {
width: 120px;
height: 120px;
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);
position: relative;
overflow: hidden;
}
.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;
}
@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: 32px;
font-weight: bold;
color: #333;
font-family: 'Courier New', monospace;
min-height: 40px;
}
.timer.active {
color: #f5576c;
}
.status-indicator {
display: inline-block;
padding: 8px 20px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
.status-waiting {
background: #e9ecef;
color: #495057;
}
.status-recording {
background: #f8d7da;
color: #842029;
}
.status-processing {
background: #cfe2ff;
color: #084298;
}
.status-completed {
background: #d1e7dd;
color: #0f5132;
}
.status-error {
background: #f8d7da;
color: #842029;
}
.waveform-container {
width: 100%;
height: 100px;
background: #f8f9fa;
border-radius: 8px;
margin: 20px 0;
position: relative;
overflow: hidden;
}
.waveform-canvas {
width: 100%;
height: 100%;
}
.transcript-section {
background: #fff;
border-radius: 12px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 30px;
min-height: 200px;
}
.transcript-section h5 {
margin-bottom: 15px;
color: #333;
}
.transcript-text {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
min-height: 150px;
font-size: 16px;
line-height: 1.8;
color: #333;
white-space: pre-wrap;
word-wrap: break-word;
}
.transcript-text.empty {
color: #999;
font-style: italic;
}
.action-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
}
.btn-primary {
background: #0d6efd;
color: white;
}
.btn-primary:hover {
background: #0b5ed7;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5c636a;
}
.btn-success {
background: #198754;
color: white;
}
.btn-success:hover {
background: #157347;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.info-box {
background: #e7f3ff;
border-left: 4px solid #0d6efd;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.info-box p {
margin: 5px 0;
color: #084298;
font-size: 13px;
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s linear infinite;
margin-left: 10px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.processing-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
color: white;
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1000;
}
.spinner-border {
width: 3rem;
height: 3rem;
border: 0.25em solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
/* 업무협의록 테이블 스타일 */
.meeting-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.meeting-table th {
background: #f8f9fa;
padding: 12px;
text-align: left;
border-bottom: 2px solid #dee2e6;
font-weight: bold;
color: #495057;
}
.meeting-table td {
padding: 12px;
border-bottom: 1px solid #dee2e6;
}
.meeting-table tr:hover {
background: #f8f9fa;
}
.summary-preview {
max-width: 300px;
color: #6c757d;
font-size: 13px;
}
.btn-small {
padding: 8px;
font-size: 16px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-right: 5px;
background: #6c757d;
color: white;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
position: relative;
}
.btn-small:hover {
background: #5c636a;
}
.btn-small i {
margin: 0;
}
/* 툴팁 스타일 */
.btn-small[title] {
position: relative;
}
.btn-small[title]:hover::after {
content: attr(title);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
padding: 6px 10px;
background: #333;
color: white;
font-size: 12px;
white-space: nowrap;
border-radius: 4px;
z-index: 1000;
pointer-events: none;
opacity: 0;
animation: tooltipFadeIn 0.2s ease-in-out forwards;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.btn-small[title]:hover::before {
content: '';
position: absolute;
bottom: calc(100% + 3px);
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #333;
z-index: 1001;
pointer-events: none;
opacity: 0;
animation: tooltipFadeIn 0.2s ease-in-out forwards;
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(-5px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* 모달 스타일 */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 30px;
max-width: 800px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
}
.close-btn {
position: absolute;
top: 15px;
right: 20px;
font-size: 28px;
font-weight: bold;
color: #999;
cursor: pointer;
}
.close-btn:hover {
color: #333;
}
.modal-body {
margin-top: 20px;
}
.detail-section {
margin-bottom: 25px;
}
.detail-section h6 {
margin-bottom: 10px;
color: #495057;
font-size: 14px;
}
.detail-text {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
line-height: 1.8;
white-space: pre-wrap;
word-wrap: break-word;
}
/* 모바일 햄버거 메뉴 스타일 */
.mobile-menu-toggle {
display: none;
position: fixed;
top: 15px;
right: 15px;
z-index: 1001;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
width: 48px;
height: 48px;
font-size: 24px;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
transition: all 0.3s ease;
align-items: center;
justify-content: center;
}
.mobile-menu-toggle:hover {
transform: scale(1.05);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.mobile-menu-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
opacity: 0;
transition: opacity 0.3s ease;
}
.mobile-menu-overlay.active {
opacity: 1;
}
.mobile-menu-sidebar {
position: fixed;
top: 0;
right: -100%;
width: 280px;
max-width: 85%;
height: 100%;
background: white;
z-index: 1000;
overflow-y: auto;
transition: right 0.3s ease;
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
}
.mobile-menu-sidebar.active {
right: 0;
}
.mobile-menu-header {
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
justify-content: space-between;
align-items: center;
}
.mobile-menu-header h4 {
margin: 0;
font-size: 18px;
}
.mobile-menu-close {
background: none;
border: none;
color: white;
font-size: 28px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-menu-content {
padding: 20px 0;
}
.mobile-menu-content .navbar-nav {
flex-direction: column;
}
.mobile-menu-content .nav-item {
width: 100%;
margin: 0;
border-bottom: 1px solid #e9ecef;
}
.mobile-menu-content .nav-link {
padding: 15px 20px;
color: #333;
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
}
.mobile-menu-content .nav-link:hover {
background: #f8f9fa;
color: #667eea;
}
/* 모바일 반응형 스타일 */
@media (max-width: 768px) {
.navbar-custom {
display: none !important;
}
.mobile-menu-toggle {
display: flex;
}
.voice-container {
margin-top: 70px;
max-width: 100%;
padding: 15px;
width: 100%;
}
.recording-section, .transcript-section {
padding: 20px;
}
.record-button {
width: 100px;
height: 100px;
font-size: 20px;
}
.transcript-text {
font-size: 14px;
padding: 15px;
}
.header-section h3 {
font-size: 24px;
}
.record-button {
width: 100px;
height: 100px;
font-size: 20px;
}
.action-buttons {
flex-direction: column;
gap: 8px;
}
.btn {
width: 100%;
min-height: 48px;
}
}
@media (max-width: 480px) {
.voice-container {
padding: 10px;
}
.record-button {
width: 90px;
height: 90px;
font-size: 18px;
}
.timer {
font-size: 24px;
}
}
@media (max-width: 360px) {
.voice-container {
padding: 8px;
}
.record-button {
width: 80px;
height: 80px;
font-size: 16px;
}
}
/* 디버그 패널 스타일 */
.debug-panel {
position: fixed;
bottom: 20px;
right: 20px;
width: 350px;
max-width: calc(100% - 40px);
max-height: 400px;
background: #1e1e1e;
border: 2px solid #667eea;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 100000;
display: none !important;
flex-direction: column;
font-family: 'Courier New', monospace;
font-size: 12px;
pointer-events: auto;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.debug-panel.active {
display: flex !important;
opacity: 1;
transform: translateY(0);
}
.debug-panel-header {
background: #667eea;
color: white;
padding: 10px 15px;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 6px 6px 0 0;
}
.debug-panel-title {
font-weight: bold;
font-size: 14px;
}
.debug-panel-controls {
display: flex;
gap: 8px;
}
.debug-panel-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.debug-panel-content {
flex: 1;
overflow-y: auto;
padding: 10px;
color: #d4d4d4;
background: #1e1e1e;
}
.debug-log {
margin: 4px 0;
padding: 4px 8px;
border-left: 3px solid #667eea;
word-break: break-word;
line-height: 1.4;
}
.debug-log.log { border-left-color: #4ec9b0; }
.debug-log.info { border-left-color: #4fc3f7; }
.debug-log.warn { border-left-color: #ffa726; color: #ffa726; }
.debug-log.error { border-left-color: #f44336; color: #ff6b6b; }
.debug-toggle-btn {
position: fixed;
bottom: 20px;
right: 20px;
width: 50px;
height: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 50%;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
cursor: pointer;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
pointer-events: auto;
touch-action: manipulation;
}
.debug-toggle-btn:hover {
transform: scale(1.1);
}
.debug-toggle-btn:active {
transform: scale(0.95);
}
.debug-toggle-btn.active {
background: #f44336;
}
@media (max-width: 768px) {
.debug-panel {
width: calc(100% - 20px);
max-width: calc(100% - 20px);
bottom: 10px;
right: 10px;
max-height: 50vh;
}
.debug-toggle-btn {
bottom: 10px;
right: 10px;
width: 44px;
height: 44px;
font-size: 18px;
}
}
</style>
<script>
// 즉시 실행: 전자결재 다이얼로그 강제 닫기
(function() {
function closeEworksDialogs() {
// 모든 전자결재 관련 모달 닫기
const modals = ['eworks_viewmodal', 'eworks_form'];
modals.forEach(function(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'none';
modal.classList.remove('show');
modal.setAttribute('aria-hidden', 'true');
}
});
// 모든 모달 배경 제거
const backdrops = document.querySelectorAll('.modal-backdrop');
backdrops.forEach(function(backdrop) {
backdrop.remove();
});
// body 클래스 정리
document.body.classList.remove('modal-open');
document.body.style.overflow = '';
document.body.style.paddingRight = '';
}
// 즉시 실행
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', closeEworksDialogs);
} else {
closeEworksDialogs();
}
// 지속적으로 확인 (다이얼로그가 나중에 열리는 경우 대비)
setInterval(closeEworksDialogs, 500);
})();
</script>
</head>
<body>
<?php include $_SERVER['DOCUMENT_ROOT'] . "/myheader.php"; ?>
<!-- 모바일 햄버거 메뉴 -->
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="메뉴 열기">
<i class="bi bi-list"></i>
</button>
<div class="mobile-menu-overlay" id="mobile-menu-overlay"></div>
<div class="mobile-menu-sidebar" id="mobile-menu-sidebar">
<div class="mobile-menu-header">
<h4>메뉴</h4>
<button class="mobile-menu-close" id="mobile-menu-close" aria-label="메뉴 닫기">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="mobile-menu-content">
<nav class="navbar-nav"></nav>
</div>
</div>
<div class="processing-overlay" id="voiceloadingOverlay" style="display: none;">
<div class="spinner-border text-light" role="status"></div>
<h4 style="margin-top: 20px;">분석 후 요약작업 수행중... 잠시만 기다려 주세요</h4>
<p>오디오 업로드 → 텍스트 변환 → AI 요약 진행 중입니다.</p>
</div>
<div class="voice-container">
<div class="header-section">
<h3><i class="bi bi-mic-fill"></i> AI 스마트 업무협의록 작성</h3>
<p>음성을 녹음하고 AI를 통해 자동으로 업무협의록을 생성합니다</p>
</div>
<div class="info-box">
<p><i class="bi bi-info-circle"></i> Chrome 브라우저에서 마이크 권한을 허용해주세요</p>
<p><i class="bi bi-clock"></i> 실시간 음성 인식 - 말하는 즉시 텍스트로 변환됩니다</p>
<p><i class="bi bi-stars"></i> 녹음이 끝나면 AI가 자동으로 업무협의록을 작성합니다</p>
</div>
<div class="recording-section">
<div class="record-controls">
<button id="record-button" class="record-button">
<i class="bi bi-mic-fill"></i>
</button>
<div id="status" class="status-indicator status-waiting">대기중</div>
<div id="timer" class="timer">00:00</div>
<div class="waveform-container">
<canvas id="waveform" class="waveform-canvas"></canvas>
</div>
</div>
</div>
<div class="transcript-section">
<h5><i class="bi bi-eye"></i> 실시간 인식 미리보기</h5>
<div id="preview-text" class="transcript-text empty">
녹음 버튼을 클릭하여 음성을 녹음하세요
</div>
<div class="action-buttons">
<button id="save-analyze-btn" class="btn btn-primary" disabled>
<i class="bi bi-cloud-upload"></i> 업무협의 종료 및 AI 분석 시작
</button>
<button id="test-sample-btn" class="btn btn-secondary" style="margin-left: 10px;" title="테스트용 샘플 오디오 생성 (개발/테스트용)">
<i class="bi bi-file-earmark-music"></i> 샘플 테스트
</button>
</div>
</div>
<!-- 업무협의록 리스트 섹션 -->
<div class="transcript-section">
<h5><i class="bi bi-list-ul"></i> 최근 업무협의록 (최근 10개)</h5>
<div id="meeting-list">
<?php if (empty($consult_list)): ?>
<p style="text-align: center; color: #999; padding: 30px;">
<i class="bi bi-inbox"></i><br>
저장된 업무협의록이 없습니다
</p>
<?php else: ?>
<table class="meeting-table">
<thead>
<tr>
<th>번호</th>
<th>제목</th>
<th>작성일</th>
<th>요약 미리보기</th>
<th>동작</th>
</tr>
</thead>
<tbody>
<?php foreach ($consult_list as $index => $consult): ?>
<tr>
<td><?= $index + 1 ?></td>
<td><?= htmlspecialchars($consult['title']) ?></td>
<td><?= date('m-d H:i', strtotime($consult['created_at'])) ?></td>
<td class="summary-preview">
<?= htmlspecialchars(mb_substr($consult['summary_text'], 0, 50)) ?>...
</td>
<td>
<button class="btn-small" onclick="viewDetail(<?= $consult['id'] ?>)" title="업무협의록 상세보기">
<i class="bi bi-eye"></i>
</button>
<?php if (!empty($consult['audio_file_path'])): ?>
<a href="download_audio.php?id=<?= $consult['id'] ?>"
class="btn-small"
download="consult_<?= $consult['id'] ?>.webm"
title="오디오 파일 다운로드">
<i class="bi bi-download"></i>
</a>
<?php else: ?>
<span class="btn-small" style="opacity: 0.5; cursor: not-allowed;" title="오디오 파일이 없습니다">
<i class="bi bi-download"></i>
</span>
<?php endif; ?>
<button class="btn-small" onclick="confirmDelete(<?= $consult['id'] ?>, '<?= htmlspecialchars(addslashes($consult['title'] ?: '업무협의녹음')) ?>')"
style="background-color: #dc3545; color: white;"
title="업무협의록 삭제">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
</div>
<!-- 상세보기 모달 -->
<div id="detail-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close-btn" onclick="closeModal()">&times;</span>
<h4 id="modal-title">업무협의록 상세</h4>
<div class="modal-body">
<div class="detail-section">
<h6><i class="bi bi-file-text"></i> 전체 텍스트</h6>
<div id="modal-transcript" class="detail-text"></div>
</div>
<div class="detail-section">
<h6><i class="bi bi-stars"></i> AI 요약</h6>
<div id="modal-summary" class="detail-text"></div>
</div>
</div>
</div>
</div>
<!-- 삭제 확인 모달 -->
<div id="delete-modal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 400px;">
<span class="close-btn" onclick="closeDeleteModal()">&times;</span>
<h4>업무협의록 삭제 확인</h4>
<div class="modal-body">
<p>정말로 이 업무협의록을 삭제하시겠습니까?</p>
<p style="font-weight: bold; color: #dc3545;" id="delete-meeting-title"></p>
<p style="font-size: 12px; color: #6c757d;">서버 파일과 Google Cloud Storage의 파일도 함께 삭제됩니다.</p>
</div>
<div style="text-align: right; margin-top: 20px;">
<button class="btn-small" onclick="closeDeleteModal()" style="margin-right: 10px; width: auto; padding: 8px 20px;">취소</button>
<button class="btn-small" onclick="deleteMeeting()" style="background-color: #dc3545; color: white; width: auto; padding: 8px 20px;">삭제</button>
</div>
</div>
</div>
<!-- 디버그 패널 -->
<button class="debug-toggle-btn" id="debug-toggle-btn" title="디버그 패널 열기/닫기">
<i class="bi bi-bug"></i>
</button>
<div class="debug-panel" id="debug-panel">
<div class="debug-panel-header">
<span class="debug-panel-title">🔍 디버그 콘솔</span>
<div class="debug-panel-controls">
<button class="debug-panel-btn" id="debug-copy-btn" title="로그 복사">
<i class="bi bi-clipboard"></i>
</button>
<button class="debug-panel-btn" id="debug-clear-btn" title="로그 지우기">
<i class="bi bi-trash"></i>
</button>
<button class="debug-panel-btn" id="debug-close-btn" title="패널 닫기">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="debug-panel-content" id="debug-content">
<div class="debug-log info">디버그 패널이 준비되었습니다. 로그가 여기에 표시됩니다.</div>
</div>
</div>
<script>
// 모바일 감지 함수
function isMobileDevice() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
function isIOS() {
return /iPhone|iPad|iPod/i.test(navigator.userAgent);
}
function isAndroid() {
return /Android/i.test(navigator.userAgent);
}
let mediaRecorder;
let audioChunks = [];
let isRecording = false;
let startTime;
let timerInterval;
// Web Speech API (미리보기용)
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
let recognition = null;
let isRecognitionActive = false;
// Google Cloud Speech-to-Text API 변수
let useGoogleAPI = false;
let recordingInterval = null;
let audioBlob = null;
let isMobile = isMobileDevice();
// 모바일에서는 Google API 사용
if (isMobile) {
useGoogleAPI = true;
// console.log는 디버그 패널 초기화 후에 작동하므로, 즉시 실행
if (window.debugLog) {
window.debugLog('모바일 감지: Google Cloud Speech-to-Text API 사용', 'info');
}
// 원본 console에도 출력 (디버그 패널이 초기화되기 전일 수 있음)
if (console && console.log) {
console.log('모바일 감지: Google Cloud Speech-to-Text API 사용');
}
}
// 음성 인식 텍스트 변수 (중복 방지)
let finalTranscript = '';
let interimTranscript = '';
let lastReceivedTranscript = ''; // 마지막으로 받은 텍스트 (중복 방지용)
// 시각화 변수
let audioContext = null;
let analyser = null;
let dataArray = null;
let animationId = null;
let mediaStream = null;
let gainNode = null; // 오디오 증폭용
let audioSource = null; // 오디오 소스
// DOM 요소
const recordBtn = document.getElementById('record-button');
const saveBtn = document.getElementById('save-analyze-btn');
const previewText = document.getElementById('preview-text');
const statusEl = document.getElementById('status');
const timerEl = document.getElementById('timer');
const waveformCanvas = document.getElementById('waveform');
const canvasCtx = waveformCanvas.getContext('2d');
// 화면 업데이트 전용 함수 (콘솔 로그와 함께 호출)
function updatePreviewDisplay() {
const displayText = finalTranscript.trim();
// 콘솔에 로그 출력 (디버그 패널에도 표시됨)
console.log('🖥️ 화면 업데이트:', displayText.length, '자');
console.log('🖥️ 화면 표시 내용 (처음 50자):', displayText.substring(0, 50));
console.log('🖥️ finalTranscript 전체:', displayText);
// 화면 업데이트
if (previewText) {
// 즉시 업데이트
previewText.textContent = displayText;
previewText.classList.remove('empty');
// 모바일에서 DOM 업데이트 강제 반영
void previewText.offsetHeight; // 강제 리플로우
// requestAnimationFrame으로 다음 프레임에 재확인
requestAnimationFrame(() => {
if (previewText) {
previewText.textContent = displayText;
previewText.classList.remove('empty');
previewText.scrollTop = previewText.scrollHeight;
}
});
console.log('✅ 화면 업데이트 완료');
} else {
console.error('❌ previewText 요소를 찾을 수 없습니다!');
}
// 버튼 활성화
if (displayText) {
saveBtn.disabled = false;
}
}
// Canvas 크기 초기화
function resizeCanvas() {
waveformCanvas.width = waveformCanvas.offsetWidth;
waveformCanvas.height = waveformCanvas.offsetHeight;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// Web Speech API 초기화
function initSpeechRecognition() {
if (!SpeechRecognition) {
alert('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome 브라우저를 사용해주세요.');
return false;
}
recognition = new SpeechRecognition();
recognition.lang = 'ko-KR';
recognition.continuous = true;
recognition.interimResults = true;
// 민감도 향상을 위한 추가 설정
recognition.maxAlternatives = 3; // 더 많은 대안 결과 허용
return true;
}
// Waveform 시각화
function drawWaveform() {
if (!analyser || !dataArray) return;
animationId = requestAnimationFrame(drawWaveform);
analyser.getByteTimeDomainData(dataArray);
canvasCtx.fillStyle = '#f8f9fa';
canvasCtx.fillRect(0, 0, waveformCanvas.width, waveformCanvas.height);
canvasCtx.lineWidth = 2;
canvasCtx.strokeStyle = '#667eea';
canvasCtx.beginPath();
const sliceWidth = waveformCanvas.width / dataArray.length;
let x = 0;
for (let i = 0; i < dataArray.length; i++) {
const v = dataArray[i] / 128.0;
const y = v * waveformCanvas.height / 2;
if (i === 0) {
canvasCtx.moveTo(x, y);
} else {
canvasCtx.lineTo(x, y);
}
x += sliceWidth;
}
canvasCtx.lineTo(waveformCanvas.width, waveformCanvas.height / 2);
canvasCtx.stroke();
}
function stopWaveform() {
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
// Clear canvas
canvasCtx.fillStyle = '#f8f9fa';
canvasCtx.fillRect(0, 0, waveformCanvas.width, waveformCanvas.height);
}
// 오디오 스트림 시작 (시각화용 및 증폭)
async function startAudioStream() {
try {
// 마이크 제약 조건 최적화 (민감도 향상)
const audioConstraints = {
audio: {
echoCancellation: false, // 에코 캔슬 비활성화 (작은 소리 감지 향상)
noiseSuppression: false, // 노이즈 억제 비활성화 (작은 소리 감지 향상)
autoGainControl: true, // 자동 게인 제어 활성화
sampleRate: 48000, // 높은 샘플레이트
channelCount: 1 // 모노 채널
}
};
mediaStream = await navigator.mediaDevices.getUserMedia(audioConstraints);
// Setup Audio Context for Visualization and Amplification
audioContext = new (window.AudioContext || window.webkitAudioContext)();
// 오디오 소스 생성
audioSource = audioContext.createMediaStreamSource(mediaStream);
// GainNode 생성 및 증폭 설정 (3배 증폭 - 작은 소리도 잘 들리도록)
gainNode = audioContext.createGain();
gainNode.gain.value = 3.0; // 3배 증폭 (최대 10까지 가능하지만 3이 적절)
// 오디오 체인: Source -> Gain -> Analyser
audioSource.connect(gainNode);
analyser = audioContext.createAnalyser();
gainNode.connect(analyser);
analyser.fftSize = 2048;
const bufferLength = analyser.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
// Start Waveform Visualization
drawWaveform();
return mediaStream;
} catch (error) {
console.error('마이크 접근 오류:', error);
alert('마이크 권한을 허용해주세요');
return null;
}
}
// 오디오 스트림 중지
function stopAudioStream() {
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
// 오디오 노드 연결 해제
if (audioSource) {
try {
audioSource.disconnect();
} catch (e) {
console.log('Audio source disconnect:', e);
}
audioSource = null;
}
if (gainNode) {
try {
gainNode.disconnect();
} catch (e) {
console.log('Gain node disconnect:', e);
}
gainNode = null;
}
if (analyser) {
try {
analyser.disconnect();
} catch (e) {
console.log('Analyser disconnect:', e);
}
analyser = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
}
// Google Cloud Speech-to-Text API 함수
// Google Cloud Speech-to-Text API 함수
async function startGoogleRecognition() {
try {
console.log('=== Google Cloud Speech-to-Text API 시작 (Consult/Timeslice Mode) ===');
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 16000,
echoCancellation: true,
noiseSuppression: true
}
});
mediaStream = stream;
// 오디오 시각화
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
analyser.fftSize = 2048;
const bufferLength = analyser.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
drawWaveform();
// MediaRecorder 설정
const options = {
mimeType: 'audio/webm;codecs=opus',
audioBitsPerSecond: 16000
};
// 지원하는 형식 확인
if (!MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
if (MediaRecorder.isTypeSupported('audio/webm')) {
options.mimeType = 'audio/webm';
} else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
options.mimeType = 'audio/ogg;codecs=opus';
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
options.mimeType = 'audio/mp4';
} else {
options.mimeType = '';
}
}
console.log('MediaRecorder 형식:', options.mimeType || '기본값');
mediaRecorder = new MediaRecorder(stream, options);
audioChunks = []; // 초기화
// 3초마다 데이터가 들어옴
mediaRecorder.ondataavailable = async (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
console.log('오디오 청크 수집:', event.data.size, 'bytes', '(총 ' + audioChunks.length + '개)');
// 실시간 미리보기 전송 (전체 오디오 전송)
if (isRecording && audioChunks.length > 0) {
const fullBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
await sendAudioToServer(fullBlob, true);
}
}
};
mediaRecorder.onstop = () => {
console.log('MediaRecorder 중지됨');
};
// 3000ms(3초)마다 ondataavailable 발생
mediaRecorder.start(3000);
recordBtn.classList.add('recording');
recordBtn.innerHTML = '<i class="bi bi-stop-fill"></i>';
updateStatus('음성 인식 중 (Google API)', 'recording');
startTimer();
isRecording = true;
isRecognitionActive = true;
console.log('✅ Google API 녹음 시작 완료');
} catch (error) {
console.error('Google API 녹음 시작 오류:', error);
updateStatus('녹음 시작 실패: ' + error.message, 'error');
alert('마이크 권한을 허용해주세요.');
}
}
function stopGoogleRecognition() {
console.log('=== Google Cloud Speech-to-Text API 중지 ===');
isRecognitionActive = false;
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
// 타이머와 시각화 중지
stopTimer();
stopWaveform();
stopAudioStream();
console.log('✅ Google API 녹음 중지 완료');
console.log('audioChunks 보존됨 (저장 버튼에서 사용):', audioChunks.length, '개 청크');
}
// 긴 작업 상태 폴링 (Google STT 등)
function pollConsultStatus(consultId, operationName, pollUrl) {
const POLL_INTERVAL = 3000; // 3초마다 확인
let errorCount = 0;
console.log(`폴링 시작: ConsultID=${consultId}, Operation=${operationName}`);
// 기존 폴링 인터벌이 있다면 제거 (안전장치)
if (window.pollingInterval) {
clearInterval(window.pollingInterval);
}
window.pollingInterval = setInterval(async () => {
try {
// URL에 파라미터가 이미 포함되어 있으므로 그대로 호출
const response = await fetch(pollUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('폴링 응답:', data);
if (data.ok) {
if (data.done) {
// 완료됨
clearInterval(window.pollingInterval);
window.pollingInterval = null;
updateStatus('분석 완료! 저장 중...', 'completed');
alert(data.message || '업무협의록 분석이 완료되었습니다.');
window.location.reload();
} else if (data.processing) {
// 계속 진행 중
updateStatus('AI 분석 중... 잠시만 기다려주세요', 'processing');
errorCount = 0; // 성공적인 응답이면 에러 카운트 초기화
} else {
// 알 수 없는 상태
console.warn('알 수 없는 폴링 상태:', data);
}
} else {
// 서버 로직 에러
clearInterval(window.pollingInterval);
window.pollingInterval = null;
updateStatus('분석 실패', 'error');
alert('분석 중 오류가 발생했습니다: ' + (data.error || '알 수 없는 오류'));
}
} catch (e) {
console.error('폴링 오류:', e);
errorCount++;
// 연속 5회 실패 시 중단
if (errorCount >= 5) {
clearInterval(window.pollingInterval);
window.pollingInterval = null;
updateStatus('통신 오류', 'error');
alert('서버와 통신 중 오류가 지속되어 확인을 중단합니다. 나중에 목록을 새로고침 해보세요.');
}
}
}, POLL_INTERVAL);
}
async function sendAudioToServer(audioBlob, isChunk = false, retryCount = 0) {
const MAX_RETRIES = 2; // 최대 2번 재시도
const MIN_AUDIO_SIZE = 2000; // 2KB 미만은 건너뛰기 (너무 작은 청크)
const FETCH_TIMEOUT = 90000; // 90초 타임아웃 (서버 타임아웃 120초보다 짧게)
try {
console.log('서버로 오디오 전송:', audioBlob.size, 'bytes', isChunk ? '(청크)' : '(최종)', retryCount > 0 ? `(재시도 ${retryCount})` : '');
// 빈 오디오 체크
if (audioBlob.size === 0) {
console.warn('⚠️ 빈 오디오 파일, 전송 건너뜀');
return;
}
// 너무 작은 청크는 건너뛰기 (청크만, 최종은 항상 전송)
if (isChunk && audioBlob.size < MIN_AUDIO_SIZE) {
console.log(`⚠️ 오디오 청크가 너무 작음 (${audioBlob.size} bytes < ${MIN_AUDIO_SIZE} bytes), 전송 건너뜀`);
return;
}
const formData = new FormData();
// 파일명에 타임스탬프 추가하여 고유성 보장
const fileName = 'recording_' + Date.now() + '.webm';
formData.append('audio', audioBlob, fileName);
// AbortController로 타임아웃 설정
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
let response;
try {
response = await fetch('api/speech_to_text.php', {
method: 'POST',
body: formData,
signal: controller.signal
});
clearTimeout(timeoutId);
} catch (fetchError) {
clearTimeout(timeoutId);
// 타임아웃 또는 네트워크 오류
if (fetchError.name === 'AbortError') {
throw new Error('HTTP 504: 요청 시간 초과 (90초)');
} else if (fetchError.name === 'TypeError' && fetchError.message.includes('Failed to fetch')) {
throw new Error('HTTP 504: 네트워크 연결 실패');
}
throw fetchError;
}
// 응답 상태 확인
if (!response.ok) {
// 504 오류인 경우 재시도
if (response.status === 504 && retryCount < MAX_RETRIES) {
console.warn(`⚠️ 504 오류 발생, ${retryCount + 1}번째 재시도...`);
await new Promise(resolve => setTimeout(resolve, 1000 * (retryCount + 1))); // 지수 백오프
return sendAudioToServer(audioBlob, isChunk, retryCount + 1);
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.success && result.transcript) {
console.log('✅ 텍스트 변환 성공:', result.transcript);
console.log('신뢰도:', result.confidence || 'N/A');
// 모바일 전용 텍스트 누적 로직: 강력한 중복 제거
const newText = result.transcript.trim();
// 빈 텍스트나 공백만 있는 텍스트는 무시
if (!newText || newText.length === 0) {
console.log('⏭️ 빈 텍스트 무시');
return;
}
// 이전 응답과 동일한 텍스트인 경우 무시
if (newText === lastReceivedTranscript) {
console.log('⏭️ 이전 응답과 동일한 텍스트 무시:', newText);
return;
}
// 모바일 로직 수정: 복잡한 중복 제거 로직 대신 전체 텍스트 교체 방식 사용
// 이유: sendAudioToServer는 누적된 전체 오디오(audioChunks)를 서버로 보냄
// 안전장치: 텍스트가 기존보다 현저히 짧아지는 경우 (오류 가능성) 무시
// 단, 초기 단계거나 짧은 텍스트일 때는 허용
const currentLength = finalTranscript.trim().length;
if (currentLength > 50 && newText.length < currentLength * 0.5) {
console.warn('⚠️ 텍스트가 비정상적으로 짧아짐, 무시 (기존:', currentLength, '자, 신규:', newText.length, '자)');
return;
}
// 텍스트 전체 교체
finalTranscript = newText;
console.log('✅ 텍스트 교체 (Mobile 전체 업데이트):', newText.length, '자');
// 마지막으로 받은 텍스트 업데이트 (성공적으로 처리된 경우만)
lastReceivedTranscript = newText;
// 화면 업데이트
updatePreviewDisplay();
updateStatus('음성 인식 중 (Google API)', 'recording');
} else {
console.error('❌ 텍스트 변환 실패:', result.error);
console.error('디버그 정보:', result.debug || result.hint || '없음');
// API 키 오류인 경우 사용자에게 상세 안내
if (result.error && (
result.error.includes('API key') ||
result.error.includes('API 키') ||
result.error.includes('not valid') ||
result.error.includes('invalid')
)) {
updateStatus('API 키 오류', 'error');
if (!isChunk) {
let errorMsg = 'Google Cloud Speech-to-Text API 키 오류가 발생했습니다.\n\n';
errorMsg += '⚠️ API 키 오류 해결 방법:\n';
errorMsg += '1. Google Cloud Console (https://console.cloud.google.com/) 접속\n';
errorMsg += '2. 프로젝트 선택: codebridge-chatbot\n';
errorMsg += '3. "API 및 서비스" > "라이브러리"에서 "Cloud Speech-to-Text API" 검색 및 활성화\n';
errorMsg += '4. "API 및 서비스" > "사용자 인증 정보"에서 API 키 확인\n';
errorMsg += '5. API 키 제한 설정에서 "Cloud Speech-to-Text API" 허용 확인\n\n';
if (result.debug) {
errorMsg += '현재 API 키 파일: ' + (result.debug.api_key_path || 'N/A') + '\n';
errorMsg += 'API 키 길이: ' + (result.debug.api_key_length || 'N/A') + ' 문자\n';
if (result.debug.service_account_path) {
errorMsg += '서비스 계정 파일: ' + result.debug.service_account_path + '\n';
}
}
alert(errorMsg);
}
} else if (!isChunk) {
// 에러 메시지에서 줄바꿈 처리
const errorMsg = result.error ? result.error.replace(/\\n/g, '\n') : '알 수 없는 오류';
updateStatus('변환 실패: ' + errorMsg.split('\n')[0], 'error');
// 상세 에러가 있으면 콘솔에 출력
if (result.error && result.error.includes('\n')) {
console.error('상세 에러:', result.error);
}
}
}
} catch (error) {
const httpCode = error.message.match(/\d+/)?.[0] || '알 수 없음';
console.error('❌ 텍스트 변환 실패: API 오류 (HTTP ' + httpCode + '):', error.message);
console.error('디버그 정보:', error.stack || '없음');
// 504 오류인 경우 재시도 (최종 요청만)
if (httpCode === '504' && !isChunk && retryCount < MAX_RETRIES) {
console.warn(`⚠️ 504 오류 발생, ${retryCount + 1}번째 재시도...`);
await new Promise(resolve => setTimeout(resolve, 2000 * (retryCount + 1))); // 지수 백오프
return sendAudioToServer(audioBlob, isChunk, retryCount + 1);
}
// 재시도 횟수 초과 또는 청크 요청인 경우에만 오류 표시
if (!isChunk || retryCount >= MAX_RETRIES) {
updateStatus('전송 실패: ' + error.message, 'error');
}
}
}
// 녹음 시작/종료 처리
recordBtn.addEventListener('click', async () => {
console.log('녹음 버튼 클릭됨, isRecording:', isRecording, 'useGoogleAPI:', useGoogleAPI, 'isMobile:', isMobile);
if (!isRecording) {
// 변수 초기화
audioChunks = [];
finalTranscript = '';
interimTranscript = '';
lastReceivedTranscript = ''; // 마지막으로 받은 텍스트도 초기화
// 모바일에서는 Google API 사용
if (useGoogleAPI) {
console.log('모바일 모드: startGoogleRecognition() 호출');
await startGoogleRecognition();
} else {
// 데스크탑: Web Speech API 사용
if (!recognition) {
if (!initSpeechRecognition()) {
return;
}
}
const stream = await startAudioStream();
if (!stream) return;
// MediaRecorder 시작 (파일 저장용)
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data);
};
mediaRecorder.start();
// Web Speech API 시작 (화면 표시용)
if (!isRecognitionActive) {
try {
recognition.start();
isRecognitionActive = true;
} catch (e) {
console.error('음성 인식 시작 실패:', e);
}
}
isRecording = true;
recordBtn.classList.add('recording');
recordBtn.innerHTML = '<i class="bi bi-stop-fill"></i>';
updateStatus('녹음 중...', 'recording');
startTimer();
saveBtn.disabled = true;
previewText.classList.remove('empty');
}
} else {
// 녹음 종료
if (useGoogleAPI) {
// 모바일: Google API 사용
stopGoogleRecognition();
} else {
// PC: Web Speech API + MediaRecorder 사용
mediaRecorder.stop();
if (isRecognitionActive) {
try {
recognition.stop();
isRecognitionActive = false;
} catch (e) {
console.error('음성 인식 종료 실패:', e);
}
}
stopTimer();
stopWaveform();
stopAudioStream();
}
isRecording = false;
recordBtn.classList.remove('recording');
recordBtn.innerHTML = '<i class="bi bi-mic-fill"></i>';
updateStatus('녹음 완료', 'completed');
// 최종 텍스트 정리 (임시 텍스트 제거)
if (finalTranscript.trim()) {
previewText.textContent = finalTranscript.trim();
saveBtn.disabled = false;
} else {
previewText.innerHTML = '<span style="color:#999;font-style:italic;">인식된 텍스트가 없습니다</span>';
saveBtn.disabled = true;
}
}
});
// Web Speech API 이벤트 핸들러 설정
function setupRecognitionHandlers() {
if (!recognition) return;
// 음성 인식 결과 처리
recognition.onresult = (event) => {
// 임시 텍스트 초기화 (중복 방지를 위해 매번 초기화)
interimTranscript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
// 확정된 텍스트는 finalTranscript에 추가
finalTranscript += transcript + ' ';
} else {
// 임시 텍스트는 interimTranscript에 저장 (누적하지 않음)
interimTranscript += transcript;
}
}
// 텍스트 업데이트 (확정된 텍스트 + 현재 임시 텍스트만 표시)
const displayText = finalTranscript + (interimTranscript ? '<span style="color:#999">' + interimTranscript + '</span>' : '');
previewText.innerHTML = displayText || '음성을 인식하고 있습니다...';
previewText.classList.remove('empty');
previewText.scrollTop = previewText.scrollHeight;
// 버튼 활성화 (확정된 텍스트가 있을 때만)
if (finalTranscript.trim()) {
saveBtn.disabled = false;
}
};
// 음성 인식 시작
recognition.onstart = () => {
isRecognitionActive = true;
updateStatus('음성 인식 중', 'recording');
};
// 음성 인식 종료
recognition.onend = () => {
isRecognitionActive = false;
if (isRecording) {
// 자동 재시작 (연속 녹음 모드)
try {
recognition.start();
} catch (e) {
console.log('Recognition restart failed:', e);
}
} else {
updateStatus('변환 완료', 'completed');
}
};
// 에러 처리
recognition.onerror = (event) => {
console.error('Speech recognition error:', event.error);
if (event.error === 'no-speech') {
// 음성이 감지되지 않음 - 자동 재시작 (작은 소리도 감지하도록 지속 시도)
if (isRecording) {
setTimeout(() => {
try {
if (isRecording && !isRecognitionActive) {
recognition.start();
}
} catch (e) {
console.log('Recognition restart after no-speech:', e);
}
}, 500);
}
return;
}
if (event.error === 'aborted') {
// 사용자가 중단함
return;
}
// 'network' 오류는 일시적이므로 재시도
if (event.error === 'network') {
if (isRecording) {
setTimeout(() => {
try {
if (isRecording && !isRecognitionActive) {
recognition.start();
}
} catch (e) {
console.log('Recognition restart after network error:', e);
}
}, 1000);
}
return;
}
updateStatus('오류 발생: ' + event.error, 'error');
if (event.error === 'not-allowed') {
alert('마이크 권한이 거부되었습니다. 브라우저 설정에서 마이크 권한을 허용해주세요.');
}
};
}
// Update Status
function updateStatus(message, statusClass) {
statusEl.textContent = message;
statusEl.className = 'status-indicator status-' + statusClass;
}
// Timer Functions
function startTimer() {
startTime = Date.now();
timerEl.classList.add('active');
timerInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
timerEl.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}, 1000);
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
timerEl.classList.remove('active');
}
// 테스트용 샘플 오디오 생성 (개발/테스트용)
const testSampleBtn = document.getElementById('test-sample-btn');
if (testSampleBtn) {
testSampleBtn.addEventListener('click', async () => {
if (!confirm('테스트용 샘플 오디오를 생성하시겠습니까?\n(실제 녹음 없이 테스트용 더미 데이터를 생성합니다)')) {
return;
}
try {
// Web Audio API를 사용하여 짧은 오디오 생성
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const sampleRate = audioContext.sampleRate;
const duration = 2; // 2초
const numSamples = sampleRate * duration;
const buffer = audioContext.createBuffer(1, numSamples, sampleRate);
const data = buffer.getChannelData(0);
// 간단한 사인파 생성 (테스트용)
for (let i = 0; i < numSamples; i++) {
data[i] = Math.sin(2 * Math.PI * 440 * i / sampleRate) * 0.1; // 440Hz, 작은 볼륨
}
// WAV로 변환
const wav = audioBufferToWav(buffer);
const blob = new Blob([wav], { type: 'audio/wav' });
// audioChunks에 추가
audioChunks = [blob];
finalTranscript = '안녕하세요. 미래기업입니다. 업무협의를 시작합니다.';
previewText.textContent = finalTranscript;
saveBtn.disabled = false;
updateStatus('샘플 데이터 준비 완료', 'completed');
alert('샘플 오디오가 생성되었습니다. "업무협의 종료 및 AI 분석 시작" 버튼을 클릭하세요.');
} catch (e) {
console.error('샘플 오디오 생성 실패:', e);
alert('샘플 오디오 생성에 실패했습니다: ' + e.message);
}
});
}
// AudioBuffer를 WAV로 변환하는 함수
function audioBufferToWav(buffer) {
const length = buffer.length;
const arrayBuffer = new ArrayBuffer(44 + length * 2);
const view = new DataView(arrayBuffer);
const channels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
// WAV 헤더 작성
const writeString = (offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, 36 + length * 2, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeString(36, 'data');
view.setUint32(40, length * 2, true);
// 데이터 작성
const channelData = buffer.getChannelData(0);
let offset = 44;
for (let i = 0; i < length; i++) {
const sample = Math.max(-1, Math.min(1, channelData[i]));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
offset += 2;
}
return arrayBuffer;
}
// 저장 및 분석 버튼 클릭
saveBtn.addEventListener('click', async () => {
// 버튼 비활성화 및 로딩 표시
saveBtn.disabled = true;
const originalBtnText = saveBtn.innerHTML;
saveBtn.innerHTML = '<span class="loading-spinner"></span> 처리 중...';
document.getElementById('voiceloadingOverlay').style.display = 'flex';
updateStatus('처리 중...', 'processing');
// Blob 생성
// 모바일에서는 audioChunks가 비어있을 수 있으므로 확인
if (!audioChunks || audioChunks.length === 0) {
console.warn('audioChunks가 비어있습니다. 녹음이 제대로 되지 않았을 수 있습니다.');
console.log('isRecording:', isRecording, 'useGoogleAPI:', useGoogleAPI, 'isMobile:', isMobile);
console.log('mediaRecorder state:', mediaRecorder ? mediaRecorder.state : 'null');
alert('녹음된 오디오 데이터가 없습니다. 녹음을 시작하고 일정 시간 이상 녹음해주세요.');
document.getElementById('voiceloadingOverlay').style.display = 'none';
saveBtn.disabled = false;
saveBtn.innerHTML = originalBtnText;
updateStatus('오류', 'error');
return;
}
console.log('저장 시 audioChunks 개수:', audioChunks.length);
console.log('저장 시 audioChunks 총 크기:', audioChunks.reduce((sum, chunk) => sum + chunk.size, 0), 'bytes');
// 모바일에서는 mediaRecorder.mimeType 사용, PC에서는 'audio/webm' 사용
const mimeType = (useGoogleAPI && mediaRecorder && mediaRecorder.mimeType) ? mediaRecorder.mimeType : 'audio/webm';
const audioBlob = new Blob(audioChunks, { type: mimeType });
console.log('생성된 audioBlob 크기:', audioBlob.size, 'bytes, type:', mimeType);
// 오디오 파일 크기 확인 (최소 크기 완화: 500 bytes 이상이면 허용)
// 20초 정도의 짧은 회의도 처리할 수 있도록 최소 크기를 낮춤
if (audioBlob.size < 500) { // 500 bytes 미만이면 너무 작음
alert('녹음된 오디오가 너무 작습니다. (' + audioBlob.size + ' bytes) 녹음을 다시 시도해주세요.');
document.getElementById('voiceloadingOverlay').style.display = 'none';
saveBtn.disabled = false;
saveBtn.innerHTML = originalBtnText;
updateStatus('오류', 'error');
return;
}
const formData = new FormData();
formData.append('audio_file', audioBlob, 'consult_record.webm');
// 확정된 텍스트 전송 (참고용)
const finalText = finalTranscript.trim();
console.log('저장 시 finalTranscript 길이:', finalText.length, '자');
console.log('저장 시 finalTranscript 내용 (처음 100자):', finalText.substring(0, 100));
console.log('저장 시 finalTranscript 내용 (마지막 100자):', finalText.substring(Math.max(0, finalText.length - 100)));
if (finalText) {
formData.append('preview_text', finalText);
console.log('✅ preview_text 전송:', finalText.length, '자');
} else {
console.warn('⚠️ finalTranscript가 비어있습니다.');
}
try {
const response = await fetch('process_consult.php', {
method: 'POST',
body: formData
});
// HTTP 상태 코드 확인
if (!response.ok) {
const errorText = await response.text();
console.error('서버 통신 오류:', response.status, response.statusText);
console.error('응답 텍스트:', errorText);
console.error('응답 헤더:', Object.fromEntries(response.headers.entries()));
let errorMessage = `서버 오류 (HTTP ${response.status})`;
if (errorText) {
try {
const errorJson = JSON.parse(errorText);
errorMessage = errorJson.error || errorMessage;
if (errorJson.details) {
errorMessage += ': ' + errorJson.details;
}
} catch (e) {
errorMessage += ': ' + errorText.substring(0, 200);
}
}
alert(errorMessage);
throw new Error(errorMessage);
}
const text = await response.text();
// 빈 응답 체크
if (!text || text.trim() === '') {
console.error('서버 응답이 비어있습니다.');
console.error('응답 상태:', response.status, response.statusText);
console.error('응답 헤더:', Object.fromEntries(response.headers.entries()));
alert('서버 응답이 비어있습니다. PHP 오류 로그를 확인해주세요.');
throw new Error('Empty server response');
}
// JSON 파싱 전에 텍스트 확인
let result;
try {
result = JSON.parse(text);
} catch (parseError) {
console.error('JSON 파싱 오류:', parseError);
console.error('응답 텍스트:', text);
console.error('응답 길이:', text.length);
console.error('응답 상태:', response.status, response.statusText);
// PHP 에러나 경고가 포함되어 있을 수 있음
if (text.includes('Warning:') || text.includes('Fatal error:') || text.includes('Parse error:')) {
alert('PHP 오류가 발생했습니다. 서버 로그를 확인해주세요.\n\n' + text.substring(0, 300));
} else {
alert('서버 응답 형식 오류가 발생했습니다: ' + text.substring(0, 200));
}
throw parseError;
}
// 비동기 처리 확인
if (result.processing) {
// 작업이 시작되었고 처리 중임
console.log('=== 음성 인식 작업 시작 ===');
console.log('Consult ID:', result.consult_id);
console.log('Operation Name:', result.operation_name);
console.log('Poll URL:', result.poll_url);
console.log('========================');
updateStatus('음성 인식 처리 중...', 'processing');
// 폴링 시작
pollConsultStatus(result.consult_id, result.operation_name, result.poll_url);
} else if (result.ok) {
// 즉시 완료된 경우 (짧은 오디오)
console.log('=== 업무협의록 저장 성공 ===');
console.log('저장 ID:', result.id);
console.log('제목:', result.title);
console.log('전문 길이:', result.transcript?.length || 0, '자');
console.log('요약 길이:', result.summary?.length || 0, '자');
console.log('========================');
alert('업무협의록이 성공적으로 생성되었습니다.\n저장 ID: ' + result.id + '\n제목: ' + result.title);
updateStatus('완료', 'completed');
// 페이지 리로드하여 리스트 갱신
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
let errorMsg = '오류 발생: ' + result.error;
if (result.details) {
errorMsg += '\n상세: ' + result.details;
}
if (result.curl_error) {
errorMsg += '\nCURL 오류: ' + result.curl_error;
}
console.error('서버 오류 응답:', result);
alert(errorMsg);
updateStatus('오류', 'error');
}
} catch (e) {
console.error('서버 통신 오류:', e);
// JSON 파싱 오류인 경우 더 자세한 정보 표시
if (e.message && e.message.includes('JSON')) {
console.error('JSON 파싱 실패 - 서버 응답에 PHP 경고가 포함되어 있을 수 있습니다.');
alert('서버 응답 오류가 발생했습니다. 페이지를 새로고침해주세요.');
} else {
alert('서버 통신 오류: ' + (e.message || '알 수 없는 오류'));
}
updateStatus('오류', 'error');
} finally {
document.getElementById('voiceloadingOverlay').style.display = 'none';
saveBtn.disabled = false;
saveBtn.innerHTML = originalBtnText;
}
});
// 초기화: Recognition 핸들러 설정
if (SpeechRecognition) {
initSpeechRecognition();
setupRecognitionHandlers();
}
// 페이지 로드 시 로딩 오버레이 숨기기 및 전자결재 다이얼로그 닫기
document.addEventListener('DOMContentLoaded', function() {
const voiceloadingOverlay = document.getElementById('voiceloadingOverlay');
if (voiceloadingOverlay) {
voiceloadingOverlay.style.display = 'none';
}
// 전자결재 다이얼로그 자동 닫기
const eworksModal = document.getElementById('eworks_viewmodal');
if (eworksModal) {
eworksModal.style.display = 'none';
eworksModal.classList.remove('show');
}
const eworksDialog = document.querySelector('#eworks_viewmodal .modal-dialog');
if (eworksDialog) {
eworksDialog.style.display = 'none';
}
// Bootstrap 모달 닫기
if (typeof jQuery !== 'undefined' && jQuery('#eworks_viewmodal').length) {
jQuery('#eworks_viewmodal').modal('hide');
}
});
// 즉시 숨기기 (백업)
setTimeout(function() {
const voiceloadingOverlay = document.getElementById('voiceloadingOverlay');
if (voiceloadingOverlay) {
voiceloadingOverlay.style.display = 'none';
}
// 전자결재 다이얼로그 강제 닫기
const eworksModal = document.getElementById('eworks_viewmodal');
if (eworksModal) {
eworksModal.style.display = 'none';
eworksModal.classList.remove('show');
eworksModal.setAttribute('aria-hidden', 'true');
}
// 모든 모달 배경 제거
const modalBackdrops = document.querySelectorAll('.modal-backdrop');
modalBackdrops.forEach(function(backdrop) {
backdrop.remove();
});
// body 클래스 정리
document.body.classList.remove('modal-open');
document.body.style.overflow = '';
document.body.style.paddingRight = '';
}, 100);
// 업무협의록 상세보기
async function viewDetail(consultId) {
try {
const response = await fetch('get_consult.php?id=' + consultId);
const text = await response.text();
// JSON 파싱 전에 텍스트 확인
let result;
try {
result = JSON.parse(text);
} catch (parseError) {
console.error('JSON 파싱 오류:', parseError);
console.error('응답 텍스트:', text);
alert('서버 응답 오류가 발생했습니다. 콘솔을 확인해주세요.');
return;
}
if (result.ok) {
document.getElementById('modal-title').textContent = result.data.title;
document.getElementById('modal-transcript').textContent = result.data.transcript_text || '텍스트 없음';
document.getElementById('modal-summary').textContent = result.data.summary_text || '요약 없음';
document.getElementById('detail-modal').style.display = 'flex';
} else {
alert('데이터를 불러올 수 없습니다: ' + result.error);
}
} catch (error) {
console.error(error);
alert('서버 오류가 발생했습니다');
}
}
// 모달 닫기
function closeModal() {
document.getElementById('detail-modal').style.display = 'none';
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeModal();
}
});
// 모달 배경 클릭 시 닫기
document.getElementById('detail-modal')?.addEventListener('click', function(event) {
if (event.target === this) {
closeModal();
}
});
// 삭제 확인 모달 관련 변수
let deleteConsultId = null;
// 삭제 확인 모달 열기
function confirmDelete(consultId, consultTitle) {
deleteConsultId = consultId;
document.getElementById('delete-meeting-title').textContent = consultTitle;
document.getElementById('delete-modal').style.display = 'flex';
}
// 삭제 확인 모달 닫기
function closeDeleteModal() {
document.getElementById('delete-modal').style.display = 'none';
deleteConsultId = null;
}
// 업무협의록 삭제 실행
async function deleteMeeting() {
if (!deleteConsultId) {
alert('삭제할 업무협의록이 선택되지 않았습니다.');
return;
}
try {
const response = await fetch('delete_consult.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'id=' + deleteConsultId
});
const text = await response.text();
let result;
try {
result = JSON.parse(text);
} catch (parseError) {
console.error('JSON 파싱 오류:', parseError);
console.error('응답 텍스트:', text);
alert('서버 응답 오류가 발생했습니다.');
return;
}
if (result.ok) {
alert('업무협의록이 삭제되었습니다.');
closeDeleteModal();
// 페이지 새로고침하여 목록 업데이트
location.reload();
} else {
alert('삭제 실패: ' + result.error);
}
} catch (error) {
console.error(error);
alert('서버 오류가 발생했습니다');
}
}
// ESC 키로 삭제 모달도 닫기
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeModal();
closeDeleteModal();
}
});
// 삭제 모달 배경 클릭 시 닫기
document.getElementById('delete-modal')?.addEventListener('click', function(event) {
if (event.target === this) {
closeDeleteModal();
}
});
// 모바일 메뉴 초기화
function initMobileMenu() {
const menuToggle = document.getElementById('mobile-menu-toggle');
const menuClose = document.getElementById('mobile-menu-close');
const menuSidebar = document.getElementById('mobile-menu-sidebar');
const menuOverlay = document.getElementById('mobile-menu-overlay');
const menuContent = document.querySelector('.mobile-menu-content .navbar-nav');
const originalNav = document.querySelector('.navbar-custom .navbar-nav');
// 원본 nav 내용을 모바일 메뉴로 복사
if (originalNav && menuContent) {
menuContent.innerHTML = originalNav.innerHTML;
// 드롭다운 메뉴 토글 기능 추가
const dropdownToggles = menuContent.querySelectorAll('.dropdown-toggle');
dropdownToggles.forEach(toggle => {
toggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
const dropdown = this.nextElementSibling;
if (dropdown && dropdown.classList.contains('dropdown-menu')) {
const isOpen = dropdown.classList.contains('show');
// 모든 드롭다운 닫기
menuContent.querySelectorAll('.dropdown-menu').forEach(menu => {
menu.classList.remove('show');
});
menuContent.querySelectorAll('.dropdown-toggle').forEach(tog => {
tog.setAttribute('aria-expanded', 'false');
});
// 클릭한 드롭다운 토글
if (!isOpen) {
dropdown.classList.add('show');
toggle.setAttribute('aria-expanded', 'true');
}
}
});
});
}
// 메뉴 열기
function openMenu() {
menuSidebar.classList.add('active');
menuOverlay.style.display = 'block';
setTimeout(() => {
menuOverlay.classList.add('active');
}, 10);
document.body.style.overflow = 'hidden';
if (menuToggle) {
menuToggle.innerHTML = '<i class="bi bi-x-lg"></i>';
menuToggle.setAttribute('aria-label', '메뉴 닫기');
}
}
// 메뉴 닫기
function closeMenu() {
menuSidebar.classList.remove('active');
menuOverlay.classList.remove('active');
setTimeout(() => {
menuOverlay.style.display = 'none';
}, 300);
document.body.style.overflow = '';
if (menuToggle) {
menuToggle.innerHTML = '<i class="bi bi-list"></i>';
menuToggle.setAttribute('aria-label', '메뉴 열기');
}
}
// 메뉴 토글
function toggleMenu() {
const isOpen = menuSidebar.classList.contains('active');
if (isOpen) {
closeMenu();
} else {
openMenu();
}
}
// 이벤트 리스너
if (menuToggle) {
menuToggle.addEventListener('click', toggleMenu);
}
if (menuClose) {
menuClose.addEventListener('click', closeMenu);
}
if (menuOverlay) {
menuOverlay.addEventListener('click', closeMenu);
}
}
// 초기화
document.addEventListener('DOMContentLoaded', function() {
initMobileMenu();
});
</script>
<!-- 디버그 패널 초기화 스크립트 -->
<script>
// 디버그 패널 초기화 (HTML 로드 후 확실하게 실행)
(function() {
let isInitialized = false; // 중복 초기화 방지
function initDebugPanelNow() {
// 이미 초기화되었으면 중단
if (isInitialized) {
console.log('디버그 패널은 이미 초기화되었습니다.');
return;
}
const debugPanel = document.getElementById('debug-panel');
const debugToggleBtn = document.getElementById('debug-toggle-btn');
const debugCloseBtn = document.getElementById('debug-close-btn');
const debugClearBtn = document.getElementById('debug-clear-btn');
const debugCopyBtn = document.getElementById('debug-copy-btn');
const debugContent = document.getElementById('debug-content');
if (!debugPanel || !debugContent || !debugToggleBtn) {
console.warn('디버그 패널 요소를 찾을 수 없습니다. 재시도 중...');
setTimeout(initDebugPanelNow, 100);
return;
}
isInitialized = true; // 초기화 시작 표시
console.log('디버그 패널 요소 찾음, 이벤트 리스너 등록 중...');
// 디버그 패널 토글
function toggleDebugPanel() {
const isActive = debugPanel.classList.contains('active');
if (isActive) {
debugPanel.classList.remove('active');
debugToggleBtn.classList.remove('active');
console.log('디버그 패널 토글: 닫힘');
} else {
debugPanel.classList.add('active');
debugToggleBtn.classList.add('active');
console.log('디버그 패널 토글: 열림');
}
}
// 로그 추가
function addLog(message, type = 'log') {
const logDiv = document.createElement('div');
logDiv.className = `debug-log ${type}`;
const timestamp = new Date().toLocaleTimeString('ko-KR');
const logMessage = typeof message === 'object' ? JSON.stringify(message, null, 2) : String(message);
logDiv.innerHTML = `<span style="color: #888; font-size: 10px;">[${timestamp}]</span> ${logMessage}`;
debugContent.appendChild(logDiv);
// 최대 로그 개수 제한
while (debugContent.children.length > 100) {
debugContent.removeChild(debugContent.firstChild);
}
// 자동 스크롤
debugContent.scrollTop = debugContent.scrollHeight;
}
// 로그 지우기
function clearLogs() {
debugContent.innerHTML = '<div class="debug-log info">로그가 지워졌습니다.</div>';
}
// 로그 복사
async function copyLogs() {
try {
// 모든 로그 요소 수집
const logElements = Array.from(debugContent.children);
if (logElements.length === 0) {
console.warn('복사할 로그가 없습니다.');
return;
}
let logText = `=== 디버그 로그 ===\n생성 시간: ${new Date().toLocaleString('ko-KR')}\n로그 개수: ${logElements.length}\n==================\n\n`;
logElements.forEach((logEl, index) => {
// 타임스탬프와 메시지 추출
const logContent = logEl.textContent || logEl.innerText;
const logType = logEl.className.includes('error') ? '[ERROR]' :
logEl.className.includes('warn') ? '[WARN]' :
logEl.className.includes('info') ? '[INFO]' :
'[LOG]';
logText += `${index + 1}. ${logType} ${logContent}\n`;
});
// 클립보드에 복사
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(logText);
// 복사 성공 피드백
const originalIcon = debugCopyBtn.innerHTML;
debugCopyBtn.innerHTML = '<i class="bi bi-check"></i>';
debugCopyBtn.style.background = 'rgba(76, 175, 80, 0.3)';
console.log('✅ 로그가 클립보드에 복사되었습니다.');
// 2초 후 원래 아이콘으로 복원
setTimeout(() => {
debugCopyBtn.innerHTML = originalIcon;
debugCopyBtn.style.background = '';
}, 2000);
} else {
// 클립보드 API를 사용할 수 없는 경우 대체 방법
const textArea = document.createElement('textarea');
textArea.value = logText;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
console.log('✅ 로그가 클립보드에 복사되었습니다. (대체 방법)');
// 복사 성공 피드백
const originalIcon = debugCopyBtn.innerHTML;
debugCopyBtn.innerHTML = '<i class="bi bi-check"></i>';
debugCopyBtn.style.background = 'rgba(76, 175, 80, 0.3)';
setTimeout(() => {
debugCopyBtn.innerHTML = originalIcon;
debugCopyBtn.style.background = '';
}, 2000);
} catch (err) {
console.error('복사 실패:', err);
alert('복사에 실패했습니다. 로그를 수동으로 선택하여 복사해주세요.');
} finally {
document.body.removeChild(textArea);
}
}
} catch (error) {
console.error('로그 복사 중 오류 발생:', error);
alert('로그 복사 중 오류가 발생했습니다: ' + error.message);
}
}
// 이벤트 리스너 등록 (중복 방지)
// 기존 리스너가 있는지 확인하고 제거
if (debugToggleBtn._debugListener) {
debugToggleBtn.removeEventListener('click', debugToggleBtn._debugListener);
}
debugToggleBtn._debugListener = function(e) {
e.preventDefault();
e.stopPropagation();
console.log('디버그 버튼 클릭됨');
toggleDebugPanel();
};
debugToggleBtn.addEventListener('click', debugToggleBtn._debugListener);
if (debugCloseBtn) {
if (debugCloseBtn._debugListener) {
debugCloseBtn.removeEventListener('click', debugCloseBtn._debugListener);
}
debugCloseBtn._debugListener = function(e) {
e.preventDefault();
e.stopPropagation();
toggleDebugPanel();
};
debugCloseBtn.addEventListener('click', debugCloseBtn._debugListener);
}
if (debugClearBtn) {
if (debugClearBtn._debugListener) {
debugClearBtn.removeEventListener('click', debugClearBtn._debugListener);
}
debugClearBtn._debugListener = function(e) {
e.preventDefault();
e.stopPropagation();
clearLogs();
};
debugClearBtn.addEventListener('click', debugClearBtn._debugListener);
}
if (debugCopyBtn) {
if (debugCopyBtn._debugListener) {
debugCopyBtn.removeEventListener('click', debugCopyBtn._debugListener);
}
debugCopyBtn._debugListener = function(e) {
e.preventDefault();
e.stopPropagation();
copyLogs();
};
debugCopyBtn.addEventListener('click', debugCopyBtn._debugListener);
}
// console 오버라이드 (모바일에서도 오류 기록)
// 기존 console 함수 백업
const originalConsole = {
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console)
};
// console 오버라이드 함수
const overrideConsole = function() {
console.log = function(...args) {
originalConsole.log(...args);
try {
const message = args.map(arg => {
if (typeof arg === 'object') {
try {
return JSON.stringify(arg, null, 2);
} catch (e) {
return String(arg);
}
}
return String(arg);
}).join(' ');
addLog(message, 'log');
} catch (e) {
originalConsole.error('디버그 패널 로그 추가 실패:', e);
}
};
console.info = function(...args) {
originalConsole.info(...args);
try {
const message = args.map(arg => {
if (typeof arg === 'object') {
try {
return JSON.stringify(arg, null, 2);
} catch (e) {
return String(arg);
}
}
return String(arg);
}).join(' ');
addLog(message, 'info');
} catch (e) {
originalConsole.error('디버그 패널 로그 추가 실패:', e);
}
};
console.warn = function(...args) {
originalConsole.warn(...args);
try {
const message = args.map(arg => {
if (typeof arg === 'object') {
try {
return JSON.stringify(arg, null, 2);
} catch (e) {
return String(arg);
}
}
return String(arg);
}).join(' ');
addLog(message, 'warn');
} catch (e) {
originalConsole.error('디버그 패널 로그 추가 실패:', e);
}
};
console.error = function(...args) {
originalConsole.error(...args);
try {
const message = args.map(arg => {
if (typeof arg === 'object') {
try {
return JSON.stringify(arg, null, 2);
} catch (e) {
return String(arg);
}
}
return String(arg);
}).join(' ');
addLog(message, 'error');
} catch (e) {
originalConsole.error('디버그 패널 로그 추가 실패:', e);
}
};
};
// 즉시 오버라이드 실행
overrideConsole();
// 전역 에러 핸들러 (처리되지 않은 오류 캡처)
window.addEventListener('error', function(event) {
addLog(`[Uncaught Error] ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`, 'error');
if (event.error && event.error.stack) {
addLog(`Stack: ${event.error.stack}`, 'error');
}
});
// Promise rejection 핸들러 (처리되지 않은 Promise 오류 캡처)
window.addEventListener('unhandledrejection', function(event) {
addLog(`[Unhandled Promise Rejection] ${event.reason}`, 'error');
if (event.reason && event.reason.stack) {
addLog(`Stack: ${event.reason.stack}`, 'error');
}
});
// 전역 함수로 export
window.debugLog = addLog;
window.clearDebugLogs = clearLogs;
window.copyDebugLogs = copyLogs;
window.toggleDebugPanel = toggleDebugPanel;
// 초기 메시지
addLog('디버그 패널이 활성화되었습니다.', 'info');
addLog('모바일에서도 콘솔 로그를 확인할 수 있습니다.', 'info');
addLog('모든 console.log, console.error 등이 여기에 기록됩니다.', 'info');
console.log('✅ 디버그 패널 초기화 완료');
}
// 즉시 실행 또는 DOMContentLoaded 후 실행
// 모바일에서도 빠르게 초기화되도록 즉시 시도
function tryInit() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
// 모바일에서는 더 빠르게 초기화
setTimeout(initDebugPanelNow, 50);
});
// DOMContentLoaded 전에도 시도 (요소가 이미 있을 수 있음)
setTimeout(initDebugPanelNow, 50);
} else {
// 이미 로드된 경우 즉시 실행
setTimeout(initDebugPanelNow, 50);
}
}
// 즉시 시도
tryInit();
// 모바일에서는 추가로 재시도
if (isMobileDevice()) {
setTimeout(initDebugPanelNow, 200);
setTimeout(initDebugPanelNow, 500);
}
})();
</script>
<?php
// 출력 버퍼 flush
ob_end_flush();
?>
</body>
</html>