Files
Arcrun/cypher-executor/src/routes/console.ts
T

1498 lines
97 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 不再當招牌數字;搜尋本身仍搜全庫)
* - 卡片詳頁: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>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${brand} Console</title>
<script>
// 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),選擇存 localStorage
document.documentElement.setAttribute('data-theme', (function () {
try { return localStorage.getItem('arcrun_console_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }
})());
</script>
<style>
* { box-sizing: border-box; }
/* ── 主題色板(一套結構兩份色值):預設淺色=宣紙米白紙紋+墨色文字;深色=定稿紙感暖黑 2a 原值不動 ── */
: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; --ok-soft: #1d6b40;
--err: #b03a26; --err-rgb: 176,58,38;
--well: rgba(30,24,14,.06); --well2: rgba(30,24,14,.08);
--bar-bg: #efe7d3; --track: rgba(30,24,14,.12); --knob: #ffffff;
}
: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; --ok-soft: #a8e8c4;
--err: #e58575; --err-rgb: 217,95,76;
--well: rgba(0,0,0,.25); --well2: rgba(0,0,0,.3);
--bar-bg: #17120e; --track: rgba(255,255,255,.08); --knob: #ede4d3;
}
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; }
input, textarea, button { font-family: inherit; }
::placeholder { color: rgba(var(--ink-rgb),.35); }
input:focus, textarea:focus { outline: 2px solid rgba(var(--amber-rgb),.5); outline-offset: 1px; }
a { color: var(--amber); }
.serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }
.mono { font-family: ui-monospace, Menlo, monospace; }
.muted { color: rgba(var(--ink-rgb),.55); }
.dim { color: rgba(var(--ink-rgb),.4); }
.err { color: var(--err); }
.ok { color: var(--ok); }
@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); } }
/* ── auth 全屏 ── */
.authwrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.authbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; text-align: center; }
.brand { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 44px; letter-spacing: .24em; color: var(--amber); }
.brand-sub { margin-top: 10px; font-size: 15px; color: rgba(var(--ink-rgb),.55); letter-spacing: .1em; }
/* ── 版面骨架 ── */
#shell { display: none; min-height: 100vh; }
#shell.on { display: flex; }
#sidenav { display: none; }
main { flex: 1; min-width: 0; padding-bottom: 96px; }
.page { max-width: 980px; margin: 0 auto; padding: 0 20px 40px; }
.page.narrow { max-width: 760px; }
.page.wide { max-width: 1080px; }
.pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(var(--amber-rgb),.4); display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.pagehead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; white-space: nowrap; }
.pagehead .m { font-size: 13.5px; color: rgba(var(--ink-rgb),.45); }
#tabbar { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bar-bg); border-top: 2px solid rgba(var(--amber-rgb),.4); display: none; z-index: 50; padding-bottom: env(safe-area-inset-bottom); }
#tabbar.on { display: flex; }
#tabbar .tab { flex: 1; text-align: center; padding: 15px 0 13px; font-size: 14.5px; cursor: pointer; min-height: 44px; color: rgba(var(--ink-rgb),.5); border-top: 2px solid transparent; margin-top: -2px; }
#tabbar .tab.on { color: var(--amber); font-weight: 600; border-top-color: var(--amber); }
@media (min-width: 1024px) {
#tabbar.on { display: none; }
main { padding-bottom: 20px; }
#sidenav { display: flex; width: 216px; flex: none; position: sticky; top: 0; height: 100vh; border-right: 2px solid rgba(var(--amber-rgb),.35); padding: 28px 0; flex-direction: column; gap: 4px; }
#sidenav .logo { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 26px; letter-spacing: .22em; color: var(--amber); padding: 0 24px 20px; }
#sidenav .nav { padding: 13px 24px; font-size: 16px; cursor: pointer; letter-spacing: .08em; color: rgba(var(--ink-rgb),.6); border-right: 2px solid transparent; }
#sidenav .nav.on { color: var(--amber); background: rgba(var(--amber-rgb),.08); border-right-color: var(--amber); font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-weight: 600; }
#sidenav .foot { margin-top: auto; padding: 0 24px; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.7; }
}
.view { display: none; }
.view.on { display: block; }
/* 登入/首次設定=flex 置中(.view.on 的 display:block 會蓋掉 .authwrap 的 flex → 內容靠左,leo 實測回饋修正) */
.authwrap.view.on { display: flex; }
/* ── 元件 ── */
.btn { padding: 13px; font-size: 16px; font-weight: 600; border-radius: 10px; border: none; background: linear-gradient(90deg,#b98330,#e8b45a); color: #241804; cursor: pointer; }
.btn:disabled { opacity: .45; cursor: default; }
.btn2 { padding: 11px 16px; font-size: 15px; border-radius: 10px; border: 1px solid rgba(var(--amber-rgb),.6); background: none; color: var(--amber); cursor: pointer; }
.btn3 { padding: 11px 16px; font-size: 15px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.7); cursor: pointer; }
.btn-danger { padding: 12px 18px; font-size: 15px; border-radius: 10px; border: 1px solid rgba(var(--err-rgb),.5); background: none; color: var(--err); cursor: pointer; }
.btn-danger:disabled { opacity: .4; cursor: default; }
.txt { width: 100%; padding: 13px 15px; font-size: 16px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.2); background: rgba(var(--ink-rgb),.05); color: var(--ink); }
textarea.txt { resize: vertical; font-family: ui-monospace, Menlo, monospace; font-size: 14.5px; }
.panel { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); }
.card-item { border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); }
.card-item.hl { border-color: rgba(var(--amber-rgb),.3); }
.tag { display: inline-block; padding: 3px 9px; border-radius: 5px; background: rgba(var(--amber-rgb),.12); color: var(--amber); font-size: 12.5px; }
.tag.green { background: rgba(var(--ok-rgb),.12); color: var(--ok); }
.chip { flex: none; padding: 9px 16px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); }
.chip.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }
.hscroll { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.subhead { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); }
.honest { padding: 22px; border-radius: 13px; border: 1px dashed rgba(var(--amber-rgb),.4); background: rgba(var(--amber-rgb),.06); text-align: center; }
.honest .h { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 19px; color: var(--amber); margin-bottom: 8px; }
.honest .b { font-size: 15px; color: rgba(var(--ink-rgb),.6); line-height: 1.7; }
#toast { position: fixed; left: 50%; bottom: 110px; transform: translateX(-50%); z-index: 99; padding: 13px 22px; border-radius: 11px; background: #e8b45a; color: #241804; font-size: 15.5px; font-weight: 600; box-shadow: 0 6px 24px rgba(0,0,0,.5); white-space: nowrap; display: none; }
/* ── 駕駛艙 ── */
.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; }
.cockgrid { display: grid; grid-template-columns: 1fr; gap: 0 40px; }
@media (min-width: 1024px) { .cockgrid { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } }
.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); cursor: pointer; }
.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; }
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); }
/* ── 搜尋 ── */
.searchrow { display: flex; gap: 10px; align-items: stretch; }
.searchrow input { flex: 1; min-width: 0; padding: 16px 18px; font-size: 18px; border-radius: 13px; border: 1.5px solid rgba(var(--amber-rgb),.35); background: rgba(var(--ink-rgb),.05); color: var(--ink); }
.sembtn { flex: none; padding: 0 20px; font-size: 16px; border-radius: 13px; cursor: pointer; letter-spacing: .1em; border: 1.5px solid rgba(var(--ink-rgb),.2); background: none; color: rgba(var(--ink-rgb),.55); }
.sembtn.on { border-color: var(--amber); background: rgba(var(--amber-rgb),.15); color: var(--amber); font-weight: 600; }
.kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; }
@media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
.kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; }
.kcard:hover { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.06); }
.kcard .kt { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 18.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }
.kcard .ks { font-size: 15px; color: rgba(var(--ink-rgb),.65); line-height: 1.65; word-break: break-word; }
.kcard .km { display: flex; gap: 10px; align-items: center; margin-top: auto; font-size: 13px; flex-wrap: wrap; }
/* ── 卡片詳頁 ── */
.cardwrap { display: flex; flex-wrap: wrap; gap: 32px; padding: 24px 0 40px; }
.cardmain { flex: 1 1 540px; min-width: 0; }
.cardside { flex: 1 1 360px; min-width: 0; }
.cardtitle { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 28px; font-weight: 600; line-height: 1.4; word-break: break-word; }
.md { margin-top: 18px; }
.md p { font-size: 16.5px; line-height: 1.85; color: rgba(var(--ink-rgb),.82); margin: 8px 0; word-break: break-word; }
.md h1, .md h2, .md h3, .md h4 { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-weight: 600; color: var(--amber); margin: 18px 0 6px; line-height: 1.4; }
.md h1 { font-size: 22px; } .md h2 { font-size: 20px; } .md h3 { font-size: 18px; } .md h4 { font-size: 17px; }
.md ul, .md ol { margin: 8px 0; padding-left: 24px; }
.md li { font-size: 16.5px; line-height: 1.8; color: rgba(var(--ink-rgb),.82); margin: 4px 0; word-break: break-word; }
.md blockquote { margin: 10px 0; padding: 12px 18px; border-left: 3px solid rgba(var(--amber-rgb),.5); font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; line-height: 1.8; color: rgba(var(--ink-rgb),.7); }
.md code { font-family: ui-monospace, Menlo, monospace; font-size: 14.5px; background: var(--well2); border-radius: 4px; padding: 1px 6px; word-break: break-all; }
.md pre { background: var(--well2); border: 1px solid rgba(var(--ink-rgb),.1); border-radius: 10px; padding: 14px; overflow-x: auto; }
.md pre code { background: none; padding: 0; }
.srcline { margin-top: 24px; display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 11px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.12); }
.graphbox { position: relative; height: 340px; border-radius: 14px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); overflow: hidden; }
.gnode { position: absolute; transform: translate(-50%,-50%); max-width: 96px; padding: 8px 11px; border-radius: 999px; text-align: center; font-size: 12.5px; line-height: 1.3; cursor: pointer; word-break: break-word; background: rgba(var(--ok-rgb),.1); border: 1px solid rgba(var(--ok-rgb),.35); color: var(--ok-soft); }
.gcenter { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 104px; height: 104px; border-radius: 50%; background: radial-gradient(circle at 36% 30%,rgba(242,209,148,.95),rgba(232,180,90,.9) 55%,rgba(138,95,30,.95)); display: grid; place-items: center; text-align: center; padding: 8px; box-shadow: 0 0 30px rgba(var(--amber-rgb),.3); }
.gcenter span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 13.5px; font-weight: 600; line-height: 1.35; color: #241804; word-break: break-word; }
.nbrow { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; cursor: pointer; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); font-size: 15.5px; }
/* ── 工作流 / 憑證 / 分流台 清單 ── */
.listcol { display: flex; flex-direction: column; gap: 10px; padding: 16px 0 8px; }
.rowhead { display: flex; align-items: center; gap: 12px; padding: 16px 18px; cursor: pointer; }
.rowbody { padding: 0 18px 18px; border-top: 1px solid rgba(var(--ink-rgb),.1); }
.pinbtn { flex: none; width: 34px; height: 34px; border-radius: 8px; border: none; cursor: pointer; font-size: 17px; background: rgba(var(--ink-rgb),.06); color: rgba(var(--ink-rgb),.4); }
.pinbtn.on { background: rgba(var(--amber-rgb),.15); color: var(--amber); }
.urlbox { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 10px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); min-width: 0; }
.urlbox .u { font-family: ui-monospace, Menlo, monospace; font-size: 13.5px; color: var(--amber); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.avatar { width: 38px; height: 38px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.25); display: grid; place-items: center; font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; color: var(--amber); flex: none; }
.maskline { display: flex; align-items: center; gap: 10px; padding: 14px; border-radius: 10px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); }
/* 分流台(Arcrun#9):三欄 80/15/5 手機直排;欄色點 ai=綠 collab=琥珀 leo=紅 */
.tri-sec { margin-top: 24px; }
.tri-sechead { display: flex; align-items: baseline; gap: 9px; }
.tri-sechead .dot { flex: none; width: 9px; height: 9px; border-radius: 50%; align-self: center; }
.tri-sec.ai .dot { background: var(--ok); } .tri-sec.collab .dot { background: var(--amber); } .tri-sec.leo .dot { background: var(--err); }
.tri-sechead .nm { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; letter-spacing: .14em; }
.tri-sechead .pc { font-size: 12.5px; color: rgba(var(--ink-rgb),.4); }
.tri-sechead .ct { margin-left: auto; font-family: ui-monospace, Menlo, monospace; font-size: 14px; color: rgba(var(--ink-rgb),.55); }
.tri-item { padding: 14px 16px; border-radius: 12px; margin-top: 9px; }
.tri-item.new { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.28); }
.tri-sec.leo .tri-item.new { background: rgba(var(--err-rgb),.06); border-color: rgba(var(--err-rgb),.3); }
.tri-item.done { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.1); opacity: .7; }
.tri-item.done .tx { text-decoration: line-through; color: rgba(var(--ink-rgb),.6); }
.tri-item .tx { font-size: 16.5px; line-height: 1.6; word-break: break-word; }
.tri-item .mt { margin-top: 9px; display: flex; gap: 7px; align-items: center; flex-wrap: wrap; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); }
.tri-item .row { display: flex; gap: 12px; align-items: flex-start; }
.tri-item .row .tx { flex: 1; min-width: 0; }
/* 勾掉鈕:≥44px 圓形(手機好按),綠框空心=待勾;按下去由後端回寫才算數(誠實,不先假裝成功) */
.tri-check { flex: none; width: 44px; height: 44px; border-radius: 50%; cursor: pointer; font-size: 19px; line-height: 1;
border: 1.5px solid rgba(var(--ok-rgb),.55); background: rgba(var(--ok-rgb),.06); color: var(--ok); }
.tri-check:active { background: rgba(var(--ok-rgb),.22); }
.tri-check:disabled { opacity: .45; cursor: default; }
/* 還原鈕(誤勾救濟,只出現在「顯示已完成」清單):小顆但仍 44px 高好按 */
.tri-restore { flex: none; min-height: 32px; padding: 5px 14px; border-radius: 999px; cursor: pointer; font-size: 13px;
border: 1px solid rgba(var(--amber-rgb),.5); background: none; color: var(--amber); }
.tri-restore:disabled { opacity: .45; cursor: default; }
.tag.red { background: rgba(var(--err-rgb),.12); color: var(--err); }
.tag.dim { background: rgba(var(--ink-rgb),.08); color: rgba(var(--ink-rgb),.55); }
/* ── 設定 ── */
.setcol { display: flex; flex-direction: column; gap: 14px; padding: 20px 0 40px; }
.switch { flex: none; margin-left: auto; width: 56px; height: 32px; border-radius: 999px; padding: 3px; background: rgba(var(--ink-rgb),.15); transition: background .25s; }
.switch.on { background: rgba(var(--ok-rgb),.5); }
.switch i { display: block; width: 26px; height: 26px; border-radius: 50%; background: var(--knob); box-shadow: 0 1px 3px rgba(0,0,0,.25); transition: transform .25s; }
.switch.on i { transform: translateX(24px); }
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
</style>
</head>
<body>
<!-- 登入 -->
<div class="authwrap view" id="v-login">
<div class="authbox">
<div>
<div class="brand">${brand}</div>
<div class="brand-sub">私人系統・僅供擁有者進入</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;text-align:left">
<input type="email" id="login-email" class="txt" placeholder="Email" style="padding:15px 16px;font-size:17px;border-radius:11px">
<input type="password" id="login-password" class="txt" placeholder="密碼" style="padding:15px 16px;font-size:17px;border-radius:11px">
<button class="btn" id="login-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em">進入</button>
<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>'}
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
</div>
</div>
<!-- 首次設定 -->
<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="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>
<div style="display:flex;flex-direction:column;gap:12px">
<input type="email" id="setup-email" class="txt" placeholder="你的 Email" style="padding:15px 16px;font-size:17px;border-radius:11px">
<input type="password" id="setup-password" class="txt" placeholder="設定密碼(至少 8 碼)" style="padding:15px 16px;font-size:17px;border-radius:11px">
<input type="password" id="setup-password2" class="txt" placeholder="再輸入一次密碼" style="padding:15px 16px;font-size:17px;border-radius:11px">
<button class="btn" id="setup-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.12em">完成設定並進入</button>
<div id="setup-status" class="err" style="font-size:14px;min-height:1.2em"></div>
</div>
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
</div>
</div>
<!-- 主應用 -->
<div id="shell">
<div id="sidenav">
<div class="logo">${brand}</div>
${rag ? '' : '<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="settings">設定</div>
<div class="nav themelabel" data-themetoggle style="margin-top:8px;font-size:14.5px">☾ 切深色</div>
<div class="foot" id="side-foot"></div>
</div>
<main>
<!-- 駕駛艙 -->
<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="cockgrid">
<div>
<div class="orb-row">
<div class="orb" id="ck-orb" style="background:radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)"><span id="ck-char">…</span></div>
<div>
<div class="orb-title" id="ck-title">載入中</div>
<div class="orb-sub" id="ck-sub"></div>
</div>
</div>
<div class="bricks">
<div class="brick amber">
<div class="lbl">今日完成</div>
<div class="num"><span id="ck-done"></span><small> / <span id="ck-total"></span> 件</small></div>
<div class="bar"><i id="ck-bar" style="width:0%"></i></div>
</div>
<div class="brick plain" data-nav="inbox">
<div class="lbl">收件匣未處理</div>
<div class="num"><span id="ck-inbox"></span><small> 條</small></div>
<div class="lbl" style="margin:10px 0 0">來自 Telegram</div>
</div>
</div>
<div class="wait-box" id="ck-waitbox">
<div class="wait-head">等你的事</div>
<div id="ck-wait" class="wait-none">載入中…</div>
<div id="ck-waitmeta" style="margin-top:10px;text-align:center;font-size:12.5px;color:rgba(var(--ink-rgb),.45);line-height:1.7"></div>
</div>
</div>
<div>
<div style="display:flex;justify-content:space-between;align-items:baseline;margin:24px 0 10px">
<span class="subhead">今日路線</span><span id="ck-route-src" style="font-size:13px;color:rgba(var(--ink-rgb),.4)">狀態即時同步</span>
</div>
<ul class="route" id="ck-today"><li class="todo"><span class="ic">○</span><span class="muted">載入中…</span></li></ul>
<div class="subhead" id="ck-weekhead" style="display:none;margin:18px 0 10px">本週</div>
<ul class="route" id="ck-week"></ul>
<div style="margin:16px 0 8px;text-align:center;font-size:12.5px;color:rgba(var(--ink-rgb),.35);line-height:1.8" id="ck-stamp">每 60 秒自動刷新</div>
</div>
</div>
</div>
<!-- 總庫搜尋 -->
<div class="view page wide" id="v-search">
<div class="pagehead"><span class="t">總庫搜尋</span><span class="m mono" id="se-scale"></span></div>
<div style="padding-top:20px">
<div class="searchrow">
<input id="se-q" placeholder="搜尋知識…" autocomplete="off">
<button class="sembtn" id="se-sem">語意</button>
</div>
<div class="hscroll" id="se-chips" style="margin-top:12px"></div>
<div id="se-banner"></div>
<div id="se-count" style="margin:20px 0 12px;font-size:14px;color:rgba(var(--ink-rgb),.5)"></div>
<div class="kgrid" id="se-results"></div>
</div>
</div>
<!-- 卡片詳頁 -->
<div class="view page wide" id="v-card">
<div class="pagehead" style="justify-content:flex-start;gap:14px">
<button class="btn2" style="border:none;padding:0;font-size:16px" data-nav="search"> 搜尋</button>
<span class="serif" style="font-size:17px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.55)">知識卡片</span>
</div>
<div class="cardwrap">
<div class="cardmain" id="cd-main"><div class="muted" style="padding:24px 0">載入中…</div></div>
<div class="cardside">
<div class="subhead" style="margin:6px 0 12px;display:block">關聯視圖</div>
<div id="cd-graph"></div>
<div id="cd-neighbors" style="margin-top:16px;display:flex;flex-direction:column;gap:7px"></div>
</div>
</div>
</div>
<!-- 工作流 -->
<div class="view page narrow" id="v-workflows">
<div class="pagehead"><span class="t">工作流</span><span class="m" id="wf-count"></span></div>
<input id="wf-q" class="txt" placeholder="搜尋工作流…" style="margin-top:18px" autocomplete="off">
<div class="listcol" id="wf-list"><div class="muted">載入中…</div></div>
<div style="margin-top:26px">
<div class="subhead" style="display:block;margin-bottom:10px">零件與 Recipes(框架資源)</div>
<div class="searchrow">
<input id="cr-q" class="txt" placeholder="如「telegram send」「gsheets append」" style="font-size:15px;padding:12px 14px;border-radius:11px;border-width:1px">
<button class="btn2" id="cr-go" style="flex:none">搜</button>
</div>
<div id="cr-out" style="margin-top:10px"></div>
</div>
</div>
<!-- 憑證管理 -->
<div class="view page narrow" id="v-creds">
<div class="pagehead"><span class="t">憑證管理</span><button class="btn2" id="cred-addbtn" style="padding:9px 16px;font-size:14.5px"> 新增憑證</button></div>
<div style="margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--ok-rgb),.05);border:1px solid rgba(var(--ok-rgb),.2);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
<span class="ok">🛡 保險箱原則</span>:系統只保存目錄與加密後的值,<b style="color:var(--ink)">任何頁面都看不到密文內容</b>。只能整筆替換或刪除。
</div>
<div id="cred-form" style="display:none;margin-top:14px" class="panel">
<div class="serif" style="font-size:17px;color:var(--amber);margin-bottom:10px">新增憑證</div>
<div style="display:flex;flex-direction:column;gap:10px">
<input id="cred-name" class="txt" placeholder="名稱(英數與底線,例:openai_main_key">
<input id="cred-service" class="txt" placeholder="服務(例:OpenAI">
<textarea id="cred-secret" class="txt" placeholder="密文值(保存後不再顯示)" rows="2"></textarea>
<button class="btn" id="cred-save">加密保存</button>
<div id="cred-form-status" style="font-size:14px;min-height:1.2em"></div>
</div>
</div>
<div class="listcol" id="cred-list"><div class="muted">載入中…</div></div>
</div>
<!-- 分流台(原收件匣,Arcrun#9 改裝=全部待辦分流台) -->
<div class="view page narrow" id="v-inbox">
<div class="pagehead"><span class="t">分流台</span><span class="m">全部待辦・80/15/5</span></div>
<div class="hscroll" id="tg-chips" style="margin-top:14px"></div>
<div id="tg-out" style="padding-top:2px"><div class="muted" style="padding:14px 0">載入中…</div></div>
</div>
<!-- 設定 -->
<div class="view page narrow" id="v-settings">
<div class="pagehead"><span class="t">設定</span></div>
<div class="setcol">
<div class="panel">
<div style="display:flex;align-items:center;gap:14px">
<div style="min-width:0">
<div style="font-size:17px;font-weight:600">深色模式</div>
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">預設淺色(紙感)。切換立即生效,選擇記在這台裝置(localStorage)。</div>
</div>
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
</div>
</div>
<div class="panel">
<div style="display:flex;align-items:center;gap:14px">
<div style="min-width:0">
<div style="font-size:17px;font-weight:600">語意搜尋(vectorize</div>
<div id="st-vec" style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">狀態偵測中…</div>
</div>
<div class="switch" id="st-vec-switch" title="此開關不能遠端改,只如實顯示狀態"><i></i></div>
</div>
<div style="margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
誠實提示:開關真相=部署端 <code style="font-size:12.5px">~/.arcrun/config.yaml</code> 的 <code style="font-size:12.5px">kbdb_embed: true</code> <code style="font-size:12.5px">acr update</code> 重部署(#32 教訓:wrangler 直推改的形態 config 要同步)。Console 只如實顯示狀態,不假裝能遠端開啟。目前狀態:<b style="color:var(--amber)" id="st-vec-state">偵測中…</b>
</div>
</div>
<div class="panel">
<div style="font-size:17px;font-weight:600">MCP token 有效期(TTL</div>
<div id="st-mcp-ttl" style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">讀取中…</div>
<div style="margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
誠實佔位:此頁<b style="color:var(--amber)">還不能改</b>這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env <code style="font-size:12.5px">MCP_TOKEN_TTL</code>mcp worker)設定。
</div>
</div>
<div class="panel">
<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 style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼">
<input type="email" id="st-pw-email" class="txt" placeholder="Email(可沿用或換新)">
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)">
<button class="btn" id="st-pw-save">更新密碼</button>
<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>
<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 class="panel">
<div style="font-size:17px;font-weight:600;margin-bottom:12px">系統資訊</div>
<div id="st-info"><div class="muted">載入中…</div></div>
</div>
<button class="btn3" id="st-logout" style="padding:14px;font-size:16px;border-radius:11px">登出</button>
</div>
</div>
</main>
</div>
<!-- 手機底部導覽 -->
<div id="tabbar">
${rag ? '' : '<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="settings">設定</div>
</div>
<div id="toast"></div>
<script>
(function () {
'use strict';
// 台北時間 helperlib/taipei-time.ts 注入,與駕駛艙 #36 台北日判定同一套)——
// console 所有日期/時間顯示固定 Asia/Taipei,不隨看的裝置時區漂移
${TAIPEI_CLIENT_JS}
var REGISTRY_BASE = ${JSON.stringify(registryBase)};
var $ = function (id) { return document.getElementById(id); };
// ── 狀態 ──
var S = {
token: localStorage.getItem('arcrun_console_session') || '',
tenant: '',
chip: '', // entry_type filter'' = 全部)
semantic: false,
cardId: '',
graphCenter: '',
wfCache: null,
pins: (function () { try { return JSON.parse(localStorage.getItem('arcrun_console_pins') || '{}'); } catch (e) { return {}; } })()
};
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function toast(msg) {
var el = $('toast');
el.textContent = msg;
el.style.display = 'block';
clearTimeout(toast._t);
toast._t = setTimeout(function () { el.style.display = 'none'; }, 2000);
}
function apiHeaders() { return { 'X-Arcrun-API-Key': S.tenant }; }
// fetch 失敗(斷網)的裸 TypeError 訊息(Failed to fetch / Load failed)→ 友善誠實文案
function friendlyErr(e) {
var m = e && e.message ? String(e.message) : String(e);
if (/failed to fetch|load failed|networkerror|network request failed/i.test(m)) return '連線中斷——請檢查網路後重試';
return m;
}
// ── 主題(深/淺):預設淺色,選擇存 localStorage(head 預載腳本已先套用防閃色)──
function getTheme() { return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; }
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem('arcrun_console_theme', t); } catch (e) { /* 私密模式存不了就只影響本次 */ }
syncThemeUI();
}
function syncThemeUI() {
var dark = getTheme() === 'dark';
document.querySelectorAll('.themelabel').forEach(function (el) { el.textContent = dark ? '☀ 切淺色' : '☾ 切深色'; });
var sw = $('st-theme-switch');
if (sw) sw.classList.toggle('on', dark);
}
document.addEventListener('click', function (ev) {
var t = ev.target.closest('[data-themetoggle]');
if (t) setTheme(getTheme() === 'dark' ? 'light' : 'dark');
});
syncThemeUI();
// created_at 容錯:epoch 秒 / 毫秒 / ISO / 'YYYY-MM-DD HH:MM:SS'
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; }
var s = /T/.test(v) ? v : String(v).replace(' ', 'T') + 'Z';
var ms = Date.parse(s);
return isNaN(ms) ? null : ms;
}
// 顯示一律台北時間(bug 修正:原本用瀏覽器本地 getFullYear/getHours——顯示結果
// 隨看的裝置時區漂移,且與後端「台北日」判定不同套;憑證頁 7/6 22:57(台北)建立的
// credential 在非 +8 裝置上會顯示成另一天)
function fmtDate(v) {
var ms = parseAtMs(v);
return ms == null ? '' : taipeiDateStr(ms);
}
function fmtDateTime(v) {
var ms = parseAtMs(v);
return ms == null ? '' : taipeiDateTimeStr(ms);
}
// ── 極簡 Markdown 渲染(先全 escape 再上格式,防 XSS)──
function mdInline(s) {
return s
.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 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)) {
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; }
var line = esc(raw);
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 (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+(.*)$/);
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; }
closeList();
out.push('<p>' + mdInline(line) + '</p>');
}
closeList();
if (inCode) out.push('</code></pre>');
return out.join('');
}
// ── 路由 ──
// VIEWS / HOME 由 server 依 CONSOLE_PROFILE 注入(full=全套 7 頁落地駕駛艙;rag=4 頁落地搜尋)。
// 不在 VIEWS 裡的 hash(含被 profile 裁掉的頁)一律導回 HOME——手打 #/cockpit 也進不去。
var VIEWS = ${JSON.stringify(views)};
var HOME = ${JSON.stringify(home)};
function currentRoute() {
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('/') };
}
function nav(view, arg) {
location.hash = '#/' + view + (arg ? '/' + encodeURIComponent(arg) : '');
}
function showView(name) {
VIEWS.forEach(function (v) { $('v-' + v).classList.toggle('on', v === name); });
var activeKey = name === 'card' ? 'search' : name;
document.querySelectorAll('#sidenav .nav, #tabbar .tab').forEach(function (el) {
el.classList.toggle('on', el.getAttribute('data-nav') === activeKey);
});
window.scrollTo(0, 0);
}
var LOADERS = { cockpit: loadCockpit, search: initSearchOnce, card: loadCard, workflows: loadWorkflows, creds: loadCreds, inbox: loadTriage, settings: loadSettings };
function route() {
var r = currentRoute();
// 隱藏頁/不存在頁 guardhash 不在 VIEWS → 改寫成 HOMEhashchange 會再進來一次正常渲染)
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);
showView(r.view);
if (LOADERS[r.view]) LOADERS[r.view]();
}
window.addEventListener('hashchange', route);
document.addEventListener('click', function (ev) {
var t = ev.target.closest('[data-nav]');
if (t) nav(t.getAttribute('data-nav'));
});
// ── 認證 ──
function showAuth(which) {
$('v-login').classList.toggle('on', which === 'login');
$('v-setup').classList.toggle('on', which === 'setup');
$('shell').classList.remove('on');
$('tabbar').classList.remove('on');
}
function showApp() {
$('v-login').classList.remove('on');
$('v-setup').classList.remove('on');
$('shell').classList.add('on');
$('tabbar').classList.add('on');
$('side-foot').innerHTML = '租戶 ' + esc(S.tenant) + '<br>' + esc(location.host);
if (!location.hash) location.hash = '#/' + HOME;
route();
}
function init() {
if (S.token) {
fetch('/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 () {
S.token = ''; localStorage.removeItem('arcrun_console_session');
checkConfigured();
});
} else {
checkConfigured();
}
}
function checkConfigured() {
fetch('/console/auth-status')
.then(function (r) { return r.json(); })
.then(function (d) { showAuth(d.configured ? 'login' : 'setup'); })
.catch(function () { showAuth('login'); });
}
function handleAuthResponse(d) {
S.token = d.session_token;
localStorage.setItem('arcrun_console_session', S.token);
S.tenant = d.tenant || '';
showApp();
}
$('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 }) })
.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); });
});
$('login-password').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') $('login-submit').click(); });
$('setup-submit').addEventListener('click', function () {
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 }) })
.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); });
});
// ── 駕駛艙 ──
var 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' }
};
var TASK_ICONS = { done: '✓', doing: '◐', todo: '○', blocked: '●' };
var 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] : '')); }
function taskLine(t) {
var cls = TASK_ICONS[t.status] ? t.status : 'blocked';
return '<li class="' + cls + '"><span class="ic">' + (TASK_ICONS[t.status] || '●') + '</span><span>' + esc(t.title) + '</span></li>';
}
function ckAge(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) + ' 天前';
}
function loadCockpit() {
var nowTpe = taipeiMonthDay(Date.now()); // 頁首日期=台北日,不吃裝置時區
$('ck-date').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
fetch('/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;
var orb = $('ck-orb');
orb.style.background = cfg.grad;
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
$('ck-char').textContent = cfg.ch;
$('ck-title').textContent = cfg.title;
$('ck-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 + '' : '');
var done = d.today_done || 0, total = d.today_total || 0;
$('ck-done').textContent = done; $('ck-total').textContent = total;
$('ck-bar').style.width = (total ? Math.round(done / total * 100) : 0) + '%';
$('ck-inbox').textContent = d.inbox_new || 0;
// 等你的事:來源與維護時間攤開講(fix/console-dashboard-live-data)——
// Gitea sprint 等leo清單是活資料源;fallback dash_wait 一定標 stale,不假裝是今天的
var wb = $('ck-waitbox'), body = $('ck-wait'), wmeta = $('ck-waitmeta');
var wm = d.waiting_meta || {};
if (d.waiting && d.waiting.length) {
wb.classList.add('has'); body.className = '';
body.innerHTML = d.waiting.map(function (w) {
return '<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' ? '(管線未接)' : '無,你不用做任何事';
}
if (wm.source === 'gitea_sprint') {
wmeta.innerHTML = '來源:sprint 等leo清單・維護於 ' + ckAge(wm.updated_ago_minutes) +
(wm.stale ? '<br><span class="err">⚠ 清單超過 2 天沒維護,可能過時</span>' : '');
} else if (wm.source === 'kbdb_dash_wait') {
wmeta.innerHTML = '<span class="err">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + ckAge(wm.updated_ago_minutes) + ',可能過時</span>';
} else if (wm.source === 'none') {
wmeta.innerHTML = '<span class="err">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
} else {
wmeta.textContent = '';
}
// 今日路線:sprint 任務板優先(來源與更新時間攤開講);dash_task fallback 沿舊誠實降級
var rm = d.route_meta || {};
var today = (d.tasks || []).filter(function (t) { return t.scope === 'today'; });
var week = (d.tasks || []).filter(function (t) { return t.scope === 'week'; });
if (rm.source === 'gitea_sprint_board') {
$('ck-route-src').textContent = '來源 sprint 任務板・更新於 ' + ckAge(rm.updated_ago_minutes);
var staleHead = rm.is_today ? '' :
'<li class="todo"><span class="ic">○</span><span class="muted">⚠ 今日任務板未更新(最後 ' + ckAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';
$('ck-today').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 || !today.length) {
$('ck-route-src').textContent = '來源 dash_task';
$('ck-today').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目——dash_task 今天沒有寫入</span></li>';
} else {
$('ck-route-src').textContent = '來源 dash_task';
$('ck-today').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + ckAge(rm.updated_ago_minutes) + '的殘留路線</span></li>' + today.map(taskLine).join('');
}
$('ck-weekhead').style.display = week.length ? '' : 'none';
$('ck-week').innerHTML = week.map(taskLine).join('');
$('ck-stamp').textContent = '每 60 秒自動刷新・上次 ' + taipeiTimeStr(Date.parse(d.generated_at)) + '(台北)';
})
.catch(function (e) {
$('ck-char').textContent = '';
$('ck-title').textContent = '讀不到狀態';
$('ck-sub').innerHTML = '<span class="err">' + esc(friendlyErr(e)) + '・每 60 秒自動重試</span>';
});
}
// 60 秒定時器無條件常駐:斷網期間照跑,網路恢復後下一輪自動刷新回正常畫面
setInterval(function () { if (currentRoute().view === 'cockpit' && $('shell').classList.contains('on')) loadCockpit(); }, 60000);
// ── 總庫搜尋 ──
// chips = KBDB 真能篩的 entry_type(誠實:不編造「電腦筆記/手機筆記」等 KBDB 沒有的分類軸)
var CHIPS = [
{ label: '全部', v: '' },
{ label: '筆記 note', v: 'note' },
{ label: '區塊 block', v: 'block' },
{ label: '數值 value', v: 'value' },
{ label: '工作流 workflow', v: 'workflow' }
];
var searchInited = false;
function initSearchOnce() {
if (searchInited) { return; }
searchInited = true;
renderChips();
loadScale();
}
function renderChips() {
$('se-chips').innerHTML = CHIPS.map(function (c, i) {
return '<button class="chip' + (S.chip === c.v ? ' on' : '') + '" data-chip="' + i + '">' + esc(c.label) + '</button>';
}).join('');
}
$('se-chips').addEventListener('click', function (ev) {
var t = ev.target.closest('[data-chip]');
if (!t) return;
S.chip = CHIPS[Number(t.getAttribute('data-chip'))].v;
renderChips();
if ($('se-q').value.trim()) doSearch();
});
// 頭部統計=精耕層 live 數字(leo 2026-07-07 裁:45.8 萬 14-E 搬遷遺產已 deprecated、
// 之後要刪——不再拿遺產總數撐場面,只顯示真的新的)。搜尋本身仍搜全庫,資料不藏。
function loadScale() {
fetch('/console/kb-scale-data')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
var n = function (v) { return v == null ? '' : v.toLocaleString(); };
var parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)];
var latest = d.wiki_card_latest_ago_minutes;
$('se-scale').textContent = '精耕層 ' + parts.join('・') +
(latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');
})
.catch(function () { /* 規模感拿不到不擋搜尋 */ });
}
$('se-sem').addEventListener('click', function () {
S.semantic = !S.semantic;
$('se-sem').classList.toggle('on', S.semantic);
if ($('se-q').value.trim()) doSearch();
});
$('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();
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();
if (!body) body = lines.join(' ');
if (body.length > 140) body = body.slice(0, 140) + '…';
return body;
}
function doSearch() {
var q = $('se-q').value.trim();
if (!q) { $('se-count').textContent = '請輸入查詢字'; return; }
$('se-banner').innerHTML = '';
$('se-count').textContent = '查詢中…';
$('se-results').innerHTML = '';
var url = '/kbdb/search?q=' + encodeURIComponent(q) + '&mode=' + (S.semantic ? 'semantic' : 'keyword');
if (S.chip) url += '&entry_type=' + encodeURIComponent(S.chip);
fetch(url, { 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) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + '')) + '</span>'; return; }
var d = x.d;
if (S.semantic && d.mode === 'keyword') {
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋尚未啟用</div><div class="b">語意搜尋用「意思」找資料,不是字面比對。<br>' + esc(d.capability_hint || '部署端尚未開啟 Vectorize——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';
}
var entries = d.entries || [];
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') + '・搜尋範圍=全庫(含 14-E 遺產)';
if (!entries.length) {
$('se-results').innerHTML = '<div class="muted" style="padding:30px 10px;text-align:center;grid-column:1/-1">找不到「' + esc(q) + '」——換個關鍵字試試。</div>';
return;
}
$('se-results').innerHTML = entries.map(function (e) {
// 精耕層(wiki_card)綠標突顯;其餘照 entry_type 原樣(不誤標——block/value/note
// 大宗是 14-E 遺產,但 entry_type 本身不足以斷言,僅精耕層正向標記)
var typeTag = e.entry_type === 'wiki_card'
? '<span class="tag green">wiki_card・精耕</span>'
: '<span class="tag">' + esc(e.entry_type || 'entry') + '</span>';
return '<div class="kcard" data-card="' + esc(e.id) + '">' +
'<div class="kt">' + esc(entryTitle(e)) + '</div>' +
'<div class="ks">' + esc(entrySnippet(e)) + '</div>' +
'<div class="km">' + typeTag +
(e.source ? '<span class="dim">' + esc(e.source) + '</span>' : '') +
'<span class="dim mono" style="margin-left:auto">' + esc(fmtDate(e.created_at)) + '</span></div></div>';
}).join('');
})
.catch(function (e) { $('se-count').innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
}
$('se-results').addEventListener('click', function (ev) {
var t = ev.target.closest('[data-card]');
if (t) nav('card', t.getAttribute('data-card'));
});
// ── 卡片詳頁 + 關聯 ──
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() })
.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; }
var e = x.d.entry;
var title = entryTitle(e);
var html = '<div class="cardtitle">' + esc(title) + '</div>' +
'<div style="display:flex;gap:10px;align-items:center;margin-top:12px;font-size:13.5px;flex-wrap:wrap">' +
'<span class="tag">' + esc(e.entry_type || 'entry') + '</span>' +
(e.page_name ? '<span class="tag green">' + esc(e.page_name) + '</span>' : '') +
'<span class="muted mono">' + esc(fmtDateTime(e.created_at)) + '</span></div>' +
'<div class="md">' + mdRender(e.content || '(此條目沒有內文)') + '</div>' +
(e.source
? '<div class="srcline"><span style="font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);flex:none">來源回溯</span><span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(e.source) + '</span></div>'
: '');
$('cd-main').innerHTML = html;
var nodeName = e.page_name || title;
S.graphCenter = nodeName;
loadNeighbors(nodeName);
})
.catch(function (er) { $('cd-main').innerHTML = '<div class="err" style="padding:24px 0">請求失敗:' + esc(friendlyErr(er)) + '</div>'; renderGraphEmpty('請求失敗'); });
}
function renderGraphEmpty(msg) {
$('cd-graph').innerHTML = '<div class="graphbox" style="display:grid;place-items:center;height:160px"><span class="muted" style="font-size:14px;padding:0 20px;text-align:center">' + esc(msg) + '</span></div>';
$('cd-neighbors').innerHTML = '';
}
function loadNeighbors(name) {
renderGraphEmpty('查詢關聯中…');
fetch('/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; }
var neighbors = x.d.neighbors || [];
var edges = x.d.edges || [];
if (!neighbors.length) { renderGraphEmpty('尚無關聯資料——這張卡還沒有 triplet 連進知識圖譜。'); return; }
renderGraph(name, neighbors.slice(0, 10));
$('cd-neighbors').innerHTML = edges.slice(0, 12).map(function (t) {
var other = t.subject === name ? t.object : t.subject;
return '<div class="nbrow" data-gnode="' + esc(other) + '">' +
'<span class="tag green" style="flex:none">' + esc(t.predicate || '關聯') + '</span>' +
'<span style="word-break:break-word">' + esc(other) + '</span>' +
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none"></span></div>';
}).join('');
})
.catch(function (e) { renderGraphEmpty('關聯服務不可達:' + friendlyErr(e)); });
}
function renderGraph(center, nodes) {
var n = nodes.length || 1;
var lines = '', chips = '';
for (var i = 0; i < n; i++) {
var a = (-90 + i * (360 / n)) * Math.PI / 180;
var x = 50 + 39 * Math.cos(a), y = 50 + 37 * Math.sin(a);
lines += '<line x1="50" y1="50" x2="' + x.toFixed(2) + '" y2="' + y.toFixed(2) + '" stroke="rgba(var(--ok-rgb),.3)" stroke-width="1" vector-effect="non-scaling-stroke" stroke-dasharray="4 4"/>';
chips += '<div class="gnode" style="left:' + x.toFixed(2) + '%;top:' + y.toFixed(2) + '%" data-gnode="' + esc(nodes[i]) + '">' + esc(nodes[i]) + '</div>';
}
var shortCenter = center.length > 16 ? center.slice(0, 16) + '…' : center;
$('cd-graph').innerHTML = '<div class="graphbox">' +
'<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="position:absolute;inset:0;width:100%;height:100%">' + lines + '</svg>' +
'<div class="gcenter"><span>' + esc(shortCenter) + '</span></div>' + chips +
'<div style="position:absolute;right:12px;bottom:10px;font-size:11.5px;color:rgba(var(--ink-rgb),.35)">╌ 知識圖譜鄰居(點節點展開)</div></div>';
}
document.addEventListener('click', function (ev) {
var t = ev.target.closest('[data-gnode]');
if (!t) return;
var name = t.getAttribute('data-gnode');
S.graphCenter = name;
loadNeighbors(name);
});
// ── 工作流 ──
function loadWorkflows() {
fetch('/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; }
S.wfCache = x.d.workflows || [];
$('wf-count').textContent = S.wfCache.length + ' 條自動化';
renderWorkflows();
})
.catch(function (e) { $('wf-list').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
$('wf-q').addEventListener('input', function () { renderWorkflows(); });
function renderWorkflows(openName) {
var list = S.wfCache || [];
var q = $('wf-q').value.trim().toLowerCase();
if (q) list = list.filter(function (w) { return (w.name + ' ' + (w.description || '')).toLowerCase().indexOf(q) >= 0; });
list = list.slice().sort(function (a, b) { return (S.pins[b.name] ? 1 : 0) - (S.pins[a.name] ? 1 : 0); });
if (!list.length) {
$('wf-list').innerHTML = '<div class="honest"><div class="h">' + (q ? '沒有符合的工作流' : '還沒有任何工作流') + '</div><div class="b">' + (q ? '換個關鍵字試試。' : '用 acr push 部署第一條 workflow 後,這裡就會出現。') + '</div></div>';
return;
}
$('wf-list').innerHTML = list.map(function (w) {
var pinned = !!S.pins[w.name];
var open = w.name === openName;
return '<div class="card-item' + (pinned ? ' hl' : '') + '" data-wf="' + esc(w.name) + '">' +
'<div class="rowhead" data-wftoggle="' + esc(w.name) + '">' +
'<button class="pinbtn' + (pinned ? ' on' : '') + '" data-wfpin="' + esc(w.name) + '" title="釘選">' + (pinned ? '★' : '☆') + '</button>' +
'<div style="min-width:0"><div style="font-size:17px;font-weight:600;line-height:1.4;word-break:break-word">' + esc(w.name) + '</div>' +
'<div style="margin-top:3px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)">' + esc(w.cron_expr ? '排程 ' + w.cron_expr : '事件觸發') + (w.created_at ? '・建立 ' + esc(fmtDate(w.created_at)) : '') + '</div></div>' +
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none">' + (open ? '▾' : '') + '</span></div>' +
(open ? renderWfBody(w) : '') + '</div>';
}).join('');
if (openName) fetchWfRuns(openName);
}
function renderWfBody(w) {
return '<div class="rowbody">' +
'<div style="margin-top:14px;font-size:15.5px;line-height:1.7;color:rgba(var(--ink-rgb),.7)">' + (w.description ? esc(w.description) : '<span class="dim">(未填描述——re-deploy 時補一句「能做什麼」它才搜得到)</span>') + '</div>' +
'<div class="urlbox" style="margin-top:14px"><span style="font-size:12.5px;letter-spacing:.1em;color:rgba(var(--ink-rgb),.45);flex:none">觸發網址</span>' +
'<span class="u">' + esc(w.webhook_url || '') + '</span>' +
'<button class="btn2" style="margin-left:auto;flex:none;padding:7px 13px;font-size:13.5px" data-wfcopy="' + esc(w.webhook_url || '') + '">複製</button></div>' +
'<div style="margin-top:14px;font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5)">最近執行</div>' +
'<div id="wf-runs" style="margin-top:8px;display:flex;flex-direction:column;gap:6px"><span class="dim" style="font-size:14px">載入中…</span></div>' +
'<button class="btn" style="margin-top:16px;width:100%" data-wfrun="' + esc(w.name) + '">手動觸發</button>' +
'<div id="wf-runmsg" style="margin-top:8px;font-size:14px;min-height:1.2em"></div></div>';
}
var wfOpen = '';
$('wf-list').addEventListener('click', function (ev) {
var pin = ev.target.closest('[data-wfpin]');
if (pin) {
var name = pin.getAttribute('data-wfpin');
if (S.pins[name]) delete S.pins[name]; else S.pins[name] = true;
localStorage.setItem('arcrun_console_pins', JSON.stringify(S.pins));
renderWorkflows(wfOpen);
ev.stopPropagation();
return;
}
var copy = ev.target.closest('[data-wfcopy]');
if (copy) {
var url = copy.getAttribute('data-wfcopy');
(navigator.clipboard ? navigator.clipboard.writeText(url) : Promise.reject()).then(
function () { toast('已複製觸發網址'); },
function () { toast('複製失敗——手動選取網址'); }
);
ev.stopPropagation();
return;
}
var run = ev.target.closest('[data-wfrun]');
if (run) { triggerWf(run.getAttribute('data-wfrun'), run); ev.stopPropagation(); return; }
var tog = ev.target.closest('[data-wftoggle]');
if (tog) {
var n = tog.getAttribute('data-wftoggle');
wfOpen = wfOpen === n ? '' : n;
renderWorkflows(wfOpen);
}
});
function fetchWfRuns(name) {
var box = document.getElementById('wf-runs');
if (!box) return;
fetch('/workflows/' + encodeURIComponent(name) + '/executions?limit=3', { headers: apiHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
var runs = (d.data && d.data.executions) || [];
if (!runs.length) { box.innerHTML = '<span class="dim" style="font-size:14px">尚無執行紀錄</span>'; return; }
box.innerHTML = runs.map(function (r) {
var okRun = r.verdict === 'success';
return '<div style="display:flex;gap:10px;align-items:baseline;font-size:14.5px">' +
'<span style="flex:none;font-size:10px;color:' + (okRun ? 'var(--ok)' : 'var(--err)') + '">●</span>' +
'<span style="color:rgba(var(--ink-rgb),.7)">' + (okRun ? '成功' : '失敗') + (typeof r.duration_ms === 'number' ? '・' + r.duration_ms + 'ms' : '') + (r.message && !okRun ? '・' + esc(String(r.message).slice(0, 60)) : '') + '</span>' +
'<span class="dim mono" style="margin-left:auto;font-size:12.5px">' + esc(r.recorded_at ? fmtDateTime(r.recorded_at) : (r.timestamp ? fmtDateTime(Number(r.timestamp)) : '')) + '</span></div>';
}).join('');
})
.catch(function () { box.innerHTML = '<span class="dim" style="font-size:14px">執行紀錄讀取失敗</span>'; });
}
function triggerWf(name, btn) {
btn.disabled = true; btn.textContent = '執行中…';
var msg = document.getElementById('wf-runmsg');
fetch('/webhooks/named/' + encodeURIComponent(name) + '/trigger', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),
body: '{}'
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
btn.disabled = false; btn.textContent = '手動觸發';
if (x.ok && x.d.success !== false) {
if (msg) msg.innerHTML = '<span class="ok">✓ 執行成功' + (typeof x.d.duration_ms === 'number' ? '・' + x.d.duration_ms + 'ms' : '') + '</span>';
toast('「' + name + '」執行完成');
} else {
if (msg) msg.innerHTML = '<span class="err">✕ ' + esc(x.d.error || '執行失敗') + '</span>';
}
fetchWfRuns(name);
})
.catch(function (e) {
btn.disabled = false; btn.textContent = '手動觸發';
if (msg) msg.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>';
});
}
// ── 零件 / recipes(v0 搜尋台保留區)──
$('cr-go').addEventListener('click', doCompSearch);
$('cr-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doCompSearch(); });
function doCompSearch() {
var q = $('cr-q').value.trim();
if (!q) return;
var out = $('cr-out');
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 }; }); })
]).then(function (rs) {
var html = '';
var comp = rs[0];
html += '<div class="dim" style="font-size:12.5px;letter-spacing:.1em;margin:10px 0 6px">COMPONENTS</div>';
if (comp.status === 'fulfilled' && comp.value.ok) {
var results = (comp.value.d.data && comp.value.d.data.results) || [];
html += results.length
? results.map(function (c) { return '<div class="card-item" style="padding:10px 14px;margin-bottom:6px;font-size:14px"><span class="tag">' + esc(c.canonical_id || c.id) + '</span> <span class="muted">' + esc(c.description || c.name || '') + '</span></div>'; }).join('')
: '<div class="muted" style="font-size:14px">沒查到符合的 component</div>';
} else {
html += '<div class="err" style="font-size:14px">components 查詢失敗(零件庫不可達)</div>';
}
var rec = rs[1];
html += '<div class="dim" style="font-size:12.5px;letter-spacing:.1em;margin:12px 0 6px">RECIPES</div>';
if (rec.status === 'fulfilled' && rec.value.ok) {
var d = rec.value.d;
if (d.found === false) {
html += '<div class="muted" style="font-size:14px">' + esc(d.hint || '沒查到符合的 recipe') + '</div>';
} else {
var recipes = d.recipes || [];
html += recipes.length
? recipes.map(function (r) { return '<div class="card-item" style="padding:10px 14px;margin-bottom:6px;font-size:14px"><span class="tag">' + esc(r.canonical_id) + '</span> <span class="muted">' + esc(r.description || r.display_name || '') + '</span></div>'; }).join('')
: '<div class="muted" style="font-size:14px">沒查到符合的 recipe</div>';
}
} else {
html += '<div class="err" style="font-size:14px">recipes 查詢失敗</div>';
}
out.innerHTML = html;
});
}
// ── 憑證管理 ──
$('cred-addbtn').addEventListener('click', function () {
var f = $('cred-form');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
$('cred-save').addEventListener('click', function () {
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; }
st.textContent = '保存中…';
fetch('/credentials', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),
body: JSON.stringify({ name: name, value: value, service: service || undefined })
})
.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) { st.innerHTML = '<span class="err">' + esc(x.d.error || '保存失敗') + '</span>'; return; }
st.textContent = '';
$('cred-name').value = ''; $('cred-service').value = ''; $('cred-secret').value = '';
$('cred-form').style.display = 'none';
toast('已加密保存');
loadCreds();
})
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
});
var credOpen = '';
var credCache = [];
function loadCreds() {
fetch('/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) {
$('cred-list').innerHTML = '<div class="honest"><div class="h">目錄讀取失敗</div><div class="b">' + esc(x.d.error || '未知錯誤') + '</div></div>';
return;
}
credCache = x.d.credentials || [];
renderCreds();
})
.catch(function (e) { $('cred-list').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
function renderCreds() {
if (!credCache.length) {
$('cred-list').innerHTML = '<div class="honest"><div class="h">還沒有任何憑證</div><div class="b">按「+ 新增憑證」加入第一筆。目錄只存名稱與服務,密文進 Workers Secrets、不可回看。</div></div>';
return;
}
$('cred-list').innerHTML = credCache.map(function (c) {
var open = credOpen === c.name;
var initial = (c.service || c.name || '?').slice(0, 1).toUpperCase();
return '<div class="card-item' + (open ? ' hl' : '') + '">' +
'<div class="rowhead" data-credtoggle="' + esc(c.name) + '">' +
'<span class="avatar">' + esc(initial) + '</span>' +
'<div style="min-width:0"><div style="font-size:16.5px;font-weight:600;word-break:break-all">' + esc(c.name) + '</div>' +
'<div style="margin-top:2px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)">' + esc(c.service || '未填服務') + '・建立於 ' + esc(fmtDateTime(c.created_at) || '—') + '(台北)</div></div>' +
'<div style="margin-left:auto;text-align:right;font-size:12.5px;color:rgba(var(--ink-rgb),.4);flex:none">最後使用<br>' + esc(c.last_used_at ? fmtDateTime(c.last_used_at) : '尚未記錄') + '</div></div>' +
(open
? '<div class="rowbody">' +
'<div class="maskline" style="margin-top:14px"><span class="mono" style="font-size:16px;letter-spacing:.3em;color:rgba(var(--ink-rgb),.4)">••••••••••••••••</span><span style="margin-left:auto;font-size:12.5px;color:rgba(var(--ink-rgb),.4)">已加密・不可回看</span></div>' +
'<textarea class="txt" id="cred-newval" placeholder="貼上新值以整筆替換…" rows="2" style="margin-top:10px"></textarea>' +
'<div style="display:flex;gap:10px;margin-top:10px;flex-wrap:wrap">' +
'<button class="btn" style="flex:1;min-width:140px" data-credreplace="' + esc(c.name) + '">整筆替換</button>' +
'<button class="btn-danger" disabled title="刪除端點尚未接上新目錄(T9 範圍)">刪除・即將開通</button></div>' +
'<div id="cred-msg" style="margin-top:8px;font-size:14px;min-height:1.2em"></div></div>'
: '') +
'</div>';
}).join('');
}
$('cred-list').addEventListener('click', function (ev) {
var rep = ev.target.closest('[data-credreplace]');
if (rep) {
var name = rep.getAttribute('data-credreplace');
var ta = document.getElementById('cred-newval');
var msg = document.getElementById('cred-msg');
var val = ta ? ta.value : '';
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), {
method: 'PUT',
headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),
body: JSON.stringify({ value: val, service: cur && cur.service ? cur.service : undefined })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
rep.disabled = false;
if (!x.ok || x.d.success === false) { if (msg) msg.innerHTML = '<span class="err">' + esc(x.d.error || '替換失敗') + '</span>'; return; }
credOpen = '';
toast('「' + name + '」已整筆替換');
loadCreds();
})
.catch(function (e) { rep.disabled = false; if (msg) msg.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
return;
}
var tog = ev.target.closest('[data-credtoggle]');
if (tog) {
var n = tog.getAttribute('data-credtoggle');
credOpen = credOpen === n ? '' : n;
renderCreds();
}
});
// ── 分流台(原收件匣,Arcrun#9todoinbox 二源、三欄 80/15/5、per-project chips)──
// 頁面只渲染 /console/triage-data 的結果(分欄/計數判定在後端純函式);chips 切換與
// 「顯示已完成」是同一包資料的前端過濾,零額外請求(leo 手機看,省來回)。
var TG = { data: null, project: '', showDone: false };
var TG_TIERS = [
{ key: 'ai', nm: 'AI 會幫我搞定', pc: '80%', empty: '這欄空的——還沒有分給 AI 的待辦' },
{ key: 'collab', nm: '協作・AI 做完你接手', pc: '15%', empty: '這欄空的——沒有要你配合的事' },
{ key: 'leo', nm: '非我不可', pc: '5%', empty: '這欄空的——沒有非你不可的事' }
];
function triItem(m) {
var done = m.status === 'done';
var meta = '';
if (m.marker) meta += '<span class="tag dim">' + esc(m.marker) + '</span>';
meta += '<span class="tag">' + esc(m.project || '未分項') + '</span>';
if (m.source) meta += '<span>' + esc(m.source) + '</span>';
if (m.unclassified && m.origin === 'todo') meta += '<span class="tag red">未分流</span>';
// 誤勾救濟:done 清單裡給「還原」(status 回 new + 移除 checked_via/checked_at,端點同一個)
if (done) meta += '<button class="tri-restore" data-trirestore="' + esc(m.id) + '">還原</button>';
meta += '<span class="mono" style="margin-left:auto">' + esc(fmtDateTime(m.at)) + '</span>';
// 勾掉=雙向銷案的 console 端終局(checked_via:console,萃取端絕不復活);
// 後端回寫成功才改本地狀態移出三欄(done 預設隱藏),不樂觀假裝
var body = done
? '<div class="tx">' + esc(m.text) + '</div>'
: '<div class="row"><div class="tx">' + esc(m.text) + '</div><button class="tri-check" data-tricheck="' + esc(m.id) + '" title="勾掉(銷案)">✓</button></div>';
return '<div class="tri-item ' + (done ? 'done' : 'new') + '">' + body + '<div class="mt">' + meta + '</div></div>';
}
function triChip(label, val, on) {
return '<button class="chip' + (on ? ' on' : '') + '" data-triproj="' + esc(val) + '">' + esc(label) + '</button>';
}
function renderTriage() {
var d = TG.data || {};
var items = d.items || [];
var out = $('tg-out');
// per-project chips:全部+各 project+(有未分項才出)未分項
var chips = triChip('全部', '', TG.project === '');
(d.projects || []).forEach(function (p) { chips += triChip(p, p, TG.project === p); });
if (items.some(function (i) { return !i.project; })) chips += triChip('未分項', '__none__', TG.project === '__none__');
$('tg-chips').innerHTML = items.length ? chips : '';
if (!items.length) {
out.innerHTML = '<div class="honest" style="margin-top:14px"><div class="h">分流台是空的</div><div class="b">資料源=KBDB todo entriesLogseq 萃取,ingest 灌入中)+ inbox entriesTelegram)。<br>寫入方尚未灌資料時這裡誠實顯示空。</div></div>';
return;
}
var vis = items.filter(function (i) {
return TG.project === '' || (TG.project === '__none__' ? !i.project : i.project === TG.project);
});
var doneN = vis.filter(function (i) { return i.status === 'done'; }).length;
var html = '';
TG_TIERS.forEach(function (t) {
var news = vis.filter(function (i) { return i.tier === t.key && i.status !== 'done'; });
var dones = TG.showDone ? vis.filter(function (i) { return i.tier === t.key && i.status === 'done'; }) : [];
html += '<div class="tri-sec ' + t.key + '"><div class="tri-sechead"><span class="dot"></span><span class="nm serif">' + t.nm + '</span><span class="pc">' + t.pc + '</span><span class="ct">' + news.length + ' 件</span></div>';
if (!news.length && !dones.length) html += '<div class="muted" style="font-size:14px;padding:10px 2px 0">' + t.empty + '</div>';
html += news.map(triItem).join('') + dones.map(triItem).join('');
html += '</div>';
});
html += '<div style="margin:24px 0 20px;text-align:center"><button class="btn3" id="tg-donetoggle" style="padding:9px 18px;font-size:13.5px;border-radius:999px">' +
(TG.showDone ? '隱藏已完成' : '顯示已完成(' + doneN + '') + '</button></div>';
out.innerHTML = html;
$('tg-donetoggle').addEventListener('click', function () { TG.showDone = !TG.showDone; renderTriage(); });
}
$('tg-chips').addEventListener('click', function (ev) {
var t = ev.target.closest('[data-triproj]');
if (!t) return;
TG.project = t.getAttribute('data-triproj');
renderTriage();
});
// 勾掉/還原:POST /console/triage-checkconsole session 保護的小端點——瀏覽器沒有 KBDB
// tokenPATCH 由 server 端做,先 GET 原 entry 整串回寫防蓋掉別的欄位)。成功才動本地
// 狀態重繪(該筆立即移出/回到三欄),失敗誠實 toast、按鈕解鎖可重試。
function triageAct(id, action, btn) {
btn.disabled = true;
fetch('/console/triage-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + S.token },
body: JSON.stringify({ entry_id: id, action: action })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (!x.ok) { btn.disabled = false; toast(x.d.error || ('失敗(HTTP ' + x.status + '')); return; }
var items = (TG.data && TG.data.items) || [];
for (var i = 0; i < items.length; i++) {
if (items[i].id === id) items[i].status = (action === 'restore' ? 'new' : 'done');
}
renderTriage();
toast(action === 'restore' ? '已還原' : '已勾掉');
})
.catch(function (e) { btn.disabled = false; toast('請求失敗:' + friendlyErr(e)); });
}
$('tg-out').addEventListener('click', function (ev) {
var chk = ev.target.closest('[data-tricheck]');
if (chk) { triageAct(chk.getAttribute('data-tricheck'), 'check', chk); return; }
var rst = ev.target.closest('[data-trirestore]');
if (rst) triageAct(rst.getAttribute('data-trirestore'), 'restore', rst);
});
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 } })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (!x.ok) {
out.innerHTML = '<div class="honest" style="margin-top:14px"><div class="h">讀不到分流台</div><div class="b">' + esc(x.d.error || ('HTTP ' + x.status)) + '</div></div>';
return;
}
TG.data = x.d;
// 記著的 project 篩選若已不存在(資料變了)→ 退回全部,別讓頁面空得不明不白
if (TG.project && TG.project !== '__none__' && (x.d.projects || []).indexOf(TG.project) < 0) TG.project = '';
renderTriage();
})
.catch(function (e) { out.innerHTML = '<div class="err" style="padding:14px 0">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
// ── 設定 ──
function loadSettings() {
// vectorize 狀態:探測既有 /kbdb/search 的 mode / capability_hint(無新端點,誠實讀降級訊號)
fetch('/kbdb/search?q=mira&mode=semantic', { headers: apiHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
var on = d.mode === 'semantic';
$('st-vec-switch').classList.toggle('on', on);
$('st-vec').textContent = on
? '已啟用・語意搜尋可用(搜尋頁切「語意」)'
: '未啟用・' + (d.capability_hint || '部署端尚未開啟 Vectorizekbdb_embed');
$('st-vec-state').textContent = on ? '已啟用(live 探測 mode=semantic' : '未啟用(live 探測降級 keyword';
})
.catch(function () {
$('st-vec').textContent = '狀態偵測失敗(KBDB 不可達)';
$('st-vec-state').textContent = '偵測失敗';
});
// MCP token TTL(誠實佔位:只顯示目前生效值,不假裝能改)
fetch('/console/settings-data')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) { $('st-mcp-ttl').textContent = '讀取失敗'; return; }
var days = Math.round(d.mcp_token_ttl_seconds / 86400 * 10) / 10;
$('st-mcp-ttl').textContent = '目前生效:' + d.mcp_token_ttl_seconds.toLocaleString() + ' 秒(約 ' + days + ' 天)・' +
(d.mcp_token_ttl_source === 'env' ? '來自部署端 env MCP_TOKEN_TTL' : '未設 env,用預設值 30 天');
})
.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; })
]).then(function (rs) {
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;
var n = function (v) { return v == null ? '' : v.toLocaleString(); };
var rows = '';
rows += '<div class="kvline"><span class="muted">服務</span><span class="mono" style="font-size:14px">' + esc(svc.service || 'arcrun-cypher-executor') + '</span></div>';
rows += '<div class="kvline"><span class="muted">版本</span><span class="mono" style="color:var(--amber)">' + esc(svc.version || '—') + '</span></div>';
rows += '<div class="kvline"><span class="muted">部署位置</span><span class="mono" style="font-size:14px">' + esc(location.host) + '</span></div>';
rows += '<div class="kvline"><span class="muted">租戶</span><span class="mono">' + esc(S.tenant) + '</span></div>';
rows += '<div class="kvline"><span class="muted">精耕層</span><span class="mono" style="color:var(--amber)">' + (kb ? 'wiki 卡 ' + n(kb.wiki_card_total) + '・三元組 ' + n(kb.triplets_total) + '・已嵌入 ' + n(kb.embedded) : '讀取失敗') + '</span></div>';
$('st-info').innerHTML = rows;
});
}
$('st-pw-save').addEventListener('click', function () {
var oldPw = $('st-pw-old').value, email = $('st-pw-email').value.trim(), newPw = $('st-pw-new').value;
var st = $('st-pw-status');
if (!oldPw) { st.innerHTML = '<span class="err">請先輸入舊密碼</span>'; return; }
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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: oldPw, email: email, password: newPw })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok) { st.innerHTML = '<span class="err">' + esc(x.d.error || '更新失敗') + '</span>'; return; }
st.innerHTML = '<span class="ok">密碼已更新,下次登入用新帳密</span>';
$('st-pw-old').value = ''; $('st-pw-email').value = ''; $('st-pw-new').value = '';
toast('密碼已更新');
})
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
});
$('st-vec-switch').addEventListener('click', function () {
toast('此開關不能遠端改——部署端 config.yaml 開 kbdb_embed 後 acr update 重部署');
});
$('st-logout').addEventListener('click', function () {
var t = S.token;
if (t) fetch('/console/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + t } }).catch(function () {});
S.token = ''; S.tenant = '';
localStorage.removeItem('arcrun_console_session');
location.hash = '';
checkConfigured();
});
init();
})();
</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'));
});