系統資訊
@@ -462,10 +455,10 @@
-
駕駛艙
+
搜尋
工作流
-
分流台
+
設定
@@ -600,8 +593,8 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
// ── 路由 ──
// VIEWS / HOME 由 server 依 CONSOLE_PROFILE 注入(full=全套 7 頁落地駕駛艙;rag=4 頁落地搜尋)。
// 不在 VIEWS 裡的 hash(含被 profile 裁掉的頁)一律導回 HOME——手打 #/cockpit 也進不去。
- var VIEWS = ["cockpit","search","card","workflows","creds","inbox","settings"];
- var HOME = "cockpit";
+ var VIEWS = ["search","card","workflows","settings"];
+ var HOME = "search";
function currentRoute() {
var h = location.hash.replace(/^#\/?/, '');
var seg = h.split('/');
diff --git a/console-ui/public/index.html b/console-ui/public/index.html
new file mode 100644
index 0000000..e931c5a
--- /dev/null
+++ b/console-ui/public/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
Arcrun RAG
+
+
+
+
+
+
+
+
正在前往搜尋頁… 沒有自動跳轉請點這裡
+
+
diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html
index 366f4e5..c207785 100644
--- a/console-ui/public/portal/index.html
+++ b/console-ui/public/portal/index.html
@@ -164,7 +164,7 @@
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
-
+
diff --git a/console-ui/scripts/build.mjs b/console-ui/scripts/build.mjs
index 786bd77..0587a61 100644
--- a/console-ui/scripts/build.mjs
+++ b/console-ui/scripts/build.mjs
@@ -18,7 +18,7 @@
* 故注入 window.ARCRUN_API_BASE,並把 fetch 的相對路徑改成 API_BASE + path。
* 見下方 rewriteFetchPaths()。
*/
-import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -30,13 +30,28 @@ const OUT = join(ROOT, 'public');
// ── 建置期組態(原本是 Worker 的 env var,現在是建置參數)────────────────
// Pages 是靜態站,沒有 per-request env;品牌/profile 這類「一個部署一個值」的
// 設定改在建置時決定(要換值=重跑 build 再部署,符合靜態站模型)。
+// 具名部署目標(deploy.targets.json):一個目標=帳號+profile+apiBase 綁在一起。
+// 帶 DEPLOY_TARGET=personal|enterprise 就套用該組值;個別環境變數仍可覆蓋(除錯用)。
+// 立此檔的原因見 deploy.targets.json 的 _readme——散在部署指令裡的參數帶漏過三次。
+const TARGET_NAME = process.env.DEPLOY_TARGET || '';
+let TARGET = {};
+if (TARGET_NAME) {
+ const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
+ TARGET = targets[TARGET_NAME];
+ if (!TARGET) {
+ const names = Object.keys(targets).filter((k) => !k.startsWith('_'));
+ throw new Error(`未知的 DEPLOY_TARGET:"${TARGET_NAME}"。可用:${names.join(' / ')}`);
+ }
+ console.log(`部署目標:${TARGET_NAME} — ${TARGET.description}`);
+}
+
const CFG = {
- brand: process.env.CONSOLE_BRAND || 'Arcrun',
- profile: process.env.CONSOLE_PROFILE || 'full',
+ brand: process.env.CONSOLE_BRAND || TARGET.brand || 'Arcrun',
+ profile: process.env.CONSOLE_PROFILE || TARGET.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 || '',
+ apiBase: process.env.ARCRUN_API_BASE || TARGET.apiBase || '',
};
/**
@@ -46,8 +61,24 @@ const CFG = {
* (如 console.ts 的 rag/views/home 由 profile 推導)。只搬模板=把那段推導邏輯
* 複製一份到本檔=雙份真相會漂移。連 body 一起求值 → 推導邏輯永遠只有一份。
*/
+/**
+ * renderer 原始檔的位置:本專案 `console-ui/src/` 優先,找不到才回退 cypher-executor。
+ *
+ * 為什麼要這層(2026-07-22 修):`5a16484` 把 UI 搬出 cypher-executor 時,
+ * **刪了 console.ts / portal-ui.ts 卻只搬走 build 產物(HTML),原始檔沒跟著搬**
+ * → build.mjs 讀不到來源,`npm run build` 從那天起就 ENOENT 死掉,
+ * 線上 HTML 是刪檔前烤好的、之後再也無法重建(profile 改了也不會生效)。
+ * 現已從 git 撈回放進 console-ui/src/——UI 原始碼跟著 UI 專案走,才是那一刀的原意。
+ * console-dashboard.ts 仍在 cypher-executor(它同時含 API),故保留回退路徑。
+ */
+function resolveSource(file) {
+ const local = join(ROOT, 'src', file.replace(/^routes\//, ''));
+ if (existsSync(local)) return local;
+ return join(SRC, file);
+}
+
function extractRendererBody(file, fnName) {
- const code = readFileSync(join(SRC, file), 'utf8');
+ const code = readFileSync(resolveSource(file), 'utf8');
const start = code.indexOf(`function ${fnName}(`);
if (start < 0) throw new Error(`找不到 ${fnName} in ${file}`);
const braceStart = code.indexOf('{', code.indexOf(')', start));
diff --git a/console-ui/scripts/deploy.mjs b/console-ui/scripts/deploy.mjs
new file mode 100644
index 0000000..6bb40ac
--- /dev/null
+++ b/console-ui/scripts/deploy.mjs
@@ -0,0 +1,51 @@
+/**
+ * deploy.mjs — 依具名目標部署 console-ui 到 Cloudflare Pages
+ *
+ * 用法:npm run deploy:personal / npm run deploy:enterprise
+ *
+ * 為什麼不直接用 `wrangler pages deploy`(2026-07-22 leo 立,實際踩到才補):
+ * **兩個帳號都有名為 arcrun-console-ui 的 Pages 專案**
+ * · leo21c → arcrun-console-ui.pages.dev(個人版 console)
+ * · uncle6 → 綁 rag-demo.arcrun.dev(企業版 demo 站)
+ * wrangler 若 OAuth 登入在 uncle6,`--project-name arcrun-console-ui` 會部到 demo 站上。
+ * 本腳本強制帶目標的 accountId,並在部署前印出目標,避免部錯帳號。
+ *
+ * 同時把 profile/apiBase 綁進目標(deploy.targets.json),不再靠部署者記得帶環境變數——
+ * 帶漏過三次:demo 站漏 profile=rag 顯示成個人版、兩站漏 apiBase 導致登入 405。
+ */
+import { readFileSync } from 'node:fs';
+import { spawnSync } from 'node:child_process';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
+const names = Object.keys(targets).filter((k) => !k.startsWith('_'));
+
+const name = process.argv[2];
+if (!name || !targets[name]) {
+ console.error(`用法:npm run deploy:
\n可用目標:${names.join(' / ')}`);
+ if (name) console.error(`(收到未知目標:"${name}")`);
+ process.exit(1);
+}
+const t = targets[name];
+
+console.log(`\n部署目標:${name}`);
+console.log(` 說明 :${t.description}`);
+console.log(` 帳號 :${t.accountId}`);
+console.log(` 專案 :${t.projectName}`);
+console.log(` profile :${t.profile}`);
+console.log(` apiBase :${t.apiBase}\n`);
+
+const env = { ...process.env, DEPLOY_TARGET: name, CLOUDFLARE_ACCOUNT_ID: t.accountId };
+
+const build = spawnSync('node', [join(ROOT, 'scripts', 'build.mjs')], { stdio: 'inherit', env });
+if (build.status !== 0) process.exit(build.status ?? 1);
+
+// --commit-dirty:本地部署常有未提交變更,不因此中斷
+const deploy = spawnSync(
+ 'npx',
+ ['wrangler', 'pages', 'deploy', 'public', '--project-name', t.projectName, '--commit-dirty=true'],
+ { stdio: 'inherit', cwd: ROOT, env },
+);
+process.exit(deploy.status ?? 1);
diff --git a/console-ui/src/console-dashboard.ts b/console-ui/src/console-dashboard.ts
new file mode 100644
index 0000000..c403443
--- /dev/null
+++ b/console-ui/src/console-dashboard.ts
@@ -0,0 +1,822 @@
+/**
+ * arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工;
+ * 2026-07-07 fix/console-dashboard-live-data:stale 資料整修,總管交辦)
+ *
+ * 端點皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
+ * - 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 佔位)。
+ *
+ * ── 2026-07-07 二修(fix/console-truth-audit):「今日完成/今日路線」接 sprint 任務板 ──
+ * leo 拍板(「今天做了這麼多事……其實就是我們到底完成了多少事」):dash_task 同 dash_wait
+ * 病(沒活管線),真相源=sprint 檔「## 任務板」勾選。比照「等你的事」#36 模式:同一輪
+ * Gitea fetch(90s 快取共用)解析任務板,「今日完成」只認「完成(今天台北日)」標記,
+ * dash_task 降 fallback;板檔今天沒 commit → 頁面誠實標「今日任務板未更新(最後 N 小時前)」。
+ *
+ * ── 2026-07-07 整修:每個區塊都讀「live 一手資料」,讀不到就誠實標示,不擺 stale 殘骸 ──
+ *
+ * 資料源診斷(leo 抱怨「等你的事錯了好幾天」的根因):
+ * - dash_wait(等你的事舊資料源)最後寫入 2026-07-04,**沒有活的維護管線**——等leo清單#11
+ * 已於 07-05 銷案(誤判),dashboard 卻繼續掛著它。真相源其實是 InkStoneCo sprint 檔的
+ * 「## 等 leo 清單」表格(progress-guard routine 每日核實維護)。
+ * - dash_task 的 scope:"today" 沒有日期——07-04 的「今日路線」到 07-07 還被當今天的。
+ * - dash_beat 是唯一有活管線的 dash_*(progress-guard/cloud-worker/watchdog 每日寫入)。
+ *
+ * 整修後的資料源:
+ * 等你的事 → 首選 Gitea sprint 檔等leo清單(需 GITEA_BASE_URL var + GITEA_TOKEN secret;
+ * 進程內 fetch Gitea API,非 GitHub、無 D20 疑慮);讀不到 → fallback dash_wait
+ * 但必標 age + stale 警示;連 dash_wait 都沒有 → 誠實顯示「管線未接」。
+ * 今日路線 → dash_task,但以台北日曆日判 is_today;非今日寫入=降級顯示「最後路線(N 天前)」,
+ * 不假裝是今天的。今日無寫入時明講管線缺口(sprint 任務板→dashboard 無自動投影)。
+ * 系統狀況 → live 健康信號:KBDB /health、/embed/backfill/status(enabled:false 誠實顯示)、
+ * kbdb-graph-plugin /triplets/stats、workflow 總數(KBDB entry_type=workflow)。
+ * 總庫規模 → KBDB entries 總數/wiki_card 數/triplets 數,全部 live API 一手拉。
+ *
+ * 燈號判定(寫死在端點,頁面只渲染):
+ * red = 「今日寫入」的任務有 blocked,或最新心跳距今 > 240 分(台北 09:00-22:00 窗內判定),
+ * 或 KBDB /health 打不通。stale 殘任務**不再**觸發燈號(07-04 的 blocked 不該讓 07-07 亮紅)。
+ * yellow = 無 red 條件,但今日任務有非標準 status(late/behind 等落後標記)。
+ * green = 其餘。
+ *
+ * 薄殼定位:聚合端點(能力長在 API 一次,rule 07 正例)——頁面零業務邏輯;判定純函式抽在
+ * lib/console-dashboard-model.ts(可單測)。讀 KBDB 走 HTTP(kbdbBase 慣例),不新增 binding。
+ */
+import { Hono } from 'hono';
+import type { Bindings } from '../types';
+import { kbdbBase, graphBase } from './kbdb-proxy';
+import { validateConsoleSession } from './console-auth';
+import {
+ type KbdbEntry,
+ type WaitingItem,
+ type WaitingModel,
+ type CachedWaitingEnvelope,
+ type SprintBoardTask,
+ type SprintSnapshot,
+ GITEA_WAITING_CACHE_TTL_SECONDS,
+ parseCreatedAtMs,
+ parseJsonContent,
+ agoMinutes,
+ buildRouteModel,
+ buildSprintRouteModel,
+ buildWaitingFallback,
+ parseSprintTaskBoard,
+ parseSprintWaitingTable,
+ pickLatestSprintFiles,
+ reviveWaitingAges,
+ sortWaitingItems,
+ 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 }>();
+
+const STALE_MINUTES = 240;
+const JUDGE_START_HOUR = 9; // 台北時間,含
+const JUDGE_END_HOUR = 22; // 台北時間,不含
+const STANDARD_TASK_STATUS = new Set(['done', 'doing', 'todo', 'blocked']);
+
+async function fetchEntries(env: Bindings, tenant: string, entryType: string, limit: number): Promise {
+ const { base, headers } = kbdbBase(env);
+ const params = new URLSearchParams({ owner_id: tenant, entry_type: entryType, limit: String(limit) });
+ try {
+ const res = await fetch(`${base}/entries?${params.toString()}`, { headers });
+ if (!res.ok) return [];
+ const data = (await res.json()) as { entries?: KbdbEntry[] };
+ return data.entries ?? [];
+ } catch {
+ return [];
+ }
+}
+
+/** 泛用 GET JSON(失敗回 null,caller 誠實顯示「讀不到」,不編數字)。 */
+async function fetchJson(url: string, headers?: Record): Promise {
+ try {
+ const res = await fetch(url, headers ? { headers } : undefined);
+ if (!res.ok) return null;
+ return (await res.json()) as T;
+ } catch {
+ return null;
+ }
+}
+
+/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
+async function fetchEntryTotal(env: Bindings, filters: Record): Promise {
+ const { base, headers } = kbdbBase(env);
+ const params = new URLSearchParams({ ...filters, limit: '1' });
+ const data = await fetchJson<{ total?: unknown }>(`${base}/entries?${params.toString()}`, headers);
+ return data && typeof data.total === 'number' ? data.total : null;
+}
+
+/**
+ * sprint 檔活資料源(同一輪 fetch 兩個產物,leo 2026-07-07 拍板加「今日完成」):
+ * - 「等你的事」=「## 等 leo 清單」表格(progress-guard 每日維護)。
+ * - 「今日完成/今日路線」=「## 任務板」checkbox(今天勾的才算今日完成)。
+ * 需 GITEA_BASE_URL(var)+ GITEA_TOKEN(secret,建議唯讀 scope)。請求序:
+ * 列目錄挑最新兩個 sprint-*.md(換 sprint 後前一檔常還有未銷案項/未收項,例:07b 開了、
+ * 🔴 mira 憑證外洩與 [🔄] T-cockpit 仍掛 07a)→ 各抓 raw、兩個 parser 吃同一份文字 →
+ * 最新檔的最後 commit 時間當「維護於」。等leo清單全解析失敗回 null → caller fallback
+ * dash_wait / dash_task(標 age),不硬湊。
+ */
+async function fetchGiteaSprint(env: Bindings, nowMs: number): Promise {
+ const base = (env.GITEA_BASE_URL ?? '').replace(/\/$/, '');
+ const token = env.GITEA_TOKEN;
+ if (!base || !token) return null;
+ const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
+ const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
+ const headers = { Authorization: `token ${token}` };
+ try {
+ const files = await fetchJson<{ name: string }[]>(`${base}/api/v1/repos/${repo}/contents/${encodeURI(dir)}`, headers);
+ if (!files) return null;
+ const sprints = pickLatestSprintFiles(files.map((f) => f.name));
+ if (!sprints.length) return null;
+ const parsed = await Promise.all(
+ sprints.map(async (name) => {
+ const rawRes = await fetch(`${base}/api/v1/repos/${repo}/raw/${encodeURI(`${dir}/${name}`)}`, { headers });
+ if (!rawRes.ok) return null;
+ const text = await rawRes.text();
+ return { waiting: parseSprintWaitingTable(text, name), board: parseSprintTaskBoard(text, name) };
+ }),
+ );
+ const readFiles = sprints.filter((_, i) => parsed[i]?.waiting != null);
+ const merged = parsed.map((p) => p?.waiting).filter((p): p is WaitingItem[] => p != null).flat();
+ if (!readFiles.length) return null; // 等leo清單全部解析失敗=誠實 fallback
+ // 任務板:新→舊合併(現役 sprint 的板先列);兩檔都沒有可解析的板 → null(fallback dash_task)
+ const boardMerged = parsed.map((p) => p?.board).filter((b): b is SprintBoardTask[] => b != null).flat();
+ // 清單上次維護時間 = 現役 sprint 檔最後 commit(progress-guard 每日 commit,>48h 沒動才算 stale)
+ let ago = -1;
+ const commits = await fetchJson<{ commit?: { committer?: { date?: string } } }[]>(
+ `${base}/api/v1/repos/${repo}/commits?path=${encodeURIComponent(`${dir}/${readFiles[0]}`)}&limit=1&stat=false&verification=false&files=false`,
+ headers,
+ );
+ const date = commits?.[0]?.commit?.committer?.date;
+ if (date) {
+ const ms = Date.parse(date);
+ if (!Number.isNaN(ms)) ago = agoMinutes(nowMs, ms);
+ }
+ return {
+ waiting: {
+ items: sortWaitingItems(merged),
+ source: 'gitea_sprint',
+ updated_ago_minutes: ago,
+ stale: ago >= 0 && ago > 48 * 60,
+ sprint_files: readFiles,
+ },
+ board: boardMerged.length ? boardMerged : null,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export type GiteaSprintFetcher = (env: Bindings, nowMs: number) => Promise;
+
+/**
+ * fetchGiteaSprint 的快取層(總管 #36 審查要求):CF Cache API(caches.default)、
+ * TTL 90s(GITEA_WAITING_CACHE_TTL_SECONDS)。前端 60 秒刷新下,Gitea 從
+ * 「每分鐘 3-4 個 API call」降到「≤1 輪/90s」;快取是查詢面的讀優化,不是輪詢。
+ *
+ * - key:合成 URL(Cache API 要求合法 URL;host 用不會真的被打的保留名),帶
+ * base/repo/dir 參數——設定變了自然 miss,不會吐到別的 Gitea 的殘資料。
+ * - hit 回放時用 reviveWaitingAges 把「維護於 N 分鐘前」隨牆鐘補算(存的是 fetch
+ * 當下的 ago,直接回放會讓時間停走)。任務板存原始 completed_days,「今天完成幾件」
+ * 由請求當下算——跨台北午夜的快取不會把昨天的完成冒領成今天。
+ * - **失敗不快取**:negative cache 會把一時網路抖動放大成 90 秒盲區,caller 該
+ * 當場 fallback dash_wait / dash_task。
+ * - cache.put 走 waitUntil(不阻塞回應);fetcher 參數可注入=單測不用真打網路。
+ * - 回傳多帶 cache:'hit'|'miss',吐進 waiting_meta 當快取生效的客觀證據(curl 兩次
+ * 第二次該是 hit)。
+ */
+export async function cachedGiteaSprint(
+ env: Bindings,
+ nowMs: number,
+ waitUntil: (p: Promise) => void,
+ fetcher: GiteaSprintFetcher = fetchGiteaSprint,
+): Promise<(SprintSnapshot & { cache: 'hit' | 'miss' }) | null> {
+ if (!env.GITEA_BASE_URL || !env.GITEA_TOKEN) return null;
+ const repo = env.GITEA_SPRINT_REPO ?? 'Leo/InkStoneCo';
+ const dir = env.GITEA_SPRINT_DIR ?? 'system-dev/docs/3-specs/autonomy-dispatch';
+ const cacheKey = new Request(
+ `https://console-dashboard.arcrun.internal/gitea-waiting?${new URLSearchParams({ base: env.GITEA_BASE_URL, repo, dir }).toString()}`,
+ );
+ const cache = caches.default;
+ try {
+ const hit = await cache.match(cacheKey);
+ if (hit) {
+ const envelope = (await hit.json()) as CachedWaitingEnvelope;
+ return {
+ waiting: reviveWaitingAges(envelope.snapshot.waiting, envelope.fetched_at_ms, nowMs),
+ board: envelope.snapshot.board,
+ cache: 'hit',
+ };
+ }
+ } catch {
+ /* cache 故障不致命,走 miss 路徑 */
+ }
+ const fresh = await fetcher(env, nowMs);
+ if (!fresh) return null; // 失敗不快取,caller 誠實 fallback
+ const envelope: CachedWaitingEnvelope = { snapshot: fresh, fetched_at_ms: nowMs };
+ try {
+ waitUntil(
+ cache.put(
+ cacheKey,
+ new Response(JSON.stringify(envelope), {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': `public, max-age=${GITEA_WAITING_CACHE_TTL_SECONDS}`,
+ },
+ }),
+ ),
+ );
+ } catch {
+ /* put 失敗只是少了快取,不影響本次回應 */
+ }
+ return { ...fresh, cache: 'miss' };
+}
+
+// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
+consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
+ const tenant = c.env.CONSOLE_TENANT || 'leo';
+ const now = Date.now();
+ const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
+ const graphUrl = graphBase(c.env);
+
+ const [
+ beatEntries,
+ taskEntries,
+ waitEntries,
+ inboxEntries,
+ giteaSprint,
+ kbdbHealth,
+ embedStatus,
+ graphStats,
+ entriesTotal,
+ wikiCardTotal,
+ workflowTotal,
+ ] = await Promise.all([
+ fetchEntries(c.env, tenant, 'dash_beat', 100),
+ fetchEntries(c.env, tenant, 'dash_task', 200),
+ fetchEntries(c.env, tenant, 'dash_wait', 100),
+ fetchEntries(c.env, tenant, 'inbox', 200),
+ cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
+ fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
+ fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
+ fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
+ // owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732)
+ fetchEntryTotal(c.env, { owner_id: tenant }),
+ fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
+ fetchEntryTotal(c.env, { entry_type: 'workflow', owner_id: tenant }),
+ ]);
+
+ // dash_beat:每 actor 最新一筆(list 已 created_at DESC → first-seen 即最新)。唯一有活管線的 dash_*。
+ const beats: { actor: string; event: string; note: string; at: string | number; ago_minutes: number }[] = [];
+ const seenActors = new Set();
+ for (const e of beatEntries) {
+ const j = parseJsonContent(e);
+ const actor = typeof j?.actor === 'string' ? j.actor : null;
+ if (!actor || seenActors.has(actor)) continue;
+ seenActors.add(actor);
+ const ms = parseCreatedAtMs(e.created_at);
+ beats.push({
+ actor,
+ event: typeof j?.event === 'string' ? (j.event as string) : '',
+ note: typeof j?.note === 'string' ? (j.note as string) : '',
+ at: e.created_at,
+ ago_minutes: agoMinutes(now, ms),
+ });
+ }
+ const lastBeat = beats.filter((b) => b.ago_minutes >= 0).sort((a, b) => a.ago_minutes - b.ago_minutes)[0] ?? null;
+
+ // 等你的事:Gitea sprint 等leo清單優先(走 90s 快取);讀不到 fallback dash_wait(帶 age + stale)
+ let waiting: WaitingModel;
+ let waitingCache: 'hit' | 'miss' | null = null;
+ if (giteaSprint) {
+ waiting = giteaSprint.waiting;
+ waitingCache = giteaSprint.cache;
+ } else {
+ waiting = buildWaitingFallback(waitEntries, now);
+ if (waiting.source === 'kbdb_dash_wait' && !(c.env.GITEA_BASE_URL && c.env.GITEA_TOKEN)) {
+ waiting.note = 'Gitea sprint 清單未接(缺 GITEA_TOKEN secret)——以下是 dash_wait 殘資料';
+ } else if (waiting.source === 'kbdb_dash_wait') {
+ waiting.note = 'Gitea sprint 清單讀取失敗——以下是 dash_wait 殘資料';
+ }
+ }
+
+ // 今日完成/今日路線:sprint 任務板優先(leo 2026-07-07 拍板——「到底完成了多少事」的
+ // 真相源=progress-guard/cloud-worker 每日勾選的板,dash_task 沒活管線降 fallback)。
+ // 板的「今日完成」只認「完成(今天台北日)」標記;板檔今天沒 commit 過 → 誠實標示。
+ const sprintRoute = giteaSprint?.board ? buildSprintRouteModel(giteaSprint.board, now) : null;
+ const route = buildRouteModel(taskEntries, now); // fallback + 燈號仍吃 dash_task 今日寫入
+ const boardAgo = giteaSprint ? giteaSprint.waiting.updated_ago_minutes : -1;
+ const boardUpdatedToday = boardAgo >= 0 && taipeiDayKey(now - boardAgo * 60000) === taipeiDayKey(now);
+
+ // inbox:未處理計數(status !== 'done';沒標 status 視為未處理)
+ const inboxNew = inboxEntries.reduce((n, e) => {
+ const j = parseJsonContent(e);
+ return j && j.status !== 'done' ? n + 1 : n;
+ }, 0);
+
+ // 燈號:只吃「今日寫入」的任務 + 心跳 + KBDB 健康(stale 殘任務不再觸發燈號)
+ const todayWrites = route.tasks.filter((t) => t.is_today_write);
+ const hasBlocked = todayWrites.some((t) => t.status === 'blocked');
+ const hasLagMark = todayWrites.some((t) => !STANDARD_TASK_STATUS.has(t.status));
+ const taipeiHour = new Date(now + 8 * 3600 * 1000).getUTCHours();
+ const inJudgeWindow = taipeiHour >= JUDGE_START_HOUR && taipeiHour < JUDGE_END_HOUR;
+ const beatStale = lastBeat === null || lastBeat.ago_minutes > STALE_MINUTES;
+ const kbdbOk = kbdbHealth?.ok === true;
+ const light: 'green' | 'yellow' | 'red' =
+ hasBlocked || (inJudgeWindow && beatStale) || !kbdbOk ? 'red' : hasLagMark ? 'yellow' : 'green';
+ const lightReason = !kbdbOk
+ ? 'KBDB 基本盤 /health 打不通'
+ : hasBlocked
+ ? '今日任務有 blocked'
+ : inJudgeWindow && beatStale
+ ? `心跳超過 ${STALE_MINUTES} 分鐘`
+ : hasLagMark
+ ? '今日任務有落後標記'
+ : '';
+
+ return c.json({
+ light,
+ light_reason: lightReason,
+ last_beat: lastBeat ? { actor: lastBeat.actor, ago_minutes: lastBeat.ago_minutes, event: lastBeat.event, note: lastBeat.note } : null,
+ beats,
+ // 路線:sprint 任務板優先(tasks 欄位形狀與 dash_task 版相容——title/status/scope);
+ // 板上開著的項 is_today_write=false(燈號沿 #36 原則只吃 dash_task 今日寫入+心跳+KBDB,
+ // 板上掛了幾天的 [!] 不會天天亮紅燈——那是「等裁決」不是「今天卡住」)
+ tasks: sprintRoute
+ ? sprintRoute.tasks.map((t, i) => ({
+ title: t.title,
+ status: t.status,
+ order: i,
+ scope: 'today' as const,
+ age_minutes: boardAgo,
+ is_today_write: t.status === 'done', // done 項必然是「今天完成」的(模型已濾)
+ sprint: t.sprint ?? null,
+ }))
+ : route.tasks.map((t) => ({
+ title: t.title,
+ status: t.status,
+ order: t.order,
+ scope: t.scope,
+ age_minutes: t.age_minutes,
+ is_today_write: t.is_today_write,
+ sprint: null,
+ })),
+ route_meta: sprintRoute
+ ? {
+ source: 'gitea_sprint_board',
+ // is_today=板檔今天(台北)有 commit 過;false → 頁面誠實標「今日任務板未更新」
+ is_today: boardUpdatedToday,
+ updated_ago_minutes: boardAgo,
+ sprint_files: waiting.sprint_files ?? null,
+ }
+ : {
+ source: 'kbdb_dash_task',
+ is_today: route.is_today,
+ updated_ago_minutes: route.updated_ago_minutes,
+ sprint_files: null,
+ },
+ today_done: sprintRoute ? sprintRoute.today_done : route.today_done,
+ today_total: sprintRoute ? sprintRoute.today_total : route.today_total,
+ done_today_titles: sprintRoute ? sprintRoute.done_today_titles : null,
+ waiting: waiting.items,
+ waiting_meta: {
+ source: waiting.source,
+ updated_ago_minutes: waiting.updated_ago_minutes,
+ stale: waiting.stale,
+ sprint_files: waiting.sprint_files ?? null,
+ note: waiting.note ?? null,
+ // Gitea 快取層狀態(hit/miss;fallback 路徑為 null)——快取生效的客觀證據
+ cache: waitingCache,
+ },
+ inbox_new: inboxNew,
+ system: {
+ kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
+ embed: embedStatus
+ ? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
+ : null,
+ graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
+ workflow_total: workflowTotal,
+ },
+ kb: {
+ entries_total: entriesTotal,
+ wiki_card_total: wikiCardTotal,
+ triplets_total: graphStats?.total ?? null,
+ },
+ generated_at: new Date(now).toISOString(),
+ });
+});
+
+// GET /console/kb-scale-data — 總庫「精耕層」規模(leo 2026-07-07 裁:45.8 萬 14-E 搬遷
+// blocks 已 deprecated 之後要刪,頭部統計**不再拿遺產數字撐場面**,只顯示真的新的)。
+// 免登入(純聚合計數、無內容原文,同 dashboard-data 標準)。3 個 subrequest,全是
+// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
+// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
+consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
+ const tenant = c.env.CONSOLE_TENANT || 'leo';
+ const { base, headers } = kbdbBase(c.env);
+ const graphUrl = graphBase(c.env);
+ const now = Date.now();
+ const [wikiCards, graphStats, embedStatus] = await Promise.all([
+ // limit=1 順手拿最新一筆 created_at(list 為 created_at DESC)=「最近寫入時間」
+ fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
+ `${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
+ headers,
+ ),
+ fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`),
+ fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
+ ]);
+ const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
+ // 讀不到的欄位誠實回 null(頁面顯示「讀不到」),不編數字
+ return c.json({
+ wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
+ wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
+ triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null,
+ embedded: embedStatus?.embedded ?? null,
+ embed_enabled: embedStatus ? embedStatus.enabled === true : null,
+ generated_at: new Date(now).toISOString(),
+ });
+});
+
+// GET /console/settings-data — 設定頁的誠實系統值(目前只有 MCP token TTL 佔位區塊用)。
+// TTL 真相住在 mcp worker 部署端 env `MCP_TOKEN_TTL`(mcp/src/types.ts,預設 2592000=30 天);
+// cypher 讀的是自己這份同名 var(deploy 時兩處要一致,#32 形態 config 同步教訓)——
+// source 欄位如實標 env/default,頁面不假裝這是能遠端改的設定。
+consoleDashboardRouter.get('/console/settings-data', (c) => {
+ const raw = c.env.MCP_TOKEN_TTL;
+ const parsed = raw ? parseInt(raw, 10) : NaN;
+ const fromEnv = Number.isFinite(parsed) && parsed > 0;
+ return c.json({
+ mcp_token_ttl_seconds: fromEnv ? parsed : 2592000,
+ mcp_token_ttl_source: fromEnv ? 'env' : 'default',
+ });
+});
+
+// GET /console/triage-data — 分流台資料(Mira Console 頁 7,Arcrun#9 收件夾改裝;原
+// /console/inbox-data 的後繼——唯一消費者是 console 頁本身,一起改裝,不留死端點)。
+// **需 console session**(Bearer):dashboard-data 只吐計數可免登入;這裡吐待辦/訊息原文屬機敏,鎖登入。
+// 資料源二合一(kb-ingest SDD R7):entry_type=todo(Logseq 萃取,Arcrun#8 ingest 線)+
+// entry_type=inbox(Telegram)。契約解析/三欄分流/計數=純函式 lib/console-triage-model.ts。
+consoleDashboardRouter.get('/console/triage-data', async (c) => {
+ const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
+ if (!ok) return c.json({ error: '需要登入(console session)' }, 401);
+
+ const tenant = c.env.CONSOLE_TENANT || 'leo';
+ const [todoEntries, inboxEntries] = await Promise.all([
+ fetchEntries(c.env, tenant, 'todo', 500),
+ fetchEntries(c.env, tenant, 'inbox', 200),
+ ]);
+ const model = buildTriageModel(todoEntries, inboxEntries);
+ return c.json({ ...model, generated_at: new Date().toISOString() });
+});
+
+// POST /console/triage-check — 分流台勾掉/還原(leo 2026-07-08 拍板;body: {entry_id, action?})。
+// 為什麼開這個小端點而不讓瀏覽器直打 KBDB:瀏覽器沒有 KBDB_INTERNAL_TOKEN(token 只能在
+// server 側,同 kbdb-graph proxy 理由),且 console session ≠ X-Arcrun-API-Key。沿用
+// triage-data 同款 session 驗證,server 端做 KBDB PATCH(kbdbBase 慣例)。
+//
+// PATCH content 需**整串回寫**(KBDB updateEntry 是欄位級覆蓋,content 給什麼存什麼)——
+// 先 GET 原 entry、只動 status/checked_* 欄再回寫,防蓋掉 text/marker/owner_tier 等別的欄位。
+// 改寫邏輯=lib/console-triage-model.ts applyTriageCheck(純函式,vitest 驗證)。
+//
+// ── 雙向銷案語意(死循環防呆,與 applyTriageCheck 註解同一套規約,萃取端會配合)──
+// console 勾掉=終局(checked_via:"console"):即使 Logseq 原文還是 TODO,萃取端也絕不
+// 復活它;Logseq 改 DONE 的由萃取端 PATCH status:done(checked_via:"logseq")。
+// console 只需忠實顯示非 done 項;還原=status 回 new + 移除 checked_via/checked_at。
+consoleDashboardRouter.post('/console/triage-check', async (c) => {
+ const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
+ if (!ok) return c.json({ error: '需要登入(console session)' }, 401);
+
+ const body = await c.req.json().catch(() => null);
+ const entryId = typeof body?.entry_id === 'string' ? body.entry_id.trim() : '';
+ if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
+ const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
+
+ const tenant = c.env.CONSOLE_TENANT || 'leo';
+ const { base, headers } = kbdbBase(c.env);
+
+ // 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
+ // 1. owner_id 必須=console 固定租戶(session 只代表 leo 這個租戶,不能改到別人的資料);
+ // 2. entry_type 限分流台的兩個來源 todo/inbox(這端點不是泛用 entry 改寫器)。
+ const got = await fetchJson<{ entry?: { owner_id?: string; entry_type?: string; content?: string | null } }>(
+ `${base}/entries/${encodeURIComponent(entryId)}`,
+ headers,
+ );
+ const entry = got?.entry;
+ if (!entry) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404);
+ if (entry.owner_id !== tenant) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404); // 不洩漏他租戶存在性
+ if (entry.entry_type !== 'todo' && entry.entry_type !== 'inbox') {
+ return c.json({ error: '只有分流台項目(todo/inbox)能在這裡勾掉' }, 400);
+ }
+
+ const newContent = applyTriageCheck(entry.content, action, new Date().toISOString());
+ const res = await fetch(`${base}/entries/${encodeURIComponent(entryId)}`, {
+ method: 'PATCH',
+ headers,
+ body: JSON.stringify({ content: newContent }),
+ });
+ if (!res.ok) return c.json({ error: `KBDB 回寫失敗(HTTP ${res.status})` }, 502);
+ return c.json({ success: true, entry_id: entryId, action, status: action === 'restore' ? 'new' : 'done' });
+});
+
+function renderDashboardHtml(brand: string): string {
+ return `
+
+
+
+
+${brand} 駕駛艙
+
+
+
+
+
+
+
+
+
+
+
收件匣未處理
+
– 條
+
來自 Telegram
+
+
+
+ 今日路線
+
+ 本週
+
+ 系統狀況live 健康信號
+
+ 每 60 秒自動刷新
+ 進入完整控制台 ›
+
+
+
+
+`;
+}
+
+// GET /console/dashboard — 駕駛艙頁(無需登入;純渲染 dashboard-data,無互動、無說明文字)
+// 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG")
+// CONSOLE_PROFILE=rag(console-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'));
+});
diff --git a/console-ui/src/console.ts b/console-ui/src/console.ts
new file mode 100644
index 0000000..bf12c5c
--- /dev/null
+++ b/console-ui/src/console.ts
@@ -0,0 +1,1623 @@
+/**
+ * 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/search(mode=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/R4,Arcrun#39):GET /kbdb/map 全館庫卡片
+ * (narrative+top entities+triplet 數+更新時間感),逐庫 GET /kbdb/map/:library 補
+ * degree/跨庫 bridges/commit_hash(詳圖拉不到就維持名字版,不擋渲染);點庫卡片=
+ * 設 library filter(/kbdb/search 既有 library 參數)進該庫搜尋;/map 空陣列或錯誤=
+ * 誠實空狀態(指引 POST /map/recompute backfill),不擋搜尋等其他功能
+ * - 卡片詳頁:GET /kbdb/entries/:id;關聯視圖走 GET /kbdb/graph/neighbors/:name
+ * (cypher 代轉 kbdb-graph-plugin,kbdb-proxy.ts;查無 triplet → 「尚無關聯資料」)
+ * - 工作流:GET /webhooks/named(list)+ POST /webhooks/named/:name/trigger(手動觸發)
+ * + GET /workflows/:name/executions(最近執行);釘選存 localStorage
+ * - 憑證:GET /credentials/catalog(D1 目錄唯讀,絕不含密文)+ POST /credentials(新增)
+ * + PUT /credentials/:name(整筆替換);刪除後端未接 D1(T9 範圍)→ 標「即將開通」
+ * - 分流台:GET /console/triage-data(需 session——待辦/訊息原文屬機敏,計數才免登入)。
+ * 資料源二合一(kb-ingest SDD R7 / Arcrun#9):entry_type=todo(Logseq 萃取)+
+ * entry_type=inbox(Telegram);三欄 80/15/5(ai/collab/leo)+ per-project chips
+ * +已完成預設隱藏可切換。分欄判定在 lib/console-triage-model.ts(純函式),頁面只渲染。
+ * 勾掉/還原走 POST /console/triage-check(同 session 保護;leo 2026-07-08 拍板)——
+ * 雙向銷案語意:console 勾掉=終局(checked_via:console,萃取端絕不復活)、Logseq 改
+ * DONE 由萃取端 PATCH(checked_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/Taipei(lib/taipei-time.ts,與駕駛艙 #36 台北日判定同套;
+ * 原本用瀏覽器本地 getter,換裝置就換日期;憑證頁加顯時分——7/6 22:57 建立不再被誤讀)。
+ * 2. 總庫搜尋/設定頁頭部統計改精耕層(458,732 是真數字——cypher 與 base 一手同值實證——
+ * 但 leo 裁 14-E 遺產 deprecated 不再顯示)。
+ * 3. 駕駛艙「今日完成/今日路線」接 sprint 任務板真資料(dashboard-data 端,同 #36 模式)。
+ *
+ * 認證模型沿 v1(console-auth.ts):單一管理員 email+password,session 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 profile(config 裁剪,非 auth——#24/#25 多人 Portal 是另一回事):
+ // full(未設即此)=全套,Mira 實例現行為一字不變。
+ // rag=企業產品樣:只留 總庫搜尋/工作流/設定(card 是搜尋的詳頁,跟著留),落地=搜尋頁。
+ // 隱藏頁的 view div 仍在 DOM(display: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 `
+
+
+
+
+${brand} Console
+
+
+
+
+
+
+
+
+
+
${brand}
+
私人系統・僅供擁有者進入
+
+
+
這是一個人的智慧總部。
若你不是擁有者,這裡沒有你要找的東西。
+ ${rag ? '' : '
免登入看駕駛艙 ›'}
+
+
+
+
+
+
+
+
+
${brand}
+
首次設定
+
系統偵測到尚未設定擁有者帳號。
設定一組 Email 與密碼,之後只有你能進入。
+
+
+
+
+
+
+
+
+
+
${brand}
+ ${rag ? '' : '
駕駛艙
'}
+
總庫搜尋
+
工作流
+ ${rag ? '' : `
憑證管理
+
分流台
`}
+
設定
+
☾ 切深色
+
+
+
+
+
+
+
${brand} 控制台
+
+
+
+
+
+
+
收件匣未處理
+
– 條
+
來自 Telegram
+
+
+
+
+
+
+ 今日路線狀態即時同步
+
+
+
本週
+
+
每 60 秒自動刷新
+
+
+
+
+
+
+
+
+
+
+
+
+
工作流
+
+
+
+
零件與 Recipes(框架資源)
+
+
+
+
+
+
+
+
+
+
+
憑證管理
+
+ 🛡 保險箱原則:系統只保存目錄與加密後的值,任何頁面都看不到密文內容。只能整筆替換或刪除。
+
+
+
+
+
+
+
+
+
+
+
設定
+
+
+
+
+
深色模式
+
預設淺色(紙感)。切換立即生效,選擇記在這台裝置(localStorage)。
+
+
+
+
+
+
+
+
語意搜尋(vectorize)
+
狀態偵測中…
+
+
+
+
+ 誠實提示:開關真相=部署端 ~/.arcrun/config.yaml 的 kbdb_embed: true + acr update 重部署(#32 教訓:wrangler 直推改的形態 config 要同步)。Console 只如實顯示狀態,不假裝能遠端開啟。目前狀態:偵測中…
+
+
+
+
MCP token 有效期(TTL)
+
讀取中…
+
+ 誠實佔位:此頁還不能改這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env MCP_TOKEN_TTL(mcp worker)設定。
+
+
+
+
更換帳號密碼
+
需輸入舊密碼驗證身分
+
+
+ ${rag ? '' : `
+
+
憑證管理
+
只保存目錄與加密值,不可回看
+
+
›
+
`}
+
+
+
+
+
+
+
+
+
+
+ ${rag ? '' : '
駕駛艙
'}
+
搜尋
+
工作流
+ ${rag ? '' : '
分流台
'}
+
設定
+
+
+
+
+
+
+
+`;
+}
+
+// GET /console — Mira Console 完整版(Arcrun#3 console 系)。registry base 現算:同帳號
+// arcrun-registry worker(WORKER_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'));
+});
diff --git a/console-ui/src/portal-ui.ts b/console-ui/src/portal-ui.ts
new file mode 100644
index 0000000..74f7970
--- /dev/null
+++ b/console-ui/src/portal-ui.ts
@@ -0,0 +1,1390 @@
+/**
+ * RAG Portal 前端殼 — P3:GET /portal 單檔 HTML(portal-auth design §1 D-1/§6,Gitea #24/#25)
+ *
+ * 形態(D-1):cypher-executor 的新路由、**獨立 HTML 殼**——不在 console 上加 if(「Admin
+ * Console 現狀不動」鐵律)。樣式重用 console 的設計語言(紙感/明體標題/琥珀強調/深淺主題),
+ * 取捨=**複製樣式子集而非抽共用模組**:抽共用要動 console.ts(違「console 不動」),且兩頁
+ * 受眾不同(owner vs 同仁)預期各自演化;代價=樣式雙份、改版要兩邊同步(PR 說明誠實記)。
+ *
+ * 頁面(design §6,leo 頁面級拍板):
+ * - 登入頁(brand=CONSOLE_BRAND;未登入只見這個殼)
+ * - 搜尋頁(落地頁):keyword/semantic/graph 三模式+結果含 source 溯源+卡片詳頁
+ * - 設定頁:改自己密碼、看自己角色與可查庫、主題切換
+ * - 工作流頁:D-8 定案 admin 可見(PORTAL_SHOW_WORKFLOWS 可調 all/off);唯讀、不開 trigger
+ * - graph 模式按 D-4 粗閘顯示(/portal/session 的 graph_allowed)
+ * - 管理頁(P4,admin-only nav):帳號管理(列表/新增/停用/重設密碼+每帳號勾選可查庫
+ * 含 ["*"] 全庫)+庫目錄管理(登記/停用/graph_source 標記)。一次性密碼只在畫面顯示
+ * 一次(server 不留明碼);一般用戶 nav 不顯示、直打 /portal/admin/* 也是 403(role 閘)。
+ * - 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 `
+
+
+
+
+${brand} Portal
+
+
+
+
+
+
+
+
+
+
${brand}
+
知識入口・Portal
+
+
+
帳號由管理員發放。忘記密碼請聯絡管理員重設。
+
+
+
+
+
+
+
+
${brand}
+
搜尋
+
總圖
+
上傳
+
工作流
+
管理
+
設定
+
☾ 切深色
+
+
+
+
+
+
+
搜尋
+
+
+
+
+
+
+
+
+
+
+
+
+
AI 問答・答案附出處,可回搜尋驗證
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
總圖
+
+
+
+ 點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版
+ (給 AI 注入用的總庫目錄)。
+
+
+
+
+
+
+
+
+
+
上傳Markdown / 純文字
+
+ 上傳的文件會進入知識庫收件管線:約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。
+
+
+
把 .md / .txt 檔案拖放到這裡
+
或
+
+
+
+
+
+
+
+
+
工作流唯讀・系統狀態
+
+ 此頁只顯示系統裡的工作流與最近執行狀態,不能觸發執行(觸發屬系統擁有者權限)。
+
+
+
+
+
+
+
設定
+
+
+
+
+
+
深色模式
+
預設淺色(紙感)。切換立即生效,選擇記在這台裝置。
+
+
+
+
+
+
更改密碼
+
需輸入舊密碼驗證身分;新密碼至少 8 碼
+
+
+
+
+
+
+
+
+
管理帳號與知識庫授權
+
+
帳號管理
+
+
新增同仁帳號
+
建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。
+
+
+
+
+
+
+
+
+
+
+
+
庫目錄管理
+
+ 這裡是「庫」的登記簿——條目歸哪個庫由資料導入(ingest)時蓋章決定;未蓋章的舊資料一律視同 general。標了「圖譜來源」的庫決定誰能用圖譜模式(全都沒標=預設 general)。
+
+
+
+
+
+
+
+
+
+
+
搜尋
+
總圖
+
上傳
+
工作流
+
管理
+
設定
+
+
+
+
+
+
+`;
+}
+
+// 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 || ''));
+});