Files
arcrun-collector/cmd/arcrun-app/frontend/src/main.js
T

1273 lines
67 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;
// page'apps'App 啟動器)| 'app:<accIdx>:<id>' | 'home' | 'ai' | 'update' | 'lib:<idx>'
//
// 🔴 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:「每個帳號有獨立的一個頁面」)──
//
// t2152026-08-08leo:「在每個知識庫上顯示是否要更新」):落後的庫名旁加一顆
// 警示點,逛清單時不用點進每個庫就能一眼看出哪個落後(完整說明+更新按鈕在
// 首頁 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 ? '' : `
<div class="sec">App</div>
<div class="nav" data-p="apps"><span class="ic">▦</span><span class="nm">App 界面</span></div>
${page.startsWith('app:') && appDetail && !appDetail.error ? `
<div class="nav" data-p="${esc(page)}" style="padding-left:44px">
<span class="ic">${esc(appDetail.icon || '▢')}</span>
<span class="nm">${esc(appDetail.name || appDetail.id)}</span>
</div>` : ''}
<div class="sec">小幫手</div>`;
$('nav').innerHTML = `
${appNav}
<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 = () => {
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 `
<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:單一知識庫的版本,首頁卡與各庫頁共用同一份(不讓兩處各寫各的)。
//
// 🔴 `inkstone/arcrun-rag#159`leo 2026-08-28):「**GUI 是國際通用,圖形指示,
// 不限語文**,所以任何出現文字都要謹慎,文字表示『我的設計不夠好所以要靠文字來補』」
// ⇒ 這一行以前是三句話(「有新版可更新(目前 x → 最新 y)」「已是最新版(x)」
// 「目前連不上這個知識庫,查不到版本」)。現在只剩**版本號與圖示**:
//
// 1.4.60 ✅ 已是最新
// 1.4.46 → 1.4.58 ⬆️ 有新版(按鈕,按下去帶 email 開安裝頁)
// ○ 從來沒查到過這台的版本
//
// 說明都收進 title(滑過去才出現),版面上一個字都不佔。
//
// 🔴 「這一輪連不上」**不再抹掉版本號**(見 collector/direct.go 那段註解):
// 版本查到過就是事實,抽風的是網路。連不上時版本號調淡,滑過去講得出來。
function kbVersionLine(a) {
const dim = a.cloudVerFresh ? '' : ' dim';
const tip = a.cloudVerFresh ? '' : '上次查到的版本(現在暫時連不上)';
if (!a.cloudVerKnown) {
// 分得出「沒查到這台的版本」與「查到了但暫時不知道最新版是多少」——
// 前者連版本號都沒有,後者有版本號可以顯示。
if (!a.cloudVerMine) {
return `<span class="kbv unknown" role="img" title="還沒查到這個知識庫的版本">○</span>`;
}
return `<span class="kbv${dim}" title="${esc(tip || '暫時查不到最新版本,稍後自動再查')}">${esc(a.cloudVerMine)}</span>`;
}
if (a.cloudVerStale) {
return `<span class="kbv${dim}" title="${esc(tip)}">${esc(a.cloudVerMine)}${esc(a.cloudVerLatest)}</span>
<button class="ico" data-updatekb="${esc(a.email || '')}"
title="前往安裝頁更新這個知識庫" aria-label="前往安裝頁更新這個知識庫">⬆️</button>`;
}
return `<span class="kbv${dim}" title="${esc(tip)}">${esc(a.cloudVerMine)}</span>
<span class="fstat ok" role="img" title="已是最新版" aria-label="已是最新版">✅</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>`;
}
// ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)──
// ══════════════════════════════════════════════════════════════════════════
// 資料夾結構樹(`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 算不算支援」——停手,那是第二份實作。
// ══════════════════════════════════════════════════════════════════════════
// datapath → 樹(undefined=還沒問、null=問過但小幫手還沒回報)
// open:path → 這個資料夾的樹展開了沒
// nodespath → { 節點路徑: 展開了沒 }(沒有紀錄=用預設,見 nodeIsOpen
// whypath → { 節點路徑: 這一列的「為什麼」被點開了沒 }(arcrun-rag#159
const treeState = { data: {}, open: {}, nodes: {}, why: {} };
// ── 資料夾那一列的狀態圖示(`inkstone/arcrun-rag#159`)────────────────────
//
// leo 2026-08-28:「補送中、資料夾結構、移除,**變成 3 個 emoticon 就好,
// 畫面上都是字壓力很大**」「他要知道的是『我的資料夾是否同步了』,**有同步打勾就好**」
//
// 🔴 這裡**只做代碼→圖示的對照**,一個判斷都不做。
// 「該不該打勾」住在 Go 那一側(cmd/arcrun-app/folder_badge.go,有測試守著)——
// 前端自己判斷就會變成第二套判準,遲早跟後端說的不一樣。
const SYNC_ICON = { ok: '✅', working: '🔄', trouble: '⚠️', unknown: '○' };
// 資料夾路徑當不了 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 = `<div class="ftmsg">讀取中…</div>`; return; }
// 🔴 分得出「還沒掃到」與「掃過、裡面是空的」——後者是 arcrun-rag#106 的正常情況
// (指定了空資料夾,它就該在畫面上存在),前者是「再等一下」。
// 兩者講同一句話,等於拿我們自己編的答案回答使用者。
if (tree === null) {
box.innerHTML = `<div class="ftmsg">還沒掃到這個資料夾,第一次同步跑完就會出現。</div>`;
return;
}
const nodes = tree.nodes || [];
if (!nodes.length) {
box.innerHTML = `<div class="ftmsg">這個資料夾目前是空的。</div>`;
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 畫(`<i>` 那個空元素),**不用 ▸▾ 字元**:
// 那兩個字在 Windows 的中文字型裡會被當成全形符號、大小與位置各機器不同,
// 而它正是 leo 2026-08-19 點名「展開字很小、滑鼠不好按」的那個東西。
// 畫出來的三角形每一台都一樣大,也才控得住點擊區。
// 可展開的那幾列是**按鈕**,不是裝飾用的 div:標上 role/tabindex/aria-expanded
// ⇒ 鍵盤按得到、輔助技術念得出「收合/展開」,機械檢查也看得見它是可操作的。
let row = `<div class="ftrow${kids.length ? ' has' : ''}${open ? ' open' : ''}" style="padding-left:${indent}px"`
+ (kids.length
? ` data-tnode="${esc(n.path)}" data-troot="${esc(path)}" role="button" tabindex="0"`
+ ` aria-expanded="${open}" title="${open ? '收合' : '展開'}${esc(n.name || '')}」"`
: '') + `>`
+ `<span class="tw">${kids.length ? '<i></i>' : ''}</span>`
+ `<span class="ic">${kids.length && open ? '📂' : '📁'}</span>`
+ `<span class="nm">${esc(n.name || '(未命名)')}</span>`;
// ── 一列只有:三角形 + 資料夾名 + `X / Y``inkstone/arcrun-rag#159`)──
//
// leo 2026-08-28:「**上下廢話刪除**,**產生 GUI 就是要讓人減少讀字降低負擔,
// 你在 GUI 寫這麼多字剛好違背它的原理**」——他的截圖上同一句
// 「這是安裝範本時鋪下來的空白樣板…」**在一棵樹裡出現了 5 次**。
//
// 🔴 **「收起來」不等於「刪掉」**(本票紅線):那些理由是使用者要救回被跳過的
// 資料夾時的線索(`inkstone/arcrun-rag#136`)。所以理由沒有被拿掉,
// 只是**不再預設佔畫面**——點那個數字就展開這一列自己的理由。
// 🔴 也**不塞進 tooltip**(同一條紅線:「不要把長句子搬進 tooltip 裡繼續長」),
// tooltip 只有「點一下看原因」這種操作提示。
let why = n.skipped
? (n.skip_reason || '(小幫手沒說明理由)')
: gapWhy(s);
// 收檔策略那句話(原本掛在樹的上方,leo 圈掉了)改掛在**根那一列**——
// 它講的就是這個監看根,點根的數字就看得到,資訊沒有消失。
if (n.parent === '-' && tree.reason) why = why ? `${tree.reason}${why}` : tree.reason;
// 整棵沒走進去(#180 之後:skipped 且 total_files 為 0)⇒ 不准顯示 0/0
// 那會是我們自己編的數字。用「—」表示「這個數字我沒有」。
const noCount = n.skipped && n.total_files === 0;
const full = !noCount && s.total > 0 && s.synced === s.total;
const shown = noCount ? '—' : `${s.synced} / ${s.total}`;
row += `<span class="num${full ? ' full' : ''}${why ? ' hasWhy' : ''}"`
+ (why
? ` data-twhy="${esc(n.path)}" data-twroot="${esc(path)}" role="button" tabindex="0" title="點一下看原因"`
: '')
+ `>${shown}</span>`;
html += row + `</div>`;
if (why && (treeState.why[path] || {})[n.path]) {
html += `<div class="ftwhy" style="padding-left:${indent + 21}px">${esc(why)}</div>`;
}
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); });
// 🔴 樹的上方與下方**什麼都不留**`inkstone/arcrun-rag#159`leo 在截圖上圈了三處):
// ① 上方的「全部展開/全部收合 + 這是一般資料夾或筆記庫…」整條拿掉
// ② 下方的「數字是『已同步 / 這個資料夾底下總共』…」整段拿掉
// ③ 下方的補送說明拿掉(狀態改由資料夾那一列的圖示表達)
// leo 原話:「**上下廢話刪除,只留乾淨 Tree**」。
//
// 收檔策略那句話(tree.reason)沒有消失——它跟每一列的理由走同一條路:
// 點根那一列的數字就看得到(emit() 裡的 hasWhy)。
//
// **唯一的例外是截斷警告**:它只在樹真的不完整時才出現,而不講就是讓畫面
// 宣稱「這是全部」——那是說謊,不是廢話。
let foot = '';
if (tree.truncated) {
foot = `<div class="ftmsg">只顯示前 ${nodes.length} 個資料夾(實際有 ${tree.total_nodes} 個)。</div>`;
}
box.innerHTML = `<div class="ftbody">${html}</div>${foot}`;
wireTree();
}
// 展開/收合某個資料夾的樹。第一次展開才去讀(同 portal 的 lazy 做法),
// 之後留在記憶體裡——它是**畫面暫存不是第二份真相源**,關掉視窗就沒了。
async function toggleFolderTree(path) {
treeState.open[path] = !treeState.open[path];
const box = $(treeBoxId(path));
// #159:那顆按鈕現在只有一個 CSS 畫的三角形(沒有文字),轉向由 class 決定。
const btn = document.querySelector(`[data-tree="${cssq(path)}"]`);
if (btn) {
btn.setAttribute('aria-expanded', String(!!treeState.open[path]));
const row = btn.closest('.folder');
if (row) row.classList.toggle('open', !!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 = `<div class="err">讀不到這個資料夾的結構:${esc(String(e))}</div>`;
return;
}
renderFolderTree(path);
}
// 屬性選擇器要跳脫引號——資料夾路徑什麼字元都可能有。
function cssq(s) { return String(s).replace(/["\\]/g, '\\$&'); }
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(); } };
});
// #159:點數字=展開這一列自己的「為什麼」。理由沒有被刪掉,只是不再預設佔畫面。
// stopPropagation:這個數字常常長在「整列可點=展開子資料夾」的列上,
// 不擋住的話點原因會順便把子資料夾收掉。
document.querySelectorAll('[data-twhy]').forEach((el) => {
const toggle = (e) => {
e.stopPropagation();
const root = el.dataset.twroot, np = el.dataset.twhy;
const st = treeState.why[root] || (treeState.why[root] = {});
st[np] = !st[np];
renderFolderTree(root);
};
el.onclick = toggle;
el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); } };
});
}
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) => f.retiring ? `
<div class="folder">
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
<span class="tag retiring">${f.retireError
? '收回時出錯,會自動再試'
: `正在從雲端收回…${f.retireRemaining ? `還有 ${f.retireRemaining} 份` : ''}`}</span>
</div>
${f.retireError ? `<div class="d folder-note">${esc(f.retireError)}</div>` : ''}` : `
<div class="folder${treeState.open[f.path] ? ' open' : ''}">
<button class="tw" data-tree="${esc(f.path)}" aria-expanded="${!!treeState.open[f.path]}"
title="展開這個資料夾" aria-label="展開或收合這個資料夾的內容"><i></i></button>
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
<span class="fstat ${esc(f.sync || 'unknown')}" role="img"
title="${esc(f.syncTip || '')}" aria-label="${esc(f.syncTip || '')}"
>${SYNC_ICON[f.sync] || SYNC_ICON.unknown}</span>
<button class="ico" data-rm="${esc(f.path)}" data-acc="${f.accIdx}"
title="移除這個資料夾" aria-label="移除這個資料夾並從知識庫收回">🗑</button>
</div>
<div class="ftbox" id="${treeBoxId(f.path)}"${treeState.open[f.path] ? '' : ' style="display:none"'}></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>`;
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 `<div class="empty">
<div class="t">還沒有連上知識庫</div>
<div class="d">App 住在你的知識庫上,連上之後這裡就會列出它裝了哪些 App。</div>
<button class="primary" id="apConnect">連上知識庫</button>
</div>`;
}
if (appsAccIdx >= accs.length) appsAccIdx = 0;
const acc = accs[appsAccIdx];
const r = appsCache[appsAccIdx];
// 知識庫切換器:只有一個庫時不畫(一顆永遠只能按自己的按鈕是純噪音)。
const switcher = accs.length > 1 ? `
<div class="appswitch">
${accs.map((a, i) => `<span class="chip ${i === appsAccIdx ? 'on' : ''}" data-appacc="${i}">${esc(a.name)}</span>`).join('')}
</div>` : '';
let sub = '查詢中…';
let body = `<div class="card"><div class="d">正在問「${esc(acc.name)}」裝了哪些 App…</div></div>`;
if (r) {
if (r.error) {
// 🔴 「問不到」與「一個都沒裝」是兩件事,畫面上必須分得出來
// (使用者該做的事完全相反:一個是修連線,一個是去裝 App)。
sub = '這次沒問到';
body = `<div class="card">
<h3>暫時看不到這個知識庫的 App</h3>
<div class="d">${esc(r.error)}</div>
<div class="acts"><button id="apRetry">再試一次</button></div>
</div>`;
} else {
const apps = r.apps || [];
sub = `${apps.length} 個 App 已安裝`;
body = `<div class="appgrid">
${apps.map((a) => `
<div class="appcell">
<div class="apptile" data-appopen="${esc(a.id)}" title="${esc(a.name)}${a.version ? ' · v' + esc(a.version) : ''}">${esc(a.icon || '▢')}</div>
<div class="nm" title="${esc(a.name)}">${esc(a.name)}</div>
</div>`).join('')}
<div class="appcell">
<div class="apptile add" id="apAdd" title="怎麼加裝 App"></div>
<div class="nm dim">加裝 App</div>
</div>
</div>
${apps.length ? '' : `<div class="card" style="margin-top:26px">
<h3>這個知識庫還沒有 App</h3>
<div class="d">App 是裝在知識庫上的:跟你的 AI 說一句「幫我裝一個 X」,
或用 <b>acr</b> 推一份 App 宣告上去。裝好之後回到這裡按「重新整理」就會出現。</div>
</div>`}`;
}
}
return `
<div class="apphead">
<div class="g">
<div class="t">App 界面</div>
<div class="s">${esc(acc.name)} · ${esc(sub)}</div>
</div>
<button id="apRefresh">重新整理</button>
</div>
${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) => `
<div class="head">
<span class="ico">${esc(ico || '▢')}</span>
<span class="nm">${esc(nm || id)}</span>
${ver ? `<span class="vr">v${esc(ver)}</span>` : ''}
<span class="sp"></span>
<button data-appback="1"> 回 App 界面</button>
</div>`;
if (!d) return `<div class="appview">${head('', id, '')}<div class="card"><div class="d">載入中…</div></div></div>`;
if (d.needsLogin) {
// session 過期/這台機器還沒換過 session。不是錯誤,是「還差一步」。
return `<div class="appview">${head(d.icon, d.name, d.version)}
<div class="card">
<h3>請先登入這個知識庫</h3>
<div class="d">要打開 App 的畫面、或執行它的動作,需要你在這個知識庫的帳號登入一次
(之後這台電腦會記住一段時間,同步不受影響)。</div>
<div class="field"><div class="lb">帳號</div>
<input type="text" id="apEmail" value="${esc(d.email || '')}" disabled/></div>
<div class="field"><div class="lb">密碼</div><input type="password" id="apPw"/></div>
<div class="err" id="apErr" style="display:none"></div>
<div class="acts"><button class="primary" id="apLogin">登入</button></div>
</div></div>`;
}
if (d.error) {
return `<div class="appview">${head(d.icon, d.name, d.version)}
<div class="card">
<h3>打不開這個 App</h3>
<div class="d">${esc(d.error)}</div>
<div class="acts"><button id="apReload">再試一次</button></div>
</div></div>`;
}
if (d.hasUi && d.uiHtml) {
// 自帶畫面 ⇒ 掛進 sandbox iframemountAppUI 會在 wire() 之後填內容)。
return `<div class="appview">${head(d.icon, d.name, d.version)}
<iframe class="appframe" id="appFrame" sandbox="allow-scripts"></iframe></div>`;
}
// 沒有自帶畫面 ⇒ 列出工作流,一條一顆「現在執行」
//(與 Portal 的系統預設畫面同一套,不另立第二種呈現)。
const wfs = d.workflows || [];
if (!wfs.length) {
return `<div class="appview">${head(d.icon, d.name, d.version)}
<div class="card"><h3>這個 App 沒有可以按的東西</h3>
<div class="d">它既沒有自己的畫面,也沒有登記任何工作流。</div></div></div>`;
}
return `<div class="appview">${head(d.icon, d.name, d.version)}
${wfs.map((w) => `
<div class="wfitem" data-wf="${esc(w.name)}">
<div class="top">
<span class="nm">${esc(w.name)}</span>
<button data-apprun="${esc(w.name)}">現在執行</button>
</div>
${w.description ? `<div class="d">${esc(w.description)}</div>` : ''}
<div class="out"></div>
</div>`).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 = `
<script>
(function () {
var seq = 0, pending = {};
window.addEventListener('message', function (e) {
var m = e.data;
if (!m || m.__arcrun !== 'result') return;
var p = pending[m.id]; if (!p) return; delete pending[m.id];
p(m.payload);
});
window.arcrunApp = {
action: function (action, payload) {
return new Promise(function (resolve) {
var id = ++seq; pending[id] = resolve;
parent.postMessage({ __arcrun: 'action', id: id, action: action, payload: payload || {} }, '*');
});
}
};
})();
<\/script>`;
// 讓 App 的畫面跟本體同一套底色/字體(它是 Arcrun 的一部分,不是外站)。
const skin = `<style>
:root{color-scheme:${document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'}}
html,body{margin:0;padding:16px;background:transparent;
color:${getComputedStyle(document.documentElement).getPropertyValue('--ink').trim() || '#17181A'};
font-family:-apple-system,"IBM Plex Sans","PingFang TC","Noto Sans TC","Microsoft JhengHei",system-ui,sans-serif;
font-size:15px}
</style>`;
f.srcdoc = '<!doctype html><meta charset="utf-8">' + skin + bridge + d.uiHtml;
f.onload = () => {
if (appFrameBridge) window.removeEventListener('message', appFrameBridge);
appFrameBridge = async (ev) => {
if (!f.contentWindow || ev.source !== f.contentWindow) return; // 只認自己這個 iframe
const m = ev.data;
if (!m || m.__arcrun !== 'action') return;
const reply = (payload) =>
f.contentWindow && f.contentWindow.postMessage({ __arcrun: 'result', id: m.id, payload }, '*');
try {
const raw = await go.RunAppAction(accIdx, d.id, String(m.action || ''), JSON.stringify(m.payload || {}));
let parsed = null;
try { parsed = JSON.parse(raw); } catch (e) { parsed = { ok: true, result: raw }; }
reply({ ok: true, status: 200, d: parsed });
} catch (ex) {
reply({ ok: false, status: 0, d: { error: String(ex) } });
}
};
window.addEventListener('message', appFrameBridge);
};
}
function renderPage() {
if (!state) return;
let html;
if (page === 'apps') html = pageApps(state);
else if (page.startsWith('app:')) {
const p = page.split(':');
html = pageApp(Number(p[1]), p.slice(2).join(':'));
}
else 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);
});
// #44:資料夾結構。換頁/重畫之後把本來就展開著的那幾棵補回去——
// 不補的話使用者每次切回這一頁都得重按一次(狀態在 treeState,畫面卻是空的)。
document.querySelectorAll('[data-tree]').forEach((b) => {
b.onclick = () => toggleFolderTree(b.dataset.tree);
if (treeState.open[b.dataset.tree]) renderFolderTree(b.dataset.tree);
});
// ── App 啟動器(arcrun-rag#137)──
on('apConnect', showConnect);
on('apRefresh', () => loadApps(appsAccIdx, true));
on('apRetry', () => loadApps(appsAccIdx, true));
on('apAdd', showHowToInstallApp);
on('apReload', () => { const p = page.split(':'); loadAppDetail(Number(p[1]), p.slice(2).join(':')); });
on('apLogin', appLogin);
document.querySelectorAll('[data-appacc]').forEach((b) => {
b.onclick = () => { appsAccIdx = Number(b.dataset.appacc); renderPage(); loadApps(appsAccIdx); };
});
document.querySelectorAll('[data-appopen]').forEach((b) => {
b.onclick = () => goToApp(appsAccIdx, b.dataset.appopen);
});
document.querySelectorAll('[data-appback]').forEach((b) => {
b.onclick = () => { page = 'apps'; appDetail = null; appDetailKey = ''; renderNav(); renderPage(); };
});
document.querySelectorAll('[data-apprun]').forEach((b) => { b.onclick = () => runAppAction(b); });
// App 自帶畫面:DOM 換好之後才掛 iframe(srcdoc 要等元素真的在文件裡)
if (page.startsWith('app:') && appDetail && appDetail.hasUi && appDetail.uiHtml) {
mountAppUI(Number(page.split(':')[1]), appDetail);
}
}
// goToApp 換到某個 App 的頁。換頁前先把上一個 App 的 postMessage 監聽器拆掉——
// 不拆的話每開一次 App 就多留一個死監聽器(而且它還綁著舊的 accIdx/appId)。
function goToApp(accIdx, id) {
if (appFrameBridge) { window.removeEventListener('message', appFrameBridge); appFrameBridge = null; }
page = 'app:' + accIdx + ':' + id;
renderNav();
loadAppDetail(accIdx, id);
}
// 「加裝 App」:桌面端**不假裝自己能安裝**——安裝是實例上的動作
// (跟 AI 說一句話,或 acr 推一份宣告)。這裡只把「東西從哪來」講清楚,
// 順便給一個開知識庫網頁的出口。
function showHowToInstallApp() {
const acc = (state.accounts || [])[appsAccIdx];
const portal = acc ? 'https://' + acc.host.replace('arcrun-cypher-executor.', 'arcrun-rag-ui.') + '/portal/' : '';
openSheet(`
<h2>怎麼加裝 App</h2>
<p>App 是裝在<b>知識庫</b>上的,不是裝在這台電腦上——所以你在任何一台電腦、
或在知識庫網頁上,看到的都是同一批 App。</p>
<p>兩種裝法:跟你的 AI 說「幫我裝一個 ⋯⋯」,或用 <b>acr</b> 把一份 App 宣告推上去。
裝好之後回到這裡按「重新整理」就會出現。</p>
<div class="acts">
<button id="c1">知道了</button>
${portal ? `<button class="primary" id="c2">開啟知識庫網頁</button>` : ''}
</div>`,
() => {
$('c1').onclick = closeSheet;
if ($('c2')) $('c2').onclick = () => { go.OpenURL(portal); closeSheet(); };
});
}
async function appLogin() {
const accIdx = Number(page.split(':')[1]);
const id = page.split(':').slice(2).join(':');
const err = $('apErr');
const btn = $('apLogin');
if (btn) btn.disabled = true;
try {
await go.PortalLogin(accIdx, $('apPw').value);
if (btn) btn.disabled = false;
loadAppDetail(accIdx, id);
} catch (ex) {
if (btn) btn.disabled = false;
if (err) { err.textContent = String(ex); err.style.display = 'block'; }
}
}
// runAppAction:沒有自帶畫面的 App,那顆「現在執行」。
// 🔴 白名單是**實例**裁決的(K6)——這裡不認得任何動作名稱,只負責把按鈕送出去、
// 把實例回的話原樣顯示。失敗就說失敗,不改寫成「可能成功」。
async function runAppAction(btn) {
const accIdx = Number(page.split(':')[1]);
const id = page.split(':').slice(2).join(':');
const item = btn.closest('.wfitem');
const out = item && item.querySelector('.out');
btn.disabled = true;
if (out) { out.className = 'out'; out.textContent = '執行中…'; }
try {
const raw = await go.RunAppAction(accIdx, id, btn.dataset.apprun, '{}');
if (out) out.textContent = '完成:' + String(raw).slice(0, 600);
} catch (ex) {
if (out) { out.className = 'out bad'; out.textContent = '失敗:' + String(ex); }
}
btn.disabled = false;
}
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]));
// arcrun-rag#137:還沒連上任何知識庫時,第一眼要落在連線精靈(首頁),
// 不是一個註定空的 App 啟動器。連上之後(accounts 從 0 變成 1)也不要硬把
// 使用者拉走——他當下正在看剛連好的東西。
if (first && page === 'apps' && !(s.accounts || []).length) page = 'home';
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(); // 首頁的狀態時間軸要跟著跳
// 🔴 這是**唯一**一次自動去問實例:第一次拿到 state(=知道有哪些知識庫)之後。
// 之後只有使用者按重新整理/切知識庫才會再問一次——**不掛在每秒的 tick 上**。
if (first && page === 'apps' && (s.accounts || []).length) loadApps(appsAccIdx);
}
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#46leo 2026-08-16 實撞):「我去把 Logseq plugin 刪掉以後,
// **採集的 wiki 沒消失**。」舊文案寫的是「已經上傳的知識卡不會被刪除」——
// 那句話**在技術上是對的**,但它預設使用者要的是「只停止同步」,
// 而他要的是「我不要這份資料了」。⇒ 病不在少一句說明,在**替他決定了**。
// 現在兩個選擇都擺出來、後果各寫一行,由他挑。
// 🔴 arcrun-rag#138leo 2026-08-24):「碎型會在每個資料夾安裝隱藏資料夾,人工刪除不容易,
// 所以當它斷連,應該要可以幫它把 Arcrun RAG 建立的資料夾刪掉」
// ⇒ 多一個**獨立的勾選框**,不是第三顆單選:雲端怎麼處理、硬碟怎麼處理是兩件事,
// 合成一個選項就又是替他決定(#46 修掉的正是那個病)。
// 🔴 預設不勾——刪檔不可逆,預設值往「什麼都不動」倒。
// 🔴 勾了才去問清單,並且把**每一筆路徑攤出來**:#138 的驗收條件白紙黑字寫著
// 「使用者要能在動手前看到將要刪掉哪些東西」,按下去就無聲刪光不算做完。
function confirmRemove(accIdx, path) {
openSheet(`
<h2>移除這個資料夾?</h2>
<p>「${esc(path)}」要怎麼處理?<b>你自己的檔案不會被動到</b>——下面兩個選擇差在雲端,最後那個勾選框差在你的硬碟。</p>
<label class="radio"><input type="radio" name="rmMode" value="takedown" checked/>
<span><b>連同雲端的知識一起收回</b><br/>
<span class="d">這個資料夾整理出來的知識會從知識庫刪除,之後搜尋找不到、AI 也不會再拿它回答。<b>刪掉就要不回來</b>。</span></span></label>
<label class="radio"><input type="radio" name="rmMode" value="unwatch"/>
<span><b>只停止同步,雲端的知識保留</b><br/>
<span class="d">以後這個資料夾有變動不會再上傳,但之前整理好的知識留在知識庫裡,搜尋和 AI 照樣找得到。</span></span></label>
<label class="radio"><input type="checkbox" id="rmClean"/>
<span><b>順便把 Arcrun RAG 放在這個資料夾裡的檔案清掉</b><br/>
<span class="d">我們會在每一層資料夾放一個隱藏的整理稿目錄(<code>.wiki</code><code>.arcrun-rag</code>),
散在各層、你自己很難刪乾淨。勾起來會先列出<b>確切要刪哪些</b>給你看過再動手;認不出是我們建的一律留著。</span></span></label>
<div id="rmPlan" class="d" style="display:none;margin:8px 0 4px"></div>
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">確定</button></div>`,
() => {
$('c1').onclick = closeSheet;
const box = $('rmClean'), out = $('rmPlan');
box.onchange = async () => {
if (!box.checked) { out.style.display = 'none'; out.innerHTML = ''; return; }
out.style.display = ''; out.textContent = '正在看這個資料夾裡有哪些是我們建的…';
try {
out.innerHTML = renderCleanupPlan(await go.PlanFolderCleanup(accIdx, path));
} catch (e) {
out.textContent = '看不到清單(' + e + ')——沒把握就先別勾這一項。';
}
};
$('c2').onclick = async () => {
const mode = document.querySelector('input[name="rmMode"]:checked');
const takedown = !mode || mode.value === 'takedown';
await go.RemoveFolder(accIdx, path, takedown, box.checked); closeSheet();
state = await go.GetState(); renderNav(); renderPage();
};
});
}
// renderCleanupPlan 把「將要刪掉什麼/刻意留下什麼」攤成使用者看得懂的清單。
// 🔴 留下的那一半一樣要顯示:沉默地留下殘渣,跟沉默地刪掉一樣糟——他要的是
// 「這個資料夾乾淨了」,那就得讓他看得到還有什麼沒清、為什麼沒清。
function renderCleanupPlan(plan) {
const rm = (plan && plan.remove) || [], keep = (plan && plan.keep) || [];
if (!rm.length && !keep.length) return '這個資料夾裡沒有找到任何 Arcrun RAG 建立的檔案,不需要清理。';
let h = '';
if (rm.length) {
h += `<b>會刪掉這 ${rm.length} 項(共 ${plan.files} 個檔):</b><ul style="margin:4px 0 0 16px">`;
for (const it of rm) h += `<li>${esc(it.rel)}${it.is_dir ? '' : ''}${it.files} 個檔)</li>`;
h += '</ul>';
}
if (keep.length) {
h += `<b style="display:block;margin-top:8px">這 ${keep.length} 項我不會動:</b><ul style="margin:4px 0 0 16px">`;
for (const k of keep) h += `<li>${esc(k.rel)}${esc(k.reason)}</li>`;
h += '</ul>';
}
return h;
}
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);