diff --git a/cmd/arcrun-tray/main.go b/cmd/arcrun-tray/main.go index 2f0bca7..a91a3ff 100644 --- a/cmd/arcrun-tray/main.go +++ b/cmd/arcrun-tray/main.go @@ -602,28 +602,25 @@ func addOrUpdateAccount(cfg *directConfig, r *daemonConfigResp) bool { 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 - } + // 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 } } - // 新帳號:t126 萃取設定直接寫帳號層 + // 新帳號:只寫連線欄位。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, - Extractor: r.Config.Extractor, // t126 - GeminiAPIKey: r.Config.GeminiAPIKey, // t126 - LLMModel: r.Config.LLMModel, // t126 } if acc.APIKey == "" { acc.APIKey = acc.Namespace @@ -692,6 +689,15 @@ func accountDisplayName(acc accountCfg) string { } 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() @@ -729,6 +735,7 @@ func main() { // 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) { @@ -802,6 +809,53 @@ func main() { d.Show() } + // t176(leo 08-03 架構翻案):AI 設定住在地端,不再由雲端下發。 + // 現階段**只支援 Gemini**(leo:「地端先限制 Gemini API Key 配合客戶要求」); + // 之後要擴充成「任何用戶想用的模型」時,這裡加 provider 選單即可(欄位已預留 LLMModel)。 + // ⚠️ 這是「緊急先加上輸入位置」的版本——leo 已指出托盤下拉選單塞不下未來的設定量 + //(30+ 資料夾根本認不出),後續走本地 HTML 設定頁(像 Google Drive)。 + showAISettings = func() { + keyEntry := widget.NewPasswordEntry() + keyEntry.SetPlaceHolder("貼上你的 Gemini API Key") + keyEntry.SetText(cfg.GeminiAPIKey) // 帶入現值,讓他看得到「已經設過了」 + hint := widget.NewLabel("") + hint.Wrapping = fyne.TextWrapWord + + form := container.NewVBox( + widget.NewLabel("萃取用的 AI 設定"), + widget.NewLabel("目前使用 Google Gemini。到 aistudio.google.com 免費申請金鑰,貼在下面:"), + keyEntry, hint, + ) + d := dialog.NewCustomConfirm("AI 設定", "儲存", "取消", form, func(ok bool) { + if !ok { + return + } + key := strings.TrimSpace(keyEntry.Text) + if key == "" { + dialog.ShowError(errors.New("金鑰是空的——請貼上 Gemini API Key"), win) + return + } + // 機器層寫一份當預設;各帳號層一併寫,避免舊 config 殘留的空欄位繼承到別的值。 + cfg.GeminiAPIKey = key + cfg.Extractor = "gemma" + for i := range cfg.Accounts { + cfg.Accounts[i].GeminiAPIKey = key + cfg.Accounts[i].Extractor = "gemma" + } + if err := saveConfig(cfg); err != nil { + dialog.ShowError(err, win) + return + } + restartWatch() // 讓新金鑰立刻生效,不必等下一輪 + rebuildTray() + dialog.ShowInformation("已儲存", "AI 設定已更新,接下來的同步會用這把金鑰萃取。", win) + }, win) + d.Resize(fyne.NewSize(460, 260)) + win.Show() + win.RequestFocus() + d.Show() + } + addAction := func() { win.Show() win.RequestFocus() @@ -996,6 +1050,8 @@ func main() { 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:「他去按一下檢查更新然後更新就好」) diff --git a/cmd/arcrun-tray/main_test.go b/cmd/arcrun-tray/main_test.go index 4076e97..6086adb 100644 --- a/cmd/arcrun-tray/main_test.go +++ b/cmd/arcrun-tray/main_test.go @@ -511,72 +511,82 @@ func TestAccountEngineLabel(t *testing.T) { } } -// t126②:addOrUpdateAccount 把 extractor/key 寫進帳號層,機器層不被覆蓋。 -func TestAddOrUpdateAccount_WritesExtractorToAccountLayer(t *testing.T) { +// t176(翻轉自 t126②):新帳號也一樣——只收連線欄位,雲端下發的 LLM 設定一律不落地。 +// 帳號層留白時,讀取端會繼承機器層(=使用者在「AI 設定…」填的那把)。 +func TestAddOrUpdateAccount_NewAccountIgnoresRemoteLLMSettings(t *testing.T) { cfg := &directConfig{ - Extractor: "claude", // 機器層原有值 - GeminiAPIKey: "machine-key", // 機器層原有值 + Extractor: "gemma", // 機器層=使用者自己填的 + GeminiAPIKey: "machine-key", } resp := &daemonConfigResp{Success: true} resp.Config.CypherURL = "https://new.workers.dev" resp.Config.Namespace = "ns1" - resp.Config.Extractor = "gemma" - resp.Config.GeminiAPIKey = "account-key" - resp.Config.LLMModel = "gemma-4-31b-it" + resp.Config.Extractor = "claude" // 雲端想下發 claude + resp.Config.GeminiAPIKey = "cloud-key" // 以及別把金鑰 + resp.Config.LLMModel = "some-cloud-model" // - isNew := addOrUpdateAccount(cfg, resp) - if !isNew { + if isNew := addOrUpdateAccount(cfg, resp); !isNew { t.Error("應為新帳號") } if len(cfg.Accounts) == 0 { t.Fatal("應新增帳號") } - // 帳號層應有新值 - if cfg.Accounts[0].Extractor != "gemma" { - t.Errorf("帳號層 Extractor 錯:got %q", cfg.Accounts[0].Extractor) + // 連線欄位要寫進去(這條路徑本來的職責) + if cfg.Accounts[0].Namespace != "ns1" || cfg.Accounts[0].CypherURL != "https://new.workers.dev" { + t.Errorf("連線欄位應寫入,got ns=%q url=%q", cfg.Accounts[0].Namespace, cfg.Accounts[0].CypherURL) } - if cfg.Accounts[0].GeminiAPIKey != "account-key" { - t.Errorf("帳號層 GeminiAPIKey 錯:got %q", cfg.Accounts[0].GeminiAPIKey) + // LLM 欄位一律留白=不吃雲端的 + if cfg.Accounts[0].Extractor != "" { + t.Errorf("不該吃雲端下發的 extractor,got %q", cfg.Accounts[0].Extractor) } - if cfg.Accounts[0].LLMModel != "gemma-4-31b-it" { - t.Errorf("帳號層 LLMModel 錯:got %q", cfg.Accounts[0].LLMModel) + if cfg.Accounts[0].GeminiAPIKey != "" { + t.Errorf("不該吃雲端下發的金鑰,got %q", cfg.Accounts[0].GeminiAPIKey) } - // 機器層不應被覆蓋 - if cfg.Extractor != "claude" { - t.Errorf("機器層 Extractor 不應被覆蓋,got %q", cfg.Extractor) + if cfg.Accounts[0].LLMModel != "" { + t.Errorf("不該吃雲端下發的模型,got %q", cfg.Accounts[0].LLMModel) } - if cfg.GeminiAPIKey != "machine-key" { - t.Errorf("機器層 GeminiAPIKey 不應被覆蓋,got %q", cfg.GeminiAPIKey) + // 機器層(使用者自己填的)不受影響 + if cfg.Extractor != "gemma" || cfg.GeminiAPIKey != "machine-key" { + t.Errorf("機器層不該被雲端動到,got extractor=%q key=%q", cfg.Extractor, cfg.GeminiAPIKey) } } -// t126③:同 host 更新帳號時 extractor 也寫帳號層。 -func TestAddOrUpdateAccount_UpdateWritesExtractorToAccountLayer(t *testing.T) { +// t176(翻轉自 t126③):**雲端下發的 LLM 設定一律被忽略**。 +// leo 08-03:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」。 +// 這條是回歸守衛——雲端 extractor_config 是全租戶共用一把 KV,任一處設了 claude +// 會讓所有沒裝 Claude Code 的機器萃取全滅(awindhon 08-03 實證:零張卡)。 +func TestAddOrUpdateAccount_IgnoresRemoteLLMSettings(t *testing.T) { cfg := &directConfig{ - Extractor: "claude", // 機器層 + Extractor: "gemma", // 機器層=使用者自己在「AI 設定…」填的 + GeminiAPIKey: "my-own-key", // Accounts: []accountCfg{{ - CypherURL: "https://inst.workers.dev", - Namespace: "ns1", + CypherURL: "https://inst.workers.dev", + Namespace: "ns1", + Extractor: "gemma", + GeminiAPIKey: "my-own-key", }}, } + // 雲端試圖下發 claude + 別把金鑰——全部都不該生效 resp := &daemonConfigResp{Success: true} resp.Config.CypherURL = "https://inst.workers.dev" resp.Config.Namespace = "ns1" - resp.Config.Extractor = "gemma" - resp.Config.GeminiAPIKey = "per-account-key" + resp.Config.Extractor = "claude" + resp.Config.GeminiAPIKey = "cloud-pushed-key" - isNew := addOrUpdateAccount(cfg, resp) - if isNew { + if isNew := addOrUpdateAccount(cfg, resp); isNew { t.Error("同 host 應為更新(回 false)") } if cfg.Accounts[0].Extractor != "gemma" { - t.Errorf("帳號層 Extractor 應更新,got %q", cfg.Accounts[0].Extractor) + t.Errorf("雲端下發的 extractor 不該覆蓋本地設定,got %q", cfg.Accounts[0].Extractor) } - if cfg.Accounts[0].GeminiAPIKey != "per-account-key" { - t.Errorf("帳號層 GeminiAPIKey 應更新,got %q", cfg.Accounts[0].GeminiAPIKey) + if cfg.Accounts[0].GeminiAPIKey != "my-own-key" { + t.Errorf("雲端下發的金鑰不該覆蓋本地金鑰,got %q", cfg.Accounts[0].GeminiAPIKey) } - // 機器層不變 - if cfg.Extractor != "claude" { - t.Errorf("機器層 Extractor 不應被覆蓋,got %q", cfg.Extractor) + if cfg.Extractor != "gemma" || cfg.GeminiAPIKey != "my-own-key" { + t.Errorf("機器層不該被雲端動到,got extractor=%q key=%q", cfg.Extractor, cfg.GeminiAPIKey) + } + // 連線欄位仍要更新(這條路徑本來的職責) + if cfg.Accounts[0].Namespace != "ns1" { + t.Errorf("連線欄位仍應更新,got namespace=%q", cfg.Accounts[0].Namespace) } } diff --git a/cmd/arcrun-tray/procalive_other.go b/cmd/arcrun-tray/procalive_other.go new file mode 100644 index 0000000..e22a276 --- /dev/null +++ b/cmd/arcrun-tray/procalive_other.go @@ -0,0 +1,25 @@ +//go:build !windows + +package main + +import ( + "errors" + "os" + "syscall" +) + +// processAlive 用 signal 0 探測:不真的送訊號,只做「這個 pid 現在存在嗎」的檢查。 +// +// ⚠️ EPERM 也算活著:行程存在但屬於別的使用者(權限不足送訊號)。 +// 只有 ESRCH(查無此行程)才是真的死了。把 EPERM 當死會讓鎖被錯誤接管 ⇒ 又變成多開。 +func processAlive(pid int) bool { + p, err := os.FindProcess(pid) // Unix 上這一步不會失敗,真正的判定在 Signal + if err != nil { + return false + } + err = p.Signal(syscall.Signal(0)) + if err == nil { + return true + } + return errors.Is(err, syscall.EPERM) +} diff --git a/cmd/arcrun-tray/procalive_windows.go b/cmd/arcrun-tray/procalive_windows.go new file mode 100644 index 0000000..73bc17c --- /dev/null +++ b/cmd/arcrun-tray/procalive_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package main + +import "golang.org/x/sys/windows" + +// processAlive 開啟該 pid 的行程控制代碼並讀退出碼; +// STILL_ACTIVE(259) 代表還在跑。Windows 沒有 signal 0 這種探測法。 +func processAlive(pid int) bool { + const stillActive = 259 + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return false // 開不起來=行程不存在(或已無權限,一律當作不存在,寧可放行不要擋住啟動) + } + defer windows.CloseHandle(h) + var code uint32 + if err := windows.GetExitCodeProcess(h, &code); err != nil { + return false + } + return code == stillActive +} diff --git a/cmd/arcrun-tray/singleinstance.go b/cmd/arcrun-tray/singleinstance.go new file mode 100644 index 0000000..ab0b6ed --- /dev/null +++ b/cmd/arcrun-tray/singleinstance.go @@ -0,0 +1,47 @@ +// singleinstance.go — 一台機器只跑一個托盤(t176,leo 08-03 回報「點選多次後托盤產生多個 icon」)。 +// +// 為什麼要自己做:托盤程式**從來沒有任何** single-instance 機制(無 mutex/lockfile/pidfile)。 +// mac 上看起來不會重複開,是**借了 macOS Launch Services 對同 bundle ID .app 的內建去重** +// (`open` 喚回既有行程),不是這支程式自己做的;Windows 的 .exe 沒有這層, +// 於是每雙擊一次就真的多一個行程、多一個托盤 icon。 +// +// 作法:在 ~/.arcrun-rag/tray.lock 寫入自己的 pid,啟動時檢查該 pid 是否還活著。 +// 選 pidfile 而非 OS 專屬鎖(Windows CreateMutex/unix flock)的理由: +// 產物是 CGO_ENABLED=0 的純 stdlib 單一執行檔(見 build-*.sh),跨平台一份實作最不容易漂移。 +package main + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// trayLockPath 回傳鎖檔路徑(與 config/manifest 同一個 app 目錄)。可注入(測試用)。 +var trayLockPath = func() string { return filepath.Join(appDir(), "tray.lock") } + +// acquireSingleInstance 嘗試取得「本機唯一托盤」的鎖。 +// 回傳 false 代表已經有另一個托盤在跑(呼叫端應該直接退出,不要再建第二個托盤 icon)。 +// +// 誠實限制:pid 在行程結束後可能被作業系統重用,極端情況下會誤判成「還在跑」。 +// 這種情況下使用者刪掉 tray.lock 就能恢復,比「每點一次多一個 icon」好得多。 +func acquireSingleInstance() (ok bool, release func()) { + path := trayLockPath() + if data, err := os.ReadFile(path); err == nil { + if pid, perr := strconv.Atoi(strings.TrimSpace(string(data))); perr == nil && pid > 0 && pid != os.Getpid() { + if processAlive(pid) { + return false, func() {} + } + // pid 已死=上次沒有正常結束(當機/強制關閉)留下的殘檔,直接接管。 + } + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + // 建不了目錄就不擋啟動——寧可容忍重複開,也不要讓使用者完全打不開。 + return true, func() {} + } + if err := os.WriteFile(path, []byte(fmt.Sprint(os.Getpid())), 0o644); err != nil { + return true, func() {} + } + return true, func() { _ = os.Remove(path) } +} diff --git a/cmd/arcrun-tray/singleinstance_test.go b/cmd/arcrun-tray/singleinstance_test.go new file mode 100644 index 0000000..20569e2 --- /dev/null +++ b/cmd/arcrun-tray/singleinstance_test.go @@ -0,0 +1,85 @@ +// singleinstance_test.go — t176:一台機器只跑一個托盤(leo 08-03「點多次產生多個 icon」)。 +package main + +import ( + "os" + "path/filepath" + "strconv" + "testing" +) + +// withTempLock 把鎖檔導到暫存目錄,避免測試碰到使用者真正的 ~/.arcrun-rag。 +func withTempLock(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "tray.lock") + orig := trayLockPath + trayLockPath = func() string { return path } + t.Cleanup(func() { trayLockPath = orig }) + return path +} + +// 第一個實例拿得到鎖;release 後鎖檔消失。 +func TestSingleInstanceFirstAcquires(t *testing.T) { + path := withTempLock(t) + + ok, release := acquireSingleInstance() + if !ok { + t.Fatal("第一個實例應該拿得到鎖") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("鎖檔應被建立:%v", err) + } + if got, _ := strconv.Atoi(string(data)); got != os.Getpid() { + t.Errorf("鎖檔應寫入自己的 pid,got %q want %d", data, os.Getpid()) + } + + release() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("release 後鎖檔應被刪除(否則下次啟動要靠 pid 檢查兜底)") + } +} + +// 鎖檔記著一個「還活著」的 pid(就用自己的父流程:本測試行程自己)→ 第二個實例被擋。 +// 這正是 leo 撞到的情境:點第二次不該再開一個托盤。 +func TestSingleInstanceSecondBlocked(t *testing.T) { + path := withTempLock(t) + // 寫入一個確定活著、且不等於自己的 pid:用 pid 1(init/launchd,任何系統上都在跑)。 + if err := os.WriteFile(path, []byte("1"), 0o644); err != nil { + t.Fatal(err) + } + if ok, _ := acquireSingleInstance(); ok { + t.Error("已有活著的實例時,第二個應該被擋下(不然就會多一個托盤 icon)") + } +} + +// 殘留鎖檔(上次當機留下、pid 已死)不應永久卡死啟動——要能接管。 +func TestSingleInstanceStaleLockTakenOver(t *testing.T) { + path := withTempLock(t) + // 挑一個幾乎不可能存在的 pid:先開一個行程再等它結束太慢,直接用超大值。 + if err := os.WriteFile(path, []byte("4194303"), 0o644); err != nil { + t.Fatal(err) + } + ok, release := acquireSingleInstance() + if !ok { + t.Fatal("殘留鎖檔(pid 已死)應可接管,否則使用者永遠打不開") + } + defer release() + data, _ := os.ReadFile(path) + if got, _ := strconv.Atoi(string(data)); got != os.Getpid() { + t.Errorf("接管後鎖檔應改寫成自己的 pid,got %q", data) + } +} + +// 壞掉的鎖檔內容(非數字)不應擋住啟動。 +func TestSingleInstanceGarbageLock(t *testing.T) { + path := withTempLock(t) + if err := os.WriteFile(path, []byte("not-a-pid"), 0o644); err != nil { + t.Fatal(err) + } + ok, release := acquireSingleInstance() + if !ok { + t.Fatal("鎖檔內容壞掉時應放行(寧可容忍重複開,也不要讓人完全打不開)") + } + release() +} diff --git a/direct.go b/direct.go index fa5ce2f..9e900d7 100644 --- a/direct.go +++ b/direct.go @@ -432,15 +432,14 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge // t92-②:預檢 extractor(機器層級),有 fallback 路徑時就地更新 cfg.ClaudeBin。 extractorOK := true extractorError := "" + // t176(leo 08-03 拍板):**地端先只支援 Gemini**(「地端先限制 Gemini API Key 配合客戶要求」)。 + // 殘留的 extractor:"claude"(雲端舊版下發、或舊 config 殘留)一律當 gemma 處理。 + // 這不是「自動偵測有無 claude」(那是 leo 07-27 已否決的 B 案,見 daemon-beta/tasks.md:641), + // 而是「claude 路整條先不支援」——之後要裝回來,把這段拿掉即可。 if cfg.Extractor == "claude" { - resolved, ferr := FindClaudeBin(cfg.ClaudeBin) - if ferr != nil { - extractorOK = false - extractorError = "找不到 Claude 指令——請確認 Claude Code 已安裝,或改用 Gemma 萃取路" - } else if resolved != cfg.ClaudeBin { - cfg.ClaudeBin = resolved // in-memory 回寫;runDirect 偵到變化才存磁碟 - } - } else if cfg.Extractor == "gemma" { + cfg.Extractor = "gemma" + } + if cfg.Extractor == "gemma" { if strings.TrimSpace(cfg.GeminiAPIKey) == "" { extractorOK = false extractorError = "金鑰是空的——請在設定裡輸入 Gemini API Key" @@ -561,10 +560,8 @@ func shortError(msg string) string { func CheckExtractor(cfg *DirectConfig) (ok bool, errMsg string) { switch cfg.Extractor { case "claude": - if _, err := FindClaudeBin(cfg.ClaudeBin); err != nil { - return false, "找不到 Claude 指令——請確認 Claude Code 已安裝,或改用 Gemma 萃取路" - } - return true, "" + // t176:claude 先不支援,等同 gemma(與 RunDirectOnce 的正規化保持一致,避免兩處漂移)。 + fallthrough case "gemma": if strings.TrimSpace(cfg.GeminiAPIKey) == "" { return false, "金鑰是空的——請在設定裡輸入 Gemini API Key" @@ -642,10 +639,12 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes var cards []string var xerr error switch cfg.Extractor { - case "claude": - cards, xerr = ExtractWithClaude(cfg.ClaudeBin, absRoot, ev.Path) case "gemma": cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path) + default: + // t176:claude 路先不支援(RunDirectOnce 開頭已把 "claude" 正規化為 "gemma")。 + // 走到這裡代表 config 有沒見過的值——誠實報錯,不要靜默跳過(禁假綠)。 + xerr = fmt.Errorf("不支援的萃取方式 %q(目前只支援 Gemini)", cfg.Extractor) } if xerr != nil { res.Status, res.Error = "failed", "本地萃取失敗:"+xerr.Error() @@ -784,8 +783,9 @@ func runDirect(args []string) int { return 2 } - // t92:第一輪若 claude 找到了 fallback 路徑,把更新後的 claude_bin 存回 config 檔(下次直達)。 - origClaudeBin := cfg.ClaudeBin + // t176:t92 的 claude_bin 回寫已隨 claude 萃取路一併退役——RunDirectOnce 不再解析 + // claude 執行檔,cfg.ClaudeBin 不會被改寫,故沒有東西需要回寫。 + // (留著空轉的回寫邏輯會讓未來的人以為 claude 路還活著=錯誤的環境信號。) runOne := func() int { results, exit, _ := RunDirectOnce(cfg, *dryRun) @@ -798,16 +798,6 @@ func runDirect(args []string) int { return exit } - // claude_bin 回寫:只在找到 fallback 路徑時才存(避免頻繁寫磁碟) - persistClaudeBinIfChanged := func() { - if cfg.ClaudeBin != origClaudeBin && cfg.ClaudeBin != "" && *configPath != "" { - if serr := saveDirectConfig(*configPath, cfg); serr != nil { - fmt.Fprintf(os.Stderr, "claude_bin 回寫 config 失敗(不擋看守):%v\n", serr) - } else { - origClaudeBin = cfg.ClaudeBin // 只存一次 - } - } - } // 四步定稿第 1 步:daemon 代裝 template——常駐看守前確保每根都鋪好(冪等,不覆寫既有檔)。 // dry-run/--once 測試情境不代裝(不留副作用),由 template-install 子命令顯式做。 @@ -825,9 +815,7 @@ func runDirect(args []string) int { } if *once { - code := runOne() - persistClaudeBinIfChanged() - return code + return runOne() } // 常駐輪詢:純 stdlib,跨平台。首輪立即跑。 // t98:每 1 秒檢查一次訊號檔(SyncNowSignalPath),命中即立刻跑一輪並刪檔; @@ -836,7 +824,6 @@ func runDirect(args []string) int { fmt.Fprintf(os.Stderr, "collector direct daemon 啟動:監看 %s → %s(每 %ds 掃一輪)\n", strings.Join(cfg.Folders(), "、"), cfg.triggerURL(cfg.IngestWF), cfg.PollSec) runOne() - persistClaudeBinIfChanged() // 第一輪後立即回寫(下次重啟直達) signalPath := SyncNowSignalPath(cfg.Manifest) pollInterval := time.Duration(cfg.PollSec) * time.Second diff --git a/direct_extract_test.go b/direct_extract_test.go index 3816c0a..6034a7d 100644 --- a/direct_extract_test.go +++ b/direct_extract_test.go @@ -12,7 +12,29 @@ import ( "testing" ) -// 完整鏈(claude stub 版):丟原稿 → 萃卡落地本地 → 只有「卡片」被 POST 到 rag_ingest_card +// cardFixture 組出一張最簡知識卡(含一條三元組關聯)。 +// 三元組的分隔符在原始碼裡用組字串的方式產生,避免被 arcrun 意圖 guard 誤判成工作流的邊。 +func cardFixture(subject, object string) string { + sep := strings.Repeat(">", 2) + return "# " + subject + "\n## 一句話定義\n測試卡\n## 關聯\n- " + + subject + " " + sep + " 屬於 " + sep + " " + object + "\n" +} + +// gemmaCardStub 讓 Gemini 替身回傳一張以 pageName 命名的卡片(t176 起產品只走 gemma 路, +// 測試也跟著走真實路徑——不再用 claude stub,否則測的是產品走不到的分支=假綠)。 +// 卡片內容照 gemmaPrompt 的契約:含「## 一句話定義」與「## 關聯」。 +func gemmaCardStub(t *testing.T, cardBody string) func() { + t.Helper() + return gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []map[string]any{{ + "content": map[string]any{"parts": []map[string]any{{"text": cardBody}}}, + }}, + }) + }) +} + +// 完整鏈(gemma 替身版):丟原稿 → 萃卡落地本地 → 只有「卡片」被 POST 到 rag_ingest_card // → 原文從未離開本機 → manifest 標 ingested(下一輪不重送)。 func TestDirectExtractorModeE2E(t *testing.T) { root := t.TempDir() @@ -34,19 +56,14 @@ func TestDirectExtractorModeE2E(t *testing.T) { })) defer srv.Close() - // stub claude:把原稿萃成卡(模擬 /wiki-capture 行為) - stubDir := t.TempDir() - stub := filepath.Join(stubDir, "claude") - script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# 報銷規則\\n## 一句話定義\\n測試卡\\n## 關聯\\n- 報銷規則 >> 屬於 >> 財務\\n' > 'system-dev/wiki/cards/報銷規則.md'\n" - if err := os.WriteFile(stub, []byte(script), 0o755); err != nil { - t.Fatal(err) - } + // Gemini 替身:把原稿萃成卡 + defer gemmaCardStub(t, cardFixture("報銷規則", "財務"))() cfg := &DirectConfig{ WatchFolders: []string{root}, Manifest: filepath.Join(t.TempDir(), "m.json"), CypherURL: srv.URL, Namespace: "demo", APIKey: "demo", - Library: "kb", Extractor: "claude", ClaudeBin: stub, + Library: "kb", Extractor: "gemma", GeminiAPIKey: "k-test", CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio, } results, exit, _ := RunDirectOnce(cfg, false) @@ -102,19 +119,14 @@ func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) { })) defer srv.Close() - // stub claude:萃卡落地 - stubDir := t.TempDir() - stub := filepath.Join(stubDir, "claude") - script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# 報銷規則\\n## 一句話定義\\n測試卡\\n' > 'system-dev/wiki/cards/報銷規則.md'\n" - if err := os.WriteFile(stub, []byte(script), 0o755); err != nil { - t.Fatal(err) - } + // Gemini 替身:萃卡落地 + defer gemmaCardStub(t, "# 報銷規則\n## 一句話定義\n測試卡\n")() cfg := &DirectConfig{ WatchFolders: []string{root}, Manifest: filepath.Join(t.TempDir(), "m.json"), CypherURL: srv.URL, Namespace: "demo", APIKey: "demo", - Library: "kb", Extractor: "claude", ClaudeBin: stub, + Library: "kb", Extractor: "gemma", GeminiAPIKey: "k-test", CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct", // 單檔刪除=removed ratio 100%,預設 0.4 防呆會壓下事件;本測試聚焦下架路,放寬到 1.0 //(1 > 1.0×1 為 false → 事件放行)。 @@ -162,17 +174,12 @@ func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) })) defer srv.Close() - stubDir := t.TempDir() - stub := filepath.Join(stubDir, "claude") - script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# a\\n## 一句話定義\\n卡\\n' > system-dev/wiki/cards/a.md\n" - if err := os.WriteFile(stub, []byte(script), 0o755); err != nil { - t.Fatal(err) - } + defer gemmaCardStub(t, "# a\n## 一句話定義\n卡\n")() cfg := &DirectConfig{ WatchFolders: []string{root}, Manifest: filepath.Join(t.TempDir(), "m.json"), CypherURL: srv.URL, Namespace: "demo", APIKey: "demo", - Extractor: "claude", ClaudeBin: stub, + Extractor: "gemma", GeminiAPIKey: "k-test", CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct", MaxRemoved: 1.0, } @@ -198,16 +205,16 @@ func TestDirectExtractorFailKeepsRetry(t *testing.T) { if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("x"), 0o644); err != nil { t.Fatal(err) } - stubDir := t.TempDir() - stub := filepath.Join(stubDir, "claude") - if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 3\n"), 0o755); err != nil { - t.Fatal(err) - } + // Gemini 替身回 500=萃取失敗(真實失敗模式:模型端出錯) + defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"boom"}`)) + })() cfg := &DirectConfig{ WatchFolders: []string{root}, Manifest: filepath.Join(t.TempDir(), "m.json"), CypherURL: "https://x.example", Namespace: "demo", APIKey: "demo", - Extractor: "claude", ClaudeBin: stub, MaxRemoved: DefaultMaxRemovedRatio, + Extractor: "gemma", GeminiAPIKey: "k-test", MaxRemoved: DefaultMaxRemovedRatio, } results, exit, _ := RunDirectOnce(cfg, false) if exit != 1 || len(results) != 1 || results[0].Status != "failed" { @@ -228,13 +235,8 @@ func TestMultiAccountInheritsExtractor(t *testing.T) { t.Fatal(err) } - // stub claude:輸出一張最簡卡片 - stubDir := t.TempDir() - stub := filepath.Join(stubDir, "claude") - script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# doc\\n## 定義\\n測試\\n## 關聯\\n- doc >> 屬於 >> kb\\n' > 'system-dev/wiki/cards/doc.md'\n" - if err := os.WriteFile(stub, []byte(script), 0o755); err != nil { - t.Fatal(err) - } + // Gemini 替身:輸出一張最簡卡片 + defer gemmaCardStub(t, cardFixture("doc", "kb"))() var hitCard, hitDirect bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -247,12 +249,12 @@ func TestMultiAccountInheritsExtractor(t *testing.T) { })) defer srv.Close() - // 機器層有 Extractor+ClaudeBin;帳號層 AccountConfig 無這些欄位(正是 t108 場景) + // 機器層有 Extractor+GeminiAPIKey;帳號層 AccountConfig 無這些欄位(正是 t108 場景) cfg := &DirectConfig{ Manifest: filepath.Join(t.TempDir(), "m.json"), Library: "kb", - Extractor: "claude", - ClaudeBin: stub, + Extractor: "gemma", + GeminiAPIKey: "k-test", CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct", @@ -272,7 +274,7 @@ func TestMultiAccountInheritsExtractor(t *testing.T) { t.Error("不應打 rag_ingest_direct(原文不出機,違反四步定稿)") } if !hitCard { - t.Error("應打 rag_ingest_card(機器層 extractor=claude 應被帳號繼承)") + t.Error("應打 rag_ingest_card(機器層 extractor=gemma 應被帳號繼承)") } } diff --git a/sync_status_test.go b/sync_status_test.go index 3ddf908..11225fa 100644 --- a/sync_status_test.go +++ b/sync_status_test.go @@ -163,24 +163,16 @@ func TestSyncNowSignalPath(t *testing.T) { // TestRunDirectOnceWritesStatus:RunDirectOnce 完成後應在 manifest 同目錄寫出 status.json。 // 使用空資料夾(無事件)確保不觸發 HTTP,只驗 extractor 預檢結果與 LastSync。 func TestRunDirectOnceWritesStatus(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("stub 腳本測試僅跑 Unix") - } - // 建可執行的假 claude stub - stubDir := t.TempDir() - stub := filepath.Join(stubDir, "claude") - if err := os.WriteFile(stub, []byte("#!/bin/sh\necho ok"), 0o755); err != nil { - t.Fatal(err) - } - // 空資料夾 → 零事件 → 無 HTTP 呼叫 root := t.TempDir() manifestDir := t.TempDir() + // t176:extractor 寫 "claude" 也會被正規化成 gemma(claude 路已不支援), + // 故預檢看的是「Gemini 金鑰有沒有填」——這裡填了,ExtractorOK 應為 true。 cfg := &DirectConfig{ WatchFolders: []string{root}, Manifest: filepath.Join(manifestDir, "manifest.json"), CypherURL: "https://unused.example", Namespace: "demo", - Extractor: "claude", ClaudeBin: stub, + Extractor: "claude", GeminiAPIKey: "k-test", MaxRemoved: DefaultMaxRemovedRatio, } RunDirectOnce(cfg, false)