feat(t215): 每個知識庫顯示是否要更新,落後就給 install 頁連結

leo 08-08:「在每個知識庫會看到其他需要更新的,在每個知識庫上顯示是否要更新,
如果要,加開啓 install 頁的連結」。小幫手以前只提示自己(daemon 本體)要不要
更新,使用者連著多個雲端知識庫時完全看不出哪一個落後。

- collector/cloud_latest.go:EvalCloudUpdate + FetchLatestCloudRelease(30 分鐘節流),
  判準與 portal 版本卡 loadVersion() 同一套(bundle_version vs install.arcrun.dev/
  api/latest 的 release,semver 逐段整數比較),不是 t103 minCloudRelease 那把相容
  底線,避免同一個知識庫在兩處得到相反答案。
- direct.go/sync_status.go:每輪同步順帶算好每個帳號的 CloudUpdateKnown/
  CloudUpdateStale/CloudLatest,寫進 status.json。
- app.go:GetState 把這些欄位接進 UIAccount,供前端讀。
- main.js/style.css:首頁新增「知識庫版本」卡(每庫一行,落後才出現「前往安裝頁
  更新」按鈕,帶 email 預填);側邊欄庫名旁加警示點;各庫頁也顯示同一行版本狀態。
  查不到版本(連不上/latest 暫時查不到)一律誠實說「查不到」,不當成「已最新」。

已用假 window.go 在瀏覽器實測四種情境(落後/已最新/連不上/查得到 mine 但查不到
latest)+零知識庫的 onboarding 頁+深色模式,畫面與按鈕行為皆符合預期。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 00:21:40 +08:00
parent 2bbfa453d3
commit 779a1b801e
8 changed files with 328 additions and 10 deletions
+105
View File
@@ -0,0 +1,105 @@
// cloud_latest.go — t2152026-08-08)「每個知識庫要不要更新」的全域比較基準。
//
// leo 原話:「在每個知識庫上顯示是否要更新,如果要,加開啓 install 頁的連結」。
// 一個使用者可能連著不只一個雲端知識庫(帳號),每個帳號各自的雲端版本可能不同步——
// 之前只有 t150 那套「小幫手自己」的更新提示,雲端側完全沒有對應的畫面。
//
// 🔴 判準**必須跟 portal 設定頁那張版本卡一致**(不能自己另立一套,見驗收要求):
// `console-ui/public/portal/index.html` 的 `loadVersion()` 拿自己的 `bundle_version`
// cypher `/health`)比對 `install.arcrun.dev/api/latest` 的 `release` 欄位,
// 用逐段整數比較(不是字串比較),非 semver 格式一律視為落後。
// 這裡原樣照抄同一個比法與同一個資料源,**不是**沿用 cloud_version.go 的
// `cloudVersionStale`——那支比的是 `minCloudRelease`(協定相容底線,「太舊會壞掉」),
// 跟這裡要回答的「有沒有更新版可以裝」是兩個不同的問題,兩把尺不能混用,
// 否則同一個知識庫會在小幫手與 portal 兩處得到相反答案。
package collector
import (
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"time"
)
const installLatestURL = "https://install.arcrun.dev/api/latest"
// latestCacheTTLinstall.arcrun.dev 自己在 CF edge 快取 5 分鐘(landing/worker.js
// 同一顆端點的既有用法),daemon 端沒必要比它更頻繁去打;同步輪詢間隔常常只有十幾秒,
// 若不節流,每個帳號每輪都會外打一次全域端點=不必要的高頻請求。30 分鐘一輪已經足夠
// 讓使用者在「新版剛發佈」後半小時內看到提示。
const latestCacheTTL = 30 * time.Minute
var (
latestMu sync.Mutex
latestCached string
latestCachedOK bool
latestFetched time.Time
)
// fetchLatestCloudReleaseRaw 可在測試中替換為 stub(同 fetchCloudVersion 慣例)。
var fetchLatestCloudReleaseRaw = func() (string, bool) {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(installLatestURL)
if err != nil {
return "", false
}
defer resp.Body.Close()
var payload struct {
Release string `json:"release"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&payload); err != nil {
return "", false
}
if strings.TrimSpace(payload.Release) == "" {
return "", false
}
return payload.Release, true
}
// FetchLatestCloudRelease 回傳目前已知的「雲端最新版」,內建節流(見 latestCacheTTL)。
// 節流窗內回快取值(含失敗快取=ok=false);窗口過了才真的重打一次。
// 這是**全域單一值**(不分帳號)——所有知識庫比的是同一個「目前最新版是什麼」。
func FetchLatestCloudRelease() (release string, ok bool) {
latestMu.Lock()
if time.Since(latestFetched) < latestCacheTTL {
release, ok = latestCached, latestCachedOK
latestMu.Unlock()
return
}
latestMu.Unlock()
release, ok = fetchLatestCloudReleaseRaw()
latestMu.Lock()
latestCached, latestCachedOK, latestFetched = release, ok, time.Now()
latestMu.Unlock()
return
}
// CloudUpdateStatus 是「這個知識庫要不要更新」的判定結果。
// Known=false 時前端要照實講「查不到」,不能當成「已是最新」——
// 靜默把「不知道」呈現成「一切正常」正是 cloud_version.go 開頭記過的那個坑。
type CloudUpdateStatus struct {
Known bool // 兩邊版本都拿得到才能下判斷
NeedsUpdate bool // Known 且落後
Mine string // 這個帳號目前的 bundle_version(可能是空字串或舊格式)
Latest string // 已知的最新版(可能是空字串=暫時查不到)
}
// EvalCloudUpdate 比較單一帳號的 bundle_version 與全域最新版。
// 與 portal 版本卡 loadVersion() 的 cmpSemver 同一套邏輯:
// - mine 拿不到、或 latest 拿不到 → Known=false(誠實說「查不到」)
// - mine 不是 semver 格式(老實例的 YYYY-MM-DD+sha)→ 一律當落後
// (新版才會寫 semver 進來,portal 端註解原話同此)
// - 兩邊都是 semver → 逐段整數比較,mine < latest 才算落後
func EvalCloudUpdate(mine string, mineOK bool, latest string, latestOK bool) CloudUpdateStatus {
mine = strings.TrimSpace(mine)
latest = strings.TrimSpace(latest)
if !mineOK || mine == "" || !latestOK || latest == "" {
return CloudUpdateStatus{Known: false, Mine: mine, Latest: latest}
}
behind := !isSemverLike(mine) || compareSemver(mine, latest) < 0
return CloudUpdateStatus{Known: true, NeedsUpdate: behind, Mine: mine, Latest: latest}
}