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:
2026-07-21 18:55:31 +08:00
parent 1b687fedb0
commit 5a164843ef
10 changed files with 655 additions and 543 deletions
+1
View File
@@ -51,3 +51,4 @@ backup-*.sql
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
.github-public/
wrangler.leo21c.toml
+11
View File
@@ -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"
}
}
+2
View File
@@ -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 || '';
// 台北時間 helperlib/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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>
@@ -1,91 +1,9 @@
/**
* Mira Console 完整版(Arcrun#3 console 系,2026-07-04 總管派工)
*
* 視覺:claude design 定稿「紙感暖黑 2a」(system-dev/docs/6-user/Mira Console 設計規劃/
* Mira Console.dc.html + Mira Style Guide.dc.html)——紙紋底 repeating-linear-gradient、
* 明體標題(Songti TC 級聯)、琥珀 var(--amber) 強調、墨綠呼吸球嵌「安」字、正文 ≥16px、
* 手機優先 390px + ≥1024px 側欄、頁面本體不出水平捲軸。
*
* 資訊架構:brief 8 頁(docs/1-vision/mira-console-design-brief.md)=
* 1 登入/首次設定 2 駕駛艙 3 總庫搜尋 4 卡片詳頁+關聯 5 工作流 6 憑證管理
* 7 分流台(原收件匣,Arcrun#9 改裝) 8 設定。
* 單檔 HTML + 原生 JS hash routing#/cockpit …),零外部資源、零 build step。
*
* 薄殼(rule 07):零業務邏輯,只 fetch 既有 API 渲染。各頁資料源(誠實原則,無源的顯示
* 誠實空狀態,禁假資料):
* - 駕駛艙:GET /console/dashboard-data(免登入聚合端點,console-dashboard.ts
* - 總庫搜尋:GET /kbdb/searchmode=semantic 未開 Vectorize 會誠實降級 + capability_hint
* chips 用 entry_type——KBDB 真能篩的欄位,不編造「電腦筆記/手機筆記」等不存在的分類);
* 頭部統計來自 GET /console/kb-scale-data(精耕層 wiki 卡/三元組/已嵌入——leo 2026-07-07
* 裁:45.8 萬 14-E 搬遷遺產 deprecated 不再當招牌數字;搜尋本身仍搜全庫);
* 搜尋框上方=藏書地圖(library-map SDD M5/R4Arcrun#39):GET /kbdb/map 全館庫卡片
* narrativetop entitiestriplet 數+更新時間感),逐庫 GET /kbdb/map/:library 補
* degree/跨庫 bridgescommit_hash(詳圖拉不到就維持名字版,不擋渲染);點庫卡片=
* 設 library filter/kbdb/search 既有 library 參數)進該庫搜尋;/map 空陣列或錯誤=
* 誠實空狀態(指引 POST /map/recompute backfill),不擋搜尋等其他功能
* - 卡片詳頁:GET /kbdb/entries/:id;關聯視圖走 GET /kbdb/graph/neighbors/:name
* cypher 代轉 kbdb-graph-pluginkbdb-proxy.ts;查無 triplet → 「尚無關聯資料」)
* - 工作流:GET /webhooks/namedlist+ POST /webhooks/named/:name/trigger(手動觸發)
* + GET /workflows/:name/executions(最近執行);釘選存 localStorage
* - 憑證:GET /credentials/catalogD1 目錄唯讀,絕不含密文)+ POST /credentials(新增)
* + PUT /credentials/:name(整筆替換);刪除後端未接 D1(T9 範圍)→ 標「即將開通」
* - 分流台:GET /console/triage-data(需 session——待辦/訊息原文屬機敏,計數才免登入)。
* 資料源二合一(kb-ingest SDD R7 / Arcrun#9):entry_type=todoLogseq 萃取)+
* entry_type=inboxTelegram);三欄 80/15/5aicollableo)+ per-project chips
* +已完成預設隱藏可切換。分欄判定在 lib/console-triage-model.ts(純函式),頁面只渲染。
* 勾掉/還原走 POST /console/triage-check(同 session 保護;leo 2026-07-08 拍板)——
* 雙向銷案語意:console 勾掉=終局(checked_via:console,萃取端絕不復活)、Logseq 改
* DONE 由萃取端 PATCHchecked_via:logseq);console 只忠實顯示非 done 項。
* - 設定:vectorize 狀態探測(讀 /kbdb/search 回應的 mode/capability_hint,無新端點;
* 開關本體需部署端 config kbdb_embed + acr update → 誠實文案講清楚、不假裝能遠端開)
* + MCP token TTL 誠實佔位(/console/settings-data;可調功能=Arcrun#19
* + /console/setup/reset 換帳密 + 系統資訊(精耕層規模)
*
* 2026-07-07 fix/console-truth-audit(總管稽核派工 + leo 裁決):
* 1. 全站日期/時間顯示統一 Asia/Taipeilib/taipei-time.ts,與駕駛艙 #36 台北日判定同套;
* 原本用瀏覽器本地 getter,換裝置就換日期;憑證頁加顯時分——7/6 22:57 建立不再被誤讀)。
* 2. 總庫搜尋/設定頁頭部統計改精耕層(458,732 是真數字——cypher 與 base 一手同值實證——
* 但 leo 裁 14-E 遺產 deprecated 不再顯示)。
* 3. 駕駛艙「今日完成/今日路線」接 sprint 任務板真資料(dashboard-data 端,同 #36 模式)。
*
* 認證模型沿 v1console-auth.ts):單一管理員 email+passwordsession token 在 localStorage
* 實際打 /kbdb/* 用後端回的固定租戶字串(CONSOLE_TENANT)。駕駛艙免登入版在 /console/dashboard。
*
* v0 的 components/recipes 查詢區(Arcrun#3 原搜尋台)收進「工作流」頁下方折疊區,不砍功能。
*
* 2026-07-04 leo 實測三回饋(總管派工二輪):
* 1. 深/淺主題:CSS custom properties 兩份色板(:root=淺色紙感宣紙米白+墨字,
* :root[data-theme=dark]=原定稿暖黑不動),預設淺色,選擇存 localStorage
* key arcrun_console_theme),head 預載腳本防閃色。切換入口=登入/首次設定頁小鈕、
* 側欄項、設定頁正式開關。金琥珀例外(按鈕漸層/狀態球/toast/gcenter)兩版不變。
* 2. 登入/首次設定置中:.authwrap.view.on 補 display:flex(原被 .view.on 的 block 蓋掉 → 靠左)。
* 3. fetch 韌性:friendlyErr() 把裸 Failed to fetch / Load failed 轉「連線中斷」誠實文案;
* 駕駛艙 60 秒定時器常駐,網路恢復自動刷回。
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
export const consoleRouter = new Hono<{ Bindings: Bindings }>();
function renderConsoleHtml(registryBase: string, brand: string, profile: string): string {
// Console profileconfig 裁剪,非 auth——#24/#25 多人 Portal 是另一回事):
// full(未設即此)=全套,Mira 實例現行為一字不變。
// rag=企業產品樣:只留 總庫搜尋/工作流/設定(card 是搜尋的詳頁,跟著留),落地=搜尋頁。
// 隱藏頁的 view div 仍在 DOMdisplay:none、路由 guard 導回,永遠點不進)——
// 因 creds/inbox 有 top-level addEventListener 綁定,抽掉 div 會讓整包 JS 啟動即炸,
// 純渲染層裁剪以「導不進去」為準,不為了抽 DOM 把 null-guard 灑滿全檔。
const rag = profile === 'rag';
const views = rag
? ['search', 'card', 'workflows', 'settings']
: ['cockpit', 'search', 'card', 'workflows', 'creds', 'inbox', 'settings'];
const home = rag ? 'search' : 'cockpit';
return `<!doctype html>
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${brand} Console</title>
<title>Arcrun Console</title>
<script>
// 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),選擇存 localStorage
document.documentElement.setAttribute('data-theme', (function () {
@@ -301,6 +219,8 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
.switch.on i { transform: translateX(24px); }
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
</style>
<script src="/config.js"></script>
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";</script>
</head>
<body>
@@ -308,7 +228,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
<div class="authwrap view" id="v-login">
<div class="authbox">
<div>
<div class="brand">${brand}</div>
<div class="brand">Arcrun</div>
<div class="brand-sub">私人系統・僅供擁有者進入</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;text-align:left">
@@ -318,7 +238,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
</div>
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">這是一個人的智慧總部。<br>若你不是擁有者,這裡沒有你要找的東西。</div>
${rag ? '' : '<a href="/console/dashboard" style="font-size:13.5px;color:rgba(var(--amber-rgb),.7)">免登入看駕駛艙 </a>'}
<a href="/console/dashboard" style="font-size:13.5px;color:rgba(var(--amber-rgb),.7)">免登入看駕駛艙 </a>
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
</div>
</div>
@@ -327,7 +247,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
<div class="authwrap view" id="v-setup">
<div class="authbox" style="max-width:440px;text-align:left">
<div style="text-align:center">
<div class="brand" style="font-size:38px">${brand}</div>
<div class="brand" style="font-size:38px">Arcrun</div>
<div class="serif" style="margin-top:12px;font-size:21px">首次設定</div>
<div style="margin-top:8px;font-size:15px;color:rgba(var(--ink-rgb),.55);line-height:1.7">系統偵測到尚未設定擁有者帳號。<br>設定一組 Email 與密碼,之後只有你能進入。</div>
</div>
@@ -345,12 +265,12 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
<!-- 主應用 -->
<div id="shell">
<div id="sidenav">
<div class="logo">${brand}</div>
${rag ? '' : '<div class="nav" data-nav="cockpit">駕駛艙</div>'}
<div class="logo">Arcrun</div>
<div class="nav" data-nav="cockpit">駕駛艙</div>
<div class="nav" data-nav="search">總庫搜尋</div>
<div class="nav" data-nav="workflows">工作流</div>
${rag ? '' : `<div class="nav" data-nav="creds">憑證管理</div>
<div class="nav" data-nav="inbox">分流台</div>`}
<div class="nav" data-nav="creds">憑證管理</div>
<div class="nav" data-nav="inbox">分流台</div>
<div class="nav" data-nav="settings">設定</div>
<div class="nav themelabel" data-themetoggle style="margin-top:8px;font-size:14.5px">☾ 切深色</div>
<div class="foot" id="side-foot"></div>
@@ -359,7 +279,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
<!-- 駕駛艙 -->
<div class="view page" id="v-cockpit">
<div class="pagehead"><span class="t">${brand}<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>
<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>
<div class="cockgrid">
<div>
<div class="orb-row">
@@ -522,13 +442,13 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
<div id="st-pw-status" style="font-size:14px;min-height:1.2em"></div>
</div>
</div>
${rag ? '' : `<div class="panel" style="display:flex;align-items:center;gap:12px;cursor:pointer" data-nav="creds">
<div class="panel" style="display:flex;align-items:center;gap:12px;cursor:pointer" data-nav="creds">
<div>
<div style="font-size:17px;font-weight:600">憑證管理</div>
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)">只保存目錄與加密值,不可回看</div>
</div>
<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);font-size:18px"></span>
</div>`}
</div>
<div class="panel">
<div style="font-size:17px;font-weight:600;margin-bottom:12px">系統資訊</div>
<div id="st-info"><div class="muted">載入中…</div></div>
@@ -542,10 +462,10 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
<!-- 手機底部導覽 -->
<div id="tabbar">
${rag ? '' : '<div class="tab" data-nav="cockpit">駕駛艙</div>'}
<div class="tab" data-nav="cockpit">駕駛艙</div>
<div class="tab" data-nav="search">搜尋</div>
<div class="tab" data-nav="workflows">工作流</div>
${rag ? '' : '<div class="tab" data-nav="inbox">分流台</div>'}
<div class="tab" data-nav="inbox">分流台</div>
<div class="tab" data-nav="settings">設定</div>
</div>
@@ -553,11 +473,18 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
<script>
(function () {
var API_BASE = window.ARCRUN_API_BASE || '';
'use strict';
// 台北時間 helperlib/taipei-time.ts 注入,與駕駛艙 #36 台北日判定同一套)——
// console 所有日期/時間顯示固定 Asia/Taipei,不隨看的裝置時區漂移
${TAIPEI_CLIENT_JS}
var REGISTRY_BASE = ${JSON.stringify(registryBase)};
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() }; }
var REGISTRY_BASE = "https://registry.arcrun.dev";
var $ = function (id) { return document.getElementById(id); };
// ── 狀態 ──
@@ -617,7 +544,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
function parseAtMs(v) {
if (v == null || v === '') return null;
if (typeof v === 'number') return v < 1e12 ? v * 1000 : v;
if (/^\\d+$/.test(v)) { var n = Number(v); return n < 1e12 ? n * 1000 : n; }
if (/^\d+$/.test(v)) { var n = Number(v); return n < 1e12 ? n * 1000 : n; }
var s = /T/.test(v) ? v : String(v).replace(' ', 'T') + 'Z';
var ms = Date.parse(s);
return isNaN(ms) ? null : ms;
@@ -637,31 +564,31 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
// ── 極簡 Markdown 渲染(先全 escape 再上格式,防 XSS)──
function mdInline(s) {
return s
.replace(/\\*\\*([^*]+)\\*\\*/g, '<b>$1</b>')
.replace(/(^|[^\\\\])\\x60([^\\x60]+)\\x60/g, '$1<code>$2</code>');
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
.replace(/(^|[^\\])\x60([^\x60]+)\x60/g, '$1<code>$2</code>');
}
function mdRender(src) {
var lines = String(src || '').split(/\\r?\\n/);
var lines = String(src || '').split(/\r?\n/);
var out = [], inCode = false, listMode = '';
function closeList() { if (listMode) { out.push(listMode === 'ul' ? '</ul>' : '</ol>'); listMode = ''; } }
for (var i = 0; i < lines.length; i++) {
var raw = lines[i];
if (/^\\s*\\x60\\x60\\x60/.test(raw)) {
if (/^\s*\x60\x60\x60/.test(raw)) {
closeList();
if (inCode) { out.push('</code></pre>'); inCode = false; }
else { out.push('<pre><code>'); inCode = true; }
continue;
}
if (inCode) { out.push(esc(raw) + '\\n'); continue; }
if (inCode) { out.push(esc(raw) + '\n'); continue; }
var line = esc(raw);
var h = raw.match(/^(#{1,4})\\s+(.*)$/);
var h = raw.match(/^(#{1,4})\s+(.*)$/);
if (h) { closeList(); out.push('<h' + h[1].length + '>' + mdInline(esc(h[2])) + '</h' + h[1].length + '>'); continue; }
if (/^\\s*&gt;\\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\\s*&gt;\\s?/, '')) + '</blockquote>'); continue; }
var li = raw.match(/^\\s*[-*+]\\s+(.*)$/);
if (/^\s*&gt;\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\s*&gt;\s?/, '')) + '</blockquote>'); continue; }
var li = raw.match(/^\s*[-*+]\s+(.*)$/);
if (li) { if (listMode !== 'ul') { closeList(); out.push('<ul>'); listMode = 'ul'; } out.push('<li>' + mdInline(esc(li[1])) + '</li>'); continue; }
var oli = raw.match(/^\\s*\\d+[.)]\\s+(.*)$/);
var oli = raw.match(/^\s*\d+[.)]\s+(.*)$/);
if (oli) { if (listMode !== 'ol') { closeList(); out.push('<ol>'); listMode = 'ol'; } out.push('<li>' + mdInline(esc(oli[1])) + '</li>'); continue; }
if (/^\\s*$/.test(raw)) { closeList(); continue; }
if (/^\s*$/.test(raw)) { closeList(); continue; }
closeList();
out.push('<p>' + mdInline(line) + '</p>');
}
@@ -673,10 +600,10 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
// ── 路由 ──
// VIEWS / HOME 由 server 依 CONSOLE_PROFILE 注入(full=全套 7 頁落地駕駛艙;rag=4 頁落地搜尋)。
// 不在 VIEWS 裡的 hash(含被 profile 裁掉的頁)一律導回 HOME——手打 #/cockpit 也進不去。
var VIEWS = ${JSON.stringify(views)};
var HOME = ${JSON.stringify(home)};
var VIEWS = ["cockpit","search","card","workflows","creds","inbox","settings"];
var HOME = "cockpit";
function currentRoute() {
var h = location.hash.replace(/^#\\/?/, '');
var h = location.hash.replace(/^#\/?/, '');
var seg = h.split('/');
return { raw: seg[0] || '', view: VIEWS.indexOf(seg[0]) >= 0 ? seg[0] : HOME, arg: seg.slice(1).join('/') };
}
@@ -724,7 +651,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
}
function init() {
if (S.token) {
fetch('/console/session', { headers: { Authorization: 'Bearer ' + S.token } })
fetch(API_BASE + '/console/session', { headers: { Authorization: 'Bearer ' + S.token } })
.then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('session 失效')); })
.then(function (d) { S.tenant = d.tenant || ''; showApp(); })
.catch(function () {
@@ -736,7 +663,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
}
}
function checkConfigured() {
fetch('/console/auth-status')
fetch(API_BASE + '/console/auth-status')
.then(function (r) { return r.json(); })
.then(function (d) { showAuth(d.configured ? 'login' : 'setup'); })
.catch(function () { showAuth('login'); });
@@ -750,7 +677,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
$('login-submit').addEventListener('click', function () {
var email = $('login-email').value.trim(), password = $('login-password').value;
$('login-status').textContent = '';
fetch('/console/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: password }) })
fetch(API_BASE + '/console/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: password }) })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) { if (!x.ok) { $('login-status').textContent = x.d.error || '登入失敗'; return; } handleAuthResponse(x.d); })
.catch(function (e) { $('login-status').textContent = '請求失敗:' + friendlyErr(e); });
@@ -760,7 +687,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
var email = $('setup-email').value.trim(), pw = $('setup-password').value, pw2 = $('setup-password2').value;
if (pw !== pw2) { $('setup-status').textContent = '兩次密碼不一致'; return; }
$('setup-status').textContent = '';
fetch('/console/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: pw }) })
fetch(API_BASE + '/console/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: pw }) })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) { if (!x.ok) { $('setup-status').textContent = x.d.error || '設定失敗'; return; } handleAuthResponse(x.d); })
.catch(function (e) { $('setup-status').textContent = '請求失敗:' + friendlyErr(e); });
@@ -788,7 +715,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
function loadCockpit() {
var nowTpe = taipeiMonthDay(Date.now()); // 頁首日期=台北日,不吃裝置時區
$('ck-date').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
fetch('/console/dashboard-data')
fetch(API_BASE + '/console/dashboard-data')
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (d) {
var cfg = LIGHT[d.light] || LIGHT.green;
@@ -930,7 +857,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
}).join('<span style="width:6px"></span>') + '</div></div>';
}
function loadLibraryMap() {
fetch('/kbdb/map', { headers: apiHeaders() })
fetch(API_BASE + '/kbdb/map', { headers: apiHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (!x.ok || x.d.success === false) {
@@ -945,7 +872,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
LM.libs = libs; LM.details = {};
renderLmCards(libs, {});
Promise.allSettled(libs.map(function (l) {
return fetch('/kbdb/map/' + encodeURIComponent(l.library), { headers: apiHeaders() })
return fetch(API_BASE + '/kbdb/map/' + encodeURIComponent(l.library), { headers: apiHeaders() })
.then(function (r) { return r.ok ? r.json() : null; });
})).then(function (rs) {
var details = {};
@@ -999,7 +926,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
// 頭部統計=精耕層 live 數字(leo 2026-07-07 裁:45.8 萬 14-E 搬遷遺產已 deprecated、
// 之後要刪——不再拿遺產總數撐場面,只顯示真的新的)。搜尋本身仍搜全庫,資料不藏。
function loadScale() {
fetch('/console/kb-scale-data')
fetch(API_BASE + '/console/kb-scale-data')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
@@ -1019,14 +946,14 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
$('se-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doSearch(); });
function entryTitle(e) {
if (e.page_name) return e.page_name;
var first = String(e.content || '').split(/\\r?\\n/).find(function (l) { return l.trim(); }) || '';
first = first.replace(/^#+\\s*/, '').replace(/^[-*>]\\s*/, '').trim();
var first = String(e.content || '').split(/\r?\n/).find(function (l) { return l.trim(); }) || '';
first = first.replace(/^#+\s*/, '').replace(/^[-*>]\s*/, '').trim();
if (first.length > 60) first = first.slice(0, 60) + '…';
return first || '(無標題・' + (e.entry_type || 'entry') + '';
}
function entrySnippet(e) {
var lines = String(e.content || '').split(/\\r?\\n/).filter(function (l) { return l.trim(); });
var body = lines.slice(1).join(' ').replace(/\\s+/g, ' ').trim();
var lines = String(e.content || '').split(/\r?\n/).filter(function (l) { return l.trim(); });
var body = lines.slice(1).join(' ').replace(/\s+/g, ' ').trim();
if (!body) body = lines.join(' ');
if (body.length > 140) body = body.slice(0, 140) + '…';
return body;
@@ -1037,7 +964,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
$('se-banner').innerHTML = '';
$('se-count').textContent = '查詢中…';
$('se-results').innerHTML = '';
var url = '/kbdb/search?q=' + encodeURIComponent(q) + '&mode=' + (S.semantic ? 'semantic' : 'keyword');
var url = API_BASE + '/kbdb/search?q=' + encodeURIComponent(q) + '&mode=' + (S.semantic ? 'semantic' : 'keyword');
if (S.chip) url += '&entry_type=' + encodeURIComponent(S.chip);
// 藏書地圖進庫(M5/R4):library filter/kbdb/search 既有參數(portal-auth P1 順延項),不新造
if (S.library) url += '&library=' + encodeURIComponent(S.library);
@@ -1081,7 +1008,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
function loadCard() {
if (!S.cardId) { $('cd-main').innerHTML = '<div class="muted" style="padding:24px 0">沒有指定卡片——從「總庫搜尋」點一張進來。</div>'; renderGraphEmpty('尚未載入卡片'); return; }
$('cd-main').innerHTML = '<div class="muted" style="padding:24px 0">載入中…</div>';
fetch('/kbdb/entries/' + encodeURIComponent(S.cardId), { headers: apiHeaders() })
fetch(API_BASE + '/kbdb/entries/' + encodeURIComponent(S.cardId), { headers: apiHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok || !x.d.entry) { $('cd-main').innerHTML = '<div class="err" style="padding:24px 0">' + esc(x.d.error || '讀不到這張卡片') + '</div>'; renderGraphEmpty('讀不到卡片'); return; }
@@ -1109,7 +1036,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
}
function loadNeighbors(name) {
renderGraphEmpty('查詢關聯中…');
fetch('/kbdb/graph/neighbors/' + encodeURIComponent(name), { headers: apiHeaders() })
fetch(API_BASE + '/kbdb/graph/neighbors/' + encodeURIComponent(name), { headers: apiHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (!x.ok) { renderGraphEmpty(x.d.error || ('關聯查詢失敗(HTTP ' + x.status + '')); return; }
@@ -1152,7 +1079,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
// ── 工作流 ──
function loadWorkflows() {
fetch('/webhooks/named', { headers: apiHeaders() })
fetch(API_BASE + '/webhooks/named', { headers: apiHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok) { $('wf-list').innerHTML = '<div class="err">' + esc(x.d.error || '讀取失敗') + '</div>'; return; }
@@ -1229,7 +1156,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
function fetchWfRuns(name) {
var box = document.getElementById('wf-runs');
if (!box) return;
fetch('/workflows/' + encodeURIComponent(name) + '/executions?limit=3', { headers: apiHeaders() })
fetch(API_BASE + '/workflows/' + encodeURIComponent(name) + '/executions?limit=3', { headers: apiHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
var runs = (d.data && d.data.executions) || [];
@@ -1247,7 +1174,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
function triggerWf(name, btn) {
btn.disabled = true; btn.textContent = '執行中…';
var msg = document.getElementById('wf-runmsg');
fetch('/webhooks/named/' + encodeURIComponent(name) + '/trigger', {
fetch(API_BASE + '/webhooks/named/' + encodeURIComponent(name) + '/trigger', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),
body: '{}'
@@ -1279,7 +1206,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
out.innerHTML = '<div class="muted">查詢中…</div>';
Promise.allSettled([
fetch(REGISTRY_BASE + '/components/search?q=' + encodeURIComponent(q)).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }),
fetch('/public-recipes?q=' + encodeURIComponent(q)).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
fetch(API_BASE + '/public-recipes?q=' + encodeURIComponent(q)).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
]).then(function (rs) {
var html = '';
var comp = rs[0];
@@ -1320,9 +1247,9 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
var name = $('cred-name').value.trim(), service = $('cred-service').value.trim(), value = $('cred-secret').value;
var st = $('cred-form-status');
if (!name || !value) { st.innerHTML = '<span class="err">名稱與密文值必填</span>'; return; }
if (!/^\\w+$/.test(name)) { st.innerHTML = '<span class="err">名稱只能包含英文字母、數字和底線</span>'; return; }
if (!/^\w+$/.test(name)) { st.innerHTML = '<span class="err">名稱只能包含英文字母、數字和底線</span>'; return; }
st.textContent = '保存中…';
fetch('/credentials', {
fetch(API_BASE + '/credentials', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),
body: JSON.stringify({ name: name, value: value, service: service || undefined })
@@ -1341,7 +1268,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
var credOpen = '';
var credCache = [];
function loadCreds() {
fetch('/credentials/catalog', { headers: apiHeaders() })
fetch(API_BASE + '/credentials/catalog', { headers: apiHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok || x.d.success === false) {
@@ -1389,7 +1316,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
if (!val.trim()) { if (msg) msg.innerHTML = '<span class="err">先貼上新值</span>'; return; }
rep.disabled = true;
var cur = credCache.find(function (c) { return c.name === name; });
fetch('/credentials/' + encodeURIComponent(name), {
fetch(API_BASE + '/credentials/' + encodeURIComponent(name), {
method: 'PUT',
headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),
body: JSON.stringify({ value: val, service: cur && cur.service ? cur.service : undefined })
@@ -1484,7 +1411,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
// 狀態重繪(該筆立即移出/回到三欄),失敗誠實 toast、按鈕解鎖可重試。
function triageAct(id, action, btn) {
btn.disabled = true;
fetch('/console/triage-check', {
fetch(API_BASE + '/console/triage-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + S.token },
body: JSON.stringify({ entry_id: id, action: action })
@@ -1510,7 +1437,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
function loadTriage() {
var out = $('tg-out');
out.innerHTML = '<div class="muted" style="padding:14px 0">載入中…</div>';
fetch('/console/triage-data', { headers: { Authorization: 'Bearer ' + S.token } })
fetch(API_BASE + '/console/triage-data', { headers: { Authorization: 'Bearer ' + S.token } })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (!x.ok) {
@@ -1528,7 +1455,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
// ── 設定 ──
function loadSettings() {
// vectorize 狀態:探測既有 /kbdb/search 的 mode / capability_hint(無新端點,誠實讀降級訊號)
fetch('/kbdb/search?q=mira&mode=semantic', { headers: apiHeaders() })
fetch(API_BASE + '/kbdb/search?q=mira&mode=semantic', { headers: apiHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
var on = d.mode === 'semantic';
@@ -1543,7 +1470,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
$('st-vec-state').textContent = '偵測失敗';
});
// MCP token TTL(誠實佔位:只顯示目前生效值,不假裝能改)
fetch('/console/settings-data')
fetch(API_BASE + '/console/settings-data')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) { $('st-mcp-ttl').textContent = '讀取失敗'; return; }
@@ -1554,8 +1481,8 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
.catch(function () { $('st-mcp-ttl').textContent = '讀取失敗'; });
// 系統資訊:worker 自報 + 精耕層規模(14-E 遺產總數不再顯示,leo 2026-07-07 裁)
Promise.allSettled([
fetch('/').then(function (r) { return r.json(); }),
fetch('/console/kb-scale-data').then(function (r) { return r.ok ? r.json() : null; })
fetch(API_BASE + '/').then(function (r) { return r.json(); }),
fetch(API_BASE + '/console/kb-scale-data').then(function (r) { return r.ok ? r.json() : null; })
]).then(function (rs) {
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;
@@ -1576,7 +1503,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
if (!email) { st.innerHTML = '<span class="err">請輸入 Email(沿用原本的也要填)</span>'; return; }
if (!newPw || newPw.length < 8) { st.innerHTML = '<span class="err">新密碼至少 8 碼</span>'; return; }
st.textContent = '更新中…';
fetch('/console/setup/reset', {
fetch(API_BASE + '/console/setup/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: oldPw, email: email, password: newPw })
@@ -1595,7 +1522,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
});
$('st-logout').addEventListener('click', function () {
var t = S.token;
if (t) fetch('/console/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + t } }).catch(function () {});
if (t) fetch(API_BASE + '/console/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + t } }).catch(function () {});
S.token = ''; S.tenant = '';
localStorage.removeItem('arcrun_console_session');
location.hash = '';
@@ -1607,17 +1534,3 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
</script>
</body>
</html>
`;
}
// GET /console — Mira Console 完整版(Arcrun#3 console 系)。registry base 現算:同帳號
// arcrun-registry workerWORKER_SUBDOMAIN 沿用既有 KBDB_BASE_URL 那套組法),沒設就退回官方公開 registry。
consoleRouter.get('/console', (c) => {
const subdomain = c.env.WORKER_SUBDOMAIN;
const registryBase = subdomain
? `https://arcrun-registry.${subdomain}.workers.dev`
: 'https://registry.arcrun.dev';
// 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG"
// 頁面組成(console-profile-trim):CONSOLE_PROFILE 未設=full 全套;"rag"=企業產品樣(搜尋落地)
return c.html(renderConsoleHtml(registryBase, c.env.CONSOLE_BRAND || 'Arcrun', c.env.CONSOLE_PROFILE || 'full'));
});
@@ -1,44 +1,9 @@
/**
* RAG Portal 前端殼 — P3GET /portal 單檔 HTMLportal-auth design §1 D-1/§6Gitea #24/#25
*
* 形態(D-1):cypher-executor 的新路由、**獨立 HTML 殼**——不在 console 上加 if(「Admin
* Console 現狀不動」鐵律)。樣式重用 console 的設計語言(紙感/明體標題/琥珀強調/深淺主題),
* 取捨=**複製樣式子集而非抽共用模組**:抽共用要動 console.ts(違「console 不動」),且兩頁
* 受眾不同(owner vs 同仁)預期各自演化;代價=樣式雙份、改版要兩邊同步(PR 說明誠實記)。
*
* 頁面(design §6,leo 頁面級拍板):
* - 登入頁(brandCONSOLE_BRAND;未登入只見這個殼)
* - 搜尋頁(落地頁):keyword/semantic/graph 三模式+結果含 source 溯源+卡片詳頁
* - 設定頁:改自己密碼、看自己角色與可查庫、主題切換
* - 工作流頁:D-8 定案 admin 可見(PORTAL_SHOW_WORKFLOWS 可調 all/off);唯讀、不開 trigger
* - graph 模式按 D-4 粗閘顯示(/portal/session 的 graph_allowed
* - 管理頁(P4admin-only nav):帳號管理(列表/新增/停用/重設密碼+每帳號勾選可查庫
* 含 ["*"] 全庫)+庫目錄管理(登記/停用/graph_source 標記)。一次性密碼只在畫面顯示
* 一次(server 不留明碼);一般用戶 nav 不顯示、直打 /portal/admin/* 也是 403role 閘)。
* - Mira 專屬頁(駕駛艙/分流台/憑證/專案)一律不進
*
* 安全(design §3.3 關鍵差異 vs console):
* - 本頁 JS **沒有任何租戶字串、沒有 X-Arcrun-API-Key**——只持 portal session token
* localStorage `arcrun_portal_session`),一切資料走 /portal/data/*server-side enforce)。
* - 前端的「藏」(graph 模式不顯示、工作流頁不顯示)只是 UX;真閘在 /portal/data/* 路由層
* 403/404),curl 直打也繞不過(#48 guard 精神)。
* - 所有動態內容經 esc() 跳脫(防 XSS,同 console 慣例);密碼欄位值不進 log、不進 URL。
*
* 薄殼(rule 07):零業務邏輯,只 fetch /portal/* 端點渲染。
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
export const portalUiRouter = new Hono<{ Bindings: Bindings }>();
function renderPortalHtml(brand: string, sourceWebBase = ''): string {
return `<!doctype html>
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${brand} Portal</title>
<title>Arcrun Portal</title>
<script>
// 主題預載(防閃色):預設淺色,選擇存 localStorage(與 console 同設計語言、不同 key
document.documentElement.setAttribute('data-theme', (function () {
@@ -198,6 +163,8 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
.switch.on i { transform: translateX(24px); }
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
</style>
<script src="/config.js"></script>
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";</script>
</head>
<body>
@@ -205,7 +172,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
<div class="authwrap view" id="v-login">
<div class="authbox">
<div>
<div class="brand">${brand}</div>
<div class="brand">Arcrun</div>
<div class="brand-sub">知識入口・Portal</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;text-align:left">
@@ -222,7 +189,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
<!-- 主應用 -->
<div id="shell">
<div id="sidenav">
<div class="logo">${brand}</div>
<div class="logo">Arcrun</div>
<div class="nav" data-nav="search">搜尋</div>
<div class="nav hide" id="nav-map" data-nav="map">總圖</div>
<div class="nav hide" id="nav-upload" data-nav="upload">上傳</div>
@@ -400,9 +367,16 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
<script>
(function () {
var API_BASE = window.ARCRUN_API_BASE || '';
'use strict';
// 台北時間 helperlib/taipei-time.ts 注入,console 同套——顯示不隨裝置時區漂移)
${TAIPEI_CLIENT_JS}
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() }; }
var $ = function (id) { return document.getElementById(id); };
// ── 狀態:只有 session token**沒有租戶字串、沒有 API key**design §3.3)──
@@ -455,7 +429,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
function parseAtMs(v) {
if (v == null || v === '') return null;
if (typeof v === 'number') return v < 1e12 ? v * 1000 : v;
if (/^\\d+$/.test(v)) { var n = Number(v); return n < 1e12 ? n * 1000 : n; }
if (/^\d+$/.test(v)) { var n = Number(v); return n < 1e12 ? n * 1000 : n; }
var s = /T/.test(v) ? v : String(v).replace(' ', 'T') + 'Z';
var ms = Date.parse(s);
return isNaN(ms) ? null : ms;
@@ -466,31 +440,31 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
// ── 極簡 Markdown 渲染(先全 escape 再上格式,防 XSSconsole 同款)──
function mdInline(s) {
return s
.replace(/\\*\\*([^*]+)\\*\\*/g, '<b>$1</b>')
.replace(/(^|[^\\\\])\\x60([^\\x60]+)\\x60/g, '$1<code>$2</code>');
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
.replace(/(^|[^\\])\x60([^\x60]+)\x60/g, '$1<code>$2</code>');
}
function mdRender(src) {
var lines = String(src || '').split(/\\r?\\n/);
var lines = String(src || '').split(/\r?\n/);
var out = [], inCode = false, listMode = '';
function closeList() { if (listMode) { out.push(listMode === 'ul' ? '</ul>' : '</ol>'); listMode = ''; } }
for (var i = 0; i < lines.length; i++) {
var raw = lines[i];
if (/^\\s*\\x60\\x60\\x60/.test(raw)) {
if (/^\s*\x60\x60\x60/.test(raw)) {
closeList();
if (inCode) { out.push('</code></pre>'); inCode = false; }
else { out.push('<pre><code>'); inCode = true; }
continue;
}
if (inCode) { out.push(esc(raw) + '\\n'); continue; }
if (inCode) { out.push(esc(raw) + '\n'); continue; }
var line = esc(raw);
var h = raw.match(/^(#{1,4})\\s+(.*)$/);
var h = raw.match(/^(#{1,4})\s+(.*)$/);
if (h) { closeList(); out.push('<h' + h[1].length + '>' + mdInline(esc(h[2])) + '</h' + h[1].length + '>'); continue; }
if (/^\\s*&gt;\\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\\s*&gt;\\s?/, '')) + '</blockquote>'); continue; }
var li = raw.match(/^\\s*[-*+]\\s+(.*)$/);
if (/^\s*&gt;\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\s*&gt;\s?/, '')) + '</blockquote>'); continue; }
var li = raw.match(/^\s*[-*+]\s+(.*)$/);
if (li) { if (listMode !== 'ul') { closeList(); out.push('<ul>'); listMode = 'ul'; } out.push('<li>' + mdInline(esc(li[1])) + '</li>'); continue; }
var oli = raw.match(/^\\s*\\d+[.)]\\s+(.*)$/);
var oli = raw.match(/^\s*\d+[.)]\s+(.*)$/);
if (oli) { if (listMode !== 'ol') { closeList(); out.push('<ol>'); listMode = 'ol'; } out.push('<li>' + mdInline(esc(oli[1])) + '</li>'); continue; }
if (/^\\s*$/.test(raw)) { closeList(); continue; }
if (/^\s*$/.test(raw)) { closeList(); continue; }
closeList();
out.push('<p>' + mdInline(line) + '</p>');
}
@@ -506,23 +480,23 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
function entrySource(e) { var m = entryMeta(e); return typeof m.source === 'string' ? m.source : ''; }
// 來源回溯超連結:PORTAL_SOURCE_WEB_BASE 有設且 source 是 gitea:// 才給 href
// 其餘回空字串=維持純文字(行為與未設時一字不變)。chunk 錨點(#n)捨棄。
var SOURCE_WEB_BASE = ${JSON.stringify(sourceWebBase)};
var SOURCE_WEB_BASE = "";
function srcHref(src) {
if (!SOURCE_WEB_BASE || String(src).indexOf('gitea://') !== 0) return '';
var p = String(src).slice(8).replace(/#\\d+$/, '');
return SOURCE_WEB_BASE.replace(/\\/+$/, '') + '/' + p.split('/').map(encodeURIComponent).join('/');
var p = String(src).slice(8).replace(/#\d+$/, '');
return SOURCE_WEB_BASE.replace(/\/+$/, '') + '/' + p.split('/').map(encodeURIComponent).join('/');
}
function entryLib(e) { var m = entryMeta(e); return (typeof m.library === 'string' && m.library) ? m.library : 'general'; }
function entryTitle(e) {
if (e.page_name) return e.page_name;
var first = String(e.content || '').split(/\\r?\\n/).find(function (l) { return l.trim(); }) || '';
first = first.replace(/^#+\\s*/, '').replace(/^[-*>]\\s*/, '').trim();
var first = String(e.content || '').split(/\r?\n/).find(function (l) { return l.trim(); }) || '';
first = first.replace(/^#+\s*/, '').replace(/^[-*>]\s*/, '').trim();
if (first.length > 60) first = first.slice(0, 60) + '…';
return first || '(無標題・' + (e.entry_type || 'entry') + '';
}
function entrySnippet(e) {
var lines = String(e.content || '').split(/\\r?\\n/).filter(function (l) { return l.trim(); });
var body = lines.slice(1).join(' ').replace(/\\s+/g, ' ').trim();
var lines = String(e.content || '').split(/\r?\n/).filter(function (l) { return l.trim(); });
var body = lines.slice(1).join(' ').replace(/\s+/g, ' ').trim();
if (!body) body = lines.join(' ');
if (body.length > 140) body = body.slice(0, 140) + '…';
return body;
@@ -542,7 +516,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
});
}
function currentRoute() {
var h = location.hash.replace(/^#\\/?/, '');
var h = location.hash.replace(/^#\/?/, '');
var seg = h.split('/');
var ok = allowedViews();
return { raw: seg[0] || '', view: ok.indexOf(seg[0]) >= 0 ? seg[0] : HOME, arg: seg.slice(1).join('/') };
@@ -607,7 +581,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
}
function boot() {
if (!S.token) { showAuth(); return; }
fetch('/portal/session', { headers: authHeaders() })
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok) { dropSession(); return; }
@@ -628,7 +602,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
if (!email || !password) { $('login-status').textContent = '請輸入 Email 與密碼'; return; }
$('login-submit').disabled = true;
$('login-status').textContent = '';
fetch('/portal/login', {
fetch(API_BASE + '/portal/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, password: password })
@@ -645,7 +619,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
.catch(function (e) { $('login-submit').disabled = false; $('login-status').textContent = friendlyErr(e); });
}
$('st-logout').addEventListener('click', function () {
fetch('/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });
fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });
dropSession();
});
@@ -674,7 +648,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
$('se-results').style.display = '';
$('se-count').textContent = '查詢中…';
$('se-results').innerHTML = '';
var url = '/portal/data/search?q=' + encodeURIComponent(q) + '&mode=' + (S.mode === 'semantic' ? 'semantic' : 'keyword');
var url = API_BASE + '/portal/data/search?q=' + encodeURIComponent(q) + '&mode=' + (S.mode === 'semantic' ? 'semantic' : 'keyword');
fetch(url, { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
@@ -719,7 +693,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
if (!q) { toast('請先輸入問題'); return; }
$('ai-go').disabled = true;
$('ai-out').innerHTML = '<div class="muted" style="margin-top:12px">AI 查閱知識庫中…</div>';
fetch('/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() })
fetch(API_BASE + '/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('ai-go').disabled = false;
@@ -768,7 +742,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
$('se-graphout').style.display = '';
$('se-count').textContent = '查詢關聯中…';
renderGraphEmpty('查詢關聯中…');
fetch('/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() })
fetch(API_BASE + '/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
@@ -831,7 +805,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
function loadCard() {
if (!S.cardId) { $('cd-main').innerHTML = '<div class="muted">沒有指定卡片——從「搜尋」點一張進來。</div>'; return; }
$('cd-main').innerHTML = '<div class="muted">載入中…</div>';
fetch('/portal/data/entries/' + encodeURIComponent(S.cardId), { headers: authHeaders() })
fetch(API_BASE + '/portal/data/entries/' + encodeURIComponent(S.cardId), { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
@@ -873,7 +847,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
$('map-md-link').innerHTML = SOURCE_WEB_BASE
? '<a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
: '存在知識庫的 00-MAP.md';
fetch('/portal/data/graph/overview', { headers: authHeaders() })
fetch(API_BASE + '/portal/data/graph/overview', { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
@@ -979,7 +953,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
// ── 工作流(唯讀)──
function loadWorkflows() {
$('wf-list').innerHTML = '<div class="muted">載入中…</div>';
fetch('/portal/data/workflows', { headers: authHeaders() })
fetch(API_BASE + '/portal/data/workflows', { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
@@ -1031,7 +1005,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
}
function uploadOneFile(file) {
var row = uploadStatusRow(file.name);
if (!/\\.(md|txt)$/i.test(file.name)) {
if (!/\.(md|txt)$/i.test(file.name)) {
setRowStatus(row, false, '已略過', '只收 .md / .txt 檔。');
return;
}
@@ -1039,7 +1013,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
reader.onerror = function () { setRowStatus(row, false, '讀檔失敗', '瀏覽器讀不到這個檔案。'); };
reader.onload = function () {
setRowStatus(row, null, '上傳中…', '');
fetch('/portal/data/upload', {
fetch(API_BASE + '/portal/data/upload', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ filename: file.name, content_b64: b64FromDataUrl(reader.result) })
@@ -1075,6 +1049,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
var adminLibs = []; // 庫目錄快取(渲染每帳號的庫勾選用)
function adminApi(method, path, body) {
path = API_BASE + path;
var opt = { method: method, headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()) };
if (body !== undefined) opt.body = JSON.stringify(body);
return fetch(path, opt).then(function (r) {
@@ -1357,7 +1332,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
$('st-pw-save').disabled = true;
st.textContent = '';
fetch('/portal/me/password', {
fetch(API_BASE + '/portal/me/password', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ current: oldPw, 'new': newPw })
@@ -1379,12 +1354,4 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
})();
</script>
</body>
</html>`;
}
// GET /portal — Portal HTML 殼(無 auth:回的是純殼,資料全在 /portal/data/* session 閘後)。
// 未 bootstrap 任何 portal_user 時登入必失敗(「尚未啟用」的誠實形態),不影響 console。
portalUiRouter.get('/portal', (c) => {
const brand = c.env.CONSOLE_BRAND || 'Arcrun';
return c.html(renderPortalHtml(brand, c.env.PORTAL_SOURCE_WEB_BASE || ''));
});
</html>
+205
View File
@@ -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.jsruntime 決定 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 Portalportal-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.');
+3
View File
@@ -0,0 +1,3 @@
name = "arcrun-console-ui"
pages_build_output_dir = "public"
compatibility_date = "2026-07-21"
+17 -6
View File
@@ -20,19 +20,32 @@ import { resumeRouter } from './routes/resume';
import { executionsRouter } from './routes/executions';
import { initSeedRouter } from './routes/init-seed';
import { kbdbProxyRouter } from './routes/kbdb-proxy';
import { consoleRouter } from './routes/console';
import { consoleAuthRouter } from './routes/console-auth';
import { consoleDashboardRouter } from './routes/console-dashboard';
import { portalRouter } from './routes/portal';
import { portalDataRouter } from './routes/portal-data';
import { portalUiRouter } from './routes/portal-ui';
const app = new Hono<{ Bindings: Bindings }>();
// 全域 CORS(允許 arcrun.dev landing page 帶 credentials 存取)
//
// 2026-07-21 cypher-ui-splitconsole/portal UI 搬到 Cloudflare Pages 後,前端與本 API
// **不再同源**,故 UI 站的 origin 必須進白名單,否則所有 fetch 會被瀏覽器擋。
// 允許來源=上面兩個 landing + UI_ORIGINSwrangler.toml [vars] 逗號分隔,實例自填
// 自己的 Pages 網域,如 https://arcrun-console-ui.pages.dev)。
// 刻意不用萬用字元:credentials:true 與 `*` 在 CORS 規格上互斥,且 Authorization/
// X-Arcrun-API-Key 是憑證等級標頭,開全域等於誰都能代打。
const STATIC_ORIGINS = ['https://arcrun.dev', 'https://www.arcrun.dev'];
app.use('*', cors({
origin: ['https://arcrun.dev', 'https://www.arcrun.dev'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
origin: (origin, c) => {
const extra = (c.env.UI_ORIGINS || '')
.split(',')
.map((s: string) => s.trim())
.filter(Boolean);
return [...STATIC_ORIGINS, ...extra].includes(origin) ? origin : null;
},
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'X-Arcrun-API-Key'],
credentials: true,
}));
@@ -54,12 +67,10 @@ app.route('/', resumeRouter);
app.route('/', executionsRouter); // LI SDD M2.1: /executions/* + /workflows/:name/executions
app.route('/', initSeedRouter); // 薄殼原則:seed recipe 是 API 行為(rule 07,壓測 §4.1
app.route('/', kbdbProxyRouter); // kbdb-base 9.5KBDB 資料層 proxy(讓 CLI 透過 cypher 達 KBDB,純轉發)
app.route('/', consoleRouter); // Arcrun#3:搜尋/控制台頁 v0(單檔 HTML+原生 JS,薄殼)
app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單 email+password 登入(單一管理員帳密,非多租戶)
app.route('/', consoleDashboardRouter); // T-cockpit ②:駕駛艙 dashboard(聚合 KBDB dash_* entries,無需登入唯讀)
app.route('/', portalRouter); // portal-auth P2#24/#25):RAG Portal 多人授權——用戶模型+認證 API
app.route('/', portalDataRouter); // portal-auth P3/portal/data/* server-side enforceowner_idlibrary 注入,安全核心)
app.route('/', portalUiRouter); // portal-auth P3GET /portal 單檔 HTML 殼(登入/搜尋/設定;獨立於 console)
// Worker 導出(fetch + scheduled
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick
+4 -300
View File
@@ -2,9 +2,12 @@
* arcrun console 駕駛艙 dashboardT-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工;
* 2026-07-07 fix/console-dashboard-live-datastale 資料整修,總管交辦)
*
* ⚠️ 2026-07-21 cypher-ui-split 第一刀:本檔只剩 **API 端點**。原本的
* `GET /console/dashboard`(單檔 HTML)已搬到 `console-ui/`Cloudflare Pages 靜態站),
* 頁面改由 Pages 託管、資料仍打本檔的 `/console/dashboard-data`。
*
* 端點皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
* - GET /console/dashboard-data:聚合 JSON。
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),每 60 秒自動刷新。
* - GET /console/kb-scale-data:精耕層規模(wiki 卡/三元組/已嵌入;2026-07-07 leo 裁
* 「遺產庫不用顯示」後 console 頭部統計改讀這裡)。
* - GET /console/settings-data:設定頁誠實系統值(MCP token TTL 佔位)。
@@ -69,7 +72,6 @@ import {
taipeiDayKey,
} from '../lib/console-dashboard-model';
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
@@ -522,301 +524,3 @@ consoleDashboardRouter.post('/console/triage-check', async (c) => {
return c.json({ success: true, entry_id: entryId, action, status: action === 'restore' ? 'new' : 'done' });
});
function renderDashboardHtml(brand: string): string {
return `<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${brand} 駕駛艙</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>
</head>
<body>
<main>
<div class="pagehead">
<div class="title serif">${brand}<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 () {
// 台北時間 helperlib/taipei-time.ts 注入,與 server 判定同一套——顯示不隨看的裝置時區漂移)
${TAIPEI_CLIENT_JS}
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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('/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>
`;
}
// GET /console/dashboard — 駕駛艙頁(無需登入;純渲染 dashboard-data,無互動、無說明文字)
// 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG"
// CONSOLE_PROFILE=ragconsole-profile-trim):駕駛艙不屬企業版頁面 → 302 回 /console。
// 選 302 不選 404:舊書籤/外鏈直接落回產品頁,不給死路(只裁 UI 頁面,資料端點行為不動)。
consoleDashboardRouter.get('/console/dashboard', (c) => {
if ((c.env.CONSOLE_PROFILE || 'full') === 'rag') return c.redirect('/console', 302);
return c.html(renderDashboardHtml(c.env.CONSOLE_BRAND || 'Arcrun'));
});