Files
arcrun-collector/cmd/arcrun-tray/main.go
T
Leo 133f5add46 t182:老 config 抹除成 Workers AI+雲端探測——leo 實測「無卡」的真因
leo 08-04 實測回報(更新 v0.15.5 後仍顯示 Gemini/丟 PDF 無卡),
查出來不是 Workers AI 不通,是**根本沒走到那條路**。三顆真 bug:

① 抹除只做一半(leo:「就要抹除改成用 Workers AI,如果保持 Gemini
   它不會改掉,那就是失敗的」)
   t181 只在 RunDirectOnce 改頂層記憶體值,但
   - makeAccountSubConfig 會把**帳號層**舊值蓋回來(t126「帳號層優先」)
   - 完全**沒寫回檔案** ⇒ 托盤是另一個行程,讀檔還是念 Gemini
   leo 的 config 正是頂層+兩個帳號各留 "gemma"、無 explicit
   ⇒ 畫面顯示 Gemini、萃取也真的跑 Gemini。
   修:遷移移到 LoadDirectConfig(每次啟動必經),逐層抹除 + 寫回檔案。
   金鑰保留(Gemini 是選配不是廢除)。

② 驗證器擋掉自己的新預設
   `extractor 只能是 claude / gemma` ⇒ config 一旦寫成 workers-ai,
   daemon 直接載入失敗起不來。v0.15.5 已帶著這顆出貨。

③ 托盤標籤照 config 舊字串念(t178 的通則版)
   t178 只把 claude 這**一個**殘留值導向 Gemini,殘留 gemma 一樣脫鉤。
   改成與 direct.go 同一條判準:沒 explicit 就一律念「雲端 AI」。

+ t182 雲端探測(leo 指定設計:「會去掃一次看雲端是否裝好,沒裝好就顯示
  workers AI 還沒通,一旦通了就顯示可用」):ProbeWorkersAI 逐帳號探
  /portal/daemon/extract(送空 text,不燒 LLM 額度),結果寫進 status.json
  由托盤「狀態:」講白話。**不做靜默退回 Gemini**——那會讓用戶永遠
  不知道自己雲端沒更新。

實測證據(非 mock,打真實例):
- youlin 實例萃真卡:3.67s,產出完整知識卡(一句話定義/要點/關鍵實體/關聯)
- 連測三次:3.51s / 3.34s / 3.59s(Gemini 實測 16.87s ⇒ 快約 5 倍)
- 兩個實例都已有 /portal/daemon/extract(皆回 400=route 存在)
- 拿 leo 真 config 複本跑遷移:三層全抹成 workers-ai、金鑰留著、重讀仍是 workers-ai

測試:新增 direct_t182_test.go(抹除全層/寫回磁碟/主動選過不動/合法值/冪等);
既有三則因預設變更而失效的斷言已更新(t108 二進位不出機的契約未放寬,仍綠)。
全套綠。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:29:56 +08:00

1263 lines
50 KiB
Go
Raw 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.
// arcrun-tray — Arcrun RAG 桌面托盤殼(CP-1 第 6 關 daemon 產品化,leo 2026-07-21 定 fyne)。
//
// 目的:讓「不開 terminal 的人」也能裝、開關、選資料夾——一個選單列/系統匣 icon 就是全部。
// 它看守 `collector direct`(子行程,見 supervisor 套件的誠實界定)並顯示狀態。
//
// ⚠️ 本檔是 fyne GUICGo+系統 webview/GL),**無法在無螢幕 sandbox build/驗**——
//
// build/簽章/公證/TCC 授權=leo/地端在真機做(紅線②③)。純邏輯(看守/config)已抽到
// supervisor 套件並在 sandbox `go test` 驗過。
//
// 真機 build
//
// cd collector/cmd/arcrun-tray && go mod tidy
// go run fyne.io/fyne/v2/cmd/fyne@latest package -os darwin -icon icon.png -name "Arcrun RAG"
// Windows-os windows;簽章/公證另見同目錄 README)
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
neturl "net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"arcrun-rag/collector/supervisor"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/widget"
)
// 版本資訊(leo 2026-07-27:「daemon 要加上版本編號」)。
//
// 為什麼需要:在此之前**沒有任何辦法從畫面看出裝的是哪一版**——07-27 花了大半天在對帳
// 「leo 手上/鏡像上/剛編的」是不是同一顆,只能靠 shasum 比對 binary。使用者更不可能做這件事,
// 回報問題時我們也無法確認他跑的是哪版(修好的東西他可能根本沒拿到,見 t63 三層落差)。
//
// 怎麼帶值:build 時用 -ldflags "-X main.version=… -X main.buildTime=…" 注入(見 build-mac.sh
// build-windows.sh)。沒注入時顯示 dev,代表「本機隨手編的、非發佈品」——這本身就是有用的資訊。
var (
version = "dev"
buildTime = ""
)
// versionLabel 給托盤選單顯示用:正式版顯示「v1.2.3」,開發版額外標出建置時間好辨識。
func versionLabel() string {
if version == "dev" && buildTime != "" {
return "dev (" + buildTime + ")"
}
return version
}
// accountCfg 是單一帳號的連線設定(t104 多帳號同時看守)。
// t126Extractor/GeminiAPIKey/LLMModel 支援帳號層覆蓋——帳號有值時優先,空值繼承機器層。
type accountCfg struct {
InstanceName string `json:"instance_name,omitempty"`
Email string `json:"email,omitempty"`
CypherURL string `json:"cypher_url"`
Namespace string `json:"namespace"`
APIKey string `json:"api_key,omitempty"`
WatchFolders []string `json:"watch_folders,omitempty"`
Libraries map[string]string `json:"libraries,omitempty"` // t52:資料夾→庫對映
// t126:每帳號獨立的萃取設定(空值繼承機器層)
Extractor string `json:"extractor,omitempty"`
GeminiAPIKey string `json:"gemini_api_key,omitempty"`
LLMModel string `json:"llm_model,omitempty"`
}
// directConfig 是 collector direct 的設定(與 collector/direct.go 的 DirectConfig 同結構)。
type directConfig struct {
// t104:多帳號清單(新制)。有值時頂層連線欄位僅保留讀取相容。
Accounts []accountCfg `json:"accounts,omitempty"`
WatchFolder string `json:"watch_folder,omitempty"` // 單數舊制(第一個資料夾的鏡像,維持相容)
WatchFolders []string `json:"watch_folders,omitempty"` // 多資料夾(舊制)
Manifest string `json:"manifest"`
CypherURL string `json:"cypher_url,omitempty"` // 舊制(新制走 Accounts
Namespace string `json:"namespace,omitempty"` // 舊制
APIKey string `json:"api_key,omitempty"`
Email string `json:"email,omitempty"`
InstanceName string `json:"instance_name,omitempty"`
Library string `json:"library,omitempty"`
Extractor string `json:"extractor,omitempty"` // "workers-ai"(預設,免金鑰)|"gemma"
// t181:使用者主動在「AI 設定…」選過才 true。false/舊 config 沒這欄 ⇒ 一律走 workers-ai。
// leo 08-04:「只要更新版本,就已經 default workers AI 了,
// 除非去一個地方切換你指定的 AI 來源」。
ExtractorExplicit bool `json:"extractor_explicit,omitempty"`
ClaudeBin string `json:"claude_bin,omitempty"` // t92collector fallback 找到後回寫
GeminiAPIKey string `json:"gemini_api_key,omitempty"` // t108gemma 路 key
LLMModel string `json:"llm_model,omitempty"` // gemma 路模型
CardIngestWF string `json:"card_ingest_workflow,omitempty"` // 收卡 workflow
Libraries map[string]string `json:"libraries,omitempty"` // t52 舊制:資料夾→庫對映
IngestWF string `json:"ingest_workflow,omitempty"`
RemovedWF string `json:"removed_workflow,omitempty"`
PollSec int `json:"poll_interval_sec,omitempty"`
MaxRemoved float64 `json:"max_removed_ratio,omitempty"`
}
// ── t91 萃取狀態可見性 ──────────────────────────────────────────────────────────
// trayAccountStatus 對映 per-account 狀態(來自 status.json account_details)。
type trayAccountStatus struct {
CloudVersion string `json:"cloud_version,omitempty"`
CloudCheckOK bool `json:"cloud_check_ok"`
}
// traySyncStatus 對映 collector 寫出的 ~/.arcrun-rag/status.json(僅含托盤需要的欄位)。
type traySyncStatus struct {
LastSync string `json:"last_sync,omitempty"`
ExtractedOK int `json:"extracted_ok"`
ExtractFailed int `json:"extract_failed"`
Failures []trayExtFail `json:"failures,omitempty"`
ExtractorOK bool `json:"extractor_ok"`
ExtractorError string `json:"extractor_error,omitempty"`
// 頂層雲端欄位(向後相容,單帳號時有值)
CloudVersion string `json:"cloud_version,omitempty"`
CloudCheckOK bool `json:"cloud_check_ok"`
// t104per-account 狀態(key = cypher_url host
AccountDetails map[string]trayAccountStatus `json:"account_details,omitempty"`
}
// ── t103 雲端版本偵測 ──────────────────────────────────────────────────────────
// trayMinCloudBuilt 是**舊格式**YYYY-MM-DD+sha)最低相容建置日期;
// trayMinCloudRelease 是 semver 世代的最低相容版本。
// 與 collector/cloud_version.go 的 minCloudBuiltminCloudRelease 保持同值——**兩處一起升**。
const trayMinCloudBuilt = "2026-07-28"
const trayMinCloudRelease = "1.4.0"
// trayCloudVersionStale 判斷雲端是否需要更新(同 collector/cloudVersionStale 邏輯)。
//
// 🔴 2026-08-02 修(leo 實撞:兩個帳號都已更新到 1.4.1,卻**都**顯示「知識庫需要更新」
//
// 且更新幾次都消不掉):原本 `version.split("+")[0] < trayMinCloudBuilt`
// 看似日期比較,**實為字串比較**。雲端版本改 semver 後 "1.4.1" < "2026-07-28" 恆為真
// ⇒ 最新版被判過舊。兩種格式並存期間必須兩種都認。
// ⚠️ 這個 bug 有**兩份**(本函式+collector/cloud_version.go),只修一份另一份照樣誤判
// (t164 才踩過「只修一條路,另一條路的用戶照樣中」)。
//
// checkOK=false/health 不可達)時回 false——**呼叫端負責顯示「查不到版本」**,
// 不可把「連不上」呈現成「不用更新」(會讓真正壞掉的機器安靜無聲)。
func trayCloudVersionStale(version string, checkOK bool) bool {
if !checkOK {
return false
}
if version == "" {
return true
}
head := strings.SplitN(version, "+", 2)[0]
if isSemverLike(head) {
return compareSemver(head, trayMinCloudRelease) < 0
}
return head < trayMinCloudBuilt
}
// isSemverLikecompareSemver:與 collector/cloud_version.go 同義。
// tray 與 collector 是兩支獨立編譯的 main package,無法共用符號,故此處複製一份。
// **改動時兩處必須同步**(同 trayMinCloud* 常數的規矩)。
func isSemverLike(v string) bool {
if v == "" || v[0] < '0' || v[0] > '9' {
return false
}
dot := strings.Index(v, ".")
dash := strings.Index(v, "-")
if dot < 0 {
return false
}
return dash < 0 || dot < dash
}
// compareSemver 逐段以整數比較(不可字串比較,否則 "1.10.0" < "1.9.0")。
func compareSemver(a, b string) int {
as := strings.Split(a, ".")
bs := strings.Split(b, ".")
n := len(as)
if len(bs) > n {
n = len(bs)
}
for i := 0; i < n; i++ {
var ai, bi int
if i < len(as) {
ai, _ = strconv.Atoi(strings.TrimSpace(as[i]))
}
if i < len(bs) {
bi, _ = strconv.Atoi(strings.TrimSpace(bs[i]))
}
if ai != bi {
if ai < bi {
return -1
}
return 1
}
}
return 0
}
type trayExtFail struct {
Path string `json:"path"`
Error string `json:"error"`
}
// appStatusFilePath 回傳狀態檔路徑:~/.arcrun-rag/status.json。
func appStatusFilePath() string { return filepath.Join(appDir(), "status.json") }
// loadAppSyncStatus 讀取狀態檔;檔不存在或解析失敗回零值(托盤自行降級)。
func loadAppSyncStatus() traySyncStatus {
var s traySyncStatus
data, err := os.ReadFile(appStatusFilePath())
if err != nil {
return s
}
_ = json.Unmarshal(data, &s)
return s
}
// syncStatusLabel 依萃取狀態決定要在「看守中」後面追加什麼文案。
// hasExtractorconfig 有設定 extractorclaude/gemma),才有萃取計數的語意。
func syncStatusLabel(sync traySyncStatus, hasExtractor bool) string {
if !hasExtractor {
return ""
}
if !sync.ExtractorOK && sync.ExtractorError != "" {
return "" // extractor 錯誤由 buildStatusLabel 整體處理,這裡不加
}
if sync.ExtractedOK > 0 {
return fmt.Sprintf(" · 已萃 %d 檔", sync.ExtractedOK)
}
return ""
}
// appDir 是設定與 manifest 落地處:~/.arcrun-rag/
func appDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".arcrun-rag")
}
// syncNowSignalPath 回傳立刻同步訊號檔路徑(~/.arcrun-rag/sync-now)。
// tray 寫入此檔,collector 的主迴圈(1 秒間隔)偵測到後立刻跑一輪同步並刪除它(t98)。
func syncNowSignalPath() string { return filepath.Join(appDir(), "sync-now") }
func configPath() string { return filepath.Join(appDir(), "config.json") }
// defaultWatchFolder 預設 ~/ArcrunRAG——**刻意避開 ~/Documents**macOS launchd 背景程序碰
// ~/Documents 會被 TCC 擋(07-19 實撞)。預設在非 TCC 禁區=零授權即可用;用戶要改到 Documents
// 才需走 TCC 同意流(見 README)。
func defaultWatchFolder() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "ArcrunRAG")
}
func loadConfig() *directConfig {
c := &directConfig{}
if data, err := os.ReadFile(configPath()); err == nil {
_ = json.Unmarshal(data, c)
}
// t104:舊格式遷移——頂層 CypherURL → Accounts[0](冪等)
if len(c.Accounts) == 0 && strings.TrimSpace(c.CypherURL) != "" {
c.Accounts = []accountCfg{{
InstanceName: c.InstanceName,
Email: c.Email,
CypherURL: c.CypherURL,
Namespace: c.Namespace,
APIKey: c.APIKey,
Libraries: c.Libraries,
WatchFolders: c.legacyFolders(),
}}
// 寫回磁碟(冪等:下次讀 Accounts 已存在就不再遷移)
_ = saveConfig(c)
}
// t126 遷移:把頂層金鑰複製到每個沒有金鑰的帳號(複製非搬移,頂層保留當預設;冪等)。
// 帳號已有自己的值(非空)→ 不覆蓋,讓帳號層設定永遠優先。
t126Migrated := false
for i := range c.Accounts {
if strings.TrimSpace(c.Accounts[i].Extractor) == "" && strings.TrimSpace(c.Extractor) != "" {
c.Accounts[i].Extractor = c.Extractor
t126Migrated = true
}
if strings.TrimSpace(c.Accounts[i].GeminiAPIKey) == "" && strings.TrimSpace(c.GeminiAPIKey) != "" {
c.Accounts[i].GeminiAPIKey = c.GeminiAPIKey
t126Migrated = true
}
if strings.TrimSpace(c.Accounts[i].LLMModel) == "" && strings.TrimSpace(c.LLMModel) != "" {
c.Accounts[i].LLMModel = c.LLMModel
t126Migrated = true
}
}
if t126Migrated {
_ = saveConfig(c)
}
if len(c.Accounts) == 0 && c.WatchFolder == "" && len(c.WatchFolders) == 0 {
c.WatchFolder = defaultWatchFolder() // 尚未連線的預設值(不觸發遷移)
}
if c.Manifest == "" {
c.Manifest = filepath.Join(appDir(), "manifest.json")
}
return c
}
// legacyFolders 回傳頂層(舊制)監看根清單(不含 accounts,供遷移使用)。
func (c *directConfig) legacyFolders() []string {
seen := map[string]bool{}
var out []string
add := func(p string) {
if p == "" || seen[p] { return }
seen[p] = true
out = append(out, p)
}
add(c.WatchFolder)
for _, f := range c.WatchFolders { add(f) }
return out
}
// addWatchFolder 把資料夾加進監看清單(去重、保序),並維持單數欄位=第一根的鏡像。
// 完整的「勾選/移除」UI 是 task 7;本函式先保證資料層正確。
func addWatchFolder(c *directConfig, p string) {
if p == "" {
return
}
if len(c.WatchFolders) == 0 && c.WatchFolder != "" && c.WatchFolder != defaultWatchFolder() {
c.WatchFolders = []string{c.WatchFolder}
}
for _, f := range c.WatchFolders {
if f == p {
return
}
}
c.WatchFolders = append(c.WatchFolders, p)
c.WatchFolder = c.WatchFolders[0]
}
// Folders 回傳監看根清單(t104:多帳號時彙整所有帳號資料夾;舊制時回頂層清單)。
// 供 registerLibraries 等跨帳號操作使用。
func (c *directConfig) Folders() []string {
if len(c.Accounts) > 0 {
seen := map[string]bool{}
var out []string
for _, acc := range c.Accounts {
for _, f := range acc.WatchFolders {
if f != "" && !seen[f] {
seen[f] = true
out = append(out, f)
}
}
}
return out
}
return c.legacyFolders()
}
// removeWatchFolder 把資料夾移出監看清單,並維持單數欄位=第一根鏡像(清單空=兩欄皆空)。
func removeWatchFolder(c *directConfig, p string) {
if len(c.WatchFolders) == 0 && c.WatchFolder == p {
c.WatchFolder = ""
return
}
var out []string
for _, f := range c.WatchFolders {
if f != p {
out = append(out, f)
}
}
c.WatchFolders = out
if len(out) > 0 {
c.WatchFolder = out[0]
} else {
c.WatchFolder = ""
}
}
func saveConfig(c *directConfig) error {
if err := os.MkdirAll(appDir(), 0o755); err != nil {
return err
}
data, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(configPath(), data, 0o600)
}
// collectorBinPath 找可用的 collector 執行檔。
// Windows 上先嘗試從內嵌位元組解壓到 ~/.arcrun-rag/bin/embed_windows.got77-lite),
// 解壓失敗才 fallback 同層目錄;Mac 直接走同層路徑,完全不受影響。
func collectorBinPath() string {
if p, err := ensureEmbeddedCollector(); err == nil && p != "" {
return p
}
exe, err := os.Executable()
if err != nil {
return "arcrun-collector"
}
name := "arcrun-collector"
if isWindows() {
name += ".exe"
}
return filepath.Join(filepath.Dir(exe), name)
}
func isWindows() bool { return os.PathSeparator == '\\' }
// connectionStatusLabel 決定托盤選單頂列「連線中:…」顯示什麼身分(t26 leo 拍板):
// 暱稱(InstanceName)優先於 email,兩者都空才顯示「未設定」——CF/cypher 全程不入此字串。
func connectionStatusLabel(instanceName, email string) string {
name := strings.TrimSpace(instanceName)
if name == "" {
name = strings.TrimSpace(email)
}
if name == "" {
name = "未設定"
}
return "連線中:" + name
}
// shortCypherHost 把 cypher_url 縮成純 host 給第二行 disabled 選單項看(fyne MenuItem 無 tooltip
// 取捨=直接多一行而非塞進同一行——選單寬度有限,host 通常已夠長)。解析失敗就原樣回傳(不隱藏錯誤設定)。
func shortCypherHost(cypherURL string) string {
u := strings.TrimSpace(cypherURL)
if u == "" {
return ""
}
if parsed, err := neturl.Parse(u); err == nil && parsed.Host != "" {
return parsed.Host
}
return u
}
// ── t54leo 2026-07-25:「最好的就是把它的帳密直接輸入」)─────────────────────
// 首次啟動不再需要用戶去網站下載 config.json 丟進隱藏資料夾:
// 輸入「知識庫網址 + 帳號密碼」→ 打 /portal/daemon/config → 設定自動寫好。
// 用戶只需要記得他剛在網站設的那組帳密(他本來就記得)。
// daemonConfig 是 /portal/daemon/config 回應的設定欄位。
// t126:加入 GeminiAPIKey / LLMModel——server 若下發金鑰,直接寫進帳號層。
type daemonConfig struct {
CypherURL string `json:"cypher_url"`
Namespace string `json:"namespace"`
Library string `json:"library"`
Extractor string `json:"extractor"`
GeminiAPIKey string `json:"gemini_api_key,omitempty"` // t126
LLMModel string `json:"llm_model,omitempty"` // t126
Email string `json:"email"`
InstanceName string `json:"instance_name"`
}
// daemonConfigResp 是 /portal/daemon/config 的回應(只含連線設定,不含知識內容)。
type daemonConfigResp struct {
Success bool `json:"success"`
Config daemonConfig `json:"config"`
Error string `json:"error"`
}
// normalizePortalURL 把用戶貼的東西變成可打的 origin:
// 允許貼完整 portal 網址(.../portal/)、只貼主機名、或大小寫/尾斜線不一致。
func normalizePortalURL(raw string) (string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return "", errors.New("請貼上你的知識庫網址")
}
if !strings.Contains(s, "://") {
s = "https://" + s
}
u, err := neturl.Parse(s)
if err != nil || u.Host == "" {
return "", errors.New("網址看起來不太對,請從信裡或瀏覽器網址列複製整段")
}
// portalGUI)與 cypherAPI)是不同子域:用戶手上的是 portal,這裡換算成 API 位址。
host := u.Host
if strings.HasPrefix(host, "arcrun-rag-ui.") {
host = "arcrun-cypher-executor." + strings.TrimPrefix(host, "arcrun-rag-ui.")
}
return "https://" + host, nil
}
// fetchConfigByLogin 用帳密向實例換取這台機器該用的設定。
func fetchConfigByLogin(portalURL, email, password string) (*daemonConfigResp, error) {
base, err := normalizePortalURL(portalURL)
if err != nil {
return nil, err
}
body, _ := json.Marshal(map[string]string{"email": strings.TrimSpace(email), "password": password})
req, err := http.NewRequest(http.MethodPost, base+"/portal/daemon/config", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
res, err := client.Do(req)
if err != nil {
return nil, errors.New("連不上這個網址——請確認網址正確、網路正常")
}
defer res.Body.Close()
var out daemonConfigResp
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return nil, errors.New("這個網址不像是 Arcrun RAG 知識庫,請再確認一次")
}
if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden {
return nil, errors.New("帳號或密碼不對——用你在知識庫網站設定的那組")
}
if !out.Success {
if out.Error != "" {
return nil, errors.New(out.Error)
}
return nil, errors.New("連線失敗,請稍後再試一次")
}
return &out, nil
}
// instanceChanged 比對兩個 cypher_url 的 hosthost 不同代表換了知識庫實例。
// 只比 host 不比 scheme/path——同實例改密碼時 URL 完全不變,不觸發資料夾清空。
// 兩端有任一為空(首次設定或異常值)時回 false,不誤觸清空。
//
// t86leo 2026-07-28 實測:youlin 時代的資料夾被同步進 geek6688 新實例—個資外洩)。
func instanceChanged(oldURL, newURL string) bool {
parseHost := func(s string) string {
u, err := neturl.Parse(strings.TrimSpace(s))
if err != nil || u.Host == "" {
return strings.TrimSpace(s)
}
return u.Host
}
oldHost := parseHost(oldURL)
newHost := parseHost(newURL)
return oldHost != "" && newHost != "" && oldHost != newHost
}
// applyRemoteConfig 把換到的設定寫進本地 config。
// 回傳 true 代表換了知識庫實例(host 不同):此時已清空 WatchFolders/WatchFolder
// 呼叫端應提示用戶重新選擇資料夾,避免舊資料夾誤傳到新知識庫(t86)。
func applyRemoteConfig(cfg *directConfig, r *daemonConfigResp) bool {
// t86:換了實例(host 改變)就清空資料夾清單——在寫入新 URL 之前比對,
// 確保比的是「舊 host vs 新 host」而非「新 vs 新」。
switched := instanceChanged(cfg.CypherURL, r.Config.CypherURL)
if switched {
cfg.WatchFolders = nil
cfg.WatchFolder = ""
}
cfg.CypherURL = r.Config.CypherURL
cfg.Namespace = r.Config.Namespace
if r.Config.Library != "" {
cfg.Library = r.Config.Library
}
if r.Config.Extractor != "" {
cfg.Extractor = r.Config.Extractor
}
cfg.Email = r.Config.Email
if r.Config.InstanceName != "" {
cfg.InstanceName = r.Config.InstanceName
}
if cfg.Manifest == "" {
cfg.Manifest = filepath.Join(appDir(), "manifest.json")
}
return switched
}
// registerLibraries 把「這台機器看守的資料夾各自對應的庫」報上雲端自動登記(t52)。
// leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵。」
// 只在連線精靈那一刻做(那時手上才有帳密;daemon 平時不存密碼)。失敗不擋連線。
func registerLibraries(portalURL, email, password string, cfg *directConfig) error {
base, err := normalizePortalURL(portalURL)
if err != nil {
return err
}
type libItem struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
}
var libs []libItem
seen := map[string]bool{}
for _, folder := range cfg.Folders() {
name := libraryNameFor(cfg, folder)
if name == "" || seen[name] {
continue
}
seen[name] = true
libs = append(libs, libItem{Name: name, DisplayName: filepath.Base(folder)})
}
if len(libs) == 0 {
return nil
}
body, _ := json.Marshal(map[string]any{"email": strings.TrimSpace(email), "password": password, "libraries": libs})
req, err := http.NewRequest(http.MethodPost, base+"/portal/daemon/libraries", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
res, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return fmt.Errorf("庫登記回 HTTP %d", res.StatusCode)
}
return nil
}
// libraryNameFor 與 collector/direct.go 的 libraryFor 同規則(資料夾名 slug;空則退回 library)。
func libraryNameFor(cfg *directConfig, folder string) string {
if cfg.Libraries != nil {
if v, ok := cfg.Libraries[folder]; ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
base := filepath.Base(strings.TrimRight(folder, string(filepath.Separator)))
var b strings.Builder
lastUnderscore := false
for _, r := range base {
switch {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_':
b.WriteRune(r)
lastUnderscore = false
case r >= 'A' && r <= 'Z':
b.WriteRune(r + 32)
lastUnderscore = false
default:
if !lastUnderscore && b.Len() > 0 {
b.WriteRune('_')
lastUnderscore = true
}
}
}
if slug := strings.Trim(b.String(), "_"); slug != "" {
return slug
}
if cfg.Library != "" {
return cfg.Library
}
return "kb"
}
// isConnected 判斷「這台機器已經連上某個知識庫」——沒有就該跳連線精靈。
func isConnected(cfg *directConfig) bool {
if len(cfg.Accounts) > 0 {
for _, acc := range cfg.Accounts {
if strings.TrimSpace(acc.CypherURL) != "" && strings.TrimSpace(acc.Namespace) != "" {
return true
}
}
return false
}
return strings.TrimSpace(cfg.CypherURL) != "" && strings.TrimSpace(cfg.Namespace) != ""
}
// addOrUpdateAccount 把連線精靈取回的設定加入(或更新)accounts 清單(t104)。
// 同 host 已存在 → 更新連線欄位,WatchFolders 保留不動;不同 host → append 新帳號。
// 回傳 true 代表是全新帳號(首次加入)。
// t126Extractor/GeminiAPIKey/LLMModel 寫進帳號層,不覆蓋機器層(機器層只留使用者手動設的預設)。
func addOrUpdateAccount(cfg *directConfig, r *daemonConfigResp) bool {
newHost := shortCypherHost(r.Config.CypherURL)
for i := range cfg.Accounts {
if shortCypherHost(cfg.Accounts[i].CypherURL) == newHost {
// 更新現有帳號(保留 WatchFolders 與 Libraries
cfg.Accounts[i].CypherURL = strings.TrimSuffix(strings.TrimSpace(r.Config.CypherURL), "/")
cfg.Accounts[i].Namespace = r.Config.Namespace
cfg.Accounts[i].Email = r.Config.Email
if r.Config.InstanceName != "" {
cfg.Accounts[i].InstanceName = r.Config.InstanceName
}
// t176leo 08-03 翻案):**不再接受雲端下發的 LLM 設定**。
// 地端用哪個模型/哪把金鑰,一律由使用者在托盤「AI 設定…」自己填。
// t126 的「每帳號一份萃取設定」照舊保留(欄位還在、讀取仍帳號層優先),
// 這裡拔掉的只是「值**從雲端來**」這條來源——t126 修的是存在哪一層,本次改的是值從哪來。
// 為什麼拔(08-03 實證):雲端 extractor_config 是**全租戶共用一把 KV**
// arcrun:portal.ts:43 portalTenant 為 worker 層級變數,不分用戶),
// 任一處設了 claude → 所有人的 daemon 都收到;沒裝 Claude Code 的機器萃取全滅,
// 而 portal 的 claude 勾選框又恆 disabled ⇒ 用戶自己解不開(awindhon 實證:零張卡)。
// leo:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」
return false
}
}
// 新帳號:只寫連線欄位。t176——LLM 設定不吃雲端下發(見上方同批註解),
// 由使用者在托盤「AI 設定…」自己填;欄位留白時讀取端會繼承機器層。
acc := accountCfg{
InstanceName: r.Config.InstanceName,
Email: r.Config.Email,
CypherURL: strings.TrimSuffix(strings.TrimSpace(r.Config.CypherURL), "/"),
Namespace: r.Config.Namespace,
}
if acc.APIKey == "" {
acc.APIKey = acc.Namespace
}
cfg.Accounts = append(cfg.Accounts, acc)
if cfg.Manifest == "" {
cfg.Manifest = filepath.Join(appDir(), "manifest.json")
}
return true
}
// addAccountWatchFolder 把資料夾加進指定帳號的監看清單(t104)。
func addAccountWatchFolder(cfg *directConfig, accIdx int, p string) {
if p == "" || accIdx >= len(cfg.Accounts) {
return
}
acc := &cfg.Accounts[accIdx]
for _, f := range acc.WatchFolders {
if f == p {
return
}
}
acc.WatchFolders = append(acc.WatchFolders, p)
}
// removeAccountWatchFolder 把資料夾從指定帳號的監看清單移出(t104)。
func removeAccountWatchFolder(cfg *directConfig, accIdx int, p string) {
if accIdx >= len(cfg.Accounts) {
return
}
acc := &cfg.Accounts[accIdx]
var out []string
for _, f := range acc.WatchFolders {
if f != p {
out = append(out, f)
}
}
acc.WatchFolders = out
}
// accountEngineLabel 依帳號或全局預設 extractor 產生「· 引擎名」後綴(t126 托盤可見性)。
// 帳號層有值時優先;兩者皆空回空字串。
//
// 🔴 t182`explicit` =使用者有沒有在「AI 設定…」**主動選過**引擎。
// 沒主動選過 ⇒ 一律念「雲端 AI」(回空字串),不管 config 裡殘留什麼舊值。
// 這是 t178 那個病的**通則版**t178 只把 `claude` 這一個殘留值導向 Gemini
// 但殘留 `gemma` 一樣會脫鉤——leo 08-04 實撞:更新到 v0.15.5 後托盤**兩個帳號
// 都還是顯示 Gemini**,因為他 config 的帳號層留著 extractor="gemma"、explicit 沒設。
// 判準必須與 `direct.go` 的預設邏輯**同一條**(沒 explicit 就是 workers-ai),
// 否則又回到「畫面說 A、實際跑 B」。
func accountEngineLabel(acc accountCfg, defaultExtractor string, explicit bool) string {
if !explicit {
return ""
}
extractor := strings.TrimSpace(acc.Extractor)
if extractor == "" {
extractor = strings.TrimSpace(defaultExtractor)
}
switch extractor {
// t181:走雲端 Workers AI**免金鑰,新的預設**)。
// 空值也走這條——`direct.go` 的預設邏輯:沒設 extractor=新用戶=走 workers-ai
// 標籤跟著一致,才不會重演 t178「畫面說 A、實際跑 B」的矛盾。
// t181leo 08-04):「上方如果用 workers AI 就**不顯示**,如果用 Gemini 會顯示 Gemini」
// ⇒ 預設路徑不佔版面(那是常態,不需要標註);只有主動選了別的引擎才標出來。
// 空值也走這條——沒設 extractor=新用戶=走 workers-aidirect.go 預設邏輯)。
case "workers-ai", "":
return ""
// t178leo 08-04 實撞:朋友的托盤顯示「oscar · Claude」,但他根本沒有 Claude):
// `claude` 也顯示 Gemini——因為 **direct.go 早就把 claude 正規化成 gemma**
// t176:地端先只支援 Gemini)。標籤若照著 config 的舊字串念,就與實際行為不符
// ⇒ 用戶看到 Claude、卻收到「Gemini 金鑰是空的」,兩個訊息互相矛盾、更難懂。
// 舊值來源=雲端舊版下發後留在 config.json 的殘留(t176 擋了新寫入,沒洗掉舊的)。
//
// 📌 Gemini 仍是**選配**leo 08-04:「客戶說他要用 Gemini,但現在變成選配,
// 不需要推廣,特定人告訴他怎麼做就好」)⇒ 已填金鑰的用戶維持這條路、標籤照實顯示。
case "gemma", "claude":
return " · Gemini"
}
return ""
}
// accountDisplayName 回傳帳號顯示名稱(暱稱 > email > host)。
func accountDisplayName(acc accountCfg) string {
if n := strings.TrimSpace(acc.InstanceName); n != "" {
return n
}
if e := strings.TrimSpace(acc.Email); e != "" {
return e
}
return shortCypherHost(acc.CypherURL)
}
func main() {
// t176:一台機器只跑一個托盤(leo 08-03「點選多次後托盤產生多個 icon」)。
// 必須在建 fyne app 之前擋——一旦 app 起來就會掛上第二個托盤 icon。
ok, releaseInstance := acquireSingleInstance()
if !ok {
fmt.Fprintln(os.Stderr, "Arcrun RAG 已經在執行中(系統匣裡找找看),這次不重複開啟。")
return
}
defer releaseInstance()
a := app.NewWithID("dev.arcrun.rag.tray")
cfg := loadConfig()
// 設定視窗(平時隱藏;選資料夾/看說明時開)
win := a.NewWindow("Arcrun RAG")
win.SetCloseIntercept(func() { win.Hide() }) // 關窗只隱藏,不結束 app(托盤續跑)
win.Resize(fyne.NewSize(420, 220))
sup := supervisor.New(collectorBinPath(), configPath())
// 狀態列(tray 選單第一項,只讀)
statusItem := fyne.NewMenuItem("狀態:尚未開始", nil)
statusItem.Disabled = true
desk, hasTray := a.(desktop.App)
// rebuildTray 依現況重建整個 tray 選單(daemon-beta t7 多資料夾):
// 每個列入的資料夾一列(點=在 Finder 打開)+各自的「刪除這個知識庫」項;
// 重設選單=刷新(比 (*Menu).Refresh() 跨版本更穩)。
var rebuildTray func()
restartWatch := func() {
sup.Stop()
sup.Start()
}
// t54 連線精靈:輸入知識庫網址+帳密 → 換設定 → 寫檔 → 開始看守。
// 沒連線過的機器一開 app 就自動跳;之後也能從選單「重新連線…」再開。
// t752026-07-28leo 同事真機實測):**連線失敗重試時必須把使用者剛打的內容帶回來**。
//
// 原本的寫法是失敗後直接呼叫 `showConnectWizard()`,註解寫著「不要把他丟回空白畫面」——
// **但行為與註解相反**:它重建整個對話框、`urlEntry` 是全新的,而 `cfg.CypherURL`
// 因為連線失敗根本沒被寫入 ⇒ 欄位就是空的 ⇒ **使用者得整段重打**。
// leo 原話:「貼上網址後說這不是個網址然後清空內容,**很糟糕**」。
// ⇒ 改成帶入上次輸入(prefill)。密碼不帶回(打錯密碼時重打比較安全,也避免把密碼留在記憶體更久)。
var showConnectWizard func()
var showAISettings func() // t176:地端 AI 設定(Gemini 金鑰)
var showConnectWizardWith func(prevURL, prevEmail string)
showConnectWizard = func() { showConnectWizardWith("", "") }
showConnectWizardWith = func(prevURL, prevEmail string) {
urlEntry := widget.NewEntry()
urlEntry.SetPlaceHolder("https://…workers.dev/portal/")
// 優先用「使用者剛才打的」,其次才是已存的設定——重試時他要看到的是自己的輸入。
if prevURL != "" {
urlEntry.SetText(prevURL)
} else if cfg.CypherURL != "" {
urlEntry.SetText(cfg.CypherURL)
}
emailEntry := widget.NewEntry()
emailEntry.SetPlaceHolder("你的 Email")
if prevEmail != "" {
emailEntry.SetText(prevEmail)
} else if cfg.Email != "" {
emailEntry.SetText(cfg.Email)
}
pwEntry := widget.NewPasswordEntry()
pwEntry.SetPlaceHolder("你在知識庫設定的密碼")
hint := widget.NewLabel("")
hint.Wrapping = fyne.TextWrapWord
form := container.NewVBox(
widget.NewLabel("連上你的知識庫"),
widget.NewLabel("貼上網址,再輸入你在網站上設定的帳號密碼:"),
urlEntry, emailEntry, pwEntry, hint,
)
d := dialog.NewCustomConfirm("Arcrun RAG", "連線", "取消", form, func(ok bool) {
if !ok {
return
}
hint.SetText("連線中…")
go func() {
r, err := fetchConfigByLogin(urlEntry.Text, emailEntry.Text, pwEntry.Text)
if err != nil {
dialog.ShowError(err, win)
// t75:把剛才打的網址與 email 帶回去,讓他只改錯的那個字,不必整段重打。
showConnectWizardWith(urlEntry.Text, emailEntry.Text)
return
}
// t104append 新帳號或更新同 host 帳號——不清空其他帳號資料夾(t86 切換清空退役)
isNew := addOrUpdateAccount(cfg, r)
if err := saveConfig(cfg); err != nil {
dialog.ShowError(err, win)
return
}
// t52:把本機資料夾對應的庫報上去自動登記
if lerr := registerLibraries(urlEntry.Text, emailEntry.Text, pwEntry.Text, cfg); lerr != nil {
fmt.Println("庫登記略過:", lerr) // 不擋連線(可能是還沒選資料夾)
}
restartWatch()
rebuildTray()
accName := strings.TrimSpace(r.Config.InstanceName)
if accName == "" {
accName = strings.TrimSpace(r.Config.Email)
}
if accName == "" {
accName = shortCypherHost(r.Config.CypherURL)
}
if isNew {
dialog.ShowInformation("帳號已加入", "已加入「"+accName+"」。\n接下來用該帳號底下的「+ 新增知識資料夾…」選擇要同步的資料夾就好。", win)
} else {
dialog.ShowInformation("帳號已更新", "「"+accName+"」的連線設定已更新。", win)
}
}()
}, win)
d.Resize(fyne.NewSize(460, 320))
win.Show()
win.RequestFocus()
d.Show()
}
// t176leo 08-03 架構翻案):AI 設定住在地端,不再由雲端下發。
// 現階段**只支援 Gemini**leo:「地端先限制 Gemini API Key 配合客戶要求」);
// 之後要擴充成「任何用戶想用的模型」時,這裡加 provider 選單即可(欄位已預留 LLMModel)。
// ⚠️ 這是「緊急先加上輸入位置」的版本——leo 已指出托盤下拉選單塞不下未來的設定量
//(30+ 資料夾根本認不出),後續走本地 HTML 設定頁(像 Google Drive)。
// t181leo 08-04):這裡是「**唯一可以切換 AI 來源的地方**」。
// leo:「只要更新版本,就已經 default workers AI 了,除非**去一個地方切換**你指定的 AI 來源」
// 「default 用 Workers AI,你要用 Gemini 要**特別去選取**,不管你現在是否有填金鑰」
// ⇒ 預設選項固定在「雲端 AI」,除非使用者**主動**改選 Gemini(才寫 ExtractorExplicit=true)。
// Gemini 是選配、不推廣(leo:「特定人告訴他怎麼做就好」),故文案不慫恿、只說明。
showAISettings = func() {
const (
optCloud = "雲端 AI(推薦・不必申請任何金鑰)"
optGemini = "Google Gemini(需要自己申請金鑰)"
)
keyEntry := widget.NewPasswordEntry()
keyEntry.SetPlaceHolder("貼上你的 Gemini API Key")
keyEntry.SetText(cfg.GeminiAPIKey) // 帶入現值,讓他看得到「已經設過了」
note := widget.NewLabel("")
note.Wrapping = fyne.TextWrapWord
// 只有「主動選過 Gemini」才預選 Gemini;其餘一律停在雲端 AI(含已填金鑰但沒選過的人)
selected := optCloud
if cfg.ExtractorExplicit && cfg.Extractor == "gemma" {
selected = optGemini
}
radio := widget.NewRadioGroup([]string{optCloud, optGemini}, func(s string) {
if s == optGemini {
keyEntry.Show()
note.SetText("到 aistudio.google.com/apikey 免費申請。\n" +
"若顯示 Billing Tier: Unavailable,代表那個 Google 帳號被 Google 限制了,換一個帳號申請。")
} else {
keyEntry.Hide()
note.SetText("用你自己 Cloudflare 帳號內建的 AI,不需要任何金鑰、不必去別的網站申請。")
}
})
radio.SetSelected(selected)
form := container.NewVBox(
widget.NewLabel("用哪個 AI 幫你整理文件?"),
radio, keyEntry, note,
)
d := dialog.NewCustomConfirm("AI 設定", "儲存", "取消", form, func(ok bool) {
if !ok {
return
}
useGemini := radio.Selected == optGemini
key := strings.TrimSpace(keyEntry.Text)
if useGemini && key == "" {
dialog.ShowError(errors.New("選了 Gemini 就要貼上金鑰;不想申請的話請改選「雲端 AI」"), win)
return
}
engine := "workers-ai"
if useGemini {
engine = "gemma"
cfg.GeminiAPIKey = key
}
// 機器層寫一份當預設;各帳號層一併寫,避免舊 config 殘留的空欄位繼承到別的值。
cfg.Extractor = engine
cfg.ExtractorExplicit = true // ← 使用者主動選過,之後不再被預設覆蓋
for i := range cfg.Accounts {
cfg.Accounts[i].Extractor = engine
if useGemini {
cfg.Accounts[i].GeminiAPIKey = key
}
}
if err := saveConfig(cfg); err != nil {
dialog.ShowError(err, win)
return
}
restartWatch() // 立刻生效,不必等下一輪
rebuildTray()
msg := "接下來的同步會用你自己 Cloudflare 帳號內建的 AI 整理文件(不需要金鑰)。"
if useGemini {
msg = "接下來的同步會用 Google Gemini 整理文件。"
}
dialog.ShowInformation("已儲存", msg, win)
}, win)
d.Resize(fyne.NewSize(520, 320))
win.Show()
win.RequestFocus()
d.Show()
}
addAction := func() {
win.Show()
win.RequestFocus()
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
return
}
// t104:多帳號模式加進第一個帳號;無帳號時走舊制
if len(cfg.Accounts) > 0 {
addAccountWatchFolder(cfg, 0, uri.Path())
} else {
addWatchFolder(cfg, uri.Path())
}
if err := saveConfig(cfg); err != nil {
dialog.ShowError(err, win)
return
}
restartWatch()
rebuildTray()
win.Hide()
}, win)
}
// t98:立刻同步——寫訊號檔觸發 collector 立即跑一輪,無需等下一個定時週期。
// syncNowItem 宣告在 rebuildTray 外,確保 click handler 的 goroutine 能還原標籤後重建選單。
var syncNowItem *fyne.MenuItem
syncNowItem = fyne.NewMenuItem("立刻同步", func() {
_ = os.MkdirAll(appDir(), 0o755)
_ = os.WriteFile(syncNowSignalPath(), []byte{}, 0o644)
syncNowItem.Label = "同步中…"
syncNowItem.Disabled = true
rebuildTray()
// 還原標籤:8 秒後 collector 已完成本輪(含狀態檔更新),順手刷新一次 tray 讓時間戳更新。
go func() {
time.Sleep(8 * time.Second)
syncNowItem.Label = "立刻同步"
syncNowItem.Disabled = false
rebuildTray()
}()
})
rebuildTray = func() {
if !hasTray {
return
}
// t91:每次重建選單時讀最新狀態,確保看守中的萃取結果即時反映。
sync := loadAppSyncStatus()
supStatus := sup.Status()
statusItem.Label = "狀態:" + buildStatusLabel(supStatus, sync, cfg.Extractor)
versionItem := fyne.NewMenuItem("版本 "+versionLabel(), nil)
versionItem.Disabled = true
// t150leo 07-29):更新提示+一鍵完成。
// leo:「我要他下載多幾次他就放棄了,所以抓到一個客戶後不能讓他有機會離開」
// 「如果它都掛着,那準備好就告訴他重啓更新」
// ⇒ 背景自動下載備妥,這裡只呈現結果;使用者**完全不必再下載**,按一下重啟即完成。
var updateItem *fyne.MenuItem
if lbl, show := updateMenuLabel(); show {
updateItem = fyne.NewMenuItem(lbl, func() {
if st := loadStaged(); st.Ready {
if err := applyStagedAndRestart(); err != nil {
dialog.ShowError(err, win)
}
return
}
// 尚在下載中:告知狀態,不重複觸發下載
dialog.ShowInformation("更新下載中",
"新版正在背景下載,完成後這裡會變成「點此重新啟動完成更新」。", win)
})
}
// 手動「檢查更新」(leo:「他去按一下檢查更新然後更新就好」)——
// 自動檢查每日一次,但使用者想立刻確認時要按得到,不必等。
checkUpdateItem := fyne.NewMenuItem("檢查更新…", func() {
go func() {
st := checkForUpdate()
switch {
case st.Err != "":
dialog.ShowInformation("檢查更新", "暫時連不上更新伺服器:\n"+st.Err, win)
case st.Available:
go prepareUpdateInBackground()
dialog.ShowInformation("有新版本",
"發現新版 "+st.Latest+",正在背景下載。\n下載完成後,選單會出現「點此重新啟動完成更新」。", win)
default:
dialog.ShowInformation("檢查更新", "你已經是最新版本("+versionLabel()+")。", win)
}
}()
})
var items []*fyne.MenuItem
// t104per-account 分組——每個帳號獨立一段(帳號名稱標題 + 資料夾 + 新增按鈕)。
if len(cfg.Accounts) > 0 {
for ai, acc := range cfg.Accounts {
accIdx := ai // capture by value,供 closure 正確引用
accHost := shortCypherHost(acc.CypherURL)
// 帳號標題(不可點);t126 附引擎名(如「geek6688 · Gemini」)
headerItem := fyne.NewMenuItem("◉ "+accountDisplayName(acc)+accountEngineLabel(acc, cfg.Extractor, cfg.ExtractorExplicit), nil)
headerItem.Disabled = true
items = append(items, headerItem)
// t103 per-account:雲端版本過舊時顯示更新提示(帶帳號識別)
if accSt, ok := sync.AccountDetails[accHost]; ok && trayCloudVersionStale(accSt.CloudVersion, accSt.CloudCheckOK) {
items = append(items, fyne.NewMenuItem(" ⚠ 知識庫需要更新(點我)", func() {
if u, err := neturl.Parse("https://install.arcrun.dev/"); err == nil {
_ = a.OpenURL(u)
}
}))
}
// 此帳號的資料夾(t101:子選單「刪除這個知識庫」作用於正確帳號)
for _, f := range acc.WatchFolders {
folder := f // capture
aidx := accIdx // capture
it := fyne.NewMenuItem(" 📁 "+filepath.Base(folder), func() {
if u, err := neturl.Parse("file://" + folder); err == nil {
_ = a.OpenURL(u)
}
})
it.ChildMenu = fyne.NewMenu("",
fyne.NewMenuItem("刪除這個知識庫", func() {
removeAccountWatchFolder(cfg, aidx, folder)
if err := saveConfig(cfg); err != nil {
dialog.ShowError(err, win)
return
}
restartWatch()
rebuildTray()
}),
)
items = append(items, it)
}
// 此帳號的「+ 新增知識資料夾…」
aidxAdd := accIdx // capture
items = append(items, fyne.NewMenuItem(" 新增知識資料夾…", func() {
win.Show()
win.RequestFocus()
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
return
}
addAccountWatchFolder(cfg, aidxAdd, uri.Path())
if err := saveConfig(cfg); err != nil {
dialog.ShowError(err, win)
return
}
restartWatch()
rebuildTray()
win.Hide()
}, win)
}))
items = append(items, fyne.NewMenuItemSeparator())
}
} else {
// 尚未連線(accounts 空)——簡化顯示
notConn := fyne.NewMenuItem("未連線到任何知識庫", nil)
notConn.Disabled = true
items = append(items, notConn, fyne.NewMenuItemSeparator())
}
items = append(items, statusItem)
// t91:有萃取失敗時加警告項,點開顯示哪些檔出了什麼問題(白話)。
if cfg.Extractor != "" && sync.ExtractFailed > 0 {
syncSnapshot := sync // 捕獲,避免 closure 讀到更新後的值
failItem := fyne.NewMenuItem(fmt.Sprintf("⚠ 萃取失敗 %d 檔", sync.ExtractFailed), func() {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("本輪有 %d 個檔案沒有成功進知識庫:\n\n", syncSnapshot.ExtractFailed))
for _, f := range syncSnapshot.Failures {
sb.WriteString("• " + f.Path + "\n " + f.Error + "\n\n")
}
dialog.ShowInformation("萃取失敗詳情", strings.TrimSpace(sb.String()), win)
})
items = append(items, failItem)
}
// t103 向後相容(單帳號舊 status.json 仍有頂層 CloudVersion):若無 AccountDetails 則看頂層
if len(sync.AccountDetails) == 0 && trayCloudVersionStale(sync.CloudVersion, sync.CloudCheckOK) {
items = append(items, fyne.NewMenuItem("⚠ 知識庫需要更新(點我)", func() {
if u, err := neturl.Parse("https://install.arcrun.dev/"); err == nil {
_ = a.OpenURL(u)
}
}))
}
// t150:有更新才插這一項(沒新版時不佔位、不干擾)
if updateItem != nil {
items = append(items, fyne.NewMenuItemSeparator(), updateItem)
}
items = append(items,
fyne.NewMenuItemSeparator(),
syncNowItem, // t98:立刻同步
// t104:「連上知識庫」改為「新增帳號」——精靈成功後 append 到 accounts,不換掉舊帳號
fyne.NewMenuItem(" 新增帳號…", func() { showConnectWizard() }),
// t176AI 設定移到地端(leo 08-03:「地端要用什麼模型就在 daemon 上輸入 API Key 設置」)
fyne.NewMenuItem("AI 設定…", func() { showAISettings() }),
fyne.NewMenuItemSeparator(),
versionItem,
checkUpdateItem, // t150:手動檢查更新(leo:「他去按一下檢查更新然後更新就好」)
// t110 二修(leo 真機:v0.14.1 拿掉自訂項後選單「兩個都沒有」=
// 「fyne 會自動附 Quit」是錯誤前提,桌面截圖才是權威):自訂結束項加回,
// 這是唯一的結束入口。若某環境 fyne 真的又附了 Quit(v0.14.0 曾見雙項),
// 屆時以抑制 fyne 端為方向,不能再拿掉這項。
fyne.NewMenuItem("結束 Arcrun RAG", func() {
sup.Stop()
a.Quit()
}),
)
desk.SetSystemTrayMenu(fyne.NewMenu("Arcrun RAG", items...))
}
if hasTray {
desk.SetSystemTrayIcon(trayIcon()) // 品牌 icon(見 trayicon.gomac 走 template 單色、Windows 走墨底完整版)
}
rebuildTray() // 開機即建初始選單(否則第一次狀態回呼前選單是空的)
// 托盤唯一指示器(leo 07-24 裁決):NSApp 起來後把 activation policy 切 Accessory
//plist LSUIElement 會被 fyne/glfw 蓋掉——07-24 真機實測第三枚坑,見 dock_darwin.go
a.Lifecycle().SetOnStarted(func() { hideDockIcon() })
refreshTray := rebuildTray
// 狀態變更 → 刷新 tray 選單(回呼在 supervisor goroutine,切回 UI thread)。
// t91label 設定統一在 rebuildTray 裡做(同時讀取 status.json);
// 此版 fyne 無 fyne.Dotray label 更新直接做即可(2026-07-21 真機 build 實測修正)。
sup.SetOnChange(func(s supervisor.Status) {
refreshTray()
})
// 設定視窗內容:說明現況 + 選資料夾按鈕(給會開窗的人;不會開的人靠 tray 選單)
win.SetContent(container.NewVBox(
widget.NewLabelWithStyle("Arcrun RAG 知識同步", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
widget.NewLabel("把檔案丟進你選的資料夾,就會自動進你的知識庫。\n這個程式會待在選單列/系統匣,關掉視窗它仍在背景看守。"),
widget.NewButton("新增知識資料夾…", func() { addAction() }),
))
// 開機即看守(若已連上知識庫)
if isConnected(cfg) {
sup.Start()
} else {
// t54:沒連過就直接跳連線精靈(輸入網址+帳密),不再要用戶去下載 config.json。
statusItem.Label = "狀態:尚未連線"
showConnectWizard()
}
// t150:背景更新檢查(每日一次,啟動後延遲 1 分鐘避免搶開機資源)。
// 有新版就默默下載備妥,選單自動變成「點此重新啟動完成更新」——
// 使用者不必再下載任何東西(leo:「我要他下載多幾次他就放棄了」)。
startUpdateWatcher()
a.Lifecycle().SetOnStopped(func() { sup.Stop() })
a.Run()
}
// humanStatus 把狀態機轉成白話(給不懂內部的人)。
// 需要考慮萃取狀態時,改用 buildStatusLabel。
func humanStatus(s supervisor.Status) string {
return buildStatusLabel(s, loadAppSyncStatus(), "")
}
// buildStatusLabel 合併 supervisor 狀態與萃取狀態,產出托盤顯示文字。
// extractor 空字串代表 config 未設定 extractor(舊制直送),此時不顯示萃取計數。
func buildStatusLabel(s supervisor.Status, sync traySyncStatus, extractor string) string {
hasExtractor := extractor != ""
switch s.State {
case supervisor.StateWatching:
// t92extractor 預檢失敗時,直接換掉「看守中」文案
if hasExtractor && !sync.ExtractorOK && sync.ExtractorError != "" {
return "⚠ 萃取引擎未就緒:" + sync.ExtractorError
}
base := "看守中"
if !s.LastRoundAt.IsZero() {
base = fmt.Sprintf("看守中 · 上次同步 %s", s.LastRoundAt.Local().Format("15:04"))
}
return base + syncStatusLabel(sync, hasExtractor)
case supervisor.StateStarting:
return "啟動中…"
case supervisor.StateError:
return "暫時出錯,正在自動重試"
default:
return "尚未開始"
}
}