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>
This commit is contained in:
dsyoon
2026-05-26 22:27:48 +09:00
parent 7bee72f287
commit 073a8343dd
84 changed files with 10883 additions and 1043 deletions

View File

@@ -8,18 +8,6 @@
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body class="meeting-minutes-page">
<% var mmDefaultCustomInstructions = `아래 회의 내용(또는 녹취/메모)을 바탕으로 다음 형식으로 정리해 주세요.
원문·전사 전체를 회의록에 다시 붙여 넣지 마세요.
‘스크립트’·‘스크랩트’(오타)·‘원문 전사’ 같은 섹션은 만들지 말고, 요약·결정·액션만 작성하세요.
회의 제목·참석자·요약 등은 ## 마크다운 제목으로 구분하세요.
1) 회의 개요: 일시, 참석자(알 수 있는 경우), 목적
2) 논의 안건별 요약
3) 결정 사항 (명확한 문장으로)
4) 액션 아이템: 별도 섹션. 각 항목에 할 일·담당자·기한을 구체적으로 적어주세요. 만약 담당자와 기한을 알 수 없으면 안적어도 무방합니다.
‘추가 권고’, ‘회의록 작성자의 제안’, 엑셀 템플릿·추적 체크리스트 제안 등은 넣지 마세요.`; %>
<% 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 }) %>
@@ -52,17 +40,11 @@
</button>
</div>
<div class="mm-prompt-body" id="mmPromptBody" hidden>
<p class="subtitle">회의록에 포함할 항목을 선택하고, 추가 지시를 입력할 수 있습니다. 형식·섹션 구성은 <strong>추가 지시</strong>가 시스템 기본보다 우선합니다. 회의 체크리스트 섹션은 DB에 별도로 켜지 않은 경우 생성하지 않으며, 저장 후 업무 체크리스트 자동 연동은 액션·후속 항목에서 추출합니다.</p>
<p class="subtitle">회의록 작성 시스템 프롬프트를 수정할 수 있습니다. 저장 후 업무 체크리스트 자동 연동은 액션·후속 항목에서 추출합니다.</p>
<div class="mm-prompt-form" id="mmPromptForm">
<div class="mm-checkbox-row" role="group" aria-label="회의록에 포함할 항목">
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncTitle" checked /> <span>제목 한 줄</span></label>
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncAtt" checked /> <span>참석자</span></label>
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncSum" checked /> <span>요약</span></label>
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncAct" checked /> <span>Action Item</span></label>
</div>
<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=""><%= mmDefaultCustomInstructions %></textarea>
<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>
@@ -91,15 +73,27 @@
</div>
</div>
</div>
<label class="mm-field">
<span class="mm-field-label">회의 원문</span>
<textarea id="mmSourceText" class="mm-textarea" rows="12" placeholder="회의 내용을 붙여 넣거나 입력하세요." <%= hasEmail ? '' : 'disabled' %>></textarea>
</label>
<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" selected>gpt-5-mini</option>
<option value="gpt-5.4">gpt-5.4</option>
<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">
@@ -118,10 +112,9 @@
<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" selected>gpt-4o-mini-transcribe (기본)</option>
<option value="gpt-4o-transcribe">gpt-4o-transcribe (고성능)</option>
<option value="gpt-4o-mini-transcribe">gpt-4o-mini-transcribe (경량·더 빠를 수 있음)</option>
<option value="gpt-4o-transcribe" selected>gpt-4o-transcribe (기본)</option>
</select>
<span class="mm-field-help">OpenAI 전사 API 공식 모델 ID와 동일합니다. 기본은 mini, 더 높은 인식 품질이 필요하면 gpt-4o-transcribe를 선택하세요.</span>
</label>
</div>
<div class="mm-audio-phase">
@@ -142,8 +135,8 @@
<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" selected>gpt-5-mini</option>
<option value="gpt-5.4">gpt-5.4</option>
<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>
@@ -155,8 +148,38 @@
</section>
<div id="mmGenProgress" class="mm-gen-progress" hidden role="status" aria-live="polite" aria-busy="false">
<div class="mm-gen-progress-track" aria-hidden="true">
<div class="mm-gen-progress-bar"></div>
<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>
@@ -171,8 +194,20 @@
</p>
<div class="mm-result-split">
<div id="mmTranscriptPane" class="mm-result-transcript-pane" hidden aria-hidden="true">
<label class="mm-result-field">
<span class="mm-result-field-label" id="mmTranscriptLabel">전사 기록</span>
<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"
@@ -180,26 +215,21 @@
spellcheck="false"
placeholder="음성 전사 텍스트가 여기에 표시됩니다."
></textarea>
</label>
</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="mmMinutesActionsView" role="toolbar" aria-label="회의록 보기">
<button type="button" class="btn-ghost mm-minutes-edit" id="mmMinutesEdit">마크다운 편집</button>
</div>
<div class="mm-minutes-actions" id="mmMinutesActionsEdit" role="toolbar" aria-label="회의록 편집" hidden>
<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="top-action mm-minutes-apply" id="mmMinutesApply">저장</button>
<button type="button" class="btn-ghost mm-minutes-cancel" id="mmMinutesCancel">취소</button>
>복사</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">
@@ -241,21 +271,30 @@
var resultSection = document.getElementById('mmResultSection');
var resultBody = document.getElementById('mmResultBody');
var minutesRenderedEl = document.getElementById('mmMinutesRendered');
var minutesActionsView = document.getElementById('mmMinutesActionsView');
var minutesActionsEdit = document.getElementById('mmMinutesActionsEdit');
var minutesEditBtn = document.getElementById('mmMinutesEdit');
var minutesCopyBtn = document.getElementById('mmMinutesCopy');
var minutesApplyBtn = document.getElementById('mmMinutesApply');
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) {
@@ -334,8 +373,9 @@
minutesRenderedEl.innerHTML = renderMinutesMarkdown(raw);
}
function setMinutesToolbarMode(editing) {
if (minutesActionsView) minutesActionsView.hidden = !!editing;
if (minutesActionsEdit) minutesActionsEdit.hidden = !editing;
if (minutesCancelBtn) minutesCancelBtn.hidden = !editing;
if (minutesEditBtn) minutesEditBtn.textContent = editing ? '편집 완료' : '마크다운 편집';
minutesInEditMode = !!editing;
}
function setMinutesViewMode(showSource) {
if (!resultBody || !minutesRenderedEl) return;
@@ -395,16 +435,57 @@
});
});
}
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;
minutesEditSnapshot = resultBody.value;
setMinutesViewMode(true);
});
}
if (minutesApplyBtn) {
minutesApplyBtn.addEventListener('click', function () {
setMinutesViewMode(false);
if (!minutesInEditMode) {
minutesEditSnapshot = resultBody.value;
setMinutesViewMode(true);
} else {
setMinutesViewMode(false);
}
});
}
if (minutesCancelBtn) {
@@ -427,6 +508,7 @@
function setCurrentMeetingId(id) {
currentMeetingId = id || null;
if (saveResultBtn) saveResultBtn.disabled = !currentMeetingId;
if (transcriptSaveBtn) transcriptSaveBtn.disabled = !currentMeetingId;
}
function setMeetingGenerating(on, msg) {
@@ -435,7 +517,17 @@
genProgressEl.setAttribute('aria-busy', on ? 'true' : 'false');
if (on) genProgressEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
if (genProgressMsg && msg) genProgressMsg.textContent = msg;
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;
@@ -445,7 +537,6 @@
if (transcriptBody) transcriptBody.disabled = !!on;
if (minutesEditBtn) minutesEditBtn.disabled = !!on;
if (minutesCopyBtn) minutesCopyBtn.disabled = !!on;
if (minutesApplyBtn) minutesApplyBtn.disabled = !!on;
if (minutesCancelBtn) minutesCancelBtn.disabled = !!on;
if (on && resultBody && minutesRenderedEl) {
if (!resultBody.hidden) {
@@ -455,6 +546,248 @@
}
}
/**
* 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();
@@ -471,17 +804,11 @@
});
}
var MM_DEFAULT_CUSTOM_INSTRUCTIONS = <%- JSON.stringify(mmDefaultCustomInstructions) %>;
function loadPrompt() {
return api('/api/meeting-minutes/prompt').then(function (d) {
var p = d.prompt || {};
document.getElementById('mmIncTitle').checked = p.includeTitleLine !== false;
document.getElementById('mmIncAtt').checked = p.includeAttendees !== false;
document.getElementById('mmIncSum').checked = p.includeSummary !== false;
document.getElementById('mmIncAct').checked = p.includeActionItems !== false;
var saved = (p.customInstructions && String(p.customInstructions).trim()) || '';
document.getElementById('mmCustomInstr').value = saved || MM_DEFAULT_CUSTOM_INSTRUCTIONS;
document.getElementById('mmCustomInstr').value = saved;
});
}
@@ -595,10 +922,10 @@
document.getElementById('mmSavePrompt').addEventListener('click', function () {
var body = {
includeTitleLine: document.getElementById('mmIncTitle').checked,
includeAttendees: document.getElementById('mmIncAtt').checked,
includeSummary: document.getElementById('mmIncSum').checked,
includeActionItems: document.getElementById('mmIncAct').checked,
includeTitleLine: true,
includeAttendees: true,
includeSummary: true,
includeActionItems: true,
includeChecklist: false,
customInstructions: document.getElementById('mmCustomInstr').value
};
@@ -766,44 +1093,276 @@
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', fileInput.files[0]);
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);
setMeetingGenerating(true, '음성 파일을 업로드하고 전사합니다. 길이에 따라 1분 이상 걸릴 수 있습니다…');
fetch('/api/meeting-minutes/generate-audio', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (r) {
return r.json().then(function (j) {
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
});
})
.then(function (d) {
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);
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;
}
})
.catch(function (e) {
alert(e.message || '생성 실패');
})
.finally(function () {
setMeetingGenerating(false);
});
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();