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:
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()) {
|
||||
|
||||
Reference in New Issue
Block a user