diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index c880ff4..7efb583 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -481,6 +481,17 @@ 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() + } + } + for i, acc := range cfg.Accounts { ui := UIAccount{Name: accountName(acc), Host: shortHost(acc.CypherURL)} for _, f := range acc.WatchFolders { diff --git a/cmd/arcrun-app/connect.go b/cmd/arcrun-app/connect.go index e847c8b..771f5fb 100644 --- a/cmd/arcrun-app/connect.go +++ b/cmd/arcrun-app/connect.go @@ -109,6 +109,7 @@ func (a *App) Connect(portalURL, email, password string) error { } // 不同實例=新增一個帳號。**不覆蓋舊帳號**——t86 事故的教訓: // 舊實例的資料夾若被帶進新實例,等於把別人的檔案同步到另一個知識庫(個資外洩)。 + wasFirstEver := len(cfg.Accounts) == 0 // 這台機器至今從沒連過任何知識庫帳號 cfg.Accounts = append(cfg.Accounts, accountCfg{ InstanceName: r.Config.InstanceName, Email: r.Config.Email, @@ -116,6 +117,11 @@ func (a *App) Connect(portalURL, email, password string) error { Namespace: r.Config.Namespace, APIKey: r.Config.Namespace, }) + if wasFirstEver { + // leo 2026-08-08:全新安裝自動裝一個「可刪除的預設庫」,讓使用者不必 + // 自己選資料夾也能立刻看到「丟檔 → 知識卡 → 搜得到」整條路(見 default_library.go)。 + seedDefaultLibraryIfFirstEver(cfg, len(cfg.Accounts)-1) + } return saveCfg(cfg) } diff --git a/cmd/arcrun-app/default_library.go b/cmd/arcrun-app/default_library.go new file mode 100644 index 0000000..e17d7c0 --- /dev/null +++ b/cmd/arcrun-app/default_library.go @@ -0,0 +1,169 @@ +package main + +// default_library.go — 全新安裝的「可刪除預設庫」(leo 2026-08-08 拍板) +// +// leo 原話:「地端更新後,要裝一個 local 的 default 庫,他可以刪除。」 +// 具體化自更早的構想:「給他裝一個 Demo 同步文件夾在 Documents,裡面有幾個檔案 +// 說明 Arcrun 的豐功偉業……如果那個檔案被產出 wiki、可以 embed、可以搜,基本就 +// 整條通了,用戶連串一個資料夾都省了……不過,只有第一次安裝,然後他刪除,只要 +// 有掛用戶自己的資料夾,就不再出現。」 +// +// 四條紅線: +// 1. 可刪——用戶刪掉就真的消失,不自己長回來 +// 2. 只有第一次——一旦掛過自己的資料夾(=這台機器已經連過任何知識庫帳號), +// 這個預設庫不再出現 +// 3. 不污染——只在自己的資料夾裡寫檔,乾淨拿走、不留殘骸 +// 4. 不產生幽靈資料——資料夾被刪掉後,config 裡的殘留引用也要清掉, +// 不留「查得到但畫面永遠空」的紀錄 +// +// 機制: +// - marker 檔(default-library-seeded.json)是「只種一次」的唯一開關—— +// 一旦寫入就永遠不再重種,即使之後資料夾被刪、watch_folders 引用被清掉。 +// - seedDefaultLibraryIfFirstEver 只在 Connect() 新增「這台機器第一個帳號」時呼叫。 +// - pruneMissingDefaultLibrary 在 GetState()(前端每秒輪詢的入口)裡自我修復: +// 發現預設庫的實體資料夾已經不在了,就把 watch_folders 裡的殘留路徑拿掉。 +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "time" +) + +func defaultLibraryMarkerPath() string { + return filepath.Join(appDir(), "default-library-seeded.json") +} + +type defaultLibraryMarker struct { + Path string `json:"path"` + SeededAt string `json:"seeded_at"` +} + +// defaultLibraryDirName 刻意把「可刪除」寫進資料夾名字本身—— +// 使用者不用讀任何說明文件,光看資料夾名稱就知道這是我們鋪的示範內容、可以放心刪。 +const defaultLibraryDirName = "Arcrun 範例庫(可刪除)" + +func defaultLibraryPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, "Documents", defaultLibraryDirName) +} + +// defaultLibraryFiles=種進預設庫的示範內容(.md,collector 一定讀得懂)。 +// 內容本身不重要,重要的是「丟檔案進去 → AI 整理成知識卡 → 搜得到」這條路走得通。 +var defaultLibraryFiles = map[string]string{ + "01-關於這個資料夾.md": `# 這個資料夾是什麼 + +這是 Arcrun 第一次安裝時自動幫你準備的示範資料夾,裡面幾份筆記已經在被整理成知識卡。 + +- 打開 Arcrun,應該很快就能在你的知識庫裡搜到「Arcrun」「知識卡」這些字。 +- 這個資料夾**可以直接刪掉**,刪掉就真的沒了,不會自己跑回來。 +- 如果你已經加了自己的資料夾,這個示範資料夾之後也不會再自動出現。 +- 想長期使用也可以——把你想存的檔案直接丟進來就行,跟其他資料夾一樣會被自動整理。 +`, + "02-Arcrun-是什麼.md": `# Arcrun 是什麼 + +Arcrun 是一個跑在背景的小幫手:你把筆記、文件丟進你指定的資料夾, +它會自動讀出內容、用 AI 整理成一張張「知識卡」,送進你自己的知識庫, +之後不管在哪台裝置,打開知識庫網站就能直接搜到。 + +常見用法: +- 開會筆記、專案文件放進同一個資料夾,事後用關鍵字就找得回來。 +- 支援 Markdown、純文字、Word、PowerPoint、PDF、CSV、Excel。 +- 掃描版 PDF(沒有文字層的圖片檔)目前還讀不了,其餘格式會自動處理。 +`, + "03-怎麼使用.md": `# 怎麼使用 + +1. 把檔案拖進被監看的資料夾(就是這個資料夾,或是你之後自己加的資料夾)。 +2. Arcrun 會在背景發現變化、用 AI 整理成知識卡、上傳到你的知識庫,不用手動按什麼。 +3. 想馬上看到結果,也可以在 App 裡按「立刻同步」。 +4. 到你的知識庫網站搜尋,就能找到剛剛整理出來的內容。 + +如果額度用完或某個檔案讀不了,App 首頁會用白話告訴你發生什麼事、要不要動手—— +不會安靜地失敗、也不會丟裸露的錯誤碼給你看。 +`, +} + +// seedDefaultLibraryIfFirstEver 只在「這台機器第一次連上任何知識庫帳號」時跑一次 +// (呼叫端 Connect() 已保證:這是 cfg.Accounts 從空變成非空的那一次新增)。 +// marker 檔一旦寫入就是永久開關——不管之後資料夾或 watch_folders 引用發生什麼事, +// 都不會再種第二次。 +func seedDefaultLibraryIfFirstEver(cfg *directConfig, accIdx int) { + if _, err := os.Stat(defaultLibraryMarkerPath()); err == nil { + return // 已經種過一次,不管現在還在不在,永遠不再種第二次 + } + path := defaultLibraryPath() + if err := writeDefaultLibraryContents(path); err != nil { + appLog("預設庫建立失敗(不影響本次連線):%v", err) + return + } + if accIdx >= 0 && accIdx < len(cfg.Accounts) { + already := false + for _, f := range cfg.Accounts[accIdx].WatchFolders { + if f == path { + already = true + break + } + } + if !already { + cfg.Accounts[accIdx].WatchFolders = append(cfg.Accounts[accIdx].WatchFolders, path) + sort.Strings(cfg.Accounts[accIdx].WatchFolders) + } + } + marker := defaultLibraryMarker{Path: path, SeededAt: time.Now().Format(time.RFC3339)} + if b, err := json.MarshalIndent(marker, "", " "); err == nil { + if err := os.MkdirAll(appDir(), 0o755); err == nil { + _ = os.WriteFile(defaultLibraryMarkerPath(), b, 0o644) + } + } +} + +// writeDefaultLibraryContents 建資料夾+寫入示範檔案。已存在的檔案不覆寫 +// (理論上不會發生——marker 擋住重複呼叫——但求安全,不要覆蓋使用者可能已經改過的內容)。 +func writeDefaultLibraryContents(dir string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + for name, body := range defaultLibraryFiles { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + continue + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + return err + } + } + return nil +} + +// pruneMissingDefaultLibrary:預設庫的實體資料夾若已經被使用者刪掉, +// 把 config 裡殘留的 watch_folders 路徑也一起清掉——不留「查得到但資料夾早就不在」 +// 的幽靈引用,也不會讓 collector 每輪都對著一個不存在的資料夾報錯。 +// 資料夾「會不會長回來」由 seedDefaultLibraryIfFirstEver 的 marker 檔擋住, +// 這裡只負責把 config 清乾淨,不寫回任何實體檔案內容。 +// 回傳 changed=cfg 是否被修改(呼叫端據此決定要不要 saveCfg)。 +func pruneMissingDefaultLibrary(cfg *directConfig) bool { + b, err := os.ReadFile(defaultLibraryMarkerPath()) + if err != nil { + return false // 從沒種過,沒東西可清 + } + var marker defaultLibraryMarker + if jerr := json.Unmarshal(b, &marker); jerr != nil || marker.Path == "" { + return false + } + if _, err := os.Stat(marker.Path); err == nil { + return false // 資料夾還在,不用清 + } + changed := false + for i := range cfg.Accounts { + keep := cfg.Accounts[i].WatchFolders[:0] + for _, f := range cfg.Accounts[i].WatchFolders { + if f == marker.Path { + changed = true + continue + } + keep = append(keep, f) + } + cfg.Accounts[i].WatchFolders = keep + } + return changed +} diff --git a/cmd/arcrun-app/default_library_e2e_test.go b/cmd/arcrun-app/default_library_e2e_test.go new file mode 100644 index 0000000..d084817 --- /dev/null +++ b/cmd/arcrun-app/default_library_e2e_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestDefaultLibraryEndToEndDryRun 是 default_library_test.go 的補強:那邊驗證的是 +// 「App 這一層的邏輯對不對」(單元層級),這支驗證的是**真的把同步引擎跑起來**, +// 證明預設庫裡的檔案真的會被 collector 認成「要處理的事件」——也就是 +// 「丟檔 → 知識卡」那條路的前半段真的通,不是紙上談兵。 +// 用 --dry-run:collector 掃到 added 事件後回報 status="planned" 就收手, +// 不會真的打雲端(沒有真的知識庫可以打),也不寫 manifest。 +func TestDefaultLibraryEndToEndDryRun(t *testing.T) { + bin := filepath.Join(t.TempDir(), "arcrun-app-e2e") + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("編不出執行檔:%v\n%s", err, out) + } + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + // ① 全新安裝、第一次連上知識庫 ⇒ 應該自動長出預設庫 + srv := fakeDaemonConfigServer(t, "https://instance-e2e.example.workers.dev") + app := &App{} + if err := app.Connect(srv.URL, "evan@example.com", "pw"); err != nil { + t.Fatalf("Connect 失敗:%v", err) + } + libPath := defaultLibraryPath() + + run := func(label string) string { + t.Helper() + cmd := exec.Command(bin, collectorModeFlag, "direct", "--once", "--dry-run", "--config", configPath()) + cmd.Env = append(os.Environ(), "HOME="+home, "USERPROFILE="+home) + out, _ := cmd.CombinedOutput() // 非零結束碼也要看輸出,不提前判死 + t.Logf("=== %s ===\n%s", label, out) + return string(out) + } + + // ② 真的跑一輪:三份示範檔應該被辨識成 planned(=萃取管線願意收) + out := run("① 全新安裝,預設庫存在") + plannedCount := strings.Count(out, `"status": "planned"`) + if plannedCount < len(defaultLibraryFiles) { + t.Fatalf("預設庫的 %d 份示範檔應該全部被排進處理佇列(status=planned),"+ + "實際只看到 %d 次,輸出:\n%s", len(defaultLibraryFiles), plannedCount, out) + } + if strings.Contains(out, "缺必填欄位") { + t.Fatalf("設定缺必填欄位:%s", out) + } + + // ③ 使用者把預設庫整個刪掉 + if err := os.RemoveAll(libPath); err != nil { + t.Fatalf("刪除失敗:%v", err) + } + if _, err := os.Stat(libPath); !os.IsNotExist(err) { + t.Fatalf("刪除沒生效:%v", err) + } + + // App 下一次讀狀態時的自我修復(等同 GetState 裡的那段):清掉殘留引用 + cfg, err := loadCfg() + if err != nil { + t.Fatalf("讀不回設定:%v", err) + } + if !pruneMissingDefaultLibrary(cfg) { + t.Fatal("應該偵測到預設庫已被刪除") + } + if err := saveCfg(cfg); err != nil { + t.Fatalf("存檔失敗:%v", err) + } + + // ④ 再跑一輪:不該再處理那三份檔(資料夾不在了),也不該把資料夾生回來 + out2 := run("② 刪除後再跑一輪") + if strings.Contains(out2, `"status": "planned"`) { + t.Fatalf("刪除後不該還有 planned 事件,輸出:\n%s", out2) + } + if _, err := os.Stat(libPath); !os.IsNotExist(err) { + t.Fatalf("預設庫刪掉後又被同步引擎生回來了:%v", err) + } + + // ⑤ marker 檔還在 ⇒ 就算再跑一次種子邏輯,也不會再出現(紅線②③④一次證完) + seedDefaultLibraryIfFirstEver(cfg, 0) + if _, err := os.Stat(libPath); !os.IsNotExist(err) { + t.Fatalf("種子邏輯把已刪除的預設庫又生回來了:%v", err) + } +} diff --git a/cmd/arcrun-app/default_library_test.go b/cmd/arcrun-app/default_library_test.go new file mode 100644 index 0000000..36afa08 --- /dev/null +++ b/cmd/arcrun-app/default_library_test.go @@ -0,0 +1,220 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// fakeDaemonConfigServer 假的知識庫登入端點,回傳固定的 daemonConfigResp, +// 讓測試能真的走過 App.Connect() 而不必打真的雲端。 +// +// 🔴 用 TLS server 不是隨便選的:normalizePortalURL()(connect.go)不管使用者貼的是 +// http:// 還是 https://,一律吐回 "https://" + host(現實世界的網址全是 https,這樣 +// 才不必逼使用者記得打 https)——所以假伺服器也一定要是 TLS,用一般 httptest.NewServer +// 會被硬轉成 https:// 打過去、連不上。同時把 http.DefaultTransport 換成信任這顆測試憑證 +// 的版本(fetchConfigByLogin 的 http.Client{} 沒指定 Transport ⇒ 用的正是這個全域預設值), +// 測試結束就還原,不影響其他測試或正式行為。 +func fakeDaemonConfigServer(t *testing.T, cypherURL string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/portal/daemon/config", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(daemonConfigResp{ + Success: true, + Config: daemonConfig{ + CypherURL: cypherURL, + Namespace: "abc123", + Library: "lib1", + Email: "evan@example.com", + InstanceName: "evan-的知識庫", + }, + }) + }) + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + origTransport := http.DefaultTransport + http.DefaultTransport = srv.Client().Transport + t.Cleanup(func() { http.DefaultTransport = origTransport }) + + return srv +} + +// TestDefaultLibrarySeededOnFirstConnect 釘住紅線①③:全新安裝、第一次連上知識庫, +// 應該自動出現一個可刪除的預設庫——裡面有真的檔案(丟檔→知識卡→搜得到那條路走得通), +// 而且只在這個資料夾自己底下寫東西,不污染 Documents 的其他地方。 +func TestDefaultLibrarySeededOnFirstConnect(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + srv := fakeDaemonConfigServer(t, "https://instance-a.example.workers.dev") + app := &App{} + if err := app.Connect(srv.URL, "evan@example.com", "pw"); err != nil { + t.Fatalf("Connect 失敗:%v", err) + } + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("讀不回設定:%v", err) + } + if len(cfg.Accounts) != 1 { + t.Fatalf("應該只有一個帳號,實際 %d 個", len(cfg.Accounts)) + } + + wantPath := defaultLibraryPath() + found := false + for _, f := range cfg.Accounts[0].WatchFolders { + if f == wantPath { + found = true + } + } + if !found { + t.Fatalf("預設庫 %s 沒有被加進 watch_folders:%v", wantPath, cfg.Accounts[0].WatchFolders) + } + + // 資料夾真的存在、裡面有真的檔案(不是空殼) + entries, err := os.ReadDir(wantPath) + if err != nil { + t.Fatalf("預設庫資料夾沒有被建出來:%v", err) + } + if len(entries) == 0 { + t.Fatal("預設庫資料夾是空的,等於沒有東西可以被整理成知識卡") + } + for _, e := range entries { + if filepath.Ext(e.Name()) != ".md" { + t.Fatalf("預設庫檔案 %s 不是 collector 讀得懂的格式(見 scan.go allowedExt)", e.Name()) + } + b, err := os.ReadFile(filepath.Join(wantPath, e.Name())) + if err != nil || len(b) == 0 { + t.Fatalf("%s 讀不到內容或是空檔——不准放空檔/佔位符", e.Name()) + } + } + + // 不污染:Documents 底下只多出這一個資料夾,沒有散落在別處的殘留檔案 + docsEntries, err := os.ReadDir(filepath.Join(home, "Documents")) + if err != nil { + t.Fatalf("讀不到 Documents:%v", err) + } + if len(docsEntries) != 1 || docsEntries[0].Name() != defaultLibraryDirName { + names := []string{} + for _, e := range docsEntries { + names = append(names, e.Name()) + } + t.Fatalf("Documents 底下應該只有預設庫這一個資料夾,實際:%v", names) + } + + // marker 檔存在,代表「已經種過一次」 + if _, err := os.Stat(defaultLibraryMarkerPath()); err != nil { + t.Fatalf("marker 檔沒寫出來:%v", err) + } +} + +// TestDefaultLibraryDeletedDoesNotComeBack 釘住紅線①④:使用者把預設庫整個刪掉之後, +// ① config 裡的殘留引用要被清乾淨(不留幽靈資料) +// ② 就算之後又跑一次「種子」邏輯,也絕對不會把資料夾或引用生回來。 +func TestDefaultLibraryDeletedDoesNotComeBack(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + srv := fakeDaemonConfigServer(t, "https://instance-b.example.workers.dev") + app := &App{} + if err := app.Connect(srv.URL, "evan@example.com", "pw"); err != nil { + t.Fatalf("Connect 失敗:%v", err) + } + + path := defaultLibraryPath() + if _, err := os.Stat(path); err != nil { + t.Fatalf("前置條件失敗:預設庫應該已經存在:%v", err) + } + + // 使用者從 Finder/檔案總管把整個資料夾刪掉 + if err := os.RemoveAll(path); err != nil { + t.Fatalf("刪除失敗:%v", err) + } + + // App 下一次讀狀態(等同 GetState 的自我修復邏輯)應該把殘留引用清掉 + cfg, err := loadCfg() + if err != nil { + t.Fatalf("讀不回設定:%v", err) + } + if !pruneMissingDefaultLibrary(cfg) { + t.Fatal("應該偵測到預設庫已被刪除並回報 changed=true") + } + if err := saveCfg(cfg); err != nil { + t.Fatalf("存檔失敗:%v", err) + } + + cfg2, _ := loadCfg() + for _, f := range cfg2.Accounts[0].WatchFolders { + if f == path { + t.Fatal("刪掉的預設庫路徑還留在 watch_folders 裡——幽靈資料") + } + } + + // 再跑一次種子邏輯(模擬使用者重開程式、或又連了一次同一個帳號)—— + // marker 檔還在,絕對不能把資料夾或引用生回來。 + seedDefaultLibraryIfFirstEver(cfg2, 0) + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("預設庫刪掉後又長回來了:%v", err) + } + for _, f := range cfg2.Accounts[0].WatchFolders { + if f == path { + t.Fatal("種子邏輯把已刪除的預設庫路徑又加回 watch_folders 了") + } + } +} + +// TestDefaultLibraryNotSeededWhenAccountsAlreadyExist 釘住紅線②: +// 「只要有掛用戶自己的資料夾,就不再出現」——這台機器如果已經連過帳號, +// 之後不管再連幾個新帳號,都不應該冒出這個預設庫。 +func TestDefaultLibraryNotSeededWhenAccountsAlreadyExist(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + // 模擬「這台機器已經有一個帳號,且已經掛了使用者自己的資料夾」 + preexisting := &directConfig{ + Accounts: []accountCfg{{ + InstanceName: "既有帳號", + CypherURL: "https://instance-existing.example.workers.dev", + Namespace: "existing", + APIKey: "existing", + WatchFolders: []string{filepath.Join(home, "我的筆記")}, + }}, + Extractor: "workers-ai", + Manifest: filepath.Join(home, ".arcrun-rag", "manifest.json"), + } + if err := saveCfg(preexisting); err != nil { + t.Fatalf("前置存檔失敗:%v", err) + } + + srv := fakeDaemonConfigServer(t, "https://instance-c.example.workers.dev") + app := &App{} + if err := app.Connect(srv.URL, "second@example.com", "pw"); err != nil { + t.Fatalf("Connect 失敗:%v", err) + } + + if _, err := os.Stat(defaultLibraryPath()); !os.IsNotExist(err) { + t.Fatalf("已有帳號的機器不該冒出預設庫,但資料夾存在:err=%v", err) + } + if _, err := os.Stat(defaultLibraryMarkerPath()); !os.IsNotExist(err) { + t.Fatal("已有帳號的機器不該寫出 marker 檔") + } + + cfg, _ := loadCfg() + if len(cfg.Accounts) != 2 { + t.Fatalf("應該有兩個帳號(既有 + 新連的),實際 %d", len(cfg.Accounts)) + } + for _, acc := range cfg.Accounts { + for _, f := range acc.WatchFolders { + if f == defaultLibraryPath() { + t.Fatal("預設庫路徑不該出現在任何帳號的 watch_folders 裡") + } + } + } +}