Files
ai_platform/views/meeting-minutes.ejs
dsyoon 073a8343dd feat: xavis ai_platform 기능 이전 및 ncue 환경 전환
xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영.
@xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 22:27:48 +09:00

1406 lines
65 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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">&rarr;</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">&rarr;</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>