refactor(cypher): 第一刀拆分——UI 搬 CF Pages,ingest 從必爆變穩定
根因(leo 2026-07-21 三個判斷全被數字證實): - 「cypher 展開上萬行,當時我就懷疑這樣跑得動嗎」→ 15,446 行、bundle 748KB - 「不相信 CF 連讀文字檔都會撞牆」→ 不是 CF 的問題,是 cypher 太肥 - 「它違反了樂高化的原則」→ 零件 4KB,調度它們的 cypher 是巨石 定位錯誤(比效能更根本):console.ts:1 自陳「Mira Console」,而 wiki 明載 「Mira 是 Arcrun 的使用者,不是開發者」→ 使用者的前端寫進了框架的執行引擎。 CLI/MCP 都已拆成獨立薄殼,唯獨 UI 沒有。責任誠實記:console.ts 標 「2026-07-04 總管派工」,是總管當初選了「就近寫在 cypher」的方便路。 本刀: - console.ts(1623行) + portal-ui.ts(1390行) 移出 → console-ui/ CF Pages 專案 (單檔 HTML + 原生 JS,零 build step,保持原特性) - index.ts 移除兩個路由掛載、修 implicit any - console-dashboard.ts 只留 API(UI 部分移出) 實測驗收(leo 指定判準:不看 /health,看真實任務): | 測試 | 拆分前 | 拆分後 | |---|---|---| | 連灌 10 張新卡(無間隔) | 從未成功 | 10 張全 create、73 條三元組 | | 同卡連跑 6 次 | 前 2 過、後 4 全 1102 | 6 次全過 | | Bundle | 747.9 KB | 528.0 KB(-29%) | Pages 已上線:arcrun-console-ui.pages.dev(console/portal 皆 200) cypher API 正常:/health、/console/dashboard-data 皆 200 未做(第二刀):console-dashboard/portal/portal-data 尚未拆,cypher 仍 528KB。 但 ingest 已穩定,急迫性下降。 註:部署撞到三個既有 self-hosted 陷阱(KV/D1 id 是官方帳號的、arcrun.dev route leo21c 無該 zone),用 wrangler.leo21c.toml 繞過,未改原始設定檔。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "arcrun-console-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Arcrun Console / Portal 靜態前端(Cloudflare Pages)——從 cypher-executor 搬出的 UI 層",
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"deploy": "npm run build && wrangler pages deploy public --project-name arcrun-console-ui",
|
||||
"preview": "npm run build && npx serve public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
|
||||
window.ARCRUN_CONFIG = { apiBase: "" };
|
||||
@@ -0,0 +1,295 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Arcrun 駕駛艙</title>
|
||||
<script>
|
||||
// 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),與 /console 共用同一 localStorage key
|
||||
document.documentElement.setAttribute('data-theme', (function () {
|
||||
try { return localStorage.getItem('arcrun_console_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }
|
||||
})());
|
||||
</script>
|
||||
<style>
|
||||
/* Mira Console 定稿視覺(紙感「2a」,Mira Style Guide 2026-07-04):
|
||||
紙紋底 repeating-linear-gradient、明體標題級聯、琥珀強調、呼吸狀態球嵌單字。
|
||||
2026-07-04 二輪:CSS custom properties 兩份色板——預設淺色(宣紙米白+墨字),深色=原定稿暖黑不動。 */
|
||||
* { box-sizing: border-box; }
|
||||
:root {
|
||||
--paper-a: #f4eddc; --paper-b: #f1e9d6;
|
||||
--ink: #2f2a20; --ink-rgb: 30,24,14;
|
||||
--amber: #8a5f1e; --amber-rgb: 138,95,30;
|
||||
--ok: #1d7a48; --ok-rgb: 29,122,72;
|
||||
--err: #b03a26; --err-rgb: 176,58,38;
|
||||
--track: rgba(30,24,14,.12);
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--paper-a: #191410; --paper-b: #1b1611;
|
||||
--ink: #ede4d3; --ink-rgb: 237,228,211;
|
||||
--amber: #e8b45a; --amber-rgb: 232,180,90;
|
||||
--ok: #7fe0a8; --ok-rgb: 63,190,120;
|
||||
--err: #e58575; --err-rgb: 217,95,76;
|
||||
--track: rgba(255,255,255,.08);
|
||||
}
|
||||
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);
|
||||
font-family: -apple-system, "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }
|
||||
.serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }
|
||||
main { max-width: 560px; margin: 0 auto; padding: 0 20px 40px; }
|
||||
.pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(var(--amber-rgb),.4); display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.pagehead .title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; }
|
||||
.pagehead .title small { font-size: 14px; letter-spacing: .3em; color: rgba(var(--ink-rgb),.5); }
|
||||
.pagehead .date { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 14px; color: rgba(var(--ink-rgb),.55); }
|
||||
.orb-row { display: flex; align-items: center; gap: 20px; padding: 26px 2px 20px; }
|
||||
.orb { width: 84px; height: 84px; border-radius: 50%; flex: none; display: grid; place-items: center; }
|
||||
.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); }
|
||||
.orb-title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; font-weight: 600; }
|
||||
.orb-sub { margin-top: 5px; font-size: 15px; color: rgba(var(--ink-rgb),.6); line-height: 1.55; }
|
||||
@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); } }
|
||||
@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); } }
|
||||
@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); } }
|
||||
.bricks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.brick { padding: 16px; border-radius: 12px; }
|
||||
.brick.amber { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.22); }
|
||||
.brick.plain { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.14); }
|
||||
.brick .lbl { font-size: 13.5px; color: rgba(var(--ink-rgb),.55); margin-bottom: 6px; }
|
||||
.brick .num { font-family: ui-monospace, Menlo, monospace; font-size: 26px; color: var(--amber); }
|
||||
.brick .num small { font-size: 15px; color: rgba(var(--ink-rgb),.5); }
|
||||
.bar { margin-top: 10px; height: 6px; border-radius: 3px; background: var(--track); }
|
||||
.bar > i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg,#b98330,#e8b45a); transition: width .6s; }
|
||||
.wait-box { margin-top: 14px; padding: 20px; border-radius: 12px; border: 1px dashed rgba(var(--ok-rgb),.3); background: rgba(var(--ok-rgb),.05); }
|
||||
.wait-box.has { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.05); }
|
||||
.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; }
|
||||
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: var(--ok); letter-spacing: .08em; text-align: center; }
|
||||
.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; }
|
||||
.wait-item .dm { color: var(--amber); font-size: 17px; flex: none; }
|
||||
.wait-meta { margin-top: 10px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); line-height: 1.7; }
|
||||
.wait-meta .warn { color: var(--err); }
|
||||
.subhead { display: flex; justify-content: space-between; align-items: baseline; margin: 24px 0 10px; }
|
||||
.subhead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); }
|
||||
.subhead .m { font-size: 13px; color: rgba(var(--ink-rgb),.4); }
|
||||
ul.route { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
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; }
|
||||
ul.route li.doing { background: rgba(var(--amber-rgb),.09); border-color: rgba(var(--amber-rgb),.3); }
|
||||
ul.route li .ic { flex: none; font-size: 15px; margin-top: 2px; }
|
||||
ul.route li.done { color: rgba(var(--ink-rgb),.65); }
|
||||
ul.route li.done .ic { color: var(--ok); }
|
||||
ul.route li.doing .ic { color: var(--amber); }
|
||||
ul.route li.todo { color: rgba(var(--ink-rgb),.6); }
|
||||
ul.route li.todo .ic { color: rgba(var(--ink-rgb),.35); }
|
||||
ul.route li.blocked .ic { color: var(--err); }
|
||||
ul.route.faded li { opacity: .55; }
|
||||
.sys { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.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; }
|
||||
.sys .row .k { color: rgba(var(--ink-rgb),.6); }
|
||||
.sys .row .v { font-family: ui-monospace, Menlo, monospace; font-size: 14px; }
|
||||
.sys .ok { color: var(--ok); }
|
||||
.sys .bad { color: var(--err); }
|
||||
.sys .off { color: rgba(var(--ink-rgb),.5); }
|
||||
.muted { color: rgba(var(--ink-rgb),.45); font-size: 14px; }
|
||||
.err { color: var(--err); font-size: 14px; }
|
||||
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.8; }
|
||||
.enter { display: block; text-align: center; font-size: 13.5px; color: rgba(var(--amber-rgb),.75); text-decoration: none; margin-top: 6px; }
|
||||
.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; }
|
||||
</style>
|
||||
<script src="/config.js"></script>
|
||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="pagehead">
|
||||
<div class="title serif">Arcrun<small> 駕駛艙</small></div>
|
||||
<div style="display:flex;align-items:baseline">
|
||||
<div class="date serif" id="date-str"></div>
|
||||
<button class="theme-btn" id="theme-btn" title="切換深/淺色">☾</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="orb-row">
|
||||
<div class="orb" id="orb" style="background:radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)"><span id="orb-char">…</span></div>
|
||||
<div>
|
||||
<div class="orb-title" id="orb-title">載入中</div>
|
||||
<div class="orb-sub" id="orb-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bricks">
|
||||
<div class="brick amber">
|
||||
<div class="lbl">今日完成</div>
|
||||
<div class="num"><span id="done-n">–</span><small> / <span id="total-n">–</span> 件</small></div>
|
||||
<div class="bar"><i id="bar-fill" style="width:0%"></i></div>
|
||||
</div>
|
||||
<div class="brick plain">
|
||||
<div class="lbl">收件匣未處理</div>
|
||||
<div class="num"><span id="inbox-n">–</span><small> 條</small></div>
|
||||
<div class="lbl" style="margin:10px 0 0">來自 Telegram</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wait-box" id="wait-box">
|
||||
<div class="wait-head">等你的事</div>
|
||||
<div id="wait-body" class="wait-none">載入中…</div>
|
||||
<div class="wait-meta" id="wait-meta"></div>
|
||||
</div>
|
||||
<div class="subhead"><span class="t">今日路線</span><span class="m" id="route-m"></span></div>
|
||||
<ul class="route" id="today-list"><li class="todo"><span class="ic">○</span>載入中…</li></ul>
|
||||
<div class="subhead" id="week-head" style="display:none"><span class="t">本週</span></div>
|
||||
<ul class="route" id="week-list"></ul>
|
||||
<div class="subhead"><span class="t">系統狀況</span><span class="m">live 健康信號</span></div>
|
||||
<div class="sys" id="sys-list"><div class="row"><span class="k">載入中…</span></div></div>
|
||||
<div class="stamp" id="stamp">每 60 秒自動刷新</div>
|
||||
<a class="enter" href="/console">進入完整控制台 ›</a>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var API_BASE = window.ARCRUN_API_BASE || '';
|
||||
// 台北時間 helper(lib/taipei-time.ts 注入,與 server 判定同一套——顯示不隨看的裝置時區漂移)
|
||||
var TAIPEI_OFFSET_MS = 28800000; // UTC+8,台北無 DST
|
||||
function tpePad(n) { n = String(n); return n.length < 2 ? '0' + n : n; }
|
||||
function taipeiDayKey(ms) { return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10); }
|
||||
function taipeiDateStr(ms) { return taipeiDayKey(ms); }
|
||||
function taipeiDateTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return taipeiDayKey(ms) + ' ' + tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()); }
|
||||
function taipeiTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()) + ':' + tpePad(d.getUTCSeconds()); }
|
||||
function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { month: d.getUTCMonth() + 1, day: d.getUTCDate() }; }
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const LIGHT = {
|
||||
green: { ch: '安', title: '系統運轉中', grad: 'radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)', anim: 'breatheGreen' },
|
||||
yellow: { ch: '趕', title: '落後趕工中', grad: 'radial-gradient(circle at 36% 30%,#f2d194,#e8b45a 55%,#8a5f1e 100%)', anim: 'breatheAmber' },
|
||||
red: { ch: '滯', title: '卡住或斷訊', grad: 'radial-gradient(circle at 36% 30%,#f0a094,#d95f4c 55%,#7e2c20 100%)', anim: 'breatheRed' }
|
||||
};
|
||||
const ICONS = { done: '✓', doing: '◐', todo: '○', blocked: '●' };
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
function taskLine(t) {
|
||||
const cls = ICONS[t.status] ? t.status : 'blocked';
|
||||
const ic = ICONS[t.status] || '●';
|
||||
return '<li class="' + cls + '"><span class="ic">' + ic + '</span><span>' + esc(t.title) + '</span></li>';
|
||||
}
|
||||
function humanAge(m) {
|
||||
if (m == null || m < 0) return '時間不明';
|
||||
if (m < 60) return m + ' 分鐘前';
|
||||
if (m < 2880) return Math.round(m / 60) + ' 小時前';
|
||||
return Math.round(m / 1440) + ' 天前';
|
||||
}
|
||||
const CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];
|
||||
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] : '')); }
|
||||
// 頁首日期=台北日(原本用瀏覽器本地時區,換裝置會漂)
|
||||
const nowTpe = taipeiMonthDay(Date.now());
|
||||
$('date-str').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
|
||||
// 深/淺切換(與 /console 共用 arcrun_console_theme;預設淺色)
|
||||
function syncThemeBtn() { $('theme-btn').textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☾'; }
|
||||
$('theme-btn').addEventListener('click', () => {
|
||||
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
try { localStorage.setItem('arcrun_console_theme', next); } catch (e) { /* 私密模式忽略 */ }
|
||||
syncThemeBtn();
|
||||
});
|
||||
syncThemeBtn();
|
||||
// fetch 失敗(斷網)的裸訊息 → 友善誠實文案;60 秒定時器常駐,網路恢復自動刷回
|
||||
function friendlyErr(e) {
|
||||
const m = e && e.message ? String(e.message) : String(e);
|
||||
return /failed to fetch|load failed|networkerror|network request failed/i.test(m) ? '連線中斷' : m;
|
||||
}
|
||||
function sysRow(k, v, cls) {
|
||||
return '<div class="row"><span class="k">' + esc(k) + '</span><span class="v ' + cls + '">' + esc(v) + '</span></div>';
|
||||
}
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/console/dashboard-data');
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const d = await res.json();
|
||||
const cfg = LIGHT[d.light] || LIGHT.green;
|
||||
const orb = $('orb');
|
||||
orb.style.background = cfg.grad;
|
||||
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
|
||||
$('orb-char').textContent = cfg.ch;
|
||||
$('orb-title').textContent = cfg.title;
|
||||
$('orb-sub').textContent = (d.last_beat
|
||||
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
|
||||
: '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '(' + d.light_reason + ')' : '');
|
||||
const done = d.today_done || 0, total = d.today_total || 0;
|
||||
$('done-n').textContent = done; $('total-n').textContent = total;
|
||||
$('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
|
||||
$('inbox-n').textContent = d.inbox_new || 0;
|
||||
// ── 等你的事:來源 + 維護時間攤開講,stale 一定警示 ──
|
||||
const wb = $('wait-box'), body = $('wait-body'), wmeta = $('wait-meta');
|
||||
const wm = d.waiting_meta || {};
|
||||
if (d.waiting && d.waiting.length) {
|
||||
wb.classList.add('has');
|
||||
body.className = '';
|
||||
body.innerHTML = d.waiting.map((w) =>
|
||||
'<div class="wait-item"><span class="dm">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +
|
||||
(w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>').join('');
|
||||
} else {
|
||||
wb.classList.remove('has');
|
||||
body.className = 'wait-none';
|
||||
body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';
|
||||
}
|
||||
let metaTxt = '';
|
||||
if (wm.source === 'gitea_sprint') {
|
||||
metaTxt = '來源:sprint 等leo清單(' + esc((wm.sprint_files || []).join('、')) + ')・清單維護於 ' + humanAge(wm.updated_ago_minutes);
|
||||
if (wm.stale) metaTxt += '<br><span class="warn">⚠ 清單超過 2 天沒維護,可能過時</span>';
|
||||
} else if (wm.source === 'kbdb_dash_wait') {
|
||||
metaTxt = '<span class="warn">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + humanAge(wm.updated_ago_minutes) + ',可能過時</span>';
|
||||
} else {
|
||||
metaTxt = '<span class="warn">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
|
||||
}
|
||||
wmeta.innerHTML = metaTxt;
|
||||
// ── 今日路線:sprint 任務板優先(來源攤開講);dash_task fallback 沿舊誠實降級 ──
|
||||
const rm = d.route_meta || {};
|
||||
const today = (d.tasks || []).filter((t) => t.scope === 'today');
|
||||
const week = (d.tasks || []).filter((t) => t.scope === 'week');
|
||||
if (rm.source === 'gitea_sprint_board') {
|
||||
$('route-m').textContent = '來源 sprint 任務板・更新於 ' + humanAge(rm.updated_ago_minutes);
|
||||
$('today-list').className = 'route';
|
||||
const staleHead = rm.is_today ? '' :
|
||||
'<li class="todo"><span class="ic">○</span><span class="muted">⚠ 今日任務板未更新(最後 ' + humanAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';
|
||||
$('today-list').innerHTML = staleHead + (today.length
|
||||
? today.map(taskLine).join('')
|
||||
: '<li class="todo"><span class="ic">○</span><span class="muted">任務板上沒有可解析的事項</span></li>');
|
||||
} else if (rm.is_today) {
|
||||
$('route-m').textContent = '更新於 ' + humanAge(rm.updated_ago_minutes);
|
||||
$('today-list').className = 'route';
|
||||
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
|
||||
} else if (today.length) {
|
||||
$('route-m').textContent = '最後路線・' + humanAge(rm.updated_ago_minutes) + '寫入';
|
||||
$('today-list').className = 'route faded';
|
||||
$('today-list').innerHTML =
|
||||
'<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + humanAge(rm.updated_ago_minutes) +
|
||||
'的殘留路線(sprint 任務板→dashboard 投影管線未接,等leo清單#15 裁決中)</span></li>' + today.map(taskLine).join('');
|
||||
} else {
|
||||
$('route-m').textContent = '';
|
||||
$('today-list').className = 'route';
|
||||
$('today-list').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">無資料——dash_task 管線未接</span></li>';
|
||||
}
|
||||
$('week-head').style.display = week.length ? '' : 'none';
|
||||
$('week-list').innerHTML = week.map(taskLine).join('');
|
||||
// ── 系統狀況 + 總庫規模(全 live,讀不到就標讀不到)──
|
||||
const sys = d.system || {}, kb = d.kb || {};
|
||||
const rows = [];
|
||||
rows.push(sysRow('KBDB 基本盤', sys.kbdb_ok ? '● 正常' : '● 打不通', sys.kbdb_ok ? 'ok' : 'bad'));
|
||||
if (sys.embed) {
|
||||
rows.push(sys.embed.enabled
|
||||
? sysRow('語意嵌入', '● 啟用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'ok')
|
||||
: sysRow('語意嵌入', '○ 停用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'bad'));
|
||||
} else {
|
||||
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
|
||||
}
|
||||
rows.push(sys.graph && sys.graph.ok
|
||||
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
|
||||
: sysRow('知識圖譜', '● 打不通', 'bad'));
|
||||
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
|
||||
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
|
||||
// 三元組/已嵌入 已各有一列)
|
||||
rows.push(sysRow('精耕層 wiki 卡', kb.wiki_card_total == null ? '讀不到' : kb.wiki_card_total + ' 張', kb.wiki_card_total == null ? 'off' : ''));
|
||||
$('sys-list').innerHTML = rows.join('');
|
||||
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(taipeiTimeStr(Date.parse(d.generated_at))) + '(台北)<br>此頁不含機敏內容,免登入';
|
||||
} catch (e) {
|
||||
$('orb-char').textContent = '?';
|
||||
$('orb-title').textContent = '讀不到狀態';
|
||||
$('orb-sub').innerHTML = '<span class="err">' + esc(friendlyErr(e)) + '・每 60 秒自動重試</span>';
|
||||
}
|
||||
}
|
||||
load();
|
||||
setInterval(load, 60000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* console-ui build — 把 cypher-executor 的三支 UI renderer 在「建置時」跑一次,
|
||||
* 產出純靜態 HTML 到 public/,交給 Cloudflare Pages 託管。
|
||||
*
|
||||
* 為什麼這樣做(cypher-ui-split 第一刀):
|
||||
* 原本 console/portal/dashboard 的 HTML 由 cypher-executor Worker 在「每次請求時」
|
||||
* 用 template literal 組出來 → 5,240 行 UI 字串永遠躺在 Worker bundle 裡(748KB),
|
||||
* 連 /health 這種什麼都不做的請求都要付 5-7ms CPU(免費層上限 10ms)。
|
||||
* UI 是靜態的(單檔 HTML+原生 JS、零外部資源),本來就該待在 Pages。
|
||||
*
|
||||
* 保持原特性(leo 反覆強調簡化):
|
||||
* - 零打包工具、零 npm 依賴:本檔只用 node 內建 fs/path,正則抽出 renderer 的
|
||||
* template literal 後求值。不引入 esbuild/vite/rollup。
|
||||
* - 產出仍是「單檔 HTML+原生 JS hash routing、零外部資源」。
|
||||
*
|
||||
* 唯一的行為差異=API base:
|
||||
* 原本 UI 與 API 同源,fetch 全用相對路徑('/kbdb/search')。搬上 Pages 後跨網域,
|
||||
* 故注入 window.ARCRUN_API_BASE,並把 fetch 的相對路徑改成 API_BASE + path。
|
||||
* 見下方 rewriteFetchPaths()。
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..');
|
||||
const SRC = join(ROOT, '..', 'cypher-executor', 'src');
|
||||
const OUT = join(ROOT, 'public');
|
||||
|
||||
// ── 建置期組態(原本是 Worker 的 env var,現在是建置參數)────────────────
|
||||
// Pages 是靜態站,沒有 per-request env;品牌/profile 這類「一個部署一個值」的
|
||||
// 設定改在建置時決定(要換值=重跑 build 再部署,符合靜態站模型)。
|
||||
const CFG = {
|
||||
brand: process.env.CONSOLE_BRAND || 'Arcrun',
|
||||
profile: process.env.CONSOLE_PROFILE || 'full',
|
||||
registryBase: process.env.REGISTRY_BASE || 'https://registry.arcrun.dev',
|
||||
sourceWebBase: process.env.PORTAL_SOURCE_WEB_BASE || '',
|
||||
// API base 走 runtime 注入(見 public/config.js),這裡只放預設值
|
||||
apiBase: process.env.ARCRUN_API_BASE || '',
|
||||
};
|
||||
|
||||
/**
|
||||
* 讀 TS 原始碼並取出整個 renderer 函式的**函式主體**(不只 template literal)。
|
||||
*
|
||||
* 取整個 body 而非只取反引號區塊,是因為 renderer 在 return 之前會先算區域變數
|
||||
* (如 console.ts 的 rag/views/home 由 profile 推導)。只搬模板=把那段推導邏輯
|
||||
* 複製一份到本檔=雙份真相會漂移。連 body 一起求值 → 推導邏輯永遠只有一份。
|
||||
*/
|
||||
function extractRendererBody(file, fnName) {
|
||||
const code = readFileSync(join(SRC, file), 'utf8');
|
||||
const start = code.indexOf(`function ${fnName}(`);
|
||||
if (start < 0) throw new Error(`找不到 ${fnName} in ${file}`);
|
||||
const braceStart = code.indexOf('{', code.indexOf(')', start));
|
||||
if (braceStart < 0) throw new Error(`${fnName} 找不到函式主體`);
|
||||
// 掃到配對的收尾大括號;需略過字串/template literal/註解裡的括號
|
||||
let i = braceStart + 1;
|
||||
let depth = 1;
|
||||
let mode = null; // null | "'" | '"' | '`' | 'line' | 'block'
|
||||
let tplDepth = 0;
|
||||
while (i < code.length && depth > 0) {
|
||||
const ch = code[i];
|
||||
const nx = code[i + 1];
|
||||
if (mode === null) {
|
||||
if (ch === '\\') { i += 2; continue; }
|
||||
if (ch === '/' && nx === '/') { mode = 'line'; i += 2; continue; }
|
||||
if (ch === '/' && nx === '*') { mode = 'block'; i += 2; continue; }
|
||||
if (ch === "'" || ch === '"') { mode = ch; i++; continue; }
|
||||
if (ch === '`') { mode = '`'; tplDepth = 0; i++; continue; }
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (mode === 'line') { if (ch === '\n') mode = null; i++; continue; }
|
||||
if (mode === 'block') { if (ch === '*' && nx === '/') { mode = null; i += 2; continue; } i++; continue; }
|
||||
if (ch === '\\') { i += 2; continue; }
|
||||
if (mode === '`') {
|
||||
// template literal 內的 ${ … } 是真程式碼,其中的引號/括號要照常計數才不會誤判收尾
|
||||
if (ch === '$' && nx === '{') { tplDepth++; i += 2; continue; }
|
||||
if (ch === '}' && tplDepth > 0) { tplDepth--; i++; continue; }
|
||||
if (ch === '`' && tplDepth === 0) { mode = null; i++; continue; }
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === mode) mode = null;
|
||||
i++;
|
||||
}
|
||||
// 去掉 TS 的型別註記(本 body 只有 `const x: T =` 這種簡單形態)
|
||||
return code.slice(braceStart + 1, i - 1).replace(/\bconst\s+(\w+):\s*[\w<>[\]|]+\s*=/g, 'const $1 =');
|
||||
}
|
||||
|
||||
/** 取出 lib/taipei-time.ts 匯出的 TAIPEI_CLIENT_JS 字串常數(UI 內嵌的客戶端時間工具)。 */
|
||||
function extractTaipeiClientJs() {
|
||||
const code = readFileSync(join(SRC, 'lib', 'taipei-time.ts'), 'utf8');
|
||||
// 形態=字串陣列 .join('\n')(見 lib/taipei-time.ts),直接求值該陣列表達式
|
||||
const m = code.match(/export const TAIPEI_CLIENT_JS\s*=\s*(\[[\s\S]*?\]\.join\('\\n'\));/);
|
||||
if (!m) throw new Error('找不到 TAIPEI_CLIENT_JS');
|
||||
return new Function(`return ${m[1]};`)();
|
||||
}
|
||||
|
||||
/**
|
||||
* 求值 renderer 函式主體。用 new Function 而非 eval——只餵建置期組態,
|
||||
* 輸入是本 repo 自己的原始碼(非使用者輸入),無注入面。
|
||||
*/
|
||||
function render(body, vars) {
|
||||
const names = Object.keys(vars);
|
||||
const fn = new Function(...names, body);
|
||||
return fn(...names.map((n) => vars[n]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 UI 內原生 JS 的相對路徑 fetch 改成打 API base。
|
||||
*
|
||||
* 只改 `fetch('/...` 與 `fetch("/...`(開頭是單斜線=同源絕對路徑)這一種形態,
|
||||
* 其餘(fetch(url, …) 這類變數形式)另由各檔的 url 組法在下面單獨處理。
|
||||
*/
|
||||
function rewriteFetchPaths(html, file) {
|
||||
// ① fetch('/xxx → fetch(API_BASE + '/xxx
|
||||
let out = html.replace(/fetch\((['"])\/(?!\/)/g, 'fetch(API_BASE + $1/');
|
||||
// ② 變數式 fetch(url, ...):url 由上方 var url = '/kbdb/search?...' 組成 →
|
||||
// 把這類「以單斜線開頭的路徑字面值指派」也補上 API_BASE
|
||||
out = out.replace(/(\bvar\s+url\s*=\s*)(['"])\/(?!\/)/g, '$1API_BASE + $2/');
|
||||
// ③ portal 的 adminApi(method, path, body):path 由呼叫端傳字面值進來,①②
|
||||
// 都掃不到(8 個呼叫點)。在 helper 內部補前綴=一處修好全部,不必改 8 個呼叫點。
|
||||
out = out.replace(
|
||||
/(function adminApi\(method, path, body\) \{)/,
|
||||
'$1\n path = API_BASE + path;'
|
||||
);
|
||||
|
||||
// 防呆:搬完後不該再有「直接 fetch 同源相對路徑」的殘留。掃到就讓建置失敗,
|
||||
// 免得漏網的呼叫點在 Pages 上打到 Pages 自己(404)才被發現。
|
||||
// 註:adminApi 的呼叫端仍是相對路徑字面值——那是對的,前綴由 helper 內部(③)加。
|
||||
const unprefixed = [...out.matchAll(/fetch\((['"])\/(?!\/)[^'"]*/g)].map((m) => m[0]);
|
||||
if (unprefixed.length) {
|
||||
throw new Error(
|
||||
`${file}:有 ${unprefixed.length} 個相對路徑 fetch 沒被改寫成 API_BASE:\n ` +
|
||||
[...new Set(unprefixed)].join('\n ')
|
||||
);
|
||||
}
|
||||
// adminApi 形態存在時,必須確認 helper 已被加上前綴(否則 8 個呼叫點全會打錯家)
|
||||
if (/function adminApi\(method, path, body\)/.test(out) && !/path = API_BASE \+ path;/.test(out)) {
|
||||
throw new Error(`${file}:偵測到 adminApi helper 但前綴注入失敗`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 在頁面 <head> 注入 config.js(runtime 決定 API base),並定義 API_BASE 供內嵌 JS 用。 */
|
||||
function injectApiBase(html) {
|
||||
const snippet = `<script src="/config.js"></script>
|
||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || ${JSON.stringify(CFG.apiBase)};</script>`;
|
||||
const withCfg = html.replace('</head>', `${snippet}\n</head>`);
|
||||
// 內嵌的 IIFE 裡宣告 API_BASE(各頁的主 <script> 都是 (function(){ … })() 形態)
|
||||
return withCfg.replace(
|
||||
/<script>\s*\(function\s*\(\)\s*\{/,
|
||||
'<script>\n(function () {\n var API_BASE = window.ARCRUN_API_BASE || \'\';'
|
||||
);
|
||||
}
|
||||
|
||||
function build(name, file, fnName, vars) {
|
||||
const body = extractRendererBody(file, fnName);
|
||||
let html = render(body, vars);
|
||||
html = rewriteFetchPaths(html, name);
|
||||
html = injectApiBase(html);
|
||||
const dest = join(OUT, name);
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
writeFileSync(dest, html, 'utf8');
|
||||
console.log(` ${name.padEnd(24)} ${(Buffer.byteLength(html) / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
|
||||
const TAIPEI_CLIENT_JS = extractTaipeiClientJs();
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
console.log('console-ui build →', OUT);
|
||||
|
||||
// /console — Admin Console 完整版(console.ts renderConsoleHtml)
|
||||
build('console/index.html', 'routes/console.ts', 'renderConsoleHtml', {
|
||||
registryBase: CFG.registryBase,
|
||||
brand: CFG.brand,
|
||||
profile: CFG.profile,
|
||||
TAIPEI_CLIENT_JS,
|
||||
});
|
||||
|
||||
// /portal — RAG Portal(portal-ui.ts renderPortalHtml)
|
||||
build('portal/index.html', 'routes/portal-ui.ts', 'renderPortalHtml', {
|
||||
brand: CFG.brand,
|
||||
sourceWebBase: CFG.sourceWebBase,
|
||||
TAIPEI_CLIENT_JS,
|
||||
});
|
||||
|
||||
// /console/dashboard — 駕駛艙(console-dashboard.ts renderDashboardHtml)
|
||||
build('console/dashboard/index.html', 'routes/console-dashboard.ts', 'renderDashboardHtml', {
|
||||
brand: CFG.brand,
|
||||
TAIPEI_CLIENT_JS,
|
||||
});
|
||||
|
||||
// config.js:部署後可直接改這一檔切 API 目標,不必重 build
|
||||
writeFileSync(
|
||||
join(OUT, 'config.js'),
|
||||
`// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
|
||||
window.ARCRUN_CONFIG = { apiBase: ${JSON.stringify(CFG.apiBase)} };
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
console.log(' config.js');
|
||||
console.log('done.');
|
||||
@@ -0,0 +1,3 @@
|
||||
name = "arcrun-console-ui"
|
||||
pages_build_output_dir = "public"
|
||||
compatibility_date = "2026-07-21"
|
||||
Reference in New Issue
Block a user