依 autoflow-agent workspace v2 設計把 PRD / 設計 / 架構 / 交付類 共享文件從個人層 .autoflow/(ignored)搬到 docs/autoflow/(進 git), 讓團隊可共享產品與架構文件,個人層只留 progress / review / testing 等 per-branch 筆記。 - 02-prd/ 21 個檔(PRD、features、market-analysis 等) - 03-design/ 18 個檔(design-spec、wireframes、flows 等) - 04-architecture/ 31 個檔(TDD、design-doc、ADR×14、API 規格等) - 07-delivery/ 3 個檔(project-summary、phase-0.6-handover、stage-deployment-setup) 合計 73 檔。原檔已從 .autoflow/ 移除(migration 工具執行 git mv, 但因 .autoflow/ 在 .gitignore 中、git 將此操作視為新增、無 rename history)。
32 KiB
Technical Design Document — visionA Cloud
Metadata
- 作者:Architect Agent
- 狀態:Draft(待三方交叉審閱)
- 最後更新:2026-04-22(反映三方交叉審閱 M-1
M-8 + Minor-2Minor-4) - 文件角色:給工程師實作時的技術契約(不是 Test-Driven Development)
- 上位文件:
design-doc.md、adr/adr-*.md - 讀者:Frontend / Backend / DevOps / Testing Agents
雛形關鍵決策(2026-04-22 使用者裁決)
- 雛形即雙 binary:
cmd/api-server+cmd/remote-proxy,不做單進程 all-in-one - 不引入 Redis(POC 也從未用過;查證 go.mod / source / docker 皆無)
- Session state 由
remote-proxy完全持有(in-memory) api-server無狀態,透過 internal HTTP 向remote-proxy查 session / 轉發請求/internal/session/:token、/internal/forward/*在 Phase 0 雛形就必須實作(不是 Phase 1)
- Session state 由
- Local agent 雛形不做:tunnel 測試端暫用 POC
edge-ai-server當 tunnel client - POC 程式碼複製:
internal/relay/internal/tunnel/internal/wsconn從edge-ai-platform複製後獨立演進 - Auth 雛形:
→ 已替換為 OIDC(Phase 0.6 / OB5;接 Innovedus Member Center;詳見 oidc-tdd.md + adr-011)StaticAuthService永遠回demo-user單一使用者 - Session 覆蓋:沿用 POC — 同 token 後連覆蓋前連;Phase 1 再重新設計
- Pairing Token 顯示:API 回傳純 hex,前端顯示層加空格
- 三個 local-tool-only 元件雛形隱藏:
OnboardingDialog、ServerStatusDashboard、ServerLogViewer
索引(本文件為總覽 + 連結)
1. 專案骨架
- 見 §1 專案骨架(本文件)
2. Backend 模組詳細
- 見 §2 Backend 模組詳細(本文件)
3. API 規格
- REST + WebSocket 詳細端點清單 →
api/api-spec.md - 內部 API(api-server ↔ remote-proxy)→
api/api-internal.md
4. 資料模型
- Go struct 定義 + Phase 1 DB schema 對應 →
database.md
5. Pairing Token 協定
- 詳細流程 + 雛形 vs Phase 1 差異 →
security.md
6. Tunnel 協定
- 詳細訊息格式、重連策略 →
tunnel.md
7. Session 管理
SessionStoreinterface + 實作策略 →tunnel.md
8. 儲存層介面
Storeinterface + LocalFS 實作 →storage.md
9. Converter 整合 API 契約
- visionA-backend 呼叫 converter 的 spec(給 converter 團隊)→
api/api-converter-contract.md - Phase 0.8 轉檔功能整合(visionA backend ↔ converter ↔ FAA ↔ MC 流程) →
conversion.md - Phase 0.8 對前端 API →
api/api-conversion.md - 架構決策 →
adr/adr-014-conversion-integration.md
10. 前端資料流與狀態管理
- 見 §10(本文件)
11. 建置與部署
- Makefile + Dockerfile + docker-compose →
build-deploy.md
12. 安全考量
- 完整安全清單 →
security.md
13. 測試策略
- 見 §13(本文件)
14. TODO 清單
- 見 §14(本文件)
§1 專案骨架
1.1 Repo 結構(Monorepo)
visionA/ # 既有 repo 根目錄
├── local-tool/ # 既有,不動
├── visionA-frontend/ # ⬅ 新增
│ ├── src/
│ │ ├── app/ # Next.js App Router
│ │ ├── components/
│ │ ├── stores/ # Zustand
│ │ ├── hooks/
│ │ ├── lib/
│ │ │ ├── api.ts # 改造:base URL = 雲端 API server
│ │ │ ├── auth.ts # stub,未來對接真實 auth
│ │ │ └── ws.ts # WebSocket 連接 util
│ │ ├── types/
│ │ └── i18n/
│ ├── public/
│ ├── package.json
│ ├── next.config.ts
│ ├── tsconfig.json
│ ├── tailwind.config.ts
│ └── .env.example # VISIONA_* / NEXT_PUBLIC_*
│
├── visionA-backend/ # ⬅ 新增
│ ├── go.mod # module "visiona-backend"
│ ├── go.sum
│ ├── Makefile
│ ├── cmd/
│ │ ├── api-server/
│ │ │ └── main.go # REST + WebSocket API(無狀態)
│ │ └── remote-proxy/
│ │ └── main.go # Tunnel server + internal HTTP API(有狀態)
│ │ # 註:雛形交付物就是這兩個 binary + docker-compose。
│ │ # 本機開發可用 `make run-dev` 平行跑(純便利工具,非交付物)。
│ │ # 不做 `cmd/dev-all-in-one`(Q1 裁決 C + design-doc.md §1.9 N10)
│ ├── internal/
│ │ ├── api/ # API handlers(給前端)
│ │ │ ├── handlers/
│ │ │ ├── middleware/
│ │ │ ├── ws/
│ │ │ └── router.go
│ │ ├── auth/ # Auth + Pairing Token
│ │ │ ├── service.go # AuthService interface
│ │ │ ├── static.go # StaticAuthService
│ │ │ ├── pairing.go # PairingStore interface
│ │ │ └── static_pairing.go # StaticPairingStore
│ │ ├── session/ # Tunnel session 管理
│ │ │ ├── store.go # SessionStore interface
│ │ │ ├── inmemory.go
│ │ │ └── types.go
│ │ ├── device/ # Device domain
│ │ │ ├── types.go
│ │ │ └── repository.go # interface + InMemory 實作
│ │ ├── model/
│ │ │ ├── types.go
│ │ │ └── repository.go
│ │ ├── cluster/ # 從 POC 複製(Q2 決策,後續獨立演進)
│ │ ├── relay/ # 從 POC 複製(同上)
│ │ ├── tunnel/ # 從 POC 複製;雛形給測試/未來 agent 用
│ │ ├── converter/ # Converter client(雛形 stub)
│ │ │ ├── client.go # interface
│ │ │ └── stub.go
│ │ ├── storage/ # S3-compat
│ │ │ ├── store.go # Store interface
│ │ │ ├── localfs.go
│ │ │ └── s3.go # 可選,雛形後期加
│ │ ├── wsconn/ # yamux over WebSocket(從 POC 複製)
│ │ ├── config/ # env config
│ │ └── logger/
│ ├── pkg/ # 對外可重用
│ │ └── protocol/ # 共用的 types(request/response schema)
│ ├── docker/
│ │ ├── Dockerfile.api-server
│ │ ├── Dockerfile.remote-proxy
│ │ └── docker-compose.yml
│ ├── scripts/
│ │ └── dev.sh
│ └── .env.example
│
├── docs/ # 共用文件(選配)
├── .autoflow/ # 文件管理(本文件所在)
└── README.md
1.2 Go module
module visiona-backend(簡短、不含 github 路徑,避免未來搬 repo 要改)- Go 1.26(與 POC 一致)
- 主要依賴:
github.com/gin-gonic/gin
github.com/gorilla/websocket
github.com/hashicorp/yamux
github.com/go-playground/validator/v10
github.com/google/uuid
github.com/aws/aws-sdk-go-v2 (可選)
1.3 Frontend Node
- Node >= 20
- npm / pnpm(沿用 local-tool 偏好)
- 主要依賴沿用 local-tool:Next.js 16、React 19、TypeScript 5、Tailwind 4、Radix UI、Zustand 5、Lucide、Recharts、driver.js、Vitest + RTL
§2 Backend 模組詳細
2.1 internal/api(api-server 用)
| 檔案 | 職責 |
|---|---|
router.go |
Gin router 組裝 |
middleware/auth.go |
從 request 解析身分並注入 UserContext 到 gin.Context |
middleware/cors.go |
CORS(沿用 local-tool) |
middleware/logger.go |
broadcaster logger(沿用 local-tool) |
handlers/device.go |
/api/devices/* — 大多轉發到 local agent |
handlers/model.go |
/api/models/* — 雲端存模型 + 轉發到 local agent 載入 |
handlers/cluster.go |
/api/clusters/* — 從 POC 搬 |
handlers/inference.go |
/api/devices/:id/inference/* |
handlers/pairing.go |
/api/pairing/* |
handlers/auth.go |
/api/auth/*(雛形 501) |
handlers/converter.go |
/api/converter/*(雛形 stub) |
handlers/storage.go |
/storage/*(雛形 LocalFS 代理) |
ws/devices.go, ws/inference.go 等 |
WebSocket upgrade + 轉發到 tunnel |
forward.go |
核心:把瀏覽器請求透過 tunnel 送到 local agent(參考 POC handleProxy) |
2.2 internal/auth(2026-04-22 M-3 修訂:介面雙層 AuthService + AuthProvider)
Auth 切分為兩層 interface,對應不同呼叫場景(詳見 security.md §2.0):
| 介面 | 層級 | 檔案 |
|---|---|---|
AuthService |
Middleware 層(每個 request 進來時解析身分) | service.go |
AuthProvider |
Handler 層(登入 / 註冊 / 登出 / token 驗證) | provider.go |
// service.go(middleware 層 — 既有)
package auth
type AuthService interface {
Authenticate(ctx context.Context, req *http.Request) (*UserContext, error)
Authorize(ctx context.Context, userCtx *UserContext, resource, action string) error
}
type UserContext struct {
UserID string
Email string
Roles []string
OrgID string
}
// static.go — 雛形(⚠️ OB5 已移除;以下程式碼僅作歷史紀錄)
// 取代為 OIDC + cookie session,見 oidc-tdd.md §4
type StaticAuthService struct {
DefaultUser UserContext
}
func (s *StaticAuthService) Authenticate(...) { return &s.DefaultUser, nil }
func (s *StaticAuthService) Authorize(...) { return nil }
// provider.go(handler 層 — 2026-04-22 新增)
package auth
import "time"
type AuthProvider interface {
Register(ctx context.Context, req *RegisterRequest) (*User, error)
Login(ctx context.Context, req *LoginRequest) (*LoginResult, error)
Logout(ctx context.Context, token string) error
ValidateToken(ctx context.Context, token string) (*UserContext, error)
GetUser(ctx context.Context, userID string) (*User, error)
}
type LoginRequest struct { Email, Password string }
type RegisterRequest struct { Email, Password, Name string }
type LoginResult struct {
User *User
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
// static_provider.go — 雛形(⚠️ OB5 已移除;以下程式碼僅作歷史紀錄)
// 取代為 OIDC + cookie session,見 oidc-tdd.md §4
type StaticAuthProvider struct {
DemoUser *User // 預設 demo-user,User struct 見 database.md §2.1
}
func (p *StaticAuthProvider) Login(ctx context.Context, req *LoginRequest) (*LoginResult, error) {
// 無論 email / password 是什麼都通過
return &LoginResult{
User: p.DemoUser,
AccessToken: "demo-access-token",
RefreshToken: "demo-refresh-token",
ExpiresAt: time.Now().Add(24 * time.Hour),
}, nil
}
func (p *StaticAuthProvider) Register(ctx, req) (*User, error) { return nil, ErrNotImplemented }
func (p *StaticAuthProvider) Logout(ctx, token) error { return nil }
func (p *StaticAuthProvider) ValidateToken(ctx, token) (*UserContext, error) {
if token == "demo-access-token" {
return &UserContext{UserID: p.DemoUser.ID, Email: p.DemoUser.Email}, nil
}
return nil, ErrInvalidToken
}
func (p *StaticAuthProvider) GetUser(ctx, userID) (*User, error) {
if userID == p.DemoUser.ID { return p.DemoUser, nil }
return nil, ErrNotFound
}
// pairing.go(不變)
type PairingStore interface {
Validate(ctx context.Context, token string) (*PairingInfo, error)
MarkUsed(ctx context.Context, token string, deviceID string) error
Create(ctx context.Context, userID string, ttl time.Duration) (plain string, info *PairingInfo, err error)
Revoke(ctx context.Context, token string) error
List(ctx context.Context, userID string) ([]*PairingInfo, error)
}
type PairingInfo struct {
TokenHash string
UserID string
DeviceID string
CreatedAt time.Time
ExpiresAt *time.Time
UsedAt *time.Time
RevokedAt *time.Time
}
// static_pairing.go — 雛形
type StaticPairingStore struct {
Token string // 格式:vAc_[0-9a-f]{32}(見 security.md §1.3)
UserID string
}
func (s *StaticPairingStore) Validate(ctx, token) (*PairingInfo, error) {
if token != s.Token { return nil, ErrInvalidToken }
return &PairingInfo{UserID: s.UserID, TokenHash: sha256hex(token)}, nil
}
func (s *StaticPairingStore) Create(...) (..., error) { return "", nil, ErrNotImplemented }
雛形實作:
→ OB5 已移除,AuthMiddleware 只走 OIDC(cookie session → UserContext),詳見 oidc-tdd.mdStaticAuthService(middleware)永遠回 demo-user→ OB5 已移除;POST /api/auth/login 一律 410 Gone,使用者改用 GET /api/auth/login redirect 到 Member CenterStaticAuthProvider(handler)Login 任何帳密都通過StaticPairingStore對 envVISIONA_PAIRING_TOKEN比對(格式必須是vAc_+ 32 hex)— 仍有效
已完成(OB1-OB5):OIDC 接 Innovedus Member Center 取代 StaticAuth。
未來擴展:PostgresPairingStore、Redis-backed user session store(取代 in-memory)、Member Center webhook 接收 user 刪除/停用通知。
2.2.1 Pairing → Session Token 時序(M-2)
Phase 1 兩階段 token 完整流程見 security.md §1.2 與 adr/adr-003-pairing-token.md。關鍵原則:
- Pairing Token(
vAc_+ 32 hex):Web UI 產生,15 min TTL,一次性使用 - Session Token(
vAs_+ 64 hex):agent 首次連 tunnel 時由 remote-proxy 換發,90 天 TTL,可撤銷 - 換發發生在
WS /tunnel/connect的 upgrade 階段(一次 DB transaction 完成 insert session + mark pairing used) - local agent 從首個 yamux control frame 收到 Session Token 後持久化儲存,之後一律用 Session Token
雛形階段為簡化:只用 Pairing Token 單階段比對,無 TTL、無升級、無撤銷,僅適合 dev。
2.3 internal/session
參見 tunnel.md。核心 interface(2026-04-22 Minor-4 新增 CleanupExpired):
type SessionStore interface {
Register(ctx context.Context, token string, sess SessionHandle) error
Unregister(ctx context.Context, token string) error
Lookup(ctx context.Context, token string) (SessionHandle, error)
Exists(ctx context.Context, token string) (bool, error)
List(ctx context.Context) ([]SessionSummary, error)
Heartbeat(ctx context.Context, token string) error
// 清除 LastSeenAt 超過 expireAfter 的 session。
// 由 remote-proxy background goroutine 每 30s 呼叫。
CleanupExpired(ctx context.Context, expireAfter time.Duration) (removed int, err error)
}
type SessionHandle interface {
// remote-proxy 本地:LocalHandle wrap *yamux.Session
// api-server 遠端查詢:RemoteHandle wrap internal HTTP client
OpenStream(ctx context.Context) (net.Conn, error)
Close() error
IsClosed() bool
}
雛形部署(不用 Redis):
remote-proxy持有 唯一的InMemoryStore(session 實體就在這個進程的記憶體)api-server不持有 session state;要查 session 時,透過 internal HTTP 呼叫remote-proxy:GET /internal/session/:token— 確認 session 是否在線POST /internal/forward/http/GET /internal/forward/ws— 轉發請求
- 在
api-server端,SessionStore的實作是ProxyClientStore(wrap HTTP client,呼叫remote-proxy) - 在
remote-proxy端,SessionStore的實作是InMemoryStore(真正持有*yamux.Session)
Phase 1 多節點時再評估:多個 remote-proxy 節點間如何共享 session metadata(可能 Redis、可能 gossip、可能 service discovery)。雛形不預設方案。參見 ADR-006。
2.4 internal/device, internal/model, internal/cluster
- types.go:Go struct(見
database.md) - repository.go:interface + InMemory 實作
- 雛形不做 ORM,InMemory 用
map + sync.RWMutex - Phase 1:新增
postgres_repository.go實作同 interface
2.5 internal/relay
- 從 POC
edge-ai-platform/server/internal/relay/server.go搬過來 - 修改:
- 把
sessions map[string]*yamux.Session抽到SessionStore handleTunnel先呼叫PairingStore.Validate(token)驗證後才接受handleProxy從SessionStore.Lookup(token)取 session(而非 map 直接取)
- 把
- 不修改的部分:
proxyWebSocket的 Hijack 邏輯isWebSocketUpgrade判斷- MJPEG streaming 的
http.Flusher處理
2.6 internal/tunnel
- 從 POC
edge-ai-platform/server/internal/tunnel/client.go搬 - 在雛形中這個模組主要是給測試 / 未來 headless tunnel-only agent 用
- local-tool 既有的雲端模式 opt-in 也會 import 這個 package(未來)
- POC code 不大改;把
localAddr等參數化
2.7 internal/converter
// client.go
type Client interface {
SubmitConvert(ctx context.Context, req *ConvertRequest) (jobID string, err error)
GetJob(ctx context.Context, jobID string) (*Job, error)
ListJobs(ctx context.Context, userID string) ([]*Job, error)
DownloadResult(ctx context.Context, jobID string) (io.ReadCloser, error)
}
// stub.go — 雛形
type StubClient struct{}
func (s *StubClient) SubmitConvert(...) (string, error) {
return "stub-job-" + uuid.NewString(), nil
}
func (s *StubClient) GetJob(ctx, id) (*Job, error) {
return &Job{ID: id, Status: "queued"}, nil
}
詳細 API 契約見 api/api-converter-contract.md。
2.8 internal/storage
見 storage.md。
2.9 internal/wsconn
- 從 POC
server/pkg/wsconn/wsconn.go搬,無修改 - 作為
net.Connadapter,讓 yamux 能在 WebSocket 上運作
2.10 internal/config
type Config struct {
APIServer struct {
Port int `env:"VISIONA_API_PORT" default:"3001"`
}
RemoteProxy struct {
TunnelPort int `env:"VISIONA_TUNNEL_PORT" default:"3800"`
InternalPort int `env:"VISIONA_PROXY_INTERNAL_PORT" default:"3801"`
}
Auth struct {
Mode string `env:"VISIONA_AUTH_MODE" default:"static"` // static / oidc / ...
// static mode:
StaticUserID string `env:"VISIONA_STATIC_USER_ID" default:"demo-user"`
}
Pairing struct {
Mode string `env:"VISIONA_PAIRING_MODE" default:"static"` // static / db
Token string `env:"VISIONA_PAIRING_TOKEN"` // for static mode
}
Session struct {
// remote-proxy 端永遠是 "inmemory"(它擁有實體 yamux session)
// api-server 端永遠是 "proxy-client"(透過 internal HTTP 查 remote-proxy)
Backend string `env:"VISIONA_SESSION_BACKEND" default:"inmemory"` // inmemory / proxy-client
// api-server 要知道 remote-proxy 的 internal HTTP 位址
ProxyInternalURL string `env:"VISIONA_PROXY_INTERNAL_URL" default:"http://localhost:3801"`
}
Storage struct {
Backend string `env:"VISIONA_STORAGE_BACKEND" default:"localfs"` // localfs / s3
LocalFSRoot string `env:"VISIONA_STORAGE_LOCALFS_ROOT" default:"./data/storage"`
LocalFSBaseURL string `env:"VISIONA_STORAGE_LOCALFS_BASE_URL" default:"http://localhost:3001/storage"`
S3Endpoint string `env:"VISIONA_S3_ENDPOINT"`
S3Bucket string `env:"VISIONA_S3_BUCKET"`
S3Region string `env:"VISIONA_S3_REGION"`
S3AccessKey string `env:"VISIONA_S3_ACCESS_KEY"`
S3SecretKey string `env:"VISIONA_S3_SECRET_KEY"`
}
Converter struct {
Mode string `env:"VISIONA_CONVERTER_MODE" default:"stub"` // stub / real
URL string `env:"VISIONA_CONVERTER_URL"`
}
}
12-Factor:全部走 env;雛形 .env.example 提供範本。
§10 前端資料流與狀態管理
10.1 Zustand Stores
從 local-tool 搬來:model-store、device-preferences-store、inference-store、camera-store、flash-store、activity-store、settings-store、tour-store。
新增:
auth-store:user,token,login(),logout()(雛形 stub)session-store:pairingToken,tunnelStatus,reconnect()
10.2 API Client 改造(src/lib/api.ts)
// 雛形:base URL 從環境變數讀,不再 hardcode localhost:3721
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'http://localhost:3001';
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const token = useAuthStore.getState().token;
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...init?.headers,
},
credentials: 'include', // for cookie-based auth future
});
if (!res.ok) throw new ApiError(res);
return res.json();
}
移除:local-tool 原本的 X-Relay-Token header 雲端模式鉤子(雲端版不再需要,authtoken 是 user token,pairing 是 backend 內部事)。
10.3 WebSocket Hook 改造(src/hooks/use-websocket.ts)
const WS_BASE = (process.env.NEXT_PUBLIC_API_BASE ?? 'http://localhost:3001')
.replace(/^http/, 'ws');
export function useWebSocket(path: string) {
// 連 wss://api.visiona.cloud/ws/xxx
// Auth 透過 first-message 或 cookie 附帶
// ...
}
10.4 Auth 流程 stub
// src/lib/auth.ts
export const authClient = {
async login(email: string, password: string) {
// 雛形:後端回 501,前端捕獲顯示「即將推出」
return apiFetch('/api/auth/login', { method: 'POST', body: JSON.stringify({email, password}) });
},
async register(...) { /* 501 */ },
async logout() { useAuthStore.getState().clear(); },
};
10.5 Pairing 流程 UI
新頁面 /devices/pair:
- 頁面顯示「請在本機 local-tool 輸入以下 Pairing Token」
- 從 env
NEXT_PUBLIC_DEV_PAIRING_TOKEN(雛形)或POST /api/pairing/token(Phase 1)取得 token 並顯示 - 右側顯示即時連線狀態(訂閱
GET /api/pairing/status或 WS/ws/pairing/status)
Token 顯示格式(Q7 裁決):
- API 回傳值永遠是純 hex / base64url(無空格、無分隔符)— 例:
pk_a1b2c3d4e5f6g7h8... - 前端顯示層為了可讀性,每 8 個字元插入空格 — 例:
pk_a1b2c3d4 e5f6g7h8 ... - 使用者複製時:UI 提供「Copy」按鈕,複製的是原始無空格 token
- 使用者貼上時:前端允許任何空白字元,送出前正規化(
.replace(/\s/g, '')) - 這保證 API 契約乾淨,同時 UI 可讀性好
10.5.1 Device 雙狀態消費(2026-04-22 Minor-3)
Device struct 現在有兩組狀態欄位(見 database.md §2.2):
interface Device {
id: string;
name: string;
deviceType: string;
// tunnel-level(雲端觀察)
remoteStatus: 'online' | 'offline' | 'reconnecting' | 'error';
lastSeenAt?: string; // ISO 8601
lastConnectedAt?: string;
// USB-level(local agent 上報)
status: 'online' | 'offline' | 'unknown';
// ...
}
前端顯示邏輯:
| 畫面位置 | 顯示依據 |
|---|---|
| Device 卡片主狀態 badge | remoteStatus(決定「雲端能不能操作它」) |
| Device 卡片副狀態 | status(USB 是否插著)— 僅在 remoteStatus === 'online' 時顯示有意義 |
| 「最後上線」時間 | lastSeenAt(tunnel 最後心跳) |
| Inference / Flash 按鈕啟用條件 | remoteStatus === 'online' && status === 'online' |
| 「重連中」loading | remoteStatus === 'reconnecting' |
| 「離線,請檢查 local-tool」提示 | remoteStatus === 'offline' |
邏輯總結:前端永遠先看 remoteStatus(雲端是否可達),再看 status(USB 是否接著)。兩者獨立更新,前端要訂閱兩種事件:
remoteStatus變化 →WS /ws/devices/:id/remote-status(來自 remote-proxy 轉發)status變化 →WS /ws/devices/:id/status(來自 local agent 的 USB 監聽事件)
10.6 local-tool-only 元件:雛形隱藏(Q6 裁決)
以下元件在 local-tool 有意義(它就是本機 server),在 visionA Cloud 無意義。雛形階段前端隱藏:
| 元件 | 原因 |
|---|---|
OnboardingDialog |
local-tool 首次啟動引導本機 server 設定;雲端版不需要 |
ServerStatusDashboard |
顯示本機 server 健康(port / process);雲端版換成 tunnel 連線狀態 |
ServerLogViewer |
顯示本機 server log;雲端版換成 device log(透過 tunnel 抓) |
實作方式:
- 沿用 local-tool 同名元件但用 feature flag 控制:
NEXT_PUBLIC_APP_MODE=cloud時不掛載 - Phase 1 視需求決定要替換、重寫,還是徹底移除
§13 測試策略
13.1 雛形期
| 類型 | 範圍 | 工具 |
|---|---|---|
| Unit | internal/* 各 package |
go test |
| Integration | api-server + remote-proxy(兩 binary)+ stub tunnel client | go test + httptest |
| E2E | 瀏覽器 → api-server → remote-proxy ← POC edge-ai-server(tunnel client) | 手動 + 簡易 Playwright |
雛形端對端驗證路徑(Q3 決策):
┌────────────────────┐ HTTPS ┌────────────────┐ internal HTTP ┌────────────────┐
│ visionA-frontend │ ──────────────► │ api-server │ ──────────────► │ remote-proxy │
│ (browser) │ │ (stateless) │ ◄────────────── │ (stateful) │
└────────────────────┘ └────────────────┘ └────────┬───────┘
│ WS + yamux
▼
┌────────────────┐
│ POC │
│ edge-ai-server │
│ (暫代 tunnel │
│ client) │
└────────────────┘
雛形 不實作 visionA/local-agent/。未來(Phase 1)建立時將從 local-tool/ 複製起步。
雛形 MVP 覆蓋目標:
internal/auth,internal/session(含InMemoryStore+ProxyClientStore),internal/storage(LocalFS):80% 行覆蓋internal/relay(tunnel accept + internal forward)、internal/tunnel:整合測試為主- api handlers:至少 happy path + error path
- Internal HTTP API(
/internal/session/:token、/internal/forward/*):雛形必須有整合測試
13.2 Phase 1
- E2E:Playwright 跑主要使用者旅程
- 壓測:vegeta 打 api-server;自製 tunnel client 模擬 N 條連線打 remote-proxy
- 混沌測試:隨機斷開 tunnel 驗證重連
§14 TODO 清單(雛形暫不實作,但已記錄)
Auth / Security
- 真實 Auth(OIDC / 自建)
- JWT / refresh token 機制
/tunnel/connectIP + token rate limit- Pairing Token DB-backed 實作(
PostgresPairingStore) - 兩階段 token(pairing short-lived + session long-lived)
- Audit log
- TLS 終止策略決定(ALB / Caddy / nginx)
Data / Persistence
- PostgreSQL + migration 機制(golang-migrate)
- 所有
InMemory*Repository→Postgres*Repository實作 - 資料備援策略
- Soft delete 行為標準化(
deleted_at)
Storage
S3Store實作 + interface conformance tests- Multipart upload for > 100MB 檔案
- Server-side encryption
Scaling(Phase 1)
- 多
remote-proxy節點間的 session metadata 共享機制(評估 Redis / gossip / service discovery — 雛形不預設方案;見 ADR-006) - api-server → proxy 跨節點路由邏輯(當一個 api-server 面對多個 proxy 節點時,需找到 session 所在節點)
- mTLS / shared secret 保護 internal HTTP endpoints(目前僅靠內網隔離)
雛形 Phase 0 必做(為清楚起見明列)
- 雙 binary 骨架:
cmd/api-server+cmd/remote-proxy api-server端ProxyClientStore(SessionStore 透過 internal HTTP 查 remote-proxy)remote-proxy端InMemoryStore+ internal HTTP endpoints(/internal/session/:token、/internal/forward/http、/internal/forward/ws)
Converter 整合
- 確認真實 converter API spec 後,替換
StubClient→HTTPClient - Job queue / webhook 機制(等 converter 團隊確認)
Observability
- 結構化 log (JSON)
- Prometheus metrics export
- OpenTelemetry trace
- SLO dashboards
- Alert rules
Local Agent
- 建立
visionA/local-agent/:從local-tool/複製起步,精簡為 tunnel-only 輕量代理(Q3 決策) - local-tool 的「雲端模式」opt-in 設定 UI(另一條線:讓既有 local-tool 也能當 tunnel client)
- 輕量 tunnel-only headless agent binary(未來針對 server / Linux 使用者)
- local agent 自動更新機制(搬 POC 的
update/?)
雛形 tunnel 驗證(過渡方案,Q3 裁決)
- 雛形 tunnel 驗證暫用 POC 的
edge-ai-server(來自edge-ai-platform)當 tunnel client - 端對端測試路徑:
visionA-frontend → api-server → remote-proxy ← POC edge-ai-server - 待
visionA/local-agent/建立後替換
DevOps
- CI pipeline(lint, test, build, push)
- Kubernetes Helm chart / Terraform module
- Staging 環境
- Blue/green or canary deploy
- 備份與災難恢復演練
Product
- Billing / 訂閱(PRD 範疇)
- 使用量統計
- 多組織(Org / Team)支援
- API key for programmatic access
待 PM / Design 確認
- pairing token UX 細節(QR code?複製貼上?WebUSB?)
- 未連線時前端呈現方式
- 模型上傳上限(容量 / 個數)
- 裝置離線時「離線模式」fallback 設計
版本記錄
| 日期 | 版本 | 變更 |
|---|---|---|
| 2026-04-21 | 0.1 | Architect Agent 初稿(待三方交叉審閱) |
| 2026-04-22 | 0.2 | 反映三方審閱 7 項使用者裁決 + ADR-006 |
| 2026-04-22 | 0.3 | 反映三方交叉審閱結果:M-1 Token 格式統一 vAc_/vAs_ hex、M-2 Token 兩階段 TTL 明確化 + 時序圖、M-3 Auth 介面雙層(AuthService + AuthProvider)、M-4 Converter API 確認 REST、M-5 心跳 10s/30s 統一、M-7 Q5 vs Multi-tab 澄清、M-8 Non-Goal 章節、Minor-2 dev-all-in-one 定位修正、Minor-3 Device 雙狀態 struct、Minor-4 SessionStore.CleanupExpired + ObjectStorage.Exists |