From be5d6e32373cf38f79db98da53ad4dc10cadd482 Mon Sep 17 00:00:00 2001 From: richblack Date: Wed, 29 Jul 2026 14:36:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(t126):=20=E8=90=83=E5=8F=96=E5=BC=95?= =?UTF-8?q?=E6=93=8E/=E9=87=91=E9=91=B0=E6=94=B9=E6=AF=8F=E5=B8=B3?= =?UTF-8?q?=E8=99=9F=E4=B8=80=E4=BB=BD=E2=80=94=E2=80=94=E5=A4=9A=E5=B8=B3?= =?UTF-8?q?=E8=99=9F=E4=B8=8D=E5=86=8D=E4=BA=92=E7=9B=B8=E8=A6=86=E8=93=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo:「2 個 CF 帳號一個填 gemma4 一個填 Mistral,daemon 會用誰的?別人也加就會有一樣的疑問」 實證:金鑰只有機器層一份 ⇒ 後連的覆蓋前一個。 修:AccountConfig 加 Extractor/GeminiAPIKey/LLMModel;帳號層優先、空值繼承機器層; 既有 config 遷移(頂層金鑰複製到各帳號,冪等);連線只寫該帳號不覆蓋機器層; 托盤帳號標題顯示「· Gemini」/「· Claude」(一眼看出誰用誰)。 三模組 go test 全綠(總管親跑)。(實作=子 CC;驗證+commit=總管) --- cmd/arcrun-tray/ArcrunRAG-mac-unsigned.zip | 4 +- cmd/arcrun-tray/main.go | 91 +++++++++++--- cmd/arcrun-tray/main_test.go | 115 ++++++++++++++--- direct.go | 30 +++++ direct_multi_test.go | 136 +++++++++++++++++++++ 5 files changed, 343 insertions(+), 33 deletions(-) diff --git a/cmd/arcrun-tray/ArcrunRAG-mac-unsigned.zip b/cmd/arcrun-tray/ArcrunRAG-mac-unsigned.zip index 097da18..851656b 100644 --- a/cmd/arcrun-tray/ArcrunRAG-mac-unsigned.zip +++ b/cmd/arcrun-tray/ArcrunRAG-mac-unsigned.zip @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:60e3460d4d9f2c52ac9fa60a8b481936059880c2923d5719421766e80e1caa6d -size 16695364 +oid sha256:001ff68139aa56c77c00669677a989ae0a6df7884bbefd67fb7ebab586a1cadf +size 16805931 diff --git a/cmd/arcrun-tray/main.go b/cmd/arcrun-tray/main.go index 0fe33e3..a107c74 100644 --- a/cmd/arcrun-tray/main.go +++ b/cmd/arcrun-tray/main.go @@ -60,6 +60,7 @@ func versionLabel() string { } // accountCfg 是單一帳號的連線設定(t104 多帳號同時看守)。 +// t126:Extractor/GeminiAPIKey/LLMModel 支援帳號層覆蓋——帳號有值時優先,空值繼承機器層。 type accountCfg struct { InstanceName string `json:"instance_name,omitempty"` Email string `json:"email,omitempty"` @@ -68,6 +69,10 @@ type accountCfg struct { 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 同結構)。 @@ -210,6 +215,28 @@ func loadConfig() *directConfig { // 寫回磁碟(冪等:下次讀 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() // 尚未連線的預設值(不觸發遷移) } @@ -349,18 +376,24 @@ func shortCypherHost(cypherURL string) string { // 輸入「知識庫網址 + 帳號密碼」→ 打 /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 struct { - CypherURL string `json:"cypher_url"` - Namespace string `json:"namespace"` - Library string `json:"library"` - Extractor string `json:"extractor"` - Email string `json:"email"` - InstanceName string `json:"instance_name"` - } `json:"config"` - Error string `json:"error"` + Success bool `json:"success"` + Config daemonConfig `json:"config"` + Error string `json:"error"` } // normalizePortalURL 把用戶貼的東西變成可打的 origin: @@ -558,6 +591,7 @@ func isConnected(cfg *directConfig) bool { // 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 { @@ -569,26 +603,33 @@ func addOrUpdateAccount(cfg *directConfig, r *daemonConfigResp) bool { if r.Config.InstanceName != "" { cfg.Accounts[i].InstanceName = r.Config.InstanceName } + // t126:萃取設定寫帳號層,不覆蓋機器層 if r.Config.Extractor != "" { - cfg.Extractor = r.Config.Extractor // 機器層級 + cfg.Accounts[i].Extractor = r.Config.Extractor + } + if r.Config.GeminiAPIKey != "" { + cfg.Accounts[i].GeminiAPIKey = r.Config.GeminiAPIKey + } + if r.Config.LLMModel != "" { + cfg.Accounts[i].LLMModel = r.Config.LLMModel } return false } } - // 新帳號 + // 新帳號:t126 萃取設定直接寫帳號層 acc := accountCfg{ InstanceName: r.Config.InstanceName, Email: r.Config.Email, CypherURL: strings.TrimSuffix(strings.TrimSpace(r.Config.CypherURL), "/"), Namespace: r.Config.Namespace, + Extractor: r.Config.Extractor, // t126 + GeminiAPIKey: r.Config.GeminiAPIKey, // t126 + LLMModel: r.Config.LLMModel, // t126 } if acc.APIKey == "" { acc.APIKey = acc.Namespace } cfg.Accounts = append(cfg.Accounts, acc) - if r.Config.Extractor != "" { - cfg.Extractor = r.Config.Extractor - } if cfg.Manifest == "" { cfg.Manifest = filepath.Join(appDir(), "manifest.json") } @@ -624,6 +665,22 @@ func removeAccountWatchFolder(cfg *directConfig, accIdx int, p string) { acc.WatchFolders = out } +// accountEngineLabel 依帳號或全局預設 extractor 產生「· 引擎名」後綴(t126 托盤可見性)。 +// 帳號層有值時優先;兩者皆空回空字串。 +func accountEngineLabel(acc accountCfg, defaultExtractor string) string { + extractor := strings.TrimSpace(acc.Extractor) + if extractor == "" { + extractor = strings.TrimSpace(defaultExtractor) + } + switch extractor { + case "gemma": + return " · Gemini" + case "claude": + return " · Claude" + } + return "" +} + // accountDisplayName 回傳帳號顯示名稱(暱稱 > email > host)。 func accountDisplayName(acc accountCfg) string { if n := strings.TrimSpace(acc.InstanceName); n != "" { @@ -807,8 +864,8 @@ func main() { accIdx := ai // capture by value,供 closure 正確引用 accHost := shortCypherHost(acc.CypherURL) - // 帳號標題(不可點) - headerItem := fyne.NewMenuItem("◉ "+accountDisplayName(acc), nil) + // 帳號標題(不可點);t126 附引擎名(如「geek6688 · Gemini」) + headerItem := fyne.NewMenuItem("◉ "+accountDisplayName(acc)+accountEngineLabel(acc, cfg.Extractor), nil) headerItem.Disabled = true items = append(items, headerItem) diff --git a/cmd/arcrun-tray/main_test.go b/cmd/arcrun-tray/main_test.go index 6cb9a6c..4076e97 100644 --- a/cmd/arcrun-tray/main_test.go +++ b/cmd/arcrun-tray/main_test.go @@ -468,20 +468,10 @@ func TestAddOrUpdateAccountPreservesMachineLayer(t *testing.T) { LLMModel: "gemma-4-31b-it", CardIngestWF: "rag_ingest_card", } - resp := &daemonConfigResp{ - Success: true, - Config: struct { - CypherURL string `json:"cypher_url"` - Namespace string `json:"namespace"` - Library string `json:"library"` - Extractor string `json:"extractor"` - Email string `json:"email"` - InstanceName string `json:"instance_name"` - }{ - CypherURL: "https://new.workers.dev", - Namespace: "newns", - }, - } + resp := &daemonConfigResp{Success: true} + resp.Config.CypherURL = "https://new.workers.dev" + resp.Config.Namespace = "newns" + addOrUpdateAccount(cfg, resp) if cfg.GeminiAPIKey != "AIzaSy-existing-key" { t.Errorf("GeminiAPIKey 被清空:got %q", cfg.GeminiAPIKey) @@ -493,3 +483,100 @@ func TestAddOrUpdateAccountPreservesMachineLayer(t *testing.T) { t.Errorf("CardIngestWF 被清空:got %q", cfg.CardIngestWF) } } + +// ── t126:每帳號獨立引擎設定 ────────────────────────────────────────────────── + +// t126①:accountEngineLabel 依帳號或 defaultExtractor 顯示引擎名(帳號層優先)。 +func TestAccountEngineLabel(t *testing.T) { + cases := []struct { + name string + acc accountCfg + defaultEx string + want string + }{ + {"帳號有gemma", accountCfg{Extractor: "gemma"}, "", " · Gemini"}, + {"帳號有claude", accountCfg{Extractor: "claude"}, "", " · Claude"}, + {"帳號空退回default-gemma", accountCfg{}, "gemma", " · Gemini"}, + {"帳號空退回default-claude", accountCfg{}, "claude", " · Claude"}, + {"兩者皆空無標籤", accountCfg{}, "", ""}, + {"帳號層優先於default", accountCfg{Extractor: "gemma"}, "claude", " · Gemini"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := accountEngineLabel(c.acc, c.defaultEx) + if got != c.want { + t.Errorf("accountEngineLabel(%+v, %q) = %q, want %q", c.acc, c.defaultEx, got, c.want) + } + }) + } +} + +// t126②:addOrUpdateAccount 把 extractor/key 寫進帳號層,機器層不被覆蓋。 +func TestAddOrUpdateAccount_WritesExtractorToAccountLayer(t *testing.T) { + cfg := &directConfig{ + Extractor: "claude", // 機器層原有值 + 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" + + isNew := addOrUpdateAccount(cfg, resp) + if !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].GeminiAPIKey != "account-key" { + t.Errorf("帳號層 GeminiAPIKey 錯:got %q", cfg.Accounts[0].GeminiAPIKey) + } + if cfg.Accounts[0].LLMModel != "gemma-4-31b-it" { + t.Errorf("帳號層 LLMModel 錯:got %q", cfg.Accounts[0].LLMModel) + } + // 機器層不應被覆蓋 + if cfg.Extractor != "claude" { + t.Errorf("機器層 Extractor 不應被覆蓋,got %q", cfg.Extractor) + } + if cfg.GeminiAPIKey != "machine-key" { + t.Errorf("機器層 GeminiAPIKey 不應被覆蓋,got %q", cfg.GeminiAPIKey) + } +} + +// t126③:同 host 更新帳號時 extractor 也寫帳號層。 +func TestAddOrUpdateAccount_UpdateWritesExtractorToAccountLayer(t *testing.T) { + cfg := &directConfig{ + Extractor: "claude", // 機器層 + Accounts: []accountCfg{{ + CypherURL: "https://inst.workers.dev", + Namespace: "ns1", + }}, + } + 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" + + isNew := addOrUpdateAccount(cfg, resp) + if isNew { + t.Error("同 host 應為更新(回 false)") + } + if cfg.Accounts[0].Extractor != "gemma" { + 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.Extractor != "claude" { + t.Errorf("機器層 Extractor 不應被覆蓋,got %q", cfg.Extractor) + } +} diff --git a/direct.go b/direct.go index 493aeba..ce1d844 100644 --- a/direct.go +++ b/direct.go @@ -36,6 +36,7 @@ import ( // AccountConfig 單一帳號的連線設定(t104 多帳號同時看守)。 // 每個帳號代表一個 Arcrun 知識庫實例;Extractor/Manifest 等機器層級設定住在 DirectConfig 頂層。 +// t126:Extractor/GeminiAPIKey/LLMModel 支援帳號層覆蓋——帳號有值時優先,空值繼承機器層。 type AccountConfig struct { InstanceName string `json:"instance_name,omitempty"` Email string `json:"email,omitempty"` @@ -44,6 +45,10 @@ type AccountConfig struct { APIKey string `json:"api_key,omitempty"` WatchFolders []string `json:"watch_folders,omitempty"` // 此帳號看守的資料夾(多根) Libraries map[string]string `json:"libraries,omitempty"` // 資料夾→庫對映(t52) + // t126:每帳號獨立的萃取設定(空值繼承 DirectConfig 頂層) + Extractor string `json:"extractor,omitempty"` + GeminiAPIKey string `json:"gemini_api_key,omitempty"` + LLMModel string `json:"llm_model,omitempty"` } // DirectConfig 是 direct 模式的設定檔(JSON)。設定只走檔案/環境,不落 code。 @@ -175,6 +180,20 @@ func LoadDirectConfig(path string) (*DirectConfig, error) { }} } + // t126 遷移:把頂層金鑰複製到每個沒有金鑰的帳號(複製非搬移,頂層保留當預設;冪等)。 + // 帳號已有自己的值(非空)→ 不覆蓋,讓帳號層設定永遠優先。 + for i := range c.Accounts { + if strings.TrimSpace(c.Accounts[i].Extractor) == "" && strings.TrimSpace(c.Extractor) != "" { + c.Accounts[i].Extractor = c.Extractor + } + if strings.TrimSpace(c.Accounts[i].GeminiAPIKey) == "" && strings.TrimSpace(c.GeminiAPIKey) != "" { + c.Accounts[i].GeminiAPIKey = c.GeminiAPIKey + } + if strings.TrimSpace(c.Accounts[i].LLMModel) == "" && strings.TrimSpace(c.LLMModel) != "" { + c.Accounts[i].LLMModel = c.LLMModel + } + } + // 驗證 var missing []string if c.Manifest == "" { @@ -359,6 +378,7 @@ type DirectResult struct { // makeAccountSubConfig 從帳號設定建出單帳號用的 DirectConfig,繼承機器層級欄位(t104)。 // 用於 RunDirectOnce 逐帳號掃描,每帳號得到獨立的 CypherURL/Namespace/WatchFolders 等。 +// t126:帳號層 Extractor/GeminiAPIKey/LLMModel 有值時優先覆蓋機器層(空字串不算「有值」)。 func (c *DirectConfig) makeAccountSubConfig(acc AccountConfig) *DirectConfig { sub := *c // 複製機器層級欄位 sub.Accounts = nil @@ -376,6 +396,16 @@ func (c *DirectConfig) makeAccountSubConfig(acc AccountConfig) *DirectConfig { if sub.Library == "" { sub.Library = "kb" } + // t126:帳號層有值時優先(空字串繼承機器層,已由 sub := *c 複製) + if strings.TrimSpace(acc.Extractor) != "" { + sub.Extractor = acc.Extractor + } + if strings.TrimSpace(acc.GeminiAPIKey) != "" { + sub.GeminiAPIKey = acc.GeminiAPIKey + } + if strings.TrimSpace(acc.LLMModel) != "" { + sub.LLMModel = acc.LLMModel + } return &sub } diff --git a/direct_multi_test.go b/direct_multi_test.go index 88ee798..a8b9a0c 100644 --- a/direct_multi_test.go +++ b/direct_multi_test.go @@ -622,6 +622,142 @@ func TestRunDirectOnceAccountResultsTagged(t *testing.T) { } } +// ── t126:每帳號獨立萃取設定 ────────────────────────────────────────────────── + +// t126①:帳號層 Extractor/GeminiAPIKey/LLMModel 有值時優先覆蓋機器層。 +func TestMakeAccountSubConfigExtractorOverride(t *testing.T) { + parent := &DirectConfig{ + Manifest: "/tmp/m.json", + Extractor: "claude", + GeminiAPIKey: "machine-key", + LLMModel: "model-machine", + PollSec: 5, + MaxRemoved: DefaultMaxRemovedRatio, + } + acc := AccountConfig{ + CypherURL: "https://a.example", + Namespace: "ns", + WatchFolders: []string{"/tmp"}, + Extractor: "gemma", // 帳號層覆蓋 + GeminiAPIKey: "account-key", // 帳號層覆蓋 + LLMModel: "model-acc", // 帳號層覆蓋 + } + sub := parent.makeAccountSubConfig(acc) + if sub.Extractor != "gemma" { + t.Errorf("① 帳號層 Extractor 應優先,got %q", sub.Extractor) + } + if sub.GeminiAPIKey != "account-key" { + t.Errorf("① 帳號層 GeminiAPIKey 應優先,got %q", sub.GeminiAPIKey) + } + if sub.LLMModel != "model-acc" { + t.Errorf("① 帳號層 LLMModel 應優先,got %q", sub.LLMModel) + } +} + +// t126②:帳號層空字串不算「有值」,應繼承機器層。 +func TestMakeAccountSubConfigExtractorFallback(t *testing.T) { + parent := &DirectConfig{ + Manifest: "/tmp/m.json", + Extractor: "gemma", + GeminiAPIKey: "machine-key", + LLMModel: "model-machine", + PollSec: 5, + MaxRemoved: DefaultMaxRemovedRatio, + } + acc := AccountConfig{ + CypherURL: "https://a.example", + Namespace: "ns", + WatchFolders: []string{"/tmp"}, + // Extractor/GeminiAPIKey/LLMModel 全空 → 繼承機器層 + } + sub := parent.makeAccountSubConfig(acc) + if sub.Extractor != "gemma" { + t.Errorf("② 帳號層空時應繼承機器層 Extractor,got %q", sub.Extractor) + } + if sub.GeminiAPIKey != "machine-key" { + t.Errorf("② 帳號層空時應繼承機器層 GeminiAPIKey,got %q", sub.GeminiAPIKey) + } + if sub.LLMModel != "model-machine" { + t.Errorf("② 帳號層空時應繼承機器層 LLMModel,got %q", sub.LLMModel) + } +} + +// t126③ 遷移:機器層金鑰複製到沒有金鑰的帳號;已有金鑰的帳號不覆蓋。 +func TestLoadDirectConfigMigratesCopyKeyToAccounts(t *testing.T) { + dir := t.TempDir() + p := writeDirectConfig(t, dir, map[string]any{ + "manifest": filepath.Join(dir, "m.json"), + "extractor": "gemma", + "gemini_api_key": "top-level-key", + "llm_model": "model-x", + "accounts": []map[string]any{ + { + "cypher_url": "https://a.example", + "namespace": "nsA", + "watch_folders": []string{"/tmp/a"}, + // 沒有 gemini_api_key → 應複製頂層 + }, + { + "cypher_url": "https://b.example", + "namespace": "nsB", + "watch_folders": []string{"/tmp/b"}, + "gemini_api_key": "b-own-key", // 已有自己的 key → 不覆蓋 + }, + }, + }) + cfg, err := LoadDirectConfig(p) + if err != nil { + t.Fatalf("LoadDirectConfig: %v", err) + } + // account A:無 key → 應複製頂層 + if cfg.Accounts[0].GeminiAPIKey != "top-level-key" { + t.Errorf("③ account A 應複製頂層 key,got %q", cfg.Accounts[0].GeminiAPIKey) + } + if cfg.Accounts[0].Extractor != "gemma" { + t.Errorf("③ account A 應複製頂層 extractor,got %q", cfg.Accounts[0].Extractor) + } + if cfg.Accounts[0].LLMModel != "model-x" { + t.Errorf("③ account A 應複製頂層 llm_model,got %q", cfg.Accounts[0].LLMModel) + } + // account B:已有自己的 key → 不覆蓋 + if cfg.Accounts[1].GeminiAPIKey != "b-own-key" { + t.Errorf("③ account B 已有 key 不應被頂層覆蓋,got %q", cfg.Accounts[1].GeminiAPIKey) + } + // 頂層保留(複製非搬移) + if cfg.GeminiAPIKey != "top-level-key" { + t.Errorf("③ 頂層 key 應保留(複製非搬移),got %q", cfg.GeminiAPIKey) + } +} + +// t126④ 冪等:帳號已有 extractor/key 時再跑遷移不覆蓋。 +func TestLoadDirectConfigMigrationIdempotent(t *testing.T) { + dir := t.TempDir() + p := writeDirectConfig(t, dir, map[string]any{ + "manifest": filepath.Join(dir, "m.json"), + "extractor": "gemma", + "gemini_api_key": "top-key", + "accounts": []map[string]any{ + { + "cypher_url": "https://a.example", + "namespace": "nsA", + "watch_folders": []string{"/tmp/a"}, + "extractor": "claude", // 帳號已有 → 不應被頂層 "gemma" 覆蓋 + "gemini_api_key": "own-key", // 帳號已有 → 不應被頂層 key 覆蓋 + }, + }, + }) + cfg, err := LoadDirectConfig(p) + if err != nil { + t.Fatalf("LoadDirectConfig: %v", err) + } + if cfg.Accounts[0].Extractor != "claude" { + t.Errorf("④ 帳號已有 extractor 不應被頂層覆蓋,got %q", cfg.Accounts[0].Extractor) + } + if cfg.Accounts[0].GeminiAPIKey != "own-key" { + t.Errorf("④ 帳號已有 key 不應被頂層覆蓋,got %q", cfg.Accounts[0].GeminiAPIKey) + } +} + func TestExpandHomeEdgeCases(t *testing.T) { home, err := os.UserHomeDir() if err != nil || home == "" {