781 lines
40 KiB
Plaintext
781 lines
40 KiB
Plaintext
<!doctype html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>회의록 AI - XAVIS</title>
|
||
<link rel="stylesheet" href="/public/styles.css" />
|
||
</head>
|
||
<body class="meeting-minutes-page">
|
||
<% var mmDefaultCustomInstructions = `아래 회의 내용(또는 녹취/메모)을 바탕으로 다음 형식으로 정리해 주세요.
|
||
|
||
원문·전사 전체를 회의록에 다시 붙여 넣지 마세요. ‘스크립트’·‘스크랩트’(오타)·‘원문 전사’ 같은 섹션은 만들지 말고, 요약·결정·액션·체크리스트만 작성하세요. 회의 제목·참석자·요약 등은 ## 마크다운 제목으로 구분하세요.
|
||
|
||
1) 회의 개요: 일시, 참석자(알 수 있는 경우), 목적
|
||
2) 논의 안건별 요약
|
||
3) 결정 사항 (명확한 문장으로)
|
||
4) 액션 아이템: 별도 섹션. 각 항목에 What(할 일)·Who(담당자)·When(기한)을 구체적으로
|
||
5) 회의 체크리스트: 전·중·후 준비·검토 항목을 [ ]로 (완료 여부 표시 가능하게)
|
||
체크리스트·액션 아이템 다음에 "추가 메모 / 확인 필요 사항" 같은 말미 섹션이나 CSV·템플릿 제안은 넣지 마세요.
|
||
체크리스트를 마지막 섹션으로 두고, 시연·피드백 회신 방식, 우선순위 재정렬·담당·기한 확정, DRM·후보군 추가 작성 제안 등 운영 안내 문단은 붙이지 마세요.
|
||
‘추가 권고’, ‘회의록 작성자의 제안’, 엑셀 템플릿·추적 체크리스트 제안 등은 넣지 마세요.`; %>
|
||
<% 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">회의록에 포함할 항목을 선택하고, 추가 지시를 입력할 수 있습니다. 시스템 프롬프트에 액션 아이템(What·Who·When)과 회의 체크리스트(전·중·후) 정의가 포함되며, 체크리스트는 업무 체크리스트 AI 연동을 위해 항상 포함됩니다.</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>
|
||
<label class="mm-checkbox-item mm-checkbox-item-locked" title="업무 체크리스트 AI에서 활용되므로 항상 켜져 있습니다.">
|
||
<input type="checkbox" id="mmIncChk" checked disabled aria-checked="true" aria-disabled="true" />
|
||
<span>체크리스트 <span class="mm-checkbox-badge">필수</span></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>
|
||
</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>
|
||
<label class="mm-field">
|
||
<span class="mm-field-label">회의 원문</span>
|
||
<textarea id="mmSourceText" class="mm-textarea" rows="12" placeholder="회의 내용을 붙여 넣거나 입력하세요." <%= hasEmail ? '' : 'disabled' %>></textarea>
|
||
</label>
|
||
<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>
|
||
</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" selected>gpt-4o-mini-transcribe (기본)</option>
|
||
<option value="gpt-4o-transcribe">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">
|
||
<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" selected>gpt-5-mini</option>
|
||
<option value="gpt-5.4">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 class="mm-gen-progress-track" aria-hidden="true">
|
||
<div class="mm-gen-progress-bar"></div>
|
||
</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">
|
||
<label class="mm-result-field">
|
||
<span class="mm-result-field-label" id="mmTranscriptLabel">전사 기록</span>
|
||
<textarea
|
||
id="mmTranscriptBody"
|
||
class="mm-result-body mm-result-textarea mm-result-textarea-half mm-transcript-textarea"
|
||
rows="14"
|
||
spellcheck="false"
|
||
placeholder="음성 전사 텍스트가 여기에 표시됩니다."
|
||
></textarea>
|
||
</label>
|
||
</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>
|
||
<button type="button" class="top-action mm-minutes-apply" id="mmMinutesApply">저장</button>
|
||
<button type="button" class="btn-ghost mm-minutes-cancel" id="mmMinutesCancel">취소</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 minutesActionsView = document.getElementById('mmMinutesActionsView');
|
||
var minutesActionsEdit = document.getElementById('mmMinutesActionsEdit');
|
||
var minutesEditBtn = document.getElementById('mmMinutesEdit');
|
||
var minutesApplyBtn = document.getElementById('mmMinutesApply');
|
||
var minutesCancelBtn = document.getElementById('mmMinutesCancel');
|
||
/** 편집 모드 진입 시점의 회의록 원문(취소 시 복원) */
|
||
var minutesEditSnapshot = '';
|
||
var transcriptBody = document.getElementById('mmTranscriptBody');
|
||
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 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 (minutesActionsView) minutesActionsView.hidden = !!editing;
|
||
if (minutesActionsEdit) minutesActionsEdit.hidden = !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) {}
|
||
}
|
||
}
|
||
if (minutesEditBtn) {
|
||
minutesEditBtn.addEventListener('click', function () {
|
||
if (!resultBody) return;
|
||
minutesEditSnapshot = resultBody.value;
|
||
setMinutesViewMode(true);
|
||
});
|
||
}
|
||
if (minutesApplyBtn) {
|
||
minutesApplyBtn.addEventListener('click', function () {
|
||
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;
|
||
}
|
||
|
||
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 (genProgressMsg && msg) genProgressMsg.textContent = msg;
|
||
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 (minutesApplyBtn) minutesApplyBtn.disabled = !!on;
|
||
if (minutesCancelBtn) minutesCancelBtn.disabled = !!on;
|
||
if (on && resultBody && minutesRenderedEl) {
|
||
if (!resultBody.hidden) {
|
||
refreshMinutesRendered();
|
||
setMinutesViewMode(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
});
|
||
});
|
||
}
|
||
|
||
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;
|
||
document.getElementById('mmIncChk').checked = true;
|
||
var saved = (p.customInstructions && String(p.customInstructions).trim()) || '';
|
||
document.getElementById('mmCustomInstr').value = saved || MM_DEFAULT_CUSTOM_INSTRUCTIONS;
|
||
});
|
||
}
|
||
|
||
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: document.getElementById('mmIncTitle').checked,
|
||
includeAttendees: document.getElementById('mmIncAtt').checked,
|
||
includeSummary: document.getElementById('mmIncSum').checked,
|
||
includeActionItems: document.getElementById('mmIncAct').checked,
|
||
includeChecklist: true,
|
||
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');
|
||
|
||
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 fd = new FormData();
|
||
fd.append('audio', fileInput.files[0]);
|
||
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);
|
||
}
|
||
})
|
||
.catch(function (e) {
|
||
alert(e.message || '생성 실패');
|
||
})
|
||
.finally(function () {
|
||
setMeetingGenerating(false);
|
||
});
|
||
});
|
||
|
||
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>
|