// 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; // page:'apps'(App 啟動器)| 'app::' | 'home' | 'ai' | 'update' | 'lib:' // // 🔴 arcrun-rag#137:預設落在 App 啟動器,不是首頁——leo 2026-08-24 的原話是 // 「**打開桌面小幫手就看到**跟 Portal 同一套的 App 啟動器」。 // 同步狀態沒有因此消失:它在**全站頁首**(statusBig/statusSub+「立刻同步」), // 每一頁都看得到;首頁那些卡片仍在側欄的「首頁」裡,一鍵可達。 // 還沒連任何知識庫時 render() 會把它改回 'home'(那裡才是連線精靈)。 let page = 'apps'; let updateInfo = null; let obStep = 1; // 首次啟動精靈目前在第幾步(issue #23,見 onboarding()) // ── App 啟動器的暫存(arcrun-rag#137)── // // 🔴 這是**畫面暫存,不是本機清單**:只活在這個視窗的記憶體裡,關掉就沒了, // 永遠不寫檔。上游 inkstone/Arcrun#82 已定「安裝態只有一份真相源」=實例上那一份, // 桌面端不准另存一份(本票紅線)。存在這裡只是為了不要每次換頁都重打一次網路。 let appsAccIdx = 0; // 啟動器現在在看哪一個知識庫 let appsCache = {}; // accIdx -> ListApps() 的回傳(undefined=還沒問,null=正在問) let appDetail = null; // 目前打開的那個 App 的詳情(GetApp() 的回傳) let appDetailKey = ''; // 'accIdx:id',避免慢回應蓋掉已經換過去的另一個 App let appFrameBridge = null; // App 自帶畫面那個 iframe 的 postMessage 監聽器(換頁時要拆掉) // ── 覆蓋層(只給必須打斷的確認)── 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) || []; // arcrun-rag#137:「App」這一段在最上面,且**只有連了知識庫才出現**—— // 沒有實例就沒有 App,把一個必定空的入口擺在第一項只會讓人以為壞了。 // 目前打開的那個 App 以子項的形式掛在「App 界面」下面(同 Portal 的做法: // 已安裝的 App 是側欄的一格),這樣使用者知道自己在哪、也回得去。 const appNav = !accs.length ? '' : `
App
${page.startsWith('app:') && appDetail && !appDetail.error ? ` ` : ''}
小幫手
`; $('nav').innerHTML = ` ${appNav} ${accs.length ? `
知識庫
` : ''} ${accs.map((a, i) => ` `).join('')}
設定
`; $('nav').querySelectorAll('.nav').forEach((el) => { el.classList.toggle('on', el.dataset.p === page); el.onclick = () => { const p = el.dataset.p; if (p === page) return; // 離開 App 頁時把 iframe 的橋拆掉(見 goToApp 同一段理由)。 if (page.startsWith('app:') && appFrameBridge) { window.removeEventListener('message', appFrameBridge); appFrameBridge = null; } page = p; renderNav(); renderPage(); if (p === 'apps') loadApps(appsAccIdx); }; }); } // ── 首頁:狀態時間軸(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.files.map((f) => `
  • ${esc(f)}
  • `).join('')} ${k.more ? `
  • …等 ${k.more} 個
  • ` : ''}
` : ''} ${k.other ? `
${esc(k.other)}
` : ''}
`; } // ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)── // ══════════════════════════════════════════════════════════════════════════ // 資料夾結構樹(`inkstone/InkStoneCo#44` 桌面那半,leo 2026-08-26) // // leo 的交付定義第一段:「在 Portal 和**桌面小幫手**上,任何一個連上的資料夾 // 都攤得開它完整的巢狀子資料夾樹,每一層看得到這層有幾份、同步了幾份、 // 沒同步的那幾份為什麼沒上去。」Portal 那半已經在跑,這裡是桌面這半。 // // 🔴 形狀規格=`inkstone/Arcrun#144`(leo 2026-08-19 驗 1.4.49 之後打回 markmap): // 「我的需求是**向右向下**,類似 terminal 的 tree,**一列一列向下往後退縮**, // 緊湊但可點擊展開,**模擬 Windows 的檔案總管的 tree**,這是一般辦公室用戶 // 都有的經驗,且可以用滑鼠輕易操作,不會佔用大面積。」 // ⇒ 一列一行、縮排、可摺疊、字級行距貼近作業系統的檔案總管。 // ⇒ **不用任何樹狀圖套件**:這個形狀就是「縮排的清單」,一個 div 一列就到位; // 引一包 library 進來只會多一份要跟著 CIS 對齊的樣式來源。 // // 🔴 **一個判準都不在這裡發明**(同 portal 那半的紅線):兩個數字與「為什麼沒收」 // 的分類全部由 collector 算好(`collector/foldertree.go`,判準活在 scan.go 那趟 // WalkDir)。這裡只做兩件事:把節點串成樹、把子樹的數字加起來。 // 哪天想在這裡寫「.py 算不算支援」——停手,那是第二份實作。 // ══════════════════════════════════════════════════════════════════════════ // data:path → 樹(undefined=還沒問、null=問過但小幫手還沒回報) // open:path → 這個資料夾的樹展開了沒 // nodes:path → { 節點路徑: 展開了沒 }(沒有紀錄=用預設,見 nodeIsOpen) const treeState = { data: {}, open: {}, nodes: {} }; // 資料夾路徑當不了 DOM id(含空白、斜線、中文)⇒ 折成一個穩定的短碼。 function treeBoxId(path) { let h = 0; for (let i = 0; i < path.length; i++) h = (h * 31 + path.charCodeAt(i)) | 0; return 'ft' + (h >>> 0).toString(36); } // 子樹合計:小幫手送的每個數字都只算「這一層直接放的檔」(存兩套遲早對不起來), // 所以要顯示「這個資料夾底下總共」就得在這裡疊一次。 // 🔴 **沒走進去**的節點整棵不計:不知道裡面有幾個檔,加 0 會讓分母說謊。 // 分辨方法(inkstone/Arcrun#180 之後):`skipped` 已經改成「這一層的檔不收」, // 而**走進去過的節點 total_files 是真的**——所以只有 `total_files === 0` 的 // skipped 節點才是「真的沒走進去」,那種才不計。 // 走進去過的(例如 `system-dev/wiki` 6 個範本檔全排除)要照算, // 不然 leo 要的「知道資料夾下有幾個檔案被萃」在合計上又變回 0。 // (與 portal 的 rollupTree 同一套算法——兩邊看到的數字必須是同一個。) function rollupTree(nodes) { const byPath = {}, kids = {}; nodes.forEach((n) => { byPath[n.path] = n; (kids[n.parent] = kids[n.parent] || []).push(n); }); const sums = {}; function walk(n) { const acc = { total: 0, synced: 0, pending: 0, unsupported: 0, excluded: 0 }; if (!n.skipped || n.total_files > 0) { acc.total = n.total_files; acc.synced = n.synced_files; acc.pending = n.pending_files; acc.unsupported = n.unsupported_files; acc.excluded = n.excluded_files; } (kids[n.path] || []).forEach((c) => { const x = walk(c); acc.total += x.total; acc.synced += x.synced; acc.pending += x.pending; acc.unsupported += x.unsupported; acc.excluded += x.excluded; }); sums[n.path] = acc; return acc; } // 根=parent 是 '-'(collector/foldertree.go 刻意用它,才分得出「我是根」與「父親是根」) (kids['-'] || []).forEach(walk); // 孤兒節點(父親不在這份清單裡,例如樹被截斷)也要走一次,否則它整棵不會被算到 nodes.forEach((n) => { if (sums[n.path] === undefined && !byPath[n.parent]) walk(n); }); return { sums, kids, byPath }; } // 差額必須解釋得了:總數 − 已同步 = 不支援 + 不收 + 處理中。 // 這行文案的存在理由就是 leo 那句「不上傳通常是不支援,比如程式碼、不支援的格式」 // ——畫面要自己回答,不必問人。 function gapWhy(s) { const parts = []; if (s.unsupported > 0) parts.push(s.unsupported + ' 份格式還讀不了'); if (s.excluded > 0) parts.push(s.excluded + ' 份不在收檔範圍(程式碼等)'); if (s.pending > 0) parts.push(s.pending + ' 份處理中'); return parts.join('・'); } // 預設只展開根那一層——**照檔案總管的行為**:打開一個資料夾看到它底下一層, // 要更深自己點。一次全攤開在 8000 個檔的資料夾上就是一面沒人看得完的牆。 function nodeIsOpen(path, n) { const st = treeState.nodes[path] || {}; return st[n.path] === undefined ? n.depth === 0 : st[n.path]; } function renderFolderTree(path) { const box = $(treeBoxId(path)); if (!box) return; const tree = treeState.data[path]; if (tree === undefined) { box.innerHTML = `
讀取中…
`; return; } // 🔴 分得出「還沒掃到」與「掃過、裡面是空的」——後者是 arcrun-rag#106 的正常情況 // (指定了空資料夾,它就該在畫面上存在),前者是「再等一下」。 // 兩者講同一句話,等於拿我們自己編的答案回答使用者。 if (tree === null) { box.innerHTML = `
同步小幫手還沒掃到這個資料夾的結構。 第一次同步跑完之後就會出現——檔案多的話要等久一點,按上面的「立刻同步」可以催它。
`; return; } const nodes = tree.nodes || []; if (!nodes.length) { box.innerHTML = `
這個資料夾目前沒有任何內容(指定了就會在這裡,等你放東西進去)。
`; return; } const r = rollupTree(nodes); let html = ''; function emit(n) { const s = r.sums[n.path] || { total: 0, synced: 0, pending: 0, unsupported: 0, excluded: 0 }; const kids = (r.kids[n.path] || []).slice().sort((a, b) => (a.path < b.path ? -1 : 1)); const open = kids.length ? nodeIsOpen(path, n) : false; // 縮排 16px 一層=檔案總管的量級;一列一行、往後退縮(Arcrun#144 的形狀)。 const indent = 4 + n.depth * 16; // 🔴 三角形用 CSS 畫(`` 那個空元素),**不用 ▸▾ 字元**: // 那兩個字在 Windows 的中文字型裡會被當成全形符號、大小與位置各機器不同, // 而它正是 leo 2026-08-19 點名「展開字很小、滑鼠不好按」的那個東西。 // 畫出來的三角形每一台都一樣大,也才控得住點擊區。 // 可展開的那幾列是**按鈕**,不是裝飾用的 div:標上 role/tabindex/aria-expanded // ⇒ 鍵盤按得到、輔助技術念得出「收合/展開」,機械檢查也看得見它是可操作的。 let row = `
` + `${kids.length ? '' : ''}` + `${kids.length && open ? '📂' : '📁'}` + `${esc(n.name || '(未命名)')}`; if (n.skipped && n.total_files === 0) { // 🔴 整棵沒走進去 ⇒ 不准顯示 0/0(那會是我們自己編的數字),改講理由。 const why = n.skip_reason || '(小幫手沒說明理由)'; row += `整個資料夾未收` + `${esc(why)}`; } else if (n.skipped) { // #180:走進去了、數字是真的,只是這一層一個檔都沒收 // ⇒ **數字照顯示**(他要知道底下有幾個),理由也照講。 const why = n.skip_reason || '(小幫手沒說明理由)'; row += `${s.synced} / ${s.total}` + `${esc(why)}`; } else { const full = s.total > 0 && s.synced === s.total; row += `${s.synced} / ${s.total}`; const why = gapWhy(s); if (why) row += `${esc(why)}`; } if (kids.length && !open) row += `…還有 ${kids.length} 個子資料夾`; html += row + `
`; if (open) kids.forEach(emit); } (r.kids['-'] || []).forEach(emit); // 父親不在清單裡的孤兒(樹被截斷時會有)——照樣列出來,缺角要看得見,不要偷偷藏起來 const seen = {}; nodes.forEach((n) => { seen[n.path] = true; }); nodes.forEach((n) => { if (n.parent !== '-' && !seen[n.parent]) emit(n); }); let foot = ''; if (tree.truncated) { foot += `
資料夾太多,只顯示前 ${nodes.length} 個(實際有 ${tree.total_nodes} 個)。
`; } foot += `
數字是「已同步 / 這個資料夾底下總共」。兩者不相等是正常的—— 差額是還讀不了的格式、不在收檔範圍的檔案(程式碼等),或還在處理中。
`; box.innerHTML = `
` + `` + `` + (tree.reason ? `${esc(tree.reason)}` : '') + `
${html}
${foot}`; wireTree(); } // 展開/收合某個資料夾的樹。第一次展開才去讀(同 portal 的 lazy 做法), // 之後留在記憶體裡——它是**畫面暫存不是第二份真相源**,關掉視窗就沒了。 async function toggleFolderTree(path) { treeState.open[path] = !treeState.open[path]; const box = $(treeBoxId(path)); const btn = document.querySelector(`[data-tree="${cssq(path)}"]`); if (btn) btn.textContent = (treeState.open[path] ? '▾ ' : '▸ ') + '資料夾結構'; if (!box) return; box.style.display = treeState.open[path] ? '' : 'none'; if (!treeState.open[path]) return; if (treeState.data[path] !== undefined) { renderFolderTree(path); return; } renderFolderTree(path); // 先畫「讀取中…」 try { const t = await go.GetFolderTree(path); treeState.data[path] = t || null; // 後端回 null=還沒回報過 } catch (e) { box.innerHTML = `
讀不到這個資料夾的結構:${esc(String(e))}
`; return; } renderFolderTree(path); } // 屬性選擇器要跳脫引號——資料夾路徑什麼字元都可能有。 function cssq(s) { return String(s).replace(/["\\]/g, '\\$&'); } function setAllTreeNodes(path, open) { const tree = treeState.data[path]; if (!tree) return; const st = {}; (tree.nodes || []).forEach((n) => { st[n.path] = open; }); if (!open) st[''] = true; // 全部收合時根自己留著,不然整棵消失、看起來像壞了 treeState.nodes[path] = st; renderFolderTree(path); } function wireTree() { document.querySelectorAll('[data-tnode]').forEach((el) => { const toggle = () => { const root = el.dataset.troot, np = el.dataset.tnode; const st = treeState.nodes[root] || (treeState.nodes[root] = {}); const tree = treeState.data[root] || { nodes: [] }; const n = (tree.nodes || []).find((x) => x.path === np); st[np] = !(st[np] === undefined ? (n && n.depth === 0) : st[np]); renderFolderTree(root); }; el.onclick = toggle; el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } }; }); document.querySelectorAll('[data-texpand]').forEach((el) => { el.onclick = () => setAllTreeNodes(el.dataset.texpand, true); }); document.querySelectorAll('[data-tcollapse]').forEach((el) => { el.onclick = () => setAllTreeNodes(el.dataset.tcollapse, false); }); } 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)} ${f.resyncNote ? '補送中' : '自動同步中'}
${f.resyncNote ? `
${esc(f.resyncNote)}
` : ''}`).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 助理直接問,都找得到。

`; } // ═══════════════════════════════════════════════════════════════════════════ // App 啟動器(arcrun-rag#137) // ═══════════════════════════════════════════════════════════════════════════ // // leo 2026-08-24:「所有的 App 需要有一個類似 Android/iOS 的九宮格啟動界面, // 每個 App 有一個 icon,**這會運行在 portal 及 daemon**。」 // // 🔴 清單一律問實例,桌面端沒有任何寫死或存檔的 App 名單(本票紅線; // 上游 inkstone/Arcrun#82「安裝態只有一份真相源」)。後端兩條取得路徑 // 與為什麼是兩條,全寫在 collector/cmd/arcrun-app/apps.go 的檔頭。 // // 🔴 **不掛在每秒的 tick 上**:只有「打開啟動器」「按重新整理」「切知識庫」 // 這三個使用者動作會真的去問實例一次。把它塞進 tick 等於自造輪詢器。 // loadApps 去問某個知識庫裝了哪些 App;問完只在「使用者還停在啟動器」時重畫。 async function loadApps(accIdx, force) { if (!force && appsCache[accIdx] !== undefined) return; appsCache[accIdx] = null; // null=問中(畫面顯示「查詢中」) if (page === 'apps') renderPage(); let res; try { res = await go.ListApps(accIdx); } catch (ex) { res = { accIdx, apps: [], error: String(ex) }; } appsCache[accIdx] = res; if (page === 'apps') renderPage(); } function pageApps(s) { const accs = s.accounts || []; if (!accs.length) { // 「沒連任何實例時要說人話」(驗收條件 1)——不是空白,也不是壞掉的樣子。 return `
還沒有連上知識庫
App 住在你的知識庫上,連上之後這裡就會列出它裝了哪些 App。
`; } if (appsAccIdx >= accs.length) appsAccIdx = 0; const acc = accs[appsAccIdx]; const r = appsCache[appsAccIdx]; // 知識庫切換器:只有一個庫時不畫(一顆永遠只能按自己的按鈕是純噪音)。 const switcher = accs.length > 1 ? `
${accs.map((a, i) => `${esc(a.name)}`).join('')}
` : ''; let sub = '查詢中…'; let body = `
正在問「${esc(acc.name)}」裝了哪些 App…
`; if (r) { if (r.error) { // 🔴 「問不到」與「一個都沒裝」是兩件事,畫面上必須分得出來 // (使用者該做的事完全相反:一個是修連線,一個是去裝 App)。 sub = '這次沒問到'; body = `

暫時看不到這個知識庫的 App

${esc(r.error)}
`; } else { const apps = r.apps || []; sub = `${apps.length} 個 App 已安裝`; body = `
${apps.map((a) => `
${esc(a.icon || '▢')}
${esc(a.name)}
`).join('')}
加裝 App
${apps.length ? '' : `

這個知識庫還沒有 App

App 是裝在知識庫上的:跟你的 AI 說一句「幫我裝一個 X」, 或用 acr 推一份 App 宣告上去。裝好之後回到這裡按「重新整理」就會出現。
`}`; } } return `
App 界面
${esc(acc.name)} · ${esc(sub)}
${switcher} ${body}`; } // ── 單一 App 的頁 ───────────────────────────────────────────────────────── async function loadAppDetail(accIdx, id) { const key = accIdx + ':' + id; appDetailKey = key; appDetail = null; renderPage(); let d; try { d = await go.GetApp(accIdx, id); } catch (ex) { d = { id, error: String(ex) }; } if (appDetailKey !== key) return; // 使用者已經換去別的 App 了,這份回應作廢 appDetail = d; renderNav(); // 側欄的 App 子項要顯示名字與 icon renderPage(); } function pageApp(accIdx, id) { const d = appDetail; const head = (ico, nm, ver) => `
${esc(ico || '▢')} ${esc(nm || id)} ${ver ? `v${esc(ver)}` : ''}
`; if (!d) return `
${head('', id, '')}
載入中…
`; if (d.needsLogin) { // session 過期/這台機器還沒換過 session。不是錯誤,是「還差一步」。 return `
${head(d.icon, d.name, d.version)}

請先登入這個知識庫

要打開 App 的畫面、或執行它的動作,需要你在這個知識庫的帳號登入一次 (之後這台電腦會記住一段時間,同步不受影響)。
帳號
密碼
`; } if (d.error) { return `
${head(d.icon, d.name, d.version)}

打不開這個 App

${esc(d.error)}
`; } if (d.hasUi && d.uiHtml) { // 自帶畫面 ⇒ 掛進 sandbox iframe(mountAppUI 會在 wire() 之後填內容)。 return `
${head(d.icon, d.name, d.version)}
`; } // 沒有自帶畫面 ⇒ 列出工作流,一條一顆「現在執行」 //(與 Portal 的系統預設畫面同一套,不另立第二種呈現)。 const wfs = d.workflows || []; if (!wfs.length) { return `
${head(d.icon, d.name, d.version)}

這個 App 沒有可以按的東西

它既沒有自己的畫面,也沒有登記任何工作流。
`; } return `
${head(d.icon, d.name, d.version)} ${wfs.map((w) => `
${esc(w.name)}
${w.description ? `
${esc(w.description)}
` : ''}
`).join('')}`; } // mountAppUI 把 App 自帶的 HTML 放進 **sandbox iframe**。 // // 🔴 為什麼一定要 iframe(這是桌面端與 Portal 的關鍵差異,不是潔癖): // Portal 是網頁,把 App 的 HTML 直接 innerHTML 進去,那段 script 最多拿到 // 同一頁的 fetch 與 session token。**桌面這半不一樣**——這裡的 window 上掛著 // `window.go.main.App`:`Connect`/`SetAI`/`RemoveFolder(…, takedown=true, cleanupLocal=true)` // 全都在上面。直接 innerHTML = 任何一個 App 的作者都能刪掉使用者雲端的知識 // ——#138 之後**連他硬碟上的整理稿也刪得掉**,這道窄門只會越來越重要。 // ⇒ `sandbox="allow-scripts"`(**不給** allow-same-origin ⇒ 不同源, // 碰不到 parent 的任何東西),只留一條 postMessage 的窄門。 // // 🔴 窄門的形狀刻意與 Portal 一致:App 作者一樣只認得 // `window.arcrunApp.action(action, payload)`,回一個 `{ok,status,d}`—— // 這樣同一個 App 的畫面在 Portal 與桌面上都跑得起來,作者不必寫兩份。 function mountAppUI(accIdx, d) { const f = $('appFrame'); if (!f) return; const bridge = `