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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user