diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go
index 8406d6a..f25c657 100644
--- a/cmd/arcrun-app/app.go
+++ b/cmd/arcrun-app/app.go
@@ -138,6 +138,50 @@ type UIState struct {
Engine string `json:"engine"` // "workers-ai" | "gemma"
GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值
ExtractedOK int `json:"extractedOK"` // 首頁「已整理幾份」
+ Steps []Step `json:"steps"` // 首頁狀態時間軸(leo #6)
+}
+
+// Step 是首頁狀態時間軸的一格。
+// 🔴 leo 08-04:「首頁要顯示的應該是 status,如果**看守、發現變化、萃取、上傳…
+// 不同 status 在哪裡顯示**?」
+// ⇒ 把一輪同步拆成四步,讓使用者看得到「現在走到哪」,而不是只有一句「看守中」。
+type Step struct {
+ Title string `json:"title"`
+ Meta string `json:"meta,omitempty"`
+ State string `json:"state"` // "done"(已完成)|"now"(進行中)|""(還沒輪到)
+}
+
+// buildSteps 依 status.json 與訊號檔推出四步的狀態。
+// 誠實邊界:collector 目前回報的是「整輪」而非逐檔階段,所以同步中時
+// 「發現變化→萃取→上傳」一起標進行中;不假裝有更細的進度。
+func buildSteps(s syncStatus, syncing bool) []Step {
+ watch := Step{Title: "看守資料夾", Meta: "有變動就自動開始", State: "done"}
+ if syncing {
+ return []Step{
+ watch,
+ {Title: "發現變化", State: "done"},
+ {Title: "用 AI 整理成知識卡", Meta: "進行中", State: "now"},
+ {Title: "上傳到你的知識庫", State: ""},
+ }
+ }
+ done := ""
+ if s.ExtractedOK > 0 || s.LastSync != "" {
+ done = "done"
+ }
+ meta := ""
+ if s.ExtractedOK > 0 {
+ meta = fmt.Sprintf("上一輪 %d 份", s.ExtractedOK)
+ }
+ up := Step{Title: "上傳到你的知識庫", Meta: meta, State: done}
+ if s.ExtractFailed > 0 {
+ up.Meta = fmt.Sprintf("%s · ⚠ %d 份失敗", meta, s.ExtractFailed)
+ }
+ return []Step{
+ watch,
+ {Title: "發現變化", Meta: "等待中", State: done},
+ {Title: "用 AI 整理成知識卡", State: done},
+ up,
+ }
}
// GetState 是前端每秒拉一次的單一入口。
@@ -164,6 +208,7 @@ func (a *App) GetState() UIState {
sync := loadSyncStatus()
st.ExtractedOK = sync.ExtractedOK
st.Syncing, st.StatusBig, st.StatusSub = describeStatus(sync)
+ st.Steps = buildSteps(sync, st.Syncing)
return st
}
diff --git a/cmd/arcrun-app/frontend/index.html b/cmd/arcrun-app/frontend/index.html
index 4661a74..5176651 100644
--- a/cmd/arcrun-app/frontend/index.html
+++ b/cmd/arcrun-app/frontend/index.html
@@ -19,12 +19,8 @@
-
+
+
+
diff --git a/cmd/arcrun-app/frontend/src/main.js b/cmd/arcrun-app/frontend/src/main.js
index a17af3e..219cd2a 100644
--- a/cmd/arcrun-app/frontend/src/main.js
+++ b/cmd/arcrun-app/frontend/src/main.js
@@ -1,8 +1,11 @@
// Arcrun 桌面 App — 前端(t193)
//
-// 版面照 leo 08-04 指定(附 Google Drive 截圖):
-// 「可以完全模仿 gdrive,**左方側邊欄,在右方替換頁面**」
-// ⇒ 設定不再開 popup,全部是右側的頁;只有「確認刪除」才用覆蓋層。
+// 版面(leo 08-04 指定,附 Google Drive 截圖):左側邊欄、右側換頁。
+// 🔴 08-04 二輪回饋(本次處理):
+// ③「開啟知識庫網頁」不該在首頁(會開到錯的庫)⇒ 移到**各庫頁的上方**
+// ④ 一個庫 30 個資料夾放不下 ⇒ **每個知識庫一個獨立分頁**
+// ⑤「加入資料夾」會加到哪個帳號?⇒ 在庫頁裡加,**作用對象就是那個庫**,不會加錯
+// ⑥ 首頁要顯示 status:看守/發現變化/萃取/上傳 ⇒ **狀態時間軸**
import './arcrun-cis.css'; // 共用底層(色票/字體/紋理)——唯一真相源在 arcrun-cis/css/
import './style.css'; // 本 App 的版面
@@ -11,73 +14,96 @@ const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
-// 跟隨系統深淺色(CIS 兩套色票都在 arcrun-cis.css 定義好了)
-const mq = window.matchMedia('(prefers-color-scheme: dark)');
-const applyTheme = () =>
- document.documentElement.setAttribute('data-theme', mq.matches ? 'dark' : 'light');
-mq.addEventListener('change', applyTheme);
-applyTheme();
+// 主題:**與 portal 同一套規則**——預設淺色,使用者切換後存 localStorage
+// (leo 問「它有淺色佈景?」⇒ 是,portal 預設就是淺色)
+const THEME_KEY = 'arcrun_app_theme';
+function applyTheme(t) {
+ document.documentElement.setAttribute('data-theme', t);
+ try { localStorage.setItem(THEME_KEY, t); } catch (e) {}
+ const b = $('themeBtn'); if (b) b.textContent = t === 'dark' ? '☀' : '☾';
+}
+applyTheme((() => { try { return localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; } })());
+$('themeBtn').onclick = () =>
+ applyTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
let state = null;
-let page = 'home';
-let updateInfo = null; // { current, latest, available, notes, staged }
+let page = 'home'; // 'home' | 'ai' | 'update' | 'lib:'
+let updateInfo = null;
// ── 覆蓋層(只給必須打斷的確認)──
-function openSheet(html, wire) {
- $('sheet').innerHTML = html; $('overlay').classList.add('on'); if (wire) wire();
-}
+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(); });
-// ── 側邊欄換頁 ──
-document.querySelectorAll('#side .nav').forEach((el) => {
- el.onclick = () => {
- page = el.dataset.page;
- document.querySelectorAll('#side .nav').forEach((n) => n.classList.toggle('on', n === el));
- renderPage();
- };
-});
+// ── 側邊欄:每個知識庫一項(leo:「每個帳號有獨立的一個頁面」)──
+function renderNav() {
+ const accs = (state && state.accounts) || [];
+ $('nav').innerHTML = `
+ ◫首頁
+ ${accs.length ? `知識庫
` : ''}
+ ${accs.map((a, i) => `
+
+ ▤${esc(a.name)}
+ ${(a.folders || []).length}
+
`).join('')}
+ 設定
+ ✧AI 設定
+ ↧版本與更新
`;
+ $('nav').querySelectorAll('.nav').forEach((el) => {
+ el.classList.toggle('on', el.dataset.p === page);
+ el.onclick = () => { page = el.dataset.p; renderNav(); renderPage(); };
+ });
+}
-// ── 各頁 ──
+// ── 首頁:狀態時間軸(leo:「看守、發現變化、萃取、上傳… 不同 status 在哪裡顯示?」)──
function pageHome(s) {
- const folders = (s.accounts || []).reduce((n, a) => n + (a.folders || []).length, 0);
if (!s.accounts || !s.accounts.length) return onboarding();
+ const st = s.steps || [];
return `
-
目前狀況
-
-
-
-
${s.extractedOK || 0}
份已整理
+
現在的狀態
+
+ ${st.map((x) => `
+
+
+ ${esc(x.title)}
+ ${esc(x.meta || '')}
+
`).join('')}
-
快速動作
-
-
-
-
+
總計
+
+
+
${s.accounts.reduce((n,a)=>n+(a.folders||[]).length,0)}
個資料夾在看守
+
${s.extractedOK || 0}
份已整理
`;
}
-function pageFolders(s) {
- if (!s.accounts || !s.accounts.length) return onboarding();
- return (s.accounts).map((a) => `
-
${esc(a.name)}${esc(a.host)}
+// ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)──
+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)}
+
+
+
+
${(a.folders || []).map((f) => `
${esc(f.path)}
自動同步中
`).join('')
- || `
還沒有資料夾
`}
- `).join('') + `
-
-
-
-
`;
+ || `
這個知識庫還沒有資料夾
+
按右上的「加入資料夾」,選一個要自動整理的資料夾。
`}`;
}
function pageAI(s) {
@@ -100,11 +126,9 @@ function pageAI(s) {
`;
}
-// leo:「檢查更新連到 docs 的檢查更新,不正確,**是直接在這裡進行檢查更新**,
-// 應該是跳到一個頁面顯示**現在版本與最新版本**,然後**下載可以進行安裝**」
function pageUpdate(s) {
const u = updateInfo;
- const latest = u ? (u.latest || '查詢中…') : '查詢中…';
+ const latest = u ? (u.latest || '查詢中…') : '按「檢查更新」查詢';
let action = `
`;
let note = '';
if (u && u.staged) {
@@ -149,32 +173,30 @@ function onboarding() {
`;
}
-// ── 渲染 ──
function renderPage() {
if (!state) return;
- const el = $('page');
- el.innerHTML = page === 'folders' ? pageFolders(state)
- : page === 'ai' ? pageAI(state)
- : page === 'update' ? pageUpdate(state)
- : pageHome(state);
+ let html;
+ if (page.startsWith('lib:')) html = pageLib(state, Number(page.slice(4)));
+ else if (page === 'ai') html = pageAI(state);
+ else if (page === 'update') html = pageUpdate(state);
+ else html = pageHome(state);
+ // 每個庫頁底下都給「新增知識庫帳號」入口
+ if (page === 'home' && state.accounts && state.accounts.length) {
+ html += ``;
+ }
+ $('page').innerHTML = html;
wire();
}
function wire() {
const on = (id, fn) => { const e = $(id); if (e) e.onclick = fn; };
- on('qAdd', addFolder); on('fAdd', addFolder);
- on('qDocs', () => go.OpenURL('https://rag.arcrun.dev/docs/'));
- on('uDocs', () => go.OpenURL('https://rag.arcrun.dev/docs/use/update/'));
- on('qPortal', () => {
- const a = state.accounts && state.accounts[0];
- if (a) go.OpenURL('https://' + a.host.replace('arcrun-cypher-executor.', 'arcrun-rag-ui.') + '/portal/');
- });
- on('fAcct', showConnect); on('obConnect', showConnect);
+ on('uDocs', () => go.OpenURL('https://rag.arcrun.dev/docs/'));
+ on('hAcct', showConnect); on('obConnect', showConnect);
on('obInstall', () => go.OpenURL('https://install.arcrun.dev/'));
on('aiSave', saveAI);
- on('uCheck', checkUpdate);
- on('uDownload', downloadUpdate);
- on('uApply', applyUpdate);
+ on('uCheck', checkUpdate); on('uDownload', downloadUpdate); on('uApply', applyUpdate);
+ document.querySelectorAll('[data-portal]').forEach((b) => { b.onclick = () => go.OpenURL(b.dataset.portal); });
+ 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);
});
@@ -182,28 +204,27 @@ function wire() {
function render(s) {
const first = !state;
+ const navChanged = state && JSON.stringify((state.accounts||[]).map(a=>[a.name,(a.folders||[]).length]))
+ !== JSON.stringify((s.accounts||[]).map(a=>[a.name,(a.folders||[]).length]));
state = s;
- $('ver').textContent = s.version ? s.version : '';
+ $('ver').textContent = s.version || '';
$('statusBig').textContent = s.statusBig;
$('statusBig').classList.toggle('syncing', !!s.syncing);
$('statusSub').textContent = s.statusSub;
- // 只有狀態列每秒更新;頁面內容不重繪,避免打斷輸入
- if (first) renderPage();
+ if (first || navChanged) { renderNav(); renderPage(); }
+ else if (page === 'home') renderPage(); // 首頁的狀態時間軸要跟著跳
}
-async function tick() {
- try { render(await go.GetState()); } catch (e) { /* 後端還沒起來 */ }
-}
+async function tick() { try { render(await go.GetState()); } catch (e) {} }
// ── 動作 ──
$('btnSync').onclick = async () => { await go.SyncNow(); tick(); };
-async function addFolder() {
- if (!state || !state.accounts.length) { showConnect(); return; }
+async function addFolder(accIdx) {
const p = await go.PickFolder();
if (!p) return;
- await go.AddFolder(0, p);
- state = await go.GetState(); renderPage();
+ await go.AddFolder(accIdx, p);
+ state = await go.GetState(); renderNav(); renderPage();
}
function confirmRemove(accIdx, path) {
@@ -215,7 +236,7 @@ function confirmRemove(accIdx, path) {
$('c1').onclick = closeSheet;
$('c2').onclick = async () => {
await go.RemoveFolder(accIdx, path); closeSheet();
- state = await go.GetState(); renderPage();
+ state = await go.GetState(); renderNav(); renderPage();
};
});
}
@@ -236,7 +257,7 @@ function showConnect() {
$('c2').onclick = async () => {
try {
await go.Connect($('u').value.trim(), $('e').value.trim(), $('p').value);
- closeSheet(); state = await go.GetState(); renderPage();
+ closeSheet(); state = await go.GetState(); renderNav(); renderPage();
} catch (ex) { $('err').textContent = String(ex); $('err').style.display = 'block'; }
};
});
@@ -250,7 +271,6 @@ async function saveAI() {
} catch (ex) { $('aiErr').textContent = String(ex); $('aiErr').style.display = 'block'; }
}
-// 更新:**在 App 裡完成**(leo 指定),不是丟去 docs
async function checkUpdate() {
updateInfo = { latest: '查詢中…' }; renderPage();
try { updateInfo = await go.CheckUpdate(); } catch (ex) { updateInfo = { err: String(ex) }; }
diff --git a/cmd/arcrun-app/frontend/src/style.css b/cmd/arcrun-app/frontend/src/style.css
index 8a5f5b4..2e83c3b 100644
--- a/cmd/arcrun-app/frontend/src/style.css
+++ b/cmd/arcrun-app/frontend/src/style.css
@@ -21,14 +21,21 @@ html, body { overflow: hidden; height: 100%; }
padding: 22px 0 14px;
border-right: 1px solid rgba(var(--ink-rgb), .1);
}
-/* 🔴 lockup 底色(實測發現):`arcrun-lockup-h-paper-on-ink.png` **自帶不透明 Ink 底板**
- (左上像素 RGBA=23,24,26,255),它的設計前提是「貼在純 Ink 表面上」。
- 我們的背景有紙張紋理 ⇒ 直接放會看到一個突兀的方塊(leo 截圖裡那個白/黑框)。
- ⇒ 深色模式下把品牌區做成純 Ink 表面,與該圖的底板無縫接合。
- 淺色模式用 `-ink.png`(左上像素 alpha=0,透明)⇒ 不需要處理。 */
-#side .brand { padding: 14px 22px 18px; }
-#side .brand img { height: 22px; display: block; }
-:root[data-theme="dark"] #side .brand { background: var(--ink); }
+/* 🔴 lockup(CIS 硬要求,leo 兩次點名):
+ ① 尺寸照 portal 側欄同一條:clamp(22px,5vw,28px) + **width:auto**
+ (只寫 height 不寫 width,容器一壓就變形/露出底板邊緣=leo 看到的白邊)
+ ② `-paper-on-ink.png` **自帶不透明 Ink 底板**(實測左上像素 RGBA=23,24,26,255),
+ 設計前提是貼在**純 Ink 表面** ⇒ 深色模式把品牌區做成純 Ink,並留 clear space
+ (CIS:clear space = one chevron height on all four sides)。
+ ③ 淺色的 `-ink.png` 是透明的,不需要底板。 */
+#side .brand { padding: 16px 22px 18px; }
+#side .brand img {
+ height: clamp(22px, 5vw, 28px); width: auto; max-width: 100%; display: block;
+}
+:root[data-theme="dark"] #side .brand {
+ background: var(--ink);
+ padding: 16px 22px 18px; /* 上下左右都留白=clear space,底板不會貼邊 */
+}
#side .brand .wm-paper { display: none; }
:root[data-theme="dark"] #side .brand .wm-ink { display: none; }
:root[data-theme="dark"] #side .brand .wm-paper { display: block; }
@@ -67,19 +74,32 @@ header {
main { flex: 1; overflow-y: auto; padding: 20px 28px 28px; }
-/* ── 元件 ── */
+/* ── 元件:規格**逐字對齊 portal**(leo:「網頁的按鈕比較精緻,跟現有的差異較大」)──
+ portal: .btn { padding:13px; font-size:16px; font-weight:600; border-radius:10px; border:none; background:var(--amber) }
+ .btn3 { padding:11px 16px; font-size:15px; border-radius:10px; border:1px solid rgba(ink,.25); background:none }
+ 這裡只把 padding 依桌面視窗略收(網頁是手機優先、桌面不需要那麼大的點擊區)。 */
button {
- border: 1px solid rgba(var(--ink-rgb), .16);
- background: transparent; color: var(--ink);
- padding: 8px 15px; border-radius: 8px; font-size: 14px; cursor: pointer;
- transition: background .12s, border-color .12s;
+ padding: 10px 16px; font-size: 14.5px; border-radius: 10px;
+ border: 1px solid rgba(var(--ink-rgb), .25);
+ background: none; color: rgba(var(--ink-rgb), .7);
+ cursor: pointer; transition: background .12s, color .12s, border-color .12s;
+}
+button:hover { background: var(--well); color: var(--ink); }
+button:disabled { opacity: .45; cursor: default; }
+button.primary {
+ border: none; background: var(--amber); color: var(--knob); font-weight: 600;
+}
+button.primary:hover { filter: brightness(1.06); background: var(--amber); }
+button.ghost {
+ border-color: transparent; background: none;
+ color: rgba(var(--ink-rgb), .45); padding: 7px 10px; font-size: 13px;
}
-button:hover { background: var(--well); }
-button.primary { background: var(--amber); border-color: var(--amber); color: var(--knob); font-weight: 600; }
-button.primary:hover { filter: brightness(1.06); }
-button.ghost { border-color: transparent; color: rgba(var(--ink-rgb), .45); padding: 6px 9px; font-size: 13px; }
button.ghost:hover { color: var(--err); background: transparent; }
+/* 主題切換(portal 有,daemon 也要有——leo:「它有淺色佈景?」
+ ⇒ 是,portal **預設淺色**,深色由使用者切換並存 localStorage。 */
+#themeBtn { padding: 7px 11px; font-size: 13px; }
+
.card {
background: var(--well); border-radius: 12px;
padding: 18px 20px; margin-bottom: 14px;
@@ -138,3 +158,45 @@ input[type=text], input[type=password] {
.sheet h2 { margin: 0 0 6px; font-size: 16.5px; }
.sheet p { margin: 0 0 4px; font-size: 14px; color: rgba(var(--ink-rgb), .55); line-height: 1.7; }
.sheet .acts { justify-content: flex-end; }
+
+/* ── 每個知識庫一個獨立分頁(leo 08-04)──
+ 「同步資料夾那個頁面如果有 2 個庫,每個庫有 30 個同步資料夾,**放得下嗎**?
+ 應該**一個庫有一個頁簽**或是其他方法可以切換。」
+ 「『加入資料夾』按下後**會加在哪個帳號**?**有必要每個帳號有獨立的一個頁面**。」
+ ⇒ 側邊欄直接列出每個知識庫;點進去就是那個庫的頁,動作全部作用在它身上,不會加錯。 */
+#side .sec {
+ padding: 16px 26px 6px; font-size: 11.5px; letter-spacing: .08em;
+ color: rgba(var(--ink-rgb), .35);
+}
+#side .nav .cnt {
+ margin-left: auto; font-size: 11.5px; opacity: .7;
+ font-family: ui-monospace, Menlo, monospace;
+}
+#side .nav .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* 每個庫的頁首:庫名+網址+「開啟知識庫網頁」(leo:不該放首頁,要在各庫上方) */
+.libhead {
+ display: flex; align-items: center; gap: 14px;
+ padding-bottom: 14px; margin-bottom: 14px;
+ border-bottom: 1px solid rgba(var(--ink-rgb), .1);
+}
+.libhead .g { flex: 1; min-width: 0; }
+.libhead .nm { font-size: 16px; font-weight: 600; }
+.libhead .host {
+ font-size: 12px; color: rgba(var(--ink-rgb), .4);
+ font-family: ui-monospace, Menlo, monospace;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+
+/* 狀態時間軸(leo:「如果看守、發現變化、萃取、上傳… 不同 status 在哪裡顯示?」) */
+.steps { display: flex; flex-direction: column; gap: 2px; margin-top: 12px; }
+.step { display: flex; align-items: center; gap: 11px; padding: 7px 0; font-size: 14px; }
+.step .dot {
+ width: 9px; height: 9px; border-radius: 50%; flex: none;
+ background: rgba(var(--ink-rgb), .18);
+}
+.step.done .dot { background: var(--ok); }
+.step.now .dot { background: var(--amber); animation: pulse 1.1s ease-in-out infinite; }
+.step.now { font-weight: 600; }
+.step .t { flex: 1; }
+.step .m { font-size: 12.5px; color: rgba(var(--ink-rgb), .45); }