Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| // 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) => | |
| `<a class="src-card" href="${escHtml(s.link)}" target="_blank" rel="noopener noreferrer">` + | |
| `<span class="src-num">${i + 1}</span>` + | |
| `<span class="src-body"><span class="src-title">${escHtml(s.title)}</span>` + | |
| `<span class="src-tag">${escHtml(s.src)}</span></span></a>` | |
| ).join(""); | |
| return `<div class="src-wrap"><div class="src-head">๐ ์ฐธ๊ณ ํ ์น ์ถ์ฒ</div><div class="src-list">${items}</div></div>`; | |
| } | |
| 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(/>/g, ">").replace(/\n/g, "<br>"); | |
| } | |
| // ===== ํฌํธ ํ์/์จ๊น ===== | |
| 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 = ` | |
| <div class="ext-icon">${(a.ext || "?").toUpperCase().slice(0,4)}</div> | |
| <div class="meta"> | |
| <div class="name">${escapeHTML(a.name)}</div> | |
| <div class="info">${fileSizeLabel(a.sizeKB)} ยท ${a.status || "์ฒ๋ฆฌ์๋ฃ"}</div> | |
| </div> | |
| <div class="status">โ ๋ถ์ ๊ฐ๋ฅ</div> | |
| `; | |
| card.appendChild(item); | |
| } | |
| return card; | |
| } | |
| function escapeHTML(s) { | |
| return (s || "").replace(/&/g, "&").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 = `<span class="tb gray">๐ ๊ธฐ์ค์ผ ${todayKR()}</span>`; | |
| const sourceBadge = `<span class="tb">๐ ์ถ์ฒ RAG</span>`; | |
| const govBadge = `<span class="tb green">๐๏ธ ๊ณต์ ์๋ด 062-120 ํ์ธ</span>`; | |
| const electionBadge = `<span class="tb warn">โ ๏ธ ์ ๊ฑฐยท์ถ๋ฒ ์ดํ ๋ณ๋ ๊ฐ๋ฅ</span>`; | |
| 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 = '<span class="spinner"></span>'; | |
| if (a.status === "error") icon = "โ"; | |
| if (a.status === "ready") icon = "โ "; | |
| pill.innerHTML = `${icon} <span class="ext">${a.ext.toUpperCase()}</span> ${escapeHTML(a.name)} <small style="opacity:0.7">${fileSizeLabel(a.sizeKB)}</small>`; | |
| 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 = '<span class="thinking-indicator"><span class="spinner"></span> JGOS๊ฐ ๋ต๋ณ์ ์ค๋นํ๊ณ ์์ต๋๋คโฆ</span>'; | |
| 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) + '<span class="cursor"></span>'; | |
| 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 || "") + | |
| '<div style="margin-top:8px;font-size:12px;color:#8a94a3">โน ๋ต๋ณ์ด ์ค๋จ๋์์ต๋๋ค.</div>'; | |
| } 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 = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>'; | |
| } else { | |
| sendBtn.classList.remove("stopping"); | |
| sendBtn.title = "์ ์ก (Enter)"; | |
| 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>'; | |
| } | |
| } | |
| // ===== ํฌํธ ์นดํ ๊ณ ๋ฆฌ ์นด๋ + ์ถ์ฒ ์ง๋ฌธ ๋ฒํผ ===== | |
| 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 = '<span class="img-hourglass">โณ</span> ์ด๋ฏธ์ง๋ฅผ ๊ทธ๋ฆฌ๋ ์ค์ ๋๋ค โ <strong>์ฝ 10์ด๋ง ๊ธฐ๋ค๋ ค์ฃผ์ธ์</strong> (<b id="img-sec">0</b>์ด)'; | |
| } | |
| 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 = `<img src="${d.image}" alt="์์ฑ ์ด๋ฏธ์ง" />` + | |
| `<a class="img-dl" href="${d.image}" download="jgos-image.png">โฌ ์ด๋ฏธ์ง ์ ์ฅ</a>`; | |
| 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(/</g, "<").replace(/\n/g, "<br>"); } | |
| } | |
| async function askTeacher(q, go) { | |
| if (!q || eduBusy) return; | |
| eduBusy = true; | |
| if (eduGoto) eduGoto.hidden = true; | |
| eduMessages.insertAdjacentHTML("beforeend", `<div class="edu-msg edu-user">${q.replace(/</g, "<")}</div>`); | |
| const ai = document.createElement("div"); | |
| ai.className = "edu-msg edu-ai"; | |
| ai.innerHTML = '<span class="edu-typing">๐งโ๐ซ ์ ์๋์ด ๋ต๋ณ์ ์ค๋นํ๊ณ ์์ด์โฆ</span>'; | |
| 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 = `<button class="edu-go-btn">โถ ์ง๊ธ ๋ฐ๋ก '${GO_LABEL[go]}'์์ ํด๋ณด๊ธฐ</button>`; | |
| 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 => ` | |
| <div class="ax-file-chip" data-demo="${f.name}" data-label="${f.label}"> | |
| <span class="ax-ext-badge ax-ext-${f.ext}">${f.ext.toUpperCase()}</span> | |
| ${f.label} | |
| <span style="opacity:0.5;font-size:0.75rem">๐</span> | |
| </div> | |
| `).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 = '<div class="ax-loading">\u23F3 \uBD88\uB7EC\uC624\uB294 \uC911...</div>'; | |
| modal.hidden = false; | |
| try { | |
| const r = await fetch("/api/demo/preview/" + encodeURIComponent(filename)); | |
| if (!r.ok) { body.innerHTML = "<p style='color:#b91c1c'>\uBBF8\uB9AC\uBCF4\uAE30 \uC2E4\uD328 (" + r.status + ")</p>"; return; } | |
| const d = await r.json(); | |
| 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>"; | |
| } catch(e) { | |
| body.innerHTML = "<p style='color:red'>\uC624\uB958: " + (e && e.message ? e.message : e) + "</p>"; | |
| } | |
| } | |
| // ๋ชจ๋ฌ ๋ซ๊ธฐ | |
| 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 = '<div class="ax-loading">โณ ํตํฉ ์ค...</div>'; } | |
| 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 => `<span class="ax-source-chip">๐ ${s.ํ์ผ} (${s.๊ฑด์}๊ฑด)</span>`).join(""); | |
| axExcelResult.innerHTML = `<div class="ax-source-list">${srcHtml}</div>${d.html || ""}`; | |
| } | |
| } catch(e) { | |
| if (axExcelResult) axExcelResult.innerHTML = `<p style="color:red">์ค๋ฅ: ${e.message}</p>`; | |
| } | |
| axExcelRun.disabled = false; | |
| axExcelRun.innerHTML = "<span>โก</span> ํตํฉ ์คํ"; | |
| }; | |
| // โโ 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 = '<div class="ax-loading">โณ ํตํฉ ์ค...</div>'; } | |
| 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 => `<span class="ax-source-chip">${s.ํฌ๋งท} ${s.ํ์ผ} (${s.๊ฑด์}๊ฑด)</span>`).join(""); | |
| axFormatResult.innerHTML = `<div class="ax-source-list">${srcHtml}</div>${d.html || ""}`; | |
| } | |
| } catch(e) { | |
| if (axFormatResult) axFormatResult.innerHTML = `<p style="color:red">์ค๋ฅ: ${e.message}</p>`; | |
| } | |
| axFormatRun.disabled = false; | |
| axFormatRun.innerHTML = "<span>โก</span> ํตํฉ ์คํ"; | |
| }; | |
| // ===== 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); | |