Files
arcrun-collector/cmd/arcrun-app/app.go
T
Leo 895d672177 fix(daemon): 移除資料夾要真的把雲端資料收回(arcrun-rag#46)
leo 2026-08-16 實撞:掛資料夾、同步完成、從清單按「移除」之後——
「我去把 Logseq plugin 刪掉以後,**採集的 wiki 沒消失**」。
內容一筆都沒少,照樣搜得到、照樣 is_embedded=1、AI 照樣拿它回答。

真兇(源碼):app.go 的 RemoveFolder 全文只做三件事——從 WatchFolders 拿掉、
saveCfg、restartWatch,**一次都沒碰撤除**。而撤除機制本身是好的(在部署白名單裡、
有測試、direct.go 真的會觸發它),只是那兩個觸發點都在「還在監看的資料夾裡某個檔
被刪掉」的差異偵測迴圈裡。⇒ 刪一個檔會撤除 /移除整個資料夾不會 。
**在使用者眼裡是同一件事,在程式裡是兩條完全不同的路,只有一條接上了撤除。**

這不只是少一個功能:產品說明卡寫著「確保資料所有權完全屬於使用者而非 SaaS
供應商」,而使用者唯一看得到的收回動作不收回任何東西 ⇒ 知情同意的問題。

修法(沿用既有那條撤除路,不另寫一份):
- drainPendingTakedowns:把既有的「待下架清單逐筆送出」抽成共用函式
- retireRootOnce:資料夾進 retiring_folders 後,把帳本裡真的上傳過的檔排進
  既有的 PendingTakedowns、走同一條撤除路;撤乾淨才刪帳本
- 進度與失敗真因寫進 status.json(level-triggered),App 看到 done 才清設定
- App 是 config.json 的唯一寫入者(兩個行程都寫=互相蓋掉對方的設定)

邊界(本票最危險的地方):path 是相對於被監看資料夾的路徑 ⇒ 兩個資料夾各有
notes.md 時 page_name 與 path 完全相同,撤除一個會連坐另一個。撤除 payload 帶
library(逐根導出,與 ingest 同一個函式算的),workflow 兩個比對節點加「library
相符才殺」。只在兩邊都有 library 時才收緊 ⇒ 舊 daemon 不送/舊卡沒有都退回原行為。

畫面:舊文案「已經上傳的知識卡不會被刪除」技術上是對的,但它替使用者決定了他要的
是「只停止同步」。改成兩個選項各寫一行後果讓他選(預設待 leo 裁)。

測試:go test ./collector/... ./collector/cmd/arcrun-app/... 全綠;
新增 direct_retire_test.go(7)/remove_folder_takedown_test.go(4)/
workflows/tests/takedown-scope.test.mjs(8)。UI 用真 dist + headless Chrome 複驗,
check-cis.sh/check-render.sh 全過。

◐ 未做:真實例端到端(不可逆且 leo 正在該機器上工作,步驟已寫成清單等總管確認)/
workflow 要重新部署才生效/未重打 bundle、未出貨。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:35:22 +08:00

879 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// app.go — Arcrun 桌面 App 的後端(t193
//
// 🔴 為什麼從 fyne 換到 Wailsleo 2026-08-04 看過 v0.16.0 畫面後拍板):
//
// leo:「功能都有了,但**美感非常糟糕**……**跟 CIS 完全無關**,
// 每個功能都開一個小小的 popup 視窗,**非常缺乏整體感**,
// 這要理解的是**原始的技術選擇是否出錯**?」
// 「我的要求是**符合 CIS**,在風格上**跟 portal 一樣**」
//
// fyne 的哲學=所有 UI 自己用 OpenGL 畫 ⇒ 不像 Mac、不像 Windows、**也不像 portal**
// CSS 套不進去、popup 外觀無法控制 ⇒ **CIS 這個硬要求在 fyne 上做不到**。
// Wails 是 WebView 殼 ⇒ 前端就是 HTML/CSS ⇒ **可以直接用 portal 那份色票與 lockup**。
// (此結論 07-27 就查過並寫進 decisions-summary.md D-daemon-UI,我卻沒在動工前提醒。)
//
// 邊界:本檔只做「把既有能力接到 UI」——config 讀寫、狀態、資料夾增刪都對齊
// collector 既有的檔案協定(~/.arcrun-rag/),不另發明一套。
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
runtime2 "runtime"
"sort"
"strings"
"time"
collector "arcrun-rag/collector"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// App 是 Wails 綁定的後端物件;前端呼叫的方法都掛在它身上。
type App struct {
ctx context.Context
}
func NewApp() *App { return &App{} }
func (a *App) startup(ctx context.Context) { a.ctx = ctx }
// ── 與 collector 共用的資料位置(路徑規則與 collector/direct.go 一致)──
func appDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".arcrun-rag")
}
func configPath() string { return filepath.Join(appDir(), "config.json") }
func statusPath() string { return filepath.Join(appDir(), "status.json") }
func syncNowSignal() string { return filepath.Join(appDir(), "sync-now") }
// ── config 結構(欄位與 collector/direct.go 的 DirectConfig 對齊)──
type accountCfg struct {
InstanceName string `json:"instance_name,omitempty"`
Email string `json:"email,omitempty"`
CypherURL string `json:"cypher_url"`
Namespace string `json:"namespace"`
APIKey string `json:"api_key,omitempty"`
WatchFolders []string `json:"watch_folders,omitempty"`
// RetiringFolders=已按「移除並收回」、雲端還沒撤乾淨的資料夾(arcrun-rag#46)。
//
// 🔴 t108 那條教訓的直接適用:saveCfg 會把整個 accounts 陣列**用這個 struct 重新序列化**
// ⇒ 這裡少一個欄位,寫在 config 裡的那一欄下次存檔就靜默消失(Go omitempty 直接不見)。
// 凡是 collector 的 AccountConfig 有、而 App 會改到的欄位,兩份必須鏡像。
RetiringFolders []string `json:"retiring_folders,omitempty"`
Extractor string `json:"extractor,omitempty"`
GeminiAPIKey string `json:"gemini_api_key,omitempty"`
}
type directConfig struct {
Accounts []accountCfg `json:"accounts,omitempty"`
WatchFolders []string `json:"watch_folders,omitempty"`
Manifest string `json:"manifest"`
Extractor string `json:"extractor,omitempty"`
ExtractorExplicit bool `json:"extractor_explicit,omitempty"`
GeminiAPIKey string `json:"gemini_api_key,omitempty"`
raw map[string]any
}
// syncStatus 對映 collector 寫的 status.json(只取 UI 要的欄位)。
type syncStatus struct {
LastSync string `json:"last_sync,omitempty"`
ExtractorOK bool `json:"extractor_ok"`
ExtractorError string `json:"extractor_error,omitempty"`
// 「上次真的有做事」那一輪(2026-08-05)——本輪計數會被下一輪歸零,
// 只靠它顯示成果,使用者在同步完成 15 秒後就看不到任何證據(leo 實撞)。
LastActivityAt string `json:"last_activity_at,omitempty"`
LastActivityOK int `json:"last_activity_ok"`
LastActivityFailed int `json:"last_activity_failed"`
// G-6.22026-08-06):collector 讀不了、因此整個跳過的檔案。
// 以前這些檔在 scan 的白名單閘就無聲蒸發,使用者只看到「什麼都沒發生」。
SkippedDocs []skippedDoc `json:"skipped_docs,omitempty"`
SkippedDocCount int `json:"skipped_doc_count"`
SkippedOtherCount int `json:"skipped_other_count"`
SkippedOtherNames []string `json:"skipped_other_names"`
// t2102026-08-08):總量進度快照+無法同步的分類統計——**同一組數字**
// 供首頁與診斷檔(t213)共用,不再各算各的。兩者的形狀(欄位、JSON tag)
// 都定義在 collector/progress.go,這裡只是原樣接住 status.json 裡的那一份,
// 不重新定義結構,避免兩邊的欄位定義漂移。
Progress collector.SyncProgress `json:"progress"`
FailureBreakdown collector.FailureBreakdown `json:"failure_breakdown"`
// t2152026-08-08):per-account 雲端版本狀態——collector.AccountSyncStatus 已經是
// GetState 要的形狀(含 t215 新欄位 CloudUpdateKnown/CloudUpdateStale/CloudLatest),
// 直接原樣接住,不重新定義一份會漂移的結構。key = instanceHostOf(cypher_url)
// (與 UIAccount.Host 同一套算法,見 shortHost)。
AccountDetails map[string]collector.AccountSyncStatus `json:"account_details,omitempty"`
// arcrun-rag#46:「移除並收回中」的資料夾進度(key=資料夾路徑)。
// 形狀定義在 collector/sync_status.go,這裡原樣接住不另定義一份會漂移的結構。
Retiring map[string]collector.RetiringStatus `json:"retiring,omitempty"`
}
type skippedDoc struct {
Path string `json:"path"`
Ext string `json:"ext"`
}
// extLabel 把副檔名翻成使用者認得的東西。
// 使用者不會因為看到「.pages」就懂,但看到「Pages」會——那是他自己按存檔時的名字。
func extLabel(ext string) string {
switch strings.ToLower(ext) {
case ".doc":
return "舊版 Word"
case ".xls":
return "舊版 Excel"
case ".ppt":
return "舊版 PowerPoint"
case ".pages":
return "Pages"
case ".numbers":
return "Numbers"
case ".key":
return "Keynote"
case ".odt", ".ods", ".odp":
return "OpenDocument"
case ".rtf":
return "RTF"
case ".epub":
return "EPUB"
case ".msg", ".eml":
return "郵件檔"
case ".wpd":
return "WordPerfect"
}
return strings.TrimPrefix(ext, ".")
}
// loadCfg 同時保留原始 map ⇒ 回寫時**不會弄丟我們沒宣告的欄位**
// config 裡還有 poll_interval_sec、libraries 等,漏寫就等於幫用戶刪設定)。
func loadCfg() (*directConfig, error) {
b, err := os.ReadFile(configPath())
if err != nil {
return &directConfig{raw: map[string]any{}}, err
}
c := &directConfig{}
if err := json.Unmarshal(b, c); err != nil {
return &directConfig{raw: map[string]any{}}, err
}
_ = json.Unmarshal(b, &c.raw)
// 🔴 自我修復:舊版存出來的 config 可能少了 collector 的必填欄位。
// 只補進記憶體不夠——collector 讀的是**磁碟上那份**,所以要寫回去。
if fillRequired(c.raw) {
if out, err := json.MarshalIndent(c.raw, "", " "); err == nil {
_ = os.WriteFile(configPath(), out, 0o600)
}
if m, ok := c.raw["manifest"].(string); ok {
c.Manifest = m
}
appLog("設定檔缺必填欄位,已自動補上 manifest=%v", c.raw["manifest"])
}
return c, nil
}
// fillRequired 補上 collector 的必填欄位,回報有沒有真的補過。
//
// 🔴 leo 2026-08-06 兩輪教訓:
//
// 第一輪:`saveCfg` 從來不寫 `manifest`collector 必填)⇒ 全新安裝一啟動就 exit 2。
// 第二輪(**我第一次修錯**):只在 saveCfg 補 ⇒ **已經存在的壞設定永遠修不好**——
// App 開起來只是**讀** config 然後啟動引擎,saveCfg 根本沒被呼叫。
// leo 的 v0.18.12 實測仍是同一句「缺必填欄位:manifest」,重試 30 次。
// 他的 config 剛好只有 saveCfg 寫的那四個鍵 ⇒ 鐵證。
// ⇒ 補必填要在**讀取時**做(升級路徑),存檔時也做(新建路徑),兩條都要。
func fillRequired(raw map[string]any) bool {
changed := false
if v, ok := raw["manifest"].(string); !ok || strings.TrimSpace(v) == "" {
raw["manifest"] = filepath.Join(appDir(), "manifest.json")
changed = true
}
return changed
}
func saveCfg(c *directConfig) error {
if c.raw == nil {
c.raw = map[string]any{}
}
// 只覆寫我們改過的鍵,其餘原樣保留
accs, _ := json.Marshal(c.Accounts)
var accAny any
_ = json.Unmarshal(accs, &accAny)
c.raw["accounts"] = accAny
c.raw["extractor"] = c.Extractor
c.raw["extractor_explicit"] = c.ExtractorExplicit
c.raw["gemini_api_key"] = c.GeminiAPIKey
// 🔴 2026-08-06 leo Windows 封測的真兇:`manifest` 是 collector 的**必填欄位**
// direct.go 的驗證:`if c.Manifest == "" { missing = append(missing, "manifest") }`),
// 而這支從來沒寫過它 ⇒ **全新安裝的機器**(config 從零長出來)永遠缺這一欄
// ⇒ collector 一啟動就 `exit status 2`、supervisor 無限重拉
// ⇒ 畫面在「看守中/沒有在跑」之間閃、加資料夾也沒反應。
// 為什麼開發機沒撞到:leo 的 Mac config 是舊版留下的、早就有這一欄
// ——**「我這台好好的」正是這個 bug 能活到封測的原因**。
fillRequired(c.raw)
out, err := json.MarshalIndent(c.raw, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(appDir(), 0o755); err != nil {
return err
}
return os.WriteFile(configPath(), out, 0o600)
}
// ── 前端要的資料形狀 ──
type UIFolder struct {
Path string `json:"path"`
AccIdx int `json:"accIdx"`
// arcrun-rag#46:這個資料夾已經被移除,正在把雲端的資料收回來。
Retiring bool `json:"retiring,omitempty"`
RetireRemaining int `json:"retireRemaining,omitempty"` // 還剩幾筆
RetireError string `json:"retireError,omitempty"` // 失敗真因(原文,不改寫)
}
type UIAccount struct {
Name string `json:"name"`
Host string `json:"host"`
Folders []UIFolder `json:"folders"`
// t2152026-08-08leo:「在每個知識庫上顯示是否要更新,如果要,加開啓 install 頁的
// 連結」)——一個使用者可能連著不只一個知識庫,之前只有小幫手自己的版本會提示更新,
// 每個知識庫各自的雲端版本完全沒有畫面。判準與 portal 版本卡同一套
// collector.EvalCloudUpdate,不是 t103 的相容底線),這裡只翻成人話,不重新判斷。
CloudVerKnown bool `json:"cloudVerKnown"` // false=查不到(連不上/還沒查過),前端要老實說「查不到」
CloudVerStale bool `json:"cloudVerStale"` // true=有新版可更新
CloudVerMine string `json:"cloudVerMine,omitempty"` // 這個知識庫目前的版本(可能連 Known=false 時也有值)
CloudVerLatest string `json:"cloudVerLatest,omitempty"` // 已知的最新版
Email string `json:"email,omitempty"` // 供「前往安裝頁更新」預填帳號(同 portal 版本卡的做法)
}
type UIState struct {
Version string `json:"version"`
StatusBig string `json:"statusBig"`
StatusSub string `json:"statusSub"`
Syncing bool `json:"syncing"`
Accounts []UIAccount `json:"accounts"`
Engine string `json:"engine"` // "workers-ai" | "gemma"
GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值
Steps []Step `json:"steps"` // 首頁狀態時間軸(leo #6
Skipped *UISkipped `json:"skipped"` // 讀不了的檔(沒有就是 null,前端不畫)
// EngineTrouble=同步引擎有問題(沒在跑/一直啟動失敗)⇒ 前端才長出「回報問題」卡。
// 沒事時不顯示,避免把「哪裡看 log」變成常駐噪音。
EngineTrouble bool `json:"engineTrouble"`
Progress UIProgress `json:"progress"` // 首頁「你的檔案」那行(t210
LogFolder string `json:"logFolder"`
// Quota=「今天的 AI 額度用完了」那張卡(P8,2026-08-09)。
//
// 🔴 存在理由:collector 這半(quota.go2026-08-07 就把 leo 定的三句話
// (成就/出口/保證)寫進 status.json 的 quota_message 了,但 App 端從來沒接——
// 使用者撞到額度時只看得到「送不上去 N 份」,正是封測者 Evan 把額度用完
// 讀成「這個 AI 沒效」的那個黑箱(歸錯因、罵錯對象)。
// 這裡原樣接住 collector 組好的三句話,不重新組字串(避免措辭漂移)。
// nil=現在不在額度冷卻中,前端不畫這張卡。
Quota *collector.QuotaNotice `json:"quota"`
}
// UISkipped=首頁那張「這些檔案現在還處理不了」的卡。
//
// 🔴 存在理由(J-1/S6 考題 G-6.2):
//
// 「Given 我丟進去的是 PDF 或 Word/When 我搜它的內容/
// Then 我一樣找得到——**或當場被告知這種檔案還不支援**,不准安靜地略過。」
//
// 後半句在這裡兌現。文字一律白話:講「你的哪個檔沒進去」「你要不要做什麼」,
// 不講 allowedExt、副檔名白名單、extractor 這些系統內部詞。
type UISkipped struct {
Title string `json:"title"` // 「有 3 個檔案現在還讀不了」
Note string `json:"note"` // 該不該做什麼——這裡的答案是「不用,之後會自動補上」
Files []string `json:"files"` // 「舊版報告.doc(舊版 Word)」
More int `json:"more"` // 沒列出來的還有幾個
Other string `json:"other"` // 非文件檔的一行說明(沒有就空字串)
}
// UIProgress=首頁「你的檔案」那張卡(t2102026-08-08,取代 08-06 的逐檔白話翻譯)。
//
// 🔴 存在理由(leo 轉述封測者 Evan 08-08 原話):「我有 9000 個檔,雲端只有 101 張卡,
// 畫面卻說『20 份沒送進知識庫』——這幾個數字到底是怎麼回事?是壞了嗎?還是繼續在跑?」
// ⇒ 首頁要能一次講完「你有幾份、我做完幾份、剩下幾份會自動接著做」,
//
// 單位一律是「份檔案」,Total/Done/Pending/CantSync 四個數字相加要等於 Total
// leo 08-08 驗法①,源頭不變式見 collector/progress.go)。
//
// 🔴 leo 08-08 追加約束(t214 預留):**分類判斷只住在 collector/progress.go 的
// ClassifyFailure 一個接縫**——這裡與前端 main.js 都不准認得任何一個分類名稱字串,
// Groups 原樣照後端給的 category/count 陣列畫,順序也照後端給的(FailCategories)。
// 之後把分類改成資料驅動時,才只需要動那一個檔。
type UIProgress struct {
Total int `json:"total"` // 你的檔案,共幾份
Done int `json:"done"` // 已送上去
Pending int `json:"pending"` // 排隊中(會自動接著做)
CantSync int `json:"cantSync"` // 送不上去(=卡住+讀不了,預設摺疊,展開看 Groups)
Groups []UIFailGroup `json:"groups"` // 「送不上去」展開後的分類統計;沒有就是空陣列
}
// UIFailGroup=一個分類與它的份數,逐字接住 collector.FailureGroup(不重新判斷)。
type UIFailGroup struct {
Category string `json:"category"`
Count int `json:"count"`
}
// buildProgress 把 collector 已經算好的 SyncProgressFailureBreakdown
// 轉成前端要的形狀——只搬資料,不重新判斷任何分類。
func buildProgress(s syncStatus) UIProgress {
p := s.Progress
u := UIProgress{
Total: p.Total,
Done: p.Done,
Pending: p.Pending,
CantSync: p.Stuck + p.Unreadable,
}
for _, g := range s.FailureBreakdown.Groups {
u.Groups = append(u.Groups, UIFailGroup{Category: g.Category, Count: g.Count})
}
return u
}
// pickQuotaNotice 從 status.json 的 per-account 狀態裡挑出「現在還有效」的額度三句話。
//
// 有效=ResumeAt 還沒到:collector 每輪重建 AccountSyncStatus,冷卻過了自然不再寫
// quota_message,但 daemon 若整夜沒跑(電腦闔蓋),status.json 會停在昨天的快照——
// 額度其實已經恢復了,這時再顯示「明天早上 8 點恢復」就是在說謊,所以以 ResumeAt 判生死。
// 多帳號同時冷卻時挑最早恢復的那份(key 排序保證同一份輸入永遠同一個輸出,好測試)。
func pickQuotaNotice(s syncStatus, now time.Time) *collector.QuotaNotice {
keys := make([]string, 0, len(s.AccountDetails))
for k := range s.AccountDetails {
keys = append(keys, k)
}
sort.Strings(keys)
var best *collector.QuotaNotice
var bestAt time.Time
for _, k := range keys {
q := s.AccountDetails[k].QuotaMessage
if q == nil {
continue
}
at, err := time.Parse(time.RFC3339, q.ResumeAt)
if err != nil || !at.After(now) {
continue // 解析不了或已恢復 ⇒ 過期快照,不畫(下一輪 collector 會清掉)
}
if best == nil || at.Before(bestAt) {
best, bestAt = q, at
}
}
return best
}
// buildSkipped 把 status.json 的三個欄位翻成首頁看得懂的一張卡。
// 完全沒有東西被略過時回 nil ⇒ 前端不畫這張卡(沒事就別佔畫面)。
func buildSkipped(s syncStatus) *UISkipped {
if s.SkippedDocCount == 0 && s.SkippedOtherCount == 0 {
return nil
}
u := &UISkipped{}
for _, d := range s.SkippedDocs {
u.Files = append(u.Files, fmt.Sprintf("%s%s", filepath.Base(d.Path), extLabel(d.Ext)))
}
if n := s.SkippedDocCount - len(s.SkippedDocs); n > 0 {
u.More = n
}
if s.SkippedDocCount > 0 {
u.Title = fmt.Sprintf("有 %d 個檔案現在還讀不了", s.SkippedDocCount)
// 誠實地告訴他「這不是你的錯,也不用你動手」——否則使用者會反覆重丟同一個檔。
u.Note = "這些格式我們還沒支援,所以沒有進你的知識庫。等支援了會自動補上,你不用重丟。" +
"急著要的話,先用原本的軟體另存成 PDF 或 Word(.docx)放進同一個資料夾就行。"
// 兩種都有時,非文件檔那句用「另外」接在讀不了的檔之後(見下)。
if s.SkippedOtherCount > 0 {
u.Other = fmt.Sprintf("另外有 %d 個不是文件的檔案(圖片、影片、壓縮檔之類)也沒有處理。",
s.SkippedOtherCount)
}
} else {
// 只有非文件檔的情況(例如整個資料夾都是照片)——不需要驚動他,但也不能不說。
//
// 🔴 2026-08-06 leo 封測截圖:這張卡長成
// 「看起來不是文件,所以跳過了。」(底下空的)
// 「**另外**有 1 個不是文件的檔案(圖片、影片、壓縮檔之類)也沒有處理。」
// 兩個病:① 同一件事講兩次 ② 「另外」前面沒有東西可以「另外」——
// 因為 Files 是空的(非文件檔不逐檔點名),通用句下面什麼都沒有。
// ⇒ 這個分支**只講一句**,把數量與例子併進來,且不留 Other。
u.Title = fmt.Sprintf("有 %d 個檔案沒有被整理", s.SkippedOtherCount)
u.Note = "看起來不是文件(圖片、影片、壓縮檔之類),所以跳過了。這是正常的,你不用做什麼。"
// 🔴 少量時把檔名列出來(leo 08-06 封測):封測者放了 .md 進去說「無法通過」,
// 而畫面只寫「有 1 個不是文件的檔案」——沒說是哪一個,誰都判斷不出發生什麼事。
// `.md` 明明在支援清單裡 ⇒ 看到檔名才知道真相(副檔名被 Windows 藏起來、存錯格式…)。
u.Files = append(u.Files, s.SkippedOtherNames...)
if n := s.SkippedOtherCount - len(s.SkippedOtherNames); n > 0 {
u.More = n
}
}
return u
}
// Step 是首頁狀態時間軸的一格。
// 🔴 leo 08-04:「首頁要顯示的應該是 status,如果**看守、發現變化、萃取、上傳…
//
// 不同 status 在哪裡顯示**?」
//
// ⇒ 把一輪同步拆成四步,讓使用者看得到「現在走到哪」,而不是只有一句「看守中」。
type Step struct {
Title string `json:"title"`
Meta string `json:"meta,omitempty"`
State string `json:"state"` // "done"(已完成)|"now"(進行中)|""(還沒輪到)
}
// buildSteps 依 status.json 與訊號檔推出四步的狀態。
// 誠實邊界:collector 目前回報的是「整輪」而非逐檔階段,所以同步中時
// 「發現變化→萃取→上傳」一起標進行中;不假裝有更細的進度。
func buildSteps(s syncStatus, syncing bool) []Step {
watch := Step{Title: "看守資料夾", Meta: "有變動就自動開始", State: "done"}
if syncing {
return []Step{
watch,
{Title: "發現變化", State: "done"},
{Title: "用 AI 整理成知識卡", Meta: "進行中", State: "now"},
{Title: "上傳到你的知識庫", State: ""},
}
}
// 🔴 2026-08-05:這三步以前只要「跑過任何一輪」(LastSync 非空)就全標綠,
// 而 meta 用的是**本輪**計數(下一輪歸零)⇒ 綠燈與實際進度脫鉤、數字又永遠空白,
// 正是 leo 說的「都顯示綠燈沒動,實際上已經做完了」。
// ⇒ 綠燈只在「真的有整理過東西」時才亮,且亮的是上次有產出那輪的數字+時間。
done := ""
if s.LastActivityOK > 0 || s.LastActivityFailed > 0 {
done = "done"
}
found := "等待中"
if done != "" {
if t, err := time.Parse(time.RFC3339, s.LastActivityAt); err == nil {
found = "上次 " + t.Local().Format("15:04")
} else {
found = "已處理"
}
}
meta := ""
if s.LastActivityOK > 0 {
meta = fmt.Sprintf("上次 %d 份", s.LastActivityOK)
}
up := Step{Title: "上傳到你的知識庫", Meta: meta, State: done}
if s.LastActivityFailed > 0 {
up.Meta = strings.TrimPrefix(fmt.Sprintf("%s · ⚠ %d 份失敗", meta, s.LastActivityFailed), " · ")
}
return []Step{
watch,
{Title: "發現變化", Meta: found, State: done},
{Title: "用 AI 整理成知識卡", State: done},
up,
}
}
// GetState 是前端每秒拉一次的單一入口。
func (a *App) GetState() UIState {
st := UIState{Version: version}
cfg, _ := loadCfg()
// 預設庫的實體資料夾若已經被使用者刪掉,把 config 裡的殘留引用清掉——
// 不留幽靈資料、也不會讓 collector 每輪都對著不存在的資料夾報錯(見 default_library.go)。
// 資料夾本身「會不會長回來」由 seedDefaultLibraryIfFirstEver 的 marker 檔擋住,這裡不會重種。
if pruneMissingDefaultLibrary(cfg) {
if err := saveCfg(cfg); err != nil {
appLog("清理已刪除的預設庫引用失敗:%v", err)
} else {
restartWatch()
}
}
sync := loadSyncStatus()
// arcrun-rag#46collector 說收乾淨了的資料夾,這裡才真的從設定裡消失
// App 是 config.json 的唯一寫入者,見 pruneFinishedRetirements)。
if pruneFinishedRetirements(cfg, sync) {
if err := saveCfg(cfg); err != nil {
appLog("清理已收回的資料夾失敗:%v", err)
} else {
restartWatch()
}
}
for i, acc := range cfg.Accounts {
ui := UIAccount{Name: accountName(acc), Host: shortHost(acc.CypherURL), Email: acc.Email}
for _, f := range acc.WatchFolders {
ui.Folders = append(ui.Folders, UIFolder{Path: f, AccIdx: i})
}
// 收回中的資料夾照樣列出來,只是標成「收回中」——不然按下移除之後它立刻消失,
// 使用者無從知道撤除還在跑、更看不到失敗的原因(那正是這張票的病的另一面)。
for _, f := range acc.RetiringFolders {
uf := UIFolder{Path: f, AccIdx: i, Retiring: true}
if st, ok := sync.Retiring[f]; ok {
uf.RetireRemaining = st.Remaining
uf.RetireError = st.LastError
}
ui.Folders = append(ui.Folders, uf)
}
// t215per-account 雲端版本狀態——key 與 Host 同一套算法(shortHost),
// 對應 collector 寫入 status.json 時用的 instanceHostOf(兩者對一般 https URL 同值)。
if accSt, ok := sync.AccountDetails[ui.Host]; ok {
ui.CloudVerKnown = accSt.CloudUpdateKnown
ui.CloudVerStale = accSt.CloudUpdateStale
ui.CloudVerMine = accSt.CloudVersion
ui.CloudVerLatest = accSt.CloudLatest
}
st.Accounts = append(st.Accounts, ui)
}
st.Engine = cfg.Extractor
if !cfg.ExtractorExplicit || st.Engine == "" {
st.Engine = "workers-ai" // 與 direct.go 的預設判準一致
}
if strings.TrimSpace(cfg.GeminiAPIKey) != "" {
st.GeminiKey = "••••••••"
}
st.Syncing, st.StatusBig, st.StatusSub = describeStatus(sync)
st.Steps = buildSteps(sync, st.Syncing)
st.Skipped = buildSkipped(sync)
st.Progress = buildProgress(sync)
st.Quota = pickQuotaNotice(sync, time.Now()) // P8:額度冷卻中 ⇒ 首頁畫三句話卡
// 引擎有問題才把「回報問題」卡叫出來(含記錄檔路徑)。
// 沒事時不顯示——否則「哪裡看 log」會變成常駐噪音,真出事時反而沒人看。
st.EngineTrouble = !collectorAlive()
st.LogFolder = appDir()
return st
}
func loadSyncStatus() syncStatus {
var s syncStatus
if b, err := os.ReadFile(statusPath()); err == nil {
_ = json.Unmarshal(b, &s)
}
return s
}
// describeStatus 產生狀態文案。
// 🔴 t195leo 08-05 實撞:「燈號是真的還是假的?」——**是假的**):
//
// 舊版只看「sync-now 訊號檔存不存在」就顯示「同步中…」。
// 但那只代表**排隊了**,不代表有人在處理:leo 的 collector 在 11:25 死掉,
// 他 11:38 按同步 ⇒ 訊號檔沒人消化 ⇒ 畫面一直說「正在整理知識卡」,
// 實際上 13 分鐘沒跑過任何一輪、也不會產卡。**這比沒有燈號更糟——它在說謊。**
//
// ⇒ 燈號改成有憑有據:
// · collector 沒在跑 → 明說「同步引擎沒有在跑」,不要假裝在整理
// · 有訊號檔且引擎活著 → 才是真的「同步中」
// · 其餘 → 看守中
//
// 🔴 2026-08-05 第二修(leo:「拖新檔進資料夾…自始至終都顯示『等待中』,
//
// 實際上已經做完了,這個 status 是壞的」)——t195 只補了「引擎活著」那半,
// 「同步中」仍靠 sync-now 訊號檔判斷,那是錯的判準,兩個理由:
// ① 訊號檔**只有手動按「立刻同步」才會產生**;leo 這次是拖檔進資料夾
// (自動觸發),整輪從頭到尾沒有訊號檔 ⇒ 畫面永遠停在「看守中/等待中」。
// ② 就算是手動按的,collector 是**先刪檔再跑**consumeSyncNowSignal),
// 所以真正在跑的那段時間訊號檔早就不見了。
// ⇒ 改用 t191 已經做好的機制:collector 開工印 phase:"start"、跑完印 "done"
// supervisor 據此維護 StateSyncingsupervisor.go)。**那條線本來就在,
// Wails 版換代時沒接上而已**——不要再自己發明第三種判斷法。
func describeStatus(s syncStatus) (syncing bool, big, sub string) {
// 🔴 一直啟動失敗(crash loop)要**優先**判,而且不受 collectorAlive 影響——
// 重起過程中狀態會短暫變成 Starting(alive=true),若照順序判就會與
// 「沒有在跑」交替出現 ⇒ 就是 leo 08-06 在 Windows ARM 看到的閃爍。
// 這裡讓失敗訊息**黏住**,畫面才會穩定,也才說得出死因。
if msg, n, looping := collectorFailure(); looping {
sub := fmt.Sprintf("已自動重試 %d 次都失敗,所以重新開啟也沒有用。", n)
if msg != "" {
sub += "原因:" + msg
} else {
sub += "詳細訊息請看記錄檔 app.log。"
}
return false, "同步引擎一直啟動失敗", sub
}
if !collectorAlive() {
msg, n, _ := collectorFailure()
// 🔴 2026-08-06:以前這裡寫「請結束 Arcrun 再重新開啟」——
// 那是**把系統的無能推給使用者**(真正該做的是自己啟動,已在 restartWatch 修)。
// 留這句當保底文案時也不要叫人重開,而是說「正在啟動」——
// 因為修完之後,唯一還會短暫看到這個狀態的時機就是剛啟動那幾秒。
sub := "正在啟動同步引擎,請稍候…"
if c, err := loadCfg(); err != nil || len(c.Accounts) == 0 {
sub = "還沒連上知識庫 ⇒ 按「新增知識庫帳號」就會開始"
}
if msg != "" {
sub = fmt.Sprintf("原因:%s(已重試 %d 次)", msg, n)
}
return false, "同步引擎沒有在跑", sub
}
if collectorSyncing() {
return true, "同步中… 正在讀檔並整理成知識卡", "請稍候,完成後會顯示整理了幾份"
}
if s.ExtractorError != "" && !s.ExtractorOK {
return false, "需要你處理一下", "⚠ " + s.ExtractorError
}
parts := []string{}
if t, err := time.Parse(time.RFC3339, s.LastSync); err == nil {
parts = append(parts, "上次檢查 "+t.Local().Format("15:04"))
}
// 用「上次真的有做事」那輪的數字,不用本輪計數——後者每輪歸零,
// 會讓剛整理完的成果在十幾秒後從畫面上消失(就是 leo 撞到的那個「壞掉的 status」)。
if s.LastActivityOK > 0 || s.LastActivityFailed > 0 {
when := ""
if t, err := time.Parse(time.RFC3339, s.LastActivityAt); err == nil {
when = t.Local().Format("15:04") + " "
}
if s.LastActivityOK > 0 {
parts = append(parts, fmt.Sprintf("%s已整理 %d 份", when, s.LastActivityOK))
}
if s.LastActivityFailed > 0 {
parts = append(parts, fmt.Sprintf("⚠ %d 份失敗", s.LastActivityFailed))
}
}
if len(parts) == 0 {
return false, "看守中 · 資料夾有變動就會自動整理", "還沒有同步紀錄"
}
return false, "看守中 · 資料夾有變動就會自動整理", strings.Join(parts, " · ")
}
func accountName(a accountCfg) string {
if s := strings.TrimSpace(a.InstanceName); s != "" {
return s
}
if s := strings.TrimSpace(a.Email); s != "" {
return s
}
return shortHost(a.CypherURL)
}
func shortHost(u string) string {
s := strings.TrimPrefix(strings.TrimPrefix(u, "https://"), "http://")
return strings.TrimSuffix(strings.SplitN(s, "/", 2)[0], "/")
}
// ── 動作(前端按鈕直接呼叫)──
// SyncNow 寫訊號檔讓 collector 立刻跑一輪(沿用 t98 的機制,不新增 IPC)。
func (a *App) SyncNow() error {
if err := os.MkdirAll(appDir(), 0o755); err != nil {
return err
}
return os.WriteFile(syncNowSignal(), []byte{}, 0o644)
}
// PickFolder 用**系統原生**資料夾選擇器。
// 🔴 這是換 Wails 的另一個實質好處(D-daemon-UI 已記):macOS 的 powerbox 機制
// 會在使用者用原生面板選資料夾時**自動授予該資料夾存取權**;fyne 自繪的 picker 拿不到。
// 未來要上 Mac App Store 或開沙箱時,原生 picker 是硬需求。
func (a *App) PickFolder() (string, error) {
return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: "選一個要自動整理的資料夾",
})
}
func (a *App) AddFolder(accIdx int, path string) error {
if strings.TrimSpace(path) == "" {
return nil
}
cfg, err := loadCfg()
if err != nil {
return err
}
if accIdx < 0 || accIdx >= len(cfg.Accounts) {
return fmt.Errorf("找不到這個知識庫帳號")
}
for _, f := range cfg.Accounts[accIdx].WatchFolders {
if f == path {
return nil // 已經在看守了,不重複加
}
}
// arcrun-rag#46:正在收回中的資料夾不能同時又加回來看守——那會變成
// 「一邊撤除、一邊重新上傳同一批檔」,兩條路互相打架,結果不可預測。
// 擋一次比事後對帳容易解釋,訊息要告訴使用者現在是什麼狀況、該怎麼辦。
for _, f := range cfg.Accounts[accIdx].RetiringFolders {
if f == path {
return fmt.Errorf("這個資料夾正在從雲端收回資料,等它收完再加回來(可在畫面上看到進度)")
}
}
cfg.Accounts[accIdx].WatchFolders = append(cfg.Accounts[accIdx].WatchFolders, path)
sort.Strings(cfg.Accounts[accIdx].WatchFolders)
if err := saveCfg(cfg); err != nil {
return err
}
restartWatch() // 立刻生效,不必等下一輪
return nil
}
// RemoveFolder 把資料夾從清單移除。
//
// 🔴 arcrun-rag#46leo 2026-08-16 實撞):「我去把 Logseq plugin 刪掉以後,
//
// **採集的 wiki 沒消失**。」——移除之後那個資料夾的內容在雲端一筆都沒少,
// 照樣搜得到、照樣是已嵌入狀態、AI 照樣拿它回答。
//
// 真兇:這支函式原本只做三件事(從 WatchFolders 拿掉、存檔、重啟看守),
// **一次都沒碰撤除**。撤除的能力本身是好的、有測試、也真的被部署,只是
// 「整個資料夾從清單移除」這條路從來不呼叫它——
// **在使用者眼裡是同一件事(我不要這份資料了),在程式裡是兩條完全不同的路。**
//
// takedown=使用者在對話框上明確選的那一個:
// - true :連同雲端已經整理好的知識一起收回(資料夾搬進 retiring_folders
// 由 collector 逐筆撤除;進度與失敗原因走 status.json 回到畫面)
// - false:只停止同步,雲端保留(=這支函式原本的行為)
//
// 為什麼做成使用者選、而不是我們替他決定:兩種都是合理的需求(換電腦/重整資料夾
// vs 我不要這份資料了),而**猜錯任何一邊都是不可逆的**——猜「保留」則產品承諾的
// 「資料所有權完全屬於使用者」是假的;猜「收回」則整理好的知識被誤刪。
// ⇒ 在動作的當下把兩個後果講清楚、讓他自己挑(見前端 confirmRemove 的文案)。
func (a *App) RemoveFolder(accIdx int, path string, takedown bool) error {
cfg, err := loadCfg()
if err != nil {
return err
}
if accIdx < 0 || accIdx >= len(cfg.Accounts) {
return fmt.Errorf("找不到這個知識庫帳號")
}
keep := []string{}
found := false
for _, f := range cfg.Accounts[accIdx].WatchFolders {
if f != path {
keep = append(keep, f)
} else {
found = true
}
}
cfg.Accounts[accIdx].WatchFolders = keep
if takedown && found {
// 只在「本來真的在看守」時排撤除——否則重複按會排出一堆重複待辦。
already := false
for _, f := range cfg.Accounts[accIdx].RetiringFolders {
if f == path {
already = true
}
}
if !already {
cfg.Accounts[accIdx].RetiringFolders = append(cfg.Accounts[accIdx].RetiringFolders, path)
}
}
if err := saveCfg(cfg); err != nil {
return err
}
restartWatch()
if takedown {
// 不必等下一輪輪詢——使用者剛按下按鈕,他期待「現在就開始」。
// 沿用既有的 sync-now 訊號檔,不新發明一套 IPC。
_ = a.SyncNow()
}
return nil
}
// pruneFinishedRetirements 把「collector 已回報收乾淨」的資料夾從設定裡清掉。
//
// 為什麼由 App 清而不是 collector 自己清:config.json 的寫入者只有 App 一個,
// 兩個行程都寫同一個檔=互相蓋掉對方的設定(t108 那類靜默掉欄位的病的另一種形狀)。
// collector 只在 status.json 說「這個根收乾淨了」,且**每輪都照現況重說**
// level-triggered)——App 關著沒看到也不會卡住,下次開起來照樣清得掉。
func pruneFinishedRetirements(cfg *directConfig, sync syncStatus) bool {
changed := false
for i := range cfg.Accounts {
keep := cfg.Accounts[i].RetiringFolders[:0:0]
for _, f := range cfg.Accounts[i].RetiringFolders {
if st, ok := sync.Retiring[f]; ok && st.Done {
changed = true
continue
}
keep = append(keep, f)
}
if len(keep) != len(cfg.Accounts[i].RetiringFolders) {
cfg.Accounts[i].RetiringFolders = keep
}
}
return changed
}
// SetAI 存 AI 設定。
// 🔴 t190:金鑰**無條件以輸入框為準**(清空=刪除)——leo 實撞過「金鑰刪不掉」。
func (a *App) SetAI(useGemini bool, key string) error {
cfg, err := loadCfg()
if err != nil {
return err
}
engine := "workers-ai"
if useGemini {
engine = "gemma"
if strings.TrimSpace(key) == "" {
return fmt.Errorf("選了 Gemini 就要貼上金鑰;不想申請的話請改選「雲端 AI」")
}
}
cfg.Extractor = engine
cfg.ExtractorExplicit = true
cfg.GeminiAPIKey = key
for i := range cfg.Accounts {
cfg.Accounts[i].Extractor = engine
cfg.Accounts[i].GeminiAPIKey = key
}
if err := saveCfg(cfg); err != nil {
return err
}
restartWatch()
return nil
}
// OpenURL 用系統瀏覽器開網址(下載頁/說明文件)。
func (a *App) OpenURL(u string) { runtime.BrowserOpenURL(a.ctx, u) }
// OpenLogFolder 在檔案總管/Finder 裡打開記錄檔資料夾。
//
// 🔴 leo 2026-08-06:「不能用一個 debug mode,就是它會把 log 寫在一個檔案?」
//
// 答:**log 一直都有寫**`~/.arcrun-rag/` 下的 collector.log 與 app.log
// collector 的 stderr 也以 `[stderr]` 開頭落在裡面)。
// 缺的不是「有沒有寫」,是**使用者找不到**——出事時他只看得到畫面上那句話,
// 沒有任何入口把他帶到檔案。這次 Windows 事故就卡在這:真正的死因
// config 缺 manifest)一直躺在 collector.log 裡,沒人看得到。
// ⇒ 不做「debug mode 開關」(多一個要教使用者的東西),而是**永遠寫、一鍵打開**。
func (a *App) OpenLogFolder() error {
dir := appDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
switch runtime2.GOOS {
case "windows":
// explorer 開啟後回傳非 0 是常態(它不等視窗關閉),所以不看 error。
_ = exec.Command("explorer", dir).Start()
case "darwin":
return exec.Command("open", dir).Start()
default:
return exec.Command("xdg-open", dir).Start()
}
return nil
}
// LogFolderPath 給畫面顯示用(讓使用者就算按鈕失效也知道去哪找)。
func (a *App) LogFolderPath() string { return appDir() }
// ── 托盤會呼叫的兩個動作(t194)──
// ShowWindow 把主視窗叫出來並帶到前景。
// 🔴 leo 2026-08-05:「**點擊托盤的 icon 就立刻展開界面**」
// ⇒ 左鍵不彈選單、直接開窗(Google Drive 的行為)。
func (a *App) ShowWindow() {
// 🔴 leo 實測③:「第一次點擊可以開啟,然後再點擊托盤就**不再跳出**」。
// 真兇有二:(a) 選單建了 ⇒ 滑鼠事件失效(見 setupTray 的 SetMenuNil
// (b) 視窗其實還在、只是被蓋住或最小化 ⇒ 只呼叫 WindowShow 沒有效果。
// ⇒ 三個都做:取消最小化、顯示、**強制帶到最前面**。
runtime.WindowUnminimise(a.ctx)
runtime.WindowShow(a.ctx)
runtime.WindowSetAlwaysOnTop(a.ctx, true)
runtime.WindowSetAlwaysOnTop(a.ctx, false) // 只用來搶焦點,不真的釘在最上層
}
// Quit 真的結束程式(=停止看守)。只有托盤右鍵那一項會呼叫。
//
// 🔴 leo 實測④:「**用強制結束把它關掉才能測試**」——代表沒有一條正常的結束路徑。
//
// 這裡先停掉 collector 子行程再關 App,否則子行程會變孤兒繼續跑。
func (a *App) Quit() {
// 🔴 2026-08-06:先舉旗再喊退。沒有這一步,OnBeforeClose 會把 runtime.Quit()
// 當成「按 ×」攔下來(Wails frontend.go:364 兩者共用同一個回呼)
// ⇒ leo 實撞:托盤右鍵「結束 Arcrun」點了沒反應,只能強制結束,
// 導致下載的新版覆蓋不掉還活著的舊版。
beginQuit()
stopSupervisor()
runtime.Quit(a.ctx)
}