VIDraft/JGOS-Origin
1
1// JGOS — 전남광주 통합특별시 시민 AI 포털 (UI/UX v2)2const $ = (s) => document.querySelector(s);3const $$ = (s) => document.querySelectorAll(s);4 5const messagesEl = $("#messages");6const portalEl = $("#portal");7const inputEl = $("#input");8const sendBtn = $("#send-btn");9const attachBtn = $("#attach-btn");10const webBtn = $("#web-btn");11const fileInput = $("#file-input");12const attachRow = $("#attach-row");13const chatListEl = $("#chat-list");14const newChatBtn = $("#new-chat-btn");15const warnBar = $("#warn-bar");16const statusEl = $("#status");17const dragOverlay = $("#drag-overlay");18const sidebarEl = $("#sidebar");19const sidebarBackdrop = $("#sidebar-backdrop");20const hamburgerBtn = $("#hamburger-btn");21const sidebarToggle = $("#sidebar-toggle");22 23// marked.js24if (window.marked) {25 marked.setOptions({26 gfm: true, breaks: true,27 highlight: (code, lang) => {28 if (window.hljs && lang && hljs.getLanguage(lang)) {29 try { return hljs.highlight(code, { language: lang }).value; } catch (e) {}30 }31 return window.hljs ? hljs.highlightAuto(code).value : code;32 },33 });34}35 36let chats = JSON.parse(localStorage.getItem("jgos_chats") || "[]");37let activeChatId = null;38let isStreaming = false;39let pendingAttachments = [];40let webSearchOn = false; // 🔍 웹검색 토글 (네이버+웹 실시간 grounding)41 42const LEADER_KEYWORDS = ["시장", "구청장", "군수", "도지사", "단체장", "당선", "임기"];43const ALLOWED_EXTS = ["hwp", "hwpx", "pdf", "docx", "pptx", "xlsx", "xls", "csv", "txt", "md",44 "jpg", "jpeg", "png", "gif", "webp"];45 46function uuid() { return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); }47function escHtml(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); }48function renderSources(srcs) {49 if (!srcs || !srcs.length) return "";50 const items = srcs.slice(0, 8).map((s, i) =>51 `<a class="src-card" href="${escHtml(s.link)}" target="_blank" rel="noopener noreferrer">` +52 `<span class="src-num">${i + 1}</span>` +53 `<span class="src-body"><span class="src-title">${escHtml(s.title)}</span>` +54 `<span class="src-tag">${escHtml(s.src)}</span></span></a>`55 ).join("");56 return `<div class="src-wrap"><div class="src-head">🔍 참고한 웹 출처</div><div class="src-list">${items}</div></div>`;57}58function saveChats() {59 // vision base64 image_url은 localStorage에 저장하지 않음 (quota 초과 방지) — 메모리엔 유지(전송용)60 const slim = () => chats.map((c) => ({61 ...c,62 messages: c.messages.map((m) => {63 if (Array.isArray(m.content)) {64 return { ...m, content: m.content.map((it) =>65 it && it.type === "image_url"66 ? { type: "image_url", image_url: { url: "[stored-image]" } }67 : it68 ) };69 }70 return m;71 }),72 }));73 try {74 localStorage.setItem("jgos_chats", JSON.stringify(slim()));75 } catch (e) {76 // quota 초과 — 최근 8개 대화만 유지하고 재시도77 try {78 chats = chats.slice(-8);79 localStorage.setItem("jgos_chats", JSON.stringify(slim()));80 } catch (e2) {81 console.warn("saveChats failed (quota):", e2); // 저장 실패해도 대화는 계속82 }83 }84}85function todayKR() {86 const d = new Date();87 return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;88}89function fileSizeLabel(kb) {90 return kb >= 1024 ? `${(kb/1024).toFixed(1)}MB` : `${kb}KB`;91}92 93// LaTeX 수식 → 유니코드 (모델이 $\rightarrow$ 같은 표기 출력 시 사람이 읽을 수 있게)94const LATEX_MAP = {95 "rightarrow": "→", "to": "→", "Rightarrow": "⇒", "longrightarrow": "→",96 "leftarrow": "←", "Leftarrow": "⇐", "leftrightarrow": "↔", "Leftrightarrow": "⇔",97 "uparrow": "↑", "downarrow": "↓",98 "times": "×", "div": "÷", "pm": "±", "mp": "∓",99 "cdot": "·", "bullet": "•", "circ": "∘", "ast": "∗",100 "approx": "≈", "sim": "∼", "simeq": "≃", "cong": "≅", "equiv": "≡",101 "le": "≤", "leq": "≤", "ge": "≥", "geq": "≥", "neq": "≠", "ne": "≠",102 "ll": "≪", "gg": "≫", "subset": "⊂", "supset": "⊃", "subseteq": "⊆", "supseteq": "⊇",103 "in": "∈", "notin": "∉", "cap": "∩", "cup": "∪", "emptyset": "∅",104 "infty": "∞", "partial": "∂", "nabla": "∇", "forall": "∀", "exists": "∃",105 "sum": "∑", "prod": "∏", "int": "∫", "sqrt": "√",106 "alpha": "α", "beta": "β", "gamma": "γ", "delta": "δ", "epsilon": "ε", "varepsilon": "ε",107 "zeta": "ζ", "eta": "η", "theta": "θ", "vartheta": "ϑ", "iota": "ι", "kappa": "κ",108 "lambda": "λ", "mu": "μ", "nu": "ν", "xi": "ξ", "pi": "π", "varpi": "ϖ",109 "rho": "ρ", "varrho": "ϱ", "sigma": "σ", "varsigma": "ς", "tau": "τ",110 "upsilon": "υ", "phi": "φ", "varphi": "ϕ", "chi": "χ", "psi": "ψ", "omega": "ω",111 "Gamma": "Γ", "Delta": "Δ", "Theta": "Θ", "Lambda": "Λ", "Xi": "Ξ", "Pi": "Π",112 "Sigma": "Σ", "Upsilon": "Υ", "Phi": "Φ", "Psi": "Ψ", "Omega": "Ω",113 "degree": "°", "prime": "′", "dprime": "″",114 "ldots": "…", "cdots": "⋯", "vdots": "⋮", "ddots": "⋱",115 "%": "%", "&": "&", "_": "_", "#": "#",116};117 118function deLatex(text) {119 if (!text || typeof text !== "string") return text;120 // $\command$ → unicode (대부분의 케이스)121 text = text.replace(/\$\\([a-zA-Z]+)\$/g, (m, cmd) => LATEX_MAP[cmd] || m);122 // \(...\) inline math, \[...\] display math 내부에서도 단일 명령어 변환123 text = text.replace(/\\\(\s*\\([a-zA-Z]+)\s*\\\)/g, (m, cmd) => LATEX_MAP[cmd] || m);124 text = text.replace(/\\\[\s*\\([a-zA-Z]+)\s*\\\]/g, (m, cmd) => LATEX_MAP[cmd] || m);125 // bare \command (math env 밖) — 한국어 본문에서 빈번126 text = text.replace(/\\([a-zA-Z]+)(?![a-zA-Z])/g, (m, cmd) => LATEX_MAP[cmd] || m);127 // 단순 $math$ — math 내부의 \rightarrow 등 처리 (위에서 처리 못 한 케이스)128 text = text.replace(/\$([^$\n]{1,30})\$/g, (m, inner) => {129 const replaced = inner.replace(/\\([a-zA-Z]+)/g, (mm, cmd) => LATEX_MAP[cmd] || mm);130 // 변환됐고 LaTeX 흔적 없으면 $ 제거131 return /[\\{}]/.test(replaced) ? m : replaced;132 });133 return text;134}135 136function renderMarkdown(md) {137 if (!md) return "";138 md = deLatex(md); // ⭐ LaTeX → unicode 변환139 if (window.marked && window.DOMPurify) {140 return DOMPurify.sanitize(marked.parse(md));141 }142 return md.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\n/g, "<br>");143}144 145// ===== 포털 표시/숨김 =====146function togglePortal(show) {147 if (!portalEl) return;148 portalEl.style.display = show ? "" : "none";149}150 151function renderChatList() {152 chatListEl.innerHTML = "";153 for (const c of chats.slice().reverse()) {154 const li = document.createElement("li");155 li.textContent = c.title || "새 상담";156 li.dataset.id = c.id;157 if (c.id === activeChatId) li.classList.add("active");158 li.onclick = () => { loadChat(c.id); closeSidebarMobile(); };159 chatListEl.appendChild(li);160 }161}162 163function loadChat(id) {164 if (isStreaming) { stopStreaming(); isStreaming = false; setSendButtonMode("send"); }165 activeChatId = id;166 const chat = chats.find((c) => c.id === id);167 if (!chat) return;168 // _streaming 잔재 정리 (이전에 중단된 미완 메시지)169 chat.messages = chat.messages.filter(m => !(m._streaming && !m.content));170 messagesEl.innerHTML = "";171 const userOrAst = chat.messages.filter(m => m.role !== "system");172 togglePortal(userOrAst.length === 0);173 for (const m of userOrAst) {174 // vision content가 list면 텍스트 부분만 화면에 표시175 let displayContent = m.content;176 if (Array.isArray(m.content)) {177 const textItem = m.content.find(x => x.type === "text");178 const imgCount = m.content.filter(x => x.type === "image_url").length;179 displayContent = (textItem?.text || "") +180 (imgCount > 0 ? `\n\n🖼️ 이미지 ${imgCount}장 첨부` : "");181 }182 appendMessage(m.role, displayContent, m.attachments || [], { stored: true });183 }184 renderChatList();185 scrollToBottom();186}187 188function newChat() {189 if (isStreaming) { stopStreaming(); isStreaming = false; setSendButtonMode("send"); }190 const id = uuid();191 const chat = { id, title: "새 상담", messages: [], created: Date.now() };192 chats.push(chat);193 saveChats();194 activeChatId = id;195 messagesEl.innerHTML = "";196 togglePortal(true); // 첫 화면 = 포털197 renderChatList();198 inputEl.focus();199}200 201// ===== 메시지 렌더링 =====202function buildAttachmentCard(attachments, role) {203 if (!attachments || attachments.length === 0) return null;204 const card = document.createElement("div");205 card.className = "msg-attachments-card";206 for (const a of attachments) {207 const item = document.createElement("div");208 item.className = "attach-card-item";209 item.innerHTML = `210 <div class="ext-icon">${(a.ext || "?").toUpperCase().slice(0,4)}</div>211 <div class="meta">212 <div class="name">${escapeHTML(a.name)}</div>213 <div class="info">${fileSizeLabel(a.sizeKB)} · ${a.status || "처리완료"}</div>214 </div>215 <div class="status">✓ 분석 가능</div>216 `;217 card.appendChild(item);218 }219 return card;220}221function escapeHTML(s) {222 return (s || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);223}224 225function appendMessage(role, content, attachments, opts = {}) {226 const div = document.createElement("div");227 div.className = `msg ${role}`;228 if (content) div.innerHTML = renderMarkdown(content);229 230 if (attachments && attachments.length > 0) {231 const card = buildAttachmentCard(attachments, role);232 if (card) div.appendChild(card);233 }234 235 // assistant 메시지에 신뢰 배지 (저장된 메시지면 즉시 추가, 스트리밍 메시지는 종료 후 추가)236 if (role === "assistant" && opts.stored && content) {237 attachTrustBadges(div, opts);238 }239 240 if (window.hljs) {241 div.querySelectorAll("pre code").forEach((b) => { try { hljs.highlightElement(b); } catch (e) {} });242 }243 messagesEl.appendChild(div);244 togglePortal(false);245 scrollToBottom();246 return div;247}248 249function attachTrustBadges(asstDiv, opts = {}) {250 if (asstDiv.querySelector(".trust-badges")) return;251 const bar = document.createElement("div");252 bar.className = "trust-badges";253 const dayBadge = `<span class="tb gray">📅 기준일 ${todayKR()}</span>`;254 const sourceBadge = `<span class="tb">📚 출처 RAG</span>`;255 const govBadge = `<span class="tb green">🏛️ 공식 안내 062-120 확인</span>`;256 const electionBadge = `<span class="tb warn">⚠️ 선거·출범 이후 변동 가능</span>`;257 bar.innerHTML = dayBadge + sourceBadge + govBadge + (opts.leader ? electionBadge : "");258 asstDiv.appendChild(bar);259}260 261function scrollToBottom() {262 // messages-wrap (외부 스크롤) 사용263 const wrap = document.getElementById("messages-wrap");264 if (wrap) wrap.scrollTop = wrap.scrollHeight;265}266 267function isLeaderQuery(text) {268 return LEADER_KEYWORDS.some((k) => text.includes(k));269}270 271// ===== 파일 업로드 =====272attachBtn.onclick = () => fileInput.click();273if (webBtn) webBtn.onclick = () => {274 webSearchOn = !webSearchOn;275 webBtn.classList.toggle("on", webSearchOn);276 webBtn.setAttribute("aria-pressed", webSearchOn ? "true" : "false");277 webBtn.title = webSearchOn278 ? "웹검색 켜짐 — 최신 정보를 함께 검색합니다 (끄려면 클릭)"279 : "웹검색 — 켜면 네이버·웹에서 최신 정보를 함께 검색합니다";280};281fileInput.onchange = (e) => { handleFiles(e.target.files); fileInput.value = ""; };282 283// drag-drop284let dragCounter = 0; let dragTimeout = null;285function showDragOverlay() {286 dragOverlay.hidden = false;287 if (dragTimeout) clearTimeout(dragTimeout);288 dragTimeout = setTimeout(() => { dragCounter = 0; dragOverlay.hidden = true; }, 1500);289}290function hideDragOverlay() {291 dragCounter = 0; dragOverlay.hidden = true;292 if (dragTimeout) { clearTimeout(dragTimeout); dragTimeout = null; }293}294hideDragOverlay();295 296document.addEventListener("dragenter", (e) => {297 if (!e.dataTransfer || !e.dataTransfer.types.includes("Files")) return;298 e.preventDefault(); dragCounter++; showDragOverlay();299});300document.addEventListener("dragover", (e) => {301 if (e.dataTransfer && e.dataTransfer.types.includes("Files")) {302 e.preventDefault();303 if (!dragOverlay.hidden && dragTimeout) {304 clearTimeout(dragTimeout);305 dragTimeout = setTimeout(() => { dragCounter = 0; dragOverlay.hidden = true; }, 1500);306 }307 }308});309document.addEventListener("dragleave", () => {310 dragCounter--;311 if (dragCounter <= 0) hideDragOverlay();312});313document.addEventListener("drop", (e) => {314 e.preventDefault();315 hideDragOverlay();316 if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);317});318document.addEventListener("dragend", () => hideDragOverlay());319document.addEventListener("keydown", (e) => {320 if (e.key === "Escape") {321 if (!dragOverlay.hidden) hideDragOverlay();322 if (sidebarEl.classList.contains("open")) closeSidebarMobile();323 }324});325dragOverlay.addEventListener("click", hideDragOverlay);326window.addEventListener("blur", hideDragOverlay);327 328async function handleFiles(files) {329 for (const file of files) {330 const ext = (file.name.split(".").pop() || "").toLowerCase();331 if (!ALLOWED_EXTS.includes(ext)) {332 alert(`지원하지 않는 형식: .${ext}\n지원: HWP, HWPX, PDF, DOCX, PPTX, XLSX, XLS, CSV, TXT, MD, 이미지`);333 continue;334 }335 const sizeMB = file.size / 1024 / 1024;336 if (sizeMB > 30) { alert(`파일 너무 큼: ${file.name} (${sizeMB.toFixed(1)}MB > 30MB)`); continue; }337 338 const atch = {339 id: uuid(), name: file.name, ext, sizeKB: Math.round(file.size / 1024),340 text: "", status: "uploading",341 isImage: ["jpg","jpeg","png","gif","webp"].includes(ext),342 imageDataUrl: null,343 };344 pendingAttachments.push(atch);345 renderAttachPills();346 347 const fd = new FormData();348 fd.append("file", file);349 try {350 const r = await fetch("/api/upload", { method: "POST", body: fd });351 if (!r.ok) throw new Error(`HTTP ${r.status}`);352 const data = await r.json();353 atch.text = data.text || "";354 atch.meta = data.meta || {};355 atch.imageDataUrl = data.image_data_url || null; // ⭐ vision용356 atch.status = "ready";357 } catch (e) {358 atch.status = "error";359 atch.error = e.message;360 }361 renderAttachPills();362 }363}364 365function renderAttachPills() {366 attachRow.innerHTML = "";367 for (const a of pendingAttachments) {368 const pill = document.createElement("div");369 pill.className = "attach-pill" + (a.status === "uploading" ? " processing" : "");370 let icon = "📎";371 if (a.status === "uploading") icon = '<span class="spinner"></span>';372 if (a.status === "error") icon = "❌";373 if (a.status === "ready") icon = "✅";374 pill.innerHTML = `${icon} <span class="ext">${a.ext.toUpperCase()}</span> ${escapeHTML(a.name)} <small style="opacity:0.7">${fileSizeLabel(a.sizeKB)}</small>`;375 const x = document.createElement("button");376 x.className = "remove"; x.innerHTML = "✕"; x.title = "삭제";377 x.onclick = () => {378 pendingAttachments = pendingAttachments.filter((p) => p.id !== a.id);379 renderAttachPills();380 };381 pill.appendChild(x);382 attachRow.appendChild(pill);383 }384}385 386// ===== send =====387let currentAbort = null; // 진행 중 요청 취소용388let saveThrottle = null; // 부분 저장 throttle389 390function stopStreaming() {391 if (currentAbort) { try { currentAbort.abort(); } catch (e) {} currentAbort = null; }392}393 394async function send(presetText) {395 // 진행 중이면 새 질문 전에 이전 요청 정지 (버그: 질문 중 다른 액션 불가 해결)396 if (isStreaming) {397 stopStreaming();398 // abort가 finally까지 도달하도록 한 tick 양보399 await new Promise((r) => setTimeout(r, 60));400 }401 const text = (presetText !== undefined ? presetText : inputEl.value).trim();402 if (!text && pendingAttachments.length === 0) return;403 if (pendingAttachments.some((a) => a.status === "uploading")) {404 alert("파일 처리 중입니다. 잠시 후 다시 시도해주세요.");405 return;406 }407 408 if (!activeChatId) newChat();409 const chat = chats.find((c) => c.id === activeChatId);410 411 const leader = isLeaderQuery(text);412 warnBar.hidden = !leader;413 414 const sentAttachments = pendingAttachments.filter((a) => a.status === "ready");415 const imageAttachments = sentAttachments.filter((a) => a.isImage && a.imageDataUrl);416 const docAttachments = sentAttachments.filter((a) => !a.isImage);417 418 // 본문 = 문서 텍스트 prefix + 사용자 텍스트419 let textPart = text;420 if (docAttachments.length > 0) {421 const docTexts = docAttachments.map((a) =>422 `[첨부문서: ${a.name} (${a.ext.toUpperCase()})]\n${a.text || "(추출 실패)"}`423 ).join("\n\n---\n\n");424 textPart = `${docTexts}\n\n---\n\n${text}`;425 }426 427 // OpenAI vision format: 이미지 있으면 content를 list로428 let fullUserContent;429 if (imageAttachments.length > 0) {430 fullUserContent = [{ type: "text", text: textPart || "이 이미지를 분석해 주세요." }];431 for (const img of imageAttachments) {432 fullUserContent.push({ type: "image_url", image_url: { url: img.imageDataUrl } });433 }434 } else {435 fullUserContent = textPart;436 }437 438 const sentAttMeta = sentAttachments.map((a) => ({439 name: a.name, ext: a.ext, sizeKB: a.sizeKB,440 status: a.isImage ? "🖼️ vision 입력" : "📄 텍스트 추출",441 isImage: a.isImage,442 }));443 444 chat.messages.push({ role: "user", content: fullUserContent, attachments: sentAttMeta });445 if (chat.messages.length === 1 || chat.title === "새 상담") {446 chat.title = (text || sentAttachments[0]?.name || "새 상담").slice(0, 30);447 }448 // 화면 표시는 텍스트 + attachment 카드만 (base64 본문은 안 보임)449 const displayText = text || (imageAttachments.length > 0450 ? `🖼️ 이미지 ${imageAttachments.length}장 분석 요청`451 : `📎 ${sentAttachments.length}개 파일 첨부`);452 appendMessage("user", displayText, sentAttMeta);453 inputEl.value = ""; inputEl.style.height = "auto";454 pendingAttachments = []; renderAttachPills();455 saveChats(); renderChatList();456 togglePortal(false);457 458 isStreaming = true;459 setSendButtonMode("stop"); // 전송 버튼 → ⏹ 정지 버튼460 const asstDiv = appendMessage("assistant", "", []);461 asstDiv.innerHTML = '<span class="thinking-indicator"><span class="spinner"></span> JGOS가 답변을 준비하고 있습니다…</span>';462 let acc = "";463 let firstTokenReceived = false;464 let aborted = false;465 let lastSources = null;466 467 // ⭐ assistant 메시지를 미리 push (부분 저장 — 새로고침 시 답변 보존)468 const asstMsgIdx = chat.messages.push({ role: "assistant", content: "", _streaming: true }) - 1;469 const flushSave = () => {470 if (saveThrottle) return;471 saveThrottle = setTimeout(() => {472 chat.messages[asstMsgIdx].content = acc;473 saveChats();474 saveThrottle = null;475 }, 400);476 };477 478 currentAbort = new AbortController();479 try {480 const resp = await fetch("/api/chat", {481 method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" },482 signal: currentAbort.signal,483 body: JSON.stringify({484 messages: chat.messages.filter((m) => m.role !== "system" && !m._streaming),485 temperature: 0.3, max_tokens: 2048, web: webSearchOn,486 }),487 });488 if (!resp.ok) throw new Error(`HTTP ${resp.status}`);489 const reader = resp.body.getReader();490 const decoder = new TextDecoder("utf-8");491 let buffer = "";492 while (true) {493 const { value, done } = await reader.read();494 if (done) break;495 buffer += decoder.decode(value, { stream: true });496 const lines = buffer.split("\n");497 buffer = lines.pop() || "";498 for (const line of lines) {499 if (!line.startsWith("data: ")) continue;500 const data = line.slice(6).trim();501 if (data === "[DONE]") continue;502 try {503 const j = JSON.parse(data);504 if (j.error) {505 asstDiv.innerHTML = renderMarkdown(`❌ **오류**: ${j.error}`);506 statusEl.classList.add("error"); statusEl.textContent = "● 오류";507 throw new Error(j.error);508 }509 if (j.sources && Array.isArray(j.sources)) { lastSources = j.sources; }510 const delta = j.choices?.[0]?.delta?.content || "";511 if (delta) {512 if (!firstTokenReceived) { firstTokenReceived = true; asstDiv.innerHTML = ""; }513 acc += delta;514 asstDiv.innerHTML = renderMarkdown(acc) + '<span class="cursor"></span>';515 flushSave(); // 부분 저장516 scrollToBottom();517 }518 } catch (e) { /* skip non-JSON */ }519 }520 }521 asstDiv.innerHTML = renderMarkdown(acc) + renderSources(lastSources);522 if (window.hljs) {523 asstDiv.querySelectorAll("pre code").forEach((b) => { try { hljs.highlightElement(b); } catch (e) {} });524 }525 if (acc) attachTrustBadges(asstDiv, { leader });526 } catch (e) {527 if (e.name === "AbortError") {528 aborted = true;529 // 부분 답변 유지 + 중단 표시530 asstDiv.innerHTML = renderMarkdown(acc || "") +531 '<div style="margin-top:8px;font-size:12px;color:#8a94a3">⏹ 답변이 중단되었습니다.</div>';532 } else {533 if (!acc) asstDiv.innerHTML = renderMarkdown(`❌ **오류**: ${e.message || e}`);534 statusEl.classList.add("error"); statusEl.textContent = "● 오류";535 }536 } finally {537 // 최종 저장 (throttle 취소 + 확정)538 if (saveThrottle) { clearTimeout(saveThrottle); saveThrottle = null; }539 chat.messages[asstMsgIdx].content = acc;540 delete chat.messages[asstMsgIdx]._streaming;541 if (!acc && aborted) chat.messages.splice(asstMsgIdx, 1); // 빈 중단은 제거542 saveChats();543 isStreaming = false; currentAbort = null;544 setSendButtonMode("send");545 inputEl.focus();546 }547}548 549// 전송 ↔ 정지 버튼 토글550function setSendButtonMode(mode) {551 if (mode === "stop") {552 sendBtn.classList.add("stopping");553 sendBtn.title = "정지";554 sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';555 } else {556 sendBtn.classList.remove("stopping");557 sendBtn.title = "전송 (Enter)";558 sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>';559 }560}561 562// ===== 포털 카테고리 카드 + 추천 질문 버튼 =====563document.addEventListener("click", (e) => {564 // 카테고리 카드565 const catCard = e.target.closest(".cat-card");566 if (catCard) {567 if (catCard.dataset.attach === "true") {568 fileInput.click();569 return;570 }571 if (catCard.dataset.q) {572 send(catCard.dataset.q);573 return;574 }575 }576 // 추천 질문 버튼577 const quickBtn = e.target.closest(".quick-btn");578 if (quickBtn && quickBtn.dataset.q) {579 send(quickBtn.dataset.q);580 return;581 }582});583 584// ===== 이벤트 =====585// 전송 버튼: streaming 중이면 정지, 아니면 전송586sendBtn.onclick = () => {587 if (isStreaming) { stopStreaming(); return; }588 send();589};590inputEl.addEventListener("keydown", (e) => {591 if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }592});593inputEl.addEventListener("input", () => {594 inputEl.style.height = "auto";595 inputEl.style.height = Math.min(200, inputEl.scrollHeight) + "px";596});597newChatBtn.onclick = newChat;598 599// ===== 모바일 사이드바 =====600function openSidebarMobile() {601 sidebarEl.classList.add("open");602 sidebarBackdrop.hidden = false;603}604function closeSidebarMobile() {605 sidebarEl.classList.remove("open");606 sidebarBackdrop.hidden = true;607}608if (hamburgerBtn) hamburgerBtn.onclick = openSidebarMobile;609if (sidebarToggle) sidebarToggle.onclick = openSidebarMobile;610if (sidebarBackdrop) sidebarBackdrop.onclick = closeSidebarMobile;611 612// ===== 상단 탭 (💬 AI 상담 / 📑 무료 오피스 / 🎨 JGOS-Image / 🏛️ 행정 AX) =====613const viewChat = $("#view-chat");614const viewOffice = $("#view-office");615const viewImage = $("#view-image");616const viewEdu = $("#view-edu");617const viewAx = $("#view-ax");618const officeFrame = $("#office-frame");619let officeLoaded = false;620function showView(name) {621 if (viewChat) viewChat.style.display = name === "chat" ? "" : "none";622 if (viewOffice) viewOffice.hidden = name !== "office";623 if (viewImage) viewImage.hidden = name !== "image";624 if (viewEdu) viewEdu.hidden = name !== "edu";625 if (viewAx) viewAx.hidden = name !== "ax";626 // 무료 오피스는 same-origin iframe(/office) lazy 로드 (JGOS 헤더/탭 유지)627 if (name === "office" && !officeLoaded && officeFrame) { officeFrame.src = "/office/"; officeLoaded = true; }628}629function switchTab(name) {630 $$(".top-tab").forEach((t) => t.classList.toggle("active", t.dataset.view === name));631 showView(name);632}633$$(".top-tab").forEach((tab) => {634 tab.onclick = () => switchTab(tab.dataset.view);635});636 637// ===== 🎨 JGOS-Image 이미지 생성 =====638const imgPrompt = $("#img-prompt");639const imgGenBtn = $("#img-gen-btn");640const imgStatus = $("#img-status");641const imgResult = $("#img-result");642const imgRatio = $("#img-ratio");643// 비율 → (width, height). Qwen-Image 권장 해상도.644const RATIO_WH = { "1:1": [1024, 1024], "3:4": [896, 1152], "9:16": [768, 1344], "4:3": [1152, 896], "16:9": [1344, 768] };645let imgTimer = null;646$$(".img-ex").forEach((b) => { b.onclick = () => { if (imgPrompt) { imgPrompt.value = b.dataset.p; imgPrompt.focus(); } }; });647async function genImage() {648 const prompt = (imgPrompt && imgPrompt.value || "").trim();649 if (!prompt) { if (imgPrompt) imgPrompt.focus(); return; }650 const [w, h] = RATIO_WH[(imgRatio && imgRatio.value) || "1:1"] || [1024, 1024];651 if (imgGenBtn) imgGenBtn.disabled = true;652 if (imgResult) imgResult.innerHTML = "";653 // 인터랙티브 프로그레스: 모래시계 애니메이션 + 경과 초 카운터654 let sec = 0;655 if (imgStatus) {656 imgStatus.hidden = false;657 imgStatus.innerHTML = '<span class="img-hourglass">⏳</span> 이미지를 그리는 중입니다 — <strong>약 10초만 기다려주세요</strong> (<b id="img-sec">0</b>초)';658 }659 if (imgTimer) clearInterval(imgTimer);660 imgTimer = setInterval(() => { sec++; const e = document.getElementById("img-sec"); if (e) e.textContent = sec; }, 1000);661 try {662 const r = await fetch("/api/image", {663 method: "POST", headers: { "Content-Type": "application/json" },664 body: JSON.stringify({ prompt, width: w, height: h }),665 });666 const d = await r.json();667 if (d.image) {668 if (imgResult) imgResult.innerHTML = `<img src="${d.image}" alt="생성 이미지" />` +669 `<a class="img-dl" href="${d.image}" download="jgos-image.png">⬇ 이미지 저장</a>`;670 if (imgStatus) imgStatus.hidden = true;671 } else {672 if (imgStatus) { imgStatus.hidden = false; imgStatus.textContent = "⚠️ " + (d.error || "이미지 생성 서버 준비 중입니다."); }673 }674 } catch (e) {675 if (imgStatus) { imgStatus.hidden = false; imgStatus.textContent = "⚠️ 생성 실패: " + e.message; }676 } finally {677 if (imgTimer) { clearInterval(imgTimer); imgTimer = null; }678 if (imgGenBtn) imgGenBtn.disabled = false;679 }680}681if (imgGenBtn) imgGenBtn.onclick = genImage;682 683// ===== 🎓 AI 교육 — AI 선생님 =====684const eduInput = $("#edu-input");685const eduSend = $("#edu-send");686const eduMessages = $("#edu-messages");687const eduGoto = $("#edu-goto");688const EDU_SYSTEM = "당신은 전남광주 통합특별시 'AI 시민 대학'의 친절한 AI 선생님입니다.\n【절대 규칙 — 위반 금지】\n1) 반드시 한국어로만 답합니다. 영어 단어·영어 정의·'Technical definition'·'Simplified'·'Metaphor' 같은 영어 메모를 절대 출력하지 마세요.\n2) 개요·계획·사고과정(들여쓰기 별표로 된 영어 정리)을 쓰지 말고, 처음부터 완성된 한국어 문장으로 바로 설명합니다.\n3) 따뜻한 존댓말, 쉬운 비유, 어르신도 이해하도록 천천히, 전문용어는 풀어서.\n예시 시작) \"안녕하세요! AI가 무엇인지 쉽게 알려드릴게요. AI는 마치 똑똑한 비서와 같아서…\"";689const GO_LABEL = { chat: "AI 상담", office: "무료 오피스", image: "이미지 생성" };690let eduBusy = false;691function eduCleanup(md) {692 // 모델이 흘리는 영어 사고/개요 줄 제거 (Technical definition, Metaphor, 들여쓰기 영어 불릿 등)693 return md.split("\n").filter((line) => {694 const t = line.trim();695 if (!t) return true;696 if (/^[*\-]?\s*\*?(Technical|Simplified|Metaphor|Definition|Note|Step\s*\d|Example|Here'?s|Okay|First,|Now,|Let'?s|I'?ll|I will)/i.test(t)) return false;697 if (/^\s{2,}[*\-]/.test(line) && !/[가-힣]/.test(t)) return false; // 한글 없는 들여쓰기 영어 불릿698 return true;699 }).join("\n").replace(/\n{3,}/g, "\n\n").trim();700}701function eduRender(md) {702 const c = eduCleanup(md);703 try { return DOMPurify.sanitize(marked.parse(c)); }704 catch { return c.replace(/</g, "<").replace(/\n/g, "<br>"); }705}706async function askTeacher(q, go) {707 if (!q || eduBusy) return;708 eduBusy = true;709 if (eduGoto) eduGoto.hidden = true;710 eduMessages.insertAdjacentHTML("beforeend", `<div class="edu-msg edu-user">${q.replace(/</g, "<")}</div>`);711 const ai = document.createElement("div");712 ai.className = "edu-msg edu-ai";713 ai.innerHTML = '<span class="edu-typing">🧑🏫 선생님이 답변을 준비하고 있어요…</span>';714 eduMessages.appendChild(ai);715 eduMessages.scrollTop = eduMessages.scrollHeight;716 let acc = "";717 try {718 const r = await fetch("/api/chat", {719 method: "POST", headers: { "Content-Type": "application/json" },720 body: JSON.stringify({ messages: [{ role: "user", content: q + "\n\n(어르신도 이해하실 수 있도록, 친근한 선생님처럼 아주 쉽게 한국어로 풀어서 설명해 주세요. 영어·개요·목차 없이 바로 설명만.)" }], max_tokens: 1500, temperature: 0.4 }),721 });722 const reader = r.body.getReader(); const dec = new TextDecoder(); let buf = "";723 while (true) {724 const { done, value } = await reader.read(); if (done) break;725 buf += dec.decode(value, { stream: true });726 let nl;727 while ((nl = buf.indexOf("\n")) >= 0) {728 const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);729 if (!line.startsWith("data:")) continue;730 const d = line.slice(5).trim(); if (d === "[DONE]" || !d) continue;731 try { const j = JSON.parse(d); const c = j.choices?.[0]?.delta?.content || ""; if (c) { acc += c; ai.innerHTML = eduRender(acc); eduMessages.scrollTop = eduMessages.scrollHeight; } } catch {}732 }733 }734 if (!acc) ai.innerHTML = "답변을 불러오지 못했어요. 잠시 후 다시 시도해 주세요.";735 } catch (e) { ai.innerHTML = "오류가 발생했어요: " + e.message; }736 if (go && GO_LABEL[go] && eduGoto) {737 eduGoto.hidden = false;738 eduGoto.innerHTML = `<button class="edu-go-btn">▶ 지금 바로 '${GO_LABEL[go]}'에서 해보기</button>`;739 eduGoto.firstChild.onclick = () => switchTab(go);740 }741 eduBusy = false;742}743$$(".edu-card").forEach((c) => { c.onclick = () => askTeacher(c.dataset.q, c.dataset.go || ""); });744function eduAsk() { const q = (eduInput && eduInput.value || "").trim(); if (q) { askTeacher(q, ""); eduInput.value = ""; } }745if (eduSend) eduSend.onclick = eduAsk;746if (eduInput) eduInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); eduAsk(); } });747 748// ===== 히어로 드롭존 (방안1) =====749const heroDropzone = $("#hero-dropzone");750if (heroDropzone) heroDropzone.onclick = () => fileInput.click();751 752// ============================================================753// ===== 🏛️ 행정 AX 탭 (2026-06-10) =====754// ============================================================755 756// 데모 파일 목록 (static/demo/ 에 업로드된 파일들)757const DEMO_FILES_EXCEL = [758 {name:"01_광주동구_복지현황.xlsx", label:"광주 동구 (XLSX)", ext:"xlsx"},759 {name:"02_전남도청_복지수급.xlsx", label:"전남 도청 (XLSX)", ext:"xlsx"},760 {name:"03_순천시_복지통계.xlsx", label:"순천시 (XLSX)", ext:"xlsx"},761 {name:"04_목포시_복지대장.xlsx", label:"목포시 (XLSX)", ext:"xlsx"},762 {name:"05_여수시_복지현황.xlsx", label:"여수시 (XLSX)", ext:"xlsx"},763];764const DEMO_FILES_FORMAT = [765 {name:"복지신청_광주동구.hwpx", label:"광주동구 공문", ext:"hwpx"},766 {name:"복지신청_전남도청.xlsx", label:"전남도청 Excel", ext:"xlsx"},767 {name:"복지수급현황.csv", label:"수급현황 CSV", ext:"csv"},768 {name:"사업설명회_자료.pptx", label:"사업설명 PPT", ext:"pptx"},769 {name:"민원접수대장.docx", label:"민원 Word", ext:"docx"},770 {name:"공문_복지공람.pdf", label:"복지 공문 PDF", ext:"pdf"},771 {name:"복지신청_한글.hwpx", label:"한글 신청서", ext:"hwpx"},772];773 774function encodeFilename(s) {775 return encodeURIComponent(s);776}777 778function renderFileChips(containerId, demoFiles) {779 const container = $("#" + containerId);780 if (!container) return;781 container.innerHTML = demoFiles.map(f => `782 <div class="ax-file-chip" data-demo="${f.name}" data-label="${f.label}">783 <span class="ax-ext-badge ax-ext-${f.ext}">${f.ext.toUpperCase()}</span>784 ${f.label}785 <span style="opacity:0.5;font-size:0.75rem">👁</span>786 </div>787 `).join("");788 container.querySelectorAll(".ax-file-chip").forEach(chip => {789 chip.onclick = () => axPreviewDemo(chip.dataset.demo, chip.dataset.label);790 });791}792 793async function axPreviewDemo(filename, label) {794 const modal = $("#ax-modal");795 const body = $("#ax-modal-body");796 const title = $("#ax-modal-title");797 if (!modal || !body) return;798 if (title) title.textContent = "\uD83D\uDCC4 " + label + " \u2014 \uBBF8\uB9AC\uBCF4\uAE30";799 body.innerHTML = '<div class="ax-loading">\u23F3 \uBD88\uB7EC\uC624\uB294 \uC911...</div>';800 modal.hidden = false;801 try {802 const r = await fetch("/api/demo/preview/" + encodeURIComponent(filename));803 if (!r.ok) { body.innerHTML = "<p style='color:#b91c1c'>\uBBF8\uB9AC\uBCF4\uAE30 \uC2E4\uD328 (" + r.status + ")</p>"; return; }804 const d = await r.json();805 body.innerHTML = (d.rows > 0) ? (d.html || "<p>\uBBF8\uB9AC\uBCF4\uAE30 \uC5C6\uC74C</p>") : "<p style='color:#92400e'>\u26A0\uFE0F \uD30C\uC2F1 \uC2E4\uD328</p>";806 } catch(e) {807 body.innerHTML = "<p style='color:red'>\uC624\uB958: " + (e && e.message ? e.message : e) + "</p>";808 }809}810 811// 모달 닫기812const axModalClose = $("#ax-modal-close");813const axModal = $("#ax-modal");814if (axModalClose) axModalClose.onclick = () => { if (axModal) axModal.hidden = true; };815if (axModal) axModal.onclick = (e) => { if (e.target === axModal) axModal.hidden = true; };816 817// 파일 chip 렌더링818renderFileChips("ax-excel-demos", DEMO_FILES_EXCEL);819renderFileChips("ax-format-demos", DEMO_FILES_FORMAT);820 821// ── Demo 1: Excel 통합 ──822const axExcelInput = $("#ax-excel-input");823const axExcelDrop = $("#ax-excel-drop");824const axExcelRun = $("#ax-excel-run");825const axExcelCount = $("#ax-excel-count");826const axExcelResult = $("#ax-excel-result");827let axExcelFiles = [];828 829function axExcelUpdateUI() {830 if (axExcelCount) axExcelCount.textContent = axExcelFiles.length ? `${axExcelFiles.length}개 파일 선택됨` : "";831 if (axExcelRun) axExcelRun.disabled = axExcelFiles.length === 0;832}833if (axExcelDrop) {834 axExcelDrop.onclick = (e) => { if (e.target === axExcelDrop || axExcelDrop.contains(e.target)) axExcelInput && axExcelInput.click(); };835 axExcelDrop.ondragover = e => { e.preventDefault(); axExcelDrop.classList.add("dragover"); };836 axExcelDrop.ondragleave = () => axExcelDrop.classList.remove("dragover");837 axExcelDrop.ondrop = e => {838 e.preventDefault(); axExcelDrop.classList.remove("dragover");839 axExcelFiles = [...axExcelFiles, ...(e.dataTransfer.files || [])];840 axExcelUpdateUI();841 };842}843if (axExcelInput) axExcelInput.onchange = () => {844 axExcelFiles = [...axExcelFiles, ...(axExcelInput.files || [])];845 axExcelUpdateUI();846};847if (axExcelRun) axExcelRun.onclick = async () => {848 if (!axExcelFiles.length) return;849 axExcelRun.disabled = true;850 axExcelRun.innerHTML = "⏳ 처리 중...";851 if (axExcelResult) { axExcelResult.hidden = false; axExcelResult.innerHTML = '<div class="ax-loading">⏳ 통합 중...</div>'; }852 try {853 const fd = new FormData();854 axExcelFiles.forEach(f => fd.append("files", f));855 const r = await fetch("/api/excel/merge", {method: "POST", body: fd});856 const d = await r.json();857 if (axExcelResult) {858 const srcHtml = (d.sources || []).map(s => `<span class="ax-source-chip">📄 ${s.파일} (${s.건수}건)</span>`).join("");859 axExcelResult.innerHTML = `<div class="ax-source-list">${srcHtml}</div>${d.html || ""}`;860 }861 } catch(e) {862 if (axExcelResult) axExcelResult.innerHTML = `<p style="color:red">오류: ${e.message}</p>`;863 }864 axExcelRun.disabled = false;865 axExcelRun.innerHTML = "<span>⚡</span> 통합 실행";866};867 868// ── Demo 2: 이종 포맷 통합 ──869const axFormatInput = $("#ax-format-input");870const axFormatDrop = $("#ax-format-drop");871const axFormatRun = $("#ax-format-run");872const axFormatCount = $("#ax-format-count");873const axFormatResult = $("#ax-format-result");874let axFormatFiles = [];875 876function axFormatUpdateUI() {877 if (axFormatCount) axFormatCount.textContent = axFormatFiles.length ? `${axFormatFiles.length}개 파일 선택됨` : "";878 if (axFormatRun) axFormatRun.disabled = axFormatFiles.length === 0;879}880if (axFormatDrop) {881 axFormatDrop.onclick = (e) => { if (e.target === axFormatDrop || axFormatDrop.contains(e.target)) axFormatInput && axFormatInput.click(); };882 axFormatDrop.ondragover = e => { e.preventDefault(); axFormatDrop.classList.add("dragover"); };883 axFormatDrop.ondragleave = () => axFormatDrop.classList.remove("dragover");884 axFormatDrop.ondrop = e => {885 e.preventDefault(); axFormatDrop.classList.remove("dragover");886 axFormatFiles = [...axFormatFiles, ...(e.dataTransfer.files || [])];887 axFormatUpdateUI();888 };889}890if (axFormatInput) axFormatInput.onchange = () => {891 axFormatFiles = [...axFormatFiles, ...(axFormatInput.files || [])];892 axFormatUpdateUI();893};894if (axFormatRun) axFormatRun.onclick = async () => {895 if (!axFormatFiles.length) return;896 axFormatRun.disabled = true;897 axFormatRun.innerHTML = "⏳ 처리 중...";898 if (axFormatResult) { axFormatResult.hidden = false; axFormatResult.innerHTML = '<div class="ax-loading">⏳ 통합 중...</div>'; }899 try {900 const fd = new FormData();901 axFormatFiles.forEach(f => fd.append("files", f));902 const r = await fetch("/api/files/merge-formats", {method: "POST", body: fd});903 const d = await r.json();904 if (axFormatResult) {905 const srcHtml = (d.sources || []).map(s => `<span class="ax-source-chip">${s.포맷} ${s.파일} (${s.건수}건)</span>`).join("");906 axFormatResult.innerHTML = `<div class="ax-source-list">${srcHtml}</div>${d.html || ""}`;907 }908 } catch(e) {909 if (axFormatResult) axFormatResult.innerHTML = `<p style="color:red">오류: ${e.message}</p>`;910 }911 axFormatRun.disabled = false;912 axFormatRun.innerHTML = "<span>⚡</span> 통합 실행";913};914 915// ===== health =====916fetch("/api/health").then((r) => r.json()).then((d) => {917 statusEl.textContent = "● " + ("JGOS-31B-Citizen");918}).catch(() => { statusEl.textContent = "● 연결 안 됨"; statusEl.classList.add("error"); });919 920// ===== init =====921if (chats.length === 0) newChat();922else loadChat(chats[chats.length - 1].id);923 