Files
arcrun-collector/cmd/arcrun-app/app.go
T
Leo bd0cead00e t193:daemon UI 換 Wails——CIS 是硬要求,fyne 做不到
leo 2026-08-04 看過 v0.16.0 畫面後:
「功能都有了,但美感非常糟糕⋯⋯跟 CIS 完全無關,每個功能都開一個小小的 popup 視窗,
 非常缺乏整體感,這要理解的是原始的技術選擇是否出錯?」
「我的要求是符合 CIS,在風格上跟 portal 一樣」
「CIS 已經提供規範,你做的連 Logo 都沒放上去,這跟美不美有關係嗎?
 要求放進 CIS 是硬要求,你做為檢查有嗎?沒有怎麼交貨?」

## 我的兩個錯
1. **動工前沒提醒做不到**——decisions-summary.md:314 的 D-daemon-UI
   是我 07-27 自己寫的調研(結論:fyne 全自繪、CSS 套不進去),寫完就沒再看。
   今天動工前該查它卻直接開寫,浪費 leo 的時間與 token。
2. **沒做 CIS 檢查就交貨**——連 logo 都沒放。

## 換 Wails(WebView 殼)
前端就是 HTML/CSS ⇒ 可以直接用 portal 那份色票與 lockup。
- style.css 的色票/字體/紙張紋理**逐字取自** portal 的 :root
  (matrix/arcrun:console-ui/public/portal/index.html),不自創任何顏色
- CIS lockup 官方 PNG,淺色 ink 版/深色 paper 版,切換規則同 portal
  (避開 08-01 作廢的自產 SVG——字腔缺失)
- 對話框改**內嵌覆蓋層**,不再每個功能開一個小 popup
- 原生資料夾選擇器(macOS powerbox 會自動授予該資料夾存取權,
  fyne 自繪 picker 拿不到;未來上 Mac App Store 是硬需求)
- 同步中的圓點會呼吸 ⇒ 看得出來在動(issue #17)
- 無帳號時是 onboarding 引導,不是空白面板

## 連線邏輯逐字沿用,不重寫
connect.go 的 normalizePortalURL/fetchConfigByLogin 取自 arcrun-tray/main.go,
含 t86 個資外洩事故的防線(不同實例=新增帳號,絕不覆蓋舊帳號的資料夾)。
今天已犯過一次「重寫別人修好的東西還修得更差」,不再犯第二次。

## 新增 check-cis.sh(交貨前必跑)
機械檢查六類:官方色票逐個到齊/**不准出現非 CIS 色**/logo 真的放進去/
不可用作廢 SVG/深色模式/字體與紙張紋理同 portal/app icon。
實跑 14 項全過。以後「沒跑過就不准交」。

⚠️ 未驗:實際畫面要 leo 開來看(我開不了視窗)。
2026-08-04 22:19:41 +08:00

302 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// app.go — Arcrun 桌面 App 的後端(t193
//
// 🔴 為什麼從 fyne 換到 Wailsleo 2026-08-04 看過 v0.16.0 畫面後拍板):
//
// leo:「功能都有了,但**美感非常糟糕**……**跟 CIS 完全無關**,
// 每個功能都開一個小小的 popup 視窗,**非常缺乏整體感**,
// 這要理解的是**原始的技術選擇是否出錯**?」
// 「我的要求是**符合 CIS**,在風格上**跟 portal 一樣**」
//
// fyne 的哲學=所有 UI 自己用 OpenGL 畫 ⇒ 不像 Mac、不像 Windows、**也不像 portal**
// CSS 套不進去、popup 外觀無法控制 ⇒ **CIS 這個硬要求在 fyne 上做不到**。
// Wails 是 WebView 殼 ⇒ 前端就是 HTML/CSS ⇒ **可以直接用 portal 那份色票與 lockup**。
// (此結論 07-27 就查過並寫進 decisions-summary.md D-daemon-UI,我卻沒在動工前提醒。)
//
// 邊界:本檔只做「把既有能力接到 UI」——config 讀寫、狀態、資料夾增刪都對齊
// collector 既有的檔案協定(~/.arcrun-rag/),不另發明一套。
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// App 是 Wails 綁定的後端物件;前端呼叫的方法都掛在它身上。
type App struct {
ctx context.Context
}
func NewApp() *App { return &App{} }
func (a *App) startup(ctx context.Context) { a.ctx = ctx }
// ── 與 collector 共用的資料位置(路徑規則與 collector/direct.go 一致)──
func appDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".arcrun-rag")
}
func configPath() string { return filepath.Join(appDir(), "config.json") }
func statusPath() string { return filepath.Join(appDir(), "status.json") }
func syncNowSignal() string { return filepath.Join(appDir(), "sync-now") }
// ── config 結構(欄位與 collector/direct.go 的 DirectConfig 對齊)──
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"`
Extractor string `json:"extractor,omitempty"`
GeminiAPIKey string `json:"gemini_api_key,omitempty"`
}
type directConfig struct {
Accounts []accountCfg `json:"accounts,omitempty"`
WatchFolders []string `json:"watch_folders,omitempty"`
Manifest string `json:"manifest"`
Extractor string `json:"extractor,omitempty"`
ExtractorExplicit bool `json:"extractor_explicit,omitempty"`
GeminiAPIKey string `json:"gemini_api_key,omitempty"`
raw map[string]any
}
// syncStatus 對映 collector 寫的 status.json(只取 UI 要的欄位)。
type syncStatus struct {
LastSync string `json:"last_sync,omitempty"`
ExtractedOK int `json:"extracted_ok"`
ExtractFailed int `json:"extract_failed"`
ExtractorOK bool `json:"extractor_ok"`
ExtractorError string `json:"extractor_error,omitempty"`
}
// loadCfg 同時保留原始 map ⇒ 回寫時**不會弄丟我們沒宣告的欄位**
// config 裡還有 poll_interval_sec、libraries 等,漏寫就等於幫用戶刪設定)。
func loadCfg() (*directConfig, error) {
b, err := os.ReadFile(configPath())
if err != nil {
return &directConfig{raw: map[string]any{}}, err
}
c := &directConfig{}
if err := json.Unmarshal(b, c); err != nil {
return &directConfig{raw: map[string]any{}}, err
}
_ = json.Unmarshal(b, &c.raw)
return c, nil
}
func saveCfg(c *directConfig) error {
if c.raw == nil {
c.raw = map[string]any{}
}
// 只覆寫我們改過的鍵,其餘原樣保留
accs, _ := json.Marshal(c.Accounts)
var accAny any
_ = json.Unmarshal(accs, &accAny)
c.raw["accounts"] = accAny
c.raw["extractor"] = c.Extractor
c.raw["extractor_explicit"] = c.ExtractorExplicit
c.raw["gemini_api_key"] = c.GeminiAPIKey
out, err := json.MarshalIndent(c.raw, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(appDir(), 0o755); err != nil {
return err
}
return os.WriteFile(configPath(), out, 0o600)
}
// ── 前端要的資料形狀 ──
type UIFolder struct {
Path string `json:"path"`
AccIdx int `json:"accIdx"`
}
type UIAccount struct {
Name string `json:"name"`
Host string `json:"host"`
Folders []UIFolder `json:"folders"`
}
type UIState struct {
Version string `json:"version"`
StatusBig string `json:"statusBig"`
StatusSub string `json:"statusSub"`
Syncing bool `json:"syncing"`
Accounts []UIAccount `json:"accounts"`
Engine string `json:"engine"` // "workers-ai" | "gemma"
GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值
}
// GetState 是前端每秒拉一次的單一入口。
func (a *App) GetState() UIState {
st := UIState{Version: version}
cfg, _ := loadCfg()
for i, acc := range cfg.Accounts {
ui := UIAccount{Name: accountName(acc), Host: shortHost(acc.CypherURL)}
for _, f := range acc.WatchFolders {
ui.Folders = append(ui.Folders, UIFolder{Path: f, AccIdx: i})
}
st.Accounts = append(st.Accounts, ui)
}
st.Engine = cfg.Extractor
if !cfg.ExtractorExplicit || st.Engine == "" {
st.Engine = "workers-ai" // 與 direct.go 的預設判準一致
}
if strings.TrimSpace(cfg.GeminiAPIKey) != "" {
st.GeminiKey = "••••••••"
}
sync := loadSyncStatus()
st.Syncing, st.StatusBig, st.StatusSub = describeStatus(sync)
return st
}
func loadSyncStatus() syncStatus {
var s syncStatus
if b, err := os.ReadFile(statusPath()); err == nil {
_ = json.Unmarshal(b, &s)
}
return s
}
// describeStatus 產生狀態文案。
// 🔴 issue #17:正在跑就要**看得出來在跑**——按「立刻同步」會寫 sync-now 訊號檔,
// collector 開工時才把它吃掉;訊號檔還在=已排隊未完成 ⇒ 顯示「同步中…」。
func describeStatus(s syncStatus) (syncing bool, big, sub string) {
if _, err := os.Stat(syncNowSignal()); err == nil {
return true, "同步中… 正在讀檔並整理成知識卡", "請稍候,完成後會顯示整理了幾份"
}
if s.ExtractorError != "" && !s.ExtractorOK {
return false, "需要你處理一下", "⚠ " + s.ExtractorError
}
parts := []string{}
if t, err := time.Parse(time.RFC3339, s.LastSync); err == nil {
parts = append(parts, "上次同步 "+t.Local().Format("15:04"))
}
if s.ExtractedOK > 0 {
parts = append(parts, fmt.Sprintf("已整理 %d 份", s.ExtractedOK))
}
if s.ExtractFailed > 0 {
parts = append(parts, fmt.Sprintf("⚠ %d 份失敗", s.ExtractFailed))
}
if len(parts) == 0 {
return false, "看守中 · 資料夾有變動就會自動整理", "還沒有同步紀錄"
}
return false, "看守中 · 資料夾有變動就會自動整理", strings.Join(parts, " · ")
}
func accountName(a accountCfg) string {
if s := strings.TrimSpace(a.InstanceName); s != "" {
return s
}
if s := strings.TrimSpace(a.Email); s != "" {
return s
}
return shortHost(a.CypherURL)
}
func shortHost(u string) string {
s := strings.TrimPrefix(strings.TrimPrefix(u, "https://"), "http://")
return strings.TrimSuffix(strings.SplitN(s, "/", 2)[0], "/")
}
// ── 動作(前端按鈕直接呼叫)──
// SyncNow 寫訊號檔讓 collector 立刻跑一輪(沿用 t98 的機制,不新增 IPC)。
func (a *App) SyncNow() error {
if err := os.MkdirAll(appDir(), 0o755); err != nil {
return err
}
return os.WriteFile(syncNowSignal(), []byte{}, 0o644)
}
// PickFolder 用**系統原生**資料夾選擇器。
// 🔴 這是換 Wails 的另一個實質好處(D-daemon-UI 已記):macOS 的 powerbox 機制
// 會在使用者用原生面板選資料夾時**自動授予該資料夾存取權**;fyne 自繪的 picker 拿不到。
// 未來要上 Mac App Store 或開沙箱時,原生 picker 是硬需求。
func (a *App) PickFolder() (string, error) {
return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: "選一個要自動整理的資料夾",
})
}
func (a *App) AddFolder(accIdx int, path string) error {
if strings.TrimSpace(path) == "" {
return nil
}
cfg, err := loadCfg()
if err != nil {
return err
}
if accIdx < 0 || accIdx >= len(cfg.Accounts) {
return fmt.Errorf("找不到這個知識庫帳號")
}
for _, f := range cfg.Accounts[accIdx].WatchFolders {
if f == path {
return nil // 已經在看守了,不重複加
}
}
cfg.Accounts[accIdx].WatchFolders = append(cfg.Accounts[accIdx].WatchFolders, path)
sort.Strings(cfg.Accounts[accIdx].WatchFolders)
return saveCfg(cfg)
}
func (a *App) RemoveFolder(accIdx int, path string) error {
cfg, err := loadCfg()
if err != nil {
return err
}
if accIdx < 0 || accIdx >= len(cfg.Accounts) {
return fmt.Errorf("找不到這個知識庫帳號")
}
keep := []string{}
for _, f := range cfg.Accounts[accIdx].WatchFolders {
if f != path {
keep = append(keep, f)
}
}
cfg.Accounts[accIdx].WatchFolders = keep
return saveCfg(cfg)
}
// SetAI 存 AI 設定。
// 🔴 t190:金鑰**無條件以輸入框為準**(清空=刪除)——leo 實撞過「金鑰刪不掉」。
func (a *App) SetAI(useGemini bool, key string) error {
cfg, err := loadCfg()
if err != nil {
return err
}
engine := "workers-ai"
if useGemini {
engine = "gemma"
if strings.TrimSpace(key) == "" {
return fmt.Errorf("選了 Gemini 就要貼上金鑰;不想申請的話請改選「雲端 AI」")
}
}
cfg.Extractor = engine
cfg.ExtractorExplicit = true
cfg.GeminiAPIKey = key
for i := range cfg.Accounts {
cfg.Accounts[i].Extractor = engine
cfg.Accounts[i].GeminiAPIKey = key
}
return saveCfg(cfg)
}
// OpenURL 用系統瀏覽器開網址(下載頁/說明文件)。
func (a *App) OpenURL(u string) { runtime.BrowserOpenURL(a.ctx, u) }