t160 世代債清除(leo:「如果你會搞不清楚,就把錯的東西刪掉」)——repo 只留一個 UI 世代
事故鏈(07-31 深夜,leo 刷新撞到):t159 重打包 staging bundle 時,build-ui-bundle 吃了 feat/step3 分支的 console-ui/public/——該分支基底早於 maind7fab7c「拿掉登記新庫」,public 停在舊世代 ⇒ youlin 實例 UI 被打回被淘汰的 「登記新庫」人工表單典範(07-27 mistakes 已記帳的 src/public 雙世代債, 這次由「分支副本停舊代」路徑引爆——債沒還,任何一個部署路徑都會引爆它)。 刪掉的(舊世代,git rm 實體刪除): - console-ui/src/(console.ts/console-dashboard.ts/portal-ui.ts)=07-22 從 cypher-executor 搬出的 renderer 快照,自 07-27 起與 public/ 真身脫鉤、只會 重產舊 UI - console-ui/scripts/build.mjs=從上述舊 renderer 重產 public 的 build 步驟 (deploy.mjs 原自動跑它=引爆器本體) - cypher POST /portal/admin/libraries(人工建庫端點)=死端點(e28e190 起 UI 零呼叫;leo 規格「要直通 daemon,同步,沒有登記這回事」)。GET 列表/PATCH 管理/t159 daemon 自動登記照舊 新唯一源:console-ui/public/=手改演進的真身(本 commit 同步到 maine28e190世代+註解字樣改寫達成「登記新庫」全檔 0 命中);deploy.mjs 改為直接託管 public(無 build 步)+世代閘(缺「不需要人工新增」或含「登記新庫」拒部署)。 驗:tsc 全綠;deploy.mjs node --check 過;重打 ui bundle 242KB 過世代閘, 指紋「登記新庫」=0/「不需要人工新增」=1。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:10px;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.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (r) { return safeJson(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.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (r) { return safeJson(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.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (r) { return safeJson(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.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (r) { return safeJson(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.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (r) { return safeJson(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.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (r) { return safeJson(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.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (r) { return safeJson(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.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (r) { return safeJson(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.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (r) { return safeJson(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.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; });
|
||||
return safeJson(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(libId))
|
||||
.then(function (x) {
|
||||
t.disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + ')')); return; }
|
||||
toast('庫已更新');
|
||||
loadAdmin();
|
||||
if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + ')')); return; }
|
||||
adminLibs = adminLibs.filter(function (l) { return l.record_id !== libId; });
|
||||
renderAdminLibs();
|
||||
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.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (r) { return safeJson(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;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import fs from 'node:fs';
|
||||
const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');
|
||||
// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)
|
||||
const start = html.indexOf('var DAEMON_BASE_DEFAULT');
|
||||
const endMark = "return '(封測版未簽章,第一次請右鍵→打開)';\n }";
|
||||
const end = html.indexOf(endMark) + endMark.length;
|
||||
if (start < 0 || end < start) throw new Error('抽不到函式區塊');
|
||||
const src = html.slice(start, end);
|
||||
|
||||
const cases = [
|
||||
['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],
|
||||
['Mac', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15'],
|
||||
['iPhone', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1'],
|
||||
['Linux', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],
|
||||
];
|
||||
let pass=0, fail=0;
|
||||
const chk=(l,c,extra='')=>{ if(c){console.log('PASS:',l);pass++;} else {console.log('FAIL:',l,extra);fail++;} };
|
||||
|
||||
for (const [name, ua] of cases) {
|
||||
const fn = new Function('navigator','window', src + '; return {daemonPick:daemonPick, daemonHint:daemonHint, daemonBase:daemonBase};');
|
||||
const api = fn({userAgent: ua}, {});
|
||||
const d = api.daemonPick();
|
||||
const label = d.sure ? d.pick.label : '(兩個都給)';
|
||||
const url = d.sure ? d.pick.url : d.mac.url + ' + ' + d.win.url;
|
||||
console.log(`\n[${name}] sure=${d.sure} → ${label}`);
|
||||
console.log(` url: ${url}`);
|
||||
if (name==='Windows') {
|
||||
chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url);
|
||||
chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('mac-unsigned.zip'));
|
||||
chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行'));
|
||||
}
|
||||
if (name==='Mac') {
|
||||
chk('Mac 給 mac zip', d.sure && d.pick.url.endsWith('ArcrunRAG-mac-unsigned.zip'));
|
||||
chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip'));
|
||||
chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));
|
||||
}
|
||||
if (name==='iPhone' || name==='Linux') {
|
||||
// iPhone 含 "Mac OS X" 但不是桌機 Mac;Linux 兩者皆非 → 都該落在「不確定=兩個都給」
|
||||
if (name==='Linux') chk('Linux 判不出來→兩個都給', d.sure===false);
|
||||
if (name==='iPhone') chk('iPhone 不該被判成 Mac(手機→兩個都給)', d.sure===false, 'sure='+d.sure);
|
||||
}
|
||||
}
|
||||
// 舊 key 相容
|
||||
const fn2 = new Function('navigator','window', src + '; return daemonBase();');
|
||||
console.log('\n[相容] daemonDownload 舊 key →', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}}));
|
||||
chk('舊 key 推得出目錄', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}})==='https://x.dev/d/');
|
||||
chk('daemonBase 新 key 優先', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonBase:'https://y.dev/z'}})==='https://y.dev/z/');
|
||||
console.log(`\n=== ${pass} passed, ${fail} failed ===`);
|
||||
process.exit(fail?1:0);
|
||||
@@ -0,0 +1,46 @@
|
||||
import fs from 'node:fs';
|
||||
const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');
|
||||
|
||||
// 抽出 safeJson 與 friendlyErr 求值
|
||||
const grab = (name) => {
|
||||
const i = html.indexOf(`function ${name}(`);
|
||||
if (i < 0) throw new Error(`找不到 ${name}`);
|
||||
let d=0, j=html.indexOf('{', i);
|
||||
for (let k=j;k<html.length;k++){ if(html[k]==='{')d++; if(html[k]==='}'){d--; if(!d){ return html.slice(i,k+1);} } }
|
||||
throw new Error('括號不平衡');
|
||||
};
|
||||
const fn = new Function(grab('safeJson') + '\n' + grab('friendlyErr') + '\nreturn {safeJson, friendlyErr};')();
|
||||
|
||||
let pass=0, fail=0;
|
||||
const t=(l,c,e='')=>{c?(console.log('PASS:',l),pass++):(console.log('FAIL:',l,e),fail++)};
|
||||
|
||||
// ① safeJson:非 JSON 不可拋例外(同事撞到的 404 HTML 頁)
|
||||
const html404 = '<!DOCTYPE html><html><body>404 Not Found</body></html>';
|
||||
await fn.safeJson({ text: () => Promise.resolve(html404) })
|
||||
.then(d => t('404 HTML → 回空物件不拋錯', typeof d === 'object' && d !== null))
|
||||
.catch(e => t('404 HTML → 不該拋錯', false, e.message));
|
||||
|
||||
await fn.safeJson({ text: () => Promise.resolve('') })
|
||||
.then(d => t('空回應 → 回空物件', JSON.stringify(d)==='{}'))
|
||||
.catch(() => t('空回應 → 不該拋錯', false));
|
||||
|
||||
await fn.safeJson({ text: () => Promise.resolve('{"error":"帳號或密碼不對"}') })
|
||||
.then(d => t('正常 JSON 仍要解析得出來', d.error === '帳號或密碼不對'), )
|
||||
.catch(() => t('正常 JSON 不該拋錯', false));
|
||||
|
||||
// ② friendlyErr:不可把技術訊息噴給使用者
|
||||
const leak = fn.friendlyErr(new Error('Unexpected non-whitespace character after JSON at position 4'));
|
||||
t('JSON 錯誤 → 不外洩原文', !/JSON|position/i.test(leak), `實得: ${leak}`);
|
||||
t('JSON 錯誤 → 說人話', /伺服器回應異常/.test(leak), `實得: ${leak}`);
|
||||
|
||||
const net = fn.friendlyErr(new Error('Failed to fetch'));
|
||||
t('網路錯誤 → 既有訊息保留', /連線中斷/.test(net), `實得: ${net}`);
|
||||
|
||||
const ours = fn.friendlyErr(new Error('帳號或密碼不對——用你在知識庫網站設定的那組'));
|
||||
t('我們自己的中文訊息 → 原樣顯示', /帳號或密碼不對/.test(ours), `實得: ${ours}`);
|
||||
|
||||
const stack = fn.friendlyErr(new Error('TypeError: Cannot read properties of undefined'));
|
||||
t('英文技術訊息 → 收斂不外洩', !/TypeError|undefined/.test(stack), `實得: ${stack}`);
|
||||
|
||||
console.log(`\n=== ${pass} passed, ${fail} failed ===`);
|
||||
process.exit(fail?1:0);
|
||||
Reference in New Issue
Block a user