Spaces:
Sleeping
Sleeping
| <html lang="ko"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>AI ์์์ฝ์น โ ๊ด๋ฆฌ์</title> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" /> | |
| <style> | |
| :root{ | |
| --ink:#1d2433; --muted:#5b6472; --paper:#f5f6f8; --card:#fff; --line:#e3e6ea; | |
| --accent:#2f7d78; --danger:#b5832c; --radius:12px; | |
| } | |
| *{box-sizing:border-box} | |
| html,body{margin:0;background:var(--paper);color:var(--ink); | |
| font-family:Pretendard,system-ui,-apple-system,sans-serif;font-size:15px;line-height:1.55;} | |
| .wrap{max-width:1100px;margin:0 auto;padding:18px 18px 80px;} | |
| h1{font-size:20px;margin:6px 0 2px;} | |
| h2{font-size:15px;margin:0 0 10px;color:var(--muted);font-weight:600;} | |
| .sub{color:var(--muted);font-size:13px;margin:0 0 18px;} | |
| .card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius); | |
| padding:16px 18px;margin-bottom:16px;} | |
| .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;} | |
| label{font-size:13px;color:var(--muted);display:block;margin:8px 0 3px;} | |
| input[type=text],input[type=password]{width:100%;padding:9px 11px;border:1px solid var(--line); | |
| border-radius:9px;font:inherit;background:#fff;} | |
| .btn{border:0;background:var(--accent);color:#fff;padding:9px 14px;border-radius:9px; | |
| font:inherit;cursor:pointer;font-weight:600;} | |
| .btn.ghost{background:#fff;color:var(--ink);border:1px solid var(--line);} | |
| .btn:disabled{opacity:.5;cursor:default;} | |
| .btn.small{padding:5px 10px;font-size:13px;} | |
| table{width:100%;border-collapse:collapse;font-size:13.5px;} | |
| th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);white-space:nowrap;} | |
| th{color:var(--muted);font-weight:600;cursor:pointer;user-select:none;} | |
| tbody tr{cursor:pointer;} | |
| tbody tr:hover{background:#f0f4f4;} | |
| .pill{display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px;border:1px solid var(--line);} | |
| .pill.completed{background:#e6f0ef;color:#1f5d58;border-color:#cfe3e1;} | |
| .pill.aborted{background:#f7e7e7;color:#9a3b3b;border-color:#eccaca;} | |
| .pill.in_progress{background:#eef0f3;color:#5b6472;} | |
| .muted{color:var(--muted);} | |
| .err{color:#9a3b3b;font-size:13px;margin-top:8px;} | |
| .ok{color:#1f5d58;font-size:13px;margin-top:8px;} | |
| .grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px;} | |
| @media(max-width:640px){.grid2{grid-template-columns:1fr;}} | |
| /* ์์ธ ๋ชจ๋ฌ */ | |
| .modal-bg{position:fixed;inset:0;background:rgba(20,24,34,.45);display:flex; | |
| align-items:flex-start;justify-content:center;padding:24px;overflow:auto;z-index:50;} | |
| .modal{background:#fff;border-radius:var(--radius);max-width:820px;width:100%;padding:20px 22px;} | |
| .kv{display:grid;grid-template-columns:140px 1fr;gap:4px 12px;font-size:13.5px;margin-bottom:14px;} | |
| .kv div:nth-child(odd){color:var(--muted);} | |
| .turn{border-left:3px solid var(--line);padding:4px 0 4px 10px;margin:6px 0;font-size:13.5px;} | |
| .turn.ai{border-color:var(--accent);} | |
| .turn.participant{border-color:var(--danger);} | |
| .turn .who{font-size:12px;color:var(--muted);} | |
| pre{background:#f5f6f8;border:1px solid var(--line);border-radius:9px;padding:10px; | |
| overflow:auto;font-size:12.5px;max-height:280px;} | |
| .sec{font-weight:600;margin:14px 0 6px;font-size:14px;} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <h1>AI ์์์ฝ์น โ ๊ด๋ฆฌ์</h1> | |
| <p class="sub">์๋ต์ ํํฉ ยท ์๋ต ๋ฐ์ดํฐ ํ์ธ/๋ค์ด๋ก๋ ยท ์ด์ ์ค์ ๊ด๋ฆฌ</p> | |
| <!-- ๋ก๊ทธ์ธ --> | |
| <div class="card" id="loginCard"> | |
| <h2>๊ด๋ฆฌ์ ํ ํฐ</h2> | |
| <div class="row" style="align-items:flex-end;"> | |
| <div style="flex:1;min-width:220px;"> | |
| <label>ADMIN_TOKEN (.env ์ ์ค์ ํ ๊ฐ)</label> | |
| <input type="password" id="tokenInput" placeholder="ํ ํฐ ์ ๋ ฅ" /> | |
| </div> | |
| <button class="btn" id="loginBtn">์ ์</button> | |
| </div> | |
| <div class="err" id="loginErr"></div> | |
| </div> | |
| <!-- ๋ฉ์ธ(๋ก๊ทธ์ธ ํ) --> | |
| <div id="main" style="display:none;"> | |
| <!-- ์ค์ --> | |
| <div class="card"> | |
| <h2>์ด์ ์ค์ </h2> | |
| <div class="grid2"> | |
| <div> | |
| <label>MODEL (Gemini ๋ชจ๋ธ๋ช )</label> | |
| <input type="text" id="model" /> | |
| </div> | |
| <div> | |
| <label>RETURN_URL (์ข ๋ฃ ํ ๋ณต๊ท URL ยท ๋น์ฐ๋ฉด ์๋ฃํ๋ฉด๋ง)</label> | |
| <input type="text" id="returnUrl" /> | |
| </div> | |
| </div> | |
| <div class="row" style="margin-top:12px;"> | |
| <button class="btn" id="saveSettings">์ค์ ์ ์ฅ</button> | |
| <span class="muted" id="keyStatus"></span> | |
| </div> | |
| <div class="ok" id="settingsMsg"></div> | |
| </div> | |
| <!-- ํํฉ --> | |
| <div class="card"> | |
| <div class="row" style="justify-content:space-between;"> | |
| <h2 style="margin:0;">์๋ต์ ํํฉ (<span id="count">0</span>๋ช )</h2> | |
| <div class="row"> | |
| <button class="btn ghost small" id="refresh">์๋ก๊ณ ์นจ</button> | |
| <button class="btn small" id="dlCsv">์์ฝ CSV ๋ค์ด๋ก๋</button> | |
| </div> | |
| </div> | |
| <div style="overflow:auto;margin-top:12px;"> | |
| <table id="tbl"> | |
| <thead><tr> | |
| <th data-k="pid">pid</th> | |
| <th data-k="completion">์ํ</th> | |
| <th data-k="criteria_done">๊ธฐ์ค์๋ฃ</th> | |
| <th data-k="final_choice">์ต์ข ์ ํ</th> | |
| <th data-k="confidence">ํ์ ๋</th> | |
| <th data-k="turns">๋ํ์</th> | |
| <th data-k="duration_sec">์์(์ด)</th> | |
| <th data-k="saved_at">์ ์ฅ์๊ฐ</th> | |
| <th>๋ค์ด๋ก๋</th> | |
| </tr></thead> | |
| <tbody id="tbody"></tbody> | |
| </table> | |
| </div> | |
| <div class="err" id="listErr"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="modalRoot"></div> | |
| <script> | |
| const $ = (id) => document.getElementById(id); | |
| let TOKEN = ""; | |
| let ROWS = []; | |
| let sortKey = "saved_at", sortDir = -1; | |
| const esc = (s) => String(s == null ? "" : s) | |
| .replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); | |
| async function api(path, opts = {}) { | |
| const headers = Object.assign({ "X-Admin-Token": TOKEN }, opts.headers || {}); | |
| const r = await fetch(path, Object.assign({}, opts, { headers })); | |
| if (!r.ok) { | |
| let msg = "HTTP " + r.status; | |
| try { const j = await r.json(); if (j.detail) msg = j.detail; } catch (e) {} | |
| throw new Error(msg); | |
| } | |
| return r; | |
| } | |
| async function login() { | |
| TOKEN = $("tokenInput").value.trim(); | |
| $("loginErr").textContent = ""; | |
| if (!TOKEN) { $("loginErr").textContent = "ํ ํฐ์ ์ ๋ ฅํ์ธ์."; return; } | |
| try { | |
| const s = await (await api("/api/admin/settings")).json(); | |
| sessionStorage.setItem("admin_token", TOKEN); | |
| $("loginCard").style.display = "none"; | |
| $("main").style.display = "block"; | |
| fillSettings(s); | |
| await loadList(); | |
| } catch (e) { | |
| $("loginErr").textContent = "์ ์ ์คํจ: " + e.message; | |
| } | |
| } | |
| function fillSettings(s) { | |
| $("model").value = s.model || ""; | |
| $("returnUrl").value = s.return_url || ""; | |
| $("keyStatus").textContent = | |
| (s.gemini_key_set ? "โ GEMINI_API_KEY ์ค์ ๋จ" : "โ GEMINI_API_KEY ์์(ํด๋ฐฑ ๋์)") | |
| + " ยท ์ ์ฅํด๋: " + (s.data_dir || ""); | |
| } | |
| async function saveSettings() { | |
| $("settingsMsg").textContent = ""; | |
| try { | |
| const body = { model: $("model").value.trim(), return_url: $("returnUrl").value.trim() }; | |
| await api("/api/admin/settings", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(body), | |
| }); | |
| $("settingsMsg").textContent = "์ ์ฅ๋์์ต๋๋ค."; | |
| setTimeout(() => ($("settingsMsg").textContent = ""), 2500); | |
| } catch (e) { | |
| $("settingsMsg").textContent = ""; | |
| $("settingsMsg").className = "err"; | |
| $("settingsMsg").textContent = "์ ์ฅ ์คํจ: " + e.message; | |
| } | |
| } | |
| async function loadList() { | |
| $("listErr").textContent = ""; | |
| try { | |
| const j = await (await api("/api/admin/list")).json(); | |
| ROWS = j.rows || []; | |
| $("count").textContent = j.count || 0; | |
| renderTable(); | |
| } catch (e) { | |
| $("listErr").textContent = "๋ชฉ๋ก ๋ก๋ ์คํจ: " + e.message; | |
| } | |
| } | |
| function renderTable() { | |
| const rows = ROWS.slice().sort((a, b) => { | |
| const x = a[sortKey], y = b[sortKey]; | |
| if (x === y) return 0; | |
| return (x > y ? 1 : -1) * sortDir; | |
| }); | |
| const tb = $("tbody"); | |
| tb.innerHTML = ""; | |
| for (const r of rows) { | |
| const tr = document.createElement("tr"); | |
| const st = esc(r.completion || "in_progress"); | |
| tr.innerHTML = | |
| `<td>${esc(r.pid)}</td>` + | |
| `<td><span class="pill ${st}">${st}</span></td>` + | |
| `<td>${r.criteria_done}/${r.criteria_total}</td>` + | |
| `<td>${esc(r.final_choice)}</td>` + | |
| `<td>${esc(r.confidence)}</td>` + | |
| `<td>${r.turns}</td>` + | |
| `<td>${r.duration_sec}</td>` + | |
| `<td class="muted">${esc(r.saved_at)}</td>` + | |
| `<td><button class="btn ghost small" data-dl="${esc(r.pid)}">JSON</button></td>`; | |
| tr.addEventListener("click", (ev) => { | |
| if (ev.target.dataset.dl) return; // ๋ค์ด๋ก๋ ๋ฒํผ์ ํ ํด๋ฆญ๊ณผ ๋ถ๋ฆฌ | |
| showDetail(r.pid); | |
| }); | |
| tb.appendChild(tr); | |
| } | |
| tb.querySelectorAll("[data-dl]").forEach((b) => | |
| b.addEventListener("click", () => downloadPid(b.dataset.dl))); | |
| } | |
| function downloadPid(pid) { | |
| // ํ ํฐ์ ์ฟผ๋ฆฌ๋ก ์ค์ด ์ ํญ์์ ๋ฐ๊ธฐ(ํค๋๋ฅผ ๋ชป ์ฐ๋ ์ง์ ๋ค์ด๋ก๋์ฉ) | |
| const url = "/api/admin/download/" + encodeURIComponent(pid) + "?token=" + encodeURIComponent(TOKEN); | |
| window.open(url, "_blank"); | |
| } | |
| function downloadCsv() { | |
| window.open("/api/admin/summary.csv?token=" + encodeURIComponent(TOKEN), "_blank"); | |
| } | |
| async function showDetail(pid) { | |
| let rec; | |
| try { | |
| rec = await (await api("/api/admin/record/" + encodeURIComponent(pid))).json(); | |
| } catch (e) { alert("์์ธ ๋ก๋ ์คํจ: " + e.message); return; } | |
| const sess = rec.session || {}, out = rec.output || {}; | |
| const crit = (rec.criteria || []).map((c) => | |
| `<tr><td>${esc(c.name)}</td><td>${esc(c.judgment_initial)}</td>` + | |
| `<td>${esc(c.judgment_final)}</td><td>${esc(c.reason)}</td><td>${esc(c.weighing)}</td></tr>` | |
| ).join(""); | |
| const turns = (rec.transcript || []).map((t) => | |
| `<div class="turn ${esc(t.role)}"><div class="who">#${t.turn} ยท ${esc(t.role)}` + | |
| `${t.criterion ? " ยท " + esc(t.criterion) : ""} ยท ${esc(t.ts)}</div>${esc(t.text)}</div>` | |
| ).join(""); | |
| $("modalRoot").innerHTML = | |
| `<div class="modal-bg" id="mbg"><div class="modal"> | |
| <div class="row" style="justify-content:space-between;"> | |
| <h2 style="margin:0;">์๋ต์ ์์ธ โ ${esc(rec.pid)}</h2> | |
| <div class="row"> | |
| <button class="btn ghost small" id="mDl">JSON ๋ค์ด๋ก๋</button> | |
| <button class="btn small" id="mClose">๋ซ๊ธฐ</button> | |
| </div> | |
| </div> | |
| <div class="kv" style="margin-top:14px;"> | |
| <div>์ํ</div><div>${esc(sess.completion)}</div> | |
| <div>์์</div><div>${esc(sess.started_at)}</div> | |
| <div>์ข ๋ฃ</div><div>${esc(sess.ended_at)}</div> | |
| <div>์์(์ด)</div><div>${esc(sess.duration_sec)}</div> | |
| <div>์ต์ข ์ ํ</div><div>${esc(out.final_choice)} (ํ์ ๋ ${esc(out.confidence)})</div> | |
| </div> | |
| <div class="sec">๊ธฐ์ค๋ณ ํ๋จ</div> | |
| <table><thead><tr><th>๊ธฐ์ค</th><th>์ด๊ธฐ</th><th>์ฌํ๊ฐ</th><th>์ด์ </th><th>์ ์ธ์ง</th></tr></thead> | |
| <tbody>${crit || '<tr><td colspan="5" class="muted">์์</td></tr>'}</tbody></table> | |
| <div class="sec">๋ํ ๋ฉ์์ง (${(rec.transcript || []).length}ํด)</div> | |
| <div>${turns || '<span class="muted">์์</span>'}</div> | |
| <div class="sec">์ ์ฒด ์๋ณธ JSON</div> | |
| <pre>${esc(JSON.stringify(rec, null, 2))}</pre> | |
| </div></div>`; | |
| $("mClose").addEventListener("click", () => ($("modalRoot").innerHTML = "")); | |
| $("mbg").addEventListener("click", (e) => { if (e.target.id === "mbg") $("modalRoot").innerHTML = ""; }); | |
| $("mDl").addEventListener("click", () => downloadPid(rec.pid)); | |
| } | |
| // ์ด๋ฒคํธ ๋ฐ์ธ๋ฉ | |
| $("loginBtn").addEventListener("click", login); | |
| $("tokenInput").addEventListener("keydown", (e) => { if (e.key === "Enter") login(); }); | |
| $("saveSettings").addEventListener("click", saveSettings); | |
| $("refresh").addEventListener("click", loadList); | |
| $("dlCsv").addEventListener("click", downloadCsv); | |
| document.querySelectorAll("th[data-k]").forEach((th) => | |
| th.addEventListener("click", () => { | |
| const k = th.dataset.k; | |
| if (sortKey === k) sortDir *= -1; else { sortKey = k; sortDir = 1; } | |
| renderTable(); | |
| })); | |
| // ์ธ์ ์ ์ ์ฅ๋ ํ ํฐ์ด ์์ผ๋ฉด ์๋ ๋ก๊ทธ์ธ ์๋ | |
| (function () { | |
| const t = sessionStorage.getItem("admin_token"); | |
| if (t) { $("tokenInput").value = t; login(); } | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |