diff --git a/cmd/arcrun-tray/main.go b/cmd/arcrun-tray/main.go index ec8c32f..6251b91 100644 --- a/cmd/arcrun-tray/main.go +++ b/cmd/arcrun-tray/main.go @@ -1009,6 +1009,65 @@ func main() { }, win) } + // 🔴 t192(leo 08-04,issue #18):主視窗——「daemon 設定項越來越多, + // **不可能統統塞在下拉選單**」「參考 Google Drive 設定畫面:點擊 daemon + // 就開一個視窗,佔螢幕約 1/2」「資料夾清單要**可捲動**」。 + // 托盤選單保留(快速動作),但**主入口改成這個視窗**。 + // 所有動作都**複用既有 handler**,不在視窗那邊重寫一份邏輯。 + mw := newMainWindow(a) + mw.sup = sup + mw.cfg = cfg + mw.reload = func() *directConfig { return cfg } + mw.onAddAccount = func() { showConnectWizard() } + mw.onAddFolder = func(accIdx int) { + dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { + if err != nil || uri == nil { + return + } + addAccountWatchFolder(cfg, accIdx, uri.Path()) + if serr := saveConfig(cfg); serr != nil { + dialog.ShowError(serr, mw.win) + return + } + restartWatch() + rebuildTray() + mw.refresh() + }, mw.win) + } + mw.onDelFolder = func(accIdx int, folder string) { + removeAccountWatchFolder(cfg, accIdx, folder) + if serr := saveConfig(cfg); serr != nil { + dialog.ShowError(serr, mw.win) + return + } + restartWatch() + rebuildTray() + mw.refresh() + } + mw.onAISettings = func() { showAISettings() } + mw.onSyncNow = func() { + _ = os.MkdirAll(appDir(), 0o755) + _ = os.WriteFile(syncNowSignalPath(), []byte{}, 0o644) + mw.refresh() + } + mw.onCheckUpdate = func() { + go func() { + st := checkForUpdate() + switch { + case st.Err != "": + dialog.ShowInformation("檢查更新", "暫時連不上更新伺服器:\n"+st.Err, mw.win) + case st.Available: + go prepareUpdateInBackground() + dialog.ShowInformation("有新版本", + "發現新版 "+st.Latest+",正在背景下載。\n下載完成後會出現「重新啟動完成更新」。", mw.win) + default: + dialog.ShowInformation("檢查更新", "你已經是最新版本("+versionLabel()+")。", mw.win) + } + }() + } + mw.build() + mw.startAutoRefresh() + // t98:立刻同步——寫訊號檔觸發 collector 立即跑一輪,無需等下一個定時週期。 // syncNowItem 宣告在 rebuildTray 外,確保 click handler 的 goroutine 能還原標籤後重建選單。 var syncNowItem *fyne.MenuItem @@ -1078,6 +1137,14 @@ func main() { var items []*fyne.MenuItem + // 🔴 t192:主視窗入口放**選單第一項**——leo 點破過同一個病兩次: + // 「能力做出來了,入口沒出現在用戶會看的地方」 + // (docs 建好卻沒連結/MCP 端點活著卻零處提及)。做完就要看得見。 + items = append(items, + fyne.NewMenuItem("開啟 Arcrun…", func() { mw.showAndRefresh() }), + fyne.NewMenuItemSeparator(), + ) + // t104:per-account 分組——每個帳號獨立一段(帳號名稱標題 + 資料夾 + 新增按鈕)。 if len(cfg.Accounts) > 0 { for ai, acc := range cfg.Accounts { @@ -1259,6 +1326,11 @@ func buildStatusLabel(s supervisor.Status, sync traySyncStatus, extractor string base = fmt.Sprintf("看守中 · 上次同步 %s", s.LastRoundAt.Local().Format("15:04")) } return base + syncStatusLabel(sync, hasExtractor) + // 🔴 t191(leo 08-04,issue #17):正在跑就要**看得出來在跑**。 + // leo:「如果它顯示『萃取中』、『同步中』⋯⋯而不是看起來好像就做完了, + // 這時使用者一看沒做完啊,就覺得是 bug」「這是不讓用戶誤解的方法」。 + case supervisor.StateSyncing: + return "同步中… 正在讀檔並整理成知識卡" case supervisor.StateStarting: return "啟動中…" case supervisor.StateError: diff --git a/cmd/arcrun-tray/mainwindow.go b/cmd/arcrun-tray/mainwindow.go new file mode 100644 index 0000000..de04164 --- /dev/null +++ b/cmd/arcrun-tray/mainwindow.go @@ -0,0 +1,278 @@ +// mainwindow.go — Arcrun 主視窗(t192,issue #18) +// +// 🔴 leo 2026-08-04 的要求(逐條): +// - 「daemon 設定項越來越多,**不可能統統塞在下拉選單**」 +// - 「參考 **Google Drive 設定畫面**:點擊 daemon 就開一個視窗,佔螢幕約 1/2」 +// - 「資料夾清單要**可捲動**——我自己每個 gitea 專案都要同步,那就是幾十個,根本塞不下」 +// - daemon 現在**沒有 onboarding** +// - 要套 CIS 視覺 +// - 併入:狀態要顯示「萃取中/同步中」(issue #17,已在 supervisor/托盤那層做) +// +// 定位(leo 原話): +// +// 「Daemon 不只用來查看 RAG,它是 Arcrun 的**木馬屠城**…… +// Daemon 是 Arcrun 優於 n8n 之處。」 +// ⇒ 它是產品門面,不是附屬小工具。 +// +// 設計原則(照 leo 立的「用戶好用、白癡化」): +// - 一眼看到「現在在做什麼」——狀態列放最上面、大字、會動 +// - 資料夾清單用 widget.List(**虛擬捲動**,幾十個也不卡) +// - 動作用白話:「加入資料夾」不是「新增 watch_folder」 +// - 沒連過帳號的人看到的是 onboarding,不是一堆空白面板 +package main + +import ( + "fmt" + "time" + + "arcrun-rag/collector/supervisor" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +// mainWindow 是常駐的應用視窗(不是每次開新的,避免多開)。 +type mainWindow struct { + win fyne.Window + app fyne.App + + statusText *widget.Label // 最上方大字狀態(「同步中…」等) + statusSub *widget.Label // 次要說明(上次同步時間、萃取計數) + folderList *widget.List // 可捲動的資料夾清單 + rows []folderRow // 目前顯示的列(帳號標題 + 資料夾) + + // 由 main.go 注入,避免這裡重寫一份邏輯 + cfg *directConfig + sup *supervisor.Supervisor + onAddAccount func() + onAddFolder func(accIdx int) + onDelFolder func(accIdx int, folder string) + onAISettings func() + onSyncNow func() + onCheckUpdate func() + reload func() *directConfig +} + +// folderRow 是清單裡的一列:帳號標題或其下的資料夾。 +// 用單一扁平清單(而非巢狀樹)才能用 widget.List 的虛擬捲動—— +// 幾十個資料夾時只算可見的那幾列,不會卡。 +type folderRow struct { + isHeader bool + accIdx int + title string // 帳號名或資料夾路徑 + sub string // 次要說明(帳號:實例網址/資料夾:所屬庫) +} + +// newMainWindow 建主視窗。約佔一般筆電螢幕的一半(leo:「佔螢幕約 1/2」)。 +func newMainWindow(a fyne.App) *mainWindow { + m := &mainWindow{app: a} + m.win = a.NewWindow("Arcrun") + m.win.Resize(fyne.NewSize(920, 620)) + m.win.SetCloseIntercept(func() { m.win.Hide() }) // 關窗=隱藏,daemon 續跑 + return m +} + +// build 組出整個畫面。必須在注入完各 callback 後呼叫。 +func (m *mainWindow) build() { + // ── 上:狀態列(一眼看到「現在在做什麼」)── + m.statusText = widget.NewLabel("啟動中…") + m.statusText.TextStyle = fyne.TextStyle{Bold: true} + m.statusSub = widget.NewLabel("") + + syncBtn := widget.NewButtonWithIcon("立刻同步", theme.ViewRefreshIcon(), func() { + if m.onSyncNow != nil { + m.onSyncNow() + } + }) + syncBtn.Importance = widget.HighImportance + + header := container.NewBorder(nil, nil, nil, + container.NewHBox(syncBtn), + container.NewVBox(m.statusText, m.statusSub), + ) + + // ── 中:可捲動的資料夾清單(leo:「幾十個,根本塞不下」)── + m.folderList = widget.NewList( + func() int { return len(m.rows) }, + func() fyne.CanvasObject { + title := widget.NewLabel("") + sub := widget.NewLabel("") + del := widget.NewButtonWithIcon("", theme.DeleteIcon(), nil) + return container.NewBorder(nil, nil, nil, del, container.NewVBox(title, sub)) + }, + func(i widget.ListItemID, o fyne.CanvasObject) { + if i < 0 || i >= len(m.rows) { + return + } + r := m.rows[i] + box := o.(*fyne.Container) + inner := box.Objects[0].(*fyne.Container) + title := inner.Objects[0].(*widget.Label) + sub := inner.Objects[1].(*widget.Label) + del := box.Objects[1].(*widget.Button) + + title.SetText(r.title) + sub.SetText(r.sub) + if r.isHeader { + title.TextStyle = fyne.TextStyle{Bold: true} + del.Hide() // 帳號列沒有「移除資料夾」 + } else { + title.TextStyle = fyne.TextStyle{} + del.Show() + row := r + del.OnTapped = func() { + dialog.ShowConfirm("移除這個資料夾?", + "「"+row.title+"」不再自動同步。\n(已經上傳的知識卡不會被刪除)", + func(ok bool) { + if ok && m.onDelFolder != nil { + m.onDelFolder(row.accIdx, row.title) + } + }, m.win) + } + } + title.Refresh() + sub.Refresh() + }, + ) + + // ── 下:動作列(白話命名,不用系統術語)── + actions := container.NewHBox( + widget.NewButtonWithIcon("加入資料夾", theme.FolderNewIcon(), func() { + // 只有一個帳號時直接加;多帳號要先選 + accIdx := m.pickAccount() + if accIdx >= 0 && m.onAddFolder != nil { + m.onAddFolder(accIdx) + } + }), + widget.NewButtonWithIcon("新增知識庫帳號", theme.ContentAddIcon(), func() { + if m.onAddAccount != nil { + m.onAddAccount() + } + }), + widget.NewButtonWithIcon("AI 設定", theme.SettingsIcon(), func() { + if m.onAISettings != nil { + m.onAISettings() + } + }), + widget.NewButtonWithIcon("檢查更新", theme.DownloadIcon(), func() { + if m.onCheckUpdate != nil { + m.onCheckUpdate() + } + }), + ) + footer := container.NewBorder(nil, nil, actions, + widget.NewLabel("版本 "+versionLabel())) + + m.win.SetContent(container.NewBorder( + container.NewVBox(header, widget.NewSeparator()), + container.NewVBox(widget.NewSeparator(), footer), + nil, nil, + m.folderList, + )) +} + +// pickAccount 決定要把資料夾加到哪個帳號。 +// 沒帳號 → 引導去連線(onboarding 的一環,不是丟一個錯誤給他)。 +func (m *mainWindow) pickAccount() int { + if m.cfg == nil || len(m.cfg.Accounts) == 0 { + dialog.ShowInformation("先連上你的知識庫", + "還沒有任何知識庫帳號。\n請先按「新增知識庫帳號」,把這台電腦連上你的雲端知識庫。", m.win) + return -1 + } + return 0 // 單帳號直接用;多帳號的選擇 UI 待 issue #19 一起做 +} + +// refresh 依現況重畫(狀態列 + 清單)。可從任何 goroutine 呼叫。 +func (m *mainWindow) refresh() { + if m.reload != nil { + if c := m.reload(); c != nil { + m.cfg = c + } + } + m.rebuildRows() + + st := m.sup.Status() + sync := loadAppSyncStatus() + m.statusText.SetText(mainWindowStatusText(st)) + m.statusSub.SetText(mainWindowSubText(st, sync)) + m.folderList.Refresh() +} + +// rebuildRows 把 config 攤平成清單列(帳號標題 + 其下資料夾)。 +func (m *mainWindow) rebuildRows() { + rows := []folderRow{} + if m.cfg != nil { + for ai, acc := range m.cfg.Accounts { + rows = append(rows, folderRow{ + isHeader: true, accIdx: ai, + title: accountDisplayName(acc), + sub: shortCypherHost(acc.CypherURL), + }) + for _, f := range acc.WatchFolders { + rows = append(rows, folderRow{accIdx: ai, title: f, sub: "自動同步中"}) + } + } + } + m.rows = rows +} + +// mainWindowStatusText 是最上方那行大字——**一眼看出現在在做什麼**(issue #17)。 +func mainWindowStatusText(s supervisor.Status) string { + switch s.State { + case supervisor.StateSyncing: + return "同步中… 正在讀檔並整理成知識卡" + case supervisor.StateWatching: + return "看守中 · 資料夾有變動就會自動整理" + case supervisor.StateStarting: + return "啟動中…" + case supervisor.StateError: + return "暫時出錯,正在自動重試" + default: + return "尚未開始" + } +} + +// mainWindowSubText 是狀態底下那行小字(時間、計數、錯誤)。 +func mainWindowSubText(s supervisor.Status, sync traySyncStatus) string { + if sync.ExtractorError != "" && !sync.ExtractorOK { + return "⚠ " + sync.ExtractorError + } + parts := []string{} + if !s.LastRoundAt.IsZero() { + parts = append(parts, "上次同步 "+s.LastRoundAt.Local().Format("15:04")) + } + if sync.ExtractedOK > 0 { + parts = append(parts, fmt.Sprintf("已整理 %d 份", sync.ExtractedOK)) + } + if sync.ExtractFailed > 0 { + parts = append(parts, fmt.Sprintf("⚠ %d 份失敗", sync.ExtractFailed)) + } + if len(parts) == 0 { + return "還沒有同步紀錄" + } + out := parts[0] + for _, p := range parts[1:] { + out += " · " + p + } + return out +} + +// showAndRefresh 開窗(若已開就帶到前景)並立即更新一次。 +func (m *mainWindow) showAndRefresh() { + m.refresh() + m.win.Show() + m.win.RequestFocus() +} + +// startAutoRefresh 每秒更新一次——**同步中的狀態要看得到在動**(issue #17)。 +// 視窗隱藏時不做事,不浪費資源。 +func (m *mainWindow) startAutoRefresh() { + go func() { + for range time.Tick(time.Second) { + m.refresh() + } + }() +} diff --git a/cmd/arcrun-tray/t192_test.go b/cmd/arcrun-tray/t192_test.go new file mode 100644 index 0000000..6b4fb0e --- /dev/null +++ b/cmd/arcrun-tray/t192_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "strings" + "testing" + "time" + + "arcrun-rag/collector/supervisor" +) + +// t192/#17:正在跑就要**看得出來在跑**。 +// leo:「按『立刻同步』後很快就回到『看守中』,看起來好像就做完了, +// 這時使用者一看沒做完啊,就覺得是 bug」。 +func TestMainWindowStatusShowsSyncing(t *testing.T) { + cases := []struct { + name string + state supervisor.State + want string + }{ + {"同步中要講在做什麼", supervisor.StateSyncing, "同步中"}, + {"看守中", supervisor.StateWatching, "看守中"}, + {"啟動中", supervisor.StateStarting, "啟動中"}, + {"出錯要說會自動重試", supervisor.StateError, "重試"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := mainWindowStatusText(supervisor.Status{State: c.state}) + if !strings.Contains(got, c.want) { + t.Errorf("state=%s → %q,應含 %q", c.state, got, c.want) + } + }) + } +} + +// 同步中的文案必須說明「正在做什麼」,不能只寫兩個字讓人乾等。 +func TestSyncingTextExplainsWhatItIsDoing(t *testing.T) { + got := mainWindowStatusText(supervisor.Status{State: supervisor.StateSyncing}) + if !strings.Contains(got, "讀檔") && !strings.Contains(got, "知識卡") { + t.Errorf("同步中應說明正在做什麼,got %q", got) + } +} + +// 副標:有錯誤時**優先顯示錯誤**(不能被「上次同步 12:34」蓋掉)。 +func TestSubTextPrioritisesError(t *testing.T) { + got := mainWindowSubText( + supervisor.Status{LastRoundAt: time.Now()}, + traySyncStatus{ExtractorOK: false, ExtractorError: "還沒連上知識庫"}, + ) + if !strings.Contains(got, "還沒連上知識庫") { + t.Errorf("有錯誤時應優先顯示,got %q", got) + } +} + +// 副標:正常時要看得到「上次同步時間」與「已整理幾份」。 +func TestSubTextShowsProgress(t *testing.T) { + got := mainWindowSubText( + supervisor.Status{LastRoundAt: time.Date(2026, 8, 4, 21, 5, 0, 0, time.Local)}, + traySyncStatus{ExtractorOK: true, ExtractedOK: 3, ExtractFailed: 1}, + ) + for _, want := range []string{"21:05", "已整理 3", "1 份失敗"} { + if !strings.Contains(got, want) { + t.Errorf("副標應含 %q,got %q", want, got) + } + } +} + +// 沒同步過時不能顯示空白(空白=用戶不知道是壞了還是還沒開始)。 +func TestSubTextNeverEmpty(t *testing.T) { + got := mainWindowSubText(supervisor.Status{}, traySyncStatus{ExtractorOK: true}) + if strings.TrimSpace(got) == "" { + t.Error("副標不可空白——用戶會分不清是壞了還是還沒開始") + } +} + +// t192/#18:資料夾清單攤平——帳號一列標題,其下每個資料夾各一列。 +// 用扁平清單才能吃 widget.List 的虛擬捲動(leo:「幾十個,根本塞不下」)。 +func TestRebuildRowsFlattensAccountsAndFolders(t *testing.T) { + m := &mainWindow{cfg: &directConfig{Accounts: []accountCfg{ + {InstanceName: "geek6688", CypherURL: "https://a.workers.dev", WatchFolders: []string{"/p/one", "/p/two"}}, + {InstanceName: "youlin", CypherURL: "https://b.workers.dev", WatchFolders: []string{"/p/three"}}, + }}} + m.rebuildRows() + + if len(m.rows) != 5 { // 2 標題 + 3 資料夾 + t.Fatalf("應攤平成 5 列,got %d:%+v", len(m.rows), m.rows) + } + if !m.rows[0].isHeader || m.rows[0].title != "geek6688" { + t.Errorf("第 0 列應是帳號標題 geek6688,got %+v", m.rows[0]) + } + if m.rows[1].isHeader || m.rows[1].title != "/p/one" { + t.Errorf("第 1 列應是資料夾 /p/one,got %+v", m.rows[1]) + } + // 刪除要作用在正確帳號上(t101 踩過:刪錯帳號的資料夾) + if m.rows[4].accIdx != 1 { + t.Errorf("第 4 列(youlin 的資料夾)accIdx 應為 1,got %d", m.rows[4].accIdx) + } +} + +// 幾十個資料夾也要能攤平(不是為了效能,是確保沒有隱藏上限)。 +func TestRebuildRowsHandlesManyFolders(t *testing.T) { + folders := make([]string, 60) + for i := range folders { + folders[i] = "/gitea/project-" + string(rune('a'+i%26)) + } + m := &mainWindow{cfg: &directConfig{Accounts: []accountCfg{ + {InstanceName: "leo", CypherURL: "https://x.workers.dev", WatchFolders: folders}, + }}} + m.rebuildRows() + if len(m.rows) != 61 { + t.Errorf("60 個資料夾+1 標題應為 61 列,got %d", len(m.rows)) + } +} + +// 沒有任何帳號時清單是空的(走 onboarding,不是顯示壞掉的空面板)。 +func TestRebuildRowsEmptyWhenNoAccount(t *testing.T) { + m := &mainWindow{cfg: &directConfig{}} + m.rebuildRows() + if len(m.rows) != 0 { + t.Errorf("無帳號時應為空清單,got %d 列", len(m.rows)) + } +} diff --git a/direct.go b/direct.go index 362ed0d..a7a71ce 100644 --- a/direct.go +++ b/direct.go @@ -896,12 +896,27 @@ func runDirect(args []string) int { // (留著空轉的回寫邏輯會讓未來的人以為 claude 路還活著=錯誤的環境信號。) runOne := func() int { + // 🔴 t191(leo 08-04,issue #17):「按『立刻同步』後**很快就回到「看守中」**, + // 看起來好像就做完了,這時使用者一看沒做完啊,就覺得是 bug」。 + // + // 真兇:舊版**只在一輪跑完後**才印 JSON ⇒ 托盤無從得知「正在跑」, + // 整段工作時間對用戶是隱形的,只看得到前後都是「看守中」。 + // ⇒ 開工前先印一筆 `phase:"start"`,托盤收到就顯示「同步中…」, + // 收到 `phase:"done"` 再切回「看守中」。 + // 形狀相容:兩筆都有 `at`,舊版托盤只會多算一次 round,不會壞掉。 + startOut, _ := json.MarshalIndent(struct { + At string `json:"at"` + Phase string `json:"phase"` + }{time.Now().Format(time.RFC3339), "start"}, "", " ") + fmt.Println(string(startOut)) + results, exit, _ := RunDirectOnce(cfg, *dryRun) out, _ := json.MarshalIndent(struct { At string `json:"at"` + Phase string `json:"phase"` Folders []string `json:"folders"` Results []DirectResult `json:"results"` - }{time.Now().Format(time.RFC3339), cfg.Folders(), results}, "", " ") + }{time.Now().Format(time.RFC3339), "done", cfg.Folders(), results}, "", " ") fmt.Println(string(out)) return exit } diff --git a/supervisor/supervisor.go b/supervisor/supervisor.go index 7712ab8..6b1ac6d 100644 --- a/supervisor/supervisor.go +++ b/supervisor/supervisor.go @@ -30,7 +30,12 @@ const ( StateStopped State = "stopped" // 未啟動或已停止 StateStarting State = "starting" // 行程剛拉起、尚無第一輪 StateWatching State = "watching" // 正常看守中(已有掃描輪) - StateError State = "error" // 行程非預期退出、等待重起 + // 🔴 t191(leo 08-04,issue #17):「按『立刻同步』後很快就回到『看守中』, + // 看起來好像就做完了⋯⋯使用者一看沒做完啊,就覺得是 bug」。 + // collector 開工前會印 phase:"start"、跑完印 phase:"done" + // ⇒ 這中間就是本狀態,讓托盤顯示「同步中…」,工作時間不再對用戶隱形。 + StateSyncing State = "syncing" // 正在掃描/萃取(收到 start、還沒收到 done) + StateError State = "error" // 行程非預期退出、等待重起 ) // Status 是托盤要顯示的即時狀態(值型別、複製安全)。 @@ -46,6 +51,8 @@ type Status struct { // round 對應 collector direct 每輪印到 stdout 的 JSON(見 direct.go runOne)。 type round struct { At string `json:"at"` + Phase string `json:"phase"` // t191:"start"(開工)/"done"(跑完);舊版沒有此欄=空 + Folder string `json:"folder"` Results []json.RawMessage `json:"results"` } @@ -298,6 +305,11 @@ func (s *Supervisor) runOnce(ctx context.Context) error { break } at := parseAt(r.At) + if r.Phase == "start" { + // t191:開工 → 顯示「同步中…」。不累加 Rounds(那是「完成幾輪」)。 + s.setState(func(st *Status) { st.State = StateSyncing }) + continue + } s.setState(func(st *Status) { st.State = StateWatching st.Rounds++