t176 daemon:LLM 設定移回地端+托盤單一實例(leo 08-03 架構翻案)

leo 回報 Windows daemon 三症狀,查完 ①③ 同根,根在「雲端控制地端」這個設計。

【①③ 真兇】雲端 extractor_config 是**全租戶共用一把 KV**
(arcrun:portal.ts:43 portalTenant = worker 層級變數,不分用戶),
任一處設了 claude → 所有人的 daemon 都收到 claude。沒裝 Claude Code 的機器
FindClaudeBin 失敗 → 每檔萃取 failed、一張卡都沒建;想去 portal 改回 gemini,
checkbox 卻恆 disabled(claude_available 恆 false,因 daemon 從未實作
report-capabilities 回報 → daemon_caps KV 永遠空)⇒ 用戶自己解不開。
awindhon 實證:雲端同步成功、Gemini key 有效、零張卡,config.json extractor="claude"。

【leo 裁示】「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後
控制地端」「地端先限制 Gemini API Key 配合客戶要求」「雲端就是 Workers AI」。

本次(daemon 端):
- addOrUpdateAccount 不再接受雲端下發的 extractor/gemini_api_key/llm_model,
  只收連線欄位。t126「每帳號一份萃取設定」照舊保留——t126 修的是「存在哪一層」,
  本次改的是「值從哪來」,兩者正交。
- 托盤新增「AI 設定…」:使用者自己填 Gemini API Key,寫本地 config 後立即生效。
- 萃取一律走 gemini:殘留的 extractor:"claude" 正規化為 gemma;claude 路退役。
  ⚠️ 這不是「自動偵測有無 claude」(leo 07-27 已否決的 B 案),是整條路先不支援。
- 清掉隨之死亡的 claude_bin 回寫(死代碼=錯誤的環境信號)。
- 托盤單一實例(症狀②):pidfile + 跨平台 processAlive。
  mac 之前不多開是借 macOS Launch Services 的巧合,Windows 沒有該層 ⇒ 每點一次多一個 icon。
  Unix 用 signal 0(EPERM 也算活著,測試抓到的實際 bug)/Windows 用 OpenProcess+ExitCode。

測試:collector 全綠、tray 全綠。5 個原本用 claude stub 的測試改走**真實 gemma 路**
(httptest 替身注入 gemmaBaseURL),不是改斷言充綠;t126②③ 兩案翻轉成
「雲端下發一律被忽略」的回歸守衛;新增 4 案 single-instance。

未送達:本 commit 只到 code,尚未打包出貨;雲端側(刪 portal AI 設定區塊、
extractor 下發、admin/extractor)未動,待部署授權。CP rag-beta 步驟仍為 ◐。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 17:48:10 +08:00
parent 7ecd740f82
commit f903a3f53f
9 changed files with 357 additions and 132 deletions
+70 -14
View File
@@ -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
}
// t176leo 08-03 翻案):**不再接受雲端下發的 LLM 設定**。
// 地端用哪個模型/哪把金鑰,一律由使用者在托盤「AI 設定…」自己填。
// t126 的「每帳號一份萃取設定」照舊保留(欄位還在、讀取仍帳號層優先),
// 這裡拔掉的只是「值**從雲端來**」這條來源——t126 修的是存在哪一層,本次改的是值從哪來。
// 為什麼拔(08-03 實證):雲端 extractor_config 是**全租戶共用一把 KV**
// arcrun:portal.ts:43 portalTenant 為 worker 層級變數,不分用戶),
// 任一處設了 claude → 所有人的 daemon 都收到;沒裝 Claude Code 的機器萃取全滅,
// 而 portal 的 claude 勾選框又恆 disabled ⇒ 用戶自己解不開(awindhon 實證:零張卡)。
// leo:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」
return false
}
}
// 新帳號: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()
}
// t176leo 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() }),
// t176AI 設定移到地端(leo 08-03:「地端要用什麼模型就在 daemon 上輸入 API Key 設置」)
fyne.NewMenuItem("AI 設定…", func() { showAISettings() }),
fyne.NewMenuItemSeparator(),
versionItem,
checkUpdateItem, // t150:手動檢查更新(leo:「他去按一下檢查更新然後更新就好」)
+46 -36
View File
@@ -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("不該吃雲端下發的 extractorgot %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)
}
}
+25
View File
@@ -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)
}
+21
View File
@@ -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
}
+47
View File
@@ -0,0 +1,47 @@
// singleinstance.go — 一台機器只跑一個托盤(t176,leo 08-03 回報「點選多次後托盤產生多個 icon」)。
//
// 為什麼要自己做:托盤程式**從來沒有任何** single-instance 機制(無 mutexlockfilepidfile)。
// mac 上看起來不會重複開,是**借了 macOS Launch Services 對同 bundle ID .app 的內建去重**
// (`open` 喚回既有行程),不是這支程式自己做的;Windows 的 .exe 沒有這層,
// 於是每雙擊一次就真的多一個行程、多一個托盤 icon。
//
// 作法:在 ~/.arcrun-rag/tray.lock 寫入自己的 pid,啟動時檢查該 pid 是否還活著。
// 選 pidfile 而非 OS 專屬鎖(Windows CreateMutexunix 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) }
}
+85
View File
@@ -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("鎖檔應寫入自己的 pidgot %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 1init/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("接管後鎖檔應改寫成自己的 pidgot %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()
}
+17 -30
View File
@@ -432,15 +432,14 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
// t92-②:預檢 extractor(機器層級),有 fallback 路徑時就地更新 cfg.ClaudeBin。
extractorOK := true
extractorError := ""
// t176leo 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, ""
// t176claude 先不支援,等同 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:
// t176claude 路先不支援(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
// t176t92 的 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
+43 -41
View File
@@ -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 應被帳號繼承)")
}
}
+3 -11
View File
@@ -163,24 +163,16 @@ func TestSyncNowSignalPath(t *testing.T) {
// TestRunDirectOnceWritesStatusRunDirectOnce 完成後應在 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()
// t176extractor 寫 "claude" 也會被正規化成 gemmaclaude 路已不支援),
// 故預檢看的是「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)