Files
Leo 779a1b801e 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>
2026-08-09 00:21:40 +08:00

106 lines
4.7 KiB
Go
Raw Permalink 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.
// 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}
}