3d13e5a54e
leo 08-04 看過 v0.17.0 後的四點,逐條回應:
## ① 「不會這裡又寫了一個新的 CSS?」——是,我又寫了一份
真兇:portal 的樣式內嵌在 HTML 裡,沒有獨立檔 ⇒ 四個站各抄一份、本來就在漂移。
解:抽出 InkStoneCo/arcrun-cis/css/arcrun-cis.css 當唯一真相源。
daemon 的 style.css 現在**一個 hex 都沒有**,顏色全走 var(--...)。
## ② 「完全模仿 gdrive,左方側邊欄,在右方替換頁面」
底部 nav bar 拿掉(leo:「幾乎沒看過這種 nav bar 在下方的」)。
改成左側邊欄四頁:首頁/同步資料夾/AI 設定/版本與更新,右側換頁。
設定不再開 popup;只有「確認移除資料夾」才用覆蓋層。
## ③ 「既然是 Web,你做的時候無法檢視?」——可以,我之前沒做
新增 check-render.sh:headless Chrome 真的渲染一次並**量像素**。
它抓到一個肉眼容易誤判的真 bug:
arcrun-lockup-h-paper-on-ink.png **自帶不透明 Ink 底板**(實測左上像素 RGBA=23,24,26,255),
設計前提是貼在純 Ink 表面;我們背景有紙張紋理 ⇒ 出現突兀方塊。
修:深色模式把品牌區做成純 Ink。實測落差 4(原本會是一個明顯方塊)。
過程中也發現自己對著**舊的 dist**除錯,浪費一輪——這支閘同時防這個。
## ④ 「檢查更新連到 docs,不正確,是直接在這裡進行」
新增「版本與更新」頁:顯示**現在版本 vs 最新版本**,
三段式全在 App 內完成——檢查/下載並安裝/重新啟動套用。
selfupdate 邏輯逐字沿用 arcrun-tray(含 t186 走 raw、t184 蓋正在跑的 .app),不重寫。
## 交貨閘(leo:「你做為檢查有嗎?沒有怎麼交貨?」)
check-cis.sh 擴充:版面檔不准出現任何 hex + 共用底層色票逐項比對。
兩支閘實跑全過。
304 lines
9.9 KiB
Go
304 lines
9.9 KiB
Go
package main
|
||
|
||
// app.go — Arcrun 桌面 App 的後端(t193)
|
||
//
|
||
// 🔴 為什麼從 fyne 換到 Wails(leo 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"` // 只回遮罩,不回真值
|
||
ExtractedOK int `json:"extractedOK"` // 首頁「已整理幾份」
|
||
}
|
||
|
||
// 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.ExtractedOK = sync.ExtractedOK
|
||
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) }
|