deliberation_coach / admin.html
jonghhhh's picture
deploy: AI ์ˆ™์˜์ฝ”์น˜
445616d verified
Raw
History Blame Contribute Delete
12.8 kB
<!DOCTYPE html>
<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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
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>