343 lines
14 KiB
Plaintext
343 lines
14 KiB
Plaintext
<!doctype html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>채팅 - XAVIS</title>
|
|
<link rel="stylesheet" href="/public/styles.css" />
|
|
</head>
|
|
<body>
|
|
<div class="app-shell">
|
|
<%- include('partials/nav', { activeMenu: 'chat' }) %>
|
|
<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>
|
|
<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>
|
|
</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>
|
|
</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)) %>;
|
|
|
|
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) {
|
|
chatGptAllowed = !!allowed;
|
|
var notice = document.getElementById('chatGateNotice');
|
|
if (chatGptAllowed) {
|
|
if (notice) notice.hidden = true;
|
|
return;
|
|
}
|
|
if (input) input.disabled = true;
|
|
if (sendBtn) sendBtn.disabled = true;
|
|
if (modelSelect) modelSelect.disabled = true;
|
|
if (notice) {
|
|
notice.hidden = false;
|
|
if (opsState === 'DEV' && !adminMode) {
|
|
notice.textContent = '로그인 후 이용 가능합니다.';
|
|
} else if (opsState === 'PROD' && !opsUserEmail) {
|
|
notice.textContent = '회사 이메일 인증(로그인) 후 이용할 수 있습니다.';
|
|
} else {
|
|
notice.textContent = '이 환경에서는 GPT 채팅을 사용할 수 없습니다.';
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
var chatMarkdownConfigured = false;
|
|
|
|
function configureChatMarkdown() {
|
|
if (chatMarkdownConfigured) return;
|
|
chatMarkdownConfigured = true;
|
|
if (typeof marked !== 'undefined') {
|
|
marked.setOptions({ breaks: true, gfm: true });
|
|
}
|
|
if (typeof DOMPurify !== 'undefined') {
|
|
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
|
|
if (node.tagName === 'A' && node.hasAttribute('href')) {
|
|
var href = node.getAttribute('href');
|
|
try {
|
|
var u = new URL(href, window.location.href);
|
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
node.removeAttribute('href');
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
node.removeAttribute('href');
|
|
return;
|
|
}
|
|
node.setAttribute('target', '_blank');
|
|
node.setAttribute('rel', 'noopener noreferrer');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/** 마크다운 → HTML 후 DOMPurify로 정제 (marked 출력은 신뢰하지 않음) */
|
|
function renderAssistantMarkdown(text) {
|
|
configureChatMarkdown();
|
|
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
|
|
return escapeHtml(text).replace(/\n/g, '<br>');
|
|
}
|
|
try {
|
|
var raw = marked.parse(String(text || ''), { async: false });
|
|
return DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } });
|
|
} catch (err) {
|
|
return escapeHtml(text).replace(/\n/g, '<br>');
|
|
}
|
|
}
|
|
|
|
function addMessage(role, content) {
|
|
if (welcomeEl) welcomeEl.style.display = 'none';
|
|
const div = document.createElement('div');
|
|
div.className = 'chat-msg ' + (role === 'user' ? 'chat-msg-user' : 'chat-msg-assistant');
|
|
var inner =
|
|
role === 'assistant'
|
|
? renderAssistantMarkdown(content)
|
|
: escapeHtml(content).replace(/\n/g, '<br>');
|
|
var contentClass =
|
|
role === 'assistant' ? 'chat-msg-content chat-md-body' : 'chat-msg-content';
|
|
div.innerHTML = '<div class="' + contentClass + '">' + inner + '</div>';
|
|
messagesEl.appendChild(div);
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
}
|
|
|
|
function appendAssistantStreamingPlaceholder() {
|
|
if (welcomeEl) welcomeEl.style.display = 'none';
|
|
const div = document.createElement('div');
|
|
div.className = 'chat-msg chat-msg-assistant chat-msg-streaming';
|
|
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>';
|
|
div.appendChild(inner);
|
|
const statusEl = document.createElement('div');
|
|
statusEl.className = 'chat-status-line';
|
|
statusEl.hidden = true;
|
|
statusEl.setAttribute('aria-live', 'polite');
|
|
div.appendChild(statusEl);
|
|
const sourcesEl = document.createElement('div');
|
|
sourcesEl.className = 'chat-sources';
|
|
sourcesEl.hidden = true;
|
|
div.appendChild(sourcesEl);
|
|
messagesEl.appendChild(div);
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
return { bubble: div, contentEl: inner, statusEl: statusEl, sourcesEl: sourcesEl };
|
|
}
|
|
|
|
function appendSourceLinks(sourcesEl, items) {
|
|
if (!sourcesEl || !items || !items.length) return;
|
|
sourcesEl.innerHTML = '';
|
|
const title = document.createElement('div');
|
|
title.className = 'chat-sources-title';
|
|
title.textContent = '출처';
|
|
sourcesEl.appendChild(title);
|
|
const ol = document.createElement('ol');
|
|
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);
|
|
ol.appendChild(li);
|
|
}
|
|
if (ol.children.length) {
|
|
sourcesEl.appendChild(ol);
|
|
sourcesEl.hidden = false;
|
|
}
|
|
}
|
|
|
|
function setStreamingContent(contentEl, text) {
|
|
contentEl.innerHTML = renderAssistantMarkdown(text);
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
}
|
|
|
|
function setLoading(loading) {
|
|
sendBtn.disabled = 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;
|
|
}
|
|
|
|
async function readSseStream(response, body) {
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let fullText = '';
|
|
let started = false;
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
let sep;
|
|
while ((sep = buffer.indexOf('\n\n')) !== -1) {
|
|
const block = buffer.slice(0, sep);
|
|
buffer = buffer.slice(sep + 2);
|
|
const lines = block.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line.indexOf('data: ') !== 0) continue;
|
|
let obj;
|
|
try {
|
|
obj = JSON.parse(line.slice(6));
|
|
} catch (parseErr) {
|
|
continue;
|
|
}
|
|
if (obj.type === 'error') {
|
|
throw new Error(obj.error || '스트림 오류');
|
|
}
|
|
if (obj.type === 'status' && obj.phase === 'web_search' && body.statusEl) {
|
|
body.statusEl.textContent = '웹 검색 중…';
|
|
body.statusEl.hidden = false;
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
}
|
|
if (obj.type === 'sources' && body.sourcesEl && obj.items) {
|
|
appendSourceLinks(body.sourcesEl, obj.items);
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
}
|
|
if (obj.type === 'done' && body.statusEl) {
|
|
body.statusEl.hidden = true;
|
|
}
|
|
if (obj.type === 'delta' && obj.text) {
|
|
if (body.statusEl) body.statusEl.hidden = true;
|
|
if (!started) {
|
|
started = true;
|
|
body.bubble.classList.remove('chat-msg-streaming');
|
|
}
|
|
fullText += obj.text;
|
|
setStreamingContent(body.contentEl, fullText);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return fullText;
|
|
}
|
|
|
|
form.addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
if (!chatGptAllowed) return;
|
|
const text = (input.value || '').trim();
|
|
if (!text) return;
|
|
|
|
addMessage('user', text);
|
|
conversationHistory.push({ role: 'user', content: text });
|
|
input.value = '';
|
|
input.style.height = 'auto';
|
|
|
|
const body = appendAssistantStreamingPlaceholder();
|
|
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) })
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(function() { return {}; });
|
|
body.bubble.remove();
|
|
addMessage('assistant', '오류: ' + (errData.error || res.statusText));
|
|
return;
|
|
}
|
|
|
|
const fullReply = await readSseStream(res, body);
|
|
body.bubble.classList.remove('chat-msg-streaming');
|
|
if (!fullReply.trim()) {
|
|
setStreamingContent(body.contentEl, '(응답이 비어 있습니다.)');
|
|
}
|
|
conversationHistory.push({ role: 'assistant', content: fullReply || '(빈 응답)' });
|
|
} catch (err) {
|
|
body.bubble.remove();
|
|
addMessage('assistant', '오류: ' + (err.message || '네트워크 오류'));
|
|
} finally {
|
|
if (body && body.statusEl) body.statusEl.hidden = true;
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
input.addEventListener('input', function() {
|
|
this.style.height = 'auto';
|
|
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
|
});
|
|
input.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
form.dispatchEvent(new Event('submit'));
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|