sync: collector/ 同步自 inkstone/arcrun-rag@45b74a4(桌面小幫手 0.18.36)

This commit is contained in:
2026-08-24 18:26:55 +08:00
parent f1658fd2ad
commit d028a66625
19 changed files with 3278 additions and 29 deletions
+435 -5
View File
@@ -27,10 +27,28 @@ $('themeBtn').onclick = () =>
applyTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
let state = null;
let page = 'home'; // 'home' | 'ai' | 'update' | 'lib:<idx>'
// 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'); }
@@ -50,7 +68,21 @@ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheet
// 之後若那個提案 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) => `
@@ -64,7 +96,17 @@ function renderNav() {
<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(); };
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);
};
});
}
@@ -411,10 +453,260 @@ function onboarding() {
</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.startsWith('lib:')) html = pageLib(state, Number(page.slice(4)));
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);
@@ -447,12 +739,105 @@ function wire() {
document.querySelectorAll('[data-rm]').forEach((b) => {
b.onclick = () => confirmRemove(Number(b.dataset.acc), b.dataset.rm);
});
// ── 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;
@@ -460,6 +845,9 @@ function render(s) {
$('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) {} }
@@ -481,28 +869,70 @@ async function addFolder(accIdx) {
// 那句話**在技術上是對的**,但它預設使用者要的是「只停止同步」,
// 而他要的是「我不要這份資料了」。⇒ 病不在少一句說明,在**替他決定了**。
// 現在兩個選擇都擺出來、後果各寫一行,由他挑。
// 🔴 arcrun-rag#138leo 2026-08-24):「碎型會在每個資料夾安裝隱藏資料夾,人工刪除不容易,
// 所以當它斷連,應該要可以幫它把 Arcrun RAG 建立的資料夾刪掉」
// ⇒ 多一個**獨立的勾選框**,不是第三顆單選:雲端怎麼處理、硬碟怎麼處理是兩件事,
// 合成一個選項就又是替他決定(#46 修掉的正是那個病)。
// 🔴 預設不勾——刪檔不可逆,預設值往「什麼都不動」倒。
// 🔴 勾了才去問清單,並且把**每一筆路徑攤出來**:#138 的驗收條件白紙黑字寫著
// 「使用者要能在動手前看到將要刪掉哪些東西」,按下去就無聲刪光不算做完。
function confirmRemove(accIdx, path) {
openSheet(`
<h2>移除這個資料夾?</h2>
<p>「${esc(path)}」要怎麼處理?<b>你電腦裡的原始檔案不會被動到</b>,兩個選擇的差別只在雲端。</p>
<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); closeSheet();
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>
+108
View File
@@ -304,3 +304,111 @@ input[type=text], input[type=password] {
width: 7px; height: 7px; border-radius: 50%; flex: none;
background: var(--err); margin-left: 4px;
}
/* ══════════════════════════════════════════════════════════════════════════
App 啟動器(arcrun-rag#137
leo 2026-08-24:「所有的 App 需要有一個類似 Android/iOS 的**九宮格啟動界面**
每個 App 有一個 icon,這會運行在 portal 及 daemon。」
🔴 尺寸/圓角/間距逐項照 leo 已核准的設計稿(Launcher-Desktop.dc.html):
磁磚 88x88、圓角 22、格線 gap 32/24、hover 上浮 2px、標籤 14px 置中。
🔴 顏色**一個 hex 都不准出現**——全部走 arcrun-cis.css 的變數(本檔開頭那條規矩)。
══════════════════════════════════════════════════════════════════════════ */
/* 頁首:知識庫名 + 幾個 App + 重新整理(同 .libhead 的結構,不另發明一種頁首) */
.apphead {
display: flex; align-items: center; gap: 14px;
padding-bottom: 18px; margin-bottom: 26px;
border-bottom: 2px solid rgba(var(--amber-rgb), .35); /* 設計稿的 accentSoft 分隔線 */
}
.apphead .g { flex: 1; min-width: 0; }
.apphead .t { font-size: 22px; font-weight: 600; letter-spacing: .04em; }
.apphead .s { margin-top: 5px; font-size: 13.5px; color: rgba(var(--ink-rgb), .55); }
/* 多個知識庫時才出現的切換器(只有一個庫時畫面上不該有這排噪音) */
.appswitch { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
/* 圓角用 10px(同 button),刻意**不用**膠囊——check-cis.sh 明文擋膠囊選中態
(leo 08-04 否決過),而且這排切換器與按鈕本來就該是同一種形狀語彙。 */
.appswitch .chip {
padding: 6px 13px; font-size: 13px; border-radius: 10px;
border: 1px solid rgba(var(--ink-rgb), .18);
color: rgba(var(--ink-rgb), .6); cursor: pointer; user-select: none;
}
.appswitch .chip.on {
border-color: var(--amber); color: var(--amber);
background: rgba(var(--amber-rgb), .08); font-weight: 600;
}
/* 九宮格本體。auto-fill 而不是固定 4 欄:桌面視窗是可以被拉窄的(min 820px),
固定欄數會在窄視窗把磁磚擠變形。 */
.appgrid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
gap: 32px 24px;
}
.appcell { display: flex; flex-direction: column; align-items: center; gap: 11px; }
.apptile {
width: 88px; height: 88px; border-radius: 22px;
display: flex; align-items: center; justify-content: center;
font-size: 38px; line-height: 1;
background: var(--well); border: 1px solid rgba(var(--ink-rgb), .1);
cursor: pointer; user-select: none;
transition: border-color .12s, background .12s, transform .12s;
}
.apptile:hover {
transform: translateY(-2px);
border-color: var(--amber);
background: rgba(var(--amber-rgb), .08);
}
/* 「加裝 App」——設計稿是虛線框+加號,語意是「這一格還沒有東西」 */
.apptile.add {
background: none; border: 1.5px dashed rgba(var(--ink-rgb), .22);
font-size: 28px; color: rgba(var(--ink-rgb), .45);
}
.appcell .nm {
font-size: 14px; text-align: center; line-height: 1.4;
max-width: 116px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.appcell .nm.dim { color: rgba(var(--ink-rgb), .55); }
/* App 頁 */
.appview { display: flex; flex-direction: column; }
.appview .head {
display: flex; align-items: center; gap: 12px;
padding-bottom: 14px; margin-bottom: 16px;
border-bottom: 1px solid rgba(var(--ink-rgb), .1);
}
.appview .head .ico { font-size: 24px; line-height: 1; }
.appview .head .nm { font-size: 17px; font-weight: 600; }
.appview .head .vr {
font-size: 12px; color: rgba(var(--ink-rgb), .4);
font-family: ui-monospace, Menlo, monospace;
}
.appview .head .sp { flex: 1; }
/* App 自帶畫面:**關在 sandbox iframe 裡**(見 main.js mountAppUI 的理由)。
高度吃滿剩下的版面,讓 App 自己決定要怎麼排。 */
/* 高度用 calc 而不是 flex:1height:100%#page 是 flex item 自帶 padding
百分比高度在那裡會多算出一段 padding 而長出一條假捲軸。
250px=頁首(約 90) + main padding(48) + App 頁首(約 60) + 呼吸。 */
.appframe {
width: 100%; height: calc(100vh - 250px); min-height: 420px;
border: 1px solid rgba(var(--ink-rgb), .1); border-radius: 12px;
background: var(--paper-a);
}
/* 沒有自帶畫面的 App=列出它的工作流,一條一顆「現在執行」
(與 Portal 的系統預設畫面同一套呈現,不另立第二種) */
.wfitem {
background: var(--well); border-radius: 12px;
padding: 15px 18px; margin-bottom: 10px;
}
.wfitem .top { display: flex; align-items: baseline; gap: 12px; }
.wfitem .nm { font-size: 15px; font-weight: 600; flex: 1; min-width: 0; }
.wfitem .d { margin-top: 7px; font-size: 13.5px; color: rgba(var(--ink-rgb), .55); line-height: 1.7; }
.wfitem .out {
margin-top: 8px; font-size: 12.5px; line-height: 1.6;
font-family: ui-monospace, Menlo, monospace;
color: rgba(var(--ink-rgb), .55);
word-break: break-all; white-space: pre-wrap;
}
.wfitem .out.bad { color: var(--err); }