Files
arcrun-collector/cmd/arcrun-tray/main.go
T
Leo be5d6e3237 fix(t126): 萃取引擎/金鑰改每帳號一份——多帳號不再互相覆蓋
leo:「2 個 CF 帳號一個填 gemma4 一個填 Mistral,daemon 會用誰的?別人也加就會有一樣的疑問」
實證:金鑰只有機器層一份 ⇒ 後連的覆蓋前一個。
修:AccountConfig 加 Extractor/GeminiAPIKey/LLMModel;帳號層優先、空值繼承機器層;
既有 config 遷移(頂層金鑰複製到各帳號,冪等);連線只寫該帳號不覆蓋機器層;
托盤帳號標題顯示「· Gemini」/「· Claude」(一眼看出誰用誰)。
三模組 go test 全綠(總管親跑)。(實作=子 CC;驗證+commit=總管)
2026-07-29 14:36:44 +08:00

1038 lines
39 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"
"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/theme"
"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"` // 機器層級:claude/gemma
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 是雲端最低相容建置日期;與 collector/cloud_version.go 的 minCloudBuilt 保持同值。
// 雲端契約變更時兩處一起升。
const trayMinCloudBuilt = "2026-07-28"
// trayCloudVersionStale 判斷雲端是否需要更新(同 collector/cloudVersionStale 邏輯)。
// checkOK=false/health 不可達)回 false(靜默不判定)。
func trayCloudVersionStale(version string, checkOK bool) bool {
if !checkOK {
return false
}
if version == "" {
return true
}
date := strings.SplitN(version, "+", 2)[0]
return date < trayMinCloudBuilt
}
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
}
// t126:萃取設定寫帳號層,不覆蓋機器層
if r.Config.Extractor != "" {
cfg.Accounts[i].Extractor = r.Config.Extractor
}
if r.Config.GeminiAPIKey != "" {
cfg.Accounts[i].GeminiAPIKey = r.Config.GeminiAPIKey
}
if r.Config.LLMModel != "" {
cfg.Accounts[i].LLMModel = r.Config.LLMModel
}
return false
}
}
// 新帳號:t126 萃取設定直接寫帳號層
acc := accountCfg{
InstanceName: r.Config.InstanceName,
Email: r.Config.Email,
CypherURL: strings.TrimSuffix(strings.TrimSpace(r.Config.CypherURL), "/"),
Namespace: r.Config.Namespace,
Extractor: r.Config.Extractor, // t126
GeminiAPIKey: r.Config.GeminiAPIKey, // t126
LLMModel: r.Config.LLMModel, // t126
}
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 托盤可見性)。
// 帳號層有值時優先;兩者皆空回空字串。
func accountEngineLabel(acc accountCfg, defaultExtractor string) string {
extractor := strings.TrimSpace(acc.Extractor)
if extractor == "" {
extractor = strings.TrimSpace(defaultExtractor)
}
switch extractor {
case "gemma":
return " · Gemini"
case "claude":
return " · Claude"
}
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() {
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 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()
}
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
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), 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)
}
}))
}
items = append(items,
fyne.NewMenuItemSeparator(),
syncNowItem, // t98:立刻同步
// t104:「連上知識庫」改為「新增帳號」——精靈成功後 append 到 accounts,不換掉舊帳號
fyne.NewMenuItem(" 新增帳號…", func() { showConnectWizard() }),
fyne.NewMenuItemSeparator(),
versionItem,
// 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(theme.StorageIcon()) // TODO 換品牌 iconleo logo 進行中);Mac 建議 template 單色
}
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()
}
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 "尚未開始"
}
}