133f5add46
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>
1263 lines
50 KiB
Go
1263 lines
50 KiB
Go
// arcrun-tray — Arcrun RAG 桌面托盤殼(CP-1 第 6 關 daemon 產品化,leo 2026-07-21 定 fyne)。
|
||
//
|
||
// 目的:讓「不開 terminal 的人」也能裝、開關、選資料夾——一個選單列/系統匣 icon 就是全部。
|
||
// 它看守 `collector direct`(子行程,見 supervisor 套件的誠實界定)並顯示狀態。
|
||
//
|
||
// ⚠️ 本檔是 fyne GUI(CGo+系統 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 多帳號同時看守)。
|
||
// t126:Extractor/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"` // t92:collector fallback 找到後回寫
|
||
GeminiAPIKey string `json:"gemini_api_key,omitempty"` // t108:gemma 路 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"`
|
||
// t104:per-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 的 minCloudBuilt/minCloudRelease 保持同值——**兩處一起升**。
|
||
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
|
||
}
|
||
|
||
// isSemverLike/compareSemver:與 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 依萃取狀態決定要在「看守中」後面追加什麼文案。
|
||
// hasExtractor=config 有設定 extractor(claude/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.go;t77-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
|
||
}
|
||
|
||
// ── t54(leo 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("網址看起來不太對,請從信裡或瀏覽器網址列複製整段")
|
||
}
|
||
// portal(GUI)與 cypher(API)是不同子域:用戶手上的是 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 的 host;host 不同代表換了知識庫實例。
|
||
// 只比 host 不比 scheme/path——同實例改密碼時 URL 完全不變,不觸發資料夾清空。
|
||
// 兩端有任一為空(首次設定或異常值)時回 false,不誤觸清空。
|
||
//
|
||
// t86(leo 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 代表是全新帳號(首次加入)。
|
||
// t126:Extractor/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
|
||
}
|
||
// t176(leo 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」的矛盾。
|
||
// t181(leo 08-04):「上方如果用 workers AI 就**不顯示**,如果用 Gemini 會顯示 Gemini」
|
||
// ⇒ 預設路徑不佔版面(那是常態,不需要標註);只有主動選了別的引擎才標出來。
|
||
// 空值也走這條——沒設 extractor=新用戶=走 workers-ai(direct.go 預設邏輯)。
|
||
case "workers-ai", "":
|
||
return ""
|
||
// t178(leo 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 就自動跳;之後也能從選單「重新連線…」再開。
|
||
// t75(2026-07-28,leo 同事真機實測):**連線失敗重試時必須把使用者剛打的內容帶回來**。
|
||
//
|
||
// 原本的寫法是失敗後直接呼叫 `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
|
||
}
|
||
// t104:append 新帳號或更新同 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()
|
||
}
|
||
|
||
// t176(leo 08-03 架構翻案):AI 設定住在地端,不再由雲端下發。
|
||
// 現階段**只支援 Gemini**(leo:「地端先限制 Gemini API Key 配合客戶要求」);
|
||
// 之後要擴充成「任何用戶想用的模型」時,這裡加 provider 選單即可(欄位已預留 LLMModel)。
|
||
// ⚠️ 這是「緊急先加上輸入位置」的版本——leo 已指出托盤下拉選單塞不下未來的設定量
|
||
//(30+ 資料夾根本認不出),後續走本地 HTML 設定頁(像 Google Drive)。
|
||
// t181(leo 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
|
||
|
||
// t150(leo 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
|
||
|
||
// t104:per-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() }),
|
||
// t176:AI 設定移到地端(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.go:mac 走 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)。
|
||
// t91:label 設定統一在 rebuildTray 裡做(同時讀取 status.json);
|
||
// 此版 fyne 無 fyne.Do,tray 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:
|
||
// t92:extractor 預檢失敗時,直接換掉「看守中」文案
|
||
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 "尚未開始"
|
||
}
|
||
}
|