1317 lines
47 KiB
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> |