Files
arcrun-collector/cmd/arcrun-app/frontend/src/main.js
T
Leo e7a72d74e1 daemon: 首次啟動改成兩步引導精靈(Gitea #23)
舊版 onboarding() 只有兩行字+兩顆按鈕,沒解釋 Arcrun 是什麼,也沒有
「我做到哪了」的感覺;『我還沒有知識庫』點下去就丟去外部網址,
回來以後要自己想起來要按哪顆鈕才能連線。

改成兩步小精靈,每步都有步驟點(1/2):
① 認識 Arcrun:三句大白話講清楚在做什麼,不用任何內部詞
② 連上或申請知識庫:『已經有了』/『還沒有』並排兩張卡,
   『還沒有』那張當場講清楚『回來這裡按哪顆鈕』,不是丟出去就沒事

連線邏輯(Connect/showConnect)完全沒動,接的是既有後端;
連線成功後落回首頁,首頁本來就有狀態時間軸+自動種好的範例資料夾
(default_library.go 的 P4),使用者不必再多做一步就看得到
『丟檔案 → 知識卡』整條路跑起來。

只動前端(main.js+style.css),零 Go 改動。check-cis.sh 全過;
用 mock window.go.main.App 在瀏覽器截圖驗過兩步畫面(截圖見 issue #23 留言)。
2026-08-09 15:03:23 +08:00

543 lines
27 KiB
JavaScript
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.
// Arcrun 桌面 App — 前端(t193
//
// 版面(leo 08-04 指定,附 Google Drive 截圖):左側邊欄、右側換頁。
// 🔴 08-04 二輪回饋(本次處理):
// ③「開啟知識庫網頁」不該在首頁(會開到錯的庫)⇒ 移到**各庫頁的上方**
// ④ 一個庫 30 個資料夾放不下 ⇒ **每個知識庫一個獨立分頁**
// ⑤「加入資料夾」會加到哪個帳號?⇒ 在庫頁裡加,**作用對象就是那個庫**,不會加錯
// ⑥ 首頁要顯示 status:看守/發現變化/萃取/上傳 ⇒ **狀態時間軸**
import './arcrun-cis.css'; // 共用底層(色票/字體/紋理)——唯一真相源在 arcrun-cis/css/
import './style.css'; // 本 App 的版面
const go = window.go.main.App;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// 主題:**與 portal 同一套規則**——預設淺色,使用者切換後存 localStorage
// (leo 問「它有淺色佈景?」⇒ 是,portal 預設就是淺色)
const THEME_KEY = 'arcrun_app_theme';
function applyTheme(t) {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem(THEME_KEY, t); } catch (e) {}
const b = $('themeBtn'); if (b) b.textContent = t === 'dark' ? '☀' : '☾';
}
applyTheme((() => { try { return localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; } })());
$('themeBtn').onclick = () =>
applyTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
let state = null;
let page = 'home'; // 'home' | 'ai' | 'update' | 'lib:<idx>'
let updateInfo = null;
let obStep = 1; // 首次啟動精靈目前在第幾步(issue #23,見 onboarding()
// ── 覆蓋層(只給必須打斷的確認)──
function openSheet(html, wire) { $('sheet').innerHTML = html; $('overlay').classList.add('on'); if (wire) wire(); }
function closeSheet() { $('overlay').classList.remove('on'); }
$('overlay').addEventListener('click', (e) => { if (e.target.id === 'overlay') closeSheet(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheet(); });
// ── 側邊欄:每個知識庫一項(leo:「每個帳號有獨立的一個頁面」)──
//
// t2152026-08-08leo:「在每個知識庫上顯示是否要更新」):落後的庫名旁加一顆
// 警示點,逛清單時不用點進每個庫就能一眼看出哪個落後(完整說明+更新按鈕在
// 首頁 cardKbVersions 與各庫頁 kbVersionLine)。
//
// 🔴 頂層 status.md 08-08 深夜記過一個**待 leo confirm、尚未定案**的提案:
// 「單一更新入口(版本白癡化)」——把小幫手自我更新與雲端知識庫更新合併成一顆按鈕。
// 那個提案沒有否定「每庫獨立列出落後狀態」這件事本身(它本來就要「點進去才看細節」),
// 只是問「總覽要不要合併」。這裡先實作 leo 這次明確要的「每庫看得到+連得到」,
// 之後若那個提案 confirm,是在這層之上疊總覽 pill,不是重做這裡。
function renderNav() {
const accs = (state && state.accounts) || [];
$('nav').innerHTML = `
<div class="nav" data-p="home"><span class="ic">◫</span><span class="nm">首頁</span></div>
${accs.length ? `<div class="sec">知識庫</div>` : ''}
${accs.map((a, i) => `
<div class="nav" data-p="lib:${i}">
<span class="ic">▤</span><span class="nm">${esc(a.name)}</span>
${a.cloudVerStale ? `<span class="dot warn" title="有新版可更新"></span>` : ''}
<span class="cnt">${(a.folders || []).length}</span>
</div>`).join('')}
<div class="sec">設定</div>
<div class="nav" data-p="ai"><span class="ic">✧</span><span class="nm">AI 設定</span></div>
<div class="nav" data-p="update"><span class="ic">↧</span><span class="nm">版本與更新</span></div>`;
$('nav').querySelectorAll('.nav').forEach((el) => {
el.classList.toggle('on', el.dataset.p === page);
el.onclick = () => { page = el.dataset.p; renderNav(); renderPage(); };
});
}
// ── 首頁:狀態時間軸(leo:「看守、發現變化、萃取、上傳… 不同 status 在哪裡顯示?」)──
function pageHome(s) {
if (!s.accounts || !s.accounts.length) return onboarding();
const st = s.steps || [];
return `
<div class="card">
<h3>現在的狀態</h3>
<div class="steps">
${st.map((x) => `
<div class="step ${esc(x.state)}">
<span class="dot"></span>
<span class="t">${esc(x.title)}</span>
<span class="m">${esc(x.meta || '')}</span>
</div>`).join('')}
</div>
</div>
${cardQuota(s.quota, s.progress)}
${cardTrouble(s)}
${cardProgress(s.progress)}
${cardSkipped(s.skipped)}
${cardKbVersions(s)}
<div class="card">
<h3>總計</h3>
<div class="kv" style="margin-top:10px">
<div><div class="big-num">${s.accounts.length}</div><div class="k">個知識庫</div></div>
<div><div class="big-num">${s.accounts.reduce((n,a)=>n+(a.folders||[]).length,0)}</div><div class="k">個資料夾在看守</div></div>
</div>
</div>`;
}
// t2152026-08-08leo:「在每個知識庫上顯示是否要更新,如果要,加開啓 install 頁的
// 連結」)——一個使用者可能連著不只一個知識庫(leo 自己就是),各自雲端版本不同步時,
// 以前完全看不出「哪一個」落後、也沒有地方按。這張卡讓使用者不必逐個庫點進去,
// 首頁一眼看完全部知識庫的版本狀態。
//
// 判準**不在這裡重新發明**:後端 collector.EvalCloudUpdate 與 portal 設定頁那張版本卡
// console-ui/public/portal/index.html 的 loadVersion())同一套比法——自己的
// bundle_version 比 install.arcrun.dev/api/latest 的 release,兩邊都是 semver 才逐段
// 整數比較,非 semver(老格式)一律視為落後。前端只負責把後端已經算好的
// cloudVerKnown/cloudVerStale 翻成人話,不做任何版本比較。
function cardKbVersions(s) {
const accs = s.accounts || [];
if (!accs.length) return '';
return `
<div class="card">
<h3>知識庫版本</h3>
<div class="kblist">
${accs.map((a) => `
<div class="kbrow">
<span class="nm">${esc(a.name)}</span>
<span class="right">${kbVersionLine(a)}</span>
</div>`).join('')}
</div>
</div>`;
}
// kbVersionLine:單一知識庫的版本判定文案,首頁卡與各庫頁共用同一份翻譯,
// 不讓兩處各寫各的文字(那樣才真的會出現「兩處講不同的話」)。
//
// 三種情況都要照實講(cloud_version.go 記過的坑:把「查不到」呈現成「一切正常」):
// ① Known 且落後 → 紅字+按鈕,按下去帶 email 開 install 頁(同 portal 版本卡的預填做法)
// ② Known 且已最新 → 淡字「已是最新版」
// ③ 不 Known → 照原因分兩句:連不上這個知識庫/查得到目前版本但暫時查不到最新版
function kbVersionLine(a) {
if (!a.cloudVerKnown) {
const detail = a.cloudVerMine
? `已知版本 ${esc(a.cloudVerMine)},暫時查不到最新版本(稍後會自動再查)`
: '目前連不上這個知識庫,查不到版本';
return `<span class="d">${detail}</span>`;
}
if (a.cloudVerStale) {
return `<span class="warn">有新版可更新(目前 ${esc(a.cloudVerMine)} → 最新 ${esc(a.cloudVerLatest)}</span>
<button class="ghost" data-updatekb="${esc(a.email || '')}">前往安裝頁更新</button>`;
}
return `<span class="d">已是最新版(${esc(a.cloudVerMine)}</span>`;
}
// installURLFor:與 portal 版本卡同一個做法——落後才需要按,按下去帶 email 讓安裝頁
// 預填,既有實例更新免辨識碼(安裝器 t154),不必讓使用者自己去 install.arcrun.dev 找。
function installURLFor(email) {
const base = 'https://install.arcrun.dev/';
return email ? base + '?email=' + encodeURIComponent(email) : base;
}
// 🔴 G-6.2「不准安靜地略過」(2026-08-06)——J-1/S6 考題的後半句:
// 「Then 我一樣找得到——**或當場被告知這種檔案還不支援**」
// 以前 .doc.pages 這類檔在 collector 掃描時就被丟掉,畫面上一個字都沒有,
// 使用者只能得到「我丟了檔,然後什麼都沒發生」這個結論。
// 這張卡就是那句話該出現的地方——**首頁**,他每次打開 App 一定會看到。
// 沒有東西被略過時後端回 null ⇒ 這裡回空字串,畫面保持乾淨(沒事不佔版面)。
// 引擎有問題時才長出來:一鍵打開紀錄檔資料夾。
// leo 2026-08-06:「不能用一個 debug mode?」——log 一直都在寫,缺的是入口。
function cardTrouble(s) {
if (!s.engineTrouble) return '';
return `
<div class="card">
<h3>需要回報這個問題?</h3>
<div class="d">
詳細的錯誤紀錄已經自動存在你電腦裡,不必開啟任何設定。<br/>
把 <b>collector.log</b> 和 <b>app.log</b> 傳給我們就能查。
</div>
<div class="d" style="margin-top:6px;opacity:.7">${esc(s.logFolder || '')}</div>
<div class="acts"><button id="hLogs">打開紀錄檔資料夾</button></div>
</div>`;
}
// 「今天的 AI 額度用完了」卡(P82026-08-09)。
//
// 🔴 存在理由:封測者 Evan 把「額度用完」讀成「這個 AI 沒效」——歸錯因、罵錯對象。
// collector 那半(quota.go08-07 就把 leo 定的三句話寫進 status.json 了,
// 但畫面從來沒接,使用者撞牆時只看得到「送不上去 N 份」。
//
// leo 08-07 定的三句話骨架(缺一不可,順序就是敘事順序):
// ① 成就:「今天已經幫你整理了 N 份」——先講做到什麼,不是先講失敗
// ② 出口:「可以換一個模型,或升級 Cloudflare(每月 5 美元)」——給選擇不是死路
// ③ 保證:「不花錢也沒關係,明天早上 8:00 會自動恢復、會接著跑」
// 三句話原文全部來自後端 QuotaNotice(quota.go 組的),這裡不重組字串——
// 避免同一件事在 status.json、診斷檔、畫面各說各話(措辭漂移)。
// 排隊數取自同一份 s.progress(t210 統計層),讓「還剩多少」也有答案。
function cardQuota(q, p) {
if (!q) return '';
const pending = p && p.pending > 0
? `<div class="d" style="margin-top:6px">還有 <b>${p.pending}</b> 份排隊中——會自動接著跑,你不用重丟。</div>` : '';
return `
<div class="card">
<h3>${esc(q.achievement)} 🎉</h3>
<div class="d" style="margin-top:6px">今天的免費 AI 額度用完了,先休息一下。<b>${esc(q.guarantee)}</b>。</div>
${pending}
<div class="d" style="margin-top:6px">急著要的話:${esc(q.exit_options)}。</div>
<div class="acts"><button class="ghost" data-openurl="https://rag.arcrun.dev/docs/">看看怎麼做</button></div>
</div>`;
}
// 你的檔案:分母 + 三個分類(t2102026-08-08,取代 08-06 逐檔白話翻譯)。
//
// 🔴 leo 08-08 轉述封測者 Evan:「我有 9000 個檔,雲端只有 101 張卡,畫面卻說
// 『20 份沒送進知識庫』——這幾個數字到底是怎麼回事?」病根是首頁每個數字都是
// 本輪的,使用者問的是總量——這張卡改講總量,四個數字(分母+已送上去+排隊中+
// 送不上去)加起來要對得起來,看完的感覺要是「我知道還沒傳,你不要擔心」。
//
// 🔴 leo 08-08:「我不要枚舉每個檔案可能的問題和解法,應該是統計的」「不解釋細節,
// 無法上傳的也摺疊,想看細節才展開」──「送不上去」預設摺疊,展開只有分類與份數,
// 不逐檔列名、不解釋、不給解法;細節去 Docs 說明文件。
//
// 🔴 分類判斷只住在後端 collector/progress.go 的 ClassifyFailure 一個接縫——
// 這裡完全不認得任何分類名稱字串,`g.category` 原樣印出、順序照後端給的陣列,
// 不在前端排序或分支判斷(t214 之後分類要改成資料驅動,才只需要動後端那一個檔)。
function cardProgress(p) {
if (!p || !p.total) return '';
return `
<div class="card">
<h3>你的檔案</h3>
<div class="kv" style="margin-top:10px;flex-wrap:wrap">
<div><div class="big-num">${p.total}</div><div class="k">共幾份</div></div>
<div><div class="big-num">${p.done}</div><div class="k">已送上去</div></div>
<div><div class="big-num">${p.pending}</div><div class="k">排隊中</div></div>
<div><div class="big-num">${p.cantSync}</div><div class="k">送不上去</div></div>
</div>
${p.cantSync > 0 ? `
<details class="fail" style="margin-top:14px">
<summary>看看是哪些原因</summary>
<ul class="breaklist">
${(p.groups || []).map((g) => `<li><span>${esc(g.category)}</span><span>${g.count} 份</span></li>`).join('')}
</ul>
<div class="d" style="margin-top:8px">這些會自動重試,你不用重丟;細節與怎麼處理,看說明文件。</div>
<div class="acts"><button class="ghost" data-openurl="https://rag.arcrun.dev/docs/">開啟使用說明</button></div>
</details>` : ''}
</div>`;
}
function cardSkipped(k) {
if (!k) return '';
return `
<div class="card">
<h3>${esc(k.title)}</h3>
<div class="d" style="margin-top:6px">${esc(k.note)}</div>
${(k.files || []).length ? `
<ul class="skiplist">
${k.files.map((f) => `<li>${esc(f)}</li>`).join('')}
${k.more ? `<li class="more">…等 ${k.more} 個</li>` : ''}
</ul>` : ''}
${k.other ? `<div class="d" style="margin-top:8px">${esc(k.other)}</div>` : ''}
</div>`;
}
// ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)──
function pageLib(s, idx) {
const a = s.accounts[idx];
if (!a) return `<div class="empty"><div class="t">找不到這個知識庫</div></div>`;
const portal = 'https://' + a.host.replace('arcrun-cypher-executor.', 'arcrun-rag-ui.') + '/portal/';
return `
<div class="libhead">
<div class="g">
<div class="nm">${esc(a.name)}</div>
<div class="host">${esc(a.host)}</div>
</div>
<button data-portal="${esc(portal)}">開啟知識庫網頁</button>
<button class="primary" data-addto="${idx}">加入資料夾</button>
</div>
<div class="kbver">${kbVersionLine(a)}</div>
${(a.folders || []).map((f) => `
<div class="folder">
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
<span class="tag">自動同步中</span>
<button class="ghost" data-rm="${esc(f.path)}" data-acc="${f.accIdx}">移除</button>
</div>`).join('')
|| `<div class="empty"><div class="t">這個知識庫還沒有資料夾</div>
<div class="d">按右上的「加入資料夾」,選一個要自動整理的資料夾。</div></div>`}`;
}
function pageAI(s) {
const gem = s.engine === 'gemma';
return `
<div class="card">
<h3>用哪個 AI 幫你整理文件?</h3>
<label class="radio"><input type="radio" name="ai" value="cloud" ${gem ? '' : 'checked'}/>
<span><b>雲端 AI</b>(推薦・不必申請任何金鑰)<br/>
<span class="d">用你自己 Cloudflare 帳號內建的 AI,不需要任何金鑰、不必去別的網站申請。</span></span></label>
<label class="radio"><input type="radio" name="ai" value="gemini" ${gem ? 'checked' : ''}/>
<span><b>Google Gemini</b>(需要自己申請金鑰)<br/>
<span class="d">進階選項。要自己去 aistudio.google.com 申請一把 API Key。</span></span></label>
<div class="field">
<div class="lb">Gemini 金鑰${s.geminiKey ? '(目前已設定,清空並儲存即可刪除)' : ''}</div>
<input type="text" id="k" placeholder="貼上你的 Gemini API Key"/>
</div>
<div class="err" id="aiErr" style="display:none"></div>
<div class="acts"><button class="primary" id="aiSave">儲存</button></div>
</div>`;
}
function pageUpdate(s) {
const u = updateInfo;
const latest = u ? (u.latest || '查詢中…') : '按「檢查更新」查詢';
let action = `<button class="primary" id="uCheck">檢查更新</button>`;
let note = '';
if (u && u.staged) {
action = `<button class="primary" id="uApply">重新啟動以完成更新</button>`;
note = `<div class="d">新版 ${esc(u.latest)} 已下載完成,重新啟動就會套用。</div>`;
} else if (u && u.available) {
action = `<button class="primary" id="uDownload">下載並安裝 ${esc(u.latest)}</button>`;
note = u.notes ? `<div class="d">${esc(u.notes)}</div>` : '';
} else if (u && u.err) {
note = `<div class="err">${esc(u.err)}</div>`;
} else if (u) {
note = `<div class="d">你已經是最新版本。</div>`;
}
return `
<div class="card">
<h3>版本與更新</h3>
<div class="kv" style="margin-top:12px">
<div><div class="big-num">${esc(s.version || '—')}</div><div class="k">目前版本</div></div>
<div><div class="big-num">${esc(latest)}</div><div class="k">最新版本</div></div>
</div>
<div style="margin-top:14px">${note}</div>
<div class="acts">${action}</div>
</div>
<div class="card">
<h3>需要協助?</h3>
<div class="d">安裝、更新、把知識庫接到你的 AI,說明文件都寫在這裡。</div>
<div class="acts"><button id="uDocs">開啟使用說明</button></div>
</div>
${cardDiagnostics()}`;
}
// 疑難排解/匯出診斷檔(t2132026-08-08):leo 直接指令「一顆按鈕下載一個檔案,
// 把檔案發給我」——這裡是那顆按鈕真正住的地方(不是雲端 portal 網頁,那邊碰不到
// 這台電腦上的資料)。按下去在**這台電腦**上合併本機統計+雲端統計成一份 JSON,
// 彈系統存檔對話框讓你選位置存。只有數字/狀態,不含你的任何文件內容。
function cardDiagnostics() {
return `
<div class="card">
<h3>疑難排解</h3>
<div class="d">搜尋或同步有問題時,可以匯出一份診斷檔給我們,幫你更快找到問題(只有統計數字,不含你的任何文件內容)。</div>
<div class="acts"><button id="uDiag">匯出診斷檔</button></div>
<div class="d" id="uDiagStatus" style="margin-top:8px"></div>
</div>`;
}
// ── 第一次打開的引導(issue #23,從 #18 拆出來)──
//
// 🔴 leo 的驗法:「拿一個從沒裝過的狀態實際走一次:不看文件、不問人,
// 能不能自己完成第一次設定並看到第一個成果。」
// 舊版只有兩顆按鈕+兩行字,沒有解釋「這是什麼」,也沒有「我做到哪了」的感覺
// ——這裡改成兩步的小精靈,每步都回答「這是什麼/我該做什麼/我做到哪了」:
// ① 認識 Arcrun:用大白話講清楚在做什麼,不用任何內部詞(collectornamespace
// 萃取…),只用「資料夾」「知識卡」「知識庫」這些使用者本來就懂或一看就懂的詞
// ② 連上或申請知識庫:兩條路並排,「還沒有」那條**當場**講清楚回來要做什麼
// (不是丟出去一個外部網址就沒事,那正是舊版讓人卡住的地方)
// 連線成功後會直接落回首頁——首頁本來就有「狀態時間軸」+自動種好的範例資料夾
// (見 default_library.go 的 P4),使用者不必再多做一步就能看到「丟檔案 → 知識卡」
// 這條路真的跑起來,那就是「第一個成果」。
function onboarding() {
const obDots = `
<div class="obdots">
<span class="obdot ${obStep === 1 ? 'on' : 'done'}">${obStep === 1 ? '1' : '✓'}</span>
<span class="obline"></span>
<span class="obdot ${obStep === 2 ? 'on' : ''}">2</span>
</div>`;
if (obStep === 2) {
return `
<div class="empty ob">
${obDots}
<div class="obcap">第 2 步・共 2 步・連上你的知識庫</div>
<div class="t">你已經有知識庫了嗎?</div>
<div class="d">知識庫是存放你「知識卡」的地方——就像信箱之於信件,之後打開它的網址就能搜尋、AI 也能直接查。</div>
<div class="obchoice">
<div class="obcard">
<div class="obh">已經有了</div>
<div class="d">手上有網址、帳號、密碼(邀請你的人會給你)。</div>
<button class="primary" id="obConnect">連上知識庫</button>
</div>
<div class="obcard">
<div class="obh">還沒有</div>
<div class="d">免費申請一個,幾分鐘完成。<br/>填完會拿到網址、帳號、密碼——<b>回到這裡</b>,按左邊「連上知識庫」貼上去就完成。</div>
<button id="obInstall">免費申請一個</button>
</div>
</div>
<button class="ghost" id="obBack" style="margin-top:16px"> 上一步</button>
</div>`;
}
return `
<div class="empty ob">
${obDots}
<div class="obcap">第 1 步・共 2 步・認識 Arcrun</div>
<div class="t">歡迎使用 Arcrun</div>
<div class="d obintro">
<p>① 你指定一個資料夾,Arcrun 會在背景幫你看著它。</p>
<p>② 資料夾裡新增或修改的檔案,會被自動整理成一張張「知識卡」。</p>
<p>③ 之後不管在哪台電腦、哪個裝置,打開你的知識庫網站,或讓你的 AI 助理直接問,都找得到。</p>
</div>
<button class="primary" id="obNext">開始設定</button>
</div>`;
}
function renderPage() {
if (!state) return;
let html;
if (page.startsWith('lib:')) html = pageLib(state, Number(page.slice(4)));
else if (page === 'ai') html = pageAI(state);
else if (page === 'update') html = pageUpdate(state);
else html = pageHome(state);
// 每個庫頁底下都給「新增知識庫帳號」入口
if (page === 'home' && state.accounts && state.accounts.length) {
html += `<div class="acts"><button id="hAcct">新增知識庫帳號</button></div>`;
}
$('page').innerHTML = html;
wire();
}
function wire() {
const on = (id, fn) => { const e = $(id); if (e) e.onclick = fn; };
on('uDocs', () => go.OpenURL('https://rag.arcrun.dev/docs/'));
on('hLogs', () => go.OpenLogFolder());
on('hLogs', () => go.OpenLogFolder());
on('hAcct', showConnect); on('obConnect', showConnect);
on('obInstall', () => go.OpenURL('https://install.arcrun.dev/'));
on('obNext', () => { obStep = 2; renderPage(); });
on('obBack', () => { obStep = 1; renderPage(); });
on('aiSave', saveAI);
on('uCheck', checkUpdate); on('uDownload', downloadUpdate); on('uApply', applyUpdate);
on('uDiag', exportDiagnostics);
document.querySelectorAll('[data-portal]').forEach((b) => { b.onclick = () => go.OpenURL(b.dataset.portal); });
document.querySelectorAll('[data-openurl]').forEach((b) => { b.onclick = () => go.OpenURL(b.dataset.openurl); });
document.querySelectorAll('[data-updatekb]').forEach((b) => {
b.onclick = () => go.OpenURL(installURLFor(b.dataset.updatekb));
});
document.querySelectorAll('[data-addto]').forEach((b) => { b.onclick = () => addFolder(Number(b.dataset.addto)); });
document.querySelectorAll('[data-rm]').forEach((b) => {
b.onclick = () => confirmRemove(Number(b.dataset.acc), b.dataset.rm);
});
}
function render(s) {
const first = !state;
const navChanged = state && JSON.stringify((state.accounts||[]).map(a=>[a.name,(a.folders||[]).length]))
!== JSON.stringify((s.accounts||[]).map(a=>[a.name,(a.folders||[]).length]));
state = s;
$('ver').textContent = s.version || '';
$('statusBig').textContent = s.statusBig;
$('statusBig').classList.toggle('syncing', !!s.syncing);
$('statusSub').textContent = s.statusSub;
if (first || navChanged) { renderNav(); renderPage(); }
else if (page === 'home') renderPage(); // 首頁的狀態時間軸要跟著跳
}
async function tick() { try { render(await go.GetState()); } catch (e) {} }
// ── 動作 ──
$('btnSync').onclick = async () => { await go.SyncNow(); tick(); };
async function addFolder(accIdx) {
const p = await go.PickFolder();
if (!p) return;
await go.AddFolder(accIdx, p);
state = await go.GetState(); renderNav(); renderPage();
}
function confirmRemove(accIdx, path) {
openSheet(`
<h2>移除這個資料夾?</h2>
<p>「${esc(path)}」不再自動同步。<br/>已經上傳的知識卡不會被刪除。</p>
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">移除</button></div>`,
() => {
$('c1').onclick = closeSheet;
$('c2').onclick = async () => {
await go.RemoveFolder(accIdx, path); closeSheet();
state = await go.GetState(); renderNav(); renderPage();
};
});
}
function showConnect() {
openSheet(`
<h2>連上你的知識庫</h2>
<p>貼上你的知識庫網址,再輸入你在網站上設定的帳號密碼。</p>
<div class="field"><div class="lb">知識庫網址</div>
<input type="text" id="u" placeholder="https://arcrun-cypher-executor.xxxx.workers.dev"/></div>
<div class="field"><div class="lb">帳號(Email</div>
<input type="text" id="e" placeholder="you@example.com"/></div>
<div class="field"><div class="lb">密碼</div><input type="password" id="p"/></div>
<div class="err" id="err" style="display:none"></div>
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">連線</button></div>`,
() => {
$('c1').onclick = closeSheet;
$('c2').onclick = async () => {
try {
await go.Connect($('u').value.trim(), $('e').value.trim(), $('p').value);
closeSheet(); state = await go.GetState(); renderNav(); renderPage();
} catch (ex) { $('err').textContent = String(ex); $('err').style.display = 'block'; }
};
});
}
async function saveAI() {
const useGemini = document.querySelector('input[name=ai]:checked').value === 'gemini';
try {
await go.SetAI(useGemini, $('k').value.trim());
state = await go.GetState(); renderPage();
} catch (ex) { $('aiErr').textContent = String(ex); $('aiErr').style.display = 'block'; }
}
async function checkUpdate() {
updateInfo = { latest: '查詢中…' }; renderPage();
try { updateInfo = await go.CheckUpdate(); } catch (ex) { updateInfo = { err: String(ex) }; }
renderPage();
}
async function downloadUpdate() {
updateInfo = Object.assign({}, updateInfo, { notes: '下載中…請稍候' }); renderPage();
try { updateInfo = await go.DownloadUpdate(); } catch (ex) { updateInfo = { err: String(ex) }; }
renderPage();
}
async function applyUpdate() {
try { await go.ApplyUpdate(); } catch (ex) { updateInfo = { err: String(ex) }; renderPage(); }
}
// 匯出診斷檔(t213):後端 ExportDiagnostics 自己彈系統存檔對話框;回傳空字串=
// 使用者按了取消,不算錯誤、不顯示紅字。
async function exportDiagnostics() {
const el = $('uDiagStatus');
if (el) el.textContent = '匯出中…';
try {
const path = await go.ExportDiagnostics();
if (el) el.textContent = path ? `已存到:${path}` : '已取消。';
} catch (ex) {
if (el) el.textContent = '匯出失敗:' + String(ex);
}
}
tick();
setInterval(tick, 1000);