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:
@@ -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">→</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>
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user