cc6534dd39
解 issue #18+#17(leo 指定併做)。 ## #17 進行中狀態——真兇是「工作時間對用戶隱形」 leo:「按『立刻同步』後很快就回到『看守中』,看起來好像就做完了, 這時使用者一看沒做完啊,就覺得是 bug」。 真兇:collector **只在一輪跑完後**才印 JSON ⇒ 托盤無從得知「正在跑」。 修:開工前先印 phase:"start"、跑完印 phase:"done"; supervisor 新增 StateSyncing;托盤/主視窗顯示「同步中… 正在讀檔並整理成知識卡」。 形狀相容:兩筆都有 at,舊版托盤只會多算一次 round,不會壞。 實測(真的跑一輪 --once): 第1筆 phase='start' at=21:31:26 第2筆 phase='done' at=21:31:27 ## #18 主視窗(新檔 mainwindow.go) leo:「daemon 設定項越來越多,不可能統統塞在下拉選單」 「參考 Google Drive 設定畫面:點擊 daemon 就開一個視窗,佔螢幕約 1/2」 「資料夾清單要可捲動——我每個 gitea 專案都要同步,那就是幾十個,根本塞不下」 - 920x620 視窗,關窗=隱藏(daemon 續跑) - 上:大字狀態+副標(上次同步/已整理幾份/失敗幾份;有錯誤優先顯示) - 中:widget.List 虛擬捲動的資料夾清單(帳號標題+其下資料夾攤平成單層) - 下:白話動作列(加入資料夾/新增知識庫帳號/AI 設定/檢查更新) - 每秒自動刷新 ⇒ 同步中看得到在動 - 無帳號時引導去連線(onboarding),不是丟錯誤或空面板 - **所有動作複用既有 handler**,不在視窗那邊重寫一份邏輯 托盤選單第一項加「開啟 Arcrun…」——leo 已點破兩次 「能力做出來了,入口沒出現在用戶會看的地方」(docs 沒連結/MCP 零處提及), 這次做完就讓它看得見。 測試:t192_test.go 八則(狀態文案四態/同步中要說明在做什麼/ 副標優先顯示錯誤/副標不可空白/清單攤平且 accIdx 正確/60 個資料夾/無帳號空清單)全過。 collector 與 supervisor 全套綠。 ⚠️ 未驗:fyne GUI 的實際外觀需真機開窗(無螢幕環境驗不到), CIS 視覺套用待 leo 看過畫面再調。
279 lines
8.9 KiB
Go
279 lines
8.9 KiB
Go
// 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()
|
||
}
|
||
}()
|
||
}
|