From ad367e450deb44d37b64c085157f431669561b83 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 22 Jul 2026 19:09:29 +0800 Subject: [PATCH] =?UTF-8?q?console-ui:=20=E4=BF=AE=E5=A5=BD=E5=A3=9E?= =?UTF-8?q?=E4=BA=86=E4=B8=80=E5=80=8B=E6=9C=88=E7=9A=84=20build=20+=20?= =?UTF-8?q?=E5=85=B7=E5=90=8D=E9=83=A8=E7=BD=B2=E7=9B=AE=E6=A8=99=EF=BC=88?= =?UTF-8?q?=E5=80=8B=E4=BA=BA=E7=89=88/=E4=BC=81=E6=A5=AD=E7=89=88?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 根因:5a16484 把 UI 搬 CF Pages 時「移出 → console-ui/」實際上只搬了 build 產物(HTML),三支 renderer 原始檔被刪且沒搬 → `npm run build` 從那天起 ENOENT 死掉,線上 HTML 是刪檔前烤好的, 一個月來無法重建(CONSOLE_PROFILE 改了也不會生效,因為 build 跑不起來)。 修法: - 從 git 撈回 console.ts(1623)/portal-ui.ts(1390)/console-dashboard.ts(822) 放進 console-ui/src/——UI 原始碼跟著 UI 專案走,才是那一刀的原意 - build.mjs 加 resolveSource():本地 src/ 優先,找不到才回退 cypher-executor 新增具名部署目標(deploy.targets.json + scripts/deploy.mjs): 一個目標=帳號+profile+apiBase 綁在一起,解決三次帶漏參數: - demo 站漏 CONSOLE_PROFILE=rag → 顯示個人版 7 頁駕駛艙(leo「進去是 Mira 介面」的真因) - 兩站漏 ARCRUN_API_BASE → apiBase 空 → 前端打自己 405 → 登不進去 - 兩帳號都有同名 arcrun-console-ui 專案且 wrangler OAuth 登入在 uncle6 → 不指定帳號 deploy 會部到 demo 站(差點蓋掉,改用 API token 鎖帳號) npm run deploy:personal → leo21c, profile=full(7頁), apiBase 指 leo21c worker npm run deploy:enterprise → uncle6, profile=rag(4頁落地搜尋), apiBase 指 cypher.arcrun.dev 實測(用戶真的會走的路): - mira.uncle6.me/console/ 200,VIEWS 7 頁,apiBase 對,CORS ACAO 通 - rag-demo.arcrun.dev 4 頁落地搜尋;登入 OK、搜「藍鯨導航儀」5 筆(未受部署影響) Co-Authored-By: Claude Opus 4.8 (1M context) --- console-ui/deploy.targets.json | 45 + console-ui/package.json | 4 +- console-ui/public/config.js | 2 +- .../public/console/dashboard/index.html | 2 +- console-ui/public/console/index.html | 25 +- console-ui/public/index.html | 29 + console-ui/public/portal/index.html | 2 +- console-ui/scripts/build.mjs | 41 +- console-ui/scripts/deploy.mjs | 51 + console-ui/src/console-dashboard.ts | 822 +++++++++ console-ui/src/console.ts | 1623 +++++++++++++++++ console-ui/src/portal-ui.ts | 1390 ++++++++++++++ 12 files changed, 4011 insertions(+), 25 deletions(-) create mode 100644 console-ui/deploy.targets.json create mode 100644 console-ui/public/index.html create mode 100644 console-ui/scripts/deploy.mjs create mode 100644 console-ui/src/console-dashboard.ts create mode 100644 console-ui/src/console.ts create mode 100644 console-ui/src/portal-ui.ts diff --git a/console-ui/deploy.targets.json b/console-ui/deploy.targets.json new file mode 100644 index 0000000..d6d28f3 --- /dev/null +++ b/console-ui/deploy.targets.json @@ -0,0 +1,45 @@ +{ + "_readme": [ + "部署目標定義檔(leo 2026-07-22 立)。一個目標=一組『帳號+profile+apiBase+專案名』。", + "", + "為什麼要這個檔:5a16484 把 UI 搬 CF Pages 後,這些值從 worker 環境變數變成 build 期參數。", + "誰部署誰要記得帶 → 帶漏了就退回預設,而預設值對兩邊都不對。今天實際踩到的:", + " · demo 站漏 CONSOLE_PROFILE=rag → 顯示個人版 7 頁駕駛艙(leo 看到『Mira 介面』的真因)", + " · 兩站都漏 ARCRUN_API_BASE → apiBase 空字串 → 前端打自己回 405 → 登不進去", + " · 兩個帳號有同名 arcrun-console-ui 專案,wrangler 又登入在 uncle6", + " → 不指定帳號直接 deploy 會部到 demo 站上(差點蓋掉)", + "", + "版本差異(leo 2026-07-22 定調):頁面都存在,由 profile 決定顯示哪些。", + " personal(full) 個人版:7 頁全開,落地駕駛艙", + " enterprise(rag) 企業版:只留 搜尋/工作流/設定/card,落地搜尋頁", + " 未來擴充:個人版新用戶上限 1、知識庫權限不可用 → 加在對應目標的欄位裡,別再散進部署指令。", + "", + "用法:npm run deploy:personal / npm run deploy:enterprise" + ], + + "personal": { + "description": "leo 私人實例(原 Mira)。入口 mira.uncle6.me → leo21c worker。", + "accountId": "51a01bfa2665bd7bc3fd080dc40cf3e1", + "projectName": "arcrun-console-ui", + "profile": "full", + "brand": "Arcrun", + "apiBase": "https://arcrun-cypher-executor.leo21c.workers.dev", + "limits": { + "maxUsers": 1, + "libraryPermissions": false + } + }, + + "enterprise": { + "description": "企業版 demo 站。rag-demo.arcrun.dev → uncle6 帳號 cypher。", + "accountId": "58309bb90fd93ad6d0fe0aae99170e9d", + "projectName": "arcrun-console-ui", + "profile": "rag", + "brand": "Arcrun", + "apiBase": "https://cypher.arcrun.dev", + "limits": { + "maxUsers": null, + "libraryPermissions": true + } + } +} diff --git a/console-ui/package.json b/console-ui/package.json index a040af2..720adf3 100644 --- a/console-ui/package.json +++ b/console-ui/package.json @@ -5,7 +5,9 @@ "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", + "deploy": "node scripts/deploy.mjs", + "deploy:personal": "node scripts/deploy.mjs personal", + "deploy:enterprise": "node scripts/deploy.mjs enterprise", "preview": "npm run build && npx serve public" } } diff --git a/console-ui/public/config.js b/console-ui/public/config.js index c23705f..dca204f 100644 --- a/console-ui/public/config.js +++ b/console-ui/public/config.js @@ -1,2 +1,2 @@ // Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。 -window.ARCRUN_CONFIG = { apiBase: "" }; +window.ARCRUN_CONFIG = { apiBase: "https://cypher.arcrun.dev" }; diff --git a/console-ui/public/console/dashboard/index.html b/console-ui/public/console/dashboard/index.html index ff5a800..9812592 100644 --- a/console-ui/public/console/dashboard/index.html +++ b/console-ui/public/console/dashboard/index.html @@ -92,7 +92,7 @@ .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; } - +
diff --git a/console-ui/public/console/index.html b/console-ui/public/console/index.html index 0c49dd7..ef360fc 100644 --- a/console-ui/public/console/index.html +++ b/console-ui/public/console/index.html @@ -220,7 +220,7 @@ .kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; } - + @@ -238,7 +238,7 @@
這是一個人的智慧總部。
若你不是擁有者,這裡沒有你要找的東西。
- 免登入看駕駛艙 › + @@ -266,11 +266,10 @@
- + - - +
@@ -442,13 +441,7 @@
-
-
-
憑證管理
-
只保存目錄與加密值,不可回看
-
- -
+
系統資訊
載入中…
@@ -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} 駕駛艙 + + + + +
+
+
${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 與密碼,之後只有你能進入。
    +
    +
    + + + + +
    +
    + +
    +
    + + +
    +
    + + ${rag ? '' : ''} + + + ${rag ? '' : ` + `} + + +
    +
    +
    + + +
    +
    ${brand} 控制台
    +
    +
    +
    +
    +
    +
    載入中
    +
    +
    +
    +
    +
    +
    今日完成
    +
    /
    +
    +
    +
    +
    收件匣未處理
    +
    +
    來自 Telegram
    +
    +
    +
    +
    等你的事
    +
    載入中…
    +
    +
    +
    +
    +
    + 今日路線狀態即時同步 +
    +
    • 載入中…
    + +
      +
      每 60 秒自動刷新
      +
      +
      +
      + + + + + +
      +
      + + 知識卡片 +
      +
      +
      載入中…
      +
      +
      關聯視圖
      +
      +
      +
      +
      +
      + + +
      +
      工作流
      + +
      載入中…
      +
      +
      零件與 Recipes(框架資源)
      +
      + + +
      +
      +
      +
      + + +
      +
      憑證管理
      +
      + 🛡 保險箱原則:系統只保存目錄與加密後的值,任何頁面都看不到密文內容。只能整筆替換或刪除。 +
      + +
      載入中…
      +
      + + +
      +
      分流台全部待辦・80/15/5
      +
      +
      載入中…
      +
      + + +
      +
      設定
      +
      +
      +
      +
      +
      深色模式
      +
      預設淺色(紙感)。切換立即生效,選擇記在這台裝置(localStorage)。
      +
      +
      +
      +
      +
      +
      +
      +
      語意搜尋(vectorize)
      +
      狀態偵測中…
      +
      +
      +
      +
      + 誠實提示:開關真相=部署端 ~/.arcrun/config.yamlkbdb_embed: trueacr 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
      +
      +
      + + + +
      +
      +
      帳號由管理員發放。忘記密碼請聯絡管理員重設。
      + +
      +
      + + +
      +
      + + + + + + + + +
      +
      +
      + + + + + +
      +
      總圖
      +
      +
      +
      + 點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版 + (給 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 || '')); +});