新增全新安裝的「可刪除預設庫」(leo 2026-08-08 拍板)

全新使用者第一次連上知識庫時,自動在 Documents 建一個
「Arcrun 範例庫(可刪除)」,裡面放三份 .md 示範內容,
不必自己選資料夾就能立刻走完「丟檔→知識卡→搜得到」。

四條紅線都有測試釘住(default_library_test.go/
default_library_e2e_test.go,含真的建 binary 跑 --dry-run 驗證):
1. 可刪——刪掉就真的消失,marker 檔擋住重種
2. 只有第一次——已有帳號的機器不會冒出來
3. 不污染——只在自己的資料夾裡寫檔
4. 不留幽靈資料——資料夾被刪後,config 殘留引用會被清掉(GetState 自我修復)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 00:22:03 +08:00
parent 89d9b314fb
commit 361f1f1f9d
5 changed files with 495 additions and 0 deletions
+11
View File
@@ -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 {
+6
View File
@@ -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)
}
+169
View File
@@ -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
}
@@ -0,0 +1,89 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestDefaultLibraryEndToEndDryRun 是 default_library_test.go 的補強:那邊驗證的是
// 「App 這一層的邏輯對不對」(單元層級),這支驗證的是**真的把同步引擎跑起來**,
// 證明預設庫裡的檔案真的會被 collector 認成「要處理的事件」——也就是
// 「丟檔 → 知識卡」那條路的前半段真的通,不是紙上談兵。
// 用 --dry-runcollector 掃到 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)
}
}
+220
View File
@@ -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 裡")
}
}
}
}