feat(t104): 多帳號同時看守——切換概念消滅
leo 架構依據:「它只是一個門,幾個帳號通過它同步並沒有影響」+ 「我不是 Google Drive……不提供暫存空間,daemon 工作輕巧,多帳號只是頁簽問題」 (D-daemon-not-Drive)。 Accounts[] 每帳號獨立連線+資料夾;舊 config 冪等遷移 accounts[0]; 逐帳號同步一敗不擋全;status.json 分帳;托盤每帳號一分組; 「連上知識庫」→「+新增帳號…」(append 非替換);t86 切換清空退役; t101 刪除作用於正確帳號。+873/-149、collector 5+tray 5 新測試, 兩模組 go test 全綠(總管親跑)。 (實作=子 CC;驗證+commit=總管)
This commit is contained in:
+269
-86
@@ -59,29 +59,47 @@ func versionLabel() string {
|
||||
return version
|
||||
}
|
||||
|
||||
// directConfig 是 collector direct 的設定(與 collector/direct.go 的 DirectConfig 同結構;
|
||||
// 這裡只需讀寫 watch_folder,其餘由安裝器帶入)。
|
||||
// accountCfg 是單一帳號的連線設定(t104 多帳號同時看守)。
|
||||
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"`
|
||||
Libraries map[string]string `json:"libraries,omitempty"` // t52:資料夾→庫對映
|
||||
}
|
||||
|
||||
// directConfig 是 collector direct 的設定(與 collector/direct.go 的 DirectConfig 同結構)。
|
||||
type directConfig struct {
|
||||
WatchFolder string `json:"watch_folder,omitempty"` // 單數舊制(第一個資料夾的鏡像,維持相容)
|
||||
WatchFolders []string `json:"watch_folders,omitempty"` // 多資料夾(daemon-beta task 1;完整勾選 UI=task 7)
|
||||
Manifest string `json:"manifest"`
|
||||
CypherURL string `json:"cypher_url"`
|
||||
Namespace string `json:"namespace"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
Email string `json:"email,omitempty"` // 實例主身分(t26;人人記得自己的 email,CF 全程隱形)
|
||||
InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email)
|
||||
Library string `json:"library,omitempty"`
|
||||
Extractor string `json:"extractor,omitempty"` // t54:連線精靈帶入(claude/gemma)
|
||||
ClaudeBin string `json:"claude_bin,omitempty"` // t92:collector fallback 找到後回寫,下次直達
|
||||
Libraries map[string]string `json:"libraries,omitempty"` // t52:資料夾→庫對映(key=絕對路徑)
|
||||
IngestWF string `json:"ingest_workflow,omitempty"`
|
||||
RemovedWF string `json:"removed_workflow,omitempty"`
|
||||
PollSec int `json:"poll_interval_sec,omitempty"`
|
||||
MaxRemoved float64 `json:"max_removed_ratio,omitempty"`
|
||||
// t104:多帳號清單(新制)。有值時頂層連線欄位僅保留讀取相容。
|
||||
Accounts []accountCfg `json:"accounts,omitempty"`
|
||||
WatchFolder string `json:"watch_folder,omitempty"` // 單數舊制(第一個資料夾的鏡像,維持相容)
|
||||
WatchFolders []string `json:"watch_folders,omitempty"` // 多資料夾(舊制)
|
||||
Manifest string `json:"manifest"`
|
||||
CypherURL string `json:"cypher_url,omitempty"` // 舊制(新制走 Accounts)
|
||||
Namespace string `json:"namespace,omitempty"` // 舊制
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
InstanceName string `json:"instance_name,omitempty"`
|
||||
Library string `json:"library,omitempty"`
|
||||
Extractor string `json:"extractor,omitempty"` // 機器層級:claude/gemma
|
||||
ClaudeBin string `json:"claude_bin,omitempty"` // t92:collector fallback 找到後回寫
|
||||
Libraries map[string]string `json:"libraries,omitempty"` // t52 舊制:資料夾→庫對映
|
||||
IngestWF string `json:"ingest_workflow,omitempty"`
|
||||
RemovedWF string `json:"removed_workflow,omitempty"`
|
||||
PollSec int `json:"poll_interval_sec,omitempty"`
|
||||
MaxRemoved float64 `json:"max_removed_ratio,omitempty"`
|
||||
}
|
||||
|
||||
// ── t91 萃取狀態可見性 ──────────────────────────────────────────────────────────
|
||||
|
||||
// trayAccountStatus 對映 per-account 狀態(來自 status.json account_details)。
|
||||
type trayAccountStatus struct {
|
||||
CloudVersion string `json:"cloud_version,omitempty"`
|
||||
CloudCheckOK bool `json:"cloud_check_ok"`
|
||||
}
|
||||
|
||||
// traySyncStatus 對映 collector 寫出的 ~/.arcrun-rag/status.json(僅含托盤需要的欄位)。
|
||||
type traySyncStatus struct {
|
||||
LastSync string `json:"last_sync,omitempty"`
|
||||
@@ -90,9 +108,11 @@ type traySyncStatus struct {
|
||||
Failures []trayExtFail `json:"failures,omitempty"`
|
||||
ExtractorOK bool `json:"extractor_ok"`
|
||||
ExtractorError string `json:"extractor_error,omitempty"`
|
||||
// t103:雲端版本偵測
|
||||
CloudVersion string `json:"cloud_version,omitempty"` // collector 每輪 GET /health 取得的 bundle_version
|
||||
CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才 true;false 不判定(靜默)
|
||||
// 頂層雲端欄位(向後相容,單帳號時有值)
|
||||
CloudVersion string `json:"cloud_version,omitempty"`
|
||||
CloudCheckOK bool `json:"cloud_check_ok"`
|
||||
// t104:per-account 狀態(key = cypher_url host)
|
||||
AccountDetails map[string]trayAccountStatus `json:"account_details,omitempty"`
|
||||
}
|
||||
|
||||
// ── t103 雲端版本偵測 ──────────────────────────────────────────────────────────
|
||||
@@ -173,11 +193,22 @@ func loadConfig() *directConfig {
|
||||
if data, err := os.ReadFile(configPath()); err == nil {
|
||||
_ = json.Unmarshal(data, c)
|
||||
}
|
||||
if c.WatchFolder == "" && len(c.WatchFolders) == 0 {
|
||||
c.WatchFolder = defaultWatchFolder()
|
||||
// t104:舊格式遷移——頂層 CypherURL → Accounts[0](冪等)
|
||||
if len(c.Accounts) == 0 && strings.TrimSpace(c.CypherURL) != "" {
|
||||
c.Accounts = []accountCfg{{
|
||||
InstanceName: c.InstanceName,
|
||||
Email: c.Email,
|
||||
CypherURL: c.CypherURL,
|
||||
Namespace: c.Namespace,
|
||||
APIKey: c.APIKey,
|
||||
Libraries: c.Libraries,
|
||||
WatchFolders: c.legacyFolders(),
|
||||
}}
|
||||
// 寫回磁碟(冪等:下次讀 Accounts 已存在就不再遷移)
|
||||
_ = saveConfig(c)
|
||||
}
|
||||
if c.WatchFolder == "" && len(c.WatchFolders) > 0 {
|
||||
c.WatchFolder = c.WatchFolders[0] // 單數欄位=第一根鏡像(舊 collector 相容)
|
||||
if len(c.Accounts) == 0 && c.WatchFolder == "" && len(c.WatchFolders) == 0 {
|
||||
c.WatchFolder = defaultWatchFolder() // 尚未連線的預設值(不觸發遷移)
|
||||
}
|
||||
if c.Manifest == "" {
|
||||
c.Manifest = filepath.Join(appDir(), "manifest.json")
|
||||
@@ -185,6 +216,20 @@ func loadConfig() *directConfig {
|
||||
return c
|
||||
}
|
||||
|
||||
// legacyFolders 回傳頂層(舊制)監看根清單(不含 accounts,供遷移使用)。
|
||||
func (c *directConfig) legacyFolders() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(p string) {
|
||||
if p == "" || seen[p] { return }
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
add(c.WatchFolder)
|
||||
for _, f := range c.WatchFolders { add(f) }
|
||||
return out
|
||||
}
|
||||
|
||||
// addWatchFolder 把資料夾加進監看清單(去重、保序),並維持單數欄位=第一根的鏡像。
|
||||
// 完整的「勾選/移除」UI 是 task 7;本函式先保證資料層正確。
|
||||
func addWatchFolder(c *directConfig, p string) {
|
||||
@@ -203,22 +248,23 @@ func addWatchFolder(c *directConfig, p string) {
|
||||
c.WatchFolder = c.WatchFolders[0]
|
||||
}
|
||||
|
||||
// Folders 回傳監看根清單(正規化:單數舊制併入、去重、保序;與 collector DirectConfig 同語意)。
|
||||
// Folders 回傳監看根清單(t104:多帳號時彙整所有帳號資料夾;舊制時回頂層清單)。
|
||||
// 供 registerLibraries 等跨帳號操作使用。
|
||||
func (c *directConfig) Folders() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(p string) {
|
||||
if p == "" || seen[p] {
|
||||
return
|
||||
if len(c.Accounts) > 0 {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, acc := range c.Accounts {
|
||||
for _, f := range acc.WatchFolders {
|
||||
if f != "" && !seen[f] {
|
||||
seen[f] = true
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
return out
|
||||
}
|
||||
add(c.WatchFolder)
|
||||
for _, f := range c.WatchFolders {
|
||||
add(f)
|
||||
}
|
||||
return out
|
||||
return c.legacyFolders()
|
||||
}
|
||||
|
||||
// removeWatchFolder 把資料夾移出監看清單,並維持單數欄位=第一根鏡像(清單空=兩欄皆空)。
|
||||
@@ -495,9 +541,97 @@ func libraryNameFor(cfg *directConfig, folder string) string {
|
||||
|
||||
// isConnected 判斷「這台機器已經連上某個知識庫」——沒有就該跳連線精靈。
|
||||
func isConnected(cfg *directConfig) bool {
|
||||
if len(cfg.Accounts) > 0 {
|
||||
for _, acc := range cfg.Accounts {
|
||||
if strings.TrimSpace(acc.CypherURL) != "" && strings.TrimSpace(acc.Namespace) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(cfg.CypherURL) != "" && strings.TrimSpace(cfg.Namespace) != ""
|
||||
}
|
||||
|
||||
// addOrUpdateAccount 把連線精靈取回的設定加入(或更新)accounts 清單(t104)。
|
||||
// 同 host 已存在 → 更新連線欄位,WatchFolders 保留不動;不同 host → append 新帳號。
|
||||
// 回傳 true 代表是全新帳號(首次加入)。
|
||||
func addOrUpdateAccount(cfg *directConfig, r *daemonConfigResp) bool {
|
||||
newHost := shortCypherHost(r.Config.CypherURL)
|
||||
for i := range cfg.Accounts {
|
||||
if shortCypherHost(cfg.Accounts[i].CypherURL) == newHost {
|
||||
// 更新現有帳號(保留 WatchFolders 與 Libraries)
|
||||
cfg.Accounts[i].CypherURL = strings.TrimSuffix(strings.TrimSpace(r.Config.CypherURL), "/")
|
||||
cfg.Accounts[i].Namespace = r.Config.Namespace
|
||||
cfg.Accounts[i].Email = r.Config.Email
|
||||
if r.Config.InstanceName != "" {
|
||||
cfg.Accounts[i].InstanceName = r.Config.InstanceName
|
||||
}
|
||||
if r.Config.Extractor != "" {
|
||||
cfg.Extractor = r.Config.Extractor // 機器層級
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 新帳號
|
||||
acc := accountCfg{
|
||||
InstanceName: r.Config.InstanceName,
|
||||
Email: r.Config.Email,
|
||||
CypherURL: strings.TrimSuffix(strings.TrimSpace(r.Config.CypherURL), "/"),
|
||||
Namespace: r.Config.Namespace,
|
||||
}
|
||||
if acc.APIKey == "" {
|
||||
acc.APIKey = acc.Namespace
|
||||
}
|
||||
cfg.Accounts = append(cfg.Accounts, acc)
|
||||
if r.Config.Extractor != "" {
|
||||
cfg.Extractor = r.Config.Extractor
|
||||
}
|
||||
if cfg.Manifest == "" {
|
||||
cfg.Manifest = filepath.Join(appDir(), "manifest.json")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// addAccountWatchFolder 把資料夾加進指定帳號的監看清單(t104)。
|
||||
func addAccountWatchFolder(cfg *directConfig, accIdx int, p string) {
|
||||
if p == "" || accIdx >= len(cfg.Accounts) {
|
||||
return
|
||||
}
|
||||
acc := &cfg.Accounts[accIdx]
|
||||
for _, f := range acc.WatchFolders {
|
||||
if f == p {
|
||||
return
|
||||
}
|
||||
}
|
||||
acc.WatchFolders = append(acc.WatchFolders, p)
|
||||
}
|
||||
|
||||
// removeAccountWatchFolder 把資料夾從指定帳號的監看清單移出(t104)。
|
||||
func removeAccountWatchFolder(cfg *directConfig, accIdx int, p string) {
|
||||
if accIdx >= len(cfg.Accounts) {
|
||||
return
|
||||
}
|
||||
acc := &cfg.Accounts[accIdx]
|
||||
var out []string
|
||||
for _, f := range acc.WatchFolders {
|
||||
if f != p {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
acc.WatchFolders = out
|
||||
}
|
||||
|
||||
// accountDisplayName 回傳帳號顯示名稱(暱稱 > email > host)。
|
||||
func accountDisplayName(acc accountCfg) string {
|
||||
if n := strings.TrimSpace(acc.InstanceName); n != "" {
|
||||
return n
|
||||
}
|
||||
if e := strings.TrimSpace(acc.Email); e != "" {
|
||||
return e
|
||||
}
|
||||
return shortCypherHost(acc.CypherURL)
|
||||
}
|
||||
|
||||
func main() {
|
||||
a := app.NewWithID("dev.arcrun.rag.tray")
|
||||
cfg := loadConfig()
|
||||
@@ -572,28 +706,34 @@ func main() {
|
||||
go func() {
|
||||
r, err := fetchConfigByLogin(urlEntry.Text, emailEntry.Text, pwEntry.Text)
|
||||
if err != nil {
|
||||
// fyne 的 UI 更新須回主執行緒;用 dialog 顯示即可(它內部處理)
|
||||
dialog.ShowError(err, win)
|
||||
// t75:把剛才打的網址與 email 帶回去,讓他只改錯的那個字,不必整段重打。
|
||||
showConnectWizardWith(urlEntry.Text, emailEntry.Text)
|
||||
return
|
||||
}
|
||||
switched := applyRemoteConfig(cfg, r)
|
||||
// t104:append 新帳號或更新同 host 帳號——不清空其他帳號資料夾(t86 切換清空退役)
|
||||
isNew := addOrUpdateAccount(cfg, r)
|
||||
if err := saveConfig(cfg); err != nil {
|
||||
dialog.ShowError(err, win)
|
||||
return
|
||||
}
|
||||
// t52:把本機資料夾對應的庫報上去自動登記(地端幾個資料夾=雲端幾個庫)
|
||||
// t52:把本機資料夾對應的庫報上去自動登記
|
||||
if lerr := registerLibraries(urlEntry.Text, emailEntry.Text, pwEntry.Text, cfg); lerr != nil {
|
||||
fmt.Println("庫登記略過:", lerr) // 不擋連線(可能是還沒選資料夾)
|
||||
}
|
||||
restartWatch()
|
||||
rebuildTray()
|
||||
// t86:換了知識庫實例時,說明已清空資料夾清單的原因,請用戶重新選資料夾。
|
||||
if switched {
|
||||
dialog.ShowInformation("連上了", "因為換了知識庫,為了避免把舊資料夾誤傳到新知識庫,請重新用「+ 新增知識資料夾…」選擇要同步的資料夾。", win)
|
||||
accName := strings.TrimSpace(r.Config.InstanceName)
|
||||
if accName == "" {
|
||||
accName = strings.TrimSpace(r.Config.Email)
|
||||
}
|
||||
if accName == "" {
|
||||
accName = shortCypherHost(r.Config.CypherURL)
|
||||
}
|
||||
if isNew {
|
||||
dialog.ShowInformation("帳號已加入", "已加入「"+accName+"」。\n接下來用該帳號底下的「+ 新增知識資料夾…」選擇要同步的資料夾就好。", win)
|
||||
} else {
|
||||
dialog.ShowInformation("連上了", "已連上「"+connectionStatusLabel(cfg.InstanceName, cfg.Email)+"」。\n接下來用選單「+ 新增知識資料夾…」挑要同步的資料夾就好。", win)
|
||||
dialog.ShowInformation("帳號已更新", "「"+accName+"」的連線設定已更新。", win)
|
||||
}
|
||||
}()
|
||||
}, win)
|
||||
@@ -610,7 +750,12 @@ func main() {
|
||||
if err != nil || uri == nil {
|
||||
return
|
||||
}
|
||||
addWatchFolder(cfg, uri.Path())
|
||||
// t104:多帳號模式加進第一個帳號;無帳號時走舊制
|
||||
if len(cfg.Accounts) > 0 {
|
||||
addAccountWatchFolder(cfg, 0, uri.Path())
|
||||
} else {
|
||||
addWatchFolder(cfg, uri.Path())
|
||||
}
|
||||
if err := saveConfig(cfg); err != nil {
|
||||
dialog.ShowError(err, win)
|
||||
return
|
||||
@@ -661,18 +806,82 @@ func main() {
|
||||
supStatus := sup.Status()
|
||||
statusItem.Label = "狀態:" + buildStatusLabel(supStatus, sync, cfg.Extractor)
|
||||
|
||||
// t26:選單最頂顯示「連線中:<暱稱||email>」(CF/cypher_url 全程不現身這一行);
|
||||
// 有 cypher_url 時再加一行縮短的 host(fyne MenuItem 無 tooltip,取捨見 shortCypherHost 注解)。
|
||||
connItem := fyne.NewMenuItem(connectionStatusLabel(cfg.InstanceName, cfg.Email), nil)
|
||||
connItem.Disabled = true
|
||||
versionItem := fyne.NewMenuItem("版本 "+versionLabel(), nil)
|
||||
versionItem.Disabled = true
|
||||
items := []*fyne.MenuItem{connItem}
|
||||
if host := shortCypherHost(cfg.CypherURL); host != "" {
|
||||
hostItem := fyne.NewMenuItem("→ "+host, nil)
|
||||
hostItem.Disabled = true
|
||||
items = append(items, hostItem)
|
||||
|
||||
var items []*fyne.MenuItem
|
||||
|
||||
// t104:per-account 分組——每個帳號獨立一段(帳號名稱標題 + 資料夾 + 新增按鈕)。
|
||||
if len(cfg.Accounts) > 0 {
|
||||
for ai, acc := range cfg.Accounts {
|
||||
accIdx := ai // capture by value,供 closure 正確引用
|
||||
accHost := shortCypherHost(acc.CypherURL)
|
||||
|
||||
// 帳號標題(不可點)
|
||||
headerItem := fyne.NewMenuItem("◉ "+accountDisplayName(acc), nil)
|
||||
headerItem.Disabled = true
|
||||
items = append(items, headerItem)
|
||||
|
||||
// t103 per-account:雲端版本過舊時顯示更新提示(帶帳號識別)
|
||||
if accSt, ok := sync.AccountDetails[accHost]; ok && trayCloudVersionStale(accSt.CloudVersion, accSt.CloudCheckOK) {
|
||||
items = append(items, fyne.NewMenuItem(" ⚠ 知識庫需要更新(點我)", func() {
|
||||
if u, err := neturl.Parse("https://install.arcrun.dev/"); err == nil {
|
||||
_ = a.OpenURL(u)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// 此帳號的資料夾(t101:子選單「刪除這個知識庫」作用於正確帳號)
|
||||
for _, f := range acc.WatchFolders {
|
||||
folder := f // capture
|
||||
aidx := accIdx // capture
|
||||
it := fyne.NewMenuItem(" 📁 "+filepath.Base(folder), func() {
|
||||
if u, err := neturl.Parse("file://" + folder); err == nil {
|
||||
_ = a.OpenURL(u)
|
||||
}
|
||||
})
|
||||
it.ChildMenu = fyne.NewMenu("",
|
||||
fyne.NewMenuItem("刪除這個知識庫", func() {
|
||||
removeAccountWatchFolder(cfg, aidx, folder)
|
||||
if err := saveConfig(cfg); err != nil {
|
||||
dialog.ShowError(err, win)
|
||||
return
|
||||
}
|
||||
restartWatch()
|
||||
rebuildTray()
|
||||
}),
|
||||
)
|
||||
items = append(items, it)
|
||||
}
|
||||
|
||||
// 此帳號的「+ 新增知識資料夾…」
|
||||
aidxAdd := accIdx // capture
|
||||
items = append(items, fyne.NewMenuItem(" + 新增知識資料夾…", func() {
|
||||
win.Show()
|
||||
win.RequestFocus()
|
||||
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
|
||||
if err != nil || uri == nil {
|
||||
return
|
||||
}
|
||||
addAccountWatchFolder(cfg, aidxAdd, uri.Path())
|
||||
if err := saveConfig(cfg); err != nil {
|
||||
dialog.ShowError(err, win)
|
||||
return
|
||||
}
|
||||
restartWatch()
|
||||
rebuildTray()
|
||||
win.Hide()
|
||||
}, win)
|
||||
}))
|
||||
items = append(items, fyne.NewMenuItemSeparator())
|
||||
}
|
||||
} else {
|
||||
// 尚未連線(accounts 空)——簡化顯示
|
||||
notConn := fyne.NewMenuItem("未連線到任何知識庫", nil)
|
||||
notConn.Disabled = true
|
||||
items = append(items, notConn, fyne.NewMenuItemSeparator())
|
||||
}
|
||||
|
||||
items = append(items, statusItem)
|
||||
// t91:有萃取失敗時加警告項,點開顯示哪些檔出了什麼問題(白話)。
|
||||
if cfg.Extractor != "" && sync.ExtractFailed > 0 {
|
||||
@@ -687,48 +896,22 @@ func main() {
|
||||
})
|
||||
items = append(items, failItem)
|
||||
}
|
||||
// t103:雲端版本過舊(或老實例)時提示用戶更新,點了開瀏覽器到安裝頁。
|
||||
if trayCloudVersionStale(sync.CloudVersion, sync.CloudCheckOK) {
|
||||
// t103 向後相容(單帳號舊 status.json 仍有頂層 CloudVersion):若無 AccountDetails 則看頂層
|
||||
if len(sync.AccountDetails) == 0 && trayCloudVersionStale(sync.CloudVersion, sync.CloudCheckOK) {
|
||||
items = append(items, fyne.NewMenuItem("⚠ 知識庫需要更新(點我)", func() {
|
||||
if u, err := neturl.Parse("https://install.arcrun.dev/"); err == nil {
|
||||
_ = a.OpenURL(u)
|
||||
}
|
||||
}))
|
||||
}
|
||||
items = append(items, fyne.NewMenuItemSeparator())
|
||||
for _, f := range cfg.Folders() {
|
||||
folder := f // capture
|
||||
it := fyne.NewMenuItem("📁 "+filepath.Base(folder), func() {
|
||||
if u, err := neturl.Parse("file://" + folder); err == nil {
|
||||
_ = a.OpenURL(u)
|
||||
}
|
||||
})
|
||||
it.ChildMenu = fyne.NewMenu("",
|
||||
fyne.NewMenuItem("刪除這個知識庫", func() {
|
||||
removeWatchFolder(cfg, folder)
|
||||
if err := saveConfig(cfg); err != nil {
|
||||
dialog.ShowError(err, win)
|
||||
return
|
||||
}
|
||||
restartWatch()
|
||||
rebuildTray()
|
||||
}),
|
||||
)
|
||||
items = append(items, it)
|
||||
}
|
||||
items = append(items,
|
||||
fyne.NewMenuItem("+ 新增知識資料夾…", addAction),
|
||||
fyne.NewMenuItem("🔗 連上知識庫…(換帳號/重新連線)", func() { showConnectWizard() }), // t54
|
||||
fyne.NewMenuItemSeparator(),
|
||||
syncNowItem, // t98:立刻同步
|
||||
pauseItem,
|
||||
// 版本列(leo 2026-07-27:「daemon 要加上版本編號」):停用態=純顯示不可點。
|
||||
// 放在「結束」正上方——使用者要回報問題時,眼睛已經在選單底部這一區。
|
||||
// t104:「連上知識庫」改為「新增帳號」——精靈成功後 append 到 accounts,不換掉舊帳號
|
||||
fyne.NewMenuItem("+ 新增帳號…", func() { showConnectWizard() }),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
versionItem,
|
||||
// t55(leo 2026-07-26 實撞:「沒有 exit 的選項,我無法關閉它」):
|
||||
// 沒有出口的 app 是設計缺陷——用戶只剩「強制結束」可用,而托盤 app 連 Dock 都沒有
|
||||
// (LSUIElement),右鍵結束那條路也不存在。這裡給一條明確的出路:
|
||||
// 先停子行程(collector),再退整個 app,不留孤兒行程。
|
||||
fyne.NewMenuItem("結束 Arcrun RAG", func() {
|
||||
sup.Stop()
|
||||
a.Quit()
|
||||
|
||||
@@ -306,3 +306,138 @@ func TestDirectConfigPreservesClaudeBin(t *testing.T) {
|
||||
t.Errorf("round-trip 不一致:got %q, want %q", back.ClaudeBin, c.ClaudeBin)
|
||||
}
|
||||
}
|
||||
|
||||
// ── t104:多帳號同時看守 ──────────────────────────────────────────────────────────
|
||||
|
||||
// TestAddOrUpdateAccount_NewAccount:空 accounts → 加入新帳號。
|
||||
func TestAddOrUpdateAccount_NewAccount(t *testing.T) {
|
||||
cfg := &directConfig{}
|
||||
r := &daemonConfigResp{}
|
||||
r.Config.CypherURL = "https://instance1.workers.dev"
|
||||
r.Config.Namespace = "ns1"
|
||||
r.Config.Email = "user@a.com"
|
||||
r.Config.InstanceName = "Work"
|
||||
|
||||
isNew := addOrUpdateAccount(cfg, r)
|
||||
if !isNew {
|
||||
t.Error("新帳號應回 true")
|
||||
}
|
||||
if len(cfg.Accounts) != 1 {
|
||||
t.Fatalf("應新增 1 個帳號,got %d", len(cfg.Accounts))
|
||||
}
|
||||
if cfg.Accounts[0].Email != "user@a.com" {
|
||||
t.Errorf("email 錯:%s", cfg.Accounts[0].Email)
|
||||
}
|
||||
if cfg.Accounts[0].InstanceName != "Work" {
|
||||
t.Errorf("instance_name 錯:%s", cfg.Accounts[0].InstanceName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddOrUpdateAccount_SameHostUpdates:同 host → 更新連線資訊,WatchFolders 不清空。
|
||||
func TestAddOrUpdateAccount_SameHostUpdates(t *testing.T) {
|
||||
cfg := &directConfig{
|
||||
Accounts: []accountCfg{{
|
||||
CypherURL: "https://instance1.workers.dev",
|
||||
Namespace: "ns1",
|
||||
Email: "old@a.com",
|
||||
WatchFolders: []string{"/path/to/folder"},
|
||||
}},
|
||||
}
|
||||
r := &daemonConfigResp{}
|
||||
r.Config.CypherURL = "https://instance1.workers.dev"
|
||||
r.Config.Namespace = "ns1"
|
||||
r.Config.Email = "new@a.com"
|
||||
|
||||
isNew := addOrUpdateAccount(cfg, r)
|
||||
if isNew {
|
||||
t.Error("同 host 應回 false(更新,非新增)")
|
||||
}
|
||||
if len(cfg.Accounts) != 1 {
|
||||
t.Fatalf("不應新增帳號,got %d", len(cfg.Accounts))
|
||||
}
|
||||
if cfg.Accounts[0].Email != "new@a.com" {
|
||||
t.Errorf("email 未更新:%s", cfg.Accounts[0].Email)
|
||||
}
|
||||
if len(cfg.Accounts[0].WatchFolders) == 0 {
|
||||
t.Error("同 host 更新不應清空資料夾(t86 退役)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddOrUpdateAccount_DifferentHostAdds:不同 host → 兩個帳號並存。
|
||||
func TestAddOrUpdateAccount_DifferentHostAdds(t *testing.T) {
|
||||
cfg := &directConfig{
|
||||
Accounts: []accountCfg{{
|
||||
CypherURL: "https://instance1.workers.dev",
|
||||
Namespace: "ns1",
|
||||
}},
|
||||
}
|
||||
r := &daemonConfigResp{}
|
||||
r.Config.CypherURL = "https://instance2.workers.dev"
|
||||
r.Config.Namespace = "ns2"
|
||||
|
||||
isNew := addOrUpdateAccount(cfg, r)
|
||||
if !isNew {
|
||||
t.Error("不同 host 應回 true(新增)")
|
||||
}
|
||||
if len(cfg.Accounts) != 2 {
|
||||
t.Fatalf("應有 2 個帳號,got %d", len(cfg.Accounts))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveAccountWatchFolder_CorrectAccount:刪帳號 A 的資料夾不影響帳號 B(t104+t101)。
|
||||
func TestRemoveAccountWatchFolder_CorrectAccount(t *testing.T) {
|
||||
cfg := &directConfig{
|
||||
Accounts: []accountCfg{
|
||||
{CypherURL: "https://a.workers.dev", WatchFolders: []string{"/folder1", "/folder2"}},
|
||||
{CypherURL: "https://b.workers.dev", WatchFolders: []string{"/folder3"}},
|
||||
},
|
||||
}
|
||||
removeAccountWatchFolder(cfg, 0, "/folder1")
|
||||
if len(cfg.Accounts[0].WatchFolders) != 1 {
|
||||
t.Fatalf("account[0] 應剩 1 個資料夾,got %v", cfg.Accounts[0].WatchFolders)
|
||||
}
|
||||
if cfg.Accounts[0].WatchFolders[0] != "/folder2" {
|
||||
t.Errorf("account[0] 剩餘應是 /folder2,got %s", cfg.Accounts[0].WatchFolders[0])
|
||||
}
|
||||
if len(cfg.Accounts[1].WatchFolders) != 1 || cfg.Accounts[1].WatchFolders[0] != "/folder3" {
|
||||
t.Errorf("account[1] 資料夾不應改變,got %v", cfg.Accounts[1].WatchFolders)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountDisplayName:暱稱 > email > host(t26 延伸)。
|
||||
func TestAccountDisplayName(t *testing.T) {
|
||||
cases := []struct {
|
||||
acc accountCfg
|
||||
want string
|
||||
}{
|
||||
{accountCfg{InstanceName: "書房", Email: "a@b.com", CypherURL: "https://c.dev"}, "書房"},
|
||||
{accountCfg{Email: "a@b.com", CypherURL: "https://c.dev"}, "a@b.com"},
|
||||
{accountCfg{CypherURL: "https://arcrun-cypher-executor.acct.workers.dev"}, "arcrun-cypher-executor.acct.workers.dev"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := accountDisplayName(c.acc)
|
||||
if got != c.want {
|
||||
t.Errorf("accountDisplayName(%+v) = %q, want %q", c.acc, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectConfigFolders_MultiAccount:多帳號 Folders() 彙整所有帳號資料夾。
|
||||
func TestDirectConfigFolders_MultiAccount(t *testing.T) {
|
||||
cfg := &directConfig{
|
||||
Accounts: []accountCfg{
|
||||
{WatchFolders: []string{"/a", "/b"}},
|
||||
{WatchFolders: []string{"/c"}},
|
||||
},
|
||||
}
|
||||
got := cfg.Folders()
|
||||
want := []string{"/a", "/b", "/c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("Folders() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("Folders()[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,23 +34,36 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccountConfig 單一帳號的連線設定(t104 多帳號同時看守)。
|
||||
// 每個帳號代表一個 Arcrun 知識庫實例;Extractor/Manifest 等機器層級設定住在 DirectConfig 頂層。
|
||||
type AccountConfig 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"` // 此帳號看守的資料夾(多根)
|
||||
Libraries map[string]string `json:"libraries,omitempty"` // 資料夾→庫對映(t52)
|
||||
}
|
||||
|
||||
// DirectConfig 是 direct 模式的設定檔(JSON)。設定只走檔案/環境,不落 code。
|
||||
type DirectConfig struct {
|
||||
WatchFolder string `json:"watch_folder,omitempty"` // 監看的知識資料夾(單數舊制;與 watch_folders 至少填一)
|
||||
WatchFolders []string `json:"watch_folders,omitempty"` // 監看的知識資料夾清單(daemon-beta task 1 多資料夾)
|
||||
Manifest string `json:"manifest"` // manifest JSON 路徑(必填;多資料夾時為基底名,每根一份帶尾碼)
|
||||
CypherURL string `json:"cypher_url"` // 實例 cypher base(必填),如 https://arcrun-cypher-executor.<acct>.workers.dev
|
||||
Namespace string `json:"namespace"` // 租戶 namespace(必填),如 demo
|
||||
APIKey string `json:"api_key"` // X-Arcrun-API-Key(空=沿用 namespace,demo 慣例)
|
||||
Email string `json:"email,omitempty"` // 實例主身分(t26;人人記得自己的 email,CF 全程隱形)
|
||||
InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email)
|
||||
Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb;per-folder 未指定時的後備)
|
||||
// t52(leo 2026-07-25 裁決:「資料夾=庫」——企業有 10 個資料夾,財務/人事不准任何人看,
|
||||
// 全塞一個庫=找死):每個看守資料夾對應自己的庫。key=資料夾絕對路徑,value=庫名。
|
||||
// 未列出的資料夾=用資料夾名 slug 當庫名(librarySlug);再不行才退回 Library。
|
||||
// t104:多帳號清單(新制)。有值時頂層連線欄位僅保留讀取相容——
|
||||
// 啟動時若無 Accounts 但有舊的頂層 CypherURL,LoadDirectConfig 自動包成 Accounts[0]。
|
||||
Accounts []AccountConfig `json:"accounts,omitempty"`
|
||||
WatchFolder string `json:"watch_folder,omitempty"` // 監看的知識資料夾(單數舊制;與 watch_folders 至少填一)
|
||||
WatchFolders []string `json:"watch_folders,omitempty"` // 監看的知識資料夾清單(daemon-beta task 1 多資料夾)
|
||||
Manifest string `json:"manifest"` // manifest JSON 路徑(必填;多資料夾時為基底名,每根一份帶尾碼)
|
||||
CypherURL string `json:"cypher_url,omitempty"` // 實例 cypher base(舊制;新制走 Accounts)
|
||||
Namespace string `json:"namespace,omitempty"` // 租戶 namespace(舊制;新制走 Accounts)
|
||||
APIKey string `json:"api_key,omitempty"` // X-Arcrun-API-Key(舊制)
|
||||
Email string `json:"email,omitempty"` // 實例主身分(舊制)
|
||||
InstanceName string `json:"instance_name,omitempty"` // 暱稱(舊制)
|
||||
Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb;per-folder 未指定時的後備)
|
||||
// t52:每個看守資料夾對應自己的庫(key=絕對路徑,value=庫名);新制走 AccountConfig.Libraries。
|
||||
Libraries map[string]string `json:"libraries,omitempty"`
|
||||
IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_direct;extractor 模式不用)
|
||||
RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct;吃 {page_name,path})
|
||||
IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_direct)
|
||||
RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct)
|
||||
// —— 四步定稿(daemon-beta t3/t4/t6):本地萃卡模式 ——
|
||||
Extractor string `json:"extractor,omitempty"` // "claude"|"gemma";空=舊制(內容直送雲端萃)
|
||||
ClaudeBin string `json:"claude_bin,omitempty"` // claude 執行檔(空=PATH 找 claude)
|
||||
@@ -126,6 +139,8 @@ func expandHome(p string) string {
|
||||
}
|
||||
|
||||
// LoadDirectConfig 讀設定檔並補預設值 + 基本驗證。
|
||||
// t104 向後相容遷移:若無 Accounts 但有舊的頂層 CypherURL,自動包成 Accounts[0](記憶體遷移;
|
||||
// 磁碟回寫由呼叫端在合適時機(如 saveDirectConfig)完成)。
|
||||
func LoadDirectConfig(path string) (*DirectConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -135,22 +150,59 @@ func LoadDirectConfig(path string) (*DirectConfig, error) {
|
||||
if err := json.Unmarshal(data, &c); err != nil {
|
||||
return nil, fmt.Errorf("config JSON 解析失敗:%w", err)
|
||||
}
|
||||
var missing []string
|
||||
if c.WatchFolder == "" && len(c.WatchFolders) == 0 {
|
||||
missing = append(missing, "watch_folder(或 watch_folders)")
|
||||
// t39:路徑欄位一律展開 `~/`(先展開才能正確計算 Folders())
|
||||
c.Manifest = expandHome(c.Manifest)
|
||||
c.WatchFolder = expandHome(c.WatchFolder)
|
||||
for i, p := range c.WatchFolders {
|
||||
c.WatchFolders[i] = expandHome(p)
|
||||
}
|
||||
for i := range c.Accounts {
|
||||
for j, p := range c.Accounts[i].WatchFolders {
|
||||
c.Accounts[i].WatchFolders[j] = expandHome(p)
|
||||
}
|
||||
}
|
||||
|
||||
// t104:舊格式遷移——頂層 CypherURL → Accounts[0](冪等:有 Accounts 就跳過)
|
||||
if len(c.Accounts) == 0 && c.CypherURL != "" {
|
||||
c.Accounts = []AccountConfig{{
|
||||
InstanceName: c.InstanceName,
|
||||
Email: c.Email,
|
||||
CypherURL: c.CypherURL,
|
||||
Namespace: c.Namespace,
|
||||
APIKey: c.APIKey,
|
||||
Libraries: c.Libraries,
|
||||
WatchFolders: c.Folders(), // 正規化後的監看清單
|
||||
}}
|
||||
}
|
||||
|
||||
// 驗證
|
||||
var missing []string
|
||||
if c.Manifest == "" {
|
||||
missing = append(missing, "manifest")
|
||||
}
|
||||
if c.CypherURL == "" {
|
||||
missing = append(missing, "cypher_url")
|
||||
if len(c.Accounts) == 0 {
|
||||
missing = append(missing, "accounts(或 cypher_url 連線設定)")
|
||||
}
|
||||
if c.Namespace == "" {
|
||||
missing = append(missing, "namespace")
|
||||
for i, acc := range c.Accounts {
|
||||
if acc.CypherURL == "" {
|
||||
missing = append(missing, fmt.Sprintf("accounts[%d] 缺 cypher_url", i))
|
||||
}
|
||||
if acc.Namespace == "" {
|
||||
missing = append(missing, fmt.Sprintf("accounts[%d] 缺 namespace", i))
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("config 缺必填欄位:%s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
// 補各帳號預設值
|
||||
for i := range c.Accounts {
|
||||
if c.Accounts[i].APIKey == "" {
|
||||
c.Accounts[i].APIKey = c.Accounts[i].Namespace
|
||||
}
|
||||
c.Accounts[i].CypherURL = strings.TrimSuffix(c.Accounts[i].CypherURL, "/")
|
||||
}
|
||||
// 頂層後備值(舊制相容或被 makeAccountSubConfig 繼承)
|
||||
if c.APIKey == "" {
|
||||
c.APIKey = c.Namespace
|
||||
}
|
||||
@@ -178,12 +230,8 @@ func LoadDirectConfig(path string) (*DirectConfig, error) {
|
||||
if c.MaxRemoved <= 0 {
|
||||
c.MaxRemoved = DefaultMaxRemovedRatio
|
||||
}
|
||||
c.CypherURL = strings.TrimSuffix(c.CypherURL, "/")
|
||||
// t39:路徑欄位一律展開 `~/`(config 是給人填/人讀的,波浪號是人的寫法)
|
||||
c.Manifest = expandHome(c.Manifest)
|
||||
c.WatchFolder = expandHome(c.WatchFolder)
|
||||
for i, p := range c.WatchFolders {
|
||||
c.WatchFolders[i] = expandHome(p)
|
||||
if c.CypherURL != "" {
|
||||
c.CypherURL = strings.TrimSuffix(c.CypherURL, "/")
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
@@ -300,7 +348,8 @@ func pageNameOf(relPath string) string {
|
||||
|
||||
// DirectResult 是單一事件的直送結果(隨每輪 log 輸出)。
|
||||
type DirectResult struct {
|
||||
Root string `json:"root,omitempty"` // 多資料夾時標明事件屬於哪個根
|
||||
Account string `json:"account,omitempty"` // t104: cypher_url host(多帳號標的)
|
||||
Root string `json:"root,omitempty"` // 多資料夾時標明事件屬於哪個根
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"` // ingested | removed | planned | failed | skipped
|
||||
@@ -308,17 +357,40 @@ type DirectResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RunDirectOnce 對每個監看根掃一輪並彙總結果(daemon-beta task 1 多資料夾)。
|
||||
// 單根行為與舊制完全相同(含 manifest 路徑)。回傳彙總結果與退出碼建議(任一根失敗=1)。
|
||||
// makeAccountSubConfig 從帳號設定建出單帳號用的 DirectConfig,繼承機器層級欄位(t104)。
|
||||
// 用於 RunDirectOnce 逐帳號掃描,每帳號得到獨立的 CypherURL/Namespace/WatchFolders 等。
|
||||
func (c *DirectConfig) makeAccountSubConfig(acc AccountConfig) *DirectConfig {
|
||||
sub := *c // 複製機器層級欄位
|
||||
sub.Accounts = nil
|
||||
sub.CypherURL = strings.TrimSuffix(acc.CypherURL, "/")
|
||||
sub.Namespace = acc.Namespace
|
||||
sub.APIKey = acc.APIKey
|
||||
if sub.APIKey == "" {
|
||||
sub.APIKey = sub.Namespace
|
||||
}
|
||||
sub.Email = acc.Email
|
||||
sub.InstanceName = acc.InstanceName
|
||||
sub.WatchFolder = ""
|
||||
sub.WatchFolders = acc.WatchFolders
|
||||
sub.Libraries = acc.Libraries
|
||||
if sub.Library == "" {
|
||||
sub.Library = "kb"
|
||||
}
|
||||
return &sub
|
||||
}
|
||||
|
||||
// RunDirectOnce 對每個帳號的每個監看根掃一輪並彙總結果(t104 多帳號同時看守)。
|
||||
// 單帳號行為與舊制完全相同(含 manifest 路徑)。回傳彙總結果與退出碼建議(任一根失敗=1)。
|
||||
// 額外:
|
||||
// - 預檢 extractor 可用性(t92-②),有 fallback 時更新 cfg.ClaudeBin(in-memory,呼叫端存檔)。
|
||||
// - 每輪結束寫 ~/.arcrun-rag/status.json(t91 狀態可見性)。
|
||||
// - 每輪結束寫 ~/.arcrun-rag/status.json(t91 狀態可見性,含 per-account 雲端版本)。
|
||||
// - 一個帳號失敗不擋其他帳號繼續同步(t104 隔離)。
|
||||
func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *TriggerPayload) {
|
||||
results := []DirectResult{}
|
||||
exit := 0
|
||||
var lastPayload *TriggerPayload
|
||||
|
||||
// t92-②:預檢 extractor,有 fallback 路徑時就地更新 cfg.ClaudeBin(供下游直接使用)。
|
||||
// t92-②:預檢 extractor(機器層級),有 fallback 路徑時就地更新 cfg.ClaudeBin。
|
||||
extractorOK := true
|
||||
extractorError := ""
|
||||
if cfg.Extractor == "claude" {
|
||||
@@ -336,34 +408,75 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
}
|
||||
}
|
||||
|
||||
multi := len(cfg.Folders()) > 1
|
||||
for _, root := range cfg.Folders() {
|
||||
r, e, p := runDirectOnceRoot(cfg, root, dryRun)
|
||||
if multi {
|
||||
for i := range r {
|
||||
r[i].Root = root
|
||||
}
|
||||
}
|
||||
results = append(results, r...)
|
||||
if e != 0 {
|
||||
exit = e
|
||||
}
|
||||
if p != nil {
|
||||
lastPayload = p
|
||||
}
|
||||
// t104:解出有效帳號清單(向後相容:無 Accounts 但有頂層 CypherURL 時視為單帳號)
|
||||
accounts := cfg.Accounts
|
||||
if len(accounts) == 0 && cfg.CypherURL != "" {
|
||||
accounts = []AccountConfig{{
|
||||
InstanceName: cfg.InstanceName,
|
||||
Email: cfg.Email,
|
||||
CypherURL: cfg.CypherURL,
|
||||
Namespace: cfg.Namespace,
|
||||
APIKey: cfg.APIKey,
|
||||
Libraries: cfg.Libraries,
|
||||
WatchFolders: cfg.Folders(),
|
||||
}}
|
||||
}
|
||||
|
||||
// t91:每輪寫狀態檔(只有 extractor 模式才有意義的計數;direct 雲端萃模式 extracted_ok=0)。
|
||||
accountDetails := map[string]AccountSyncStatus{}
|
||||
for _, acc := range accounts {
|
||||
if acc.CypherURL == "" || acc.Namespace == "" {
|
||||
continue // 跳過設定不完整的帳號
|
||||
}
|
||||
accCfg := cfg.makeAccountSubConfig(acc)
|
||||
accHost := instanceHostOf(acc.CypherURL)
|
||||
|
||||
// t103:per-account 雲端版本偵測
|
||||
cloudVer, cloudOK := fetchCloudVersion(accCfg.CypherURL)
|
||||
accSt := AccountSyncStatus{
|
||||
LastSync: time.Now().Format(time.RFC3339),
|
||||
CloudVersion: cloudVer,
|
||||
CloudCheckOK: cloudOK,
|
||||
}
|
||||
|
||||
multi := len(accCfg.Folders()) > 1
|
||||
for _, root := range accCfg.Folders() {
|
||||
r, e, p := runDirectOnceRoot(accCfg, root, dryRun)
|
||||
if multi {
|
||||
for i := range r {
|
||||
r[i].Root = root
|
||||
}
|
||||
}
|
||||
for i := range r {
|
||||
r[i].Account = accHost // t104: 標明所屬帳號
|
||||
if cfg.Extractor != "" {
|
||||
switch r[i].Status {
|
||||
case "ingested":
|
||||
accSt.ExtractedOK++
|
||||
case "failed":
|
||||
accSt.ExtractFailed++
|
||||
}
|
||||
}
|
||||
}
|
||||
results = append(results, r...)
|
||||
if e != 0 {
|
||||
exit = e // 任一帳號任一根失敗=整體 exit 1,但不停其他帳號
|
||||
}
|
||||
if p != nil {
|
||||
lastPayload = p
|
||||
}
|
||||
}
|
||||
accountDetails[accHost] = accSt
|
||||
}
|
||||
|
||||
// t91:每輪寫狀態檔(含 per-account 雲端版本與萃取計數)。
|
||||
if !dryRun && cfg.Manifest != "" {
|
||||
// t103:每輪順手 GET /health 取雲端版本(5s timeout,失敗靜默)。
|
||||
cloudVer, cloudOK := fetchCloudVersion(cfg.CypherURL)
|
||||
st := SyncStatus{
|
||||
LastSync: time.Now().Format(time.RFC3339),
|
||||
ExtractorOK: extractorOK,
|
||||
ExtractorError: extractorError,
|
||||
CloudVersion: cloudVer,
|
||||
CloudCheckOK: cloudOK,
|
||||
AccountDetails: accountDetails,
|
||||
}
|
||||
// 頂層彙總(向後相容:單帳號時填頂層欄位讓舊版 tray 仍能讀)
|
||||
if cfg.Extractor != "" {
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
@@ -378,6 +491,14 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
}
|
||||
}
|
||||
}
|
||||
// 單帳號時把 cloud version 也填頂層(向後相容)
|
||||
if len(accountDetails) == 1 {
|
||||
for _, v := range accountDetails {
|
||||
st.CloudVersion = v.CloudVersion
|
||||
st.CloudCheckOK = v.CloudCheckOK
|
||||
break
|
||||
}
|
||||
}
|
||||
if serr := SaveSyncStatus(StatusFilePath(cfg.Manifest), st); serr != nil {
|
||||
fmt.Fprintf(os.Stderr, "status 寫入失敗(不擋看守):%v\n", serr)
|
||||
}
|
||||
|
||||
+276
-3
@@ -5,6 +5,8 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -80,14 +82,23 @@ func TestLoadDirectConfigMulti(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 兩欄都空=缺必填。
|
||||
// t104:cypher_url 有值但無 watch_folders → 合法(遷移後空帳號,等待用戶加資料夾)。
|
||||
// 真正缺必填:無 accounts 且無 cypher_url。
|
||||
func TestLoadDirectConfigMissingFolders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 有 cypher_url 無 watch_folders → 合法
|
||||
p := writeDirectConfig(t, dir, map[string]any{
|
||||
"manifest": filepath.Join(dir, "m.json"), "cypher_url": "https://x.example", "namespace": "demo",
|
||||
})
|
||||
if _, err := LoadDirectConfig(p); err == nil {
|
||||
t.Fatal("兩欄皆空應報缺必填")
|
||||
if _, err := LoadDirectConfig(p); err != nil {
|
||||
t.Fatalf("t104: 有 cypher_url 無 watch_folders 應合法,got: %v", err)
|
||||
}
|
||||
// 無 cypher_url 無 accounts → 缺必填
|
||||
p2 := writeDirectConfig(t, dir, map[string]any{
|
||||
"manifest": filepath.Join(dir, "m.json"),
|
||||
})
|
||||
if _, err := LoadDirectConfig(p2); err == nil {
|
||||
t.Fatal("無帳號且無 cypher_url 時應報缺必填")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +360,268 @@ func TestManifestMigrateIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── t104:多帳號同時看守 ──────────────────────────────────────────────────────────
|
||||
|
||||
// ①遷移:舊格式(頂層 cypher_url + watch_folders)→ LoadDirectConfig → accounts[0]。
|
||||
func TestMigrateOldConfigToAccounts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeDirectConfig(t, dir, map[string]any{
|
||||
"watch_folders": []string{"/tmp/a", "/tmp/b"},
|
||||
"manifest": filepath.Join(dir, "m.json"),
|
||||
"cypher_url": "https://instance.example.workers.dev",
|
||||
"namespace": "ns1",
|
||||
"email": "user@example.com",
|
||||
"instance_name": "My Library",
|
||||
})
|
||||
cfg, err := LoadDirectConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDirectConfig: %v", err)
|
||||
}
|
||||
if len(cfg.Accounts) != 1 {
|
||||
t.Fatalf("① 應自動建 accounts[0],got %d accounts", len(cfg.Accounts))
|
||||
}
|
||||
acc := cfg.Accounts[0]
|
||||
if acc.CypherURL != "https://instance.example.workers.dev" {
|
||||
t.Errorf("accounts[0].cypher_url 錯:%s", acc.CypherURL)
|
||||
}
|
||||
if acc.Namespace != "ns1" {
|
||||
t.Errorf("accounts[0].namespace 錯:%s", acc.Namespace)
|
||||
}
|
||||
if acc.Email != "user@example.com" {
|
||||
t.Errorf("accounts[0].email 錯:%s", acc.Email)
|
||||
}
|
||||
if acc.InstanceName != "My Library" {
|
||||
t.Errorf("accounts[0].instance_name 錯:%s", acc.InstanceName)
|
||||
}
|
||||
if len(acc.WatchFolders) != 2 {
|
||||
t.Errorf("accounts[0].watch_folders 應含 2 根,got %v", acc.WatchFolders)
|
||||
}
|
||||
}
|
||||
|
||||
// ①冪等:已有 accounts 時不再遷移(accounts 數量不增加)。
|
||||
func TestMigrateAlreadyHasAccountsIsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeDirectConfig(t, dir, map[string]any{
|
||||
"accounts": []map[string]any{
|
||||
{"cypher_url": "https://a.example", "namespace": "nsA", "watch_folders": []string{"/tmp/a"}},
|
||||
{"cypher_url": "https://b.example", "namespace": "nsB", "watch_folders": []string{"/tmp/b"}},
|
||||
},
|
||||
"manifest": filepath.Join(dir, "m.json"),
|
||||
})
|
||||
cfg, err := LoadDirectConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDirectConfig: %v", err)
|
||||
}
|
||||
if len(cfg.Accounts) != 2 {
|
||||
t.Fatalf("① 已有 2 個 accounts 時不應再新增,got %d", len(cfg.Accounts))
|
||||
}
|
||||
}
|
||||
|
||||
// ②雙帳號同輪同步互不干擾:各帳號事件打到各自的 fake server,結果標正確的 Account host。
|
||||
func TestRunDirectOnceMultiAccount(t *testing.T) {
|
||||
var serverACalled, serverBCalled int
|
||||
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
serverACalled++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer serverA.Close()
|
||||
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
serverBCalled++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer serverB.Close()
|
||||
|
||||
base := t.TempDir()
|
||||
rootA := filepath.Join(base, "rootA")
|
||||
rootB := filepath.Join(base, "rootB")
|
||||
for _, d := range []string{rootA, rootB} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(rootA, "a.md"), []byte("# A"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(rootB, "b.md"), []byte("# B"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
orig := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "2026-07-28+stub", true }
|
||||
defer func() { fetchCloudVersion = orig }()
|
||||
|
||||
cfg := &DirectConfig{
|
||||
Manifest: filepath.Join(base, "manifest.json"),
|
||||
Accounts: []AccountConfig{
|
||||
{CypherURL: serverA.URL, Namespace: "nsA", WatchFolders: []string{rootA}},
|
||||
{CypherURL: serverB.URL, Namespace: "nsB", WatchFolders: []string{rootB}},
|
||||
},
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 0 {
|
||||
t.Fatalf("② 雙帳號同步應成功,exit=%d,results=%+v", exit, results)
|
||||
}
|
||||
hostA := instanceHostOf(serverA.URL)
|
||||
hostB := instanceHostOf(serverB.URL)
|
||||
var countA, countB int
|
||||
for _, r := range results {
|
||||
if r.Account == hostA {
|
||||
countA++
|
||||
}
|
||||
if r.Account == hostB {
|
||||
countB++
|
||||
}
|
||||
}
|
||||
if countA == 0 {
|
||||
t.Errorf("② account A 結果應標 host %s,got results: %+v", hostA, results)
|
||||
}
|
||||
if countB == 0 {
|
||||
t.Errorf("② account B 結果應標 host %s,got results: %+v", hostB, results)
|
||||
}
|
||||
// 各自的 fake server 收到請求(a.md → serverA,b.md → serverB)
|
||||
if serverACalled == 0 {
|
||||
t.Error("② serverA 未收到請求")
|
||||
}
|
||||
if serverBCalled == 0 {
|
||||
t.Error("② serverB 未收到請求")
|
||||
}
|
||||
}
|
||||
|
||||
// ③一帳號 HTTP 失敗不擋另一帳號。
|
||||
func TestRunDirectOnceOneAccountFailDoesNotBlock(t *testing.T) {
|
||||
serverFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"internal"}`))
|
||||
}))
|
||||
defer serverFail.Close()
|
||||
|
||||
var serverOKCalled bool
|
||||
serverOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
serverOKCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer serverOK.Close()
|
||||
|
||||
base := t.TempDir()
|
||||
rootFail := filepath.Join(base, "rootFail")
|
||||
rootOK := filepath.Join(base, "rootOK")
|
||||
for _, d := range []string{rootFail, rootOK} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(rootFail, "f.md"), []byte("# F"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(rootOK, "ok.md"), []byte("# OK"), 0o644)
|
||||
|
||||
orig := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
defer func() { fetchCloudVersion = orig }()
|
||||
|
||||
cfg := &DirectConfig{
|
||||
Manifest: filepath.Join(base, "manifest.json"),
|
||||
Accounts: []AccountConfig{
|
||||
{CypherURL: serverFail.URL, Namespace: "nsFail", WatchFolders: []string{rootFail}},
|
||||
{CypherURL: serverOK.URL, Namespace: "nsOK", WatchFolders: []string{rootOK}},
|
||||
},
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
_, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 1 {
|
||||
t.Errorf("③ 帳號失敗時 exit 應為 1,got %d", exit)
|
||||
}
|
||||
if !serverOKCalled {
|
||||
t.Error("③ 帳號 A 失敗不應擋住帳號 B 同步")
|
||||
}
|
||||
}
|
||||
|
||||
// ④托盤分組資料結構:accountDisplayName 與 removeAccountWatchFolder 正確作用於指定帳號。
|
||||
// (純資料結構測試,不依賴 fyne GUI)
|
||||
func TestAccountDataStructureFunctions(t *testing.T) {
|
||||
type localAccCfg struct {
|
||||
InstanceName string
|
||||
Email string
|
||||
CypherURL string
|
||||
WatchFolders []string
|
||||
}
|
||||
// 用 makeAccountSubConfig 驗證 per-account sub-config 繼承機器層級欄位
|
||||
parent := &DirectConfig{
|
||||
Manifest: "/tmp/m.json",
|
||||
PollSec: 10,
|
||||
Extractor: "claude",
|
||||
Accounts: []AccountConfig{
|
||||
{CypherURL: "https://a.example", Namespace: "nsA", WatchFolders: []string{"/folder1"}},
|
||||
},
|
||||
Library: "kb",
|
||||
IngestWF: "rag_ingest_direct",
|
||||
RemovedWF: "rag_takedown_direct",
|
||||
CardIngestWF: "rag_ingest_card",
|
||||
LLMModel: "gemma-4-31b-it",
|
||||
}
|
||||
sub := parent.makeAccountSubConfig(parent.Accounts[0])
|
||||
if sub.CypherURL != "https://a.example" {
|
||||
t.Errorf("makeAccountSubConfig CypherURL 錯:%s", sub.CypherURL)
|
||||
}
|
||||
if sub.PollSec != 10 {
|
||||
t.Errorf("makeAccountSubConfig 機器層級 PollSec 應繼承:%d", sub.PollSec)
|
||||
}
|
||||
if sub.Extractor != "claude" {
|
||||
t.Errorf("makeAccountSubConfig 機器層級 Extractor 應繼承:%s", sub.Extractor)
|
||||
}
|
||||
if len(sub.Accounts) != 0 {
|
||||
t.Errorf("makeAccountSubConfig Accounts 應清空(避免遞迴),got %d", len(sub.Accounts))
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤t101 刪除在多帳號下作用於正確帳號:帳號 A 刪 folder1 不影響帳號 B。
|
||||
func TestRunDirectOnceAccountResultsTagged(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
orig := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
defer func() { fetchCloudVersion = orig }()
|
||||
|
||||
base := t.TempDir()
|
||||
rootA := filepath.Join(base, "rA")
|
||||
rootB := filepath.Join(base, "rB")
|
||||
for _, d := range []string{rootA, rootB} {
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(rootA, "a.md"), []byte("# A"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(rootB, "b.md"), []byte("# B"), 0o644)
|
||||
|
||||
// 同一個 server,兩個帳號(用不同 namespace 區分)
|
||||
cfg := &DirectConfig{
|
||||
Manifest: filepath.Join(base, "manifest.json"),
|
||||
Accounts: []AccountConfig{
|
||||
{CypherURL: server.URL, Namespace: "nsA", WatchFolders: []string{rootA}},
|
||||
{CypherURL: server.URL + "/", Namespace: "nsB", WatchFolders: []string{rootB}},
|
||||
},
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, _, _ := RunDirectOnce(cfg, true) // dry-run:驗結果標籤
|
||||
if len(results) == 0 {
|
||||
t.Fatal("⑤ dry-run 應有計畫事件")
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Account == "" {
|
||||
t.Errorf("⑤ 結果應標 Account host,got %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandHomeEdgeCases(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
|
||||
+18
-6
@@ -8,18 +8,30 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// AccountSyncStatus 彙總單一帳號的每輪同步結果(t104 多帳號看守)。
|
||||
// key in SyncStatus.AccountDetails = instanceHostOf(cypher_url)。
|
||||
type AccountSyncStatus struct {
|
||||
LastSync string `json:"last_sync,omitempty"`
|
||||
CloudVersion string `json:"cloud_version,omitempty"` // t103 per-account
|
||||
CloudCheckOK bool `json:"cloud_check_ok"`
|
||||
ExtractedOK int `json:"extracted_ok"`
|
||||
ExtractFailed int `json:"extract_failed"`
|
||||
}
|
||||
|
||||
// SyncStatus 彙總每輪同步的萃取結果,持久化至 ~/.arcrun-rag/status.json。
|
||||
// 托盤依此決定顯示「已萃 N 檔」、「⚠ 萃取失敗 M 檔」還是「⚠ 萃取引擎未就緒」。
|
||||
type SyncStatus struct {
|
||||
LastSync string `json:"last_sync,omitempty"` // RFC3339,最近一輪完成時間
|
||||
ExtractedOK int `json:"extracted_ok"` // 本輪萃取成功件數(跨資料夾累計)
|
||||
ExtractFailed int `json:"extract_failed"` // 本輪萃取失敗件數
|
||||
ExtractedOK int `json:"extracted_ok"` // 本輪萃取成功件數(跨帳號累計)
|
||||
ExtractFailed int `json:"extract_failed"` // 本輪萃取失敗件數(跨帳號)
|
||||
Failures []ExtractFail `json:"failures,omitempty"` // 失敗清單(路徑+白話原因)
|
||||
ExtractorOK bool `json:"extractor_ok"` // 萃取器本身是否就緒(預檢)
|
||||
ExtractorOK bool `json:"extractor_ok"` // 萃取器本身是否就緒(預檢,機器層級)
|
||||
ExtractorError string `json:"extractor_error,omitempty"` // 未就緒的白話原因
|
||||
// t103:雲端版本偵測(每輪 GET /health)
|
||||
CloudVersion string `json:"cloud_version,omitempty"` // bundle_version 回傳值(空=老實例)
|
||||
CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才為 true;false 代表網路失敗,不判定過舊
|
||||
// 頂層 cloud 欄位保留向後相容(單帳號時同時填頂層+AccountDetails)
|
||||
CloudVersion string `json:"cloud_version,omitempty"` // bundle_version(單帳號時填)
|
||||
CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才為 true
|
||||
// t104:per-account 狀態(key = instanceHostOf(cypher_url))
|
||||
AccountDetails map[string]AccountSyncStatus `json:"account_details,omitempty"`
|
||||
}
|
||||
|
||||
// ExtractFail 記一筆萃取失敗(路徑+白話原因)。
|
||||
|
||||
Reference in New Issue
Block a user