496 lines
20 KiB
Go
496 lines
20 KiB
Go
package main
|
||
|
||
// apps.go — 桌面小幫手的 App 啟動器後端(inkstone/arcrun-rag#137)
|
||
//
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 這個檔案在解什麼
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// leo 2026-08-24:「所有的 App 需要有一個類似 Android/iOS 的九宮格啟動界面,
|
||
// 每個 App 有一個 icon,**這會運行在 portal 及 daemon**。」
|
||
//
|
||
// 桌面這半沒有自己的 KBDB,也**不准**在本機另存一份 App 清單
|
||
// (上游 `inkstone/Arcrun#82` 已定「安裝態只有一份真相源」=實例的 `{tenant}:app:{id}`)。
|
||
// ⇒ 本檔做的事只有一件:**去使用者連的那個實例問**,把答案原樣交給畫面。
|
||
// 沒有任何預設清單、沒有快取到磁碟、沒有第二份 schema。
|
||
//
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 為什麼是「兩條路」而不是一條(這是本票的規格判斷,理由寫在這裡不寫在票上)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 上游同一份安裝態有兩個對外面:
|
||
//
|
||
// ① `/portal/data/apps*` 認 **portal session**(Bearer)——Portal 那半走這條,
|
||
// 清單/詳情(含畫面 HTML)/動作三支齊全。
|
||
// ② `/apps` 認 **X-Arcrun-API-Key**(=實例 namespace)——CLI/操盤 AI 走這條,
|
||
// **只有清單**,沒有詳情、沒有動作。
|
||
//
|
||
// 小幫手手上本來就有 ②的鑰匙(`config.json` 的 `api_key`,連線時實例自己下發的),
|
||
// 但**沒有**①的 session(密碼刻意零落地,見 connect.go)。
|
||
//
|
||
// 所以:
|
||
// - **清單一律先走①、拿不到就退回②**。兩支回的都是 `summarizeApp` 的同一個形狀
|
||
// (id/name/icon/has_ui/version),所以九宮格**在完全沒登入的情況下也是真資料**。
|
||
// - **詳情與動作只有①有**,所以那兩件需要一次 portal 登入。連線精靈當下順手換一張
|
||
// session(`Connect` 已經有密碼在手,見 connect.go 的呼叫點),使用者通常不會再被問。
|
||
// session 過期(實例預設 7 天)才會在 App 頁上出現一次登入框。
|
||
//
|
||
// 🔴 密碼仍然零落地:存的是 session token,不是密碼(與 connect.go 同一條紅線)。
|
||
//
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 不做輪詢
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 畫面每秒打一次 `GetState()`,但**本檔的任何函式都不掛在那條線上**——
|
||
// App 清單只在「打開啟動器」與「按重新整理」時才去問實例一次。
|
||
// 把網路請求塞進每秒的 tick 等於自己造一個輪詢器,那是明令禁止的形狀。
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// appHTTPTimeout:App 動作背後是一整條工作流(可能打 LLM),比單純讀清單久得多。
|
||
// 清單/詳情用短的,動作用長的——不讓「讀清單」被一條慢工作流的預算拖著。
|
||
const (
|
||
appReadTimeout = 20 * time.Second
|
||
appActionTimeout = 120 * time.Second
|
||
)
|
||
|
||
// ── 前端要的形狀 ───────────────────────────────────────────────────────────
|
||
|
||
// UIApp=九宮格上的一格。逐欄對應上游 `summarizeApp`,不多不少——
|
||
// 多一欄就是在桌面端發明一個實例不認得的概念。
|
||
type UIApp struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Icon string `json:"icon"`
|
||
HasUI bool `json:"hasUi"`
|
||
Version string `json:"version"`
|
||
}
|
||
|
||
// UIAppList=一個知識庫的啟動器內容。
|
||
//
|
||
// 🔴 Error 與「空清單」是兩件事,不准混成同一句話(arcrun-rag#10「寧可明顯失敗」):
|
||
// - `Error != ""` ⇒ 問不到(連不上/實例太舊沒有這支端點)——畫面要說「問不到」
|
||
// - `Error == "" && len(Apps) == 0` ⇒ 真的一個都沒裝——畫面要說「還沒有 App」
|
||
//
|
||
// 兩者長得像,但使用者該做的事完全相反。
|
||
type UIAppList struct {
|
||
AccIdx int `json:"accIdx"`
|
||
Account string `json:"account"` // 知識庫顯示名(畫面上的切換器用)
|
||
Host string `json:"host"`
|
||
Apps []UIApp `json:"apps"`
|
||
Error string `json:"error"`
|
||
// Source=這份清單是用哪把鑰匙問到的("session"/"apikey")。給診斷用,
|
||
// 畫面不依它分支——兩條路的內容是同一份安裝態。
|
||
Source string `json:"source"`
|
||
}
|
||
|
||
// UIAppWorkflow=App 的一條工作流(沒有自帶畫面的 App 就靠這個列表當預設畫面,
|
||
// 與 Portal 的 renderAppView 同一套呈現,不另立第二種)。
|
||
type UIAppWorkflow struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
}
|
||
|
||
// UIAppDetail=點進一個 App 之後要的全部東西。
|
||
type UIAppDetail struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Icon string `json:"icon"`
|
||
Version string `json:"version"`
|
||
HasUI bool `json:"hasUi"`
|
||
UIHtml string `json:"uiHtml"`
|
||
Workflows []UIAppWorkflow `json:"workflows"`
|
||
Actions []string `json:"actions"`
|
||
// NeedsLogin=這台機器沒有(或已過期)這個知識庫的 portal session。
|
||
// 不是錯誤,是「還差一步」——畫面要長出登入框,不是紅字。
|
||
NeedsLogin bool `json:"needsLogin"`
|
||
Email string `json:"email"` // 登入框預填(帳號本來就存在 config 裡)
|
||
Error string `json:"error"`
|
||
}
|
||
|
||
// ── 內部:HTTP 小工具 ─────────────────────────────────────────────────────
|
||
|
||
// apiBaseOf 回傳這個帳號的 API base(cypher origin)。
|
||
// 連線精靈存進來的 CypherURL 已經是 API 位址(normalizePortalURL 換算過),
|
||
// 這裡只做去尾斜線,**不重新發明第二套換算**。
|
||
func apiBaseOf(a accountCfg) string {
|
||
return strings.TrimRight(strings.TrimSpace(a.CypherURL), "/")
|
||
}
|
||
|
||
// accountAt 取第 idx 個帳號;越界回錯(前端傳來的索引可能落後於剛改過的設定)。
|
||
func accountAt(cfg *directConfig, idx int) (accountCfg, error) {
|
||
if cfg == nil || idx < 0 || idx >= len(cfg.Accounts) {
|
||
return accountCfg{}, errors.New("找不到這個知識庫")
|
||
}
|
||
return cfg.Accounts[idx], nil
|
||
}
|
||
|
||
// appDo 送一個請求並讀回 body。回傳 (status, body, err);err 只代表「連不上」,
|
||
// HTTP 4xx/5xx 一律當成「連得上但被拒絕」由呼叫端判讀——
|
||
// 把兩者混成一個 error 就分不出「網路斷了」與「密碼過期了」。
|
||
func appDo(req *http.Request, timeout time.Duration) (int, []byte, error) {
|
||
res, err := (&http.Client{Timeout: timeout}).Do(req)
|
||
if err != nil {
|
||
return 0, nil, errors.New("連不上這個知識庫——請確認網路正常")
|
||
}
|
||
defer res.Body.Close()
|
||
b, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20)) // App 自帶畫面可能不小,但給上限
|
||
return res.StatusCode, b, nil
|
||
}
|
||
|
||
// appErrMessage 從實例回的 JSON 撈 error 欄位;撈不到就用狀態碼講人話。
|
||
func appErrMessage(status int, body []byte) string {
|
||
var e struct {
|
||
Error string `json:"error"`
|
||
}
|
||
if json.Unmarshal(body, &e) == nil && strings.TrimSpace(e.Error) != "" {
|
||
return e.Error
|
||
}
|
||
switch status {
|
||
case http.StatusNotFound:
|
||
return "這個知識庫還沒有 App 功能(實例版本較舊,更新後就會出現)"
|
||
case http.StatusUnauthorized, http.StatusForbidden:
|
||
return "這個知識庫的登入已過期"
|
||
}
|
||
return fmt.Sprintf("知識庫回了 HTTP %d", status)
|
||
}
|
||
|
||
// ── portal session(詳情與動作唯一的鑰匙)────────────────────────────────
|
||
|
||
// sessionValid:留 60 秒安全邊際,免得剛好在請求路上過期。
|
||
func sessionValid(a accountCfg) bool {
|
||
return strings.TrimSpace(a.PortalSession) != "" &&
|
||
(a.PortalSessionExp == 0 || time.Now().Unix() < a.PortalSessionExp-60)
|
||
}
|
||
|
||
// portalLogin 用帳密換一張 portal session token。
|
||
//
|
||
// 🔴 這支是**唯一**碰密碼的地方,而且密碼只活在參數裡:換完就丟,不寫檔、不 log。
|
||
func portalLogin(base, email, password string) (token string, expiresIn int64, err error) {
|
||
body, _ := json.Marshal(map[string]string{
|
||
"email": strings.ToLower(strings.TrimSpace(email)),
|
||
"password": password,
|
||
})
|
||
req, err := http.NewRequest(http.MethodPost, base+"/portal/login", bytes.NewReader(body))
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
status, raw, err := appDo(req, appReadTimeout)
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
var out struct {
|
||
Success bool `json:"success"`
|
||
SessionToken string `json:"session_token"`
|
||
SessionExpires int64 `json:"session_expires_in"`
|
||
Error string `json:"error"`
|
||
}
|
||
_ = json.Unmarshal(raw, &out)
|
||
if status != http.StatusOK || !out.Success || out.SessionToken == "" {
|
||
if strings.TrimSpace(out.Error) != "" {
|
||
return "", 0, errors.New(out.Error)
|
||
}
|
||
return "", 0, errors.New(appErrMessage(status, raw))
|
||
}
|
||
return out.SessionToken, out.SessionExpires, nil
|
||
}
|
||
|
||
// storeSession 把換到的 session 寫回設定(只存 token 與到期時間,不存密碼)。
|
||
func storeSession(idx int, token string, expiresIn int64) error {
|
||
cfg, err := loadCfg()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if idx < 0 || idx >= len(cfg.Accounts) {
|
||
return errors.New("找不到這個知識庫")
|
||
}
|
||
cfg.Accounts[idx].PortalSession = token
|
||
if expiresIn > 0 {
|
||
cfg.Accounts[idx].PortalSessionExp = time.Now().Unix() + expiresIn
|
||
} else {
|
||
cfg.Accounts[idx].PortalSessionExp = 0
|
||
}
|
||
return saveCfg(cfg)
|
||
}
|
||
|
||
// clearSession 在實例說「這張 session 不算數」時把它丟掉。
|
||
// 留著一張已知無效的 token 只會讓下一次再撞一次 401,然後畫面又閃一下錯誤。
|
||
func clearSession(idx int) {
|
||
cfg, err := loadCfg()
|
||
if err != nil || idx < 0 || idx >= len(cfg.Accounts) {
|
||
return
|
||
}
|
||
if cfg.Accounts[idx].PortalSession == "" && cfg.Accounts[idx].PortalSessionExp == 0 {
|
||
return
|
||
}
|
||
cfg.Accounts[idx].PortalSession = ""
|
||
cfg.Accounts[idx].PortalSessionExp = 0
|
||
if err := saveCfg(cfg); err != nil {
|
||
appLog("清掉過期的知識庫登入失敗:%v", err)
|
||
}
|
||
}
|
||
|
||
// tryStoreSessionFromLogin 在連線精靈成功之後順手換一張 session(connect.go 呼叫)。
|
||
// **失敗不算連線失敗**——沒有 session 只是「App 詳情要再登入一次」,
|
||
// 而資料夾同步這條主線根本用不到它。
|
||
func tryStoreSessionFromLogin(cfg *directConfig, idx int, email, password string) {
|
||
if idx < 0 || idx >= len(cfg.Accounts) {
|
||
return
|
||
}
|
||
token, exp, err := portalLogin(apiBaseOf(cfg.Accounts[idx]), email, password)
|
||
if err != nil {
|
||
appLog("連線成功但沒換到知識庫的 App 登入(不影響同步):%v", err)
|
||
return
|
||
}
|
||
cfg.Accounts[idx].PortalSession = token
|
||
if exp > 0 {
|
||
cfg.Accounts[idx].PortalSessionExp = time.Now().Unix() + exp
|
||
}
|
||
}
|
||
|
||
// ── 前端呼叫的四支 ─────────────────────────────────────────────────────────
|
||
|
||
// ListApps 回一個知識庫上「實際裝了的」App。
|
||
//
|
||
// 兩條路的順序與理由見檔頭。**沒有任何寫死的清單**——問不到就誠實說問不到。
|
||
func (a *App) ListApps(accIdx int) UIAppList {
|
||
out := UIAppList{AccIdx: accIdx}
|
||
cfg, _ := loadCfg()
|
||
acc, err := accountAt(cfg, accIdx)
|
||
if err != nil {
|
||
out.Error = err.Error()
|
||
return out
|
||
}
|
||
out.Account = accountName(acc)
|
||
out.Host = shortHost(acc.CypherURL)
|
||
base := apiBaseOf(acc)
|
||
if base == "" {
|
||
out.Error = "這個知識庫沒有網址,請重新連線一次"
|
||
return out
|
||
}
|
||
|
||
// ① portal session(與 Portal 那半同一支端點、同一份權限)
|
||
if sessionValid(acc) {
|
||
req, _ := http.NewRequest(http.MethodGet, base+"/portal/data/apps", nil)
|
||
req.Header.Set("Authorization", "Bearer "+acc.PortalSession)
|
||
status, raw, derr := appDo(req, appReadTimeout)
|
||
switch {
|
||
case derr != nil:
|
||
out.Error = derr.Error()
|
||
return out
|
||
case status == http.StatusOK:
|
||
out.Apps = parseAppList(raw)
|
||
out.Source = "session"
|
||
return out
|
||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||
clearSession(accIdx) // 過期了 ⇒ 掉到 ② 去,清單照樣看得到
|
||
default:
|
||
// 其他錯(例如 5xx)**不要**掉到 ②——那會把「實例出問題」偽裝成正常。
|
||
out.Error = appErrMessage(status, raw)
|
||
return out
|
||
}
|
||
}
|
||
|
||
// ② 實例 API key(小幫手本來就有的那把;只有清單,沒有詳情/動作)
|
||
if strings.TrimSpace(acc.APIKey) == "" {
|
||
out.Error = "這台電腦還沒有這個知識庫的鑰匙,請重新連線一次"
|
||
return out
|
||
}
|
||
req, _ := http.NewRequest(http.MethodGet, base+"/apps", nil)
|
||
req.Header.Set("X-Arcrun-API-Key", acc.APIKey)
|
||
status, raw, derr := appDo(req, appReadTimeout)
|
||
if derr != nil {
|
||
out.Error = derr.Error()
|
||
return out
|
||
}
|
||
if status != http.StatusOK {
|
||
out.Error = appErrMessage(status, raw)
|
||
return out
|
||
}
|
||
out.Apps = parseAppList(raw)
|
||
out.Source = "apikey"
|
||
return out
|
||
}
|
||
|
||
// parseAppList 讀 `{apps:[…]}`。兩支端點回的都是 summarizeApp 的形狀,所以只有一份解析。
|
||
// 純函式(單測用)。
|
||
func parseAppList(raw []byte) []UIApp {
|
||
var body struct {
|
||
Apps []struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Icon string `json:"icon"`
|
||
HasUI bool `json:"has_ui"`
|
||
Version string `json:"version"`
|
||
} `json:"apps"`
|
||
}
|
||
if json.Unmarshal(raw, &body) != nil {
|
||
return nil
|
||
}
|
||
apps := make([]UIApp, 0, len(body.Apps))
|
||
for _, x := range body.Apps {
|
||
name := x.Name
|
||
if strings.TrimSpace(name) == "" {
|
||
name = x.ID
|
||
}
|
||
apps = append(apps, UIApp{ID: x.ID, Name: name, Icon: x.Icon, HasUI: x.HasUI, Version: x.Version})
|
||
}
|
||
return apps
|
||
}
|
||
|
||
// GetApp 取一個 App 的詳情(自帶畫面/工作流清單/動作白名單)。
|
||
//
|
||
// 🔴 只有 portal session 這一條路——上游的 API key 面**沒有**詳情端點
|
||
//
|
||
// (`/apps` 只有列表;見 `cypher-executor/src/routes/apps.ts`)。
|
||
// 所以沒有 session 時回 NeedsLogin,讓畫面長一次登入框,而不是回一句錯誤。
|
||
func (a *App) GetApp(accIdx int, id string) UIAppDetail {
|
||
out := UIAppDetail{ID: id}
|
||
cfg, _ := loadCfg()
|
||
acc, err := accountAt(cfg, accIdx)
|
||
if err != nil {
|
||
out.Error = err.Error()
|
||
return out
|
||
}
|
||
out.Email = acc.Email
|
||
if !sessionValid(acc) {
|
||
out.NeedsLogin = true
|
||
return out
|
||
}
|
||
base := apiBaseOf(acc)
|
||
req, _ := http.NewRequest(http.MethodGet, base+"/portal/data/apps/"+urlPathEscape(id), nil)
|
||
req.Header.Set("Authorization", "Bearer "+acc.PortalSession)
|
||
status, raw, derr := appDo(req, appReadTimeout)
|
||
if derr != nil {
|
||
out.Error = derr.Error()
|
||
return out
|
||
}
|
||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||
clearSession(accIdx)
|
||
out.NeedsLogin = true
|
||
return out
|
||
}
|
||
if status != http.StatusOK {
|
||
out.Error = appErrMessage(status, raw)
|
||
return out
|
||
}
|
||
var d struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Icon string `json:"icon"`
|
||
Version string `json:"version"`
|
||
HasUI bool `json:"has_ui"`
|
||
UIHtml string `json:"ui_html"`
|
||
Actions []string `json:"actions"`
|
||
Workflows []struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
} `json:"workflows"`
|
||
}
|
||
if json.Unmarshal(raw, &d) != nil {
|
||
out.Error = "這個知識庫回的內容看不懂(版本可能不相容)"
|
||
return out
|
||
}
|
||
out.ID, out.Name, out.Icon, out.Version = d.ID, d.Name, d.Icon, d.Version
|
||
out.HasUI, out.UIHtml, out.Actions = d.HasUI, d.UIHtml, d.Actions
|
||
for _, w := range d.Workflows {
|
||
out.Workflows = append(out.Workflows, UIAppWorkflow{Name: w.Name, Description: w.Description})
|
||
}
|
||
if strings.TrimSpace(out.Name) == "" {
|
||
out.Name = out.ID
|
||
}
|
||
return out
|
||
}
|
||
|
||
// RunAppAction 觸發 App 的一個白名單動作,回傳實例給的結果 JSON(原樣字串)。
|
||
//
|
||
// 🔴 白名單由**實例**裁決,不在這裡複製一份判斷(K6:前端/桌面端都不是裁決點)。
|
||
//
|
||
// 這裡不認得任何 action 名稱,只負責把使用者按的那顆按鈕送出去。
|
||
//
|
||
// payloadJSON 是前端組好的 JSON 物件字串;空字串=沒有 payload。
|
||
func (a *App) RunAppAction(accIdx int, id, action, payloadJSON string) (string, error) {
|
||
cfg, _ := loadCfg()
|
||
acc, err := accountAt(cfg, accIdx)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if strings.TrimSpace(action) == "" {
|
||
return "", errors.New("沒有指定要做什麼")
|
||
}
|
||
if !sessionValid(acc) {
|
||
return "", errors.New("這個知識庫的登入已過期,請在畫面上重新登入一次")
|
||
}
|
||
var payload any = map[string]any{}
|
||
if s := strings.TrimSpace(payloadJSON); s != "" {
|
||
if err := json.Unmarshal([]byte(s), &payload); err != nil {
|
||
return "", errors.New("送出的內容格式不對")
|
||
}
|
||
}
|
||
body, _ := json.Marshal(map[string]any{"action": action, "payload": payload})
|
||
req, err := http.NewRequest(http.MethodPost,
|
||
apiBaseOf(acc)+"/portal/data/apps/"+urlPathEscape(id)+"/action", bytes.NewReader(body))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+acc.PortalSession)
|
||
status, raw, derr := appDo(req, appActionTimeout)
|
||
if derr != nil {
|
||
return "", derr
|
||
}
|
||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||
// 403 也可能是「這個動作不在白名單」——那句話由實例自己講,這裡照抄。
|
||
if status == http.StatusUnauthorized {
|
||
clearSession(accIdx)
|
||
}
|
||
return "", errors.New(appErrMessage(status, raw))
|
||
}
|
||
if status != http.StatusOK {
|
||
return "", errors.New(appErrMessage(status, raw))
|
||
}
|
||
return string(raw), nil
|
||
}
|
||
|
||
// PortalLogin 讓使用者在 App 頁上補一次登入(session 過期時)。
|
||
// email 用 config 裡存的那個(就是他連線時用的帳號),只問密碼。
|
||
func (a *App) PortalLogin(accIdx int, password string) error {
|
||
cfg, _ := loadCfg()
|
||
acc, err := accountAt(cfg, accIdx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if strings.TrimSpace(acc.Email) == "" {
|
||
return errors.New("這個知識庫沒有記錄帳號,請用「新增知識庫帳號」重新連一次")
|
||
}
|
||
token, exp, err := portalLogin(apiBaseOf(acc), acc.Email, password)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return storeSession(accIdx, token, exp)
|
||
}
|
||
|
||
// urlPathEscape:App id 的字元集上游已限死 `^[a-z][a-z0-9_-]{0,63}$`,
|
||
// 但我們是**收方**不是**發方**——照樣 escape,不假設對面永遠守規矩。
|
||
func urlPathEscape(s string) string {
|
||
var b strings.Builder
|
||
for _, r := range s {
|
||
switch {
|
||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||
r == '-', r == '_', r == '.', r == '~':
|
||
b.WriteRune(r)
|
||
default:
|
||
for _, c := range []byte(string(r)) {
|
||
fmt.Fprintf(&b, "%%%02X", c)
|
||
}
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|