xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영. @xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외. Co-authored-by: Cursor <cursoragent@cursor.com>
1406 lines
65 KiB
Plaintext
1406 lines
65 KiB
Plaintext
<!doctype html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<%- include('partials/favicon') %>
|
||
<title>회의록 AI - XAVIS</title>
|
||
<link rel="stylesheet" href="/public/styles.css" />
|
||
</head>
|
||
<body class="meeting-minutes-page">
|
||
<% var mmNow = new Date(); var mmTodayIso = mmNow.getFullYear() + '-' + ('0' + (mmNow.getMonth() + 1)).slice(-2) + '-' + ('0' + mmNow.getDate()).slice(-2); %>
|
||
<div class="app-shell">
|
||
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
|
||
<div class="content-area">
|
||
<header class="topbar">
|
||
<h1>회의록 AI</h1>
|
||
<a class="top-action-link" href="/ai-explore">AI 목록</a>
|
||
</header>
|
||
<main class="container meeting-minutes-main">
|
||
<a href="/ai-explore" class="prompts-back" aria-label="AI 탐색으로 돌아가기">← AI</a>
|
||
<% var hasEmail = typeof meetingUserEmail !== 'undefined' && meetingUserEmail; %>
|
||
<% if (!hasEmail) { %>
|
||
<p class="chat-api-warning" style="margin-bottom: 16px">
|
||
이메일 인증(OPS <code>PROD</code>) 또는 DEV에서 <strong>관리자 모드</strong>, SUPER(데모)에서는 별도 로그인 없이 이용할 수 있습니다. (DEV 테스트: <code>MEETING_DEV_EMAIL</code>)
|
||
</p>
|
||
<% } %>
|
||
<div class="mm-layout">
|
||
<aside class="mm-sidebar panel">
|
||
<h2 class="mm-sidebar-title">내 회의록</h2>
|
||
<button type="button" class="btn-ghost mm-refresh" id="mmListRefresh" <%= hasEmail ? '' : 'disabled' %>>새로고침</button>
|
||
<ul class="mm-meeting-list" id="mmMeetingList" role="list"></ul>
|
||
<p class="mm-list-empty" id="mmListEmpty" hidden>저장된 회의록이 없습니다.</p>
|
||
</aside>
|
||
<div class="mm-workspace">
|
||
<section class="panel mm-prompt-panel mm-prompt-collapsed" id="mmPromptPanel">
|
||
<div class="mm-prompt-panel-head">
|
||
<h2 id="mmPromptHeading">출력 형식 (프롬프트)</h2>
|
||
<button type="button" class="mm-prompt-toggle" id="mmPromptToggle" aria-expanded="false" aria-controls="mmPromptBody" aria-labelledby="mmPromptHeading" title="펼치기·접기">
|
||
<span class="mm-prompt-toggle-icon" aria-hidden="true">▼</span>
|
||
</button>
|
||
</div>
|
||
<div class="mm-prompt-body" id="mmPromptBody" hidden>
|
||
<p class="subtitle">회의록 작성 시스템 프롬프트를 수정할 수 있습니다. 저장 후 업무 체크리스트 자동 연동은 액션·후속 항목에서 추출합니다.</p>
|
||
<div class="mm-prompt-form" id="mmPromptForm">
|
||
<div class="mm-custom-block">
|
||
<label class="mm-field-label" for="mmCustomInstr">추가 지시</label>
|
||
<textarea id="mmCustomInstr" class="mm-textarea mm-custom-instr-textarea" rows="9" placeholder="비워두면 시스템 기본 구조(6개 섹션)로 회의록을 작성합니다. 추가로 지시할 내용이 있을 때만 입력하세요."></textarea>
|
||
</div>
|
||
<div class="mm-prompt-actions">
|
||
<button type="button" class="top-action" id="mmSavePrompt" <%= hasEmail ? '' : 'disabled' %>>프롬프트 저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="mm-tabs" role="tablist">
|
||
<button type="button" class="mm-tab is-active" id="mmTabText" data-tab="text" role="tab" aria-selected="true" aria-controls="mmPanelText">텍스트 입력</button>
|
||
<button type="button" class="mm-tab" id="mmTabAudio" data-tab="audio" role="tab" aria-selected="false" aria-controls="mmPanelAudio">음성 파일</button>
|
||
</div>
|
||
|
||
<div class="mm-tab-panel mm-field-stack" id="mmPanelText" role="tabpanel" aria-labelledby="mmTabText" aria-hidden="false">
|
||
<div class="mm-field mm-title-date-field">
|
||
<div class="mm-title-date-labels">
|
||
<span class="mm-field-label">제목</span>
|
||
<span class="mm-field-label">날짜</span>
|
||
</div>
|
||
<div class="mm-title-date-box">
|
||
<input type="text" id="mmTitleText" class="mm-input mm-title-input" placeholder="회의 제목" maxlength="500" <%= hasEmail ? '' : 'disabled' %> />
|
||
<div class="mm-date-wrap">
|
||
<input type="date" id="mmDateText" class="mm-date-native" value="<%= mmTodayIso %>" <%= hasEmail ? '' : 'disabled' %> />
|
||
<button type="button" class="mm-date-trigger" id="mmDateTextBtn" <%= hasEmail ? '' : 'disabled' %> aria-label="달력 열기">▼</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mm-field">
|
||
<div class="mm-minutes-header mm-source-text-header">
|
||
<label class="mm-field-label" for="mmSourceText">회의 원문</label>
|
||
<div class="mm-minutes-actions">
|
||
<button
|
||
type="button"
|
||
class="mm-minutes-copy"
|
||
id="mmSourceTextCopy"
|
||
title="회의 원문을 클립보드에 복사합니다."
|
||
aria-label="회의 원문을 클립보드에 복사합니다."
|
||
<%= hasEmail ? '' : 'disabled' %>
|
||
>복사</button>
|
||
</div>
|
||
</div>
|
||
<textarea id="mmSourceText" class="mm-textarea" rows="12" placeholder="회의 내용을 붙여 넣거나 입력하세요." <%= hasEmail ? '' : 'disabled' %> aria-label="회의 원문"></textarea>
|
||
</div>
|
||
<label class="mm-field mm-field-narrow">
|
||
<span class="mm-field-label">회의록 생성 모델</span>
|
||
<select id="mmModelText" class="mm-select" <%= hasEmail ? '' : 'disabled' %>>
|
||
<option value="gpt-5-mini">gpt-5-mini</option>
|
||
<option value="gpt-5.4" selected>gpt-5.4</option>
|
||
</select>
|
||
</label>
|
||
<div class="form-actions mm-form-actions">
|
||
<button type="button" class="top-action" id="mmGenText" <%= hasEmail ? '' : 'disabled' %>>회의록 생성</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mm-tab-panel mm-field-stack mm-audio-panel" id="mmPanelAudio" role="tabpanel" aria-labelledby="mmTabAudio" aria-hidden="true" hidden>
|
||
<div class="mm-audio-phase">
|
||
<h3 class="mm-section-heading">1. 음성 전사</h3>
|
||
<label class="mm-field">
|
||
<span class="mm-field-label">음성 파일</span>
|
||
<input type="file" id="mmAudioFile" class="mm-file-input" accept=".mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm,.ogg,.flac" <%= hasEmail ? '' : 'disabled' %> />
|
||
</label>
|
||
<p class="mm-audio-hint">지원 포맷: mp3, m4a, wav 등. 파일당 최대 300MB입니다.</p>
|
||
<label class="mm-field mm-transcribe-model-only">
|
||
<span class="mm-field-label">전사 모델 (OpenAI)</span>
|
||
<select id="mmWhisperModel" class="mm-select" <%= hasEmail ? '' : 'disabled' %> title="음성→텍스트 API 모델">
|
||
<option value="gpt-4o-mini-transcribe">gpt-4o-mini-transcribe (경량·더 빠를 수 있음)</option>
|
||
<option value="gpt-4o-transcribe" selected>gpt-4o-transcribe (기본)</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="mm-audio-phase">
|
||
<h3 class="mm-section-heading">2. 회의록 작성</h3>
|
||
<div class="mm-field mm-title-date-field">
|
||
<div class="mm-title-date-labels">
|
||
<span class="mm-field-label">제목</span>
|
||
<span class="mm-field-label">날짜</span>
|
||
</div>
|
||
<div class="mm-title-date-box">
|
||
<input type="text" id="mmTitleAudio" class="mm-input mm-title-input" placeholder="회의 제목" maxlength="500" <%= hasEmail ? '' : 'disabled' %> />
|
||
<div class="mm-date-wrap">
|
||
<input type="date" id="mmDateAudio" class="mm-date-native" value="<%= mmTodayIso %>" <%= hasEmail ? '' : 'disabled' %> />
|
||
<button type="button" class="mm-date-trigger" id="mmDateAudioBtn" <%= hasEmail ? '' : 'disabled' %> aria-label="달력 열기">▼</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<label class="mm-field mm-field-narrow">
|
||
<span class="mm-field-label">회의록 생성 모델</span>
|
||
<select id="mmModelAudio" class="mm-select" <%= hasEmail ? '' : 'disabled' %>>
|
||
<option value="gpt-5-mini">gpt-5-mini</option>
|
||
<option value="gpt-5.4" selected>gpt-5.4</option>
|
||
</select>
|
||
</label>
|
||
<p class="mm-audio-summary-hint">전사된 텍스트를 아래 프롬프트(출력 형식)에 맞게 요약·정리합니다.</p>
|
||
<div class="form-actions mm-form-actions">
|
||
<button type="button" class="top-action" id="mmGenAudio" <%= hasEmail ? '' : 'disabled' %>>전사 및 회의록 생성</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div id="mmGenProgress" class="mm-gen-progress" hidden role="status" aria-live="polite" aria-busy="false">
|
||
<div id="mmGenAudioPipeline" class="mm-gen-audio-pipeline" hidden aria-label="음성 회의록 처리 단계">
|
||
<div class="mm-gen-pipeline-steps" role="list">
|
||
<span
|
||
class="mm-gen-pipeline-step"
|
||
id="mmGenStepUpload"
|
||
data-mm-step-name="업로드"
|
||
role="listitem"
|
||
><strong class="mm-gen-pipeline-step-num">1</strong> 업로드</span>
|
||
<span class="mm-gen-pipeline-join" aria-hidden="true">→</span>
|
||
<span
|
||
class="mm-gen-pipeline-step"
|
||
id="mmGenStepTranscribe"
|
||
data-mm-step-name="전사"
|
||
role="listitem"
|
||
><strong class="mm-gen-pipeline-step-num">2</strong> 전사</span>
|
||
<span class="mm-gen-pipeline-join" aria-hidden="true">→</span>
|
||
<span
|
||
class="mm-gen-pipeline-step"
|
||
id="mmGenStepTranslate"
|
||
data-mm-step-name="번역"
|
||
role="listitem"
|
||
><strong class="mm-gen-pipeline-step-num">3</strong> 번역</span>
|
||
</div>
|
||
<p class="mm-gen-pipeline-note" id="mmGenTranslateNote" hidden>
|
||
「번역」단계에서는 전문 템플릿에 맞게 회의 내용 전체를 LLM으로 서술 형식 회의록으로 작성합니다 (속도 표시 아님).
|
||
</p>
|
||
</div>
|
||
<div class="mm-gen-progress-track-row">
|
||
<div class="mm-gen-progress-track" aria-hidden="true">
|
||
<div class="mm-gen-progress-bar"></div>
|
||
</div>
|
||
<span class="mm-gen-progress-pct" id="mmGenProgressPct" hidden></span>
|
||
</div>
|
||
<p class="mm-gen-progress-msg" id="mmGenProgressMsg">처리 중…</p>
|
||
</div>
|
||
|
||
<section class="panel mm-result-panel" id="mmResultSection" hidden>
|
||
<div class="mm-result-head">
|
||
<h2 class="mm-result-title">생성 결과</h2>
|
||
<button type="button" class="top-action" id="mmSaveResult" disabled>저장</button>
|
||
</div>
|
||
<p class="mm-result-hint">
|
||
생성 결과에서 <strong>회의록</strong>을 수정한 뒤 <strong>저장</strong>하면 DB에 반영되며, 회의록 기준으로 액션 아이템·체크리스트가 업무 체크리스트 AI에 다시 연동됩니다. 음성 전사가 있는 회의는 <strong>음성 파일</strong> 탭을 선택하면 아래 <strong>전사 기록</strong>도 함께 수정할 수 있습니다. 텍스트 입력으로 만든 회의는 위쪽 <strong>회의 원문</strong>에서 원문을 고칩니다.
|
||
</p>
|
||
<div class="mm-result-split">
|
||
<div id="mmTranscriptPane" class="mm-result-transcript-pane" hidden aria-hidden="true">
|
||
<div class="mm-result-field">
|
||
<div class="mm-minutes-header">
|
||
<span class="mm-result-field-label" id="mmTranscriptLabel">전사 기록</span>
|
||
<div class="mm-minutes-actions">
|
||
<button
|
||
type="button"
|
||
class="mm-minutes-copy mm-transcript-copy"
|
||
id="mmTranscriptCopy"
|
||
title="전사 기록을 클립보드에 복사합니다."
|
||
aria-label="전사 기록을 클립보드에 복사합니다."
|
||
>복사</button>
|
||
<button type="button" class="top-action mm-minutes-apply" id="mmTranscriptSave" disabled>저장</button>
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
id="mmTranscriptBody"
|
||
class="mm-result-body mm-result-textarea mm-result-textarea-half mm-transcript-textarea"
|
||
rows="14"
|
||
spellcheck="false"
|
||
placeholder="음성 전사 텍스트가 여기에 표시됩니다."
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="mm-result-field">
|
||
<div class="mm-minutes-header">
|
||
<span class="mm-result-field-label">회의록</span>
|
||
<div class="mm-minutes-actions" id="mmMinutesActions" role="toolbar" aria-label="회의록 액션">
|
||
<button
|
||
type="button"
|
||
class="mm-minutes-copy"
|
||
id="mmMinutesCopy"
|
||
title="회의록을 복사합니다."
|
||
aria-label="회의록을 복사합니다."
|
||
>복사</button>
|
||
<button type="button" class="btn-ghost mm-minutes-edit" id="mmMinutesEdit">마크다운 편집</button>
|
||
<button type="button" class="btn-ghost mm-minutes-cancel" id="mmMinutesCancel" hidden>취소</button>
|
||
</div>
|
||
</div>
|
||
<div class="mm-minutes-editor-wrap">
|
||
<div
|
||
id="mmMinutesRendered"
|
||
class="mm-minutes-rendered mm-minutes-rendered-empty"
|
||
role="region"
|
||
aria-label="회의록 (마크다운 렌더링)"
|
||
tabindex="0"
|
||
>
|
||
회의록이 없습니다. 마크다운 편집으로 내용을 입력하세요.
|
||
</div>
|
||
<textarea
|
||
id="mmResultBody"
|
||
class="mm-result-body mm-result-textarea mm-result-textarea-half mm-minutes-source"
|
||
rows="14"
|
||
spellcheck="false"
|
||
hidden
|
||
placeholder="마크다운으로 회의록을 편집합니다. 「저장」으로 뷰로 돌아가거나 「취소」로 편집 전 내용으로 되돌립니다."
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
<script src="/vendor/marked/marked.umd.js"></script>
|
||
<script src="/vendor/dompurify/purify.min.js"></script>
|
||
<script>
|
||
(function () {
|
||
var hasEmail = <%= hasEmail ? 'true' : 'false' %>;
|
||
if (!hasEmail) return;
|
||
|
||
var listEl = document.getElementById('mmMeetingList');
|
||
var emptyEl = document.getElementById('mmListEmpty');
|
||
var resultSection = document.getElementById('mmResultSection');
|
||
var resultBody = document.getElementById('mmResultBody');
|
||
var minutesRenderedEl = document.getElementById('mmMinutesRendered');
|
||
var minutesEditBtn = document.getElementById('mmMinutesEdit');
|
||
var minutesCopyBtn = document.getElementById('mmMinutesCopy');
|
||
var minutesCancelBtn = document.getElementById('mmMinutesCancel');
|
||
/** 편집 모드 진입 시점의 회의록 원문(취소 시 복원) */
|
||
var minutesEditSnapshot = '';
|
||
var minutesInEditMode = false;
|
||
var transcriptBody = document.getElementById('mmTranscriptBody');
|
||
var transcriptCopyBtn = document.getElementById('mmTranscriptCopy');
|
||
var sourceTextCopyBtn = document.getElementById('mmSourceTextCopy');
|
||
var mmSourceTextEl = document.getElementById('mmSourceText');
|
||
var transcriptSaveBtn = document.getElementById('mmTranscriptSave');
|
||
var transcriptLabel = document.getElementById('mmTranscriptLabel');
|
||
/** true: 음성 전사(transcript_text) 편집, false: 텍스트 회의 원문(source_text) 편집 */
|
||
var lastMeetingTranscriptIsAudio = false;
|
||
var saveResultBtn = document.getElementById('mmSaveResult');
|
||
var genProgressEl = document.getElementById('mmGenProgress');
|
||
var genProgressMsg = document.getElementById('mmGenProgressMsg');
|
||
var genProgressBar = genProgressEl ? genProgressEl.querySelector('.mm-gen-progress-bar') : null;
|
||
var genProgressPct = document.getElementById('mmGenProgressPct');
|
||
var mmAudioPipeEl = document.getElementById('mmGenAudioPipeline');
|
||
var mmGenTranslateNoteEl = document.getElementById('mmGenTranslateNote');
|
||
/** 음성 「전사 및 회의록 생성」 진행 중 디버그 스냅샷 — 콘솔: getMeetingMinutesAudioDiag() */
|
||
var mmAudioJobDiag = { active: false };
|
||
var mmAudioLastUiPhase = 'upload';
|
||
var currentMeetingId = null;
|
||
|
||
function escapeHtmlMm(s) {
|
||
var div = document.createElement('div');
|
||
div.textContent = s;
|
||
return div.innerHTML;
|
||
}
|
||
var minutesMarkdownConfigured = false;
|
||
function configureMinutesMarkdown() {
|
||
if (minutesMarkdownConfigured) return;
|
||
minutesMarkdownConfigured = true;
|
||
if (typeof marked !== 'undefined') {
|
||
marked.setOptions({ async: false, breaks: true, gfm: true });
|
||
}
|
||
var domPurify = typeof window !== 'undefined' && window.DOMPurify ? window.DOMPurify : typeof DOMPurify !== 'undefined' ? DOMPurify : null;
|
||
if (domPurify) {
|
||
domPurify.addHook('afterSanitizeAttributes', function (node) {
|
||
if (node.tagName === 'A' && node.hasAttribute('href')) {
|
||
var href = node.getAttribute('href');
|
||
try {
|
||
var u = new URL(href, window.location.href);
|
||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||
node.removeAttribute('href');
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
node.removeAttribute('href');
|
||
return;
|
||
}
|
||
node.setAttribute('target', '_blank');
|
||
node.setAttribute('rel', 'noopener noreferrer');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
function getDomPurify() {
|
||
return typeof window !== 'undefined' && window.DOMPurify
|
||
? window.DOMPurify
|
||
: typeof DOMPurify !== 'undefined'
|
||
? DOMPurify
|
||
: null;
|
||
}
|
||
function getMarkedParseFn() {
|
||
if (typeof marked === 'undefined') return null;
|
||
if (typeof marked.parse === 'function') return marked.parse.bind(marked);
|
||
if (typeof marked === 'function') return marked;
|
||
return null;
|
||
}
|
||
function renderMinutesMarkdown(text) {
|
||
configureMinutesMarkdown();
|
||
var src = String(text || '');
|
||
var parseFn = getMarkedParseFn();
|
||
var purify = getDomPurify();
|
||
if (!parseFn || !purify) {
|
||
return escapeHtmlMm(src).replace(/\n/g, '<br>');
|
||
}
|
||
try {
|
||
var raw = parseFn(src, { async: false });
|
||
if (raw != null && typeof raw.then === 'function') {
|
||
return escapeHtmlMm(src).replace(/\n/g, '<br>');
|
||
}
|
||
return purify.sanitize(String(raw || ''), { USE_PROFILES: { html: true } });
|
||
} catch (err) {
|
||
return escapeHtmlMm(src).replace(/\n/g, '<br>');
|
||
}
|
||
}
|
||
function refreshMinutesRendered() {
|
||
if (!minutesRenderedEl) return;
|
||
var raw = resultBody ? resultBody.value : '';
|
||
if (!String(raw).trim()) {
|
||
minutesRenderedEl.classList.add('mm-minutes-rendered-empty');
|
||
minutesRenderedEl.innerHTML = '회의록이 없습니다. 마크다운 편집으로 내용을 입력하세요.';
|
||
return;
|
||
}
|
||
minutesRenderedEl.classList.remove('mm-minutes-rendered-empty');
|
||
minutesRenderedEl.innerHTML = renderMinutesMarkdown(raw);
|
||
}
|
||
function setMinutesToolbarMode(editing) {
|
||
if (minutesCancelBtn) minutesCancelBtn.hidden = !editing;
|
||
if (minutesEditBtn) minutesEditBtn.textContent = editing ? '편집 완료' : '마크다운 편집';
|
||
minutesInEditMode = !!editing;
|
||
}
|
||
function setMinutesViewMode(showSource) {
|
||
if (!resultBody || !minutesRenderedEl) return;
|
||
if (!showSource) {
|
||
refreshMinutesRendered();
|
||
resultBody.hidden = true;
|
||
minutesRenderedEl.hidden = false;
|
||
setMinutesToolbarMode(false);
|
||
} else {
|
||
minutesRenderedEl.hidden = true;
|
||
resultBody.hidden = false;
|
||
setMinutesToolbarMode(true);
|
||
try {
|
||
resultBody.focus();
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
function copyTextToClipboard(text, done) {
|
||
var t = String(text || '');
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(t).then(done).catch(function () {
|
||
legacyCopyTextToClipboard(t, done);
|
||
});
|
||
return;
|
||
}
|
||
legacyCopyTextToClipboard(t, done);
|
||
}
|
||
function legacyCopyTextToClipboard(text, done) {
|
||
var ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.setAttribute('readonly', '');
|
||
ta.style.position = 'fixed';
|
||
ta.style.left = '-9999px';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try {
|
||
document.execCommand('copy');
|
||
} catch (e) {}
|
||
document.body.removeChild(ta);
|
||
if (typeof done === 'function') done();
|
||
}
|
||
if (minutesCopyBtn) {
|
||
minutesCopyBtn.addEventListener('click', function () {
|
||
if (!resultBody) return;
|
||
var md = String(resultBody.value || '');
|
||
if (!md.trim()) {
|
||
alert('복사할 회의록 내용이 없습니다.');
|
||
return;
|
||
}
|
||
var btn = minutesCopyBtn;
|
||
var prev = btn.textContent;
|
||
copyTextToClipboard(md, function () {
|
||
btn.textContent = '복사됨';
|
||
window.setTimeout(function () {
|
||
btn.textContent = prev;
|
||
}, 1600);
|
||
});
|
||
});
|
||
}
|
||
if (transcriptCopyBtn) {
|
||
transcriptCopyBtn.addEventListener('click', function () {
|
||
if (!transcriptBody) return;
|
||
var txt = String(transcriptBody.value || '');
|
||
if (!txt.trim()) {
|
||
alert('복사할 전사 기록이 없습니다.');
|
||
return;
|
||
}
|
||
var btn = transcriptCopyBtn;
|
||
var prev = btn.textContent;
|
||
copyTextToClipboard(txt, function () {
|
||
btn.textContent = '복사됨';
|
||
window.setTimeout(function () {
|
||
btn.textContent = prev;
|
||
}, 1600);
|
||
});
|
||
});
|
||
}
|
||
if (sourceTextCopyBtn && mmSourceTextEl) {
|
||
sourceTextCopyBtn.addEventListener('click', function () {
|
||
var txt = String(mmSourceTextEl.value || '');
|
||
if (!txt.trim()) {
|
||
alert('복사할 회의 원문이 없습니다.');
|
||
return;
|
||
}
|
||
var btn = sourceTextCopyBtn;
|
||
var prev = btn.textContent;
|
||
copyTextToClipboard(txt, function () {
|
||
btn.textContent = '복사됨';
|
||
window.setTimeout(function () {
|
||
btn.textContent = prev;
|
||
}, 1600);
|
||
});
|
||
});
|
||
}
|
||
if (transcriptSaveBtn) {
|
||
transcriptSaveBtn.addEventListener('click', function () {
|
||
if (saveResultBtn && !saveResultBtn.disabled) {
|
||
saveResultBtn.click();
|
||
}
|
||
});
|
||
}
|
||
if (minutesEditBtn) {
|
||
minutesEditBtn.addEventListener('click', function () {
|
||
if (!resultBody) return;
|
||
if (!minutesInEditMode) {
|
||
minutesEditSnapshot = resultBody.value;
|
||
setMinutesViewMode(true);
|
||
} else {
|
||
setMinutesViewMode(false);
|
||
}
|
||
});
|
||
}
|
||
if (minutesCancelBtn) {
|
||
minutesCancelBtn.addEventListener('click', function () {
|
||
if (resultBody) resultBody.value = minutesEditSnapshot;
|
||
setMinutesViewMode(false);
|
||
});
|
||
}
|
||
|
||
/** 음성 파일 탭일 때만 생성 결과의 전사 기록 영역 표시(텍스트 입력은 회의 원문과 중복이므로 숨김) */
|
||
function applyTranscriptPaneVisibility() {
|
||
var pane = document.getElementById('mmTranscriptPane');
|
||
var audioTab = document.getElementById('mmTabAudio');
|
||
if (!pane || !audioTab) return;
|
||
var show = audioTab.classList.contains('is-active');
|
||
pane.hidden = !show;
|
||
pane.setAttribute('aria-hidden', show ? 'false' : 'true');
|
||
}
|
||
|
||
function setCurrentMeetingId(id) {
|
||
currentMeetingId = id || null;
|
||
if (saveResultBtn) saveResultBtn.disabled = !currentMeetingId;
|
||
if (transcriptSaveBtn) transcriptSaveBtn.disabled = !currentMeetingId;
|
||
}
|
||
|
||
function setMeetingGenerating(on, msg) {
|
||
if (genProgressEl) {
|
||
genProgressEl.hidden = !on;
|
||
genProgressEl.setAttribute('aria-busy', on ? 'true' : 'false');
|
||
if (on) genProgressEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
if (!on) mmSetAudioPipelineVisible(false);
|
||
if (!on) showIndeterminateProgress();
|
||
if (genProgressMsg && on) {
|
||
if (arguments.length >= 2) {
|
||
genProgressMsg.textContent =
|
||
msg === undefined || msg === null ? '' : String(msg);
|
||
} else {
|
||
/** 진행 시작 시 초기 HTML「처리 중…」 등이 다음 업데이트 전까지 보이지 않도록 비움 */
|
||
genProgressMsg.textContent = '';
|
||
}
|
||
}
|
||
var gText = document.getElementById('mmGenText');
|
||
var gAudio = document.getElementById('mmGenAudio');
|
||
if (gText) gText.disabled = !!on;
|
||
if (gAudio) gAudio.disabled = !!on;
|
||
if (saveResultBtn) saveResultBtn.disabled = !!on || !currentMeetingId;
|
||
if (resultBody) resultBody.disabled = !!on;
|
||
if (transcriptBody) transcriptBody.disabled = !!on;
|
||
if (minutesEditBtn) minutesEditBtn.disabled = !!on;
|
||
if (minutesCopyBtn) minutesCopyBtn.disabled = !!on;
|
||
if (minutesCancelBtn) minutesCancelBtn.disabled = !!on;
|
||
if (on && resultBody && minutesRenderedEl) {
|
||
if (!resultBody.hidden) {
|
||
refreshMinutesRendered();
|
||
setMinutesViewMode(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* fetch ReadableStream SSE 파싱. onStreamHeaders / onUploaded 둘 중 하나로 응답 헤더 직후 1회 콜백.
|
||
*/
|
||
function consumeSseFromFetch(response, handlers) {
|
||
var hdr = handlers.onStreamHeaders || handlers.onUploaded;
|
||
hdr && hdr();
|
||
if (!response.body) {
|
||
handlers.onError && handlers.onError(new Error('응답 스트림을 읽을 수 없습니다.'));
|
||
return;
|
||
}
|
||
var reader = response.body.getReader();
|
||
var decoder = new TextDecoder();
|
||
var sseBuffer = '';
|
||
var sseEvent = 'message';
|
||
|
||
function parseSseChunk(text) {
|
||
sseBuffer += text;
|
||
var lines = sseBuffer.split('\n');
|
||
sseBuffer = lines.pop();
|
||
for (var i = 0; i < lines.length; i++) {
|
||
var line = lines[i];
|
||
if (line.length && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
|
||
if (line.startsWith('event: ')) {
|
||
sseEvent = line.slice(7).trim();
|
||
} else if (line.startsWith('data: ')) {
|
||
try {
|
||
var data = JSON.parse(line.slice(6));
|
||
handlers.onSseEvent && handlers.onSseEvent(sseEvent, data);
|
||
} catch (_) {}
|
||
sseEvent = 'message';
|
||
}
|
||
}
|
||
}
|
||
|
||
function pump() {
|
||
return reader.read().then(function (result) {
|
||
if (result.done) {
|
||
handlers.onEnd && handlers.onEnd();
|
||
return;
|
||
}
|
||
parseSseChunk(decoder.decode(result.value, { stream: true }));
|
||
return pump();
|
||
});
|
||
}
|
||
return pump();
|
||
}
|
||
|
||
/** 1단계: multipart 업로드 → JSON { jobId }. onUploadProgress, onJobReady(jobId), onError */
|
||
function postPrepareMeetingAudioJob(url, formData, callbacks) {
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('POST', url);
|
||
xhr.withCredentials = true;
|
||
xhr.responseType = 'text';
|
||
xhr.upload.onprogress = function (ev) {
|
||
callbacks.onUploadProgress &&
|
||
callbacks.onUploadProgress(ev.lengthComputable, ev.loaded, ev.total || 0);
|
||
};
|
||
xhr.onerror = function () {
|
||
callbacks.onError && callbacks.onError(new Error('네트워크 오류(업로드)'));
|
||
};
|
||
xhr.onload = function () {
|
||
try {
|
||
if (xhr.status < 200 || xhr.status >= 300) {
|
||
var jFail = null;
|
||
try {
|
||
jFail = xhr.responseText ? JSON.parse(xhr.responseText) : null;
|
||
} catch (_) {
|
||
jFail = null;
|
||
}
|
||
var em =
|
||
(jFail && jFail.error && String(jFail.error).slice(0, 400)) ||
|
||
(xhr.responseText || '').replace(/\s+/g, ' ').trim().slice(0, 200) ||
|
||
'HTTP ' + xhr.status;
|
||
callbacks.onError && callbacks.onError(new Error(em));
|
||
return;
|
||
}
|
||
var j = null;
|
||
try {
|
||
j = JSON.parse(xhr.responseText || '');
|
||
} catch (_) {
|
||
callbacks.onError && callbacks.onError(new Error('서버 응답 형식 오류(JSON)'));
|
||
return;
|
||
}
|
||
if (!j || !j.jobId) {
|
||
callbacks.onError && callbacks.onError(new Error('jobId가 응답에 없습니다.'));
|
||
return;
|
||
}
|
||
callbacks.onJobReady && callbacks.onJobReady(typeof j.jobId === 'string' ? j.jobId : String(j.jobId));
|
||
} catch (e) {
|
||
callbacks.onError &&
|
||
callbacks.onError(new Error(e && e.message ? e.message : String(e)));
|
||
}
|
||
};
|
||
xhr.send(formData);
|
||
}
|
||
|
||
/** 2단계: GET SSE (fetch 스트림 — 단일 업로드+SSE 연결보다 브라우저/프록시에서 증분 수신 안정적) */
|
||
function streamMeetingAudioJobSse(jobId, handlers) {
|
||
var streamUrl =
|
||
'/api/meeting-minutes/stream-audio/' + encodeURIComponent(jobId);
|
||
return fetch(streamUrl, { method: 'GET', credentials: 'same-origin' })
|
||
.then(function (response) {
|
||
var ct = (response.headers.get('content-type') || '').toLowerCase();
|
||
if (!response.ok) {
|
||
return response
|
||
.json()
|
||
.catch(function () {
|
||
return {};
|
||
})
|
||
.then(function (jo) {
|
||
var msg =
|
||
(jo && jo.error && String(jo.error).slice(0, 400)) ||
|
||
'HTTP ' + response.status;
|
||
handlers.onError && handlers.onError(new Error(msg || 'SSE 연결 실패'));
|
||
});
|
||
}
|
||
if (ct.indexOf('text/event-stream') === -1) {
|
||
return response.text().then(function (t) {
|
||
var msg = (t || '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
||
handlers.onError &&
|
||
handlers.onError(new Error(msg || 'SSE가 아닙니다.'));
|
||
});
|
||
}
|
||
return consumeSseFromFetch(response, handlers);
|
||
})
|
||
.catch(function (e) {
|
||
handlers.onError && handlers.onError(e);
|
||
});
|
||
}
|
||
|
||
/** 진행 바를 실제 퍼센트 모드로 설정 (인라인 스타일로 CSS 캐시 무관하게 동작) */
|
||
function showDeterminedProgress(pct) {
|
||
pct = Math.min(100, Math.max(0, Math.round(pct)));
|
||
if (genProgressBar) {
|
||
genProgressBar.classList.add('mm-gen-progress-bar--determined');
|
||
genProgressBar.style.animation = 'none';
|
||
genProgressBar.style.transform = 'none';
|
||
genProgressBar.style.width = pct + '%';
|
||
}
|
||
if (genProgressPct) {
|
||
genProgressPct.removeAttribute('hidden');
|
||
genProgressPct.hidden = false;
|
||
genProgressPct.textContent = pct + '%';
|
||
}
|
||
}
|
||
|
||
/** 진행 바를 인디터미넌트(슬라이딩) 모드로 복귀 */
|
||
function showIndeterminateProgress() {
|
||
if (genProgressBar) {
|
||
genProgressBar.classList.remove('mm-gen-progress-bar--determined');
|
||
genProgressBar.style.animation = '';
|
||
genProgressBar.style.transform = '';
|
||
genProgressBar.style.width = '';
|
||
}
|
||
if (genProgressPct) {
|
||
genProgressPct.hidden = true;
|
||
genProgressPct.textContent = '';
|
||
}
|
||
}
|
||
|
||
function mmPipelineStepNodes() {
|
||
return [
|
||
document.getElementById('mmGenStepUpload'),
|
||
document.getElementById('mmGenStepTranscribe'),
|
||
document.getElementById('mmGenStepTranslate'),
|
||
].filter(Boolean);
|
||
}
|
||
|
||
/** 음성 파이프라인 UI 표시 여부 — 텍스트 회의 원문 생성과 구분 */
|
||
function mmSetAudioPipelineVisible(on) {
|
||
if (mmAudioPipeEl) mmAudioPipeEl.hidden = !on;
|
||
if (!on) {
|
||
mmPipelineStepNodes().forEach(function (el) {
|
||
el.classList.remove('is-done', 'is-active', 'is-pending');
|
||
el.removeAttribute('aria-current');
|
||
});
|
||
}
|
||
if (!on && mmGenTranslateNoteEl) mmGenTranslateNoteEl.hidden = true;
|
||
}
|
||
|
||
function mmUpdatePipelineStepHighlight(phaseKey) {
|
||
var order = ['upload', 'transcribe', 'translate'];
|
||
var ix = order.indexOf(phaseKey);
|
||
if (ix < 0) ix = 0;
|
||
mmPipelineStepNodes().forEach(function (el, i) {
|
||
el.classList.remove('is-done', 'is-active', 'is-pending');
|
||
el.removeAttribute('aria-current');
|
||
if (i < ix) el.classList.add('is-done');
|
||
else if (i === ix) {
|
||
el.classList.add('is-active');
|
||
el.setAttribute('aria-current', 'step');
|
||
} else el.classList.add('is-pending');
|
||
});
|
||
}
|
||
|
||
/** 순차 단계 배지(up/trans/translate)·막대·본문 메시지. pct가 null이면 막대 인디터미넌트. */
|
||
function mmSetAudioGenerationPhase(phaseKey, pct, detailMsg) {
|
||
if (phaseKey) mmAudioLastUiPhase = phaseKey;
|
||
mmUpdatePipelineStepHighlight(phaseKey);
|
||
var pctRounded = null;
|
||
if (
|
||
pct !== null &&
|
||
pct !== undefined &&
|
||
typeof pct === 'number' &&
|
||
!isNaN(pct)
|
||
) {
|
||
pctRounded = Math.min(100, Math.max(0, Math.round(pct)));
|
||
}
|
||
if (pct === null) {
|
||
showIndeterminateProgress();
|
||
} else if (pctRounded !== null) {
|
||
showDeterminedProgress(pctRounded);
|
||
}
|
||
if (mmGenTranslateNoteEl) mmGenTranslateNoteEl.hidden = phaseKey !== 'translate';
|
||
if (!genProgressMsg) return;
|
||
if (detailMsg !== undefined && detailMsg !== null && detailMsg !== '') {
|
||
genProgressMsg.textContent =
|
||
pctRounded !== null ? pctRounded + '% · ' + detailMsg : detailMsg;
|
||
} else if (pctRounded !== null && phaseKey === 'transcribe') {
|
||
genProgressMsg.textContent =
|
||
pctRounded + '% · 서버 처리 및 전사를 기다리는 중입니다…';
|
||
}
|
||
}
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.getMeetingMinutesAudioDiag = function () {
|
||
if (!mmAudioJobDiag || !mmAudioJobDiag.active) {
|
||
return {
|
||
active: false,
|
||
hintKo:
|
||
'진행 중인 음성 회의 생성이 없거나 이미 종료되었습니다. 「전사 및 회의록 생성」을 누른 뒤 이 함수를 실행하세요.',
|
||
};
|
||
}
|
||
try {
|
||
return JSON.parse(JSON.stringify(mmAudioJobDiag));
|
||
} catch (e2) {
|
||
return { active: true, cloneError: String(e2 && e2.message) };
|
||
}
|
||
};
|
||
}
|
||
|
||
function api(path, opts) {
|
||
return fetch(path, Object.assign({ credentials: 'same-origin' }, opts || {})).then(function (r) {
|
||
var ct = (r.headers.get('content-type') || '').toLowerCase();
|
||
if (ct.indexOf('application/json') === -1) {
|
||
return r.text().then(function (t) {
|
||
var msg = (t || '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
||
throw new Error(msg || 'HTTP ' + r.status + ' ' + (r.statusText || ''));
|
||
});
|
||
}
|
||
return r.json().then(function (j) {
|
||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||
return j;
|
||
});
|
||
});
|
||
}
|
||
|
||
function loadPrompt() {
|
||
return api('/api/meeting-minutes/prompt').then(function (d) {
|
||
var p = d.prompt || {};
|
||
var saved = (p.customInstructions && String(p.customInstructions).trim()) || '';
|
||
document.getElementById('mmCustomInstr').value = saved;
|
||
});
|
||
}
|
||
|
||
function loadMeetings() {
|
||
return api('/api/meeting-minutes/meetings').then(function (d) {
|
||
var list = d.meetings || [];
|
||
listEl.innerHTML = '';
|
||
if (!list.length) {
|
||
emptyEl.hidden = false;
|
||
return;
|
||
}
|
||
emptyEl.hidden = true;
|
||
list.forEach(function (m) {
|
||
var li = document.createElement('li');
|
||
li.className = 'mm-meeting-item';
|
||
var t = document.createElement('button');
|
||
t.type = 'button';
|
||
t.className = 'mm-meeting-item-btn';
|
||
var title = (m.title || '제목 없음').slice(0, 60);
|
||
var md = m.meetingDate ? String(m.meetingDate).slice(0, 10) : '';
|
||
var mdKo = '';
|
||
if (md && md.length >= 10) {
|
||
var p = md.split('-');
|
||
if (p.length === 3) mdKo = p[0] + '. ' + p[1] + '. ' + p[2] + '.';
|
||
}
|
||
var dt = m.createdAt ? new Date(m.createdAt).toLocaleString('ko-KR') : '';
|
||
t.textContent = title + (mdKo ? ' · ' + mdKo : '') + (dt ? ' · ' + dt : '');
|
||
t.addEventListener('click', function () {
|
||
showMeeting(m.id);
|
||
});
|
||
var del = document.createElement('button');
|
||
del.type = 'button';
|
||
del.className = 'mm-meeting-del';
|
||
del.setAttribute('aria-label', '삭제');
|
||
del.textContent = '×';
|
||
del.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
if (!confirm('이 회의록을 삭제할까요?')) return;
|
||
api('/api/meeting-minutes/meetings/' + encodeURIComponent(m.id), { method: 'DELETE' }).then(loadMeetings);
|
||
});
|
||
li.appendChild(t);
|
||
li.appendChild(del);
|
||
listEl.appendChild(li);
|
||
});
|
||
});
|
||
}
|
||
|
||
function showMeeting(id) {
|
||
api('/api/meeting-minutes/meetings/' + encodeURIComponent(id)).then(function (d) {
|
||
var m = d.meeting;
|
||
resultSection.hidden = false;
|
||
setCurrentMeetingId(m.id);
|
||
if (transcriptLabel) {
|
||
transcriptLabel.textContent =
|
||
m.transcriptText && String(m.transcriptText).trim() ? '전사 기록' : '회의 원문';
|
||
}
|
||
lastMeetingTranscriptIsAudio = !!(m.transcriptText && String(m.transcriptText).trim());
|
||
if (transcriptBody) {
|
||
if (lastMeetingTranscriptIsAudio) {
|
||
transcriptBody.value = m.transcriptText != null ? String(m.transcriptText) : '';
|
||
} else {
|
||
transcriptBody.value = m.sourceText != null ? String(m.sourceText) : '';
|
||
}
|
||
}
|
||
resultBody.value = m.generatedMinutes != null ? String(m.generatedMinutes) : '';
|
||
setMinutesViewMode(false);
|
||
applyTranscriptPaneVisibility();
|
||
var title = m.title || '';
|
||
document.getElementById('mmTitleText').value = title;
|
||
document.getElementById('mmTitleAudio').value = title;
|
||
var md = m.meetingDate ? String(m.meetingDate).slice(0, 10) : '';
|
||
document.getElementById('mmDateText').value = md && md.length >= 10 ? md : '';
|
||
document.getElementById('mmDateAudio').value = md && md.length >= 10 ? md : '';
|
||
var src = '';
|
||
if (m.sourceText && String(m.sourceText).trim()) src = m.sourceText;
|
||
else if (m.transcriptText && String(m.transcriptText).trim()) src = m.transcriptText;
|
||
document.getElementById('mmSourceText').value = src;
|
||
if (m.chatModel) {
|
||
var mt = document.getElementById('mmModelText');
|
||
var ma = document.getElementById('mmModelAudio');
|
||
if (mt) {
|
||
for (var i = 0; i < mt.options.length; i++) {
|
||
if (mt.options[i].value === m.chatModel) {
|
||
mt.selectedIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (ma) {
|
||
for (var j = 0; j < ma.options.length; j++) {
|
||
if (ma.options[j].value === m.chatModel) {
|
||
ma.selectedIndex = j;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (m.transcriptionModel) {
|
||
var wEl = document.getElementById('mmWhisperModel');
|
||
if (wEl) {
|
||
for (var k = 0; k < wEl.options.length; k++) {
|
||
if (wEl.options[k].value === m.transcriptionModel) {
|
||
wEl.selectedIndex = k;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
document.getElementById('mmSavePrompt').addEventListener('click', function () {
|
||
var body = {
|
||
includeTitleLine: true,
|
||
includeAttendees: true,
|
||
includeSummary: true,
|
||
includeActionItems: true,
|
||
includeChecklist: false,
|
||
customInstructions: document.getElementById('mmCustomInstr').value
|
||
};
|
||
api('/api/meeting-minutes/prompt', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
}).then(function () {
|
||
alert('저장되었습니다.');
|
||
}).catch(function (e) {
|
||
alert(e.message || '저장 실패');
|
||
});
|
||
});
|
||
|
||
document.getElementById('mmSaveResult').addEventListener('click', function () {
|
||
if (!currentMeetingId) return;
|
||
setMeetingGenerating(true, '저장 중…');
|
||
var savePayload = { generatedMinutes: resultBody.value };
|
||
if (lastMeetingTranscriptIsAudio) {
|
||
savePayload.transcriptText = transcriptBody ? transcriptBody.value : '';
|
||
} else {
|
||
savePayload.sourceText = transcriptBody ? transcriptBody.value : '';
|
||
}
|
||
api('/api/meeting-minutes/meetings/' + encodeURIComponent(currentMeetingId) + '/save', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(savePayload)
|
||
})
|
||
.then(function (d) {
|
||
if (d.meeting) {
|
||
if (d.meeting.generatedMinutes != null) resultBody.value = d.meeting.generatedMinutes;
|
||
setMinutesViewMode(false);
|
||
if (transcriptBody) {
|
||
if (lastMeetingTranscriptIsAudio && d.meeting.transcriptText != null) {
|
||
transcriptBody.value = String(d.meeting.transcriptText);
|
||
}
|
||
if (!lastMeetingTranscriptIsAudio && d.meeting.sourceText != null) {
|
||
transcriptBody.value = String(d.meeting.sourceText);
|
||
var stEl = document.getElementById('mmSourceText');
|
||
if (stEl) stEl.value = String(d.meeting.sourceText);
|
||
}
|
||
}
|
||
}
|
||
loadMeetings();
|
||
var cs = d.checklistSync;
|
||
if (cs && cs.imported > 0) {
|
||
alert('저장되었습니다. 업무 체크리스트에 ' + cs.imported + '건이 반영되었습니다.');
|
||
} else if (cs && cs.extractError && !cs.disabled) {
|
||
alert('저장되었습니다. (체크리스트 자동 연동: ' + cs.extractError + ')');
|
||
} else {
|
||
alert('저장되었습니다.');
|
||
}
|
||
})
|
||
.catch(function (e) {
|
||
alert(e.message || '저장 실패');
|
||
})
|
||
.finally(function () {
|
||
setMeetingGenerating(false);
|
||
});
|
||
});
|
||
|
||
document.getElementById('mmListRefresh').addEventListener('click', loadMeetings);
|
||
|
||
function wireMeetingDatePicker(btnId, inputId) {
|
||
var btn = document.getElementById(btnId);
|
||
var inp = document.getElementById(inputId);
|
||
if (!btn || !inp) return;
|
||
btn.addEventListener('click', function () {
|
||
if (typeof inp.showPicker === 'function') inp.showPicker();
|
||
else inp.focus();
|
||
});
|
||
}
|
||
wireMeetingDatePicker('mmDateTextBtn', 'mmDateText');
|
||
wireMeetingDatePicker('mmDateAudioBtn', 'mmDateAudio');
|
||
|
||
(function wireAudioFileTitleAutofill() {
|
||
var audioFileEl = document.getElementById('mmAudioFile');
|
||
var titleAudioEl = document.getElementById('mmTitleAudio');
|
||
if (!audioFileEl || !titleAudioEl) return;
|
||
function stripExtension(filename) {
|
||
if (!filename) return '';
|
||
var base = String(filename).replace(/\\/g, '/').split('/').pop() || '';
|
||
var i = base.lastIndexOf('.');
|
||
return i > 0 ? base.slice(0, i) : base;
|
||
}
|
||
audioFileEl.addEventListener('change', function () {
|
||
var f = audioFileEl.files && audioFileEl.files[0];
|
||
if (!f || !f.name) return;
|
||
titleAudioEl.value = stripExtension(f.name).slice(0, 500);
|
||
});
|
||
})();
|
||
|
||
document.querySelectorAll('.mm-tab').forEach(function (tab) {
|
||
tab.addEventListener('click', function () {
|
||
var name = tab.getAttribute('data-tab');
|
||
document.querySelectorAll('.mm-tab').forEach(function (t) {
|
||
t.classList.toggle('is-active', t === tab);
|
||
t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
|
||
});
|
||
var panelText = document.getElementById('mmPanelText');
|
||
var panelAudio = document.getElementById('mmPanelAudio');
|
||
panelText.hidden = name !== 'text';
|
||
panelAudio.hidden = name !== 'audio';
|
||
panelText.setAttribute('aria-hidden', name !== 'text' ? 'true' : 'false');
|
||
panelAudio.setAttribute('aria-hidden', name !== 'audio' ? 'true' : 'false');
|
||
applyTranscriptPaneVisibility();
|
||
});
|
||
});
|
||
|
||
document.getElementById('mmGenText').addEventListener('click', function () {
|
||
var title = (document.getElementById('mmTitleText').value || '').trim();
|
||
if (!title) {
|
||
alert('제목을 입력해 주세요.');
|
||
return;
|
||
}
|
||
var sourceText = (document.getElementById('mmSourceText').value || '').trim();
|
||
if (!sourceText) {
|
||
alert('회의 원문을 입력해 주세요.');
|
||
return;
|
||
}
|
||
setMeetingGenerating(true, '회의 원문을 보내고 있습니다…');
|
||
api('/api/meeting-minutes/generate-text', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: title,
|
||
meetingDate: document.getElementById('mmDateText').value,
|
||
sourceText: sourceText,
|
||
model: document.getElementById('mmModelText').value
|
||
})
|
||
})
|
||
.then(function (d) {
|
||
resultSection.hidden = false;
|
||
setCurrentMeetingId(d.meeting && d.meeting.id);
|
||
lastMeetingTranscriptIsAudio = false;
|
||
if (transcriptLabel) transcriptLabel.textContent = '회의 원문';
|
||
if (transcriptBody) transcriptBody.value = (d.meeting && d.meeting.sourceText) || '';
|
||
resultBody.value = (d.meeting && d.meeting.generatedMinutes) || '';
|
||
setMinutesViewMode(false);
|
||
applyTranscriptPaneVisibility();
|
||
loadMeetings();
|
||
var cs = d.checklistSync;
|
||
if (cs && cs.imported > 0) {
|
||
alert('업무 체크리스트에 ' + cs.imported + '건이 자동 반영되었습니다. (업무 체크리스트 AI에서 확인)');
|
||
} else if (cs && cs.extractError && !cs.disabled) {
|
||
console.warn('체크리스트 자동 추출:', cs.extractError);
|
||
}
|
||
})
|
||
.catch(function (e) {
|
||
alert(e.message || '생성 실패');
|
||
})
|
||
.finally(function () {
|
||
setMeetingGenerating(false);
|
||
});
|
||
});
|
||
|
||
document.getElementById('mmGenAudio').addEventListener('click', function () {
|
||
var fileInput = document.getElementById('mmAudioFile');
|
||
if (!fileInput.files || !fileInput.files.length) {
|
||
alert('음성 파일을 선택해 주세요.');
|
||
return;
|
||
}
|
||
var audioTitle = (document.getElementById('mmTitleAudio').value || '').trim();
|
||
if (!audioTitle) {
|
||
alert('제목을 입력해 주세요.');
|
||
return;
|
||
}
|
||
var audioFile = fileInput.files[0];
|
||
|
||
mmSetAudioPipelineVisible(true);
|
||
mmAudioLastUiPhase = 'upload';
|
||
mmUpdatePipelineStepHighlight('upload');
|
||
|
||
var fileSize = audioFile.size || 0;
|
||
/** multipart 경계·헤더 등 보정 분모(multipart 업로드 total 미제공 시) */
|
||
var mpSlop =
|
||
fileSize > 0
|
||
? Math.min(262144, Math.max(6144, Math.floor(fileSize / 50) + 2048))
|
||
: 0;
|
||
var xhrLoadedBytes = 0;
|
||
var xhrEstimatedTotalBytes =
|
||
fileSize > 0 ? fileSize + mpSlop : 0;
|
||
var xhrKnownUploadTotal = fileSize > 0;
|
||
var uploadStartTime = Date.now();
|
||
var uploadPhase = true;
|
||
var transcribePrepPct = 0;
|
||
var segmentMode = false;
|
||
|
||
var lastSseName = '';
|
||
var lastSseAtMs = 0;
|
||
var lastProgressStage = null;
|
||
var lastSegDone = 0;
|
||
var lastSegTotal = 0;
|
||
|
||
function mmAudioDiagRefresh() {
|
||
var hintKo = '';
|
||
if (uploadPhase) {
|
||
hintKo =
|
||
'① POST prepare-audio: XHR 업로드·파일 디스크 저장 후 jobId JSON · ② GET stream-audio: fetch SSE로 증분 수신 (단일 업로드+SSE 연결은 일부 브라우저/프록시가 본문을 버퍼링할 수 있습니다).';
|
||
} else if (mmAudioLastUiPhase === 'translate') {
|
||
hintKo = '번역 단계: 전사 결과를 회의록 형식으로 LLM이 작성 중입니다.';
|
||
} else if (segmentMode) {
|
||
hintKo =
|
||
'전사 단계: 세그먼트 처리 중 (' +
|
||
lastSegDone +
|
||
' / ' +
|
||
lastSegTotal +
|
||
')';
|
||
} else {
|
||
hintKo =
|
||
'전사 준비: 서버에서 설정·분할 등 — 세그먼트 init 이벤트 전일 수 있습니다.';
|
||
}
|
||
mmAudioJobDiag = {
|
||
active: true,
|
||
startedAtMs: uploadStartTime,
|
||
elapsedMs: Date.now() - uploadStartTime,
|
||
audioFileBytes: fileSize,
|
||
uploadPhase: uploadPhase,
|
||
xhrKnownTotal: xhrKnownUploadTotal,
|
||
xhrUploadedBytes: xhrLoadedBytes,
|
||
xhrEstimatedTotalBytes: xhrEstimatedTotalBytes,
|
||
uiPhase: mmAudioLastUiPhase,
|
||
transcribePrepPct: transcribePrepPct,
|
||
segmentMode: segmentMode,
|
||
transcribeProgress: { done: lastSegDone, total: lastSegTotal },
|
||
lastProgressStage: lastProgressStage,
|
||
lastSseEvent: lastSseName,
|
||
lastSseAtMs: lastSseAtMs || null,
|
||
hintKo: hintKo,
|
||
};
|
||
}
|
||
|
||
mmAudioDiagRefresh();
|
||
|
||
setMeetingGenerating(true);
|
||
mmSetAudioGenerationPhase(
|
||
'upload',
|
||
0,
|
||
'파일 업로드를 시작합니다(XHR)·전송률은 바이트 기준입니다.'
|
||
);
|
||
|
||
var sseError = null;
|
||
|
||
function warmTranscriptionAfterPrepare() {
|
||
window.setTimeout(function () {
|
||
transcribePrepPct = 6;
|
||
segmentMode = false;
|
||
mmSetAudioGenerationPhase(
|
||
'transcribe',
|
||
transcribePrepPct,
|
||
'전사: DB 및 프롬프트 로드·음성 준비 중 (이후 세그먼트별 진행률).'
|
||
);
|
||
mmAudioDiagRefresh();
|
||
}, 0);
|
||
}
|
||
|
||
function mmMeetingAudioFatal(e) {
|
||
uploadPhase = false;
|
||
mmAudioJobDiag = {
|
||
active: false,
|
||
endedAtMs: Date.now(),
|
||
hintKo: '요청 실패: ' + (e && e.message ? e.message : String(e)),
|
||
};
|
||
alert(e.message || '생성 실패');
|
||
setMeetingGenerating(false);
|
||
}
|
||
|
||
var mmAudioStreamHandlers = {
|
||
onSseEvent: function (event, data) {
|
||
try {
|
||
lastSseName = event;
|
||
lastSseAtMs = Date.now();
|
||
if (event === 'progress' && data && data.stage) lastProgressStage = data.stage;
|
||
if (event === 'progress' && data && data.stage === 'transcribe') {
|
||
lastSegDone = data.done != null ? data.done : 0;
|
||
lastSegTotal = data.total != null ? data.total : 1;
|
||
}
|
||
if (event === 'accepted') {
|
||
transcribePrepPct = Math.max(transcribePrepPct, 22);
|
||
var am = data && data.message ? String(data.message) : '';
|
||
mmSetAudioGenerationPhase(
|
||
'transcribe',
|
||
transcribePrepPct,
|
||
am || '서버 접수 후 설정 로드 중입니다.'
|
||
);
|
||
return;
|
||
}
|
||
if (event === 'heartbeat') {
|
||
if (!segmentMode) {
|
||
transcribePrepPct = Math.min(94, transcribePrepPct + 3);
|
||
mmSetAudioGenerationPhase(
|
||
'transcribe',
|
||
transcribePrepPct,
|
||
'서버 처리 및 전사를 기다리는 중입니다…'
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (event === 'progress') {
|
||
var stage = data.stage;
|
||
var done = data.done || 0;
|
||
var total = data.total || 1;
|
||
if (stage === 'prep') {
|
||
transcribePrepPct = Math.max(transcribePrepPct, 52);
|
||
mmSetAudioGenerationPhase(
|
||
'transcribe',
|
||
transcribePrepPct,
|
||
(data.message && String(data.message)) ||
|
||
'전사 준비(음성 구간 처리·전사 엔진 연결) 중입니다.'
|
||
);
|
||
return;
|
||
}
|
||
if (stage === 'init') {
|
||
segmentMode = true;
|
||
lastSegDone = 0;
|
||
lastSegTotal = total;
|
||
mmSetAudioGenerationPhase(
|
||
'transcribe',
|
||
0,
|
||
total > 1
|
||
? '전사 시작: 구간별 API 호출 (0 / ' +
|
||
total +
|
||
' 완료) — 이 단계만 100% 기준입니다.'
|
||
: '전사 API 호출이 시작되었습니다. 이 단계만 100% 기준입니다.'
|
||
);
|
||
return;
|
||
}
|
||
if (stage === 'transcribe') {
|
||
var segPct = Math.min(100, Math.round((done / total) * 100));
|
||
mmSetAudioGenerationPhase(
|
||
'transcribe',
|
||
segPct,
|
||
total > 1
|
||
? '구간별 전사 중 (' + done + ' / ' + total + ' 완료)…'
|
||
: '전사 API 응답 처리 중…'
|
||
);
|
||
return;
|
||
}
|
||
} else if (event === 'generating') {
|
||
mmSetAudioGenerationPhase(
|
||
'translate',
|
||
null,
|
||
'전사 결과를 회의록 형식(LLM 작성)으로 정리합니다.'
|
||
);
|
||
} else if (event === 'done') {
|
||
mmSetAudioGenerationPhase(
|
||
'translate',
|
||
100,
|
||
'회의록 작성·저장 및 체크리스트 후처리까지 완료되었습니다.'
|
||
);
|
||
var d = data;
|
||
resultSection.hidden = false;
|
||
setCurrentMeetingId(d.meeting && d.meeting.id);
|
||
lastMeetingTranscriptIsAudio = true;
|
||
if (transcriptLabel) transcriptLabel.textContent = '전사 기록';
|
||
if (transcriptBody) transcriptBody.value = (d.meeting && d.meeting.transcriptText) || '';
|
||
resultBody.value = (d.meeting && d.meeting.generatedMinutes) || '';
|
||
setMinutesViewMode(false);
|
||
applyTranscriptPaneVisibility();
|
||
fileInput.value = '';
|
||
loadMeetings();
|
||
var cs = d.checklistSync;
|
||
if (cs && cs.imported > 0) {
|
||
alert(
|
||
'업무 체크리스트에 ' +
|
||
cs.imported +
|
||
'건이 자동 반영되었습니다. (업무 체크리스트 AI에서 확인)'
|
||
);
|
||
} else if (cs && cs.extractError && !cs.disabled) {
|
||
console.warn('체크리스트 자동 추출:', cs.extractError);
|
||
}
|
||
} else if (event === 'error') {
|
||
sseError = (data && data.message) || '생성 실패';
|
||
}
|
||
} finally {
|
||
mmAudioDiagRefresh();
|
||
}
|
||
},
|
||
onEnd: function () {
|
||
mmAudioJobDiag = {
|
||
active: false,
|
||
endedAtMs: Date.now(),
|
||
hintKo: sseError
|
||
? '오류로 종료: ' + sseError
|
||
: '스트림 종료(정상 완료 또는 서버 연결 종료).',
|
||
};
|
||
if (sseError) alert(sseError);
|
||
setMeetingGenerating(false);
|
||
},
|
||
onError: mmMeetingAudioFatal,
|
||
};
|
||
|
||
var fd = new FormData();
|
||
fd.append('audio', audioFile);
|
||
fd.append('title', audioTitle);
|
||
fd.append('meetingDate', document.getElementById('mmDateAudio').value);
|
||
fd.append('model', document.getElementById('mmModelAudio').value);
|
||
fd.append('whisperModel', document.getElementById('mmWhisperModel').value);
|
||
|
||
postPrepareMeetingAudioJob('/api/meeting-minutes/prepare-audio', fd, {
|
||
onUploadProgress: function (computable, loaded, total) {
|
||
xhrLoadedBytes = loaded;
|
||
var effTotal = 0;
|
||
if (computable && total > 0) {
|
||
effTotal = total;
|
||
} else if (fileSize > 0) {
|
||
effTotal = fileSize + mpSlop;
|
||
}
|
||
xhrEstimatedTotalBytes = effTotal;
|
||
xhrKnownUploadTotal = !!(effTotal > 0);
|
||
if (!uploadPhase || effTotal <= 0) {
|
||
mmAudioDiagRefresh();
|
||
return;
|
||
}
|
||
var pct = Math.min(99, Math.round((loaded * 100) / Math.max(effTotal, 1)));
|
||
var detail =
|
||
'업로드 중… ' +
|
||
loaded.toLocaleString() +
|
||
' / 약 ' +
|
||
effTotal.toLocaleString() +
|
||
' 바이트';
|
||
mmSetAudioGenerationPhase('upload', pct, detail);
|
||
mmAudioDiagRefresh();
|
||
},
|
||
onJobReady: function (jobId) {
|
||
uploadPhase = false;
|
||
mmSetAudioGenerationPhase(
|
||
'upload',
|
||
100,
|
||
'업로드를 마쳤습니다. 전사 스트림에 연결합니다…'
|
||
);
|
||
mmAudioDiagRefresh();
|
||
warmTranscriptionAfterPrepare();
|
||
streamMeetingAudioJobSse(jobId, mmAudioStreamHandlers);
|
||
},
|
||
onError: mmMeetingAudioFatal,
|
||
});
|
||
});
|
||
|
||
applyTranscriptPaneVisibility();
|
||
loadPrompt().then(loadMeetings).catch(function () {});
|
||
})();
|
||
</script>
|
||
<script>
|
||
(function () {
|
||
var panel = document.getElementById('mmPromptPanel');
|
||
var btn = document.getElementById('mmPromptToggle');
|
||
var body = document.getElementById('mmPromptBody');
|
||
if (!panel || !btn || !body) return;
|
||
function applyOpen(open) {
|
||
if (open) {
|
||
panel.classList.remove('mm-prompt-collapsed');
|
||
body.hidden = false;
|
||
btn.setAttribute('aria-expanded', 'true');
|
||
} else {
|
||
panel.classList.add('mm-prompt-collapsed');
|
||
body.hidden = true;
|
||
btn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
}
|
||
try {
|
||
if (localStorage.getItem('meetingMinutesPromptOpen') === '1') {
|
||
applyOpen(true);
|
||
}
|
||
} catch (e) {}
|
||
btn.addEventListener('click', function () {
|
||
var nowCollapsed = panel.classList.toggle('mm-prompt-collapsed');
|
||
body.hidden = nowCollapsed;
|
||
btn.setAttribute('aria-expanded', String(!nowCollapsed));
|
||
try {
|
||
localStorage.setItem('meetingMinutesPromptOpen', nowCollapsed ? '0' : '1');
|
||
} catch (e) {}
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|