// JGOS — 전남광주 통합특별시 시민 AI 포털 (UI/UX v2) const $ = (s) => document.querySelector(s); const $$ = (s) => document.querySelectorAll(s); const messagesEl = $("#messages"); const portalEl = $("#portal"); const inputEl = $("#input"); const sendBtn = $("#send-btn"); const attachBtn = $("#attach-btn"); const webBtn = $("#web-btn"); const fileInput = $("#file-input"); const attachRow = $("#attach-row"); const chatListEl = $("#chat-list"); const newChatBtn = $("#new-chat-btn"); const warnBar = $("#warn-bar"); const statusEl = $("#status"); const dragOverlay = $("#drag-overlay"); const sidebarEl = $("#sidebar"); const sidebarBackdrop = $("#sidebar-backdrop"); const hamburgerBtn = $("#hamburger-btn"); const sidebarToggle = $("#sidebar-toggle"); // marked.js if (window.marked) { marked.setOptions({ gfm: true, breaks: true, highlight: (code, lang) => { if (window.hljs && lang && hljs.getLanguage(lang)) { try { return hljs.highlight(code, { language: lang }).value; } catch (e) {} } return window.hljs ? hljs.highlightAuto(code).value : code; }, }); } let chats = JSON.parse(localStorage.getItem("jgos_chats") || "[]"); let activeChatId = null; let isStreaming = false; let pendingAttachments = []; let webSearchOn = false; // 🔍 웹검색 토글 (네이버+웹 실시간 grounding) const LEADER_KEYWORDS = ["시장", "구청장", "군수", "도지사", "단체장", "당선", "임기"]; const ALLOWED_EXTS = ["hwp", "hwpx", "pdf", "docx", "pptx", "xlsx", "xls", "csv", "txt", "md", "jpg", "jpeg", "png", "gif", "webp"]; function uuid() { return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); } function escHtml(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); } function renderSources(srcs) { if (!srcs || !srcs.length) return ""; const items = srcs.slice(0, 8).map((s, i) => `` + `${i + 1}` + `${escHtml(s.title)}` + `${escHtml(s.src)}` ).join(""); return `
🔍 참고한 웹 출처
${items}
`; } function saveChats() { // vision base64 image_url은 localStorage에 저장하지 않음 (quota 초과 방지) — 메모리엔 유지(전송용) const slim = () => chats.map((c) => ({ ...c, messages: c.messages.map((m) => { if (Array.isArray(m.content)) { return { ...m, content: m.content.map((it) => it && it.type === "image_url" ? { type: "image_url", image_url: { url: "[stored-image]" } } : it ) }; } return m; }), })); try { localStorage.setItem("jgos_chats", JSON.stringify(slim())); } catch (e) { // quota 초과 — 최근 8개 대화만 유지하고 재시도 try { chats = chats.slice(-8); localStorage.setItem("jgos_chats", JSON.stringify(slim())); } catch (e2) { console.warn("saveChats failed (quota):", e2); // 저장 실패해도 대화는 계속 } } } function todayKR() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function fileSizeLabel(kb) { return kb >= 1024 ? `${(kb/1024).toFixed(1)}MB` : `${kb}KB`; } // LaTeX 수식 → 유니코드 (모델이 $\rightarrow$ 같은 표기 출력 시 사람이 읽을 수 있게) const LATEX_MAP = { "rightarrow": "→", "to": "→", "Rightarrow": "⇒", "longrightarrow": "→", "leftarrow": "←", "Leftarrow": "⇐", "leftrightarrow": "↔", "Leftrightarrow": "⇔", "uparrow": "↑", "downarrow": "↓", "times": "×", "div": "÷", "pm": "±", "mp": "∓", "cdot": "·", "bullet": "•", "circ": "∘", "ast": "∗", "approx": "≈", "sim": "∼", "simeq": "≃", "cong": "≅", "equiv": "≡", "le": "≤", "leq": "≤", "ge": "≥", "geq": "≥", "neq": "≠", "ne": "≠", "ll": "≪", "gg": "≫", "subset": "⊂", "supset": "⊃", "subseteq": "⊆", "supseteq": "⊇", "in": "∈", "notin": "∉", "cap": "∩", "cup": "∪", "emptyset": "∅", "infty": "∞", "partial": "∂", "nabla": "∇", "forall": "∀", "exists": "∃", "sum": "∑", "prod": "∏", "int": "∫", "sqrt": "√", "alpha": "α", "beta": "β", "gamma": "γ", "delta": "δ", "epsilon": "ε", "varepsilon": "ε", "zeta": "ζ", "eta": "η", "theta": "θ", "vartheta": "ϑ", "iota": "ι", "kappa": "κ", "lambda": "λ", "mu": "μ", "nu": "ν", "xi": "ξ", "pi": "π", "varpi": "ϖ", "rho": "ρ", "varrho": "ϱ", "sigma": "σ", "varsigma": "ς", "tau": "τ", "upsilon": "υ", "phi": "φ", "varphi": "ϕ", "chi": "χ", "psi": "ψ", "omega": "ω", "Gamma": "Γ", "Delta": "Δ", "Theta": "Θ", "Lambda": "Λ", "Xi": "Ξ", "Pi": "Π", "Sigma": "Σ", "Upsilon": "Υ", "Phi": "Φ", "Psi": "Ψ", "Omega": "Ω", "degree": "°", "prime": "′", "dprime": "″", "ldots": "…", "cdots": "⋯", "vdots": "⋮", "ddots": "⋱", "%": "%", "&": "&", "_": "_", "#": "#", }; function deLatex(text) { if (!text || typeof text !== "string") return text; // $\command$ → unicode (대부분의 케이스) text = text.replace(/\$\\([a-zA-Z]+)\$/g, (m, cmd) => LATEX_MAP[cmd] || m); // \(...\) inline math, \[...\] display math 내부에서도 단일 명령어 변환 text = text.replace(/\\\(\s*\\([a-zA-Z]+)\s*\\\)/g, (m, cmd) => LATEX_MAP[cmd] || m); text = text.replace(/\\\[\s*\\([a-zA-Z]+)\s*\\\]/g, (m, cmd) => LATEX_MAP[cmd] || m); // bare \command (math env 밖) — 한국어 본문에서 빈번 text = text.replace(/\\([a-zA-Z]+)(?![a-zA-Z])/g, (m, cmd) => LATEX_MAP[cmd] || m); // 단순 $math$ — math 내부의 \rightarrow 등 처리 (위에서 처리 못 한 케이스) text = text.replace(/\$([^$\n]{1,30})\$/g, (m, inner) => { const replaced = inner.replace(/\\([a-zA-Z]+)/g, (mm, cmd) => LATEX_MAP[cmd] || mm); // 변환됐고 LaTeX 흔적 없으면 $ 제거 return /[\\{}]/.test(replaced) ? m : replaced; }); return text; } function renderMarkdown(md) { if (!md) return ""; md = deLatex(md); // ⭐ LaTeX → unicode 변환 if (window.marked && window.DOMPurify) { return DOMPurify.sanitize(marked.parse(md)); } return md.replace(/&/g, "&").replace(//g, ">").replace(/\n/g, "
"); } // ===== 포털 표시/숨김 ===== function togglePortal(show) { if (!portalEl) return; portalEl.style.display = show ? "" : "none"; } function renderChatList() { chatListEl.innerHTML = ""; for (const c of chats.slice().reverse()) { const li = document.createElement("li"); li.textContent = c.title || "새 상담"; li.dataset.id = c.id; if (c.id === activeChatId) li.classList.add("active"); li.onclick = () => { loadChat(c.id); closeSidebarMobile(); }; chatListEl.appendChild(li); } } function loadChat(id) { if (isStreaming) { stopStreaming(); isStreaming = false; setSendButtonMode("send"); } activeChatId = id; const chat = chats.find((c) => c.id === id); if (!chat) return; // _streaming 잔재 정리 (이전에 중단된 미완 메시지) chat.messages = chat.messages.filter(m => !(m._streaming && !m.content)); messagesEl.innerHTML = ""; const userOrAst = chat.messages.filter(m => m.role !== "system"); togglePortal(userOrAst.length === 0); for (const m of userOrAst) { // vision content가 list면 텍스트 부분만 화면에 표시 let displayContent = m.content; if (Array.isArray(m.content)) { const textItem = m.content.find(x => x.type === "text"); const imgCount = m.content.filter(x => x.type === "image_url").length; displayContent = (textItem?.text || "") + (imgCount > 0 ? `\n\n🖼️ 이미지 ${imgCount}장 첨부` : ""); } appendMessage(m.role, displayContent, m.attachments || [], { stored: true }); } renderChatList(); scrollToBottom(); } function newChat() { if (isStreaming) { stopStreaming(); isStreaming = false; setSendButtonMode("send"); } const id = uuid(); const chat = { id, title: "새 상담", messages: [], created: Date.now() }; chats.push(chat); saveChats(); activeChatId = id; messagesEl.innerHTML = ""; togglePortal(true); // 첫 화면 = 포털 renderChatList(); inputEl.focus(); } // ===== 메시지 렌더링 ===== function buildAttachmentCard(attachments, role) { if (!attachments || attachments.length === 0) return null; const card = document.createElement("div"); card.className = "msg-attachments-card"; for (const a of attachments) { const item = document.createElement("div"); item.className = "attach-card-item"; item.innerHTML = `
${(a.ext || "?").toUpperCase().slice(0,4)}
${escapeHTML(a.name)}
${fileSizeLabel(a.sizeKB)} · ${a.status || "처리완료"}
✓ 분석 가능
`; card.appendChild(item); } return card; } function escapeHTML(s) { return (s || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function appendMessage(role, content, attachments, opts = {}) { const div = document.createElement("div"); div.className = `msg ${role}`; if (content) div.innerHTML = renderMarkdown(content); if (attachments && attachments.length > 0) { const card = buildAttachmentCard(attachments, role); if (card) div.appendChild(card); } // assistant 메시지에 신뢰 배지 (저장된 메시지면 즉시 추가, 스트리밍 메시지는 종료 후 추가) if (role === "assistant" && opts.stored && content) { attachTrustBadges(div, opts); } if (window.hljs) { div.querySelectorAll("pre code").forEach((b) => { try { hljs.highlightElement(b); } catch (e) {} }); } messagesEl.appendChild(div); togglePortal(false); scrollToBottom(); return div; } function attachTrustBadges(asstDiv, opts = {}) { if (asstDiv.querySelector(".trust-badges")) return; const bar = document.createElement("div"); bar.className = "trust-badges"; const dayBadge = `📅 기준일 ${todayKR()}`; const sourceBadge = `📚 출처 RAG`; const govBadge = `🏛️ 공식 안내 062-120 확인`; const electionBadge = `⚠️ 선거·출범 이후 변동 가능`; bar.innerHTML = dayBadge + sourceBadge + govBadge + (opts.leader ? electionBadge : ""); asstDiv.appendChild(bar); } function scrollToBottom() { // messages-wrap (외부 스크롤) 사용 const wrap = document.getElementById("messages-wrap"); if (wrap) wrap.scrollTop = wrap.scrollHeight; } function isLeaderQuery(text) { return LEADER_KEYWORDS.some((k) => text.includes(k)); } // ===== 파일 업로드 ===== attachBtn.onclick = () => fileInput.click(); if (webBtn) webBtn.onclick = () => { webSearchOn = !webSearchOn; webBtn.classList.toggle("on", webSearchOn); webBtn.setAttribute("aria-pressed", webSearchOn ? "true" : "false"); webBtn.title = webSearchOn ? "웹검색 켜짐 — 최신 정보를 함께 검색합니다 (끄려면 클릭)" : "웹검색 — 켜면 네이버·웹에서 최신 정보를 함께 검색합니다"; }; fileInput.onchange = (e) => { handleFiles(e.target.files); fileInput.value = ""; }; // drag-drop let dragCounter = 0; let dragTimeout = null; function showDragOverlay() { dragOverlay.hidden = false; if (dragTimeout) clearTimeout(dragTimeout); dragTimeout = setTimeout(() => { dragCounter = 0; dragOverlay.hidden = true; }, 1500); } function hideDragOverlay() { dragCounter = 0; dragOverlay.hidden = true; if (dragTimeout) { clearTimeout(dragTimeout); dragTimeout = null; } } hideDragOverlay(); document.addEventListener("dragenter", (e) => { if (!e.dataTransfer || !e.dataTransfer.types.includes("Files")) return; e.preventDefault(); dragCounter++; showDragOverlay(); }); document.addEventListener("dragover", (e) => { if (e.dataTransfer && e.dataTransfer.types.includes("Files")) { e.preventDefault(); if (!dragOverlay.hidden && dragTimeout) { clearTimeout(dragTimeout); dragTimeout = setTimeout(() => { dragCounter = 0; dragOverlay.hidden = true; }, 1500); } } }); document.addEventListener("dragleave", () => { dragCounter--; if (dragCounter <= 0) hideDragOverlay(); }); document.addEventListener("drop", (e) => { e.preventDefault(); hideDragOverlay(); if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files); }); document.addEventListener("dragend", () => hideDragOverlay()); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { if (!dragOverlay.hidden) hideDragOverlay(); if (sidebarEl.classList.contains("open")) closeSidebarMobile(); } }); dragOverlay.addEventListener("click", hideDragOverlay); window.addEventListener("blur", hideDragOverlay); async function handleFiles(files) { for (const file of files) { const ext = (file.name.split(".").pop() || "").toLowerCase(); if (!ALLOWED_EXTS.includes(ext)) { alert(`지원하지 않는 형식: .${ext}\n지원: HWP, HWPX, PDF, DOCX, PPTX, XLSX, XLS, CSV, TXT, MD, 이미지`); continue; } const sizeMB = file.size / 1024 / 1024; if (sizeMB > 30) { alert(`파일 너무 큼: ${file.name} (${sizeMB.toFixed(1)}MB > 30MB)`); continue; } const atch = { id: uuid(), name: file.name, ext, sizeKB: Math.round(file.size / 1024), text: "", status: "uploading", isImage: ["jpg","jpeg","png","gif","webp"].includes(ext), imageDataUrl: null, }; pendingAttachments.push(atch); renderAttachPills(); const fd = new FormData(); fd.append("file", file); try { const r = await fetch("/api/upload", { method: "POST", body: fd }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json(); atch.text = data.text || ""; atch.meta = data.meta || {}; atch.imageDataUrl = data.image_data_url || null; // ⭐ vision용 atch.status = "ready"; } catch (e) { atch.status = "error"; atch.error = e.message; } renderAttachPills(); } } function renderAttachPills() { attachRow.innerHTML = ""; for (const a of pendingAttachments) { const pill = document.createElement("div"); pill.className = "attach-pill" + (a.status === "uploading" ? " processing" : ""); let icon = "📎"; if (a.status === "uploading") icon = ''; if (a.status === "error") icon = "❌"; if (a.status === "ready") icon = "✅"; pill.innerHTML = `${icon} ${a.ext.toUpperCase()} ${escapeHTML(a.name)} ${fileSizeLabel(a.sizeKB)}`; const x = document.createElement("button"); x.className = "remove"; x.innerHTML = "✕"; x.title = "삭제"; x.onclick = () => { pendingAttachments = pendingAttachments.filter((p) => p.id !== a.id); renderAttachPills(); }; pill.appendChild(x); attachRow.appendChild(pill); } } // ===== send ===== let currentAbort = null; // 진행 중 요청 취소용 let saveThrottle = null; // 부분 저장 throttle function stopStreaming() { if (currentAbort) { try { currentAbort.abort(); } catch (e) {} currentAbort = null; } } async function send(presetText) { // 진행 중이면 새 질문 전에 이전 요청 정지 (버그: 질문 중 다른 액션 불가 해결) if (isStreaming) { stopStreaming(); // abort가 finally까지 도달하도록 한 tick 양보 await new Promise((r) => setTimeout(r, 60)); } const text = (presetText !== undefined ? presetText : inputEl.value).trim(); if (!text && pendingAttachments.length === 0) return; if (pendingAttachments.some((a) => a.status === "uploading")) { alert("파일 처리 중입니다. 잠시 후 다시 시도해주세요."); return; } if (!activeChatId) newChat(); const chat = chats.find((c) => c.id === activeChatId); const leader = isLeaderQuery(text); warnBar.hidden = !leader; const sentAttachments = pendingAttachments.filter((a) => a.status === "ready"); const imageAttachments = sentAttachments.filter((a) => a.isImage && a.imageDataUrl); const docAttachments = sentAttachments.filter((a) => !a.isImage); // 본문 = 문서 텍스트 prefix + 사용자 텍스트 let textPart = text; if (docAttachments.length > 0) { const docTexts = docAttachments.map((a) => `[첨부문서: ${a.name} (${a.ext.toUpperCase()})]\n${a.text || "(추출 실패)"}` ).join("\n\n---\n\n"); textPart = `${docTexts}\n\n---\n\n${text}`; } // OpenAI vision format: 이미지 있으면 content를 list로 let fullUserContent; if (imageAttachments.length > 0) { fullUserContent = [{ type: "text", text: textPart || "이 이미지를 분석해 주세요." }]; for (const img of imageAttachments) { fullUserContent.push({ type: "image_url", image_url: { url: img.imageDataUrl } }); } } else { fullUserContent = textPart; } const sentAttMeta = sentAttachments.map((a) => ({ name: a.name, ext: a.ext, sizeKB: a.sizeKB, status: a.isImage ? "🖼️ vision 입력" : "📄 텍스트 추출", isImage: a.isImage, })); chat.messages.push({ role: "user", content: fullUserContent, attachments: sentAttMeta }); if (chat.messages.length === 1 || chat.title === "새 상담") { chat.title = (text || sentAttachments[0]?.name || "새 상담").slice(0, 30); } // 화면 표시는 텍스트 + attachment 카드만 (base64 본문은 안 보임) const displayText = text || (imageAttachments.length > 0 ? `🖼️ 이미지 ${imageAttachments.length}장 분석 요청` : `📎 ${sentAttachments.length}개 파일 첨부`); appendMessage("user", displayText, sentAttMeta); inputEl.value = ""; inputEl.style.height = "auto"; pendingAttachments = []; renderAttachPills(); saveChats(); renderChatList(); togglePortal(false); isStreaming = true; setSendButtonMode("stop"); // 전송 버튼 → ⏹ 정지 버튼 const asstDiv = appendMessage("assistant", "", []); asstDiv.innerHTML = ' JGOS가 답변을 준비하고 있습니다…'; let acc = ""; let firstTokenReceived = false; let aborted = false; let lastSources = null; // ⭐ assistant 메시지를 미리 push (부분 저장 — 새로고침 시 답변 보존) const asstMsgIdx = chat.messages.push({ role: "assistant", content: "", _streaming: true }) - 1; const flushSave = () => { if (saveThrottle) return; saveThrottle = setTimeout(() => { chat.messages[asstMsgIdx].content = acc; saveChats(); saveThrottle = null; }, 400); }; currentAbort = new AbortController(); try { const resp = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, signal: currentAbort.signal, body: JSON.stringify({ messages: chat.messages.filter((m) => m.role !== "system" && !m._streaming), temperature: 0.3, max_tokens: 2048, web: webSearchOn, }), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const reader = resp.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.startsWith("data: ")) continue; const data = line.slice(6).trim(); if (data === "[DONE]") continue; try { const j = JSON.parse(data); if (j.error) { asstDiv.innerHTML = renderMarkdown(`❌ **오류**: ${j.error}`); statusEl.classList.add("error"); statusEl.textContent = "● 오류"; throw new Error(j.error); } if (j.sources && Array.isArray(j.sources)) { lastSources = j.sources; } const delta = j.choices?.[0]?.delta?.content || ""; if (delta) { if (!firstTokenReceived) { firstTokenReceived = true; asstDiv.innerHTML = ""; } acc += delta; asstDiv.innerHTML = renderMarkdown(acc) + ''; flushSave(); // 부분 저장 scrollToBottom(); } } catch (e) { /* skip non-JSON */ } } } asstDiv.innerHTML = renderMarkdown(acc) + renderSources(lastSources); if (window.hljs) { asstDiv.querySelectorAll("pre code").forEach((b) => { try { hljs.highlightElement(b); } catch (e) {} }); } if (acc) attachTrustBadges(asstDiv, { leader }); } catch (e) { if (e.name === "AbortError") { aborted = true; // 부분 답변 유지 + 중단 표시 asstDiv.innerHTML = renderMarkdown(acc || "") + '
⏹ 답변이 중단되었습니다.
'; } else { if (!acc) asstDiv.innerHTML = renderMarkdown(`❌ **오류**: ${e.message || e}`); statusEl.classList.add("error"); statusEl.textContent = "● 오류"; } } finally { // 최종 저장 (throttle 취소 + 확정) if (saveThrottle) { clearTimeout(saveThrottle); saveThrottle = null; } chat.messages[asstMsgIdx].content = acc; delete chat.messages[asstMsgIdx]._streaming; if (!acc && aborted) chat.messages.splice(asstMsgIdx, 1); // 빈 중단은 제거 saveChats(); isStreaming = false; currentAbort = null; setSendButtonMode("send"); inputEl.focus(); } } // 전송 ↔ 정지 버튼 토글 function setSendButtonMode(mode) { if (mode === "stop") { sendBtn.classList.add("stopping"); sendBtn.title = "정지"; sendBtn.innerHTML = ''; } else { sendBtn.classList.remove("stopping"); sendBtn.title = "전송 (Enter)"; sendBtn.innerHTML = ''; } } // ===== 포털 카테고리 카드 + 추천 질문 버튼 ===== document.addEventListener("click", (e) => { // 카테고리 카드 const catCard = e.target.closest(".cat-card"); if (catCard) { if (catCard.dataset.attach === "true") { fileInput.click(); return; } if (catCard.dataset.q) { send(catCard.dataset.q); return; } } // 추천 질문 버튼 const quickBtn = e.target.closest(".quick-btn"); if (quickBtn && quickBtn.dataset.q) { send(quickBtn.dataset.q); return; } }); // ===== 이벤트 ===== // 전송 버튼: streaming 중이면 정지, 아니면 전송 sendBtn.onclick = () => { if (isStreaming) { stopStreaming(); return; } send(); }; inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }); inputEl.addEventListener("input", () => { inputEl.style.height = "auto"; inputEl.style.height = Math.min(200, inputEl.scrollHeight) + "px"; }); newChatBtn.onclick = newChat; // ===== 모바일 사이드바 ===== function openSidebarMobile() { sidebarEl.classList.add("open"); sidebarBackdrop.hidden = false; } function closeSidebarMobile() { sidebarEl.classList.remove("open"); sidebarBackdrop.hidden = true; } if (hamburgerBtn) hamburgerBtn.onclick = openSidebarMobile; if (sidebarToggle) sidebarToggle.onclick = openSidebarMobile; if (sidebarBackdrop) sidebarBackdrop.onclick = closeSidebarMobile; // ===== 상단 탭 (💬 AI 상담 / 📑 무료 오피스 / 🎨 JGOS-Image / 🏛️ 행정 AX) ===== const viewChat = $("#view-chat"); const viewOffice = $("#view-office"); const viewImage = $("#view-image"); const viewEdu = $("#view-edu"); const viewAx = $("#view-ax"); const officeFrame = $("#office-frame"); let officeLoaded = false; function showView(name) { if (viewChat) viewChat.style.display = name === "chat" ? "" : "none"; if (viewOffice) viewOffice.hidden = name !== "office"; if (viewImage) viewImage.hidden = name !== "image"; if (viewEdu) viewEdu.hidden = name !== "edu"; if (viewAx) viewAx.hidden = name !== "ax"; // 무료 오피스는 same-origin iframe(/office) lazy 로드 (JGOS 헤더/탭 유지) if (name === "office" && !officeLoaded && officeFrame) { officeFrame.src = "/office/"; officeLoaded = true; } } function switchTab(name) { $$(".top-tab").forEach((t) => t.classList.toggle("active", t.dataset.view === name)); showView(name); } $$(".top-tab").forEach((tab) => { tab.onclick = () => switchTab(tab.dataset.view); }); // ===== 🎨 JGOS-Image 이미지 생성 ===== const imgPrompt = $("#img-prompt"); const imgGenBtn = $("#img-gen-btn"); const imgStatus = $("#img-status"); const imgResult = $("#img-result"); const imgRatio = $("#img-ratio"); // 비율 → (width, height). Qwen-Image 권장 해상도. const RATIO_WH = { "1:1": [1024, 1024], "3:4": [896, 1152], "9:16": [768, 1344], "4:3": [1152, 896], "16:9": [1344, 768] }; let imgTimer = null; $$(".img-ex").forEach((b) => { b.onclick = () => { if (imgPrompt) { imgPrompt.value = b.dataset.p; imgPrompt.focus(); } }; }); async function genImage() { const prompt = (imgPrompt && imgPrompt.value || "").trim(); if (!prompt) { if (imgPrompt) imgPrompt.focus(); return; } const [w, h] = RATIO_WH[(imgRatio && imgRatio.value) || "1:1"] || [1024, 1024]; if (imgGenBtn) imgGenBtn.disabled = true; if (imgResult) imgResult.innerHTML = ""; // 인터랙티브 프로그레스: 모래시계 애니메이션 + 경과 초 카운터 let sec = 0; if (imgStatus) { imgStatus.hidden = false; imgStatus.innerHTML = ' 이미지를 그리는 중입니다 — 약 10초만 기다려주세요 (0초)'; } if (imgTimer) clearInterval(imgTimer); imgTimer = setInterval(() => { sec++; const e = document.getElementById("img-sec"); if (e) e.textContent = sec; }, 1000); try { const r = await fetch("/api/image", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt, width: w, height: h }), }); const d = await r.json(); if (d.image) { if (imgResult) imgResult.innerHTML = `생성 이미지` + `⬇ 이미지 저장`; if (imgStatus) imgStatus.hidden = true; } else { if (imgStatus) { imgStatus.hidden = false; imgStatus.textContent = "⚠️ " + (d.error || "이미지 생성 서버 준비 중입니다."); } } } catch (e) { if (imgStatus) { imgStatus.hidden = false; imgStatus.textContent = "⚠️ 생성 실패: " + e.message; } } finally { if (imgTimer) { clearInterval(imgTimer); imgTimer = null; } if (imgGenBtn) imgGenBtn.disabled = false; } } if (imgGenBtn) imgGenBtn.onclick = genImage; // ===== 🎓 AI 교육 — AI 선생님 ===== const eduInput = $("#edu-input"); const eduSend = $("#edu-send"); const eduMessages = $("#edu-messages"); const eduGoto = $("#edu-goto"); const EDU_SYSTEM = "당신은 전남광주 통합특별시 'AI 시민 대학'의 친절한 AI 선생님입니다.\n【절대 규칙 — 위반 금지】\n1) 반드시 한국어로만 답합니다. 영어 단어·영어 정의·'Technical definition'·'Simplified'·'Metaphor' 같은 영어 메모를 절대 출력하지 마세요.\n2) 개요·계획·사고과정(들여쓰기 별표로 된 영어 정리)을 쓰지 말고, 처음부터 완성된 한국어 문장으로 바로 설명합니다.\n3) 따뜻한 존댓말, 쉬운 비유, 어르신도 이해하도록 천천히, 전문용어는 풀어서.\n예시 시작) \"안녕하세요! AI가 무엇인지 쉽게 알려드릴게요. AI는 마치 똑똑한 비서와 같아서…\""; const GO_LABEL = { chat: "AI 상담", office: "무료 오피스", image: "이미지 생성" }; let eduBusy = false; function eduCleanup(md) { // 모델이 흘리는 영어 사고/개요 줄 제거 (Technical definition, Metaphor, 들여쓰기 영어 불릿 등) return md.split("\n").filter((line) => { const t = line.trim(); if (!t) return true; 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; if (/^\s{2,}[*\-]/.test(line) && !/[가-힣]/.test(t)) return false; // 한글 없는 들여쓰기 영어 불릿 return true; }).join("\n").replace(/\n{3,}/g, "\n\n").trim(); } function eduRender(md) { const c = eduCleanup(md); try { return DOMPurify.sanitize(marked.parse(c)); } catch { return c.replace(/"); } } async function askTeacher(q, go) { if (!q || eduBusy) return; eduBusy = true; if (eduGoto) eduGoto.hidden = true; eduMessages.insertAdjacentHTML("beforeend", `
${q.replace(/`); const ai = document.createElement("div"); ai.className = "edu-msg edu-ai"; ai.innerHTML = '🧑‍🏫 선생님이 답변을 준비하고 있어요…'; eduMessages.appendChild(ai); eduMessages.scrollTop = eduMessages.scrollHeight; let acc = ""; try { const r = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: [{ role: "user", content: q + "\n\n(어르신도 이해하실 수 있도록, 친근한 선생님처럼 아주 쉽게 한국어로 풀어서 설명해 주세요. 영어·개요·목차 없이 바로 설명만.)" }], max_tokens: 1500, temperature: 0.4 }), }); const reader = r.body.getReader(); const dec = new TextDecoder(); let buf = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let nl; while ((nl = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (!line.startsWith("data:")) continue; const d = line.slice(5).trim(); if (d === "[DONE]" || !d) continue; 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 {} } } if (!acc) ai.innerHTML = "답변을 불러오지 못했어요. 잠시 후 다시 시도해 주세요."; } catch (e) { ai.innerHTML = "오류가 발생했어요: " + e.message; } if (go && GO_LABEL[go] && eduGoto) { eduGoto.hidden = false; eduGoto.innerHTML = ``; eduGoto.firstChild.onclick = () => switchTab(go); } eduBusy = false; } $$(".edu-card").forEach((c) => { c.onclick = () => askTeacher(c.dataset.q, c.dataset.go || ""); }); function eduAsk() { const q = (eduInput && eduInput.value || "").trim(); if (q) { askTeacher(q, ""); eduInput.value = ""; } } if (eduSend) eduSend.onclick = eduAsk; if (eduInput) eduInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); eduAsk(); } }); // ===== 히어로 드롭존 (방안1) ===== const heroDropzone = $("#hero-dropzone"); if (heroDropzone) heroDropzone.onclick = () => fileInput.click(); // ============================================================ // ===== 🏛️ 행정 AX 탭 (2026-06-10) ===== // ============================================================ // 데모 파일 목록 (static/demo/ 에 업로드된 파일들) const DEMO_FILES_EXCEL = [ {name:"01_광주동구_복지현황.xlsx", label:"광주 동구 (XLSX)", ext:"xlsx"}, {name:"02_전남도청_복지수급.xlsx", label:"전남 도청 (XLSX)", ext:"xlsx"}, {name:"03_순천시_복지통계.xlsx", label:"순천시 (XLSX)", ext:"xlsx"}, {name:"04_목포시_복지대장.xlsx", label:"목포시 (XLSX)", ext:"xlsx"}, {name:"05_여수시_복지현황.xlsx", label:"여수시 (XLSX)", ext:"xlsx"}, ]; const DEMO_FILES_FORMAT = [ {name:"복지신청_광주동구.hwpx", label:"광주동구 공문", ext:"hwpx"}, {name:"복지신청_전남도청.xlsx", label:"전남도청 Excel", ext:"xlsx"}, {name:"복지수급현황.csv", label:"수급현황 CSV", ext:"csv"}, {name:"사업설명회_자료.pptx", label:"사업설명 PPT", ext:"pptx"}, {name:"민원접수대장.docx", label:"민원 Word", ext:"docx"}, {name:"공문_복지공람.pdf", label:"복지 공문 PDF", ext:"pdf"}, {name:"복지신청_한글.hwpx", label:"한글 신청서", ext:"hwpx"}, ]; function encodeFilename(s) { return encodeURIComponent(s); } function renderFileChips(containerId, demoFiles) { const container = $("#" + containerId); if (!container) return; container.innerHTML = demoFiles.map(f => `
${f.ext.toUpperCase()} ${f.label} 👁
`).join(""); container.querySelectorAll(".ax-file-chip").forEach(chip => { chip.onclick = () => axPreviewDemo(chip.dataset.demo, chip.dataset.label); }); } async function axPreviewDemo(filename, label) { const modal = $("#ax-modal"); const body = $("#ax-modal-body"); const title = $("#ax-modal-title"); if (!modal || !body) return; if (title) title.textContent = "\uD83D\uDCC4 " + label + " \u2014 \uBBF8\uB9AC\uBCF4\uAE30"; body.innerHTML = '
\u23F3 \uBD88\uB7EC\uC624\uB294 \uC911...
'; modal.hidden = false; try { const r = await fetch("/api/demo/preview/" + encodeURIComponent(filename)); if (!r.ok) { body.innerHTML = "

\uBBF8\uB9AC\uBCF4\uAE30 \uC2E4\uD328 (" + r.status + ")

"; return; } const d = await r.json(); body.innerHTML = (d.rows > 0) ? (d.html || "

\uBBF8\uB9AC\uBCF4\uAE30 \uC5C6\uC74C

") : "

\u26A0\uFE0F \uD30C\uC2F1 \uC2E4\uD328

"; } catch(e) { body.innerHTML = "

\uC624\uB958: " + (e && e.message ? e.message : e) + "

"; } } // 모달 닫기 const axModalClose = $("#ax-modal-close"); const axModal = $("#ax-modal"); if (axModalClose) axModalClose.onclick = () => { if (axModal) axModal.hidden = true; }; if (axModal) axModal.onclick = (e) => { if (e.target === axModal) axModal.hidden = true; }; // 파일 chip 렌더링 renderFileChips("ax-excel-demos", DEMO_FILES_EXCEL); renderFileChips("ax-format-demos", DEMO_FILES_FORMAT); // ── Demo 1: Excel 통합 ── const axExcelInput = $("#ax-excel-input"); const axExcelDrop = $("#ax-excel-drop"); const axExcelRun = $("#ax-excel-run"); const axExcelCount = $("#ax-excel-count"); const axExcelResult = $("#ax-excel-result"); let axExcelFiles = []; function axExcelUpdateUI() { if (axExcelCount) axExcelCount.textContent = axExcelFiles.length ? `${axExcelFiles.length}개 파일 선택됨` : ""; if (axExcelRun) axExcelRun.disabled = axExcelFiles.length === 0; } if (axExcelDrop) { axExcelDrop.onclick = (e) => { if (e.target === axExcelDrop || axExcelDrop.contains(e.target)) axExcelInput && axExcelInput.click(); }; axExcelDrop.ondragover = e => { e.preventDefault(); axExcelDrop.classList.add("dragover"); }; axExcelDrop.ondragleave = () => axExcelDrop.classList.remove("dragover"); axExcelDrop.ondrop = e => { e.preventDefault(); axExcelDrop.classList.remove("dragover"); axExcelFiles = [...axExcelFiles, ...(e.dataTransfer.files || [])]; axExcelUpdateUI(); }; } if (axExcelInput) axExcelInput.onchange = () => { axExcelFiles = [...axExcelFiles, ...(axExcelInput.files || [])]; axExcelUpdateUI(); }; if (axExcelRun) axExcelRun.onclick = async () => { if (!axExcelFiles.length) return; axExcelRun.disabled = true; axExcelRun.innerHTML = "⏳ 처리 중..."; if (axExcelResult) { axExcelResult.hidden = false; axExcelResult.innerHTML = '
⏳ 통합 중...
'; } try { const fd = new FormData(); axExcelFiles.forEach(f => fd.append("files", f)); const r = await fetch("/api/excel/merge", {method: "POST", body: fd}); const d = await r.json(); if (axExcelResult) { const srcHtml = (d.sources || []).map(s => `📄 ${s.파일} (${s.건수}건)`).join(""); axExcelResult.innerHTML = `
${srcHtml}
${d.html || ""}`; } } catch(e) { if (axExcelResult) axExcelResult.innerHTML = `

오류: ${e.message}

`; } axExcelRun.disabled = false; axExcelRun.innerHTML = " 통합 실행"; }; // ── Demo 2: 이종 포맷 통합 ── const axFormatInput = $("#ax-format-input"); const axFormatDrop = $("#ax-format-drop"); const axFormatRun = $("#ax-format-run"); const axFormatCount = $("#ax-format-count"); const axFormatResult = $("#ax-format-result"); let axFormatFiles = []; function axFormatUpdateUI() { if (axFormatCount) axFormatCount.textContent = axFormatFiles.length ? `${axFormatFiles.length}개 파일 선택됨` : ""; if (axFormatRun) axFormatRun.disabled = axFormatFiles.length === 0; } if (axFormatDrop) { axFormatDrop.onclick = (e) => { if (e.target === axFormatDrop || axFormatDrop.contains(e.target)) axFormatInput && axFormatInput.click(); }; axFormatDrop.ondragover = e => { e.preventDefault(); axFormatDrop.classList.add("dragover"); }; axFormatDrop.ondragleave = () => axFormatDrop.classList.remove("dragover"); axFormatDrop.ondrop = e => { e.preventDefault(); axFormatDrop.classList.remove("dragover"); axFormatFiles = [...axFormatFiles, ...(e.dataTransfer.files || [])]; axFormatUpdateUI(); }; } if (axFormatInput) axFormatInput.onchange = () => { axFormatFiles = [...axFormatFiles, ...(axFormatInput.files || [])]; axFormatUpdateUI(); }; if (axFormatRun) axFormatRun.onclick = async () => { if (!axFormatFiles.length) return; axFormatRun.disabled = true; axFormatRun.innerHTML = "⏳ 처리 중..."; if (axFormatResult) { axFormatResult.hidden = false; axFormatResult.innerHTML = '
⏳ 통합 중...
'; } try { const fd = new FormData(); axFormatFiles.forEach(f => fd.append("files", f)); const r = await fetch("/api/files/merge-formats", {method: "POST", body: fd}); const d = await r.json(); if (axFormatResult) { const srcHtml = (d.sources || []).map(s => `${s.포맷} ${s.파일} (${s.건수}건)`).join(""); axFormatResult.innerHTML = `
${srcHtml}
${d.html || ""}`; } } catch(e) { if (axFormatResult) axFormatResult.innerHTML = `

오류: ${e.message}

`; } axFormatRun.disabled = false; axFormatRun.innerHTML = " 통합 실행"; }; // ===== health ===== fetch("/api/health").then((r) => r.json()).then((d) => { statusEl.textContent = "● " + ("JGOS-31B-Citizen"); }).catch(() => { statusEl.textContent = "● 연결 안 됨"; statusEl.classList.add("error"); }); // ===== init ===== if (chats.length === 0) newChat(); else loadChat(chats[chats.length - 1].id);