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:
@@ -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>
|
||||
|
||||
@@ -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
771
views/ai-cases-compose.ejs
Normal 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 활용 사례 > 작성하기 - 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 활용 사례 > <%= 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
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(/ /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>
|
||||
@@ -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> > 관리자</p>
|
||||
<p class="breadcrumb"><a href="/ai-cases">AI 활용 사례</a> > 관리자</p>
|
||||
<p class="muted admin-hint">슬러그는 URL에 쓰이므로 영문·숫자·하이픈만 사용하세요. <strong>원문 PDF 경로</strong>(<code>/public/...</code>)가 있으면 상세는 PDF 페이지 이미지로 보여 주며, 이때 <strong>본문(Markdown)은 비워도 됩니다</strong>. PDF가 없을 때는 본문이 필수입니다.</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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">
|
||||
로그인 후 이용 가능합니다.
|
||||
|
||||
@@ -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
33
views/ai-fscan.ejs
Normal 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
308
views/ai-use-case-submission-detail.ejs
Normal file
308
views/ai-use-case-submission-detail.ejs
Normal 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>
|
||||
154
views/chat.ejs
154
views/chat.ejs
@@ -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()) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,18 +8,6 @@
|
||||
<link rel="stylesheet" href="/public/styles.css" />
|
||||
</head>
|
||||
<body class="meeting-minutes-page">
|
||||
<% var mmDefaultCustomInstructions = `아래 회의 내용(또는 녹취/메모)을 바탕으로 다음 형식으로 정리해 주세요.
|
||||
|
||||
원문·전사 전체를 회의록에 다시 붙여 넣지 마세요.
|
||||
‘스크립트’·‘스크랩트’(오타)·‘원문 전사’ 같은 섹션은 만들지 말고, 요약·결정·액션만 작성하세요.
|
||||
회의 제목·참석자·요약 등은 ## 마크다운 제목으로 구분하세요.
|
||||
|
||||
1) 회의 개요: 일시, 참석자(알 수 있는 경우), 목적
|
||||
2) 논의 안건별 요약
|
||||
3) 결정 사항 (명확한 문장으로)
|
||||
4) 액션 아이템: 별도 섹션. 각 항목에 할 일·담당자·기한을 구체적으로 적어주세요. 만약 담당자와 기한을 알 수 없으면 안적어도 무방합니다.
|
||||
|
||||
‘추가 권고’, ‘회의록 작성자의 제안’, 엑셀 템플릿·추적 체크리스트 제안 등은 넣지 마세요.`; %>
|
||||
<% var mmNow = new Date(); var mmTodayIso = mmNow.getFullYear() + '-' + ('0' + (mmNow.getMonth() + 1)).slice(-2) + '-' + ('0' + mmNow.getDate()).slice(-2); %>
|
||||
<div class="app-shell">
|
||||
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
|
||||
@@ -52,17 +40,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="mm-prompt-body" id="mmPromptBody" hidden>
|
||||
<p class="subtitle">회의록에 포함할 항목을 선택하고, 추가 지시를 입력할 수 있습니다. 형식·섹션 구성은 <strong>추가 지시</strong>가 시스템 기본보다 우선합니다. 회의 체크리스트 섹션은 DB에 별도로 켜지 않은 경우 생성하지 않으며, 저장 후 업무 체크리스트 자동 연동은 액션·후속 항목에서 추출합니다.</p>
|
||||
<p class="subtitle">회의록 작성 시스템 프롬프트를 수정할 수 있습니다. 저장 후 업무 체크리스트 자동 연동은 액션·후속 항목에서 추출합니다.</p>
|
||||
<div class="mm-prompt-form" id="mmPromptForm">
|
||||
<div class="mm-checkbox-row" role="group" aria-label="회의록에 포함할 항목">
|
||||
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncTitle" checked /> <span>제목 한 줄</span></label>
|
||||
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncAtt" checked /> <span>참석자</span></label>
|
||||
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncSum" checked /> <span>요약</span></label>
|
||||
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncAct" checked /> <span>Action Item</span></label>
|
||||
</div>
|
||||
<div class="mm-custom-block">
|
||||
<label class="mm-field-label" for="mmCustomInstr">추가 지시</label>
|
||||
<textarea id="mmCustomInstr" class="mm-textarea mm-custom-instr-textarea" rows="9" placeholder=""><%= mmDefaultCustomInstructions %></textarea>
|
||||
<textarea id="mmCustomInstr" class="mm-textarea mm-custom-instr-textarea" rows="9" placeholder="비워두면 시스템 기본 구조(6개 섹션)로 회의록을 작성합니다. 추가로 지시할 내용이 있을 때만 입력하세요."></textarea>
|
||||
</div>
|
||||
<div class="mm-prompt-actions">
|
||||
<button type="button" class="top-action" id="mmSavePrompt" <%= hasEmail ? '' : 'disabled' %>>프롬프트 저장</button>
|
||||
@@ -91,15 +73,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="mm-field">
|
||||
<span class="mm-field-label">회의 원문</span>
|
||||
<textarea id="mmSourceText" class="mm-textarea" rows="12" placeholder="회의 내용을 붙여 넣거나 입력하세요." <%= hasEmail ? '' : 'disabled' %>></textarea>
|
||||
</label>
|
||||
<div class="mm-field">
|
||||
<div class="mm-minutes-header mm-source-text-header">
|
||||
<label class="mm-field-label" for="mmSourceText">회의 원문</label>
|
||||
<div class="mm-minutes-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="mm-minutes-copy"
|
||||
id="mmSourceTextCopy"
|
||||
title="회의 원문을 클립보드에 복사합니다."
|
||||
aria-label="회의 원문을 클립보드에 복사합니다."
|
||||
<%= hasEmail ? '' : 'disabled' %>
|
||||
>복사</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="mmSourceText" class="mm-textarea" rows="12" placeholder="회의 내용을 붙여 넣거나 입력하세요." <%= hasEmail ? '' : 'disabled' %> aria-label="회의 원문"></textarea>
|
||||
</div>
|
||||
<label class="mm-field mm-field-narrow">
|
||||
<span class="mm-field-label">회의록 생성 모델</span>
|
||||
<select id="mmModelText" class="mm-select" <%= hasEmail ? '' : 'disabled' %>>
|
||||
<option value="gpt-5-mini" selected>gpt-5-mini</option>
|
||||
<option value="gpt-5.4">gpt-5.4</option>
|
||||
<option value="gpt-5-mini">gpt-5-mini</option>
|
||||
<option value="gpt-5.4" selected>gpt-5.4</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="form-actions mm-form-actions">
|
||||
@@ -118,10 +112,9 @@
|
||||
<label class="mm-field mm-transcribe-model-only">
|
||||
<span class="mm-field-label">전사 모델 (OpenAI)</span>
|
||||
<select id="mmWhisperModel" class="mm-select" <%= hasEmail ? '' : 'disabled' %> title="음성→텍스트 API 모델">
|
||||
<option value="gpt-4o-mini-transcribe" selected>gpt-4o-mini-transcribe (기본)</option>
|
||||
<option value="gpt-4o-transcribe">gpt-4o-transcribe (고성능)</option>
|
||||
<option value="gpt-4o-mini-transcribe">gpt-4o-mini-transcribe (경량·더 빠를 수 있음)</option>
|
||||
<option value="gpt-4o-transcribe" selected>gpt-4o-transcribe (기본)</option>
|
||||
</select>
|
||||
<span class="mm-field-help">OpenAI 전사 API 공식 모델 ID와 동일합니다. 기본은 mini, 더 높은 인식 품질이 필요하면 gpt-4o-transcribe를 선택하세요.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mm-audio-phase">
|
||||
@@ -142,8 +135,8 @@
|
||||
<label class="mm-field mm-field-narrow">
|
||||
<span class="mm-field-label">회의록 생성 모델</span>
|
||||
<select id="mmModelAudio" class="mm-select" <%= hasEmail ? '' : 'disabled' %>>
|
||||
<option value="gpt-5-mini" selected>gpt-5-mini</option>
|
||||
<option value="gpt-5.4">gpt-5.4</option>
|
||||
<option value="gpt-5-mini">gpt-5-mini</option>
|
||||
<option value="gpt-5.4" selected>gpt-5.4</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="mm-audio-summary-hint">전사된 텍스트를 아래 프롬프트(출력 형식)에 맞게 요약·정리합니다.</p>
|
||||
@@ -155,8 +148,38 @@
|
||||
</section>
|
||||
|
||||
<div id="mmGenProgress" class="mm-gen-progress" hidden role="status" aria-live="polite" aria-busy="false">
|
||||
<div class="mm-gen-progress-track" aria-hidden="true">
|
||||
<div class="mm-gen-progress-bar"></div>
|
||||
<div id="mmGenAudioPipeline" class="mm-gen-audio-pipeline" hidden aria-label="음성 회의록 처리 단계">
|
||||
<div class="mm-gen-pipeline-steps" role="list">
|
||||
<span
|
||||
class="mm-gen-pipeline-step"
|
||||
id="mmGenStepUpload"
|
||||
data-mm-step-name="업로드"
|
||||
role="listitem"
|
||||
><strong class="mm-gen-pipeline-step-num">1</strong> 업로드</span>
|
||||
<span class="mm-gen-pipeline-join" aria-hidden="true">→</span>
|
||||
<span
|
||||
class="mm-gen-pipeline-step"
|
||||
id="mmGenStepTranscribe"
|
||||
data-mm-step-name="전사"
|
||||
role="listitem"
|
||||
><strong class="mm-gen-pipeline-step-num">2</strong> 전사</span>
|
||||
<span class="mm-gen-pipeline-join" aria-hidden="true">→</span>
|
||||
<span
|
||||
class="mm-gen-pipeline-step"
|
||||
id="mmGenStepTranslate"
|
||||
data-mm-step-name="번역"
|
||||
role="listitem"
|
||||
><strong class="mm-gen-pipeline-step-num">3</strong> 번역</span>
|
||||
</div>
|
||||
<p class="mm-gen-pipeline-note" id="mmGenTranslateNote" hidden>
|
||||
「번역」단계에서는 전문 템플릿에 맞게 회의 내용 전체를 LLM으로 서술 형식 회의록으로 작성합니다 (속도 표시 아님).
|
||||
</p>
|
||||
</div>
|
||||
<div class="mm-gen-progress-track-row">
|
||||
<div class="mm-gen-progress-track" aria-hidden="true">
|
||||
<div class="mm-gen-progress-bar"></div>
|
||||
</div>
|
||||
<span class="mm-gen-progress-pct" id="mmGenProgressPct" hidden></span>
|
||||
</div>
|
||||
<p class="mm-gen-progress-msg" id="mmGenProgressMsg">처리 중…</p>
|
||||
</div>
|
||||
@@ -171,8 +194,20 @@
|
||||
</p>
|
||||
<div class="mm-result-split">
|
||||
<div id="mmTranscriptPane" class="mm-result-transcript-pane" hidden aria-hidden="true">
|
||||
<label class="mm-result-field">
|
||||
<span class="mm-result-field-label" id="mmTranscriptLabel">전사 기록</span>
|
||||
<div class="mm-result-field">
|
||||
<div class="mm-minutes-header">
|
||||
<span class="mm-result-field-label" id="mmTranscriptLabel">전사 기록</span>
|
||||
<div class="mm-minutes-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="mm-minutes-copy mm-transcript-copy"
|
||||
id="mmTranscriptCopy"
|
||||
title="전사 기록을 클립보드에 복사합니다."
|
||||
aria-label="전사 기록을 클립보드에 복사합니다."
|
||||
>복사</button>
|
||||
<button type="button" class="top-action mm-minutes-apply" id="mmTranscriptSave" disabled>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
id="mmTranscriptBody"
|
||||
class="mm-result-body mm-result-textarea mm-result-textarea-half mm-transcript-textarea"
|
||||
@@ -180,26 +215,21 @@
|
||||
spellcheck="false"
|
||||
placeholder="음성 전사 텍스트가 여기에 표시됩니다."
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-result-field">
|
||||
<div class="mm-minutes-header">
|
||||
<span class="mm-result-field-label">회의록</span>
|
||||
<div class="mm-minutes-actions" id="mmMinutesActionsView" role="toolbar" aria-label="회의록 보기">
|
||||
<button type="button" class="btn-ghost mm-minutes-edit" id="mmMinutesEdit">마크다운 편집</button>
|
||||
</div>
|
||||
<div class="mm-minutes-actions" id="mmMinutesActionsEdit" role="toolbar" aria-label="회의록 편집" hidden>
|
||||
<div class="mm-minutes-actions" id="mmMinutesActions" role="toolbar" aria-label="회의록 액션">
|
||||
<button
|
||||
type="button"
|
||||
class="mm-minutes-copy"
|
||||
id="mmMinutesCopy"
|
||||
title="회의록을 복사합니다."
|
||||
aria-label="회의록을 복사합니다."
|
||||
>
|
||||
복사
|
||||
</button>
|
||||
<button type="button" class="top-action mm-minutes-apply" id="mmMinutesApply">저장</button>
|
||||
<button type="button" class="btn-ghost mm-minutes-cancel" id="mmMinutesCancel">취소</button>
|
||||
>복사</button>
|
||||
<button type="button" class="btn-ghost mm-minutes-edit" id="mmMinutesEdit">마크다운 편집</button>
|
||||
<button type="button" class="btn-ghost mm-minutes-cancel" id="mmMinutesCancel" hidden>취소</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-minutes-editor-wrap">
|
||||
@@ -241,21 +271,30 @@
|
||||
var resultSection = document.getElementById('mmResultSection');
|
||||
var resultBody = document.getElementById('mmResultBody');
|
||||
var minutesRenderedEl = document.getElementById('mmMinutesRendered');
|
||||
var minutesActionsView = document.getElementById('mmMinutesActionsView');
|
||||
var minutesActionsEdit = document.getElementById('mmMinutesActionsEdit');
|
||||
var minutesEditBtn = document.getElementById('mmMinutesEdit');
|
||||
var minutesCopyBtn = document.getElementById('mmMinutesCopy');
|
||||
var minutesApplyBtn = document.getElementById('mmMinutesApply');
|
||||
var minutesCancelBtn = document.getElementById('mmMinutesCancel');
|
||||
/** 편집 모드 진입 시점의 회의록 원문(취소 시 복원) */
|
||||
var minutesEditSnapshot = '';
|
||||
var minutesInEditMode = false;
|
||||
var transcriptBody = document.getElementById('mmTranscriptBody');
|
||||
var transcriptCopyBtn = document.getElementById('mmTranscriptCopy');
|
||||
var sourceTextCopyBtn = document.getElementById('mmSourceTextCopy');
|
||||
var mmSourceTextEl = document.getElementById('mmSourceText');
|
||||
var transcriptSaveBtn = document.getElementById('mmTranscriptSave');
|
||||
var transcriptLabel = document.getElementById('mmTranscriptLabel');
|
||||
/** true: 음성 전사(transcript_text) 편집, false: 텍스트 회의 원문(source_text) 편집 */
|
||||
var lastMeetingTranscriptIsAudio = false;
|
||||
var saveResultBtn = document.getElementById('mmSaveResult');
|
||||
var genProgressEl = document.getElementById('mmGenProgress');
|
||||
var genProgressMsg = document.getElementById('mmGenProgressMsg');
|
||||
var genProgressBar = genProgressEl ? genProgressEl.querySelector('.mm-gen-progress-bar') : null;
|
||||
var genProgressPct = document.getElementById('mmGenProgressPct');
|
||||
var mmAudioPipeEl = document.getElementById('mmGenAudioPipeline');
|
||||
var mmGenTranslateNoteEl = document.getElementById('mmGenTranslateNote');
|
||||
/** 음성 「전사 및 회의록 생성」 진행 중 디버그 스냅샷 — 콘솔: getMeetingMinutesAudioDiag() */
|
||||
var mmAudioJobDiag = { active: false };
|
||||
var mmAudioLastUiPhase = 'upload';
|
||||
var currentMeetingId = null;
|
||||
|
||||
function escapeHtmlMm(s) {
|
||||
@@ -334,8 +373,9 @@
|
||||
minutesRenderedEl.innerHTML = renderMinutesMarkdown(raw);
|
||||
}
|
||||
function setMinutesToolbarMode(editing) {
|
||||
if (minutesActionsView) minutesActionsView.hidden = !!editing;
|
||||
if (minutesActionsEdit) minutesActionsEdit.hidden = !editing;
|
||||
if (minutesCancelBtn) minutesCancelBtn.hidden = !editing;
|
||||
if (minutesEditBtn) minutesEditBtn.textContent = editing ? '편집 완료' : '마크다운 편집';
|
||||
minutesInEditMode = !!editing;
|
||||
}
|
||||
function setMinutesViewMode(showSource) {
|
||||
if (!resultBody || !minutesRenderedEl) return;
|
||||
@@ -395,16 +435,57 @@
|
||||
});
|
||||
});
|
||||
}
|
||||
if (transcriptCopyBtn) {
|
||||
transcriptCopyBtn.addEventListener('click', function () {
|
||||
if (!transcriptBody) return;
|
||||
var txt = String(transcriptBody.value || '');
|
||||
if (!txt.trim()) {
|
||||
alert('복사할 전사 기록이 없습니다.');
|
||||
return;
|
||||
}
|
||||
var btn = transcriptCopyBtn;
|
||||
var prev = btn.textContent;
|
||||
copyTextToClipboard(txt, function () {
|
||||
btn.textContent = '복사됨';
|
||||
window.setTimeout(function () {
|
||||
btn.textContent = prev;
|
||||
}, 1600);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (sourceTextCopyBtn && mmSourceTextEl) {
|
||||
sourceTextCopyBtn.addEventListener('click', function () {
|
||||
var txt = String(mmSourceTextEl.value || '');
|
||||
if (!txt.trim()) {
|
||||
alert('복사할 회의 원문이 없습니다.');
|
||||
return;
|
||||
}
|
||||
var btn = sourceTextCopyBtn;
|
||||
var prev = btn.textContent;
|
||||
copyTextToClipboard(txt, function () {
|
||||
btn.textContent = '복사됨';
|
||||
window.setTimeout(function () {
|
||||
btn.textContent = prev;
|
||||
}, 1600);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (transcriptSaveBtn) {
|
||||
transcriptSaveBtn.addEventListener('click', function () {
|
||||
if (saveResultBtn && !saveResultBtn.disabled) {
|
||||
saveResultBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (minutesEditBtn) {
|
||||
minutesEditBtn.addEventListener('click', function () {
|
||||
if (!resultBody) return;
|
||||
minutesEditSnapshot = resultBody.value;
|
||||
setMinutesViewMode(true);
|
||||
});
|
||||
}
|
||||
if (minutesApplyBtn) {
|
||||
minutesApplyBtn.addEventListener('click', function () {
|
||||
setMinutesViewMode(false);
|
||||
if (!minutesInEditMode) {
|
||||
minutesEditSnapshot = resultBody.value;
|
||||
setMinutesViewMode(true);
|
||||
} else {
|
||||
setMinutesViewMode(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (minutesCancelBtn) {
|
||||
@@ -427,6 +508,7 @@
|
||||
function setCurrentMeetingId(id) {
|
||||
currentMeetingId = id || null;
|
||||
if (saveResultBtn) saveResultBtn.disabled = !currentMeetingId;
|
||||
if (transcriptSaveBtn) transcriptSaveBtn.disabled = !currentMeetingId;
|
||||
}
|
||||
|
||||
function setMeetingGenerating(on, msg) {
|
||||
@@ -435,7 +517,17 @@
|
||||
genProgressEl.setAttribute('aria-busy', on ? 'true' : 'false');
|
||||
if (on) genProgressEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
if (genProgressMsg && msg) genProgressMsg.textContent = msg;
|
||||
if (!on) mmSetAudioPipelineVisible(false);
|
||||
if (!on) showIndeterminateProgress();
|
||||
if (genProgressMsg && on) {
|
||||
if (arguments.length >= 2) {
|
||||
genProgressMsg.textContent =
|
||||
msg === undefined || msg === null ? '' : String(msg);
|
||||
} else {
|
||||
/** 진행 시작 시 초기 HTML「처리 중…」 등이 다음 업데이트 전까지 보이지 않도록 비움 */
|
||||
genProgressMsg.textContent = '';
|
||||
}
|
||||
}
|
||||
var gText = document.getElementById('mmGenText');
|
||||
var gAudio = document.getElementById('mmGenAudio');
|
||||
if (gText) gText.disabled = !!on;
|
||||
@@ -445,7 +537,6 @@
|
||||
if (transcriptBody) transcriptBody.disabled = !!on;
|
||||
if (minutesEditBtn) minutesEditBtn.disabled = !!on;
|
||||
if (minutesCopyBtn) minutesCopyBtn.disabled = !!on;
|
||||
if (minutesApplyBtn) minutesApplyBtn.disabled = !!on;
|
||||
if (minutesCancelBtn) minutesCancelBtn.disabled = !!on;
|
||||
if (on && resultBody && minutesRenderedEl) {
|
||||
if (!resultBody.hidden) {
|
||||
@@ -455,6 +546,248 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* fetch ReadableStream SSE 파싱. onStreamHeaders / onUploaded 둘 중 하나로 응답 헤더 직후 1회 콜백.
|
||||
*/
|
||||
function consumeSseFromFetch(response, handlers) {
|
||||
var hdr = handlers.onStreamHeaders || handlers.onUploaded;
|
||||
hdr && hdr();
|
||||
if (!response.body) {
|
||||
handlers.onError && handlers.onError(new Error('응답 스트림을 읽을 수 없습니다.'));
|
||||
return;
|
||||
}
|
||||
var reader = response.body.getReader();
|
||||
var decoder = new TextDecoder();
|
||||
var sseBuffer = '';
|
||||
var sseEvent = 'message';
|
||||
|
||||
function parseSseChunk(text) {
|
||||
sseBuffer += text;
|
||||
var lines = sseBuffer.split('\n');
|
||||
sseBuffer = lines.pop();
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
if (line.length && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
|
||||
if (line.startsWith('event: ')) {
|
||||
sseEvent = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
try {
|
||||
var data = JSON.parse(line.slice(6));
|
||||
handlers.onSseEvent && handlers.onSseEvent(sseEvent, data);
|
||||
} catch (_) {}
|
||||
sseEvent = 'message';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pump() {
|
||||
return reader.read().then(function (result) {
|
||||
if (result.done) {
|
||||
handlers.onEnd && handlers.onEnd();
|
||||
return;
|
||||
}
|
||||
parseSseChunk(decoder.decode(result.value, { stream: true }));
|
||||
return pump();
|
||||
});
|
||||
}
|
||||
return pump();
|
||||
}
|
||||
|
||||
/** 1단계: multipart 업로드 → JSON { jobId }. onUploadProgress, onJobReady(jobId), onError */
|
||||
function postPrepareMeetingAudioJob(url, formData, callbacks) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url);
|
||||
xhr.withCredentials = true;
|
||||
xhr.responseType = 'text';
|
||||
xhr.upload.onprogress = function (ev) {
|
||||
callbacks.onUploadProgress &&
|
||||
callbacks.onUploadProgress(ev.lengthComputable, ev.loaded, ev.total || 0);
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
callbacks.onError && callbacks.onError(new Error('네트워크 오류(업로드)'));
|
||||
};
|
||||
xhr.onload = function () {
|
||||
try {
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
var jFail = null;
|
||||
try {
|
||||
jFail = xhr.responseText ? JSON.parse(xhr.responseText) : null;
|
||||
} catch (_) {
|
||||
jFail = null;
|
||||
}
|
||||
var em =
|
||||
(jFail && jFail.error && String(jFail.error).slice(0, 400)) ||
|
||||
(xhr.responseText || '').replace(/\s+/g, ' ').trim().slice(0, 200) ||
|
||||
'HTTP ' + xhr.status;
|
||||
callbacks.onError && callbacks.onError(new Error(em));
|
||||
return;
|
||||
}
|
||||
var j = null;
|
||||
try {
|
||||
j = JSON.parse(xhr.responseText || '');
|
||||
} catch (_) {
|
||||
callbacks.onError && callbacks.onError(new Error('서버 응답 형식 오류(JSON)'));
|
||||
return;
|
||||
}
|
||||
if (!j || !j.jobId) {
|
||||
callbacks.onError && callbacks.onError(new Error('jobId가 응답에 없습니다.'));
|
||||
return;
|
||||
}
|
||||
callbacks.onJobReady && callbacks.onJobReady(typeof j.jobId === 'string' ? j.jobId : String(j.jobId));
|
||||
} catch (e) {
|
||||
callbacks.onError &&
|
||||
callbacks.onError(new Error(e && e.message ? e.message : String(e)));
|
||||
}
|
||||
};
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
/** 2단계: GET SSE (fetch 스트림 — 단일 업로드+SSE 연결보다 브라우저/프록시에서 증분 수신 안정적) */
|
||||
function streamMeetingAudioJobSse(jobId, handlers) {
|
||||
var streamUrl =
|
||||
'/api/meeting-minutes/stream-audio/' + encodeURIComponent(jobId);
|
||||
return fetch(streamUrl, { method: 'GET', credentials: 'same-origin' })
|
||||
.then(function (response) {
|
||||
var ct = (response.headers.get('content-type') || '').toLowerCase();
|
||||
if (!response.ok) {
|
||||
return response
|
||||
.json()
|
||||
.catch(function () {
|
||||
return {};
|
||||
})
|
||||
.then(function (jo) {
|
||||
var msg =
|
||||
(jo && jo.error && String(jo.error).slice(0, 400)) ||
|
||||
'HTTP ' + response.status;
|
||||
handlers.onError && handlers.onError(new Error(msg || 'SSE 연결 실패'));
|
||||
});
|
||||
}
|
||||
if (ct.indexOf('text/event-stream') === -1) {
|
||||
return response.text().then(function (t) {
|
||||
var msg = (t || '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||
handlers.onError &&
|
||||
handlers.onError(new Error(msg || 'SSE가 아닙니다.'));
|
||||
});
|
||||
}
|
||||
return consumeSseFromFetch(response, handlers);
|
||||
})
|
||||
.catch(function (e) {
|
||||
handlers.onError && handlers.onError(e);
|
||||
});
|
||||
}
|
||||
|
||||
/** 진행 바를 실제 퍼센트 모드로 설정 (인라인 스타일로 CSS 캐시 무관하게 동작) */
|
||||
function showDeterminedProgress(pct) {
|
||||
pct = Math.min(100, Math.max(0, Math.round(pct)));
|
||||
if (genProgressBar) {
|
||||
genProgressBar.classList.add('mm-gen-progress-bar--determined');
|
||||
genProgressBar.style.animation = 'none';
|
||||
genProgressBar.style.transform = 'none';
|
||||
genProgressBar.style.width = pct + '%';
|
||||
}
|
||||
if (genProgressPct) {
|
||||
genProgressPct.removeAttribute('hidden');
|
||||
genProgressPct.hidden = false;
|
||||
genProgressPct.textContent = pct + '%';
|
||||
}
|
||||
}
|
||||
|
||||
/** 진행 바를 인디터미넌트(슬라이딩) 모드로 복귀 */
|
||||
function showIndeterminateProgress() {
|
||||
if (genProgressBar) {
|
||||
genProgressBar.classList.remove('mm-gen-progress-bar--determined');
|
||||
genProgressBar.style.animation = '';
|
||||
genProgressBar.style.transform = '';
|
||||
genProgressBar.style.width = '';
|
||||
}
|
||||
if (genProgressPct) {
|
||||
genProgressPct.hidden = true;
|
||||
genProgressPct.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function mmPipelineStepNodes() {
|
||||
return [
|
||||
document.getElementById('mmGenStepUpload'),
|
||||
document.getElementById('mmGenStepTranscribe'),
|
||||
document.getElementById('mmGenStepTranslate'),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
/** 음성 파이프라인 UI 표시 여부 — 텍스트 회의 원문 생성과 구분 */
|
||||
function mmSetAudioPipelineVisible(on) {
|
||||
if (mmAudioPipeEl) mmAudioPipeEl.hidden = !on;
|
||||
if (!on) {
|
||||
mmPipelineStepNodes().forEach(function (el) {
|
||||
el.classList.remove('is-done', 'is-active', 'is-pending');
|
||||
el.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
if (!on && mmGenTranslateNoteEl) mmGenTranslateNoteEl.hidden = true;
|
||||
}
|
||||
|
||||
function mmUpdatePipelineStepHighlight(phaseKey) {
|
||||
var order = ['upload', 'transcribe', 'translate'];
|
||||
var ix = order.indexOf(phaseKey);
|
||||
if (ix < 0) ix = 0;
|
||||
mmPipelineStepNodes().forEach(function (el, i) {
|
||||
el.classList.remove('is-done', 'is-active', 'is-pending');
|
||||
el.removeAttribute('aria-current');
|
||||
if (i < ix) el.classList.add('is-done');
|
||||
else if (i === ix) {
|
||||
el.classList.add('is-active');
|
||||
el.setAttribute('aria-current', 'step');
|
||||
} else el.classList.add('is-pending');
|
||||
});
|
||||
}
|
||||
|
||||
/** 순차 단계 배지(up/trans/translate)·막대·본문 메시지. pct가 null이면 막대 인디터미넌트. */
|
||||
function mmSetAudioGenerationPhase(phaseKey, pct, detailMsg) {
|
||||
if (phaseKey) mmAudioLastUiPhase = phaseKey;
|
||||
mmUpdatePipelineStepHighlight(phaseKey);
|
||||
var pctRounded = null;
|
||||
if (
|
||||
pct !== null &&
|
||||
pct !== undefined &&
|
||||
typeof pct === 'number' &&
|
||||
!isNaN(pct)
|
||||
) {
|
||||
pctRounded = Math.min(100, Math.max(0, Math.round(pct)));
|
||||
}
|
||||
if (pct === null) {
|
||||
showIndeterminateProgress();
|
||||
} else if (pctRounded !== null) {
|
||||
showDeterminedProgress(pctRounded);
|
||||
}
|
||||
if (mmGenTranslateNoteEl) mmGenTranslateNoteEl.hidden = phaseKey !== 'translate';
|
||||
if (!genProgressMsg) return;
|
||||
if (detailMsg !== undefined && detailMsg !== null && detailMsg !== '') {
|
||||
genProgressMsg.textContent =
|
||||
pctRounded !== null ? pctRounded + '% · ' + detailMsg : detailMsg;
|
||||
} else if (pctRounded !== null && phaseKey === 'transcribe') {
|
||||
genProgressMsg.textContent =
|
||||
pctRounded + '% · 서버 처리 및 전사를 기다리는 중입니다…';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.getMeetingMinutesAudioDiag = function () {
|
||||
if (!mmAudioJobDiag || !mmAudioJobDiag.active) {
|
||||
return {
|
||||
active: false,
|
||||
hintKo:
|
||||
'진행 중인 음성 회의 생성이 없거나 이미 종료되었습니다. 「전사 및 회의록 생성」을 누른 뒤 이 함수를 실행하세요.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(mmAudioJobDiag));
|
||||
} catch (e2) {
|
||||
return { active: true, cloneError: String(e2 && e2.message) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function api(path, opts) {
|
||||
return fetch(path, Object.assign({ credentials: 'same-origin' }, opts || {})).then(function (r) {
|
||||
var ct = (r.headers.get('content-type') || '').toLowerCase();
|
||||
@@ -471,17 +804,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
var MM_DEFAULT_CUSTOM_INSTRUCTIONS = <%- JSON.stringify(mmDefaultCustomInstructions) %>;
|
||||
|
||||
function loadPrompt() {
|
||||
return api('/api/meeting-minutes/prompt').then(function (d) {
|
||||
var p = d.prompt || {};
|
||||
document.getElementById('mmIncTitle').checked = p.includeTitleLine !== false;
|
||||
document.getElementById('mmIncAtt').checked = p.includeAttendees !== false;
|
||||
document.getElementById('mmIncSum').checked = p.includeSummary !== false;
|
||||
document.getElementById('mmIncAct').checked = p.includeActionItems !== false;
|
||||
var saved = (p.customInstructions && String(p.customInstructions).trim()) || '';
|
||||
document.getElementById('mmCustomInstr').value = saved || MM_DEFAULT_CUSTOM_INSTRUCTIONS;
|
||||
document.getElementById('mmCustomInstr').value = saved;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -595,10 +922,10 @@
|
||||
|
||||
document.getElementById('mmSavePrompt').addEventListener('click', function () {
|
||||
var body = {
|
||||
includeTitleLine: document.getElementById('mmIncTitle').checked,
|
||||
includeAttendees: document.getElementById('mmIncAtt').checked,
|
||||
includeSummary: document.getElementById('mmIncSum').checked,
|
||||
includeActionItems: document.getElementById('mmIncAct').checked,
|
||||
includeTitleLine: true,
|
||||
includeAttendees: true,
|
||||
includeSummary: true,
|
||||
includeActionItems: true,
|
||||
includeChecklist: false,
|
||||
customInstructions: document.getElementById('mmCustomInstr').value
|
||||
};
|
||||
@@ -766,44 +1093,276 @@
|
||||
alert('제목을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
var audioFile = fileInput.files[0];
|
||||
|
||||
mmSetAudioPipelineVisible(true);
|
||||
mmAudioLastUiPhase = 'upload';
|
||||
mmUpdatePipelineStepHighlight('upload');
|
||||
|
||||
var fileSize = audioFile.size || 0;
|
||||
/** multipart 경계·헤더 등 보정 분모(multipart 업로드 total 미제공 시) */
|
||||
var mpSlop =
|
||||
fileSize > 0
|
||||
? Math.min(262144, Math.max(6144, Math.floor(fileSize / 50) + 2048))
|
||||
: 0;
|
||||
var xhrLoadedBytes = 0;
|
||||
var xhrEstimatedTotalBytes =
|
||||
fileSize > 0 ? fileSize + mpSlop : 0;
|
||||
var xhrKnownUploadTotal = fileSize > 0;
|
||||
var uploadStartTime = Date.now();
|
||||
var uploadPhase = true;
|
||||
var transcribePrepPct = 0;
|
||||
var segmentMode = false;
|
||||
|
||||
var lastSseName = '';
|
||||
var lastSseAtMs = 0;
|
||||
var lastProgressStage = null;
|
||||
var lastSegDone = 0;
|
||||
var lastSegTotal = 0;
|
||||
|
||||
function mmAudioDiagRefresh() {
|
||||
var hintKo = '';
|
||||
if (uploadPhase) {
|
||||
hintKo =
|
||||
'① POST prepare-audio: XHR 업로드·파일 디스크 저장 후 jobId JSON · ② GET stream-audio: fetch SSE로 증분 수신 (단일 업로드+SSE 연결은 일부 브라우저/프록시가 본문을 버퍼링할 수 있습니다).';
|
||||
} else if (mmAudioLastUiPhase === 'translate') {
|
||||
hintKo = '번역 단계: 전사 결과를 회의록 형식으로 LLM이 작성 중입니다.';
|
||||
} else if (segmentMode) {
|
||||
hintKo =
|
||||
'전사 단계: 세그먼트 처리 중 (' +
|
||||
lastSegDone +
|
||||
' / ' +
|
||||
lastSegTotal +
|
||||
')';
|
||||
} else {
|
||||
hintKo =
|
||||
'전사 준비: 서버에서 설정·분할 등 — 세그먼트 init 이벤트 전일 수 있습니다.';
|
||||
}
|
||||
mmAudioJobDiag = {
|
||||
active: true,
|
||||
startedAtMs: uploadStartTime,
|
||||
elapsedMs: Date.now() - uploadStartTime,
|
||||
audioFileBytes: fileSize,
|
||||
uploadPhase: uploadPhase,
|
||||
xhrKnownTotal: xhrKnownUploadTotal,
|
||||
xhrUploadedBytes: xhrLoadedBytes,
|
||||
xhrEstimatedTotalBytes: xhrEstimatedTotalBytes,
|
||||
uiPhase: mmAudioLastUiPhase,
|
||||
transcribePrepPct: transcribePrepPct,
|
||||
segmentMode: segmentMode,
|
||||
transcribeProgress: { done: lastSegDone, total: lastSegTotal },
|
||||
lastProgressStage: lastProgressStage,
|
||||
lastSseEvent: lastSseName,
|
||||
lastSseAtMs: lastSseAtMs || null,
|
||||
hintKo: hintKo,
|
||||
};
|
||||
}
|
||||
|
||||
mmAudioDiagRefresh();
|
||||
|
||||
setMeetingGenerating(true);
|
||||
mmSetAudioGenerationPhase(
|
||||
'upload',
|
||||
0,
|
||||
'파일 업로드를 시작합니다(XHR)·전송률은 바이트 기준입니다.'
|
||||
);
|
||||
|
||||
var sseError = null;
|
||||
|
||||
function warmTranscriptionAfterPrepare() {
|
||||
window.setTimeout(function () {
|
||||
transcribePrepPct = 6;
|
||||
segmentMode = false;
|
||||
mmSetAudioGenerationPhase(
|
||||
'transcribe',
|
||||
transcribePrepPct,
|
||||
'전사: DB 및 프롬프트 로드·음성 준비 중 (이후 세그먼트별 진행률).'
|
||||
);
|
||||
mmAudioDiagRefresh();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function mmMeetingAudioFatal(e) {
|
||||
uploadPhase = false;
|
||||
mmAudioJobDiag = {
|
||||
active: false,
|
||||
endedAtMs: Date.now(),
|
||||
hintKo: '요청 실패: ' + (e && e.message ? e.message : String(e)),
|
||||
};
|
||||
alert(e.message || '생성 실패');
|
||||
setMeetingGenerating(false);
|
||||
}
|
||||
|
||||
var mmAudioStreamHandlers = {
|
||||
onSseEvent: function (event, data) {
|
||||
try {
|
||||
lastSseName = event;
|
||||
lastSseAtMs = Date.now();
|
||||
if (event === 'progress' && data && data.stage) lastProgressStage = data.stage;
|
||||
if (event === 'progress' && data && data.stage === 'transcribe') {
|
||||
lastSegDone = data.done != null ? data.done : 0;
|
||||
lastSegTotal = data.total != null ? data.total : 1;
|
||||
}
|
||||
if (event === 'accepted') {
|
||||
transcribePrepPct = Math.max(transcribePrepPct, 22);
|
||||
var am = data && data.message ? String(data.message) : '';
|
||||
mmSetAudioGenerationPhase(
|
||||
'transcribe',
|
||||
transcribePrepPct,
|
||||
am || '서버 접수 후 설정 로드 중입니다.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event === 'heartbeat') {
|
||||
if (!segmentMode) {
|
||||
transcribePrepPct = Math.min(94, transcribePrepPct + 3);
|
||||
mmSetAudioGenerationPhase(
|
||||
'transcribe',
|
||||
transcribePrepPct,
|
||||
'서버 처리 및 전사를 기다리는 중입니다…'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event === 'progress') {
|
||||
var stage = data.stage;
|
||||
var done = data.done || 0;
|
||||
var total = data.total || 1;
|
||||
if (stage === 'prep') {
|
||||
transcribePrepPct = Math.max(transcribePrepPct, 52);
|
||||
mmSetAudioGenerationPhase(
|
||||
'transcribe',
|
||||
transcribePrepPct,
|
||||
(data.message && String(data.message)) ||
|
||||
'전사 준비(음성 구간 처리·전사 엔진 연결) 중입니다.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (stage === 'init') {
|
||||
segmentMode = true;
|
||||
lastSegDone = 0;
|
||||
lastSegTotal = total;
|
||||
mmSetAudioGenerationPhase(
|
||||
'transcribe',
|
||||
0,
|
||||
total > 1
|
||||
? '전사 시작: 구간별 API 호출 (0 / ' +
|
||||
total +
|
||||
' 완료) — 이 단계만 100% 기준입니다.'
|
||||
: '전사 API 호출이 시작되었습니다. 이 단계만 100% 기준입니다.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (stage === 'transcribe') {
|
||||
var segPct = Math.min(100, Math.round((done / total) * 100));
|
||||
mmSetAudioGenerationPhase(
|
||||
'transcribe',
|
||||
segPct,
|
||||
total > 1
|
||||
? '구간별 전사 중 (' + done + ' / ' + total + ' 완료)…'
|
||||
: '전사 API 응답 처리 중…'
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (event === 'generating') {
|
||||
mmSetAudioGenerationPhase(
|
||||
'translate',
|
||||
null,
|
||||
'전사 결과를 회의록 형식(LLM 작성)으로 정리합니다.'
|
||||
);
|
||||
} else if (event === 'done') {
|
||||
mmSetAudioGenerationPhase(
|
||||
'translate',
|
||||
100,
|
||||
'회의록 작성·저장 및 체크리스트 후처리까지 완료되었습니다.'
|
||||
);
|
||||
var d = data;
|
||||
resultSection.hidden = false;
|
||||
setCurrentMeetingId(d.meeting && d.meeting.id);
|
||||
lastMeetingTranscriptIsAudio = true;
|
||||
if (transcriptLabel) transcriptLabel.textContent = '전사 기록';
|
||||
if (transcriptBody) transcriptBody.value = (d.meeting && d.meeting.transcriptText) || '';
|
||||
resultBody.value = (d.meeting && d.meeting.generatedMinutes) || '';
|
||||
setMinutesViewMode(false);
|
||||
applyTranscriptPaneVisibility();
|
||||
fileInput.value = '';
|
||||
loadMeetings();
|
||||
var cs = d.checklistSync;
|
||||
if (cs && cs.imported > 0) {
|
||||
alert(
|
||||
'업무 체크리스트에 ' +
|
||||
cs.imported +
|
||||
'건이 자동 반영되었습니다. (업무 체크리스트 AI에서 확인)'
|
||||
);
|
||||
} else if (cs && cs.extractError && !cs.disabled) {
|
||||
console.warn('체크리스트 자동 추출:', cs.extractError);
|
||||
}
|
||||
} else if (event === 'error') {
|
||||
sseError = (data && data.message) || '생성 실패';
|
||||
}
|
||||
} finally {
|
||||
mmAudioDiagRefresh();
|
||||
}
|
||||
},
|
||||
onEnd: function () {
|
||||
mmAudioJobDiag = {
|
||||
active: false,
|
||||
endedAtMs: Date.now(),
|
||||
hintKo: sseError
|
||||
? '오류로 종료: ' + sseError
|
||||
: '스트림 종료(정상 완료 또는 서버 연결 종료).',
|
||||
};
|
||||
if (sseError) alert(sseError);
|
||||
setMeetingGenerating(false);
|
||||
},
|
||||
onError: mmMeetingAudioFatal,
|
||||
};
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('audio', fileInput.files[0]);
|
||||
fd.append('audio', audioFile);
|
||||
fd.append('title', audioTitle);
|
||||
fd.append('meetingDate', document.getElementById('mmDateAudio').value);
|
||||
fd.append('model', document.getElementById('mmModelAudio').value);
|
||||
fd.append('whisperModel', document.getElementById('mmWhisperModel').value);
|
||||
setMeetingGenerating(true, '음성 파일을 업로드하고 전사합니다. 길이에 따라 1분 이상 걸릴 수 있습니다…');
|
||||
fetch('/api/meeting-minutes/generate-audio', { method: 'POST', body: fd, credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
return r.json().then(function (j) {
|
||||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||
return j;
|
||||
});
|
||||
})
|
||||
.then(function (d) {
|
||||
resultSection.hidden = false;
|
||||
setCurrentMeetingId(d.meeting && d.meeting.id);
|
||||
lastMeetingTranscriptIsAudio = true;
|
||||
if (transcriptLabel) transcriptLabel.textContent = '전사 기록';
|
||||
if (transcriptBody) transcriptBody.value = (d.meeting && d.meeting.transcriptText) || '';
|
||||
resultBody.value = (d.meeting && d.meeting.generatedMinutes) || '';
|
||||
setMinutesViewMode(false);
|
||||
applyTranscriptPaneVisibility();
|
||||
fileInput.value = '';
|
||||
loadMeetings();
|
||||
var cs = d.checklistSync;
|
||||
if (cs && cs.imported > 0) {
|
||||
alert('업무 체크리스트에 ' + cs.imported + '건이 자동 반영되었습니다. (업무 체크리스트 AI에서 확인)');
|
||||
} else if (cs && cs.extractError && !cs.disabled) {
|
||||
console.warn('체크리스트 자동 추출:', cs.extractError);
|
||||
|
||||
postPrepareMeetingAudioJob('/api/meeting-minutes/prepare-audio', fd, {
|
||||
onUploadProgress: function (computable, loaded, total) {
|
||||
xhrLoadedBytes = loaded;
|
||||
var effTotal = 0;
|
||||
if (computable && total > 0) {
|
||||
effTotal = total;
|
||||
} else if (fileSize > 0) {
|
||||
effTotal = fileSize + mpSlop;
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
alert(e.message || '생성 실패');
|
||||
})
|
||||
.finally(function () {
|
||||
setMeetingGenerating(false);
|
||||
});
|
||||
xhrEstimatedTotalBytes = effTotal;
|
||||
xhrKnownUploadTotal = !!(effTotal > 0);
|
||||
if (!uploadPhase || effTotal <= 0) {
|
||||
mmAudioDiagRefresh();
|
||||
return;
|
||||
}
|
||||
var pct = Math.min(99, Math.round((loaded * 100) / Math.max(effTotal, 1)));
|
||||
var detail =
|
||||
'업로드 중… ' +
|
||||
loaded.toLocaleString() +
|
||||
' / 약 ' +
|
||||
effTotal.toLocaleString() +
|
||||
' 바이트';
|
||||
mmSetAudioGenerationPhase('upload', pct, detail);
|
||||
mmAudioDiagRefresh();
|
||||
},
|
||||
onJobReady: function (jobId) {
|
||||
uploadPhase = false;
|
||||
mmSetAudioGenerationPhase(
|
||||
'upload',
|
||||
100,
|
||||
'업로드를 마쳤습니다. 전사 스트림에 연결합니다…'
|
||||
);
|
||||
mmAudioDiagRefresh();
|
||||
warmTranscriptionAfterPrepare();
|
||||
streamMeetingAudioJobSse(jobId, mmAudioStreamHandlers);
|
||||
},
|
||||
onError: mmMeetingAudioFatal,
|
||||
});
|
||||
});
|
||||
|
||||
applyTranscriptPaneVisibility();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user