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
+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)
}
}