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

@@ -18,8 +18,12 @@
<main class="container">
<section class="panel">
<p class="subtitle" style="margin-bottom: 16px">
OPS 이메일(<strong>@xavis.co.kr</strong>) 매직 링크로 <strong>로그인에 성공한</strong> 사용자 목록입니다. 이메일과 최근 접속일(마지막 로그인 시각)을 표시합니다.
OPS 이메일(<strong>@ncue.net</strong>) 매직 링크로 <strong>로그인에 성공한</strong> 사용자 목록입니다. 이메일과 최근 접속일(마지막 로그인 시각)을 표시합니다.
</p>
<p class="admin-hint" style="margin-bottom: 16px">
<strong>전체 로그아웃</strong>을 실행하면 해당 사용자의 모든 디바이스에서 OPS 세션이 즉시 무효화됩니다. 이후 다시 매직 링크로 로그인해야 합니다.
</p>
<p id="adminUsersFlash" class="form-message" hidden role="status"></p>
<% if (typeof dbError !== 'undefined' && dbError) { %>
<p class="admin-error">목록을 불러오지 못했습니다: <%= dbError %></p>
<% } else if (!pgConnected) { %>
@@ -33,11 +37,13 @@
<tr>
<th scope="col">이메일</th>
<th scope="col">최근 접속일</th>
<th scope="col">세션 무효화</th>
<th scope="col">작업</th>
</tr>
</thead>
<tbody>
<% users.forEach(function (u) { %>
<tr>
<tr data-email="<%= u.email %>">
<td><%= u.email %></td>
<td>
<% if (u.lastLoginAt) { %>
@@ -46,6 +52,22 @@
<% } %>
</td>
<td class="js-sessions-revoked-at">
<% if (u.sessionsRevokedAt) { %>
<%= new Date(u.sessionsRevokedAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }) %>
<% } else { %>
<% } %>
</td>
<td>
<button
type="button"
class="danger btn-sm js-revoke-sessions-btn"
data-email="<%= u.email %>"
>
전체 로그아웃
</button>
</td>
</tr>
<% }); %>
</tbody>
@@ -57,5 +79,54 @@
</main>
</div>
</div>
<script>
(function () {
const flash = document.getElementById("adminUsersFlash");
function showFlash(text, isError) {
if (!flash) return;
flash.textContent = text;
flash.hidden = false;
flash.style.color = isError ? "#b91c1c" : "#059669";
}
document.querySelectorAll(".js-revoke-sessions-btn").forEach(function (btn) {
btn.addEventListener("click", async function () {
const email = btn.getAttribute("data-email") || "";
if (!email) return;
const ok = window.confirm(
email + " 사용자의 모든 디바이스에서 OPS 세션을 만료(전체 로그아웃)시키겠습니까?"
);
if (!ok) return;
btn.disabled = true;
try {
const res = await fetch("/api/admin/users/revoke-sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email }),
});
const data = await res.json().catch(function () {
return {};
});
if (!res.ok) {
throw new Error(data.error || "요청 실패");
}
const row = btn.closest("tr");
const cell = row && row.querySelector(".js-sessions-revoked-at");
if (cell && data.revokedAt) {
cell.textContent = new Date(data.revokedAt).toLocaleString("ko-KR", {
timeZone: "Asia/Seoul",
});
}
showFlash(email + " 사용자의 세션이 무효화되었습니다.");
} catch (err) {
showFlash(err.message || "세션 만료 처리 실패", true);
} finally {
btn.disabled = false;
}
});
});
})();
</script>
</body>
</html>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<%- include('partials/favicon') %>
<title><%= story.title %> - AI 성공 사례 - XAVIS</title>
<title><%= story.title %> - AI 활용 사례 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
@@ -16,7 +16,7 @@
<div class="content-area">
<% if (showPdfViewer) { %>
<main class="viewer-wrap ai-case-viewer">
<a href="/ai-cases" class="back-link">← AI 성공 사례로 돌아가기</a>
<a href="/ai-cases" class="back-link">← AI 활용 사례로 돌아가기</a>
<h1><%= story.title %></h1>
<p class="description"><%= story.excerpt || (story.department + ' · ' + story.author) %></p>
<div class="ppt-tools ai-case-ppt-tools ppt-tools-row">
@@ -69,7 +69,7 @@
</main>
<% } else { %>
<main class="viewer-wrap ai-case-viewer">
<a href="/ai-cases" class="back-link">← AI 성공 사례로 돌아가기</a>
<a href="/ai-cases" class="back-link">← AI 활용 사례로 돌아가기</a>
<h1><%= story.title %></h1>
<p class="description"><%= story.excerpt || (story.department + ' · ' + story.author) %></p>
<div class="ppt-tools ai-case-ppt-tools">

771
views/ai-cases-compose.ejs Normal file
View File

@@ -0,0 +1,771 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<%- include('partials/favicon') %>
<title>AI 활용 사례 &gt; 작성하기 - XAVIS</title>
<link rel="stylesheet" href="https://uicdn.toast.com/editor/3.2.2/toastui-editor.min.css" />
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
<div class="content-area">
<header class="topbar">
<h1>AI 활용 사례 &gt; <%= editPayload ? "수정" : "작성" %></h1>
<a class="top-action-link top-action-link--ghost" href="/ai-cases">목록</a>
</header>
<main class="container container-narrow use-case-compose">
<p class="use-case-compose__hint">
<strong>작성자: </strong> <%= typeof submitterEmail !== "undefined" ? submitterEmail : "" %>
</p>
<form id="use-case-form" class="use-case-compose__form" novalidate>
<div class="use-case-field">
<label for="uctitle">제목 <span class="req">*</span></label>
<input
type="text"
id="uctitle"
name="title"
class="use-case-title-input"
required
maxlength="200"
placeholder="제목을 입력하세요"
autocomplete="off"
autofocus
/>
</div>
<div class="use-case-section use-case-section--rich use-case-section--merged">
<label class="label-block" for="edUseCaseBody">본문 (STAR: 1~4번) <span class="req">*</span></label>
<p class="field-hint use-case-merge-hint">한 편집기 안에서 1.Situation → 4.Result 순서로 작성합니다.<br>구역을 나누는 제목·블록을 삭제하면 저장이 실패할 수 있습니다.</p>
<div id="edUseCaseBody" class="use-case-editor-host use-case-editor-host--merged" aria-label="STAR 본문"></div>
</div>
<p class="use-case-charcount" id="ucCharLine" aria-live="polite">0 / <%= maxBodyTotal %>자</p>
<div class="use-case-compose__meta">
<div class="use-case-field use-case-field--tags">
<label for="uctags">활용 AI</label>
<input
type="text"
id="uctags"
name="tags"
class="use-case-tag-input"
placeholder="쉼표로 구분 (예, Claude, Slack, Canvas)"
autocomplete="off"
/>
<p class="field-hint">활용한 AI 툴을 입력해주세요.</p>
</div>
<div class="use-case-field" id="ucThumbField">
<span class="label-block">썸네일 이미지 <% if (!editPayload) { %><span class="req">*</span><% } else { %><span class="opt">(변경·추가 시에만)</span><% } %></span>
<p class="field-hint" id="ucThumbFieldHint">최대 <%= maxThumbCount %>개, 개당 5MB, 가급 1:1 비율 권장</p>
<% if (typeof editPayload !== "undefined" && editPayload) { %>
<p class="field-hint use-case-attach-hint">이미지 우측 상단 <strong>X</strong>로 삭제할 수 있습니다. 새 이미지는 아래에서 고르면 목록 끝에 추가됩니다(합계 최대 <%= maxThumbCount %>개).</p>
<% } %>
<div class="use-case-dropzone" id="ucThumbZone">
<input type="file" id="ucThumb" name="thumbnail" accept="image/*" class="use-case-file-input" multiple />
<label for="ucThumb" class="use-case-dropzone__label"
>클릭하거나 드래그하여 썸네일을 선택하세요 (최대 <%= maxThumbCount %>개)</label
>
</div>
<div class="use-case-thumb-grid" id="ucThumbPreviewGrid" hidden>
<% if (typeof editPayload !== "undefined" && editPayload && editPayload.existingThumbnails && editPayload.existingThumbnails.length) { %>
<% editPayload.existingThumbnails.forEach(function (t) { var fn = (t.originalName || "썸네일").replace(/[<>"]/g, "") || (t.relativePath && t.relativePath.split("/").pop()) || "썸네일"; %>
<figure class="use-case-thumb-tile" data-existing-rp="<%- encodeURIComponent(t.relativePath) %>">
<img src="<%= t.relativePath %>" alt="<%= fn %>" loading="lazy" decoding="async" />
<button type="button" class="use-case-thumb-remove" aria-label="<%= fn %> 제거" title="제거">×</button>
</figure>
<% }); %>
<% } %>
</div>
</div>
<div class="use-case-field">
<span class="label-block">첨부 파일 <span class="opt">(선택, 최대 <%= maxAttachCount %>개·20MB)</span></span>
<% if (typeof editPayload !== "undefined" && editPayload) { %>
<p class="field-hint use-case-attach-hint">수정 시: 아래 <strong>기존 첨부</strong>를 체크해 삭제한 뒤 새 파일을 선택할 수 있습니다(최대 <%= maxAttachCount %>개).</p>
<% } %>
<input type="file" id="ucFiles" name="attachments" class="use-case-file-multi" />
<% if (typeof editPayload !== "undefined" && editPayload && editPayload.existingAttachments && editPayload.existingAttachments.length) { %>
<p class="field-hint use-case-existing-attach__title">기존 첨부 — 제거하려면 체크</p>
<ul class="use-case-existing-attach">
<% editPayload.existingAttachments.forEach(function (a) { var fn = (a.originalName || "").replace(/[<>"]/g, "") || (a.relativePath && a.relativePath.split("/").pop()) || "첨부"; %>
<li>
<label class="use-case-cb"
><input type="checkbox" class="js-uc-remove-attach" data-rp="<%- encodeURIComponent(a.relativePath) %>" /> <%= fn %></label
>
</li>
<% }); %>
</ul>
<% } %>
<ul class="use-case-file-list" id="ucFileList" hidden></ul>
</div>
</div>
<div class="use-case-actions">
<a href="/ai-cases" class="btn-ghost use-case-btn-cancel">취소</a>
<button type="submit" class="top-action use-case-btn-save" id="ucSubmit"><%= editPayload ? "수정 저장" : "저장하기" %></button>
</div>
<p class="form-message use-case-form-msg" id="ucFormMsg" hidden></p>
<% if (editPayload) { %>
<script type="application/json" id="uc-edit-json">
<%- JSON.stringify(editPayload).replace(/</g, "\\u003c") %>
</script>
<% } %>
</form>
</main>
</div>
</div>
<script src="https://uicdn.toast.com/editor/3.2.2/toastui-editor-all.min.js"></script>
<script src="https://uicdn.toast.com/editor/3.2.2/i18n/ko-kr.min.js"></script>
<script>
(function () {
var maxTotal = <%= maxBodyTotal %>;
var maxThumbCount = <%= maxThumbCount %>;
var maxAttachCount = <%= maxAttachCount %>;
var ph = {
situation: <%- JSON.stringify(placeholders.situation) %>,
task: <%- JSON.stringify(placeholders.task) %>,
action: <%- JSON.stringify(placeholders.action) %>,
result: <%- JSON.stringify(placeholders.result) %>,
};
var charLine = document.getElementById("ucCharLine");
var editJson = document.getElementById("uc-edit-json");
var editPayload = null;
if (editJson) {
try {
editPayload = JSON.parse(editJson.textContent);
} catch (e) {
editPayload = null;
}
}
var form = document.getElementById("use-case-form");
var msg = document.getElementById("ucFormMsg");
var submitBtn = document.getElementById("ucSubmit");
var thumb = document.getElementById("ucThumb");
var thumbGrid = document.getElementById("ucThumbPreviewGrid");
var pendingThumbFiles = [];
var pendingThumbUrls = [];
var filesIn = document.getElementById("ucFiles");
var fileList = document.getElementById("ucFileList");
var sectionIds = ["uc-situation", "uc-task", "uc-action", "uc-result"];
var fieldKeys = ["situation", "taskGoal", "actionTaken", "resultOutcome"];
var phKeysByField = {
situation: "situation",
taskGoal: "task",
actionTaken: "action",
resultOutcome: "result",
};
var placeholdersActive = !editPayload;
var examplesComposeMode = !editPayload;
if (typeof toastui === "undefined" || !toastui.Editor) {
if (form) {
var p = document.createElement("p");
p.className = "form-message use-case-form-msg";
p.style.color = "#b91c1c";
p.textContent = "에디터를 불러오지 못했습니다. 네트워크·방화벽을 확인하거나 uicdn.toast.com 접속을 허용해 주세요.";
form.insertBefore(p, form.firstChild);
}
return;
}
function esc(s) {
if (s == null) return "";
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function paraFromPh(text) {
if (!text) return "<p><br></p>";
return (
'<p class="uc-example-text" data-uc-example="1">' +
esc(text).replace(/\r\n/g, "\n").split("\n").join("<br>") +
"</p>"
);
}
function buildHtmlFromSections(sec) {
sec = sec || {};
return (
'<div class="uc-doc">' +
'<div id="uc-situation" class="uc-section"><h2>1. Situation (배경)</h2>' +
(sec.situation || "<p><br></p>") +
"</div>" +
'<div id="uc-task" class="uc-section"><h2>2. Task (과제/목표)</h2>' +
(sec.taskGoal || "<p><br></p>") +
"</div>" +
'<div id="uc-action" class="uc-section"><h2>3. Action (행동)</h2>' +
(sec.actionTaken || "<p><br></p>") +
"</div>" +
'<div id="uc-result" class="uc-section"><h2>4. Result (결과)</h2>' +
(sec.resultOutcome || "<p><br></p>") +
"</div>" +
"</div>"
);
}
function buildInitialHtml() {
return buildHtmlFromSections({
situation: paraFromPh(ph.situation),
taskGoal: paraFromPh(ph.task),
actionTaken: paraFromPh(ph.action),
resultOutcome: paraFromPh(ph.result),
});
}
function normalizePlain(s) {
return String(s || "")
.replace(/\s+/g, " ")
.trim();
}
function isPlaceholderBody(html, phKey) {
var phPlain = normalizePlain(ph[phKey]);
if (!phPlain) return false;
return normalizePlain(stripForCount(html)) === phPlain;
}
function sectionHasExampleMarker(html) {
return /uc-example-text|data-uc-example/.test(String(html || ""));
}
function isSectionBodyEmpty(html) {
return stripForCount(html).length === 0;
}
function hasUserBodyInput() {
var ex = extractSections(editor.getHTML());
for (var i = 0; i < fieldKeys.length; i++) {
var body = ex[fieldKeys[i]] || "";
if (sectionHasExampleMarker(body)) return false;
if (!isSectionBodyEmpty(body) && !isPlaceholderBody(body, phKeysByField[fieldKeys[i]])) {
return true;
}
}
return false;
}
function restoreExamplesIfEmptyDraft() {
if (!examplesComposeMode || placeholdersActive) return;
if (hasUserBodyInput()) return;
placeholdersActive = true;
editor.setHTML(buildInitialHtml());
count();
}
/** 본문 영역 첫 클릭 시 STAR 1~4 예시 문단만 제거(제목은 유지) */
function clearExampleContentOnActivate() {
if (!placeholdersActive) return;
placeholdersActive = false;
var empty = "<p><br></p>";
var wrapper = document.createElement("div");
wrapper.innerHTML = editor.getHTML();
var clearedById = false;
for (var i = 0; i < sectionIds.length; i++) {
var sec = wrapper.querySelector("#" + sectionIds[i]);
if (!sec) continue;
var head = sec.querySelector(":scope > h1, :scope > h2, :scope > h3, :scope > h4");
sec.innerHTML = (head ? head.outerHTML : "") + empty;
clearedById = true;
}
setTimeout(function () {
if (clearedById) {
editor.setHTML(wrapper.innerHTML);
} else {
editor.setHTML(
buildHtmlFromSections({
situation: empty,
taskGoal: empty,
actionTaken: empty,
resultOutcome: empty,
})
);
}
count();
}, 0);
}
/** @returns {{ situation: string, taskGoal: string, actionTaken: string, resultOutcome: string, ok: boolean, err?: string }} */
function extractSections(html) {
var out = { situation: "", taskGoal: "", actionTaken: "", resultOutcome: "", ok: true };
var w = document.createElement("div");
w.innerHTML = html;
var got = 0;
for (var i = 0; i < sectionIds.length; i++) {
var sec = w.querySelector("#" + sectionIds[i]);
if (sec) {
var c = sec.cloneNode(true);
var hx = c.querySelector(":scope > h1, :scope > h2, :scope > h3, :scope > h4");
if (hx) hx.remove();
out[fieldKeys[i]] = c.innerHTML.trim();
got++;
}
}
if (got === 4) {
out.ok = true;
return out;
}
var byH = extractByHeadings(w);
if (byH) {
byH.ok = true;
return byH;
}
return { situation: out.situation, taskGoal: out.taskGoal, actionTaken: out.actionTaken, resultOutcome: out.resultOutcome, ok: false, err: "split" };
}
function extractByHeadings(wrap) {
var patterns = [
{ key: "situation", re: /1\.\s*Situation/i },
{ key: "taskGoal", re: /2\.\s*Task/i },
{ key: "actionTaken", re: /3\.\s*Action/i },
{ key: "resultOutcome", re: /4\.\s*Result/i },
];
var heads = wrap.querySelectorAll("h1, h2, h3, h4");
var found = [];
for (var h = 0; h < heads.length; h++) {
var t = (heads[h].textContent || "").replace(/\s+/g, " ").trim();
for (var p = 0; p < patterns.length; p++) {
if (patterns[p].re.test(t)) {
found.push({ key: patterns[p].key, el: heads[h] });
break;
}
}
}
if (found.length < 4) {
return null;
}
var seen = {};
for (var f = 0; f < found.length; f++) {
if (seen[found[f].key]) return null;
seen[found[f].key] = true;
}
if (Object.keys(seen).length !== 4) return null;
found.sort(function (a, b) {
var po = a.el.compareDocumentPosition(b.el);
if (po & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (po & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
var res = { situation: "", taskGoal: "", actionTaken: "", resultOutcome: "" };
for (var s = 0; s < found.length; s++) {
var head = found[s].el;
var boundary = s + 1 < found.length ? found[s + 1].el : null;
var n = head.nextSibling;
var parts = [];
while (n) {
if (boundary && n === boundary) break;
if (n.nodeType === 1) {
parts.push(n.outerHTML);
}
n = n.nextSibling;
}
res[found[s].key] = parts.join("").trim();
}
return res;
}
function stripForCount(html) {
if (!html) return "";
return String(html)
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&#[0-9]+;/g, " ")
.replace(/&[a-zA-Z0-9]+;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
var edHost = document.getElementById("edUseCaseBody");
var editor = new toastui.Editor({
el: edHost,
height: "720px",
initialEditType: "wysiwyg",
previewStyle: "vertical",
hideModeSwitch: true,
useCommandShortcut: true,
usageStatistics: false,
language: "ko",
placeholder: "STAR 4구역(1~4번)이 보이지 않으면 페이지를 새로고침하세요.",
toolbarItems: [
["heading", "bold", "italic", "strike"],
["hr", "quote"],
["ul", "ol", "task", "indent", "outdent"],
["table", "link"],
["code", "codeblock"],
],
});
if (editPayload && editPayload.html) {
document.getElementById("uctitle").value = editPayload.title || "";
document.getElementById("uctags").value = editPayload.tags || "";
editor.setHTML(editPayload.html);
if (editPayload.existingThumbnails && editPayload.existingThumbnails.length) {
var h = document.getElementById("ucThumbFieldHint");
if (h) h.textContent = "새로 선택하면 목록 끝에 추가됩니다. (최대 " + maxThumbCount + "개, 개당 5MB, 1:1 권장)";
if (thumbGrid) thumbGrid.hidden = false;
}
} else {
editor.setHTML(buildInitialHtml());
}
var titleInput = document.getElementById("uctitle");
function focusTitleInput() {
if (titleInput) titleInput.focus({ preventScroll: true });
}
if (examplesComposeMode) {
edHost.addEventListener(
"mousedown",
function (ev) {
if (!placeholdersActive || ev.button !== 0) return;
if (!ev.target.closest(".toastui-editor-contents")) return;
clearExampleContentOnActivate();
},
true
);
document.addEventListener(
"mousedown",
function (ev) {
if (ev.button !== 0 || placeholdersActive) return;
if (edHost.contains(ev.target)) return;
restoreExamplesIfEmptyDraft();
},
true
);
}
focusTitleInput();
setTimeout(focusTitleInput, 0);
setTimeout(focusTitleInput, 100);
function count() {
var ex = extractSections(editor.getHTML());
var n = 0;
for (var i = 0; i < fieldKeys.length; i++) {
n += stripForCount(ex[fieldKeys[i]] || "").length;
}
if (charLine) {
charLine.textContent = n + " / " + maxTotal + "자";
}
return n;
}
editor.on("change", function () {
count();
});
count();
function countKeptExistingThumbs() {
if (!thumbGrid) return 0;
return thumbGrid.querySelectorAll(".use-case-thumb-tile[data-existing-rp]:not(.is-removed)").length;
}
function countPendingThumbs() {
return pendingThumbFiles.length;
}
function countRemainingThumbsOnSubmit() {
return countKeptExistingThumbs() + countPendingThumbs();
}
function revokePendingThumbUrls() {
for (var ui = 0; ui < pendingThumbUrls.length; ui++) {
try {
URL.revokeObjectURL(pendingThumbUrls[ui]);
} catch (e) {}
}
pendingThumbUrls = [];
}
function updateThumbGridVisibility() {
if (!thumbGrid) return;
var visible =
thumbGrid.querySelectorAll(".use-case-thumb-tile:not(.is-removed)").length > 0 ||
countPendingThumbs() > 0;
thumbGrid.hidden = !visible;
}
function renderPendingThumbTiles() {
if (!thumbGrid) return;
var oldPending = thumbGrid.querySelectorAll(".use-case-thumb-tile[data-pending-index]");
for (var oi = 0; oi < oldPending.length; oi++) {
oldPending[oi].remove();
}
revokePendingThumbUrls();
for (var pi = 0; pi < pendingThumbFiles.length; pi++) {
var file = pendingThumbFiles[pi];
var url = URL.createObjectURL(file);
pendingThumbUrls.push(url);
var fig = document.createElement("figure");
fig.className = "use-case-thumb-tile";
fig.setAttribute("data-pending-index", String(pi));
var img = document.createElement("img");
img.src = url;
img.alt = file.name || "썸네일";
img.loading = "lazy";
img.decoding = "async";
var btn = document.createElement("button");
btn.type = "button";
btn.className = "use-case-thumb-remove";
btn.setAttribute("aria-label", (file.name || "썸네일") + " 제거");
btn.title = "제거";
btn.textContent = "×";
fig.appendChild(img);
fig.appendChild(btn);
thumbGrid.appendChild(fig);
}
updateThumbGridVisibility();
}
function notifyThumbLimitPopup(addedCount, selectedCount) {
var base = "썸네일은 최대 " + maxThumbCount + "개까지 업로드할 수 있습니다.";
if (typeof addedCount === "number" && typeof selectedCount === "number" && selectedCount > addedCount && addedCount > 0) {
window.alert(base + "\n\n선택하신 " + selectedCount + "개 중 " + addedCount + "개만 추가되었습니다.");
return;
}
if (typeof addedCount === "number" && addedCount === 0) {
window.alert(base + "\n\n더 이상 추가할 수 없습니다. 기존 썸네일을 삭제한 뒤 다시 선택해 주세요.");
return;
}
window.alert(base);
}
function addPendingThumbFiles(fileList) {
if (!fileList || !fileList.length) return true;
var keptExisting = countKeptExistingThumbs();
var remaining = maxThumbCount - keptExisting - pendingThumbFiles.length;
if (remaining <= 0) {
notifyThumbLimitPopup(0, fileList.length);
return false;
}
var selectedCount = fileList.length;
var addedCount = 0;
for (var ai = 0; ai < fileList.length; ai++) {
if (addedCount >= remaining) break;
var f = fileList[ai];
if (!/^image\//.test(f.type || "")) {
msg.textContent = "썸네일은 이미지 파일만 업로드할 수 있습니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return false;
}
if (f.size > 5 * 1024 * 1024) {
msg.textContent = '"' + (f.name || "파일") + '"은 5MB 이하여야 합니다.';
msg.style.color = "#b91c1c";
msg.hidden = false;
return false;
}
pendingThumbFiles.push(f);
addedCount++;
}
if (selectedCount > remaining) {
notifyThumbLimitPopup(addedCount, selectedCount);
}
msg.hidden = true;
renderPendingThumbTiles();
return true;
}
if (thumbGrid) {
thumbGrid.addEventListener("click", function (ev) {
var btn = ev.target.closest(".use-case-thumb-remove");
if (!btn) return;
ev.preventDefault();
var tile = btn.closest(".use-case-thumb-tile");
if (!tile) return;
if (tile.hasAttribute("data-existing-rp")) {
tile.classList.add("is-removed");
updateThumbGridVisibility();
return;
}
if (tile.hasAttribute("data-pending-index")) {
var idx = parseInt(tile.getAttribute("data-pending-index"), 10);
if (!Number.isNaN(idx) && idx >= 0 && idx < pendingThumbFiles.length) {
pendingThumbFiles.splice(idx, 1);
renderPendingThumbTiles();
}
}
});
}
if (thumb) {
thumb.addEventListener("change", function () {
if (!thumb.files || !thumb.files.length) return;
addPendingThumbFiles(thumb.files);
thumb.value = "";
});
}
if (filesIn && fileList) {
filesIn.addEventListener("change", function () {
fileList.innerHTML = "";
if (!filesIn.files || !filesIn.files.length) {
fileList.hidden = true;
return;
}
if (filesIn.files.length > maxAttachCount) {
msg.textContent = "첨부 파일은 최대 " + maxAttachCount + "개만 선택할 수 있습니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
filesIn.value = "";
fileList.hidden = true;
return;
}
var keptAttach = 0;
var attachCbs = document.querySelectorAll(".js-uc-remove-attach");
for (var ai = 0; ai < attachCbs.length; ai++) {
if (!attachCbs[ai].checked) keptAttach++;
}
if (keptAttach + filesIn.files.length > maxAttachCount) {
msg.textContent =
"첨부 파일은 최대 " +
maxAttachCount +
"개입니다. 기존 첨부를 제거한 뒤 새 파일을 선택해 주세요.";
msg.style.color = "#b91c1c";
msg.hidden = false;
filesIn.value = "";
fileList.hidden = true;
return;
}
msg.hidden = true;
fileList.hidden = false;
for (var k = 0; k < filesIn.files.length; k++) {
var li = document.createElement("li");
li.textContent = filesIn.files[k].name;
fileList.appendChild(li);
}
});
}
if (form) {
form.addEventListener("submit", function (ev) {
ev.preventDefault();
var ex = extractSections(editor.getHTML());
if (ex && ex.ok === false) {
msg.textContent =
"1~4번 구역(#uc-situation 등)을 찾을 수 없습니다. 구역을 삭제하지 말고, 제목(1. Situation …)이 보이는지 확인해 주세요.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
if (count() > maxTotal) {
msg.textContent = "본문(4개 섹션 합계)은 " + maxTotal + "자 이하여야 합니다. (보이는 텍스트 기준)";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
for (var v = 0; v < fieldKeys.length; v++) {
var fk = fieldKeys[v];
var body = ex[fk];
if (
isSectionBodyEmpty(body) ||
isPlaceholderBody(body, phKeysByField[fk]) ||
sectionHasExampleMarker(body)
) {
msg.textContent =
(v + 1) +
"번 구역에 내용이 없습니다. 예시는 본문 영역 클릭 시 자동으로 지워집니다. 본인 내용을 입력해 주세요. (빈 줄·서식만이면 제출할 수 없습니다.)";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
}
if (!editPayload) {
if (countPendingThumbs() < 1) {
msg.textContent = "썸네일 이미지를 1개 이상 선택해 주세요.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
} else if (countRemainingThumbsOnSubmit() < 1) {
msg.textContent = "썸네일은 1개 이상 남겨야 합니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
if (countRemainingThumbsOnSubmit() > maxThumbCount) {
notifyThumbLimitPopup();
msg.textContent = "썸네일은 최대 " + maxThumbCount + "개까지 등록할 수 있습니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
for (var tv = 0; tv < pendingThumbFiles.length; tv++) {
if (pendingThumbFiles[tv].size > 5 * 1024 * 1024) {
msg.textContent = "썸네일 " + (tv + 1) + "번째 파일은 5MB 이하여야 합니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
}
var isEdit = editPayload && editPayload.id;
var fd = new FormData();
fd.append("title", document.getElementById("uctitle").value.trim());
fd.append("situation", ex.situation);
fd.append("taskGoal", ex.taskGoal);
fd.append("actionTaken", ex.actionTaken);
fd.append("resultOutcome", ex.resultOutcome);
fd.append("tags", document.getElementById("uctags").value);
for (var t = 0; t < pendingThumbFiles.length; t++) {
fd.append("thumbnail", pendingThumbFiles[t]);
}
var keptAttachSubmit = 0;
var attachCbsSubmit = document.querySelectorAll(".js-uc-remove-attach");
for (var asi = 0; asi < attachCbsSubmit.length; asi++) {
if (!attachCbsSubmit[asi].checked) keptAttachSubmit++;
}
var newAttachCount = filesIn && filesIn.files ? filesIn.files.length : 0;
if (keptAttachSubmit + newAttachCount > maxAttachCount) {
msg.textContent =
"첨부 파일은 최대 " +
maxAttachCount +
"개입니다. 기존 첨부를 제거한 뒤 새 파일을 선택해 주세요.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
if (filesIn && filesIn.files) {
for (var f = 0; f < filesIn.files.length; f++) {
if (filesIn.files[f].size > 20 * 1024 * 1024) {
msg.textContent = "첨부 파일은 20MB 이하여야 합니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
fd.append("attachments", filesIn.files[f]);
}
}
if (isEdit) {
var rmThumb = [];
if (thumbGrid) {
var removedTiles = thumbGrid.querySelectorAll(".use-case-thumb-tile[data-existing-rp].is-removed");
for (var ti = 0; ti < removedTiles.length; ti++) {
try {
var trp = decodeURIComponent(removedTiles[ti].getAttribute("data-existing-rp") || "");
if (trp) rmThumb.push(trp);
} catch (e) {}
}
}
if (rmThumb.length) {
fd.append("removeThumbnailPaths", JSON.stringify(rmThumb));
}
var rm = [];
var cbs = document.querySelectorAll(".js-uc-remove-attach");
for (var ci = 0; ci < cbs.length; ci++) {
if (cbs[ci].checked) {
try {
var rp = decodeURIComponent(cbs[ci].getAttribute("data-rp") || "");
if (rp) rm.push(rp);
} catch (e) {}
}
}
if (rm.length) {
fd.append("removeAttachmentPaths", JSON.stringify(rm));
}
}
msg.hidden = true;
submitBtn.disabled = true;
var url = isEdit ? "/api/ai-use-case-submissions/" + encodeURIComponent(editPayload.id) : "/api/ai-use-case-submissions";
var method = isEdit ? "PUT" : "POST";
fetch(url, { method: method, body: fd, credentials: "same-origin" })
.then(function (r) {
return r.json().then(function (j) {
return { ok: r.ok, j: j, status: r.status };
});
})
.then(function (o) {
if (o.ok && o.j && o.j.ok) {
if (isEdit && o.j && o.j.id) {
window.location.href = "/ai-cases/submit/" + encodeURIComponent(o.j.id) + "?updated=1";
} else {
window.location.href = "/ai-cases?submitted=1";
}
return;
}
msg.textContent = (o.j && o.j.error) || "저장에 실패했습니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
})
.catch(function () {
msg.textContent = "요청에 실패했습니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
})
.finally(function () {
submitBtn.disabled = false;
});
});
}
})();
</script>
</body>
</html>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<%- include('partials/favicon') %>
<title>AI 성공 사례 관리 - XAVIS</title>
<title>AI 활용 사례 관리 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
<style>
.visually-hidden {
@@ -37,12 +37,12 @@
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
<div class="content-area">
<header class="topbar">
<h1>AI 성공 사례 관리</h1>
<h1>AI 활용 사례 관리</h1>
<a class="top-action-link" href="/ai-cases">목록(사용자 화면)</a>
</header>
<main class="container">
<section class="panel">
<p class="breadcrumb"><a href="/ai-cases">AI 성공 사례</a> &gt; 관리자</p>
<p class="breadcrumb"><a href="/ai-cases">AI 활용 사례</a> &gt; 관리자</p>
<p class="muted admin-hint">슬러그는 URL에 쓰이므로 영문·숫자·하이픈만 사용하세요. <strong>원문 PDF 경로</strong>(<code>/public/...</code>)가 있으면 상세는 PDF 페이지 이미지로 보여 주며, 이때 <strong>본문(Markdown)은 비워도 됩니다</strong>. PDF가 없을 때는 본문이 필수입니다.</p>
</section>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<%- include('partials/favicon') %>
<title>AI 성공 사례 - XAVIS</title>
<title>AI 활용 사례 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
@@ -16,12 +16,22 @@
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
<div class="content-area">
<header class="topbar">
<h1>AI 성공 사례</h1>
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
<a class="top-action-link" href="/ai-cases/write">사례 등록·관리</a>
<% } %>
<h1>AI 활용 사례</h1>
<div class="topbar-actions">
<% if (typeof canComposeUseCase !== 'undefined' && canComposeUseCase) { %>
<a class="top-action-link" href="/ai-cases/compose" title="글쓰기"
><span class="top-action-icon" aria-hidden="true">✎</span> 글쓰기</a
>
<% } %>
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
<a class="top-action-link" href="/ai-cases/write">사례 등록·관리</a>
<% } %>
</div>
</header>
<main class="container">
<% if (typeof submittedOk !== 'undefined' && submittedOk) { %>
<p class="form-message" style="margin-bottom: 12px; color: #047857">제출이 저장되었습니다.</p>
<% } %>
<% if (typeof successStoryDetailAllowed !== 'undefined' && !successStoryDetailAllowed) { %>
<p class="chat-api-warning" style="margin-bottom: 16px">
로그인 후 이용 가능합니다.

View File

@@ -12,6 +12,7 @@
class="<%= (typeof aiExploreDevGuestRestricted !== 'undefined' && aiExploreDevGuestRestricted) ? 'ai-explore-page ai-explore-dev-guest' : 'ai-explore-page' %>"
>
<% var aiGuestDev = typeof aiExploreDevGuestRestricted !== 'undefined' && aiExploreDevGuestRestricted; %>
<% var _tclOk = typeof taskChecklistMenuAllowed !== 'undefined' && taskChecklistMenuAllowed; %>
<% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
@@ -42,21 +43,31 @@
aria-label="AI 서비스 제목·설명 검색"
<% if (aiGuestDev) { %>disabled aria-disabled="true"<% } %>
/>
<div class="ai-type-filters" role="radiogroup" aria-label="AI 타입 필터">
<label class="ai-type-filter-option">
<input type="radio" name="aiTypeFilter" value="all" checked <% if (aiGuestDev) { %>disabled aria-disabled="true"<% } %> />
<span>전체</span>
</label>
<label class="ai-type-filter-option">
<input type="radio" name="aiTypeFilter" value="general" <% if (aiGuestDev) { %>disabled aria-disabled="true"<% } %> />
<span>일반</span>
</label>
<label class="ai-type-filter-option">
<input type="radio" name="aiTypeFilter" value="xscan" <% if (aiGuestDev) { %>disabled aria-disabled="true"<% } %> />
<span>XScan</span>
</label>
<label class="ai-type-filter-option">
<input type="radio" name="aiTypeFilter" value="fscan" <% if (aiGuestDev) { %>disabled aria-disabled="true"<% } %> />
<span>FScan</span>
</label>
</div>
</form>
</section>
<section class="panel">
<h2>AI 서비스</h2>
<div class="ai-card-grid">
<a href="/ai-explore/prompts" class="ai-card ai-card-link">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
<h3>프롬프트</h3>
<p>업무별 기본 프롬프트를 모아 두고, 복사해 바로 활용할 수 있는 라이브러리입니다.</p>
<div class="tag-row"><span class="tag-chip">#프롬프트</span></div>
</a>
<% if (aiGuestDev) { %>
<article class="ai-card ai-card-disabled" aria-disabled="true">
<article class="ai-card ai-card-disabled" aria-disabled="true" data-ai-type="general">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
@@ -65,7 +76,7 @@
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#회의록</span></div>
</article>
<% } else { %>
<a href="/ai-explore/meeting-minutes" class="ai-card ai-card-link">
<a href="/ai-explore/meeting-minutes" class="ai-card ai-card-link" data-ai-type="general">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
@@ -74,8 +85,9 @@
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#회의록</span></div>
</a>
<% } %>
<% if (_tclOk) { %>
<% if (aiGuestDev) { %>
<article class="ai-card ai-card-disabled" aria-disabled="true">
<article class="ai-card ai-card-disabled" aria-disabled="true" data-ai-type="general">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
@@ -84,7 +96,7 @@
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#체크리스트</span></div>
</article>
<% } else { %>
<a href="/ai-explore/task-checklist" class="ai-card ai-card-link">
<a href="/ai-explore/task-checklist" class="ai-card ai-card-link" data-ai-type="general">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
@@ -93,6 +105,45 @@
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#체크리스트</span></div>
</a>
<% } %>
<% } %>
<% if (aiGuestDev) { %>
<article class="ai-card ai-card-disabled" aria-disabled="true" data-ai-type="general">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
<h3>일반 채팅</h3>
<p>ChatGPT를 이용한 채팅 서비스입니다.</p>
<div class="tag-row"><span class="tag-chip">#질의응답.</span><span class="tag-chip">ChatGPT</span></div>
</article>
<% } else { %>
<a href="/ai-explore/chat" class="ai-card ai-card-link" data-ai-type="general">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
<h3>일반 채팅</h3>
<p>ChatGPT를 이용한 채팅 서비스입니다.</p>
<div class="tag-row"><span class="tag-chip">#질의응답.</span><span class="tag-chip">ChatGPT</span></div>
</a>
<% } %>
<% if (aiGuestDev) { %>
<article class="ai-card ai-card-disabled" aria-disabled="true" data-ai-type="fscan">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
<h3>FSCAN 조사각 선정도우미</h3>
<p>검사 대상물 치수(H/W) 기반으로 FSCAN 시리즈 모델 선정을 돕는 도구입니다.</p>
<div class="tag-row"><span class="tag-chip">#FSCAN</span><span class="tag-chip">#선정도우미</span></div>
</article>
<% } else { %>
<a href="/ai-explore/fscan" class="ai-card ai-card-link" data-ai-type="fscan">
<div class="ai-card-header">
<span class="status-chip public">공개중</span>
</div>
<h3>FSCAN 조사각 선정도우미</h3>
<p>검사 대상물 치수(H/W) 기반으로 FSCAN 시리즈 모델 선정을 돕는 도구입니다.</p>
<div class="tag-row"><span class="tag-chip">#FSCAN</span><span class="tag-chip">#선정도우미</span></div>
</a>
<% } %>
</div>
</section>
</main>
@@ -109,6 +160,7 @@
if (devGuest) return;
var cards = grid.querySelectorAll(".ai-card");
var typeInputs = form.querySelectorAll('input[name="aiTypeFilter"]');
function cardTitleDescriptionText(el) {
var parts = [];
@@ -122,9 +174,14 @@
function applyFilter() {
var q = (input.value || "").trim().toLowerCase();
var checkedType = form.querySelector('input[name="aiTypeFilter"]:checked');
var selectedType = checkedType ? String(checkedType.value || "all") : "all";
cards.forEach(function (el) {
var text = cardTitleDescriptionText(el);
var show = !q || text.indexOf(q) !== -1;
var cardType = String(el.getAttribute("data-ai-type") || "general").toLowerCase();
var textMatched = !q || text.indexOf(q) !== -1;
var typeMatched = selectedType === "all" || cardType === selectedType;
var show = textMatched && typeMatched;
el.hidden = !show;
el.setAttribute("aria-hidden", show ? "false" : "true");
});
@@ -132,11 +189,16 @@
input.addEventListener("input", applyFilter);
input.addEventListener("search", applyFilter);
typeInputs.forEach(function (el) {
el.addEventListener("change", applyFilter);
});
form.addEventListener("submit", function (e) {
e.preventDefault();
applyFilter();
});
applyFilter();
})();
</script>
</body>

33
views/ai-fscan.ejs Normal file
View File

@@ -0,0 +1,33 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<%- include('partials/favicon') %>
<title>FSCAN 조사각 선정도우미 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body class="ai-fscan-page">
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
<div class="content-area">
<header class="topbar">
<h1>FSCAN 조사각 선정도우미</h1>
<a class="top-action-link" href="/ai-explore">AI 목록</a>
</header>
<main class="container container-ai-full">
<a href="/ai-explore" class="prompts-back" aria-label="AI 탐색으로 돌아가기">← AI</a>
<section class="panel">
<p class="subtitle">검사 대상물의 치수(H/W) 기반으로 FSCAN 시리즈 모델을 빠르게 선정합니다.</p>
<iframe
class="fscan-embed-frame"
src="/public/resources/fscan/fscan-selector-v1.html"
title="FSCAN 조사각 선정도우미"
loading="lazy"
></iframe>
</section>
</main>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,308 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<%- include('partials/favicon') %>
<title><%= title %> - AI 활용 사례 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
<div class="content-area">
<main class="viewer-wrap ai-case-viewer">
<a href="/ai-cases" class="back-link">← AI 활용 사례로 돌아가기</a>
<% if (typeof updatedOk !== "undefined" && updatedOk) { %>
<p class="form-message" style="margin: 0 0 12px; color: #047857">수정이 저장되었습니다.</p>
<% } %>
<h1><%= title %></h1>
<p class="description">일반 제출 사례 · <%= author %><% if (submitterEmail) { %> (<%= submitterEmail %>)<% } %></p>
<div class="ppt-tools ai-case-ppt-tools">
<div class="ai-case-tools-meta">
<span>조회 <b id="submissionViewCount"><%= typeof viewCount !== "undefined" ? viewCount : 0 %></b></span>
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
<button
type="button"
class="prompt-lib-like ai-case-submission-like<%= typeof myLike !== "undefined" && myLike ? " is-liked" : "" %>"
id="submissionLikeBtn"
title="<%= typeof opsUserEmail !== "undefined" && opsUserEmail ? "좋아요" : "로그인 후 사용" %>"
<% if (typeof opsUserEmail === "undefined" || !opsUserEmail) { %>disabled<% } %>
>
<span class="prompt-lib-like-icon" aria-hidden="true">♥</span>
<span id="submissionLikeCount"><%= typeof likeCount !== "undefined" ? likeCount : 0 %></span>
</button>
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
<span><%= typeof department !== "undefined" ? department : "일반 제출" %> · <%= author %><% if (publishedAt) { %> · <%= publishedAt %><% } %></span>
<% if (typeof canEditSubmission !== "undefined" && canEditSubmission && typeof submissionId !== "undefined" && submissionId) { %>
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
<a href="/ai-cases/compose?edit=<%= encodeURIComponent(submissionId) %>" class="ai-case-inline-link">수정하기</a>
<% } %>
</div>
</div>
<% if ((tags || []).length) { %>
<div class="tag-row ai-case-tag-row">
<% (tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
</div>
<% } %>
<section class="slide-list">
<article class="slide-card">
<header>
<h2>1. Situation (배경)</h2>
</header>
<div class="chat-md-body success-detail-body-in-card success-submission-html"><%- situationHtml %></div>
</article>
<article class="slide-card">
<header>
<h2>2. Task (과제/목표)</h2>
</header>
<div class="chat-md-body success-detail-body-in-card success-submission-html"><%- taskHtml %></div>
</article>
<article class="slide-card">
<header>
<h2>3. Action (행동)</h2>
</header>
<div class="chat-md-body success-detail-body-in-card success-submission-html"><%- actionHtml %></div>
</article>
<article class="slide-card">
<header>
<h2>4. Result (결과)</h2>
</header>
<div class="chat-md-body success-detail-body-in-card success-submission-html"><%- resultHtml %></div>
</article>
</section>
<% if (typeof thumbnailImages !== "undefined" && thumbnailImages && thumbnailImages.length) { %>
<section class="success-submission-cover-section" id="submissionScreenshots">
<div class="success-submission-cover-header">
<div>
<h2 class="success-submission-cover-title">실행 화면</h2>
<p class="success-submission-cover-hint">이미지를 클릭하면 원본 크기로 자세히 볼 수 있습니다.</p>
</div>
<% if (thumbnailImages.length > 1) { %>
<div class="slide-layout-toggle" role="group" aria-label="실행 화면 단 수">
<span class="slide-layout-toggle-label">보기</span>
<button type="button" class="slide-layout-btn js-screenshot-cols-btn" data-cols="1" aria-pressed="false">1단</button>
<button type="button" class="slide-layout-btn js-screenshot-cols-btn is-active" data-cols="2" aria-pressed="true">2단</button>
<button type="button" class="slide-layout-btn js-screenshot-cols-btn" data-cols="4" aria-pressed="false">4단</button>
</div>
<% } %>
</div>
<div class="success-submission-cover-grid success-submission-cover-grid--cols-2" id="submissionScreenshotGrid">
<% thumbnailImages.forEach(function (t, idx) { var tfn = (t.originalName || "실행 화면").replace(/[<>"]/g, ""); %>
<figure class="success-submission-cover-item">
<button
type="button"
class="success-submission-screenshot-btn js-screenshot-open"
data-screenshot-index="<%= idx %>"
aria-label="<%= tfn %> 크게 보기"
>
<img src="<%= t.relativePath %>" alt="<%= tfn %>" loading="lazy" decoding="async" />
</button>
<figcaption class="success-submission-screenshot-caption"><%= tfn %></figcaption>
</figure>
<% }); %>
</div>
</section>
<% } else if (coverImageUrl) { %>
<section class="success-submission-cover-section success-submission-cover--single" id="submissionScreenshots">
<h2 class="success-submission-cover-title">실행 화면</h2>
<p class="success-submission-cover-hint">이미지를 클릭하면 원본 크기로 자세히 볼 수 있습니다.</p>
<figure class="success-submission-cover-item">
<button type="button" class="success-submission-screenshot-btn js-screenshot-open" data-screenshot-index="0" aria-label="실행 화면 크게 보기">
<img src="<%= coverImageUrl %>" alt="실행 화면" loading="lazy" decoding="async" />
</button>
</figure>
</section>
<% } %>
<% if (attachments && attachments.length) { %>
<section class="slide-card" style="margin-top: 12px">
<header>
<h2>첨부 파일</h2>
</header>
<ul class="success-submission-attach-list">
<% attachments.forEach((a) => { %>
<li>
<a href="<%= a.relativePath %>" target="_blank" rel="noopener noreferrer"><%= a.originalName || "파일" %></a>
</li>
<% }) %>
</ul>
</section>
<% } %>
</main>
</div>
</div>
<div class="ai-case-lightbox" id="aiCaseLightbox" hidden role="dialog" aria-modal="true" aria-labelledby="aiCaseLightboxCaption">
<button type="button" class="ai-case-lightbox__close" id="aiCaseLightboxClose" aria-label="닫기">×</button>
<button type="button" class="ai-case-lightbox__nav ai-case-lightbox__nav--prev" id="aiCaseLightboxPrev" aria-label="이전 이미지"></button>
<button type="button" class="ai-case-lightbox__nav ai-case-lightbox__nav--next" id="aiCaseLightboxNext" aria-label="다음 이미지"></button>
<div class="ai-case-lightbox__dialog">
<img class="ai-case-lightbox__img" id="aiCaseLightboxImg" alt="" />
<p class="ai-case-lightbox__caption" id="aiCaseLightboxCaption"></p>
<p class="ai-case-lightbox__meta" id="aiCaseLightboxMeta"></p>
</div>
</div>
<script>
(function () {
var screenshotSection = document.getElementById("submissionScreenshots");
if (screenshotSection) {
var openButtons = screenshotSection.querySelectorAll(".js-screenshot-open");
var lightbox = document.getElementById("aiCaseLightbox");
var lightboxImg = document.getElementById("aiCaseLightboxImg");
var lightboxCaption = document.getElementById("aiCaseLightboxCaption");
var lightboxMeta = document.getElementById("aiCaseLightboxMeta");
var lightboxClose = document.getElementById("aiCaseLightboxClose");
var lightboxPrev = document.getElementById("aiCaseLightboxPrev");
var lightboxNext = document.getElementById("aiCaseLightboxNext");
var slides = [];
var activeIndex = 0;
var lastFocus = null;
openButtons.forEach(function (btn) {
var img = btn.querySelector("img");
if (!img) return;
slides.push({
src: img.getAttribute("src") || "",
alt: img.getAttribute("alt") || "실행 화면",
});
});
var screenshotGrid = document.getElementById("submissionScreenshotGrid");
var colButtons = screenshotSection.querySelectorAll(".js-screenshot-cols-btn");
var colsStorageKey = "aiCaseScreenshotColumns";
function applyScreenshotCols(n) {
if (!screenshotGrid) return;
screenshotGrid.classList.remove(
"success-submission-cover-grid--cols-1",
"success-submission-cover-grid--cols-2",
"success-submission-cover-grid--cols-4",
);
screenshotGrid.classList.add("success-submission-cover-grid--cols-" + n);
colButtons.forEach(function (btn) {
var on = btn.getAttribute("data-cols") === String(n);
btn.setAttribute("aria-pressed", on ? "true" : "false");
btn.classList.toggle("is-active", on);
});
try {
localStorage.setItem(colsStorageKey, String(n));
} catch (e) {}
}
if (screenshotGrid && colButtons.length) {
var savedCols = null;
try {
savedCols = localStorage.getItem(colsStorageKey);
} catch (e) {}
var initialCols = savedCols === "1" || savedCols === "2" || savedCols === "4" ? savedCols : "2";
applyScreenshotCols(initialCols);
colButtons.forEach(function (btn) {
btn.addEventListener("click", function () {
var n = btn.getAttribute("data-cols");
if (n === "1" || n === "2" || n === "4") applyScreenshotCols(n);
});
});
}
function renderLightbox(index) {
if (!slides.length || !lightboxImg) return;
activeIndex = (index + slides.length) % slides.length;
var slide = slides[activeIndex];
lightboxImg.src = slide.src;
lightboxImg.alt = slide.alt;
if (lightboxCaption) lightboxCaption.textContent = slide.alt;
if (lightboxMeta) {
lightboxMeta.textContent = slides.length > 1 ? activeIndex + 1 + " / " + slides.length : "";
}
if (lightboxPrev) lightboxPrev.hidden = slides.length <= 1;
if (lightboxNext) lightboxNext.hidden = slides.length <= 1;
}
function openLightbox(index) {
if (!lightbox || !slides.length) return;
lastFocus = document.activeElement;
renderLightbox(index);
lightbox.hidden = false;
document.body.style.overflow = "hidden";
if (lightboxClose) lightboxClose.focus();
}
function closeLightbox() {
if (!lightbox) return;
lightbox.hidden = true;
document.body.style.overflow = "";
if (lightboxImg) lightboxImg.removeAttribute("src");
if (lastFocus && typeof lastFocus.focus === "function") lastFocus.focus();
}
openButtons.forEach(function (btn) {
btn.addEventListener("click", function () {
var idx = parseInt(btn.getAttribute("data-screenshot-index") || "0", 10);
openLightbox(Number.isFinite(idx) ? idx : 0);
});
});
if (lightboxClose) lightboxClose.addEventListener("click", closeLightbox);
if (lightboxPrev) {
lightboxPrev.addEventListener("click", function () {
renderLightbox(activeIndex - 1);
});
}
if (lightboxNext) {
lightboxNext.addEventListener("click", function () {
renderLightbox(activeIndex + 1);
});
}
if (lightbox) {
lightbox.addEventListener("click", function (ev) {
if (ev.target === lightbox) closeLightbox();
});
}
document.addEventListener("keydown", function (ev) {
if (!lightbox || lightbox.hidden) return;
if (ev.key === "Escape") closeLightbox();
if (ev.key === "ArrowLeft" && lightboxPrev && !lightboxPrev.hidden) renderLightbox(activeIndex - 1);
if (ev.key === "ArrowRight" && lightboxNext && !lightboxNext.hidden) renderLightbox(activeIndex + 1);
});
}
var likeBtn = document.getElementById("submissionLikeBtn");
var likeCountEl = document.getElementById("submissionLikeCount");
var submissionId = <%- JSON.stringify(typeof submissionId !== "undefined" ? submissionId : null) %>;
if (!likeBtn || !submissionId) return;
likeBtn.addEventListener("click", function () {
if (likeBtn.disabled) return;
likeBtn.disabled = true;
fetch("/api/ai-use-case-submissions/" + encodeURIComponent(submissionId) + "/like/toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: "{}",
})
.then(function (r) {
return r.json().then(function (j) {
return { ok: r.ok, j: j };
});
})
.then(function (o) {
if (o.ok && o.j && o.j.ok) {
if (likeCountEl) likeCountEl.textContent = String(o.j.likeCount || 0);
likeBtn.classList.toggle("is-liked", !!o.j.liked);
} else if (o.j && o.j.error) {
alert(o.j.error);
}
})
.catch(function () {
alert("좋아요 처리에 실패했습니다.");
})
.finally(function () {
likeBtn.disabled = false;
});
});
})();
</script>
</body>
</html>

View File

@@ -9,51 +9,52 @@
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'chat' }) %>
<%- include('partials/nav', { activeMenu: (typeof activeMenu !== 'undefined' ? activeMenu : 'ai-explore') }) %>
<div class="content-area chat-area">
<header class="topbar">
<h1>채팅</h1>
</header>
<main class="chat-main">
<p id="chatApiWarning" class="chat-api-warning" hidden>
<strong>API 키 없음.</strong> 프로젝트 루트 <code>.env</code>에 <code>OPENAI_API_KEY</code>를 설정한 뒤 서버를 재시작하세요.
</p>
<main
id="chatMain"
class="chat-main"
data-chat-gpt-allowed="<%= chatGptAllowed ? '1' : '0' %>"
data-ops-state="<%= (typeof opsState !== 'undefined' ? String(opsState) : 'DEV') %>"
data-admin-mode="<%= adminMode ? '1' : '0' %>"
data-ops-user-email="<%= (typeof opsUserEmail !== 'undefined' && opsUserEmail) ? '1' : '0' %>"
>
<p id="chatApiWarning" class="chat-api-warning" hidden></p>
<p id="chatGateNotice" class="chat-api-warning" hidden></p>
<div class="chat-messages" id="chatMessages">
<div class="chat-welcome" id="chatWelcome">
<h2>안녕하세요, 오늘 무엇을 도와드릴까요?</h2>
<p>AI와 대화하며 업무를 효율적으로 처리해보세요.</p>
<p>사내 AI 챗봇으로 답변합니다.</p>
</div>
</div>
<div class="chat-input-wrap">
<form class="chat-form" id="chatForm">
<select id="chatModel" class="chat-model-select" title="채팅 모델">
<option value="gpt-5-mini" selected>gpt-5-mini</option>
<option value="gpt-5.4">gpt-5.4</option>
</select>
<textarea id="chatInput" placeholder="무엇이든 물어보세요" rows="1"></textarea>
<button type="submit" id="sendBtn" class="chat-send-btn" title="전송">↑</button>
</form>
<p class="chat-disclaimer">자비스는 실수할 수 있습니다. 중요한 정보는 재차 확인하세요.</p>
<p class="chat-disclaimer">AI 답변은 실수할 수 있습니다. 중요한 정보는 원문 규정과 함께 확인하세요.</p>
</div>
</main>
</div>
</div>
<script src="/vendor/marked/marked.umd.js"></script>
<script src="/vendor/dompurify/purify.min.js"></script>
<script>
(function() {
var chatGptAllowed = <%= JSON.stringify(!!chatGptAllowed) %>;
var opsState = <%- JSON.stringify(typeof opsState !== 'undefined' ? opsState : 'DEV') %>;
var adminMode = <%= JSON.stringify(!!adminMode) %>;
var opsUserEmail = <%= JSON.stringify(!!(typeof opsUserEmail !== 'undefined' && opsUserEmail)) %>;
(function () {
const chatMain = document.getElementById('chatMain');
var chatGptAllowed = !!(chatMain && chatMain.dataset.chatGptAllowed === '1');
var opsState = (chatMain && chatMain.dataset.opsState) || 'DEV';
var adminMode = !!(chatMain && chatMain.dataset.adminMode === '1');
var opsUserEmail = !!(chatMain && chatMain.dataset.opsUserEmail === '1');
const messagesEl = document.getElementById('chatMessages');
const welcomeEl = document.getElementById('chatWelcome');
const form = document.getElementById('chatForm');
const input = document.getElementById('chatInput');
const sendBtn = document.getElementById('sendBtn');
const modelSelect = document.getElementById('chatModel');
let conversationHistory = [];
function applyChatGptGate(allowed) {
@@ -65,7 +66,6 @@
}
if (input) input.disabled = true;
if (sendBtn) sendBtn.disabled = true;
if (modelSelect) modelSelect.disabled = true;
if (notice) {
notice.hidden = false;
if (opsState === 'DEV' && !adminMode) {
@@ -78,19 +78,6 @@
}
}
applyChatGptGate(chatGptAllowed);
fetch('/api/chat/config')
.then(function(r) { return r.json(); })
.then(function(cfg) {
var w = document.getElementById('chatApiWarning');
if (w && cfg && !cfg.configured) w.hidden = false;
if (cfg && typeof cfg.chatGptAllowed === 'boolean') {
applyChatGptGate(cfg.chatGptAllowed);
}
})
.catch(function() {});
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
@@ -126,7 +113,6 @@
}
}
/** 마크다운 → HTML 후 DOMPurify로 정제 (marked 출력은 신뢰하지 않음) */
function renderAssistantMarkdown(text) {
configureChatMarkdown();
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
@@ -162,7 +148,11 @@
const inner = document.createElement('div');
inner.className = 'chat-msg-content chat-md-body';
inner.innerHTML =
'<span class="chat-typing-dots" aria-label="응답 생성 중"><span class="chat-typing-dot">·</span><span class="chat-typing-dot">·</span><span class="chat-typing-dot">·</span></span>';
'<span class="chat-progress-indicator" role="status" aria-live="polite" aria-label="응답 중">' +
'<span class="chat-progress-text">응답 중</span>' +
'<span class="chat-progress-dots" aria-hidden="true"><span></span><span></span><span></span></span>' +
'<span class="chat-progress-track" aria-hidden="true"><span class="chat-progress-fill"></span></span>' +
'</span>';
div.appendChild(inner);
const statusEl = document.createElement('div');
statusEl.className = 'chat-status-line';
@@ -189,24 +179,35 @@
ol.className = 'chat-sources-list';
for (let i = 0; i < items.length; i++) {
const item = items[i];
const url = item && item.url;
if (!url || typeof url !== 'string') continue;
let href;
try {
const u = new URL(url);
if (u.protocol !== 'http:' && u.protocol !== 'https:') continue;
href = u.href;
} catch (e) {
continue;
}
const li = document.createElement('li');
const a = document.createElement('a');
a.href = href;
a.target = '_blank';
a.rel = 'noopener noreferrer';
const t = item.title && String(item.title).trim();
a.textContent = t || href;
li.appendChild(a);
const ex = item.excerpt && String(item.excerpt).trim();
const url = item && typeof item.url === 'string' ? item.url.trim() : '';
let href = '';
if (url) {
try {
const u = new URL(url);
if (u.protocol === 'http:' || u.protocol === 'https:') {
href = u.href;
}
} catch (e) {}
}
if (href) {
const a = document.createElement('a');
a.href = href;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = t || href;
li.appendChild(a);
} else {
li.textContent = t || '출처';
}
if (ex) {
const exDiv = document.createElement('div');
exDiv.className = 'chat-source-excerpt';
exDiv.textContent = ex;
li.appendChild(exDiv);
}
ol.appendChild(li);
}
if (ol.children.length) {
@@ -220,12 +221,27 @@
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function extractApiErrorMessage(payload, fallback) {
if (!payload) return fallback || '요청 실패';
if (typeof payload === 'string') return payload;
if (typeof payload.error === 'string' && payload.error.trim()) return payload.error.trim();
if (payload.error && typeof payload.error === 'object') {
if (typeof payload.error.message === 'string' && payload.error.message.trim()) {
return payload.error.message.trim();
}
if (typeof payload.error.error === 'string' && payload.error.error.trim()) {
return payload.error.error.trim();
}
}
if (typeof payload.message === 'string' && payload.message.trim()) return payload.message.trim();
return fallback || '요청 실패';
}
function setLoading(loading) {
sendBtn.disabled = loading;
sendBtn.textContent = loading ? '...' : '↑';
sendBtn.textContent = loading ? '' : '↑';
sendBtn.classList.toggle('is-busy', loading);
sendBtn.setAttribute('aria-busy', loading ? 'true' : 'false');
if (modelSelect) modelSelect.disabled = loading;
if (input) input.disabled = loading;
}
@@ -257,7 +273,7 @@
throw new Error(obj.error || '스트림 오류');
}
if (obj.type === 'status' && obj.phase === 'web_search' && body.statusEl) {
body.statusEl.textContent = '웹 검색 중';
body.statusEl.textContent = '웹 검색 중...';
body.statusEl.hidden = false;
messagesEl.scrollTop = messagesEl.scrollHeight;
}
@@ -283,6 +299,31 @@
return fullText;
}
applyChatGptGate(chatGptAllowed);
fetch('/api/chat/config')
.then(function(r) { return r.json(); })
.then(function(cfg) {
var w = document.getElementById('chatApiWarning');
if (!cfg || !cfg.configured) {
if (w) {
w.hidden = false;
w.innerHTML = '<strong>OpenAI API 연결 없음.</strong> 서버의 <code>OPENAI_API_KEY</code> 설정을 확인해 주세요.';
}
return;
}
if (cfg.error && w) {
w.hidden = false;
w.innerHTML = '<strong>채팅 오류.</strong> ' + escapeHtml(cfg.error);
} else if (w) {
w.hidden = true;
}
if (cfg && typeof cfg.chatGptAllowed === 'boolean') {
applyChatGptGate(cfg.chatGptAllowed);
}
})
.catch(function() {});
form.addEventListener('submit', async function(e) {
e.preventDefault();
if (!chatGptAllowed) return;
@@ -298,20 +339,17 @@
setLoading(true);
try {
const model = document.getElementById('chatModel').value;
const res = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: model, messages: conversationHistory.slice(-20) })
body: JSON.stringify({ messages: conversationHistory.slice(-20) })
});
if (!res.ok) {
const errData = await res.json().catch(function() { return {}; });
body.bubble.remove();
addMessage('assistant', '오류: ' + (errData.error || res.statusText));
addMessage('assistant', '오류: ' + extractApiErrorMessage(errData, res.statusText || '요청 실패'));
return;
}
const fullReply = await readSseStream(res, body);
body.bubble.classList.remove('chat-msg-streaming');
if (!fullReply.trim()) {

View File

@@ -145,7 +145,7 @@
<p class="login-lead">회사 이메일로 본인 확인 후 서비스를 이용할 수 있습니다.</p>
<div class="login-steps">
<ol>
<li>아래에 <strong>@xavis.co.kr</strong> 이메일을 입력하고 <strong>검증</strong>을 누릅니다.</li>
<li>아래에 <strong>@ncue.net</strong> 이메일을 입력하고 <strong>검증</strong>을 누릅니다.</li>
<li>해당 메일함으로 전송된 <strong>인증 링크</strong>를 엽니다.</li>
<li>인증이 완료되면 바로 서비스 화면으로 이동합니다.</li>
</ol>
@@ -159,12 +159,12 @@
name="email"
required
autocomplete="email"
placeholder="name@xavis.co.kr"
placeholder="name@ncue.net"
inputmode="email"
/>
<button type="submit" class="btn-verify" id="opsVerifyBtn">검증</button>
<p class="login-msg" id="opsLoginMsg" role="status" aria-live="polite"></p>
<p class="login-hint">허용 도메인: @xavis.co.kr 만 가능합니다.</p>
<p class="login-hint">허용 도메인: @ncue.net 만 가능합니다.</p>
</form>
</div>
</div>

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();

View File

@@ -23,11 +23,17 @@
/>
<div class="nav-logo-divider" role="presentation" aria-hidden="true"></div>
</div>
<a href="/chat" class="nav-item <%= activeMenu === 'chat' ? 'active' : '' %>">채팅</a>
<% var _guideBotOk = typeof guideBotMenuAllowed !== "undefined" && guideBotMenuAllowed; %>
<% if (_guideBotOk) { %>
<a href="/guide-bot" class="nav-item <%= activeMenu === 'guide-bot' ? 'active' : '' %>">가이드봇</a>
<a href="/wm" class="nav-item <%= activeMenu === 'wm' ? 'active' : '' %>">WM</a>
<% } %>
<div class="nav-separator"></div>
<a href="/ai-explore" class="nav-item <%= activeMenu === 'ai-explore' ? 'active' : '' %>">AI</a>
<a href="/ai-explore/prompts" class="nav-item <%= activeMenu === 'prompts' ? 'active' : '' %>">프롬프트</a>
<a href="/learning" class="nav-item <%= activeMenu === 'learning' ? 'active' : '' %>">학습센터</a>
<a href="/ax-apply" class="nav-item <%= activeMenu === 'ax-apply' ? 'active' : '' %>">과제신청</a>
<a href="/ai-cases" class="nav-item <%= activeMenu === 'ai-cases' ? 'active' : '' %>">성공사례</a>
<a href="/ai-cases" class="nav-item <%= activeMenu === 'ai-cases' ? 'active' : '' %>">AI 활용 사례</a>
<% var _dashOk = typeof dashboardMenuAllowed !== 'undefined' && dashboardMenuAllowed; %>
<% if (_dashOk) { %>
<div class="nav-separator"></div>

View File

@@ -1,8 +1,12 @@
<% var detailAllowed = typeof successStoryDetailAllowed !== 'undefined' ? successStoryDetailAllowed : true; %>
<% var _cover = story.coverImageUrl && String(story.coverImageUrl).trim(); %>
<% var _detailHref = (story.submissionId) ? ("/ai-cases/submit/" + encodeURIComponent(String(story.submissionId))) : ("/ai-cases/" + encodeURIComponent(String(story.slug || ""))); %>
<% var _isSubmission = !!story.submissionId || story._source === "submission"; %>
<% var _viewCount = typeof story.viewCount !== "undefined" ? story.viewCount : 0; %>
<% var _likeCount = typeof story.likeCount !== "undefined" ? story.likeCount : 0; %>
<article class="success-story-card<%= detailAllowed ? '' : ' success-story-card--locked' %>">
<% if (detailAllowed) { %>
<a class="success-story-link" href="/ai-cases/<%= story.slug %>">
<a class="success-story-link" href="<%= _detailHref %>">
<div class="success-thumb<%= _cover ? ' success-thumb--cover' : ' success-thumb--gradient' %>" aria-hidden="true">
<% if (_cover) { %>
<div class="success-thumb-media">
@@ -25,7 +29,7 @@
<% }) %>
</div>
<small class="success-meta">
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
<% if (_isSubmission) { %>조회 <%= _viewCount %> · ♥ <%= _likeCount %><% if (story.publishedAt) { %> · <% } %><% } %><% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
</small>
</div>
</a>
@@ -56,7 +60,7 @@
<% }) %>
</div>
<small class="success-meta">
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
<% if (_isSubmission) { %>조회 <%= _viewCount %> · ♥ <%= _likeCount %><% if (story.publishedAt) { %> · <% } %><% } %><% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
</small>
</div>
</div>