HUTAMS_AUDIO/app/templates/index.html

1317 lines
47 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HUTAMS — LTE-R 기반 지능형 관제 대시보드</title>
<!-- [Chapter 9.0] PWA Manifest 연동 -->
<link rel="manifest" href="/static/manifest.json">
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-base: #0f172a;
--bg-panel: #1e293b;
--border-color: #334155;
--accent: #38bdf8;
--text-main: #f8fafc;
--text-muted: #94a3b8;
}
/* Live Mode Radar Pulse Effect */
@keyframes radar-pulse {
0% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
70% {
box-shadow: 0 0 0 20px rgba(239, 68, 68, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
}
}
.live-pulse-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
background-color: #1e293b;
border: 1px dashed #ef4444;
border-radius: 12px;
}
.live-pulse-icon {
font-size: 2.5rem;
color: #ef4444;
border-radius: 50%;
animation: radar-pulse 2s infinite;
margin-bottom: 1rem;
}
body {
background-color: var(--bg-base);
color: var(--text-main);
font-family: 'Inter', sans-serif;
height: 100vh;
overflow: hidden;
}
/* Top Navigation */
.topbar {
background: linear-gradient(90deg, #0d1b2e 0%, #1e293b 100%);
height: 60px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 1.5rem;
}
.brand-logo {
color: var(--accent);
font-weight: 700;
font-size: 1.2rem;
letter-spacing: 1px;
}
.brand-sub {
color: var(--text-muted);
font-weight: 400;
font-size: 0.85rem;
margin-left: 0.5rem;
}
/* Layout */
.app-container {
display: flex;
height: calc(100vh - 60px);
flex-direction: row;
}
.history-panel {
flex: 0 0 32%;
/* Initial flex-basis */
min-width: 20%;
max-width: 60%;
background-color: var(--bg-panel);
display: flex;
flex-direction: column;
}
#resizer {
width: 6px;
background-color: var(--border-color);
cursor: col-resize;
transition: background-color 0.2s;
flex-shrink: 0;
z-index: 10;
}
#resizer:hover,
#resizer.active {
background-color: var(--accent);
}
.realtime-panel {
flex: 1 1 0;
/* Takes remaining space */
display: flex;
flex-direction: column;
background-color: var(--bg-base);
}
/* Disable text selection during drag */
.no-select {
user-select: none;
-webkit-user-select: none;
}
.panel-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background-color: rgba(0, 0, 0, 0.1);
font-weight: 600;
flex-shrink: 0;
}
.scroll-area {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
/* History List Cards */
.h-card {
background-color: #0f172a;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.h-card:hover {
border-color: var(--accent);
background-color: #1a2235;
transform: translateY(-1px);
}
.h-card.urgent {
border-left: 4px solid #ef4444;
}
.h-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.4rem;
}
.h-title {
font-weight: 600;
font-size: 0.95rem;
line-height: 1.3;
}
.h-time {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
margin-left: 0.5rem;
}
.h-summary {
font-size: 0.85rem;
color: #cbd5e1;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
/* 표준 속성 (lint 경고 해결) */
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Badges */
.badge-긴급 {
background-color: #ef4444;
color: white;
}
.badge-일반 {
background-color: #334155;
color: var(--accent);
}
.badge-관제 {
background-color: #0ea5e9;
color: white;
}
.badge-열차 {
background-color: #10b981;
color: white;
}
.badge-미상 {
background-color: #64748b;
color: white;
}
.badge {
font-weight: 500;
font-size: 0.75rem;
margin-right: 0.4rem;
padding: 0.35em 0.65em;
}
/* Forms & Inputs */
.form-control-dark,
.form-select-dark {
background-color: #0f172a;
border: 1px solid var(--border-color);
color: var(--text-main);
}
.form-control-dark:focus,
.form-select-dark:focus {
background-color: #0f172a;
color: var(--text-main);
border-color: var(--accent);
box-shadow: 0 0 0 0.2rem rgba(56, 189, 248, 0.25);
}
.upload-zone {
border: 2px dashed var(--border-color);
border-radius: 12px;
padding: 2.5rem;
text-align: center;
cursor: pointer;
background-color: #1e293b;
transition: all 0.2s ease;
}
.upload-zone:hover {
border-color: var(--accent);
background-color: #24344d;
}
/* Segment Items */
.segment-item {
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.8rem 1rem;
margin-bottom: 0.5rem;
cursor: pointer;
transition: background-color 0.2s;
}
.segment-item:hover {
background-color: rgba(56, 189, 248, 0.08);
border-color: rgba(56, 189, 248, 0.4);
}
.seg-time {
font-family: monospace;
color: var(--accent);
font-size: 0.8rem;
min-width: 95px;
display: inline-block;
}
/* Audio Player Custom */
audio {
width: 100%;
height: 40px;
border-radius: 8px;
filter: invert(1) hue-rotate(180deg) contrast(1.2);
}
/* Modal Overrides */
.modal-content {
background-color: var(--bg-panel);
border: 1px solid var(--border-color);
}
.modal-header,
.modal-footer {
border-color: var(--border-color);
}
.btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
/* Spinner */
.custom-spinner {
width: 3rem;
height: 3rem;
border: 4px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: s-spin 1s linear infinite;
}
@keyframes s-spin {
to {
transform: rotate(360deg);
}
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
/* ── 실시간 스트림 누적 블록 ── */
.rt-block {
padding: 0.75rem 1rem;
border-left: 3px solid var(--accent);
background: rgba(30, 41, 59, 0.6);
border-radius: 0 8px 8px 0;
margin-bottom: 0.5rem;
}
.rt-block-header {
font-size: 0.9rem;
}
/* ── Chapter 7.0: 긴급 카드 블링킹 및 확인(Acknowledge) 처리 ── */
@keyframes urgency-blink {
0% {
border-color: #dc3545;
box-shadow: 0 0 10px rgba(220, 53, 69, 0.8);
}
50% {
border-color: transparent;
box-shadow: none;
}
100% {
border-color: #dc3545;
box-shadow: 0 0 10px rgba(220, 53, 69, 0.8);
}
}
@keyframes icon-blink {
0% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.card-urgent-blinking {
animation: urgency-blink 1s infinite;
border: 2px solid #dc3545 !important;
cursor: pointer;
}
.card-urgent-blinking .urgency-icon {
animation: icon-blink 1s infinite;
display: inline-block;
}
.card-urgent-acknowledged {
border: 2px solid #dc3545 !important;
}
/* ── Daily 채팅 버블 (카카오톡 스타일) ── */
.chat-bubble {
padding: 0.5rem 0.8rem;
border-radius: 16px;
font-size: 0.88rem;
line-height: 1.5;
max-width: 100%;
word-break: break-word;
}
.chat-left {
background: #334155;
color: #f1f5f9;
border-top-left-radius: 4px;
}
.chat-right {
background: #0ea5e9;
color: #fff;
border-top-right-radius: 4px;
}
</style>
</head>
<body>
<!-- Topbar -->
<header class="topbar">
<div class="brand-logo">HUTAMS<span class="brand-sub">LTE-R 무전 관제 플랫폼</span></div>
<div class="ms-auto text-muted small d-flex align-items-center gap-3">
<span><i class="bi bi-cpu"></i> LLM 0.8B Active</span>
<span><i class="bi bi-database"></i> SQLite Sync</span>
</div>
</header>
<div class="app-container">
<!-- Left: History Panel (Dual Tab) -->
<aside class="history-panel">
<div class="panel-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-archive-fill me-2 text-info"></i>무전 이력</span>
<span class="badge bg-secondary rounded-pill" id="history-count">0</span>
</div>
<!-- Dual Tab -->
<div class="px-3 pt-2" style="border-bottom:1px solid var(--border-color);">
<ul class="nav nav-pills nav-fill gap-1 pb-2" id="historyTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active py-1 small" id="tab-list" data-bs-toggle="pill" data-bs-target="#pane-list"
type="button" role="tab">
<i class="bi bi-list-ul me-1"></i>요약 리스트
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link py-1 small" id="tab-daily" data-bs-toggle="pill" data-bs-target="#pane-daily"
type="button" role="tab">
<i class="bi bi-chat-dots me-1"></i>일자별 기록
</button>
</li>
</ul>
</div>
<div class="tab-content d-flex flex-column flex-grow-1 overflow-hidden">
<!-- Tab 1: 요약 리스트 -->
<div id="pane-list" class="tab-pane fade show active d-flex flex-column flex-grow-1 overflow-hidden"
role="tabpanel">
<div class="p-2 border-bottom" style="border-color:var(--border-color)!important;">
<div class="input-group input-group-sm">
<span class="input-group-text bg-transparent border-end-0 border-secondary text-muted">
<i class="bi bi-search"></i></span>
<input type="text" id="search-input" class="form-control form-control-dark border-start-0"
placeholder="키워드 검색...">
</div>
</div>
<div class="scroll-area flex-grow-1" id="history-list">
<div class="text-center text-muted mt-5"><i class="bi bi-arrow-clockwise"></i></div>
</div>
</div>
<!-- Tab 2: 일자별 기록 (채팅뷰) -->
<div id="pane-daily" class="tab-pane fade d-flex flex-column flex-grow-1 overflow-hidden" role="tabpanel">
<div class="p-2 border-bottom" style="border-color:var(--border-color)!important;">
<input type="date" id="daily-date" class="form-control form-control-sm form-control-dark" value=""
title="조회 날짜">
</div>
<div class="scroll-area flex-grow-1" id="daily-chat-area">
<div class="text-center text-muted mt-5 small">날짜를 선택하면 전체 통신 기록이 나타납니다.</div>
</div>
</div>
</div>
</aside>
<!-- Resizer (Splitter) -->
<div id="resizer"></div>
<!-- Right: Realtime & Actions Panel -->
<main class="realtime-panel">
<div class="panel-header d-flex justify-content-between align-items-center">
<div><i class="bi bi-broadcast me-2 text-info"></i>실시간 무전 STT 변환</div>
<div class="form-check form-switch fw-normal small mb-0 d-flex align-items-center gap-2">
<label class="form-check-label text-muted" for="modeSwitch">Test Mode</label>
<input class="form-check-input mt-0" type="checkbox" role="switch" id="modeSwitch">
<label class="form-check-label text-danger" for="modeSwitch" id="liveLabel" style="opacity: 0.5;">Live
Mode</label>
</div>
</div>
<div class="scroll-area">
<!-- Live Mode Waiting Panel -->
<div id="live-waiting-panel" class="mb-4" style="display:none;">
<div class="live-pulse-container">
<i class="bi bi-mic-fill live-pulse-icon"></i>
<h5 class="text-danger fw-bold">🔴 실시간 무전 수신 대기 중...</h5>
<p class="text-muted small mb-0">웹소켓(WebSocket)이 연결되어 오디오 스트림을 감청합니다.</p>
</div>
</div>
<!-- Upload Form -->
<form id="stt-form" class="mb-4">
<div class="upload-zone mb-3" id="upload-zone" onclick="document.getElementById('audio-file').click()">
<i class="bi bi-cloud-upload display-5 text-info mb-3"></i>
<h5>오디오 파일 업로드</h5>
<p class="text-muted small mb-0">클릭하거나 파일을 이 영역으로 드래그 (m4a, wav 등)</p>
<input type="file" id="audio-file" style="display:none" accept="audio/*,video/*">
</div>
<div id="file-indicator" class="text-info small mb-3 text-center" style="display:none;"></div>
<div class="row g-2 mb-3">
<div class="col-md-4">
<label class="form-label small text-muted">언어</label>
<select id="lang-select" class="form-select form-select-dark">
<option value="ko" selected>한국어</option>
<option value="en">English</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label small text-muted">녹음 시작 시간 (옵션)</label>
<input type="datetime-local" step="1" id="base-dt" class="form-control form-control-dark">
</div>
</div>
<button type="submit" id="submit-btn" class="btn btn-info w-100 fw-bold text-white shadow-sm">
<i class="bi bi-play-fill"></i> STT 변환 시작
</button>
</form>
<!-- Loading State -->
<div id="loading-area" class="text-center py-5" style="display:none;">
<div class="custom-spinner mx-auto mb-3"></div>
<h6 class="text-info">STT & LLM 연산 중입니다...</h6>
<p class="text-muted small">AI가 화자 분리 및 요약을 수행하고 있습니다.</p>
</div>
<!-- Realtime Stream Area & Context Panel -->
<div id="rt-container" class="row g-3 flex-grow-1 h-100" style="display:none;">
<div class="col-lg-8 d-flex flex-column h-100">
<!-- 메신저형 누적 뷰 -->
<div id="rt-stream" class="flex-grow-1 overflow-auto pe-2"></div>
</div>
<div class="col-lg-4 d-flex flex-column h-100 border-start border-secondary ps-3">
<h6 class="text-info fw-bold mb-3"><i class="bi bi-lightbulb-fill text-warning me-2"></i>관련 지식 (Context)
</h6>
<div id="context-panel" class="flex-grow-1 overflow-auto pb-3">
<div class="text-muted small text-center mt-5"><i class="bi bi-inbox mb-2 d-block fs-3"></i>지식 대기 중...
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Detail Modal -->
<div class="modal fade" id="detailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<span id="modal-urgency" class="badge me-2"></span>
<h5 class="modal-title d-inline-block fw-bold" id="modal-title">무전 상세 정보</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="text-muted small mb-3 align-items-center d-flex gap-3">
<span id="modal-time"><i class="bi bi-clock me-1"></i> -</span>
<span id="modal-filename"><i class="bi bi-file-earmark-music me-1"></i> -</span>
</div>
<div class="p-3 rounded mb-4"
style="background-color: rgba(0,0,0,0.2); border: 1px solid var(--border-color);">
<p class="mb-2 text-light" id="modal-summary"></p>
<div id="modal-keywords"></div>
</div>
<audio id="modal-audio" controls class="mb-4"></audio>
<h6 class="text-muted mb-3"><i class="bi bi-chat-text me-2"></i>상세 통신 기록</h6>
<div id="modal-segments"></div>
</div>
<div class="modal-footer justify-content-between">
<div class="small text-muted" id="modal-bench"></div>
<div>
<button type="button" class="btn btn-outline-info me-2" id="export-btn"><i
class="bi bi-download me-1"></i>TXT 내보내기</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
</div>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Application Logic -->
<script>
// 전역 상태
let allRecords = [];
let currentDetailRecord = null;
let localFileUrl = null;
let rtAudioUrl = null;
const detailModal = new bootstrap.Modal(document.getElementById('detailModal'));
// 유틸: 초 포맷팅
const fmt = sec => {
if (sec == null) return "00:00";
const m = String(Math.floor(sec / 60)).padStart(2, '0');
const s = String(Math.floor(sec % 60)).padStart(2, '0');
return `${m}:${s}`;
};
// ─── 1. 좌측 이력 패널 로드 ───
async function loadHistory(keyword = '') {
const url = `/api/v1/records?limit=50${keyword ? '&keyword=' + encodeURIComponent(keyword) : ''}`;
const listEl = document.getElementById('history-list');
try {
const res = await fetch(url);
const data = await res.json();
allRecords = data.records || [];
document.getElementById('history-count').textContent = data.total;
if (allRecords.length === 0) {
listEl.innerHTML = '<div class="text-center text-muted mt-5"><i class="bi bi-inbox display-4 d-block mb-3"></i>기록이 없습니다.</div>';
return;
}
listEl.innerHTML = '';
allRecords.forEach(r => {
const isUrgent = r.urgency === '긴급';
const urgencyClass = `badge-${r.urgency || '일반'}`;
const dt = r.created_at ? new Date(r.created_at).toLocaleString() : '-';
// 카드 마크업
const card = document.createElement('div');
card.className = `h-card ${isUrgent ? 'urgent' : ''}`;
card.onclick = () => openModal(r.id);
card.innerHTML = `
<div class="h-title-row">
<div class="h-title text-truncate me-2">${r.title || r.filename}</div>
<div class="d-flex align-items-center">
<span class="badge ${urgencyClass} rounded-pill">${r.urgency || '일반'}</span>
</div>
</div>
<div class="h-summary mb-2" title="${r.summary}">${r.summary || r.full_text || '요약 정보 없음'}</div>
<div class="h-time"><i class="bi bi-clock me-1"></i>${dt}</div>
`;
listEl.appendChild(card);
});
} catch (err) {
listEl.innerHTML = `<div class="text-danger mt-4 text-center">불러오기 실패: ${err.message}</div>`;
}
}
// 검색 디바운스 처리
let searchTimer;
document.getElementById('search-input').addEventListener('input', e => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadHistory(e.target.value.trim()), 400);
});
// ─── 2. 모달 열기 및 데이터 주입 ───
function openModal(id) {
const record = allRecords.find(r => r.id === id);
if (!record) return;
currentDetailRecord = record;
// 헤더
const uBadge = document.getElementById('modal-urgency');
uBadge.className = `badge badge-${record.urgency || '일반'} rounded-pill`;
uBadge.textContent = record.urgency || '일반';
document.getElementById('modal-title').textContent = record.title || '상세 정보';
// 메타 정보
document.getElementById('modal-time').innerHTML = `<i class="bi bi-clock me-1"></i> ${new Date(record.created_at).toLocaleString()}`;
document.getElementById('modal-filename').innerHTML = `<i class="bi bi-file-earmark-music me-1"></i> ${record.filename}`;
document.getElementById('modal-summary').textContent = record.summary || record.full_text;
const kwArea = document.getElementById('modal-keywords');
kwArea.innerHTML = '';
if (record.keywords) {
record.keywords.split(',').forEach(k => {
kwArea.innerHTML += `<span class="badge bg-secondary me-1 fw-normal">${k.trim()}</span>`;
});
}
// 오디오 소스 정적 서빙 경로 연동
const audio = document.getElementById('modal-audio');
if (record.filename) {
audio.src = `/static/audio/${record.filename}`;
audio.style.display = 'block';
} else {
audio.src = '';
audio.style.display = 'none';
}
// 벤치마크
document.getElementById('modal-bench').innerHTML = `처리시간: <b>${record.processing_time_sec}s</b> | RAM: <b>${record.peak_memory_mb}MB</b>`;
// 세그먼트 (화자 뱃지 포함)
const segArea = document.getElementById('modal-segments');
segArea.innerHTML = '';
if (record.segments && record.segments.length > 0) {
record.segments.forEach(seg => {
const spkrBadge = seg.speaker ? `<span class="badge badge-${seg.speaker}">${seg.speaker}</span>` : '';
const div = document.createElement('div');
div.className = 'segment-item d-flex align-items-start';
div.innerHTML = `
<div class="seg-time">${fmt(seg.start_sec)}</div>
<div class="flex-grow-1">
${spkrBadge} <span class="text-light">${seg.text}</span>
</div>
`;
// 재생 기능 (로컬 임시 URL이 살아있을때만 의미있음)
div.onclick = () => { if (audio.src) { audio.currentTime = seg.start_sec; audio.play(); } };
segArea.appendChild(div);
});
} else {
segArea.innerHTML = `<div class="p-3 text-muted">${record.full_text}</div>`;
}
detailModal.show();
}
// TXT 내보내기
document.getElementById('export-btn').onclick = () => {
if (!currentDetailRecord) return;
const r = currentDetailRecord;
let content = `제목: ${r.title}\n일시: ${r.created_at}\n요약: ${r.summary}\n긴급도: ${r.urgency}\n\n[통신 기록]\n`;
if (r.segments && r.segments.length > 0) {
r.segments.forEach(s => { content += `[${fmt(s.start_sec)}] ${s.speaker ? '<' + s.speaker + '> ' : ''}${s.text}\n`; });
} else {
content += r.full_text;
}
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `HUTAMS_Record_${r.id}.txt`;
a.click();
};
// ─── 3. 실시간 업로드 및 타자기 효과 ───
const fileInput = document.getElementById('audio-file');
const uploadZone = document.getElementById('upload-zone');
const fileInd = document.getElementById('file-indicator');
uploadZone.ondragover = e => { e.preventDefault(); uploadZone.style.borderColor = "var(--accent)"; };
uploadZone.ondragleave = e => { uploadZone.style.borderColor = "var(--border-color)"; };
uploadZone.ondrop = e => {
e.preventDefault();
uploadZone.style.borderColor = "var(--border-color)";
if (e.dataTransfer.files.length) { fileInput.files = e.dataTransfer.files; handleFileSelect(); }
};
fileInput.onchange = handleFileSelect;
function handleFileSelect() {
if (fileInput.files[0]) {
fileInd.innerHTML = `<i class="bi bi-file-earmark-music me-1"></i> 선택됨: ${fileInput.files[0].name}`;
fileInd.style.display = 'block';
if (localFileUrl) URL.revokeObjectURL(localFileUrl);
localFileUrl = URL.createObjectURL(fileInput.files[0]);
}
}
document.getElementById('stt-form').onsubmit = async (e) => {
e.preventDefault();
if (!fileInput.files[0]) return alert('파일을 먼저 선택해주세요.');
// Toggle UI
document.getElementById('submit-btn').disabled = true;
document.getElementById('realtime-result').style.display = 'none';
document.getElementById('loading-area').style.display = 'block';
const fd = new FormData();
fd.append('audio', fileInput.files[0]);
fd.append('language', document.getElementById('lang-select').value);
const bdt = document.getElementById('base-dt').value;
if (bdt) fd.append('base_datetime', bdt);
try {
const res = await fetch('/api/v1/transcribe', { method: 'POST', body: fd });
if (!res.ok) throw new Error((await res.json()).detail || `HTTP ${res.status}`);
const data = await res.json();
renderRealtimeStream(data, true); // true = typewriter
loadHistory();
} catch (err) {
alert(`변환 실패: ${err.message}`);
} finally {
document.getElementById('loading-area').style.display = 'none';
document.getElementById('submit-btn').disabled = false;
fileInput.value = '';
fileInd.style.display = 'none';
}
};
// ─── 3. 누적형 실시간 스트림 렌더러 (Jitter Debounce 적용) ────────────────
const RT_MAX_BLOCKS = 100;
// UI 깜빡임 방지용 대기열 (Debounce Queue)
let _sttResultQueue = {};
let currentSharedAudio = null;
let currentSharedPlayBtn = null;
function playSegmentAudio(id, btn) {
if (currentSharedAudio) {
currentSharedAudio.pause();
currentSharedAudio.currentTime = 0;
if (currentSharedPlayBtn) {
currentSharedPlayBtn.innerHTML = '<i class="bi bi-play-fill"></i> 재생';
}
}
currentSharedAudio = new Audio(`/api/v1/segments/${id}/audio`);
currentSharedPlayBtn = btn;
btn.innerHTML = '<i class="bi bi-stop-fill"></i> 정지';
currentSharedAudio.play().catch(e => {
console.error("Audio play failed:", e);
alert("오디오 다운로드 또는 재생에 실패했습니다.");
btn.innerHTML = '<i class="bi bi-play-fill"></i> 재생';
});
currentSharedAudio.onended = () => {
btn.innerHTML = '<i class="bi bi-play-fill"></i> 재생';
currentSharedAudio = null;
currentSharedPlayBtn = null;
};
}
function scheduleRealtimeStream(data, typewriter = true) {
if (!typewriter) {
// late_join 처럼 즉각 표시해야 하는 것은 딜레이 없이 렌더링
renderRealtimeStream(data, false);
return;
}
// 새 레코드를 바로 그리지 않고 1500ms 대기 (이 안에 thread_updated가 오면 취소/병합됨)
const keyId = data.segments && data.segments.length > 0 ? data.segments[0].id : data.id;
_sttResultQueue[keyId] = {
data: data,
typewriter: typewriter,
timer: setTimeout(() => {
renderRealtimeStream(data, true);
delete _sttResultQueue[keyId];
}, 1500)
};
}
function renderRealtimeStream(data, typewriter = true) {
const container = document.getElementById('rt-container');
const stream = document.getElementById('rt-stream');
container.style.display = 'flex';
container.closest('.scroll-area').style.display = 'flex';
container.closest('.scroll-area').style.flexDirection = 'column';
// ── 큐 초과 시 오래된 블록 제거 ──
while (stream.children.length >= RT_MAX_BLOCKS) {
stream.removeChild(stream.firstChild);
}
// ── 새 Record 블록 생성 ──
const urgency = data.urgency || '일반';
const isUrgent = urgency === '긴급';
const block = document.createElement('div');
block.className = `rt-block ${isUrgent ? 'card-urgent-blinking' : ''}`;
if (isUrgent) {
block.onclick = function () {
this.classList.remove('card-urgent-blinking');
this.classList.add('card-urgent-acknowledged');
};
}
block.style.cssText = typewriter
? 'opacity:0; transform:translateY(8px); transition:all 0.35s ease;'
: '';
// 키워드 뱃지 HTML
const kwHtml = data.keywords
? data.keywords.split(',').map(k =>
`<span class="badge bg-secondary fw-normal me-1">${k.trim()}</span>`).join('')
: '';
// 열번 추출 뱃지
const trainNoHtml = data.train_number
? `<span class="badge bg-warning text-dark ms-2"><i class="bi bi-train-front-fill me-1"></i>${data.train_number}</span>`
: '';
const urgencyIconHtml = isUrgent ? '<i class="bi bi-exclamation-triangle-fill text-danger me-1 urgency-icon"></i>' : '';
// 헤더
const ts = new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
block.innerHTML = `
<div class="rt-block-header d-flex justify-content-between align-items-center mb-1">
<span class="fw-bold text-white">${data.title || '실시간 무전'}${trainNoHtml}</span>
<span class="d-flex align-items-center gap-2">
${urgencyIconHtml}
<span class="badge badge-${urgency}">${urgency}</span>
<span class="text-muted small">${ts}</span>
</span>
</div>
<p class="text-secondary small mb-1">${data.summary || ''}</p>
<div class="mb-2">${kwHtml}</div>
<div class="rt-block-segs"></div>
<hr class="border-secondary my-2">
`;
stream.appendChild(block);
// ── 세그먼트 렌더링 ──
const segArea = block.querySelector('.rt-block-segs');
const segs = data.segments || [];
if (segs.length > 0) {
segs.forEach((seg, i) => {
const div = document.createElement('div');
div.className = 'segment-item d-flex align-items-start mb-1';
const spkr = seg.speaker
? `<span class="badge badge-${seg.speaker} me-1">${seg.speaker}</span>` : '';
const playBtn = seg.id
? `<button class="btn btn-sm btn-outline-info py-0 px-1 ms-2" style="font-size:0.75rem;" onclick="playSegmentAudio(${seg.id}, this)"><i class="bi bi-play-fill"></i> 재생</button>`
: '';
div.innerHTML = `
<div class="seg-time align-self-center">${fmt(seg.start_sec)}</div>
<div class="flex-grow-1 align-self-center lh-sm">${spkr}<span class="type-target text-light"></span>${playBtn}</div>
`;
segArea.appendChild(div);
if (typewriter) {
setTimeout(() => {
const target = div.querySelector('.type-target');
let idx = 0;
const txt = seg.text || '';
(function typeChar() {
if (idx < txt.length) {
target.textContent += txt.charAt(idx++);
setTimeout(typeChar, 22);
}
})();
}, 200 + i * 500);
} else {
div.querySelector('.type-target').textContent = seg.text || '';
}
});
} else {
segArea.innerHTML = `<div class="p-2 text-muted small">${data.text || data.summary || ''}</div>`;
}
// ── 블록 Fade-in ──
if (typewriter) {
requestAnimationFrame(() => {
block.style.opacity = '1';
block.style.transform = 'translateY(0)';
});
}
// ── Auto-scroll: 렌더 완료 후 맨 아래로 ──
const scrollEl = stream.closest('.scroll-area');
setTimeout(() => {
scrollEl.scrollTop = scrollEl.scrollHeight;
}, typewriter ? 300 : 50);
}
// ─── 4. Daily 채팅뷰 탭 로직 ─────────────────────────────────────────────
const dailyDateInput = document.getElementById('daily-date');
// 오늘 날짜를 기본값으로
dailyDateInput.value = new Date().toISOString().slice(0, 10);
// ── Daily 채팅뷰 상태 변수 ──
let _dailyCursor = null;
let _dailyLoading = false;
let _dailyHasMore = false;
let _dailyCurrentDate = '';
function _renderSegBubble(seg) {
const spkr = seg.speaker || '불명';
const timeStr = seg.absolute_start_time
? new Date(seg.absolute_start_time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: fmt(seg.start_sec);
const txt = seg.text || '';
const reviewedMark = seg.is_reviewed ? ' <i class="bi bi-check2 text-success" title="검토완료"></i>' : '';
const bubble = document.createElement('div');
if (spkr === '불명') {
bubble.className = 'd-flex justify-content-center mb-2 px-2';
bubble.innerHTML = `
<div class="text-center" style="max-width:90%;">
<span class="badge bg-secondary fw-normal small">${spkr}${reviewedMark}</span>
<div class="text-muted small mt-1">${txt}</div>
<div style="font-size:0.68rem; color:#64748b;">${timeStr}</div>
</div>`;
} else {
const isCtrl = spkr === '관제';
bubble.className = `d-flex mb-2 ${isCtrl ? 'flex-row-reverse' : 'flex-row'} align-items-end gap-2 px-2`;
bubble.innerHTML = `
<div style="max-width:75%;">
<div class="small text-muted mb-1 ${isCtrl ? 'text-end' : ''}">${spkr}${reviewedMark}</div>
<div class="chat-bubble chat-${isCtrl ? 'right' : 'left'}">${txt}</div>
<div class="small text-muted mt-1 ${isCtrl ? 'text-end' : ''}" style="font-size:0.7rem;">${timeStr}</div>
</div>`;
}
return bubble;
}
async function _fetchDailyPage(dateStr, cursor) {
const cursorParam = cursor != null ? `&cursor=${cursor}` : '';
const res = await fetch(`/api/v1/segments/daily?date=${dateStr}&limit=100${cursorParam}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json(); // { items, next_cursor, has_more }
}
async function loadDailyChat(dateStr) {
const chatArea = document.getElementById('daily-chat-area');
chatArea.innerHTML = '<div class="text-center text-muted mt-5 small"><i class="bi bi-arrow-clockwise"></i> 불러오는 중...</div>';
_dailyCursor = null;
_dailyHasMore = false;
_dailyCurrentDate = dateStr;
try {
const page = await _fetchDailyPage(dateStr, null);
console.debug(`[Daily] ${dateStr}: items=${page.items.length}, has_more=${page.has_more}, next_cursor=${page.next_cursor}`);
if (!page.items.length) {
chatArea.innerHTML = '<div class="text-center text-muted mt-5 small"><i class="bi bi-inbox"></i><br>해당 날짜에 기록이 없습니다.</div>';
return;
}
chatArea.innerHTML = '';
page.items.forEach(seg => chatArea.appendChild(_renderSegBubble(seg)));
chatArea.scrollTop = chatArea.scrollHeight;
_dailyCursor = page.next_cursor;
_dailyHasMore = page.has_more;
// 더 불러오기 버튼 표시
if (_dailyHasMore) _appendLoadMoreBtn(chatArea, dateStr);
} catch (err) {
chatArea.innerHTML = `<div class="text-center text-danger mt-5 small">불러오기 실패: ${err.message}</div>`;
console.error('[Daily] API 오류:', err);
}
}
function _appendLoadMoreBtn(chatArea, dateStr) {
const btn = document.createElement('div');
btn.className = 'text-center my-3'; btn.id = 'daily-load-more';
btn.innerHTML = '<button class="btn btn-sm btn-outline-secondary">다음 기록 더 보기</button>';
btn.querySelector('button').onclick = async () => {
if (_dailyLoading || !_dailyHasMore) return;
_dailyLoading = true;
btn.querySelector('button').textContent = '불러오는 중...';
try {
const page = await _fetchDailyPage(dateStr, _dailyCursor);
btn.remove();
page.items.forEach(seg => chatArea.appendChild(_renderSegBubble(seg)));
_dailyCursor = page.next_cursor;
_dailyHasMore = page.has_more;
if (_dailyHasMore) _appendLoadMoreBtn(chatArea, dateStr);
} catch (e) { console.error('[Daily] 더보기 실패:', e); }
finally { _dailyLoading = false; }
};
chatArea.appendChild(btn);
}
// 날짜 변경 시 자동 로드
dailyDateInput.addEventListener('change', () => loadDailyChat(dailyDateInput.value));
// Daily 탭 클릭 시 자동 로드
document.getElementById('tab-daily').addEventListener('shown.bs.tab', () => {
loadDailyChat(dailyDateInput.value);
});
// ── Init ──
loadHistory();
// ─── 5. 드래그 스플리터 (Resizer) 로직 ───
const resizer = document.getElementById('resizer');
const leftPanel = document.querySelector('.history-panel');
let isResizing = false;
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
resizer.classList.add('active');
document.body.classList.add('no-select'); // 드래그 중 텍스트 선택 방지
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
// 좌측 패널의 새로운 너비 계산 (전체 창 대비)
const newWidth = (e.clientX / window.innerWidth) * 100;
// 최소 20%, 최대 60% 제한
if (newWidth >= 20 && newWidth <= 60) {
leftPanel.style.flexBasis = `${newWidth}%`;
}
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
resizer.classList.remove('active');
document.body.classList.remove('no-select');
}
});
// ─── 5. UI 토글 모드 및 WebSocket 로직 ───
const modeSwitch = document.getElementById('modeSwitch');
const sttForm = document.getElementById('stt-form');
const liveWaitingPanel = document.getElementById('live-waiting-panel');
const liveLabel = document.getElementById('liveLabel');
let ws = null;
modeSwitch.addEventListener('change', (e) => {
const isLiveMode = e.target.checked;
if (isLiveMode) {
// Live Mode 활성화
sttForm.style.display = 'none';
liveWaitingPanel.style.display = 'block';
liveLabel.style.opacity = '1';
// WebSocket 연결 시작
connectWebSocket();
} else {
// Test Mode 복귀
sttForm.style.display = 'block';
liveWaitingPanel.style.display = 'none';
liveLabel.style.opacity = '0.5';
// WebSocket 연결 종료
if (ws) {
ws.close();
ws = null;
}
}
});
function connectWebSocket() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/live`;
ws = new WebSocket(wsUrl);
ws.onopen = () => { console.log("[WS] Connected"); };
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.debug("[WS] received:", data.type, data.title || '');
if (data.type === 'info') return;
if (data.type === 'stt_result') {
const renderData = {
id: data.record_id,
title: data.title || '실시간 무전',
summary: data.summary || data.full_text || '',
keywords: data.keywords || '',
urgency: data.urgency || '일반',
train_number: data.train_number || null,
segments: data.segments || [],
text: data.text || data.full_text || '',
};
// late_join=true → 즉시 표시( typewriter=false), 신규 → 타자기 효과
scheduleRealtimeStream(renderData, !data.late_join);
// history ajax reloading with delay
setTimeout(loadHistory, 100);
}
if (data.type === 'context_discovered') {
renderContextCard(data.data.contexts);
}
if (data.type === 'thread_updated') {
const upd = data.data;
console.debug(`[Thread] Action=${upd.action}, RecordID=${upd.record_id}, Speaker=${upd.speaker}`);
// [Chapter 8.0] State Loss 완전 해결 및 UI Jitter 방어
if (upd.action === 'append' && _sttResultQueue[upd.segment_id]) {
const pendingReq = _sttResultQueue[upd.segment_id];
const pendingData = pendingReq.data;
clearTimeout(pendingReq.timer);
delete _sttResultQueue[upd.segment_id];
console.debug(`[Jitter 방지] segment=${upd.segment_id} 의 렌더링을 취소하고 기존 DOM 카드에 병합을 시작합니다.`);
const stream = document.getElementById('rt-stream');
if (stream.lastChild) {
const block = stream.lastChild;
const segArea = block.querySelector('.rt-block-segs');
if (segArea) {
const segs = pendingData.segments || [];
segs.forEach((seg, i) => {
const div = document.createElement('div');
div.className = 'segment-item d-flex align-items-start mb-1';
const spkr = upd.speaker ? `<span class="badge badge-${upd.speaker} me-1">${upd.speaker}</span>` : '';
const playBtn = seg.id ? `<button class="btn btn-sm btn-outline-info py-0 px-1 ms-2" style="font-size:0.75rem;" onclick="playSegmentAudio(${seg.id}, this)"><i class="bi bi-play-fill"></i> 재생</button>` : '';
div.innerHTML = `
<div class="seg-time align-self-center">${fmt(seg.start_sec)}</div>
<div class="flex-grow-1 align-self-center lh-sm">${spkr}<span class="type-target text-light"></span>${playBtn}</div>
`;
segArea.appendChild(div);
const target = div.querySelector('.type-target');
let idx = 0;
const txt = seg.text || '';
(function typeChar() {
if (idx < txt.length) {
target.textContent += txt.charAt(idx++);
setTimeout(typeChar, 22);
}
})();
});
}
// 클래스 및 상태 보존 (업데이트 시 기존 Acknowledged 상태면 변경하지 않음)
if (pendingData.urgency === '긴급' && !block.classList.contains('card-urgent-acknowledged')) {
block.classList.add('card-urgent-blinking');
block.onclick = function () {
this.classList.remove('card-urgent-blinking');
this.classList.add('card-urgent-acknowledged');
};
}
// 열차 번호 뱃지 보존/추가
if (pendingData.train_number) {
const headerTitle = block.querySelector('.rt-block-header span.fw-bold');
if (headerTitle && !headerTitle.innerHTML.includes(pendingData.train_number)) {
headerTitle.innerHTML += `<span class="badge bg-warning text-dark ms-2"><i class="bi bi-train-front-fill me-1"></i>${pendingData.train_number}</span>`;
}
}
// Summary나 키워드도 여기서 텍스트 통합할 수 있지만, 요구사항인 뱃지와 클래스 병합에 집중
}
} else if (upd.action === 'new' && _sttResultQueue[upd.segment_id]) {
const pendingReq = _sttResultQueue[upd.segment_id];
clearTimeout(pendingReq.timer);
const pendingData = pendingReq.data;
const typewriter = pendingReq.typewriter;
delete _sttResultQueue[upd.segment_id];
renderRealtimeStream(pendingData, typewriter);
console.debug(`[Jitter 해결] segment=${upd.segment_id} 의 렌더링 지연 대기를 풀고 새 카드로 즉시 표출합니다.`);
}
// 1) 좌측 히스토리 패널 AJAX 갱신 (전체 새로고침 없이 목록 UI만 갱신)
loadHistory();
// 2) Daily 뷰가 활성화되어 있으면 갱신
if (document.getElementById('tab-daily').classList.contains('active')) {
loadDailyChat(document.getElementById('daily-date').value);
}
}
} catch (err) {
console.error("[WS] Parse Error", err);
}
};
ws.onclose = () => { console.log("[WS] Disconnected"); };
ws.onerror = (e) => { console.warn("[WS] Error", e); };
}
// ─── 6. Context 렌더러 (최대 5개 유지) ───
function renderContextCard(contexts) {
const panel = document.getElementById('context-panel');
// "대기 중..." 메시지 제거
if (panel.querySelector('.bi-inbox')) {
panel.innerHTML = '';
}
contexts.forEach(ctx => {
const cdiv = document.createElement('div');
cdiv.className = 'card bg-dark border-secondary mb-2 context-card fade-in';
cdiv.innerHTML = `
<div class="card-body p-2">
<h6 class="card-title fw-bold text-info small mb-1"><i class="bi bi-tag-fill me-1"></i>${ctx.keyword}</h6>
<div class="card-subtitle small fw-bold mb-1">${ctx.title}</div>
<p class="card-text small text-muted lh-sm mb-0">${ctx.desc}</p>
</div>
`;
panel.prepend(cdiv);
});
// 최대 5개 유지, 오래된 것 제거
while (panel.children.length > 5) {
panel.removeChild(panel.lastChild);
}
}
</script>
</body>
</html>