CoolFace
Apppublic

VIDraft/JGOS-Origin

sourceHugging Faceupdated 25d agoView on Hugging Face
1likes
script.js923 linesDownload Raw Back to static
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");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, "&lt;").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, "&lt;")}</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