@@ -186,6 +186,27 @@
< / div >
< / div >
<!-- 首次設定(t49, leo 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" >
@@ -310,6 +331,50 @@
< div id = "st-pw-status" style = "font-size:14px;min-height:1.2em" > < / div >
< / div >
< / div >
<!-- 07 - 27 leo:金鑰與下載原本只在一次性的「還差 N 步」卡片裡,按了「稍後再說」
或裝完就再也找不到。換金鑰/換電腦重新下載都需要常駐入口,故搬進設定頁。 -->
< div class = "panel" >
<!-- t87 07 - 28 leo:乾淨網址(只有 origin,不含 /portal/# 後綴),供小幫手連線用 -->
< div style = "display:flex;align-items:center;gap:8px;margin-bottom:14px;font-size:13.5px" >
< span style = "color:rgba(var(--ink-rgb),.6);white-space:nowrap" > 你的知識庫網址(小幫手連線用)< / span >
< code id = "st-origin-url" style = "flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--ink)" > < / code >
< button class = "btn3" id = "st-copy-url" style = "padding:5px 12px;font-size:13px;white-space:nowrap;flex:none" > 複製< / button >
< / div >
< 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 style = "margin-top:12px" >
< a class = "btn3" id = "st-daemon-dl" href = "#" style = "display:inline-block;padding:11px 18px;border-radius:10px;text-decoration:none" > 下載 Mac 版< / a >
< div style = "margin-top:8px;font-size:13px;line-height:1.7;color:rgba(var(--ink-rgb),.5)" > 封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。< / div >
< / div >
< / div >
<!-- t131 合併 AI 設定(Gemini API Key 必填;Claude 加強版視 daemon 回報 enable) -->
< div class = "panel" id = "st-ai-panel" >
< div style = "font-size:17px;font-weight:600" > AI 設定< / div >
< div style = "margin-top:14px;display:flex;flex-direction:column;gap:16px" >
<!-- 第一格:Gemini API Key(必填,聊天+萃取共用) -->
< div >
< div style = "font-size:14px;font-weight:500;margin-bottom:4px" > Gemini API Key< / div >
< div style = "font-size:13px;line-height:1.6;color:rgba(var(--ink-rgb),.55);margin-bottom:8px" > 聊天問答與文件萃取都用這一把。< a href = "https://aistudio.google.com/apikey" target = "_blank" rel = "noopener" > 免費申請< / a > ,金鑰只存在你自己的知識庫裡。< / div >
< input type = "password" id = "st-ai-key" class = "txt" placeholder = "貼上 Gemini API Key" autocomplete = "off" >
< / div >
<!-- 第二格:Claude 加強版(選填,依 daemon 回報 enable) -->
< div style = "padding-top:12px;border-top:1px solid rgba(var(--ink-rgb),.08)" >
< div style = "font-size:14px;font-weight:500;margin-bottom:6px" > 讓知識卡整理得更好(選填)< / div >
< label style = "display:flex;gap:8px;align-items:flex-start;cursor:pointer" >
< input type = "checkbox" id = "st-ai-use-claude" style = "margin-top:3px;flex:none" disabled >
< span style = "font-size:14px" > 本地萃取改用 Claude Code< / span >
< / label >
< div id = "st-ai-claude-desc" style = "margin-top:8px;font-size:13px;line-height:1.65;color:rgba(var(--ink-rgb),.55)" >
讀你文件、整理成知識卡的那個 AI,換成更強的模型。< b style = "color:var(--ink)" > 卡片會更抓得到重點、關聯也連得更準< / b > ,之後搜尋和問答的品質跟著提升。需要你的電腦已安裝 Claude Code。不填就用上面那把 Gemini,一樣能用。
< / div >
< div id = "st-ai-claude-hint" style = "margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.4);display:none" > < / div >
< / div >
< div >
< button class = "btn" id = "st-ai-save" > 儲存 AI 設定< / button >
< div id = "st-ai-status" style = "font-size:14px;min-height:1.2em;margin-top:8px" > < / div >
< / div >
< / div >
< / div >
< button class = "btn3" id = "st-logout" style = "padding:14px;font-size:16px;border-radius:11px" > 登出< / button >
< / div >
< / div >
@@ -334,18 +399,15 @@
< 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 style = "display:flex;align-items:center;gap:8px;margin-bottom :10 px;font-size:13.5px " >
< span style = "color:rgba( var(--ink-rgb),.6);white-space:nowrap" > 你的知識庫網址(小幫手連線用) < / span >
< code id = "ad-origin-url" style = "flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--ink)" > < / code >
< button class = "btn3" id = "ad-copy-url" style = "padding:5px 12px;font-size:13px;white-space:nowrap;flex:none" > 複製< / button >
< / 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 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) " >
<!-- 07 - 27 leo:拿掉人工建庫表單——庫由「同步小幫手」看守資料夾時自動蓋章產生,
人工登記只會製造對不上的空庫。這裡只做「已存在的庫」的管理。 -->
裝好 < b style = "color:var(--ink)" > 同步小幫手 < / b > 並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面—— < b style = "color:var(--ink)" > 不需要人工新增 < / b > 。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。
< / div >
< div class = "listcol" id = "ad-libs" > < div class = "muted" > 載入中…< / div > < / div >
< / div >
@@ -399,10 +461,32 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
clearTimeout(toast._t);
toast._t = setTimeout(function () { el.style.display = 'none'; }, 2000);
}
// safeJson:把回應當 JSON 解析,**解析不了就回空物件而不是拋例外**(t75 ①,2026-07-28)。
//
// 為什麼需要:原本各處都直接 r.json(),但伺服器不一定回 JSON——404 頁、Cloudflare 錯誤頁、
// 反向代理的 HTML 都是純文字。JSON.parse 一爆,錯誤就沿著 .catch 走到 friendlyErr,
// **技術訊息原文被噴到畫面上**。leo 同事實測看到的就是:
// 「Unexpected non-whitespace character after JSON at position 4」
// 真正的原因其實是 `/portal/admin/chat-key` 回 404(實例的 cypher 是舊版沒這端點),
// 但使用者只看得到一句看不懂的英文 ⇒ 無從判斷是自己打錯還是系統壞了。
function safeJson(r) {
return r.text().then(function (t) {
if (!t) return {};
try { return JSON.parse(t); } catch (e) { return {}; }
});
}
// friendlyErr:給使用者看的訊息,**不外洩技術細節**(t75 ①)。
// 原本最後一行是 `return m` =把任何例外訊息原樣顯示,包含 JSON parse 錯誤、
// stack 片段這種對使用者毫無意義、只會嚇到人的東西。
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;
// JSON 解析類=伺服器回了非預期內容(多半是 404/代理錯誤頁),對使用者說人話。
if (/json|unexpected token|unexpected non-whitespace/i.test(m)) return '伺服器回應異常,請稍後再試一次(若持續發生請回報)';
// 其餘:只在含中文(=我們自己寫的訊息)時原樣顯示;純英文技術訊息一律收斂。
if (/[\u4e00-\u9fff]/.test(m)) return m;
return '操作失敗,請稍後再試一次';
}
function authHeaders() { return S.token ? { 'Authorization': 'Bearer ' + S.token } : {}; }
@@ -548,14 +632,31 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
// ── 認證流 ──
function showAuth() {
$('v-firstsetup').classList.remove('on');
$('v-login').classList.add('on');
$('shell').classList.remove('on');
$('tabbar').classList.remove('on');
// t49:全新實例(還沒有任何帳號)→ 換顯示「首次設定」,不讓用戶對著登入殼發呆。
// 探測走 console/auth-status( configured 布林,console 首設同一顆;失敗就保持登入殼=誠實降級)。
fetch(API_BASE + '/console/auth-status')
.then(function (r) { return safeJson(r); })
.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');
// t53( leo 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);
@@ -582,7 +683,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
function boot() {
if (!S.token) { showAuth(); return; }
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
.then(function (r) { return r.j son().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (r) { return safeJ son(r ).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (!x.ok) { dropSession(); return; }
S.profile = x.d;
@@ -607,7 +708,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, password: password })
})
.then(function (r) { return r.j son().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (r) { return safeJ son(r ).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('login-submit').disabled = false;
if (!x.ok) { $('login-status').textContent = x.d.error || '登入失敗'; return; }
@@ -618,11 +719,308 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
})
.catch(function (e) { $('login-submit').disabled = false; $('login-status').textContent = friendlyErr(e); });
}
// t72 OS 分流(2026-07-27, leo:「客戶是用 windows 的」)——在此之前四處寫死「下載 Mac 版」,
// Windows 客戶按下去會拿到 Mac 的 .app=按了連到錯的東西(leo 判準:那不算友善)。
// 判不出 OS 時**兩個都給**,不替用戶猜;且無論判成哪個,頁面都留「不是這個系統?」的另一版連結
// ——UA 會判錯,判錯時用戶要有路可走(施工圖 §3 注意事項 1、3)。
var DAEMON_BASE_DEFAULT = 'https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/';
var DAEMON_MAC = 'ArcrunRAG-mac-unsigned.zip';
var DAEMON_WIN = 'ArcrunRAG-win-unsigned.zip';
// Mac 那顆 21MB > jsDelivr 單檔 20MB 上限(實測回 "File size exceeded...")→ 一律走 raw;
// Windows 13MB 雖在限內,同走 raw 保持單一來源、少一個會壞的地方。
function daemonBase() {
var cfg = (window.ARCRUN_CONFIG || {});
if (cfg.daemonBase) return String(cfg.daemonBase).replace(/\/?$/, '/');
// 舊 key 相容(施工圖 §3 注意事項 2):daemonDownload 是「單一檔案網址」,
// 有設就尊重它當 Mac 版網址,並由它推出目錄給 Windows 版用。
if (cfg.daemonDownload) {
var s = String(cfg.daemonDownload);
return s.slice(0, s.lastIndexOf('/') + 1) || DAEMON_BASE_DEFAULT;
}
return DAEMON_BASE_DEFAULT;
}
function daemonPick() {
var base = daemonBase();
var ua = navigator.userAgent || '';
// 手機/平板先排除:iOS 的 UA 含 "Mac OS X",不排除會把 iPhone 判成 Mac,
// 讓手機用戶下載一個裝不起來的桌面 app(測到才發現,2026-07-27)。
// 手機=判不出桌面 OS → 落到「兩個都給」,用戶回桌機再選。
var isMobile = /iPhone|iPad|iPod|Android|Mobile/i.test(ua);
var isWin = !isMobile & & /Windows NT/i.test(ua);
var isMac = !isMobile & & /Macintosh|Mac OS X/i.test(ua) & & !/Windows/i.test(ua);
var mac = { os: 'mac', label: '下載 Mac 版', url: base + DAEMON_MAC };
var win = { os: 'win', label: '下載 Windows 版', url: base + DAEMON_WIN };
if (isWin) return { pick: win, other: mac, sure: true };
if (isMac) return { pick: mac, other: win, sure: true };
return { pick: null, other: null, sure: false, mac: mac, win: win };
}
// 封測期第一次開啟的擋關提示(未簽章)——Mac/Windows 攔法不同,話術也不同。
function daemonHint(os) {
if (os === 'win') return '(封測版未簽章,Windows 第一次會跳藍色視窗擋下來——點「更多資訊」→「仍要執行」就好)';
return '(封測版未簽章,第一次請右鍵→打開)';
}
// 07-27:設定頁的常駐入口(下載小幫手/換 AI 金鑰)——與一次性卡片同一組 API
(function () {
var dl = $('st-daemon-dl');
if (dl) {
var d = daemonPick();
if (d.sure) {
dl.setAttribute('href', d.pick.url);
dl.textContent = d.pick.label;
// 判對了也要留另一版的路(UA 會判錯)
var alt = document.createElement('span');
alt.className = 'muted';
alt.style.cssText = 'font-size:12.5px;margin-left:8px';
alt.innerHTML = '不是這個系統?< a href = "' + d.other.url + '" > ' + d.other.label + '< / a > ';
if (dl.parentNode) dl.parentNode.insertBefore(alt, dl.nextSibling);
} else {
// 判不出來=兩個都給,不預設 Mac
dl.setAttribute('href', d.mac.url);
dl.textContent = d.mac.label;
var both = document.createElement('span');
both.className = 'muted';
both.style.cssText = 'font-size:12.5px;margin-left:8px';
both.innerHTML = '或 < a href = "' + d.win.url + '" > ' + d.win.label + '< / a > ';
if (dl.parentNode) dl.parentNode.insertBefore(both, dl.nextSibling);
}
}
})();
// t131 AI 設定(合併 Gemini API Key+ Claude 加強版)
(function () {
// 進入設定頁時讀取現有設定(GET /portal/admin/ai)
function loadAiConfig() {
if (!(S.profile & & S.profile.role === 'admin')) return;
fetch(API_BASE + '/portal/admin/ai', { headers: authHeaders() })
.then(function (r) { return r.ok ? safeJson(r) : null; })
.then(function (d) {
if (!d) return;
var ki = $('st-ai-key');
if (ki & & d.has_key) ki.placeholder = '已設定(留空=不變更)';
var cb = $('st-ai-use-claude');
if (cb) {
// 有 claude 才能勾;沒有則停用並顯示提示
var hint = $('st-ai-claude-hint');
if (d.claude_available) {
cb.disabled = false;
cb.checked = !!d.use_claude_for_extract;
if (hint) hint.style.display = 'none';
} else {
cb.disabled = true;
cb.checked = false;
if (hint) {
hint.style.display = '';
// 區分:從未連上小幫手 vs 有連上但沒裝 Claude
hint.textContent = d.has_key
? '你的電腦沒有偵測到 Claude Code;裝好並讓小幫手重連一次後,這個選項就會開啟。'
: '連上小幫手後才知道你的電腦有沒有 Claude Code。';
}
}
}
})
.catch(function () { /* 讀不到不擋頁面 */ });
}
window._loadAiConfig = loadAiConfig;
// 儲存
var sb = $('st-ai-save');
if (sb) sb.addEventListener('click', function () {
var m = $('st-ai-status');
var k = (($('st-ai-key') & & $('st-ai-key').value) || '').trim();
var cb = $('st-ai-use-claude');
var useClaud = cb & & !cb.disabled ? cb.checked : undefined;
var body = {};
if (k) body.gemini_api_key = k;
if (useClaud !== undefined) body.use_claude_for_extract = useClaud;
sb.disabled = true; m.textContent = '儲存中…'; m.style.color = '';
fetch(API_BASE + '/portal/admin/ai', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify(body)
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
sb.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { m.textContent = (x.d & & x.d.error) || '儲存失敗'; m.style.color = '#b4462f'; return; }
if ($('st-ai-key')) { $('st-ai-key').value = ''; $('st-ai-key').placeholder = '已設定(留空=不變更)'; }
var claudeOn = x.d & & x.d.use_claude_for_extract;
m.textContent = claudeOn
? '已儲存,萃取改用 Claude Code。小幫手請重連一次生效。'
: '已儲存,AI 問答與萃取皆可使用。';
m.style.color = '#3f7a4f';
})
.catch(function (e) { sb.disabled = false; m.textContent = friendlyErr(e); m.style.color = '#b4462f'; });
});
})();
$('st-logout').addEventListener('click', function () {
fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });
dropSession();
});
// t87 07-28 leo:知識庫網址 helper(只有 origin,不含 /portal/# 後綴),兩處 UI 共用
function copyOriginUrl(btn) {
var url = location.origin;
var orig = btn.textContent;
if (!navigator.clipboard || !navigator.clipboard.writeText) {
alert('請手動選取並複製:' + url);
return;
}
navigator.clipboard.writeText(url).then(function () {
btn.textContent = '已複製';
setTimeout(function () { btn.textContent = orig; }, 1500);
}).catch(function () {
alert('請手動選取並複製:' + url);
});
}
(function () {
['st-origin-url', 'ad-origin-url'].forEach(function (id) {
var el = $(id); if (el) el.textContent = location.origin;
});
['st-copy-url', 'ad-copy-url'].forEach(function (id) {
var el = $(id); if (el) el.addEventListener('click', function () { copyOriginUrl(this); });
});
})();
// ── 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.key) return; // t54 起只剩兩件:設定改由小幫手輸入帳密自取,不再下載檔案
var cfg = window.ARCRUN_CONFIG || {};
var dpick = daemonPick(); // t72 OS 分流(同一組判定,見上面 daemonPick)
var daemonUrl = dpick.sure ? dpick.pick.url : dpick.mac.url;
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 > 還差 ' + (2 - (s.daemon?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了< / b > '
+ row(s.daemon, 'daemon',
'< b > 下載同步小幫手< / b > (把資料夾變成知識庫)< br > '
+ (daemonUrl
// t72:按鈕字樣+擋關話術都跟著 OS 走;並且一律附「另一個系統」的連結
//(UA 判錯時用戶要有路走;判不出來時=兩個平等並列,不預設 Mac)
? '< a href = "' + esc(daemonUrl) + '" id = "sc-daemon" > '
+ esc(dpick.sure ? dpick.pick.label : dpick.mac.label) + '< / a > '
+ '< span class = "muted" style = "font-size:12.5px" > '
+ esc(daemonHint(dpick.sure ? dpick.pick.os : 'mac')) + '< / span > '
+ '< div class = "muted" style = "font-size:12.5px" > '
+ (dpick.sure
? '不是這個系統?< a href = "' + esc(dpick.other.url) + '" > ' + esc(dpick.other.label) + '< / a > '
: '或 < a href = "' + esc(dpick.win.url) + '" > ' + esc(dpick.win.label) + '< / a > ')
+ '< / div > '
// 誠實降級(leo 判準:按了連到錯的機制=不友善):沒有可用下載點時
// 不放死連結,說明現況並讓這步可手動打勾,不卡住清單。
: '< span class = "muted" style = "font-size:13px" > 封測期由我們直接寄給你安裝檔;'
+ '收到後回來按 < a href = "#" id = "sc-daemon" > 我已經裝好了< / a > 。< / span > ')
// t54(leo:「最好的就是把它的帳密直接輸入」):設定不再是一個要下載的檔案——
// 小幫手第一次開啟會問網址+帳密,自己去換設定。
+ '< div class = "muted" style = "font-size:12.5px;margin-top:4px" > 裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了,不用下載設定檔。< / div > ')
+ 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'); });
// t54: config.json 下載鈕已移除(設定由小幫手憑帳密自取)
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/ai', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ gemini_api_key: k })
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
kb.disabled = false;
if (!x.ok || !x.d.success) { m.textContent = (x.d & & 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 || '初始化沒有成功,請再試一次');
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 */ }
})
.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; }
@@ -650,7 +1048,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
$('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.j son().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJ son(r ).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; }
@@ -694,7 +1092,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); 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.j son().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJ son(r ).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('ai-go').disabled = false;
if (guard401(x.status)) return;
@@ -743,7 +1141,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
$('se-count').textContent = '查詢關聯中…';
renderGraphEmpty('查詢關聯中…');
fetch(API_BASE + '/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() })
.then(function (r) { return r.j son().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJ son(r ).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; }
@@ -806,7 +1204,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
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.j son().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJ son(r ).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; }
@@ -848,7 +1246,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
? ': < 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.j son().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJ son(r ).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; }
@@ -954,7 +1352,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
function loadWorkflows() {
$('wf-list').innerHTML = '< div class = "muted" > 載入中…< / div > ';
fetch(API_BASE + '/portal/data/workflows', { headers: authHeaders() })
.then(function (r) { return r.j son().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJ son(r ).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; }
@@ -1018,7 +1416,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ filename: file.name, content_b64: b64FromDataUrl(reader.result) })
})
.then(function (r) { return r.j son().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJ son(r ).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; }
@@ -1053,7 +1451,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
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.j son().then(function (d) { return { ok: r.ok, status: r.status, d: d }; });
return safeJ son(r ).then(function (d) { return { ok: r.ok, status: r.status, d: d }; });
});
}
@@ -1128,22 +1526,37 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
}
function renderAdminLibs() {
if (!adminLibs.length) { $('ad-libs').innerHTML = '< div class = "muted" style = "padding:16px 4px" > 還沒有登記任何庫。未登記時整個系統視同單一 general 庫 。< / div > '; return; }
if (!adminLibs.length) { $('ad-libs').innerHTML = '< div class = "muted" style = "padding:16px 4px" > 同步小幫手還沒送來任何庫。裝好小幫手並選好資料夾後,庫會自動出現在這裡 。< / div > '; return; }
$('ad-libs').innerHTML = adminLibs.map(function (l) {
var disabled = l.status === 'disabled';
return '< div class = "card-item" ' + ( disabled ? ' style = "opacity:.6" ' : ' ' ) + ' > ' +
var notWatching = l.daemon_watching === false; // daemon 有報告但此庫不在其中
var removeBtn = l.auto
? '< button class = "btn2" style = "color:#c0392b;border-color:#e57373;margin-left:auto" data-act = "lib-remove-auto" data-name = "' + esc(l.name) + '" > 移除< / button > '
: '< button class = "btn2" style = "color:#c0392b;border-color:#e57373;margin-left:auto" data-act = "lib-remove" data-id = "' + esc(l.record_id) + '" data-name = "' + esc(l.display_name || l.name) + '" > 移除< / button > ';
// t142:卡數+三元組數顯示(政府驗收用)。兩者皆 0 顯示「還沒有內容」,不顯示「0 張」。
var cardCount = typeof l.card_count === 'number' ? l.card_count : undefined;
var tripletCount = typeof l.triplet_count === 'number' ? l.triplet_count : undefined;
var statsHtml = '';
if (cardCount !== undefined || tripletCount !== undefined) {
var parts = [];
if (cardCount > 0) parts.push(cardCount + ' 張知識卡');
if (tripletCount > 0) parts.push(tripletCount + ' 條關聯');
statsHtml = '< div style = "margin-top:5px;font-size:13px;color:rgba(var(--ink-rgb),.5)" > ' +
(parts.length ? parts.join('・') : '還沒有內容') + '< / div > ';
}
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(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 > ';
(l.auto
? '< span class = "tag green " > 啟用中 < / span > < span class = "tag" > 同步自動出現 < / span > '
: '< span class = "tag ' + (disabled ? 'dim' : 'green') + '" > ' + (disabled ? '已停用' : '啟用中' ) + '< / span > ') +
removeBtn +
'< / div > ' +
statsHtml +
(!l.auto & & 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 > ' : '') +
(notWatching ? '< div style = "margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.45)" > 小幫手目前沒有在同步這個資料夾 < / div > ' : '' ) +
'< / div > ';
}).join('');
}
@@ -1181,28 +1594,6 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.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) {
@@ -1280,24 +1671,39 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.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';
}
// 移除已登記庫(有 record_id)
if (act === 'lib-remove') {
var libId = t.getAttribute('data-id') || '' ;
var libName = t.getAttribute('data-name') || libId;
if (!confirm('把「' + libName + '」從目錄移除?\n\n資料不會刪除,重新同步時會再出現。')) return ;
t.disabled = true;
adminApi('PATCH ', '/portal/admin/libraries/' + encodeURIComponent(lid), body2 )
adminApi('DELETE ', '/portal/admin/libraries/' + encodeURIComponent(libI d))
.then(function (x) {
t.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { toast(x.d.error || ('更新 失敗(HTTP ' + x.status + ') ')); return; }
toast('庫已更新' );
load Admin();
if (!x.ok) { toast(x.d.error || ('移除 失敗(HTTP ' + x.status + ') ')); return; }
adminLibs = adminLibs.filter(function (l) { return l.record_id !== libId; } );
render AdminLibs ();
toast('已從目錄移除');
})
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
// 移除 auto 庫(無 record_id, entries 標 deprecated)
if (act === 'lib-remove-auto') {
var autoName = t.getAttribute('data-name') || '';
var input = window.prompt('移除「' + autoName + '」會讓它的內容不再被搜尋到(資料保留可還原)。\n\n請輸入庫名確認:');
if (input === null) return; // 取消
if (input.trim() !== autoName) { toast('庫名輸入不符,取消移除'); return; }
t.disabled = true;
adminApi('DELETE', '/portal/admin/libraries/by-name/' + encodeURIComponent(autoName), { confirm: autoName })
.then(function (x) {
t.disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + ') ')); return; }
adminLibs = adminLibs.filter(function (l) { return l.name !== autoName; });
renderAdminLibs();
toast('已移除(' + (x.d.deprecated_count || 0) + ' 筆標為不可搜)');
})
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
@@ -1312,6 +1718,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
function loadSettings() {
var p = S.profile;
if (!p) { $('st-me').innerHTML = '< div class = "muted" > 載入中…< / div > '; return; }
if (window._loadAiConfig) window._loadAiConfig();
var libs = p.libraries || [];
var libHtml = libs.indexOf('*') >= 0
? '< span class = "tag green" > 全部知識庫< / span > '
@@ -1337,7 +1744,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ current: oldPw, 'new': newPw })
})
.then(function (r) { return r.j son().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (r) { return safeJ son(r ).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;