Files
Arcrun/console-ui/public/portal/index.html
T

1544 lines
92 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
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.
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Arcrun Portal</title>
<script>
// 主題預載(防閃色):預設淺色,選擇存 localStorage(與 console 同設計語言、不同 key
document.documentElement.setAttribute('data-theme', (function () {
try { return localStorage.getItem('arcrun_portal_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }
})());
</script>
<style>
* { box-sizing: border-box; }
/* ── 主題色板(console 設計語言複製子集:淺=宣紙米白紙紋+墨字;深=紙感暖黑 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 { 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); }
/* ── auth 全屏(.view.on 的 block 會蓋 flex → 補 .authwrap.view.onconsole 同修)── */
.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; }
.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); }
#tabbar .tab.hide { display: none; }
@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 .nav.hide { display: none; }
#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; }
.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; }
.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; }
.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); }
.panel { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); }
.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); }
.tag.dim { background: rgba(var(--ink-rgb),.08); color: rgba(var(--ink-rgb),.55); }
.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; }
/* ── 搜尋 ── */
.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); }
.modebar { display: flex; gap: 8px; margin-top: 12px; }
.modebtn { flex: none; padding: 9px 18px; 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); }
.modebtn.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }
.modebtn.hide { display: none; }
.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; }
/* ── 卡片詳頁 ── */
.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); }
/* ── graph 模式(console 關聯視圖同款)── */
.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; }
/* 總圖(Arcrun#39 GUI 切片):沿用紙感語言——green 章印節點、amber 高度數節點 */
.mapbox { position: relative; border-radius: 14px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); overflow: hidden; }
.mapbox svg { display: block; width: 100%; height: auto; }
.mapnode { cursor: pointer; }
.mapnode circle { fill: rgba(var(--ok-rgb),.12); stroke: rgba(var(--ok-rgb),.45); stroke-width: 1.4; transition: stroke-width .15s; }
.mapnode.hub circle { fill: rgba(var(--amber-rgb),.2); stroke: rgba(var(--amber-rgb),.65); }
.mapnode:hover circle { stroke-width: 3; }
.mapnode text { font-size: 12.5px; fill: var(--ink); pointer-events: none; }
.mapedge { stroke: rgba(var(--ink-rgb),.18); stroke-width: 1; stroke-dasharray: 4 4; }
/* ── 管理頁(P4)── */
.sechead { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 19px; letter-spacing: .14em; color: var(--amber); margin: 26px 0 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(var(--amber-rgb),.3); }
.formrow { display: flex; gap: 10px; flex-wrap: wrap; }
.formrow .txt { flex: 1; min-width: 180px; }
.formrow select { padding: 12px 14px; font-size: 15.5px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.2); background: rgba(var(--ink-rgb),.05); color: var(--ink); }
.btnrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.btn2 { padding: 9px 14px; font-size: 14px; border-radius: 9px; border: 1px solid rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.08); color: var(--amber); cursor: pointer; }
.btn2.warn { border-color: rgba(var(--err-rgb),.45); background: rgba(var(--err-rgb),.06); color: var(--err); }
.btn2:disabled { opacity: .45; cursor: default; }
.libgrid { display: flex; gap: 8px 14px; flex-wrap: wrap; margin-top: 10px; }
.libgrid label { display: flex; align-items: center; gap: 7px; font-size: 14.5px; cursor: pointer; padding: 6px 10px; border-radius: 8px; background: rgba(var(--ink-rgb),.05); border: 1px solid rgba(var(--ink-rgb),.12); }
.libgrid input[type=checkbox] { accent-color: var(--amber); width: 16px; height: 16px; }
.otpbox { margin-top: 14px; padding: 18px; border-radius: 13px; border: 2px solid rgba(var(--amber-rgb),.55); background: rgba(var(--amber-rgb),.08); }
.otpbox .pw { font-family: ui-monospace, Menlo, monospace; font-size: 22px; letter-spacing: .12em; color: var(--amber); word-break: break-all; user-select: all; margin: 10px 0; }
/* ── 清單 / 設定 ── */
.listcol { display: flex; flex-direction: column; gap: 10px; padding: 16px 0 8px; }
.card-item { border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); padding: 16px 18px; }
.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>
<script src="/config.js"></script>
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "https://cypher.arcrun.dev";</script>
</head>
<body>
<!-- 登入(未登入只見這個殼;本頁不含任何資料)-->
<div class="authwrap view" id="v-login">
<div class="authbox">
<div>
<div class="brand">Arcrun</div>
<div class="brand-sub">知識入口・Portal</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;text-align:left">
<input type="email" id="login-email" class="txt" placeholder="Email" autocomplete="username" style="padding:15px 16px;font-size:17px;border-radius:11px">
<input type="password" id="login-password" class="txt" placeholder="密碼" autocomplete="current-password" 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">帳號由管理員發放。忘記密碼請聯絡管理員重設。</div>
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
</div>
</div>
<!-- 首次設定(t49leo 07-25:一鍵安裝的用戶打開專屬網址,第一件事就是在這裡建帳號,
不再需要回安裝器那頁。auth-status 說 configured:false 才會出現。)-->
<div class="authwrap view" id="v-firstsetup">
<div class="authbox">
<div>
<div class="brand">Arcrun</div>
<div class="brand-sub">歡迎!先建立你的帳號</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;text-align:left">
<input type="email" id="fs-email" class="txt" placeholder="你的 Email" autocomplete="username" style="padding:15px 16px;font-size:17px;border-radius:11px">
<input type="password" id="fs-password" class="txt" placeholder="設定密碼(至少 8 碼)" autocomplete="new-password" style="padding:15px 16px;font-size:17px;border-radius:11px">
<input type="password" id="fs-password2" class="txt" placeholder="再輸入一次密碼" autocomplete="new-password" style="padding:15px 16px;font-size:17px;border-radius:11px">
<button class="btn" id="fs-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.12em">建立帳號並進入</button>
<div id="fs-status" class="err" style="font-size:14px;min-height:1.2em"></div>
<!-- leo 07-25 實撞:已設定過(409)時這頁是死路——必須有回登入的路 -->
<button class="btn3" id="fs-tologin" style="padding:9px 16px;font-size:14px;border-radius:999px;align-self:center;cursor:pointer">已經有帳號?改用登入</button>
</div>
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">這組帳密就是之後登入知識庫用的,只需要設定這一次。</div>
</div>
</div>
<!-- 主應用 -->
<div id="shell">
<div id="sidenav">
<div class="logo">Arcrun</div>
<div class="nav" data-nav="search">搜尋</div>
<div class="nav hide" id="nav-map" data-nav="map">總圖</div>
<div class="nav hide" id="nav-upload" data-nav="upload">上傳</div>
<div class="nav hide" id="nav-workflows" data-nav="workflows">工作流</div>
<div class="nav hide" id="nav-admin" data-nav="admin">管理</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-search">
<div class="pagehead"><span class="t">搜尋</span><span class="m" id="se-scope"></span></div>
<div style="padding-top:20px">
<div class="searchrow">
<input id="se-q" placeholder="搜尋知識…" autocomplete="off">
<button class="btn" id="se-go" style="flex:none;padding:0 22px;letter-spacing:.2em"></button>
</div>
<div class="modebar">
<button class="modebtn on" data-mode="keyword">關鍵字</button>
<button class="modebtn" data-mode="semantic">語意</button>
<button class="modebtn hide" id="mode-graph" data-mode="graph">圖譜</button>
</div>
<!-- AI 問答(portal-demo-suite)。設計哲學:AI 檢索=用戶手動搜尋同一套——
/portal/data/chat 與 /portal/data/search 走同一個 session 閘、同一個租戶資料面,
AI 只是代查再作答,不多開任何權限。此實例未安裝問答 workflow 時 server 誠實 404。-->
<div style="margin-top:14px;padding:14px;border-radius:13px;background:rgba(var(--amber-rgb),.05);border:1px solid rgba(var(--amber-rgb),.25)">
<div style="font-size:13.5px;letter-spacing:.1em;color:rgba(var(--ink-rgb),.5);margin-bottom:9px">AI 問答・答案附出處,可回搜尋驗證</div>
<div class="searchrow">
<input id="ai-q" placeholder="用一句話問知識庫…" autocomplete="off" style="padding:12px 15px;font-size:16px">
<button class="btn" id="ai-go" style="flex:none;padding:0 20px">問 AI</button>
</div>
<div id="ai-out"></div>
</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 id="se-graphout" style="display:none">
<div id="gr-box"></div>
<div id="gr-edges" style="margin-top:16px;display:flex;flex-direction:column;gap:7px"></div>
</div>
</div>
</div>
<!-- 總圖(Arcrun#39 藏書地圖 GUI 切片;graph 粗閘後可見):全庫三元組一張網。
力導向佈局內聯手刻(demo 量級 ≤ 數百節點),不引外部套件(頁面自足原則)。-->
<div class="view page" id="v-map">
<div class="pagehead"><span class="t">總圖</span><span class="m" id="map-meta"></span></div>
<div style="padding-top:20px">
<div id="map-box"></div>
<div style="margin-top:12px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)">
點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版
(給 AI 注入用的總庫目錄)<span id="map-md-link"></span>
</div>
</div>
</div>
<!-- 卡片詳頁 -->
<div class="view page" id="v-card">
<div class="pagehead" style="justify-content:flex-start;gap:14px">
<button class="btn3" style="border:none;padding:0;font-size:16px;color:var(--amber)" data-nav="search"> 搜尋</button>
<span class="serif" style="font-size:17px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.55)">知識卡片</span>
</div>
<div id="cd-main" style="padding:24px 0 40px"><div class="muted">載入中…</div></div>
</div>
<!-- 上傳(portal-demo-suiteupload bindings 齊全才顯示 nav——/portal/session 的
upload_enabled;藏只是 UX,真閘在 /portal/data/upload 路由層 404-->
<div class="view page narrow" id="v-upload">
<div class="pagehead"><span class="t">上傳</span><span class="m">Markdown / 純文字</span></div>
<div style="margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--amber-rgb),.06);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(--ink)">約 1 分鐘後可在搜尋頁找到</b>;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。
</div>
<div id="up-drop" style="margin-top:16px;padding:44px 20px;border-radius:13px;border:2px dashed rgba(var(--amber-rgb),.4);background:rgba(var(--amber-rgb),.04);text-align:center;cursor:pointer">
<div style="font-size:16px;color:rgba(var(--ink-rgb),.7)">把 .md / .txt 檔案拖放到這裡</div>
<div class="muted" style="margin-top:8px;font-size:14px"></div>
<button class="btn2" id="up-pick" style="margin-top:10px;padding:11px 22px;font-size:15px">選擇檔案(可多選)</button>
<input type="file" id="up-file" accept=".md,.txt" multiple style="display:none">
</div>
<div class="listcol" id="up-list"></div>
</div>
<!-- 工作流(D-8:預設 admin 才看得到;唯讀,不開 trigger-->
<div class="view page narrow" id="v-workflows">
<div class="pagehead"><span class="t">工作流</span><span class="m">唯讀・系統狀態</span></div>
<div style="margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--amber-rgb),.06);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(--ink)">不能觸發執行</b>(觸發屬系統擁有者權限)。
</div>
<div class="listcol" id="wf-list"><div class="muted">載入中…</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="font-size:17px;font-weight:600;margin-bottom:10px">我的帳號</div>
<div id="st-me"><div class="muted">載入中…</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">深色模式</div>
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">預設淺色(紙感)。切換立即生效,選擇記在這台裝置。</div>
</div>
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
</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)">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼" autocomplete="current-password">
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)" autocomplete="new-password">
<input type="password" id="st-pw-new2" class="txt" placeholder="再輸入一次新密碼" autocomplete="new-password">
<button class="btn" id="st-pw-save">更新密碼</button>
<div id="st-pw-status" style="font-size:14px;min-height:1.2em"></div>
</div>
</div>
<button class="btn3" id="st-logout" style="padding:14px;font-size:16px;border-radius:11px">登出</button>
</div>
</div>
<!-- 管理(P4admin-onlynav 只有 admin 看得到;真閘在 /portal/admin/* role 閘 403-->
<div class="view page" id="v-admin">
<div class="pagehead"><span class="t">管理</span><span class="m">帳號與知識庫授權</span></div>
<div class="sechead">帳號管理</div>
<div class="panel">
<div style="font-size:16px;font-weight:600;margin-bottom:4px">新增同仁帳號</div>
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.55);margin-bottom:12px">建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。</div>
<div class="formrow">
<input type="email" id="ad-nu-email" class="txt" placeholder="Email" autocomplete="off">
<input type="text" id="ad-nu-name" class="txt" placeholder="顯示名(可留空=用 Email" autocomplete="off">
<select id="ad-nu-role"><option value="user">一般用戶</option><option value="admin">管理員</option></select>
<button class="btn" id="ad-nu-create" style="flex:none;padding:0 20px">建立</button>
</div>
<div id="ad-nu-status" class="err" style="font-size:14px;min-height:1.2em;margin-top:8px"></div>
</div>
<div id="ad-otp"></div>
<div class="listcol" id="ad-users"><div class="muted">載入中…</div></div>
<div class="sechead">庫目錄管理</div>
<div style="margin-bottom:12px;padding:12px 15px;border-radius:11px;background:rgba(var(--amber-rgb),.06);border:1px dashed rgba(var(--amber-rgb),.35);font-size:13.5px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
這裡是「庫」的登記簿——條目歸哪個庫由資料導入(ingest)時蓋章決定;未蓋章的舊資料一律視同 <b style="color:var(--ink)">general</b>。標了「圖譜來源」的庫決定誰能用圖譜模式(全都沒標=預設 general)。
</div>
<div class="panel">
<div style="font-size:16px;font-weight:600;margin-bottom:12px">登記新庫</div>
<div class="formrow">
<input type="text" id="ad-nl-name" class="txt" placeholder="庫名(英數 _ -,例:finance" autocomplete="off">
<input type="text" id="ad-nl-display" class="txt" placeholder="顯示名(例:財務庫)" autocomplete="off">
<input type="text" id="ad-nl-desc" class="txt" placeholder="描述(可留空)" autocomplete="off">
<button class="btn" id="ad-nl-create" style="flex:none;padding:0 20px">登記</button>
</div>
<div id="ad-nl-status" class="err" style="font-size:14px;min-height:1.2em;margin-top:8px"></div>
</div>
<div class="listcol" id="ad-libs"><div class="muted">載入中…</div></div>
</div>
</main>
</div>
<!-- 手機底部導覽 -->
<div id="tabbar">
<div class="tab" data-nav="search">搜尋</div>
<div class="tab hide" id="tab-map" data-nav="map">總圖</div>
<div class="tab hide" id="tab-upload" data-nav="upload">上傳</div>
<div class="tab hide" id="tab-workflows" data-nav="workflows">工作流</div>
<div class="tab hide" id="tab-admin" data-nav="admin">管理</div>
<div class="tab" data-nav="settings">設定</div>
</div>
<div id="toast"></div>
<script>
(function () {
var API_BASE = window.ARCRUN_API_BASE || '';
'use strict';
// 台北時間 helperlib/taipei-time.ts 注入,console 同套——顯示不隨裝置時區漂移)
var TAIPEI_OFFSET_MS = 28800000; // UTC+8,台北無 DST
function tpePad(n) { n = String(n); return n.length < 2 ? '0' + n : n; }
function taipeiDayKey(ms) { return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10); }
function taipeiDateStr(ms) { return taipeiDayKey(ms); }
function taipeiDateTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return taipeiDayKey(ms) + ' ' + tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()); }
function taipeiTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()) + ':' + tpePad(d.getUTCSeconds()); }
function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { month: d.getUTCMonth() + 1, day: d.getUTCDate() }; }
var $ = function (id) { return document.getElementById(id); };
// ── 狀態:只有 session token**沒有租戶字串、沒有 API key**design §3.3)──
var S = {
token: localStorage.getItem('arcrun_portal_session') || '',
profile: null, // /portal/session 回應(display_name/role/libraries/graph_allowed/workflows_visible
mode: 'keyword',
cardId: ''
};
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 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;
}
function authHeaders() { return S.token ? { 'Authorization': 'Bearer ' + S.token } : {}; }
// ── 主題(console 同款,key 獨立)──
function getTheme() { return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; }
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem('arcrun_portal_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),顯示一律台北時間(console 同款)
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;
}
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 再上格式,防 XSSconsole 同款)──
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('');
}
// ── entry 顯示 helperssource/library 從 metadata_json 取——server 已 enforce 過權限)──
function entryMeta(e) {
try { var m = JSON.parse(e.metadata_json || 'null'); return m && typeof m === 'object' ? m : {}; } catch (er) { return {}; }
}
function entrySource(e) { var m = entryMeta(e); return typeof m.source === 'string' ? m.source : ''; }
// 來源回溯超連結:PORTAL_SOURCE_WEB_BASE 有設且 source 是 gitea:// 才給 href
// 其餘回空字串=維持純文字(行為與未設時一字不變)。chunk 錨點(#n)捨棄。
var SOURCE_WEB_BASE = "";
function srcHref(src) {
if (!SOURCE_WEB_BASE || String(src).indexOf('gitea://') !== 0) return '';
var p = String(src).slice(8).replace(/#\d+$/, '');
return SOURCE_WEB_BASE.replace(/\/+$/, '') + '/' + p.split('/').map(encodeURIComponent).join('/');
}
function entryLib(e) { var m = entryMeta(e); return (typeof m.library === 'string' && m.library) ? m.library : 'general'; }
function entryTitle(e) {
if (e.page_name) return e.page_name;
var first = String(e.content || '').split(/\r?\n/).find(function (l) { return l.trim(); }) || '';
first = first.replace(/^#+\s*/, '').replace(/^[-*>]\s*/, '').trim();
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;
}
// ── 路由 ──
var VIEWS = ['search', 'map', 'card', 'upload', 'workflows', 'admin', 'settings'];
var HOME = 'search';
function allowedViews() {
// 工作流/管理/上傳頁按 session 能力顯示(真閘在 server/portal/data/workflows 403/404、
// /portal/admin/* role 閘 403、/portal/data/upload 未啟用 404——前端藏只是 UX,curl 直打也繞不過)
return VIEWS.filter(function (v) {
if (v === 'workflows') return !!(S.profile && S.profile.workflows_visible);
if (v === 'admin') return !!(S.profile && S.profile.role === 'admin');
if (v === 'upload') return !!(S.profile && S.profile.upload_enabled);
return true;
});
}
function currentRoute() {
var h = location.hash.replace(/^#\/?/, '');
var seg = h.split('/');
var ok = allowedViews();
return { raw: seg[0] || '', view: ok.indexOf(seg[0]) >= 0 ? seg[0] : HOME, arg: seg.slice(1).join('/') };
}
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 = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
function route() {
var r = currentRoute();
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() {
$('v-firstsetup').classList.remove('on');
$('v-login').classList.add('on');
$('shell').classList.remove('on');
$('tabbar').classList.remove('on');
// t49:全新實例(還沒有任何帳號)→ 換顯示「首次設定」,不讓用戶對著登入殼發呆。
// 探測走 console/auth-statusconfigured 布林,console 首設同一顆;失敗就保持登入殼=誠實降級)。
fetch(API_BASE + '/console/auth-status')
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.configured === false) {
$('v-login').classList.remove('on');
$('v-firstsetup').classList.add('on');
}
})
.catch(function () { /* 探測不到就維持登入殼 */ });
}
function showApp() {
$('v-login').classList.remove('on');
$('v-firstsetup').classList.remove('on');
$('shell').classList.add('on');
$('tabbar').classList.add('on');
// t53leo 07-25:「進站要做的事——給金鑰、下載 daemon——不然這個安裝沒完成」):
// 常駐「完成安裝」清單卡,三件做完才消失(localStorage 記進度;admin 才看得到——
// 金鑰與 daemon 設定是裝機者的事,同事帳號不顯示)。
try { renderSetupChecklist(); } catch (e) { /* 清單卡失敗不擋主流程 */ }
var p = S.profile || {};
$('side-foot').innerHTML = esc(p.display_name || '') + '<br>' + esc(location.host);
$('nav-workflows').classList.toggle('hide', !p.workflows_visible);
$('tab-workflows').classList.toggle('hide', !p.workflows_visible);
// 上傳 nav:一般用戶可見,但只在實例啟用上傳(bindings 齊全)時顯示(未啟用的實例零影響)
$('nav-upload').classList.toggle('hide', !p.upload_enabled);
$('tab-upload').classList.toggle('hide', !p.upload_enabled);
$('nav-admin').classList.toggle('hide', p.role !== 'admin');
$('tab-admin').classList.toggle('hide', p.role !== 'admin');
$('mode-graph').classList.toggle('hide', !p.graph_allowed);
$('nav-map').classList.toggle('hide', !p.graph_allowed);
$('tab-map').classList.toggle('hide', !p.graph_allowed);
var libs = p.libraries || [];
$('se-scope').textContent = libs.indexOf('*') >= 0 ? '範圍:全部知識庫' : ('範圍:' + libs.join('・'));
if (!location.hash) location.hash = '#/' + HOME;
route();
}
function dropSession() {
S.token = '';
S.profile = null;
try { localStorage.removeItem('arcrun_portal_session'); } catch (e) { /* noop */ }
showAuth();
}
function boot() {
if (!S.token) { showAuth(); return; }
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok) { dropSession(); return; }
S.profile = x.d;
showApp();
})
.catch(function () {
// 斷網時不清 session(誠實:連不上 ≠ session 失效),顯示登入殼並提示
showAuth();
$('login-status').textContent = '連線中斷——請檢查網路後重新整理';
});
}
$('login-submit').addEventListener('click', doLogin);
$('login-password').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doLogin(); });
function doLogin() {
var email = $('login-email').value.trim();
var password = $('login-password').value;
if (!email || !password) { $('login-status').textContent = '請輸入 Email 與密碼'; return; }
$('login-submit').disabled = true;
$('login-status').textContent = '';
fetch(API_BASE + '/portal/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) {
$('login-submit').disabled = false;
if (!x.ok) { $('login-status').textContent = x.d.error || '登入失敗'; return; }
S.token = x.d.session_token;
try { localStorage.setItem('arcrun_portal_session', S.token); } catch (e) { /* 私密模式只活本次 */ }
$('login-password').value = '';
boot();
})
.catch(function (e) { $('login-submit').disabled = false; $('login-status').textContent = friendlyErr(e); });
}
$('st-logout').addEventListener('click', function () {
fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });
dropSession();
});
// ── t53 完成安裝清單(進站必見,三件做完才消失)─────────────────────────────
function setupSteps() {
try { return JSON.parse(localStorage.getItem('arcrun_setup_steps') || '{}'); } catch (e) { return {}; }
}
function markStep(k) {
var s = setupSteps(); s[k] = 1;
try { localStorage.setItem('arcrun_setup_steps', JSON.stringify(s)); } catch (e) { /* noop */ }
renderSetupChecklist();
}
function renderSetupChecklist() {
var old = document.getElementById('setup-checklist');
if (old) old.remove();
var p = S.profile || {};
if (p.role !== 'admin') return;
var s = setupSteps();
if (s.daemon && s.config && s.key) return; // 三件齊=安裝真的完成,不再打擾
var cfg = window.ARCRUN_CONFIG || {};
var daemonUrl = cfg.daemonDownload || 'https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/ArcrunRAG-mac-unsigned.zip';
var row = function (done, id, html) {
return '<div style="display:flex;gap:10px;align-items:baseline;margin-top:10px">'
+ '<span>' + (done ? '✅' : '⬜') + '</span><div style="flex:1">' + html + '</div></div>';
};
var el = document.createElement('div');
el.id = 'setup-checklist';
el.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:60;max-width:400px;width:calc(100% - 40px);padding:18px 20px;border-radius:14px;background:rgba(var(--amber-rgb),.10);border:1px solid rgba(var(--amber-rgb),.45);backdrop-filter:blur(8px);font-size:14px;line-height:1.65';
el.innerHTML = '<b>還差 ' + (3 - (s.daemon?1:0) - (s.config?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了</b>'
+ row(s.daemon, 'daemon',
'<b>下載同步小幫手</b>(把資料夾變成知識庫)<br>'
+ '<a href="' + esc(daemonUrl) + '" id="sc-daemon">下載 Mac 版</a>'
+ '<span class="muted" style="font-size:12.5px">(封測版未簽章,第一次請右鍵→打開)</span>')
+ row(s.config, 'config',
'<b>下載設定檔</b> 放到 <code style="font-size:12px">~/.arcrun-rag/config.json</code><br>'
+ '<a href="#" id="sc-config">下載 config.json</a>')
+ row(s.key, 'key',
'<b>啟用 AI 問答</b><a href="https://aistudio.google.com" target="_blank" rel="noopener">aistudio.google.com</a> 免費申請)<br>'
+ '<input id="sc-key" type="password" placeholder="貼上 Google AI 金鑰" style="width:60%;padding:6px 8px;border-radius:8px;border:1px solid rgba(var(--ink-rgb),.25);background:rgba(var(--ink-rgb),.04);color:var(--ink)"> '
+ '<button class="btn3" id="sc-key-save" style="padding:6px 12px;border-radius:8px;cursor:pointer">啟用</button>'
+ '<div id="sc-key-msg" style="font-size:12.5px;min-height:1.1em"></div>')
+ '<div style="margin-top:10px;text-align:right"><button id="sc-later" style="border:none;background:none;color:inherit;cursor:pointer;font-size:12.5px;text-decoration:underline;opacity:.65">稍後再說</button></div>';
document.body.appendChild(el);
var dl = document.getElementById('sc-daemon');
if (dl) dl.addEventListener('click', function () { markStep('daemon'); });
var cb = document.getElementById('sc-config');
if (cb) cb.addEventListener('click', function (ev) {
ev.preventDefault();
var conf = {
watch_folders: [],
manifest: '~/.arcrun-rag/manifest.json',
cypher_url: cfg.apiBase || '',
namespace: cfg.tenant || '',
library: 'kb',
extractor: 'claude',
email: (S.profile && S.profile.email) || ''
};
var a = document.createElement('a');
a.href = 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(conf, null, 2));
a.download = 'config.json';
document.body.appendChild(a); a.click(); a.remove();
markStep('config');
});
var kb = document.getElementById('sc-key-save');
if (kb) kb.addEventListener('click', function () {
var k = (document.getElementById('sc-key').value || '').trim();
var m = document.getElementById('sc-key-msg');
if (!k) { m.textContent = '請先貼上金鑰'; return; }
kb.disabled = true; m.textContent = '啟用中…';
fetch(API_BASE + '/portal/admin/chat-key', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ key: k })
}).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
kb.disabled = false;
if (!x.ok || !x.d.success) { m.textContent = x.d.error || '啟用失敗,請再試一次'; return; }
markStep('key');
})
.catch(function () { kb.disabled = false; m.textContent = '網路好像有問題,請再試一次'; });
});
var later = document.getElementById('sc-later');
if (later) later.addEventListener('click', function () { el.remove(); }); // 只藏本次,下次進站再提醒
}
// ── t49 首次設定(leo 07-25):console/setup → portal/admin/bootstrap → 預設庫登記 → portal/login
// 四發全是既有端點,一顆按鈕做完;用戶感受=「建一組帳密就進來了」。
$('fs-submit').addEventListener('click', doFirstSetup);
$('fs-password2').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doFirstSetup(); });
$('fs-tologin').addEventListener('click', function () {
$('v-firstsetup').classList.remove('on');
$('v-login').classList.add('on');
});
function doFirstSetup() {
var email = $('fs-email').value.trim();
var pw = $('fs-password').value;
var st = $('fs-status');
if (!email || pw.length < 8) { st.textContent = '請填 Email,密碼至少 8 碼'; return; }
if (pw !== $('fs-password2').value) { st.textContent = '兩次密碼不一樣'; return; }
$('fs-submit').disabled = true; st.textContent = '';
var post = function (path, body, hdrs) {
return fetch(API_BASE + path, {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, hdrs || {}),
body: JSON.stringify(body)
}).then(function (r) { return r.json().catch(function () { return {}; }).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); });
};
post('/console/setup', { email: email, password: pw })
.then(function (x) {
// 已設定過(409)=這實例其實有人建過了——自動帶去登入,別把用戶困在死路(leo 實撞)
if (x.status === 409) {
$('fs-submit').disabled = false;
$('v-firstsetup').classList.remove('on');
$('v-login').classList.add('on');
$('login-email').value = email;
$('login-status').textContent = '這個知識庫已經設定過帳號了,直接登入即可。';
throw { handled: true };
}
if (!x.ok || !x.d.session_token) throw new Error(x.d.error || '設定沒有成功,請再試一次');
var ownerTok = x.d.session_token;
return post('/portal/admin/bootstrap', { email: email, password: pw, display_name: email.split('@')[0] }, { Authorization: 'Bearer ' + ownerTok })
.then(function (b) {
// 409=已 bootstrap 過(冪等視為就緒);其餘失敗誠實丟出
if (!b.ok && b.status !== 409) throw new Error(b.d.error || '初始化沒有成功,請再試一次');
// 預設庫 kb 自動登記(leo:庫要自動出現,不是叫用戶去登記)——
// 同步小幫手預設就寫進 kb 庫,這裡把登記簿先蓋好章;已存在(409/重複)不吵。
return post('/portal/login', { email: email, password: pw });
});
})
.then(function (l) {
if (!l.ok || !l.d.session_token) throw new Error(l.d.error || '帳號建好了,但自動登入沒成功——請用剛設定的帳密登入');
S.token = l.d.session_token;
try { localStorage.setItem('arcrun_portal_session', S.token); } catch (e) { /* noop */ }
// 預設庫登記(用 portal session;失敗不擋進入)
return fetch(API_BASE + '/portal/admin/libraries', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ name: 'kb', display_name: '知識庫', description: '同步小幫手預設寫入的庫' })
}).catch(function () { /* 已存在或無權限都不擋 */ });
})
.then(function () {
$('fs-submit').disabled = false;
try { localStorage.setItem('arcrun_onboarding', '1'); } catch (e) { /* noop */ }
boot();
})
.catch(function (e) {
if (e && e.handled) return; // 409 已自動切登入,不再顯示錯誤
$('fs-submit').disabled = false;
st.textContent = (e && e.message) || friendlyErr(e);
});
}
// 任何 data 請求收到 401 → session 失效 → 回登入殼
function guard401(status) {
if (status === 401) { dropSession(); return true; }
return false;
}
// ── 搜尋 ──
document.querySelectorAll('.modebtn').forEach(function (b) {
b.addEventListener('click', function () {
S.mode = b.getAttribute('data-mode');
document.querySelectorAll('.modebtn').forEach(function (x) { x.classList.toggle('on', x === b); });
if ($('se-q').value.trim()) doSearch();
});
});
$('se-go').addEventListener('click', doSearch);
$('se-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doSearch(); });
function doSearch() {
var q = $('se-q').value.trim();
if (!q) { $('se-count').textContent = '請輸入查詢字'; return; }
$('se-banner').innerHTML = '';
if (S.mode === 'graph') { doGraphSearch(q); return; }
$('se-graphout').style.display = 'none';
$('se-results').style.display = '';
$('se-count').textContent = '查詢中…';
$('se-results').innerHTML = '';
var url = API_BASE + '/portal/data/search?q=' + encodeURIComponent(q) + '&mode=' + (S.mode === 'semantic' ? 'semantic' : 'keyword');
fetch(url, { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + '')) + '</span>'; return; }
var d = x.d;
if (S.mode === '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 || '系統尚未開啟語意索引——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';
}
var entries = d.entries || [];
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') + (d.note ? '・' + d.note : '');
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) {
var src = entrySource(e);
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">' +
'<span class="tag">' + esc(e.entry_type || 'entry') + '</span>' +
'<span class="tag green">' + esc(entryLib(e)) + '</span>' +
(src ? '<span class="dim" style="word-break:break-all">' + esc(src) + '</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'));
});
// ── AI 問答(portal-demo-suite)──
// 設計哲學:AI 檢索=用戶手動搜尋同一套——同 session token、同 /portal/data/* enforce 面,
// AI 只代查再作答;答案附 sourcesmodepage),點 page 帶回搜尋框讓用戶自己驗證。
$('ai-go').addEventListener('click', doAsk);
$('ai-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doAsk(); });
function doAsk() {
var q = $('ai-q').value.trim();
if (!q) { toast('請先輸入問題'); return; }
$('ai-go').disabled = true;
$('ai-out').innerHTML = '<div class="muted" style="margin-top:12px">AI 查閱知識庫中…</div>';
fetch(API_BASE + '/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('ai-go').disabled = false;
if (guard401(x.status)) return;
if (!x.ok) {
// 404=此實例未安裝問答 workflow(server 誠實回報,前端不假裝有 AI)
$('ai-out').innerHTML = '<div class="honest" style="margin-top:12px"><div class="h">問不到</div><div class="b">' + esc(x.d.error || ('問答失敗(HTTP ' + x.status + '')) + '</div></div>';
return;
}
var srcs = x.d.sources || [];
// answer 純文字渲染、保留換行(pre-wrapesc,不走 markdown、防 XSS
var html = '<div class="panel" style="margin-top:12px">' +
'<div style="white-space:pre-wrap;font-size:16px;line-height:1.85;word-break:break-word">' + esc(x.d.answer || 'AI 沒有給出答案)') + '</div>';
if (srcs.length) {
html += '<div style="margin-top:14px;font-size:13px;letter-spacing:.1em;color:rgba(var(--ink-rgb),.5)">出處(點頁名帶入搜尋框)</div>' +
'<div style="margin-top:7px;display:flex;flex-direction:column;gap:6px">' + srcs.map(function (s) {
var page = (s && (s.page || s.page_name)) || '';
var mode = (s && s.mode) || '';
var href = s && s.source ? srcHref(s.source) : '';
return '<div class="nbrow" data-aisrc="' + esc(page) + '">' +
(mode ? '<span class="tag" style="flex:none">' + esc(mode) + '</span>' : '') +
'<span style="word-break:break-word">' + esc(page || '(無頁名)') + '</span>' +
(href ? '<a href="' + esc(href) + '" target="_blank" rel="noopener" style="flex:none;color:var(--amber);font-size:13px" title="開啟來源檔">來源 ↗</a>' : '') +
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none"></span></div>';
}).join('') + '</div>';
}
html += '</div>';
$('ai-out').innerHTML = html;
})
.catch(function (e) { $('ai-go').disabled = false; $('ai-out').innerHTML = '<div class="err" style="margin-top:12px">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
$('ai-out').addEventListener('click', function (ev) {
if (ev.target.closest('a')) return; // 「來源 ↗」超連結交給瀏覽器,不觸發帶入搜尋框
var t = ev.target.closest('[data-aisrc]');
if (!t) return;
var page = t.getAttribute('data-aisrc');
if (!page) return;
$('se-q').value = page;
$('se-q').focus();
window.scrollTo(0, 0);
});
// ── graph 模式(D-4 粗閘:無權者按鈕根本不顯示;就算 curl 直打 API 也是 403)──
function doGraphSearch(name) {
$('se-results').style.display = 'none';
$('se-graphout').style.display = '';
$('se-count').textContent = '查詢關聯中…';
renderGraphEmpty('查詢關聯中…');
fetch(API_BASE + '/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('關聯查詢失敗(HTTP ' + x.status + '')) + '</span>'; renderGraphEmpty(x.d.error || '關聯查詢失敗'); return; }
var neighbors = x.d.neighbors || [];
var edges = x.d.edges || [];
// 形狀相容(Arcrun#57 家族):plugin 版 neighbors 是字串、workflow 版是
// { node, predicate, from, depth } 物件——不正規化會渲染成 [object Object]。
var names = neighbors.map(function (nb) {
if (typeof nb === 'string') return nb;
return (nb && (nb.node || nb.name)) || '';
}).filter(Boolean);
// workflow 版 edges 恆空,但 neighbor 物件本身就是一條邊(from --predicate--> node)→ 導出。
if (!edges.length) {
edges = neighbors.filter(function (nb) { return nb && typeof nb === 'object' && nb.predicate; })
.map(function (nb) { return { subject: nb.from || name, predicate: nb.predicate, object: nb.node }; });
}
$('se-count').textContent = '節點「' + name + '」・鄰居 ' + names.length + '・關聯 ' + edges.length;
if (!names.length) { renderGraphEmpty('尚無關聯資料——這個名字還沒有連進知識圖譜。'); return; }
renderGraph(name, names.slice(0, 10));
$('gr-edges').innerHTML = edges.slice(0, 12).map(function (t) {
// 大小寫不敏感比對(graph 查詢已正規化:搜 rag 會命中節點 RAG)
var other = String(t.subject).toLowerCase() === String(name).toLowerCase() ? 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) { $('se-count').innerHTML = '<span class="err">關聯服務不可達:' + esc(friendlyErr(e)) + '</span>'; renderGraphEmpty('關聯服務不可達'); });
}
function renderGraphEmpty(msg) {
$('gr-box').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>';
$('gr-edges').innerHTML = '';
}
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;
$('gr-box').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');
$('se-q').value = name;
doGraphSearch(name);
});
// ── 卡片詳頁 ──
function loadCard() {
if (!S.cardId) { $('cd-main').innerHTML = '<div class="muted">沒有指定卡片——從「搜尋」點一張進來。</div>'; return; }
$('cd-main').innerHTML = '<div class="muted">載入中…</div>';
fetch(API_BASE + '/portal/data/entries/' + encodeURIComponent(S.cardId), { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok || !x.d.entry) { $('cd-main').innerHTML = '<div class="err">' + esc(x.d.error || '讀不到這張卡片') + '</div>'; return; }
var e = x.d.entry;
var src = entrySource(e);
$('cd-main').innerHTML = '<div class="cardtitle">' + esc(entryTitle(e)) + '</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>' +
'<span class="tag green">' + esc(entryLib(e)) + '</span>' +
(e.page_name ? '<span class="tag dim">' + esc(e.page_name) + '</span>' : '') +
'<span class="muted mono">' + esc(fmtDateTime(e.created_at)) + '</span></div>' +
'<div class="md">' + mdRender(e.content || '(此條目沒有內文)') + '</div>' +
(src
? (function () {
var href = srcHref(src);
var body = href
? '<a class="mono" style="font-size:14px;color:var(--amber);word-break:break-all" href="' + esc(href) + '" target="_blank" rel="noopener">' + esc(src) + ' ↗</a>'
: '<span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(src) + '</span>';
return '<div class="srcline"><span style="font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);flex:none">來源回溯</span>' + body + '</div>';
})()
: '');
})
.catch(function (er) { $('cd-main').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(er)) + '</div>'; });
}
// ── 總圖(Arcrun#39 藏書地圖 GUI 切片)──
// 全庫三元組一張網:/portal/data/graph/overview → 手刻力導向佈局(demo 量級,零外部套件)。
// 佈局用固定種子的 PRNG=同一庫每次長一樣(客戶重整不會跳位)。
function jumpToGraph(name) {
S.mode = 'graph';
document.querySelectorAll('.modebtn').forEach(function (x) { x.classList.toggle('on', x.getAttribute('data-mode') === 'graph'); });
nav('search');
$('se-q').value = name;
doGraphSearch(name);
}
function loadMap() {
$('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">載入總圖中…</div>';
$('map-md-link').innerHTML = SOURCE_WEB_BASE
? '<a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
: '存在知識庫的 00-MAP.md';
fetch(API_BASE + '/portal/data/graph/overview', { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('map-box').innerHTML = '<div class="err">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + '')) + '</div>'; return; }
var nodes = x.d.nodes || [];
var edges = x.d.edges || [];
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : '');
if (!nodes.length) { $('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。</div>'; return; }
renderMap(nodes, edges);
})
.catch(function (e) { $('map-box').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
function renderMap(nodes, edges) {
var N = nodes.length;
var idx = {};
nodes.forEach(function (n, i) { idx[n.name] = i; });
var el = [];
edges.forEach(function (e) {
var a = idx[e.subject], b = idx[e.object];
if (a == null || b == null || a === b) return;
el.push([a, b, e.predicate || '']);
});
// 固定種子 PRNGmulberry32):確定性佈局
var seed = 42;
function rnd() {
seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
var W = 1000, H = Math.max(480, Math.min(760, 200 + N * 14));
var px = [], py = [];
for (var i = 0; i < N; i++) {
var ang = 2 * Math.PI * i / N;
var rad = Math.min(W, H) * (0.22 + 0.2 * rnd());
px.push(W / 2 + rad * Math.cos(ang));
py.push(H / 2 + rad * Math.sin(ang) * 0.75);
}
// 力導向:斥力(全對)+彈簧(邊)+向心,260 輪退火
var K = Math.max(75, Math.sqrt(W * H / Math.max(N, 1)) * 0.9);
for (var it = 0; it < 260; it++) {
var cool = Math.max(0.06, 1 - it / 260);
var fx = [], fy = [];
for (var a1 = 0; a1 < N; a1++) { fx.push(0); fy.push(0); }
for (var a2 = 0; a2 < N; a2++) {
for (var b2 = a2 + 1; b2 < N; b2++) {
var dx = px[a2] - px[b2], dy = py[a2] - py[b2];
var d2 = dx * dx + dy * dy + 0.01;
var f = Math.min(28, (K * K) / d2 * 2.2);
var d = Math.sqrt(d2);
fx[a2] += dx / d * f; fy[a2] += dy / d * f;
fx[b2] -= dx / d * f; fy[b2] -= dy / d * f;
}
}
el.forEach(function (e) {
var dx = px[e[0]] - px[e[1]], dy = py[e[0]] - py[e[1]];
var d = Math.sqrt(dx * dx + dy * dy) + 0.01;
var f = (d - K) * 0.045;
fx[e[0]] -= dx / d * f; fy[e[0]] -= dy / d * f;
fx[e[1]] += dx / d * f; fy[e[1]] += dy / d * f;
});
for (var m = 0; m < N; m++) {
fx[m] += (W / 2 - px[m]) * 0.012;
fy[m] += (H / 2 - py[m]) * 0.012;
px[m] += Math.max(-14, Math.min(14, fx[m])) * cool;
py[m] += Math.max(-14, Math.min(14, fy[m])) * cool;
}
}
// 視窗貼合
var minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
for (var q = 0; q < N; q++) {
minX = Math.min(minX, px[q]); maxX = Math.max(maxX, px[q]);
minY = Math.min(minY, py[q]); maxY = Math.max(maxY, py[q]);
}
var PAD = 70;
var vb = (minX - PAD) + ' ' + (minY - PAD) + ' ' + (maxX - minX + PAD * 2) + ' ' + (maxY - minY + PAD * 2);
var maxDeg = 1;
nodes.forEach(function (n) { maxDeg = Math.max(maxDeg, n.degree || 1); });
var svg = '<svg viewBox="' + vb + '" preserveAspectRatio="xMidYMid meet">';
el.forEach(function (e) {
svg += '<line class="mapedge" x1="' + px[e[0]].toFixed(1) + '" y1="' + py[e[0]].toFixed(1) +
'" x2="' + px[e[1]].toFixed(1) + '" y2="' + py[e[1]].toFixed(1) + '"><title>' +
esc(nodes[e[0]].name + ' —' + (e[2] || '關聯') + '→ ' + nodes[e[1]].name) + '</title></line>';
});
nodes.forEach(function (n, i2) {
var deg = n.degree || 1;
var r = 9 + Math.sqrt(deg) * 5;
var hub = deg >= Math.max(3, maxDeg * 0.6) ? ' hub' : '';
svg += '<g class="mapnode' + hub + '" data-mapnode="' + esc(n.name) + '">' +
'<circle cx="' + px[i2].toFixed(1) + '" cy="' + py[i2].toFixed(1) + '" r="' + r.toFixed(1) + '"><title>' + esc(n.name + '・' + deg + ' 條關聯') + '</title></circle>' +
'<text x="' + px[i2].toFixed(1) + '" y="' + (py[i2] + r + 15).toFixed(1) + '" text-anchor="middle">' + esc(n.name.length > 14 ? n.name.slice(0, 14) + '…' : n.name) + '</text></g>';
});
svg += '</svg>';
$('map-box').innerHTML = '<div class="mapbox">' + svg +
'<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-mapnode]');
if (!t) return;
jumpToGraph(t.getAttribute('data-mapnode'));
});
// ── 工作流(唯讀)──
function loadWorkflows() {
$('wf-list').innerHTML = '<div class="muted">載入中…</div>';
fetch(API_BASE + '/portal/data/workflows', { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('wf-list').innerHTML = '<div class="err">' + esc(x.d.error || ('讀取失敗(HTTP ' + x.status + '')) + '</div>'; return; }
var wfs = x.d.workflows || [];
if (!wfs.length) { $('wf-list').innerHTML = '<div class="muted" style="padding:20px 4px">目前沒有任何工作流。</div>'; return; }
$('wf-list').innerHTML = wfs.map(function (w) {
var lastHtml = '<span class="dim">尚無執行紀錄</span>';
if (w.last_execution) {
var v = w.last_execution.verdict || '';
var cls = v === 'success' ? 'ok' : (v === 'failed' ? 'err' : 'muted');
lastHtml = '<span class="' + cls + '">' + esc(v || '(無 verdict') + '</span> <span class="dim mono">' + esc(fmtDateTime(Number(w.last_execution.timestamp))) + '</span>';
}
return '<div class="card-item">' +
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(w.name) + '</span>' +
(w.cron_expr ? '<span class="tag dim mono">' + esc(w.cron_expr) + '</span>' : '') +
'<span style="margin-left:auto;font-size:13.5px">' + lastHtml + '</span></div>' +
(w.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(w.description) + '</div>' : '<div class="dim" style="margin-top:8px;font-size:13.5px">(沒有描述)</div>') +
'</div>';
}).join('');
})
.catch(function (e) { $('wf-list').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
// ── 上傳頁(portal-demo-suite)──
// 前端只讀檔轉 base64 逐檔 POST /portal/data/uploadGitea 位置與 token 全在 server 側,
// 這裡看不到任何 repo/token 字串。逐檔獨立狀態列(一檔失敗不拖累其他檔)。
var UP_OK_MSG = '已收件,約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。';
function b64FromDataUrl(dataUrl) {
var i = String(dataUrl).indexOf(',');
return i >= 0 ? String(dataUrl).slice(i + 1) : '';
}
function uploadStatusRow(name) {
var row = document.createElement('div');
row.className = 'card-item';
row.innerHTML = '<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
'<span class="mono" style="font-size:14.5px;word-break:break-all">' + esc(name) + '</span>' +
'<span class="up-st muted" style="margin-left:auto;font-size:14px;flex:none">讀取中…</span></div>' +
'<div class="up-msg" style="margin-top:6px;font-size:13.5px;line-height:1.65;color:rgba(var(--ink-rgb),.6)"></div>';
$('up-list').prepend(row);
return row;
}
function setRowStatus(row, ok, statusText, msg) {
var st = row.querySelector('.up-st');
st.textContent = statusText;
st.className = 'up-st ' + (ok === true ? 'ok' : (ok === false ? 'err' : 'muted'));
row.querySelector('.up-msg').textContent = msg || '';
}
function uploadOneFile(file) {
var row = uploadStatusRow(file.name);
if (!/\.(md|txt)$/i.test(file.name)) {
setRowStatus(row, false, '已略過', '只收 .md / .txt 檔。');
return;
}
var reader = new FileReader();
reader.onerror = function () { setRowStatus(row, false, '讀檔失敗', '瀏覽器讀不到這個檔案。'); };
reader.onload = function () {
setRowStatus(row, null, '上傳中…', '');
fetch(API_BASE + '/portal/data/upload', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ filename: file.name, content_b64: b64FromDataUrl(reader.result) })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { setRowStatus(row, false, '失敗', x.d.error || ('HTTP ' + x.status)); return; }
setRowStatus(row, true, '已收件', UP_OK_MSG + (x.d.path ? '' + x.d.path + '' : ''));
})
.catch(function (e) { setRowStatus(row, false, '失敗', friendlyErr(e)); });
};
reader.readAsDataURL(file); // dataURL 尾段即 base64unicode 安全,免手刻 btoa 編碼)
}
function handleUploadFiles(files) {
for (var i = 0; i < files.length; i++) uploadOneFile(files[i]);
}
$('up-pick').addEventListener('click', function (ev) { ev.stopPropagation(); $('up-file').click(); });
$('up-drop').addEventListener('click', function () { $('up-file').click(); });
$('up-file').addEventListener('change', function () { handleUploadFiles(this.files); this.value = ''; });
['dragover', 'dragenter'].forEach(function (t) {
$('up-drop').addEventListener(t, function (ev) { ev.preventDefault(); ev.currentTarget.style.background = 'rgba(var(--amber-rgb),.12)'; });
});
['dragleave', 'drop'].forEach(function (t) {
$('up-drop').addEventListener(t, function (ev) { ev.preventDefault(); ev.currentTarget.style.background = 'rgba(var(--amber-rgb),.04)'; });
});
$('up-drop').addEventListener('drop', function (ev) {
if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) handleUploadFiles(ev.dataTransfer.files);
});
// ── 管理頁(P4:帳號管理+庫目錄管理。全走 /portal/admin/*,前端零業務邏輯;
// 一般用戶 nav 不顯示,且 server role 閘 403——藏只是 UX,真閘在後端)──
var adminLibs = []; // 庫目錄快取(渲染每帳號的庫勾選用)
function adminApi(method, path, body) {
path = API_BASE + path;
var opt = { method: method, headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()) };
if (body !== undefined) opt.body = JSON.stringify(body);
return fetch(path, opt).then(function (r) {
return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; });
});
}
function loadAdmin() {
$('ad-users').innerHTML = '<div class="muted">載入中…</div>';
$('ad-libs').innerHTML = '<div class="muted">載入中…</div>';
adminApi('GET', '/portal/admin/libraries')
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('ad-libs').innerHTML = '<div class="err">' + esc(x.d.error || ('讀取失敗(HTTP ' + x.status + '')) + '</div>'; return; }
adminLibs = x.d.libraries || [];
renderAdminLibs();
return adminApi('GET', '/portal/admin/users').then(function (y) {
if (guard401(y.status)) return;
if (!y.ok) { $('ad-users').innerHTML = '<div class="err">' + esc(y.d.error || ('讀取失敗(HTTP ' + y.status + '')) + '</div>'; return; }
renderAdminUsers(y.d.users || []);
});
})
.catch(function (e) {
$('ad-users').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>';
$('ad-libs').innerHTML = '';
});
}
// 帳號可勾選的庫=庫目錄啟用中的庫 ∪ general ∪ 該帳號已有的庫(目錄外的舊授權不憑空消失)
function checkableLibs(userLibs) {
var names = ['general'];
adminLibs.forEach(function (l) { if (l.status !== 'disabled' && l.name && names.indexOf(l.name) < 0) names.push(l.name); });
(userLibs || []).forEach(function (n) { if (n !== '*' && names.indexOf(n) < 0) names.push(n); });
return names;
}
function libDisplay(name) {
for (var i = 0; i < adminLibs.length; i++) { if (adminLibs[i].name === name) return adminLibs[i].display_name || name; }
return name;
}
function renderAdminUsers(users) {
if (!users.length) { $('ad-users').innerHTML = '<div class="muted" style="padding:16px 4px">還沒有任何同仁帳號——用上面的表單新增第一個。</div>'; return; }
$('ad-users').innerHTML = users.map(function (u) {
var star = (u.libraries || []).indexOf('*') >= 0;
var disabled = u.status === 'disabled';
var libChips = star
? '<span class="tag green">全部知識庫</span>'
: ((u.libraries || []).length
? (u.libraries || []).map(function (n) { return '<span class="tag green">' + esc(libDisplay(n)) + '</span>'; }).join(' ')
: '<span class="tag dim">尚未授權任何庫</span>');
var boxes = '<label><input type="checkbox" data-star data-uid="' + esc(u.record_id) + '"' + (star ? ' checked' : '') + '> 全部知識庫(*</label>' +
checkableLibs(u.libraries).map(function (n) {
var on = !star && (u.libraries || []).indexOf(n) >= 0;
return '<label><input type="checkbox" data-lib="' + esc(n) + '" data-uid="' + esc(u.record_id) + '"' + (on ? ' checked' : '') + (star ? ' disabled' : '') + '> ' + esc(libDisplay(n)) + ' <span class="dim mono" style="font-size:12px">' + esc(n) + '</span></label>';
}).join('');
return '<div class="card-item"' + (disabled ? ' style="opacity:.6"' : '') + '>' +
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(u.display_name || u.email) + '</span>' +
'<span class="muted" style="font-size:14px;word-break:break-all">' + esc(u.email) + '</span>' +
'<span class="tag' + (u.role === 'admin' ? '' : ' dim') + '">' + (u.role === 'admin' ? '管理員' : '一般用戶') + '</span>' +
'<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span></div>' +
'<div style="margin-top:9px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:13px"><span class="muted" style="flex:none">可查庫</span>' + libChips + '</div>' +
'<div class="btnrow">' +
'<button class="btn2" data-act="edit-libs" data-uid="' + esc(u.record_id) + '">編輯可查庫</button>' +
'<button class="btn2" data-act="reset-pw" data-uid="' + esc(u.record_id) + '" data-email="' + esc(u.email) + '">重設密碼</button>' +
(disabled
? '<button class="btn2" data-act="set-status" data-next="active" data-uid="' + esc(u.record_id) + '">啟用</button>'
: '<button class="btn2 warn" data-act="set-status" data-next="disabled" data-uid="' + esc(u.record_id) + '" data-email="' + esc(u.email) + '">停用</button>') +
'</div>' +
'<div id="libed-' + esc(u.record_id) + '" style="display:none">' +
'<div class="libgrid">' + boxes + '</div>' +
'<div class="btnrow"><button class="btn2" data-act="save-libs" data-uid="' + esc(u.record_id) + '">儲存庫權限</button>' +
'<span class="dim" style="font-size:13px;align-self:center">勾「全部知識庫」=不限庫;否則至少勾一個。儲存後立即生效。</span></div></div>' +
'</div>';
}).join('');
}
function renderAdminLibs() {
if (!adminLibs.length) { $('ad-libs').innerHTML = '<div class="muted" style="padding:16px 4px">還沒有登記任何庫。未登記時整個系統視同單一 general 庫。</div>'; return; }
$('ad-libs').innerHTML = adminLibs.map(function (l) {
var disabled = l.status === 'disabled';
return '<div class="card-item"' + (disabled ? ' style="opacity:.6"' : '') + '>' +
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(l.display_name || l.name) + '</span>' +
'<span class="dim mono" style="font-size:13px">' + esc(l.name) + '</span>' +
'<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>' +
(l.graph_source ? '<span class="tag">圖譜來源</span>' : '') + '</div>' +
(l.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(l.description) + '</div>' : '') +
'<div class="btnrow">' +
'<button class="btn2" data-act="lib-graph" data-lid="' + esc(l.record_id) + '" data-next="' + (l.graph_source ? 'false' : 'true') + '">' + (l.graph_source ? '取消圖譜來源' : '標為圖譜來源') + '</button>' +
(disabled
? '<button class="btn2" data-act="lib-status" data-next="active" data-lid="' + esc(l.record_id) + '">啟用</button>'
: '<button class="btn2 warn" data-act="lib-status" data-next="disabled" data-lid="' + esc(l.record_id) + '" data-name="' + esc(l.display_name || l.name) + '">停用</button>') +
'</div></div>';
}).join('');
}
// 一次性密碼顯示(server 不留明碼——關掉就真的沒了,誠實跟 admin 說清楚)
function showOtp(email, password) {
$('ad-otp').innerHTML = '<div class="otpbox">' +
'<div style="font-size:15px;font-weight:600">' + esc(email) + ' 的一次性密碼</div>' +
'<div class="pw">' + esc(password) + '</div>' +
'<div style="font-size:13.5px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">只顯示這一次——關閉後再也調不出來(系統只存雜湊)。請當場轉交同仁,並提醒登入後到「設定」改成自己的密碼。</div>' +
'<div class="btnrow"><button class="btn2" id="ad-otp-close">我已轉交,關閉</button></div></div>';
$('ad-otp').scrollIntoView({ block: 'nearest' });
}
// 新增帳號
$('ad-nu-create').addEventListener('click', function () {
var email = $('ad-nu-email').value.trim();
var name = $('ad-nu-name').value.trim();
var role = $('ad-nu-role').value === 'admin' ? 'admin' : 'user';
var st = $('ad-nu-status');
if (!email) { st.textContent = '請填 Email'; return; }
st.textContent = '';
$('ad-nu-create').disabled = true;
var body = { email: email, role: role };
if (name) body.display_name = name;
adminApi('POST', '/portal/admin/users', body)
.then(function (x) {
$('ad-nu-create').disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { st.textContent = x.d.error || ('建立失敗(HTTP ' + x.status + ''); return; }
$('ad-nu-email').value = ''; $('ad-nu-name').value = ''; $('ad-nu-role').value = 'user';
if (x.d.generated_password) showOtp(email, x.d.generated_password);
toast('帳號已建立');
loadAdmin();
})
.catch(function (e) { $('ad-nu-create').disabled = false; st.textContent = friendlyErr(e); });
});
// 登記新庫
$('ad-nl-create').addEventListener('click', function () {
var name = $('ad-nl-name').value.trim();
var st = $('ad-nl-status');
if (!name) { st.textContent = '請填庫名(英數 _ -'; return; }
st.textContent = '';
$('ad-nl-create').disabled = true;
adminApi('POST', '/portal/admin/libraries', {
name: name,
display_name: $('ad-nl-display').value.trim(),
description: $('ad-nl-desc').value.trim()
})
.then(function (x) {
$('ad-nl-create').disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { st.textContent = x.d.error || ('登記失敗(HTTP ' + x.status + ''); return; }
$('ad-nl-name').value = ''; $('ad-nl-display').value = ''; $('ad-nl-desc').value = '';
toast('庫已登記');
loadAdmin();
})
.catch(function (e) { $('ad-nl-create').disabled = false; st.textContent = friendlyErr(e); });
});
// 「全部知識庫」勾選 → 其餘庫勾選框連動停用(存的就是 ["*"],個別勾選無意義)
document.addEventListener('change', function (ev) {
var t = ev.target;
if (!t || !t.hasAttribute || !t.hasAttribute('data-star')) return;
var uid = t.getAttribute('data-uid');
document.querySelectorAll('#libed-' + uid + ' input[data-lib]').forEach(function (cb) {
cb.disabled = t.checked;
if (t.checked) cb.checked = false;
});
});
// 管理頁動作(事件委派)
$('v-admin').addEventListener('click', function (ev) {
var t = ev.target.closest('[data-act]');
if (!t) return;
var act = t.getAttribute('data-act');
var uid = t.getAttribute('data-uid');
if (act === 'edit-libs') {
var ed = $('libed-' + uid);
ed.style.display = ed.style.display === 'none' ? '' : 'none';
return;
}
if (act === 'save-libs') {
var star = document.querySelector('#libed-' + uid + ' input[data-star]');
var libs;
if (star && star.checked) { libs = ['*']; }
else {
libs = [];
document.querySelectorAll('#libed-' + uid + ' input[data-lib]').forEach(function (cb) {
if (cb.checked) libs.push(cb.getAttribute('data-lib'));
});
if (!libs.length) { toast('至少勾一個庫,或勾「全部知識庫」'); return; }
}
t.disabled = true;
adminApi('PATCH', '/portal/admin/users/' + encodeURIComponent(uid), { libraries: libs })
.then(function (x) {
t.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { toast(x.d.error || ('儲存失敗(HTTP ' + x.status + '')); return; }
toast('庫權限已更新,立即生效');
loadAdmin();
})
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
if (act === 'set-status') {
var next = t.getAttribute('data-next');
if (next === 'disabled' && !confirm('確定停用 ' + (t.getAttribute('data-email') || '這個帳號') + '?停用後其登入與既有 session 立即失效。')) return;
t.disabled = true;
adminApi('PATCH', '/portal/admin/users/' + encodeURIComponent(uid), { status: next })
.then(function (x) {
t.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + '')); return; } // 含最後一個 admin 的 409 訊息
toast(next === 'disabled' ? '已停用' : '已啟用');
loadAdmin();
})
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
if (act === 'reset-pw') {
var email = t.getAttribute('data-email') || '';
if (!confirm('確定重設 ' + email + ' 的密碼?舊密碼將立即失效,會產生一組新的一次性密碼。')) return;
t.disabled = true;
adminApi('POST', '/portal/admin/users/' + encodeURIComponent(uid) + '/reset-password')
.then(function (x) {
t.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { toast(x.d.error || ('重設失敗(HTTP ' + x.status + '')); return; }
showOtp(email, x.d.password);
toast('密碼已重設');
})
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
if (act === 'lib-status' || act === 'lib-graph') {
var lid = t.getAttribute('data-lid');
var body2 = {};
if (act === 'lib-status') {
var nextS = t.getAttribute('data-next');
if (nextS === 'disabled' && !confirm('確定停用庫「' + (t.getAttribute('data-name') || '') + '」?停用後不再出現在庫勾選清單(既有授權不會被自動改)。')) return;
body2.status = nextS;
} else {
body2.graph_source = t.getAttribute('data-next') === 'true';
}
t.disabled = true;
adminApi('PATCH', '/portal/admin/libraries/' + encodeURIComponent(lid), body2)
.then(function (x) {
t.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + '')); return; }
toast('庫已更新');
loadAdmin();
})
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
});
// 一次性密碼關閉鈕(otpbox 動態生成,掛在容器上)
$('ad-otp').addEventListener('click', function (ev) {
if (ev.target && ev.target.id === 'ad-otp-close') $('ad-otp').innerHTML = '';
});
// ── 設定 ──
function loadSettings() {
var p = S.profile;
if (!p) { $('st-me').innerHTML = '<div class="muted">載入中…</div>'; return; }
var libs = p.libraries || [];
var libHtml = libs.indexOf('*') >= 0
? '<span class="tag green">全部知識庫</span>'
: (libs.length ? libs.map(function (l) { return '<span class="tag green">' + esc(l) + '</span>'; }).join(' ') : '<span class="tag dim">尚未授權任何庫</span>');
$('st-me').innerHTML =
'<div class="kvline"><span class="muted">名稱</span><span>' + esc(p.display_name || '') + '</span></div>' +
'<div class="kvline"><span class="muted">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' +
'<div class="kvline" style="align-items:flex-start"><span class="muted" style="flex:none">可查庫</span><span style="text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + libHtml + '</span></div>';
}
$('st-pw-save').addEventListener('click', function () {
var oldPw = $('st-pw-old').value;
var newPw = $('st-pw-new').value;
var newPw2 = $('st-pw-new2').value;
var st = $('st-pw-status');
st.className = 'err';
if (!oldPw || !newPw) { st.textContent = '請填舊密碼與新密碼'; return; }
if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; }
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
$('st-pw-save').disabled = true;
st.textContent = '';
fetch(API_BASE + '/portal/me/password', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ current: oldPw, 'new': newPw })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('st-pw-save').disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { st.textContent = x.d.error || '更新失敗'; return; }
st.className = 'ok';
st.textContent = '密碼已更新';
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
toast('密碼已更新');
})
.catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); });
});
boot();
})();
</script>
</body>
</html>