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:「他去按一下檢查更新然後更新就好」)