0bfe37cffb
- build-bundles.mjs(work/installer-batch-0725 版)+build-ui-bundle.mjs 全量建: tier1 19(18 TinyGo+code)+tier2 4(cypher/kbdb/registry/mcp)+ui=24 顆 - manifest built=2026-07-31 source=Arcrun@7e87a33(feat/step3-missing-guidance) - 死零件 km_writer/claude_api/kbdb_upsert_block 消失(G13);claude_api 在 .component-builds 仍殘留、建置時排除(上游 Arcrun 待刪,已落帳) - daemon 段沿用 v0.15.0(zips 非本機可重建、非本次修復範圍) - 含 feat/step3 修復:isVirtualIoName(Input 角色假 found)+registry input_schema 透傳+not_found 分型指路 suggestion+recipe 兩庫查詢 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
24 lines
207 KiB
JavaScript
24 lines
207 KiB
JavaScript
// arcrun-rag-ui — 用戶實例的 GUI(console-ui/public 內嵌;t20④d-2 由 build-ui-bundle.mjs 產生,勿手改)
|
||
const FILES = {"/console/dashboard/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"<!doctype html>\n<html lang=\"zh-Hant\">\n<head>\n<meta charset=\"utf-8\"/>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Arcrun 駕駛艙</title>\n<script>\n // 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),與 /console 共用同一 localStorage key\n document.documentElement.setAttribute('data-theme', (function () {\n try { return localStorage.getItem('arcrun_console_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }\n })());\n</script>\n<style>\n /* Mira Console 定稿視覺(紙感「2a」,Mira Style Guide 2026-07-04):\n 紙紋底 repeating-linear-gradient、明體標題級聯、琥珀強調、呼吸狀態球嵌單字。\n 2026-07-04 二輪:CSS custom properties 兩份色板——預設淺色(宣紙米白+墨字),深色=原定稿暖黑不動。 */\n * { box-sizing: border-box; }\n :root {\n --paper-a: #f4eddc; --paper-b: #f1e9d6;\n --ink: #2f2a20; --ink-rgb: 30,24,14;\n --amber: #8a5f1e; --amber-rgb: 138,95,30;\n --ok: #1d7a48; --ok-rgb: 29,122,72;\n --err: #b03a26; --err-rgb: 176,58,38;\n --track: rgba(30,24,14,.12);\n }\n :root[data-theme=\"dark\"] {\n --paper-a: #191410; --paper-b: #1b1611;\n --ink: #ede4d3; --ink-rgb: 237,228,211;\n --amber: #e8b45a; --amber-rgb: 232,180,90;\n --ok: #7fe0a8; --ok-rgb: 63,190,120;\n --err: #e58575; --err-rgb: 217,95,76;\n --track: rgba(255,255,255,.08);\n }\n html, body { margin: 0; background: repeating-linear-gradient(0deg,var(--paper-a) 0px,var(--paper-a) 3px,var(--paper-b) 3px,var(--paper-b) 4px); color: var(--ink);\n font-family: -apple-system, \"PingFang TC\", \"Microsoft JhengHei\", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }\n .serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }\n main { max-width: 560px; margin: 0 auto; padding: 0 20px 40px; }\n .pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(var(--amber-rgb),.4); display: flex; justify-content: space-between; align-items: baseline; }\n .pagehead .title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; }\n .pagehead .title small { font-size: 14px; letter-spacing: .3em; color: rgba(var(--ink-rgb),.5); }\n .pagehead .date { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 14px; color: rgba(var(--ink-rgb),.55); }\n .orb-row { display: flex; align-items: center; gap: 20px; padding: 26px 2px 20px; }\n .orb { width: 84px; height: 84px; border-radius: 50%; flex: none; display: grid; place-items: center; }\n .orb span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 30px; font-weight: 600; color: rgba(10,20,14,.85); text-shadow: 0 1px 0 rgba(255,255,255,.25); }\n .orb-title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; font-weight: 600; }\n .orb-sub { margin-top: 5px; font-size: 15px; color: rgba(var(--ink-rgb),.6); line-height: 1.55; }\n @keyframes breatheGreen { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--ok-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--ok-rgb),.55); } }\n @keyframes breatheAmber { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--amber-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--amber-rgb),.6); } }\n @keyframes breatheRed { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--err-rgb),.4); } 50% { box-shadow: 0 0 44px 16px rgba(var(--err-rgb),.65); } }\n .bricks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }\n .brick { padding: 16px; border-radius: 12px; }\n .brick.amber { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.22); }\n .brick.plain { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.14); }\n .brick .lbl { font-size: 13.5px; color: rgba(var(--ink-rgb),.55); margin-bottom: 6px; }\n .brick .num { font-family: ui-monospace, Menlo, monospace; font-size: 26px; color: var(--amber); }\n .brick .num small { font-size: 15px; color: rgba(var(--ink-rgb),.5); }\n .bar { margin-top: 10px; height: 6px; border-radius: 3px; background: var(--track); }\n .bar > i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg,#b98330,#e8b45a); transition: width .6s; }\n .wait-box { margin-top: 14px; padding: 20px; border-radius: 12px; border: 1px dashed rgba(var(--ok-rgb),.3); background: rgba(var(--ok-rgb),.05); }\n .wait-box.has { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.05); }\n .wait-head { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); margin-bottom: 10px; text-align: center; }\n .wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: var(--ok); letter-spacing: .08em; text-align: center; }\n .wait-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; margin-top: 8px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.3); font-size: 16px; line-height: 1.5; }\n .wait-item .dm { color: var(--amber); font-size: 17px; flex: none; }\n .wait-meta { margin-top: 10px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); line-height: 1.7; }\n .wait-meta .warn { color: var(--err); }\n .subhead { display: flex; justify-content: space-between; align-items: baseline; margin: 24px 0 10px; }\n .subhead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); }\n .subhead .m { font-size: 13px; color: rgba(var(--ink-rgb),.4); }\n ul.route { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }\n ul.route li { display: flex; align-items: flex-start; gap: 12px; padding: 13px 16px; border-radius: 11px; background: rgba(var(--ink-rgb),.045); border: 1px solid transparent; font-size: 16px; line-height: 1.4; }\n ul.route li.doing { background: rgba(var(--amber-rgb),.09); border-color: rgba(var(--amber-rgb),.3); }\n ul.route li .ic { flex: none; font-size: 15px; margin-top: 2px; }\n ul.route li.done { color: rgba(var(--ink-rgb),.65); }\n ul.route li.done .ic { color: var(--ok); }\n ul.route li.doing .ic { color: var(--amber); }\n ul.route li.todo { color: rgba(var(--ink-rgb),.6); }\n ul.route li.todo .ic { color: rgba(var(--ink-rgb),.35); }\n ul.route li.blocked .ic { color: var(--err); }\n ul.route.faded li { opacity: .55; }\n .sys { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }\n .sys .row { display: flex; justify-content: space-between; align-items: baseline; padding: 10px 14px; border-radius: 10px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); font-size: 14.5px; }\n .sys .row .k { color: rgba(var(--ink-rgb),.6); }\n .sys .row .v { font-family: ui-monospace, Menlo, monospace; font-size: 14px; }\n .sys .ok { color: var(--ok); }\n .sys .bad { color: var(--err); }\n .sys .off { color: rgba(var(--ink-rgb),.5); }\n .muted { color: rgba(var(--ink-rgb),.45); font-size: 14px; }\n .err { color: var(--err); font-size: 14px; }\n .stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.8; }\n .enter { display: block; text-align: center; font-size: 13.5px; color: rgba(var(--amber-rgb),.75); text-decoration: none; margin-top: 6px; }\n .theme-btn { flex: none; margin-left: 12px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.65); font-size: 16px; cursor: pointer; line-height: 1; align-self: center; }\n</style>\n<script src=\"/config.js\"></script>\n<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || \"https://cypher.arcrun.dev\";</script>\n</head>\n<body>\n<main>\n <div class=\"pagehead\">\n <div class=\"title serif\">Arcrun<small> 駕駛艙</small></div>\n <div style=\"display:flex;align-items:baseline\">\n <div class=\"date serif\" id=\"date-str\"></div>\n <button class=\"theme-btn\" id=\"theme-btn\" title=\"切換深/淺色\">☾</button>\n </div>\n </div>\n <div class=\"orb-row\">\n <div class=\"orb\" id=\"orb\" style=\"background:radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)\"><span id=\"orb-char\">…</span></div>\n <div>\n <div class=\"orb-title\" id=\"orb-title\">載入中</div>\n <div class=\"orb-sub\" id=\"orb-sub\"></div>\n </div>\n </div>\n <div class=\"bricks\">\n <div class=\"brick amber\">\n <div class=\"lbl\">今日完成</div>\n <div class=\"num\"><span id=\"done-n\">–</span><small> / <span id=\"total-n\">–</span> 件</small></div>\n <div class=\"bar\"><i id=\"bar-fill\" style=\"width:0%\"></i></div>\n </div>\n <div class=\"brick plain\">\n <div class=\"lbl\">收件匣未處理</div>\n <div class=\"num\"><span id=\"inbox-n\">–</span><small> 條</small></div>\n <div class=\"lbl\" style=\"margin:10px 0 0\">來自 Telegram</div>\n </div>\n </div>\n <div class=\"wait-box\" id=\"wait-box\">\n <div class=\"wait-head\">等你的事</div>\n <div id=\"wait-body\" class=\"wait-none\">載入中…</div>\n <div class=\"wait-meta\" id=\"wait-meta\"></div>\n </div>\n <div class=\"subhead\"><span class=\"t\">今日路線</span><span class=\"m\" id=\"route-m\"></span></div>\n <ul class=\"route\" id=\"today-list\"><li class=\"todo\"><span class=\"ic\">○</span>載入中…</li></ul>\n <div class=\"subhead\" id=\"week-head\" style=\"display:none\"><span class=\"t\">本週</span></div>\n <ul class=\"route\" id=\"week-list\"></ul>\n <div class=\"subhead\"><span class=\"t\">系統狀況</span><span class=\"m\">live 健康信號</span></div>\n <div class=\"sys\" id=\"sys-list\"><div class=\"row\"><span class=\"k\">載入中…</span></div></div>\n <div class=\"stamp\" id=\"stamp\">每 60 秒自動刷新</div>\n <a class=\"enter\" href=\"/console\">進入完整控制台 ›</a>\n</main>\n<script>\n(function () {\n var API_BASE = window.ARCRUN_API_BASE || '';\n // 台北時間 helper(lib/taipei-time.ts 注入,與 server 判定同一套——顯示不隨看的裝置時區漂移)\n var TAIPEI_OFFSET_MS = 28800000; // UTC+8,台北無 DST\nfunction tpePad(n) { n = String(n); return n.length < 2 ? '0' + n : n; }\nfunction taipeiDayKey(ms) { return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10); }\nfunction taipeiDateStr(ms) { return taipeiDayKey(ms); }\nfunction taipeiDateTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return taipeiDayKey(ms) + ' ' + tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()); }\nfunction taipeiTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()) + ':' + tpePad(d.getUTCSeconds()); }\nfunction taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { month: d.getUTCMonth() + 1, day: d.getUTCDate() }; }\n const $ = (id) => document.getElementById(id);\n const LIGHT = {\n green: { ch: '安', title: '系統運轉中', grad: 'radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)', anim: 'breatheGreen' },\n yellow: { ch: '趕', title: '落後趕工中', grad: 'radial-gradient(circle at 36% 30%,#f2d194,#e8b45a 55%,#8a5f1e 100%)', anim: 'breatheAmber' },\n red: { ch: '滯', title: '卡住或斷訊', grad: 'radial-gradient(circle at 36% 30%,#f0a094,#d95f4c 55%,#7e2c20 100%)', anim: 'breatheRed' }\n };\n const ICONS = { done: '✓', doing: '◐', todo: '○', blocked: '●' };\n function esc(s) {\n return String(s ?? '').replace(/[&<>\"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[c]));\n }\n function taskLine(t) {\n const cls = ICONS[t.status] ? t.status : 'blocked';\n const ic = ICONS[t.status] || '●';\n return '<li class=\"' + cls + '\"><span class=\"ic\">' + ic + '</span><span>' + esc(t.title) + '</span></li>';\n }\n function humanAge(m) {\n if (m == null || m < 0) return '時間不明';\n if (m < 60) return m + ' 分鐘前';\n if (m < 2880) return Math.round(m / 60) + ' 小時前';\n return Math.round(m / 1440) + ' 天前';\n }\n const CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];\n function cnDay(n) { return n <= 10 ? CNUM[n] : (n < 20 ? '十' + (n % 10 ? CNUM[n % 10] : '') : CNUM[Math.floor(n / 10)] + '十' + (n % 10 ? CNUM[n % 10] : '')); }\n // 頁首日期=台北日(原本用瀏覽器本地時區,換裝置會漂)\n const nowTpe = taipeiMonthDay(Date.now());\n $('date-str').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';\n // 深/淺切換(與 /console 共用 arcrun_console_theme;預設淺色)\n function syncThemeBtn() { $('theme-btn').textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☾'; }\n $('theme-btn').addEventListener('click', () => {\n const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';\n document.documentElement.setAttribute('data-theme', next);\n try { localStorage.setItem('arcrun_console_theme', next); } catch (e) { /* 私密模式忽略 */ }\n syncThemeBtn();\n });\n syncThemeBtn();\n // fetch 失敗(斷網)的裸訊息 → 友善誠實文案;60 秒定時器常駐,網路恢復自動刷回\n function friendlyErr(e) {\n const m = e && e.message ? String(e.message) : String(e);\n return /failed to fetch|load failed|networkerror|network request failed/i.test(m) ? '連線中斷' : m;\n }\n function sysRow(k, v, cls) {\n return '<div class=\"row\"><span class=\"k\">' + esc(k) + '</span><span class=\"v ' + cls + '\">' + esc(v) + '</span></div>';\n }\n async function load() {\n try {\n const res = await fetch(API_BASE + '/console/dashboard-data');\n if (!res.ok) throw new Error('HTTP ' + res.status);\n const d = await res.json();\n const cfg = LIGHT[d.light] || LIGHT.green;\n const orb = $('orb');\n orb.style.background = cfg.grad;\n orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';\n $('orb-char').textContent = cfg.ch;\n $('orb-title').textContent = cfg.title;\n $('orb-sub').textContent = (d.last_beat\n ? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')\n : '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '(' + d.light_reason + ')' : '');\n const done = d.today_done || 0, total = d.today_total || 0;\n $('done-n').textContent = done; $('total-n').textContent = total;\n $('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';\n $('inbox-n').textContent = d.inbox_new || 0;\n // ── 等你的事:來源 + 維護時間攤開講,stale 一定警示 ──\n const wb = $('wait-box'), body = $('wait-body'), wmeta = $('wait-meta');\n const wm = d.waiting_meta || {};\n if (d.waiting && d.waiting.length) {\n wb.classList.add('has');\n body.className = '';\n body.innerHTML = d.waiting.map((w) =>\n '<div class=\"wait-item\"><span class=\"dm\">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +\n (w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>').join('');\n } else {\n wb.classList.remove('has');\n body.className = 'wait-none';\n body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';\n }\n let metaTxt = '';\n if (wm.source === 'gitea_sprint') {\n metaTxt = '來源:sprint 等leo清單(' + esc((wm.sprint_files || []).join('、')) + ')・清單維護於 ' + humanAge(wm.updated_ago_minutes);\n if (wm.stale) metaTxt += '<br><span class=\"warn\">⚠ 清單超過 2 天沒維護,可能過時</span>';\n } else if (wm.source === 'kbdb_dash_wait') {\n metaTxt = '<span class=\"warn\">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + humanAge(wm.updated_ago_minutes) + ',可能過時</span>';\n } else {\n metaTxt = '<span class=\"warn\">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';\n }\n wmeta.innerHTML = metaTxt;\n // ── 今日路線:sprint 任務板優先(來源攤開講);dash_task fallback 沿舊誠實降級 ──\n const rm = d.route_meta || {};\n const today = (d.tasks || []).filter((t) => t.scope === 'today');\n const week = (d.tasks || []).filter((t) => t.scope === 'week');\n if (rm.source === 'gitea_sprint_board') {\n $('route-m').textContent = '來源 sprint 任務板・更新於 ' + humanAge(rm.updated_ago_minutes);\n $('today-list').className = 'route';\n const staleHead = rm.is_today ? '' :\n '<li class=\"todo\"><span class=\"ic\">○</span><span class=\"muted\">⚠ 今日任務板未更新(最後 ' + humanAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';\n $('today-list').innerHTML = staleHead + (today.length\n ? today.map(taskLine).join('')\n : '<li class=\"todo\"><span class=\"ic\">○</span><span class=\"muted\">任務板上沒有可解析的事項</span></li>');\n } else if (rm.is_today) {\n $('route-m').textContent = '更新於 ' + humanAge(rm.updated_ago_minutes);\n $('today-list').className = 'route';\n $('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class=\"todo\"><span class=\"ic\">○</span><span class=\"muted\">今日無排定項目</span></li>';\n } else if (today.length) {\n $('route-m').textContent = '最後路線・' + humanAge(rm.updated_ago_minutes) + '寫入';\n $('today-list').className = 'route faded';\n $('today-list').innerHTML =\n '<li class=\"todo\"><span class=\"ic\">○</span><span class=\"muted\">今日尚無路線寫入——以下是 ' + humanAge(rm.updated_ago_minutes) +\n '的殘留路線(sprint 任務板→dashboard 投影管線未接,等leo清單#15 裁決中)</span></li>' + today.map(taskLine).join('');\n } else {\n $('route-m').textContent = '';\n $('today-list').className = 'route';\n $('today-list').innerHTML = '<li class=\"todo\"><span class=\"ic\">○</span><span class=\"muted\">無資料——dash_task 管線未接</span></li>';\n }\n $('week-head').style.display = week.length ? '' : 'none';\n $('week-list').innerHTML = week.map(taskLine).join('');\n // ── 系統狀況 + 總庫規模(全 live,讀不到就標讀不到)──\n const sys = d.system || {}, kb = d.kb || {};\n const rows = [];\n rows.push(sysRow('KBDB 基本盤', sys.kbdb_ok ? '● 正常' : '● 打不通', sys.kbdb_ok ? 'ok' : 'bad'));\n if (sys.embed) {\n rows.push(sys.embed.enabled\n ? sysRow('語意嵌入', '● 啟用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'ok')\n : sysRow('語意嵌入', '○ 停用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'bad'));\n } else {\n rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));\n }\n rows.push(sys.graph && sys.graph.ok\n ? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')\n : sysRow('知識圖譜', '● 打不通', 'bad'));\n rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));\n // 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;\n // 三元組/已嵌入 已各有一列)\n rows.push(sysRow('精耕層 wiki 卡', kb.wiki_card_total == null ? '讀不到' : kb.wiki_card_total + ' 張', kb.wiki_card_total == null ? 'off' : ''));\n $('sys-list').innerHTML = rows.join('');\n $('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(taipeiTimeStr(Date.parse(d.generated_at))) + '(台北)<br>此頁不含機敏內容,免登入';\n } catch (e) {\n $('orb-char').textContent = '?';\n $('orb-title').textContent = '讀不到狀態';\n $('orb-sub').innerHTML = '<span class=\"err\">' + esc(friendlyErr(e)) + '・每 60 秒自動重試</span>';\n }\n }\n load();\n setInterval(load, 60000);\n})();\n</script>\n</body>\n</html>\n"},"/console/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"<!doctype html>\n<html lang=\"zh-Hant\">\n<head>\n<meta charset=\"utf-8\"/>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Arcrun Console</title>\n<script>\n // 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),選擇存 localStorage\n document.documentElement.setAttribute('data-theme', (function () {\n try { return localStorage.getItem('arcrun_console_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }\n })());\n</script>\n<style>\n * { box-sizing: border-box; }\n /* ── 主題色板(一套結構兩份色值):預設淺色=宣紙米白紙紋+墨色文字;深色=定稿紙感暖黑 2a 原值不動 ── */\n :root {\n --paper-a: #f4eddc; --paper-b: #f1e9d6; /* 宣紙米白紙紋 */\n --ink: #2f2a20; --ink-rgb: 30,24,14; /* 墨色文字 */\n --amber: #8a5f1e; --amber-rgb: 138,95,30; /* 琥珀沿用、調深到淺底可讀 */\n --ok: #1d7a48; --ok-rgb: 29,122,72; --ok-soft: #1d6b40;\n --err: #b03a26; --err-rgb: 176,58,38;\n --well: rgba(30,24,14,.06); --well2: rgba(30,24,14,.08);\n --bar-bg: #efe7d3; --track: rgba(30,24,14,.12); --knob: #ffffff;\n }\n :root[data-theme=\"dark\"] {\n --paper-a: #191410; --paper-b: #1b1611;\n --ink: #ede4d3; --ink-rgb: 237,228,211;\n --amber: #e8b45a; --amber-rgb: 232,180,90;\n --ok: #7fe0a8; --ok-rgb: 63,190,120; --ok-soft: #a8e8c4;\n --err: #e58575; --err-rgb: 217,95,76;\n --well: rgba(0,0,0,.25); --well2: rgba(0,0,0,.3);\n --bar-bg: #17120e; --track: rgba(255,255,255,.08); --knob: #ede4d3;\n }\n html, body { margin: 0; background: repeating-linear-gradient(0deg,var(--paper-a) 0px,var(--paper-a) 3px,var(--paper-b) 3px,var(--paper-b) 4px); color: var(--ink);\n font-family: -apple-system, \"PingFang TC\", \"Microsoft JhengHei\", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }\n input, textarea, button { font-family: inherit; }\n ::placeholder { color: rgba(var(--ink-rgb),.35); }\n input:focus, textarea:focus { outline: 2px solid rgba(var(--amber-rgb),.5); outline-offset: 1px; }\n a { color: var(--amber); }\n .serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }\n .mono { font-family: ui-monospace, Menlo, monospace; }\n .muted { color: rgba(var(--ink-rgb),.55); }\n .dim { color: rgba(var(--ink-rgb),.4); }\n .err { color: var(--err); }\n .ok { color: var(--ok); }\n @keyframes breatheGreen { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--ok-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--ok-rgb),.55); } }\n @keyframes breatheAmber { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--amber-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--amber-rgb),.6); } }\n @keyframes breatheRed { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--err-rgb),.4); } 50% { box-shadow: 0 0 44px 16px rgba(var(--err-rgb),.65); } }\n\n /* ── auth 全屏 ── */\n .authwrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }\n .authbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; text-align: center; }\n .brand { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 44px; letter-spacing: .24em; color: var(--amber); }\n .brand-sub { margin-top: 10px; font-size: 15px; color: rgba(var(--ink-rgb),.55); letter-spacing: .1em; }\n\n /* ── 版面骨架 ── */\n #shell { display: none; min-height: 100vh; }\n #shell.on { display: flex; }\n #sidenav { display: none; }\n main { flex: 1; min-width: 0; padding-bottom: 96px; }\n .page { max-width: 980px; margin: 0 auto; padding: 0 20px 40px; }\n .page.narrow { max-width: 760px; }\n .page.wide { max-width: 1080px; }\n .pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(var(--amber-rgb),.4); display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }\n .pagehead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; white-space: nowrap; }\n .pagehead .m { font-size: 13.5px; color: rgba(var(--ink-rgb),.45); }\n #tabbar { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bar-bg); border-top: 2px solid rgba(var(--amber-rgb),.4); display: none; z-index: 50; padding-bottom: env(safe-area-inset-bottom); }\n #tabbar.on { display: flex; }\n #tabbar .tab { flex: 1; text-align: center; padding: 15px 0 13px; font-size: 14.5px; cursor: pointer; min-height: 44px; color: rgba(var(--ink-rgb),.5); border-top: 2px solid transparent; margin-top: -2px; }\n #tabbar .tab.on { color: var(--amber); font-weight: 600; border-top-color: var(--amber); }\n @media (min-width: 1024px) {\n #tabbar.on { display: none; }\n main { padding-bottom: 20px; }\n #sidenav { display: flex; width: 216px; flex: none; position: sticky; top: 0; height: 100vh; border-right: 2px solid rgba(var(--amber-rgb),.35); padding: 28px 0; flex-direction: column; gap: 4px; }\n #sidenav .logo { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 26px; letter-spacing: .22em; color: var(--amber); padding: 0 24px 20px; }\n #sidenav .nav { padding: 13px 24px; font-size: 16px; cursor: pointer; letter-spacing: .08em; color: rgba(var(--ink-rgb),.6); border-right: 2px solid transparent; }\n #sidenav .nav.on { color: var(--amber); background: rgba(var(--amber-rgb),.08); border-right-color: var(--amber); font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-weight: 600; }\n #sidenav .foot { margin-top: auto; padding: 0 24px; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.7; }\n }\n .view { display: none; }\n .view.on { display: block; }\n /* 登入/首次設定=flex 置中(.view.on 的 display:block 會蓋掉 .authwrap 的 flex → 內容靠左,leo 實測回饋修正) */\n .authwrap.view.on { display: flex; }\n\n /* ── 元件 ── */\n .btn { padding: 13px; font-size: 16px; font-weight: 600; border-radius: 10px; border: none; background: linear-gradient(90deg,#b98330,#e8b45a); color: #241804; cursor: pointer; }\n .btn:disabled { opacity: .45; cursor: default; }\n .btn2 { padding: 11px 16px; font-size: 15px; border-radius: 10px; border: 1px solid rgba(var(--amber-rgb),.6); background: none; color: var(--amber); cursor: pointer; }\n .btn3 { padding: 11px 16px; font-size: 15px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.7); cursor: pointer; }\n .btn-danger { padding: 12px 18px; font-size: 15px; border-radius: 10px; border: 1px solid rgba(var(--err-rgb),.5); background: none; color: var(--err); cursor: pointer; }\n .btn-danger:disabled { opacity: .4; cursor: default; }\n .txt { width: 100%; padding: 13px 15px; font-size: 16px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.2); background: rgba(var(--ink-rgb),.05); color: var(--ink); }\n textarea.txt { resize: vertical; font-family: ui-monospace, Menlo, monospace; font-size: 14.5px; }\n .panel { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); }\n .card-item { border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); }\n .card-item.hl { border-color: rgba(var(--amber-rgb),.3); }\n .tag { display: inline-block; padding: 3px 9px; border-radius: 5px; background: rgba(var(--amber-rgb),.12); color: var(--amber); font-size: 12.5px; }\n .tag.green { background: rgba(var(--ok-rgb),.12); color: var(--ok); }\n .chip { flex: none; padding: 9px 16px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); }\n .chip.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }\n .hscroll { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }\n .subhead { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); }\n .honest { padding: 22px; border-radius: 13px; border: 1px dashed rgba(var(--amber-rgb),.4); background: rgba(var(--amber-rgb),.06); text-align: center; }\n .honest .h { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 19px; color: var(--amber); margin-bottom: 8px; }\n .honest .b { font-size: 15px; color: rgba(var(--ink-rgb),.6); line-height: 1.7; }\n #toast { position: fixed; left: 50%; bottom: 110px; transform: translateX(-50%); z-index: 99; padding: 13px 22px; border-radius: 11px; background: #e8b45a; color: #241804; font-size: 15.5px; font-weight: 600; box-shadow: 0 6px 24px rgba(0,0,0,.5); white-space: nowrap; display: none; }\n\n /* ── 駕駛艙 ── */\n .orb-row { display: flex; align-items: center; gap: 20px; padding: 26px 2px 20px; }\n .orb { width: 84px; height: 84px; border-radius: 50%; flex: none; display: grid; place-items: center; }\n .orb span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 30px; font-weight: 600; color: rgba(10,20,14,.85); text-shadow: 0 1px 0 rgba(255,255,255,.25); }\n .orb-title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; font-weight: 600; }\n .orb-sub { margin-top: 5px; font-size: 15px; color: rgba(var(--ink-rgb),.6); line-height: 1.55; }\n .cockgrid { display: grid; grid-template-columns: 1fr; gap: 0 40px; }\n @media (min-width: 1024px) { .cockgrid { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } }\n .bricks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }\n .brick { padding: 16px; border-radius: 12px; }\n .brick.amber { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.22); }\n .brick.plain { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.14); cursor: pointer; }\n .brick .lbl { font-size: 13.5px; color: rgba(var(--ink-rgb),.55); margin-bottom: 6px; }\n .brick .num { font-family: ui-monospace, Menlo, monospace; font-size: 26px; color: var(--amber); }\n .brick .num small { font-size: 15px; color: rgba(var(--ink-rgb),.5); }\n .bar { margin-top: 10px; height: 6px; border-radius: 3px; background: var(--track); }\n .bar > i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg,#b98330,#e8b45a); transition: width .6s; }\n .wait-box { margin-top: 14px; padding: 20px; border-radius: 12px; border: 1px dashed rgba(var(--ok-rgb),.3); background: rgba(var(--ok-rgb),.05); }\n .wait-box.has { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.05); }\n .wait-head { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); margin-bottom: 10px; text-align: center; }\n .wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: var(--ok); letter-spacing: .08em; text-align: center; }\n .wait-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; margin-top: 8px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.3); font-size: 16px; line-height: 1.5; }\n .wait-item .dm { color: var(--amber); font-size: 17px; flex: none; }\n ul.route { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }\n ul.route li { display: flex; align-items: flex-start; gap: 12px; padding: 13px 16px; border-radius: 11px; background: rgba(var(--ink-rgb),.045); border: 1px solid transparent; font-size: 16px; line-height: 1.4; }\n ul.route li.doing { background: rgba(var(--amber-rgb),.09); border-color: rgba(var(--amber-rgb),.3); }\n ul.route li .ic { flex: none; font-size: 15px; margin-top: 2px; }\n ul.route li.done { color: rgba(var(--ink-rgb),.65); } ul.route li.done .ic { color: var(--ok); }\n ul.route li.doing .ic { color: var(--amber); }\n ul.route li.todo { color: rgba(var(--ink-rgb),.6); } ul.route li.todo .ic { color: rgba(var(--ink-rgb),.35); }\n ul.route li.blocked .ic { color: var(--err); }\n\n /* ── 搜尋 ── */\n .searchrow { display: flex; gap: 10px; align-items: stretch; }\n .searchrow input { flex: 1; min-width: 0; padding: 16px 18px; font-size: 18px; border-radius: 13px; border: 1.5px solid rgba(var(--amber-rgb),.35); background: rgba(var(--ink-rgb),.05); color: var(--ink); }\n .sembtn { flex: none; padding: 0 20px; font-size: 16px; border-radius: 13px; cursor: pointer; letter-spacing: .1em; border: 1.5px solid rgba(var(--ink-rgb),.2); background: none; color: rgba(var(--ink-rgb),.55); }\n .sembtn.on { border-color: var(--amber); background: rgba(var(--amber-rgb),.15); color: var(--amber); font-weight: 600; }\n .kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; }\n @media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }\n .kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; }\n .kcard:hover { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.06); }\n .kcard .kt { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 18.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }\n .kcard .ks { font-size: 15px; color: rgba(var(--ink-rgb),.65); line-height: 1.65; word-break: break-word; }\n .kcard .km { display: flex; gap: 10px; align-items: center; margin-top: auto; font-size: 13px; flex-wrap: wrap; }\n\n /* ── 卡片詳頁 ── */\n .cardwrap { display: flex; flex-wrap: wrap; gap: 32px; padding: 24px 0 40px; }\n .cardmain { flex: 1 1 540px; min-width: 0; }\n .cardside { flex: 1 1 360px; min-width: 0; }\n .cardtitle { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 28px; font-weight: 600; line-height: 1.4; word-break: break-word; }\n .md { margin-top: 18px; }\n .md p { font-size: 16.5px; line-height: 1.85; color: rgba(var(--ink-rgb),.82); margin: 8px 0; word-break: break-word; }\n .md h1, .md h2, .md h3, .md h4 { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-weight: 600; color: var(--amber); margin: 18px 0 6px; line-height: 1.4; }\n .md h1 { font-size: 22px; } .md h2 { font-size: 20px; } .md h3 { font-size: 18px; } .md h4 { font-size: 17px; }\n .md ul, .md ol { margin: 8px 0; padding-left: 24px; }\n .md li { font-size: 16.5px; line-height: 1.8; color: rgba(var(--ink-rgb),.82); margin: 4px 0; word-break: break-word; }\n .md blockquote { margin: 10px 0; padding: 12px 18px; border-left: 3px solid rgba(var(--amber-rgb),.5); font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; line-height: 1.8; color: rgba(var(--ink-rgb),.7); }\n .md code { font-family: ui-monospace, Menlo, monospace; font-size: 14.5px; background: var(--well2); border-radius: 4px; padding: 1px 6px; word-break: break-all; }\n .md pre { background: var(--well2); border: 1px solid rgba(var(--ink-rgb),.1); border-radius: 10px; padding: 14px; overflow-x: auto; }\n .md pre code { background: none; padding: 0; }\n .srcline { margin-top: 24px; display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 11px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.12); }\n .graphbox { position: relative; height: 340px; border-radius: 14px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); overflow: hidden; }\n .gnode { position: absolute; transform: translate(-50%,-50%); max-width: 96px; padding: 8px 11px; border-radius: 999px; text-align: center; font-size: 12.5px; line-height: 1.3; cursor: pointer; word-break: break-word; background: rgba(var(--ok-rgb),.1); border: 1px solid rgba(var(--ok-rgb),.35); color: var(--ok-soft); }\n .gcenter { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 104px; height: 104px; border-radius: 50%; background: radial-gradient(circle at 36% 30%,rgba(242,209,148,.95),rgba(232,180,90,.9) 55%,rgba(138,95,30,.95)); display: grid; place-items: center; text-align: center; padding: 8px; box-shadow: 0 0 30px rgba(var(--amber-rgb),.3); }\n .gcenter span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 13.5px; font-weight: 600; line-height: 1.35; color: #241804; word-break: break-word; }\n .nbrow { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; cursor: pointer; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); font-size: 15.5px; }\n\n /* ── 工作流 / 憑證 / 分流台 清單 ── */\n .listcol { display: flex; flex-direction: column; gap: 10px; padding: 16px 0 8px; }\n .rowhead { display: flex; align-items: center; gap: 12px; padding: 16px 18px; cursor: pointer; }\n .rowbody { padding: 0 18px 18px; border-top: 1px solid rgba(var(--ink-rgb),.1); }\n .pinbtn { flex: none; width: 34px; height: 34px; border-radius: 8px; border: none; cursor: pointer; font-size: 17px; background: rgba(var(--ink-rgb),.06); color: rgba(var(--ink-rgb),.4); }\n .pinbtn.on { background: rgba(var(--amber-rgb),.15); color: var(--amber); }\n .urlbox { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 10px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); min-width: 0; }\n .urlbox .u { font-family: ui-monospace, Menlo, monospace; font-size: 13.5px; color: var(--amber); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }\n .avatar { width: 38px; height: 38px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.25); display: grid; place-items: center; font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; color: var(--amber); flex: none; }\n .maskline { display: flex; align-items: center; gap: 10px; padding: 14px; border-radius: 10px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); }\n /* 分流台(Arcrun#9):三欄 80/15/5 手機直排;欄色點 ai=綠 collab=琥珀 leo=紅 */\n .tri-sec { margin-top: 24px; }\n .tri-sechead { display: flex; align-items: baseline; gap: 9px; }\n .tri-sechead .dot { flex: none; width: 9px; height: 9px; border-radius: 50%; align-self: center; }\n .tri-sec.ai .dot { background: var(--ok); } .tri-sec.collab .dot { background: var(--amber); } .tri-sec.leo .dot { background: var(--err); }\n .tri-sechead .nm { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; letter-spacing: .14em; }\n .tri-sechead .pc { font-size: 12.5px; color: rgba(var(--ink-rgb),.4); }\n .tri-sechead .ct { margin-left: auto; font-family: ui-monospace, Menlo, monospace; font-size: 14px; color: rgba(var(--ink-rgb),.55); }\n .tri-item { padding: 14px 16px; border-radius: 12px; margin-top: 9px; }\n .tri-item.new { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.28); }\n .tri-sec.leo .tri-item.new { background: rgba(var(--err-rgb),.06); border-color: rgba(var(--err-rgb),.3); }\n .tri-item.done { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.1); opacity: .7; }\n .tri-item.done .tx { text-decoration: line-through; color: rgba(var(--ink-rgb),.6); }\n .tri-item .tx { font-size: 16.5px; line-height: 1.6; word-break: break-word; }\n .tri-item .mt { margin-top: 9px; display: flex; gap: 7px; align-items: center; flex-wrap: wrap; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); }\n .tri-item .row { display: flex; gap: 12px; align-items: flex-start; }\n .tri-item .row .tx { flex: 1; min-width: 0; }\n /* 勾掉鈕:≥44px 圓形(手機好按),綠框空心=待勾;按下去由後端回寫才算數(誠實,不先假裝成功) */\n .tri-check { flex: none; width: 44px; height: 44px; border-radius: 50%; cursor: pointer; font-size: 19px; line-height: 1;\n border: 1.5px solid rgba(var(--ok-rgb),.55); background: rgba(var(--ok-rgb),.06); color: var(--ok); }\n .tri-check:active { background: rgba(var(--ok-rgb),.22); }\n .tri-check:disabled { opacity: .45; cursor: default; }\n /* 還原鈕(誤勾救濟,只出現在「顯示已完成」清單):小顆但仍 44px 高好按 */\n .tri-restore { flex: none; min-height: 32px; padding: 5px 14px; border-radius: 999px; cursor: pointer; font-size: 13px;\n border: 1px solid rgba(var(--amber-rgb),.5); background: none; color: var(--amber); }\n .tri-restore:disabled { opacity: .45; cursor: default; }\n .tag.red { background: rgba(var(--err-rgb),.12); color: var(--err); }\n .tag.dim { background: rgba(var(--ink-rgb),.08); color: rgba(var(--ink-rgb),.55); }\n\n /* ── 設定 ── */\n .setcol { display: flex; flex-direction: column; gap: 14px; padding: 20px 0 40px; }\n .switch { flex: none; margin-left: auto; width: 56px; height: 32px; border-radius: 999px; padding: 3px; background: rgba(var(--ink-rgb),.15); transition: background .25s; }\n .switch.on { background: rgba(var(--ok-rgb),.5); }\n .switch i { display: block; width: 26px; height: 26px; border-radius: 50%; background: var(--knob); box-shadow: 0 1px 3px rgba(0,0,0,.25); transition: transform .25s; }\n .switch.on i { transform: translateX(24px); }\n .kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }\n</style>\n<script src=\"/config.js\"></script>\n<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || \"https://cypher.arcrun.dev\";</script>\n</head>\n<body>\n\n<!-- 登入 -->\n<div class=\"authwrap view\" id=\"v-login\">\n <div class=\"authbox\">\n <div>\n <div class=\"brand\">Arcrun</div>\n <div class=\"brand-sub\">私人系統・僅供擁有者進入</div>\n </div>\n <div style=\"display:flex;flex-direction:column;gap:12px;text-align:left\">\n <input type=\"email\" id=\"login-email\" class=\"txt\" placeholder=\"Email\" style=\"padding:15px 16px;font-size:17px;border-radius:11px\">\n <input type=\"password\" id=\"login-password\" class=\"txt\" placeholder=\"密碼\" style=\"padding:15px 16px;font-size:17px;border-radius:11px\">\n <button class=\"btn\" id=\"login-submit\" style=\"margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em\">進入</button>\n <div id=\"login-status\" class=\"err\" style=\"font-size:14px;min-height:1.2em\"></div>\n </div>\n <div style=\"font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7\">這是一個人的智慧總部。<br>若你不是擁有者,這裡沒有你要找的東西。</div>\n \n <button class=\"btn3 themelabel\" data-themetoggle style=\"align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px\">☾ 切深色</button>\n </div>\n</div>\n\n<!-- 首次設定 -->\n<div class=\"authwrap view\" id=\"v-setup\">\n <div class=\"authbox\" style=\"max-width:440px;text-align:left\">\n <div style=\"text-align:center\">\n <div class=\"brand\" style=\"font-size:38px\">Arcrun</div>\n <div class=\"serif\" style=\"margin-top:12px;font-size:21px\">首次設定</div>\n <div style=\"margin-top:8px;font-size:15px;color:rgba(var(--ink-rgb),.55);line-height:1.7\">系統偵測到尚未設定擁有者帳號。<br>設定一組 Email 與密碼,之後只有你能進入。</div>\n </div>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <input type=\"email\" id=\"setup-email\" class=\"txt\" placeholder=\"你的 Email\" style=\"padding:15px 16px;font-size:17px;border-radius:11px\">\n <input type=\"password\" id=\"setup-password\" class=\"txt\" placeholder=\"設定密碼(至少 8 碼)\" style=\"padding:15px 16px;font-size:17px;border-radius:11px\">\n <input type=\"password\" id=\"setup-password2\" class=\"txt\" placeholder=\"再輸入一次密碼\" style=\"padding:15px 16px;font-size:17px;border-radius:11px\">\n <button class=\"btn\" id=\"setup-submit\" style=\"margin-top:6px;padding:15px;font-size:17px;letter-spacing:.12em\">完成設定並進入</button>\n <div id=\"setup-status\" class=\"err\" style=\"font-size:14px;min-height:1.2em\"></div>\n </div>\n <button class=\"btn3 themelabel\" data-themetoggle style=\"align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px\">☾ 切深色</button>\n </div>\n</div>\n\n<!-- 主應用 -->\n<div id=\"shell\">\n <div id=\"sidenav\">\n <div class=\"logo\">Arcrun</div>\n \n <div class=\"nav\" data-nav=\"search\">總庫搜尋</div>\n <div class=\"nav\" data-nav=\"workflows\">工作流</div>\n \n <div class=\"nav\" data-nav=\"settings\">設定</div>\n <div class=\"nav themelabel\" data-themetoggle style=\"margin-top:8px;font-size:14.5px\">☾ 切深色</div>\n <div class=\"foot\" id=\"side-foot\"></div>\n </div>\n <main>\n\n <!-- 駕駛艙 -->\n <div class=\"view page\" id=\"v-cockpit\">\n <div class=\"pagehead\"><span class=\"t\">Arcrun<span style=\"font-size:14px;letter-spacing:.3em;color:rgba(var(--ink-rgb),.5)\"> 控制台</span></span><span class=\"m serif\" id=\"ck-date\"></span></div>\n <div class=\"cockgrid\">\n <div>\n <div class=\"orb-row\">\n <div class=\"orb\" id=\"ck-orb\" style=\"background:radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)\"><span id=\"ck-char\">…</span></div>\n <div>\n <div class=\"orb-title\" id=\"ck-title\">載入中</div>\n <div class=\"orb-sub\" id=\"ck-sub\"></div>\n </div>\n </div>\n <div class=\"bricks\">\n <div class=\"brick amber\">\n <div class=\"lbl\">今日完成</div>\n <div class=\"num\"><span id=\"ck-done\">–</span><small> / <span id=\"ck-total\">–</span> 件</small></div>\n <div class=\"bar\"><i id=\"ck-bar\" style=\"width:0%\"></i></div>\n </div>\n <div class=\"brick plain\" data-nav=\"inbox\">\n <div class=\"lbl\">收件匣未處理</div>\n <div class=\"num\"><span id=\"ck-inbox\">–</span><small> 條</small></div>\n <div class=\"lbl\" style=\"margin:10px 0 0\">來自 Telegram</div>\n </div>\n </div>\n <div class=\"wait-box\" id=\"ck-waitbox\">\n <div class=\"wait-head\">等你的事</div>\n <div id=\"ck-wait\" class=\"wait-none\">載入中…</div>\n <div id=\"ck-waitmeta\" style=\"margin-top:10px;text-align:center;font-size:12.5px;color:rgba(var(--ink-rgb),.45);line-height:1.7\"></div>\n </div>\n </div>\n <div>\n <div style=\"display:flex;justify-content:space-between;align-items:baseline;margin:24px 0 10px\">\n <span class=\"subhead\">今日路線</span><span id=\"ck-route-src\" style=\"font-size:13px;color:rgba(var(--ink-rgb),.4)\">狀態即時同步</span>\n </div>\n <ul class=\"route\" id=\"ck-today\"><li class=\"todo\"><span class=\"ic\">○</span><span class=\"muted\">載入中…</span></li></ul>\n <div class=\"subhead\" id=\"ck-weekhead\" style=\"display:none;margin:18px 0 10px\">本週</div>\n <ul class=\"route\" id=\"ck-week\"></ul>\n <div style=\"margin:16px 0 8px;text-align:center;font-size:12.5px;color:rgba(var(--ink-rgb),.35);line-height:1.8\" id=\"ck-stamp\">每 60 秒自動刷新</div>\n </div>\n </div>\n </div>\n\n <!-- 總庫搜尋(library-map SDD M5/R4:搜尋框上方=藏書地圖——先看全館有哪些庫,點庫卡片帶 library filter 進庫搜尋) -->\n <div class=\"view page wide\" id=\"v-search\">\n <div class=\"pagehead\"><span class=\"t\">總庫搜尋</span><span class=\"m mono\" id=\"se-scale\"></span></div>\n <div style=\"padding-top:20px\">\n <div id=\"lm-section\" style=\"margin-bottom:24px\">\n <div style=\"display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px\">\n <span class=\"subhead\">藏書地圖</span><span id=\"lm-meta\" style=\"font-size:13px;color:rgba(var(--ink-rgb),.4)\"></span>\n </div>\n <div class=\"kgrid\" id=\"lm-cards\"><div class=\"muted\" style=\"font-size:14px\">地圖載入中…</div></div>\n <div id=\"lm-bridges\"></div>\n </div>\n <div class=\"searchrow\">\n <input id=\"se-q\" placeholder=\"搜尋知識…\" autocomplete=\"off\">\n <button class=\"sembtn\" id=\"se-sem\">語意</button>\n </div>\n <div id=\"se-libbar\"></div>\n <div class=\"hscroll\" id=\"se-chips\" style=\"margin-top:12px\"></div>\n <div id=\"se-banner\"></div>\n <div id=\"se-count\" style=\"margin:20px 0 12px;font-size:14px;color:rgba(var(--ink-rgb),.5)\"></div>\n <div class=\"kgrid\" id=\"se-results\"></div>\n </div>\n </div>\n\n <!-- 卡片詳頁 -->\n <div class=\"view page wide\" id=\"v-card\">\n <div class=\"pagehead\" style=\"justify-content:flex-start;gap:14px\">\n <button class=\"btn2\" style=\"border:none;padding:0;font-size:16px\" data-nav=\"search\">‹ 搜尋</button>\n <span class=\"serif\" style=\"font-size:17px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.55)\">知識卡片</span>\n </div>\n <div class=\"cardwrap\">\n <div class=\"cardmain\" id=\"cd-main\"><div class=\"muted\" style=\"padding:24px 0\">載入中…</div></div>\n <div class=\"cardside\">\n <div class=\"subhead\" style=\"margin:6px 0 12px;display:block\">關聯視圖</div>\n <div id=\"cd-graph\"></div>\n <div id=\"cd-neighbors\" style=\"margin-top:16px;display:flex;flex-direction:column;gap:7px\"></div>\n </div>\n </div>\n </div>\n\n <!-- 工作流 -->\n <div class=\"view page narrow\" id=\"v-workflows\">\n <div class=\"pagehead\"><span class=\"t\">工作流</span><span class=\"m\" id=\"wf-count\"></span></div>\n <input id=\"wf-q\" class=\"txt\" placeholder=\"搜尋工作流…\" style=\"margin-top:18px\" autocomplete=\"off\">\n <div class=\"listcol\" id=\"wf-list\"><div class=\"muted\">載入中…</div></div>\n <div style=\"margin-top:26px\">\n <div class=\"subhead\" style=\"display:block;margin-bottom:10px\">零件與 Recipes(框架資源)</div>\n <div class=\"searchrow\">\n <input id=\"cr-q\" class=\"txt\" placeholder=\"如「telegram send」「gsheets append」\" style=\"font-size:15px;padding:12px 14px;border-radius:11px;border-width:1px\">\n <button class=\"btn2\" id=\"cr-go\" style=\"flex:none\">搜</button>\n </div>\n <div id=\"cr-out\" style=\"margin-top:10px\"></div>\n </div>\n </div>\n\n <!-- 憑證管理 -->\n <div class=\"view page narrow\" id=\"v-creds\">\n <div class=\"pagehead\"><span class=\"t\">憑證管理</span><button class=\"btn2\" id=\"cred-addbtn\" style=\"padding:9px 16px;font-size:14.5px\">+ 新增憑證</button></div>\n <div style=\"margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--ok-rgb),.05);border:1px solid rgba(var(--ok-rgb),.2);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)\">\n <span class=\"ok\">🛡 保險箱原則</span>:系統只保存目錄與加密後的值,<b style=\"color:var(--ink)\">任何頁面都看不到密文內容</b>。只能整筆替換或刪除。\n </div>\n <div id=\"cred-form\" style=\"display:none;margin-top:14px\" class=\"panel\">\n <div class=\"serif\" style=\"font-size:17px;color:var(--amber);margin-bottom:10px\">新增憑證</div>\n <div style=\"display:flex;flex-direction:column;gap:10px\">\n <input id=\"cred-name\" class=\"txt\" placeholder=\"名稱(英數與底線,例:openai_main_key)\">\n <input id=\"cred-service\" class=\"txt\" placeholder=\"服務(例:OpenAI)\">\n <textarea id=\"cred-secret\" class=\"txt\" placeholder=\"密文值(保存後不再顯示)\" rows=\"2\"></textarea>\n <button class=\"btn\" id=\"cred-save\">加密保存</button>\n <div id=\"cred-form-status\" style=\"font-size:14px;min-height:1.2em\"></div>\n </div>\n </div>\n <div class=\"listcol\" id=\"cred-list\"><div class=\"muted\">載入中…</div></div>\n </div>\n\n <!-- 分流台(原收件匣,Arcrun#9 改裝=全部待辦分流台) -->\n <div class=\"view page narrow\" id=\"v-inbox\">\n <div class=\"pagehead\"><span class=\"t\">分流台</span><span class=\"m\">全部待辦・80/15/5</span></div>\n <div class=\"hscroll\" id=\"tg-chips\" style=\"margin-top:14px\"></div>\n <div id=\"tg-out\" style=\"padding-top:2px\"><div class=\"muted\" style=\"padding:14px 0\">載入中…</div></div>\n </div>\n\n <!-- 設定 -->\n <div class=\"view page narrow\" id=\"v-settings\">\n <div class=\"pagehead\"><span class=\"t\">設定</span></div>\n <div class=\"setcol\">\n <div class=\"panel\">\n <div style=\"display:flex;align-items:center;gap:14px\">\n <div style=\"min-width:0\">\n <div style=\"font-size:17px;font-weight:600\">深色模式</div>\n <div style=\"margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)\">預設淺色(紙感)。切換立即生效,選擇記在這台裝置(localStorage)。</div>\n </div>\n <div class=\"switch\" id=\"st-theme-switch\" data-themetoggle style=\"cursor:pointer\"><i></i></div>\n </div>\n </div>\n <div class=\"panel\">\n <div style=\"display:flex;align-items:center;gap:14px\">\n <div style=\"min-width:0\">\n <div style=\"font-size:17px;font-weight:600\">語意搜尋(vectorize)</div>\n <div id=\"st-vec\" style=\"margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)\">狀態偵測中…</div>\n </div>\n <div class=\"switch\" id=\"st-vec-switch\" title=\"此開關不能遠端改,只如實顯示狀態\"><i></i></div>\n </div>\n <div style=\"margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)\">\n 誠實提示:開關真相=部署端 <code style=\"font-size:12.5px\">~/.arcrun/config.yaml</code> 的 <code style=\"font-size:12.5px\">kbdb_embed: true</code> + <code style=\"font-size:12.5px\">acr update</code> 重部署(#32 教訓:wrangler 直推改的形態 config 要同步)。Console 只如實顯示狀態,不假裝能遠端開啟。目前狀態:<b style=\"color:var(--amber)\" id=\"st-vec-state\">偵測中…</b>\n </div>\n </div>\n <div class=\"panel\">\n <div style=\"font-size:17px;font-weight:600\">MCP token 有效期(TTL)</div>\n <div id=\"st-mcp-ttl\" style=\"margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)\">讀取中…</div>\n <div style=\"margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)\">\n 誠實佔位:此頁<b style=\"color:var(--amber)\">還不能改</b>這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env <code style=\"font-size:12.5px\">MCP_TOKEN_TTL</code>(mcp worker)設定。\n </div>\n </div>\n <div class=\"panel\">\n <div style=\"font-size:17px;font-weight:600\">更換帳號密碼</div>\n <div style=\"margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)\">需輸入舊密碼驗證身分</div>\n <div style=\"margin-top:14px;display:flex;flex-direction:column;gap:10px\">\n <input type=\"password\" id=\"st-pw-old\" class=\"txt\" placeholder=\"舊密碼\">\n <input type=\"email\" id=\"st-pw-email\" class=\"txt\" placeholder=\"Email(可沿用或換新)\">\n <input type=\"password\" id=\"st-pw-new\" class=\"txt\" placeholder=\"新密碼(至少 8 碼)\">\n <button class=\"btn\" id=\"st-pw-save\">更新密碼</button>\n <div id=\"st-pw-status\" style=\"font-size:14px;min-height:1.2em\"></div>\n </div>\n </div>\n \n <div class=\"panel\">\n <div style=\"font-size:17px;font-weight:600;margin-bottom:12px\">系統資訊</div>\n <div id=\"st-info\"><div class=\"muted\">載入中…</div></div>\n </div>\n <button class=\"btn3\" id=\"st-logout\" style=\"padding:14px;font-size:16px;border-radius:11px\">登出</button>\n </div>\n </div>\n\n </main>\n</div>\n\n<!-- 手機底部導覽 -->\n<div id=\"tabbar\">\n \n <div class=\"tab\" data-nav=\"search\">搜尋</div>\n <div class=\"tab\" data-nav=\"workflows\">工作流</div>\n \n <div class=\"tab\" data-nav=\"settings\">設定</div>\n</div>\n\n<div id=\"toast\"></div>\n\n<script>\n(function () {\n var API_BASE = window.ARCRUN_API_BASE || '';\n 'use strict';\n // 台北時間 helper(lib/taipei-time.ts 注入,與駕駛艙 #36 台北日判定同一套)——\n // console 所有日期/時間顯示固定 Asia/Taipei,不隨看的裝置時區漂移\n var TAIPEI_OFFSET_MS = 28800000; // UTC+8,台北無 DST\nfunction tpePad(n) { n = String(n); return n.length < 2 ? '0' + n : n; }\nfunction taipeiDayKey(ms) { return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10); }\nfunction taipeiDateStr(ms) { return taipeiDayKey(ms); }\nfunction taipeiDateTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return taipeiDayKey(ms) + ' ' + tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()); }\nfunction taipeiTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()) + ':' + tpePad(d.getUTCSeconds()); }\nfunction taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { month: d.getUTCMonth() + 1, day: d.getUTCDate() }; }\n var REGISTRY_BASE = \"https://registry.arcrun.dev\";\n var $ = function (id) { return document.getElementById(id); };\n\n // ── 狀態 ──\n var S = {\n token: localStorage.getItem('arcrun_console_session') || '',\n tenant: '',\n chip: '', // entry_type filter('' = 全部)\n library: '', // library filter('' = 全館;藏書地圖點庫卡片設定,M5/R4)\n semantic: false,\n cardId: '',\n graphCenter: '',\n wfCache: null,\n pins: (function () { try { return JSON.parse(localStorage.getItem('arcrun_console_pins') || '{}'); } catch (e) { return {}; } })()\n };\n\n function esc(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, function (c) {\n return { '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[c];\n });\n }\n function toast(msg) {\n var el = $('toast');\n el.textContent = msg;\n el.style.display = 'block';\n clearTimeout(toast._t);\n toast._t = setTimeout(function () { el.style.display = 'none'; }, 2000);\n }\n function apiHeaders() { return { 'X-Arcrun-API-Key': S.tenant }; }\n\n // fetch 失敗(斷網)的裸 TypeError 訊息(Failed to fetch / Load failed)→ 友善誠實文案\n function friendlyErr(e) {\n var m = e && e.message ? String(e.message) : String(e);\n if (/failed to fetch|load failed|networkerror|network request failed/i.test(m)) return '連線中斷——請檢查網路後重試';\n return m;\n }\n\n // ── 主題(深/淺):預設淺色,選擇存 localStorage(head 預載腳本已先套用防閃色)──\n function getTheme() { return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; }\n function setTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('arcrun_console_theme', t); } catch (e) { /* 私密模式存不了就只影響本次 */ }\n syncThemeUI();\n }\n function syncThemeUI() {\n var dark = getTheme() === 'dark';\n document.querySelectorAll('.themelabel').forEach(function (el) { el.textContent = dark ? '☀ 切淺色' : '☾ 切深色'; });\n var sw = $('st-theme-switch');\n if (sw) sw.classList.toggle('on', dark);\n }\n document.addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-themetoggle]');\n if (t) setTheme(getTheme() === 'dark' ? 'light' : 'dark');\n });\n syncThemeUI();\n\n // created_at 容錯:epoch 秒 / 毫秒 / ISO / 'YYYY-MM-DD HH:MM:SS'\n function parseAtMs(v) {\n if (v == null || v === '') return null;\n if (typeof v === 'number') return v < 1e12 ? v * 1000 : v;\n if (/^\\d+$/.test(v)) { var n = Number(v); return n < 1e12 ? n * 1000 : n; }\n var s = /T/.test(v) ? v : String(v).replace(' ', 'T') + 'Z';\n var ms = Date.parse(s);\n return isNaN(ms) ? null : ms;\n }\n // 顯示一律台北時間(bug 修正:原本用瀏覽器本地 getFullYear/getHours——顯示結果\n // 隨看的裝置時區漂移,且與後端「台北日」判定不同套;憑證頁 7/6 22:57(台北)建立的\n // credential 在非 +8 裝置上會顯示成另一天)\n function fmtDate(v) {\n var ms = parseAtMs(v);\n return ms == null ? '' : taipeiDateStr(ms);\n }\n function fmtDateTime(v) {\n var ms = parseAtMs(v);\n return ms == null ? '' : taipeiDateTimeStr(ms);\n }\n\n // ── 極簡 Markdown 渲染(先全 escape 再上格式,防 XSS)──\n function mdInline(s) {\n return s\n .replace(/\\*\\*([^*]+)\\*\\*/g, '<b>$1</b>')\n .replace(/(^|[^\\\\])\\x60([^\\x60]+)\\x60/g, '$1<code>$2</code>');\n }\n function mdRender(src) {\n var lines = String(src || '').split(/\\r?\\n/);\n var out = [], inCode = false, listMode = '';\n function closeList() { if (listMode) { out.push(listMode === 'ul' ? '</ul>' : '</ol>'); listMode = ''; } }\n for (var i = 0; i < lines.length; i++) {\n var raw = lines[i];\n if (/^\\s*\\x60\\x60\\x60/.test(raw)) {\n closeList();\n if (inCode) { out.push('</code></pre>'); inCode = false; }\n else { out.push('<pre><code>'); inCode = true; }\n continue;\n }\n if (inCode) { out.push(esc(raw) + '\\n'); continue; }\n var line = esc(raw);\n var h = raw.match(/^(#{1,4})\\s+(.*)$/);\n if (h) { closeList(); out.push('<h' + h[1].length + '>' + mdInline(esc(h[2])) + '</h' + h[1].length + '>'); continue; }\n if (/^\\s*>\\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\\s*>\\s?/, '')) + '</blockquote>'); continue; }\n var li = raw.match(/^\\s*[-*+]\\s+(.*)$/);\n if (li) { if (listMode !== 'ul') { closeList(); out.push('<ul>'); listMode = 'ul'; } out.push('<li>' + mdInline(esc(li[1])) + '</li>'); continue; }\n var oli = raw.match(/^\\s*\\d+[.)]\\s+(.*)$/);\n if (oli) { if (listMode !== 'ol') { closeList(); out.push('<ol>'); listMode = 'ol'; } out.push('<li>' + mdInline(esc(oli[1])) + '</li>'); continue; }\n if (/^\\s*$/.test(raw)) { closeList(); continue; }\n closeList();\n out.push('<p>' + mdInline(line) + '</p>');\n }\n closeList();\n if (inCode) out.push('</code></pre>');\n return out.join('');\n }\n\n // ── 路由 ──\n // VIEWS / HOME 由 server 依 CONSOLE_PROFILE 注入(full=全套 7 頁落地駕駛艙;rag=4 頁落地搜尋)。\n // 不在 VIEWS 裡的 hash(含被 profile 裁掉的頁)一律導回 HOME——手打 #/cockpit 也進不去。\n var VIEWS = [\"search\",\"card\",\"workflows\",\"settings\"];\n var HOME = \"search\";\n function currentRoute() {\n var h = location.hash.replace(/^#\\/?/, '');\n var seg = h.split('/');\n return { raw: seg[0] || '', view: VIEWS.indexOf(seg[0]) >= 0 ? seg[0] : HOME, arg: seg.slice(1).join('/') };\n }\n function nav(view, arg) {\n location.hash = '#/' + view + (arg ? '/' + encodeURIComponent(arg) : '');\n }\n function showView(name) {\n VIEWS.forEach(function (v) { $('v-' + v).classList.toggle('on', v === name); });\n var activeKey = name === 'card' ? 'search' : name;\n document.querySelectorAll('#sidenav .nav, #tabbar .tab').forEach(function (el) {\n el.classList.toggle('on', el.getAttribute('data-nav') === activeKey);\n });\n window.scrollTo(0, 0);\n }\n var LOADERS = { cockpit: loadCockpit, search: initSearchOnce, card: loadCard, workflows: loadWorkflows, creds: loadCreds, inbox: loadTriage, settings: loadSettings };\n function route() {\n var r = currentRoute();\n // 隱藏頁/不存在頁 guard:hash 不在 VIEWS → 改寫成 HOME(hashchange 會再進來一次正常渲染)\n if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }\n if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);\n showView(r.view);\n if (LOADERS[r.view]) LOADERS[r.view]();\n }\n window.addEventListener('hashchange', route);\n document.addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-nav]');\n if (t) nav(t.getAttribute('data-nav'));\n });\n\n // ── 認證 ──\n function showAuth(which) {\n $('v-login').classList.toggle('on', which === 'login');\n $('v-setup').classList.toggle('on', which === 'setup');\n $('shell').classList.remove('on');\n $('tabbar').classList.remove('on');\n }\n function showApp() {\n $('v-login').classList.remove('on');\n $('v-setup').classList.remove('on');\n $('shell').classList.add('on');\n $('tabbar').classList.add('on');\n $('side-foot').innerHTML = '租戶 ' + esc(S.tenant) + '<br>' + esc(location.host);\n if (!location.hash) location.hash = '#/' + HOME;\n route();\n }\n function init() {\n if (S.token) {\n fetch(API_BASE + '/console/session', { headers: { Authorization: 'Bearer ' + S.token } })\n .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('session 失效')); })\n .then(function (d) { S.tenant = d.tenant || ''; showApp(); })\n .catch(function () {\n S.token = ''; localStorage.removeItem('arcrun_console_session');\n checkConfigured();\n });\n } else {\n checkConfigured();\n }\n }\n function checkConfigured() {\n fetch(API_BASE + '/console/auth-status')\n .then(function (r) { return r.json(); })\n .then(function (d) { showAuth(d.configured ? 'login' : 'setup'); })\n .catch(function () { showAuth('login'); });\n }\n function handleAuthResponse(d) {\n S.token = d.session_token;\n localStorage.setItem('arcrun_console_session', S.token);\n S.tenant = d.tenant || '';\n showApp();\n }\n $('login-submit').addEventListener('click', function () {\n var email = $('login-email').value.trim(), password = $('login-password').value;\n $('login-status').textContent = '';\n fetch(API_BASE + '/console/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: password }) })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })\n .then(function (x) { if (!x.ok) { $('login-status').textContent = x.d.error || '登入失敗'; return; } handleAuthResponse(x.d); })\n .catch(function (e) { $('login-status').textContent = '請求失敗:' + friendlyErr(e); });\n });\n $('login-password').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') $('login-submit').click(); });\n $('setup-submit').addEventListener('click', function () {\n var email = $('setup-email').value.trim(), pw = $('setup-password').value, pw2 = $('setup-password2').value;\n if (pw !== pw2) { $('setup-status').textContent = '兩次密碼不一致'; return; }\n $('setup-status').textContent = '';\n fetch(API_BASE + '/console/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: pw }) })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })\n .then(function (x) { if (!x.ok) { $('setup-status').textContent = x.d.error || '設定失敗'; return; } handleAuthResponse(x.d); })\n .catch(function (e) { $('setup-status').textContent = '請求失敗:' + friendlyErr(e); });\n });\n\n // ── 駕駛艙 ──\n var LIGHT = {\n green: { ch: '安', title: '系統運轉中', grad: 'radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)', anim: 'breatheGreen' },\n yellow: { ch: '趕', title: '落後趕工中', grad: 'radial-gradient(circle at 36% 30%,#f2d194,#e8b45a 55%,#8a5f1e 100%)', anim: 'breatheAmber' },\n red: { ch: '滯', title: '卡住或斷訊', grad: 'radial-gradient(circle at 36% 30%,#f0a094,#d95f4c 55%,#7e2c20 100%)', anim: 'breatheRed' }\n };\n var TASK_ICONS = { done: '✓', doing: '◐', todo: '○', blocked: '●' };\n var CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];\n function cnDay(n) { return n <= 10 ? CNUM[n] : (n < 20 ? '十' + (n % 10 ? CNUM[n % 10] : '') : CNUM[Math.floor(n / 10)] + '十' + (n % 10 ? CNUM[n % 10] : '')); }\n function taskLine(t) {\n var cls = TASK_ICONS[t.status] ? t.status : 'blocked';\n return '<li class=\"' + cls + '\"><span class=\"ic\">' + (TASK_ICONS[t.status] || '●') + '</span><span>' + esc(t.title) + '</span></li>';\n }\n function ckAge(m) {\n if (m == null || m < 0) return '時間不明';\n if (m < 60) return m + ' 分鐘前';\n if (m < 2880) return Math.round(m / 60) + ' 小時前';\n return Math.round(m / 1440) + ' 天前';\n }\n function loadCockpit() {\n var nowTpe = taipeiMonthDay(Date.now()); // 頁首日期=台北日,不吃裝置時區\n $('ck-date').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';\n fetch(API_BASE + '/console/dashboard-data')\n .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })\n .then(function (d) {\n var cfg = LIGHT[d.light] || LIGHT.green;\n var orb = $('ck-orb');\n orb.style.background = cfg.grad;\n orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';\n $('ck-char').textContent = cfg.ch;\n $('ck-title').textContent = cfg.title;\n $('ck-sub').textContent = (d.last_beat\n ? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')\n : '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '(' + d.light_reason + ')' : '');\n var done = d.today_done || 0, total = d.today_total || 0;\n $('ck-done').textContent = done; $('ck-total').textContent = total;\n $('ck-bar').style.width = (total ? Math.round(done / total * 100) : 0) + '%';\n $('ck-inbox').textContent = d.inbox_new || 0;\n // 等你的事:來源與維護時間攤開講(fix/console-dashboard-live-data)——\n // Gitea sprint 等leo清單是活資料源;fallback dash_wait 一定標 stale,不假裝是今天的\n var wb = $('ck-waitbox'), body = $('ck-wait'), wmeta = $('ck-waitmeta');\n var wm = d.waiting_meta || {};\n if (d.waiting && d.waiting.length) {\n wb.classList.add('has'); body.className = '';\n body.innerHTML = d.waiting.map(function (w) {\n return '<div class=\"wait-item\"><span class=\"dm\">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +\n (w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>';\n }).join('');\n } else {\n wb.classList.remove('has'); body.className = 'wait-none';\n body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';\n }\n if (wm.source === 'gitea_sprint') {\n wmeta.innerHTML = '來源:sprint 等leo清單・維護於 ' + ckAge(wm.updated_ago_minutes) +\n (wm.stale ? '<br><span class=\"err\">⚠ 清單超過 2 天沒維護,可能過時</span>' : '');\n } else if (wm.source === 'kbdb_dash_wait') {\n wmeta.innerHTML = '<span class=\"err\">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + ckAge(wm.updated_ago_minutes) + ',可能過時</span>';\n } else if (wm.source === 'none') {\n wmeta.innerHTML = '<span class=\"err\">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';\n } else {\n wmeta.textContent = '';\n }\n // 今日路線:sprint 任務板優先(來源與更新時間攤開講);dash_task fallback 沿舊誠實降級\n var rm = d.route_meta || {};\n var today = (d.tasks || []).filter(function (t) { return t.scope === 'today'; });\n var week = (d.tasks || []).filter(function (t) { return t.scope === 'week'; });\n if (rm.source === 'gitea_sprint_board') {\n $('ck-route-src').textContent = '來源 sprint 任務板・更新於 ' + ckAge(rm.updated_ago_minutes);\n var staleHead = rm.is_today ? '' :\n '<li class=\"todo\"><span class=\"ic\">○</span><span class=\"muted\">⚠ 今日任務板未更新(最後 ' + ckAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';\n $('ck-today').innerHTML = staleHead + (today.length\n ? today.map(taskLine).join('')\n : '<li class=\"todo\"><span class=\"ic\">○</span><span class=\"muted\">任務板上沒有可解析的事項</span></li>');\n } else if (rm.is_today || !today.length) {\n $('ck-route-src').textContent = '來源 dash_task';\n $('ck-today').innerHTML = today.length ? today.map(taskLine).join('') : '<li class=\"todo\"><span class=\"ic\">○</span><span class=\"muted\">今日無排定項目——dash_task 今天沒有寫入</span></li>';\n } else {\n $('ck-route-src').textContent = '來源 dash_task';\n $('ck-today').innerHTML = '<li class=\"todo\"><span class=\"ic\">○</span><span class=\"muted\">今日尚無路線寫入——以下是 ' + ckAge(rm.updated_ago_minutes) + '的殘留路線</span></li>' + today.map(taskLine).join('');\n }\n $('ck-weekhead').style.display = week.length ? '' : 'none';\n $('ck-week').innerHTML = week.map(taskLine).join('');\n $('ck-stamp').textContent = '每 60 秒自動刷新・上次 ' + taipeiTimeStr(Date.parse(d.generated_at)) + '(台北)';\n })\n .catch(function (e) {\n $('ck-char').textContent = '?';\n $('ck-title').textContent = '讀不到狀態';\n $('ck-sub').innerHTML = '<span class=\"err\">' + esc(friendlyErr(e)) + '・每 60 秒自動重試</span>';\n });\n }\n // 60 秒定時器無條件常駐:斷網期間照跑,網路恢復後下一輪自動刷新回正常畫面\n setInterval(function () { if (currentRoute().view === 'cockpit' && $('shell').classList.contains('on')) loadCockpit(); }, 60000);\n\n // ── 總庫搜尋 ──\n // chips = KBDB 真能篩的 entry_type(誠實:不編造「電腦筆記/手機筆記」等 KBDB 沒有的分類軸)\n var CHIPS = [\n { label: '全部', v: '' },\n { label: '筆記 note', v: 'note' },\n { label: '區塊 block', v: 'block' },\n { label: '數值 value', v: 'value' },\n { label: '工作流 workflow', v: 'workflow' }\n ];\n var searchInited = false;\n function initSearchOnce() {\n if (searchInited) { return; }\n searchInited = true;\n renderChips();\n loadScale();\n loadLibraryMap();\n }\n\n // ── 藏書地圖(library-map SDD M5/R4)──\n // 資料源:GET /kbdb/map(cypher 代轉 KBDB 基本盤,kbdb-proxy.ts——同 /kbdb/search 慣例)。\n // 兩段式渲染:先用全館列表(top_entities=名字 top3)立即出卡片,再逐庫拉 /kbdb/map/:library\n // 補 degree/bridges/commit_hash(庫數少;詳圖拉不到就維持名字版——加分項不擋渲染)。\n // 空陣列/錯誤=誠實空狀態,只佔地圖區塊,不擋下方搜尋。\n var LM = { libs: null, details: {} }; // 地圖快取:點卡片重繪「搜尋中」標記時不用重打 API\n function lmHonest(head, body) {\n $('lm-cards').innerHTML = '<div class=\"honest\" style=\"grid-column:1/-1\"><div class=\"h\">' + esc(head) + '</div><div class=\"b\">' + body + '</div></div>';\n $('lm-bridges').innerHTML = '';\n $('lm-meta').textContent = '';\n }\n function lmEntityLine(ents) {\n // ents:詳圖版 [{name, degree}] 或全館列表版 ['name']——兩形都渲染,degree 有才顯示\n return (ents || []).slice(0, 5).map(function (t) {\n var name = typeof t === 'string' ? t : (t && t.name) || '';\n var deg = t && typeof t === 'object' && typeof t.degree === 'number' ? t.degree : null;\n return '<span class=\"tag green\">' + esc(name) + (deg != null ? '<span style=\"opacity:.65\">・' + deg + '</span>' : '') + '</span>';\n }).join(' ');\n }\n function renderLmCards(libs, details) {\n $('lm-meta').textContent = libs.length + ' 個庫・點卡片進該庫搜尋';\n $('lm-cards').innerHTML = libs.map(function (l) {\n var d = details[l.library];\n var ents = d && d.top_entities && d.top_entities.length ? d.top_entities : l.top_entities;\n // 更新時間感:commit_hash(詳圖有才顯示,短碼)+ updated_at(epoch 秒,台北時間)\n var stamp = (d && d.commit_hash ? '<span class=\"mono\">' + esc(String(d.commit_hash).slice(0, 7)) + '</span>・' : '') +\n '更新 ' + esc(fmtDateTime(l.updated_at) || '—');\n return '<div class=\"kcard\" data-lib=\"' + esc(l.library) + '\">' +\n '<div class=\"kt\">' + esc(l.library) + (S.library === l.library ? ' <span class=\"tag\" style=\"font-size:11.5px;vertical-align:middle\">搜尋中</span>' : '') + '</div>' +\n '<div class=\"ks\">' + (l.narrative ? esc(l.narrative) : '<span class=\"dim\">(此庫尚無 narrative——recompute 時可帶入)</span>') + '</div>' +\n (ents && ents.length ? '<div style=\"display:flex;gap:6px;flex-wrap:wrap\">' + lmEntityLine(ents) + '</div>' : '') +\n '<div class=\"km\"><span class=\"mono\" style=\"color:var(--amber)\">' + (Number(l.triplet_count) || 0) + ' 三元組</span>' +\n '<span class=\"dim\" style=\"margin-left:auto;font-size:12.5px\">' + stamp + '</span></div></div>';\n }).join('');\n }\n function renderLmBridges(details) {\n // 跨庫橋:詳圖 bridges=[{entity, libraries[]}],多庫詳圖去重後列一小區;沒資料就整區不出\n var seen = {}, rows = [];\n Object.keys(details).forEach(function (k) {\n (details[k].bridges || []).forEach(function (b) {\n if (!b || !b.entity || seen[b.entity]) return;\n seen[b.entity] = true;\n rows.push(b);\n });\n });\n if (!rows.length) { $('lm-bridges').innerHTML = ''; return; }\n $('lm-bridges').innerHTML = '<div style=\"margin-top:14px;padding:14px 16px;border-radius:12px;background:rgba(var(--ok-rgb),.05);border:1px solid rgba(var(--ok-rgb),.2)\">' +\n '<div style=\"font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);margin-bottom:8px\">跨庫橋(同一 entity 出現在多個庫)</div>' +\n '<div style=\"display:flex;gap:8px;flex-wrap:wrap\">' + rows.slice(0, 12).map(function (b) {\n return '<span class=\"tag green\">' + esc(b.entity) + '</span><span class=\"dim\" style=\"font-size:12.5px;align-self:center\">' + esc((b.libraries || []).join(' ↔ ')) + '</span>';\n }).join('<span style=\"width:6px\"></span>') + '</div></div>';\n }\n function loadLibraryMap() {\n fetch(API_BASE + '/kbdb/map', { headers: apiHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n if (!x.ok || x.d.success === false) {\n lmHonest('讀不到藏書地圖', esc(x.d.error || ('HTTP ' + x.status)) + '<br>不影響下方搜尋,可直接搜全庫。');\n return;\n }\n var libs = x.d.libraries || [];\n if (!libs.length) {\n lmHonest('還沒有藏書地圖', '還沒有任何庫跑過重算——對 KBDB 呼 <code style=\"font-size:12.5px\">POST /map/recompute?library=庫名</code> backfill 後,這裡會出現全館導覽。<br>不影響下方搜尋,可直接搜全庫。');\n return;\n }\n LM.libs = libs; LM.details = {};\n renderLmCards(libs, {});\n Promise.allSettled(libs.map(function (l) {\n return fetch(API_BASE + '/kbdb/map/' + encodeURIComponent(l.library), { headers: apiHeaders() })\n .then(function (r) { return r.ok ? r.json() : null; });\n })).then(function (rs) {\n var details = {};\n rs.forEach(function (r) {\n if (r.status === 'fulfilled' && r.value && r.value.map && r.value.map.library) details[r.value.map.library] = r.value.map;\n });\n LM.details = details;\n renderLmCards(libs, details);\n renderLmBridges(details);\n });\n })\n .catch(function (e) { lmHonest('地圖服務不可達', esc(friendlyErr(e)) + '<br>不影響下方搜尋,可直接搜全庫。'); });\n }\n // 點庫卡片=設 library filter(/kbdb/search 既有 library 參數,多值逗號分隔——此處單庫)進庫搜尋\n $('lm-cards').addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-lib]');\n if (!t) return;\n S.library = t.getAttribute('data-lib');\n renderLibBar();\n if (LM.libs) renderLmCards(LM.libs, LM.details);\n $('se-q').focus();\n if ($('se-q').value.trim()) doSearch();\n toast('已鎖定庫「' + S.library + '」——輸入關鍵字搜這個庫');\n });\n function renderLibBar() {\n $('se-libbar').innerHTML = S.library\n ? '<div style=\"margin-top:10px;display:flex;align-items:center;gap:8px;font-size:14px\">' +\n '<span class=\"tag\">庫 ' + esc(S.library) + '</span>' +\n '<button class=\"chip\" id=\"se-libclear\" style=\"padding:5px 12px;font-size:13px\">✕ 清除庫篩選(搜全館)</button></div>'\n : '';\n var clear = document.getElementById('se-libclear');\n if (clear) clear.addEventListener('click', function () {\n S.library = '';\n renderLibBar();\n if (LM.libs) renderLmCards(LM.libs, LM.details);\n if ($('se-q').value.trim()) doSearch();\n });\n }\n function renderChips() {\n $('se-chips').innerHTML = CHIPS.map(function (c, i) {\n return '<button class=\"chip' + (S.chip === c.v ? ' on' : '') + '\" data-chip=\"' + i + '\">' + esc(c.label) + '</button>';\n }).join('');\n }\n $('se-chips').addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-chip]');\n if (!t) return;\n S.chip = CHIPS[Number(t.getAttribute('data-chip'))].v;\n renderChips();\n if ($('se-q').value.trim()) doSearch();\n });\n // 頭部統計=精耕層 live 數字(leo 2026-07-07 裁:45.8 萬 14-E 搬遷遺產已 deprecated、\n // 之後要刪——不再拿遺產總數撐場面,只顯示真的新的)。搜尋本身仍搜全庫,資料不藏。\n function loadScale() {\n fetch(API_BASE + '/console/kb-scale-data')\n .then(function (r) { return r.ok ? r.json() : null; })\n .then(function (d) {\n if (!d) return;\n var n = function (v) { return v == null ? '?' : v.toLocaleString(); };\n var parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)];\n var latest = d.wiki_card_latest_ago_minutes;\n $('se-scale').textContent = '精耕層 ' + parts.join('・') +\n (latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');\n })\n .catch(function () { /* 規模感拿不到不擋搜尋 */ });\n }\n $('se-sem').addEventListener('click', function () {\n S.semantic = !S.semantic;\n $('se-sem').classList.toggle('on', S.semantic);\n if ($('se-q').value.trim()) doSearch();\n });\n $('se-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doSearch(); });\n function entryTitle(e) {\n if (e.page_name) return e.page_name;\n var first = String(e.content || '').split(/\\r?\\n/).find(function (l) { return l.trim(); }) || '';\n first = first.replace(/^#+\\s*/, '').replace(/^[-*>]\\s*/, '').trim();\n if (first.length > 60) first = first.slice(0, 60) + '…';\n return first || '(無標題・' + (e.entry_type || 'entry') + ')';\n }\n function entrySnippet(e) {\n var lines = String(e.content || '').split(/\\r?\\n/).filter(function (l) { return l.trim(); });\n var body = lines.slice(1).join(' ').replace(/\\s+/g, ' ').trim();\n if (!body) body = lines.join(' ');\n if (body.length > 140) body = body.slice(0, 140) + '…';\n return body;\n }\n function doSearch() {\n var q = $('se-q').value.trim();\n if (!q) { $('se-count').textContent = '請輸入查詢字'; return; }\n $('se-banner').innerHTML = '';\n $('se-count').textContent = '查詢中…';\n $('se-results').innerHTML = '';\n var url = API_BASE + '/kbdb/search?q=' + encodeURIComponent(q) + '&mode=' + (S.semantic ? 'semantic' : 'keyword');\n if (S.chip) url += '&entry_type=' + encodeURIComponent(S.chip);\n // 藏書地圖進庫(M5/R4):library filter=/kbdb/search 既有參數(portal-auth P1 順延項),不新造\n if (S.library) url += '&library=' + encodeURIComponent(S.library);\n fetch(url, { headers: apiHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n if (!x.ok) { $('se-count').innerHTML = '<span class=\"err\">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + ')')) + '</span>'; return; }\n var d = x.d;\n if (S.semantic && d.mode === 'keyword') {\n $('se-banner').innerHTML = '<div class=\"honest\" style=\"margin-top:18px\"><div class=\"h\">語意搜尋尚未啟用</div><div class=\"b\">語意搜尋用「意思」找資料,不是字面比對。<br>' + esc(d.capability_hint || '部署端尚未開啟 Vectorize——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';\n }\n var entries = d.entries || [];\n $('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') +\n '・搜尋範圍=' + (S.library ? '庫「' + S.library + '」' : '全庫(含 14-E 遺產)');\n if (!entries.length) {\n $('se-results').innerHTML = '<div class=\"muted\" style=\"padding:30px 10px;text-align:center;grid-column:1/-1\">找不到「' + esc(q) + '」——換個關鍵字試試。</div>';\n return;\n }\n $('se-results').innerHTML = entries.map(function (e) {\n // 精耕層(wiki_card)綠標突顯;其餘照 entry_type 原樣(不誤標——block/value/note\n // 大宗是 14-E 遺產,但 entry_type 本身不足以斷言,僅精耕層正向標記)\n var typeTag = e.entry_type === 'wiki_card'\n ? '<span class=\"tag green\">wiki_card・精耕</span>'\n : '<span class=\"tag\">' + esc(e.entry_type || 'entry') + '</span>';\n return '<div class=\"kcard\" data-card=\"' + esc(e.id) + '\">' +\n '<div class=\"kt\">' + esc(entryTitle(e)) + '</div>' +\n '<div class=\"ks\">' + esc(entrySnippet(e)) + '</div>' +\n '<div class=\"km\">' + typeTag +\n (e.source ? '<span class=\"dim\">' + esc(e.source) + '</span>' : '') +\n '<span class=\"dim mono\" style=\"margin-left:auto\">' + esc(fmtDate(e.created_at)) + '</span></div></div>';\n }).join('');\n })\n .catch(function (e) { $('se-count').innerHTML = '<span class=\"err\">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });\n }\n $('se-results').addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-card]');\n if (t) nav('card', t.getAttribute('data-card'));\n });\n\n // ── 卡片詳頁 + 關聯 ──\n function loadCard() {\n if (!S.cardId) { $('cd-main').innerHTML = '<div class=\"muted\" style=\"padding:24px 0\">沒有指定卡片——從「總庫搜尋」點一張進來。</div>'; renderGraphEmpty('尚未載入卡片'); return; }\n $('cd-main').innerHTML = '<div class=\"muted\" style=\"padding:24px 0\">載入中…</div>';\n fetch(API_BASE + '/kbdb/entries/' + encodeURIComponent(S.cardId), { headers: apiHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })\n .then(function (x) {\n if (!x.ok || !x.d.entry) { $('cd-main').innerHTML = '<div class=\"err\" style=\"padding:24px 0\">' + esc(x.d.error || '讀不到這張卡片') + '</div>'; renderGraphEmpty('讀不到卡片'); return; }\n var e = x.d.entry;\n var title = entryTitle(e);\n var html = '<div class=\"cardtitle\">' + esc(title) + '</div>' +\n '<div style=\"display:flex;gap:10px;align-items:center;margin-top:12px;font-size:13.5px;flex-wrap:wrap\">' +\n '<span class=\"tag\">' + esc(e.entry_type || 'entry') + '</span>' +\n (e.page_name ? '<span class=\"tag green\">' + esc(e.page_name) + '</span>' : '') +\n '<span class=\"muted mono\">' + esc(fmtDateTime(e.created_at)) + '</span></div>' +\n '<div class=\"md\">' + mdRender(e.content || '(此條目沒有內文)') + '</div>' +\n (e.source\n ? '<div class=\"srcline\"><span style=\"font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);flex:none\">來源回溯</span><span class=\"mono\" style=\"font-size:14px;color:var(--amber);word-break:break-all\">' + esc(e.source) + '</span></div>'\n : '');\n $('cd-main').innerHTML = html;\n var nodeName = e.page_name || title;\n S.graphCenter = nodeName;\n loadNeighbors(nodeName);\n })\n .catch(function (er) { $('cd-main').innerHTML = '<div class=\"err\" style=\"padding:24px 0\">請求失敗:' + esc(friendlyErr(er)) + '</div>'; renderGraphEmpty('請求失敗'); });\n }\n function renderGraphEmpty(msg) {\n $('cd-graph').innerHTML = '<div class=\"graphbox\" style=\"display:grid;place-items:center;height:160px\"><span class=\"muted\" style=\"font-size:14px;padding:0 20px;text-align:center\">' + esc(msg) + '</span></div>';\n $('cd-neighbors').innerHTML = '';\n }\n function loadNeighbors(name) {\n renderGraphEmpty('查詢關聯中…');\n fetch(API_BASE + '/kbdb/graph/neighbors/' + encodeURIComponent(name), { headers: apiHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n if (!x.ok) { renderGraphEmpty(x.d.error || ('關聯查詢失敗(HTTP ' + x.status + ')')); return; }\n var neighbors = x.d.neighbors || [];\n var edges = x.d.edges || [];\n if (!neighbors.length) { renderGraphEmpty('尚無關聯資料——這張卡還沒有 triplet 連進知識圖譜。'); return; }\n renderGraph(name, neighbors.slice(0, 10));\n $('cd-neighbors').innerHTML = edges.slice(0, 12).map(function (t) {\n var other = t.subject === name ? t.object : t.subject;\n return '<div class=\"nbrow\" data-gnode=\"' + esc(other) + '\">' +\n '<span class=\"tag green\" style=\"flex:none\">' + esc(t.predicate || '關聯') + '</span>' +\n '<span style=\"word-break:break-word\">' + esc(other) + '</span>' +\n '<span style=\"margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none\">›</span></div>';\n }).join('');\n })\n .catch(function (e) { renderGraphEmpty('關聯服務不可達:' + friendlyErr(e)); });\n }\n function renderGraph(center, nodes) {\n var n = nodes.length || 1;\n var lines = '', chips = '';\n for (var i = 0; i < n; i++) {\n var a = (-90 + i * (360 / n)) * Math.PI / 180;\n var x = 50 + 39 * Math.cos(a), y = 50 + 37 * Math.sin(a);\n lines += '<line x1=\"50\" y1=\"50\" x2=\"' + x.toFixed(2) + '\" y2=\"' + y.toFixed(2) + '\" stroke=\"rgba(var(--ok-rgb),.3)\" stroke-width=\"1\" vector-effect=\"non-scaling-stroke\" stroke-dasharray=\"4 4\"/>';\n chips += '<div class=\"gnode\" style=\"left:' + x.toFixed(2) + '%;top:' + y.toFixed(2) + '%\" data-gnode=\"' + esc(nodes[i]) + '\">' + esc(nodes[i]) + '</div>';\n }\n var shortCenter = center.length > 16 ? center.slice(0, 16) + '…' : center;\n $('cd-graph').innerHTML = '<div class=\"graphbox\">' +\n '<svg viewBox=\"0 0 100 100\" preserveAspectRatio=\"none\" style=\"position:absolute;inset:0;width:100%;height:100%\">' + lines + '</svg>' +\n '<div class=\"gcenter\"><span>' + esc(shortCenter) + '</span></div>' + chips +\n '<div style=\"position:absolute;right:12px;bottom:10px;font-size:11.5px;color:rgba(var(--ink-rgb),.35)\">╌ 知識圖譜鄰居(點節點展開)</div></div>';\n }\n document.addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-gnode]');\n if (!t) return;\n var name = t.getAttribute('data-gnode');\n S.graphCenter = name;\n loadNeighbors(name);\n });\n\n // ── 工作流 ──\n function loadWorkflows() {\n fetch(API_BASE + '/webhooks/named', { headers: apiHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })\n .then(function (x) {\n if (!x.ok) { $('wf-list').innerHTML = '<div class=\"err\">' + esc(x.d.error || '讀取失敗') + '</div>'; return; }\n S.wfCache = x.d.workflows || [];\n $('wf-count').textContent = S.wfCache.length + ' 條自動化';\n renderWorkflows();\n })\n .catch(function (e) { $('wf-list').innerHTML = '<div class=\"err\">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });\n }\n $('wf-q').addEventListener('input', function () { renderWorkflows(); });\n function renderWorkflows(openName) {\n var list = S.wfCache || [];\n var q = $('wf-q').value.trim().toLowerCase();\n if (q) list = list.filter(function (w) { return (w.name + ' ' + (w.description || '')).toLowerCase().indexOf(q) >= 0; });\n list = list.slice().sort(function (a, b) { return (S.pins[b.name] ? 1 : 0) - (S.pins[a.name] ? 1 : 0); });\n if (!list.length) {\n $('wf-list').innerHTML = '<div class=\"honest\"><div class=\"h\">' + (q ? '沒有符合的工作流' : '還沒有任何工作流') + '</div><div class=\"b\">' + (q ? '換個關鍵字試試。' : '用 acr push 部署第一條 workflow 後,這裡就會出現。') + '</div></div>';\n return;\n }\n $('wf-list').innerHTML = list.map(function (w) {\n var pinned = !!S.pins[w.name];\n var open = w.name === openName;\n return '<div class=\"card-item' + (pinned ? ' hl' : '') + '\" data-wf=\"' + esc(w.name) + '\">' +\n '<div class=\"rowhead\" data-wftoggle=\"' + esc(w.name) + '\">' +\n '<button class=\"pinbtn' + (pinned ? ' on' : '') + '\" data-wfpin=\"' + esc(w.name) + '\" title=\"釘選\">' + (pinned ? '★' : '☆') + '</button>' +\n '<div style=\"min-width:0\"><div style=\"font-size:17px;font-weight:600;line-height:1.4;word-break:break-word\">' + esc(w.name) + '</div>' +\n '<div style=\"margin-top:3px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)\">' + esc(w.cron_expr ? '排程 ' + w.cron_expr : '事件觸發') + (w.created_at ? '・建立 ' + esc(fmtDate(w.created_at)) : '') + '</div></div>' +\n '<span style=\"margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none\">' + (open ? '▾' : '›') + '</span></div>' +\n (open ? renderWfBody(w) : '') + '</div>';\n }).join('');\n if (openName) fetchWfRuns(openName);\n }\n function renderWfBody(w) {\n return '<div class=\"rowbody\">' +\n '<div style=\"margin-top:14px;font-size:15.5px;line-height:1.7;color:rgba(var(--ink-rgb),.7)\">' + (w.description ? esc(w.description) : '<span class=\"dim\">(未填描述——re-deploy 時補一句「能做什麼」它才搜得到)</span>') + '</div>' +\n '<div class=\"urlbox\" style=\"margin-top:14px\"><span style=\"font-size:12.5px;letter-spacing:.1em;color:rgba(var(--ink-rgb),.45);flex:none\">觸發網址</span>' +\n '<span class=\"u\">' + esc(w.webhook_url || '') + '</span>' +\n '<button class=\"btn2\" style=\"margin-left:auto;flex:none;padding:7px 13px;font-size:13.5px\" data-wfcopy=\"' + esc(w.webhook_url || '') + '\">複製</button></div>' +\n '<div style=\"margin-top:14px;font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5)\">最近執行</div>' +\n '<div id=\"wf-runs\" style=\"margin-top:8px;display:flex;flex-direction:column;gap:6px\"><span class=\"dim\" style=\"font-size:14px\">載入中…</span></div>' +\n '<button class=\"btn\" style=\"margin-top:16px;width:100%\" data-wfrun=\"' + esc(w.name) + '\">手動觸發</button>' +\n '<div id=\"wf-runmsg\" style=\"margin-top:8px;font-size:14px;min-height:1.2em\"></div></div>';\n }\n var wfOpen = '';\n $('wf-list').addEventListener('click', function (ev) {\n var pin = ev.target.closest('[data-wfpin]');\n if (pin) {\n var name = pin.getAttribute('data-wfpin');\n if (S.pins[name]) delete S.pins[name]; else S.pins[name] = true;\n localStorage.setItem('arcrun_console_pins', JSON.stringify(S.pins));\n renderWorkflows(wfOpen);\n ev.stopPropagation();\n return;\n }\n var copy = ev.target.closest('[data-wfcopy]');\n if (copy) {\n var url = copy.getAttribute('data-wfcopy');\n (navigator.clipboard ? navigator.clipboard.writeText(url) : Promise.reject()).then(\n function () { toast('已複製觸發網址'); },\n function () { toast('複製失敗——手動選取網址'); }\n );\n ev.stopPropagation();\n return;\n }\n var run = ev.target.closest('[data-wfrun]');\n if (run) { triggerWf(run.getAttribute('data-wfrun'), run); ev.stopPropagation(); return; }\n var tog = ev.target.closest('[data-wftoggle]');\n if (tog) {\n var n = tog.getAttribute('data-wftoggle');\n wfOpen = wfOpen === n ? '' : n;\n renderWorkflows(wfOpen);\n }\n });\n function fetchWfRuns(name) {\n var box = document.getElementById('wf-runs');\n if (!box) return;\n fetch(API_BASE + '/workflows/' + encodeURIComponent(name) + '/executions?limit=3', { headers: apiHeaders() })\n .then(function (r) { return r.json(); })\n .then(function (d) {\n var runs = (d.data && d.data.executions) || [];\n if (!runs.length) { box.innerHTML = '<span class=\"dim\" style=\"font-size:14px\">尚無執行紀錄</span>'; return; }\n box.innerHTML = runs.map(function (r) {\n var okRun = r.verdict === 'success';\n return '<div style=\"display:flex;gap:10px;align-items:baseline;font-size:14.5px\">' +\n '<span style=\"flex:none;font-size:10px;color:' + (okRun ? 'var(--ok)' : 'var(--err)') + '\">●</span>' +\n '<span style=\"color:rgba(var(--ink-rgb),.7)\">' + (okRun ? '成功' : '失敗') + (typeof r.duration_ms === 'number' ? '・' + r.duration_ms + 'ms' : '') + (r.message && !okRun ? '・' + esc(String(r.message).slice(0, 60)) : '') + '</span>' +\n '<span class=\"dim mono\" style=\"margin-left:auto;font-size:12.5px\">' + esc(r.recorded_at ? fmtDateTime(r.recorded_at) : (r.timestamp ? fmtDateTime(Number(r.timestamp)) : '')) + '</span></div>';\n }).join('');\n })\n .catch(function () { box.innerHTML = '<span class=\"dim\" style=\"font-size:14px\">執行紀錄讀取失敗</span>'; });\n }\n function triggerWf(name, btn) {\n btn.disabled = true; btn.textContent = '執行中…';\n var msg = document.getElementById('wf-runmsg');\n fetch(API_BASE + '/webhooks/named/' + encodeURIComponent(name) + '/trigger', {\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),\n body: '{}'\n })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })\n .then(function (x) {\n btn.disabled = false; btn.textContent = '手動觸發';\n if (x.ok && x.d.success !== false) {\n if (msg) msg.innerHTML = '<span class=\"ok\">✓ 執行成功' + (typeof x.d.duration_ms === 'number' ? '・' + x.d.duration_ms + 'ms' : '') + '</span>';\n toast('「' + name + '」執行完成');\n } else {\n if (msg) msg.innerHTML = '<span class=\"err\">✕ ' + esc(x.d.error || '執行失敗') + '</span>';\n }\n fetchWfRuns(name);\n })\n .catch(function (e) {\n btn.disabled = false; btn.textContent = '手動觸發';\n if (msg) msg.innerHTML = '<span class=\"err\">請求失敗:' + esc(friendlyErr(e)) + '</span>';\n });\n }\n\n // ── 零件 / recipes(v0 搜尋台保留區)──\n $('cr-go').addEventListener('click', doCompSearch);\n $('cr-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doCompSearch(); });\n function doCompSearch() {\n var q = $('cr-q').value.trim();\n if (!q) return;\n var out = $('cr-out');\n out.innerHTML = '<div class=\"muted\">查詢中…</div>';\n Promise.allSettled([\n fetch(REGISTRY_BASE + '/components/search?q=' + encodeURIComponent(q)).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }),\n fetch(API_BASE + '/public-recipes?q=' + encodeURIComponent(q)).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })\n ]).then(function (rs) {\n var html = '';\n var comp = rs[0];\n html += '<div class=\"dim\" style=\"font-size:12.5px;letter-spacing:.1em;margin:10px 0 6px\">COMPONENTS</div>';\n if (comp.status === 'fulfilled' && comp.value.ok) {\n var results = (comp.value.d.data && comp.value.d.data.results) || [];\n html += results.length\n ? results.map(function (c) { return '<div class=\"card-item\" style=\"padding:10px 14px;margin-bottom:6px;font-size:14px\"><span class=\"tag\">' + esc(c.canonical_id || c.id) + '</span> <span class=\"muted\">' + esc(c.description || c.name || '') + '</span></div>'; }).join('')\n : '<div class=\"muted\" style=\"font-size:14px\">沒查到符合的 component</div>';\n } else {\n html += '<div class=\"err\" style=\"font-size:14px\">components 查詢失敗(零件庫不可達)</div>';\n }\n var rec = rs[1];\n html += '<div class=\"dim\" style=\"font-size:12.5px;letter-spacing:.1em;margin:12px 0 6px\">RECIPES</div>';\n if (rec.status === 'fulfilled' && rec.value.ok) {\n var d = rec.value.d;\n if (d.found === false) {\n html += '<div class=\"muted\" style=\"font-size:14px\">' + esc(d.hint || '沒查到符合的 recipe') + '</div>';\n } else {\n var recipes = d.recipes || [];\n html += recipes.length\n ? recipes.map(function (r) { return '<div class=\"card-item\" style=\"padding:10px 14px;margin-bottom:6px;font-size:14px\"><span class=\"tag\">' + esc(r.canonical_id) + '</span> <span class=\"muted\">' + esc(r.description || r.display_name || '') + '</span></div>'; }).join('')\n : '<div class=\"muted\" style=\"font-size:14px\">沒查到符合的 recipe</div>';\n }\n } else {\n html += '<div class=\"err\" style=\"font-size:14px\">recipes 查詢失敗</div>';\n }\n out.innerHTML = html;\n });\n }\n\n // ── 憑證管理 ──\n $('cred-addbtn').addEventListener('click', function () {\n var f = $('cred-form');\n f.style.display = f.style.display === 'none' ? '' : 'none';\n });\n $('cred-save').addEventListener('click', function () {\n var name = $('cred-name').value.trim(), service = $('cred-service').value.trim(), value = $('cred-secret').value;\n var st = $('cred-form-status');\n if (!name || !value) { st.innerHTML = '<span class=\"err\">名稱與密文值必填</span>'; return; }\n if (!/^\\w+$/.test(name)) { st.innerHTML = '<span class=\"err\">名稱只能包含英文字母、數字和底線</span>'; return; }\n st.textContent = '保存中…';\n fetch(API_BASE + '/credentials', {\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),\n body: JSON.stringify({ name: name, value: value, service: service || undefined })\n })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })\n .then(function (x) {\n if (!x.ok || x.d.success === false) { st.innerHTML = '<span class=\"err\">' + esc(x.d.error || '保存失敗') + '</span>'; return; }\n st.textContent = '';\n $('cred-name').value = ''; $('cred-service').value = ''; $('cred-secret').value = '';\n $('cred-form').style.display = 'none';\n toast('已加密保存');\n loadCreds();\n })\n .catch(function (e) { st.innerHTML = '<span class=\"err\">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });\n });\n var credOpen = '';\n var credCache = [];\n function loadCreds() {\n fetch(API_BASE + '/credentials/catalog', { headers: apiHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })\n .then(function (x) {\n if (!x.ok || x.d.success === false) {\n $('cred-list').innerHTML = '<div class=\"honest\"><div class=\"h\">目錄讀取失敗</div><div class=\"b\">' + esc(x.d.error || '未知錯誤') + '</div></div>';\n return;\n }\n credCache = x.d.credentials || [];\n renderCreds();\n })\n .catch(function (e) { $('cred-list').innerHTML = '<div class=\"err\">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });\n }\n function renderCreds() {\n if (!credCache.length) {\n $('cred-list').innerHTML = '<div class=\"honest\"><div class=\"h\">還沒有任何憑證</div><div class=\"b\">按「+ 新增憑證」加入第一筆。目錄只存名稱與服務,密文進 Workers Secrets、不可回看。</div></div>';\n return;\n }\n $('cred-list').innerHTML = credCache.map(function (c) {\n var open = credOpen === c.name;\n var initial = (c.service || c.name || '?').slice(0, 1).toUpperCase();\n return '<div class=\"card-item' + (open ? ' hl' : '') + '\">' +\n '<div class=\"rowhead\" data-credtoggle=\"' + esc(c.name) + '\">' +\n '<span class=\"avatar\">' + esc(initial) + '</span>' +\n '<div style=\"min-width:0\"><div style=\"font-size:16.5px;font-weight:600;word-break:break-all\">' + esc(c.name) + '</div>' +\n '<div style=\"margin-top:2px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)\">' + esc(c.service || '未填服務') + '・建立於 ' + esc(fmtDateTime(c.created_at) || '—') + '(台北)</div></div>' +\n '<div style=\"margin-left:auto;text-align:right;font-size:12.5px;color:rgba(var(--ink-rgb),.4);flex:none\">最後使用<br>' + esc(c.last_used_at ? fmtDateTime(c.last_used_at) : '尚未記錄') + '</div></div>' +\n (open\n ? '<div class=\"rowbody\">' +\n '<div class=\"maskline\" style=\"margin-top:14px\"><span class=\"mono\" style=\"font-size:16px;letter-spacing:.3em;color:rgba(var(--ink-rgb),.4)\">••••••••••••••••</span><span style=\"margin-left:auto;font-size:12.5px;color:rgba(var(--ink-rgb),.4)\">已加密・不可回看</span></div>' +\n '<textarea class=\"txt\" id=\"cred-newval\" placeholder=\"貼上新值以整筆替換…\" rows=\"2\" style=\"margin-top:10px\"></textarea>' +\n '<div style=\"display:flex;gap:10px;margin-top:10px;flex-wrap:wrap\">' +\n '<button class=\"btn\" style=\"flex:1;min-width:140px\" data-credreplace=\"' + esc(c.name) + '\">整筆替換</button>' +\n '<button class=\"btn-danger\" disabled title=\"刪除端點尚未接上新目錄(T9 範圍)\">刪除・即將開通</button></div>' +\n '<div id=\"cred-msg\" style=\"margin-top:8px;font-size:14px;min-height:1.2em\"></div></div>'\n : '') +\n '</div>';\n }).join('');\n }\n $('cred-list').addEventListener('click', function (ev) {\n var rep = ev.target.closest('[data-credreplace]');\n if (rep) {\n var name = rep.getAttribute('data-credreplace');\n var ta = document.getElementById('cred-newval');\n var msg = document.getElementById('cred-msg');\n var val = ta ? ta.value : '';\n if (!val.trim()) { if (msg) msg.innerHTML = '<span class=\"err\">先貼上新值</span>'; return; }\n rep.disabled = true;\n var cur = credCache.find(function (c) { return c.name === name; });\n fetch(API_BASE + '/credentials/' + encodeURIComponent(name), {\n method: 'PUT',\n headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),\n body: JSON.stringify({ value: val, service: cur && cur.service ? cur.service : undefined })\n })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })\n .then(function (x) {\n rep.disabled = false;\n if (!x.ok || x.d.success === false) { if (msg) msg.innerHTML = '<span class=\"err\">' + esc(x.d.error || '替換失敗') + '</span>'; return; }\n credOpen = '';\n toast('「' + name + '」已整筆替換');\n loadCreds();\n })\n .catch(function (e) { rep.disabled = false; if (msg) msg.innerHTML = '<span class=\"err\">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });\n return;\n }\n var tog = ev.target.closest('[data-credtoggle]');\n if (tog) {\n var n = tog.getAttribute('data-credtoggle');\n credOpen = credOpen === n ? '' : n;\n renderCreds();\n }\n });\n\n // ── 分流台(原收件匣,Arcrun#9:todo+inbox 二源、三欄 80/15/5、per-project chips)──\n // 頁面只渲染 /console/triage-data 的結果(分欄/計數判定在後端純函式);chips 切換與\n // 「顯示已完成」是同一包資料的前端過濾,零額外請求(leo 手機看,省來回)。\n var TG = { data: null, project: '', showDone: false };\n var TG_TIERS = [\n { key: 'ai', nm: 'AI 會幫我搞定', pc: '80%', empty: '這欄空的——還沒有分給 AI 的待辦' },\n { key: 'collab', nm: '協作・AI 做完你接手', pc: '15%', empty: '這欄空的——沒有要你配合的事' },\n { key: 'leo', nm: '非我不可', pc: '5%', empty: '這欄空的——沒有非你不可的事' }\n ];\n function triItem(m) {\n var done = m.status === 'done';\n var meta = '';\n if (m.marker) meta += '<span class=\"tag dim\">' + esc(m.marker) + '</span>';\n meta += '<span class=\"tag\">' + esc(m.project || '未分項') + '</span>';\n if (m.source) meta += '<span>' + esc(m.source) + '</span>';\n if (m.unclassified && m.origin === 'todo') meta += '<span class=\"tag red\">未分流</span>';\n // 誤勾救濟:done 清單裡給「還原」(status 回 new + 移除 checked_via/checked_at,端點同一個)\n if (done) meta += '<button class=\"tri-restore\" data-trirestore=\"' + esc(m.id) + '\">還原</button>';\n meta += '<span class=\"mono\" style=\"margin-left:auto\">' + esc(fmtDateTime(m.at)) + '</span>';\n // 勾掉=雙向銷案的 console 端終局(checked_via:console,萃取端絕不復活);\n // 後端回寫成功才改本地狀態移出三欄(done 預設隱藏),不樂觀假裝\n var body = done\n ? '<div class=\"tx\">' + esc(m.text) + '</div>'\n : '<div class=\"row\"><div class=\"tx\">' + esc(m.text) + '</div><button class=\"tri-check\" data-tricheck=\"' + esc(m.id) + '\" title=\"勾掉(銷案)\">✓</button></div>';\n return '<div class=\"tri-item ' + (done ? 'done' : 'new') + '\">' + body + '<div class=\"mt\">' + meta + '</div></div>';\n }\n function triChip(label, val, on) {\n return '<button class=\"chip' + (on ? ' on' : '') + '\" data-triproj=\"' + esc(val) + '\">' + esc(label) + '</button>';\n }\n function renderTriage() {\n var d = TG.data || {};\n var items = d.items || [];\n var out = $('tg-out');\n // per-project chips:全部+各 project+(有未分項才出)未分項\n var chips = triChip('全部', '', TG.project === '');\n (d.projects || []).forEach(function (p) { chips += triChip(p, p, TG.project === p); });\n if (items.some(function (i) { return !i.project; })) chips += triChip('未分項', '__none__', TG.project === '__none__');\n $('tg-chips').innerHTML = items.length ? chips : '';\n if (!items.length) {\n out.innerHTML = '<div class=\"honest\" style=\"margin-top:14px\"><div class=\"h\">分流台是空的</div><div class=\"b\">資料源=KBDB todo entries(Logseq 萃取,ingest 灌入中)+ inbox entries(Telegram)。<br>寫入方尚未灌資料時這裡誠實顯示空。</div></div>';\n return;\n }\n var vis = items.filter(function (i) {\n return TG.project === '' || (TG.project === '__none__' ? !i.project : i.project === TG.project);\n });\n var doneN = vis.filter(function (i) { return i.status === 'done'; }).length;\n var html = '';\n TG_TIERS.forEach(function (t) {\n var news = vis.filter(function (i) { return i.tier === t.key && i.status !== 'done'; });\n var dones = TG.showDone ? vis.filter(function (i) { return i.tier === t.key && i.status === 'done'; }) : [];\n html += '<div class=\"tri-sec ' + t.key + '\"><div class=\"tri-sechead\"><span class=\"dot\"></span><span class=\"nm serif\">' + t.nm + '</span><span class=\"pc\">' + t.pc + '</span><span class=\"ct\">' + news.length + ' 件</span></div>';\n if (!news.length && !dones.length) html += '<div class=\"muted\" style=\"font-size:14px;padding:10px 2px 0\">' + t.empty + '</div>';\n html += news.map(triItem).join('') + dones.map(triItem).join('');\n html += '</div>';\n });\n html += '<div style=\"margin:24px 0 20px;text-align:center\"><button class=\"btn3\" id=\"tg-donetoggle\" style=\"padding:9px 18px;font-size:13.5px;border-radius:999px\">' +\n (TG.showDone ? '隱藏已完成' : '顯示已完成(' + doneN + ')') + '</button></div>';\n out.innerHTML = html;\n $('tg-donetoggle').addEventListener('click', function () { TG.showDone = !TG.showDone; renderTriage(); });\n }\n $('tg-chips').addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-triproj]');\n if (!t) return;\n TG.project = t.getAttribute('data-triproj');\n renderTriage();\n });\n // 勾掉/還原:POST /console/triage-check(console session 保護的小端點——瀏覽器沒有 KBDB\n // token,PATCH 由 server 端做,先 GET 原 entry 整串回寫防蓋掉別的欄位)。成功才動本地\n // 狀態重繪(該筆立即移出/回到三欄),失敗誠實 toast、按鈕解鎖可重試。\n function triageAct(id, action, btn) {\n btn.disabled = true;\n fetch(API_BASE + '/console/triage-check', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + S.token },\n body: JSON.stringify({ entry_id: id, action: action })\n })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n if (!x.ok) { btn.disabled = false; toast(x.d.error || ('失敗(HTTP ' + x.status + ')')); return; }\n var items = (TG.data && TG.data.items) || [];\n for (var i = 0; i < items.length; i++) {\n if (items[i].id === id) items[i].status = (action === 'restore' ? 'new' : 'done');\n }\n renderTriage();\n toast(action === 'restore' ? '已還原' : '已勾掉');\n })\n .catch(function (e) { btn.disabled = false; toast('請求失敗:' + friendlyErr(e)); });\n }\n $('tg-out').addEventListener('click', function (ev) {\n var chk = ev.target.closest('[data-tricheck]');\n if (chk) { triageAct(chk.getAttribute('data-tricheck'), 'check', chk); return; }\n var rst = ev.target.closest('[data-trirestore]');\n if (rst) triageAct(rst.getAttribute('data-trirestore'), 'restore', rst);\n });\n function loadTriage() {\n var out = $('tg-out');\n out.innerHTML = '<div class=\"muted\" style=\"padding:14px 0\">載入中…</div>';\n fetch(API_BASE + '/console/triage-data', { headers: { Authorization: 'Bearer ' + S.token } })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n if (!x.ok) {\n out.innerHTML = '<div class=\"honest\" style=\"margin-top:14px\"><div class=\"h\">讀不到分流台</div><div class=\"b\">' + esc(x.d.error || ('HTTP ' + x.status)) + '</div></div>';\n return;\n }\n TG.data = x.d;\n // 記著的 project 篩選若已不存在(資料變了)→ 退回全部,別讓頁面空得不明不白\n if (TG.project && TG.project !== '__none__' && (x.d.projects || []).indexOf(TG.project) < 0) TG.project = '';\n renderTriage();\n })\n .catch(function (e) { out.innerHTML = '<div class=\"err\" style=\"padding:14px 0\">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });\n }\n\n // ── 設定 ──\n function loadSettings() {\n // vectorize 狀態:探測既有 /kbdb/search 的 mode / capability_hint(無新端點,誠實讀降級訊號)\n fetch(API_BASE + '/kbdb/search?q=mira&mode=semantic', { headers: apiHeaders() })\n .then(function (r) { return r.json(); })\n .then(function (d) {\n var on = d.mode === 'semantic';\n $('st-vec-switch').classList.toggle('on', on);\n $('st-vec').textContent = on\n ? '已啟用・語意搜尋可用(搜尋頁切「語意」)'\n : '未啟用・' + (d.capability_hint || '部署端尚未開啟 Vectorize(kbdb_embed)');\n $('st-vec-state').textContent = on ? '已啟用(live 探測 mode=semantic)' : '未啟用(live 探測降級 keyword)';\n })\n .catch(function () {\n $('st-vec').textContent = '狀態偵測失敗(KBDB 不可達)';\n $('st-vec-state').textContent = '偵測失敗';\n });\n // MCP token TTL(誠實佔位:只顯示目前生效值,不假裝能改)\n fetch(API_BASE + '/console/settings-data')\n .then(function (r) { return r.ok ? r.json() : null; })\n .then(function (d) {\n if (!d) { $('st-mcp-ttl').textContent = '讀取失敗'; return; }\n var days = Math.round(d.mcp_token_ttl_seconds / 86400 * 10) / 10;\n $('st-mcp-ttl').textContent = '目前生效:' + d.mcp_token_ttl_seconds.toLocaleString() + ' 秒(約 ' + days + ' 天)・' +\n (d.mcp_token_ttl_source === 'env' ? '來自部署端 env MCP_TOKEN_TTL' : '未設 env,用預設值 30 天');\n })\n .catch(function () { $('st-mcp-ttl').textContent = '讀取失敗'; });\n // 系統資訊:worker 自報 + 精耕層規模(14-E 遺產總數不再顯示,leo 2026-07-07 裁)\n Promise.allSettled([\n fetch(API_BASE + '/').then(function (r) { return r.json(); }),\n fetch(API_BASE + '/console/kb-scale-data').then(function (r) { return r.ok ? r.json() : null; })\n ]).then(function (rs) {\n var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};\n var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;\n var n = function (v) { return v == null ? '?' : v.toLocaleString(); };\n var rows = '';\n rows += '<div class=\"kvline\"><span class=\"muted\">服務</span><span class=\"mono\" style=\"font-size:14px\">' + esc(svc.service || 'arcrun-cypher-executor') + '</span></div>';\n rows += '<div class=\"kvline\"><span class=\"muted\">版本</span><span class=\"mono\" style=\"color:var(--amber)\">' + esc(svc.version || '—') + '</span></div>';\n rows += '<div class=\"kvline\"><span class=\"muted\">部署位置</span><span class=\"mono\" style=\"font-size:14px\">' + esc(location.host) + '</span></div>';\n rows += '<div class=\"kvline\"><span class=\"muted\">租戶</span><span class=\"mono\">' + esc(S.tenant) + '</span></div>';\n rows += '<div class=\"kvline\"><span class=\"muted\">精耕層</span><span class=\"mono\" style=\"color:var(--amber)\">' + (kb ? 'wiki 卡 ' + n(kb.wiki_card_total) + '・三元組 ' + n(kb.triplets_total) + '・已嵌入 ' + n(kb.embedded) : '讀取失敗') + '</span></div>';\n $('st-info').innerHTML = rows;\n });\n }\n $('st-pw-save').addEventListener('click', function () {\n var oldPw = $('st-pw-old').value, email = $('st-pw-email').value.trim(), newPw = $('st-pw-new').value;\n var st = $('st-pw-status');\n if (!oldPw) { st.innerHTML = '<span class=\"err\">請先輸入舊密碼</span>'; return; }\n if (!email) { st.innerHTML = '<span class=\"err\">請輸入 Email(沿用原本的也要填)</span>'; return; }\n if (!newPw || newPw.length < 8) { st.innerHTML = '<span class=\"err\">新密碼至少 8 碼</span>'; return; }\n st.textContent = '更新中…';\n fetch(API_BASE + '/console/setup/reset', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ current_password: oldPw, email: email, password: newPw })\n })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })\n .then(function (x) {\n if (!x.ok) { st.innerHTML = '<span class=\"err\">' + esc(x.d.error || '更新失敗') + '</span>'; return; }\n st.innerHTML = '<span class=\"ok\">密碼已更新,下次登入用新帳密</span>';\n $('st-pw-old').value = ''; $('st-pw-email').value = ''; $('st-pw-new').value = '';\n toast('密碼已更新');\n })\n .catch(function (e) { st.innerHTML = '<span class=\"err\">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });\n });\n $('st-vec-switch').addEventListener('click', function () {\n toast('此開關不能遠端改——部署端 config.yaml 開 kbdb_embed 後 acr update 重部署');\n });\n $('st-logout').addEventListener('click', function () {\n var t = S.token;\n if (t) fetch(API_BASE + '/console/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + t } }).catch(function () {});\n S.token = ''; S.tenant = '';\n localStorage.removeItem('arcrun_console_session');\n location.hash = '';\n checkConfigured();\n });\n\n init();\n})();\n</script>\n</body>\n</html>\n"},"/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"<!doctype html>\n<html lang=\"zh-Hant\">\n<head>\n<meta charset=\"utf-8\">\n<title>Arcrun RAG</title>\n<!--\n 根目錄直接導向搜尋 Portal。\n\n 為什麼不做「選擇介面」的導覽頁(2026-07-21 leo 實際撞到):\n 這個網域(rag-demo.arcrun.dev)是給**客戶測試**用的入口,\n 客戶測試指南寫的就是「一個網址、一組帳密」——多一層選擇=多一個困惑點,\n 而且會讓客戶看到 Admin Console 這個維運介面(不該對客戶露出)。\n\n 維運者要進 console 直接打 /console/ 即可。\n-->\n<meta http-equiv=\"refresh\" content=\"0; url=/portal/\">\n<link rel=\"canonical\" href=\"/portal/\">\n<script>location.replace('/portal/');</script>\n<style>\n body{margin:0;min-height:100vh;display:grid;place-items:center;\n background:#faf8f5;color:#6b635a;\n font:15px/1.7 system-ui,-apple-system,\"Noto Sans TC\",sans-serif}\n @media(prefers-color-scheme:dark){body{background:#1a1816;color:#9a9186}}\n</style>\n</head>\n<body>\n <p>正在前往搜尋頁… <a href=\"/portal/\">沒有自動跳轉請點這裡</a></p>\n</body>\n</html>\n"},"/portal/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"<!doctype html>\n<html lang=\"zh-Hant\">\n<head>\n<meta charset=\"utf-8\"/>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Arcrun Portal</title>\n<script>\n // 主題預載(防閃色):預設淺色,選擇存 localStorage(與 console 同設計語言、不同 key)\n document.documentElement.setAttribute('data-theme', (function () {\n try { return localStorage.getItem('arcrun_portal_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }\n })());\n</script>\n<style>\n * { box-sizing: border-box; }\n /* ── 主題色板(console 設計語言複製子集:淺=宣紙米白紙紋+墨字;深=紙感暖黑 2a)── */\n :root {\n --paper-a: #f4eddc; --paper-b: #f1e9d6;\n --ink: #2f2a20; --ink-rgb: 30,24,14;\n --amber: #8a5f1e; --amber-rgb: 138,95,30;\n --ok: #1d7a48; --ok-rgb: 29,122,72; --ok-soft: #1d6b40;\n --err: #b03a26; --err-rgb: 176,58,38;\n --well: rgba(30,24,14,.06); --well2: rgba(30,24,14,.08);\n --bar-bg: #efe7d3; --track: rgba(30,24,14,.12); --knob: #ffffff;\n }\n :root[data-theme=\"dark\"] {\n --paper-a: #191410; --paper-b: #1b1611;\n --ink: #ede4d3; --ink-rgb: 237,228,211;\n --amber: #e8b45a; --amber-rgb: 232,180,90;\n --ok: #7fe0a8; --ok-rgb: 63,190,120; --ok-soft: #a8e8c4;\n --err: #e58575; --err-rgb: 217,95,76;\n --well: rgba(0,0,0,.25); --well2: rgba(0,0,0,.3);\n --bar-bg: #17120e; --track: rgba(255,255,255,.08); --knob: #ede4d3;\n }\n html, body { margin: 0; background: repeating-linear-gradient(0deg,var(--paper-a) 0px,var(--paper-a) 3px,var(--paper-b) 3px,var(--paper-b) 4px); color: var(--ink);\n font-family: -apple-system, \"PingFang TC\", \"Microsoft JhengHei\", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }\n input, textarea, button { font-family: inherit; }\n ::placeholder { color: rgba(var(--ink-rgb),.35); }\n input:focus { outline: 2px solid rgba(var(--amber-rgb),.5); outline-offset: 1px; }\n a { color: var(--amber); }\n .serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }\n .mono { font-family: ui-monospace, Menlo, monospace; }\n .muted { color: rgba(var(--ink-rgb),.55); }\n .dim { color: rgba(var(--ink-rgb),.4); }\n .err { color: var(--err); }\n .ok { color: var(--ok); }\n\n /* ── auth 全屏(.view.on 的 block 會蓋 flex → 補 .authwrap.view.on,console 同修)── */\n .authwrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }\n .authbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; text-align: center; }\n .brand { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 44px; letter-spacing: .24em; color: var(--amber); }\n .brand-sub { margin-top: 10px; font-size: 15px; color: rgba(var(--ink-rgb),.55); letter-spacing: .1em; }\n\n /* ── 版面骨架 ── */\n #shell { display: none; min-height: 100vh; }\n #shell.on { display: flex; }\n #sidenav { display: none; }\n main { flex: 1; min-width: 0; padding-bottom: 96px; }\n .page { max-width: 980px; margin: 0 auto; padding: 0 20px 40px; }\n .page.narrow { max-width: 760px; }\n .pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(var(--amber-rgb),.4); display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }\n .pagehead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; white-space: nowrap; }\n .pagehead .m { font-size: 13.5px; color: rgba(var(--ink-rgb),.45); }\n #tabbar { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bar-bg); border-top: 2px solid rgba(var(--amber-rgb),.4); display: none; z-index: 50; padding-bottom: env(safe-area-inset-bottom); }\n #tabbar.on { display: flex; }\n #tabbar .tab { flex: 1; text-align: center; padding: 15px 0 13px; font-size: 14.5px; cursor: pointer; min-height: 44px; color: rgba(var(--ink-rgb),.5); border-top: 2px solid transparent; margin-top: -2px; }\n #tabbar .tab.on { color: var(--amber); font-weight: 600; border-top-color: var(--amber); }\n #tabbar .tab.hide { display: none; }\n @media (min-width: 1024px) {\n #tabbar.on { display: none; }\n main { padding-bottom: 20px; }\n #sidenav { display: flex; width: 216px; flex: none; position: sticky; top: 0; height: 100vh; border-right: 2px solid rgba(var(--amber-rgb),.35); padding: 28px 0; flex-direction: column; gap: 4px; }\n #sidenav .logo { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 26px; letter-spacing: .22em; color: var(--amber); padding: 0 24px 20px; }\n #sidenav .nav { padding: 13px 24px; font-size: 16px; cursor: pointer; letter-spacing: .08em; color: rgba(var(--ink-rgb),.6); border-right: 2px solid transparent; }\n #sidenav .nav.on { color: var(--amber); background: rgba(var(--amber-rgb),.08); border-right-color: var(--amber); font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-weight: 600; }\n #sidenav .nav.hide { display: none; }\n #sidenav .foot { margin-top: auto; padding: 0 24px; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.7; }\n }\n .view { display: none; }\n .view.on { display: block; }\n .authwrap.view.on { display: flex; }\n\n /* ── 元件 ── */\n .btn { padding: 13px; font-size: 16px; font-weight: 600; border-radius: 10px; border: none; background: linear-gradient(90deg,#b98330,#e8b45a); color: #241804; cursor: pointer; }\n .btn:disabled { opacity: .45; cursor: default; }\n .btn3 { padding: 11px 16px; font-size: 15px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.7); cursor: pointer; }\n .txt { width: 100%; padding: 13px 15px; font-size: 16px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.2); background: rgba(var(--ink-rgb),.05); color: var(--ink); }\n .panel { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); }\n .tag { display: inline-block; padding: 3px 9px; border-radius: 5px; background: rgba(var(--amber-rgb),.12); color: var(--amber); font-size: 12.5px; }\n .tag.green { background: rgba(var(--ok-rgb),.12); color: var(--ok); }\n .tag.dim { background: rgba(var(--ink-rgb),.08); color: rgba(var(--ink-rgb),.55); }\n .honest { padding: 22px; border-radius: 13px; border: 1px dashed rgba(var(--amber-rgb),.4); background: rgba(var(--amber-rgb),.06); text-align: center; }\n .honest .h { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 19px; color: var(--amber); margin-bottom: 8px; }\n .honest .b { font-size: 15px; color: rgba(var(--ink-rgb),.6); line-height: 1.7; }\n #toast { position: fixed; left: 50%; bottom: 110px; transform: translateX(-50%); z-index: 99; padding: 13px 22px; border-radius: 11px; background: #e8b45a; color: #241804; font-size: 15.5px; font-weight: 600; box-shadow: 0 6px 24px rgba(0,0,0,.5); white-space: nowrap; display: none; }\n\n /* ── 搜尋 ── */\n .searchrow { display: flex; gap: 10px; align-items: stretch; }\n .searchrow input { flex: 1; min-width: 0; padding: 16px 18px; font-size: 18px; border-radius: 13px; border: 1.5px solid rgba(var(--amber-rgb),.35); background: rgba(var(--ink-rgb),.05); color: var(--ink); }\n .modebar { display: flex; gap: 8px; margin-top: 12px; }\n .modebtn { flex: none; padding: 9px 18px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); }\n .modebtn.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }\n .modebtn.hide { display: none; }\n .kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; }\n @media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }\n .kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; }\n .kcard:hover { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.06); }\n .kcard .kt { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 18.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }\n .kcard .ks { font-size: 15px; color: rgba(var(--ink-rgb),.65); line-height: 1.65; word-break: break-word; }\n .kcard .km { display: flex; gap: 10px; align-items: center; margin-top: auto; font-size: 13px; flex-wrap: wrap; }\n\n /* ── 卡片詳頁 ── */\n .cardtitle { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 28px; font-weight: 600; line-height: 1.4; word-break: break-word; }\n .md { margin-top: 18px; }\n .md p { font-size: 16.5px; line-height: 1.85; color: rgba(var(--ink-rgb),.82); margin: 8px 0; word-break: break-word; }\n .md h1, .md h2, .md h3, .md h4 { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-weight: 600; color: var(--amber); margin: 18px 0 6px; line-height: 1.4; }\n .md h1 { font-size: 22px; } .md h2 { font-size: 20px; } .md h3 { font-size: 18px; } .md h4 { font-size: 17px; }\n .md ul, .md ol { margin: 8px 0; padding-left: 24px; }\n .md li { font-size: 16.5px; line-height: 1.8; color: rgba(var(--ink-rgb),.82); margin: 4px 0; word-break: break-word; }\n .md blockquote { margin: 10px 0; padding: 12px 18px; border-left: 3px solid rgba(var(--amber-rgb),.5); font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; line-height: 1.8; color: rgba(var(--ink-rgb),.7); }\n .md code { font-family: ui-monospace, Menlo, monospace; font-size: 14.5px; background: var(--well2); border-radius: 4px; padding: 1px 6px; word-break: break-all; }\n .md pre { background: var(--well2); border: 1px solid rgba(var(--ink-rgb),.1); border-radius: 10px; padding: 14px; overflow-x: auto; }\n .md pre code { background: none; padding: 0; }\n .srcline { margin-top: 24px; display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 11px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.12); }\n\n /* ── graph 模式(console 關聯視圖同款)── */\n .graphbox { position: relative; height: 340px; border-radius: 14px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); overflow: hidden; }\n .gnode { position: absolute; transform: translate(-50%,-50%); max-width: 96px; padding: 8px 11px; border-radius: 999px; text-align: center; font-size: 12.5px; line-height: 1.3; cursor: pointer; word-break: break-word; background: rgba(var(--ok-rgb),.1); border: 1px solid rgba(var(--ok-rgb),.35); color: var(--ok-soft); }\n .gcenter { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 104px; height: 104px; border-radius: 50%; background: radial-gradient(circle at 36% 30%,rgba(242,209,148,.95),rgba(232,180,90,.9) 55%,rgba(138,95,30,.95)); display: grid; place-items: center; text-align: center; padding: 8px; box-shadow: 0 0 30px rgba(var(--amber-rgb),.3); }\n .gcenter span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 13.5px; font-weight: 600; line-height: 1.35; color: #241804; word-break: break-word; }\n .nbrow { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; cursor: pointer; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); font-size: 15.5px; }\n /* 總圖(Arcrun#39 GUI 切片):沿用紙感語言——green 章印節點、amber 高度數節點 */\n .mapbox { position: relative; border-radius: 14px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); overflow: hidden; }\n .mapbox svg { display: block; width: 100%; height: auto; }\n .mapnode { cursor: pointer; }\n .mapnode circle { fill: rgba(var(--ok-rgb),.12); stroke: rgba(var(--ok-rgb),.45); stroke-width: 1.4; transition: stroke-width .15s; }\n .mapnode.hub circle { fill: rgba(var(--amber-rgb),.2); stroke: rgba(var(--amber-rgb),.65); }\n .mapnode:hover circle { stroke-width: 3; }\n .mapnode text { font-size: 12.5px; fill: var(--ink); pointer-events: none; }\n .mapedge { stroke: rgba(var(--ink-rgb),.18); stroke-width: 1; stroke-dasharray: 4 4; }\n\n /* ── 管理頁(P4)── */\n .sechead { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 19px; letter-spacing: .14em; color: var(--amber); margin: 26px 0 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(var(--amber-rgb),.3); }\n .formrow { display: flex; gap: 10px; flex-wrap: wrap; }\n .formrow .txt { flex: 1; min-width: 180px; }\n .formrow select { padding: 12px 14px; font-size: 15.5px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.2); background: rgba(var(--ink-rgb),.05); color: var(--ink); }\n .btnrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }\n .btn2 { padding: 9px 14px; font-size: 14px; border-radius: 9px; border: 1px solid rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.08); color: var(--amber); cursor: pointer; }\n .btn2.warn { border-color: rgba(var(--err-rgb),.45); background: rgba(var(--err-rgb),.06); color: var(--err); }\n .btn2:disabled { opacity: .45; cursor: default; }\n .libgrid { display: flex; gap: 8px 14px; flex-wrap: wrap; margin-top: 10px; }\n .libgrid label { display: flex; align-items: center; gap: 7px; font-size: 14.5px; cursor: pointer; padding: 6px 10px; border-radius: 8px; background: rgba(var(--ink-rgb),.05); border: 1px solid rgba(var(--ink-rgb),.12); }\n .libgrid input[type=checkbox] { accent-color: var(--amber); width: 16px; height: 16px; }\n .otpbox { margin-top: 14px; padding: 18px; border-radius: 13px; border: 2px solid rgba(var(--amber-rgb),.55); background: rgba(var(--amber-rgb),.08); }\n .otpbox .pw { font-family: ui-monospace, Menlo, monospace; font-size: 22px; letter-spacing: .12em; color: var(--amber); word-break: break-all; user-select: all; margin: 10px 0; }\n\n /* ── 清單 / 設定 ── */\n .listcol { display: flex; flex-direction: column; gap: 10px; padding: 16px 0 8px; }\n .card-item { border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); padding: 16px 18px; }\n .setcol { display: flex; flex-direction: column; gap: 14px; padding: 20px 0 40px; }\n .switch { flex: none; margin-left: auto; width: 56px; height: 32px; border-radius: 999px; padding: 3px; background: rgba(var(--ink-rgb),.15); transition: background .25s; }\n .switch.on { background: rgba(var(--ok-rgb),.5); }\n .switch i { display: block; width: 26px; height: 26px; border-radius: 50%; background: var(--knob); box-shadow: 0 1px 3px rgba(0,0,0,.25); transition: transform .25s; }\n .switch.on i { transform: translateX(24px); }\n .kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }\n</style>\n<script src=\"/config.js\"></script>\n<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || \"https://cypher.arcrun.dev\";</script>\n</head>\n<body>\n\n<!-- 登入(未登入只見這個殼;本頁不含任何資料)-->\n<div class=\"authwrap view\" id=\"v-login\">\n <div class=\"authbox\">\n <div>\n <div class=\"brand\">Arcrun</div>\n <div class=\"brand-sub\">知識入口・Portal</div>\n </div>\n <div style=\"display:flex;flex-direction:column;gap:12px;text-align:left\">\n <input type=\"email\" id=\"login-email\" class=\"txt\" placeholder=\"Email\" autocomplete=\"username\" style=\"padding:15px 16px;font-size:17px;border-radius:11px\">\n <input type=\"password\" id=\"login-password\" class=\"txt\" placeholder=\"密碼\" autocomplete=\"current-password\" style=\"padding:15px 16px;font-size:17px;border-radius:11px\">\n <button class=\"btn\" id=\"login-submit\" style=\"margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em\">登入</button>\n <div id=\"login-status\" class=\"err\" style=\"font-size:14px;min-height:1.2em\"></div>\n </div>\n <div style=\"font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7\">帳號由管理員發放。忘記密碼請聯絡管理員重設。</div>\n <button class=\"btn3 themelabel\" data-themetoggle style=\"align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px\">☾ 切深色</button>\n </div>\n</div>\n\n<!-- 主應用 -->\n<div id=\"shell\">\n <div id=\"sidenav\">\n <div class=\"logo\">Arcrun</div>\n <div class=\"nav\" data-nav=\"search\">搜尋</div>\n <div class=\"nav hide\" id=\"nav-map\" data-nav=\"map\">總圖</div>\n <div class=\"nav hide\" id=\"nav-upload\" data-nav=\"upload\">上傳</div>\n <div class=\"nav hide\" id=\"nav-workflows\" data-nav=\"workflows\">工作流</div>\n <div class=\"nav hide\" id=\"nav-admin\" data-nav=\"admin\">管理</div>\n <div class=\"nav\" data-nav=\"settings\">設定</div>\n <div class=\"nav themelabel\" data-themetoggle style=\"margin-top:8px;font-size:14.5px\">☾ 切深色</div>\n <div class=\"foot\" id=\"side-foot\"></div>\n </div>\n <main>\n\n <!-- 搜尋(落地頁)-->\n <div class=\"view page\" id=\"v-search\">\n <div class=\"pagehead\"><span class=\"t\">搜尋</span><span class=\"m\" id=\"se-scope\"></span></div>\n <div style=\"padding-top:20px\">\n <div class=\"searchrow\">\n <input id=\"se-q\" placeholder=\"搜尋知識…\" autocomplete=\"off\">\n <button class=\"btn\" id=\"se-go\" style=\"flex:none;padding:0 22px;letter-spacing:.2em\">搜</button>\n </div>\n <div class=\"modebar\">\n <button class=\"modebtn on\" data-mode=\"keyword\">關鍵字</button>\n <button class=\"modebtn\" data-mode=\"semantic\">語意</button>\n <button class=\"modebtn hide\" id=\"mode-graph\" data-mode=\"graph\">圖譜</button>\n </div>\n <!-- AI 問答(portal-demo-suite)。設計哲學:AI 檢索=用戶手動搜尋同一套——\n /portal/data/chat 與 /portal/data/search 走同一個 session 閘、同一個租戶資料面,\n AI 只是代查再作答,不多開任何權限。此實例未安裝問答 workflow 時 server 誠實 404。-->\n <div style=\"margin-top:14px;padding:14px;border-radius:13px;background:rgba(var(--amber-rgb),.05);border:1px solid rgba(var(--amber-rgb),.25)\">\n <div style=\"font-size:13.5px;letter-spacing:.1em;color:rgba(var(--ink-rgb),.5);margin-bottom:9px\">AI 問答・答案附出處,可回搜尋驗證</div>\n <div class=\"searchrow\">\n <input id=\"ai-q\" placeholder=\"用一句話問知識庫…\" autocomplete=\"off\" style=\"padding:12px 15px;font-size:16px\">\n <button class=\"btn\" id=\"ai-go\" style=\"flex:none;padding:0 20px\">問 AI</button>\n </div>\n <div id=\"ai-out\"></div>\n </div>\n <div id=\"se-banner\"></div>\n <div id=\"se-count\" style=\"margin:20px 0 12px;font-size:14px;color:rgba(var(--ink-rgb),.5)\"></div>\n <div class=\"kgrid\" id=\"se-results\"></div>\n <div id=\"se-graphout\" style=\"display:none\">\n <div id=\"gr-box\"></div>\n <div id=\"gr-edges\" style=\"margin-top:16px;display:flex;flex-direction:column;gap:7px\"></div>\n </div>\n </div>\n </div>\n\n <!-- 總圖(Arcrun#39 藏書地圖 GUI 切片;graph 粗閘後可見):全庫三元組一張網。\n 力導向佈局內聯手刻(demo 量級 ≤ 數百節點),不引外部套件(頁面自足原則)。-->\n <div class=\"view page\" id=\"v-map\">\n <div class=\"pagehead\"><span class=\"t\">總圖</span><span class=\"m\" id=\"map-meta\"></span></div>\n <div style=\"padding-top:20px\">\n <div id=\"map-box\"></div>\n <div style=\"margin-top:12px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)\">\n 點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版\n (給 AI 注入用的總庫目錄)<span id=\"map-md-link\"></span>。\n </div>\n </div>\n </div>\n\n <!-- 卡片詳頁 -->\n <div class=\"view page\" id=\"v-card\">\n <div class=\"pagehead\" style=\"justify-content:flex-start;gap:14px\">\n <button class=\"btn3\" style=\"border:none;padding:0;font-size:16px;color:var(--amber)\" data-nav=\"search\">‹ 搜尋</button>\n <span class=\"serif\" style=\"font-size:17px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.55)\">知識卡片</span>\n </div>\n <div id=\"cd-main\" style=\"padding:24px 0 40px\"><div class=\"muted\">載入中…</div></div>\n </div>\n\n <!-- 上傳(portal-demo-suite:upload bindings 齊全才顯示 nav——/portal/session 的\n upload_enabled;藏只是 UX,真閘在 /portal/data/upload 路由層 404)-->\n <div class=\"view page narrow\" id=\"v-upload\">\n <div class=\"pagehead\"><span class=\"t\">上傳</span><span class=\"m\">Markdown / 純文字</span></div>\n <div style=\"margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--amber-rgb),.06);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)\">\n 上傳的文件會進入知識庫收件管線:<b style=\"color:var(--ink)\">約 1 分鐘後可在搜尋頁找到</b>;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。\n </div>\n <div id=\"up-drop\" style=\"margin-top:16px;padding:44px 20px;border-radius:13px;border:2px dashed rgba(var(--amber-rgb),.4);background:rgba(var(--amber-rgb),.04);text-align:center;cursor:pointer\">\n <div style=\"font-size:16px;color:rgba(var(--ink-rgb),.7)\">把 .md / .txt 檔案拖放到這裡</div>\n <div class=\"muted\" style=\"margin-top:8px;font-size:14px\">或</div>\n <button class=\"btn2\" id=\"up-pick\" style=\"margin-top:10px;padding:11px 22px;font-size:15px\">選擇檔案(可多選)</button>\n <input type=\"file\" id=\"up-file\" accept=\".md,.txt\" multiple style=\"display:none\">\n </div>\n <div class=\"listcol\" id=\"up-list\"></div>\n </div>\n\n <!-- 工作流(D-8:預設 admin 才看得到;唯讀,不開 trigger)-->\n <div class=\"view page narrow\" id=\"v-workflows\">\n <div class=\"pagehead\"><span class=\"t\">工作流</span><span class=\"m\">唯讀・系統狀態</span></div>\n <div style=\"margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--amber-rgb),.06);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)\">\n 此頁只顯示系統裡的工作流與最近執行狀態,<b style=\"color:var(--ink)\">不能觸發執行</b>(觸發屬系統擁有者權限)。\n </div>\n <div class=\"listcol\" id=\"wf-list\"><div class=\"muted\">載入中…</div></div>\n </div>\n\n <!-- 設定 -->\n <div class=\"view page narrow\" id=\"v-settings\">\n <div class=\"pagehead\"><span class=\"t\">設定</span></div>\n <div class=\"setcol\">\n <div class=\"panel\">\n <div style=\"font-size:17px;font-weight:600;margin-bottom:10px\">我的帳號</div>\n <div id=\"st-me\"><div class=\"muted\">載入中…</div></div>\n </div>\n <div class=\"panel\">\n <div style=\"display:flex;align-items:center;gap:14px\">\n <div style=\"min-width:0\">\n <div style=\"font-size:17px;font-weight:600\">深色模式</div>\n <div style=\"margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)\">預設淺色(紙感)。切換立即生效,選擇記在這台裝置。</div>\n </div>\n <div class=\"switch\" id=\"st-theme-switch\" data-themetoggle style=\"cursor:pointer\"><i></i></div>\n </div>\n </div>\n <div class=\"panel\">\n <div style=\"font-size:17px;font-weight:600\">更改密碼</div>\n <div style=\"margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)\">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>\n <div style=\"margin-top:14px;display:flex;flex-direction:column;gap:10px\">\n <input type=\"password\" id=\"st-pw-old\" class=\"txt\" placeholder=\"舊密碼\" autocomplete=\"current-password\">\n <input type=\"password\" id=\"st-pw-new\" class=\"txt\" placeholder=\"新密碼(至少 8 碼)\" autocomplete=\"new-password\">\n <input type=\"password\" id=\"st-pw-new2\" class=\"txt\" placeholder=\"再輸入一次新密碼\" autocomplete=\"new-password\">\n <button class=\"btn\" id=\"st-pw-save\">更新密碼</button>\n <div id=\"st-pw-status\" style=\"font-size:14px;min-height:1.2em\"></div>\n </div>\n </div>\n <button class=\"btn3\" id=\"st-logout\" style=\"padding:14px;font-size:16px;border-radius:11px\">登出</button>\n </div>\n </div>\n\n <!-- 管理(P4,admin-only:nav 只有 admin 看得到;真閘在 /portal/admin/* role 閘 403)-->\n <div class=\"view page\" id=\"v-admin\">\n <div class=\"pagehead\"><span class=\"t\">管理</span><span class=\"m\">帳號與知識庫授權</span></div>\n\n <div class=\"sechead\">帳號管理</div>\n <div class=\"panel\">\n <div style=\"font-size:16px;font-weight:600;margin-bottom:4px\">新增同仁帳號</div>\n <div style=\"font-size:13.5px;color:rgba(var(--ink-rgb),.55);margin-bottom:12px\">建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。</div>\n <div class=\"formrow\">\n <input type=\"email\" id=\"ad-nu-email\" class=\"txt\" placeholder=\"Email\" autocomplete=\"off\">\n <input type=\"text\" id=\"ad-nu-name\" class=\"txt\" placeholder=\"顯示名(可留空=用 Email)\" autocomplete=\"off\">\n <select id=\"ad-nu-role\"><option value=\"user\">一般用戶</option><option value=\"admin\">管理員</option></select>\n <button class=\"btn\" id=\"ad-nu-create\" style=\"flex:none;padding:0 20px\">建立</button>\n </div>\n <div id=\"ad-nu-status\" class=\"err\" style=\"font-size:14px;min-height:1.2em;margin-top:8px\"></div>\n </div>\n <div id=\"ad-otp\"></div>\n <div class=\"listcol\" id=\"ad-users\"><div class=\"muted\">載入中…</div></div>\n\n <div class=\"sechead\">庫目錄管理</div>\n <div style=\"margin-bottom:12px;padding:12px 15px;border-radius:11px;background:rgba(var(--amber-rgb),.06);border:1px dashed rgba(var(--amber-rgb),.35);font-size:13.5px;line-height:1.7;color:rgba(var(--ink-rgb),.65)\">\n 這裡是「庫」的登記簿——條目歸哪個庫由資料導入(ingest)時蓋章決定;未蓋章的舊資料一律視同 <b style=\"color:var(--ink)\">general</b>。標了「圖譜來源」的庫決定誰能用圖譜模式(全都沒標=預設 general)。\n </div>\n <div class=\"panel\">\n <div style=\"font-size:16px;font-weight:600;margin-bottom:12px\">登記新庫</div>\n <div class=\"formrow\">\n <input type=\"text\" id=\"ad-nl-name\" class=\"txt\" placeholder=\"庫名(英數 _ -,例:finance)\" autocomplete=\"off\">\n <input type=\"text\" id=\"ad-nl-display\" class=\"txt\" placeholder=\"顯示名(例:財務庫)\" autocomplete=\"off\">\n <input type=\"text\" id=\"ad-nl-desc\" class=\"txt\" placeholder=\"描述(可留空)\" autocomplete=\"off\">\n <button class=\"btn\" id=\"ad-nl-create\" style=\"flex:none;padding:0 20px\">登記</button>\n </div>\n <div id=\"ad-nl-status\" class=\"err\" style=\"font-size:14px;min-height:1.2em;margin-top:8px\"></div>\n </div>\n <div class=\"listcol\" id=\"ad-libs\"><div class=\"muted\">載入中…</div></div>\n </div>\n\n </main>\n</div>\n\n<!-- 手機底部導覽 -->\n<div id=\"tabbar\">\n <div class=\"tab\" data-nav=\"search\">搜尋</div>\n <div class=\"tab hide\" id=\"tab-map\" data-nav=\"map\">總圖</div>\n <div class=\"tab hide\" id=\"tab-upload\" data-nav=\"upload\">上傳</div>\n <div class=\"tab hide\" id=\"tab-workflows\" data-nav=\"workflows\">工作流</div>\n <div class=\"tab hide\" id=\"tab-admin\" data-nav=\"admin\">管理</div>\n <div class=\"tab\" data-nav=\"settings\">設定</div>\n</div>\n\n<div id=\"toast\"></div>\n\n<script>\n(function () {\n var API_BASE = window.ARCRUN_API_BASE || '';\n 'use strict';\n // 台北時間 helper(lib/taipei-time.ts 注入,console 同套——顯示不隨裝置時區漂移)\n var TAIPEI_OFFSET_MS = 28800000; // UTC+8,台北無 DST\nfunction tpePad(n) { n = String(n); return n.length < 2 ? '0' + n : n; }\nfunction taipeiDayKey(ms) { return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10); }\nfunction taipeiDateStr(ms) { return taipeiDayKey(ms); }\nfunction taipeiDateTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return taipeiDayKey(ms) + ' ' + tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()); }\nfunction taipeiTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()) + ':' + tpePad(d.getUTCSeconds()); }\nfunction taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { month: d.getUTCMonth() + 1, day: d.getUTCDate() }; }\n var $ = function (id) { return document.getElementById(id); };\n\n // ── 狀態:只有 session token;**沒有租戶字串、沒有 API key**(design §3.3)──\n var S = {\n token: localStorage.getItem('arcrun_portal_session') || '',\n profile: null, // /portal/session 回應(display_name/role/libraries/graph_allowed/workflows_visible)\n mode: 'keyword',\n cardId: ''\n };\n\n function esc(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, function (c) {\n return { '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[c];\n });\n }\n function toast(msg) {\n var el = $('toast');\n el.textContent = msg;\n el.style.display = 'block';\n clearTimeout(toast._t);\n toast._t = setTimeout(function () { el.style.display = 'none'; }, 2000);\n }\n function friendlyErr(e) {\n var m = e && e.message ? String(e.message) : String(e);\n if (/failed to fetch|load failed|networkerror|network request failed/i.test(m)) return '連線中斷——請檢查網路後重試';\n return m;\n }\n function authHeaders() { return S.token ? { 'Authorization': 'Bearer ' + S.token } : {}; }\n\n // ── 主題(console 同款,key 獨立)──\n function getTheme() { return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; }\n function setTheme(t) {\n document.documentElement.setAttribute('data-theme', t);\n try { localStorage.setItem('arcrun_portal_theme', t); } catch (e) { /* 私密模式存不了 */ }\n syncThemeUI();\n }\n function syncThemeUI() {\n var dark = getTheme() === 'dark';\n document.querySelectorAll('.themelabel').forEach(function (el) { el.textContent = dark ? '☀ 切淺色' : '☾ 切深色'; });\n var sw = $('st-theme-switch');\n if (sw) sw.classList.toggle('on', dark);\n }\n document.addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-themetoggle]');\n if (t) setTheme(getTheme() === 'dark' ? 'light' : 'dark');\n });\n syncThemeUI();\n\n // created_at 容錯(epoch 秒/毫秒/ISO),顯示一律台北時間(console 同款)\n function parseAtMs(v) {\n if (v == null || v === '') return null;\n if (typeof v === 'number') return v < 1e12 ? v * 1000 : v;\n if (/^\\d+$/.test(v)) { var n = Number(v); return n < 1e12 ? n * 1000 : n; }\n var s = /T/.test(v) ? v : String(v).replace(' ', 'T') + 'Z';\n var ms = Date.parse(s);\n return isNaN(ms) ? null : ms;\n }\n function fmtDate(v) { var ms = parseAtMs(v); return ms == null ? '' : taipeiDateStr(ms); }\n function fmtDateTime(v) { var ms = parseAtMs(v); return ms == null ? '' : taipeiDateTimeStr(ms); }\n\n // ── 極簡 Markdown 渲染(先全 escape 再上格式,防 XSS;console 同款)──\n function mdInline(s) {\n return s\n .replace(/\\*\\*([^*]+)\\*\\*/g, '<b>$1</b>')\n .replace(/(^|[^\\\\])\\x60([^\\x60]+)\\x60/g, '$1<code>$2</code>');\n }\n function mdRender(src) {\n var lines = String(src || '').split(/\\r?\\n/);\n var out = [], inCode = false, listMode = '';\n function closeList() { if (listMode) { out.push(listMode === 'ul' ? '</ul>' : '</ol>'); listMode = ''; } }\n for (var i = 0; i < lines.length; i++) {\n var raw = lines[i];\n if (/^\\s*\\x60\\x60\\x60/.test(raw)) {\n closeList();\n if (inCode) { out.push('</code></pre>'); inCode = false; }\n else { out.push('<pre><code>'); inCode = true; }\n continue;\n }\n if (inCode) { out.push(esc(raw) + '\\n'); continue; }\n var line = esc(raw);\n var h = raw.match(/^(#{1,4})\\s+(.*)$/);\n if (h) { closeList(); out.push('<h' + h[1].length + '>' + mdInline(esc(h[2])) + '</h' + h[1].length + '>'); continue; }\n if (/^\\s*>\\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\\s*>\\s?/, '')) + '</blockquote>'); continue; }\n var li = raw.match(/^\\s*[-*+]\\s+(.*)$/);\n if (li) { if (listMode !== 'ul') { closeList(); out.push('<ul>'); listMode = 'ul'; } out.push('<li>' + mdInline(esc(li[1])) + '</li>'); continue; }\n var oli = raw.match(/^\\s*\\d+[.)]\\s+(.*)$/);\n if (oli) { if (listMode !== 'ol') { closeList(); out.push('<ol>'); listMode = 'ol'; } out.push('<li>' + mdInline(esc(oli[1])) + '</li>'); continue; }\n if (/^\\s*$/.test(raw)) { closeList(); continue; }\n closeList();\n out.push('<p>' + mdInline(line) + '</p>');\n }\n closeList();\n if (inCode) out.push('</code></pre>');\n return out.join('');\n }\n\n // ── entry 顯示 helpers(source/library 從 metadata_json 取——server 已 enforce 過權限)──\n function entryMeta(e) {\n try { var m = JSON.parse(e.metadata_json || 'null'); return m && typeof m === 'object' ? m : {}; } catch (er) { return {}; }\n }\n function entrySource(e) { var m = entryMeta(e); return typeof m.source === 'string' ? m.source : ''; }\n // 來源回溯超連結:PORTAL_SOURCE_WEB_BASE 有設且 source 是 gitea:// 才給 href;\n // 其餘回空字串=維持純文字(行為與未設時一字不變)。chunk 錨點(#n)捨棄。\n var SOURCE_WEB_BASE = \"\";\n function srcHref(src) {\n if (!SOURCE_WEB_BASE || String(src).indexOf('gitea://') !== 0) return '';\n var p = String(src).slice(8).replace(/#\\d+$/, '');\n return SOURCE_WEB_BASE.replace(/\\/+$/, '') + '/' + p.split('/').map(encodeURIComponent).join('/');\n }\n function entryLib(e) { var m = entryMeta(e); return (typeof m.library === 'string' && m.library) ? m.library : 'general'; }\n function entryTitle(e) {\n if (e.page_name) return e.page_name;\n var first = String(e.content || '').split(/\\r?\\n/).find(function (l) { return l.trim(); }) || '';\n first = first.replace(/^#+\\s*/, '').replace(/^[-*>]\\s*/, '').trim();\n if (first.length > 60) first = first.slice(0, 60) + '…';\n return first || '(無標題・' + (e.entry_type || 'entry') + ')';\n }\n function entrySnippet(e) {\n var lines = String(e.content || '').split(/\\r?\\n/).filter(function (l) { return l.trim(); });\n var body = lines.slice(1).join(' ').replace(/\\s+/g, ' ').trim();\n if (!body) body = lines.join(' ');\n if (body.length > 140) body = body.slice(0, 140) + '…';\n return body;\n }\n\n // ── 路由 ──\n var VIEWS = ['search', 'map', 'card', 'upload', 'workflows', 'admin', 'settings'];\n var HOME = 'search';\n function allowedViews() {\n // 工作流/管理/上傳頁按 session 能力顯示(真閘在 server:/portal/data/workflows 403/404、\n // /portal/admin/* role 閘 403、/portal/data/upload 未啟用 404——前端藏只是 UX,curl 直打也繞不過)\n return VIEWS.filter(function (v) {\n if (v === 'workflows') return !!(S.profile && S.profile.workflows_visible);\n if (v === 'admin') return !!(S.profile && S.profile.role === 'admin');\n if (v === 'upload') return !!(S.profile && S.profile.upload_enabled);\n return true;\n });\n }\n function currentRoute() {\n var h = location.hash.replace(/^#\\/?/, '');\n var seg = h.split('/');\n var ok = allowedViews();\n return { raw: seg[0] || '', view: ok.indexOf(seg[0]) >= 0 ? seg[0] : HOME, arg: seg.slice(1).join('/') };\n }\n function nav(view, arg) {\n location.hash = '#/' + view + (arg ? '/' + encodeURIComponent(arg) : '');\n }\n function showView(name) {\n VIEWS.forEach(function (v) { $('v-' + v).classList.toggle('on', v === name); });\n var activeKey = name === 'card' ? 'search' : name;\n document.querySelectorAll('#sidenav .nav, #tabbar .tab').forEach(function (el) {\n el.classList.toggle('on', el.getAttribute('data-nav') === activeKey);\n });\n window.scrollTo(0, 0);\n }\n var LOADERS = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };\n function route() {\n var r = currentRoute();\n if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }\n if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);\n showView(r.view);\n if (LOADERS[r.view]) LOADERS[r.view]();\n }\n window.addEventListener('hashchange', route);\n document.addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-nav]');\n if (t) nav(t.getAttribute('data-nav'));\n });\n\n // ── 認證流 ──\n function showAuth() {\n $('v-login').classList.add('on');\n $('shell').classList.remove('on');\n $('tabbar').classList.remove('on');\n }\n function showApp() {\n $('v-login').classList.remove('on');\n $('shell').classList.add('on');\n $('tabbar').classList.add('on');\n var p = S.profile || {};\n $('side-foot').innerHTML = esc(p.display_name || '') + '<br>' + esc(location.host);\n $('nav-workflows').classList.toggle('hide', !p.workflows_visible);\n $('tab-workflows').classList.toggle('hide', !p.workflows_visible);\n // 上傳 nav:一般用戶可見,但只在實例啟用上傳(bindings 齊全)時顯示(未啟用的實例零影響)\n $('nav-upload').classList.toggle('hide', !p.upload_enabled);\n $('tab-upload').classList.toggle('hide', !p.upload_enabled);\n $('nav-admin').classList.toggle('hide', p.role !== 'admin');\n $('tab-admin').classList.toggle('hide', p.role !== 'admin');\n $('mode-graph').classList.toggle('hide', !p.graph_allowed);\n $('nav-map').classList.toggle('hide', !p.graph_allowed);\n $('tab-map').classList.toggle('hide', !p.graph_allowed);\n var libs = p.libraries || [];\n $('se-scope').textContent = libs.indexOf('*') >= 0 ? '範圍:全部知識庫' : ('範圍:' + libs.join('・'));\n if (!location.hash) location.hash = '#/' + HOME;\n route();\n }\n function dropSession() {\n S.token = '';\n S.profile = null;\n try { localStorage.removeItem('arcrun_portal_session'); } catch (e) { /* noop */ }\n showAuth();\n }\n function boot() {\n if (!S.token) { showAuth(); return; }\n fetch(API_BASE + '/portal/session', { headers: authHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })\n .then(function (x) {\n if (!x.ok) { dropSession(); return; }\n S.profile = x.d;\n showApp();\n })\n .catch(function () {\n // 斷網時不清 session(誠實:連不上 ≠ session 失效),顯示登入殼並提示\n showAuth();\n $('login-status').textContent = '連線中斷——請檢查網路後重新整理';\n });\n }\n $('login-submit').addEventListener('click', doLogin);\n $('login-password').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doLogin(); });\n function doLogin() {\n var email = $('login-email').value.trim();\n var password = $('login-password').value;\n if (!email || !password) { $('login-status').textContent = '請輸入 Email 與密碼'; return; }\n $('login-submit').disabled = true;\n $('login-status').textContent = '';\n fetch(API_BASE + '/portal/login', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email: email, password: password })\n })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })\n .then(function (x) {\n $('login-submit').disabled = false;\n if (!x.ok) { $('login-status').textContent = x.d.error || '登入失敗'; return; }\n S.token = x.d.session_token;\n try { localStorage.setItem('arcrun_portal_session', S.token); } catch (e) { /* 私密模式只活本次 */ }\n $('login-password').value = '';\n boot();\n })\n .catch(function (e) { $('login-submit').disabled = false; $('login-status').textContent = friendlyErr(e); });\n }\n $('st-logout').addEventListener('click', function () {\n fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });\n dropSession();\n });\n\n // 任何 data 請求收到 401 → session 失效 → 回登入殼\n function guard401(status) {\n if (status === 401) { dropSession(); return true; }\n return false;\n }\n\n // ── 搜尋 ──\n document.querySelectorAll('.modebtn').forEach(function (b) {\n b.addEventListener('click', function () {\n S.mode = b.getAttribute('data-mode');\n document.querySelectorAll('.modebtn').forEach(function (x) { x.classList.toggle('on', x === b); });\n if ($('se-q').value.trim()) doSearch();\n });\n });\n $('se-go').addEventListener('click', doSearch);\n $('se-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doSearch(); });\n function doSearch() {\n var q = $('se-q').value.trim();\n if (!q) { $('se-count').textContent = '請輸入查詢字'; return; }\n $('se-banner').innerHTML = '';\n if (S.mode === 'graph') { doGraphSearch(q); return; }\n $('se-graphout').style.display = 'none';\n $('se-results').style.display = '';\n $('se-count').textContent = '查詢中…';\n $('se-results').innerHTML = '';\n var url = API_BASE + '/portal/data/search?q=' + encodeURIComponent(q) + '&mode=' + (S.mode === 'semantic' ? 'semantic' : 'keyword');\n fetch(url, { headers: authHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n if (guard401(x.status)) return;\n if (!x.ok) { $('se-count').innerHTML = '<span class=\"err\">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + ')')) + '</span>'; return; }\n var d = x.d;\n if (S.mode === 'semantic' && d.mode === 'keyword') {\n $('se-banner').innerHTML = '<div class=\"honest\" style=\"margin-top:18px\"><div class=\"h\">語意搜尋尚未啟用</div><div class=\"b\">語意搜尋用「意思」找資料,不是字面比對。<br>' + esc(d.capability_hint || '系統尚未開啟語意索引——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';\n }\n var entries = d.entries || [];\n $('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') + (d.note ? '・' + d.note : '');\n if (!entries.length) {\n $('se-results').innerHTML = '<div class=\"muted\" style=\"padding:30px 10px;text-align:center;grid-column:1/-1\">找不到「' + esc(q) + '」——換個關鍵字試試。</div>';\n return;\n }\n $('se-results').innerHTML = entries.map(function (e) {\n var src = entrySource(e);\n return '<div class=\"kcard\" data-card=\"' + esc(e.id) + '\">' +\n '<div class=\"kt\">' + esc(entryTitle(e)) + '</div>' +\n '<div class=\"ks\">' + esc(entrySnippet(e)) + '</div>' +\n '<div class=\"km\">' +\n '<span class=\"tag\">' + esc(e.entry_type || 'entry') + '</span>' +\n '<span class=\"tag green\">' + esc(entryLib(e)) + '</span>' +\n (src ? '<span class=\"dim\" style=\"word-break:break-all\">' + esc(src) + '</span>' : '') +\n '<span class=\"dim mono\" style=\"margin-left:auto\">' + esc(fmtDate(e.created_at)) + '</span></div></div>';\n }).join('');\n })\n .catch(function (e) { $('se-count').innerHTML = '<span class=\"err\">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });\n }\n $('se-results').addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-card]');\n if (t) nav('card', t.getAttribute('data-card'));\n });\n\n // ── AI 問答(portal-demo-suite)──\n // 設計哲學:AI 檢索=用戶手動搜尋同一套——同 session token、同 /portal/data/* enforce 面,\n // AI 只代查再作答;答案附 sources(mode+page),點 page 帶回搜尋框讓用戶自己驗證。\n $('ai-go').addEventListener('click', doAsk);\n $('ai-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doAsk(); });\n function doAsk() {\n var q = $('ai-q').value.trim();\n if (!q) { toast('請先輸入問題'); return; }\n $('ai-go').disabled = true;\n $('ai-out').innerHTML = '<div class=\"muted\" style=\"margin-top:12px\">AI 查閱知識庫中…</div>';\n fetch(API_BASE + '/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n $('ai-go').disabled = false;\n if (guard401(x.status)) return;\n if (!x.ok) {\n // 404=此實例未安裝問答 workflow(server 誠實回報,前端不假裝有 AI)\n $('ai-out').innerHTML = '<div class=\"honest\" style=\"margin-top:12px\"><div class=\"h\">問不到</div><div class=\"b\">' + esc(x.d.error || ('問答失敗(HTTP ' + x.status + ')')) + '</div></div>';\n return;\n }\n var srcs = x.d.sources || [];\n // answer 純文字渲染、保留換行(pre-wrap+esc,不走 markdown、防 XSS)\n var html = '<div class=\"panel\" style=\"margin-top:12px\">' +\n '<div style=\"white-space:pre-wrap;font-size:16px;line-height:1.85;word-break:break-word\">' + esc(x.d.answer || '(AI 沒有給出答案)') + '</div>';\n if (srcs.length) {\n html += '<div style=\"margin-top:14px;font-size:13px;letter-spacing:.1em;color:rgba(var(--ink-rgb),.5)\">出處(點頁名帶入搜尋框)</div>' +\n '<div style=\"margin-top:7px;display:flex;flex-direction:column;gap:6px\">' + srcs.map(function (s) {\n var page = (s && (s.page || s.page_name)) || '';\n var mode = (s && s.mode) || '';\n var href = s && s.source ? srcHref(s.source) : '';\n return '<div class=\"nbrow\" data-aisrc=\"' + esc(page) + '\">' +\n (mode ? '<span class=\"tag\" style=\"flex:none\">' + esc(mode) + '</span>' : '') +\n '<span style=\"word-break:break-word\">' + esc(page || '(無頁名)') + '</span>' +\n (href ? '<a href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\" style=\"flex:none;color:var(--amber);font-size:13px\" title=\"開啟來源檔\">來源 ↗</a>' : '') +\n '<span style=\"margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none\">›</span></div>';\n }).join('') + '</div>';\n }\n html += '</div>';\n $('ai-out').innerHTML = html;\n })\n .catch(function (e) { $('ai-go').disabled = false; $('ai-out').innerHTML = '<div class=\"err\" style=\"margin-top:12px\">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });\n }\n $('ai-out').addEventListener('click', function (ev) {\n if (ev.target.closest('a')) return; // 「來源 ↗」超連結交給瀏覽器,不觸發帶入搜尋框\n var t = ev.target.closest('[data-aisrc]');\n if (!t) return;\n var page = t.getAttribute('data-aisrc');\n if (!page) return;\n $('se-q').value = page;\n $('se-q').focus();\n window.scrollTo(0, 0);\n });\n\n // ── graph 模式(D-4 粗閘:無權者按鈕根本不顯示;就算 curl 直打 API 也是 403)──\n function doGraphSearch(name) {\n $('se-results').style.display = 'none';\n $('se-graphout').style.display = '';\n $('se-count').textContent = '查詢關聯中…';\n renderGraphEmpty('查詢關聯中…');\n fetch(API_BASE + '/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n if (guard401(x.status)) return;\n if (!x.ok) { $('se-count').innerHTML = '<span class=\"err\">' + esc(x.d.error || ('關聯查詢失敗(HTTP ' + x.status + ')')) + '</span>'; renderGraphEmpty(x.d.error || '關聯查詢失敗'); return; }\n var neighbors = x.d.neighbors || [];\n var edges = x.d.edges || [];\n // 形狀相容(Arcrun#57 家族):plugin 版 neighbors 是字串、workflow 版是\n // { node, predicate, from, depth } 物件——不正規化會渲染成 [object Object]。\n var names = neighbors.map(function (nb) {\n if (typeof nb === 'string') return nb;\n return (nb && (nb.node || nb.name)) || '';\n }).filter(Boolean);\n // workflow 版 edges 恆空,但 neighbor 物件本身就是一條邊(from --predicate--> node)→ 導出。\n if (!edges.length) {\n edges = neighbors.filter(function (nb) { return nb && typeof nb === 'object' && nb.predicate; })\n .map(function (nb) { return { subject: nb.from || name, predicate: nb.predicate, object: nb.node }; });\n }\n $('se-count').textContent = '節點「' + name + '」・鄰居 ' + names.length + '・關聯 ' + edges.length;\n if (!names.length) { renderGraphEmpty('尚無關聯資料——這個名字還沒有連進知識圖譜。'); return; }\n renderGraph(name, names.slice(0, 10));\n $('gr-edges').innerHTML = edges.slice(0, 12).map(function (t) {\n // 大小寫不敏感比對(graph 查詢已正規化:搜 rag 會命中節點 RAG)\n var other = String(t.subject).toLowerCase() === String(name).toLowerCase() ? t.object : t.subject;\n return '<div class=\"nbrow\" data-gnode=\"' + esc(other) + '\">' +\n '<span class=\"tag green\" style=\"flex:none\">' + esc(t.predicate || '關聯') + '</span>' +\n '<span style=\"word-break:break-word\">' + esc(other) + '</span>' +\n '<span style=\"margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none\">›</span></div>';\n }).join('');\n })\n .catch(function (e) { $('se-count').innerHTML = '<span class=\"err\">關聯服務不可達:' + esc(friendlyErr(e)) + '</span>'; renderGraphEmpty('關聯服務不可達'); });\n }\n function renderGraphEmpty(msg) {\n $('gr-box').innerHTML = '<div class=\"graphbox\" style=\"display:grid;place-items:center;height:160px\"><span class=\"muted\" style=\"font-size:14px;padding:0 20px;text-align:center\">' + esc(msg) + '</span></div>';\n $('gr-edges').innerHTML = '';\n }\n function renderGraph(center, nodes) {\n var n = nodes.length || 1;\n var lines = '', chips = '';\n for (var i = 0; i < n; i++) {\n var a = (-90 + i * (360 / n)) * Math.PI / 180;\n var x = 50 + 39 * Math.cos(a), y = 50 + 37 * Math.sin(a);\n lines += '<line x1=\"50\" y1=\"50\" x2=\"' + x.toFixed(2) + '\" y2=\"' + y.toFixed(2) + '\" stroke=\"rgba(var(--ok-rgb),.3)\" stroke-width=\"1\" vector-effect=\"non-scaling-stroke\" stroke-dasharray=\"4 4\"/>';\n chips += '<div class=\"gnode\" style=\"left:' + x.toFixed(2) + '%;top:' + y.toFixed(2) + '%\" data-gnode=\"' + esc(nodes[i]) + '\">' + esc(nodes[i]) + '</div>';\n }\n var shortCenter = center.length > 16 ? center.slice(0, 16) + '…' : center;\n $('gr-box').innerHTML = '<div class=\"graphbox\">' +\n '<svg viewBox=\"0 0 100 100\" preserveAspectRatio=\"none\" style=\"position:absolute;inset:0;width:100%;height:100%\">' + lines + '</svg>' +\n '<div class=\"gcenter\"><span>' + esc(shortCenter) + '</span></div>' + chips +\n '<div style=\"position:absolute;right:12px;bottom:10px;font-size:11.5px;color:rgba(var(--ink-rgb),.35)\">╌ 知識圖譜鄰居(點節點展開)</div></div>';\n }\n document.addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-gnode]');\n if (!t) return;\n var name = t.getAttribute('data-gnode');\n $('se-q').value = name;\n doGraphSearch(name);\n });\n\n // ── 卡片詳頁 ──\n function loadCard() {\n if (!S.cardId) { $('cd-main').innerHTML = '<div class=\"muted\">沒有指定卡片——從「搜尋」點一張進來。</div>'; return; }\n $('cd-main').innerHTML = '<div class=\"muted\">載入中…</div>';\n fetch(API_BASE + '/portal/data/entries/' + encodeURIComponent(S.cardId), { headers: authHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n if (guard401(x.status)) return;\n if (!x.ok || !x.d.entry) { $('cd-main').innerHTML = '<div class=\"err\">' + esc(x.d.error || '讀不到這張卡片') + '</div>'; return; }\n var e = x.d.entry;\n var src = entrySource(e);\n $('cd-main').innerHTML = '<div class=\"cardtitle\">' + esc(entryTitle(e)) + '</div>' +\n '<div style=\"display:flex;gap:10px;align-items:center;margin-top:12px;font-size:13.5px;flex-wrap:wrap\">' +\n '<span class=\"tag\">' + esc(e.entry_type || 'entry') + '</span>' +\n '<span class=\"tag green\">' + esc(entryLib(e)) + '</span>' +\n (e.page_name ? '<span class=\"tag dim\">' + esc(e.page_name) + '</span>' : '') +\n '<span class=\"muted mono\">' + esc(fmtDateTime(e.created_at)) + '</span></div>' +\n '<div class=\"md\">' + mdRender(e.content || '(此條目沒有內文)') + '</div>' +\n (src\n ? (function () {\n var href = srcHref(src);\n var body = href\n ? '<a class=\"mono\" style=\"font-size:14px;color:var(--amber);word-break:break-all\" href=\"' + esc(href) + '\" target=\"_blank\" rel=\"noopener\">' + esc(src) + ' ↗</a>'\n : '<span class=\"mono\" style=\"font-size:14px;color:var(--amber);word-break:break-all\">' + esc(src) + '</span>';\n return '<div class=\"srcline\"><span style=\"font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);flex:none\">來源回溯</span>' + body + '</div>';\n })()\n : '');\n })\n .catch(function (er) { $('cd-main').innerHTML = '<div class=\"err\">請求失敗:' + esc(friendlyErr(er)) + '</div>'; });\n }\n\n // ── 總圖(Arcrun#39 藏書地圖 GUI 切片)──\n // 全庫三元組一張網:/portal/data/graph/overview → 手刻力導向佈局(demo 量級,零外部套件)。\n // 佈局用固定種子的 PRNG=同一庫每次長一樣(客戶重整不會跳位)。\n function jumpToGraph(name) {\n S.mode = 'graph';\n document.querySelectorAll('.modebtn').forEach(function (x) { x.classList.toggle('on', x.getAttribute('data-mode') === 'graph'); });\n nav('search');\n $('se-q').value = name;\n doGraphSearch(name);\n }\n function loadMap() {\n $('map-box').innerHTML = '<div class=\"muted\" style=\"padding:30px 10px\">載入總圖中…</div>';\n $('map-md-link').innerHTML = SOURCE_WEB_BASE\n ? ':<a href=\"' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '\" target=\"_blank\" rel=\"noopener\" style=\"color:var(--amber)\">00-MAP.md ↗</a>'\n : '存在知識庫的 00-MAP.md';\n fetch(API_BASE + '/portal/data/graph/overview', { headers: authHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n if (guard401(x.status)) return;\n if (!x.ok) { $('map-box').innerHTML = '<div class=\"err\">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')')) + '</div>'; return; }\n var nodes = x.d.nodes || [];\n var edges = x.d.edges || [];\n $('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : '');\n if (!nodes.length) { $('map-box').innerHTML = '<div class=\"muted\" style=\"padding:30px 10px\">知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。</div>'; return; }\n renderMap(nodes, edges);\n })\n .catch(function (e) { $('map-box').innerHTML = '<div class=\"err\">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });\n }\n function renderMap(nodes, edges) {\n var N = nodes.length;\n var idx = {};\n nodes.forEach(function (n, i) { idx[n.name] = i; });\n var el = [];\n edges.forEach(function (e) {\n var a = idx[e.subject], b = idx[e.object];\n if (a == null || b == null || a === b) return;\n el.push([a, b, e.predicate || '']);\n });\n // 固定種子 PRNG(mulberry32):確定性佈局\n var seed = 42;\n function rnd() {\n seed = (seed + 0x6D2B79F5) | 0;\n var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n }\n var W = 1000, H = Math.max(480, Math.min(760, 200 + N * 14));\n var px = [], py = [];\n for (var i = 0; i < N; i++) {\n var ang = 2 * Math.PI * i / N;\n var rad = Math.min(W, H) * (0.22 + 0.2 * rnd());\n px.push(W / 2 + rad * Math.cos(ang));\n py.push(H / 2 + rad * Math.sin(ang) * 0.75);\n }\n // 力導向:斥力(全對)+彈簧(邊)+向心,260 輪退火\n var K = Math.max(75, Math.sqrt(W * H / Math.max(N, 1)) * 0.9);\n for (var it = 0; it < 260; it++) {\n var cool = Math.max(0.06, 1 - it / 260);\n var fx = [], fy = [];\n for (var a1 = 0; a1 < N; a1++) { fx.push(0); fy.push(0); }\n for (var a2 = 0; a2 < N; a2++) {\n for (var b2 = a2 + 1; b2 < N; b2++) {\n var dx = px[a2] - px[b2], dy = py[a2] - py[b2];\n var d2 = dx * dx + dy * dy + 0.01;\n var f = Math.min(28, (K * K) / d2 * 2.2);\n var d = Math.sqrt(d2);\n fx[a2] += dx / d * f; fy[a2] += dy / d * f;\n fx[b2] -= dx / d * f; fy[b2] -= dy / d * f;\n }\n }\n el.forEach(function (e) {\n var dx = px[e[0]] - px[e[1]], dy = py[e[0]] - py[e[1]];\n var d = Math.sqrt(dx * dx + dy * dy) + 0.01;\n var f = (d - K) * 0.045;\n fx[e[0]] -= dx / d * f; fy[e[0]] -= dy / d * f;\n fx[e[1]] += dx / d * f; fy[e[1]] += dy / d * f;\n });\n for (var m = 0; m < N; m++) {\n fx[m] += (W / 2 - px[m]) * 0.012;\n fy[m] += (H / 2 - py[m]) * 0.012;\n px[m] += Math.max(-14, Math.min(14, fx[m])) * cool;\n py[m] += Math.max(-14, Math.min(14, fy[m])) * cool;\n }\n }\n // 視窗貼合\n var minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;\n for (var q = 0; q < N; q++) {\n minX = Math.min(minX, px[q]); maxX = Math.max(maxX, px[q]);\n minY = Math.min(minY, py[q]); maxY = Math.max(maxY, py[q]);\n }\n var PAD = 70;\n var vb = (minX - PAD) + ' ' + (minY - PAD) + ' ' + (maxX - minX + PAD * 2) + ' ' + (maxY - minY + PAD * 2);\n var maxDeg = 1;\n nodes.forEach(function (n) { maxDeg = Math.max(maxDeg, n.degree || 1); });\n var svg = '<svg viewBox=\"' + vb + '\" preserveAspectRatio=\"xMidYMid meet\">';\n el.forEach(function (e) {\n svg += '<line class=\"mapedge\" x1=\"' + px[e[0]].toFixed(1) + '\" y1=\"' + py[e[0]].toFixed(1) +\n '\" x2=\"' + px[e[1]].toFixed(1) + '\" y2=\"' + py[e[1]].toFixed(1) + '\"><title>' +\n esc(nodes[e[0]].name + ' —' + (e[2] || '關聯') + '→ ' + nodes[e[1]].name) + '</title></line>';\n });\n nodes.forEach(function (n, i2) {\n var deg = n.degree || 1;\n var r = 9 + Math.sqrt(deg) * 5;\n var hub = deg >= Math.max(3, maxDeg * 0.6) ? ' hub' : '';\n svg += '<g class=\"mapnode' + hub + '\" data-mapnode=\"' + esc(n.name) + '\">' +\n '<circle cx=\"' + px[i2].toFixed(1) + '\" cy=\"' + py[i2].toFixed(1) + '\" r=\"' + r.toFixed(1) + '\"><title>' + esc(n.name + '・' + deg + ' 條關聯') + '</title></circle>' +\n '<text x=\"' + px[i2].toFixed(1) + '\" y=\"' + (py[i2] + r + 15).toFixed(1) + '\" text-anchor=\"middle\">' + esc(n.name.length > 14 ? n.name.slice(0, 14) + '…' : n.name) + '</text></g>';\n });\n svg += '</svg>';\n $('map-box').innerHTML = '<div class=\"mapbox\">' + svg +\n '<div style=\"position:absolute;right:12px;bottom:10px;font-size:11.5px;color:rgba(var(--ink-rgb),.35)\">╌ 知識庫總圖(點節點看鄰居)</div></div>';\n }\n document.addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-mapnode]');\n if (!t) return;\n jumpToGraph(t.getAttribute('data-mapnode'));\n });\n\n // ── 工作流(唯讀)──\n function loadWorkflows() {\n $('wf-list').innerHTML = '<div class=\"muted\">載入中…</div>';\n fetch(API_BASE + '/portal/data/workflows', { headers: authHeaders() })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n if (guard401(x.status)) return;\n if (!x.ok) { $('wf-list').innerHTML = '<div class=\"err\">' + esc(x.d.error || ('讀取失敗(HTTP ' + x.status + ')')) + '</div>'; return; }\n var wfs = x.d.workflows || [];\n if (!wfs.length) { $('wf-list').innerHTML = '<div class=\"muted\" style=\"padding:20px 4px\">目前沒有任何工作流。</div>'; return; }\n $('wf-list').innerHTML = wfs.map(function (w) {\n var lastHtml = '<span class=\"dim\">尚無執行紀錄</span>';\n if (w.last_execution) {\n var v = w.last_execution.verdict || '';\n var cls = v === 'success' ? 'ok' : (v === 'failed' ? 'err' : 'muted');\n lastHtml = '<span class=\"' + cls + '\">' + esc(v || '(無 verdict)') + '</span> <span class=\"dim mono\">' + esc(fmtDateTime(Number(w.last_execution.timestamp))) + '</span>';\n }\n return '<div class=\"card-item\">' +\n '<div style=\"display:flex;align-items:baseline;gap:10px;flex-wrap:wrap\">' +\n '<span class=\"serif\" style=\"font-size:17px;font-weight:600\">' + esc(w.name) + '</span>' +\n (w.cron_expr ? '<span class=\"tag dim mono\">' + esc(w.cron_expr) + '</span>' : '') +\n '<span style=\"margin-left:auto;font-size:13.5px\">' + lastHtml + '</span></div>' +\n (w.description ? '<div style=\"margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)\">' + esc(w.description) + '</div>' : '<div class=\"dim\" style=\"margin-top:8px;font-size:13.5px\">(沒有描述)</div>') +\n '</div>';\n }).join('');\n })\n .catch(function (e) { $('wf-list').innerHTML = '<div class=\"err\">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });\n }\n\n // ── 上傳頁(portal-demo-suite)──\n // 前端只讀檔轉 base64 逐檔 POST /portal/data/upload;Gitea 位置與 token 全在 server 側,\n // 這裡看不到任何 repo/token 字串。逐檔獨立狀態列(一檔失敗不拖累其他檔)。\n var UP_OK_MSG = '已收件,約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。';\n function b64FromDataUrl(dataUrl) {\n var i = String(dataUrl).indexOf(',');\n return i >= 0 ? String(dataUrl).slice(i + 1) : '';\n }\n function uploadStatusRow(name) {\n var row = document.createElement('div');\n row.className = 'card-item';\n row.innerHTML = '<div style=\"display:flex;align-items:baseline;gap:10px;flex-wrap:wrap\">' +\n '<span class=\"mono\" style=\"font-size:14.5px;word-break:break-all\">' + esc(name) + '</span>' +\n '<span class=\"up-st muted\" style=\"margin-left:auto;font-size:14px;flex:none\">讀取中…</span></div>' +\n '<div class=\"up-msg\" style=\"margin-top:6px;font-size:13.5px;line-height:1.65;color:rgba(var(--ink-rgb),.6)\"></div>';\n $('up-list').prepend(row);\n return row;\n }\n function setRowStatus(row, ok, statusText, msg) {\n var st = row.querySelector('.up-st');\n st.textContent = statusText;\n st.className = 'up-st ' + (ok === true ? 'ok' : (ok === false ? 'err' : 'muted'));\n row.querySelector('.up-msg').textContent = msg || '';\n }\n function uploadOneFile(file) {\n var row = uploadStatusRow(file.name);\n if (!/\\.(md|txt)$/i.test(file.name)) {\n setRowStatus(row, false, '已略過', '只收 .md / .txt 檔。');\n return;\n }\n var reader = new FileReader();\n reader.onerror = function () { setRowStatus(row, false, '讀檔失敗', '瀏覽器讀不到這個檔案。'); };\n reader.onload = function () {\n setRowStatus(row, null, '上傳中…', '');\n fetch(API_BASE + '/portal/data/upload', {\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),\n body: JSON.stringify({ filename: file.name, content_b64: b64FromDataUrl(reader.result) })\n })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n if (guard401(x.status)) return;\n if (!x.ok) { setRowStatus(row, false, '失敗', x.d.error || ('HTTP ' + x.status)); return; }\n setRowStatus(row, true, '已收件', UP_OK_MSG + (x.d.path ? '(' + x.d.path + ')' : ''));\n })\n .catch(function (e) { setRowStatus(row, false, '失敗', friendlyErr(e)); });\n };\n reader.readAsDataURL(file); // dataURL 尾段即 base64(unicode 安全,免手刻 btoa 編碼)\n }\n function handleUploadFiles(files) {\n for (var i = 0; i < files.length; i++) uploadOneFile(files[i]);\n }\n $('up-pick').addEventListener('click', function (ev) { ev.stopPropagation(); $('up-file').click(); });\n $('up-drop').addEventListener('click', function () { $('up-file').click(); });\n $('up-file').addEventListener('change', function () { handleUploadFiles(this.files); this.value = ''; });\n ['dragover', 'dragenter'].forEach(function (t) {\n $('up-drop').addEventListener(t, function (ev) { ev.preventDefault(); ev.currentTarget.style.background = 'rgba(var(--amber-rgb),.12)'; });\n });\n ['dragleave', 'drop'].forEach(function (t) {\n $('up-drop').addEventListener(t, function (ev) { ev.preventDefault(); ev.currentTarget.style.background = 'rgba(var(--amber-rgb),.04)'; });\n });\n $('up-drop').addEventListener('drop', function (ev) {\n if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) handleUploadFiles(ev.dataTransfer.files);\n });\n\n // ── 管理頁(P4:帳號管理+庫目錄管理。全走 /portal/admin/*,前端零業務邏輯;\n // 一般用戶 nav 不顯示,且 server role 閘 403——藏只是 UX,真閘在後端)──\n var adminLibs = []; // 庫目錄快取(渲染每帳號的庫勾選用)\n\n function adminApi(method, path, body) {\n path = API_BASE + path;\n var opt = { method: method, headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()) };\n if (body !== undefined) opt.body = JSON.stringify(body);\n return fetch(path, opt).then(function (r) {\n return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; });\n });\n }\n\n function loadAdmin() {\n $('ad-users').innerHTML = '<div class=\"muted\">載入中…</div>';\n $('ad-libs').innerHTML = '<div class=\"muted\">載入中…</div>';\n adminApi('GET', '/portal/admin/libraries')\n .then(function (x) {\n if (guard401(x.status)) return;\n if (!x.ok) { $('ad-libs').innerHTML = '<div class=\"err\">' + esc(x.d.error || ('讀取失敗(HTTP ' + x.status + ')')) + '</div>'; return; }\n adminLibs = x.d.libraries || [];\n renderAdminLibs();\n return adminApi('GET', '/portal/admin/users').then(function (y) {\n if (guard401(y.status)) return;\n if (!y.ok) { $('ad-users').innerHTML = '<div class=\"err\">' + esc(y.d.error || ('讀取失敗(HTTP ' + y.status + ')')) + '</div>'; return; }\n renderAdminUsers(y.d.users || []);\n });\n })\n .catch(function (e) {\n $('ad-users').innerHTML = '<div class=\"err\">請求失敗:' + esc(friendlyErr(e)) + '</div>';\n $('ad-libs').innerHTML = '';\n });\n }\n\n // 帳號可勾選的庫=庫目錄啟用中的庫 ∪ general ∪ 該帳號已有的庫(目錄外的舊授權不憑空消失)\n function checkableLibs(userLibs) {\n var names = ['general'];\n adminLibs.forEach(function (l) { if (l.status !== 'disabled' && l.name && names.indexOf(l.name) < 0) names.push(l.name); });\n (userLibs || []).forEach(function (n) { if (n !== '*' && names.indexOf(n) < 0) names.push(n); });\n return names;\n }\n function libDisplay(name) {\n for (var i = 0; i < adminLibs.length; i++) { if (adminLibs[i].name === name) return adminLibs[i].display_name || name; }\n return name;\n }\n\n function renderAdminUsers(users) {\n if (!users.length) { $('ad-users').innerHTML = '<div class=\"muted\" style=\"padding:16px 4px\">還沒有任何同仁帳號——用上面的表單新增第一個。</div>'; return; }\n $('ad-users').innerHTML = users.map(function (u) {\n var star = (u.libraries || []).indexOf('*') >= 0;\n var disabled = u.status === 'disabled';\n var libChips = star\n ? '<span class=\"tag green\">全部知識庫</span>'\n : ((u.libraries || []).length\n ? (u.libraries || []).map(function (n) { return '<span class=\"tag green\">' + esc(libDisplay(n)) + '</span>'; }).join(' ')\n : '<span class=\"tag dim\">尚未授權任何庫</span>');\n var boxes = '<label><input type=\"checkbox\" data-star data-uid=\"' + esc(u.record_id) + '\"' + (star ? ' checked' : '') + '> 全部知識庫(*)</label>' +\n checkableLibs(u.libraries).map(function (n) {\n var on = !star && (u.libraries || []).indexOf(n) >= 0;\n return '<label><input type=\"checkbox\" data-lib=\"' + esc(n) + '\" data-uid=\"' + esc(u.record_id) + '\"' + (on ? ' checked' : '') + (star ? ' disabled' : '') + '> ' + esc(libDisplay(n)) + ' <span class=\"dim mono\" style=\"font-size:12px\">' + esc(n) + '</span></label>';\n }).join('');\n return '<div class=\"card-item\"' + (disabled ? ' style=\"opacity:.6\"' : '') + '>' +\n '<div style=\"display:flex;align-items:baseline;gap:10px;flex-wrap:wrap\">' +\n '<span class=\"serif\" style=\"font-size:17px;font-weight:600\">' + esc(u.display_name || u.email) + '</span>' +\n '<span class=\"muted\" style=\"font-size:14px;word-break:break-all\">' + esc(u.email) + '</span>' +\n '<span class=\"tag' + (u.role === 'admin' ? '' : ' dim') + '\">' + (u.role === 'admin' ? '管理員' : '一般用戶') + '</span>' +\n '<span class=\"tag ' + (disabled ? 'dim' : 'green') + '\">' + (disabled ? '已停用' : '啟用中') + '</span></div>' +\n '<div style=\"margin-top:9px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:13px\"><span class=\"muted\" style=\"flex:none\">可查庫</span>' + libChips + '</div>' +\n '<div class=\"btnrow\">' +\n '<button class=\"btn2\" data-act=\"edit-libs\" data-uid=\"' + esc(u.record_id) + '\">編輯可查庫</button>' +\n '<button class=\"btn2\" data-act=\"reset-pw\" data-uid=\"' + esc(u.record_id) + '\" data-email=\"' + esc(u.email) + '\">重設密碼</button>' +\n (disabled\n ? '<button class=\"btn2\" data-act=\"set-status\" data-next=\"active\" data-uid=\"' + esc(u.record_id) + '\">啟用</button>'\n : '<button class=\"btn2 warn\" data-act=\"set-status\" data-next=\"disabled\" data-uid=\"' + esc(u.record_id) + '\" data-email=\"' + esc(u.email) + '\">停用</button>') +\n '</div>' +\n '<div id=\"libed-' + esc(u.record_id) + '\" style=\"display:none\">' +\n '<div class=\"libgrid\">' + boxes + '</div>' +\n '<div class=\"btnrow\"><button class=\"btn2\" data-act=\"save-libs\" data-uid=\"' + esc(u.record_id) + '\">儲存庫權限</button>' +\n '<span class=\"dim\" style=\"font-size:13px;align-self:center\">勾「全部知識庫」=不限庫;否則至少勾一個。儲存後立即生效。</span></div></div>' +\n '</div>';\n }).join('');\n }\n\n function renderAdminLibs() {\n if (!adminLibs.length) { $('ad-libs').innerHTML = '<div class=\"muted\" style=\"padding:16px 4px\">還沒有登記任何庫。未登記時整個系統視同單一 general 庫。</div>'; return; }\n $('ad-libs').innerHTML = adminLibs.map(function (l) {\n var disabled = l.status === 'disabled';\n return '<div class=\"card-item\"' + (disabled ? ' style=\"opacity:.6\"' : '') + '>' +\n '<div style=\"display:flex;align-items:baseline;gap:10px;flex-wrap:wrap\">' +\n '<span class=\"serif\" style=\"font-size:17px;font-weight:600\">' + esc(l.display_name || l.name) + '</span>' +\n '<span class=\"dim mono\" style=\"font-size:13px\">' + esc(l.name) + '</span>' +\n '<span class=\"tag ' + (disabled ? 'dim' : 'green') + '\">' + (disabled ? '已停用' : '啟用中') + '</span>' +\n (l.graph_source ? '<span class=\"tag\">圖譜來源</span>' : '') + '</div>' +\n (l.description ? '<div style=\"margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)\">' + esc(l.description) + '</div>' : '') +\n '<div class=\"btnrow\">' +\n '<button class=\"btn2\" data-act=\"lib-graph\" data-lid=\"' + esc(l.record_id) + '\" data-next=\"' + (l.graph_source ? 'false' : 'true') + '\">' + (l.graph_source ? '取消圖譜來源' : '標為圖譜來源') + '</button>' +\n (disabled\n ? '<button class=\"btn2\" data-act=\"lib-status\" data-next=\"active\" data-lid=\"' + esc(l.record_id) + '\">啟用</button>'\n : '<button class=\"btn2 warn\" data-act=\"lib-status\" data-next=\"disabled\" data-lid=\"' + esc(l.record_id) + '\" data-name=\"' + esc(l.display_name || l.name) + '\">停用</button>') +\n '</div></div>';\n }).join('');\n }\n\n // 一次性密碼顯示(server 不留明碼——關掉就真的沒了,誠實跟 admin 說清楚)\n function showOtp(email, password) {\n $('ad-otp').innerHTML = '<div class=\"otpbox\">' +\n '<div style=\"font-size:15px;font-weight:600\">' + esc(email) + ' 的一次性密碼</div>' +\n '<div class=\"pw\">' + esc(password) + '</div>' +\n '<div style=\"font-size:13.5px;line-height:1.7;color:rgba(var(--ink-rgb),.65)\">只顯示這一次——關閉後再也調不出來(系統只存雜湊)。請當場轉交同仁,並提醒登入後到「設定」改成自己的密碼。</div>' +\n '<div class=\"btnrow\"><button class=\"btn2\" id=\"ad-otp-close\">我已轉交,關閉</button></div></div>';\n $('ad-otp').scrollIntoView({ block: 'nearest' });\n }\n\n // 新增帳號\n $('ad-nu-create').addEventListener('click', function () {\n var email = $('ad-nu-email').value.trim();\n var name = $('ad-nu-name').value.trim();\n var role = $('ad-nu-role').value === 'admin' ? 'admin' : 'user';\n var st = $('ad-nu-status');\n if (!email) { st.textContent = '請填 Email'; return; }\n st.textContent = '';\n $('ad-nu-create').disabled = true;\n var body = { email: email, role: role };\n if (name) body.display_name = name;\n adminApi('POST', '/portal/admin/users', body)\n .then(function (x) {\n $('ad-nu-create').disabled = false;\n if (guard401(x.status)) return;\n if (!x.ok) { st.textContent = x.d.error || ('建立失敗(HTTP ' + x.status + ')'); return; }\n $('ad-nu-email').value = ''; $('ad-nu-name').value = ''; $('ad-nu-role').value = 'user';\n if (x.d.generated_password) showOtp(email, x.d.generated_password);\n toast('帳號已建立');\n loadAdmin();\n })\n .catch(function (e) { $('ad-nu-create').disabled = false; st.textContent = friendlyErr(e); });\n });\n\n // 登記新庫\n $('ad-nl-create').addEventListener('click', function () {\n var name = $('ad-nl-name').value.trim();\n var st = $('ad-nl-status');\n if (!name) { st.textContent = '請填庫名(英數 _ -)'; return; }\n st.textContent = '';\n $('ad-nl-create').disabled = true;\n adminApi('POST', '/portal/admin/libraries', {\n name: name,\n display_name: $('ad-nl-display').value.trim(),\n description: $('ad-nl-desc').value.trim()\n })\n .then(function (x) {\n $('ad-nl-create').disabled = false;\n if (guard401(x.status)) return;\n if (!x.ok) { st.textContent = x.d.error || ('登記失敗(HTTP ' + x.status + ')'); return; }\n $('ad-nl-name').value = ''; $('ad-nl-display').value = ''; $('ad-nl-desc').value = '';\n toast('庫已登記');\n loadAdmin();\n })\n .catch(function (e) { $('ad-nl-create').disabled = false; st.textContent = friendlyErr(e); });\n });\n\n // 「全部知識庫」勾選 → 其餘庫勾選框連動停用(存的就是 [\"*\"],個別勾選無意義)\n document.addEventListener('change', function (ev) {\n var t = ev.target;\n if (!t || !t.hasAttribute || !t.hasAttribute('data-star')) return;\n var uid = t.getAttribute('data-uid');\n document.querySelectorAll('#libed-' + uid + ' input[data-lib]').forEach(function (cb) {\n cb.disabled = t.checked;\n if (t.checked) cb.checked = false;\n });\n });\n\n // 管理頁動作(事件委派)\n $('v-admin').addEventListener('click', function (ev) {\n var t = ev.target.closest('[data-act]');\n if (!t) return;\n var act = t.getAttribute('data-act');\n var uid = t.getAttribute('data-uid');\n\n if (act === 'edit-libs') {\n var ed = $('libed-' + uid);\n ed.style.display = ed.style.display === 'none' ? '' : 'none';\n return;\n }\n if (act === 'save-libs') {\n var star = document.querySelector('#libed-' + uid + ' input[data-star]');\n var libs;\n if (star && star.checked) { libs = ['*']; }\n else {\n libs = [];\n document.querySelectorAll('#libed-' + uid + ' input[data-lib]').forEach(function (cb) {\n if (cb.checked) libs.push(cb.getAttribute('data-lib'));\n });\n if (!libs.length) { toast('至少勾一個庫,或勾「全部知識庫」'); return; }\n }\n t.disabled = true;\n adminApi('PATCH', '/portal/admin/users/' + encodeURIComponent(uid), { libraries: libs })\n .then(function (x) {\n t.disabled = false;\n if (guard401(x.status)) return;\n if (!x.ok) { toast(x.d.error || ('儲存失敗(HTTP ' + x.status + ')')); return; }\n toast('庫權限已更新,立即生效');\n loadAdmin();\n })\n .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });\n return;\n }\n if (act === 'set-status') {\n var next = t.getAttribute('data-next');\n if (next === 'disabled' && !confirm('確定停用 ' + (t.getAttribute('data-email') || '這個帳號') + '?停用後其登入與既有 session 立即失效。')) return;\n t.disabled = true;\n adminApi('PATCH', '/portal/admin/users/' + encodeURIComponent(uid), { status: next })\n .then(function (x) {\n t.disabled = false;\n if (guard401(x.status)) return;\n if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + ')')); return; } // 含最後一個 admin 的 409 訊息\n toast(next === 'disabled' ? '已停用' : '已啟用');\n loadAdmin();\n })\n .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });\n return;\n }\n if (act === 'reset-pw') {\n var email = t.getAttribute('data-email') || '';\n if (!confirm('確定重設 ' + email + ' 的密碼?舊密碼將立即失效,會產生一組新的一次性密碼。')) return;\n t.disabled = true;\n adminApi('POST', '/portal/admin/users/' + encodeURIComponent(uid) + '/reset-password')\n .then(function (x) {\n t.disabled = false;\n if (guard401(x.status)) return;\n if (!x.ok) { toast(x.d.error || ('重設失敗(HTTP ' + x.status + ')')); return; }\n showOtp(email, x.d.password);\n toast('密碼已重設');\n })\n .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });\n return;\n }\n if (act === 'lib-status' || act === 'lib-graph') {\n var lid = t.getAttribute('data-lid');\n var body2 = {};\n if (act === 'lib-status') {\n var nextS = t.getAttribute('data-next');\n if (nextS === 'disabled' && !confirm('確定停用庫「' + (t.getAttribute('data-name') || '') + '」?停用後不再出現在庫勾選清單(既有授權不會被自動改)。')) return;\n body2.status = nextS;\n } else {\n body2.graph_source = t.getAttribute('data-next') === 'true';\n }\n t.disabled = true;\n adminApi('PATCH', '/portal/admin/libraries/' + encodeURIComponent(lid), body2)\n .then(function (x) {\n t.disabled = false;\n if (guard401(x.status)) return;\n if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + ')')); return; }\n toast('庫已更新');\n loadAdmin();\n })\n .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });\n return;\n }\n });\n // 一次性密碼關閉鈕(otpbox 動態生成,掛在容器上)\n $('ad-otp').addEventListener('click', function (ev) {\n if (ev.target && ev.target.id === 'ad-otp-close') $('ad-otp').innerHTML = '';\n });\n\n // ── 設定 ──\n function loadSettings() {\n var p = S.profile;\n if (!p) { $('st-me').innerHTML = '<div class=\"muted\">載入中…</div>'; return; }\n var libs = p.libraries || [];\n var libHtml = libs.indexOf('*') >= 0\n ? '<span class=\"tag green\">全部知識庫</span>'\n : (libs.length ? libs.map(function (l) { return '<span class=\"tag green\">' + esc(l) + '</span>'; }).join(' ') : '<span class=\"tag dim\">尚未授權任何庫</span>');\n $('st-me').innerHTML =\n '<div class=\"kvline\"><span class=\"muted\">名稱</span><span>' + esc(p.display_name || '') + '</span></div>' +\n '<div class=\"kvline\"><span class=\"muted\">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' +\n '<div class=\"kvline\" style=\"align-items:flex-start\"><span class=\"muted\" style=\"flex:none\">可查庫</span><span style=\"text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end\">' + libHtml + '</span></div>';\n }\n $('st-pw-save').addEventListener('click', function () {\n var oldPw = $('st-pw-old').value;\n var newPw = $('st-pw-new').value;\n var newPw2 = $('st-pw-new2').value;\n var st = $('st-pw-status');\n st.className = 'err';\n if (!oldPw || !newPw) { st.textContent = '請填舊密碼與新密碼'; return; }\n if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; }\n if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }\n $('st-pw-save').disabled = true;\n st.textContent = '';\n fetch(API_BASE + '/portal/me/password', {\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),\n body: JSON.stringify({ current: oldPw, 'new': newPw })\n })\n .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })\n .then(function (x) {\n $('st-pw-save').disabled = false;\n if (guard401(x.status)) return;\n if (!x.ok) { st.textContent = x.d.error || '更新失敗'; return; }\n st.className = 'ok';\n st.textContent = '密碼已更新';\n $('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';\n toast('密碼已更新');\n })\n .catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); });\n });\n\n boot();\n})();\n</script>\n</body>\n</html>"}};
|
||
function b64ToBytes(b64) { const bin = atob(b64); const a = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) a[i] = bin.charCodeAt(i); return a; }
|
||
export default {
|
||
async fetch(request, env) {
|
||
const url = new URL(request.url);
|
||
let p = url.pathname;
|
||
// config.js 動態產生:apiBase 指向同帳號的 cypher(WORKER_SUBDOMAIN 由安裝器注入)
|
||
if (p === '/config.js') {
|
||
const sub = env.WORKER_SUBDOMAIN || '';
|
||
const apiBase = sub ? `https://arcrun-cypher-executor.${sub}.workers.dev` : '';
|
||
return new Response(`window.ARCRUN_CONFIG = { apiBase: ${JSON.stringify(apiBase)} };`, { headers: { 'content-type': TYPES_JS } });
|
||
}
|
||
if (p.endsWith('/')) p += 'index.html';
|
||
let f = FILES[p] || FILES[p + '/index.html'] || FILES[p + '.html'];
|
||
if (!f && !p.includes('.')) f = FILES[p + '/index.html'];
|
||
if (!f) { p = '/portal/index.html'; f = FILES[p]; } // SPA fallback→portal
|
||
if (!f) return new Response('not found', { status: 404 });
|
||
const body = f.b64 ? b64ToBytes(f.data) : f.data;
|
||
return new Response(body, { headers: { 'content-type': f.type, 'cache-control': 'no-cache' } });
|
||
},
|
||
};
|
||
const TYPES_JS = 'application/javascript; charset=utf-8';
|