e7a72d74e1
舊版 onboarding() 只有兩行字+兩顆按鈕,沒解釋 Arcrun 是什麼,也沒有 「我做到哪了」的感覺;『我還沒有知識庫』點下去就丟去外部網址, 回來以後要自己想起來要按哪顆鈕才能連線。 改成兩步小精靈,每步都有步驟點(1/2): ① 認識 Arcrun:三句大白話講清楚在做什麼,不用任何內部詞 ② 連上或申請知識庫:『已經有了』/『還沒有』並排兩張卡, 『還沒有』那張當場講清楚『回來這裡按哪顆鈕』,不是丟出去就沒事 連線邏輯(Connect/showConnect)完全沒動,接的是既有後端; 連線成功後落回首頁,首頁本來就有狀態時間軸+自動種好的範例資料夾 (default_library.go 的 P4),使用者不必再多做一步就看得到 『丟檔案 → 知識卡』整條路跑起來。 只動前端(main.js+style.css),零 Go 改動。check-cis.sh 全過; 用 mock window.go.main.App 在瀏覽器截圖驗過兩步畫面(截圖見 issue #23 留言)。
543 lines
27 KiB
JavaScript
543 lines
27 KiB
JavaScript
// Arcrun 桌面 App — 前端(t193)
|
||
//
|
||
// 版面(leo 08-04 指定,附 Google Drive 截圖):左側邊欄、右側換頁。
|
||
// 🔴 08-04 二輪回饋(本次處理):
|
||
// ③「開啟知識庫網頁」不該在首頁(會開到錯的庫)⇒ 移到**各庫頁的上方**
|
||
// ④ 一個庫 30 個資料夾放不下 ⇒ **每個知識庫一個獨立分頁**
|
||
// ⑤「加入資料夾」會加到哪個帳號?⇒ 在庫頁裡加,**作用對象就是那個庫**,不會加錯
|
||
// ⑥ 首頁要顯示 status:看守/發現變化/萃取/上傳 ⇒ **狀態時間軸**
|
||
import './arcrun-cis.css'; // 共用底層(色票/字體/紋理)——唯一真相源在 arcrun-cis/css/
|
||
import './style.css'; // 本 App 的版面
|
||
|
||
const go = window.go.main.App;
|
||
const $ = (id) => document.getElementById(id);
|
||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
|
||
// 主題:**與 portal 同一套規則**——預設淺色,使用者切換後存 localStorage
|
||
// (leo 問「它有淺色佈景?」⇒ 是,portal 預設就是淺色)
|
||
const THEME_KEY = 'arcrun_app_theme';
|
||
function applyTheme(t) {
|
||
document.documentElement.setAttribute('data-theme', t);
|
||
try { localStorage.setItem(THEME_KEY, t); } catch (e) {}
|
||
const b = $('themeBtn'); if (b) b.textContent = t === 'dark' ? '☀' : '☾';
|
||
}
|
||
applyTheme((() => { try { return localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; } })());
|
||
$('themeBtn').onclick = () =>
|
||
applyTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
|
||
|
||
let state = null;
|
||
let page = 'home'; // 'home' | 'ai' | 'update' | 'lib:<idx>'
|
||
let updateInfo = null;
|
||
let obStep = 1; // 首次啟動精靈目前在第幾步(issue #23,見 onboarding())
|
||
|
||
// ── 覆蓋層(只給必須打斷的確認)──
|
||
function openSheet(html, wire) { $('sheet').innerHTML = html; $('overlay').classList.add('on'); if (wire) wire(); }
|
||
function closeSheet() { $('overlay').classList.remove('on'); }
|
||
$('overlay').addEventListener('click', (e) => { if (e.target.id === 'overlay') closeSheet(); });
|
||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheet(); });
|
||
|
||
// ── 側邊欄:每個知識庫一項(leo:「每個帳號有獨立的一個頁面」)──
|
||
//
|
||
// t215(2026-08-08,leo:「在每個知識庫上顯示是否要更新」):落後的庫名旁加一顆
|
||
// 警示點,逛清單時不用點進每個庫就能一眼看出哪個落後(完整說明+更新按鈕在
|
||
// 首頁 cardKbVersions 與各庫頁 kbVersionLine)。
|
||
//
|
||
// 🔴 頂層 status.md 08-08 深夜記過一個**待 leo confirm、尚未定案**的提案:
|
||
// 「單一更新入口(版本白癡化)」——把小幫手自我更新與雲端知識庫更新合併成一顆按鈕。
|
||
// 那個提案沒有否定「每庫獨立列出落後狀態」這件事本身(它本來就要「點進去才看細節」),
|
||
// 只是問「總覽要不要合併」。這裡先實作 leo 這次明確要的「每庫看得到+連得到」,
|
||
// 之後若那個提案 confirm,是在這層之上疊總覽 pill,不是重做這裡。
|
||
function renderNav() {
|
||
const accs = (state && state.accounts) || [];
|
||
$('nav').innerHTML = `
|
||
<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 = () => { page = el.dataset.p; renderNav(); renderPage(); };
|
||
});
|
||
}
|
||
|
||
// ── 首頁:狀態時間軸(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>`;
|
||
}
|
||
|
||
// 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 `
|
||
<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:單一知識庫的版本判定文案,首頁卡與各庫頁共用同一份翻譯,
|
||
// 不讓兩處各寫各的文字(那樣才真的會出現「兩處講不同的話」)。
|
||
//
|
||
// 三種情況都要照實講(cloud_version.go 記過的坑:把「查不到」呈現成「一切正常」):
|
||
// ① Known 且落後 → 紅字+按鈕,按下去帶 email 開 install 頁(同 portal 版本卡的預填做法)
|
||
// ② Known 且已最新 → 淡字「已是最新版」
|
||
// ③ 不 Known → 照原因分兩句:連不上這個知識庫/查得到目前版本但暫時查不到最新版
|
||
function kbVersionLine(a) {
|
||
if (!a.cloudVerKnown) {
|
||
const detail = a.cloudVerMine
|
||
? `已知版本 ${esc(a.cloudVerMine)},暫時查不到最新版本(稍後會自動再查)`
|
||
: '目前連不上這個知識庫,查不到版本';
|
||
return `<span class="d">${detail}</span>`;
|
||
}
|
||
if (a.cloudVerStale) {
|
||
return `<span class="warn">有新版可更新(目前 ${esc(a.cloudVerMine)} → 最新 ${esc(a.cloudVerLatest)})</span>
|
||
<button class="ghost" data-updatekb="${esc(a.email || '')}">前往安裝頁更新</button>`;
|
||
}
|
||
return `<span class="d">已是最新版(${esc(a.cloudVerMine)})</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 額度用完了」卡(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
|
||
? `<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>`;
|
||
}
|
||
|
||
// 你的檔案:分母 + 三個分類(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 `
|
||
<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>`;
|
||
}
|
||
|
||
// ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)──
|
||
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) => `
|
||
<div class="folder">
|
||
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
|
||
<span class="tag">自動同步中</span>
|
||
<button class="ghost" data-rm="${esc(f.path)}" data-acc="${f.accIdx}">移除</button>
|
||
</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()}`;
|
||
}
|
||
|
||
// 疑難排解/匯出診斷檔(t213,2026-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:用大白話講清楚在做什麼,不用任何內部詞(collector/namespace/
|
||
// 萃取…),只用「資料夾」「知識卡」「知識庫」這些使用者本來就懂或一看就懂的詞
|
||
// ② 連上或申請知識庫:兩條路並排,「還沒有」那條**當場**講清楚回來要做什麼
|
||
// (不是丟出去一個外部網址就沒事,那正是舊版讓人卡住的地方)
|
||
// 連線成功後會直接落回首頁——首頁本來就有「狀態時間軸」+自動種好的範例資料夾
|
||
// (見 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>`;
|
||
}
|
||
|
||
function renderPage() {
|
||
if (!state) return;
|
||
let html;
|
||
if (page.startsWith('lib:')) html = pageLib(state, Number(page.slice(4)));
|
||
else if (page === 'ai') html = pageAI(state);
|
||
else if (page === 'update') html = pageUpdate(state);
|
||
else html = pageHome(state);
|
||
// 每個庫頁底下都給「新增知識庫帳號」入口
|
||
if (page === 'home' && state.accounts && state.accounts.length) {
|
||
html += `<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);
|
||
});
|
||
}
|
||
|
||
function render(s) {
|
||
const first = !state;
|
||
const navChanged = state && JSON.stringify((state.accounts||[]).map(a=>[a.name,(a.folders||[]).length]))
|
||
!== JSON.stringify((s.accounts||[]).map(a=>[a.name,(a.folders||[]).length]));
|
||
state = s;
|
||
$('ver').textContent = s.version || '';
|
||
$('statusBig').textContent = s.statusBig;
|
||
$('statusBig').classList.toggle('syncing', !!s.syncing);
|
||
$('statusSub').textContent = s.statusSub;
|
||
if (first || navChanged) { renderNav(); renderPage(); }
|
||
else if (page === 'home') renderPage(); // 首頁的狀態時間軸要跟著跳
|
||
}
|
||
|
||
async function tick() { try { render(await go.GetState()); } catch (e) {} }
|
||
|
||
// ── 動作 ──
|
||
$('btnSync').onclick = async () => { await go.SyncNow(); tick(); };
|
||
|
||
async function addFolder(accIdx) {
|
||
const p = await go.PickFolder();
|
||
if (!p) return;
|
||
await go.AddFolder(accIdx, p);
|
||
state = await go.GetState(); renderNav(); renderPage();
|
||
}
|
||
|
||
function confirmRemove(accIdx, path) {
|
||
openSheet(`
|
||
<h2>移除這個資料夾?</h2>
|
||
<p>「${esc(path)}」不再自動同步。<br/>已經上傳的知識卡不會被刪除。</p>
|
||
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">移除</button></div>`,
|
||
() => {
|
||
$('c1').onclick = closeSheet;
|
||
$('c2').onclick = async () => {
|
||
await go.RemoveFolder(accIdx, path); closeSheet();
|
||
state = await go.GetState(); renderNav(); renderPage();
|
||
};
|
||
});
|
||
}
|
||
|
||
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);
|