// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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:' 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:「每個帳號有獨立的一個頁面」)── // // t215(2026-08-08,leo:「在每個知識庫上顯示是否要更新」):落後的庫名旁加一顆 // 警示點,逛清單時不用點進每個庫就能一眼看出哪個落後(完整說明+更新按鈕在 // 首頁 cardKbVersions 與各庫頁 kbVersionLine)。 // // 🔴 頂層 status.md 08-08 深夜記過一個**待 leo confirm、尚未定案**的提案: // 「單一更新入口(版本白癡化)」——把小幫手自我更新與雲端知識庫更新合併成一顆按鈕。 // 那個提案沒有否定「每庫獨立列出落後狀態」這件事本身(它本來就要「點進去才看細節」), // 只是問「總覽要不要合併」。這裡先實作 leo 這次明確要的「每庫看得到+連得到」, // 之後若那個提案 confirm,是在這層之上疊總覽 pill,不是重做這裡。 function renderNav() { const accs = (state && state.accounts) || []; $('nav').innerHTML = ` ${accs.length ? `
知識庫
` : ''} ${accs.map((a, i) => ` `).join('')}
設定
`; $('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 `

現在的狀態

${st.map((x) => `
${esc(x.title)} ${esc(x.meta || '')}
`).join('')}
${cardQuota(s.quota, s.progress)} ${cardTrouble(s)} ${cardProgress(s.progress)} ${cardSkipped(s.skipped)} ${cardKbVersions(s)}

總計

${s.accounts.length}
個知識庫
${s.accounts.reduce((n,a)=>n+(a.folders||[]).length,0)}
個資料夾在看守
`; } // t215(2026-08-08,leo:「在每個知識庫上顯示是否要更新,如果要,加開啓 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 `

知識庫版本

${accs.map((a) => `
${esc(a.name)} ${kbVersionLine(a)}
`).join('')}
`; } // kbVersionLine:單一知識庫的版本判定文案,首頁卡與各庫頁共用同一份翻譯, // 不讓兩處各寫各的文字(那樣才真的會出現「兩處講不同的話」)。 // // 三種情況都要照實講(cloud_version.go 記過的坑:把「查不到」呈現成「一切正常」): // ① Known 且落後 → 紅字+按鈕,按下去帶 email 開 install 頁(同 portal 版本卡的預填做法) // ② Known 且已最新 → 淡字「已是最新版」 // ③ 不 Known → 照原因分兩句:連不上這個知識庫/查得到目前版本但暫時查不到最新版 function kbVersionLine(a) { if (!a.cloudVerKnown) { const detail = a.cloudVerMine ? `已知版本 ${esc(a.cloudVerMine)},暫時查不到最新版本(稍後會自動再查)` : '目前連不上這個知識庫,查不到版本'; return `${detail}`; } if (a.cloudVerStale) { return `有新版可更新(目前 ${esc(a.cloudVerMine)} → 最新 ${esc(a.cloudVerLatest)}) `; } return `已是最新版(${esc(a.cloudVerMine)})`; } // 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 `

需要回報這個問題?

詳細的錯誤紀錄已經自動存在你電腦裡,不必開啟任何設定。
collector.logapp.log 傳給我們就能查。
${esc(s.logFolder || '')}
`; } // 「今天的 AI 額度用完了」卡(P8,2026-08-09)。 // // 🔴 存在理由:封測者 Evan 把「額度用完」讀成「這個 AI 沒效」——歸錯因、罵錯對象。 // collector 那半(quota.go)08-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 ? `
還有 ${p.pending} 份排隊中——會自動接著跑,你不用重丟。
` : ''; return `

${esc(q.achievement)} 🎉

今天的免費 AI 額度用完了,先休息一下。${esc(q.guarantee)}
${pending}
急著要的話:${esc(q.exit_options)}。
`; } // 你的檔案:分母 + 三個分類(t210,2026-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 `

你的檔案

${p.total}
共幾份
${p.done}
已送上去
${p.pending}
排隊中
${p.cantSync}
送不上去
${p.cantSync > 0 ? `
看看是哪些原因
    ${(p.groups || []).map((g) => `
  • ${esc(g.category)}${g.count} 份
  • `).join('')}
這些會自動重試,你不用重丟;細節與怎麼處理,看說明文件。
` : ''}
`; } function cardSkipped(k) { if (!k) return ''; return `

${esc(k.title)}

${esc(k.note)}
${(k.files || []).length ? ` ` : ''} ${k.other ? `
${esc(k.other)}
` : ''}
`; } // ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)── function pageLib(s, idx) { const a = s.accounts[idx]; if (!a) return `
找不到這個知識庫
`; const portal = 'https://' + a.host.replace('arcrun-cypher-executor.', 'arcrun-rag-ui.') + '/portal/'; return `
${esc(a.name)}
${esc(a.host)}
${kbVersionLine(a)}
${(a.folders || []).map((f) => f.retiring ? `
${esc(f.path)} ${f.retireError ? '收回時出錯,會自動再試' : `正在從雲端收回…${f.retireRemaining ? `還有 ${f.retireRemaining} 份` : ''}`}
${f.retireError ? `
${esc(f.retireError)}
` : ''}` : `
${esc(f.path)} 自動同步中
`).join('') || `
這個知識庫還沒有資料夾
按右上的「加入資料夾」,選一個要自動整理的資料夾。
`}`; } function pageAI(s) { const gem = s.engine === 'gemma'; return `

用哪個 AI 幫你整理文件?

Gemini 金鑰${s.geminiKey ? '(目前已設定,清空並儲存即可刪除)' : ''}
`; } function pageUpdate(s) { const u = updateInfo; const latest = u ? (u.latest || '查詢中…') : '按「檢查更新」查詢'; let action = ``; let note = ''; if (u && u.staged) { action = ``; note = `
新版 ${esc(u.latest)} 已下載完成,重新啟動就會套用。
`; } else if (u && u.available) { action = ``; note = u.notes ? `
${esc(u.notes)}
` : ''; } else if (u && u.err) { note = `
${esc(u.err)}
`; } else if (u) { note = `
你已經是最新版本。
`; } return `

版本與更新

${esc(s.version || '—')}
目前版本
${esc(latest)}
最新版本
${note}
${action}

需要協助?

安裝、更新、把知識庫接到你的 AI,說明文件都寫在這裡。
${cardDiagnostics()}`; } // 疑難排解/匯出診斷檔(t213,2026-08-08):leo 直接指令「一顆按鈕下載一個檔案, // 把檔案發給我」——這裡是那顆按鈕真正住的地方(不是雲端 portal 網頁,那邊碰不到 // 這台電腦上的資料)。按下去在**這台電腦**上合併本機統計+雲端統計成一份 JSON, // 彈系統存檔對話框讓你選位置存。只有數字/狀態,不含你的任何文件內容。 function cardDiagnostics() { return `

疑難排解

搜尋或同步有問題時,可以匯出一份診斷檔給我們,幫你更快找到問題(只有統計數字,不含你的任何文件內容)。
`; } // ── 第一次打開的引導(issue #23,從 #18 拆出來)── // // 🔴 leo 的驗法:「拿一個從沒裝過的狀態實際走一次:不看文件、不問人, // 能不能自己完成第一次設定並看到第一個成果。」 // 舊版只有兩顆按鈕+兩行字,沒有解釋「這是什麼」,也沒有「我做到哪了」的感覺 // ——這裡改成兩步的小精靈,每步都回答「這是什麼/我該做什麼/我做到哪了」: // ① 認識 Arcrun:用大白話講清楚在做什麼,不用任何內部詞(collector/namespace/ // 萃取…),只用「資料夾」「知識卡」「知識庫」這些使用者本來就懂或一看就懂的詞 // ② 連上或申請知識庫:兩條路並排,「還沒有」那條**當場**講清楚回來要做什麼 // (不是丟出去一個外部網址就沒事,那正是舊版讓人卡住的地方) // 連線成功後會直接落回首頁——首頁本來就有「狀態時間軸」+自動種好的範例資料夾 // (見 default_library.go 的 P4),使用者不必再多做一步就能看到「丟檔案 → 知識卡」 // 這條路真的跑起來,那就是「第一個成果」。 function onboarding() { const obDots = `
${obStep === 1 ? '1' : '✓'} 2
`; if (obStep === 2) { return `
${obDots}
第 2 步・共 2 步・連上你的知識庫
你已經有知識庫了嗎?
知識庫是存放你「知識卡」的地方——就像信箱之於信件,之後打開它的網址就能搜尋、AI 也能直接查。
已經有了
手上有網址、帳號、密碼(邀請你的人會給你)。
還沒有
免費申請一個,幾分鐘完成。
填完會拿到網址、帳號、密碼——回到這裡,按左邊「連上知識庫」貼上去就完成。
`; } return `
${obDots}
第 1 步・共 2 步・認識 Arcrun
歡迎使用 Arcrun

① 你指定一個資料夾,Arcrun 會在背景幫你看著它。

② 資料夾裡新增或修改的檔案,會被自動整理成一張張「知識卡」。

③ 之後不管在哪台電腦、哪個裝置,打開你的知識庫網站,或讓你的 AI 助理直接問,都找得到。

`; } 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 += `
`; } $('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(); } // 移除資料夾=兩個後果完全不同的動作,所以給兩顆按鈕,不給一顆猜。 // // 🔴 arcrun-rag#46(leo 2026-08-16 實撞):「我去把 Logseq plugin 刪掉以後, // **採集的 wiki 沒消失**。」舊文案寫的是「已經上傳的知識卡不會被刪除」—— // 那句話**在技術上是對的**,但它預設使用者要的是「只停止同步」, // 而他要的是「我不要這份資料了」。⇒ 病不在少一句說明,在**替他決定了**。 // 現在兩個選擇都擺出來、後果各寫一行,由他挑。 function confirmRemove(accIdx, path) { openSheet(`

移除這個資料夾?

「${esc(path)}」要怎麼處理?你電腦裡的原始檔案不會被動到,兩個選擇的差別只在雲端。

`, () => { $('c1').onclick = closeSheet; $('c2').onclick = async () => { const mode = document.querySelector('input[name="rmMode"]:checked'); const takedown = !mode || mode.value === 'takedown'; await go.RemoveFolder(accIdx, path, takedown); closeSheet(); state = await go.GetState(); renderNav(); renderPage(); }; }); } function showConnect() { openSheet(`

連上你的知識庫

貼上你的知識庫網址,再輸入你在網站上設定的帳號密碼。

知識庫網址
帳號(Email)
密碼
`, () => { $('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);