chore(autoflow): migrate .autoflow/ 共享層文件至 docs/autoflow/

依 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)。
This commit is contained in:
jim800121chen 2026-05-04 16:55:55 +08:00
parent 7575d4f8ee
commit fb7da5d180
73 changed files with 21721 additions and 0 deletions

151
docs/autoflow/02-prd/PRD.md Normal file
View File

@ -0,0 +1,151 @@
# visionA Cloud — 產品需求文件PRD索引
| 項目 | 內容 |
|------|------|
| 產品名稱 | visionA Cloud |
| 產品代號 | visionA-frontend / visionA-backend |
| 文件版本 | v0.1Phase 0 雛形規劃) |
| 最後更新 | 2026-04-21 |
| 狀態 | 三方聯合討論中PM / Design / Architect 平行) |
| 主要負責人 | PM Agent |
| 相關專案 | `local-tool/`(離線版,不動)、`edge-ai-platform`POC要轉正 |
---
## 文件結構
本 PRD 採用模組化結構PRD.md 為索引檔,各章節拆成獨立子檔案。
```
.autoflow/02-prd/
├── PRD.md ← 本檔(索引 + 各章節一句話摘要)
├── strategy.md ← 第 1 章:產品策略
├── product-positioning.md ← 第 2 章產品定位visionA Cloud vs local-tool vs POC
├── market-analysis.md ← 第 3 章:市場分析
├── user-research.md ← 第 4 章用戶研究Persona、旅程
├── user-stories.md ← 第 5 章User StoriesRICE 排序)
├── features/ ← 第 6 章:功能規格(按功能拆分)
│ ├── feature-device-management.md
│ ├── feature-model-management.md
│ ├── feature-inference.md
│ ├── feature-pairing.md ← 新增Pairing 流程
│ ├── feature-cluster-inference.md
│ ├── feature-dashboard.md
│ ├── feature-workspace.md
│ ├── feature-auth.md ← 雛形 TODO
│ ├── feature-converter-integration.md ← 轉檔整合 TODO
│ └── feature-billing.md ← 未來 TODO
├── nonfunctional.md ← 第 7 章:非功能性需求
├── interface-contracts.md ← 第 8 章:介面契約(重要,給 converter 團隊等)
├── success-metrics.md ← 第 9 章:成功指標
├── roadmap.md ← 第 10 章開發範圍與階段Phase 0 / 1 / 2
└── risks.md ← 第 11 章:風險與相依
```
---
## 章節摘要
### [1. 產品策略](strategy.md)
visionA Cloud 是把 edge-ai-platform POC 升格的正式雲端產品,定位為「**Kneron 邊緣 AI 裝置的雲端操作平台**」,讓開發者透過瀏覽器遠端操作自己筆電 / 現場機上的 Kneron 裝置KL520 / KL720支援單裝置與多裝置叢集推論。與 local-tool離線版共享相同 UI 與核心功能,差別在前端連的是雲端 API而非 localhost。
**核心價值主張**:「不用打開筆電,也能管你的 Kneron 裝置。」
**目標受眾**Kneron FAE內部、Kneron 生態系開發者(外部)、做 PoC 的系統整合商。
### [2. 產品定位](product-positioning.md)
釐清 visionA Cloud 在 Innovedus 產品線中的位置:
- **local-tool**(離線版):現場 demo、網路鎖死的客戶場景 → **完全不動**
- **visionA Cloud**(本專案):遠端協作、多人共用、長駐叢集 → **Phase 0 雛形**
- **edge-ai-platform**POC已完成歷史使命逐步 deprecate
兩種模式用「同一套前端」加上不同 API base URL 決定,不做模式切換 UI。
### [3. 市場分析](market-analysis.md)
邊緣 AI 開發工具市場簡要分析,競品如 NVIDIA Triton Inference Server、Edge Impulse Studio、SenseCraft AI、AWS IoT Greengrass。差異化聚焦「**Kneron 專用 + 雲端遠端存取 + 低延遲 tunnel + 叢集推論**」的組合,這是現有競品都沒有單一提供的。
### [4. 用戶研究](user-research.md)
主要 Persona
1. **阿哲 — Kneron 客戶 FAE**:常出差做 demo需要遠端查看現場客戶機台的推論狀態。
2. **Sarah — SI 系統整合商**:在多個客戶端佈署 Kneron 裝置,需要統一介面管理。
3. **Mike — AI 應用開發者**:寫應用 A/B test要叢集跑多裝置比對效能。
用戶旅程涵蓋「認知 → 註冊 → Pairing → 首次推論 → 日常使用」。
### [5. User StoriesRICE 排序)](user-stories.md)
所有 User Story 按 RICE 評分排序P0雛形必做、P1Phase 1、P2未來。重點 stories
- **作為開發者**,我要在瀏覽器註冊登入,看到我名下所有裝置。
- **作為開發者**,我要把我筆電上的 local agent 配對到 visionA Cloud 帳號。
- **作為 FAE**,我要選一個遠端裝置 + 模型 + 來源,按「開始」就看到即時推論結果。
- **作為 SI**,我要把多個裝置組叢集,做加權 round-robin 推論。
### [6. 功能規格](features/)
按功能拆成獨立檔,每個功能含描述、使用者行為、驗收條件:
| 功能 | 優先級 | 檔案 | 狀態 |
|------|--------|------|------|
| 裝置管理 | P0 | [feature-device-management.md](features/feature-device-management.md) | 搬自 local-tool走 remote-proxy |
| 模型管理 | P0 | [feature-model-management.md](features/feature-model-management.md) | 7 預設 + 上傳,走 S3 介面 |
| 推論操作 | P0 | [feature-inference.md](features/feature-inference.md) | Camera / Image / Video / Batch |
| Pairing 流程 | P0 | [feature-pairing.md](features/feature-pairing.md) | 新增,取代 POC 的 MAC 寫死 |
| 工作區 | P0 | [feature-workspace.md](features/feature-workspace.md) | 裝置 → 模型 → 來源 |
| 叢集推論 | P1 | [feature-cluster-inference.md](features/feature-cluster-inference.md) | 從 POC 搬,加權 RR |
| 儀表板 | P1 | [feature-dashboard.md](features/feature-dashboard.md) | 搬自 local-tool |
| 會員系統 | P2TODO | [feature-auth.md](features/feature-auth.md) | 雛形只定介面 |
| 轉檔整合 | P0Phase 0.8 MVP | [feature-converter-integration.md](features/feature-converter-integration.md) | upload→轉檔→半自動處理converter / FAA / MC 已就緒 |
| Billing | P2TODO | [feature-billing.md](features/feature-billing.md) | 未來 |
### [7. 非功能性需求](nonfunctional.md)
效能推論延遲、API RT、tunnel throughput、安全pairing token 生命週期、傳輸加密、隔離、可擴展性Session 狀態管理、無狀態 API Server、可用性降級策略、離線覆蓋、可觀測性metrics / log
### [8. 介面契約Interface Contracts](interface-contracts.md)
**本章節特別重要**,因為 Phase 0 有很多 TODO介面契約是跨團隊合作的合約
- **AuthProvider 介面**(給未來會員系統填血)
- **kneron_model_converter API 契約**visionA-backend 作為 client反向定義
- **ObjectStorage 介面**S3-compatible雛形用 local filesystem 實作)
- **SessionStore 介面**(雛形 in-memory未來 Redis
- **BillingProvider 介面**(未來)
### [9. 成功指標](success-metrics.md)
Phase 0 雛形指標跑得動、Pairing 成功率、推論端到端延遲、Phase 1 MVP 指標WAU、Pairing conversion、首次推論時間、長期北極星指標每週推論次數 per user
### [10. 開發範圍與階段](roadmap.md)
- **Phase 0本次雛形**建骨架、跑得動、介面清楚、Auth/DB/Storage 用 stub
- **Phase 1**(未來 1-2 季):接真 Auth、接真 DB、接真 Storage
- **Phase 2**(未來 2-4 季轉檔整合、Billing、多區域部署
### [11. 風險與相依](risks.md)
- 和 kneron_model_converter 團隊的協作節奏
- 不能改壞 local-tool 的風險
- tunnel 在企業網路 NAT / Proxy / 防火牆的穿透風險
- Pairing token 洩漏的安全風險
---
## 使用說明(給其他 Agent
**Design Agent、Architect Agent**
- 需要全貌時讀本索引檔
- 需要細節時讀對應子檔案
- 不要所有檔案一次讀,會爆 context
**Orchestrator**
- 追蹤 TODO 清單時讀 `roadmap.md``interface-contracts.md`
- 追蹤 Phase 0 驗收時讀各 `feature-*.md` 的「驗收條件」段落

View File

@ -0,0 +1,200 @@
# Feature會員系統P0 介面;實作 TODO → Phase 1
> 父文件:[PRD.md](../PRD.md) | 對應 User StoriesUS-01、US-02、US-13、US-22、US-TODO-01、US-TODO-02、US-TODO-03
>
> **⚠️ 重要**:本功能**只在 Phase 0 定義介面****不實作真實 Auth**。
> Phase 0 的目標是讓雛形「看起來像有 Auth」前端頁面有、後端 API 有、存個假的 session cookie但 token 不驗簽、user 不落 DB。
> Phase 1 再換成真實 AuthJWT / OAuth + DB
---
## 範圍說明
| Phase 0 做 | Phase 1 做 |
|-----------|-----------|
| 登入頁 / 註冊頁 UI | 真實 Auth 後端 |
| POST /api/auth/login / register 的 stub handler | JWT 簽發 / 驗證 |
| 簡易 session cookiein-memory | Refresh token rotation |
| 個人設定頁 UI 骨架 | 密碼重設流程 |
| 登出功能(清 cookie | Email 驗證 |
| — | OAuthGoogle / GitHub |
| — | 2FA |
| — | 權限 / Role 系統 |
---
## 使用者行為Phase 0
### 登入頁(`/login`
- Email + 密碼輸入
- 「登入」按鈕
- 「還沒有帳號?註冊」連結
- Phase 1忘記密碼連結、OAuth 按鈕
### 註冊頁(`/register`
- Email + 密碼 + 確認密碼
- 「建立帳號」按鈕
- Phase 0submit 後直接登入in-memory 記一個 user
- Phase 1寄 email 驗證 + 存 DB
### 個人設定頁(`/account`
Phase 0只做 UI 骨架,顯示 user email + 登出按鈕。
Phase 1 加:
- 密碼變更
- Email 變更
- 頭像上傳
- 刪除帳號
- 2FA 設定
- 已連結的 OAuth 提供者
- API Key 管理
### 登出
- 清除前端 token / cookie
- 導回 `/login`
---
## 技術細節(給 Architect 參考)
### AuthProvider 介面
Phase 0 要定義清楚介面Phase 1 直接換實作。
```go
// internal/auth/provider.go
type AuthProvider interface {
Register(ctx context.Context, email, password string) (*User, error)
Login(ctx context.Context, email, password string) (*Session, error)
ValidateToken(ctx context.Context, token string) (*User, error)
Logout(ctx context.Context, token string) error
// Phase 1
// RefreshToken(ctx context.Context, refreshToken string) (*Session, error)
// RequestPasswordReset(ctx context.Context, email string) error
// ConfirmPasswordReset(ctx context.Context, token, newPassword string) error
}
type User struct {
ID string
Email string
// Phase 1更多欄位Name、Avatar、Created、...
}
type Session struct {
Token string
UserID string
ExpiresAt time.Time
// Phase 1RefreshToken string
}
```
**Phase 0 實作:`StubAuthProvider`**
- 記憶體 `map[string]*User`email → user
- Session token = 隨機 32 字元(不簽、不驗)
- 註冊與登入不做任何安全檢查(接受任何密碼)
- 目的只是**讓前端能測整個流程**,不是真安全
**Phase 1 實作:`JWTAuthProvider`**
- bcrypt 存密碼
- JWT 簽發(有 secret
- User 存 PostgreSQL
- Refresh Token rotation
- Rate limiting
### Auth Middleware
api-server 的所有需要登入的 endpoint 都過 middleware
```go
func RequireAuth(provider AuthProvider) gin.HandlerFunc {
return func(c *gin.Context) {
token := extractToken(c) // from cookie or Authorization header
user, err := provider.ValidateToken(c.Request.Context(), token)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
return
}
c.Set("user", user)
c.Next()
}
}
```
Phase 0 的 middleware 一樣跑,只是 `provider` 是 stub。
---
## API 端點Phase 0
| Method | Path | 說明 |
|--------|------|------|
| POST | `/api/auth/register` | 註冊stub|
| POST | `/api/auth/login` | 登入stub|
| POST | `/api/auth/logout` | 登出 |
| GET | `/api/auth/me` | 取得當前 user 資訊 |
Phase 1 加:
| Method | Path | 說明 |
|--------|------|------|
| POST | `/api/auth/refresh` | Refresh token |
| POST | `/api/auth/password/reset` | 請求密碼重設 |
| POST | `/api/auth/password/confirm` | 確認密碼重設 |
| POST | `/api/auth/oauth/{provider}` | OAuth callback |
| DELETE | `/api/account` | 刪除帳號 |
---
## 驗收條件Phase 0
- [ ] `/login``/register``/account` 三個頁面可打開
- [ ] 註冊 → 自動登入 → 跳轉到 `/`
- [ ] 已登入狀態下訪問 `/login` → 自動跳 `/`
- [ ] 未登入狀態下訪問需登入頁面(`/``/devices` 等)→ 跳 `/login`
- [ ] 登出後 cookie 清除
- [ ] 兩個不同 user 的資料互相隔離(裝置列表、模型等)
- [ ] AuthProvider 介面定義完整
- [ ] StubAuthProvider 實作能通過基本 flow
- [ ] `/account` 頁面顯示當前 email + 登出按鈕(其他欄位灰掉或 TODO 標記)
---
## Phase 0 的 TODO 清單(明確追蹤)
- **TODO-AUTH-01**:換 JWTAuthProviderPhase 1 核心)
- **TODO-AUTH-02**DB 層PostgreSQL schema for users、sessions
- **TODO-AUTH-03**Email 驗證流程(需 email 服務SendGrid / SES
- **TODO-AUTH-04**:密碼重設流程
- **TODO-AUTH-05**OAuthGoogle、GitHub
- **TODO-AUTH-06**2FATOTP
- **TODO-AUTH-07**:密碼強度規則
- **TODO-AUTH-08**Rate limiting防暴力
- **TODO-AUTH-09**Account 刪除(含所有裝置、模型、叢集清理)
- **TODO-AUTH-10**:個人設定頁完整功能
- **TODO-AUTH-11**Role / Permission 系統Phase 2for 企業版)
- **TODO-AUTH-12**API Key 管理Phase 2
---
## 安全警示Phase 0 限制)
⚠️ **Phase 0 的 Auth 不是真的 Auth。絕對不能上線給真用戶。**
限制:
- 密碼明文比對in-memory
- Token 不簽、任何人偽造任何 token 都能通過
- 無 rate limiting
- 無 HTTPS 強制Phase 0 dev 環境可能 http://
**Phase 0 的 visionA Cloud 只給內部 FAE 測試**,不開放給外部。
---
## 連結
- 回:[PRD 索引](../PRD.md)
- 相關:[介面契約 — AuthProvider](../interface-contracts.md)

View File

@ -0,0 +1,77 @@
# FeatureBillingP2Phase 0 不做)
> 父文件:[PRD.md](../PRD.md) | 對應 User StoriesUS-27
>
> **⚠️ Phase 0 完全不做**。本文件只留介面定義與未來規劃。
---
## 範圍
Phase 0 / Phase 1 全部跳過。Phase 2 再規劃。
---
## 為什麼 Phase 0 不做
- Phase 0 是雛形,只給內部測試
- 商業模式尚未定案(訂閱 / usage-based / freemium 都有可能)
- 接金流Stripe 等)會拖慢雛形進度
---
## 可能的商業模式Phase 2 待討論)
### 模式 A訂閱制
| 方案 | 月費 | 裝置數上限 | 模型儲存 | 叢集 |
|------|------|----------|---------|------|
| Free | $0 | 1 | 100MB | ❌ |
| Pro | $19 | 5 | 10GB | ✅ |
| Team | $99 | 無限 | 100GB | ✅ |
| Enterprise | 客製 | — | — | + SSO, SLA |
### 模式 B按推論次數
- $X / 1M inferences
- 前 100K 免費
### 模式 CFreemium + 轉檔計費
- 本體免費使用
- 轉檔服務按次計費($Y / 轉檔)
---
## BillingProvider 介面Phase 2
```go
// internal/billing/provider.go
type BillingProvider interface {
CreateCustomer(ctx context.Context, user *User) (*Customer, error)
CreateSubscription(ctx context.Context, customerID, planID string) (*Subscription, error)
CancelSubscription(ctx context.Context, subscriptionID string) error
ReportUsage(ctx context.Context, customerID, meterID string, quantity int64) error
GetInvoices(ctx context.Context, customerID string) ([]Invoice, error)
}
```
Phase 2 實作候選:
- Stripe最成熟SaaS 標配)
- Paddle全球稅務處理較好
- Lemon Squeezy小團隊友善
---
## 其他未定案
- 企業合約 / PO 流程
- 退款政策
- 多幣別
- 發票系統(特別是台灣統一發票)
---
## 連結
- 回:[PRD <20><>](../PRD.md)

View File

@ -0,0 +1,102 @@
# Feature叢集推論P1
> 父文件:[PRD.md](../PRD.md) | 對應 User StoriesUS-16、US-17
>
> **Phase 0 不實作**。Phase 0 只留模組目錄(`internal/cluster/`),從 POC 搬過來但不接入 API。Phase 1 正式產品化。
---
## 概述
叢集推論是 edge-ai-platform POC 驗證過的功能:把多台 Kneron 裝置組成叢集,按**加權 Round-Robin** 分派推論任務。
這是 visionA Cloud 相對於 local-tool 的**核心差異化功能**。
---
## 使用者行為Phase 1
### 叢集列表頁(`/clusters`
- 使用者能看到自己建立的叢集
- 每個叢集顯示:名稱、裝置數、活躍狀態、最近推論量
- 能建立新叢集、刪除叢集、編輯叢集
### 建立叢集流程
1. 點「新建叢集」
2. 輸入叢集名稱
3. 從已配對裝置挑選 2+ 台裝置加入
4. 為每台裝置設權重1-10
5. 確認建立
### 叢集推論操作
- 進入某叢集的 workspace`/clusters/[id]/workspace`
- 選模型(必須叢集內所有裝置都相容)
- 選來源Camera / Image / Video / Batch
- 啟動後dispatcher 按加權 RR 分派任務到各裝置
- UI 顯示:
- 每台裝置的即時負載(%
- 每台裝置的推論 FPS
- 整體吞吐量
- Bounding box overlay 仍然顯示(但標注是哪台裝置推論的)
### 故障降級
- 某台裝置 tunnel 斷線 → 自動從 dispatcher 移除
- 叢集內剩餘裝置繼續服務
- 裝置重新上線 → 自動回加入叢集
---
## 技術細節(從 POC 搬)
從 POC `cluster/` 模組搬:
- `Dispatcher`:加權 RR 邏輯
- `Manager`:叢集 CRUD
- `Pipeline`:單幀 `RunInference` 或連續 `StartContinuous`
**要做的產品化改造**
- 加 user_id 隔離POC 只有一個用戶)
- 叢集 metadata 存 DBPhase 1 接 DB 後)
- MaxClusterSize 可配置
- 健康度監控與告警
---
## 驗收條件Phase 1不在 Phase 0 範圍)
- [ ] 能建立、編輯、刪除叢集
- [ ] 叢集內至少能加 5 台裝置(上限可配置)
- [ ] 加權 RR 分派正確(實測權重 3:1 的裝置,流量比約 3:1
- [ ] 某裝置離線時,叢集自動降級,剩餘裝置繼續服務
- [ ] 裝置重新上線自動加回
- [ ] 每台裝置的即時負載 UI 更新
- [ ] 不同使用者的叢集互相隔離
---
## Phase 0 要做的準備
- [ ] `internal/cluster/` 模組從 POC 搬到 visionA-backend但**不接入 API handlers**
- [ ] Phase 0 的前端 `/clusters` 頁面:可留空白或顯示「即將推出」
- [ ] 介面層面預留API Server router 保留 `/api/clusters/...` 路徑的預定義,先回 501 Not Implemented
---
## Phase 2 的 TODO
- **TODO 1**:跨使用者的叢集共享(多租戶團隊功能)
- **TODO 2**:動態權重調整(根據實測效能自動調)
- **TODO 3**:叢集級別的 Model A/B Testing
- **TODO 4**:叢集級別的 fallback policy不只 RR還能 failover
---
## 連結
- 回:[PRD 索引](../PRD.md)
- 相關:[推論操作](feature-inference.md)

View File

@ -0,0 +1,246 @@
# Feature轉檔功能整合P0 — Phase 0.8 MVP
> 父文件:[PRD.md](../PRD.md) | 對應 User StoriesUS-24、US-TODO-05
>
> **狀態變更2026-05-02**Phase 0 規劃為「P2、只定 API 契約」Phase 0.8 提升為 P0 MVP 並落地實作。
> 本檔保留 Phase 0 的設計決策脈絡API 契約、ConverterClient 介面),新增 Phase 0.8 的 MVP 範圍與整合決策。
---
## 1. 概要
Kneron 的 `.nef` 是 Kneron 晶片專用格式,使用者手上常是 ONNX / TFLite需要轉檔才能在 KL520 / KL720 / KL630 / KL730 上跑。`kneron_model_converter` 已有完整轉檔 servicePhase 1 已完成 `POST /api/v1/jobs` 提交、`GET` 查狀態、`POST /promote` 把結果搬上 File Access Agent
Phase 0.8 的目標是在 visionA Cloud **接入 converter**,讓使用者**不離開 visionA**就能完成「上傳原始模型 → 轉檔 → 進模型庫推論」的完整流程,把原本斷裂的兩個網站合併成一條動線。
跟「模型管理P0」的關係轉檔產生的 `.nef` 是模型庫的一個**新來源**(與「使用者直接上傳 nef」並列。轉檔成功後使用者**自己決定**是否要把 NEF 加進模型庫;加進去後走的是模型管理既有的 `/api/models/init+finalize` 流程,並標記 `Source="converted"` + `SourceJobID=<converter job_id>`
---
## 2. User StoriesPhase 0.8 MVP 範圍)
| ID | Story | 優先級 |
|----|-------|--------|
| US-24a | 作為 AI 應用開發者,**我有一個 ONNX 模型**,想轉成 NEF 放到我的 KL720 device 上跑,希望全程在 visionA Cloud 完成 | P0 |
| US-24b | 作為使用者,**轉檔完成後**我想決定是「加到模型庫直接推論」還是「下載 NEF 回去自己用」,或者兩個都做 | P0 |
| US-24c | 作為使用者,**轉檔失敗時**我想看到清楚的錯誤原因(不是只有 `failed`),知道是檔案格式不支援、量化失敗、還是其他問題 | P0 |
> Phase 0 規劃中 US-24 的「自動把 NEF 推進模型庫」(端到端全自動)改為 Phase 0.8 的**半自動**設計user 顯式選擇)。理由:避免「使用者試了個轉檔但其實只想下載結果」也被自動塞進模型庫。
---
## 3. 功能需求Phase 0.8 MVP
### F1「轉檔」進入點
- 左側 sidebar 新增「轉檔」tab與既有的 Devices / Models / Inference 並列
- 進入後顯示**轉檔頁面**:上傳區 + 設定區 + 開始按鈕
- 不在 `/models` 頁面內混合(避免「上傳模型」按鈕同時要處理 nef / onnx / tflite 兩種流程UX 變複雜)
### F2上傳與設定
- **支援檔案格式**`.onnx``.tflite`Phase 0.8 不支援 `.pt` / `.h5`,由 converter 能力決定)
- **必填欄位**
- 來源檔案(拖拽或選擇)
- 目標 chipKL520 / KL630 / KL720 / KL730單選
- **可選欄位**
- Reference images多張給 converter 做精度校準用)
- 任務名稱(顯示用,預設用檔名)
- **檔案大小限制**
- 模型檔:≤ 500 MBconverter 端的限制;應透過 config 對齊,建議 `CONVERTER_MAX_MODEL_SIZE_MB`
- Reference images每張 ≤ 10 MB、總數 ≤ 100 張
- **上傳行為**upload 走 **visionA backend streaming proxy**(見 §6 整合決策 D1browser → backend → converter使用者看到的是「上傳到 visionA」的單一進度條XHR upload event
### F3轉檔執行與進度
- 上傳完成後自動切到「轉檔進度頁」(同一個 tab不開新分頁
- 進度顯示用 **polling**(前端每 510 秒打一次 `GET /api/conversion/{job_id}`
- status 機械化的四個狀態:`queued` / `running` / `succeeded` / `failed`
- `running` 時若 converter 提供 progress 比例則顯示百分比,無提供則顯示「轉檔中⋯」+ 跑馬燈
- **同 user 同時只能跑一個 active job**converter 端會回 409 `user_has_active_job`;前端拿到 409 時 UI 提示「你已有一個轉檔任務正在進行,請等待完成或重新整理」並禁用「開始轉檔」
### F4完成後的半自動結果處理
轉檔狀態變為 `succeeded` 後,頁面顯示:
- 任務摘要(來源檔名、目標 chip、輸出 NEF 大小、checksum
- **兩個並列按鈕**
- **「加到模型庫」**:走 F6 流程
- **「下載」**:走 F7 流程
- 兩個按鈕**互不互斥**(使用者可以兩個都按),按鈕點擊後不消失,可重複觸發
- 提醒「7 天後 converter 會自動清除這個任務請在期限內完成處理」converter Phase 1 已有 7 天 GC 機制)
### F5失敗錯誤訊息
- 狀態變為 `failed` 時顯示**翻譯後**的 user-friendly 錯誤訊息
- 對照表visionA backend 維護,避免暴露 converter 內部訊息):
| converter error code | 顯示給使用者的訊息 |
|---|---|
| `UNSUPPORTED_FORMAT` | 此模型格式目前不支援,請改用 ONNX / TFLite |
| `INVALID_CHECKSUM` | 檔案傳輸過程毀損,請重新上傳 |
| `QUANTIZATION_FAILED` | 模型內含不支援的運算子,無法量化到目標晶片 |
| `MODEL_TOO_LARGE` | 模型超過 500 MB 上限 |
| `QUOTA_EXCEEDED` | 系統暫時繁忙,請稍後再試 |
| 其他 / unknown | 轉檔失敗,請稍後重試。若持續發生請聯絡支援團隊(顯示 job_id 供回報) |
- 失敗任務也顯示 job_id縮短前 8 碼)供使用者報修參考
### F6「加到模型庫」流程
- 前端 → visionA backend `POST /api/conversion/{job_id}/promote-to-models`
- visionA backend
1. 確認 job 屬於該 user 且狀態為 `succeeded`
2. 從 converter 取得 `target_object_key`promote 階段已上 FAA
3. **server-to-server** 從 FAA pull NEF`files:download.read` scope不走 delegated token
4. 走既有 `/api/models/init` + `/api/models/finalize` 三段式流程進模型庫
5. 在 `model.Source = "converted"``model.SourceJobID = <converter job_id>``internal/model.Model` 已預埋此兩欄位,無需擴 schema
- 完成後前端 toast「已加入模型庫」+ 提供連結跳到 `/models/{model_id}`
- 若同一 job 已被加入過模型庫,回 409 並顯示「此任務已加入過,請至模型庫查看」
### F7「下載」流程
- 前端 → visionA backend `GET /api/conversion/{job_id}/download`server-side 302 redirect → FAA
- visionA backend
1. 確認 job 屬於該 user 且狀態為 `succeeded`
2. 跟 Member Center 換 **delegated download token**scope `files:download.delegate`TTL 5 分鐘)
3. 直接回 HTTP 302 Redirect`Location: <FAA-URL>/files/{key}?access_token=<token>`token 不暴露給 frontend JS
- 前端觸發方式:
- 用 anchor tag`<a href="/api/conversion/{job_id}/download" download>`)或 `window.location.href = '/api/conversion/{job_id}/download'`
- Browser 跟著 302 redirect 到 FAA瀏覽器內建下載管理器接手
- 觸發後 browser 內建下載管理器接手(無自訂進度條 — 但因為大檔下載 browser 自己有 UI是合理 trade-off
- **不需要 FAA 加 CORS**server-side 302 redirect + browser navigation 完全不適用 CORSFAA owner 2026-05-02 確認 + FAA TestSite `DownloadFileDirect` 範例驗證)
- Browser **直連 FAA**(透過 302 跳轉),不經 visionA backend 中轉檔案內容,避免 N 次跨 internet 流量
---
## 4. 非功能需求
| 類別 | 需求 |
|---|---|
| 大小上限 | 模型 ≤ 500 MBref image 每張 ≤ 10 MB、總計 ≤ 100 張 |
| 上傳體驗 | 上傳進度條XHR `upload.progress` 事件);上傳期間禁止離開頁面(`beforeunload` warning |
| 並行限制 | 同 user 同時最多 1 個 active jobconverter enforce 409 `user_has_active_job` |
| 任務保留 | 7 天後 converter 自動 GCUI 在結果頁顯示倒數提醒 |
| 安全 | 所有 visionA → converter 的呼叫帶 service account JWT使用者不直接接觸 converter 認證 |
| 可觀測性 | visionA backend log 每個 job 的 lifecyclesubmit、poll status change、import、download token issued |
---
## 5. Non-GoalsPhase 0.8 不做)
| # | 不做的事 | 原因 / 後續 |
|---|---------|-----------|
| N1 | 轉檔歷史清單(`/converter/jobs`| Phase 0.8 只支援「眼前這個 job」不做 listconverter Phase 1 GC 7 天,做歷史也只能看 7 天的CP 值低 |
| N2 | 取消正在跑的 job | converter 已支援 `POST /jobs/{id}/cancel`,但 UX flow 與錯誤狀態複雜,留待 Phase 1 |
| N3 | 多 chip 同時轉檔(一次轉成多個目標)| converter 端尚不支援user 可重複跑 |
| N4 | SSE / WebSocket 進度推送 | polling 已足夠前端複雜度低Phase 1 量大時再評估 |
| N5 | 進階轉檔參數FP16、自訂量化| 預設 INT8足以涵蓋 80% case |
| N6 | 模型版本管理(同來源轉多版)/ A/B 比較 | 與「模型管理 Phase 2」共同規劃 |
| N7 | 轉檔配額計費 | Phase 2 Billing 一併處理 |
| N8 | Webhook push 模式converter → visionA| Phase 0.8 純 pollingwebhook 在 converter Phase 1 已實作但 visionA 暫不接,避免在 stage 環境管理 webhook URL / 簽章 |
---
## 6. 整合決策Phase 0.8 確認)
| # | 議題 | 決策 | 理由 |
|---|------|------|------|
| D1 | Upload 流量路徑 | Browser → visionA backend → converterstreaming proxy| 一次性上傳;不需 converter 改 endpoint保持「user 只認 visionA」單一信任邊界 |
| D2 | Download 流量路徑 | Browser 直連 FAA用 delegated token| 同一 NEF 可能被多 user / 多次下載到 device經 backend 中轉會 N 次跨 internet 燒流量 |
| D3 | 結果處理 | 半自動user 顯式選擇 import / download / 都做)| 避免「user 只是試試」的 NEF 被自動推進模型庫 |
| D4 | 進度更新 | Polling 510 秒一次 | 簡單可靠;轉檔本身耗時 110 分鐘polling 開銷可忽略 |
| D5 | 通訊協定 | converter API 採既有 REST `/api/v1/jobs`,不新增 endpoint | converter 完全不用動 |
| D6 | 進入點 | Sidebar 獨立 tab不混進 `/models` | UX 流程線性、避免「上傳模型」按鈕承載過多分支 |
---
## 7. 整合 Dependency 一覽
| 系統 | Phase 0.8 是否需要動? | 細節 |
|---|---|---|
| **kneron_model_converter** | ❌ 完全不用動 | `POST /api/v1/jobs``GET /api/v1/jobs/{id}``POST /api/v1/jobs/{id}/promote` 全部已實作Phase 1 完成) |
| **File Access Agent (FAA)** | ❌ 完全不用動 | server-side 302 redirect 模式不需要 CORSFAA owner 2026-05-02 確認 + TestSite 範例驗證);既有 `PUT /files/{key}``GET /files/{key}?access_token=`、delegated download token validation 都已實作 |
| **Member Center (MC)** | ⚠️ 確認 visionA service client 4 個 scope 已授權 | `converter:job.write``converter:job.read``files:download.read``files:download.delegate` |
| **visionA-backend** | ✅ 新增 `/api/conversion/*` 路由群 + ConverterClient HTTP 實作 | `internal/model.Model` 已預埋 `Source``SourceJobID`,無需 schema migration |
| **visionA-frontend** | ✅ 新增「轉檔」tab + upload / progress / result 三個畫面 | UI 設計依現有設計系統 |
> 跨團隊 P0/P1 工作項目詳見 `kneron_model_converter/docs/TODO-visionA-integration.md`
---
## 8. 成功指標KPI
| 指標 | 目標Phase 0.8 | 量測方式 |
|---|---|---|
| 第一個內部使用者轉檔成功率 | > 80% | converter job status 統計succeeded / total |
| 從上傳到拿到 NEF 的 P95 時間 | < 10 分鐘含上傳 + 轉檔 + promote| visionA backend import / download 觸發點 log timestamp |
| 「加到模型庫」按鈕點擊率 | > 50%(驗證半自動設計合理)| 前端事件埋點 / backend `/promote-to-models` 呼叫次數 |
| 轉檔失敗錯誤訊息可理解率 | 100% 失敗 case 都對應到 §F5 表內訊息 | 失敗 log review |
| Stage 環境每週至少 5 次成功 e2e | — | converter job log 統計 |
---
## 9. 驗收條件Phase 0.8
### 功能驗收
- [ ] 左側 sidebar 顯示「轉檔」tab
- [ ] 可上傳 `.onnx` 模型 + 選 KL720 chip + 0 張 ref image跑通 e2e
- [ ] 可上傳 `.onnx` + KL720 + 5 張 ref imagese2e 成功
- [ ] 上傳進度條正確顯示0% → 100%
- [ ] 轉檔中頁面 polling 正確顯示 `queued` / `running` / `succeeded` 狀態變化
- [ ] 完成頁顯示「加到模型庫」與「下載」兩個按鈕
- [ ] 點「加到模型庫」後 `/models` 頁可看到新模型,標記為「轉檔來源」
- [ ] 點「下載」後 browser 開始下載 NEF檔名合理
- [ ] 同一個 job 可重複按「加到模型庫」(第二次顯示 409 已加入過)
- [ ] 同一個 job 可重複按「下載」拿到新 token
- [ ] 同 user 已有 active job 時submit 第二個 job 顯示 409 提示
- [ ] 上傳 600 MB 檔案被拒(前端先擋 + 後端兜底)
- [ ] 上傳 `.pb`(不支援格式)顯示明確錯誤
- [ ] 轉檔失敗時顯示翻譯後的錯誤訊息 + job_id
### 整合驗收
- [ ] visionA service client 在 MC 已有 4 個 scope人工確認
- [ ] 端到端browser → visionA backend → converter → FAA → browserstage 環境跑通
- [ ] `model.Source="converted"` + `SourceJobID=<job_id>` 正確寫入 DB
- [ ] FAA delegated token TTL 5 分鐘正確;過期後再次點下載拿到新 token
---
## 10. 後續 Phase 規劃Non-Goals 升級路線)
| Phase | 項目 | 說明 |
|---|---|---|
| Phase 1 | 轉檔歷史清單 | 列出該 user 過去 7 天的 jobs配合 converter 提供 `GET /api/v1/jobs?user_id=` |
| Phase 1 | 取消 job | UI 加「取消」按鈕,呼叫 `POST /jobs/{id}/cancel` |
| Phase 1 | 自訂下載進度條 / 暫停恢復 | 改成 visionA backend stream proxy 模式(多一跳但有完整 UI 控制);只在使用者要求時做,目前 browser 內建下載管理器足夠 |
| Phase 1 | Webhook push 進度 | converter → visionA backend webhook用於精確進度與避免 polling 浪費 |
| Phase 2 | 進階參數FP16 / 自訂量化)| converter 暴露更多 knob 後接入 |
| Phase 2 | 多 chip 同時轉 | 一次提交產出多個 NEF |
| Phase 2 | 模型版本管理 | 同來源 ONNX 不同 chip 的多版 NEF 視為同一邏輯模型的 variant |
| Phase 2 | 轉檔配額 / Billing | 與 Billing feature 一併處理 |
---
## 11. 給 Architect / Design 的注意事項
- **Architect**
- visionA backend 需新增 `internal/converter/` 套件實作 `HTTPConverterClient`(取代 Phase 0 的 Stub
- 上傳要走 streaming proxy`io.Copy` + `multipart.Reader`**不可 buffer 全 RAM、不可寫 disk**
- polling 端點 `/api/conversion/{job_id}` 要做 user-scoped 授權檢查
- promote-to-models 流程要 idempotent同 job 重複呼叫不重複建模型)
- **Design**
- 轉檔 tab 的 wireframeupload → progress → result需獨立設計
- 失敗狀態的視覺處理(顏色 / icon參考既有錯誤模式
- 「加到模型庫」與「下載」兩個按鈕的視覺平衡(不要讓使用者覺得有預設答案)
- 進度條設計要區分「上傳階段」0100% 精確)與「轉檔階段」(不確定百分比)
---
## 12. 連結
- 回:[PRD 索引](../PRD.md)
- 相關:[模型管理](feature-model-management.md)、[介面契約](../interface-contracts.md)
- 跨專案:`kneron_model_converter/docs/TODO-visionA-integration.md``kneron_model_converter/apps/task-scheduler/docs/openapi.yaml`

View File

@ -0,0 +1,61 @@
# Feature儀表板P1
> 父文件:[PRD.md](../PRD.md) | 對應 User StoriesUS-14
>
> **Phase 0 基本版**:對應 local-tool 的首頁 `/`,顯示裝置列表 + 簡易統計。
> **Phase 1 完整版**:加上活動時間軸、詳細統計卡片。
---
## 概述
`/` 根目錄頁面,使用者登入後看到的第一個畫面。
對比 local-toollocal-tool 首頁顯示本機裝置、本機活動。visionA Cloud 顯示雲端配對的裝置、跨時段的活動紀錄。
---
## 使用者行為
### Phase 0 最小版
- Header 問候語「Hi {user_name}」
- 「快速開始」卡片:
- 「配對新裝置」→ `/devices`
- 「開始推論」→ `/workspace`
- 「瀏覽模型」→ `/models`
- 裝置狀態摘要:「你有 X 台裝置,其中 Y 台在線」
- 空狀態0 裝置):顯示引導文案 + 配對 CTA
### Phase 1 完整版(沿用 local-tool
- 活動時間軸:近 N 小時的推論紀錄、裝置事件
- 統計卡片:
- 今日推論次數
- 活躍裝置數
- 上傳的模型數
- 裝置列表(縮略版)
- 最近使用的模型
---
## 驗收條件Phase 0
- [ ] 登入後自動跳轉到 `/`
- [ ] 顯示問候語 + 快速開始卡片
- [ ] 裝置狀態摘要數字正確
- [ ] 空狀態引導文案顯示正確(新用戶首次登入)
---
## Phase 0 的 TODO
- **TODO**:活動時間軸(需 DBPhase 1
- **TODO**:詳細統計卡片(需 DB 埋點Phase 1
- **TODO**自訂儀表板佈局Phase 2
---
## 連結
- 回:[PRD 索引](../PRD.md)

View File

@ -0,0 +1,120 @@
# Feature裝置管理P0
> 父文件:[PRD.md](../PRD.md) | 對應 User StoriesUS-03、US-06、US-07
---
## 概述
使用者能在雲端瀏覽自己所有已配對的 Kneron 裝置、查看狀態、連接 / 斷開、查看韌體資訊。
對比 local-toolUI 幾乎一樣,差別在「裝置來源」— local-tool 掃本機 USBvisionA Cloud 列表來自 pairing 的 local agent透過 tunnel 掃 agent 端的 USB
---
## 使用者行為
### 裝置列表頁(`/devices`
顯示當前使用者名下所有已配對的裝置:
| 欄位 | 內容 | 來源 |
|------|------|------|
| 裝置名稱 | 使用者自訂或預設例如「KL520-001」| Phase 0預設Phase 1可編輯 |
| 型號 | KL520 / KL720 | 從 local agent 回報 |
| 連線狀態 | 🟢 在線 / 🟡 Tunnel 重連中 / ⚪ 離線 | Remote-proxy session 狀態 |
| Tunnel 延遲 | RTT ms | 定期 ping |
| 韌體版本 | 例如 `KL520_FW_v1.2.3` | 從 local agent 回報 |
| 所屬 agent | 哪台筆電 / 機台 | Pairing 時填Phase 1|
| 最後活動 | 時間戳記 | 推論紀錄 |
| 操作 | 連接 / 斷開 / 詳細 / 撤銷配對Phase 1| — |
### 裝置詳細頁(`/devices/[id]`
沿用 local-tool 的版型,新增:
- **Tunnel 資訊區塊**:當前 session ID、連線時長、延遲圖、斷線重連次數
- **Agent 資訊**:這台裝置來自哪個 local agent、agent 版本
- **撤銷配對按鈕**Phase 1
### 操作
- **連接 / 斷開**:和 local-tool 一樣(透過 tunnel 轉發 `/api/devices/{id}/connect`
- **掃描新裝置**:觸發 agent 端的 USB 掃描tunnel 轉發 `/api/devices/scan`
- **撤銷配對**Phase 1撤銷該裝置的 Session Token強制斷線
---
## 技術細節(給 Architect 參考)
### 裝置狀態來源
```
┌─────────────────────────────────────────────────────────┐
│ Phase 0 資料流in-memory
│ │
│ Browser ──GET /api/devices──> api-server │
│ │ │
│ │ 查 session 狀態 │
│ ▼ │
│ SessionStore (in-mem) │
│ │ │
│ │ 對每個在線 session │
│ ▼ │
│ remote-proxy (tunnel) │
│ │ │
│ │ yamux stream │
│ ▼ │
│ local agent (:3721) │
│ │ │
│ │ 本機 KneronPLUS │
│ ▼ │
│ Kneron KL520/KL720 │
└─────────────────────────────────────────────────────────┘
```
### 快取策略
Phase 0不快取每次 GET 都即時查 local agent。
Phase 1考慮前端 SWR 快取 + 後端 5 秒 memory cache減少 tunnel 流量。
### 即時事件
沿用 local-tool 的 `/ws/devices/events` WebSocket但改由 visionA-backend 的 api-server 廣播:
- Agent 上線 / 下線
- 裝置連接 / 斷開
- 裝置健康度變化
api-server 透過 remote-proxy 的 events 通道接收 agent 事件,再廣播給對應的前端 WebSocket client。
---
## 驗收條件Phase 0
- [ ] 登入後 `/devices` 頁面顯示當前使用者的裝置列表
- [ ] 未配對時顯示空狀態 + 「配對新裝置」CTA
- [ ] 裝置在線 / 離線狀態正確agent 下線 5 秒內反映)
- [ ] 能打開裝置詳細頁,顯示韌體版本等資訊
- [ ] 連接 / 斷開操作能成功執行
- [ ] 掃描 USB 裝置能觸發 agent 端動作
- [ ] Tunnel 延遲顯示正確(單位 ms
- [ ] 兩個不同使用者的裝置列表互相隔離multi-tenant 基本)
- [ ] UI 與 local-tool 一致性達到 90%+
---
## Phase 0 的 TODO
- **TODO**裝置名稱自訂編輯Phase 1
- **TODO**:裝置分組 / 標籤Phase 2
- **TODO**裝置健康度告警規則Phase 2
- **TODO**撤銷配對按鈕Phase 0 顯示 disabledPhase 1 實作)
---
## 連結
- 回:[PRD 索引](../PRD.md)
- 相關:[Pairing 流程](feature-pairing.md)、[推論操作](feature-inference.md)

View File

@ -0,0 +1,133 @@
# Feature推論操作P0CameraP1Image / Video / Batch
> 父文件:[PRD.md](../PRD.md) | 對應 User StoriesUS-10、US-11、US-15
---
## 概述
使用者選裝置 + 模型 + 來源後能啟動推論並即時看到結果bounding box / 分類結果)。
這是**visionA Cloud 最核心的價值體驗**。推論結果要即時、低延遲、UI 和 local-tool 一致。
---
## 使用者行為
### Workspace 進入點(`/workspace``/workspace/[deviceId]`
三段式選擇:
1. **選裝置**:從已配對的裝置列表挑一個
2. **選模型**:從模型庫挑一個(需和裝置型號相容)
3. **選來源**
- **CameraPhase 0**:使用者 agent 端的 USB / IP camera
- **ImagePhase 1**:從瀏覽器上傳單張圖片
- **VideoPhase 1**從瀏覽器上傳影片MP4/AVI/MOV 等)
- **Batch ImagesPhase 1**:批次上傳多張圖片
選完後進推論工作區。
### 推論工作區
**UI 沿用 local-tool 的設計**
- 左上:即時 MJPEG 畫面 + overlaybounding box + label + confidence
- 右側:控制面板
- 信心度門檻 slider
- 推論 FPS / 延遲顯示
- 開始 / 停止按鈕
- 底下:分類結果 / 偵測框列表
- 右上 headerTunnel 延遲顯示visionA Cloud 獨有)
### Camera 推論流程Phase 0 重點)
```
┌────────────────────────────────────────────────────────────┐
│ Camera 推論資料流 │
│ │
│ 1. Browser 呼叫 api-serverPOST /workspace/start │
│ { deviceId, modelId, source: "camera", cameraId } │
│ ▼ │
│ 2. api-server 找到對應的 tunnel session │
│ 透過 remote-proxy 轉發到 local agent │
│ ▼ │
│ 3. local agent 啟動 KneronPLUS inference pipeline │
│ Camera → 幀捕捉 → Kneron → 推論結果 │
│ ▼ │
│ 4. local agent 把 MJPEG stream 推給 tunnel │
│ tunnel ─yamux stream─> remote-proxy ─> api-server │
│ ▼ │
│ 5. Browser 透過 /api/camera/stream 拿到 MJPEG │
│ (遠端 stream 經過 api-server proxy 到瀏覽器) │
│ ▼ │
│ 6. Browser 透過 WebSocket /ws/inference 拿到推論結果 │
│ Canvas overlay bounding box │
└────────────────────────────────────────────────────────────┘
```
### 關鍵技術挑戰(給 Architect 參考)
1. **MJPEG binary stream 要能過 tunnel**yamux 支援,但需要測試吞吐量
2. **WebSocket推論結果也要過 tunnel**:雲端前端的 WebSocket 連到 api-serverapi-server 的對應連線再過 tunnel 到 local agent
3. **多 client 存取同一個 stream**local-tool 支援多 client multipart MJPEG。visionA Cloud 可能一個使用者開多個 tab要處理並發
4. **Tunnel 斷線處理**:推論中 tunnel 斷線UI 要明確提示並自動重連
### 延遲預算
| 路徑段 | 延遲 |
|--------|-----|
| Camera → local agent 幀捕捉 | ~10ms |
| local agent → Kneron 推論 | ~30msmodel 而異)|
| local agent → remote-proxytunnel經 WAN| ~50-200ms RTT |
| remote-proxy → api-server | ~5ms同機房|
| api-server → Browser | ~10-50ms |
| **端到端總延遲P95 目標)** | **< 500ms** |
local-tool 端到端 ~150-250ms多的部分主要是 tunnel 的 WAN RTT
---
## 驗收條件Phase 0Camera only
- [ ] 能從 Workspace 頁選裝置 + 模型 + Camera 來源
- [ ] 點「開始」後MJPEG 畫面在 2 秒內出現
- [ ] Bounding box / 分類結果 overlay 正確即時更新
- [ ] 信心度門檻調整立即反映在畫面
- [ ] 推論 FPS 顯示正確(大約 10-30 fps
- [ ] 端到端延遲 P95 < 500ms內網測試
- [ ] Tunnel 延遲顯示正確header
- [ ] Tunnel 斷線時立即提示使用者並自動嘗試重連
- [ ] 重連成功後推論自動恢復
- [ ] 停止推論後,畫面停在最後一幀 + 有明確「已停止」狀態
- [ ] 同一使用者開兩個 tab 看同一裝置推論 → 都能看(多 client multipart
- [ ] 不同使用者之間互相隔離(不會看到別人的推論畫面)
---
## Phase 1 擴充Image / Video / Batch
Image / Video / Batch 推論**不需要即時 stream**,使用者上傳檔案到 api-server透過 tunnel 把檔案送到 local agentagent 做完回傳結果。
技術挑戰:
- 大檔案傳輸的 tunnel 效率
- 檔案臨時儲存策略api-server 記憶體 or 暫存磁碟)
- 影片 seek 操作的 tunnel 往返
---
## Phase 0 的 TODO
- **TODO 1**Image / Video / Batch 推論Phase 1
- **TODO 2**:推論結果的 history / 回放Phase 1需 DB
- **TODO 3**:多 client 同時看同一推論的效能優化Phase 1
- **TODO 4**:推論結果下載 / exportPhase 1
- **TODO 5**Camera 來源的使用者選擇 UI — local agent 可能有多個 camera要讓使用者選
- **TODO 6**:推論 logging / analyticsPhase 2用於叢集指標追蹤
---
## 連結
- 回:[PRD 索引](../PRD.md)
- 相關:[叢集推論](feature-cluster-inference.md)、[工作區](feature-workspace.md)、[非功能性需求 — 效能](../nonfunctional.md)

View File

@ -0,0 +1,145 @@
# Feature模型管理P0
> 父文件:[PRD.md](../PRD.md) | 對應 User StoriesUS-08、US-09、US-23
---
## 概述
使用者能瀏覽系統預設的 7 個 Kneron `.nef` 模型,上傳自己的模型,管理模型庫。
對比 local-tool
- local-tool 的模型存在使用者電腦 `~/Library/Application Support/visiona-local/custom-models/`
- visionA Cloud 的模型**存在雲端**Phase 0 用 local filesystem 實作 ObjectStorage 介面Phase 1 換 S3/MinIO
---
## 使用者行為
### 模型庫頁(`/models`
UI 沿用 local-tool但資料來源改為 visionA-backend。
**三個區塊**
1. **預設模型**(系統共用,所有使用者都能用)
- 7 個預置模型KL520 × 4 + KL720 × 3
- Phase 0seed 到 backend 的 local fs
- Phase 1seed 到 S3 的 `public/` prefix
2. **我的模型**(使用者上傳的)
- 空狀態顯示 CTA「上傳模型」
- 每個模型顯示:名稱、大小、上傳時間、支援硬體、上傳者
3. **組織模型**Phase 2留介面
- 如果使用者在團隊 workspace能看到團隊共享模型
**篩選**按任務類型classification / object_detection、硬體KL520 / KL720、關鍵字。
### 上傳模型
1. 點「上傳模型」→ 拖拽 or 選檔
2. 檔案類型:`.nef`
3. 檔案大小限制:**Phase 0 上限 100MBPhase 1 上限 500MB後續依付費方案調整**
- 此限制必須落在 configenv var 或 config file**不可硬編碼**,方便跨 Phase 調整
- 建議 config key`MODEL_UPLOAD_MAX_SIZE_MB`(預設 100
- 前端與後端都要同步使用這個值(前端顯示錯誤提示、後端做實際拒絕)
4. 上傳進度 bar
5. 後端儲存到 ObjectStorage 介面實作
6. 上傳完成後解析 metadata檔案 header
7. Phase 0metadata 存 in-memory mapPhase 1存 DB
### 模型詳細頁(`/models/[id]`
- 基本資訊名稱、ID、大小、MD5
- 支援硬體KL520 / KL720
- Metadata輸入尺寸、任務類型
- 效能數據(推論 FPS、延遲— Phase 0 預設模型有,自上傳的沒有
- 下載按鈕(重新下載到本機)
- 刪除按鈕(只對自上傳模型)
---
## 技術細節(給 Architect 參考)
### ObjectStorage 介面(重點)
Phase 0 要定義清楚介面,讓 Phase 1 可直接換 S3/MinIO 不動業務邏輯。
```go
// internal/storage/storage.go
type ObjectStorage interface {
Upload(ctx context.Context, key string, reader io.Reader, size int64) error
Download(ctx context.Context, key string) (io.ReadCloser, error)
Delete(ctx context.Context, key string) error
List(ctx context.Context, prefix string) ([]ObjectInfo, error)
// Phase 1presigned URL
GetDownloadURL(ctx context.Context, key string, expiresIn time.Duration) (string, error)
GetUploadURL(ctx context.Context, key string, expiresIn time.Duration) (string, error)
}
type ObjectInfo struct {
Key string
Size int64
LastModified time.Time
ETag string
}
```
**Phase 0 實作:`LocalFSStorage`**
- 存在 `./data/models/{user_id}/{model_id}.nef`
- `GetDownloadURL` 回傳 `/api/models/{id}/download`(走 api-server 串流)
**Phase 1 實作:`S3Storage`**
- 用 AWS SDK 或 minio-go
- `GetDownloadURL` / `GetUploadURL` 回傳真的 presigned URL
### 上傳大檔案策略
- Phase 0Multipart form upload 直接進 api-serverapi-server 再寫進 local fs
- 限制100MB 以下
- api-server 的記憶體與磁碟 I/O 壓力
- Phase 1前端直接用 presigned URL 上傳到 S3不過 api-server
- 需要 CORS 設定
- 上傳完成後通知 api-server 更新 metadata
### 預設模型 seed
Phase 0backend 啟動時,檢查 `data/models/system/` 是否有預設模型,沒有就從 bundled resources 複製過去。
Phase 1預設模型在 `s3://visiona-models/system/` 共享給所有 user。
---
## 驗收條件Phase 0
- [ ] `/models` 頁面顯示 7 個預設模型
- [ ] 不同使用者看到自己的「我的模型」,互相隔離
- [ ] 上傳 `.nef` 檔成功,出現在列表
- [ ] 上傳進度 bar 顯示正確
- [ ] 上傳 > 100MB 被拒絕回傳明確錯誤Phase 0 限制值)
- [ ] 上傳大小限制從 config`MODEL_UPLOAD_MAX_SIZE_MB`,預設 100讀取非硬編碼
- [ ] 前端顯示的大小限制與後端實際拒絕的值一致
- [ ] 上傳非 `.nef` 檔被拒絕
- [ ] 能下載已上傳的模型
- [ ] 能刪除自上傳的模型,不能刪預設模型
- [ ] ObjectStorage 介面定義完整(有 interface + LocalFSStorage 實作)
- [ ] 介面層面預留 S3 實作的 hook切換時不動業務邏輯
---
## Phase 0 的 TODO
- **TODO 1**presigned URL 上傳Phase 1
- **TODO 2**模型版本管理Phase 2
- **TODO 3**:模型標籤 / 搜尋增強Phase 2
- **TODO 4**團隊共享模型Phase 2
- **TODO 5**模型來源追蹤是轉檔來的還是使用者上傳的Phase 2
- **TODO 6**:效能數據收集(推論時自動紀錄 FPS / 延遲Phase 1
---
## 連結
- 回:[PRD 索引](../PRD.md)
- 相關:[介面契約 — ObjectStorage](../interface-contracts.md)、[轉檔整合](feature-converter-integration.md)

View File

@ -0,0 +1,181 @@
# FeaturePairing 流程P0雛形必做
> 父文件:[PRD.md](../PRD.md) | 對應 User StoriesUS-04、US-05、US-06、US-18、US-19
---
## 概述
Pairing 是 visionA Cloud 獨有的流程local-tool 沒有):把使用者筆電上的 local agent 連到 visionA Cloud 的 remote-proxy讓雲端能代管該裝置。
這個流程替代了 POC 中「Token = SHA256(MAC)[:16]」的硬編碼方式,改為**雲端發 token、綁 user + device、有 expiry、可撤銷**的產品化版本。
---
## 使用者行為
### 正向流程Happy Path
1. 使用者在瀏覽器登入 visionA Cloud
2. 在「裝置」頁面點「配對新裝置」
3. 彈出對話框,顯示:
- 一組 Pairing Token例如 `vAc_7f3c8e2a9b1d0f5e...`
- QR code方便手機掃或直接貼
- 說明文字:「在你的 local agent 貼上這個 token」
- 倒數計時「15 分鐘內要完成配對」
4. 使用者打開筆電上的 local-tool
5. Phase 0 妥協)手動編輯 local-tool 的設定檔,填入:
- `VISIONA_RELAY_URL=wss://relay.visiona.cloud`
- `VISIONA_PAIRING_TOKEN=vAc_7f3c8e2a9b1d0f5e...`
- 重啟 local-tool
6. local-tool 的 tunnel client 啟動,連到 remote-proxy
7. remote-proxy 驗證 token → 找到對應 user → 記錄 device 已配對
8. 雲端前端透過 WebSocket 收到「裝置上線」事件 → 即時更新裝置列表
9. 使用者看到新裝置出現,完成配對
### 異常流程
**A. Token 過期**
- 超過 15 分鐘未完成配對 → local-tool 連線時 remote-proxy 回 403
- local-tool 顯示「Pairing Token 已過期,請重新取得」
- 使用者回雲端重新產生
**B. Token 已被使用**
- Token 預設為一次性。配對成功後不能再用
- 再次用同一 token → 回 409 Conflict
- 雲端 UI 提示「此 token 已使用」
**C. Token 無效**
- 亂打 token → 401 Unauthorized
**D. 網路中斷後重連**
- local-tool 已配對的裝置tunnel 斷線後自動重連
- 重連時用的是 **Session Token長期**,不是 Pairing Token一次性
- Session Token 在首次配對成功後由 remote-proxy 發給 local-toollocal-tool 存到本機 config
---
## Token 設計
### Pairing Token一次性、短期
| 屬性 | 值 |
|------|---|
| 格式 | `vAc_` + 32 字元 hex範例`vAc_7f3c8e2a9b1d0f5e...`|
| 產生者 | api-server |
| 儲存 | Phase 0in-memory mapPhase 1DB |
| TTL | 預設 15 分鐘(可調整)|
| 使用次數 | 1 次 |
| 綁定 | user_id產生者|
### Session Token長期、可撤銷
| 屬性 | 值 |
|------|---|
| 格式 | `vAs_` + 64 字元 hex |
| 產生者 | remote-proxy首次 pairing 成功後發)|
| 儲存 | Phase 0in-memory mapPhase 1DB<br>local-tool 端:寫入 config 檔 |
| TTL | **90 天**(到期或使用者主動撤銷時失效)|
| 使用次數 | 無限TTL 內每次重連共用)|
| 綁定 | user_id + device_id |
> **兩階段 TTL 設計**
> - **Pairing Token 階段**:使用者在雲端產 token、貼到 local agent15 分鐘內要完成首次連線,一次性使用
> - **Session Token 階段**local agent 首次連上 remote-proxy 後,由 remote-proxy 發 Session Token 寫入 local config之後 tunnel 斷線重連、跨 session 維持長連線都用這把 token90 天內有效
>
> Phase 0 先做固定 90 天 TTLPhase 1 加 rotation到期前一段時間自動換新 token避免使用者介入
### 為什麼分兩種 Token
- Pairing Token 一次性、短期 → 防止 token 洩漏的危害被放大(最壞情況只影響 15 分鐘)
- Session Token 長期 → 避免每次重連都要使用者介入,但 90 天 TTL 限制洩漏風險
- 兩階段模式類似 OAuth 的 authorization code + refresh token
---
## 介面契約API 層面)
完整 API spec 由 Architect Agent 寫入 TDD本文件只列出關鍵端點
### POST `/api/pairing/generate`
**Request** (需登入)
```json
{}
```
**Response 200**
```json
{
"pairing_token": "vAc_7f3c8e2a9b1d0f5e...",
"expires_at": "2026-04-21T10:30:00Z",
"relay_url": "wss://relay.visiona.cloud/tunnel/connect"
}
```
### WebSocket `wss://relay.visiona.cloud/tunnel/connect?token={token}`
**流程**
1. local-tool 發起 WebSocket 連線,帶 token可能是 Pairing Token 或 Session Token
2. remote-proxy 驗證:
- 若是 Pairing Token檢查未過期、未使用 → 發 Session Token透過 WebSocket 首個 message 傳給 local-tool→ 標記 Pairing Token 已使用
- 若是 Session Token檢查未撤銷 → 接受連線
3. 建立 yamux session沿用 POC 的設計)
4. 後續所有 HTTP/WebSocket 請求透過 yamux stream 轉發到 local agent
### POST `/api/devices/{device_id}/revoke` Phase 1
**功能**:撤銷某 Session Token強制該裝置斷線。
---
## 驗收條件Phase 0
- [ ] 登入後,裝置頁面的「配對新裝置」按鈕可點
- [ ] 點擊後彈出對話框,顯示 Pairing Token明文 + QR code
- [ ] Pairing Token 格式符合規範(`vAc_` + 32 hex
- [ ] Pairing Token TTL = 15 分鐘,過期後 local-tool 連不上
- [ ] local-tool 用正確 token 連線 → 成功建立 tunnel
- [ ] local-tool 用錯誤 / 過期 / 已使用的 token 連線 → 收到明確錯誤
- [ ] 配對成功後,雲端裝置列表 5 秒內顯示新裝置
- [ ] local-tool 收到 Session Token 並寫入本機 config
- [ ] Session Token 格式符合規範(`vAs_` + 64 hex
- [ ] Session Token TTL = 90 天,到期後 local-tool 連線被拒,需重新 pairing
- [ ] tunnel 斷線後local-tool 用 Session Token 自動重連TTL 內)
- [ ] 全流程繁體中文 UI
- [ ] TODOPhase 0 不實作「撤銷」功能,但要在 UI 留按鈕disabled
---
## Phase 0 的 TODO
- **TODO 1**local-tool 端的整合 — Phase 0 先讓使用者手動編輯 config 檔Phase 1 要在 local-tool 內建 UI「配對到 visionA Cloud」
- **TODO 2**QR code 產生 — 可用前端套件(例如 `qrcode.react`),但優先級低,可先只顯示明文 token
- **TODO 3**Token 撤銷 — 介面留著,實作留到 Phase 1
- **TODO 4**:多 local agent 同時配對 — Phase 0 每個 user 最多 1 台 agent 同時連線Phase 1 支援多台
- **TODO 5**Session Token rotation — Phase 0 採固定 90 天 TTL到期需重新 pairingPhase 1 做到期前自動 rotation無需使用者介入
- **TODO 6**Pairing 流程的使用者引導tutorial — Phase 0 不做Phase 1 加 onboarding
---
## 安全考量
**Phase 0 最小要求**
- Token 必須透過 TLS`wss://`)傳輸,不能走明文
- Token 儲存在記憶體不要 log 出來
- Pairing Token 產生後不要再可讀取(只有產生當下顯示給使用者)
**Phase 1 要加的**
- Token 在 DB 以 hash 儲存(不存明文)
- Rate limiting防暴力破解
- IP 地址記錄(配對時、連線時)
- 異常偵測(同一 token 從不同 IP 嘗試連線)
---
## 連結
- 回:[PRD 索引](../PRD.md)
- 相關:[介面契約](../interface-contracts.md)、[非功能性需求](../nonfunctional.md)

View File

@ -0,0 +1,50 @@
# Feature工作區P0
> 父文件:[PRD.md](../PRD.md) | 對應 User StoriesUS-10
---
## 概述
工作區是使用者做推論的主要介面,對應 `/workspace``/workspace/[deviceId]` 頁面。
對比 local-toolUI 幾乎完全一致。
---
## 使用者行為
### `/workspace`(選擇頁)
- 三段式流程:選裝置 → 選相容模型 → 選推論來源
- Phase 0只支援 Camera 來源Phase 1 加 Image / Video / Batch
- 選定後跳轉到 `/workspace/[deviceId]`
### `/workspace/[deviceId]`(推論操作頁)
- 沿用 local-tool 的工作區版型
- 詳見 [feature-inference.md](feature-inference.md)
---
## 驗收條件Phase 0
- [ ] `/workspace` 可從裝置列表進入
- [ ] 選裝置 + 選模型 + 選來源的三段式流程能完成
- [ ] 相容性檢查KL520 不能選 KL720 模型,反之亦然
- [ ] 跳轉到 `/workspace/[deviceId]` 後推論操作可用
- [ ] 離開頁面時自動停止推論(不留尾巴)
---
## Phase 0 的 TODO
- **TODO**:工作區「偏好設定」記憶(上次用哪個模型、哪個 camera
- **TODO**:從 `/workspace/[deviceId]` 切換其他裝置的快捷操作
---
## 連結
- 回:[PRD 索引](../PRD.md)
- 相關:[推論操作](feature-inference.md)、[裝置管理](feature-device-management.md)、[模型管理](feature-model-management.md)

View File

@ -0,0 +1,330 @@
# 8. 介面契約Interface Contracts — visionA Cloud
> 父文件:[PRD.md](PRD.md)
>
> **本章節是 Phase 0 的核心產出之一**。Phase 0 有很多 TODO介面契約讓未來能「無痛換實作」。
---
## 8.1 為什麼介面這麼重要
Phase 0 是雛形階段,很多子系統用 **stub 實作**,但:
- **雛形不實作 ≠ 雛形不設計**
- 介面interface / contract現在就要定義清楚
- 未來只換實作,不動業務邏輯
五大關鍵介面:
1. **AuthProvider** — 會員系統Phase 0 stub → Phase 1 JWT
2. **SessionStore** — Tunnel session 狀態Phase 0 in-memory → Phase 1 Redis
3. **ObjectStorage** — 模型檔儲存Phase 0 local fs → Phase 1 S3/MinIO
4. **ConverterClient** — 轉檔服務Phase 0 stub → Phase 2 真實 API
5. **BillingProvider** — 計費Phase 0 / 1 都不做 → Phase 2+
> 實際 Go interface 定義由 Architect Agent 寫進 TDD。本文件定義的是**需求與合約**。
---
## 8.2 AuthProvider 介面
### 目的
讓 visionA-backend 的所有 Auth 相關功能(登入、註冊、驗 token、登出透過一個 interface 抽象Phase 0 接 stubPhase 1 接真實 Auth。
### 必須提供的能力
| 能力 | 說明 | Phase 0 | Phase 1 |
|------|------|---------|---------|
| Register | 建立新 useremail + password| stub僅接受固定 demo-user| DB |
| Login | 驗證 user發 session token | **接受任何帳密,一律回 demo-user + stub token** | JWT |
| ValidateToken | 驗證 token回傳 user | 查 in-memory map | 驗簽 + 查 DB |
| Logout | 撤銷 token | 刪 in-memory entry | 加 blacklist |
| GetUser | 查 user 資訊 | in-memory固定 demo-user| DB |
| — 以下為 **Phase 1** 才做 — | | | |
| RefreshToken | Refresh token rotation | ❌Phase 1 | ✅ |
| RequestPasswordReset | 寄 email 重設 | ❌Phase 1 | ✅ |
| ConfirmPasswordReset | 確認重設 | ❌Phase 1 | ✅ |
| Delete | 刪除帳號 + 所有相關資源 | ❌Phase 1 | ✅ |
### Phase 0 雛形實作:`StaticAuthProvider`
Phase 0 不做真實會員系統,改用 `StaticAuthProvider`
- **Login**:不驗證 email/password一律接受並回 `demo-user` 的 stub token
- **ValidateToken**:只認得 `StaticAuthProvider` 自己發的 stub token驗過回 `demo-user`
- **Logout**:從 in-memory map 刪 token
- **GetUser**:固定回 `demo-user`email: `demo@visiona.cloud`
- **Register**Phase 0 可回 `ErrNotImplemented` 或直接回 demo-userArchitect 決定)
- **Phase 1 方法**RefreshToken / RequestPasswordReset / ConfirmPasswordReset / Deletestub直接回 `ErrNotImplemented`
這個設計讓前端登入流程能跑通任何帳密都能登入但不涉及真實會員資料。Phase 1 換成 `JWTAuthProvider`(綁 DB + JWT 簽章)時,所有業務邏輯不用動。
### 錯誤類型
必須定義:`ErrUserNotFound``ErrInvalidCredentials``ErrTokenExpired``ErrTokenInvalid``ErrUserAlreadyExists``ErrNotImplemented`Phase 0 stub 用)。
### 使用方式
api-server 啟動時透過 DI 注入 `AuthProvider` 實作:
```go
// Phase 0
authProvider := stub.NewStaticAuthProvider()
// Phase 1
authProvider := jwt.NewJWTAuthProvider(db, jwtSecret)
router := api.NewRouter(authProvider, ...)
```
**Middleware 設計**
```go
router.Use(auth.RequireAuth(authProvider)) // 需登入的路由
```
### 詳細介面定義
見 [features/feature-auth.md § 技術細節](features/feature-auth.md#技術細節給-architect-參考)。
---
## 8.3 SessionStore 介面
### 目的
Tunnel session 狀態管理。api-server 和 remote-proxy 都需要查詢「某 user/pairing token 對應哪個 tunnel session」Phase 0 兩個 binary 共用記憶體(同一個 process 或 shared memoryPhase 1 用 Redis 跨節點共享。
### 必須提供的能力
| 能力 | 說明 |
|------|------|
| CreatePairingToken | 建立一次性 pairing token綁 user_id15 min TTL |
| ConsumePairingToken | 驗證並消耗 pairing token回傳對應 user_id |
| CreateSession | 建立 tunnel session發 session token |
| GetSession | 從 session token 查 session回 user_id、device_id、created_at 等)|
| RevokeSession | 撤銷 session登出或使用者要求撤銷|
| ListSessionsByUser | 查使用者所有活躍 session |
| UpdateSessionHeartbeat | 更新 session 最後心跳時間 |
| CleanupExpired | 清過期 session |
### Phase 0 實作:`MemorySessionStore`
- 單一 process 內用 sync.Map / mutex
- api-server 和 remote-proxy **必須跑在同一個 process**(用 goroutine 起兩個 serverOR
- **共享 memory store 透過 gRPC / HTTP**remote-proxy 呼叫 api-server 的 internal API 查 session
> **Architect 需決策**Phase 0 的 api-server 和 remote-proxy 是一個 process 還是兩個?
> 兩個 binary 就是兩個 process不能共享記憶體。
> 方案 A兩個 process + internal gRPC 同步 session方案 B單 binary 啟動時透過參數選擇角色Phase 0 都跑在一起。
> **建議 Phase 0 用方案 A**(符合最終架構),即便兩個 process 都在本機跑,介面也走 HTTP/gRPC。
### Phase 1 實作:`RedisSessionStore`
- 所有狀態存 Redis
- api-server 和 remote-proxy 都連同一個 Redis
- 支援多節點部署
### 資料模型(邏輯)
```
PairingToken:
- token (string, primary)
- user_id (string)
- created_at (timestamp)
- expires_at (timestamp)
- consumed (bool)
- consumed_at (timestamp, nullable)
Session:
- session_token (string, primary)
- user_id (string)
- device_id (string)
- pairing_token_used (string, FK)
- created_at (timestamp)
- last_heartbeat_at (timestamp)
- revoked (bool)
- revoked_at (timestamp, nullable)
- proxy_node_id (string, Phase 1 for routing)
```
---
## 8.4 ObjectStorage 介面
### 目的
統一模型檔(`.nef`)、未來使用者上傳檔(影片、圖片)、推論結果等二進位資料的儲存。
### 必須提供的能力
| 能力 | 說明 |
|------|------|
| Upload | 上傳檔案到指定 key |
| Download | 下載檔案 |
| Delete | 刪除檔案 |
| List | 列出某 prefix 下的所有 key |
| Exists | 檢查 key 是否存在 |
| GetSize | 取得檔案大小 |
| GetDownloadURL | 取得下載 URLPhase 0 走自己的 APIPhase 1 用 presigned|
| GetUploadURL | Phase 1presigned upload URL |
### Key 規範
採統一的 key schema讓未來換 S3 不需要 migrate
```
models/{user_id}/{model_id}.nef ← 使用者上傳
models/system/{model_id}.nef ← 預設模型(所有 user 共享)
uploads/{user_id}/{upload_id}.{ext} ← 使用者臨時上傳(影片、圖片等)
converter-inputs/{job_id}/{filename} ← 轉檔的來源檔(臨時)
converter-outputs/{job_id}/model.nef ← 轉檔結果
```
### Phase 0 實作:`LocalFSStorage`
- 存在 `./data/storage/{key}`
- `GetDownloadURL` 回傳 `/api/storage/download?key=...`api-server 自己 stream 出去
- 需做 key 合法性檢查(不能 path traversal
### Phase 1 實作:`S3Storage` / `MinIOStorage`
- 標準 AWS S3 SDK 或 minio-go
- 原生 presigned URL
- Bucket 策略:
- `visiona-models`:公開讀權限 for `system/` prefix其他要 presigned
- `visiona-uploads`:全私有
### 檔案大小限制
- Phase 0100 MB記憶體限制
- Phase 1500 MBmodel、5 GBvideo upload
---
## 8.5 ConverterClient 介面
### 目的
呼叫 kneron_model_converter 服務做格式轉檔。Phase 0 / 1 用 stubPhase 2 接真實 API。
### 介面能力
見 [features/feature-converter-integration.md](features/feature-converter-integration.md) 的完整 API 合約。
**核心能力**
- `Submit(ConvertRequest) → ConvertJob`
- `GetStatus(jobID) → ConvertJob`
- `Download(jobID) → io.ReadCloser`
- `Cancel(jobID) → error`
### 現況converter API 尚未存在
**visionA-backend 先定義介面,對 converter 團隊提出 spec 需求**。流程:
1. Phase 0visionA-backend 定義 `ConverterClient` interface + `StubConverterClient` 實作
2. Phase 0PM Agent 把 API spec`feature-converter-integration.md`)正式交付給 converter 團隊
3. Phase 0-1converter 團隊依 spec 實作 API
4. Phase 2visionA-backend 實作 `HTTPConverterClient`,換掉 stub
### Stub 行為
`StubConverterClient`
- Submit 回 fake job_id + status = queued
- GetStatus 第一次回 queued第二次回 processing第三次之後回 completed給 fake download URL
- Download 回一個內建的 fake `.nef`(就是某個預設模型)
- 讓前端能跑完整流程 UI 開發
---
## 8.6 BillingProvider 介面Phase 2
### 目的
抽象計費與訂閱管理,方便 Phase 2 接 Stripe / Paddle / 其他。
### 介面能力
| 能力 | 說明 |
|------|------|
| CreateCustomer | 建立計費客戶 |
| CreateSubscription | 建立訂閱 |
| CancelSubscription | 取消訂閱 |
| UpdateSubscription | 升降級 |
| ReportUsage | 上報使用量usage-based pricing |
| CreateCheckoutSession | 建立結帳 sessionhosted checkout|
| HandleWebhook | 處理 provider webhook付款成功、失敗等|
| GetInvoices | 列出 invoice |
### Phase 0 / 1 都不做
Phase 0 / 1 visionA-backend 不存在任何 billing 相關程式碼。Phase 2 再從頭加。
---
## 8.7 給外部團隊的 API 合約清單
Phase 0 期間,需要對外溝通的契約:
### 對 kneron_model_converter 團隊
**交付文件**[features/feature-converter-integration.md](features/feature-converter-integration.md)
**對方 Action Items**
- 審閱 API spec
- 評估實作時程
- 提供 dev 環境與 test API key
- 決定 webhook 簽章格式
- 決定 rate limit 數字
### 對 local-tool 團隊(同 repo
Phase 0 期間 local-tool **完全不動**。Phase 1 才考慮整合:
- local-tool 新增「配對到 visionA Cloud」功能
- 要共享哪些 Go 套件tunnel client 可能可共用)
### 對 Innovedus IT / DevOps
Phase 0 後期,若要部署 staging 環境,需要:
- 一個子域名(例如 `cloud.visiona.dev`
- TLS 憑證Let's Encrypt
- Docker host 或 AWS account
- DNS 管理權
---
## 8.8 API 版本策略
### URL 版本
所有 visionA Cloud API 採 URL 版本策略:
```
/api/v1/... ← Phase 0 從 v1 開始
/api/v2/... ← 未來 breaking change 時
```
### 相容性
- 同一 major version 內必須向後相容
- 遇到必須 breaking 時開新 major version舊版保留至少 6 個月
- Phase 0 內部使用,允許 v1 小幅變動(不視為 breaking
### WebSocket URL
```
wss://api.visiona.cloud/api/v1/ws/...
wss://relay.visiona.cloud/v1/tunnel/connect
```
---
## 連結
- 上一章:[非功能性需求](nonfunctional.md)
- 下一章:[成功指標](success-metrics.md)
- 跳回:[PRD 索引](PRD.md)

View File

@ -0,0 +1,166 @@
# 3. 市場分析 — visionA Cloud
> 父文件:[PRD.md](PRD.md)
>
> 註Phase 0 雛形階段,市場分析以「**為產品定位提供參照**」為主,不做完整 TAM/SAM/SOM 數字推估。待 Phase 1 MVP 前會做更詳細的市場驗證。
---
## 3.1 市場概述
**邊緣 AI 推論裝置管理市場**可以分成三層:
1. **硬體層**Kneron、NVIDIA Jetson、Google Coral、Hailo、NXP i.MX
2. **模型訓練層**Edge Impulse、SenseCraft AI、Roboflow、kneron_model_converter
3. **推論與部署管理層**本產品所在NVIDIA Triton、AWS IoT Greengrass、Azure IoT Edge、本地自建工具
**市場規模參考數字**(產業報告,非精準值):
- 全球邊緣 AI 晶片市場 2025 約 $10B2030 CAGR ~20%
- 邊緣推論軟體市場 2025 約 $2B成長率略高於硬體
- Kneron 在 NPU 市場屬於中小型玩家,主戰場在亞洲(台灣、日本、中國)+ 美國西岸
**visionA Cloud 的 SAM**Kneron 裝置使用者,保守估算 Kneron 生態系全球開發者 5,000-10,000 人,加上內部 FAE 50 人。Phase 0 目標觸及 ~50 人(內部 + 早期採用者)。
---
## 3.2 競品分析
### 3.2.1 競品清單
| 競品 | 類型 | 和 visionA Cloud 的重疊度 | 威脅等級 |
|------|------|------------------------|---------|
| NVIDIA Triton Inference Server | 推論 Server | 中(不針對邊緣,鎖 NVIDIA 硬體)| 低 |
| Edge Impulse Studio | 訓練 + 部署 | 中(重 training不重遠端操作| 中 |
| SenseCraft AI | 訓練 + 部署 | 中(鎖 Seeed 硬體)| 低 |
| AWS IoT Greengrass | 邊緣設備管理 | 高(設備管理理念相近)| 中 |
| Azure IoT Edge | 邊緣設備管理 | 高(同上)| 中 |
| Kneron 自家工具KneronPLUS SDK| SDK / CLI | 低(命令列工具,沒有 UI| 無 |
| edge-ai-platform POC自家| — | 100%(就是前身)| — |
| local-tool自家| 離線桌面 | 高(同一批用戶)| 互補不衝突 |
### 3.2.2 重點競品深度分析
#### NVIDIA Triton Inference Server
| 維度 | 內容 |
|------|------|
| 定位 | 企業級推論 server跑在資料中心 / 雲端 GPU |
| 優勢 | 極高效能、多框架TF/PyTorch/ONNX、成熟 |
| 劣勢 | 鎖 NVIDIA 硬體、不是為「邊緣裝置遠端管理」設計、學習曲線陡峭 |
| 對我們的啟發 | 他們的 Model Repository、HTTP/gRPC 雙 API 可參考 |
| 威脅 | 不直接 — 不同硬體生態、不同場景 |
#### Edge Impulse Studio
| 維度 | 內容 |
|------|------|
| 定位 | 端到端邊緣 AI MLOps 平台(資料收集 → 訓練 → 部署) |
| 優勢 | 250K+ 開發者、40+ 硬體支援、完整 MLOps |
| 劣勢 | 訓練導向、部署後管理薄弱、Kneron 支援有限 |
| 對我們的啟發 | 他們的 Live Classification類似我們的 workspace體驗很好 |
| 威脅 | 中 — 如果他們加強 Kneron 支援和遠端管理,會直接競爭 |
#### AWS IoT Greengrass
| 維度 | 內容 |
|------|------|
| 定位 | AWS 生態系邊緣運算平台 |
| 優勢 | 企業級、與 AWS IoT Core 整合、大規模 |
| 劣勢 | 綁定 AWS、配置複雜、不是 AI 專用 |
| 對我們的啟發 | 他們的 device pairing / shadow 機制可參考 |
| 威脅 | 中 — 企業客戶可能偏好 AWS 生態系 |
#### edge-ai-platform POC前身
| 維度 | 內容 |
|------|------|
| 定位 | Kneron 內部 POC驗證 relay + cluster 可行性 |
| 優勢 | 已驗證核心技術tunnel、叢集推論|
| 劣勢 | 無 auth、token hardcode、沒有產品化包裝、文件分散 |
| 對我們的繼承 | 搬核心模組relay / tunnel / cluster / wsconn|
---
## 3.3 差異化策略
### 3.3.1 visionA Cloud 的獨特定位UVP
> **「專為 Kneron 邊緣 AI 裝置打造的雲端遠端操作平台 — 不做訓練,不綁雲,就是要讓你打開瀏覽器就能操作自己的 Kneron 裝置。」**
四個核心差異化:
1. **Kneron 專用** → 不分心做 40+ 硬體,深度優化 KL520 / KL720
2. **遠端 tunnel** → 不需要 VPN、公開 IPlocal agent 主動連雲端 WebSocket
3. **叢集推論** → 加權 RR多裝置並行Edge Impulse / SenseCraft 都沒有
4. **和 local-tool 互補** → 同一個使用者可同時使用UI 一致
### 3.3.2 護城河分析
| 護城河 | 強度 | 可維持多久 | 說明 |
|-------|------|-----------|------|
| Kneron 生態系整合 | 高 | 2-3 年 | 我們是 InnovedusKneron 自家),官方渠道優勢 |
| Tunnel 技術yamux over WebSocket | 低 | 6 個月 | 競品可抄 |
| local-tool + cloud 雙模式 | 中 | 1-2 年 | 技術不難,但做到 UI 完全一致需要長期累積 |
| 叢集推論 | 低 | 6 個月 | POC 已展示,競品可抄 |
| 和 kneron_model_converter 整合 | 中 | 1-2 年 | 取決於 converter 團隊的護城河 |
**結論**:最強的護城河是**「Kneron 官方身份 + local-tool 熟客基礎」**,技術護城河薄。需要靠快速迭代 + 生態系整合維持優勢。
### 3.3.3 沒做這個會怎樣(反面論證)
如果不做 visionA Cloud
- FAE 繼續每次出差帶筆電 → 疲勞 + 出錯
- SI 客戶管不了多裝置 → Kneron 採購天花板被限制
- POC 技術被閒置 → 沈沒成本
- 用戶被 Edge Impulse / SenseCraft 吸走 → 生態系流失
---
## 3.4 市場進入策略(簡版)
> 註Phase 0 雛形階段只做內部使用,完整 GTM 策略在 Phase 1 前規劃。
### Phase 02026 Q2
- 對象Kneron 內部 FAE + Innovedus 團隊
- 通路:內部公告、直接拉人測試
- 目標:技術驗證,拿到 5-10 位深度回饋
### Phase 1 MVP2026 Q3
- 對象Kneron 外部生態系中**已知的早期採用者**(從 local-tool / POC 用戶名單找)
- 通路Email 邀請、1:1 onboarding
- 目標100 個 Pairing50 個 WAD
### Phase 22026 Q4+
- 對象Kneron 晶片採購新客戶、Kneron 官網訪客
- 通路Kneron 官網首頁、developer portal 整合、技術部落格
- 目標:北極星指標穩定成長
**在地化策略**
- Phase 0-1繁體中文 + English沿用 local-tool 的 i18n
- Phase 2加入簡體中文、日文主要亞洲市場需求
---
## 3.5 關鍵假設與驗證
| 假設 | 驗證方式Phase 0 | 成功標準 |
|------|-------------------|---------|
| 使用者願意把 local agent 連上雲端 | 內部 FAE 測試 Pairing 流程 | 5/5 FAE 完成 Pairing |
| 企業網路能穿透NAT / Proxy / Firewall | 在不同客戶網路做 tunnel 連線測試 | 至少在 3 種企業網路成功 |
| 推論端到端延遲可接受 | 實測 P95 延遲 | < 500ms local ~300ms tunnel|
| UI 體驗一致性local vs cloud | 讓用過 local 的 FAE 試 cloud | 無需額外學習 |
| 叢集推論對 SI 有價值 | 展示給目標 SI 客戶 | 至少 2 家表達興趣 |
---
## 連結
- 上一章:[產品定位](product-positioning.md)
- 下一章:[用戶研究](user-research.md)
- 跳回:[PRD 索引](PRD.md)

View File

@ -0,0 +1,284 @@
# 7. 非功能性需求 — visionA Cloud
> 父文件:[PRD.md](PRD.md)
---
## 7.1 效能
### 7.1.1 API 回應時間
| 端點類型 | Phase 0 目標 | Phase 1 目標 | 備註 |
|---------|-------------|-------------|------|
| 簡單 API`/api/auth/me``/api/devices` 列表) | P95 < 200ms | P95 < 100ms | 不過 tunnel |
| 裝置操作(透過 tunnel | P95 < 500ms + tunnel RTT | P95 < 300ms + tunnel RTT | tunnel 往返 |
| 模型上傳100MB | P95 < 60 | P95 < 30 presigned| 網路頻寬限制 |
| Pairing 產生 token | P95 < 100ms | P95 < 50ms | |
### 7.1.2 推論效能
| 指標 | Phase 0 目標 | Phase 1 目標 |
|------|-------------|-------------|
| Camera 端到端延遲 P95 | < 500ms | < 350ms |
| Camera FPS | >= 10 fps | >= 20 fps視裝置 + 模型)|
| 首次推論啟動時間 | < 3 | < 2 |
參考值local-tool本機的 Camera 端到端延遲 ~150-250msvisionA Cloud 多出的部分主要是 tunnel WAN RTT。
### 7.1.3 Tunnel 效能
| 指標 | Phase 0 目標 |
|------|-------------|
| Tunnel 建立時間 | < 2 TLS handshake + yamux handshake|
| Tunnel 重連時間 | < 5 斷線偵測到重連成功|
| Tunnel throughput | >= 5 Mbps至少能跑 MJPEG 480p|
| 並發 yamux streams per session | >= 32 |
### 7.1.4 前端效能
| 指標 | Phase 0 目標 |
|------|-------------|
| 首頁 FCP | < 2 cold load|
| 頁面切換 | < 300msApp Router prefetch|
| WebSocket 訊息處理 | < 50ms |
| MJPEG 畫面渲染 | 維持 >= 20 fps |
---
## 7.2 安全性
### 7.2.1 傳輸加密
- **必須使用 TLS**Phase 1 起強制Phase 0 dev 環境可暫用 http
- Tunnel 必須用 `wss://`(不能走明文 WebSocket
- TLS 版本:>= 1.2,建議 1.3
- HSTS headerPhase 1 起)
### 7.2.2 認證與授權
| 需求 | Phase 0 | Phase 1 |
|------|---------|---------|
| 使用者登入 | Stub不安全| JWT + bcrypt |
| Session 管理 | 記憶體 token | Redis + rotation |
| Pairing Token 安全 | 一次性 + 15min TTL | + DB hash 儲存 + rate limit |
| Session Token 安全 | 純字串存記憶體 | + DB hash + 撤銷機制 + rotation |
| API Rate Limiting | ❌ 不做 | ✅ 必做Auth 相關 endpoint|
| CSRF 防護 | ❌cookie-only| SameSite=Strict cookies |
| XSS 防護 | React 自動轉義 | + CSP header |
### 7.2.3 資料隔離
**Phase 0 即須滿足**
- 使用者 A 不能看到使用者 B 的裝置、模型、叢集
- 所有 API endpoint 必須檢查 resource 的 `owner_user_id` vs current user
- WebSocket 連線也要做同樣檢查
### 7.2.4 敏感資料保護
| 資料 | 處理方式 |
|------|---------|
| 密碼 | Phase 0 不存stub 只記 in-memoryPhase 1 bcrypt |
| Session Token | 不 log 出來 |
| Pairing Token 產生後 | 只顯示一次後端不可再查明文Phase 1 存 hash|
| 使用者模型檔 | 存在 ObjectStorage需 user 授權才能下載 |
| 推論影像 / 結果 | Phase 0 不儲存Phase 1 如有儲存需加密 |
### 7.2.5 依賴套件安全
- Go`go mod audit`(或 Snyk每月掃一次
- Node`npm audit` 每週掃一次
- Docker 映像:每月重 build 套新 base image
- 漏洞回應 SLACritical 1 週修High 2 週修
---
## 7.3 可擴展性Scalability
### 7.3.1 Phase 0 定位:可跑得動就好
- 單機部署 OK
- 支援 ~10 同時使用者、~20 裝置同時在線
- 狀態 in-memory重啟就沒了
### 7.3.2 Phase 1 定位:支援 100 人同時使用
- 接 PostgreSQL 作 persistent store
- 接 Redis 作 session store / tunnel session map跨 api-server instance 共享)
- api-server 可水平擴展stateless
- remote-proxy 仍然有狀態tunnel session 在記憶體),但可用 consistent hashing 分散
### 7.3.3 Phase 2+ 定位:支援 1000+ 使用者
- Multi-region 部署
- CDN 前置
- S3 / MinIO 大檔儲存
- remote-proxy 支援 session migration裝置換一個 proxy 節點不會斷)
### 7.3.4 架構預留
**Phase 0 就要預留的擴展點**
- 狀態抽象SessionStore 介面)→ 換 Redis 不動業務邏輯
- AuthProvider 介面 → 換真實 Auth 不動業務邏輯
- ObjectStorage 介面 → 換 S3 不動業務邏輯
- api-server 儘量 stateless除了 Auth 快取)
- config-driven環境變數 / YAML
---
## 7.4 可用性Availability
### 7.4.1 Phase 0 SLO內部使用
| 指標 | 目標 |
|------|------|
| api-server uptime | 95%(有 bug 接受)|
| remote-proxy uptime | 99%(影響面大,較嚴)|
| 前端可訪問 | 99%CDN|
### 7.4.2 Phase 1 SLO外部早期採用者
| 指標 | 目標 |
|------|------|
| Overall uptime | 99%(月最多 7 小時 downtime|
| Tunnel session 存活率 | 99.5% |
| API 成功率 | 99.5% |
### 7.4.3 降級策略
**Tunnel 斷線時**
- 前端顯示「裝置離線」狀態,推論頁面明確提示
- 自動重連(指數退避)
- 不要讓整個 session 失效
**Backend 崩潰時**
- 前端顯示「服務暫時無法使用」Offline Overlay沿用 local-tool 的 pattern
- WebSocket 自動重連
- 使用者重新整理頁面即可恢復
**Converter API 不可用時**Phase 2
- 轉檔功能暫時不可用,但其他功能不受影響
- UI 顯示「轉檔服務暫時中斷」
### 7.4.4 Graceful Shutdown
api-server / remote-proxy 關機時:
- 不再接受新連線
- 已有連線完成後才關
- 廣播 `system.shutdown-imminent` WebSocket 事件讓前端做準備
- (沿用 local-tool 的 `/api/system/shutdown-notify` pattern
---
## 7.5 可觀測性Observability
### 7.5.1 Phase 0 最小要求
- **結構化 log**JSON 格式,含 user_id / request_id / device_id
- **log level**INFO / WARN / ERROR / DEBUG
- 記錄每個 API 請求method、path、status、duration、user_id
- 記錄每個 tunnel session 生命週期事件(建立、斷線、重連)
### 7.5.2 Phase 1 加強
- **Metrics**Prometheus 格式
- HTTP 請求數 / 延遲分佈 by endpoint
- Tunnel session 數 by status
- 推論請求數 by user / device
- 錯誤率 by endpoint
- **Tracing**OpenTelemetry跨 api-server ↔ remote-proxy ↔ agent 的 trace
- **Dashboards**Grafana關鍵指標視覺化
- **Alerting**錯誤率異常、tunnel 大量斷線等
### 7.5.3 Phase 2 加強
- 使用者層級的 analytics需尊重 privacy
- Business metricsWAD、Pairing conversion、推論量
---
## 7.6 相容性與支援
### 7.6.1 瀏覽器支援
| 瀏覽器 | 支援版本 |
|--------|---------|
| Chrome | 最新 3 個版本 |
| Firefox | 最新 3 個版本 |
| Safari | 最新 2 個版本 |
| Edge | 最新 3 個版本 |
| IE | ❌ 不支援 |
### 7.6.2 Local Agent 支援
| 平台 | 支援版本 |
|------|---------|
| macOS | 14+ (x86_64) |
| Windows | 10+ (x64) |
| Linux | Ubuntu 22.04+ (x86_64) |
| ARM | ❌ Phase 0 不支援local-tool 限制)|
### 7.6.3 多語系i18n
- Phase 0繁體中文 + English沿用 local-tool
- Phase 2加簡體中文、日文
### 7.6.4 無障礙a11y
- Phase 0沿用 local-tool 的 a11y 水準Radix UI 內建 a11y
- 基本要求:鍵盤操作可行、重要元素有 aria-label
- Phase 1做 WCAG 2.1 AA 自動化測試axe-core
---
## 7.7 部署與維運
### 7.7.1 部署方式
**Phase 0**
- Docker Compose 本機啟動
- 兩個 container`api-server` + `remote-proxy`
- 前端靜態部署Vercel / Netlify / S3+CloudFront
- 儲存local filesystem
**Phase 1**
- 仍以 Docker 為主
- 加上 PostgreSQL + Redis
- Cloud-agnosticAWS / GCP / self-hosted 皆可)
- 加 ALB / Cloud LB
**Phase 2**
- Kubernetes如有需要多節點
- Multi-region
### 7.7.2 CI/CD
- Phase 0手動 build + deployGitHub Actions on main push 可選)
- Phase 1GitHub Actions每 PR 跑測試merge 自動 deploy 到 staging
- Phase 2Blue/green deployment
### 7.7.3 Backup
- Phase 1 起DB 每日 backup保留 30 天
- 模型檔S3 有 versioning
---
## 7.8 法規與合規Phase 1+ 才認真處理)
Phase 0 內部使用不需要。Phase 1 外部開放前要完成:
- [ ] Terms of Service
- [ ] Privacy Policy明列哪些資料被收集、如何使用
- [ ] Cookie Policy有用 cookie
- [ ] GDPR如果要對歐洲用戶開放
- [ ] 台灣個資法(主場市場)
---
## 連結
- 上一章:[User Stories](user-stories.md)
- 下一章:[介面契約](interface-contracts.md)
- 跳回:[PRD 索引](PRD.md)

View File

@ -0,0 +1,167 @@
# 2. 產品定位 — visionA Cloud vs local-tool vs edge-ai-platform POC
> 父文件:[PRD.md](PRD.md)
---
## 2.1 三個產品的關係圖
```
┌─────────────────────────────────────────────────────────────┐
│ Innovedus 產品線關係 │
│ │
│ edge-ai-platform (POC) ─── Deprecate ────> 2026 Q3 封存 │
│ │ │
│ │ 把核心模組搬過來relay / tunnel / cluster
│ ▼ │
│ visionA Cloud本專案Phase 0 雛形中) │
│ │ │
│ │ │
│ │ 共用同一套前端 UI不同 API base URL
│ │ │
│ ▼ │
│ local-tool離線版繼續維護不動
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## 2.2 三者的定位比較
| 維度 | local-tool | edge-ai-platform POC | visionA Cloud |
|------|-----------|---------------------|---------------|
| 產品形態 | 桌面 AppWails 打包)| 桌面 App + Relay Server | Web SaaS |
| 網路需求 | 完全離線 | 有/無網路皆可 | 必須連網 |
| 目標場景 | 現場 demo、鎖網環境 | POC 實驗 | 遠端操作、多裝置管理 |
| 使用者數 | 單人(本機)| 單人 / POC 小隊 | 多用戶、多租戶 |
| Auth | 無 | 無token hardcode| 有(雛形 stub|
| 模型儲存 | 本機 filesystem | 本機 filesystem | S3-compat 介面 |
| 叢集推論 | ❌ 不支援 | ✅ 有POC 版)| ✅ 有(產品化)|
| Tunnel 遠端 | ❌ 不支援 | ✅ 有token = MAC hash| ✅ 有Pairing Token|
| 維護策略 | **持續維護、不變動** | **停止功能開發、2026 Q3 封存** | **主力產品,長期投入** |
---
## 2.3 核心原則:兩種模式,同一套前端
**使用者不需要選「模式」。** 前端程式碼只有一份,差別在:
```typescript
// local-tool: 前端跑在 Wails WebViewAPI base URL 是 localhost
const API_BASE = "http://127.0.0.1:3721"
// visionA Cloud: 前端跑在瀏覽器API base URL 是雲端
const API_BASE = "https://api.visiona.cloud"
```
實作上:
- visionA-frontend 編譯成兩個產物:
- `build/local/` → 餵給 local-tool 的 Wails embed`API_BASE=localhost`
- `build/cloud/` → 部署到 CDN / 雲端 hosting`API_BASE=api.visiona.cloud`
- 兩者共用 95% 的元件、頁面、store。只有
- API base URL 不同
- Auth 相關頁面(登入 / 註冊 / Pairing**只在雲端版出現**
- local-tool 特有的 Wails 控制台事件**只在離線版處理**(透過 feature flag / build flag
### 為什麼這樣做
1. **不用維護兩份 UI**:改一次 bug 兩邊都好
2. **local-tool 不被動到**Phase 0 階段 local-tool 的前端原始碼不動,等 Phase 1 再評估是否把 visionA-frontend 反向 sync 回 local-tool
3. **使用者體驗一致**:用過 local-tool 的人轉到 cloud 不用重學
### 什麼情況下共享程式碼會破功
- local-tool 的 WebSocket URL 是 `ws://127.0.0.1:3721/ws/...`cloud 是 `wss://api.visiona.cloud/ws/...` — 必須抽到 config
- local-tool 沒有 Authcloud 有 Auth — Auth 相關元件用 feature flag 隱藏
- local-tool 顯示 ServerStatusDashboardWails 控制台cloud 不該顯示 — 用 feature flag
**Design Agent 需留意**:設計規格需要標注哪些元件「只在 cloud 顯示」、哪些「只在 local 顯示」。
---
## 2.4 使用情境對照
### 情境 AFAE 阿哲做客戶 demo
| 步驟 | 傳統做法local-tool | visionA Cloud 做法 |
|------|---------------------|-------------------|
| 準備 | 帶筆電 + Kneron 裝置到客戶現場 | 客戶現場先寄一台筆電 + 裝置過去(或客戶自備),裝 local agent 並 pairing |
| Demo 中 | 筆電螢幕分享給客戶 | 阿哲自己筆電開 visionA Cloud畫面分享給客戶或客戶自己用瀏覽器打開|
| Demo 後 | 收回筆電 | 裝置留在客戶那,阿哲之後繼續用 cloud 管理 |
### 情境 BSI Sarah 管多個客戶現場
| 步驟 | 傳統做法 | visionA Cloud 做法 |
|------|---------|-------------------|
| 佈署 | 每個客戶一台筆電跑 local-toolSarah 要飛去每家 | 每個客戶佈署一台 local agent + pairingSarah 在辦公室集中管理 |
| 監控 | 打電話問客戶「現在裝置狀態?」 | 打開儀表板看所有裝置即時狀態 |
| 叢集 | 做不到 | 把多客戶的裝置組叢集,做加權 RR |
### 情境 C開發者 Mike 做模型 A/B Test
| 步驟 | 傳統做法 | visionA Cloud 做法 |
|------|---------|-------------------|
| 對比 | 手動切換模型一個一個跑 | 叢集推論,同時在 3 台裝置跑不同模型,一個畫面看結果 |
| 轉檔 | 跑去 kneron_model_converter 網站手動轉 | 從 visionA Cloud 點「轉檔」,後端打 converter API |
---
## 2.5 產品邊界(做什麼、不做什麼)
### Phase 0 做:
- ✅ 裝置管理(遠端)
- ✅ 模型管理(上傳、瀏覽)
- ✅ 推論操作Camera / Image / Video / Batch
- ✅ Pairing 流程
- ✅ 基本儀表板
- ✅ 會員系統介面stub雛形不接真的 auth
- ✅ 叢集推論(從 POC 搬)
- ✅ 轉檔整合的 API 契約(等 converter 團隊實作)
### Phase 0 不做:
- ❌ 模型訓練(那是 Edge Impulse / converter 的事)
- ❌ 真實 Auth 系統JWT、OAuth、2FA 等)— 留介面,用 stub
- ❌ 真實資料庫接入 — 用 in-memory
- ❌ 真實 S3 / MinIO — 用 local filesystem 實作 ObjectStorage 介面
- ❌ Billing、使用量計費
- ❌ Multi-region / HA 部署
- ❌ 真實 Converter 整合(等 API 建好再接)
- ❌ ObservabilityPrometheus / Grafana / Tracing
- ❌ Mobile App
### 永遠不做:
- ❌ 取代 local-tool — local-tool 的離線場景是真實需求,會一直存在
- ❌ 模型訓練功能 — 超出產品邊界
- ❌ 非 Kneron 硬體支援 — 違反產品定位
---
## 2.6 對 local-tool 的影響(必須控制)
visionA Cloud 開發過程中,**local-tool 必須保持 0 regression**。
| 項目 | 處理方式 |
|------|---------|
| local-tool 的前端程式碼 | Phase 0 完全不動 |
| local-tool 的後端程式碼 | Phase 0 完全不動 |
| local-tool 的 Go module (`visiona-local`) | 和 visionA-backend 的 module (`visiona-backend` 暫定) **完全獨立** |
| 共用程式碼 | Phase 0 不共用各自維護。Phase 1 再評估是否抽共用 pkg |
| local-tool 測試 | 繼續在 CI 跑,不能因為新專案而停擺 |
**未來Phase 1+)可能的整合方向**(不在本 PRD 範圍):
- 讓 local-tool 同時具備「pairing 上雲」的功能 → 變成同時是離線工具 + cloud agent
- 前端程式碼抽成 monorepo 共用 package
---
## 連結
- 上一章:[產品策略](strategy.md)
- 下一章:[市場分析](market-analysis.md)
- 跳回:[PRD 索引](PRD.md)

View File

@ -0,0 +1,118 @@
# 11. 風險與相依 — visionA Cloud
> 父文件:[PRD.md](PRD.md)
---
## 11.1 風險分析
| # | 風險 | 可能性 | 影響 | 緩解措施 | 負責人 |
|---|------|-------|------|---------|--------|
| R01 | **破壞 local-tool** — visionA Cloud 開發過程中意外改到 local-tool | 中 | 高 | Monorepo 分離目錄、local-tool 完全獨立的 Go module、CI 持續跑 local-tool 測試、code review 嚴格檢查 | Orchestrator |
| R02 | **Tunnel 在企業網路穿透失敗** — NAT / Proxy / Firewall 擋住 WebSocket | 中 | 高 | Phase 0 在至少 3 種企業網路測試、使用標準 443 port + TLS、提供 HTTP CONNECT proxy 支援Phase 1 | Architect |
| R03 | **Pairing Token 洩漏** — token 被截獲,別人接管使用者裝置 | 低 | 高 | 強制 TLS、一次性 + 15min TTL、Phase 1 加 hash 儲存、IP 紀錄、rate limit | Architect |
| R04 | **converter 團隊延遲交付** — 我們等 converter 團隊實作 API | 高 | 中 | Phase 0 用 stub 讓前端流程能走完;轉檔整合 list 在 Phase 2不卡 Phase 1 MVP | Orchestrator / PM |
| R05 | **推論延遲過大** — tunnel 的 WAN RTT 導致推論體驗不如 local-tool | 中 | 中 | 前端延遲透明化、Phase 1 支援就近 region 部署、衡量後如果 P95 > 500ms 考慮降級(只給高速網路用) | Architect |
| R06 | **Scope Creep** — 雛形做著做著功能就暴增 | 高 | 中 | PRD 明確列 Phase 0 Scope、PM 嚴格守門、超過 Scope 的功能一律往 Phase 1 / 2 推 | PM |
| R07 | **in-memory state 讓雛形不能 restart** — Phase 0 的 in-memory session / auth 一 restart 就全部沒 | 高 | 低 | 這是 Phase 0 預期行為Phase 1 換 Redis + DB 解決;使用者教育(內部 FAE 知道雛形就是這樣) | Architect |
| R08 | **資料隔離 bug** — 使用者 A 看到 B 的裝置 / 模型 | 低 | 高 | 所有 API handler 必做 ownership 檢查、Reviewer 特別關注、Phase 1 加整合測試 | Reviewer |
| R09 | **MJPEG over tunnel 效能不足** — yamux 吞吐不夠跑 camera stream | 中 | 中 | Phase 0 早期做 spike test、若不行改用 WebRTC但複雜度高、降低 MJPEG 解析度與 fps | Architect |
| R10 | **UI 雙模式不一致** — local-tool 和 cloud 版前端漸漸 diverge | 中 | 中 | Phase 0 先不共用程式碼Phase 1 規劃 monorepo 共用 package、UI 測試截圖比對 | Frontend |
| R11 | **內部 FAE 缺乏動機測試雛形** — 大家都很忙 | 中 | 中 | 提前協調資源、提供簡單的測試腳本、給測試者獎勵(公開表揚)、主管背書 | PM |
| R12 | **Auth stub 不小心上線給外部** — Phase 0 安全性極弱 | 低 | 極高 | 部署環境用網段限制(只內部 IP 可連)、環境變數明確標 `DEV_MODE=true`、Banner 顯示「雛形」 | DevOps |
| R13 | **converter 團隊改 API spec** — 我們定義的 spec 對方做不出來或要改 | 高 | 低 | Spec review session、版本化v1 / v2、stub 先跑前端 UX 不綁死 | PM |
| R14 | **Kneron USB 驅動在 local agent 跑不穩** — agent 端的 Kneron SDK 掛掉 | 中 | 中 | 這是 local-tool 既有風險不新增agent 端加健康檢查 + 自動重啟 | Local-tool team |
| R15 | **Legal / 合規未處理** — Phase 1 對外開放但沒有 Terms / Privacy | 中 | 高 | Phase 1 前 1 個月開始準備法律文件、用樣板 + 律師審 | PM / 法務 |
---
## 11.2 技術相依
### 11.2.1 對外部團隊的相依
| 相依對象 | 相依項目 | 時間敏感度 | 備案 |
|---------|---------|-----------|------|
| kneron_model_converter 團隊 | 轉檔 API 實作 | Phase 2 才需要 | 用 stub 撐住 Phase 0-1 |
| Innovedus IT | Staging 環境子域名、TLS| Phase 1 前 | Phase 0 在本機跑即可 |
| Kneron 官方 | KneronPLUS SDK 穩定性 | 持續 | 既有依賴,不新增 |
### 11.2.2 對內部專案的相依
| 相依對象 | 相依項目 | 備註 |
|---------|---------|------|
| local-tool | 作為 local agent 使用 | Phase 0 用**未修改版** local-tool 測試Phase 1 才考慮整合新功能 |
| edge-ai-platform POC | 從中搬 relay / tunnel / cluster / wsconn 模組 | Phase 0 完成後 POC 就封存 |
### 11.2.3 對第三方套件的相依
**前端**(沿用 local-tool 版本):
- Next.js 16App Router
- React 19
- Tailwind 4
- Radix UI
- Zustand
- **新增**:無(都沿用 local-tool 的 stack
**後端**(沿用 POC 架構):
- Go 1.26
- Gin
- gorilla/websocket
- hashicorp/yamux
- **新增Phase 0 用 stub但介面要預留**:無實作層面的新依賴
**Phase 1 會新增**
- PostgreSQL / pgx
- Redis / go-redis
- JWT library例如 `golang-jwt/jwt`
- AWS SDK 或 minio-go
- bcrypt
- email librarysendgrid 或 SES
---
## 11.3 Phase 0 的已知限制(透明告知)
使用者(內部 FAE在使用 Phase 0 雛形時需知道:
- **Auth 是 stub**:重啟 backend 後所有 user / session 消失,需重新註冊
- **資料不持久**:上傳的模型在 backend 重啟後會消失local fs 存的話仍在,但 in-memory metadata 會消失)
- **Pairing Token 不撤銷**Phase 0 一旦發出就無法主動撤銷,只能等 15min TTL
- **不支援大規模**:同時 10+ user 可能遇到效能問題
- **安全性極弱**:絕對不可用真實密碼,也不可佈署到公開網際網路
以上限制會在 visionA Cloud 前端明顯位置顯示 Banner「⚠ 雛形版本,僅供內部測試」)。
---
## 11.4 不可跨越的紅線
以下情況必須立即停下專案重新討論:
1. **local-tool regression** — 任何新改動導致 local-tool 既有測試 fail
2. **Auth stub 意外對外** — 任何真實使用者能訪問 Phase 0 環境
3. **資料隔離穿透** — 使用者 A 能看到 B 的資料
4. **Pairing 流程無法在任何企業網路跑通** — 代表核心技術路線需重新評估
5. **Phase 0 超時 2 個月以上** — 代表 Scope 有問題,需重新定義
---
## 11.5 開放問題(待使用者決定)
以下問題目前沒有明確答案,建議在 Phase 0 進行中釐清:
- [ ] **Q1**:雛形環境要部署到哪?本機 / 公司內部 VM / 外部 cloud影響安全設計
- [ ] **Q2**`visionA` 是正式產品名嗎?還是只是代號?(影響 domain 選擇與對外命名)
- [ ] **Q3**Pairing Token 的 TTL 15 分鐘合適嗎?(影響使用者體驗)
- [ ] **Q4**Phase 0 是否允許「一個 user 只能 pairing 一台 agent」的限制簡化設計但影響測試覆蓋
- [ ] **Q5**:內部 FAE 測試後,哪些回饋算是 Phase 0 bug要修哪些推到 Phase 1判準
- [ ] **Q6**:轉檔 API spec 要交給 converter 團隊的正式時機?(影響他們的排程)
---
## 連結
- 上一章:[開發範圍與階段](roadmap.md)
- 跳回:[PRD 索引](PRD.md)

View File

@ -0,0 +1,230 @@
# 10. 開發範圍與階段 — visionA Cloud
> 父文件:[PRD.md](PRD.md)
---
## 10.1 三階段總覽
```
┌─────────────────────────────────────────────────────────────────┐
│ Phase 0本次 Phase 1 Phase 2+ │
│ 雛形 / 骨架 MVP外部早期採用 產品化(商業化) │
│ 2026 Q2 2026 Q3 2026 Q4+ │
│ │
│ • 跑得動 • 接真 Auth • Billing │
│ • 介面清楚 • 接真 DB • 轉檔整合(真) │
│ • 雙模式共存 • 接真 Storage • 多租戶 / 團隊 │
│ • 內部 FAE 測試 • 叢集功能 • 多區域部署 │
│ • local-tool 不動 • 圖片/影片推論 • 公開 API │
│ • Auth 是 stub • 外部 ~100 用戶 • 正式上線 │
└─────────────────────────────────────────────────────────────────┘
```
---
## 10.2 Phase 0本次雛形2026 Q2
### 10.2.1 目標
**一句話**visionA Cloud 的架構骨架 + 基本頁面 + API Server + Remote Proxy 跑得動,使用者能在瀏覽器完成「註冊 → 配對 → 推論」流程,但 Auth / DB / Storage 都是 stub。
### 10.2.2 範圍Scope
**必做P0**
- visionA-frontend 骨架 + P0 頁面(登入、註冊、首頁、裝置、模型、工作區、設定)
- visionA-backend `cmd/api-server`(對前端的 REST + WebSocket
- visionA-backend `cmd/remote-proxy`(從 POC 搬 relay升級 Pairing Token
- Pairing 流程端到端(至少手動編輯 local-tool config 能跑通)
- Camera 推論端到端(透過 tunnel
- 模型上傳local fs 實作 ObjectStorage 介面)
- 會員系統 **stub**in-memory AuthProvider
- SessionStore **in-memory 實作**
- ConverterClient **stub 實作**(讓前端流程可走完)
- 前端 `/clusters` 頁面預留(可顯示「即將推出」)
- i18n 繁中 + English沿用 local-tool
- 基本的 CI至少 unit test 跑得過)
**明確不做**
- 真 AuthJWT / OAuth / DB
- 真 DBPostgreSQL
- 真 S3 / MinIO
- 叢集推論 API只搬 `internal/cluster/` 模組)
- 儀表板時間軸與統計(只做快速開始版)
- 圖片 / 影片 / Batch 推論(只做 Camera
- 轉檔 API 真實對接
- Billing
- ObservabilityPrometheus / Grafana / Tracing
- 正式部署到雲端dev 環境能跑就好)
### 10.2.3 里程碑
| 週 | 里程碑 | 產出 |
|---|-------|------|
| W1-2 | 三方文件完成 | PRD、Design Spec、TDD 全部通過三方審閱 |
| W3 | 骨架搭起來 | monorepo 結構建好、兩個 binary 能啟動、前端能打開登入頁 |
| W4 | Auth stub + 前端 P0 頁面切換 | 能註冊 → 登入 → 看到空 dashboard |
| W5-6 | Pairing 流程 + Tunnel 重搬 | 用 test local-tool修改版 config能 pairing 成功 |
| W7 | Camera 推論跑通 | 至少一位 FAE 能遠端看到 Camera + overlay |
| W8 | 模型管理 + 上傳 | ObjectStorage 介面完成,上傳 `.nef` 可用 |
| W9 | 內部 FAE 測試 | 5+ FAE 完成端到端推論 |
| W10 | 修 bug + Phase 0 驗收 | 達成所有驗收條件,進入 Phase 1 規劃 |
### 10.2.4 Phase 0 TODO 總清單(匯整)
所有 Phase 0 留下的 TODO統一列在此處交給 Orchestrator 追蹤:
**Auth 相關**
- TODO-AUTH-01換 JWTAuthProvider
- TODO-AUTH-02DB schemausers / sessions
- TODO-AUTH-03Email 驗證流程
- TODO-AUTH-04密碼重設流程
- TODO-AUTH-05OAuthGoogle / GitHub
- TODO-AUTH-062FA
- TODO-AUTH-07密碼強度規則
- TODO-AUTH-08Rate limiting
- TODO-AUTH-09Account 刪除
- TODO-AUTH-10個人設定頁完整功能
- TODO-AUTH-11Role / Permission
- TODO-AUTH-12API Key 管理
**Storage 相關**
- TODO-STO-01S3/MinIO 實作
- TODO-STO-02Presigned URL 上傳
- TODO-STO-03模型版本管理
- TODO-STO-04檔案掃毒
**Session / Tunnel 相關**
- TODO-SESS-01Redis SessionStore 實作
- TODO-SESS-02Session Token rotation
- TODO-SESS-03Session 撤銷功能
- TODO-SESS-04多節點 remote-proxy + consistent hashing
- TODO-SESS-05Tunnel 斷線事件通知優化
**Pairing 相關**
- TODO-PAIR-01local-tool 內建 Pairing UI
- TODO-PAIR-02QR code 生成
- TODO-PAIR-03Pairing 成功後的使用者引導
**Converter 相關**
- TODO-CONV-01正式對接 converter API
- TODO-CONV-02Webhook 簽章驗證
- TODO-CONV-03轉檔進度 UX 優化
- TODO-CONV-04支援格式擴充初期只支援 ONNX
**功能擴充**
- TODO-FEAT-01圖片 / 影片 / Batch 推論
- TODO-FEAT-02叢集推論 API 實作與 UI
- TODO-FEAT-03儀表板時間軸與統計
- TODO-FEAT-04多 client 看同一推論的效能優化
**可觀測性**
- TODO-OBS-01Prometheus metrics
- TODO-OBS-02Grafana dashboard
- TODO-OBS-03OpenTelemetry tracing
- TODO-OBS-04Log 聚合ELK / Loki
**部署**
- TODO-DEP-01真的雲端部署staging + production
- TODO-DEP-02TLS 憑證
- TODO-DEP-03DNS / Subdomain
- TODO-DEP-04CDN前端
- TODO-DEP-05CI/CD pipeline
**商業化Phase 2**
- TODO-BIZ-01Billing 介面 + Stripe 整合
- TODO-BIZ-02定價方案設計
- TODO-BIZ-03Terms / Privacy Policy
- TODO-BIZ-04GDPR / 台灣個資法合規
---
## 10.3 Phase 1 MVP2026 Q3外部早期採用者
### 10.3.1 目標
- 接真 AuthJWT + DB
- 接真 StorageS3 或 MinIO
- 完成 Pairing、叢集、儀表板功能
- 部署到真的雲端環境
- 對外開放給 ~100 位早期採用者
### 10.3.2 主要交付
- 替換所有 Phase 0 stub 為真實作
- 叢集推論 UI + API
- 圖片 / 影片 / Batch 推論
- 真實部署staging + production
- CI/CD
- 基本 Observability
- User Story US-14 ~ US-23 全部完成
### 10.3.3 成功標準
- WAD >= 30
- Pairing 轉換率 > 60%
- 推論延遲 P95 < 400ms
- 系統 uptime > 99%
---
## 10.4 Phase 2+2026 Q4 起,產品化)
### 10.4.1 範圍
- 轉檔整合完成converter API 真實對接)
- BillingStripe
- 多租戶 / 團隊功能
- Multi-region 部署
- 公開 APIfor Mike 這類獨立開發者)
- Mobile appread-only看裝置狀態
- 合規文件Terms / Privacy / GDPR
- Marketing site 整合
### 10.4.2 成功標準
- MRR $5K+
- 付費用戶 50+
- NPS > 40
---
## 10.5 策略性路線圖Now / Next / Later
### Now本季Phase 0
- 雛形架構跑得動
- 內部 FAE 測試通過
- 介面契約穩定
### Next下季Phase 1 MVP
- 真實 Auth / DB / Storage
- 叢集推論產品化
- 部署到雲端
### Later6-12 個月Phase 2+
- 商業化 Billing
- 轉檔整合
- Mobile + 公開 API
- 多區域
---
## 連結
- 上一章:[成功指標](success-metrics.md)
- 下一章:[風險與相依](risks.md)
- 跳回:[PRD 索引](PRD.md)

View File

@ -0,0 +1,174 @@
# 1. 產品策略 — visionA Cloud
> 父文件:[PRD.md](PRD.md)
---
## 1.1 產品願景(模擬新聞稿)
### 標題visionA Cloud 正式推出 — 讓 Kneron 邊緣 AI 裝置「人在家中坐,推論千里來」
### 副標題:為 Kneron FAE 與生態系開發者打造的雲端操作平台,瀏覽器即能遠端管理、監控、推論自己的 Kneron 裝置
**問題背景**
Kneron KL520 / KL720 是強大的邊緣 AI 推論晶片,但開發者與 FAE 在實際使用時有三個痛點:
1. **demo 現場得帶筆電**:目前唯一穩定的開發工具是 local-tool 離線版demo 必須把整台筆電搬去客戶那。
2. **多裝置難以集中管理**:一個 SI 客戶可能同時在不同分店佈署 5 台 Kneron沒有統一的遠端介面。
3. **叢集推論只有 POC**edge-ai-platform POC 展示了多裝置加權 round-robin 的潛力,但沒有正式產品化。
**visionA Cloud 的解法**
visionA Cloud 是一個雲端 SaaS。使用者在自己的筆電、工廠機台、店頭機裝上「local agent」即現有 local-tool透過 Pairing Token 把裝置配對到雲端帳號。之後在任何瀏覽器開啟 visionA Cloud就能看到自己所有裝置、上傳模型、啟動推論跟操作本機一樣流暢 — 但**人不用在裝置旁邊**。
**典型用戶體驗引述**
> 「以前去客戶做 POC要帶一整個後背包裝筆電。現在我筆電放辦公室裝置留在客戶現場我在咖啡廳開瀏覽器就能跑推論給客戶看 Teams 螢幕分享。體驗完全不同。」
>
> — 阿哲Kneron FAE
**如何開始使用**
1. 在 visionA Cloud 註冊帳號
2. 筆電上安裝 visionA local agent即 local-tool未來會內建 Pairing 功能)
3. 從雲端頁面取得 Pairing Token貼進 local agent
4. 刷新雲端頁面,裝置自動出現,開始推論
### FAQ
- **Q這跟 local-tool 有什麼不同?**
Alocal-tool 是離線桌面 app適合網路鎖死的 demo 場景visionA Cloud 是雲端 SaaS適合需要遠端操作、跨裝置管理的場景。**兩者共用同一套 UI 與核心功能**,使用者可以自由選擇或同時使用。
- **Q和 Edge Impulse、SenseCraft 比?**
A他們是「訓練模型 + 部署到裝置」的通用平台我們是「Kneron 專用 + 雲端遠端存取」的操作平台。我們不做模型訓練,只做已訓練好的 `.nef` 模型的部署與推論操作(但有提供對接 kneron_model_converter 的介面)。
- **Q定價如何**
APhase 0 雛形階段免費內部使用。商業模式(訂閱 / 按推論次數 / freemium在 Phase 2 規劃Phase 0 只定介面不接金流。
- **Q什麼時候可以用**
APhase 0 雛形目標 2026 Q2 完成架構跑得動、基本頁面可用、in-memory auth。Phase 1 MVP 目標 2026 Q3接真 Auth/DB/Storage
---
## 1.2 目標用戶
### 主要用戶群Phase 0 / Phase 1
1. **Kneron 內部 FAE 與 Innovedus 業務**~50 人)
- 最熟悉 Kneron 裝置的人,常跑客戶現場
- 容忍雛形不穩,會給詳細回饋
2. **Kneron 生態系 ISV / SI 開發者**~數百人)
- 已經在用 local-tool 或 edge-ai-platform POC 的現有用戶
- 痛點明確:多客戶多裝置要集中管理
### 次要用戶群Phase 2
3. **試用 Kneron 晶片的新開發者**(長尾)
- 從 visionA Cloud 這個「門面」第一次接觸 Kneron
- 對他們而言,雲端門檻比離線桌面 app 低
### 非目標用戶(明確排除)
- ❌ 想做**模型訓練**的人 — 他們應該用 Edge Impulse 或 kneron_model_converter
- ❌ 想部署**非 Kneron 硬體**的人 — 不在產品範圍
- ❌ 企業級 MLOps / 大規模 inference production — 我們不是 NVIDIA Triton 競品
---
## 1.3 核心問題與價值主張
### 問題(用戶視角)
| 問題 | 現況 |
|------|------|
| P1Kneron 裝置必須連實體筆電才能操作 | local-tool 是桌面 app使用者人不在時沒辦法用 |
| P2多裝置沒有集中式管理 | 一個 FAE 手上可能有 3-5 台機台,要一台一台開 local-tool |
| P3POC 的叢集推論沒產品化 | 加權 round-robin 很好用,但 POC 的 auth / 多租戶 / token 都沒做 |
| P4非 Kneron 格式的模型要手動轉檔 | kneron_model_converter 目前是獨立網站,使用者體驗斷裂 |
### 價值主張
**對開發者**:用瀏覽器就能操作 Kneron 裝置,不用被綁在實體機器旁。
**對 SI / FAE**:一個帳號管所有客戶現場的裝置,一個畫面看到全貌。
**對 Kneron 生態系**:降低 Kneron 的使用門檻,讓「試用 Kneron」不用裝一堆東西。
### 為什麼是現在做
1. **local-tool 已穩定**UI 與業務邏輯成熟,前端可以直接搬
2. **edge-ai-platform POC 驗證了核心技術**WebSocket + yamux tunnel、叢集推論都跑得動
3. **Kneron 生態系正在成長**:需要一個「雲端門面」承接新用戶
4. **kneron_model_converter 團隊要整合**visionA Cloud 是整合入口
---
## 1.4 OKRPhase 0 雛形階段)
### ObjectiveO建立 visionA Cloud 的技術基座與產品框架
- **KR1**Phase 0 雛形 2026 Q2 完成,包含以下可驗收產出
- visionA-frontend 所有 P0 頁面可打開、可操作(接 mock 或真 API
- visionA-backend 兩個 binaryapi-server + remote-proxy可起得來
- 至少一個 local-tool 可透過 Pairing Token 連上 visionA Cloud並跑通一次端到端推論
- **KR2**Phase 0 內部測試,至少 5 位 Kneron FAE 完成 Pairing + 推論測試
- **KR3**介面契約文件interface-contracts.md完成與 converter 團隊對齊一次 API spec
### ObjectiveO為 Phase 1 MVP 打好基礎
- **KR1**:所有 TODO 項目Auth / DB / Storage / Converter有明確的「替換點」與介面定義
- **KR2**:文件完整度 — PRD / Design Spec / TDD 三方交叉審閱通過
- **KR3**:避免 local-tool 被破壞 — local-tool 的所有既有測試持續通過0 regression
---
## 1.5 北極星指標與指標體系
### 北極星指標(長期)
**每週活躍裝置數Weekly Active Devices, WAD**
定義:過去 7 天內,至少成功完成一次推論的已 pairing 裝置數。
為什麼選這個:
- 對 B2B / 開發者工具WAUUser容易膨脹人來註冊就算但 WADDevice代表**真正在用**
- Kneron 裝置本身是硬體投資,裝置被使用 = 用戶正在從中獲得價值
- 一個用戶多個裝置 / 多個用戶共用一個裝置都反映在這個指標
### 指標體系
```
WAD每週活躍裝置數
├── 驅動指標:新 Pairing 數、每週人均推論次數、裝置留存率
│ ├── 輸入指標:註冊到 Pairing 的轉換率
│ ├── 輸入指標Pairing 後 24 小時內首次推論率
│ ├── 輸入指標:每週上傳模型數
│ └── 輸入指標:叢集建立數
└── 護欄指標(不能惡化的):
├── 推論端到端延遲 P95 < 500ms local-tool ~300ms tunnel overhead
├── Tunnel session uptime > 99%
├── API 錯誤率 < 1%
└── local-tool regression bug 數 = 0
```
### Phase 0 可追蹤的指標(簡化版)
Phase 0 雛形只追蹤最小集:
| 指標 | 目標值 | 追蹤方式 |
|------|--------|---------|
| Pairing 成功率 | > 90% | Server log |
| 端到端推論可跑 | Yes | 手動測試 |
| API Server 崩潰 | 0 次 / 週 | Log / Monitoring |
| local-tool 測試全過 | 100% | CI |
其他指標等 Phase 1 接 DB 後才開始埋點追蹤。
---
## 連結
- 下一章:[產品定位](product-positioning.md)
- 或跳回:[PRD 索引](PRD.md)

View File

@ -0,0 +1,178 @@
# 9. 成功指標 — visionA Cloud
> 父文件:[PRD.md](PRD.md)
---
## 9.1 北極星指標
### 長期Phase 2+每週活躍裝置數WAD
**定義**:過去 7 天內至少完成一次成功推論的已配對裝置數量。
**為什麼選這個**
- B2B 開發者工具WAU 容易膨脹人來註冊就算WADDevice代表真正有在用 Kneron 硬體
- Kneron 晶片本身是硬體 BOM 投資,裝置被使用 = 客戶從中獲得價值 = 會繼續買更多 Kneron
- 一個 SI 管 10 台裝置,這指標真實反映他產生的價值
**計算公式**
```
WAD = COUNT(DISTINCT device_id)
WHERE last_inference_at >= NOW() - 7 days
AND paired = true
```
---
## 9.2 Phase 0雛形可追蹤指標
Phase 0 不接 DB所以只能追最小集都從 log 抓:
| 指標 | 目標 | 追蹤方式 |
|------|------|---------|
| Pairing 成功率 | > 90% | Log成功 Pairing 數 / 嘗試 Pairing 數 |
| 內部 FAE 完成端到端推論的人數 | >= 5 | 手動記錄 |
| API Server 崩潰次數 | 0 / 週 | Log / Monitoring |
| Remote Proxy 崩潰次數 | 0 / 週 | Log / Monitoring |
| local-tool regression bug | 0 | local-tool CI |
| Phase 0 驗收條件達成率 | 100% | 參考各 feature 的驗收條件清單 |
---
## 9.3 Phase 1 MVP 指標(接 DB 後開始埋點)
### 獲客Acquisition
| 指標 | MVP 3 個月目標 | 6 個月目標 |
|------|--------------|----------|
| 註冊 user 數(累計)| 50 | 200 |
| Pairing 裝置數(累計)| 30 | 150 |
### 啟用Activation
| 指標 | 定義 | MVP 目標 |
|------|------|---------|
| Pairing 轉換率 | 註冊後 24 小時內成功 Pairing / 註冊人數 | > 60% |
| 首次推論轉換率 | Pairing 後 24 小時內完成首次推論 / Pairing | > 70% |
| Activation Rate | 註冊後 7 天內完成首次推論 / 註冊人數 | > 40% |
### 留存Retention
| 指標 | MVP 目標 |
|------|---------|
| D7 留存率user| > 30% |
| D30 留存率user| > 20% |
| W1 留存率device一週內再使用| > 60% |
### 使用量Engagement
| 指標 | MVP 目標 |
|------|---------|
| 每週人均推論次數 | > 50 次 |
| 每週活躍使用者WAU | > 50 |
| **每週活躍裝置WAD北極星** | > 30 |
---
## 9.4 護欄指標(不能惡化的)
### Phase 0
| 指標 | 門檻(不可超過)|
|------|--------------|
| 推論端到端延遲 P95 | < 500ms |
| Tunnel 建立失敗率 | < 10% |
| API 5xx 錯誤率 | < 5% |
| local-tool regression | 0 |
### Phase 1
| 指標 | 門檻 |
|------|------|
| 推論端到端延遲 P95 | < 400ms |
| Tunnel 建立失敗率 | < 3% |
| Tunnel session uptime | > 99% |
| API 5xx 錯誤率 | < 1% |
| 頁面 FCP | < 2 |
| Pairing Token 洩漏事件 | 0 |
| 資料隔離 bugA 看到 B 的資料)| 0 |
---
## 9.5 Phase 2+ 長期指標
### 商業指標(如啟用 Billing
| 指標 | 12 個月目標 |
|------|-----------|
| MRR | $5K |
| 付費用戶數 | 50 |
| 付費轉換率(註冊 → 付費)| > 5% |
| 月流失率 | < 10% |
| NPS | > 40 |
### 生態系指標
| 指標 | 目標 |
|------|------|
| 跨 Phase 1 的用戶 60%+ 每月至少 pairing 2 個裝置 | ✅ |
| 叢集功能被活躍使用(有 10+ 個 user 在用叢集)| ✅ |
| 轉檔整合完成Phase 2| ✅ |
---
## 9.6 指標體系視覺化
```
WAD每週活躍裝置← 北極星
├── 驅動指標:
│ ├── 新 Pairing 數(每週)
│ ├── 人均裝置數(用戶規模化)
│ └── 人均推論次數(使用深度)
│ │
│ ├── 輸入Pairing 轉換率
│ ├── 輸入:首次推論轉換率
│ ├── 輸入:推論延遲(體驗品質)
│ └── 輸入Tunnel 穩定性
└── 護欄:
├── 技術護欄延遲、錯誤率、uptime
├── 安全護欄token 洩漏、資料隔離)
└── 產品護欄local-tool 0 regression
```
---
## 9.7 Phase 0 判定 Go / No-Go 的標準
Phase 0 完成後,判定是否進 Phase 1 的標準:
### 必達(硬性條件)
- [ ] 所有 P0 user stories 驗收條件達成
- [ ] 至少 5 位內部 FAE 完成端到端推論
- [ ] local-tool regression = 0
- [ ] 三方交叉審閱PM / Design / Architect通過
### 軟性條件(沒達到也可推進,但要改善)
- [ ] Pairing 成功率 > 90%
- [ ] 端到端延遲 P95 < 500ms
- [ ] 內部 FAE 滿意度 >= 7/10簡短訪談
- [ ] 無重大 crash / 安全性問題
### 退場標準(達成就不再推進)
- [ ] Tunnel 技術在真實企業網路NAT / Proxy / Firewall穿透率 < 30%
- [ ] 內部測試發現核心體驗明顯不如 local-tool延遲過大且無法優化
---
## 連結
- 上一章:[介面契約](interface-contracts.md)
- 下一章:[開發範圍與階段](roadmap.md)
- 跳回:[PRD 索引](PRD.md)

View File

@ -0,0 +1,139 @@
# 4. 用戶研究 — visionA Cloud
> 父文件:[PRD.md](PRD.md)
---
## 4.1 用戶 Persona
### Persona 1阿哲 — Kneron FAE主要用戶
| 項目 | 內容 |
|------|------|
| 角色 | Kneron FAEField Application Engineer|
| 年齡 / 背景 | 32 歲資工本科5 年嵌入式系統經驗 |
| 工作內容 | 跑客戶 demo、POC 支援、技術諮詢 |
| 目標 | 把 Kneron 晶片賣出去,讓客戶相信 Kneron 能做到 |
| 痛點 | 1. 出差要帶整台筆電 + 裝置,體力活<br>2. demo 環境每次不同,配置繁瑣<br>3. 客戶問「能不能遠端來看進度?」目前說不行 |
| 行為模式 | 每週 2-3 次出差,每次 demo 1-3 小時demo 後客戶還會持續發問 |
| 技術素養 | 高Linux、Python、C++|
| 願意付費 | 公司付,不在意 |
| 一句話描述 | 「我希望能**遠端讓客戶看到 Kneron 跑推論**,這樣我就可以**不用飛來飛去**。」 |
### Persona 2Sarah — SI 系統整合商(主要用戶)
| 項目 | 內容 |
|------|------|
| 角色 | SI 技術主管(系統整合商)|
| 年齡 / 背景 | 38 歲,電機本科,資深工程師轉管理 |
| 工作內容 | 把 Kneron 導入客戶專案(例如零售店頭人流分析、工廠瑕疵檢測)|
| 目標 | 讓客戶的 Kneron 部署順利運轉,減少現場支援 |
| 痛點 | 1. 一個專案 3-10 台 Kneron 佈在不同地點,沒統一畫面<br>2. 客戶回報「裝置怪怪的」只能派人去現場<br>3. 模型改版要逐台更新 |
| 行為模式 | 每週管 2-5 個專案,每個專案生命週期 3-12 個月 |
| 技術素養 | 高,自己帶一個 3-5 人的工程團隊 |
| 願意付費 | 願意(公司成本),但要有明顯 ROI省出差費 / 人力)|
| 一句話描述 | 「我希望能**一個儀表板看到所有客戶現場的 Kneron 狀態**,這樣我就可以**少派工程師出差**。」 |
### Persona 3Mike — AI 應用開發者(次要用戶)
| 項目 | 內容 |
|------|------|
| 角色 | 獨立開發者 / 新創 ML engineer |
| 年齡 / 背景 | 28 歲資工碩士2 年 ML 經驗 |
| 工作內容 | 開發 AI 應用原型,評估不同邊緣硬體(在 Jetson、Coral、Kneron 間比較)|
| 目標 | 找到性價比最好的硬體 + 模型組合 |
| 痛點 | 1. 想同時跑多個模型比效能,但 local-tool 一次只能一個<br>2. 轉檔要去 converter 網站,使用者體驗斷裂<br>3. 沒辦法和隊友共享推論結果 |
| 行為模式 | 每天開發 4-6 小時2-4 週評估一次硬體 |
| 技術素養 | 高PyTorch、ONNX、熟悉 ML pipeline|
| 願意付費 | 個人用戶willing-to-pay 低(< $50/mo但公司預算可以 |
| 一句話描述 | 「我希望能**在一個介面跑 A/B test 不同模型**,這樣我就可以**快速選出最佳方案**。」 |
---
## 4.2 用戶旅程地圖
### 主要旅程:阿哲第一次使用 visionA Cloud
| 階段 | 用戶行為 | 想法 / 感受 | 痛點 | 機會點 |
|------|---------|-----------|------|--------|
| **認知** | 聽到內部公告「visionA Cloud 雛形可試用」| 「終於有雲端版了,以前一直說要做」| 不知道和 local-tool 有什麼差別 | 用清楚的對照表說明定位 |
| **註冊** | 打開 visionA Cloud 首頁 → 輸入公司 email | 「希望不要填太多欄位」| 表單太長會跳出 | Phase 0 雛形:只要 email + 密碼,其他 TODO |
| **Pairing** | 登入後看到空的裝置列表 → 點「配對新裝置」→ 複製 Pairing Token → 打開筆電上的 local-tool → 貼 token | 「步驟不能太多5 步內要搞定」| 不確定 local-tool 該從哪裡貼 token | Phase 1local-tool 要內建「Pairing」UIPhase 0TODO手動編輯 config |
| **首次推論** | 配對成功 → 裝置列表出現 → 選裝置 → 選模型 → 選 Camera 來源 → 開始推論 | 「畫面跟 local-tool 一樣,直覺」| 遠端有延遲,會不會卡 | 在 UI 明確顯示連線狀態和延遲 |
| **持續使用** | 每週 demo 前開 cloud 確認裝置在線 | 「和本機一樣順」| Tunnel 斷線重連體驗 | 自動重連 + 狀態透明 |
| **推薦** | 和其他 FAE 分享 | 「你也試試,不用帶筆電了」| — | 內建「邀請隊友」功能Phase 2|
### 次要旅程Sarah 導入 visionA Cloud 管理多客戶現場
| 階段 | 用戶行為 | 痛點 | 機會點 |
|------|---------|------|--------|
| 認知 | 從 Innovedus 業務聽到產品 | 擔心可靠性(企業專案不能當機)| 強調雙模式 — cloud 斷線時還有 local-tool 可用 |
| 評估 | 在內部測試環境試 Pairing | 企業網路 proxy 可能擋 WebSocket | TDD 要規劃 proxy / TLS 穿透測試 |
| 導入 | 逐個客戶現場佈署 local agent | 客戶 IT 可能要審 | 提供安全白皮書Phase 1|
| 日常 | 每天早上開儀表板巡房 | 裝置離線沒通知 | Phase 1加 alerting / email 通知 |
| 升級 | 介紹給下一個客戶 | — | 打造 SI-friendly pricingPhase 2|
---
## 4.3 關鍵洞察
### 洞察 1使用者不想學新 UI
兩個 Persona 都是已在用 local-tool 的人。visionA Cloud 的 UI **必須和 local-tool 幾乎一樣**,只加上必要的雲端元素(裝置列表含「遠端 / 本機」狀態、登入頁、Pairing 頁)。
**Design Agent 請留意**:設計規格 90% 參考 local-tool 現有頁面,新增的只有 `/login``/register``/account``/pair``/clusters`(從 POC 搬),其他頁面**保持一致**。
### 洞察 2連線狀態必須極度透明
local-tool 是 localhost連線成功率 99.99%。visionA Cloud 過 tunnel連線會斷會重連。使用者對「遠端不可靠」有心理預期但**不透明的斷線最讓人抓狂**。
**設計要求**
- 裝置狀態要明確分「在線 / 離線 / tunnel 斷線重連中」
- 推論中如果 tunnel 斷,要立即提示並自動重連
- 延遲要顯示(例如 header 上顯示「tunnel RTT: 120ms」
### 洞察 3Pairing 是最大摩擦點
從「註冊」到「首次推論」的轉換漏斗中,**Pairing 那一步最容易掉用戶**。使用者要跨兩個介面(瀏覽器 + 筆電 local-tool要複製貼上 token。
**Phase 0 的妥協**:允許 token 手動編輯 local-tool config 檔(給技術高素養用戶)。
**Phase 1 的理想**local-tool 內建「配對到 visionA Cloud」按鈕瀏覽器 callback 自動帶 token 過去。
### 洞察 4SI 最在意的是「少派人出差」
對阿哲FAE核心價值是「自己少累一點」。對 SarahSI核心價值是「團隊少派工程師出差」這是**可量化的成本節省**。
**Phase 1 行銷素材**可以用這個角度:「每月省下 X 次出差 = 省 Y 元 + Z 天工程師時間」。
### 洞察 5Mike 是次要但不能忽視的用戶
Mike獨立開發者不是主力但他們是**未來 SI 和 FAE 的潛在來源**(獨立開發者可能被 Kneron 挖角或進 SI。而且 Mike 的使用量高(每天開發),對 UX 細節敏感。
Phase 0 不會針對 Mike 做客製,但 Phase 2 要考慮:
- 個人訂閱方案(比 SI 方案便宜)
- 模型 A/B 比較功能workspace 升級)
- 開放 API讓 Mike 寫自動化腳本)
---
## 4.4 未回答的問題(需要用戶訪談)
Phase 0 後期 / Phase 1 前,建議做 5-10 次用戶訪談,回答以下問題:
- [ ] SI 客戶的 IT 政策有多嚴NAT / Proxy / TLS 要求)
- [ ] FAE 做 demo 時 tunnel 斷一次能忍受嗎?還是直接失敗?
- [ ] 使用者期待的 pairing token 生命週期是多久1 小時7 天?永久?)
- [ ] 叢集推論是否真的對 SI 有價值?或只是 nice-to-have
- [ ] 使用者是否願意把模型(可能是商業機密)上傳到 visionA Cloud 的 S3
這些問題的答案會影響 Phase 1 的功能優先級與 Auth / Security 設計。
---
## 連結
- 上一章:[市場分析](market-analysis.md)
- 下一章:[User Stories](user-stories.md)
- 跳回:[PRD 索引](PRD.md)

View File

@ -0,0 +1,120 @@
# 5. User StoriesRICE 排序)— visionA Cloud
> 父文件:[PRD.md](PRD.md)
---
## 5.1 RICE 評分說明
| 維度 | 定義 | 範圍 |
|------|------|------|
| Reach | 每季觸及的用戶數粗估Phase 0 用戶基數小,數字為相對值)| 1-100 |
| Impact | 對北極星指標WAD的影響程度 | 0.25 / 0.5 / 1 / 2 / 3 |
| Confidence | 估算信心度 | 50% / 80% / 100% |
| Effort | 預估開發人月Architect 會再核對)| 0.1 - 10 |
| **RICE** | Reach × Impact × Confidence / Effort | — |
> 註Phase 0 雛形的 Reach / Effort 是**相對估值**,不是絕對數字。主要目的是排優先級,不是財務預估。
---
## 5.2 所有 User StoriesRICE 排序)
### 🔴 P0 — Phase 0 雛形必做
| # | Story | Persona | Reach | Impact | Conf. | Effort | RICE | 備註 |
|---|-------|---------|-------|--------|-------|--------|------|------|
| US-01 | 作為開發者,我要**在瀏覽器打開 visionA Cloud 並看到登入頁** | 全部 | 100 | 3 | 100% | 0.5 | 600 | Phase 0 雛形UI onlyauth stub |
| US-02 | 作為開發者,我要**註冊帳號(雛形只要 email + 密碼)** | 全部 | 100 | 3 | 100% | 0.3 | 1000 | Phase 0in-memory不存 DB |
| US-03 | 作為開發者,我要**登入後看到我名下的裝置列表(空的也要)** | 全部 | 100 | 3 | 100% | 1 | 300 | 核心頁面 |
| US-04 | 作為開發者,我要**從雲端頁面取得一組 Pairing Token** | 全部 | 100 | 3 | 100% | 1 | 300 | Pairing 核心 |
| US-05 | 作為開發者,我要**讓我筆電上的 local agent 用這個 token 連上 visionA Cloud** | 全部 | 100 | 3 | 100% | 3 | 100 | 後端重點remote-proxy + tunnel |
| US-06 | 作為開發者,我要**Pairing 成功後,裝置自動出現在雲端頁面** | 全部 | 100 | 3 | 100% | 0.5 | 600 | WebSocket 即時更新 |
| US-07 | 作為開發者,我要**在雲端頁面看到裝置詳細(型號、韌體、健康度)** | 全部 | 100 | 2 | 100% | 0.5 | 400 | 搬自 local-tool |
| US-08 | 作為開發者,我要**瀏覽 7 個預設模型** | 全部 | 100 | 2 | 100% | 0.5 | 400 | 搬自 local-tool |
| US-09 | 作為開發者,我要**上傳我自己的 .nef 模型(雛形:走 local filesystem 實作的 S3 介面)** | 全部 | 80 | 2 | 80% | 1 | 128 | ObjectStorage 介面重點 |
| US-10 | 作為 FAE我要**進入工作區,選遠端裝置 + 模型 + Camera 來源,開始推論** | 阿哲 | 80 | 3 | 80% | 3 | 64 | 核心體驗tunnel 要穩 |
| US-11 | 作為 FAE我要**看到遠端 Camera 的即時 MJPEG 串流 + 推論結果 overlay** | 阿哲 | 80 | 3 | 80% | 2 | 96 | tunnel 要能 stream binary |
| US-12 | 作為開發者,我要**在 Settings 切換語言(繁中/English和主題Dark Mode** | 全部 | 100 | 0.5 | 100% | 0.2 | 250 | 沿用 local-tool i18n |
| US-13 | 作為開發者,我要**登出(雛形:清 token** | 全部 | 100 | 1 | 100% | 0.1 | 1000 | Phase 0 簡易版 |
### 🟡 P1 — Phase 1 MVP
| # | Story | Persona | Reach | Impact | Conf. | Effort | RICE | 備註 |
|---|-------|---------|-------|--------|-------|--------|------|------|
| US-14 | 作為開發者,我要**看到一個儀表板,顯示所有裝置狀態 + 最近活動** | 全部 | 80 | 2 | 80% | 2 | 64 | 搬自 local-tool |
| US-15 | 作為 FAE我要**用上傳圖片/影片做推論,不只 Camera** | 阿哲 | 60 | 2 | 80% | 2 | 48 | 搬自 local-tool |
| US-16 | 作為 SI我要**把多台裝置組成叢集,按加權 RR 做推論** | Sarah | 40 | 3 | 70% | 3 | 28 | 從 POC 搬 + 產品化 |
| US-17 | 作為 SI我要**看到叢集中每台裝置的負載分佈** | Sarah | 30 | 2 | 70% | 2 | 21 | POC 已有雛形 |
| US-18 | 作為開發者,我要**Pairing Token 有 expiry過期自動失效** | 全部 | 80 | 2 | 80% | 1.5 | 85 | 安全性 |
| US-19 | 作為開發者,我要**能撤銷某個裝置的 Pairing** | 全部 | 60 | 1 | 80% | 0.5 | 96 | 安全性 |
| US-20 | 作為開發者,我要**看到每次推論的 log / history** | 全部 | 50 | 1 | 70% | 1.5 | 23 | 需要 DB |
| US-21 | 作為 SI我要**邀請隊友加入我的 workspace共享裝置** | Sarah | 40 | 2 | 60% | 3 | 16 | 多租戶 |
| US-22 | 作為開發者,我要**接真的 Auth 系統JWT / OAuth** | 全部 | 80 | 2 | 80% | 4 | 32 | 替換 Phase 0 stub |
| US-23 | 作為開發者,我要**上傳到真的 S3 / MinIO不是 local fs** | 全部 | 80 | 1 | 80% | 2 | 32 | 替換 Phase 0 stub |
### 🟢 P2 — Phase 2+(未來)
| # | Story | Persona | Reach | Impact | Conf. | Effort | RICE | 備註 |
|---|-------|---------|-------|--------|-------|--------|------|------|
| US-24 | 作為開發者,我要**直接在 visionA Cloud 轉檔(非 Kneron 格式 → .nef** | Mike | 70 | 2 | 60% | 5 | 17 | 等 converter 團隊完成 API |
| US-25 | 作為 Mike我要**同時跑多個模型比較效能** | Mike | 40 | 2 | 60% | 3 | 16 | Workspace 升級 |
| US-26 | 作為開發者,我要**用 API key 寫自動化腳本(公開 API** | Mike | 30 | 1 | 50% | 3 | 5 | 開放平台 |
| US-27 | 作為 SI我要**看使用量計費Billing** | Sarah | 40 | 2 | 50% | 5 | 8 | 商業化 |
| US-28 | 作為開發者,我要**接收 email 通知(裝置離線、推論完成等)** | 全部 | 60 | 1 | 70% | 2 | 21 | Notification 系統 |
| US-29 | 作為 SI我要**每個客戶現場一個獨立 tenant** | Sarah | 30 | 2 | 60% | 5 | 7 | 多租戶加強 |
| US-30 | 作為開發者,我要**用手機 app 看裝置狀態read-only** | 全部 | 40 | 1 | 40% | 5 | 3 | Mobile |
### 🔵 雛形介面 TODOPhase 0 只做介面不接實作)
| # | Story | 雛形做什麼 | 未來要做什麼 |
|---|-------|-----------|------------|
| US-TODO-01 | 會員註冊 | 前端頁面 + 後端 stub handler | 接真 AuthJWT / OAuth|
| US-TODO-02 | 忘記密碼 | 前端頁面只 | 接 email 服務 |
| US-TODO-03 | 個人設定頁 | 前端骨架只 | 密碼變更、頭像上傳等 |
| US-TODO-04 | Billing 頁 | 不做 | Stripe 整合 |
| US-TODO-05 | 轉檔整合 | **定義 API 契約**給 converter 團隊 | 真的接 converter API |
---
## 5.3 MVP 功能範圍Phase 0 雛形)
**納入 Phase 0**:所有 P0 storiesUS-01 ~ US-13
**明確排除**
- 真實 Auth用 stub
- 真實 DB用 in-memory
- 真實 S3用 local fs 實作 ObjectStorage 介面)
- 叢集功能Phase 1
- 儀表板Phase 1
- 圖片/影片推論Phase 1Phase 0 只做 Camera
- 忘記密碼 / 個人設定(只有介面)
- Billing不做
- 轉檔整合(只定契約)
---
## 5.4 驗收條件總覽Phase 0
Phase 0 完成的定義:以下全部為 Yes。
- [ ] visionA-frontend 能部署成靜態網站並在瀏覽器打開
- [ ] visionA-backend 的 `cmd/api-server` 可單獨跑health check OK
- [ ] visionA-backend 的 `cmd/remote-proxy` 可單獨跑health check OK
- [ ] 使用者能在瀏覽器完成註冊 + 登入stub
- [ ] 使用者能從雲端頁面取得一組 Pairing Token
- [ ] 至少一台裝有 local-tool 的筆電,能用 Pairing Token 連上 remote-proxy可能需臨時修改 local-tool config
- [ ] Pairing 成功後,裝置即時出現在雲端頁面
- [ ] 使用者能從雲端選裝置 + 模型 + Camera跑一次端到端推論
- [ ] 模型上傳功能可用(雖然實際存 local fs
- [ ] 所有 P0 頁面切換順暢,無明顯 bug
- [ ] local-tool 所有既有測試持續通過0 regression
---
## 連結
- 上一章:[用戶研究](user-research.md)
- 下一章:[功能規格](features/)
- 跳回:[PRD 索引](PRD.md)

View File

@ -0,0 +1,452 @@
# 元件庫清單與規格 — visionA Cloud
> **本元件庫 100% 沿用 `local-tool/frontend/src/components/`**Frontend Agent 實作時直接搬(或整併到 monorepo shared package。本文件整理既有元件清單與用途並定義雲端版**新增的 4 個元件**。
---
## 1. 元件分類總覽
| 類別 | 數量 | 來源 | 狀態 |
|------|------|------|------|
| 基礎 UIShadcn 風)| 22 | `src/components/ui/` | 沿用 |
| Layout | 4 | `src/components/layout/` | 沿用Sidebar 需擴充) |
| Dashboard | 3 | `src/components/dashboard/` | 沿用 |
| Devices | 8 | `src/components/devices/` | 沿用(需擴充遠端狀態) |
| Models | 6 | `src/components/models/` | 沿用 |
| Inference | 5 | `src/components/inference/` | 沿用 |
| Camera | 多個 | `src/components/camera/` | 沿用 |
| 特殊 | 7 | 根目錄 | 沿用(部分可能棄用) |
| **雲端版新增** | 4 | `src/components/cloud/`(建議)| 本次新增 |
---
## 2. 基礎 UI 元件(沿用 Shadcn 風)
來源:`local-tool/frontend/src/components/ui/`
| 元件 | 檔案 | 用途 |
|------|------|------|
| `Button` | `button.tsx` | 6 variantsdefault / destructive / outline / secondary / ghost / link8 sizesxs / sm / default / lg / icon*3|
| `Card` | `card.tsx` | 資訊容器(含 Header / Title / Description / Content / Footer / Action 子元件)|
| `Dialog` | `dialog.tsx` | Modal 對話框Radix Dialog 封裝)|
| `AlertDialog` | `alert-dialog.tsx` | 確認危險操作的專用 Dialog無法 ESC 關閉)|
| `Tabs` | `tabs.tsx` | 分頁切換Radix Tabs 封裝)|
| `Input` | `input.tsx` | 單行文字輸入框h-9 |
| `Select` | `select.tsx` | 下拉選單Radix Select 封裝)|
| `Badge` | `badge.tsx` | 標籤徽章6 variants |
| `Slider` | `slider.tsx` | 滑桿Radix Slider 封裝,用於 confidence threshold|
| `Progress` | `progress.tsx` | 進度條Radix Progress |
| `Checkbox` | `checkbox.tsx` | 多選框Radix Checkbox |
| `Label` | `label.tsx` | 表單標籤Radix Label |
| `Separator` | `separator.tsx` | 分隔線 |
| `ScrollArea` | `scroll-area.tsx` | 自訂滾動條Radix ScrollArea |
| `Sonner` | `sonner.tsx` | Toast 通知Sonner 封裝) |
| `EmptyState` | `empty-state.tsx` | 空狀態頁面Icon + Title + Description + Action |
**狀態覆蓋(所有互動元件都需具備):**
- Default / Hover / Active / Focus-visible / Disabled / Loading
- Focus-visible 使用 shadcn 預設 `ring-[3px]`(符合 WCAG AA
- Disabled 統一 `disabled:pointer-events-none disabled:opacity-50`
---
## 3. Layout 元件(沿用 + 擴充)
來源:`local-tool/frontend/src/components/layout/`
| 元件 | 檔案 | 用途 | 雲端版變更 |
|------|------|------|-----------|
| `Header` | `header.tsx` | 頂部列:平台標題 + HelpButton + ConnectionStatus | 平台標題改「visionA Cloud」ConnectionStatus 語義變更(見下) |
| `Sidebar` | `sidebar.tsx` | 左側導航Dashboard / Models / Devices / Workspace / Settings | **新增**:底部 UserMenu**新增**`/clusters` 導航項 |
| `ConnectionStatus` | `connection-status.tsx` | 伺服器連線狀態徽章(綠 / 紅點)| 語義變更local-tool 指「本機 server」Cloud 指「雲端 API Server」|
| `HelpButton` | `help-button.tsx` | 開啟引導driver.js tour| Phase 0 保留Phase 1 重寫內容為雲端版 |
### 3.1 Sidebar 變更規格(雲端版)
```
┌────────────────────────┐
│ [Logo] visionA Cloud │ ← h-14border-b
├────────────────────────┤
│ ▸ Dashboard │
│ ▸ Models │
│ ▸ Devices │
│ ▸ Clusters (new) │ ← 新增
│ ▸ Workspace │
│ ▸ Settings │
│ │
│ (flex-1) │
│ │
├────────────────────────┤
│ [avatar] username ▾ │ ← 新增 UserMenu點開展示 Account / Sign out
├────────────────────────┤
│ v0.1.0 │
└────────────────────────┘
```
**導航項目(繁中 / English**
| i18n key | 繁中 | English | icon |
|---------|------|---------|------|
| `nav.dashboard` | 儀表板 | Dashboard | `LayoutDashboard` |
| `nav.modelLibrary` | 模型庫 | Models | `Boxes` |
| `nav.devices` | 裝置 | Devices | `Cable` |
| `nav.clusters` | 叢集 | Clusters | `Network` |
| `nav.workspace` | 工作區 | Workspace | `Play` |
| `nav.settings` | 設定 | Settings | `Settings` |
> **改動說明**local-tool 目前用 `{ icon: 'H' }` 這種單字母佔位(見 `sidebar.tsx:13-18`設計上不理想。Design Review 列為 **Minor**。**雲端版建議改用 Lucide Icon**,與產品視覺語言一致。
### 3.2 ConnectionStatus 語義變更
原 local-tool 的 `ConnectionStatus` 檢查 `fetch('/system/health')`,顯示「伺服器已連線」。
**雲端版需要區分兩層連線:**
| 層級 | 顯示位置 | 含義 | 綠 / 紅 / 黃 |
|------|---------|------|-------------|
| 雲端 API | Header 右側(現有位置)| 前端能不能打雲端 API Server | 綠OK / 紅API 不可達 |
| 遠端 Agent | 個別裝置卡片 / 詳情頁 | 該使用者的 local agent 有沒有連上雲端 | 綠:在線 / 灰:離線 / 黃:連線中 |
**Cloud 版 `ConnectionStatus` 行為:**
- 綠點 + 「已連線」→ 可以正常使用
- 紅點 + 「連線失敗,重試中」→ 整個服務不可用,顯示全域 `NetworkErrorBanner`
- 不再顯示 local-tool 的「伺服器未連線」— 雲端 API 應該永遠可達,失敗應觸發明顯警告
---
## 4. Dashboard 元件(沿用)
來源:`local-tool/frontend/src/components/dashboard/`
| 元件 | 用途 | 雲端版變更 |
|------|------|-----------|
| `StatCard` | 統計卡片(標題 + 數字 + icon | 無 |
| `ActivityTimeline` | 近期活動時間軸 | 無(但資料來源從 local store 改為雲端事件)|
| `ConnectedDevicesList` | 已連線裝置列表Dashboard 上的)| **小改動**:裝置項目要顯示「最後心跳 X 秒前」|
---
## 5. Devices 元件(沿用 + 擴充)
來源:`local-tool/frontend/src/components/devices/`
| 元件 | 用途 | 雲端版變更 |
|------|------|-----------|
| `DeviceList` | 裝置卡片網格 | 無 |
| `DeviceCard` | 單一裝置卡片(名稱、狀態、類型、韌體、已燒錄模型、操作按鈕)| **擴充**:右上角新增 `RemoteDeviceBadge`(見第 10 節)|
| `DeviceStatus` / `DeviceStatusBadge` | 狀態點 + 文字徽章 | **新增狀態**`offline`(遠端掉線)、`reconnecting`(重連中) |
| `DeviceHealthCard` | 健康狀態(運行時間、韌體版本、最後活動)| **擴充**新增「Pairing Token」、「最後心跳」欄位 |
| `DeviceConnectionLog` | 連線歷史紀錄 | 無(但雲端版 log 應包含 tunnel 事件) |
| `DeviceSettingsCard` | 裝置別名、備註 | 無 |
| `FlashDialog` | 燒錄模型對話框 | 無(行為透過 tunnel 傳到 local agent |
| `FlashProgress` | 燒錄進度 | 無WebSocket 透過雲端中繼) |
### 5.1 DeviceStatusBadge 擴充
**新增狀態(雲端版):**
| 狀態 | 顏色 | i18n key | 文字(繁中)|
|------|------|---------|-----------|
| `offline` | `bg-gray-400` | `devices.status.offline` | 離線 |
| `reconnecting` | `bg-yellow-400 animate-pulse` | `devices.status.reconnecting` | 重新連線中 |
> 既有的 `detected` / `disconnected` 狀態在雲端版可能用不到使用者不會直接「scan」遠端裝置。雛形不刪除但 UI 上會以 Pairing 流程取代 scan。
---
## 6. Models 元件(沿用,無變更)
來源:`local-tool/frontend/src/components/models/`
- `ModelCard` / `ModelGrid` / `ModelFilters` / `ModelDetail` / `ModelUploadDialog` / `ModelComparisonDialog`
雲端版行為差異:上傳 `.nef` 檔案走雲端儲存S3-compat但 UI 不變。
---
## 7. Inference 元件(沿用,無變更)
來源:`local-tool/frontend/src/components/inference/`
- `InferencePanel` / `PerformanceMetrics` / `ClassificationResult` / `VideoProgress` / `ConfidenceSlider`
雲端版行為差異:推論結果透過 WebSocket 從 local agent → 雲端 → 瀏覽器。UI 不變。
---
## 8. Camera 元件(沿用,無變更)
來源:`local-tool/frontend/src/components/camera/`
- `CameraInferenceView` / `CameraControls` / `SourceSelector` / `BatchImageThumbnails`
**雲端版注意:**
- Camera stream 仍然走 local agent硬體存取
- MJPEG stream 透過 tunnel 從 local agent → 雲端 → 瀏覽器顯示
- Image / Video 上傳Phase 0 仍走 local agent 處理Phase 1+ 考慮直上雲端
---
## 9. 特殊元件(部分調整)
來源:`local-tool/frontend/src/components/`
| 元件 | 用途 | 雲端版變更 |
|------|------|-----------|
| `OnboardingDialog` | 首次訪問引導 Dialog | **Phase 0 暫時保留**Phase 1 重寫(現版本假設「插 USB Dongle」雲端不適用|
| `GuidedTour` | driver.js 步驟引導 | **Phase 0 暫時保留**Phase 1 重寫為 Cloud Tour |
| `ServerStatusDashboard` | 後端 Go runtime 狀態 | **Phase 0 可移除 or 隱藏**(使用者看不到雲端 runtime留供 Phase 1 重新定位 |
| `ServerLogViewer` | 伺服器即時日誌 | 同上 |
| `LangSync` | 語言狀態同步 | 無變更 |
| `ThemeSync` | 主題跟隨系統 | 無變更 |
| `StoreHydration` | Zustand store hydration | 無變更 |
---
## 10. 雲端版新增元件
**建議路徑**`visionA-frontend/src/components/cloud/`
### 10.1 `UserMenu`
**用途**Sidebar 底部的使用者選單,雲端版必備。
**視覺**
```
┌──────────────────────────┐
│ [avatar] jim@example.com ▾ │ ← trigger
└──────────────────────────┘
↓ click
┌──────────────────────────┐
│ 帳號設定 (Account) │
│ 語言切換 (Language) │
│ ─────────────── │
│ 登出 (Sign out) │
└──────────────────────────┘
```
**規格:**
- 基底元件Radix DropdownMenushadcn 封裝)
- TriggerButton variant=ghostsize=default
- Avatar40px 圓形(`rounded-full`),顯示使用者 Email 首字母 or GravatarURL
- 下拉位置:`side="top" align="start"`(展開往上)
- 項目圖示Lucide `UserCog` / `Globe` / `LogOut`
**i18n key**
- `account.menu.accountSettings`
- `account.menu.language`
- `account.menu.signOut`
**雛形簡化**Phase 0 `signOut` 直接跳回 `/login`(不接 API`account.menu.accountSettings``/account` stub 頁。
**無障礙:**
- Trigger 可 Tab 聚焦、Enter / Space 展開
- 下拉內容鍵盤可 Arrow 導航
- `aria-label` = 使用者 Email
---
### 10.2 `PairingTokenCard`
**用途**`/devices/pair` 頁面的主要卡片,展示 Pairing Token 並提供複製功能。
**視覺**
```
┌────────────────────────────────────────────────┐
│ 🔗 Pairing Token │
│ │
│ 使用這組 token 讓你的 local agent 連上雲端 │
│ ─────────────────────────────────────── │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ vAc_a1b2c3d4e5f6a7b8 │ │
│ │ c9d0e1f2a3b4c5d6e7f8 │ │
│ │ (vAc_ + 32 hex視覺切兩行 │ │
│ │ 複製為完整 36 字元無空格) │ │
│ └────────────────────────────────────────┘ │
│ │
│ [📋 複製] [🔄 重新產生] │
│ │
│ ⏱ 剩餘 14:52 ──────────────── │
│ (進度條 bg-primary → amber(≤10:00) │
│ → red(≤3:00);過期轉灰 disabled) │
│ 📅 產生時間14:30 │
└────────────────────────────────────────────────┘
```
**規格:**
- 基底:`Card` + CardHeader + CardContent + CardFooter
- Token 格式:`vAc_` + 32 字元 hex總長 36TTL **15 分鐘**,一次性使用
- Token 顯示區:`bg-muted font-mono text-xl tracking-wider p-4 rounded-md select-all`
- 視覺切兩行(第 1 行 `vAc_` + 16 hex第 2 行 16 hexMobile 降為 `text-lg`
- 複製到剪貼簿永遠是**完整 36 字元無空格無換行**
- 過期狀態:`text-muted-foreground line-through`
- 複製按鈕:`variant=default` + Lucide `Copy` icon按下後暫態改為「已複製 ✓」2 秒,伴隨 toast過期時 disabled
- 重新產生:`variant=outline` + Lucide `RefreshCw`;點擊後 AlertDialog 確認「重新產生會讓舊 token 失效,新 token 有效期 15 分鐘」
- 倒數計時器15 分鐘 TTL 必備):
- 格式 `mm:ss`1 秒更新一次
- 進度條 `h-1.5 w-full`,初始 `bg-primary`,≤ 10:00 → `bg-amber-500`,≤ 3:00 → `bg-red-500` 且卡片加 `ring-1 ring-red-300`
- ≤ 0:30 發一次 Toast「Token 即將過期」
- 0:00 自動切過期狀態;主 CTA 變「重新產生 token」
**互動:**
- 按複製:`navigator.clipboard.writeText(token)` + `showSuccess(t('pairing.copied'))`
- 按重新產生:開 AlertDialog確認後呼叫 API → 更新 token + 重置計時
**i18n key**
- `pairing.tokenTitle`
- `pairing.tokenDescription`
- `pairing.copy` / `pairing.copied` / `pairing.regenerate`
- `pairing.expiresIn` / `pairing.generatedAt`
- `pairing.regenerateConfirm` / `pairing.regenerateWarning`
**無障礙:**
- Token 區塊:`aria-label="Pairing token"``role="text"`
- 複製按鈕Enter / Space 可觸發
- 重新產生AlertDialog 的焦點陷阱shadcn 內建)
---
### 10.3 `RemoteDeviceBadge`
**用途**:在雲端場景下顯示單一遠端裝置的連線狀態,取代 / 擴充既有 `DeviceStatusBadge`
**視覺變體:**
```
🟢 在線 ← bg-green-500 + 文字
🟡 重新連線中 (pulse) ← bg-yellow-400 animate-pulse
⚪ 離線・最後心跳 2 分鐘前 ← bg-gray-400 + 文字 + 次要文字timestamp
🔴 錯誤・無法建立 tunnel ← bg-red-500 + 文字 + tooltip 顯示錯誤詳情
```
**規格:**
- 基底:`div.flex.items-center.gap-2`
- 狀態點:`h-2.5 w-2.5 rounded-full`(沿用 `DeviceStatusBadge` 樣式)
- 狀態文字:`text-sm`
- 次要資訊(最後心跳):`text-xs text-muted-foreground`
- `pulse` 狀態:`animate-pulse` class
**Props**
```tsx
interface RemoteDeviceBadgeProps {
status: 'online' | 'offline' | 'reconnecting' | 'error';
lastSeenAt?: string; // ISO 8601 timestamp
errorMessage?: string; // tooltip 顯示
size?: 'sm' | 'md'; // 預設 md
}
```
**使用情境:**
- `/devices` 裝置卡片右上角(取代 `DeviceStatusBadge`
- `/devices/[id]` 裝置詳情頁的大型狀態顯示size=md
- Dashboard `ConnectedDevicesList` 的每個項目右側
- `/workspace/[deviceId]` 頂部(裝置掉線時要能即時看到)
**i18n key新增**
- `remote.status.online` → 在線
- `remote.status.offline` → 離線
- `remote.status.reconnecting` → 重新連線中
- `remote.status.error` → 連線錯誤
- `remote.lastSeen` → 最後心跳 {time}
- `remote.lastSeenNever` → 從未連線
**無障礙:**
- 角色:`role="status"` + `aria-live="polite"`(狀態改變時通知 SR
- 不只靠顏色:圓點 + 文字雙保險
- 錯誤狀態提供 `aria-describedby` 指向 tooltip 的完整訊息
**時間格式化(建議用 `date-fns` 或輕量 util**
- < 60 剛剛
- < 60 X 分鐘前
- < 24 X 小時前
- ≥ 24 時 → 絕對時間「04/20 14:30」
---
### 10.4 `NetworkErrorBanner`
**用途**:全域警告橫幅,當雲端 API 不可達時顯示於 Header 下方。
**視覺**
```
┌────────────────────────────────────────────────────────┐
│ ⚠ 連線中斷 — 無法連上雲端服務。正在重試... [立即重試] │
└────────────────────────────────────────────────────────┘
```
**規格:**
- 背景:`bg-amber-50 dark:bg-amber-950/30`
- 邊框:`border-b border-amber-300 dark:border-amber-700`
- 文字:`text-amber-800 dark:text-amber-200 text-sm`
- 圖示Lucide `AlertTriangle``text-amber-600`
- 位置Header 下方 sticky`sticky top-14 z-40`
- 右側按鈕:`Button variant=outline size=sm`
**狀態變化:**
- API 正常 → 不顯示
- API 失敗 > 3 次連續 → 顯示 banner
- API 恢復 → banner 切換為「已恢復連線」短暫顯示 3 秒後消失
**i18n key**
- `network.disconnected.title` → 連線中斷
- `network.disconnected.description` → 無法連上雲端服務。正在重試...
- `network.disconnected.retryButton` → 立即重試
- `network.restored` → 已恢復連線 ✓
**無障礙:**
- `role="alert"` + `aria-live="assertive"`(重要警告)
- 重試按鈕:`aria-label="重試連線"`
---
## 11. 元件狀態覆蓋檢查表(給 Frontend QA 與 Design QA
每個互動元件都應有以下狀態:
| 狀態 | 所有按鈕 / Input | 卡片列表 | 頁面 | 資料載入 |
|------|-----------------|---------|------|---------|
| Default | ✅ | ✅ | ✅ | — |
| Hover | ✅ | ✅(整個卡片)| — | — |
| Active / Pressed | ✅ | — | — | — |
| Focus-visible | ✅ | ✅ | — | — |
| Disabled | ✅ | — | — | — |
| Loading | ✅Loader2 icon| ✅skeleton| ✅skeleton| ✅skeleton|
| Empty | — | ✅EmptyState| ✅EmptyState| — |
| Error | — | ✅error banner| ✅error page| ✅retry|
| Success | ✅toast| — | — | — |
---
## 12. UX Writing 規範(簡版,完整版見 Flow 子檔)
**產品語調:**
- 對象是開發者可用技術術語如「Pairing Token」、「tunnel」、「session」但不濫用
- 錯誤訊息要**說清楚發生什麼事 + 使用者能做什麼**
- 避免過度客氣(不需要「請」「您」滿天飛)
- 繁中台灣用語(不用「鏈接」用「連結」;不用「您」頻繁出現)
**常用文案(整合到 i18n**
| 場景 | 繁中 | English |
|------|------|---------|
| 載入中 | 載入中... | Loading... |
| 儲存成功 | 已儲存 | Saved |
| 儲存失敗 | 儲存失敗,請重試 | Save failed, please retry |
| 刪除確認 | 確定要刪除「{name}」嗎?此操作無法復原。 | Delete "{name}"? This cannot be undone. |
| 空列表 | 還沒有任何紀錄 | Nothing here yet |
| API 錯誤 | 連線失敗,請檢查網路或稍後再試 | Connection failed, check network or try later |
| 權限不足 | 沒有權限進行此操作 | No permission for this action |
| Session 過期 | 登入已過期,請重新登入 | Session expired, please sign in |
---
## 13. 實作建議(給 Frontend Agent
1. **Monorepo 結構**:若 `visionA-frontend``local-tool` 之後都在同 repo可抽出 `packages/ui/``packages/i18n/` 共用避免重複。Phase 0 不需要,直接複製即可。
2. **新元件檔案放 `src/components/cloud/`**,命名清晰,不要與既有元件混放。
3. **測試**:新增的元件(`UserMenu``PairingTokenCard``RemoteDeviceBadge``NetworkErrorBanner`)都要有對應 Vitest 測試。
4. **Storybook / Playground**Phase 0 不要求Phase 1 考慮引入 Storybook。

View File

@ -0,0 +1,225 @@
# Design Review 報告 — local-tool 現況
> 本文件對 **local-tool 既有版型** 做一次 Design Review用於決定雲端版要沿用、微調、或未來迭代哪些部分。
>
> **雛形階段Phase 0不改** — 這份報告的結論寫入 Phase 1+ 的改善清單。
>
> 範圍:`local-tool/frontend/src/` 的 UI 層(不包含業務邏輯)
---
## 1. 整體評估
**⚠️ 有改善空間** — 核心視覺風格Shadcn / Radix / Tailwind 4專業成熟元件組織清晰Dark Mode 與 i18n 都內建到基礎層。但在**無障礙細節、互動規格完整度、錯誤處理一致性**幾個面向有提升空間。整體品質適合做 B2B / 開發者工具,不需要大改。
---
## 2. 評分卡
| 維度 | 評分1-5| 說明 |
|------|-----------|------|
| 視覺一致性 | 4 | Shadcn 為基礎大多數頁面視覺語言一致。少數地方Sidebar icon 用字母、狀態色散在各元件)不一致 |
| 無障礙WCAG 2.1 AA| 3 | Shadcn 內建不錯,但自訂元件缺乏 ARIA 規範、部分狀態靠顏色傳達 |
| 響應式RWD| 3 | Desktop 設計良好Tablet 可用Mobile 基本不可操作 |
| 互動回饋 | 4 | Loading / Error 大部分有處理;細節如 toast 時機、disabled tooltip 不完整 |
| 資訊架構 | 4 | Sidebar 導航直觀,頁面層級清晰 |
| UX Writing | 3 | 部分文案直接寫在 tsx沒走 i18n、錯誤訊息參差 |
| Dark Mode 支援 | 5 | 完整,所有頁面驗證過(由 oklch + CSS 變數) |
| 圖示語言 | 3 | Lucide Icon 使用得當,但 Sidebar 用「H / M / D / W / S」字母佔位不理想 |
**平均3.6 / 5** — 基礎扎實,細節待打磨。
---
## 3. Critical必須修影響可用性 / 安全 / 法規)
> 雛形階段**不改**,但 Phase 1 啟動時優先處理。
| # | 問題 | 影響頁面 | 建議 |
|---|------|---------|------|
| C1 | Sidebar 的字母佔位 icon 不符合圖示語言 | Sidebar 全域 | 改用 Lucide icon`LayoutDashboard` / `Boxes` / `Cable` / `Play` / `Settings` |
| C2 | Sidebar 選單項目色彩對比不足hover 後 `text-muted-foreground` 在某些背景上對比低於 4.5:1 | Sidebar | 確認 hover 狀態文字使用 `text-accent-foreground`,驗證對比 |
| C3 | `/devices` 右上的 WinUSB driver / udev hint 等文字**直接寫在 tsx**`devices/page.tsx:76, 92-101`),未走 i18n | `/devices` | 全部文案改走 `t()` |
| C4 | `/workspace/[deviceId]` 的「← 返回」「工作區:」「停止推論」「開始推論」等文字**直接寫在 tsx**`workspace-client.tsx:62-79` | Workspace | 改走 i18n |
| C5 | `/workspace/page.tsx` 整頁文字直接寫死(`23-39 行`),無 i18n | Workspace 選擇頁 | 改走 i18n |
---
## 4. Major建議修影響體驗或一致性
| # | 問題 | 影響範圍 | 建議 |
|---|------|---------|------|
| M1 | 裝置狀態色(`statusColors`**沒納入 Design Tokens**,散落在 `device-status.tsx` | 狀態系統 | 抽出為 Design Token或至少統一 constants方便 Dark Mode 對照與未來擴充 |
| M2 | 狀態僅靠「顏色點」傳達(綠 / 黃 / 紅 / 灰),對色盲不友善 | 裝置狀態徽章 | 除了顏色 + 文字,考慮加小 icon✓ / ✗ / ⚠)作為冗餘 |
| M3 | `ConnectedDevicesList` / `DeviceCard` 等卡片沒有明確的 `Hover` 視覺變化(部分有 `hover:bg-accent`,部分沒有)| 裝置 / 模型卡片 | 統一 hover 規格(可點擊卡片一律加 `cursor-pointer hover:bg-accent` 或邊框變色)|
| M4 | `FlashDialog` 有成功 / 失敗狀態但**沒有明確的取消 / 中止**按鈕 | FlashDialog | 加「取消燒錄」按鈕(若 backend 支援)|
| M5 | Disabled 狀態的按鈕 **沒有 tooltip 解釋原因**(例如「為什麼 disabled| 所有 disabled 按鈕 | 加 Tooltipshadcn 有 Tooltip 元件),解釋條件 |
| M6 | `/settings``ServerStatusDashboard` / `ServerLogViewer` 資訊密度過高(開發者喜歡,但對一般使用者過度)| Settings | Phase 1 收起為可展開區塊,或移到 Developer Mode |
| M7 | `OnboardingDialog` 假設使用者剛插 USB Dongle在雲端版不適用 | Dashboard | 雲端版重寫 Onboarding 流程(引導 Pairing|
| M8 | Toast 持續時間未統一Sonner 預設 vs 特定場景自訂)| 全域 | 統一 toast 持續時間政策(成功 3s、錯誤 5s|
| M9 | `confirm()` 原生 dialog`cluster-card.tsx:66`)視覺不一致 | Cluster 刪除 | 改用 `AlertDialog`shadcn 既有元件)|
| M10 | Mobile 版 Sidebar 未折疊為 Sheet / Drawer會擠壓內容 | Mobile RWD | 加 responsive SheetMobile 時漢堡選單開啟 |
| M11 | `/models` 比較模式的浮動工具列(`fixed bottom-6 z-50`)在 Mobile 上可能被 safe-area 遮住 | `/models` | 加 `pb-safe``bottom-[calc(env(safe-area-inset-bottom)+1.5rem)]` |
| M12 | Form 驗證錯誤(如 ModelUploadDialog**沒用 aria-invalid + aria-describedby**Screen Reader 無法讀出錯誤 | 所有表單 | 加 ARIA 屬性 |
---
## 5. Minor未來修微小改善
| # | 問題 | 建議 |
|---|------|------|
| m1 | Dashboard 的 StatCard icon 色(藍 / 紫 / 綠 / 黃硬編碼Dark Mode 下對比略弱 | 改用 `text-chart-*` token 或調整 Dark Mode 亮度 |
| m2 | Skeleton`device-detail-client.tsx:34-35`)過於簡陋(只是灰色塊) | 改為結構相似的多區塊 skeleton更符合載入後實際佈局 |
| m3 | `ActivityTimeline` 空狀態只顯示「尚無活動紀錄」,缺乏引導 | 加入 CTA「掃描第一台裝置」local 版)/「配對第一台裝置」(雲端版)|
| m4 | Version 顯示寫在 sidebar 底部 `v0.1.0`,但與 Settings 頁的版本可能不同步 | 統一來源(讀 package.json 或 env variable|
| m5 | Typography scale 未定義(只用 Tailwind 預設) | Phase 2 可考慮建立語義 typographyheading / body / caption|
| m6 | 沒有 Focus Trap 機制在自訂 Modalshadcn Dialog 有內建,但 custom popup 沒有)| 檢查所有 custom popup |
| m7 | 沒有 Skip to main content 連結 | 對鍵盤使用者友善,加入 |
| m8 | 部分 icon 沒 `aria-hidden="true"`(純裝飾 icon 不應被 SR 念出)| Review 全專案 icon 使用 |
| m9 | 沒有 `prefers-reduced-motion` 專屬規則(雖然 Tailwind 4 內建 utility 但未全面應用)| Review `animate-*` class 使用 |
| m10 | `sidebar.tsx``text-xs font-bold` 字母佔位 icon 在 Dark Mode 下視覺稍弱 | 改用 Lucide 後問題自然消除 |
| m11 | 沒有「Remember previous language / theme selection」UX 提示 | 加註設定會儲存到 localStorage |
| m12 | 錯誤訊息中英文夾雜(如 `'driver 安裝失敗'`| 統一走 i18n |
---
## 6. 缺失的項目
| 項目 | 重要性 | 建議 |
|------|--------|------|
| Loading State 統一規範 | 高 | 統一 Skeleton 策略:資料載入 < 200ms 不顯示 200ms 顯示 skeleton |
| Error State 統一規範 | 高 | 統一「網路錯誤 / 伺服器錯誤 / 資料不存在」的 UI |
| Empty State 多樣化 | 中 | 目前只有通用 `EmptyState` 元件;可加「有資料但被篩選掉」「初次使用」「永久空」三種語氣 |
| Tooltip 系統 | 中 | shadcn 有 Tooltip但全專案使用不一致 |
| Breadcrumb | 中 | 深層頁面(`/models/[id]` / `/devices/[id]/...`)沒麵包屑 |
| Search | 中 | 全域搜尋Cmd+K對開發者工具很有幫助 |
| Keyboard shortcuts | 低 | Phase 1+ 可考慮(如 `/` 聚焦搜尋、`g d` 跳 Dashboard 等)|
| 無障礙審查axe-core 自動化)| 高 | 加入 CI防止回退 |
| Design Token 文件Storybook / Ladle| 中 | 方便未來團隊成員查閱 |
| User Documentation | 中 | 產品內嵌的說明文件 / Help Center |
---
## 7. 優點(做得好的地方)
1. **✅ Shadcn / Radix / Tailwind 4 技術選型成熟** — 無障礙、Dark Mode、可組合性都有好基礎
2. **`oklch()` 色彩空間** — 現代、可感知線性Light / Dark 對應優雅
3. **✅ i18n 系統自製但完整** — 繁中 + English 雙語支援,涵蓋率高(少數 miss 的 string 已列入 Critical
4. **✅ Zustand stores 組織清晰** — 每個領域一個 store易維護
5. **✅ 元件分類良好** — `ui/` 基礎 / 業務元件models/devices/inference/camera/dashboard/ layout 三層結構
6. **✅ Dark Mode 原生支援** — `ThemeSync` 跟隨系統,無需使用者手動切換
7. **✅ GuidedTour + OnboardingDialog** — 有考慮新手引導(雖然雲端版要重寫)
8. **✅ Empty State 有專用元件** — `empty-state.tsx` 是一個好抽象
9. **✅ 狀態色有動畫提示** — `connecting``animate-pulse` 傳達「進行中」的感覺
10. **✅ Test coverage** — 462 個 test 檔案(前端)顯示有測試文化
---
## 8. 響應式 Review
| 斷點 | 現況 | 評價 |
|------|------|------|
| Mobile (< 640px) | Sidebar 擠壓主內容部分頁面Workspace無法正常使用 | 未優化建議雛形階段不投入使用者注意力放 desktop |
| Tablet (640-1024) | 可用,但 Sidebar 占 `w-60` 仍顯擁擠 | 🟡 可接受,未來可改 drawer |
| Desktop (≥ 1024) | 設計良好 | ✅ 主要目標 |
| Wide (≥ 1440) | 無最大寬度限制,內容在超寬螢幕會過散 | 🟡 建議加 `max-w-7xl` 或類似 |
**雲端版 Phase 0 決策****Desktop First**Mobile 不保證完整體驗,只確保能登入 + 看 Dashboard 概要。
---
## 9. 互動設計 Review
| 項目 | 現況 | 建議 |
|------|------|------|
| Hover 狀態 | 大多 ✅ | 卡片類 hover 規格需統一 |
| Active / Pressed | 大多 ✅shadcn 內建)| — |
| Focus ring | ✅(`ring-[3px]` 符合 WCAG AA| — |
| Disabled | ✅(`disabled:opacity-50`| 加 tooltip 解釋原因Major|
| Loading | 大多 ✅Loader2 icon / skeleton| skeleton 結構化Minor|
| Empty | ✅EmptyState 元件)| 多樣化語氣(缺失)|
| Error | 部分 ⚠️ | 統一規範(缺失)|
| Success | ✅toast| 持續時間統一Major|
| 動畫 | 有基本 `animate-pulse` / `animate-spin` | `prefers-reduced-motion` 支援Minor|
| 轉場 | 無明確轉場Next.js App Router 預設)| Phase 1+ 可加頁面轉場 |
---
## 10. UX Writing Review
**做得好:**
- 大多數文字走 i18n集中在 `zh-TW.ts` / `en.ts`
- 語氣友善(「請插入 Kneron USB Dongle 並點擊掃描」)
- 明確的錯誤後果說明(「確定要刪除「{name}」嗎?此操作無法復原。」)
**待改進:**
- 部分中英夾雜M12
- 部分直接寫在 tsx 繞過 i18nC3 / C4 / C5
- Toast 訊息長度不一,有些過長
- 空狀態語氣不夠引導m3
---
## 11. Dark Mode Review
**全頁面抽查**Light ↔ Dark 切換):
| 頁面 | Dark Mode 品質 |
|------|---------------|
| `/` Dashboard | ✅ 良好 |
| `/devices` | ✅ 良好udev hint banner 的 amber 色系在 Dark 下仍清晰 |
| `/devices/[id]` | ✅ 良好 |
| `/models` | ✅ 良好 |
| `/models/[id]` | ✅ 良好 |
| `/workspace/[deviceId]` | ✅ 良好Camera frame 視覺略弱但可接受)|
| `/settings` | ✅ 良好 |
**結論**Dark Mode 執行完整,**不需要在雲端版重做**,直接沿用即可。
---
## 12. 雲端版的優先處理
雲端版 Phase 0 雛形**最需要處理的**(從以上清單挑出):
1. **C3 / C4 / C5**i18n 遺漏)— 雲端版寫新頁面時就要求走 i18n避免累積
2. **M1**(狀態色納入 Token— 新增 `RemoteDeviceBadge` 時就要統一
3. **M2**(狀態不只靠顏色)— 新增 `RemoteDeviceBadge` 時就要實作
4. **M7**OnboardingDialog 重寫)— 雲端版情境完全不同直接不做Phase 0或重寫Phase 1
5. **M10**Mobile Sidebar— 雲端版使用者可能會在 iPad / 手機臨時查看,建議 Phase 1 做 Sheet
6. **缺失項**Loading / Error 統一規範)— 雲端版場景掉線頻率更高,需要規範
其他 Critical / Major 因為在 local-tool 身上,**雛形階段暫緩**,由 local-tool 團隊自行決定何時處理。
---
## 13. 給使用者的建議
**如果你要在 local-tool 上修這些問題,建議順序:**
1. **先修 Critical**C1 C5影響一致性與法規
2. **再做無障礙補強**M2 / M12 / m6 / m7 / m8 / m9可用一次性任務批次處理
3. **Design Token 整理**M1趁重構一起做
4. **Mobile RWD**M10 / M11獨立任務優先級低
5. **Empty / Error / Loading 規範**:跨多檔案,獨立任務
**不建議的做法**:在 local-tool 和 visionA Cloud 同時改,會增加複雜度。建議 local-tool 當前版本穩定(已有 462 個測試),**穩住現狀**,等 visionA Cloud Phase 0 雛形完成後,再統一迭代兩邊共用的元件。
---
## 14. 方法論
本審查方法:
- **靜態檢視**:讀程式碼(`src/app/``src/components/``src/lib/i18n/`
- **視覺推論**:根據 className 推估視覺效果,未實際運行
- **WCAG 2.1 AA 為基準**色彩對比、鍵盤操作、ARIA、可還原動畫
- **與最新 Shadcn 慣例對照**:核對是否偏離標準做法
**未涵蓋(需要實際執行產品才能做)**
- 實際的可用性測試(需要找真人)
- 效能 profiling
- 實機 Screen Reader 驗證NVDA / VoiceOver
- 實機觸控測試iPad / 手機)
這些建議在 Phase 1+ 的 QA 階段補。

View File

@ -0,0 +1,309 @@
# visionA Cloud — 設計規格Design Spec索引
| 項目 | 內容 |
|------|------|
| 專案代號 | visionA Cloud`visionA-frontend` |
| 文件版本 | v0.1Phase 0 雛形規劃) |
| 最後更新 | 2026-04-21 |
| 狀態 | 三方聯合討論中Design Agent 產出) |
| 主要負責 | Design Agent |
| 基礎設計稿 | `local-tool/frontend/` 既有實作Shadcn 風)+ `edge-ai-platform` POC 的 clusters 頁面 |
| 模式 | 既有版型反向整理 + 補齊雲端版缺失 + 局部 Design Review |
> **本規格從既有程式碼反向整理**。版型與 Design Tokens 直接沿用 local-tool以確保「離線版 / 雲端版同一套前端」的決策可執行。雲端版新增的頁面登入、註冊、帳號、Pairing、Clusters與狀態在線 / 離線 / 遠端掉線)為本次新增設計。
---
## 文件結構
本規格採用模組化結構,避免單檔過大。本檔為索引,詳細內容拆成以下子檔案:
```
.autoflow/03-design/
├── design-spec.md ← 本檔(索引 + 設計系統概述 + 整體策略)
├── design-tokens.md ← Design Tokens 三層架構
├── components.md ← 元件庫清單與規格
├── pages.md ← 頁面結構(既有 + 雲端新增)
├── flows/
│ ├── flow-pairing.md ← Pairing 流程(最重要的新增流程)
│ ├── flow-offline-handling.md ← 離線 / 掉線 UI 行為
│ ├── flow-auth.md ← 登入 / 註冊流程(雛形 stub
│ └── flow-conversion.md ← 轉檔流程Phase 0.8 新增)
├── design-review.md ← 對既有 local-tool 版型的 Design Review
└── wireframes/ ← 文字版 wireframe僅針對雲端新增頁面
├── wf-login.md
├── wf-register.md
├── wf-account.md
├── wf-pairing.md
├── wf-clusters.md
└── wireframe-conversion.md ← 轉檔頁面 wireframePhase 0.8 新增)
```
> `03-design/prototype/` 在 Phase 0 雛形**暫不產出** high-fidelity prototype。版型直接沿用 local-tool 實作,前端工程師可直接參考 `local-tool/frontend/` 複用元件。
---
## 1. 設計系統概述
### 1.1 設計哲學
**「Editor-grade Console — 開發者每天要用 8 小時也不累的工作台」。**
visionA Cloud 的使用者是**開發者 / FAE / SI**,不是一般消費者。他們:
- 一天可能開著這個頁面數小時
- 熟悉技術細節,不需要過度的引導
- 重視資訊密度與快速操作,勝過視覺華麗
- 多半在桌面環境工作Mac / Windows
- 對載入狀態、連線狀態、錯誤訊息的「誠實性」非常敏感
**設計原則(由此延伸):**
1. **資訊優先於裝飾** — 任何視覺元素都要有功能理由。不用漸層、不用花俏動畫。
2. **誠實呈現狀態** — 在線 / 離線 / 連線中 / 錯誤,一定要讓使用者看得出差別(不是只用顏色,必須搭配文字 / icon
3. **一致性勝過驚喜** — 與 local-tool 完全共用同一套元件與版型,使用者在兩種模式間切換無感。
4. **可預期勝過可愛** — 按鈕按下去會發生什麼事,應該從文字、位置、圖示就能預測。
5. **鍵盤友善** — 所有操作都應該有鍵盤路徑(符合 WCAG 2.1 AA
6. **Dark Mode 第一** — 跟隨系統主題,不做強制光亮。
### 1.2 設計目標
| 目標 | 衡量方式(供 Testing 參考) |
|------|--------------------------|
| 與 local-tool 視覺一致 | 同一使用者在兩種模式間能一眼認出是同個產品 |
| 雲端特性清晰 | 使用者隨時知道「我現在連的是哪台遠端裝置」「它在線嗎」 |
| 學習成本 < 10 分鐘 | 看得懂 Dashboard能找到 `/devices/pair`能開 Workspace |
| 無障礙符合 WCAG 2.1 AA | axe-core 自動掃描 0 個 Critical鍵盤可操作所有核心流程 |
| Dark Mode 完整 | 100% 頁面在 Dark Mode 下色彩對比達標 |
### 1.3 設計範圍Phase 0 雛形)
| 類別 | 頁面 / 元件 | 範圍 |
|------|-----------|------|
| **沿用 local-tool**(版型完全不動) | `/``/devices``/devices/[id]``/models``/models/[id]``/workspace``/workspace/[deviceId]``/settings` | 複用元件、複用 Design Tokens只改 API base URL 的連法 |
| **從 POC 搬**(適配雲端) | `/clusters``/workspace/cluster/[clusterId]` | 視覺風格與既有頁面對齊,搬完就等同「原生雲端功能」 |
| **雲端新增 — 雛形 stub** | `/login``/register``/account` | 低保真,能 compile 可導覽即可UI 細節可 Phase 1 細化 |
| **雲端新增 — 完整設計** | `/devices/pair`、Sidebar 的帳號區塊、裝置掉線徽章與降級體驗 | 本次規格 focus 的核心 |
| **全域行為改動** | 頂部 `ConnectionStatus``api.ts` 錯誤處理、WebSocket 重連 UI | 雲端場景連線邏輯不同,需要調整 |
### 1.4 設計範圍外(不做的事)
- 高保真 prototypeFigma 或 HTML— 版型沿用 local-tool沒有價值
- 行銷 Landing Page — 不在雛形範圍
- Onboarding Wizard — 雲端版的 onboarding 等 Phase 1 再細化
- Mobile 響應式完整版 — 雛形只保證 DesktopTablet 可讀Mobile 降級(詳見第 6 節)
- 重新發明 Design Tokens — 完全沿用 local-tool 的 CSS 變數
---
## 2. 子文件總覽
詳細內容在各子文件。以下是一句話摘要:
### [2.1 Design Tokens](design-tokens.md)
**完整沿用 local-tool 的 shadcn CSS 變數系統**,包含 Reference / Semantic / Component 三層。色彩使用 `oklch()` 色彩空間、radius base `0.625rem`、Tailwind 4 `@theme inline`。Dark Mode 透過 `.dark` class 切換。**本專案不新增、不覆寫任何 Token。**
### [2.2 元件庫清單](components.md)
**基礎 UIShadcn 風22 個**Button、Card、Dialog、AlertDialog、Tabs、Input、Select、Badge、Slider、Progress、Checkbox、Label、Separator、ScrollArea、Sonnertoast、EmptyState…
**業務元件(沿用)**models/、devices/、inference/、camera/、dashboard/
**Layout**Header、Sidebar、ConnectionStatus、HelpButton
**新增(雲端版)**UserMenu、PairingTokenCard、RemoteDeviceBadge、NetworkErrorBanner
### [2.3 頁面結構](pages.md)
逐頁規格:沿用頁面列出「版型沿用 + 雲端適配點」,新增頁面列出「版型、主要區塊、互動重點」。雲端新增 5 個頁面(`/login``/register``/account``/devices/pair``/clusters`)。
### [2.4 Pairing 流程](flows/flow-pairing.md)
**雲端版最關鍵的新增流程**。使用者在雲端 Web 登入 → 進 `/devices/pair` → 產生 Pairing Token`vAc_` + 32 字元 hexTTL **15 分鐘**一次性使用local agent 成功換取後獲得 90 天 Session Token 維持 tunnel使用者感受不到後者→ 複製 token → 到自己電腦的 local agent 貼上 → 顯示 local agent 已連上雲端 → 回 `/devices` 看到遠端裝置。
### [2.5 離線 / 掉線處理](flows/flow-offline-handling.md)
雲端版特有local agent 可能掉線、tunnel 可能斷。裝置列表、裝置詳情頁、Workspace 都要能「誠實顯示狀態」。建議新增一個全域的 `RemoteDeviceBadge`,在裝置列表 / 卡片 / Header 顯示「最後心跳 X 秒前」。
### [2.6 登入 / 註冊流程](flows/flow-auth.md)
Phase 0 雛形只需要 **stub 頁面**Email + Password 輸入框、一個登入按鈕、一個「還沒有帳號?註冊」連結。後端尚未接,送出後直接跳 Dashboard 即可。實際 OAuth / 密碼驗證 Phase 1 再做。
### [2.7 Design Review](design-review.md)
對既有 local-tool 版型做一次檢查。目前已知的改善點整理成 Critical / Major / Minor 三級,**雛形階段不改**,但寫進文件給 Phase 1+ 參考。
### [2.8 轉檔流程](flows/flow-conversion.md) + [Wireframe](wireframes/wireframe-conversion.md)
**Phase 0.8 新增的核心動線**。Sidebar 加「轉檔」tabWand2 icon單頁 `/conversion` 用 state 機切 `idle / uploading / processing / completed.success / completed.failed` 五個畫面。上傳走 visionA backend streaming proxyXHR + 進度條),轉檔以 polling510 秒)追狀態,完成後**半自動**讓使用者選「加到模型庫」或「下載」兩按鈕互不互斥。失敗時顯示翻譯後的錯誤訊息PRD §F5 對照表)+ suggestions。**前端不持久化 jobId**,由 backend `GET /jobs/active` 提供 source of truth跨瀏覽器 / 多分頁 / 重新整理都能正確恢復。
---
## 3. 技術對齊
| 項目 | 決策 | 備註 |
|------|------|------|
| UI 框架 | Next.js App Router 16 + React 19 | 沿用 local-tool |
| 樣式 | Tailwind CSS 4 + shadcn/uiRadix UI 封裝)| 沿用 |
| 圖示 | Lucide React | 沿用 |
| 狀態管理 | Zustand 5 | 沿用;新增 `auth-store``session-store` |
| Toast | Sonner | 沿用 |
| i18n | 自訂實作(`src/lib/i18n`),繁中 + English | 沿用 |
| 圖表 | Recharts 3 | 沿用Workspace 效能圖表) |
| 引導 | driver.js | 沿用Dashboard onboarding |
| 無障礙 | 以 shadcn/ui 內建 ARIA 為基礎,關鍵頁面補強 | 目標 WCAG 2.1 AA |
| Dark Mode | 跟隨系統主題(`theme-sync.tsx`| 沿用 |
---
## 4. 設計與開發的職責切分
為避免設計與實作脫節,明確職責:
| 誰做 | 什麼 |
|------|------|
| **Design Agent本文件** | Design Tokens 規格 / 元件清單 / 頁面結構 / 互動規格 / UX Writing 文案規範 / Design Review |
| **Architect Agent** | API 契約 / Session 設計 / 狀態同步策略 / Token 生命週期 |
| **Frontend Agent** | 依本文件實作 React 元件 / 接 API / 實作 WebSocket 重連邏輯 |
| **PM Agent** | User Story / 成功指標 / Persona |
**設計規格有衝突時的排序:** 使用者口述需求 > PRD > 本設計規格 > local-tool 現況 > 個人偏好。
---
## 5. 與 PM / Architect 的協作提醒
### 5.1 給 PM 的提醒
- Phase 0 雛形**不做完整 Onboarding**。Dashboard 的 `OnboardingDialog` 在雲端版情境不合適local-tool 的版本假設使用者「插 USB Dongle 掃描」,雲端版第一步是 Pairing建議 Phase 1 重寫。
- `/settings` 頁的「Advanced」分頁目前有 ServerStatusDashboard 與 ServerLogViewer**雲端版應該移除或重新定位**(使用者看不到「他電腦上的 local agent log」除非有專門的 remote log 設計)— 建議 Phase 1 再處理。
- 未來如果有 Billing`/account` 會擴展成 `/account/*`profile / billing / api-keys / sessions。Phase 0 只做單頁 stub。
### 5.2 給 Architect 的提醒
- **裝置狀態同步**:雲端版裝置狀態依賴 local agent 的 heartbeat / WebSocket前端必須能顯示「上次心跳 X 秒前」。建議 API 直接回傳 `lastSeenAt` 欄位ISO 8601
- **Pairing Token 顯示格式**設計上用「8+8 分隔顯示」(`a1b2c3d4 e5f6g7h8`方便肉眼辨識複製API 回傳請保持原始無空白字串,由前端格式化顯示。
- **WebSocket 重連策略**:前端會用指數退避,但需要 Architect 確認後端能否容忍短暫重連抖動。
- **錯誤分類**:前端需要區分「雲端 API 不可達」整個服務掛vs「local agent 掉線」單裝置不可用。API 錯誤回應的 `error.code` 請明確分類,讓 UI 可以給不同提示。
---
## 6. 響應式策略(整體總綱,詳見 pages.md
| 斷點 | 寬度 | 雛形策略 |
|------|------|---------|
| Mobile | < 640px | **降級顯示**能讀但不建議操作顯示提示建議使用桌面版於關鍵頁面 PairingWorkspace |
| Tablet | 640 1024px | **可用**Sidebar 折疊為 drawer主內容區單欄 |
| Desktop | 1024 1440px | **主要目標**Sidebar 展開w-60主內容區自適應 |
| Wide | > 1440px | 最大寬度限制 max-w-7xl1280px內容置中 |
**核心決策****Desktop First**。使用者是開發者主要用桌機。Mobile 僅確保能登入、看 Dashboard 資訊、讀通知;不要求能操作推論。
---
## 7. 國際化策略(沿用 local-tool
- **支援語言**:繁體中文(`zh-TW`、English`en`
- **預設語言**:跟隨系統 locale無法辨識則 `en`
- **切換位置**`/settings` → 一般 → 語言;使用者選擇會存 `localStorage`
- **文案位置**`src/lib/i18n/zh-TW.ts``src/lib/i18n/en.ts`
- **新增雲端版的文案 key**
```
auth.login.* 登入頁
auth.register.* 註冊頁
auth.error.* 認證錯誤訊息
account.* 帳號設定頁
pairing.* Pairing 頁與流程
cluster.* 叢集(已在 POC 存在,整併)
remote.* 遠端裝置相關(掉線、心跳、最後連線)
nav.cloudAppName visionA Cloud取代 visionA Local
```
- **UX Writing 原則**:參考 `components.md` 第 8 節。
---
## 8. 無障礙基準WCAG 2.1 AA
### 8.1 色彩對比
- 一般文字對背景 ≥ 4.5:1
- 大文字18pt+/14pt Bold+)≥ 3:1
- Icon / UI 元件 ≥ 3:1
- 不只靠顏色傳達資訊(裝置狀態徽章同時有「圓點 + 文字」)
### 8.2 鍵盤操作
- 所有互動元素可 Tab 聚焦
- Focus ring 可見shadcn 預設 `ring-[3px]` 已達標)
- Modal 聚焦陷阱Dialog 自動處理)
- Escape 關閉 ModalDialog 自動處理)
- Skip link全新頁面需要加
### 8.3 ARIA 與語意
- 使用語意化 HTML`<nav>``<main>``<aside>``<header>`
- 動態內容使用 `aria-live` 通知 Screen Reader如 toast、裝置連線狀態變化
- 圖示只做裝飾時加 `aria-hidden="true"`
- 表單 label 與 input 綁定
### 8.4 觸控目標(即使是桌面版)
- 最小點擊區域 32×32shadcn Button `size=default` h-9 px-4 剛好)
- Mobile 版需求:最小 44×44雛形不保證
### 8.5 Reduce Motion
- 支援 `prefers-reduced-motion`,停用非必要動畫(已由 Tailwind 4 內建)
詳細無障礙檢查項目見 `design-review.md`
---
## 9. 交付物清單
本次 Design Agent 的交付:
| 檔案 | 內容 | 狀態 |
|------|------|------|
| `design-spec.md`(本檔)| 索引 + 設計系統概述 + 整體策略 | ✅ 完成 |
| `design-tokens.md` | Design Tokens 三層架構(從 local-tool 反向整理) | ✅ 完成 |
| `components.md` | 元件庫清單與規格 | ✅ 完成 |
| `pages.md` | 頁面結構(逐頁) | ✅ 完成 |
| `flows/flow-pairing.md` | Pairing 流程(核心新增) | ✅ 完成 |
| `flows/flow-offline-handling.md` | 離線 / 掉線 UI 行為 | ✅ 完成 |
| `flows/flow-auth.md` | 登入 / 註冊(雛形 stub | ✅ 完成 |
| `design-review.md` | 對 local-tool 的設計審查 | ✅ 完成 |
| `wireframes/wf-*.md` | 文字版 wireframe5 個新增頁面) | ✅ 完成 |
| `prototype/` | Hi-fi prototype | ❌ 不做(雛形階段無價值) |
---
## 10. TODO需後續補齊
| 項目 | 負責方 | 時機 |
|------|-------|------|
| `/account` 完整設計Profile / API Keys / Sessions / Billing | Design Agent | Phase 1 |
| 完整登入 / 註冊流程含社群登入、密碼重設、Email 驗證) | Design Agent | Phase 1 |
| 雲端版 Onboarding取代 local-tool 的 USB Dongle 版本) | Design Agent + PM | Phase 1 |
| 管理面 / Admin Console若有多租戶管理需求 | Design Agent | Phase 2 |
| Billing UI | Design Agent + PM | Phase 2 |
| Mobile 完整響應式設計 | Design Agent | Phase 1+(待使用者回饋) |
| 設計 QA實作完成後 | Design Agent | Phase 0 開發完成時 |
| 對 ServerLogViewer / ServerStatusDashboard 在雲端情境的重新設計 | Design Agent | Phase 1 |
---
## 11. 交叉審閱點Three-way review
交給 PM 與 Architect 審閱時,請特別檢查:
**PM 審閱:**
- 設計規格涵蓋的頁面是否與 PRD User Stories 完全對齊?
- Pairing 流程的 UX 是否符合 Persona 使用情境?
- UX Writing 的語調是否符合產品定位?
**Architect 審閱:**
- `RemoteDeviceBadge` 顯示「最後心跳 X 秒前」需要後端提供 `lastSeenAt`API 契約是否支援?
- Pairing Token 的 `vAc_` + 32 hex 格式與 15 分鐘 TTL 是否與後端實作一致?(已對齊 PRD / TDD
- WebSocket 掉線後的 UI 降級,前端要不要 fallback 到 polling
- 登入流程雛形的「送出直接跳轉」是否會導致未來改正式 auth 時大幅重寫?建議 Architect 確認 auth-store 的介面,讓雛形與正式實作 API 相容。

View File

@ -0,0 +1,272 @@
# Design Tokens — visionA Cloud
> 本文件從 `local-tool/frontend/src/app/globals.css` 反向整理,並確認本專案**完全沿用**既有 Tokens不新增、不覆寫。
>
> 底層採用 Tailwind CSS 4 的 `@theme inline` 機制,以 shadcn/ui 慣例命名 CSS 變數;色彩使用 `oklch()` 色彩空間。Dark Mode 透過 `.dark` class 於 `<html>` 切換,由 `ThemeSync` 元件根據系統偏好設定。
---
## 1. Reference Tokens原始值
### 1.1 色彩 — Light Theme`:root`
| Token | CSS 變數 | oklch 值 | 近似 Hex | 用途 |
|-------|---------|---------|---------|------|
| bg.base | `--background` | `oklch(1 0 0)` | `#FFFFFF` | 頁面背景 |
| fg.base | `--foreground` | `oklch(0.145 0 0)` | `#252525` | 主要文字 |
| surface.card | `--card` | `oklch(1 0 0)` | `#FFFFFF` | Card 背景 |
| surface.card.fg | `--card-foreground` | `oklch(0.145 0 0)` | `#252525` | Card 內文 |
| surface.popover | `--popover` | `oklch(1 0 0)` | `#FFFFFF` | Popover / Dropdown 背景 |
| surface.popover.fg | `--popover-foreground` | `oklch(0.145 0 0)` | `#252525` | Popover 內文 |
| brand.primary | `--primary` | `oklch(0.205 0 0)` | `#343434` | 主要按鈕(近黑,不是傳統藍)|
| brand.primary.fg | `--primary-foreground` | `oklch(0.985 0 0)` | `#FAFAFA` | 主按鈕文字 |
| state.secondary | `--secondary` | `oklch(0.97 0 0)` | `#F7F7F7` | 次要按鈕背景 |
| state.secondary.fg | `--secondary-foreground` | `oklch(0.205 0 0)` | `#343434` | 次要按鈕文字 |
| state.muted | `--muted` | `oklch(0.97 0 0)` | `#F7F7F7` | 禁用 / 非主要背景 |
| state.muted.fg | `--muted-foreground` | `oklch(0.556 0 0)` | `#888888` | 輔助文字 |
| state.accent | `--accent` | `oklch(0.97 0 0)` | `#F7F7F7` | Hover / Active 背景 |
| state.accent.fg | `--accent-foreground` | `oklch(0.205 0 0)` | `#343434` | Accent 上的文字 |
| state.destructive | `--destructive` | `oklch(0.577 0.245 27.325)` | 紅色 | 刪除 / 危險 |
| border.base | `--border` | `oklch(0.922 0 0)` | `#EBEBEB` | 邊框 |
| input.base | `--input` | `oklch(0.922 0 0)` | `#EBEBEB` | Input 邊框 |
| ring.focus | `--ring` | `oklch(0.708 0 0)` | `#B4B4B4` | Focus ring |
| chart.1 | `--chart-1` | `oklch(0.646 0.222 41.116)` | 橘 | 圖表色 1 |
| chart.2 | `--chart-2` | `oklch(0.6 0.118 184.704)` | 青 | 圖表色 2 |
| chart.3 | `--chart-3` | `oklch(0.398 0.07 227.392)` | 深藍 | 圖表色 3 |
| chart.4 | `--chart-4` | `oklch(0.828 0.189 84.429)` | 黃綠 | 圖表色 4 |
| chart.5 | `--chart-5` | `oklch(0.769 0.188 70.08)` | 金 | 圖表色 5 |
| sidebar.bg | `--sidebar` | `oklch(0.985 0 0)` | `#FAFAFA` | Sidebar 背景 |
| sidebar.fg | `--sidebar-foreground` | `oklch(0.145 0 0)` | `#252525` | Sidebar 文字 |
| sidebar.primary | `--sidebar-primary` | `oklch(0.205 0 0)` | `#343434` | Sidebar 主色 |
| sidebar.border | `--sidebar-border` | `oklch(0.922 0 0)` | `#EBEBEB` | Sidebar 分隔線 |
### 1.2 色彩 — Dark Theme`.dark`
| Token | CSS 變數 | oklch 值 | 用途差異 |
|-------|---------|---------|---------|
| bg.base | `--background` | `oklch(0.145 0 0)` | 深灰黑背景 |
| fg.base | `--foreground` | `oklch(0.985 0 0)` | 近白文字 |
| surface.card | `--card` | `oklch(0.205 0 0)` | Card 比頁面亮一階 |
| brand.primary | `--primary` | `oklch(0.922 0 0)` | Dark 模式主按鈕變亮 |
| state.secondary | `--secondary` | `oklch(0.269 0 0)` | 次要元素背景 |
| state.destructive | `--destructive` | `oklch(0.704 0.191 22.216)` | Dark 模式紅色提亮 |
| border.base | `--border` | `oklch(1 0 0 / 10%)` | 半透明白邊框 |
| input.base | `--input` | `oklch(1 0 0 / 15%)` | 半透明 Input |
| chart.1 | `--chart-1` | `oklch(0.488 0.243 264.376)` | 紫藍 |
| chart.2 | `--chart-2` | `oklch(0.696 0.17 162.48)` | 綠松 |
| chart.3 | `--chart-3` | `oklch(0.769 0.188 70.08)` | 金 |
| chart.4 | `--chart-4` | `oklch(0.627 0.265 303.9)` | 紫 |
| chart.5 | `--chart-5` | `oklch(0.645 0.246 16.439)` | 橘紅 |
### 1.3 狀態色(非 shadcn token散落在業務元件
**裝置狀態徽章(`device-status.tsx`**使用 Tailwind 原生色:
| 狀態 | Tailwind class | 備註 |
|------|---------------|------|
| detected | `bg-gray-400` | 已偵測但未連線 |
| connecting | `bg-yellow-400` | 連線中 |
| connected | `bg-green-500` | 已連線 |
| flashing | `bg-yellow-500` | 燒錄中 |
| inferencing | `bg-blue-500` | 推論中 |
| error | `bg-red-500` | 錯誤 |
| disconnected | `bg-gray-400` | 已中斷 |
**注意**:這些顏色**未納入 Design Tokens**Design Review 指出這是一個 Minor 問題(詳見 `design-review.md`)。雛形階段保留現況。
### 1.4 字型
```css
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
```
使用 Vercel Geist 字體(`next/font`)。無其他字重 / 字級的 Token 定義,全由 Tailwind utility class 控制。
### 1.5 圓角
| Token | CSS 變數 | 計算值 |
|-------|---------|-------|
| radius.base | `--radius` | `0.625rem`10px |
| radius.sm | `--radius-sm` | `calc(var(--radius) - 4px)` = 6px |
| radius.md | `--radius-md` | `calc(var(--radius) - 2px)` = 8px |
| radius.lg | `--radius-lg` | `var(--radius)` = 10px |
| radius.xl | `--radius-xl` | `calc(var(--radius) + 4px)` = 14px |
| radius.2xl | `--radius-2xl` | `calc(var(--radius) + 8px)` = 18px |
| radius.3xl | `--radius-3xl` | `calc(var(--radius) + 12px)` = 22px |
| radius.4xl | `--radius-4xl` | `calc(var(--radius) + 16px)` = 26px |
Card 預設使用 `rounded-xl`14pxButton 預設 `rounded-md`8pxBadge 預設 `rounded-full`
### 1.6 間距(沿用 Tailwind 4 預設)
無自訂 spacing token全用 Tailwind `space-*` / `p-*` / `m-*` / `gap-*`4px 為 base`1`=4px、`2`=8px、`4`=16px、`6`=24px、`8`=32px
**常用間距決策(觀察自 local-tool**
- 頁面上下 spacing`space-y-6`24px
- Card 內垂直間距:`gap-6``space-y-3`
- Header 高度:`h-14`56px
- Sidebar 寬度:`w-60`240px
- 頁面主內容 padding`p-6``px-6`
- Icon 大小:`h-4 w-4`16px為主`h-5 w-5`20px為強調
### 1.7 Shadow
無自訂 shadow token使用 Tailwind 預設:
- Card`shadow-sm`
- Floating panel / Toast`shadow-lg`
- Buttonshadcn`shadow-xs`(低強度)
### 1.8 Z-index觀察自 local-tool未明確 token 化)
| 層級 | z-index | 用途 |
|------|--------|------|
| base | auto | 一般內容 |
| sticky | 30 | Header、Sidebar 的 sticky 狀態(如果有)|
| dropdown | 40 | Select、Popover |
| modal | 50 | Dialog、AlertDialog |
| toast | 50+ | Sonner toastSonner 內建) |
| floating tools | 50 | `/models` 的比較模式浮動工具列(`fixed bottom-6 z-50`|
### 1.9 Transition
無自訂 transition token使用 shadcn 內建:
- 按鈕:`transition-all`
- Input`transition-[color,box-shadow]`
- Sidebar 項目切換:`transition-colors`
- 持續時間:預設 Tailwind150ms無明確定義
---
## 2. Semantic Tokens語義映射
本層由 CSS 變數名稱本身承擔語義角色,不再做額外映射。以下是「用途 → 使用哪個 token」的對照
| 用途 | Token | class 範例 |
|------|-------|-----------|
| 頁面背景 | `--background` | `bg-background` |
| 主要文字 | `--foreground` | `text-foreground` |
| 卡片 / 區塊背景 | `--card` | `bg-card` |
| 輔助說明文字 | `--muted-foreground` | `text-muted-foreground` |
| 主要 CTA 按鈕 | `--primary` | `bg-primary text-primary-foreground` |
| 次要按鈕 | `--secondary` | `bg-secondary` |
| Outline 按鈕邊框 | `--border` | `border` |
| Hover 背景 | `--accent` | `hover:bg-accent` |
| 警告 / 危險操作 | `--destructive` | `bg-destructive` |
| Focus ring | `--ring` | `focus-visible:ring-ring` |
| 表單 Input 邊框 | `--input` | `border-input` |
| Sidebar 背景 | `--sidebar` | `bg-sidebar` |
| 圖表配色 | `--chart-1..5` | `fill-[var(--chart-1)]` |
### 2.1 新增的「半語義」類(雲端版 UI 使用)
以下**不新增 Token**,但約定用法,確保雲端版 UI 一致:
| 用途 | 實作方式 |
|------|---------|
| 裝置狀態:在線 | `bg-green-500`(保留既有設計) |
| 裝置狀態:掉線(離線) | `bg-gray-400` + `dark:bg-gray-600` |
| 裝置狀態:連線中 / 重連中 | `bg-yellow-400` + animate-pulse |
| 警告橫幅背景(如 udev hint | `bg-amber-50 dark:bg-amber-950/30` + `border-amber-300` |
| 成功提示綠 | 沿用 `bg-green-500` 或 Sonner success toast |
| 資訊提示藍 | `bg-blue-50 dark:bg-blue-950/30` + `border-blue-300` |
---
## 3. Component Tokens
Component 層在 shadcn/ui 系統下由 `cva` 變體處理,不維護獨立 token table。以下列出**元件在 Design Token 上的對應**
### 3.1 Button
| 變體 | 背景 | 文字 | Hover | Focus ring |
|------|------|------|-------|-----------|
| default | `--primary` | `--primary-foreground` | `bg-primary/90` | `--ring` |
| destructive | `--destructive` | `text-white` | `bg-destructive/90` | `--destructive/20` |
| outline | `--background` | `--foreground` | `bg-accent` + `text-accent-foreground` | `--ring` |
| secondary | `--secondary` | `--secondary-foreground` | `bg-secondary/80` | `--ring` |
| ghost | transparent | `--foreground` | `bg-accent` | `--ring` |
| link | transparent | `--primary` | `underline` | `--ring` |
| Size | 高度 | 水平 padding | 說明 |
|------|------|-------------|------|
| xs | 24px | 8px | 表格 / 密集列表 |
| sm | 32px | 12px | 卡片內按鈕 |
| default | 36px | 16px | 一般頁面按鈕 |
| lg | 40px | 24px | CTA 或表單提交 |
| icon | 36×36 | - | 純圖示按鈕(正方形) |
| icon-xs / sm / lg | 24/32/40 | - | 搭配 xs/sm/lg |
### 3.2 Card
- 背景:`--card`
- 文字:`--card-foreground`
- 邊框:`--border``border`
- 圓角:`rounded-xl`14px
- 內距:`py-6 px-6`
- Shadow`shadow-sm`
### 3.3 Badge
- Variants: `default` / `secondary` / `destructive` / `outline` / `ghost` / `link`
- 形狀:`rounded-full``px-2 py-0.5``text-xs font-medium`
- 色彩映射同 Button簡化版
### 3.4 Input
- 高度36px`h-9`
- 邊框:`--input`
- Focus ring`--ring/50` with `ring-[3px]`
- 無效狀態:`aria-invalid``border-destructive` + `ring-destructive/20`
- Dark Mode`bg-input/30`
### 3.5 DialogModal
- 背景:`--background`
- 邊框:`--border`
- 圓角:`rounded-lg`
- 最大寬度:依 context一般 `max-w-lg`512px
- Overlay黑色半透明shadcn 預設)
### 3.6 Sidebarlayout
- 寬度240px`w-60`
- 背景:`--sidebar`
- 邊框右:`--sidebar-border`
- 項目 active`bg-primary text-primary-foreground`
- 項目 hover非 active`bg-accent text-accent-foreground`
- Logo 區高度56px`h-14`
- 項目垂直 padding`py-2`8px高度 36px
### 3.7 新增元件的 Token 對應(雲端版)
| 元件 | 背景 | 文字 | 邊框 | 備註 |
|------|------|------|------|------|
| UserMenu Trigger | `--card` | `--foreground` | `--border` | Sidebar 底部新增 |
| PairingTokenCard | `--card` | `--foreground` | `--border` | Pairing 頁主要卡片 |
| PairingTokenDisplay | `--muted` | `font-mono` | `--border` | `vAc_` + 32 hex36 字元視覺切兩行複製為完整字串TTL 15 分鐘含倒數計時器 |
| RemoteDeviceBadge (online) | `bg-green-500` | `white` | - | 綠點 + 文字 |
| RemoteDeviceBadge (offline) | `bg-gray-400` | `white` | - | 灰點 + 文字 + 上次心跳時間 |
| RemoteDeviceBadge (degraded) | `bg-yellow-400` | - | - | 黃點 + animate-pulse |
| NetworkErrorBanner | `bg-amber-50` + `border-amber-300` | `text-amber-800` | - | Dark: `bg-amber-950/30` |
---
## 4. 不納入 Token 的設計決策(但需記錄)
| 決策 | 說明 |
|------|------|
| 使用 `oklch()` 而非 `hsl()` / `hex()` | Tailwind 4 + shadcn 新版慣例,色域廣、可感知線性變化 |
| 不定義 Typography scale | 全用 Tailwind 預設text-xs / sm / base / lg / xl / 2xl...),避免過度設計 |
| 不定義 Opacity scale | 用 Tailwind 預設(`/10` / `/20` / `/50` / `/90` |
| 不用 CSS Preprocessor | 純 Tailwind 4 + CSS variables簡化 build pipeline |
---
## 5. 給 Frontend Agent 的提醒
1. **直接複製** `local-tool/frontend/src/app/globals.css``visionA-frontend/src/app/globals.css`**完全不動**。
2. Dark Mode 跟隨系統(沿用 `ThemeSync`)。
3. 新元件一律用 CSS 變數 + Tailwind class不要寫 inline style、不要寫 color 的 hex / rgb。
4. 有新 token 需求(例如極少數的狀態色)先跟 Design Agent 討論,再決定是否加入 Token 或沿用現有。
5. 圖表色用 `--chart-1..5`,確保 Light / Dark Mode 自動切換。

View File

@ -0,0 +1,429 @@
# 登入 / 註冊流程 — visionA Cloud
> **Phase 0 雛形範圍**:僅做低保真 stub表單 + 按鈕 + 路由),**不接後端認證**。使用者送出登入 / 註冊表單後直接跳轉到目標頁即可。
>
> 完整 auth 流程OAuth、2FA、密碼重設、Email 驗證、ToS / Privacy Policy、rate limit 等)**Phase 1+ 再設計**。
>
> 本檔記錄 Phase 0 雛形規格 + Phase 1+ TODO。
---
## 1. 雛形範圍決策
| 項目 | Phase 0 | Phase 1 TODO |
|------|---------|-------------|
| 登入表單 UI | ✅ 做 | 完整化 |
| 註冊表單 UI | ✅ 做 | 完整化 |
| 登入 API 串接 | ❌ 不做(直接跳轉)| 做 |
| 註冊 API 串接 | ❌ 不做 | 做 |
| OAuthGoogle / GitHub| ❌ 不做 | 做 |
| 密碼強度驗證 | ❌ 不做 | 做 |
| Email 驗證 | ❌ 不做 | 做 |
| 密碼重設 | ❌ 不做 | 做 |
| 2FA | ❌ 不做 | 做Phase 2 |
| ToS / Privacy Policy 勾選 | ❌ 不做 | 做 |
| 社群登入 | ❌ 不做 | 做Phase 2 |
| Rate Limit 提示 | ❌ 不做 | 做 |
| 帳號鎖定機制 | ❌ 不做 | 做 |
---
## 1.1 雛形 Auth 行為(三方一致)
Phase 0 **不接後端認證**,三方已對齊行為如下(對應 Reviewer C3
| 項目 | Phase 0 行為 |
|------|-------------|
| 登入表單送出 | HTML5 驗證通過後 → `router.push('/')`**不**打 API**不**做身分驗證 |
| 註冊表單送出 | 同上 → `router.push('/')` |
| Session / Token | 不存(前端不發、不讀);重新整理依然可進任何頁面 |
| 登出按鈕 | 點擊 → `router.push('/login')`,不清 session本來就沒存 |
| 受保護路由 | 雛形期所有 `/*` 路由對匿名使用者開放,**不做**導向 /login |
### 1.2 雛形 Banner全域
為了避免 demo 被誤認為正式版本,所有已登入的主畫面(`(main)/layout.tsx`)頂部顯示常駐 Banner
```
┌─────────────────────────────────────────────────────────────┐
│ 🚧 雛形版本 · 登入僅為 UI 示意,未實作身分驗證;資料皆為假資料 │
└─────────────────────────────────────────────────────────────┘
```
**規格:**
- 位置:`Header` 正下方,`sticky top-14 z-30`(在 `NetworkErrorBanner z-40` 之下,以利網路錯誤優先)
- 樣式:`bg-amber-100 dark:bg-amber-900/40 text-amber-900 dark:text-amber-100 border-b border-amber-300`
- 文字:`text-xs font-medium text-center py-1.5`
- 圖示:`Construction` / `Wrench`Lucide`h-3.5 w-3.5`
- 可關閉?**不可關閉**(雛形階段要讓使用者持續意識到)
- `role="status"` + `aria-label="雛形版本提示"`
- 響應式Mobile 文字改為「🚧 雛形版本demo」縮短版
### 1.3 i18n key雛形 Banner
```
prototype.banner.label → 雛形版本 · 登入僅為 UI 示意,未實作身分驗證;資料皆為假資料
prototype.banner.short → 雛形版本demo
prototype.banner.ariaLabel → 雛形版本提示
```
---
## 2. 路由與 Layout
```
app/
├── (auth)/
│ ├── layout.tsx ← 無 Sidebar / Header 的獨立 layout
│ ├── login/page.tsx ← /login
│ └── register/page.tsx ← /register
├── (main)/
│ ├── layout.tsx ← 有 Sidebar / Header 的主 layout既有
│ ├── page.tsx ← /
│ ├── devices/...
│ └── ...
└── layout.tsx ← root layoutThemeSync / LangSync / Toaster
```
**`(auth)/layout.tsx` 規格:**
- 無 Sidebar、無 Header
- 全螢幕 `min-h-screen``bg-background`
- 內容置中:`flex items-center justify-center`
- 無 padding wrapper頁面自己處理
---
## 3. `/login` — 登入頁
### 3.1 視覺Phase 0
```
┌────────────────────────────────────────────────────────┐
│ │
│ │
│ [Logo] visionA Cloud │
│ Edge AI Platform │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ Email │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ you@example.com │ │ │
│ │ └──────────────────────────────────┘ │ │
│ │ │ │
│ │ 密碼 │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ •••••••• │ │ │
│ │ └──────────────────────────────────┘ │ │
│ │ │ │
│ │ [ 登入 ] │ │
│ │ │ │
│ │ ──────── 還沒有帳號? ───────── │ │
│ │ │ │
│ │ [ 建立新帳號 ] │ │
│ └──────────────────────────────────────┘ │
│ │
│ 忘記密碼? | 語言:繁中 ▾ │
│ │
└────────────────────────────────────────────────────────┘
```
### 3.2 規格
- **容器**`max-w-md w-full mx-auto px-4`
- **品牌區**
- Logo`h-12 w-12 rounded-lg`
- 產品名:`text-2xl font-bold`
- 副標題:`text-sm text-muted-foreground`
- 區塊間距:`space-y-2 mb-8`
- **Card**:既有 `Card` + `CardContent``py-8 px-6`
- **表單區塊**`space-y-4`
- **Input**:既有 shadcn`type="email"` / `type="password"` 搭配 `autoComplete`
- **登入按鈕**`w-full` + `variant=default` + `size=default`
- **分隔線 + 建立帳號**
- `Separator` + 中間文字(使用 Tailwind 的 flex center hack
- 建立帳號 `Button variant=outline w-full`
- **底部**
- 「忘記密碼?」:`Button variant=link size=sm`disabled
- 語言切換:輕量化 Select僅 2 選項)
### 3.3 行為Phase 0 簡化)
```tsx
async function handleLogin(e: FormEvent) {
e.preventDefault();
// Phase 0: 不接 API直接跳 dashboard
// Phase 1: const res = await api.post('/auth/login', { email, password })
router.push('/');
}
```
- 表單用 HTML5 `required`Phase 0
- 無 loading statePhase 1+
- 無 error statePhase 1+
- 送出直接跳 `/`
### 3.4 鍵盤行為
- Tab 順序Email → Password → 登入 → 建立新帳號 → 忘記密碼 → 語言
- Email 或 Password focused 時 Enter → 提交表單
- 「建立新帳號」Enter / Space 導航到 `/register`
- 語言切換Space 開啟 SelectArrow 選擇Enter 確認
### 3.5 響應式
- Mobile`px-4` 確保左右留白;其他結構不變
- Tablet / Desktop置中`max-w-md` 限寬
### 3.6 i18n key
```
auth.login.title → 登入
auth.login.subtitle → 歡迎回到 visionA Cloud
auth.login.email → Email
auth.login.emailPlaceholder → you@example.com
auth.login.password → 密碼
auth.login.submit → 登入
auth.login.submitting → 登入中...
auth.login.forgotPassword → 忘記密碼?
auth.login.noAccount → 還沒有帳號?
auth.login.createAccount → 建立新帳號
```
---
## 4. `/register` — 註冊頁
### 4.1 視覺Phase 0
```
┌────────────────────────────────────────────────────────┐
│ │
│ [Logo] visionA Cloud │
│ Edge AI Platform │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ 建立帳號 │ │
│ │ │ │
│ │ Email │ │
│ │ [____________________________________] │ │
│ │ │ │
│ │ 密碼 │ │
│ │ [____________________________________] │ │
│ │ │ │
│ │ 確認密碼 │ │
│ │ [____________________________________] │ │
│ │ │ │
│ │ [ 建立帳號 ] │ │
│ │ │ │
│ │ ──────── 已經有帳號? ────────── │ │
│ │ │ │
│ │ [ 登入 ] │ │
│ └──────────────────────────────────────┘ │
│ │
│ 語言:繁中 ▾ │
│ │
└────────────────────────────────────────────────────────┘
```
### 4.2 規格差異(相對 /login
- 新增「確認密碼」欄位
- 「建立帳號」按鈕取代「登入」
- 底部連結改為「已經有帳號? → 登入」
### 4.3 驗證規則Phase 0 最小化)
- EmailHTML5 `type="email" required`
- 密碼:`required`**不驗證強度**
- 確認密碼失去焦點時比對client-side不符則顯示紅框 + 下方文字
**簡單驗證範例:**
```tsx
const [confirmError, setConfirmError] = useState('');
function handleConfirmBlur() {
if (password && confirm && password !== confirm) {
setConfirmError(t('auth.register.passwordMismatch'));
} else {
setConfirmError('');
}
}
```
顯示:`<Input aria-invalid={!!confirmError}>` + `<p className="text-xs text-destructive">{confirmError}</p>`
### 4.4 行為Phase 0 簡化)
```tsx
async function handleRegister(e: FormEvent) {
e.preventDefault();
if (password !== confirm) {
setConfirmError(t('auth.register.passwordMismatch'));
return;
}
// Phase 0: 不接 API直接引導到 pairing
router.push('/devices/pair');
}
```
**註冊成功後的導航**:跳 `/devices/pair` 引導第一次配對(避免使用者看到空白 Dashboard
### 4.5 i18n key
```
auth.register.title → 建立帳號
auth.register.subtitle → 開始使用 visionA Cloud
auth.register.email → Email
auth.register.password → 密碼
auth.register.confirmPassword → 確認密碼
auth.register.passwordMismatch → 兩次輸入的密碼不一致
auth.register.submit → 建立帳號
auth.register.submitting → 建立中...
auth.register.hasAccount → 已經有帳號?
auth.register.signIn → 登入
```
---
## 5. 登入狀態管理Phase 0 雛形)
### 5.1 `useAuthStore`Zustand
**目的**Phase 0 讓前端能在「未登入 / 已登入」兩個狀態間切換,即使後端沒接。
```typescript
interface AuthStore {
user: { email: string; displayName?: string } | null;
isAuthenticated: boolean;
login: (email: string) => void; // Phase 0: 只存 localStorage
logout: () => void;
}
```
**Phase 0 簡化實作**
- `login(email)`:設 `user = { email }``isAuthenticated = true`、存 `localStorage`
- `logout()`:清空、跳 `/login`
- Hydration頁面載入從 `localStorage` 恢復狀態
**Phase 1 升級**:改為真正的 JWT / Session cookie`login` 會呼叫 API、存 token`logout` 會呼叫 API 使 token 失效。
### 5.2 路由保護Phase 0 Middleware
**最簡版**:在 `(main)/layout.tsx` 或 Root 層加 hook
```tsx
'use client';
function MainLayout({ children }) {
const { isAuthenticated } = useAuthStore();
const router = useRouter();
useEffect(() => {
if (!isAuthenticated) router.replace('/login');
}, [isAuthenticated]);
// ... 顯示 Sidebar + Header + children
}
```
**注意**Phase 0 保護邏輯很弱(只是 client-side redirect**不是真正的安全機制**。任何直接 API 請求都應該由後端驗證。
---
## 6. 登出流程
**觸發點**
- Sidebar 底部 `UserMenu` → 「登出」
- `/account` 頁面(未來)
- Session 過期自動觸發Phase 1+
**行為Phase 0**
1. `useAuthStore.logout()`
2. 清空 localStorage 的 `user`
3. 顯示 toast「已登出」
4. `router.push('/login')`
**Phase 1+**
- 呼叫 `POST /api/auth/logout` 讓 token 失效
- 清除所有 cookies
- 若有未儲存的資料,顯示確認 Dialog
---
## 7. 首次登入的引導
**使用者第一次登入完成後**(有個好方法:**從註冊頁進來的**
- 註冊後跳 `/devices/pair`(引導第一次配對)
- Dashboard 的 `OnboardingDialog`local-tool 既有版本假設 USB Dongle**Phase 0 雲端版暫時 disable**
- Phase 1 做新的雲端版 Onboarding「歡迎來到 visionA Cloud — 讓我們先配對你的第一台裝置」→ 跳 `/devices/pair`
---
## 8. 錯誤情境Phase 1 再實作)
雛形不做,但在這裡列出 Phase 1 要處理的情境:
| 情境 | UI 回饋 |
|------|---------|
| 密碼錯誤 | Input 下方紅字「Email 或密碼不正確」(不明說哪個錯,避免 enumeration attack |
| Email 不存在 | 同上 |
| 帳號被鎖(短時間內失敗太多次)| 顯示「此帳號暫時被鎖定,請 15 分鐘後再試」 |
| Email 已被註冊(註冊時)| Input 下方紅字「此 Email 已被使用」 |
| 密碼強度不足 | 密碼下方即時顯示強度指示器 |
| 網路錯誤 | Toast「連線失敗請稍後再試」 |
| ToS 未勾選 | Button disabled + 紅字提示 |
---
## 9. 安全考量(給 Architect
雛形不做,但 Phase 1 必須:
- 密碼 hashbcrypt / argon2
- TokenJWTshort-lived access + refresh或 session cookiehttpOnly + secure + SameSite
- CSRF 保護
- XSS 防護Content-Security-Policy header
- Rate Limit失敗登入 N 次鎖定 X 分鐘)
- 密碼政策(最少長度、需字母 + 數字等,視法規要求)
- 記錄登入來源 IP / User-Agent/account/sessions 頁面顯示)
---
## 10. 無障礙
- 所有表單 `<label htmlFor>``<input id>` 對應
- `autoComplete` 正確設定(`email` / `current-password` / `new-password`
- 錯誤訊息用 `aria-describedby` 指向 input
- Tab 順序符合視覺順序
- Enter 可提交表單
- Focus ring 清晰可見shadcn 預設達標)
- 密碼欄不使用 `aria-live`(避免 SR 念密碼)
---
## 11. 設計示意(給 Frontend Agent 參考既有 Shadcn Auth 模板)
Shadcn 官方有 `auth-01` / `auth-02` 等區塊範例。可參考:
- https://ui.shadcn.com/blocksAuthentication 分類)
**選擇 `auth-01``auth-04` 風格**(左側表單 + 右側品牌區或全螢幕置中)。雛形建議**全螢幕置中**(更簡潔)。
Phase 1 可升級為左右分欄 + 右側行銷訊息 / 圖片。
---
## 12. Phase 1+ TODO完整清單
| 項目 | 重要性 |
|------|-------|
| OAuthGoogle、GitHub、Microsoft | 高 |
| Email 驗證流程 | 高 |
| 密碼重設Email 連結 → 重設頁)| 高 |
| 密碼強度指示器 | 高 |
| ToS / Privacy Policy 勾選 | 高(法規) |
| Remember me 選項 | 中 |
| 防機器人reCAPTCHA / Turnstile | 中 |
| Rate limit 提示 UI | 中 |
| 2FATOTP / WebAuthn / SMS| 中Phase 2|
| SSO for 企業SAML| 低Phase 2+|
| Magic linkpasswordless| 低Phase 2|
| 登入行為分析 / 異常偵測 | 低Phase 2+|

View File

@ -0,0 +1,550 @@
# 轉檔流程 — visionA Cloud
> Phase 0.8 雲端版新增流程。使用者把 ONNX / TFLite 模型轉成 `.nef`,全程在 visionA Cloud 內完成,不必跳到 converter 站台。
>
> 對應 wireframe[`wireframes/wireframe-conversion.md`](../wireframes/wireframe-conversion.md)
> 對應 Feature spec[`02-prd/features/feature-converter-integration.md`](../../02-prd/features/feature-converter-integration.md)
---
## 1. User Story
> **作為** 一個 Kneron AI 應用開發者,
> **我想要** 把手上的 ONNX / TFLite 模型直接在 visionA Cloud 轉成 `.nef`
> **這樣** 我就能立刻把它部署到我配對的 KL 裝置,不用先去 converter 站台再回來,整個動線是一條直線。
**成功條件:**
- 從上傳到拿到 `.nef` 的 P95 時間 < 10 分鐘含上傳 + 轉檔 + promote
- 完成後**使用者顯式選擇**結果如何處理(加到模型庫 / 下載 / 兩個都做)
- 失敗時錯誤訊息可理解PRD §F5 對照表)
---
## 2. 設計決策摘要
對齊 Feature spec §6「整合決策」。設計面承接的關鍵決策
| # | 決策 | UX 含意 |
|---|------|--------|
| D1 | Upload 走 visionA backend streaming proxy非 presigned PUT| 使用者**只看到一個進度條**browser → visionA。不暴露 converter 端點 |
| D2 | Download 走 server-side 換 token + 302 redirect → browser 直連 FAA | 點下載 → 前端打 `GET /api/conversion/{job_id}/download` → backend 302 redirect 到 FAA沒有第二段「下載到瀏覽器」進度token 不暴露給前端 JS |
| D3 | 結果處理半自動user 顯式選擇)| 完成後**永遠**顯示「加到模型庫」「下載」兩個按鈕,不自動執行 |
| D4 | Polling 510 秒一次 | 不做 SSE / WebSocket前端複雜度低不顯示「即時百分比」(converter 不給) |
| D5 | converter API 不動 | UI 直接綁 visionA backend 的 `/api/conversion/*` |
| D6 | Sidebar 獨立 tab不混 `/models` | 入口單一、心智清楚 |
---
## 3. 流程全景圖Mermaid
```mermaid
sequenceDiagram
autonumber
participant U as User<br/>(Browser)
participant FE as visionA Frontend<br/>(/conversion)
participant BE as visionA Backend<br/>(/api/conversion/*)
participant CV as kneron_model_converter
participant FAA as File Access Agent
Note over U,FE: ── State Aidle ──
U->>FE: 進 /conversion
FE->>BE: GET /api/conversion/active
alt 已有 active job
BE-->>FE: { active: true, jobId, status }
FE->>FE: 直接切 processing 畫面§5
else 無 active job
BE-->>FE: { active: false }
FE->>U: 顯示空狀態 + 「開始轉檔」CTA
end
Note over U,FE: ── State B選檔 + 設定 ──
U->>FE: 點「開始轉檔」開 Upload Dialog
U->>FE: 選 .onnx / .tflite + 選 chip + (可選) ref images
FE->>FE: 前端驗證(副檔名、大小、必填)
U->>FE: 按「開始上傳」
Note over U,FE: ── State CuploadingXHR streaming proxy──
FE->>BE: POST /api/conversion/init<br/>(multipart, XHR upload.onprogress)
BE->>CV: forward stream 到 POST /api/v1/jobs
CV-->>BE: 201 Created { job_id }
BE-->>FE: 200 { job_id, status: queued }
FE->>FE: Dialog 自動關閉,主畫面切 processing
Note over U,FE: ── State Dprocessingpolling──
loop 每 510 秒(分頁可見時)
FE->>BE: GET /api/conversion/{job_id}
BE->>CV: GET /api/v1/jobs/{job_id}
CV-->>BE: { status: queued/running/succeeded/failed, ... }
BE-->>FE: { status, error_code?, ... }
end
alt status = succeeded
Note over CV,FAA: converter 內部 promote 已上 FAA
FE->>U: 顯示 success 畫面§7
else status = failed
FE->>U: 顯示 failed 畫面§8
end
Note over U,FE: ── State Ecompleted.success ──
alt 使用者點「加到模型庫」
U->>FE: 點 + 確認 Dialog輸入名稱
FE->>BE: POST /api/conversion/{job_id}/promote-to-models
BE->>FAA: server-to-server pull NEF
FAA-->>BE: NEF binary
BE->>BE: 走既有 /api/models/init + /finalize
BE-->>FE: 200 { model_id }
FE->>U: toast 「已加入模型庫」+ 連結
end
alt 使用者點「下載」
U->>FE: 點按鈕
FE->>U: window.location.href = '/api/conversion/{job_id}/download'
U->>BE: GET /api/conversion/{job_id}/download
BE->>BE: 跟 MC 換 delegated tokenserver-side
BE-->>U: HTTP 302 Redirect<br/>Location: <FAA-URL>/files/{key}?access_token=...
U->>FAA: GET /files/{key}?access_token=...(跟著 redirect
FAA-->>U: NEF binary瀏覽器下載
end
```
---
## 4. State Machine前端
`/conversion` 路由內以單一 store 維護狀態(建議:`useConversionStore` Zustand
```
idle
│ click「開始轉檔」
upload-form-open (Upload Dialog 顯示中)
│ submit
uploading (Dialog 內、XHR onprogress 0100%)
├── XHR 4xx/5xx → idle (toast error)
├── 取消上傳 → idle (toast canceled)
└── XHR 200 + got job_id
processing (主畫面、polling)
├── poll status=succeeded → completed.success
├── poll status=failed → completed.failed
├── poll 5 次 fail → 顯示「重試」按鈕,不離開 processing
└── 使用者離開頁面 / 重新整理 → 下次進入 §3.idle 自動恢復
```
**狀態保存策略**D6 補充):
| 狀態 | 存 localStorage | 為何 |
|------|------------------|------|
| `idle` | 否 | 預設狀態 |
| `upload-form-open` 內的選檔 | 否 | 檔案物件無法序列化,使用者重新整理就清空 |
| `uploading` 進度 | 否 | XHR 中斷無法續傳 |
| `processing` job_id | **否** | 由 backend `GET /jobs/active` 提供 source of truth不靠前端記 |
| `completed` 結果 | 否 | 重新整理後從 backend 重新取(仍需要顯示給使用者) |
**核心原則:前端不持久化 jobId全部由 backend `GET /jobs/active` 提供。** 這樣「換瀏覽器 / 多分頁 / 私密模式」都能正確還原。
---
## 5. State 細節
### 5.1 idle — 進入頁面 / 完成後重置
**進入點:**
- 直接進 `/conversion`(從 sidebar
- 完成後點「開始新轉檔」
- 失敗後點「重新開始」
**載入流程:**
1. Mount 時打 `GET /api/conversion/active`
2. 若 `active=true` → 跳 `processing` 或對應 completed 狀態
3. 若 `active=false` → 顯示空狀態wireframe §3
**邊界:**
- API 失敗 → 顯示「無法載入轉檔狀態,請重試」+ retry button不假設沒 active job 就讓使用者開新的,避免重複 submit
### 5.2 upload-form-open — Dialog 內選檔
**操作:**
- 拖拽檔案到 dropzone或點「選擇檔案」開 file picker
- 輸入任務名稱(自動帶檔名 stem可改
- 選 chip4 個 RadioGroup 必選)
- 加 ref images多選選填
**前端驗證 — submit 前:**
| 規則 | 違反訊息 | 阻擋送出 |
|------|---------|---------|
| 必須選檔 | `conversion.upload.error.noFile` | ✅ |
| 副檔名 `.onnx` / `.tflite` | `conversion.upload.error.unsupported` | ✅ |
| 模型 ≤ 500 MB | `conversion.upload.error.modelTooLarge` | ✅ |
| 必選 chip | `conversion.upload.error.noChip` | ✅ |
| 每張 ref ≤ 10 MB | `conversion.upload.error.refTooLarge` | ✅ 該檔 |
| ref images ≤ 100 張 | `conversion.upload.error.refTooMany` | ✅ |
驗證失敗顯示在對應欄位下方紅字(`text-sm text-destructive`),同時「開始上傳」按鈕變 disabled。
**取消:**
- 點 `[✕]``[取消]` → 關 Dialog、回 idle、選檔狀態清空
### 5.3 uploading — Dialog 內顯示進度
**送出:**
1. 構造 `FormData`model file + ref images + 任務名稱 + chip
2. `XMLHttpRequest` POST 到 `/api/conversion/init`
3. `xhr.upload.onprogress` 更新 progress`loaded / total`
4. 計算預估剩餘:取最近 3 秒移動平均速度
**進度條顯示文案:**
| 狀態 | 顯示 |
|------|------|
| `progress < 100%` | `已上傳 11.9 / 28.4 MB · 預估剩餘 0:24` |
| `progress = 100%` 但 server 還沒回 | `即將完成…`XHR 已送完但 backend 還在 forward 到 converter|
| 等待時間超過 5 秒 | `伺服器處理中…` |
**離開警告:**
- `window.addEventListener('beforeunload', e => { e.preventDefault(); e.returnValue = ''; })`
- 取消 / 完成 / 失敗時 cleanup
**Tab 標題更新:**
```
visionA Cloud · 上傳中 (42%) ← 動態插入百分比
```
完成後還原為 `visionA Cloud`
**取消:**
- 點「取消上傳」→ AlertDialog 確認 → `xhr.abort()` → toast「已取消上傳」→ 回 idle
- 重要visionA backend 收到取消信號後**也要對 converter 發 cancel**(避免孤立 job
**失敗:**
| HTTP | 行為 |
|------|------|
| 4xx 一般(不含 409 | Dialog 內紅字顯示錯誤;保留 「重試」按鈕(重新打 submit|
| 409 `user_has_active_job` | Dialog 關閉 → 切 processing 畫面 + banner「您已有一個轉檔正在進行中已切換至該任務」|
| 5xx / 網路 | Dialog 內顯示「上傳失敗:{訊息}」+ 「重試」 |
### 5.4 processing — 主畫面、polling
**Polling 策略:**
```typescript
const POLL_FAST_INTERVAL = 5_000; // 060 秒
const POLL_SLOW_INTERVAL = 10_000; // 60+ 秒
const POLL_MAX_RETRIES = 5;
// 暫停 / 恢復
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
pollNow();
resumePolling();
} else {
pausePolling();
}
});
```
**狀態變化處理:**
| 從 → 到 | 觸發 | UI 更新 |
|--------|------|---------|
| `queued``queued` | 持續 polling | 無變化(保持 stage 1 完成、stage 2 當前) |
| `queued``running` | 第一次拿到 running | stage 2 標完成、stage 3 當前 |
| `running``running` | 持續 polling | indeterminate progress 持續動 |
| `*``succeeded` | 拿到 succeeded | 切 §5.5 success 畫面、停 polling、tab title 更新 |
| `*``failed` | 拿到 failed | 切 §5.6 failed 畫面、停 polling |
| 連 5 次 polling 失敗 | exponential backoff 用完 | 顯示「無法取得轉檔狀態」+ retry 按鈕(不切走 processing |
**Tab 標題更新:**
| 狀態 | Tab title |
|------|----------|
| processing | `visionA Cloud · 轉檔中…` |
| 完成5 秒內持續顯示)| `✓ 轉檔完成 · visionA Cloud` |
| 失敗 | `⚠ 轉檔失敗 · visionA Cloud` |
> 完成 / 失敗時用 emoji 是為了在分頁列上一眼可見reduced-motion / SR 友善(純文字)。
**長時間排隊提示:**
- `queued` 持續 > 5 分鐘 → banner「目前排隊較久你可以離開此頁稍後再回」
- `running` 持續 > 15 分鐘 → banner「轉檔耗時較長仍在進行中」
### 5.5 completed.success — 顯示結果 + 半自動分支
**進入時:**
- 停止 polling
- 顯示成功 Cardwireframe §7
- toast「轉檔完成」+ action「下載 .nef」使用者直接 toast 點下載也 OK
**「加到模型庫」分支:**
```
1. 點按鈕 → 開 AlertDialog含名稱輸入欄
- 預設 name = job.name 或 source filename stem + "_" + chiplowercased
- 例yolov5s.onnx + KL720 → "yolov5s_kl720"
2. 使用者確認名稱 → 按「加到模型庫」
3. 按鈕變 spinner + disabled避免重複點
4. POST /api/conversion/{job_id}/promote-to-models
body: { name }
5. 成功200 + model_id
- toast「已加入模型庫」+ action 連結 /models/{model_id}
- 結果 Card 內按鈕變綠勾「✓ 已加入(前往查看 →)」
- 按鈕仍可重複點(再點會 409 重複)
6. 409 already imported
- toast「此任務已加入過模型庫」+ action「查看現有模型 →」
7. 其他錯誤toast 顯示 + 按鈕回原狀態
```
**「下載」分支:**
```
1. 點按鈕 → 按鈕短暫變 spinner + 「準備下載…」
2. window.location.href = '/api/conversion/{job_id}/download'
(或用 anchor tag <a href="..." download>,效果等價)
3. backend 收到後 server-side 跟 MC 換 delegated token
直接回 HTTP 302 Redirect → Location 指向 FAA URL
4. 瀏覽器跟著 302 跳轉,內建下載管理器接管下載
- 按鈕回原狀態(可重複點 → 重新換新 token
- toast「下載已開始」+ 「若沒看到下載提示,請檢查瀏覽器設定」
5. 4xx / 5xxbackend 還沒到 redirect 階段就 fail
- 因為已經 navigation瀏覽器會顯示 backend 的錯誤頁
- 為避免此情境,可選用 fetch + manual handling 偵測 status 後再 navigate
- 簡化版:直接 navigate靠 backend 顯示錯誤頁;遇到 4xx/5xx 使用者按 back 即可
注意token 不暴露給 frontend JS整個換 token 流程在 backend 內完成。
```
**為何兩個按鈕互不互斥?** PRD §F4 D3 明文:使用者可以兩個都做(先試試下載驗證、再加進模型庫;或反過來)。按鈕**不會**因為按過就消失,只會在 import 成功後加綠勾標記。
**過期提醒:**
- 計算 `expires_at - now()` → 顯示「6 天 21 小時後自動清除」
- 每分鐘更新一次(不需要每秒)
- 過期當下頁面切「已過期」狀態wireframe §8.2),按鈕全部 disabled
**「開始新轉檔」:**
- 點擊 → 重置 store → 回 idle 狀態
- 不需要清除 backend jobconverter 自己 7 天 GC
### 5.6 completed.failed — 顯示錯誤 + 重試引導
**進入時:**
- 停止 polling
- 顯示 failed Cardwireframe §8
- toast「轉檔失敗{user-friendly message}」
**錯誤翻譯:** 對照 PRD §F5 表 + wireframe §8.1。前端從 `error_code` 查 i18n key
```typescript
const message = t(`conversion.error.${errorCode}.message`)
?? t('conversion.error.unknown.message');
const suggestions = t(`conversion.error.${errorCode}.suggestions`)
?? t('conversion.error.unknown.suggestions');
```
未知 code 一律 fallback 到 `unknown`
**「重新開始」:**
- 重置 store → 回 idle
- 上一個失敗的 job 不會自動重送converter 根本沒收到,或已 failed使用者需要重新選檔
**「回模型庫」:**
- `router.push('/models')`
**「複製任務 ID」**
- `navigator.clipboard.writeText(fullJobId)` → toast「已複製任務 ID」
---
## 6. 邊界情境
### 6.1 同 user 已有 active job409
PRD §F3 D1converter 端 enforce「同 user 同時 1 個 active job」。
| 情境 | UI 行為 |
|------|---------|
| 進入 `/conversion`、有 active | 直接落 processing§5.1|
| 點「開始轉檔」、submit 時拿到 409 | Dialog 自動關閉、切 processing、banner「您已有一個轉檔正在進行中已切換至該任務」|
| 點「開始新轉檔」(在 success 畫面)、實際上有別的 active | 同上(理論上 success 表示自己的 job 已結束,不會撞)|
→ 設計上**不要**在 idle 顯示「您有 active job」就把 CTA 變 disabled因為 §5.1 已經會直接跳走。
### 6.2 上傳到一半失敗
| 失敗點 | 已產生 converter job | UI 行為 |
|--------|----------------------|---------|
| 網路斷在前段XHR 還在 forward| 否 | toast「上傳失敗請重試」+ Dialog 內可 retry |
| 網路斷在 backend → converter 之間 | 可能(看 backend 實作)| backend 應 cancel converter job前端 retry 不會撞 409 |
| 取消上傳 | 視 backend 實作 | backend 應 cancel converter job |
**給 Architect 的補充**visionA backend 在 forward stream 失敗 / 收到 cancel 時,**必須**對 converter 發 `POST /api/v1/jobs/{id}/cancel`,否則使用者下一次 submit 就撞 409 直到 converter idle 為止。
### 6.3 Job 7 天後過期
converter Phase 1 已實作 7 天 GC。前端體驗
| 進入點 | UI |
|--------|----|
| 使用者重新整理 success 畫面(過期後)| `GET /jobs/active` 回 404 → 進 idle如果有 backend cache 顯示「已過期」hint card 更友善 |
| 使用者點「下載」/「加到模型庫」(過期後)| 4xx 失敗 → toast 顯示 + 自動切「已過期」狀態 |
理想做法success 畫面**每分鐘 check** 一次 `expires_at`,到期當下自動切「已過期」(不靠 polling 回 404
### 6.4 多分頁同時開 `/conversion`
| 情境 | 行為 |
|------|------|
| 兩個分頁都在 idle | 各自獨立、互不影響 |
| 分頁 A submit 開始 upload分頁 B 進 idle | 分頁 B 會在頁面 mount 時打 `/jobs/active`,發現 A 已開始 → 直接落 processing 畫面 |
| 兩個分頁同時點「開始轉檔」並各自 submit | 第二個 submit 會收 409 → 切 processing 顯示**已存在的** job |
| 一個分頁完成、按「加到模型庫」、另一個分頁仍在 processing | A 已 model importedB 的 polling 拿到 succeeded 也切到 success 畫面,不會撞(兩個分頁狀態一致)|
**不需要跨分頁通訊BroadcastChannel**,靠 backend 是 source of truth 就足夠。
### 6.5 使用者在 uploading 中重新整理 / 關掉
- XHR 中斷
- backend 偵測到 stream 結束(沒收滿)→ cancel converter job
- 使用者重進頁面 → `/jobs/active` 回 false → 落 idle
### 6.6 使用者在 processing 中重新整理 / 關掉 / 切走
- 沒影響backend / converter 繼續跑
- 重進 → `/jobs/active` 回 true → 落 processing → 繼續 polling
→ 這是 visionA Cloud 相對 local-tool 的核心優勢:「跑一個轉檔可以離開電腦」。在 idle 空狀態與 processing hint 都會說明這點。
### 6.7 上傳大檔500 MB的 UX
- 進度條 + ETA基於 `loaded / total` 移動平均)
- 不擋 UI使用者可以**離開** `/conversion` 切到別頁XHR 仍在背景跑、Dialog 關掉但 XHR 不取消)
- ⚠️ 雛形範圍**不做**這個(會把上傳邏輯從 component 拆到 store / context。Phase 0.8 規格:**Dialog 關掉 = 上傳取消**。
- 文案上 §11 的 `conversion.uploading.warning` 已聲明「請勿關閉此分頁」
- 分頁標題持續更新百分比(讓使用者切到別的分頁也能看進度)
- 慢網(< 1 MB/sETA 顯示估計 8 分鐘這種長時間使用者要意識到要等
### 6.8 下載失敗 / 取消
- `window.location.href = url` 是瀏覽器 navigation不會回到 visionA 顯示錯誤
- 如果 token 已過期5 分鐘 TTL瀏覽器會顯示 FAA 的 403 頁面
- 緩解:使用者回 `/conversion` 再點一次「下載」即可重拿 token
---
## 7. UX Writing 要點
對齊 design-spec.md §1.1「誠實呈現狀態」+ components.md §12「對開發者語調」
| 場景 | 寫法 | 不要 |
|------|------|------|
| 空狀態 heading | 「還沒有進行中的轉檔」 | ❌「您尚未建立任何轉換任務」(過度禮貌) |
| 開始按鈕 | 「開始轉檔」 | ❌「立即開始」「執行轉換」(贅字) |
| processing hint | 「你可以離開此頁面,回來時會自動更新進度」 | ❌「請耐心等候」(沒提供資訊) |
| 失敗 | 「模型內含不支援的運算子,無法量化到目標晶片」 | ❌「轉檔失敗,請重試」(沒說原因) |
| 過期 | 「此轉檔結果保留期為 7 天,目前已超過保留期限並自動清除」 | ❌「資源已不存在」technical 語) |
| 取消上傳確認 | 「上傳尚未完成,確定取消?」 | ❌「您確定要中止此操作嗎?」 |
| 加入模型庫 toast | 「已加入模型庫」 | ❌「您的模型已成功加入至模型庫中」 |
→ 全部走 i18n key§wireframe §11**不在元件 hardcode**。
---
## 8. 給其他 Agent 的補充
### 8.1 給 PM
1. **「加到模型庫」確認 Dialog 內欄位**:建議**只保留模型名稱**一個欄位,預設 `{job.name}_{chip.toLowerCase()}`。描述 / tags 留 Phase 1。如果 PM 認為要做最簡 UX「點下去就 import 不問」,請明確 confirm我移除 Dialog直接走 `/models/{id}` 後使用者再去改名)。
2. **`GET /jobs/active` 端點**UX 設計依賴「進頁面就知道有沒有 active job」。如果這 API 沒列在 PRD §F 段,請補上;否則建議用 query param `?resume=true` + 前端 localStorage jobId 替代(但體驗較差,跨瀏覽器壞掉)。
3. **「開始新轉檔」 vs 結果保留**success 畫面下方有兩個動作result card 內的「加模型庫 / 下載」+ 卡片外面的「開始新轉檔」)。我有意把「開始新轉檔」放結果卡片**外**而不是並列,避免「使用者剛轉完想下載結果,結果一不小心點到開新的」造成困擾。如果 PM 覺得這樣動線不夠順,可以改放結果卡片內,但加 confirm dialog。
4. **錯誤訊息 i18n fallback**`conversion.error.unknown.*` 用於未知 code未來 converter 加新 code 時,前端**先**有合理 fallback後端 i18n 表更新前不會看到 raw code。
### 8.2 給 Architect
1. **新端點建議:`GET /api/conversion/active`**
- 回 `{ active: bool, job?: { id, status, source_filename, target_chip, started_at, expires_at } }`
- 用於 idle / 重新整理時恢復狀態
- 沒這個端點 = 前端要自己用 localStorage 記 jobId跨瀏覽器 / 私密模式 / 多裝置壞掉
2. **Polling 對 backend 的負擔**510 秒/次/user。建議在 visionA backend 對同一個 jobId 做 23 秒 cache避免 hammer converter。預期 10+ concurrent 時必要。
3. **Upload XHR onprogress 的精確度**streaming proxy 模式下,前端進度 = browser → backend 的進度,不是 backend → converter 的進度。如果 backend buffer 過深,前端 100% 完成但 backend 還在傳,使用者會等不耐煩。建議 backend 在 forward 完成後才回 200把這段 buffer 算進進度。
4. **Cancel 時的清理**:使用者按「取消上傳」/ 重新整理 → backend 偵測到 stream 結束 → **務必**對 converter 發 cancel否則該 user 的下一個 submit 會撞 409 直到 converter idle。
5. **Job expires_at 的來源**success 畫面顯示「6 天 21 小時後清除」需要確切時間。如果 converter 不直接給backend 自行 `created_at + 7d` 推算並回。
6. **Phase 1 升級時的相容性**:未來 converter 提供 sub-progress百分比/ webhook → 前端只要在 `processing` 畫面把 indeterminate 換 determinate progress、減少 polling 頻率即可UI 結構不變。
### 8.3 給 Frontend
實作備忘(不是要你照做,是 design 角度的提醒):
1. **`useConversionStore`** 建議結構:
```typescript
{
state: 'idle' | 'uploading' | 'processing' | 'success' | 'failed' | 'expired';
job: ConversionJob | null; // 來自 GET /jobs/active 或 polling
uploadProgress: number; // 0100
uploadEta: number; // seconds, 移動平均算
pollErrorCount: number;
// actions
hydrate(): Promise<void>; // mount 時打 GET /jobs/active
submitUpload(payload): Promise<void>;
cancelUpload(): void;
importToModels(name): Promise<void>;
requestDownload(): Promise<void>;
reset(): void;
}
```
2. **`usePageTitle(title)`** hook上傳中 / processing 動態改 `document.title`cleanup 還原。
3. **Indeterminate Progress**shadcn `Progress` 不帶 value 時是空條,需要加 CSS animation建議 `bg-gradient-to-r from-primary via-primary/40 to-primary` + `animate-[shimmer_2s_linear_infinite]`,並對 `prefers-reduced-motion` fallback 為純色)。
4. **`beforeunload` 警告**:只在 uploading 狀態 attachprocessing 不需要(離開不會中斷後端)。
5. **Test 重點**state machine 的轉移是核心邏輯;建議寫 component test手動觸發 store action、assert UI加上 `GET /jobs/active` 三種回應的 visual snapshot。
---
## 9. 不在 Phase 0.8 範圍
對齊 PRD §5 + wireframe §13
| 項目 | 何時做 | 影響 UX 的部分 |
|------|--------|---------------|
| 轉檔歷史清單 | Phase 1 / 之後 | 目前使用者只能看「眼前這個 job」跑完換新的舊的看不到converter 7 天 GC 也會清)|
| 取消正在跑的 job | Phase 1 | processing 畫面**沒有**取消按鈕converter 已支援UI 不暴露)|
| 多 chip 同時轉 | converter 不支援 | RadioGroup 單選、不是 Checkbox |
| SSE / WebSocket | Phase 1 量大時 | 純 polling、有 510 秒延遲(人眼可接受)|
| 進階參數FP16 等)| Phase 1 | Upload Dialog 沒有「進階」摺疊區 |
| 模型版本 / A/B | Phase 2 | model.SourceJobID 已預埋,可追溯但 UI 不展示 |
| Webhookconverter → visionA push| Phase 0.8 純 polling | backend 不訂閱 |
| 上傳離開頁面繼續跑 | Phase 1 | Dialog 關閉 = 上傳取消 |
---
## 10. KPI / 驗收與設計的對應
| KPIPRD §8| 設計面如何支撐 |
|-------------|---------------|
| 第一個內部使用者轉檔成功率 > 80% | 失敗訊息精準、suggestions 引導;前端驗證提早攔下不支援格式 |
| 上傳到 NEF P95 < 10 分鐘 | Polling 間隔合理不擋使用者離開頁面tab title 通知 |
| 「加到模型庫」點擊率 > 50% | 兩個按鈕視覺權重相當(不偏左 / 不預設 highlight、Dialog 摩擦力低 |
| 失敗錯誤訊息可理解率 100% | §F5 對照表 + i18n unknown fallback |
---
## 11. 待 Reviewer 確認的設計選擇
整理本流程中我做了選擇但**可逆**的決定,給 Design / PM 後續 review
| # | 決策點 | 我的選擇 | 替代方案 | 影響 |
|---|--------|---------|---------|------|
| Q1 | sidebar icon | `Wand2` ✨ | `FileCog` / `Replace` | 視覺風格 |
| Q2 | sidebar 位置 | 模型庫之後 | 設定之前 / 工作區之後 | 心智模型 |
| Q3 | 「加到模型庫」是否需 Dialog 確認名稱 | 需要、單欄位 | 靜默 import / 多欄位(含描述)| 摩擦力 vs 控制感 |
| Q4 | 「開始新轉檔」位置 | 結果卡片**外**下方 | 結果卡片內、與兩個 action 並列 | 誤點風險 |
| Q5 | uploading 階段 Dialog 內顯示進度 vs 全頁切換 | Dialog 內 | 上傳完直接全頁切 processing 並顯示進度 | 視覺一致性 vs 多狀態切換次數 |
| Q6 | processing 不給取消按鈕 | 不給 | 給 + 確認 dialog | UX 安全 vs 控制感 |
| Q7 | success 兩按鈕順序 | 「加到模型庫」左、「下載」右 | 反過來 | 主動作優先級 |
如果使用者 / PM / Architect 對任一項有不同意見,文件以這份為準,調整後**回頭更新此表 + wireframe + i18n**。

View File

@ -0,0 +1,411 @@
# 模型上傳流程 — visionA Cloud
> 雲端版新增流程。使用者上傳 Kneron 編譯後的 `.nef` 模型檔到雲端 object storage之後才能選模型燒錄到遠端裝置。
>
> **技術背景**(給 Design 協作者):模型檔可能達 100MB若走 tunnel / API server 會拖慢服務。Architect 決策走 **presigned PUT**:前端向後端要一組有期限的 PUT URL前端直接把檔案 PUT 到 object storageS3 / R2 / GCS不經過 API server 也不經過 local agent tunnel。進度 / 失敗偵測靠瀏覽器 `XMLHttpRequest.upload.onprogress`
>
> 對應頁面:`/models`(入口) + `UploadModelDialog`(模態框,或走獨立 `/models/upload` 頁,見 6 節決策)
>
> 配套文字版 wireframe`wireframes/wf-model-upload.md`(本次一併建立概念,實作期補圖)
---
## 1. User Story
> **作為** 一個 Kneron 開發者,
> **我想要** 把本地編好的 `.nef` 模型檔上傳到雲端,
> **這樣** 我就能在任何裝置、任何地方把這個模型燒錄到 Kneron 硬體上。
**成功條件:**
- 100MB 雛形上限內的檔案可在 3G 以上網速下 60 秒內完成
- 上傳失敗能明確告訴使用者原因(網路、過期、大小、格式)並可**續傳 / 重試**
- 上傳中不阻塞其他頁面操作(背景上傳)
---
## 2. 範圍與限制Phase 0 雛形)
| 項目 | Phase 0 | Phase 1+ |
|------|---------|---------|
| 單檔最大 | **100 MB**(前端硬限) | 2 GB + 分段上傳 |
| 副檔名 | **`.nef`** 限定(前端驗證) | `.nef` / `.onnx` / 其他 |
| 同時上傳數 | 1雛形不支援佇列 | N 個並行 |
| 中途暫停 | 不支援 | 支援 |
| 續傳 | 失敗後全檔重傳 | 分段續傳 |
| 取消 | 支援abort XHR | 同 |
| 背景上傳 | 不支援(離開頁面 = 中斷)| 支援Service Worker |
| 病毒掃描進度 | 不顯示(後端非同步處理) | 顯示掃描狀態 |
---
## 3. 完整流程
```
使用者在 /models 按「上傳模型」
開啟 UploadModelDialog
┌────────────────────────┐
│ 步驟 A · 選檔 + 填 meta │
│ - 檔案 (.nef) │
│ - 名稱、版本、備註 │
└────────────────────────┘
↓ 按「開始上傳」
前端驗證:
- 副檔名 .nef
- 大小 ≤ 100 MB
- 必填欄位
驗證失敗 → 顯示 error不發 API
POST /api/models/upload-url
body: { filename, size, contentType, metadata }
→ 後端產 presigned PUT URLTTL 15 分鐘)
前端 PUT file 直接到 storage URL不經 API server
- XHR.upload.onprogress → 更新進度條
- 可 abort
成功 (HTTP 200) 失敗
↓ ↓
POST /api/models/confirm 顯示錯誤原因
body: { uploadId, etag } → 重試 / 重新取 URL
→ 後端驗證 checksum、寫入 DB
Toast「✓ 模型 {name} 已上傳」
關閉 Dialog回到 /models 列表(新模型顯示在頂部)
後端非同步掃毒 / 解析 metadata → 狀態更新WebSocket push
```
---
## 4. UI 設計
### 4.1 入口(`/models` 頁面)
```
┌────────────────────────────────────────────────┐
│ 模型庫 │
│ 管理雲端上的 Kneron 模型 │
│ │
│ [📤 上傳模型] │
├────────────────────────────────────────────────┤
│ ModelCard · ModelCard · ModelCard ... │
└────────────────────────────────────────────────┘
```
「上傳模型」按鈕:
- `variant=default size=default`
- 右上角(既有版型)
- 圖示:`Upload` (Lucide)
### 4.2 UploadModelDialog — 選檔階段
```
┌─────────────────────────────────────────────────┐
│ 上傳模型 [✕] │
├─────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ │ │
│ │ 📁 拖曳 .nef 檔到此處 │ │
│ │ │ │
│ │ 或 [選擇檔案] │ │
│ │ │ │
│ │ 支援格式:.nef · 最大 100 MB │ │
│ │ │ │
│ └───────────────────────────────────────────┘ │
│ (bg-muted/50 border-2 border-dashed, h-48) │
│ │
│ 已選檔案: │
│ ┌───────────────────────────────────────────┐ │
│ │ 📄 yolov5s_kl520.nef │ │
│ │ 47.3 MB · 修改於 2026-04-21 │ │
│ │ [✕ 移除] │ │
│ └───────────────────────────────────────────┘ │
│ (選好後才出現hover 淡 bg-accent) │
│ │
│ 模型名稱 * │
│ ┌───────────────────────────────────────────┐ │
│ │ YOLOv5s KL520 │ │
│ └───────────────────────────────────────────┘ │
│ (預設帶入檔名去副檔名,可編輯) │
│ │
│ 版本 │
│ ┌───────────────────────────────────────────┐ │
│ │ v1.0 │ │
│ └───────────────────────────────────────────┘ │
│ │
│ 備註(選填) │
│ ┌───────────────────────────────────────────┐ │
│ │ 針對戶外停車場場景訓練 │ │
│ └───────────────────────────────────────────┘ │
│ (Textarea, rows=3) │
│ │
│ [取消] [開始上傳] │
└─────────────────────────────────────────────────┘
```
**規格:**
- Dialog `max-w-lg`
- Drop zone`h-48 border-2 border-dashed rounded-lg bg-muted/50`hover / drag over 時 `border-primary bg-primary/5`
- `<input type="file" accept=".nef" hidden>`
- 「開始上傳」:檔案未選 / 必填未填 → disabled
### 4.3 UploadModelDialog — 上傳中
```
┌─────────────────────────────────────────────────┐
│ 上傳中 [取消] │
├─────────────────────────────────────────────────┤
│ │
│ 📄 yolov5s_kl520.nef │
│ │
│ ████████████████████░░░░░░░░░░ 62% │
│ (Progress 元件, h-2, bg-primary) │
│ │
│ 28.5 MB / 47.3 MB · 3.4 MB/s · 剩餘約 6 秒 │
│ (text-sm text-muted-foreground) │
│ │
│ ⓘ 請勿關閉此視窗或導航到其他頁面 │
│ │
└─────────────────────────────────────────────────┘
```
**規格:**
- Progress bar既有 `Progress` 元件
- 百分比:`XHR.upload.onprogress``loaded / total`
- 速度:滑動視窗(最近 3 秒的 `loaded` 差 / 時間差)
- 剩餘時間:`(total - loaded) / speed`< 5 秒顯示即將完成
- Dialog `onOpenChange` 被阻擋:正在上傳時 ESC / 點外面**不**關 dialog防誤觸使用者只能按「取消」
- 「取消」AlertDialog「確定要取消上傳已傳的資料會作廢。」→ 確認後 `xhr.abort()`
### 4.4 UploadModelDialog — 成功
```
┌─────────────────────────────────────────────────┐
│ │
│ ✓ │
│ (CheckCircle2, green, h-16) │
│ │
│ 上傳完成 │
│ │
│ yolov5s_kl520.nef (47.3 MB) │
│ │
│ 模型即將進入安全掃描,完成後可燒錄 │
│ │
│ [完成] │
└─────────────────────────────────────────────────┘
```
- 按「完成」關 Dialogtoast「✓ 模型 YOLOv5s KL520 已上傳」
- 列表頂部插入新模型,狀態 badge「掃描中」Phase 1 精緻化)
### 4.5 UploadModelDialog — 錯誤狀態
不同錯誤顯示不同文案與行動:
| 錯誤 | 文案 | CTA |
|------|------|-----|
| 副檔名錯誤(前端擋)| 「只支援 .nef 檔案,你選的是 .onnx」| [重新選檔] |
| 超過大小(前端擋)| 「檔案太大({size} MB最大允許 100 MB」| [重新選檔] |
| 取不到 presigned URL | 「伺服器忙碌,請稍後再試」| [重試] [取消] |
| Presigned URL 過期PUT 403| 「上傳授權已過期,重新取得中...」→ 自動重拿 URL 重傳(僅 1 次) | 自動 |
| 網路中斷 | 「網路中斷,上傳已暫停」| [繼續上傳](雛形=從頭重傳) [取消] |
| Storage 回 5xx | 「儲存空間暫時無法回應,請稍後再試」| [重試] [取消] |
| Confirm API 失敗 | 「上傳完成,但伺服器確認失敗,請重新上傳」| [重新上傳] |
| 取消(使用者觸發)| 不顯示錯誤 | 關 Dialog |
錯誤視覺:
```
┌─────────────────────────────────────────────────┐
│ ⚠ 上傳失敗 │
│ │
│ yolov5s_kl520.nef │
│ │
│ 網路中斷,上傳已暫停 │
│ │
│ 已上傳 28.5 MB / 47.3 MB62%
│ │
│ [取消] [重試] │
└─────────────────────────────────────────────────┘
```
---
## 5. 互動細節
### 5.1 拖曳上傳
- Drop zone 接受 `.nef`其他格式drop 後立刻顯示錯誤
- 整個視窗偵測 `dragenter` 顯示 drop zone 放大提示(`ring-2 ring-primary`
- `dragleave` / drop 後移除 highlight
### 5.2 離開頁面的防護
- 上傳中 `window.onbeforeunload` 提示「上傳進行中,確定要離開嗎?」
- 使用者同意 → abort XHR資料作廢
### 5.3 重複檔名
- 若同名模型已存在Dialog 送出時顯示確認「已存在同名模型,要新增版本還是覆蓋?」
- **新增版本**:送出時把版本欄位自動 `+1`Phase 0 簡化為使用者自己改版本欄)
- **覆蓋**後端支援後再做Phase 1
### 5.4 模型卡片上的新狀態
`ModelCard` 新增狀態 badgePhase 0 簡單版Phase 1 強化):
| 狀態 | Badge | 可操作 |
|------|-------|--------|
| uploading僅上傳期間| 🔵 上傳中 | 不顯示於列表Dialog 內)|
| scanning | 🟡 掃描中 | 不可燒錄 |
| ready | 🟢 可用 | 可燒錄 |
| rejected | 🔴 檢測失敗 | 可刪除、不可燒錄 |
---
## 6. 流程 vs 頁面 — Dialog vs Page 決策
**採用Dialog**(在 `/models` 觸發)
理由:
- 雛形只支援單檔上傳Dialog 夠用
- 保留使用者所在頁面上下文,上傳完可以立刻看到列表
- 獨立頁面 `/models/upload` 留到 Phase 1 支援多檔 / 佇列時再做
URL 不變(不用 routerDialog 的開關用 Zustand store 狀態管理(`uploadStore`),方便未來移到全域 FAB。
---
## 7. API 契約(給 Backend / Architect
### 7.1 `POST /api/models/upload-url`
```json
Request:
{
"filename": "yolov5s_kl520.nef",
"size": 49573888,
"contentType": "application/octet-stream",
"metadata": {
"name": "YOLOv5s KL520",
"version": "v1.0",
"notes": "針對戶外停車場場景訓練"
}
}
Response 200:
{
"uploadId": "upl_01HXXXX",
"putUrl": "https://storage.visiona.ai/models/upl_01HXXXX?X-Amz-Signature=...",
"expiresAt": "2026-04-21T14:45:00Z",
"headers": {
"Content-Type": "application/octet-stream",
"x-amz-meta-model-name": "YOLOv5s KL520"
}
}
```
### 7.2 前端直送 storage
```
PUT {putUrl}
Content-Type: application/octet-stream
body: (file binary)
```
### 7.3 `POST /api/models/confirm`
```json
Request:
{
"uploadId": "upl_01HXXXX",
"etag": "<storage 回的 ETag>"
}
Response 200:
{
"model": {
"id": "mdl_...",
"name": "YOLOv5s KL520",
"status": "scanning",
...
}
}
```
---
## 8. 無障礙
- Dialog既有 shadcn 已處理焦點陷阱與 ESC上傳中 ESC 被攔截)
- Drop zone`role="button" tabIndex={0}`Enter / Space 開啟檔案選擇器
- Progress`role="progressbar" aria-valuenow aria-valuemin aria-valuemax`
- 錯誤訊息:`role="alert"`
- 成功:`role="status" aria-live="polite"`
- 不只靠顏色Progress 旁顯示百分比文字,錯誤有圖示
---
## 9. i18n key
```
models.upload.button → 上傳模型
models.upload.dialog.title → 上傳模型
models.upload.dropzone.label → 拖曳 .nef 檔到此處
models.upload.dropzone.or → 或
models.upload.dropzone.browse → 選擇檔案
models.upload.dropzone.hint → 支援格式:.nef · 最大 100 MB
models.upload.selectedFile → 已選檔案
models.upload.field.name → 模型名稱
models.upload.field.version → 版本
models.upload.field.notes → 備註(選填)
models.upload.action.remove → 移除
models.upload.action.cancel → 取消
models.upload.action.start → 開始上傳
models.upload.uploading.title → 上傳中
models.upload.uploading.hint → 請勿關閉此視窗或導航到其他頁面
models.upload.uploading.stats → {uploaded} / {total} · {speed} · 剩餘約 {eta}
models.upload.uploading.almostDone → 即將完成
models.upload.cancelConfirm.title → 確定要取消上傳?
models.upload.cancelConfirm.desc → 已傳的資料會作廢
models.upload.success.title → 上傳完成
models.upload.success.scanHint → 模型即將進入安全掃描,完成後可燒錄
models.upload.error.invalidType → 只支援 .nef 檔案,你選的是 {type}
models.upload.error.tooLarge → 檔案太大({size}),最大允許 100 MB
models.upload.error.requiredField → {field} 為必填
models.upload.error.urlFailed → 伺服器忙碌,請稍後再試
models.upload.error.urlExpired → 上傳授權已過期,重新取得中...
models.upload.error.networkLost → 網路中斷,上傳已暫停
models.upload.error.storage5xx → 儲存空間暫時無法回應,請稍後再試
models.upload.error.confirmFailed → 上傳完成,但伺服器確認失敗,請重新上傳
models.upload.error.retry → 重試
models.upload.error.resume → 繼續上傳
models.upload.toast.uploaded → ✓ 模型 {name} 已上傳
models.upload.leaveWarning → 上傳進行中,確定要離開嗎?
```
---
## 10. 響應式
| 斷點 | 調整 |
|------|------|
| Mobile (< 640px) | Dialog `max-w-full h-full rounded-none`近似全螢幕Drop zone `h-36` |
| Tablet / Desktop | `max-w-lg` 居中 |
---
## 11. TODOPhase 1+
| 項目 | 時機 |
|------|------|
| 多檔佇列上傳 | Phase 1 |
| 分段上傳resumable, > 100MB| Phase 1 |
| 背景上傳Service Worker 持續)| Phase 2 |
| 獨立 `/models/upload` 頁 | Phase 1有佇列時|
| 上傳歷史 / 失敗重試列表 | Phase 2 |
| 拖曳排序上傳順序 | Phase 2 |
| 病毒掃描詳細進度 | Phase 1 |

View File

@ -0,0 +1,364 @@
# 離線 / 掉線 UI 行為 — visionA Cloud
> 雲端版的根本差異:使用者的 Kneron 裝置**不在瀏覽器那端**,而是在某台電腦上透過 local agent 中繼。這條 tunnel 會因為網路抖動、電腦休眠、agent 被關等原因中斷。UI 必須**誠實呈現狀態**,不讓使用者以為「點了沒反應 = 壞了」。
---
## 1. 連線拓樸與失效點
```
Browser (User) ←HTTPS/WSS→ Cloud API Server ←yamux/WS→ Local Agent ←USB→ Kneron Device
A B C D
│ │ │ │
▼ ▼ ▼ ▼
斷網 (A) 雲端服務掛 (B) Agent 掉線 (C) USB 拔掉 (D)
```
四種失效層級UI 給使用者的回饋應該不同:
| 失效點 | 影響範圍 | UI 呈現 |
|-------|--------|---------|
| A. 使用者斷網 | 全部操作失效 | 瀏覽器層級navigator.onLine+ 全域 `NetworkErrorBanner` |
| B. 雲端 API 掛 | 全部操作失效 | 全域 `NetworkErrorBanner` |
| C. Local agent 掉線 | 該使用者的**所有**遠端裝置 | 每個裝置卡片顯示離線Activity log 寫入 |
| D. USB 裝置拔除 | **單一**裝置 | 該裝置狀態 `error` / `disconnected`(既有行為)|
**核心設計原則:**
1. **「我這端沒事」vs「那台電腦沒事」要可區分** — 使用者需要判斷要怎麼處理
2. **狀態要即時** — 透過 WebSocket 推送 + 合理的 heartbeat interval
3. **優雅降級** — 裝置掉線時,操作按鈕要變成 disabled而不是點了丟 error
---
## 2. 裝置連線狀態模型
雲端版的 Device 新增 `remoteStatus` 欄位(遠端 tunnel 層級),與既有 `status` 欄位USB / 硬體層級)並存:
```typescript
interface Device {
// 既有
id: string;
name: string;
type: string;
status: 'detected' | 'connecting' | 'connected' | 'flashing' | 'inferencing' | 'error' | 'disconnected';
firmwareVersion?: string;
flashedModel?: string;
// 雲端版新增
remoteStatus: 'online' | 'offline' | 'reconnecting' | 'error';
lastSeenAt: string; // ISO 8601最後心跳時間
hostName?: string; // local agent 回報的主機名稱
pairedAt: string; // 配對時間
errorMessage?: string; // 錯誤訊息remoteStatus=error 時)
}
```
### 2.1 狀態組合矩陣
| `remoteStatus` | `status`USB| 使用者看到 | 可執行操作 |
|---------------|---------------|-----------|----------|
| online | connected | 🟢 已連線 | 全部 |
| online | inferencing | 🔵 推論中 | 停止推論、切換來源 |
| online | flashing | 🟡 燒錄中 | 等待中(唯讀) |
| online | error | 🔴 裝置錯誤 | 重試、查看日誌 |
| online | disconnected | ⚪ 裝置未連接 | 重新連接agent 端) |
| **reconnecting** | * | 🟡 重新連線中pulse | 唯讀等待 |
| **offline** | * | ⚪ **離線**・最後心跳 X 前 | 解除配對、查看歷史 |
| error | * | 🔴 連線錯誤:{message} | 查看 troubleshooting |
**顯示優先級**`remoteStatus != online`UI **優先顯示** `remoteStatus`因為連遠端都沒連上USB 狀態已不可信)。
---
## 3. 全域NetworkErrorBanner
**觸發條件**:雲端 API 不可達A 或 B 失效)
### 3.1 偵測邏輯(給 Frontend 參考)
```typescript
// 簡化版邏輯
let consecutiveFailures = 0;
setInterval(async () => {
try {
await fetch('/api/system/health');
consecutiveFailures = 0;
hideBanner();
} catch {
consecutiveFailures++;
if (consecutiveFailures >= 3) showBanner();
}
}, 10_000);
// 搭配 navigator.onLine 事件
window.addEventListener('offline', () => showBanner({ cause: 'local' }));
window.addEventListener('online', () => checkHealth());
```
### 3.2 狀態展示
| 狀態 | 文案 | 樣式 | 按鈕 |
|------|------|------|------|
| 本機斷網 | 「你的網路似乎離線了」 | `bg-amber-50` | [重試] |
| API 失敗重試中 | 「連線中斷 — 無法連上雲端服務。正在重試...」 | `bg-amber-50` | [立即重試] |
| 恢復 | 「✓ 已恢復連線」 | `bg-green-50`(短暫 3 秒消失) | - |
### 3.3 行為
- 顯示於 Header 下方sticky top-14 z-40
- 顯示期間**不阻擋 UI 操作**(使用者仍可點擊按鈕;按下會顯示 toast 錯誤)
- 恢復後 3 秒自動消失
詳細元件規格見 `components.md` 10.4。
---
## 4. 裝置列表:離線裝置顯示
### 4.1 在 `/devices`
```
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Kneron KL520 🟢 │ │ Kneron KL720 ⚪ │ │ Kneron KL520 🟡 │
│ │ │ 離線・2 分鐘前 │ │ 重連中... │
│ 類型KL520 │ │ 類型KL720 │ │ 類型KL520 │
│ 韌體2.3.1 │ │ 韌體cache │ │ 韌體2.3.1 │
│ │ │ │ │ │
│ [管理][工作區] │ │ [管理][解除配對] │ │ [管理]disabled
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
**離線裝置卡片**
- `opacity-75`(淡化但仍可讀)
- `RemoteDeviceBadge` 顯示「離線・最後心跳 2 分鐘前」
- 「工作區」按鈕 hidden 或 disabled
- 「管理」按鈕仍可用(進詳情頁看歷史)
- 新增「解除配對」選項Phase 0 可放進詳情頁而非卡片)
### 4.2 排序建議
- 預設在線優先online → reconnecting → offline → error其次按配對時間
- Phase 1 加篩選:`[全部] [在線] [離線]`
### 4.3 Dashboard ConnectedDevicesList
同樣策略:離線裝置顯示最後心跳時間;若全部裝置離線,顯示 EmptyState「所有裝置都離線了[查看 troubleshooting]」。
---
## 5. 裝置詳情頁(`/devices/[id]`):離線降級
### 5.1 頁面頂部狀態 Banner
`remoteStatus === 'offline'`
```
┌──────────────────────────────────────────────────────────────┐
│ ⚠ 此裝置目前離線 │
│ 最後心跳時間2026-04-21 14:282 分鐘前) │
│ 所在電腦office-mac │
│ 部分操作無法使用,待 local agent 重新連線後自動恢復 │
│ [重新整理] │
└──────────────────────────────────────────────────────────────┘
```
**樣式**`bg-amber-50 dark:bg-amber-950/30` + `border-amber-300`;圖示 `AlertTriangle`
### 5.2 按鈕降級
| 按鈕 | 在線 | 離線 |
|------|------|------|
| 燒錄模型FlashDialog| 可用 | disabled + tooltip「裝置離線中」|
| 開啟工作區 | 可用 | disabled + tooltip「裝置離線中」|
| 中斷連線 | 可用 | 隱藏(已無連線可中斷) |
| 解除配對 | 可用 | **可用**(使用者仍可清除此紀錄) |
| 編輯別名 / 備註 | 可用 | 可用(本地資料修改,不需要 agent |
### 5.3 Cached Data
離線時顯示最後已知資訊(從 server cache 讀):
- 韌體版本
- 已燒錄模型
- 裝置健康狀態(標註「資料截至 X 時間」)
### 5.4 自動恢復
- 頁面持續透過 WebSocket 監聽狀態變化
- `remoteStatus: online` 推送到 → 自動隱藏 banner、恢復按鈕 → toast「✓ 裝置已重新連線」
---
## 6. Workspace`/workspace/[deviceId]`):執行中掉線處理
Workspace 是最不能忍受掉線的頁面(正在跑推論)。
### 6.1 頂部狀態列
既有 Workspace 頁頂部有 `← 返回 + 裝置名稱 + FlashDialog + 開始/停止推論`,新增:
```
← 返回 工作區office-mac / Kneron KL520 🟢 在線 (150ms latency)
└─ 點擊展開詳情
```
連線品質提示Phase 1+
- Latency < 200ms 🟢 流暢
- 200-500ms → 🟡 稍慢
- > 500ms → 🔴 不穩
### 6.2 裝置掉線時的全頁遮罩
```
┌──────────────────────────────────────────────────────────────┐
│ │
│ 🔌 │
│ │
│ 裝置已離線 │
│ │
│ 與 Kneron KL520 的連線中斷,推論已自動停止 │
│ │
│ [等待重連 (0:23)] [返回裝置列表] │
│ │
│ 📄 最後 5 張推論結果已儲存在 /activity │
│ │
└──────────────────────────────────────────────────────────────┘
```
**行為:**
- 遮罩層 `bg-background/80 backdrop-blur-sm`,覆蓋 CameraInferenceView + InferencePanel
- Camera stream 顯示最後一幀 + overlay「連線中斷」
- 持續 polling / WebSocket 重連嘗試
- 若 60 秒內重連成功 → 遮罩消失toast「✓ 裝置已重新連線,請手動重啟推論」(不要自動 resume
- 若 60 秒仍未連線 → 提示「建議先返回,稍後再試」
### 6.3 推論 buffer / 資料保全
- Workspace 裡的推論結果(分類結果、效能指標)暫存在 Zustand store
- 掉線時保留最近資料,可供使用者查看(唯讀)
- 重連後使用者需要**手動重啟**推論(避免意外累積費用或資源浪費)
---
## 7. Cluster Workspace部分離線的降級
叢集工作區(`/workspace/cluster/[clusterId]`)是多裝置場景,允許部分失效。
### 7.1 Degraded 狀態
從 POC 搬來的叢集已經有 `degraded` 狀態某裝置離線叢集自動降級。UI 呈現:
```
叢集Production Cluster A 🟡 降級執行中 (2/3 裝置在線)
成員裝置:
● office-mac KL520 🟢 在線 w=3 處理 85 fps
● home-pi KL720 🟢 在線 w=1 處理 25 fps
● backup KL520 ⚪ 離線 w=3 (已降級)
```
### 7.2 全叢集掉線
所有成員都離線 → 叢集狀態 `offline` → 顯示「叢集目前無法使用,等待裝置重新連線」
---
## 8. Toast / 通知策略
| 事件 | Toast 類型 | 持續時間 | 範例文字 |
|------|-----------|---------|---------|
| 裝置上線 | success | 3s | 「Kneron KL520 已連線」 |
| 裝置掉線(首次)| error | 5s | 「Kneron KL520 已離線」 |
| 推論被迫停止(掉線)| warning | 5s | 「裝置離線,推論已停止」 |
| 重新連線成功 | success | 3s | 「✓ Kneron KL520 已重新連線」 |
| API 失敗(單次)| error | 4s | 「操作失敗,請稍後再試」 |
| Pairing 完成 | success | 4s | 「✓ Kneron KL520 已成功配對」 |
| Pairing 失敗 | error | 5s | 「配對失敗:{原因}」 |
**重要****不要**對同一個裝置的反覆掉線 spam toast。建議 debounce / throttle同裝置同狀態 60 秒內只發一次通知。
---
## 9. Activity Timeline 擴充
Dashboard 的活動時間軸新增雲端版事件類型:
| Event | Icon | 文案範例 |
|-------|------|---------|
| `device_paired` | `Link2` | 已配對裝置 Kneron KL520 |
| `device_unpaired` | `Unlink` | 已解除配對 Kneron KL520 |
| `device_online` | `CheckCircle` | Kneron KL520 已上線 |
| `device_offline` | `XCircle` | Kneron KL520 已離線 |
| `tunnel_reconnected` | `RefreshCw` | 與 office-mac 的連線已恢復 |
| `cluster_degraded` | `AlertTriangle` | Production Cluster A 降級執行中 |
---
## 10. i18n key整合
```
remote.status.online → 在線 / Online
remote.status.offline → 離線 / Offline
remote.status.reconnecting → 重新連線中 / Reconnecting
remote.status.error → 連線錯誤 / Connection error
remote.lastSeen → 最後心跳 {time} / Last seen {time}
remote.lastSeenNever → 從未連線 / Never connected
remote.banner.offline.title → 此裝置目前離線
remote.banner.offline.description → 部分操作無法使用,待 local agent 重新連線後自動恢復
remote.banner.offline.refresh → 重新整理
remote.workspace.disconnected.title → 裝置已離線
remote.workspace.disconnected.description → 與 {deviceName} 的連線中斷,推論已自動停止
remote.workspace.disconnected.waiting → 等待重連 ({time})
remote.workspace.disconnected.backToList → 返回裝置列表
remote.toast.online → {deviceName} 已連線
remote.toast.offline → {deviceName} 已離線
remote.toast.reconnected → ✓ {deviceName} 已重新連線
remote.toast.inferenceStopped → 裝置離線,推論已停止
network.disconnected.title → 連線中斷
network.disconnected.description → 無法連上雲端服務。正在重試...
network.disconnected.retryButton → 立即重試
network.restored → ✓ 已恢復連線
```
---
## 11. 無障礙
- 所有狀態變更透過 `aria-live="polite"` 宣告
- `RemoteDeviceBadge``role="status"`
- 錯誤遮罩層用 `role="alertdialog"` + 焦點陷阱
- 不只靠顏色(綠 / 紅 / 黃),都搭配文字與 icon
- 掉線遮罩的按鈕可 Tab 聚焦、Enter 觸發
- 重連倒數計時不閃爍(避免 seizure-inducing motion
---
## 12. 效能考量
- Heartbeat intervallocal agent ↔ 雲端 **10 秒 / 次**(全棧統一)
- 掉線判定閾值:**3 次未收到心跳30 秒)** 才標記為 offline避免短暫抖動誤判
- UI 呈現對應時機:
- 第 1 次心跳 miss10s 起):不做任何變化,避免閃爍
- 第 2 次心跳 miss20s 起):裝置狀態切為 🟡 `reconnecting`(重連中),卡片 `opacity-90`**不**發 toast
- 第 3 次心跳 miss30s 達成):標記為 ⚪ `offline`,發 toast「{裝置名稱} 已離線」Workspace 遮罩觸發
- 任一期間心跳恢復:立即切回 🟢 `online`,若曾通知則發「✓ {裝置名稱} 已重新連線」
- 前端 polling 避免過於密集;優先使用 WebSocket push
- 離線裝置不主動 polling 詳情 API等使用者進詳情頁才拉
---
## 13. TODO
| 項目 | 時機 |
|------|------|
| Latency 顯示 | Phase 1 |
| 連線品質圖表(過去 24 小時 uptime| Phase 1 |
| 使用者可訂閱特定裝置的掉線通知Email / Slack / Webhook| Phase 2 |
| 自動重啟推論(使用者可選擇)| Phase 2 |
| SLA / 可用性儀表板 | Phase 2+ |

View File

@ -0,0 +1,504 @@
# Pairing 流程設計 — visionA Cloud
> **雲端版最關鍵的新增流程**。使用者要能在雲端 Web 取得 Pairing Token、帶到自己電腦上的 local agent、完成配對之後就能從瀏覽器遠端控制自己的 Kneron 裝置。
>
> 配套的文字版 wireframe 見 [`wireframes/wf-pairing.md`](../wireframes/wf-pairing.md)。
---
## 1. User Story
> **作為** 一個 Kneron 開發者,
> **我想要** 用一組短短的 token 讓雲端和我桌機上的 Kneron 裝置建立連線,
> **這樣** 我就能從任何地方(出差、客戶端、家裡)用瀏覽器操作我的裝置,不需要每次都連 VPN 或 SSH。
**成功條件**
- Token 產生到完成連線 < 3 分鐘
- 使用者不需要懂 tunnel / yamux / WebSocket 等技術細節
- 失敗時能明確告訴使用者哪一步錯、怎麼修
---
## 2. 流程全景圖
```
雲端 Web 使用者電腦 雲端後端
─────── ───────── ────────
local agent
(Go binary)
[登入] ──→ [/devices/pair] ──────────────────────────────────→ POST /api/pairing/token
▼ ↓
產生 token 產生 vAc_ + 32 hex token
▼ (綁 user_id, TTL 15 分鐘)
[PairingTokenCard 顯示 token] ↓
▼ 回傳 token
使用者按「複製」 ←─────
切換到「下載 local agent」分頁
根據 OS 顯示對應下載連結 + 指令
使用者下載、安裝、啟動 local agent
▼ local agent 啟動 ────→ GET /tunnel/connect?token=xxx
▼ ↓
第 3 步:等待連線 WebSocket 升級
▼ yamux session 建立
[polling] GET /api/pairing/status?token=xxx ──────────────────→ ↓
▲ 回 200 + 裝置資訊
│ ↓
◀───────────────────────────────────────────────────────────
「✓ 已連線!」+ 顯示裝置資訊
[繼續] 按鈕
跳 /devices顯示新裝置
```
---
### 2.1 Token 格式與兩階段 Token 模型
**雲端有兩種 token使用者只會接觸到第一種**
| Token | 用途 | 格式 | TTL | 使用者可見 |
|-------|------|------|-----|----------|
| **Pairing Token** | 一次性配對 token使用者貼到 local agent 啟動連線 | `vAc_` + 32 字元 hex總長 36| **15 分鐘** | ✅ 看得到、要複製 |
| **Session Token** | Local agent 換到後維持 tunnel 連線用 | 後端規格(使用者不關心) | 90 天 | ❌ 完全看不到 |
**換取流程(使用者無感):**
```
1. 雲端 Web 產生 Pairing TokenvAc_xxxx…→ 使用者複製
2. 使用者貼到 local agent → local agent 用這個 token 打 /tunnel/connect
3. 雲端驗證 Pairing Token → 核發 Session Token 給 local agent
4. Pairing Token 立即失效one-time use
5. 後續 local agent 所有連線都帶 Session Token使用者完全感受不到
```
**UI 顯示策略36 字元太長、15 分鐘很緊):**
- API 回傳完整 `vAc_` + 32 hex一整串共 36 字元,無空格)
- **UI 顯示切成兩行**提升可讀性(`font-mono text-xl tracking-wider`
- 第 1 行:`vAc_` 前綴 + 前 16 字元 hex共 20 字元)
- 第 2 行:後 16 字元 hex
- 行內用 `-` 或純空白切開都行,**切法純視覺**`select-all` 選取時選到整個區塊
- **實際複製到剪貼簿的永遠是無空格、無換行的完整字串**`vAc_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8`
- Mobile< 640px字級降為 `text-lg`仍切兩行不允許水平捲動
- 螢幕閱讀器:`aria-label` 給完整字串,但文字節點用分組避免一字一念
---
## 3. 三步式 Stepper
使用既有視覺語彙,以 Tabs 或 Stepper UI 呈現。**建議使用 Stepper**(因為步驟有順序依賴)。
### 3.1 視覺
```
┌─────────────────────────────────────────────────────────────────┐
│ ← 返回 │
│ │
│ 配對新裝置 │
│ 讓你的 Kneron 裝置連上雲端,就能從任何地方遠端操作 │
│ │
│ ●──────────────────●──────────────────○ │
│ 1 2 3 │
│ 取得 Token 設定 Local Agent 確認連線 │
│ │
│ [當前步驟內容] │
│ │
│ [上一步] [下一步] │
└─────────────────────────────────────────────────────────────────┘
```
### 3.2 Stepper 規格
- 步驟指示器:`flex items-center gap-2`,使用 Lucide `Circle` / `CheckCircle`
- 已完成:`bg-primary text-primary-foreground` + `CheckCircle` 填色
- 當前:`ring-2 ring-primary bg-background`
- 未完成:`bg-muted text-muted-foreground`
- 步驟間連線:`h-0.5 bg-muted`(已完成段落 `bg-primary`
- 下方步驟標題:`text-sm font-medium`(已完成/當前)、`text-muted-foreground`(未完成)
### 3.3 底部按鈕
- 上一步:`variant=outline`,第 1 步 disabled
- 下一步:`variant=default`,第 3 步改為「完成 → 進入裝置列表」
---
## 4. Step 1 — 取得 Token
### 4.1 版型
```
┌──────────────────────────────────────────────────────────────┐
│ Step 1 · 取得 Pairing Token │
│ │
│ 複製下方 token在 Step 2 讓 local agent 使用這組 token 連線雲端 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 🔗 你的 Pairing Token │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ vAc_a1b2c3d4e5f6a7b8 │ │ │
│ │ │ c9d0e1f2a3b4c5d6e7f8 │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ │ (視覺切兩行,複製永遠是完整 36 字元無空格) │ │
│ │ │ │
│ │ [📋 複製] [🔄 重新產生] │ │
│ │ │ │
│ │ ⏱ 剩餘 14:52 ─────────────────────── │ │
│ │ 進度條bg-primary隨時間變 amber → red │ │
│ │ 📅 產生時間14:30 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ⚠ 這組 token 15 分鐘內有效,請立刻到 Step 2 完成配對 │
│ token 是一次性使用,完成配對後自動失效 │
│ │
└──────────────────────────────────────────────────────────────┘
```
### 4.2 主要元件
- `PairingTokenCard`(見 `components.md` 10.2)— 含倒數計時器
- 下方安全提示:`Callout` 樣式(`bg-amber-50 border-amber-300`,或用 Alert 元件)
### 4.3 初始狀態
- 進入頁面自動呼叫 `POST /api/pairing/token` 產生新 token`vAc_` + 32 hexTTL 15 分鐘)
- 客戶端啟動 1 秒一次的倒數計時(從 `expiresAt - now()` 算起),不 polling 後端
- 若 API 失敗:顯示 error state 「無法產生 token請重試」+ 重試按鈕
### 4.4 倒數計時器視覺規格
TTL 從 72 小時降到 15 分鐘,倒數計時器要**顯著且突出**,讓使用者意識到要馬上行動:
| 剩餘時間 | 視覺 | 行為 |
|---------|------|------|
| > 10:00 | `text-foreground` + 進度條 `bg-primary` | 正常顯示 |
| 10:00 3:00 | `text-amber-600` + 進度條 `bg-amber-500` | 文字變粗 `font-medium` |
| 3:00 0:30 | `text-red-600` + 進度條 `bg-red-500` + 卡片 `ring-1 ring-red-300` | 計時文字前加 ⚠;建議配 `prefers-reduced-motion` 尊重,不閃爍 |
| ≤ 0:30 | 同上 + Toast「Token 即將過期,請立刻完成或重新產生」 | 只 Toast 一次 |
| 00:00 | Token 轉灰 `text-muted-foreground line-through`;複製按鈕 disabled顯示「此 token 已過期,請重新產生」 | `aria-live="assertive"` 宣告 |
**格式**`mm:ss`(例:`14:52``02:17``00:08`不要顯示小時15 分鐘內絕不超過 15:00。
### 4.5 互動行為
| 操作 | 行為 |
|------|------|
| 點「複製」| 呼叫 `navigator.clipboard.writeText(token)` 複製**完整 36 字元無空格**字串;按鈕暫態變「已複製 ✓」2 秒toast「Token 已複製到剪貼簿15 分鐘內有效」|
| 點「重新產生」| 開 AlertDialog「確定要重新產生舊 token 將立即失效,新 token 有效期 15 分鐘。」→ 確認後 POST API更新 UI重啟倒數 |
| Token 過期0:00| 自動將 UI 切至過期狀態;主 CTA 從「下一步」改為「重新產生 token」|
| 進入 Step 2 前 | 檢查使用者是否已複製(用 state。未複製則禁止下一步提示「請先複製 token」或允許繼續但 toast warning |
### 4.6 i18n key
```
pairing.step1.title → Step 1 · 取得 Pairing Token
pairing.step1.description → 複製下方 token15 分鐘內到 Step 2 完成配對
pairing.token.title → 你的 Pairing Token
pairing.copy → 複製
pairing.copied → 已複製
pairing.regenerate → 重新產生
pairing.timeRemaining → 剩餘 {time}
pairing.expiresIn15min → 15 分鐘內有效
pairing.generatedAt → 產生時間:{time}
pairing.security.warning → 這組 token 15 分鐘內有效,請立刻到 Step 2 完成配對
pairing.security.oneTime → token 是一次性使用,完成配對後自動失效
pairing.regenerateConfirm.title → 確定要重新產生?
pairing.regenerateConfirm.description → 舊 token 將立即失效,新 token 有效期 15 分鐘
pairing.toast.copied → Token 已複製到剪貼簿15 分鐘內有效
pairing.toast.generateFailed → 無法產生 token請重試
pairing.toast.expiringSoon → Token 即將過期,請立刻完成或重新產生
pairing.token.expired.label → 此 token 已過期,請重新產生
```
---
## 5. Step 2 — 設定 Local Agent
### 5.1 版型
```
┌──────────────────────────────────────────────────────────────┐
│ Step 2 · 設定 Local Agent │
│ │
│ 在你的電腦下載並啟動 local agent讓它連上雲端 │
│ │
│ 選擇作業系統: │
│ [ macOS ] [ Windows ] [ Linux ] │
│ ▲ active │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 1. 下載 local agent │ │
│ │ [📥 下載 visionA-local-agent-macos.dmg] │ │
│ │ │ │
│ │ 2. 安裝並啟動 │ │
│ │ 打開 DMG拖曳到 Applications雙擊啟動 │ │
│ │ │ │
│ │ 3. 輸入 Pairing Token │ │
│ │ 在 local agent 的「雲端」設定頁貼上 token │ │
│ │ │ │
│ │ 或使用命令列CLI 進階使用者): │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ visiona-agent --pair-token=\ │ │ │
│ │ │ vAc_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8 │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ │ (複製時為完整單行無反斜線) │ │
│ │ │ │
│ │ 4. 連線成功後,回到這個頁面進入 Step 3 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 💡 已經有 local agent[跳到 Step 3] │
└──────────────────────────────────────────────────────────────┘
```
### 5.2 作業系統分頁
使用 `Tabs` 元件,三個 tab`macOS` / `Windows` / `Linux`,自動偵測 `navigator.userAgent` 設預設。
**平台差異:**
| OS | 下載檔案 | 安裝步驟 | 命令列 |
|----|---------|---------|--------|
| macOS | `.dmg` | 拖曳到 Applications | `visiona-agent --pair-token=vAc_...` |
| Windows | `.exe` installer | 雙擊執行 | `visiona-agent.exe --pair-token=vAc_...` |
| Linux | `.AppImage` | `chmod +x` 後執行 | `./visiona-agent --pair-token=vAc_...` |
### 5.3 指令區塊
- 使用 `bg-muted font-mono text-sm p-3 rounded-md` 樣式
- 右上角有「複製」小按鈕
- 將使用者 token 自動嵌入指令(`--pair-token=[當前 token]`
### 5.4 互動
| 操作 | 行為 |
|------|------|
| 切換 OS Tab | 內容重新渲染 |
| 點「下載」按鈕 | 直接下載對應 OS 的 installer |
| 點指令「複製」| 複製整行 `visiona-agent --pair-token=vAc_xxxx…`(完整 36 字元 token單行無跳行|
| 「已經有 local agent」| 直接跳 Step 3 |
### 5.5 i18n key
```
pairing.step2.title → Step 2 · 設定 Local Agent
pairing.step2.description → 在你的電腦下載並啟動 local agent...
pairing.step2.selectOS → 選擇作業系統
pairing.step2.download → 下載 local agent
pairing.step2.install → 安裝並啟動
pairing.step2.installHint.macos → 打開 DMG拖曳到 Applications...
pairing.step2.installHint.windows → 雙擊 installer...
pairing.step2.installHint.linux → chmod +x 後執行...
pairing.step2.enterToken → 輸入 Pairing Token
pairing.step2.tokenHint → 在 local agent 的「雲端」設定頁貼上 token
pairing.step2.orCli → 或使用命令列CLI 進階使用者):
pairing.step2.waitConnect → 連線成功後,回到這個頁面進入 Step 3
pairing.step2.alreadyHaveAgent → 已經有 local agent[跳到 Step 3]
```
---
## 6. Step 3 — 確認連線
### 6.1 視覺(等待中)
```
┌──────────────────────────────────────────────────────────────┐
│ Step 3 · 確認連線 │
│ │
│ ⏳ 等待 local agent 連線... │
│ │
│ 已等待 0:15最長 3 分鐘) │
│ │
│ [取消] [查看 Troubleshooting] │
│ │
│ 提示: │
│ • 確認 local agent 已啟動 │
│ • 確認 token 正確無誤 │
│ • 確認你的網路可以連到 cloud.visiona.ai │
└──────────────────────────────────────────────────────────────┘
```
### 6.2 視覺(成功)
```
┌──────────────────────────────────────────────────────────────┐
│ Step 3 · 確認連線 │
│ │
│ ✓ 已成功連線! │
│ │
│ 檢測到的裝置: │
│ ┌────────────────────────────────────┐ │
│ │ 🔌 Kneron KL520 │ │
│ │ Firmware 2.3.1 · Host: office-mac│ │
│ └────────────────────────────────────┘ │
│ │
│ [進入裝置列表 →] │
└──────────────────────────────────────────────────────────────┘
```
### 6.3 視覺(失敗 / 超時)
```
┌──────────────────────────────────────────────────────────────┐
│ Step 3 · 確認連線 │
│ │
│ ⚠ 連線超時 │
│ │
│ 超過 3 分鐘沒收到 local agent 連線 │
│ │
│ 可能原因: │
│ • local agent 尚未啟動 │
│ • token 輸入錯誤 │
│ • 防火牆擋住 WebSocket 連線 │
│ │
│ [重新檢查] [回到 Step 2] [查看完整說明] │
└──────────────────────────────────────────────────────────────┘
```
### 6.4 行為
- 進入 Step 3 自動開始 polling `/api/pairing/status?token=xxx`(每 3 秒)
- 計時器顯示已等待時間
- 成功(`status: connected`):停止 polling顯示成功畫面 + 裝置資訊
- 超時(> 3 分鐘):停止 polling顯示失敗畫面
- 「取消」:返回 `/devices`token 保留(使用者可稍後重試)
- 「重新檢查」:重啟 polling
### 6.5 i18n key
```
pairing.step3.title → Step 3 · 確認連線
pairing.step3.waiting → 等待 local agent 連線...
pairing.step3.elapsed → 已等待 {time}(最長 3 分鐘)
pairing.step3.cancel → 取消
pairing.step3.troubleshooting → 查看 Troubleshooting
pairing.step3.hints.title → 提示:
pairing.step3.hints.running → 確認 local agent 已啟動
pairing.step3.hints.token → 確認 token 正確無誤
pairing.step3.hints.network → 確認你的網路可以連到 cloud.visiona.ai
pairing.step3.success → 已成功連線!
pairing.step3.success.detected → 檢測到的裝置:
pairing.step3.success.continue → 進入裝置列表 →
pairing.step3.failure.timeout → 連線超時
pairing.step3.failure.reason → 超過 3 分鐘沒收到 local agent 連線
pairing.step3.failure.causes → 可能原因:
pairing.step3.failure.retry → 重新檢查
pairing.step3.failure.backToStep2 → 回到 Step 2
pairing.step3.failure.docs → 查看完整說明
```
---
## 7. 邊界情境
### 7.1 Pairing Token 過期15 分鐘後)
- 進入 Step 3 polling 時若收到 `status: expired` → 顯示「Token 已過期」+ 「回到 Step 1 重新產生」
- Step 1 若使用者停留超過 15 分鐘未下一步,倒數歸零 UI 會自動切到過期狀態(見 4.4
- **注意**:這裡過期的是 Pairing Token15 分鐘。Local agent 一旦換到 Session Token90 天),後續連線不受影響,使用者看不到這層
### 7.2 同一 token 被多次使用
- Architect 端決策token 應該是**一次性**的(綁第一個連上的 local agent
- 後續若同 token 被其他 agent 嘗試連線 → Step 3 polling 顯示「此 token 已被其他裝置使用」
### 7.3 網路中斷polling 失敗)
- 顯示臨時錯誤 + 自動重試(指數退避)
- 3 次失敗後提示「網路不穩,請檢查連線」
### 7.4 頁面離開 / 重新整理
- 若 token 已產生但未完成連線:重新進入頁面應該偵測到並回到 Step 3或重回 Step 1 重新產)
- 使用 URL query param `?token=xxx` 保存進行中的 token
### 7.5 多裝置一次配對
- Phase 0一次只配對一個裝置一個 token 對一台 agent / 一台 host
- Phase 1考慮支援一台 host 多張 Kneron 卡agent 端回報多裝置)
---
## 8. 成功後的導航
完成配對後:
1. 在 `/devices` 列表看到新裝置(`RemoteDeviceBadge` = 在線)
2. Toast「裝置 Kneron KL520 已成功配對」
3. Activity Timeline 新增一筆 `device_paired` 紀錄
---
## 9. 給 Architect / Backend 的 API 契約提議
### 9.1 `POST /api/pairing/token`
Pairing Token`vAc_` 前綴 + 32 字元 hexTTL 15 分鐘,一次性使用。
```json
Request: (需 auth雛形可 skip auth
Response 200:
{
"token": "vAc_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8",
"expiresAt": "2026-04-21T14:45:00Z",
"createdAt": "2026-04-21T14:30:00Z",
"ttlSeconds": 900
}
```
Session Token使用者看不到雲端 ↔ local agent 內部TTL 90 天Pairing Token 成功換取後發給 local agent。此層 UI 無關,僅記錄備查。
### 9.2 `GET /api/pairing/status?token=xxx`
```json
Response 200:
{
"status": "pending" | "connected" | "expired" | "used",
"device": {
"id": "kl520-usb-0001",
"name": "Kneron KL520",
"type": "KL520",
"firmwareVersion": "2.3.1",
"hostName": "office-mac"
} | null,
"pairedAt": "2026-04-21T14:35:00Z" | null
}
```
### 9.3 `DELETE /api/pairing/token/:token`
重新產生時,先刪除舊的。
### 9.4 `POST /api/devices/:id/unpair`
解除配對(從 `/devices/[id]` 的 DeviceSettingsCard 或 `/account` 觸發)。
---
## 10. 無障礙檢查
- Stepper`role="list"` + 每個 step `role="listitem"` + 當前 step `aria-current="step"`
- Token 顯示:`aria-label="Pairing token, {token}"`(但不念出字元)
- 複製按鈕:`aria-live="polite"` 宣告「已複製」
- Step 3 等待:`aria-live="polite"` 宣告 status 變更
- 所有按鈕可 Tab 聚焦、Enter / Space 觸發
- 錯誤訊息:`role="alert"`
---
## 11. TODO
| 項目 | 時機 |
|------|------|
| Local agent UI 的「雲端」分頁設計(接收 token 的那端)| Phase 0 同步設計(需跨 local-tool另開需求|
| ~~Token 過期警告~~ → 已納入 Phase 0見 4.4 倒數計時器,因 TTL 降至 15 分鐘必做)| — |
| 支援 Email 發送 token方便跨裝置| Phase 2 |
| QR Code 配對(手機掃描)| Phase 2 |
| 一次性 pairing code更短 6 位數字UX 更好)| Phase 2 |

View File

@ -0,0 +1,518 @@
# 頁面結構規格 — visionA Cloud
> 本文件逐頁列出所有頁面的版型、主要區塊、互動重點與雲端版改動。
>
> - **沿用頁面**:版型與行為 100% 沿用 local-tool只改 API base URL 與新增遠端狀態顯示
> - **雲端新增 stub**Phase 0 雛形只做低保真骨架Phase 1 完整化
> - **雲端新增完整**:本次完整設計,工程師可直接實作
---
## 頁面總覽
| 路由 | 類型 | 主要元件 | Phase 0 狀態 |
|------|------|---------|-------------|
| `/` | 沿用 | DashboardStats + Activity + Devices | 沿用 + 小調整 |
| `/login` | 新增 stub | 登入表單 | Stub簡化 |
| `/register` | 新增 stub | 註冊表單 | Stub簡化 |
| `/account` | 新增 stub | 帳號設定 | Stub單頁 |
| `/devices` | 沿用 | 裝置列表 | 沿用 + 改造 |
| `/devices/pair` | 新增 | Pairing 流程 | **完整設計** |
| `/devices/[id]` | 沿用 | 裝置詳情 | 沿用 + 遠端狀態 |
| `/models` | 沿用 | 模型庫 | 沿用 |
| `/models/[id]` | 沿用 | 模型詳情 | 沿用 |
| `/clusters` | 從 POC 搬 | 叢集列表 | 沿用 POC 設計 |
| `/workspace` | 沿用 | 工作區選擇 | 沿用 + 遠端狀態 |
| `/workspace/[deviceId]` | 沿用 | 推論操作 | 沿用 + 掉線降級 |
| `/settings` | 沿用 | 設定 | 沿用 + 小調整 |
---
## 1. `/` — Dashboard儀表板
**類型**:沿用 + 小調整
### 1.1 既有版型(不變)
```
┌─────────────────────────────────────────────────────────┐
│ 標題 + 副標題 │
├─────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ StatCard │ │ StatCard │ │ StatCard │ │ StatCard │ │
│ │ 模型數 │ │ 裝置數 │ │ 已連線 │ │ 燒錄次數 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
├─────────────────────────────────────────────────────────┤
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ ConnectedDevicesList │ │ ActivityTimeline │ │
│ │(已連線裝置) │ │(近期活動) │ │
│ └──────────────────────┘ └──────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ Quick Actions │
│ [瀏覽模型] [管理裝置] [上傳模型] │
└─────────────────────────────────────────────────────────┘
```
### 1.2 雲端版調整
| 項目 | 改動 |
|------|------|
| 標題 | `visionA Local``visionA Cloud` |
| StatCard「已連線」 | 指「線上遠端裝置」,不再是 USB 連接狀態 |
| ConnectedDevicesList | 每個項目右側新增 `RemoteDeviceBadge`(離線 / 最後心跳)|
| OnboardingDialog | Phase 0 暫時移除或改為「去配對裝置」CTA |
| Quick Actions | 新增「配對裝置Pair Device」按鈕導向 `/devices/pair` |
### 1.3 空狀態
當使用者是第一次進入(沒有裝置、沒有活動):
```
┌─────────────────────────────────────────────────────────┐
│ 🔗 │
│ 還沒有任何裝置 │
│ 配對你的第一台 Kneron 裝置,開始雲端推論之旅 │
│ │
│ [配對裝置] │
└─────────────────────────────────────────────────────────┘
```
使用 `EmptyState` 元件Icon=Lucide `Link2`action=跳 `/devices/pair`
### 1.4 互動重點
- StatCard 點擊 → 跳對應列表頁
- ConnectedDevicesList 點擊裝置 → 跳 `/workspace/[deviceId]`
- ActivityTimeline 自動 polling / WebSocket 更新(每 5 秒 or 即時)
---
## 2. `/login` — 登入(新增 stub
**類型**:新增 stubPhase 0 簡化Phase 1 完整化)
### 2.1 Phase 0 版型
```
┌─────────────────────────────────────────────────────────┐
│ │
│ │
│ [Logo] visionA Cloud │
│ Edge AI Platform │
│ │
│ ┌────────────────────────────────┐ │
│ │ Email │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ you@example.com │ │ │
│ │ └────────────────────────┘ │ │
│ │ │ │
│ │ Password │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ •••••••• │ │ │
│ │ └────────────────────────┘ │ │
│ │ │ │
│ │ [ 登入 ] │ │
│ │ │ │
│ │ ────── 還沒有帳號? ────── │ │
│ │ [ 建立新帳號 ] │ │
│ └────────────────────────────────┘ │
│ │
│ 忘記密碼? | 語言切換:繁中 ▾ │
│ │
└─────────────────────────────────────────────────────────┘
```
### 2.2 規格
- **Layout**:無 Sidebar / Header 的獨立版型(`app/(auth)/layout.tsx`
- **容器**`max-w-md` 置中,背景 `bg-background`
- **Logo**:與 Sidebar 同一個(`/visiona-logo.png`
- **表單**`Card` 包起來,內含 Label + Input + Button
- **登入按鈕**`variant=default` `size=default` 撐滿寬度(`w-full`
- **註冊連結**`variant=outline` 撐滿寬度
- **底部**:忘記密碼連結 + 語言切換(小型 Select
### 2.3 Phase 0 雛形簡化
- 送出表單**不接後端**:直接 `router.push('/')`
- 表單驗證只有「必填」HTML5 `required`),無格式驗證
- 「忘記密碼」:灰色 disabled
- 「建立新帳號」:跳 `/register`
- 登入 error 狀態:不做
### 2.4 Phase 1 待辦
- OAuthGoogle / GitHub
- 密碼強度驗證、Email 格式驗證
- 「記住我」Checkbox
- Email 驗證流程
- 登入失敗的明確錯誤訊息(密碼錯 / 帳號不存在 / 被鎖)
- Rate limit 提示
- 雙因素驗證2FA
### 2.5 響應式
- Desktop / Tablet / Mobile 都單欄置中,自然 responsive
- Mobile表單寬度自適應 `w-full` + padding `px-4`
### 2.6 i18n key新增
```
auth.login.title → 登入 / Sign in
auth.login.subtitle → 歡迎回到 visionA Cloud
auth.login.email → Email
auth.login.password → 密碼 / Password
auth.login.submit → 登入
auth.login.forgotPassword → 忘記密碼?
auth.login.noAccount → 還沒有帳號?
auth.login.createAccount → 建立新帳號
```
### 2.7 無障礙
- 表單 Label 與 Input `htmlFor` / `id` 對應
- Email input `type="email" autoComplete="email"`
- Password input `type="password" autoComplete="current-password"`
- 提交按鈕在 Input 聚焦時 Enter 可觸發
- Tab 順序Email → Password → 登入 → 建立新帳號 → 忘記密碼 → 語言
---
## 3. `/register` — 註冊(新增 stub
**類型**:新增 stub
### 3.1 Phase 0 版型
`/login` 類似,差異:
```
標題:建立帳號
表單欄位:
- Email必填
- 密碼(必填)
- 確認密碼(必填,與密碼相同)
按鈕:[建立帳號] → 成功後跳 /devices/pair引導第一次配對
返回連結:已有帳號? [登入]
```
### 3.2 雛形簡化
- 送出不接 API直接 `router.push('/devices/pair')`(引導第一次 Pairing
- 密碼確認檢查:用 client-side 即時比對(失焦驗證)
- 無 ToS / Privacy Policy 勾選Phase 1 補)
### 3.3 Phase 1 待辦
- Email 驗證流程
- 密碼強度指示器
- ToS / Privacy Policy 勾選
- 防機器人reCAPTCHA / Turnstile
- 重複 Email 檢查
---
## 4. `/account` — 帳號設定(新增 stub
**類型**:新增 stubPhase 0 單頁Phase 1 拆成多頁)
### 4.1 Phase 0 版型
```
┌─────────────────────────────────────────────────────────┐
│ 帳號設定 │
│ 管理你的個人資料與偏好 │
├─────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 個人資料 │ │
│ │ Email jim@example.com不可編輯 │ │
│ │ 顯示名稱 [Jim Chen____________] [儲存] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 已配對的裝置 │ │
│ │ • KL520 (192.168.1.23) 配對於 04/18 [解除配對] │ │
│ │ • KL720 (office-mac) 配對於 04/21 [解除配對] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 危險區Danger Zone │ │
│ │ 刪除帳號 [刪除帳號] │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 4.2 規格
- 使用既有 `Tabs` or 單頁 `Card` 多區塊(雛形選單頁)
- 每個區塊 `Card`,內含 Header + Content
- 危險區使用 `destructive` 變體
### 4.3 雛形簡化
- 所有操作不接 API按鈕點擊只顯示 toast「此功能開發中」
- 資料顯示為假資料mock
- 「刪除帳號」按下後只顯示 AlertDialog確認後也不真執行
### 4.4 Phase 1 TODO
- 拆成子路由 `/account/profile``/account/devices``/account/api-keys``/account/sessions`
- 頭像上傳
- 密碼變更
- API Keys 管理
- Session 管理(顯示登入過的裝置 / 瀏覽器)
- Billing 頁面
- 團隊 / 組織切換
---
## 5. `/devices` — 裝置列表(沿用 + 改造)
**類型**:沿用版型,改造行為
### 5.1 既有版型
```
┌─────────────────────────────────────────────────────────┐
│ 裝置 [安裝 USB Driver] [掃描] │
│ 管理你的 Edge AI 裝置 │
├─────────────────────────────────────────────────────────┤
│ [⚠ udev hint banner需要時顯示] │
├─────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Device │ │ Device │ │ Device │ │
│ │ Card │ │ Card │ │ Card │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 5.2 雲端版改造
| 項目 | 改動 |
|------|------|
| 右上按鈕 | **移除**「安裝 USB Driver」「掃描」local-tool 限定);**新增** `[配對新裝置]` → 導向 `/devices/pair` |
| udev hint banner | **移除**(雲端版不需要)|
| DeviceCard | 右上角狀態從 `DeviceStatusBadge` 改為 `RemoteDeviceBadge`;顯示「最後心跳」 |
| 空狀態 | 無裝置時顯示:「還沒有配對的裝置,[配對第一台裝置] →」|
| 排序 / 篩選 | Phase 0 維持原樣按建立時間排序Phase 1 加篩選「在線 / 全部」 |
### 5.3 空狀態設計
```
┌─────────────────────────────────────────────────────────┐
│ 🔗 │
│ 還沒有配對的裝置 │
│ 在你的電腦上執行 local agent 並完成配對, │
│ 就能從任何地方存取你的 Kneron 裝置 │
│ │
│ [配對第一台裝置] [查看配對說明] │
└─────────────────────────────────────────────────────────┘
```
---
## 6. `/devices/pair` — Pairing 流程(新增 — 完整設計)
**類型**:新增,雲端版核心流程。
**完整設計見** [`flows/flow-pairing.md`](flows/flow-pairing.md) 與 [`wireframes/wf-pairing.md`](wireframes/wf-pairing.md)。
### 6.1 簡要概念
三步式 Stepper
```
[1] 取得 Token → [2] 下載 / 設定 local agent → [3] 確認連線
```
- 第 1 步:`PairingTokenCard`(見 `components.md` 10.2
- 第 2 步平台選擇Mac / Windows / Linux→ 顯示下載連結 + 一行設定指令
- 第 3 步即時狀態polling `/api/pairing/status?token=...`)→ 成功後自動跳 `/devices`
---
## 7. `/devices/[id]` — 裝置詳情(沿用 + 遠端狀態)
**類型**:沿用版型 + 小擴充
### 7.1 既有版型
```
┌─────────────────────────────────────────────────────────┐
│ ← 返回 │
│ [裝置名稱] (alias) │
│ [原始名稱] │
│ [DeviceStatusBadge] [燒錄] [開啟工作區] [中斷] │
├─────────────────────────────────────────────────────────┤
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ 裝置資訊 │ │ 模型狀態 │ │
│ │ ID / 類型 / 韌體 / Port│ │ 已燒錄模型 / 準備狀態 │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ DeviceHealthCard │ │ DeviceConnectionLog │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ DeviceSettingsCardalias / notes
└─────────────────────────────────────────────────────────┘
```
### 7.2 雲端版擴充
| 項目 | 改動 |
|------|------|
| 標題區狀態 | `DeviceStatusBadge``RemoteDeviceBadge`(顯示最後心跳時間)|
| DeviceHealthCard | 新增 `Pairing Token`(遮罩顯示後 4 碼)、`配對時間``所在電腦 hostname`local agent 提供)|
| DeviceConnectionLog | 新增 `tunnel reconnect` 事件 |
| DeviceSettingsCard | 新增「解除配對」按鈕destructive|
### 7.3 掉線降級
當裝置 `RemoteDeviceBadge.status === 'offline'`
- 大型警告橫幅:「此裝置目前離線,部分操作無法使用」
- 「燒錄」「開啟工作區」按鈕 disabled
- 顯示最後已知狀態(韌體版本、已燒錄模型等,讀自 cache
- 「解除配對」仍可用
---
## 8. `/models``/models/[id]``/workspace``/settings`(沿用)
### 8.1 `/models` — 模型庫
- 版型完全沿用(`ModelGrid` + `ModelFilters` + `ModelUploadDialog` + 比較模式)
- 雲端版差異:上傳 `.nef` 走雲端儲存S3-compatUI 不變
- 空狀態使用既有 `EmptyState`
### 8.2 `/models/[id]` — 模型詳情
- 完全沿用
- 「部署至裝置」的 FlashDialog 會列出**使用者所有在線遠端裝置**
### 8.3 `/workspace` — 工作區選擇
- 版型沿用(卡片網格,每個是可開啟工作區的裝置)
- 雲端版:卡片上顯示 `RemoteDeviceBadge`;離線裝置卡片 `opacity-50` 且 disabled 無法點擊
- 空狀態保持:「沒有已連線的裝置,前往裝置管理」
### 8.4 `/workspace/[deviceId]` — 推論操作介面
- 版型完全沿用(左側 CameraInferenceView + 右側 InferencePanel
- **新增掉線降級**
- 頂部新增 `RemoteDeviceBadge`,顯著顯示狀態
- 裝置掉線時:
- 停止推論(前端主動呼叫 stop
- 顯示全頁遮罩:「裝置已離線,請稍候或返回裝置列表」+ 兩個按鈕 `[返回]` `[重試]`
- Camera stream 暫停,顯示「連線中斷」占位圖
### 8.5 `/settings` — 設定
- 版型沿用 Tabs一般 / 硬體 / 模型 / 進階)
- 雲端版調整:
- **「硬體」分頁**Phase 0 暫時隱藏或改名(雲端版使用者看不到自己電腦的 Python runtimePhase 1 改為「Local Agent 設定」
- **「進階」分頁**`ServerStatusDashboard` / `ServerLogViewer` 暫時隱藏;`Backend URL` 可改為「雲端 API Endpoint」開發者可切 staging / production
- 「一般」分頁不變
---
## 9. `/clusters` — 叢集列表(從 POC 搬)
**類型**:從 `edge-ai-platform` POC 搬,視覺對齊既有版型
### 9.1 版型(已在 POC 存在)
```
┌─────────────────────────────────────────────────────────┐
│ 叢集 [建立叢集] │
│ 管理你的多裝置推論叢集 │
├─────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Cluster │ │ Cluster │ │ Cluster │ │
│ │ Card │ │ Card │ │ Card │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘
```
每個 `ClusterCard`(見 POC `cluster-card.tsx`
- 叢集名稱 + 狀態 Badgeidle / inferencing / degraded
- 裝置數量、模型
- 裝置列表(每個顯示 deviceName + type + weight
- 操作:[開啟工作區] / [刪除]
### 9.2 雲端版調整
| 項目 | 改動 |
|------|------|
| 裝置列表顯示 | 每個裝置項目新增 `RemoteDeviceBadge`(顯示在線狀態)|
| degraded 狀態 | Badge 旁新增 tooltip 說明「某裝置離線,已自動降級」|
| 建立叢集 Dialog | Device 選擇只列出「在線」裝置 |
### 9.3 空狀態
```
還沒有叢集
叢集讓你把多台 Kneron 裝置組合起來做平行推論
[建立第一個叢集]disabled 當沒有任何裝置時)
```
### 9.4 `/workspace/cluster/[clusterId]` — 叢集工作區
- 從 POC 搬(`workspace/cluster/[clusterId]/cluster-workspace-client.tsx`
- 視覺對齊單裝置 Workspace版型相似
- 裝置狀態列顯示所有叢集成員的 `RemoteDeviceBadge`
- 任一裝置離線 → 狀態列顯示「降級執行中X/Y 裝置在線)」
---
## 10. 頁面互動總表
| 頁面 | 主要互動 | 資料載入方式 |
|------|---------|-------------|
| `/` | 讀取 stats、裝置、活動 | useEffect + polling / WS |
| `/login` | 提交登入 | form submit |
| `/register` | 提交註冊 | form submit |
| `/account` | 編輯個人資料 | form submit |
| `/devices` | 列表、配對 CTA | useEffect + WS |
| `/devices/pair` | 產 token、複製、輪詢 | polling(`/api/pairing/status`) |
| `/devices/[id]` | CRUD 裝置設定、燒錄、開工作區 | useEffect + WS |
| `/models` | 篩選、上傳、比較 | useEffect |
| `/models/[id]` | 檢視、部署、刪除 | useEffect |
| `/clusters` | 建立、開啟工作區、刪除 | useEffect |
| `/workspace` | 選擇裝置 | useEffect |
| `/workspace/[deviceId]` | 推論控制start/stop/source | useEffect + WS |
| `/workspace/cluster/[id]` | 叢集推論 | useEffect + WS |
| `/settings` | 切語言、主題、重置 | localStorage |
---
## 11. 響應式表現(逐頁)
| 頁面 | Mobile (< 640px) | Tablet (6401024) | Desktop ( 1024) |
|------|-----------------|-------------------|-----------------|
| `/` | 降級(單欄)| 單欄 | 4 欄 Stats + 2 欄主內容 |
| `/login` / `/register` | 可用 | 可用 | 可用 |
| `/devices` / `/models` / `/clusters` | 單欄卡片 | 2 欄 | 3 欄 |
| `/devices/pair` | 可用(步驟堆疊)| 可用 | 可用(步驟橫排)|
| `/workspace/[deviceId]` | **不支援**(顯示提示「請用桌面版」)| 簡化(上下排)| 左右分欄 |
| `/settings` | 單欄 Tab | 單欄 | 單欄Tab 橫排)|
**雛形 Mobile 策略**
- Sidebar 改 Sheetdrawer漢堡選單觸發
- 非關鍵頁面能用,但不保證完整體驗
- `/workspace/[deviceId]` 這類需要大畫面的,顯示:「建議使用桌面版以獲得最佳體驗」
---
## 12. 頁面載入策略
所有頁面:
| 載入階段 | 顯示 |
|---------|------|
| 0200ms | 什麼都不顯示(避免閃爍) |
| 200ms資料回傳 | Skeleton灰色塊shimmer 動畫可選)|
| 資料回傳 | 內容顯示 |
| 資料錯誤 | 錯誤狀態 + 重試按鈕 |
| 資料為空 | EmptyState |
Skeleton 使用 shadcn 慣例 `bg-muted animate-pulse`,已在 `device-detail-client.tsx` 示範。

View File

@ -0,0 +1,121 @@
# Design 對 PRD + TDD 的交叉審閱
| 項目 | 內容 |
|------|------|
| 審閱者 | Design Agent |
| 審閱日期 | 2026-04-21 |
| 審閱對象 | PRDv0.1+ Design Doc / TDDv0.1+ api-spec.md + tunnel.md |
| 基準 | 設計規格 `03-design/`(含 Pairing 與 Offline 兩大流程)|
---
## 1. 總評
**體驗完整度評分3.5 / 5 ⚠️ 有改善空間**
骨架與關鍵流程REST 轉發、tunnel 斷連語意、Pairing API在三方文件間**對得上**,但在 **Pairing token 格式、離線訊號來源、錯誤語意、雛形 stub 的 UX 落點**有幾處**跨文件不一致**會直接讓 Phase 0 雛形體驗破功。建議在進入雛形骨架前修正 Critical 項。
---
## 2. 流程完整性檢查
| 流程 | PRD | TDD / API | Design 規格 | 判定 |
|------|-----|----------|------------|------|
| Pairing 正向 | ✅ | ✅API `/api/pairing/token` + `/status`| ✅3 步 stepper| 🔴 **Token 格式三方不一致**(見 C1|
| Pairing Token 過期 | ✅ 15min | ✅ TTL 可調 | ⚠️ 設計規格寫 **72 小時** | 🔴 **C2** |
| Pairing Token 已使用 | ✅ `409 Conflict` | ⚠️ POC 行為「後來覆蓋前」| ⚠️ 設計規格顯示「此 token 已被其他裝置使用」 | 🟡 **M1**(語意衝突)|
| 裝置掉線tunnel 斷)| ⚠️ 僅提「自動重連」| ✅ `502 TUNNEL_DISCONNECTED` | ✅ 完整NetworkErrorBanner + 全頁遮罩 + 60s 倒數)| 🟡 **M2**(心跳 / lastSeen 未定義)|
| Workspace 推論中斷線 | ⚠️ 「立即提示 + 自動重連」| ✅ 不 transparent retry | ✅ 覆蓋遮罩 + 不自動重啟推論 | ✅ 對齊 |
| 登入 / 註冊stub| ✅ 可 stub | ✅ 回 501 | ⚠️ flow-auth 寫「送出直接跳 Dashboard」| 🔴 **C3**501 vs 直接跳轉矛盾)|
| Model 上傳大檔 | ⚠️ 未提進度 | ✅ presigned PUT不走 tunnel| ❌ **設計規格缺上傳進度 / 失敗重試 UI** | 🔴 **C4** |
| 雛形多 user 隔離 | ✅ 驗收條件要求 | ⚠️ Q4 裁決:**StaticAuthService 永遠 demo-user單一使用者**| ✅(預設隔離)| 🟡 **M3**(驗收條件無法通過)|
---
## 3. UX 風險(技術選型 → 體驗)
**R1 · Tunnel 延遲對即時感的衝擊 🔴**
TDD 延遲預算 P95 < 500msheartbeat 30syamux `flow-offline-handling.md` 45 秒判定掉線」、「15 秒心跳」。**兩套數字不一致**。使用者在 Workspace 按停止推論卻要等 30s 才看到狀態變化會被誤以為 App 當了建議 heartbeat 10-15s判定掉線 30s UI 離線最大感知延遲 30 明確
**R2 · MJPEG 單向流無回壓 🟡**
api-spec `GET /api/camera/stream` 透過 yamux stream 走 MJPEG。若瀏覽器 tab 切到背景、或頻寬不足MJPEG 會造成 yamux window 塞爆拖慢控制通道(切換 slider、停止推論按鈕。TDD 未定義**背景 tab 時是否暫停 stream**。設計規格也沒寫「頁面不可見時」行為。Chrome 預設 throttle setInterval但 MJPEG 是伺服器 push — 需要前端 visibilitychange 主動關 stream。
**R3 · 501 回應如何呈現 🔴**
TDD 雛形大量端點回 `501 NOT_IMPLEMENTED`登入、pairing token 產生、revoke、converter、batch 推論)。設計規格**沒有 501 的統一 UX 呈現**。目前 flow-auth 寫「送出直接跳 Dashboard」是繞過 API 的前端 hack但 PRD 驗收條件 US-01 要求「看到登入頁」US-02 要求「完成註冊」— 三方對雛形 auth 的期望不一致。
**R4 · Pairing Token 顯示格式與 API 脫鉤 🟡**
Q7 裁決「API 回傳純 hex前端加空格顯示」。TDD `api-spec.md` 範例用 `pk_AbCd1234...`(前綴 + base62PRD 用 `vAc_` + 32 hex設計規格用 16 字元 hex + 8+8 分隔。**三方 token 格式各自為政**。雛形實作時必須選一個,否則前端格式化 `.replace` 會錯字元數。
**R5 · ConnectedDevicesList / 全域 Badge 缺資料源 🟡**
設計規格要求 `RemoteDeviceBadge` 顯示「最後心跳 X 秒前」TDD `Device` 結構與 `InMemoryDeviceRepository` 未定義 `lastSeenAt` 欄位,只有 `SessionStore.Summary` 有。需明確:**前端取 lastSeen 走哪支 API**?走 `/api/pairing/status`(單一 tunnel還是每個 device 都要有一個欄位?
**R6 · i18n 缺 key 🟢**
設計規格列出 ~40 個 `pairing.*` / `remote.*` / `network.*` i18n keyTDD / 前端改造章節§10未提及 i18n 擴充工作。需要在前端任務清單加一項「新增 i18n key」。
---
## 4. 設計規格 vs PRD / TDD 的矛盾點
| # | 類別 | Design 規格寫的 | PRD / TDD 寫的 | 建議解法 |
|---|------|----------------|--------------|---------|
| **C1** | Token 格式 | 16 字元 hex`a1b2c3d4 e5f6g7h8` 顯示 | PRD`vAc_` + 32 hexTDD`pk_AbCd1234`(範例)| **三方統一**:建議採 PRD 格式 `vAc_` + 32 hex設計規格改為 8+8+8+8 分四段顯示 |
| **C2** | Token TTL | **72 小時** | PRD / TDD**15 分鐘** | 設計規格改 15 分鐘72 小時安全風險過高PRD 對) |
| **C3** | Auth 雛形行為 | 送出表單直接跳 Dashboard | TDD回 501 | 設計規格增加「雛形 stub 模式」版本:前端偵測 501 → 前端強制過 → 跳 Dashboard + banner「雛形模式尚未接入認證」 |
| **C4** | Model 上傳 | 無進度 / 失敗 UI | presigned PUT 流程 | 補 `pages.md` 模型上傳頁的「上傳進度條 / 大小限制 / checksum 失敗 / presigned expire」四個狀態 |
| **M1** | 同 token 多連線 | 「被其他裝置使用」錯誤提示 | POC 行為「後來覆蓋前」 | Q5 已裁決沿用 POC設計規格改為顯示「此 token 已配對過 — 舊連線已被取代」,或隱藏該錯誤(因為覆蓋是成功的) |
| **M2** | 心跳 / 掉線判定 | 15s 心跳 + 45s 判定 | tunnel.md30s yamux keepalive | 統一:**10s 心跳 / 30s 判定**TDD tunnel.md 補充 |
| **M3** | 多 user 隔離驗收 | 設計要求裝置列表隔離 | Q4 裁決:雛形單一使用者 | PRD US-10 / feature-inference 驗收條件「不同使用者互相隔離」改為 **Phase 1 必須** |
| **M4** | `remoteStatus` 欄位 | Device 新增 5 個欄位 | `InMemoryDeviceRepository` / `Device` struct 未涵蓋 | TDD database.md 補上 `remoteStatus` / `lastSeenAt` / `hostName` / `pairedAt` / `errorMessage` |
---
## 5. 發現的問題
### Critical阻擋雛形進入開發
- **[C1]** Pairing Token 格式 PRD / TDD / Design 三方不一致 → 先對齊格式
- **[C2]** Pairing Token TTL 設計寫 72h、PRD 寫 15min → 安全性必須是 15min
- **[C3]** 雛形 Auth 的三方預期不一致501 vs 前端強跳)→ 定義「雛形模式 banner」
- **[C4]** 設計規格缺大檔上傳的進度 / 失敗 / 重試 UIfeature-inference Phase 1 還有影片上傳)
### Major
- **[M1]** 同 token 多連線語意:顯示錯誤 vs 靜默覆蓋 → 統一
- **[M2]** 心跳 / 掉線判定數字三方不一致
- **[M3]** 多 user 隔離驗收條件與 Q4 裁決衝突
- **[M4]** Device 結構缺 `remoteStatus` 等欄位,前端 UI 無資料可顯示
- **[M5]** 501 回應的統一 UX 呈現未定義
### Minor / Suggestion
- **[m1]** Background tab 時 MJPEG stream 行為未定
- **[m2]** flow-pairing.md 的 token 顯示範例16 hex與 PRD32 hex長度不同導致 QR Code 尺寸需重估
- **[m3]** TDD §10 未提 i18n key 擴充任務
- **[m4]** WebSocket `/ws/pairing/status` 設計規格沒用,還在 pollingStep 3建議改用 WS 即時
- **[m5]** Reduce Motion 在 flow-offline-handling 的「重連倒數」需特別測試(不要閃爍)
---
## 6. 設計規格需修改清單
| 檔案 | 修改 |
|------|------|
| `flow-pairing.md` §4.1 / §4.5 | Token 格式改 `vAc_` + 32 hexTTL 15 分鐘(對齊 PRD|
| `flow-pairing.md` §6.4 | polling → 改用 `WS /ws/pairing/status`TDD 已定義)|
| `flow-auth.md` | 新增「雛形模式 banner」說明 401/501 時的 UX |
| `flow-offline-handling.md` §12 | 心跳 10s / 判定掉線 30s |
| `pages.md`(模型管理頁)| **新增**上傳進度 / 錯誤 / presigned expire 狀態 |
| `components.md` `RemoteDeviceBadge` | 標注資料來源 = `/api/pairing/status``last_seen_at` |
| `design-spec.md` §5.2 給 Architect 提醒 | 補「需要 Device.remoteStatus / lastSeenAt 欄位」|
---
## 7. 結論
PRD 與 TDD **骨架堅實**,但 **Pairing Token 格式、TTL、心跳參數、501 UX、大檔上傳、雛形 Auth 流程**六處跨文件不一致。若不先對齊,雛形實作時會出現「前端以 72h 產 UI後端 15min 失效」等漏洞。建議:
1. **先開 30 分鐘同步**統一 C1-C4由 Orchestrator 召集 PM / Architect / Design
2. 三方同步修文件後,再次互審;設計規格依第 6 節修訂
3. 通過後再進入雛形骨架
完整度達標才進入開發,可避免雛形一跑起來就遇到「規格對不上」的尷尬。

View File

@ -0,0 +1,721 @@
# visionA Agent — UI 設計規格
| 項目 | 內容 |
|------|------|
| 專案代號 | visionA Agent`visiona-agent` |
| 文件版本 | v0.1 |
| 最後更新 | 2026-04-22 |
| 狀態 | Design Agent 產出M 級任務,不走三方聯合討論) |
| 定位 | visionA 雲端版的 local agent / tunnel bridge |
| 技術堆疊 | Wails v2 + React + TypeScript + Tailwind 4 + shadcn/ui與 visionA-frontend 完全相同)|
| Bundle ID | `com.innovedus.visiona-agent`(獨立於 local-tool 的 `com.innovedus.visiona-local`|
> **本規格從 visionA-frontend 的 Design System 延伸**。Design Tokens、元件、字型、Dark Mode 全部沿用,確保同一使用者在雲端 Web 與桌面 Agent 之間切換「能一眼認出是同家人」。
---
## 1. 設計概述
### 1.1 產品定位
visionA Agent 是**純橋樑**:它只做一件事 — 讓使用者桌機上的 Kneron 裝置「出現在雲端」。所有裝置管理、模型管理、推論操作都在 visionA 雲端 Web UI。Agent 本身**沒有**裝置面板、沒有攝影機預覽、沒有推論視窗。
它是一個「**開了就忘掉它**」的工具使用者配對完之後Agent 只要維持連線、不出問題就好。
### 1.2 與 visionA-frontend 的對應關係
| 使用者流程 | visionA-frontendWeb| visionA Agent桌面 |
|-----------|---------------------|--------------------|
| 取得 Pairing Token | `/devices/pair` Step 1 | — |
| 下載並啟動 Agent | `/devices/pair` Step 2指引| **Agent 安裝後首次啟動** |
| 輸入 Token 配對 | — | **配對頁** |
| 確認連線建立 | `/devices/pair` Step 3 | **狀態頁**(雙端同時顯示)|
| 後續使用裝置 | `/devices``/workspace/*` | 持續維持連線,無需互動 |
**設計職責切分**Web 負責「拿到 token」+「看到裝置」Agent 負責「用 token 連上」+「顯示連線狀態」。
### 1.3 設計原則
1. **極簡優先** — 一個 utility 不是一個 product。不做 onboarding tour、不做裝飾性插圖、不做動畫秀。
2. **誠實呈現狀態** — online / offline / reconnecting / error 必須清楚區分,不只靠顏色(搭配 icon + 文字)。
3. **與雲端視覺一致** — Design Tokens 100% 沿用,使用者看到會立刻聯想「這是 visionA 那家」。
4. **跨平台** — 不做任何倚賴特定 OS 的互動(不做 traffic light 自訂、不做 system tray、不做 Win11 Mica
5. **鍵盤友善** — 所有操作 Tab 可達,符合 WCAG 2.1 AA。
6. **Dark Mode 跟隨 OS** — 不提供手動切換,不需要多餘開關。
### 1.4 範圍內 vs 範圍外
| 範圍內 | 範圍外 |
|--------|--------|
| 連線狀態頁、配對頁、設定頁 | 裝置管理 / 模型管理 / 推論操作 UI |
| 全域 Header + Tab 導航 | System Tray / Menu Bar 圖示 |
| 開機自啟、Log 匯出、Relay URL 設定 | Onboarding Tour / Tutorial |
| 繁中 + English跟 OS 語言,無 UI switcher | i18n 切換 UI、多語言 admin |
| Dark Mode 跟隨系統 | 手動 Dark Mode toggle |
---
## 2. Design Tokens沿用 visionA-frontend
完整沿用 `.autoflow/03-design/design-tokens.md`。實作時 **直接複製 visionA-frontend 的 `globals.css`**,不動一行。
### 2.1 Agent 會用到的 token 清單
| 用途 | Token / class |
|------|---------------|
| 視窗背景 | `--background``bg-background` |
| 主要文字 | `--foreground``text-foreground` |
| 次要文字 | `--muted-foreground``text-muted-foreground` |
| 卡片 / 區塊 | `--card` + `border` + `shadow-sm` + `rounded-xl` |
| 主按鈕 | `--primary` + `--primary-foreground` |
| 次要按鈕 | `--secondary` / outline variant |
| 危險按鈕(重置 / 斷線)| `--destructive` |
| Input 邊框 | `--input`focus `--ring/50` |
| Tab bar 背景 | `--sidebar`(沿用) |
| Focus ring | `--ring``ring-[3px]`|
### 2.2 連線狀態色(沿用雲端版既有約定)
`flow-offline-handling.md` 第 2 節的狀態模型對齊:
| 狀態 | 顏色light / dark| IconLucide|
|------|---------------------|---------------|
| online | `bg-green-500` | `CheckCircle2` |
| offline | `bg-gray-400` + `dark:bg-gray-600` | `PowerOff` |
| reconnecting | `bg-yellow-400` + `animate-pulse` | `RefreshCw`(旋轉) |
| notPaired | `bg-gray-400` | `Unlink` |
| error | `bg-red-500` | `AlertTriangle` |
**無障礙**:每個狀態**必須同時有**「圓點顏色 + Icon + 文字標籤」,不只靠顏色。
### 2.3 字型
- UI 文字:`font-sans`Geist Sans沿用
- Token 顯示、Log、Relay URL`font-mono`Geist Mono
---
## 3. 全域元件 — Header / Tab 導航
### 3.1 版型
```
┌──────────────────────────────────────────────────────────────────┐
│ [Logo] visionA Agent 狀態 · 配對 · 設定 🟢 在線 │ ← h-14
├──────────────────────────────────────────────────────────────────┤
│ │
│ [ 當前分頁內容 ] │
│ │
└──────────────────────────────────────────────────────────────────┘
```
### 3.2 規格
- **高度**`h-14`56px與 visionA-frontend Header 一致
- **背景**`bg-sidebar` + `border-b border-sidebar-border`
- **左側**Logo + 產品名「visionA Agent」`text-sm font-semibold`
- **中央(或左側緊接)**:三個 Tab
- 「狀態」Status— 預設
- 「配對」Pair
- 「設定」Settings
- 使用 shadcn `Tabs` 風格active tab `bg-primary text-primary-foreground rounded-md`,其餘 `hover:bg-accent`
- **右上角**`ConnectionStatusBadge`(小型 `RemoteDeviceBadge` 的簡化版)
- 圓點 + 文字(例:`🟢 在線` / `⚪ 離線` / `🟡 重連中` / `🔗 未配對`
- 點擊可切回「狀態」tab
### 3.3 互動
- Tab 切換:`Ctrl/Cmd+1/2/3` 鍵盤快捷鍵
- Badge 永遠即時反映連線狀態(從 backend event 推送)
- **沒有**雲端版的 `PrototypeBanner`、**沒有** Sidebar視窗太小、功能太少
### 3.4 視窗規格Wails 設定)
| 項目 | 值 | 說明 |
|------|---|------|
| 初始大小 | **720 × 560** | 比 800×600 稍收斂,足夠不壅擠 |
| 最小大小 | 640 × 480 | 強制最小,避免壞掉 |
| 最大大小 | 不限 | 使用者想放大就放大(但內容有 `max-w-2xl` 收斂) |
| Title | `visionA Agent` | OS title bar |
| Resizable | Yes | 使用者可調整 |
| 關閉行為 | **退出 process** | 不隱藏到 tray已決策 |
| Icon | visionA 品牌 logo3 平台各別規格) | — |
---
## 4. 頁面 1 — 連線狀態頁(首頁 / 預設 Tab
### 4.1 版型(已配對 + 在線)
```
┌──────────────────────────────────────────────────────────────────┐
│ │
│ ╭────────────────────╮ │
│ │ ✓ 已連線 │ │
│ │ 🟢 │ │
│ ╰────────────────────╯ │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 帳號 jim@innovedus.com │ │
│ │ Relay wss://relay.visionA.cloud │ │
│ │ 連線開始 2026-04-22 14:30已連線 2 小時 15 分) │ │
│ │ Session vAs_a1b2c3d4 ··· e7f8 │ │
│ │ │ │
│ │ [ 斷開連線 ] [ 重新配對 ] │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ 最近連線紀錄 [ 查看完整 Log ] │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 14:30 ✓ 已連線 │ │
│ │ 14:29 ⟳ 連線中... │ │
│ │ 14:28 ▲ 程式啟動 │ │
│ │ ...(最多 10 筆) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
```
### 4.2 主要元件
#### (A) StatusHero — 大狀態指示器
- **大小**:直徑 80px 的圓,置中
- **內容**
- 頂層:狀態 IconLucide`h-8 w-8`
- 底層:圓點 + 狀態文字(`text-xl font-semibold`
- **狀態變體**(對應 2.2 狀態色):
- online綠圓 + `CheckCircle2` + 「已連線」
- offline灰圓 + `PowerOff` + 「離線」
- reconnecting黃圓 pulse + `RefreshCw` 旋轉 + 「重新連線中...」+ 次行 `text-xs text-muted-foreground`「第 {n}/5 次嘗試」
- notPaired灰圓 + `Unlink` + 「尚未配對」+ 次行按鈕「立即配對 →」切到配對頁
- error紅圓 + `AlertTriangle` + 「連線錯誤」+ 次行 `text-xs text-red-600`「{errorMessage}」
#### (B) InfoCard — 連線資訊卡
- shadcn `Card``p-6 space-y-3`
- **欄位**(每行 `grid grid-cols-[120px_1fr]`,左欄 `text-muted-foreground text-sm`,右欄 `text-sm font-mono`
| 欄位 | 內容 | 未配對 / 離線時 |
|------|------|---------------|
| 帳號 | `jim@innovedus.com` | `—`(未配對)/ 快取顯示(離線) |
| Relay | `wss://relay.visionA.cloud` | 顯示當前設定值 |
| 連線開始 | `2026-04-22 14:30已連線 2 小時 15 分)` | 未配對 `—`;離線顯示上次連線時間 + 「已離線 {duration}」|
| Session | `vAs_a1b2c3d4 ··· e7f8`(遮蔽中段)| 未配對 `—`;離線保留顯示(以 `text-muted-foreground` 淡化) |
- **Session Token 遮蔽規則**:顯示 `前綴(vAs_) + 前 8 hex + " ··· " + 後 4 hex`(共約 20 字元可見)。完整 token 永遠不顯示、不可複製、不寫進 log。
#### (C) 主要按鈕
於 InfoCard 下方,排成一列:
| 狀態 | 主按鈕 | 次按鈕 |
|------|--------|--------|
| online | `[ 斷開連線 ]``variant=outline`| `[ 重新配對 ]`ghost 配 `AlertDialog` |
| offline | `[ 立即重試 ]``variant=default`| `[ 重新配對 ]` |
| reconnecting | `[ 取消並手動重連 ]`outline重連中停用自動策略一次| `[ 重新配對 ]` |
| notPaired | `[ 前往配對 → ]`default切 tab| — |
| error | `[ 重試 ]`default+ `[ 查看 log ]`ghost| `[ 重新配對 ]` |
- 「斷開連線」→ `AlertDialog`「確定要斷開嗎斷開後遠端將無法使用此裝置。Session Token 不會被撤銷,下次可直接重連。」
- 「重新配對」→ `AlertDialog`「確定要重新配對?目前的 Session 會立即失效,需重新取得 Pairing Token。」確認後切配對頁並清除本地 Session Token。
#### (D) RecentLog — 最近連線紀錄
- shadcn `Card`,標題 + 右上角「查看完整 Log」連結切到設定頁 → Log 區塊)
- 每筆紀錄:`時間(HH:mm) · Icon · 事件文字`
- `✓ 已連線` / `⟳ 連線中...(嘗試 {n}/5` / `✗ 連線失敗:{reason}` / `▲ 程式啟動` / `◼ 程式關閉` / `⚙ 設定已更新`
- 最多顯示 10 筆,`text-xs font-mono`,超過出現「查看完整 Log」連結
- **只顯示使用者應該看得懂的事件**debug 級別的細節進 log 檔不上 UI
### 4.3 空狀態(尚未配對)
未配對時StatusHero 顯示 notPaired 狀態InfoCard 欄位皆為 `—`RecentLog 顯示:
```
┌─────────────────────────────────┐
│ 🔗 │
│ │
│ 尚未配對裝置 │
│ │
│ 去雲端 Web 產生 Pairing Token
│ 在「配對」分頁貼上即可開始 │
│ │
│ [ 前往配對 → ] │
└─────────────────────────────────┘
```
### 4.4 錯誤狀態
- 連線錯誤StatusHero 顯示 errorInfoCard 最下方紅色 banner`bg-red-50 dark:bg-red-950/30 border-red-300`)顯示 `errorMessage`「Pairing Token 已撤銷請重新配對」「Relay URL 不可達」)
- 錯誤分類(對應 `flow-offline-handling.md` 第 1 節的 A/B/C 失效點):
- **本機斷網**A「你的網路似乎離線了請檢查 Wi-Fi / 有線連線」
- **雲端不可達**B「無法連上 visionA 雲端服務,正在重試...」
- **認證失敗**「Session Token 已失效,請重新配對」
- **未知錯誤**:「連線錯誤,請[查看 log]{連結}」
### 4.5 互動行為
| 操作 | 行為 |
|------|------|
| 進入頁面 | 自動從 backend 取當前狀態(透過 Wails runtime event |
| 狀態變化 | backend 推送事件 → 前端即時更新 StatusHero + Badge + InfoCard |
| 點「斷開連線」| AlertDialog 確認 → call backend `Disconnect()` → 狀態變 offline |
| 點「重新連線」| call backend `Reconnect()` → 狀態變 reconnecting |
| 點「查看完整 Log」| 切到設定頁,自動 scroll 到 Log 區塊 |
| 視窗 resize | 內容以 `max-w-2xl mx-auto` 置中,大螢幕不會過寬 |
### 4.6 驗收條件
- [ ] 五種狀態online / offline / reconnecting / notPaired / error視覺各異且符合 2.2 色表
- [ ] Session Token 永遠只顯示遮蔽版
- [ ] 離線時「連線開始」欄位顯示「已離線 {duration}」並使用 `text-muted-foreground`
- [ ] 「斷開連線」「重新配對」都有 `AlertDialog` 確認
- [ ] 最近 Log 最多 10 筆,時間格式 `HH:mm`
- [ ] Dark Mode 下所有顏色對比達 4.5:1
- [ ] 完整鍵盤導航Tab / Enter / Space / Esc
---
## 5. 頁面 2 — 配對頁
### 5.1 版型
```
┌──────────────────────────────────────────────────────────────────┐
│ │
│ 配對到 visionA 雲端 │
│ 貼上 Pairing Token讓你的裝置出現在雲端 Web │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Pairing Token │ │
│ │ ┌────────────────────────────────────────────────────────┐│ │
│ │ │ vAc_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8 ││ │
│ │ └────────────────────────────────────────────────────────┘│ │
│ │ ✓ 格式正確36 字元) │ │
│ │ │ │
│ │ 還沒有 token[ 到雲端網頁產生 ↗ ] │ │
│ │ │ │
│ │ [ 取消 ] [ 配對 ] │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ 💡 Token 15 分鐘內有效一次性使用。Agent 會自動換取長期 Session│ │
│ 你不需要記住或重複貼上。 │ │
│ │
└──────────────────────────────────────────────────────────────────┘
```
### 5.2 元件規格
#### (A) 標題區
- `h1`:「配對到 visionA 雲端」(`text-2xl font-semibold`
- `description`:「貼上 Pairing Token讓你的裝置出現在雲端 Web」`text-sm text-muted-foreground`
#### (B) Token 輸入卡
- shadcn `Card``p-6 space-y-4``max-w-xl mx-auto`
- `Label`「Pairing Token」
- shadcn `Input`
- `font-mono text-sm`(等寬)
- `placeholder="貼上 vAc_... 格式的 token"`
- 自動 `spellcheck={false}``autocapitalize="none"``autocomplete="off"`
- **貼上時自動清除所有空格與換行**(雲端網頁顯示可能有空格分隔,如 `vAc_a1b2c3d4 e5f6a7b8 ...`
- **焦點**進入自動全選(方便覆蓋貼上)
- **即時格式驗證**(在 input 下方顯示 hint line
- 空:`text-muted-foreground`「請貼上 token」
- 格式不對:`text-red-600`「格式不正確 — token 應為 `vAc_` 開頭 + 32 字元 hex」+ `AlertCircle` icon
- 格式正確:`text-green-600`「✓ 格式正確36 字元)」+ `CheckCircle2` icon
- 邊框狀態:預設 `border-input` / 錯誤 `border-destructive` + `ring-destructive/20` / 正確 `border-green-500`
#### (C) 輔助連結
- 「還沒有 token[ 到雲端網頁產生 ↗ ]」
- 點擊 → `wails.BrowserOpenURL("https://visionA.cloud/devices/pair")`
- **Phase 0 雛形**URL 寫死 placeholder正式上線再改為環境變數驅動
#### (D) 按鈕列(右對齊)
- 「取消」(`variant=ghost`)→ 切回狀態頁
- 「配對」(`variant=default`):格式不正確時 disabled
- 配對中:按鈕變「`⟳ 正在配對...`」(`aria-busy=true`),其餘 input / cancel disabled
#### (E) 底部提示
`Alert` 元件(`variant=default``bg-blue-50 dark:bg-blue-950/30 border-blue-300`
> 💡 Token 15 分鐘內有效一次性使用。Agent 會自動換取長期 Session你不需要記住或重複貼上。
### 5.3 配對流程狀態
| 階段 | UI |
|------|---|
| 閒置 | 如 5.1 所示,按鈕依格式驗證 enable / disable |
| 驗證中(貼上後)| 即時驗證格式100ms debounce無 loading純 client-side 正規表達式) |
| 配對中 | 按鈕變 spinner「正在連線到雲端...」,其餘 disabled。backend 呼叫 WS `/tunnel/connect?token=vAc_...` |
| 成功 | Toast `✓ 配對成功` → 0.5s 後自動切回「狀態」tab → StatusHero 顯示 online |
| 失敗 | 停留在本頁Input 下方紅色 `Alert` 顯示錯誤訊息(見 5.4),按鈕恢復為「重試」+「取消」|
### 5.4 錯誤訊息對照
| 後端錯誤代碼 | 顯示訊息 | 建議動作 |
|-------------|---------|---------|
| `token_expired` | 「此 Token 已過期15 分鐘有效)」 | 「回雲端網頁重新產生 →」 |
| `token_used` | 「此 Token 已被其他裝置使用」 | 「回雲端網頁重新產生 →」 |
| `token_invalid` | 「Token 無效或格式錯誤」 | 「請檢查是否複製完整」 |
| `token_revoked` | 「此 Token 已被撤銷」 | 「回雲端網頁重新產生 →」 |
| `network_error` | 「無法連上雲端服務,請檢查網路」 | 「重試」 |
| `relay_unreachable` | 「Relay `{url}` 無法連線,請確認設定」 | 「前往設定 →」|
| `unknown` | 「配對失敗,請查看 log」 | 「查看 log」 |
### 5.5 互動行為
| 操作 | 行為 |
|------|------|
| Focus Input | 全選現有內容 |
| 貼上 token | trim + 去空白 + 去換行;立即格式驗證 |
| 輸入中 | 100ms debounce 後顯示驗證結果 |
| Enter 鍵 | 若格式正確則觸發「配對」;否則無動作 |
| Esc 鍵 | 觸發「取消」(切回狀態頁,清空 input |
| 配對成功 | Toast + auto navigate不留在本頁 |
| 配對失敗 | Input 保留原值(方便使用者修改),顯示錯誤,按鈕變「重試」|
### 5.6 i18n key
```
pair.title → 配對到 visionA 雲端
pair.description → 貼上 Pairing Token讓你的裝置出現在雲端 Web
pair.tokenLabel → Pairing Token
pair.tokenPlaceholder → 貼上 vAc_... 格式的 token
pair.hint.empty → 請貼上 token
pair.hint.invalid → 格式不正確 — token 應為 vAc_ 開頭 + 32 字元 hex
pair.hint.valid → 格式正確36 字元)
pair.noToken → 還沒有 token
pair.openCloud → 到雲端網頁產生
pair.cancel → 取消
pair.submit → 配對
pair.submitting → 正在配對...
pair.bottomHint → Token 15 分鐘內有效一次性使用。Agent 會自動換取長期 Session你不需要記住或重複貼上。
pair.toast.success → 配對成功
pair.error.expired → 此 Token 已過期15 分鐘有效)
pair.error.used → 此 Token 已被其他裝置使用
pair.error.invalid → Token 無效或格式錯誤
pair.error.revoked → 此 Token 已被撤銷
pair.error.network → 無法連上雲端服務,請檢查網路
pair.error.relayUnreachable → Relay {url} 無法連線,請確認設定
pair.error.unknown → 配對失敗,請查看 log
pair.error.regenerate → 回雲端網頁重新產生
pair.error.goSettings → 前往設定
pair.error.viewLog → 查看 log
pair.error.retry → 重試
```
### 5.7 驗收條件
- [ ] 貼上含空格 / 換行的 token 自動清乾淨
- [ ] 格式驗證正規表達式:`/^vAc_[a-f0-9]{32}$/i`(大小寫不敏感)
- [ ] 格式不正確時按鈕 disabled
- [ ] 配對中所有 input + 按鈕 disabled僅保留「取消」
- [ ] 成功後切回狀態頁,並更新全域狀態
- [ ] 所有錯誤訊息都能明確告訴使用者「下一步該做什麼」
- [ ] 支援完整鍵盤操作Enter 送出、Esc 取消)
---
## 6. 頁面 3 — 設定頁
### 6.1 版型
```
┌──────────────────────────────────────────────────────────────────┐
│ 設定 │
│ │
│ ── 連線 ───────────────────────────────────────────────────── │
│ Relay URL [ wss://relay.visionA.cloud ] [ 測試連線 ]│
│ 當前設定將於下次連線時生效 │
│ │
│ ── 行為 ───────────────────────────────────────────────────── │
│ ☐ 開機自動啟動 visionA Agent │
│ │
│ 重連策略 ◉ 自動重試(指數退避,最多 5 次) │
│ ○ 手動重連 │
│ │
│ ── Log ───────────────────────────────────────────────────── │
│ Log 等級 [ Info ▼ ]Debug / Info / Warn / Error
│ Log 位置 ~/Library/Application Support/visionA Agent/logs/ │
│ [ 開啟資料夾 ] [ 匯出 Log... ] │
│ │
│ ── 關於 ───────────────────────────────────────────────────── │
│ 版本 visionA Agent 0.1.0 │
│ [ 檢查更新 ] [ 開啟文件 ↗ ] [ GitHub ↗ ] │
│ │
│ 危險區域 [ 重置所有設定 ] │
│ │
└──────────────────────────────────────────────────────────────────┘
```
### 6.2 區塊規格
容器:`max-w-2xl mx-auto p-6 space-y-8`。每個區塊:`space-y-3`,區塊間用 `Separator`
#### 6.2.1 連線
- **Relay URL**
- `Label` + `Input``font-mono text-sm`+ `Button "測試連線"``variant=outline size=sm`
- 預設值:`wss://relay.visionA.cloud`
- 驗證:必須以 `wss://``ws://` 開頭
- 變更不會立即套用;下方 `text-xs text-muted-foreground` 顯示「當前設定將於下次連線時生效」
- 若與當前連線值不同:顯示橙色 badge `未套用`
- **測試連線**按鈕:
- 點擊後嘗試 WebSocket handshake不帶 token只測 reachability
- Loadingspinner
- 成功:`Toast ✓ Relay 可達({latency}ms`
- 失敗:`Toast ✗ 無法連上 {url}{reason}`
#### 6.2.2 行為
- **開機自動啟動**
- shadcn `Checkbox` + `Label`
- 預設**關閉**(已決策)
- 勾選後立即套用backend 寫入對應 OS 啟動項macOS Login Items / Windows Registry Run / Linux `.desktop` autostart
- 變更時 toast「✓ 已啟用開機自啟動」/ 「已關閉開機自啟動」
- **重連策略**
- shadcn `RadioGroup`
- `自動重試` — 預設指數退避1s → 2s → 4s → 8s → 16s最多 5 次失敗後停止並轉為手動
- `手動重連` — 連線中斷後不自動重試,使用者需在狀態頁點「立即重試」
#### 6.2.3 Log
- **Log 等級**shadcn `Select`,選項 `Debug / Info / Warn / Error`,預設 `Info`
- 變更立即生效
- **Log 位置**:唯讀顯示 OS 對應路徑(`font-mono text-xs`
- macOS`~/Library/Application Support/visionA Agent/logs/`
- Windows`%APPDATA%\visionA Agent\logs\`
- Linux`~/.config/visionA-agent/logs/`
- 「開啟資料夾」按鈕call backend 開檔案總管到該路徑
- **匯出 Log**按鈕:
- 點擊 → OS save dialogWails runtime `SaveFileDialog`
- 預設檔名:`visionA-agent-log-YYYYMMDD-HHmmss.zip`
- 內容:壓縮最近 7 天的 log 檔
- 匯出中:按鈕 loading完成 toast「✓ 已匯出到 {path}」
#### 6.2.4 關於
- **版本**`visionA Agent {version}`(從 `runtime.Environment` 取)
- **檢查更新**按鈕:
- Phase 0 stubcall backend `CheckForUpdates()`,雛形直接回 `up-to-date`
- Toast`✓ 已是最新版本` / `有新版本可用v0.2.0 [ 開啟下載頁 ]`
- **開啟文件**`BrowserOpenURL("https://docs.visionA.cloud/agent")`
- **GitHub**`BrowserOpenURL("https://github.com/innovedus/visionA-agent")`
#### 6.2.5 危險區域
- 小標:`危險區域``text-sm font-medium text-destructive`
- **重置所有設定**按鈕:
- `variant=destructive size=sm`
- 點擊 → shadcn `AlertDialog`
- 標題:「確定要重置所有設定嗎?」
- 內文「這將清除Relay URL、開機自啟、Log 等級、**以及已配對的 Session Token**。你將需要重新配對才能使用。此操作無法復原。」
- 按鈕:`取消` / `確定重置`destructive
- 確認後backend 清除 config + session token → toast「已重置所有設定」→ 切到配對頁顯示 notPaired
### 6.3 i18n key
```
settings.title → 設定
settings.connection → 連線
settings.relayUrl → Relay URL
settings.relayUrl.hint → 當前設定將於下次連線時生效
settings.relayUrl.unapplied → 未套用
settings.testConnection → 測試連線
settings.testConnection.success → Relay 可達({latency}ms
settings.testConnection.failed → 無法連上 {url}{reason}
settings.behavior → 行為
settings.autoStart → 開機自動啟動 visionA Agent
settings.autoStart.enabled → 已啟用開機自啟動
settings.autoStart.disabled → 已關閉開機自啟動
settings.reconnectStrategy → 重連策略
settings.reconnectStrategy.auto → 自動重試(指數退避,最多 5 次)
settings.reconnectStrategy.manual → 手動重連
settings.log → Log
settings.logLevel → Log 等級
settings.logLocation → Log 位置
settings.openFolder → 開啟資料夾
settings.exportLog → 匯出 Log...
settings.exportLog.success → 已匯出到 {path}
settings.about → 關於
settings.version → 版本
settings.checkUpdate → 檢查更新
settings.checkUpdate.upToDate → 已是最新版本
settings.checkUpdate.available → 有新版本可用:{version}
settings.openDocs → 開啟文件
settings.github → GitHub
settings.dangerZone → 危險區域
settings.reset → 重置所有設定
settings.reset.confirm.title → 確定要重置所有設定嗎?
settings.reset.confirm.description → 這將清除Relay URL、開機自啟、Log 等級、以及已配對的 Session Token。你將需要重新配對才能使用。此操作無法復原。
settings.reset.confirm.cancel → 取消
settings.reset.confirm.ok → 確定重置
settings.reset.done → 已重置所有設定
```
### 6.4 驗收條件
- [ ] Relay URL 驗證 `ws(s)://` 前綴
- [ ] 變更 Relay URL 不立即套用顯示「未套用」badge
- [ ] 「測試連線」真正發 WS handshake不是假 loading
- [ ] 開機自啟 checkbox 在三平台都能正確寫入 OS 啟動項
- [ ] Log 位置顯示 OS 對應路徑,可開啟資料夾
- [ ] 匯出 Log 檔名格式正確、可正常解壓
- [ ] 重置設定會清 Session Token 並切回配對頁
---
## 7. 跨平台適配
### 7.1 macOS
| 項目 | 處理 |
|------|------|
| 視窗 chrome | **Wails 預設**(標準紅黃綠按鈕),不自訂 traffic light 位置 |
| 選單列 | Wails 預設 MenuApp / Edit / Window / Help不加自訂項 |
| 檔案對話框 | `runtime.SaveFileDialog` / `OpenDirectoryDialog` |
| 啟動項 | `~/Library/LaunchAgents/com.innovedus.visiona-agent.plist` |
| Log 路徑 | `~/Library/Application Support/visionA Agent/logs/` |
| 打包 | DMG參考 local-tool 已有的 `create-dmg` 流程)|
### 7.2 Windows
| 項目 | 處理 |
|------|------|
| 視窗 chrome | 標準 title barWindows 11 Mica 可選但**不強制** |
| 檔案對話框 | `runtime.SaveFileDialog`Wails 內建用 Win32 API |
| 啟動項 | Registry `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` |
| Log 路徑 | `%APPDATA%\visionA Agent\logs\` |
| 打包 | NSIS installer`.exe`|
### 7.3 Linux
| 項目 | 處理 |
|------|------|
| 視窗 chrome | 依 DEGNOME / KDE / XFCE走預設 |
| 檔案對話框 | GTK / QtWails 依系統偵測) |
| 啟動項 | `~/.config/autostart/visiona-agent.desktop` |
| Log 路徑 | `~/.config/visionA-agent/logs/`XDG 規範)|
| 打包 | AppImage |
### 7.4 共通原則
- **不用** hover-only 操作(觸控筆電也要能用 — 所有資訊 hover 才顯示的 tooltip都必須 focus 也顯示)
- **不用** OS 特殊動畫 API避免 Linux 裝了 compositor 死機)
- **不用** 特殊字型(全跟 GeistWails 會 bundle
- **檔案路徑**顯示時一律 `font-mono text-xs`,避免字型不同導致錯位
---
## 8. Dark Mode
- 跟隨 OSmacOS `appearance` / Windows `apps use dark theme` / Linux `color-scheme` media query
- 實作方式:沿用 visionA-frontend 的 `ThemeSync` 元件react hook + `matchMedia`
- **不提供**手動切換 UI
- 所有顏色用 CSS 變數Design Tokens自動跟 `.dark` class 切換
- 圖示Lucide SVG`currentColor`,自動跟文字色
- **logo 圖片**需要 light / dark 兩版logo-light.svg / logo-dark.svg
---
## 9. 無障礙基準WCAG 2.1 AA
- **色彩對比**:一般文字 ≥ 4.5:1大文字 ≥ 3:1UI 元件 ≥ 3:1
- **不只靠顏色**:所有狀態搭配 icon + 文字
- **鍵盤導航**
- Tab 依視覺順序移動
- `Ctrl/Cmd+1/2/3` 切 Tab
- `Enter` 送出表單
- `Esc` 關閉 Dialog / 取消操作
- Focus ring 永遠可見(`ring-[3px] ring-ring`
- **ARIA**
- Tab 用 `role="tab"` / `role="tabpanel"` / `aria-selected`
- StatusHero 用 `role="status" aria-live="polite"`
- Toast 用 Sonner 預設 `aria-live="assertive"`
- AlertDialog 用 shadcn 內建焦點陷阱
- **螢幕閱讀器**
- Session Token 遮蔽版:`aria-label="Session token, ending in e7f8"`(不念完整字串)
- 圖示裝飾:`aria-hidden="true"`
- **Reduce motion**:支援 `prefers-reduced-motion`,停用 pulse / spinner 旋轉
---
## 10. i18n 字典 keys 預估
| 分類 | keys 數 |
|------|--------|
| 全域nav / header / common| ~15 |
| 狀態頁status| ~25 |
| 配對頁pair| ~30 |
| 設定頁settings| ~40 |
| 錯誤訊息errors| ~15 |
| Toast | ~10 |
| **總計** | **~135 keys** |
兩語言(繁中 + English共約 270 條翻譯。文件位置建議:
```
visiona-agent/frontend/src/lib/i18n/
├── zh-TW.ts
└── en.ts
```
偵測優先序OS locale → 預設 `en`(若非 `zh-*` 均 fallback en
---
## 11. 需使用者裁決的設計決定
以下三點為設計過程中稍有不確定、建議使用者快速裁決的點(不影響動工,可先照 Design 建議實作):
| # | 問題 | Design 建議 | 備選 |
|---|------|-----------|------|
| 1 | 視窗大小是否允許使用者記住上次大小? | **記住**Wails 預設行為) | 每次固定 720×560 |
| 2 | 配對成功後的轉場 | **0.5 秒後自動切回狀態頁**(有 toast 確認) | 停留原地,顯示大 ✓ 後讓使用者手動點繼續 |
| 3 | 「檢查更新」按鈕在 Phase 0 雛形的行為 | **stub直接回 up-to-date** | 直接隱藏按鈕Phase 1 再開 |
---
## 12. 交付檔案
| 檔案 | 狀態 |
|------|------|
| `.autoflow/03-design/visiona-agent-spec.md`(本檔)| ✅ 完成 |
**不產出**
- wireframe 子檔(本檔內已含 ASCII wireframe小規模 utility 無需再拆)
- 高保真 prototype雛形階段無必要
- 獨立 design-tokens完全沿用 visionA-frontend 既有檔案)
---
## 13. 給 Architect / Frontend Agent 的提醒
### 13.1 給 Architect
- Agent 與 remote-proxy 的通訊契約WS upgrade、session token 持久化、reconnect 指數退避)請對齊 `04-architecture/security.md` 的 Pairing / Session 兩階段協定
- 前端Wails runtime 呼叫 Go backend建議定義清楚的 API`Connect(token) / Disconnect() / Reconnect() / GetStatus() / TestRelay(url) / ExportLog() / ResetAll()`
- 狀態同步backend 用 Wails `EventsEmit("connection:status", payload)` 推送,前端 `EventsOn` 監聽
### 13.2 給 Frontend未來撰寫 Agent 前端的工程師)
- **直接複製** `visionA-frontend/src/app/globals.css` 到 Agent 專案,**不動一行**
- **直接複製** shadcn 基礎元件Button / Card / Dialog / Input / Tabs / Checkbox / RadioGroup / Select / AlertDialog / Alert / Sonner
- **不引入** Next.jsAgent 是純 SPA用 Vite + React Router或不用 router三個 tab 用 state 切換)
- i18n 沿用 visionA-frontend 的 `src/lib/i18n` 模式(自訂 hook + TS 字典)
- 所有 backend 呼叫走 Wails runtime bindings不碰 HTTP
- Dark Mode sync沿用 `ThemeSync` 元件
---
**本規格結束。共 3 頁面 + 1 全域導航 + 3 平台適配 + 135 i18n keys符合「極簡 utility」定位。**

View File

@ -0,0 +1,110 @@
# Wireframe — `/account`
> 文字版 wireframe。Phase 0 單頁 stubPhase 1 拆多頁。
---
## 佈局Desktop
```
┌────────────────────────────────────────────────────────────────┐
│ [Sidebar] [Header] │
│ ────────────────────────────────────────────────────────────── │
│ │
│ 帳號設定 │
│ 管理你的個人資料與偏好 │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Card: 個人資料 │ │
│ │ │ │
│ │ Email jim@example.com (readonly, bg-muted) │ │
│ │ │ │
│ │ 顯示名稱 [Jim Chen____________] [儲存 Button] │ │
│ │ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Card: 已配對的裝置 │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐│ │
│ │ │ Kneron KL520 — office-mac ││ │
│ │ │ 配對於 2026-04-18 最後心跳 2 分鐘前 ││ │
│ │ │ [🟢 在線] [解除配對] ││ │
│ │ └────────────────────────────────────────────────────┘│ │
│ │ ┌────────────────────────────────────────────────────┐│ │
│ │ │ Kneron KL720 — home-pi ││ │
│ │ │ 配對於 2026-04-21 最後心跳 5 天前 ││ │
│ │ │ [⚪ 離線] [解除配對] ││ │
│ │ └────────────────────────────────────────────────────┘│ │
│ │ │ │
│ │ [+ 配對新裝置] → /devices/pair │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Card: 偏好設定 │ │
│ │ (此區段可能與 /settings 重複Phase 0 暫 skip 或只做一個)│ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Card: 危險區Danger Zone │ │
│ │ border: border-destructive/50 │ │
│ │ │ │
│ │ 登出此瀏覽器 [登出] │ │
│ │ ───────── │ │
│ │ 刪除帳號 [刪除帳號 (destructive)] │ │
│ │ 一旦刪除,此操作無法復原 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
```
---
## 互動Phase 0 簡化)
| 操作 | Phase 0 行為 | Phase 1 行為 |
|------|-------------|-------------|
| 儲存顯示名稱 | toast「此功能開發中」 | 真實 API 呼叫 |
| 解除配對 | AlertDialog 確認後 → toast「此功能開發中」 | 呼叫 `DELETE /api/devices/:id` |
| 配對新裝置 | 跳 `/devices/pair` | 同 |
| 登出 | `useAuthStore.logout()` → 跳 `/login` | 同 + 呼叫 server logout |
| 刪除帳號 | AlertDialog → toast「此功能開發中」 | 完整流程 |
---
## 資料來源
**Phase 0Mock 資料):**
- `user.email`:從 `useAuthStore`
- `user.displayName`:從 `localStorage.displayName` 讀(若無,預設 Email 前綴)
- 配對裝置列表:從 `useDeviceStore`
**Phase 1**
- 改為 `GET /api/account/me` 拉真實資料
- 裝置列表 `GET /api/devices?owner=me`
---
## 響應式
- DesktopCard 單欄 `max-w-3xl`
- Tablet同 Desktop
- MobileCard 撐滿,`px-4`
---
## Phase 1 TODO
拆成以下子路由:
| 子路由 | 內容 |
|-------|------|
| `/account/profile` | Email、顯示名稱、頭像、密碼變更 |
| `/account/devices` | 已配對裝置管理 |
| `/account/api-keys` | Personal Access Tokensfor CLI / API 使用) |
| `/account/sessions` | 登入過的裝置 / 瀏覽器,可撤銷 |
| `/account/preferences` | 語言、主題、通知偏好(與 /settings 合併?)|
| `/account/billing` | 訂閱方案、付款方式、發票 |
| `/account/danger` | 刪除帳號、匯出資料GDPR |
導航Tabs 或 Sidebar 副選單皆可。

View File

@ -0,0 +1,177 @@
# Wireframe — `/clusters`
> 文字版 wireframe。從 `edge-ai-platform` POC 搬來,視覺對齊既有 Shadcn 風格。
---
## 佈局Desktop
```
┌────────────────────────────────────────────────────────────────┐
│ [Sidebar] [Header] │
│ ────────────────────────────────────────────────────────────── │
│ │
│ 叢集 [+ 建立叢集] │
│ 管理你的多裝置推論叢集 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ ClusterCard A │ │ ClusterCard B │ │ ClusterCard C │ │
│ │ │ │ │ │ │ │
│ │ Production A │ │ Dev Cluster │ │ Test Cluster │ │
│ │ 🟢 推論中 │ │ 🔵 閒置 │ │ 🟡 降級 │ │
│ │ │ │ │ │ │ │
│ │ 裝置3 │ │ 裝置2 │ │ 裝置3 │ │
│ │ 模型yolov5s │ │ 模型:- │ │ 模型yolov5s │ │
│ │ │ │ │ │ │ │
│ │ • KL520 w=3 🟢│ │ • KL720 w=1 🟢│ │ • KL520 w=3 🟢│ │
│ │ • KL720 w=1 🟢│ │ • KL520 w=1 🟢│ │ • KL720 w=1 🟢│ │
│ │ • KL520 w=3 🟢│ │ │ │ • KL520 w=3 ⚪│ │
│ │ │ │ │ │ │ │
│ │ [工作區][刪除] │ │ [工作區][刪除] │ │ [工作區][刪除] │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
```
---
## ClusterCard 規格(沿用 POC 的 `cluster-card.tsx`,加強化)
```
┌───────────────────────────────────────┐
│ CardHeader (pb-3) │
│ ┌─────────────────────┐ ┌───────────┐ │
│ │ CardTitle text-base │ │ Badge │ │
│ │ Production Cluster A│ │ 狀態 │ │
│ └─────────────────────┘ └───────────┘ │
│ │
│ CardContent (space-y-3) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 裝置 (label) │ │ 模型 (label) │ │
│ │ 3 (value) │ │ yolov5s │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ 裝置列表text-xs: │
│ • office-mac KL520 w=3 [🟢 在線] │
│ • home-pi KL720 w=1 [🟢 在線] │
│ • backup KL520 w=3 [⚪ 離線] │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ [工作區] │ │ [刪除] │ │
│ │ outline │ │ ghost │ │
│ └──────────┘ └──────────┘ │
└───────────────────────────────────────┘
```
---
## 狀態 Badge
| 狀態 | Badge variant | 文字 |
|------|--------------|------|
| idle | secondary | 閒置 |
| inferencing | default | 推論中 |
| degraded | destructive (實際用 yellow) | 降級執行中 |
| offline | secondary + opacity-50 | 全部離線 |
---
## 雲端版新增
| 位置 | 改動 |
|------|------|
| 每個裝置項目右側 | 新增 `RemoteDeviceBadge`(🟢 / ⚪)|
| degraded 狀態 | Badge 旁加 Tooltip 說明「X 裝置離線,已自動降級」 |
| 「建立叢集」Dialog 的裝置選擇器 | 只列出 `remoteStatus === 'online'` 的裝置;離線裝置 disabled |
---
## 空狀態
```
┌────────────────────────────────────────────────────────────────┐
│ │
│ 🕸 │
│ │
│ 還沒有叢集 │
│ │
│ 叢集讓你把多台 Kneron 裝置組合起來做平行推論 │
│ │
│ [建立第一個叢集] │
│ (若沒有任何已配對裝置則 disabled + tooltip: │
│ 「請先配對至少 1 台裝置」) │
│ │
└────────────────────────────────────────────────────────────────┘
```
使用既有 `EmptyState` 元件icon = Lucide `Network`
---
## 建立叢集 Dialog沿用 POC
```
Dialog: 建立叢集
叢集名稱 * [________________________]
選擇裝置:
☑ Kneron KL520 (office-mac) 權重 [3 ▼] 🟢 在線
☑ Kneron KL720 (home-pi) 權重 [1 ▼] 🟢 在線
☐ Kneron KL520 (backup) 權重 [- ] ⚪ 離線 (disabled)
模型:[ yolov5s ▼ ]
[取消] [建立]
```
---
## 互動
| 操作 | 行為 |
|------|------|
| 點卡片「工作區」| 跳 `/workspace/cluster/[id]` |
| 點卡片「刪除」| AlertDialog 確認後呼叫 `DELETE /api/clusters/:id` |
| 點「建立叢集」| 開 `ClusterCreateDialog` |
| 切換 degraded 狀態 tooltip | Hover / Focus Badge 時顯示原因 |
---
## 響應式
| 斷點 | 欄數 |
|------|------|
| Mobile (< 640px) | 1 |
| Tablet (640-1024) | 2 欄 |
| Desktop (≥ 1024) | 3 欄 |
---
## 無障礙
- Card 整體可 Tab 聚焦(若設為 `asChild` Link
- 刪除 AlertDialog 焦點陷阱shadcn 內建)
- 狀態 Badge 有文字 + 顏色雙重傳達
- Tooltip 使用 `aria-describedby`
---
## i18n key沿用 POC `cluster.*`
```
cluster.title → 叢集
cluster.subtitle → 管理你的多裝置推論叢集
cluster.createCluster → 建立叢集
cluster.devices → 裝置
cluster.openWorkspace → 工作區
cluster.deleteConfirm → 確定要刪除「{name}」嗎?
cluster.status.idle → 閒置
cluster.status.inferencing → 推論中
cluster.status.degraded → 降級執行中
cluster.emptyTitle → 還沒有叢集
cluster.emptyDescription → 叢集讓你把多台 Kneron 裝置組合起來做平行推論
cluster.emptyAction → 建立第一個叢集
cluster.noDevicesHint → 請先配對至少 1 台裝置
```

View File

@ -0,0 +1,110 @@
# Wireframe — `/login`
> 文字版 wireframe供 Frontend Agent 參考實作。Phase 0 雛形範圍。
---
## 佈局Desktop ≥ 1024px
```
┌────────────────────────────────────────────────────────────────┐
│ 背景bg-background │
│ │
│ │
│ │
│ ┌──────────────────────────┐ │
│ │ [48x48 Logo rounded] │ │
│ │ visionA Cloud │ │
│ │ Edge AI Platform │ │
│ └──────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ Card (w-full, py-8 px-6) │ │
│ │ │ │
│ │ Label: Email │ │
│ │ ┌───────────────────────────────────┐ │ │
│ │ │ Input type=email │ │ │
│ │ │ placeholder: you@example.com │ │ │
│ │ │ autoComplete=email required │ │ │
│ │ └───────────────────────────────────┘ │ │
│ │ │ │
│ │ Label: 密碼 │ │
│ │ ┌───────────────────────────────────┐ │ │
│ │ │ Input type=password │ │ │
│ │ │ autoComplete=current-password │ │ │
│ │ │ required │ │ │
│ │ └───────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌───────────────────────────────────┐ │ │
│ │ │ Button variant=default w-full │ │ │
│ │ │ 登入 │ │ │
│ │ └───────────────────────────────────┘ │ │
│ │ │ │
│ │ ─────── 還沒有帳號? ─────── │ │
│ │ (Separator 左右 + 中央文字) │ │
│ │ │ │
│ │ ┌───────────────────────────────────┐ │ │
│ │ │ Link → /register │ │ │
│ │ │ Button variant=outline w-full │ │ │
│ │ │ 建立新帳號 │ │ │
│ │ └───────────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
│ │
│ 忘記密碼? (link disabled) | 語言:繁中 ▾ │
│ │
│ │
└────────────────────────────────────────────────────────────────┘
```
---
## 關鍵尺寸
- 容器寬度:`max-w-md`448px
- 水平 padding`px-4`
- 垂直間距:品牌區與 Card 之間 `mt-8`Card 與底部連結之間 `mt-4`
- Card 內部:`gap-y-4`
- Logo`h-12 w-12 rounded-lg`
- 品牌文字:`text-2xl font-bold` 主標 + `text-sm text-muted-foreground` 副標
---
## 響應式
- Mobile< 640px結構不變`px-4` 確保左右 padding
- Tablet / Desktop結構不變置中
---
## 互動
| 元素 | 行為 |
|------|------|
| Email Input | 聚焦顯示 ringTab 移到 Password |
| Password Input | 聚焦顯示 ringEnter 提交表單 |
| 登入 Button | click / Enter 觸發 `handleLogin`Phase 0 直接 `router.push('/')` |
| 建立新帳號 | Link 導航到 `/register` |
| 忘記密碼? | Phase 0 disabled |
| 語言切換 | Select 切換後 store 更新,整頁重新渲染 |
---
## Dark Mode
自動跟隨系統(`ThemeSync`),無特殊處理。
---
## 無障礙
- Form 提交靠 `<form onSubmit>`(可按 Enter
- Input Label 用 `<Label htmlFor>`
- Button 有明確文字,無需 `aria-label`
- Tab 順序Email → Password → 登入 → 建立新帳號 → 忘記密碼 → 語言切換
- Skip linkPhase 1+ 再加
---
## 連結到的 i18n
`flows/flow-auth.md` 第 3.6 節。

View File

@ -0,0 +1,246 @@
# Wireframe — `/devices/pair`
> 文字版 wireframe。雲端版最重要的新頁面。對應設計規格見 `flows/flow-pairing.md`
---
## 佈局Desktop
```
┌────────────────────────────────────────────────────────────────┐
│ [Sidebar] [Header] │
│ ────────────────────────────────────────────────────────────── │
│ │
│ [← 返回] │
│ │
│ 配對新裝置 │
│ 讓你的 Kneron 裝置連上雲端,就能從任何地方遠端操作 │
│ │
│ ─────────────── Stepper ─────────────── │
│ │
│ ● ──────────── ○ ──────────── ○ │
│ 1 2 3 │
│ 取得 Token 設定 Local Agent 確認連線 │
│ │
│ ─────────────── Step 1 內容 ─────────────── │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Card: PairingTokenCard │ │
│ │ │ │
│ │ 🔗 你的 Pairing Token │ │
│ │ │ │
│ │ 複製下方 token在 Step 2 讓 local agent 使用 │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ bg-muted, font-mono, text-xl, tracking-wider │ │ │
│ │ │ vAc_a1b2c3d4e5f6a7b8 │ │ │
│ │ │ c9d0e1f2a3b4c5d6e7f8 │ │ │
│ │ │ select-all, p-4, rounded-md │ │ │
│ │ │ (視覺切兩行;複製 = 完整 36 字元無空白) │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ [📋 複製] [🔄 重新產生] │ │
│ │ │ │
│ │ ⏱ 剩餘 14:52 ────────────────── │ │
│ │ (進度條 bg-primary → amber(≤10:00) → red(≤3:00)) │ │
│ │ 過期時 token 轉灰 + 複製 disabled │ │
│ │ 📅 產生時間14:30 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ ⚠ bg-amber-50 Alert │ │
│ │ 這組 token 15 分鐘內有效,請立刻到 Step 2 完成配對 │ │
│ │ token 是一次性使用,完成配對後自動失效 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ [下一步:設定 Agent] │
└────────────────────────────────────────────────────────────────┘
```
---
## Step 2 內容(切換後)
```
─────────────── Step 2 內容 ───────────────
┌───────────────────────────────────────────────────────┐
│ Tabs作業系統
│ [ macOS ] [ Windows ] [ Linux ] │
│ ▲ active │
├───────────────────────────────────────────────────────┤
│ │
│ 1. 下載 local agent │
│ ┌──────────────────────────────────────────────┐ │
│ │ [📥 下載 visionA-local-agent-macos.dmg] │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ 2. 安裝並啟動 │
│ 打開 DMG拖曳到 Applications雙擊啟動 │
│ │
│ 3. 輸入 Pairing Token │
│ 在 local agent 的「雲端」設定頁貼上 token │
│ │
│ 或使用命令列CLI 進階使用者): │
│ ┌──────────────────────────────────────────────┐ │
│ │ bg-muted font-mono text-sm │ │
│ │ visiona-agent --pair-token= │ │
│ │ vAc_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8 │ │
│ │ (複製為單行 36 字元 token) [📋 複製] │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ 4. 連線成功後,回到這個頁面進入 Step 3 │
└───────────────────────────────────────────────────────┘
💡 已經有 local agent[跳到 Step 3 Link]
[上一步] [下一步:確認連線]
```
---
## Step 3 內容 — Waiting 狀態
```
─────────────── Step 3 內容 ───────────────
┌───────────────────────────────────────────────────────┐
│ Card: 等待中 │
│ │
│ ⏳ │
│ (Loader2 animate-spin) │
│ │
│ 等待 local agent 連線... │
│ │
│ 已等待 0:23最長 3 分鐘) │
│ (text-muted-foreground) │
│ │
│ [取消] [查看 Troubleshooting] │
│ │
│ ────────────────────────────────────────── │
│ │
│ 提示: │
│ • 確認 local agent 已啟動 │
│ • 確認 token 正確無誤 │
│ • 確認網路可以連到 cloud.visiona.ai │
└───────────────────────────────────────────────────────┘
[上一步]
```
---
## Step 3 內容 — Success 狀態
```
┌───────────────────────────────────────────────────────┐
│ Card: 成功 │
│ │
│ ✓ (text-green-500 大圖示) │
│ (CheckCircle2, h-16 w-16) │
│ │
│ 已成功連線! │
│ │
│ 檢測到的裝置: │
│ │
│ ┌────────────────────────────────────┐ │
│ │ 🔌 Kneron KL520 │ │
│ │ Firmware 2.3.1 │ │
│ │ Host: office-mac │ │
│ └────────────────────────────────────┘ │
│ │
│ [進入裝置列表 →] (w-full, variant=default) │
└───────────────────────────────────────────────────────┘
```
---
## Step 3 內容 — Timeout / Error 狀態
```
┌───────────────────────────────────────────────────────┐
│ Card: 錯誤 │
│ │
│ ⚠ (text-destructive) │
│ │
│ 連線超時 │
│ │
│ 超過 3 分鐘沒收到 local agent 連線 │
│ │
│ 可能原因: │
│ • local agent 尚未啟動 │
│ • token 輸入錯誤 │
│ • 防火牆擋住 WebSocket 連線 │
│ │
│ [重新檢查] [回到 Step 2] [查看完整說明] │
└───────────────────────────────────────────────────────┘
```
---
## Stepper 視覺細節
```
已完成:
┌──┐
│✓ │ bg-primary text-primary-foreground rounded-full h-8 w-8
└──┘ flex items-center justify-center
1
取得 Token text-sm font-medium (text-foreground)
當前:
┌──┐
│●2│ ring-2 ring-primary bg-background rounded-full h-8 w-8
└──┘ flex items-center justify-center
2
設定 Agent text-sm font-medium text-foreground
未完成:
┌──┐
│ 3│ bg-muted text-muted-foreground rounded-full h-8 w-8
└──┘
3
確認連線 text-sm text-muted-foreground
連線線:
────── 已完成段bg-primary h-0.5
────── 未完成段bg-muted h-0.5
```
---
## URL 狀態
為了讓使用者可以中途關閉後重返URL 編碼狀態:
- `/devices/pair` → 預設 Step 1
- `/devices/pair?step=2` → 跳到 Step 2
- `/devices/pair?step=3&token=xxx` → 跳到 Step 3繼續等待已產的 token
重新產生 token 時更新 URL。
---
## 響應式
| 斷點 | 調整 |
|------|------|
| Mobile (< 640px) | Stepper 步驟文字隱藏或縮短Card padding 調小 `p-4` |
| Tablet / Desktop | 完整呈現 |
---
## 無障礙
- Stepper`<ol role="list">` + `<li role="listitem" aria-current="step">`
- Token 顯示區:`aria-label="Pairing token"` + `role="text"`(不要一個一個念字元)
- 複製按鈕:按下後 `aria-live="polite"` 宣告「已複製」
- Step 3 polling 狀態變更:`aria-live="polite"`
- 成功 / 失敗:`role="status"` + `aria-live="assertive"`
- 所有按鈕可 Tab 聚焦
---
## 對應的 i18n key
`flows/flow-pairing.md` 第 4-6 節的 i18n key 清單。

View File

@ -0,0 +1,77 @@
# Wireframe — `/register`
> 文字版 wireframe。結構與 `/login` 近乎相同,差異列於本文。
---
## 佈局
參考 `wf-login.md`,差異如下:
```
Card 內容改為:
Label: Email
Input (type=email, required, autoComplete=email)
Label: 密碼
Input (type=password, required, autoComplete=new-password)
Label: 確認密碼
Input (type=password, required, autoComplete=new-password)
// 失焦時比對 password不符顯示 error 紅字
Button: 建立帳號 (w-full, variant=default)
Separator + 「已經有帳號?」
Link → /login
Button: 登入 (w-full, variant=outline)
```
---
## 驗證Phase 0 最小化)
- **Email**`required` + `type=email`HTML5 內建)
- **密碼**`required`,無強度驗證
- **確認密碼**
- `onBlur` 時比對 password
- 不符:`aria-invalid=true` + 紅色邊框 + 下方 `<p class="text-xs text-destructive">兩次輸入的密碼不一致</p>`
---
## 提交行為Phase 0
```tsx
async function handleRegister(e) {
e.preventDefault();
if (password !== confirm) {
setError('passwordMismatch');
return;
}
// Phase 0: 不接 API
useAuthStore.getState().login(email);
router.push('/devices/pair'); // 引導首次配對
}
```
---
## 標題
```
[Logo]
visionA Cloud
Edge AI Platform
Card 內上方大標)
建立帳號
開始使用 visionA Cloud
```
---
## Phase 1 TODO
`flows/flow-auth.md` 第 12 節。

View File

@ -0,0 +1,924 @@
# Wireframe — 轉檔(`/conversion`
> 文字版 wireframe。Phase 0.8 雲端版新增頁面 — 把使用者「ONNX → NEF」的轉檔旅程留在 visionA Cloud 內完成,不必再跳到 converter 站台。
>
> 對應流程文件:[`flows/flow-conversion.md`](../flows/flow-conversion.md)
> 對應 Feature spec[`02-prd/features/feature-converter-integration.md`](../../02-prd/features/feature-converter-integration.md)
---
## 0. 設計對齊備註
- **版型**:沿用既有 AppShellSidebar + Header + main。轉檔頁 `/conversion`(單一 route內以 state 機切四個畫面 — `idle` / `uploading` / `processing` / `completed`(含 success / failed 兩支線),不開新分頁。
- **Sidebar 加位**:在「裝置 / 模型 / 工作區」之後、「叢集 / 設定」之前與「Models」相鄰視覺上把「上傳模型」與「轉檔產生模型」放成兩個並排入口符合心智模型。Icon 採 Lucide [`Wand2`](https://lucide.dev/icons/wand-2) — 「魔法棒」隱喻「把一個格式變成另一個」,比 `Workflow`(流程感過重)/ `Cpu`(已被裝置語義佔用)/ `RefreshCw`(暗示同步、不是轉換)更貼切。**最終決定見 §10「icon 替代方案」**。
- **元件複用**:上傳區塊抄一份 `ModelUploadDialog` 改名 `ConversionUploadDialog`(不直接共用,因為需求差太多 — 轉檔有 chip 必填、ref images 多檔、500 MB 上限)。其餘 Dialog / Card / Button / Progress / Badge / EmptyState / Sonner toast 全部直接複用。
- **Design Tokens**:不新增任何 token。chip 選擇器底色用 `--accent`,進度條 `--primary`error 用 `--destructive`,警示 banner 沿用 `bg-amber-50 / dark:bg-amber-950/30` + `border-amber-300`
- **i18n**:所有文案都會走 i18nzh-TW + enkey 命名空間 `conversion.*`,整理在 §11。
---
## 1. Sidebar 與進入點
### 1.1 Sidebar 變更(追加項目)
```
┌────────────────────────┐
│ [vA] visionA Cloud │ h-14, border-b
├────────────────────────┤
│ ▸ 儀表板 │
│ ▸ 裝置 │
│ ▸ 模型庫 │
│ ▸ 工作區 │
│ ▸ 轉檔 ← new │ ← Wand2 icon
│ ▸ 叢集 │
│ ▸ 設定 │
│ │
│ (flex-1) │
├────────────────────────┤
│ v0.1.0 · Phase 0.8 │
└────────────────────────┘
```
新增的 NavItem
```ts
{ href: "/conversion", labelKey: "nav.conversion", icon: Wand2 }
```
i18n
- `nav.conversion` → 繁中「轉檔」/ English「Convert」
放置位置決策:**模型庫之後**。理由:使用者心智「我有一個外部模型,想讓它能在我的 KL 裝置上跑」的下一步通常是「我已經知道有 Models 頁可以管模型,那 Convert 應該就在它附近」。
### 1.2 入口
- 主入口Sidebar 「轉檔」tab → 進 `/conversion`
- 次要入口Phase 0.8 不做但保留思考):`/models` 上傳 Dialog 內加一個「我有 ONNX需要先轉檔 →」連結;先不做避免分支太多。
---
## 2. 頁面狀態總覽
`/conversion` 是單頁,內部依 state 機切四種畫面。state 由前端 store 決定(`useConversionStore`,等 frontend 時再實作),不靠 URL query。
```
┌──────────────────────────────────────────────────────────────────┐
│ idle ┌───────► processing ─────┐ │
│ (無進行中 job、 │ polling │ │
│ 顯示空狀態 + CTA │ ▼ │
│ │ │ completed │
│ ▼ │ ├─ success │
│ uploading ───────┘ └─ failed │
XHR 進度條 0100% (兩種分支) │
└──────────────────────────────────────────────────────────────────┘
```
| 狀態 | 觸發進入 | 觸發離開 |
|------|---------|---------|
| `idle` | 預設 / 完成後按「開始新轉檔」/ 失敗後按「重新開始」 | 點「開始轉檔」開 Upload Dialog |
| `uploading` | Upload Dialog 內按「開始上傳」 | XHR 完成(→ processing/ 失敗(→ idle + toast/ 取消 |
| `processing` | upload 完成、收到 `job_id` | poll 到 `succeeded` / `failed` |
| `completed.success` | poll 到 `status: succeeded` | 「開始新轉檔」回 `idle` |
| `completed.failed` | poll 到 `status: failed` 或拿到非 200 | 「重新開始」回 `idle` |
---
## 3. State A`idle` — 無進行中 Job預設畫面
### 3.1 整體版型Desktop, ≥ 1024px
```
┌──────────────────────────────────────────────────────────────────┐
│ [Sidebar] [Header] │
│ ────────────────────────────────────────────────────────────────── │
│ mx-auto max-w-7xl px-6 py-8 space-y-6 │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 轉檔 text-2xl font-bold │ │
│ │ 把 ONNX / TFLite 模型轉成 .nef跑在 Kneron 邊緣裝置上 │ │
│ │ text-muted-foreground │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ EmptyState (rounded-xl border bg-card py-16 text-center) │ │
│ │ │ │
│ │ ✨ (Wand2, h-12 w-12, text-muted-foreground) │ │
│ │ │ │
│ │ 還沒有進行中的轉檔 text-lg font-semibold │ │
│ │ │ │
│ │ 上傳一個 ONNX / TFLite 模型,選擇目標 Kneron 晶片, │ │
│ │ 我們幫你產出可直接燒錄的 .nef 檔案 │ │
│ │ (max-w-md mx-auto text-sm text-muted-foreground) │ │
│ │ │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ [✨ 開始轉檔] │ │ │
│ │ │ variant=default size=lg │ │ │
│ │ └────────────────────────────┘ │ │
│ │ │ │
│ │ 支援 .onnx / .tflite · 最大 500 MB │ │
│ │ text-xs text-muted-foreground │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 關於轉檔 │ │
│ │ text-sm text-muted-foreground (折疊區,預設展開) │ │
│ │ • 一次只能跑一個轉檔任務(包含其他分頁) │ │
│ │ • 完成後 7 天內可下載結果,過期自動清除 │ │
│ │ • 轉檔約耗時 110 分鐘,依模型大小而定 │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
### 3.2 互動
| 元素 | 互動 | 行為 |
|------|------|------|
| 「開始轉檔」CTA | click / Enter / Space | 開啟 `ConversionUploadDialog`(見 §4|
### 3.3 邊界 — 已有 active job
進入 `/conversion` 時前端**先打一次** `GET /api/conversion/active`visionA backend 提供,回傳該 user 是否有進行中的 job
| 回應 | UI 行為 |
|------|---------|
| `{ active: false }` | 顯示 `idle` 畫面(如上) |
| `{ active: true, jobId, status, ... }` | 直接跳 `processing` 畫面(見 §6banner 加註「您離開前的轉檔仍在進行中」|
> 這個檢查也涵蓋「使用者開了第二個分頁」「重新整理」「離開後再回來」三種情境,**不需要前端額外狀態保存**。
---
## 4. Upload Dialog`ConversionUploadDialog`
複用 `Dialog``Input``Label``Select``Button``Progress` 元件;參考既有 `ModelUploadDialog` 改寫。
### 4.1 階段 A — 選檔與設定(`select`
```
┌────────────────────────────────────────────────────────────┐
│ 開始轉檔 [✕] │
│ DialogHeader · DialogTitle · DialogDescription │
│ 上傳模型、選擇目標晶片,可選擇加上 reference images 提升精度 │
├────────────────────────────────────────────────────────────┤
│ space-y-5 px-6 py-4 │
│ │
│ Label: 來源模型 * │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 📁 拖曳 .onnx / .tflite 到此處 │ │
│ │ │ │
│ │ 或 [選擇檔案] │ │
│ │ │ │
│ │ 支援格式:.onnx · .tflite · 最大 500 MB │ │
│ │ │ │
│ └──────────────────────────────────────────────────────┘ │
│ border-2 border-dashed rounded-md bg-muted/50 h-32 │
│ 選了之後變成: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 📄 yolov5s.onnx · 28.4 MB [✕ 移除] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ Label: 任務名稱(選填) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ yolov5s預設帶檔名 stem可改 │ │
│ └──────────────────────────────────────────────────────┘ │
│ hint: text-xs text-muted-foreground │
│ 顯示用,不影響輸出檔名 │
│ │
│ Label: 目標晶片 * │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ │KL520 │ │KL630 │ │KL720 │ │KL730 │ │ │
│ │ │ ● │ │ │ │ │ │ │ │ │
│ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │
│ │ 4 個 ChipPill (RadioGroup),單選 │ │
│ │ active: bg-primary text-primary-foreground │ │
│ │ hover: bg-accent │ │
│ │ border rounded-md px-4 py-3 cursor-pointer min-w-20 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ Label: Reference images選填
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 📁 拖曳圖片到此處(選填) │ │
│ │ 或 [選擇檔案] · 最多 100 張,每張 ≤ 10 MB │ │
│ │ border-2 border-dashed rounded-md bg-muted/30 h-20 │ │
│ └──────────────────────────────────────────────────────┘ │
│ 選了之後: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 已選 12 張 ref images共 24.3 MB [移除全部] │ │
│ │ ▸ 縮圖 gridhover 顯示 ✕ 個別移除) │ │
│ └──────────────────────────────────────────────────────┘ │
│ hint: 加上 ref images 可提升量化後精度(可選) │
│ │
├────────────────────────────────────────────────────────────┤
│ DialogFooter │
│ [取消] [開始轉檔] │
│ variant=outline · variant=default │
│ │
│ 「開始轉檔」disabled 條件:未選檔 / 未選 chip / 任一檔超大 │
└────────────────────────────────────────────────────────────┘
```
**前端驗證(按下「開始轉檔」前):**
| 驗證 | 失敗訊息 |
|------|---------|
| 必須選檔 | 「請選擇 .onnx 或 .tflite 檔案」|
| 副檔名為 `.onnx``.tflite` | 「不支援的格式,請改用 ONNX 或 TFLite」|
| 模型 ≤ 500 MB | 「模型超過 500 MB 上限,請改用較小的模型」|
| 必須選 chip | 「請選擇目標晶片」|
| 每張 ref image ≤ 10 MB | 「{filename} 超過 10 MB請移除或壓縮後再試」|
| ref images 總數 ≤ 100 張 | 「Reference images 上限 100 張」|
驗證失敗error 顯示在對應欄位下方(`text-sm text-destructive`),不發 API。
### 4.2 階段 B — 上傳中(`uploading`
按下「開始轉檔」後 Dialog 內容切換(**不關 Dialog**,使用者要看到進度):
```
┌────────────────────────────────────────────────────────────┐
│ 上傳中 [✕*] │
├────────────────────────────────────────────────────────────┤
│ │
│ 📤 正在上傳到 visionA… │
│ text-base font-medium │
│ │
│ yolov5s.onnx · 28.4 MB │
│ text-sm text-muted-foreground │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ ████████████████████░░░░░░░░░░░░░░░░ 42% │ │
│ └──────────────────────────────────────────────┘ │
│ Progress bar (h-2, --primary) │
│ │
│ 已上傳 11.9 / 28.4 MB · 預估剩餘 0:24 │
│ text-xs text-muted-foreground │
│ │
│ ⚠ 請勿關閉此分頁,否則上傳會中斷 │
│ text-xs text-amber-700 dark:text-amber-300 │
│ │
├────────────────────────────────────────────────────────────┤
│ [取消上傳] │
│ variant=outline │
└────────────────────────────────────────────────────────────┘
```
- 進度由 XHR `upload.onprogress``loaded / total`)計算
- 預估剩餘 = 用最近 3 秒移動平均速度算 ETA不足 1 秒顯示「即將完成…」
- `[✕*]` Dialog 右上角關閉按鈕在此階段**保留可點**,但點擊會問 AlertDialog「上傳尚未完成確定取消
- 「取消上傳」:`xhr.abort()` → 回 `idle` 畫面 + toast「已取消上傳」
- `beforeunload`上傳中觸發瀏覽器原生離開警告visionA backend 已 cancel converter job
### 4.3 階段 C — 上傳完成、轉檔啟動
XHR 200 後visionA backend 已 forward 到 converter 並拿到 `job_id`
- Dialog 自動關閉
- 主畫面切到 §6 `processing` 狀態
- toast「已開始轉檔任務 #{shortJobId})」
### 4.4 階段 D — 上傳失敗
| 錯誤 | UI |
|------|----|
| 409 已有 active job | Dialog 關閉,主畫面切 `processing` 並 banner 提示「您已有一個轉檔正在進行中,已切換至該任務」|
| 4xx檔案被拒| Dialog 內顯示錯誤紅字 + 「重試」按鈕 |
| 5xx / 網路 | Dialog 內顯示「上傳失敗:{訊息}」+ 「重試」 |
---
## 5. State`uploading`(在 Dialog 內,非全頁)
實際上 uploading 是 Dialog 內的階段§4.2),主畫面背後仍是 `idle`。**主畫面不顯示進度條**,避免使用者以為要看兩處。
但**瀏覽器分頁標題**會更新:
```
visionA Cloud · 上傳中 (42%)
```
讓使用者就算切到別的分頁也能看到進度。
---
## 6. State`processing` — 轉檔進行中
```
┌──────────────────────────────────────────────────────────────────┐
│ [Sidebar] [Header] │
│ ────────────────────────────────────────────────────────────────── │
│ mx-auto max-w-7xl px-6 py-8 space-y-6 │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 轉檔 │ │
│ │ text-2xl font-bold │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Card: 進行中 (border, rounded-xl, p-6) │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ 📄 yolov5s.onnx → KL720 🔵 轉檔中 │ │ │
│ │ │ Badge: bg-blue-500 text-white animate-pulse │ │ │
│ │ │ 任務 #a1b2c3d4 · 開始於 5 分鐘前 │ │ │
│ │ │ text-sm text-muted-foreground │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 進度stage indicator不可點 │ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ │ ✓ │────│ ● │────│ 3 │ │ │
│ │ │ 1 │ │ 2 │ │ │ │ │
│ │ └──────┘ └──────┘ └──────┘ │ │
│ │ 上傳完成 解析模型 編譯 NEF │ │
│ │ text-sm text-foreground text-muted-foreground │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ 處理中… │ │ │
│ │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━ animate-pulse │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ Progress (indeterminate, h-1.5, --primary) │ │
│ │ hint: text-xs text-muted-foreground │ │
│ │ 通常需要 110 分鐘 · 你可以離開此頁面,回來時會自動更新進度 │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 你可以放著不管 │ │
│ │ text-sm text-muted-foreground │ │
│ │ • 我們會在背景持續查詢進度(每 510 秒一次) │ │
│ │ • 完成後分頁標題會通知你 │ │
│ │ • 此頁面關掉也沒關係,回來時會自動恢復 │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
### 6.1 Stage indicator 規格
三段式 stepper**不是** ONNX → BIE → NEF 那種編譯內部階段,因為 converter Phase 1 不暴露細階段;簡化成使用者能感知的三段):
| Stage | 完成條件 | converter 狀態對應 |
|-------|---------|-------------------|
| 1. 上傳完成 | XHR 200 回到 visionA backend、拿到 job_id | (前端自己標完成)|
| 2. 解析模型 | converter status 變 `running` | poll status `running` 即標完成 stage 1 + 開始 stage 2 |
| 3. 編譯 NEF | converter status 變 `succeeded` | poll status `succeeded` 標 stage 3 完成 |
> **注意**converter Phase 1 只回 `queued / running / succeeded / failed`,沒有 sub-stage。前端的「解析模型 / 編譯 NEF」純粹是 UI 上把 `running` 切成兩段視覺,不代表真實內部階段。如果 converter 後續加 progress 比例(已在 N4 後續規劃),可改成單一 progress bar 顯示百分比。
stage 視覺:
```
完成:
┌──┐ bg-primary text-primary-foreground rounded-full h-8 w-8
│✓ │ flex items-center justify-center
└──┘
文字text-sm font-medium
當前:
┌──┐ ring-2 ring-primary bg-background rounded-full h-8 w-8
│● │ text-primary
└──┘
文字text-sm font-medium
未完成:
┌──┐ bg-muted text-muted-foreground rounded-full h-8 w-8
│ 3│
└──┘
文字text-sm text-muted-foreground
連線h-0.5(已完成段 bg-primary、未完成段 bg-muted
```
### 6.2 進度條策略
- Phase 0.8 converter 不給 progress 比例 → 用 **indeterminate progress**shadcn `Progress` 不帶 `value` 屬性 + `animate-pulse`
- 文字:`處理中…`(不要謊報百分比)
- 不要顯示「預估剩餘時間」,因為沒有可靠資料
> **Phase 1 待 converter 提供 progress 後升級**:改成 `<Progress value={pct} />`,文字改成 `處理中({pct}%`
### 6.3 Polling 行為
| 項目 | 規格 |
|------|------|
| 間隔 | 每 5 秒一次(前 60 秒)→ 每 10 秒(之後)|
| 端點 | `GET /api/conversion/{job_id}`visionA backend 中繼)|
| 暫停 | 分頁不可見(`document.visibilityState !== 'visible'`)時暫停;回到可見立即補打一次 |
| 失敗重試 | 指數退避 1s / 2s / 4s / 8s / 上限 30s連 5 次失敗顯示「無法取得轉檔狀態,請重試」+ retry 按鈕 |
| 終止 | 收到 `succeeded` / `failed` 或使用者離開頁面 |
### 6.4 邊界情境
| 情境 | UI 反應 |
|------|---------|
| 使用者關掉分頁、過 10 分鐘回來 | 重進 `/conversion` → §3.3 active job 檢查命中 → 直接落 `processing` 畫面 |
| 使用者開了第二個分頁 | 兩個分頁各自 polling 同一個 job狀態同步無需跨分頁通訊|
| Polling 一直拿到 `queued` 超過 5 分鐘 | banner 提示「目前排隊較久,你可以離開此頁稍後再回」|
| 跑超過 15 分鐘還沒完 | 不主動終止banner 加註「轉檔耗時較長,仍在進行中」|
---
## 7. State`completed.success` — 轉檔成功
```
┌──────────────────────────────────────────────────────────────────┐
│ [Sidebar] [Header] │
│ ────────────────────────────────────────────────────────────────── │
│ mx-auto max-w-7xl px-6 py-8 space-y-6 │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 轉檔 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Card: 完成 (border-green-300 bg-green-50/40 │ │
│ │ dark:bg-green-950/20 dark:border-green-800) │ │
│ │ │ │
│ │ ✓ 轉檔完成 │ │
│ │ CheckCircle2, h-6 w-6, text-green-600 │ │
│ │ text-lg font-semibold │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ yolov5s.onnx → yolov5s_kl720.nef │ │ │
│ │ │ text-sm font-mono │ │ │
│ │ │ │ │ │
│ │ │ 目標晶片KL720 輸出大小4.2 MB │ │ │
│ │ │ 耗時3 分 14 秒 checksumsha256:a1b2… │ │ │
│ │ │ text-sm text-muted-foreground │ │ │
│ │ │ 任務 #{shortJobId} │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 接下來要做什麼? │ │
│ │ text-sm font-medium │ │
│ │ │ │
│ │ ┌────────────────────────┐ ┌────────────────────────┐ │ │
│ │ │ 📚 加到模型庫 │ │ ⬇ 下載 .nef │ │ │
│ │ │ 之後可以從模型庫部署 │ │ 存到本機自行使用 │ │ │
│ │ │ 到任何 KL720 裝置 │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ [加到模型庫] │ │ [下載] │ │ │
│ │ │ variant=default │ │ variant=outline │ │ │
│ │ └────────────────────────┘ └────────────────────────┘ │ │
│ │ Card 內兩格 grid (md: grid-cols-2 gap-4) │ │
│ │ │ │
│ │ ⏳ 此轉檔結果將在 6 天 21 小時後自動清除,請在期限內完成處理 │ │
│ │ text-xs text-amber-700 dark:text-amber-300 │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ [✨ 開始新轉檔] │ │
│ │ variant=outline w-full │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ 完成 + 還沒按過任何按鈕也允許開新轉檔converter 不再有 active job
└──────────────────────────────────────────────────────────────────┘
```
### 7.1 「加到模型庫」流程(按鈕)
```
1. 點擊 → 開 AlertDialog 確認 + 輸入欄位(複用 visionA 既有 import flow
┌────────────────────────────────────────────────┐
│ 加到模型庫 │
│ │
│ Label: 模型名稱(預設帶 job 任務名) │
│ ┌──────────────────────────────────────────┐ │
│ │ yolov5s_kl720 │ │
│ └──────────────────────────────────────────┘ │
│ │
│ Label: 描述(選填) │
│ ┌──────────────────────────────────────────┐ │
│ │ │ │
│ └──────────────────────────────────────────┘ │
│ Textarea (rows=3) │
│ │
│ ▸ 來源轉檔job #{shortJobId}text-xs │
│ ▸ 目標晶片KL720自動帶入
│ │
│ [取消] [加到模型庫] │
└────────────────────────────────────────────────┘
2. 確認後呼叫 POST /api/conversion/{job_id}/promote-to-models
3. Loading按鈕變 spinner + 「處理中…」disabled
4. 200 OK
- toast「已加入模型庫」+ action「前往模型庫 →」連結到 /models/{model_id}
- 「加到模型庫」按鈕變綠勾 + 副標「✓ 已加入(前往查看 →)」(仍可點再加,但會 409
5. 409 already imported
- toast「此任務已加入過模型庫」+ 連結「查看現有模型 →」
6. 其他錯誤toast 顯示錯誤訊息 + 「重試」
```
**為何不直接彈兩個按鈕沒有確認 dialog 也 OK** 因為 PRD §F4 的決策是「半自動 = user 顯式選擇」,但「加到模型庫」會建立永久資源(出現在 `/models`),讓使用者**確認名稱**比靜默 import 更符合心智 — 跟 `ModelUploadDialog` 的做法一致。
> 👉 給 PM上面的「模型名稱 / 描述」要不要做進 Phase 0.8 Dialog如果你想最簡可以直接用 job.name 自動填、不問使用者,省一個 Dialog。我這邊建議**保留**這個小 Dialog 但只給「名稱」一個欄位(描述放 Phase 1理由使用者「轉檔任務名」≠「模型庫名稱」的心智差異很常見。
### 7.2 「下載」流程(按鈕)
```
1. 點擊 → 按鈕短暫進 loadingspinner + 「準備下載…」)
2. 觸發 navigationwindow.location.href = '/api/conversion/{job_id}/download'
(或用 anchor tag <a href="/api/conversion/{job_id}/download" download>,效果等價)
3. visionA backend 收到後 server-side 跟 MC 換 delegated token
直接回 HTTP 302 Redirect → Location: <FAA-URL>/files/{key}?access_token=...
4. 瀏覽器自動跟著 302 跳轉到 FAA內建下載管理器接管下載
- 按鈕回到原狀態(不變灰,使用者可重複下載 → 重新換新 token
- toast「下載已開始」+ 副標「若沒看到下載提示,請檢查瀏覽器設定」
5. 4xx / 5xxbackend 還沒到 redirect 階段就 fail
- 因為已 navigation瀏覽器會顯示 backend 的錯誤頁;使用者按 back 即可
- 或前端可選用 fetch + 偵測 status 後才 navigate避免 navigate 到錯誤頁)
```
**重點token 不暴露給 frontend JS**,整個換 token 流程在 backend 內完成,前端只看得到 `/api/conversion/{job_id}/download` 這個 URL。
**Phase 0.8 短期方案FAA 還沒加 CORS 前)**:靠 navigation download + 302 redirect瀏覽器內建下載管理器接手無自訂進度條。
**FAA 加 CORS 後(升級)**:可改用 `fetch('/api/conversion/{job_id}/download', { redirect: 'follow' })` + ReadableStream + 自訂進度條Dialog 內顯示下載進度),仍維持 server-side 換 token 設計。本檔保留視覺位置,實作時再補。
### 7.3 「開始新轉檔」按鈕
直接重置 store state 回 `idle`**不清除舊 job 紀錄**(仍可從 §3.3 active job 機制判斷,但因為剛剛已 completed不會有 active舊 job 結果如果使用者沒下載 / 沒 import過 7 天自動 GC
⚠️ 邊界:使用者按「開始新轉檔」**之前**「加到模型庫」「下載」按鈕仍應**保持可用**(不在使用者按「開始新轉檔」時消失)— 因為使用者可能想兩個都做。「開始新轉檔」應該被視為**離開這個結果頁**的明確動作。
---
## 8. State`completed.failed` — 轉檔失敗
```
┌──────────────────────────────────────────────────────────────────┐
│ [Sidebar] [Header] │
│ ────────────────────────────────────────────────────────────────── │
│ mx-auto max-w-7xl px-6 py-8 space-y-6 │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Card: 失敗 (border-destructive/40 bg-destructive/5 │ │
│ │ dark:bg-destructive/10) │ │
│ │ │ │
│ │ ⚠ 轉檔失敗 │ │
│ │ AlertCircle, h-6 w-6, text-destructive │ │
│ │ text-lg font-semibold │ │
│ │ │ │
│ │ {translatedErrorMessage} │ │
│ │ text-sm text-foreground │ │
│ │ 例:「模型內含不支援的運算子,無法量化到目標晶片」 │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ yolov5s.onnx → KL720失敗 │ │ │
│ │ │ text-sm │ │ │
│ │ │ 錯誤代碼QUANTIZATION_FAILED │ │ │
│ │ │ 任務 #{shortJobId} │ │ │
│ │ │ text-xs font-mono text-muted-foreground │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 💡 你可以試試: │ │
│ │ text-sm font-medium │ │
│ │ • 確認模型已用標準 PyTorch / TensorFlow export │ │
│ │ • 簡化模型結構(移除 Custom Op │ │
│ │ • 改用較小的 batch size 或 input shape │ │
│ │ text-sm text-muted-foreground │ │
│ │ suggestions 依 error code 切換) │ │
│ │ │ │
│ │ ┌────────────────────────┐ ┌────────────────────────┐ │ │
│ │ │ [重新開始] │ │ [回模型庫] │ │ │
│ │ │ variant=default │ │ variant=outline │ │ │
│ │ └────────────────────────┘ └────────────────────────┘ │ │
│ │ │ │
│ │ 若持續發生,請複製任務 ID #{fullJobId} 聯絡支援團隊 │ │
│ │ [📋 複製任務 ID] │ │
│ │ text-xs text-muted-foreground · variant=ghost size=xs │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
### 8.1 錯誤訊息對照(依 PRD §F5
| converter error code | 顯示文案zh-TW | suggestions 顯示 |
|---------------------|-----------------|-----------------|
| `UNSUPPORTED_FORMAT` | 此模型格式目前不支援,請改用 ONNX / TFLite | 確認檔案副檔名、用標準 export 工具 |
| `INVALID_CHECKSUM` | 檔案傳輸過程毀損,請重新上傳 | 重新開始上傳 |
| `QUANTIZATION_FAILED` | 模型內含不支援的運算子,無法量化到目標晶片 | 簡化模型、移除 Custom Op、改 input shape |
| `MODEL_TOO_LARGE` | 模型超過 500 MB 上限 | 改用較小模型 / Pruning |
| `QUOTA_EXCEEDED` | 系統暫時繁忙,請稍後再試 | 等 5 分鐘重試 |
| 其他 / unknown | 轉檔失敗,請稍後重試。若持續發生請聯絡支援團隊 | 複製 job ID 回報 |
i18n key`conversion.error.{code}.message` / `conversion.error.{code}.suggestions`(見 §11
### 8.2 「Job 已過期」特殊狀態
當使用者重新整理頁面、active job 檢查回 404converter 7 天 GC 已清除):
```
┌──────────────────────────────────────────────────────────────────┐
│ Card: 已過期 (border-muted bg-muted/30) │
│ │
│ ⏰ 此轉檔結果已過期 │
│ Clock, h-6 w-6, text-muted-foreground │
│ │
│ 轉檔結果保留期為 7 天,目前已超過保留期限並自動清除。 │
│ │
│ [✨ 開始新轉檔] │
└──────────────────────────────────────────────────────────────────┘
```
---
## 9. 響應式
沿用 design-spec.md §6 整體策略,逐狀態調整:
| 斷點 | `idle` | `uploading`(在 Dialog 內)| `processing` | `completed.success` |
|------|--------|--------------------------|--------------|--------------------|
| Mobile (< 640px) | EmptyState 縮窄 max-w-fullCTA 全寬;「關於轉檔摺疊 | Dialog fullscreen-on-mobileshadcn 預設行為| stage indicator 改縱向堆疊icon 圓點 + 標籤一行Card padding `p-4` | 兩個 action card 改縱向堆疊grid-cols-1 |
| Tablet (6401024) | 居中、max-w-2xl | Dialog 寬 max-w-lg | stage indicator 橫排但縮短連線 | grid-cols-2 |
| Desktop (≥ 1024) | 完整呈現 | 完整呈現 | 完整呈現 | grid-cols-2 |
`/conversion` 在 Mobile 不顯示「請使用桌面版」警告(不像 `/workspace` 那麼依賴大螢幕,可用)。但**500 MB 檔案在 Mobile 上傳**體驗極差,給一個 hint banner
```
Mobile 設備上傳大型模型可能不穩定,建議使用桌面版瀏覽器
(只在 Mobile 顯示)
```
---
## 10. icon 替代方案(給設計討論)
任務指定 `Wand2` / `Workflow` / `Cpu` / `RefreshCw` 四選一,本 wireframe 採 **`Wand2`**。理由與替代方案:
| icon | 隱喻 | 採用? | 原因 |
|------|------|-------|------|
| **`Wand2`** ✨ | 「魔法轉換」一個東西變成另一個 | ✅ 採用 | 直覺、與既有 Boxes / Cable / LayoutDashboard 視覺密度相近 |
| `Workflow` | 流程 / 多步驟 | ❌ | 太流程感、容易跟 CI/CD 混淆 |
| `Cpu` | 晶片 / 硬體 | ❌ | 已被裝置語義佔據(`Devices` tab 概念上更該用這個) |
| `RefreshCw` | 同步 / 重整 | ❌ | 暗示週期性同步,不是一次性轉換 |
備案:如果使用者覺得 `Wand2` 太可愛不夠工程感,可改 `FileCog``Wand2` 的工程版)或 `Replace`更精準的「A→B」隱喻— 待 review 確認。
---
## 11. i18n key 規劃
### 11.1 Sidebar / 導航
```
nav.conversion → 轉檔 / Convert
```
### 11.2 頁面標題
```
conversion.title → 轉檔
conversion.subtitle → 把 ONNX / TFLite 模型轉成 .nef跑在 Kneron 邊緣裝置上
```
### 11.3 idle 空狀態
```
conversion.idle.heading → 還沒有進行中的轉檔
conversion.idle.description → 上傳一個 ONNX / TFLite 模型,選擇目標 Kneron 晶片,我們幫你產出可直接燒錄的 .nef 檔案
conversion.idle.cta → 開始轉檔
conversion.idle.formats → 支援 .onnx / .tflite · 最大 500 MB
conversion.idle.about.title → 關於轉檔
conversion.idle.about.line1 → 一次只能跑一個轉檔任務(包含其他分頁)
conversion.idle.about.line2 → 完成後 7 天內可下載結果,過期自動清除
conversion.idle.about.line3 → 轉檔約耗時 110 分鐘,依模型大小而定
```
### 11.4 Upload Dialog
```
conversion.upload.title → 開始轉檔
conversion.upload.description → 上傳模型、選擇目標晶片,可選擇加上 reference images 提升精度
conversion.upload.source.label → 來源模型
conversion.upload.source.dropzone → 拖曳 .onnx / .tflite 到此處
conversion.upload.source.or → 或
conversion.upload.source.browse → 選擇檔案
conversion.upload.source.formatHint → 支援格式:.onnx · .tflite · 最大 500 MB
conversion.upload.source.remove → 移除
conversion.upload.name.label → 任務名稱(選填)
conversion.upload.name.hint → 顯示用,不影響輸出檔名
conversion.upload.chip.label → 目標晶片
conversion.upload.refImages.label → Reference images選填
conversion.upload.refImages.dropzone → 拖曳圖片到此處(選填)
conversion.upload.refImages.hint → 加上 ref images 可提升量化後精度(最多 100 張,每張 ≤ 10 MB
conversion.upload.refImages.summary → 已選 {count} 張 ref images共 {totalSize}
conversion.upload.refImages.removeAll → 移除全部
conversion.upload.cancel → 取消
conversion.upload.start → 開始轉檔
conversion.upload.error.noFile → 請選擇 .onnx 或 .tflite 檔案
conversion.upload.error.unsupported → 不支援的格式,請改用 ONNX 或 TFLite
conversion.upload.error.modelTooLarge → 模型超過 500 MB 上限,請改用較小的模型
conversion.upload.error.noChip → 請選擇目標晶片
conversion.upload.error.refTooLarge → {filename} 超過 10 MB請移除或壓縮後再試
conversion.upload.error.refTooMany → Reference images 上限 100 張
```
### 11.5 Uploading 階段
```
conversion.uploading.title → 上傳中
conversion.uploading.heading → 正在上傳到 visionA…
conversion.uploading.progress → 已上傳 {loaded} / {total} · 預估剩餘 {eta}
conversion.uploading.almostDone → 即將完成…
conversion.uploading.warning → 請勿關閉此分頁,否則上傳會中斷
conversion.uploading.cancel → 取消上傳
conversion.uploading.cancelConfirm → 上傳尚未完成,確定取消?
conversion.uploading.tabTitle → visionA Cloud · 上傳中 ({pct}%)
conversion.uploading.toastCanceled → 已取消上傳
conversion.uploading.toastFailed → 上傳失敗:{reason}
conversion.uploading.toastStarted → 已開始轉檔(任務 #{shortJobId}
```
### 11.6 Processing 階段
```
conversion.processing.title → 轉檔
conversion.processing.cardHeading → 進行中
conversion.processing.statusBadge → 轉檔中
conversion.processing.startedAgo → 開始於 {time}
conversion.processing.stage1 → 上傳完成
conversion.processing.stage2 → 解析模型
conversion.processing.stage3 → 編譯 NEF
conversion.processing.processing → 處理中…
conversion.processing.hint → 通常需要 110 分鐘 · 你可以離開此頁面,回來時會自動更新進度
conversion.processing.background.title → 你可以放著不管
conversion.processing.background.l1 → 我們會在背景持續查詢進度(每 510 秒一次)
conversion.processing.background.l2 → 完成後分頁標題會通知你
conversion.processing.background.l3 → 此頁面關掉也沒關係,回來時會自動恢復
conversion.processing.queueLong → 目前排隊較久,你可以離開此頁稍後再回
conversion.processing.runLong → 轉檔耗時較長,仍在進行中
conversion.processing.pollFailed → 無法取得轉檔狀態,請重試
conversion.processing.bannerActive → 您離開前的轉檔仍在進行中
conversion.processing.bannerExisting → 您已有一個轉檔正在進行中,已切換至該任務
```
### 11.7 已有 active job 提示idle 頁也會用到)
```
conversion.busy.title → 您已有一個轉檔正在進行中
conversion.busy.cta → 查看進度
```
### 11.8 Success 結果
```
conversion.success.heading → 轉檔完成
conversion.success.summary.chip → 目標晶片
conversion.success.summary.size → 輸出大小
conversion.success.summary.duration → 耗時
conversion.success.summary.checksum → checksum
conversion.success.summary.jobId → 任務
conversion.success.nextStep → 接下來要做什麼?
conversion.success.import.title → 加到模型庫
conversion.success.import.description → 之後可以從模型庫部署到任何 {chip} 裝置
conversion.success.import.cta → 加到模型庫
conversion.success.import.dialog.title → 加到模型庫
conversion.success.import.dialog.nameLabel → 模型名稱
conversion.success.import.dialog.descLabel → 描述(選填)
conversion.success.import.dialog.sourceLabel → 來源
conversion.success.import.dialog.sourceValue → 轉檔job #{shortJobId}
conversion.success.import.dialog.confirm → 加到模型庫
conversion.success.import.dialog.cancel → 取消
conversion.success.import.processing → 處理中…
conversion.success.import.toastDone → 已加入模型庫
conversion.success.import.toastDoneAction → 前往模型庫 →
conversion.success.import.toastDup → 此任務已加入過模型庫
conversion.success.import.toastDupAction → 查看現有模型 →
conversion.success.import.statusDone → ✓ 已加入(前往查看 →)
conversion.success.download.title → 下載 .nef
conversion.success.download.description → 存到本機自行使用
conversion.success.download.cta → 下載
conversion.success.download.preparing → 準備下載…
conversion.success.download.toastStart → 下載已開始
conversion.success.download.toastHint → 若沒看到下載提示,請檢查瀏覽器設定
conversion.success.download.toastFail → 下載連結取得失敗
conversion.success.expiry → 此轉檔結果將在 {time} 後自動清除,請在期限內完成處理
conversion.success.startNew → 開始新轉檔
```
### 11.9 Failed 結果
```
conversion.failed.heading → 轉檔失敗
conversion.failed.errorCode → 錯誤代碼
conversion.failed.suggestionsTitle → 你可以試試:
conversion.failed.retry → 重新開始
conversion.failed.backToModels → 回模型庫
conversion.failed.contactSupport → 若持續發生,請複製任務 ID 聯絡支援團隊
conversion.failed.copyJobId → 複製任務 ID
conversion.failed.toastJobIdCopied → 已複製任務 ID
conversion.error.UNSUPPORTED_FORMAT.message → 此模型格式目前不支援,請改用 ONNX / TFLite
conversion.error.UNSUPPORTED_FORMAT.suggestions → ["確認檔案副檔名","用標準 export 工具"]
conversion.error.INVALID_CHECKSUM.message → 檔案傳輸過程毀損,請重新上傳
conversion.error.INVALID_CHECKSUM.suggestions → ["重新開始上傳"]
conversion.error.QUANTIZATION_FAILED.message → 模型內含不支援的運算子,無法量化到目標晶片
conversion.error.QUANTIZATION_FAILED.suggestions → ["簡化模型結構","移除 Custom Op","改用較小的 input shape"]
conversion.error.MODEL_TOO_LARGE.message → 模型超過 500 MB 上限
conversion.error.MODEL_TOO_LARGE.suggestions → ["改用較小模型","嘗試 Pruning / Quantization"]
conversion.error.QUOTA_EXCEEDED.message → 系統暫時繁忙,請稍後再試
conversion.error.QUOTA_EXCEEDED.suggestions → ["等 5 分鐘後重試"]
conversion.error.unknown.message → 轉檔失敗,請稍後重試。若持續發生請聯絡支援團隊
conversion.error.unknown.suggestions → ["複製任務 ID 回報給支援團隊"]
```
### 11.10 已過期 / 找不到
```
conversion.expired.heading → 此轉檔結果已過期
conversion.expired.description → 轉檔結果保留期為 7 天,目前已超過保留期限並自動清除。
conversion.expired.startNew → 開始新轉檔
```
---
## 12. 無障礙
| 區塊 | 規格 |
|------|------|
| Sidebar 「轉檔」項目 | `aria-current="page"`(沿用 sidebar 規格)|
| EmptyState | `role="status"`(無互動結構,純宣告)|
| Upload Dialog | shadcn `Dialog` 內建 focus trap、ESC 關閉 |
| Drop zone | `role="button"` + `tabIndex=0` + `aria-label="拖曳或選擇模型檔案"`Enter / Space 觸發 file picker |
| Chip RadioGroup | `role="radiogroup"` + `aria-label="目標晶片"`;每個 chip `role="radio"` + `aria-checked` |
| 上傳進度 | `role="progressbar"` + `aria-valuenow={pct}`uploading`aria-busy="true"`processing indeterminate|
| Stage indicator | `<ol role="list">` + 每 stage `<li role="listitem">` + 當前 `aria-current="step"` + 完成 `aria-label="{name}(已完成)"` |
| Status banner / 錯誤 | `role="alert"` + `aria-live="assertive"`completed.failed |
| 成功 toast | sonner 已自帶 `role="status" aria-live="polite"` |
| Tab 標題更新 | `document.title` 變動時 SR 不會主動朗讀,但對視覺使用者足夠 |
| 觸控目標 | 兩個主要 action 按鈕高度 `h-10`40px符合 32px 桌面門檻 |
| Dark Mode | 所有色塊都用 token / Tailwind dark variant無 hardcoded hex |
**色彩對比驗證(手動 sample**
- success card`bg-green-50 text-foreground` → > 12:1OK
- failed card`bg-destructive/5 text-foreground` → > 10:1OK
- amber expiry hint`text-amber-700 on bg-card` → 5.7:1≥ AA 4.5:1
---
## 13. Phase 0.8 不做(明確列出)
對齊 PRD §5 Non-Goals本 wireframe **不設計**以下元素:
- ❌ 轉檔歷史清單(`/conversion/history`
- ❌ 取消正在跑的 job UI`processing` 畫面**沒有** 取消按鈕,只有 hint「離開沒關係」
- ❌ 多 chip 同時轉檔chip RadioGroup 是單選,不是 Checkbox
- ❌ SSE / WebSocket 進度推送(純 polling
- ❌ 進階參數FP16、自訂量化、batch size
- ❌ 模型版本管理 / A/B 比較
- ❌ 配額計費 UI
---
## 14. 對 PM / Architect 的補充建議
> (也整理在交付回報中,這裡放完整版)
### 給 PM
1. **「加到模型庫」是否需要 Dialog 確認名稱?** 我的建議是**保留**單欄位 Dialog只問模型名稱、預設帶 job name描述放 Phase 1。如果你想最簡可以直接靜默 import — 但這樣使用者沒辦法控制 `/models` 頁顯示的名稱。請決定。
2. **「開始新轉檔」的位置**:我把它放在 success 結果卡片**外面下方**,避免被誤點而離開結果頁;舊 job 的 import / download 按鈕仍在 result card 內可重複使用。建議 confirm。
3. **active job 檢查端點**`/conversion` 載入時要打 `GET /api/conversion/active`(或類似),這個端點 visionA backend 是否已規劃?如果沒有,可以用「使用者按開始時才打」的方式 fallback但體驗會差一點使用者切回頁面要先按按鈕才知道有 job
4. **錯誤訊息對照表**§8.1 的對照沿用 PRD §F5。如果 converter 未來新增 error code需要同步更新這張表建議在 backend 加 i18n fallback未知 code 一律用 `conversion.error.unknown.*`
### 給 Architect
1. **Active job 檢查 API**:建議加 `GET /api/conversion/active`,回 `{ active: bool, jobId?, status?, source_filename?, target_chip?, started_at? }`。沒這個 API 就要靠前端 store 或 localStorage 記 jobId會讓「換瀏覽器 / 換分頁」這個情境壞掉。
2. **Polling 對 backend 的負擔**visionA backend 中繼 polling 時510 秒/次/user記得 cache 個 23 秒避免 hammer converter。如果預期 10+ concurrent users這層 cache 必要。
3. **Upload streaming proxy 的進度**`XMLHttpRequest.upload.onprogress` 算的是 browser → visionA backend 的進度。如果 backend 是把 stream forward 到 converter前端進度條 100% 不代表 converter 收到 100% — 之間有 backend buffering 延遲。如果這個延遲明顯,建議 backend 在 forward 完成後才 200而不是 browser 上傳完就 200。請確認語意。
4. **Job 7 天 GC 提醒**success card 的「6 天 21 小時後清除」需要 backend 從 `job.expires_at` 算。如果 converter 不直接給 expires_at要 visionA backend 自行從 `created_at + 7d` 推算並回 frontend。
5. **延伸**未來如果要支援「使用者主動取消轉檔」Phase 1UI 位置我會放在 `processing` 畫面右上角的 menukebab— 但這個 wireframe 不畫,避免雛形階段引入複雜度。
### 給 Frontend
1. 「上傳中」分頁標題更新(`conversion.uploading.tabTitle`)需要 `document.title = ...` 動態改;上傳完成或頁面卸載要還原。建議寫個小 hook `usePageTitle(title)`
2. Indeterminate progress barshadcn `Progress` 沒有 indeterminate 樣式,需要自己加 CSS animation`animate-pulse` 或 stripe shimmer
3. `prefers-reduced-motion`indeterminate progress 與 spinner 都要尊重reduced-motion 下改用靜態 dot pattern 或純文字。
---
## 15. 對應的 Mermaid 流程圖
```mermaid
stateDiagram-v2
[*] --> idle
idle --> uploading : 點「開始轉檔」+ 選檔/選 chip + 送出
uploading --> idle : 取消 / 上傳失敗
uploading --> processing : XHR 200 + 拿到 job_id
processing --> success : poll 到 status=succeeded
processing --> failed : poll 到 status=failed
success --> idle : 點「開始新轉檔」
failed --> idle : 點「重新開始」
note right of processing
polling /api/conversion/{job_id}
每 510 秒一次
分頁不可見時暫停
end note
note right of idle
進入頁面先打 GET /jobs/active
若有 active 直接落 processing
end note
```

View File

@ -0,0 +1,741 @@
# Technical Design Document — visionA Cloud
## Metadata
- **作者**Architect Agent
- **狀態**Draft待三方交叉審閱
- **最後更新**2026-04-22反映三方交叉審閱 M-1~M-8 + Minor-2~Minor-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
- **Local agent 雛形不做**tunnel 測試端暫用 POC `edge-ai-server` 當 tunnel client
- **POC 程式碼複製**`internal/relay` / `internal/tunnel` / `internal/wsconn``edge-ai-platform` 複製後獨立演進
- **Auth 雛形**~~`StaticAuthService` 永遠回 `demo-user` 單一使用者~~ → **已替換為 OIDC**Phase 0.6 / OB5接 Innovedus Member Center詳見 [oidc-tdd.md](./oidc-tdd.md) + [adr-011](./adr/adr-011-supersede-adr-005.md)
- **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-spec.md)
- 內部 APIapi-server ↔ remote-proxy→ [`api/api-internal.md`](api/api-internal.md)
### 4. 資料模型
- Go struct 定義 + Phase 1 DB schema 對應 → [`database.md`](database.md)
### 5. Pairing Token 協定
- 詳細流程 + 雛形 vs Phase 1 差異 → [`security.md`](security.md#pairing-token)
### 6. Tunnel 協定
- 詳細訊息格式、重連策略 → [`tunnel.md`](tunnel.md)
### 7. Session 管理
- `SessionStore` interface + 實作策略 → [`tunnel.md`](tunnel.md#session-management)
### 8. 儲存層介面
- `Store` interface + LocalFS 實作 → [`storage.md`](storage.md)
### 9. Converter 整合 API 契約
- visionA-backend 呼叫 converter 的 spec給 converter 團隊)→ [`api/api-converter-contract.md`](api/api-converter-contract.md)
- **Phase 0.8 轉檔功能整合**visionA backend ↔ converter ↔ FAA ↔ MC 流程) → [`conversion.md`](conversion.md)
- Phase 0.8 對前端 API → [`api/api-conversion.md`](api/api-conversion.md)
- 架構決策 → [`adr/adr-014-conversion-integration.md`](adr/adr-014-conversion-integration.md)
### 10. 前端資料流與狀態管理
- 見 §10本文件
### 11. 建置與部署
- Makefile + Dockerfile + docker-compose → [`build-deploy.md`](build-deploy.md)
### 12. 安全考量
- 完整安全清單 → [`security.md`](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/ # 共用的 typesrequest/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-toolNext.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` |
```go
// service.gomiddleware 層 — 既有)
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.gohandler 層 — 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-userUser 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 }
```
**雛形實作**
- ~~`StaticAuthService`middleware永遠回 demo-user~~**OB5 已移除**AuthMiddleware 只走 OIDCcookie session → UserContext詳見 [oidc-tdd.md](./oidc-tdd.md)
- ~~`StaticAuthProvider`handlerLogin 任何帳密都通過~~**OB5 已移除**POST /api/auth/login 一律 410 Gone使用者改用 GET /api/auth/login redirect 到 Member Center
- `StaticPairingStore` 對 env `VISIONA_PAIRING_TOKEN` 比對(格式必須是 `vAc_` + 32 hex— 仍有效
**已完成**OB1-OB5OIDC 接 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 hexWeb UI 產生15 min TTL**一次性使用**
- **Session Token**`vAs_` + 64 hexagent 首次連 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`](tunnel.md#session-management)。核心 interface2026-04-22 Minor-4 新增 `CleanupExpired`
```go
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`](database.md)
- **repository.go**interface + InMemory 實作
- 雛形不做 ORMInMemory 用 `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`
```go
// 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`](api/api-converter-contract.md)。
### 2.8 `internal/storage`
見 [`storage.md`](storage.md)。
### 2.9 `internal/wsconn`
- 從 POC `server/pkg/wsconn/wsconn.go` 搬,無修改
- 作為 `net.Conn` adapter讓 yamux 能在 WebSocket 上運作
### 2.10 `internal/config`
```go
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`
```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 tokenpairing 是 backend 內部事)。
### 10.3 WebSocket Hook 改造(`src/hooks/use-websocket.ts`
```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
```ts
// 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`
1. 頁面顯示「請在本機 local-tool 輸入以下 Pairing Token」
2. 從 env `NEXT_PUBLIC_DEV_PAIRING_TOKEN`(雛形)或 `POST /api/pairing/token`Phase 1取得 token 並顯示
3. 右側顯示即時連線狀態(訂閱 `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
```ts
interface Device {
id: string;
name: string;
deviceType: string;
// tunnel-level雲端觀察
remoteStatus: 'online' | 'offline' | 'reconnecting' | 'error';
lastSeenAt?: string; // ISO 8601
lastConnectedAt?: string;
// USB-levellocal 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-servertunnel 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`LocalFS80% 行覆蓋
- `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
- E2EPlaywright 跑主要使用者旅程
- 壓測vegeta 打 api-server自製 tunnel client 模擬 N 條連線打 remote-proxy
- 混沌測試:隨機斷開 tunnel 驗證重連
---
## §14 TODO 清單(雛形暫不實作,但已記錄)
### Auth / Security
- [ ] 真實 AuthOIDC / 自建)
- [ ] JWT / refresh token 機制
- [ ] `/tunnel/connect` IP + token rate limit
- [ ] Pairing Token DB-backed 實作(`PostgresPairingStore`
- [ ] 兩階段 tokenpairing 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
### ScalingPhase 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 必做(為清楚起見明列)
- [x] 雙 binary 骨架:`cmd/api-server` + `cmd/remote-proxy`
- [x] `api-server``ProxyClientStore`SessionStore 透過 internal HTTP 查 remote-proxy
- [x] `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 pipelinelint, 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 |

View File

@ -0,0 +1,96 @@
# ADR-001visionA-backend 採用單一 Go 專案 / 雙 binary 結構
## 狀態
Accepted — 2026-04-21
## 背景 (Context)
visionA Cloud 後端需同時提供兩種完全不同性質的服務:
1. **API Server**(對 visionA-frontend
- 面向瀏覽器,提供 REST + WebSocket 介面
- **無狀態**:水平擴展容易,可放在無狀態的容器 / Serverless 執行環境
- 流量特性:一般 request/response延遲敏感< 200ms
- 負載模式:可預測,隨 DAU 成長線性增加
2. **Remote Proxy**(對使用者端的 local-tool 或 local agent
- 面向 local agent提供 WebSocket 長連線 + yamux 多工
- **有狀態**:每個 local agent 開一條 tunnelsession 綁在該 proxy 節點
- 流量特性:長連線(小時~天)、資料量差異大(控制指令 vs MJPEG / 推論串流)
- 負載模式:長連線 cost 主要在記憶體和 file descriptor不是 CPU
POCedge-ai-platform已經將 `edge-ai-server``relay-server` 分拆成兩個 binary但兩者位於不同的 module / 不同的 cmd 路徑,共用程式碼是透過 import 來達成。
## 決策 (Decision)
visionA-backend 採用**一個 Go module、兩個 binary**的結構:
```
visionA-backend/
├── go.mod # module "visiona-backend"
├── cmd/
│ ├── api-server/main.go # 對前端的 REST + WebSocket API
│ └── remote-proxy/main.go # 對 local agent 的 tunnel server
└── internal/ # 兩個 binary 共用
├── api/
├── session/
├── relay/
├── tunnel/
├── storage/
└── ...
```
- 兩個 binary 使用**同一個 go.mod**,可直接共用 `internal/` 下的套件
- 各自有獨立的 `cmd/xxx/main.go`,選擇要載入哪些模組
- 共享狀態session metadata抽象為 `internal/session` 的 interface
- `remote-proxy` 端用 `InMemoryStore`(真正持有 `*yamux.Session`
- `api-server` 端用 `ProxyClientStore`(透過 internal HTTP 查 `remote-proxy`,無本地 state
- **雛形 Phase 0 就採雙 binary + internal HTTP**2026-04-22 Q1 裁決)—— 不做 all-in-one 單進程版本,因為那會讓 `api-server` 有狀態、無法驗證真正的部署拓撲
- **不引入 Redis**POC 也沒用過;見 ADR-006
## 考慮過的替代方案
| 方案 | 優點 | 缺點 | 排除原因 |
|------|------|------|---------|
| **單 binaryAPI + Proxy 同一進程)** | 最簡單session 直接記憶體共用 | 無狀態 API 被迫與有狀態 Proxy 綁死API 無法獨立水平擴展;擴容成本與 tunnel 連線耦合 | 無法水平擴展 APIProduction 不可行 |
| **雙 module兩個獨立 Go 專案)** | 完全隔離 | 共用程式碼types、protocol、session interface要拆到第三個 module 或手動複製 | 維護兩倍 go.mod共用改動成本高POC 已走過這條路 |
| **三 binaryAPI + Proxy + Worker** | 關注點更分離 | 雛形階段沒有 worker workload過度設計 | 雛形用不到 |
| **單 binary + feature flag 決定角色** | 部署單一 image啟動時決定角色 | `-mode=api` vs `-mode=proxy` 會讓 main 變成 if/else 分派binary 含不必要依賴 | 編譯時分開更乾淨 |
| **雙 module + 共用 shared module** | 完全隔離 + 有共用 | 三個 Git repo / module 要同步版本CI 複雜 | 雛形階段不需要 |
## 後果 (Consequences)
### 正面影響
- **部署獨立**API Server 可放在無狀態的容器集群(如 ECS Fargate / Cloud RunRemote Proxy 放在支援長連線的 stateful 環境
- **擴展獨立**API 隨流量 auto-scaleProxy 隨 tunnel 連線數 scale兩者擴容邏輯不同
- **Release 獨立**API 新功能不需要重啟 Proxy 進程(保持 tunnel 不中斷)
- **共用方便**types、protocol、session interface 直接放 `internal/`,編譯時檢查
- **CI / Docker 簡單**:兩個 Dockerfile同一個 go.mod依賴管理單一
### 負面影響(接受的取捨)
- **雛形就有 internal HTTP hop**`api-server → remote-proxy` 多一次 HTTP 呼叫。localhost ~0.1ms,跨機 LAN ~1-5ms。接受此成本換取「雛形部署拓撲 = Production 部署拓撲」
- **觀測稍複雜**:兩個 binary 的 metrics / logs 要分別收集後 join
- **本機開發要開兩個進程**:用 `docker-compose``Makefile dev` target 同時啟動兩者
### 風險
- **session 查詢延遲**:雛形 localhost internal HTTP 呼叫(~0.1ms可忽略Phase 1 跨機 LAN ~1-5ms/次
- **remote-proxy 是雛形有狀態單點**`remote-proxy` process 重啟 → 所有 tunnel session 遺失,使用者需重新 pair見 ADR-006
- **多節點路由複雜度Phase 1**:當有 N 個 remote-proxy 節點時api-server 需要能找到「某個 token 的 tunnel 在哪個 proxy」——`tunnel.md` §5.4 已列出候選方案,屆時新增 ADR
- **Phase 1 session metadata 共享機制未定****雛形不預先決定** Redis / Consul / Gossip避免過早最佳化見 ADR-006
## 合規性
- [x] 與 Architect 評估確認
- [x] 與 PM 確認對業務的影響(部署形態)
- [x] 使用者 Q1 裁決 C雛形就用雙 binary + internal HTTP2026-04-22
- [ ] 成本影響已評估Phase 1 多節點時 session metadata 共享方案定案後再估)
## 相關文件
- Design Doc §2系統邊界與組件
- Design Doc §4水平擴展策略
- TDD §1專案骨架、§2.3session 模組、§7雛形啟動
- ADR-006雛形不引入 Redis
- `api/api-internal.md`internal HTTP API 規格)

View File

@ -0,0 +1,91 @@
# ADR-002沿用 POC 的 Tunnel 協定WebSocket + yamux
## 狀態
Accepted — 2026-04-21
## 背景 (Context)
visionA Cloud 最核心的技術要素是:**瀏覽器透過雲端反向代理,操作使用者本地電腦上的 Kneron 裝置**。
這要求在「雲端 Proxy」與「使用者電腦的 local agent」之間建立一條
- **由 local agent 主動出站建立**(穿越 NAT / 企業防火牆)
- **雙向多工**:同時傳遞 HTTP 控制指令、長輪詢 WebSocket、MJPEG 串流、推論結果串流
- **低延遲**:串流資料需保留 real-time 特性
- **可重連**:網路短暫斷線後自動恢復
POCedge-ai-platform已用 **WebSocket + hashicorp/yamux** 實作並驗證可行:
- 外層 `gorilla/websocket` 負責「由 client 發起、能穿越企業代理」
- 內層 `yamux` 把單條 WebSocket 升級為多工 stream每個 HTTP 請求開一條 yamux stream
- WebSocket binary frame 之上用 `wsconn` adapter 把 WebSocket 包成 `net.Conn`
POC 源碼重點:
- `relay/server.go`:收 `/tunnel/connect`、建立 `yamux.Server(netConn)`、按 token 收在 `map[token]*yamux.Session`
- `tunnel/client.go`Dial WebSocket → `yamux.Client()` → 接收 stream → 轉給本機 `127.0.0.1:3721`
- `pkg/wsconn/wsconn.go``websocket.Conn``net.Conn` adapterbinary frame
## 決策 (Decision)
**雛形沿用 POC 的 tunnel 協定不做重大改變**
- 外層:`github.com/gorilla/websocket`version ≥ 1.5.3
- 多工:`github.com/hashicorp/yamux`default config
- Adapter把 POC 的 `wsconn` 搬到 `visionA-backend/internal/wsconn`(或 `pkg/wsconn`,視是否對外暴露)
- 端點:`WS /tunnel/connect?token=xxx`remote-proxy 側)
- 訊息語義:沿用 POC 的「yamux stream 裡傳完整 HTTP request/response」
**沿用的好處:代碼已在 POC 驗證過能穿越 NAT、能處理 WebSocket upgrade`proxyWebSocket` 的 Hijack 邏輯)、能處理 MJPEG streaming。**
**雛形會升級的部分(與 ADR-003 相關)**
- Token 生成機制SHA256(MAC) → Pairing Token詳見 ADR-003
- `handleTunnel` 加上 token 驗證 hook雛形驗 env 寫死 token未來驗 DB
- Session 管理抽 interface雛形 in-memory未來 Redis詳見 ADR-001
**未來可能會重新評估的項目(非雛形範疇)**
- 若 stream 吞吐或 head-of-line blocking 成瓶頸 → 評估 HTTP/3 (QUIC) 或 gRPC bidi stream
- 若需要端對端加密(繞過雲端看到明文)→ 在 yamux 之上再包一層 TLS / Noise
- 若需要跨雲的地理就近路由 → 加入 proxy routing 層,由 local agent 選擇 entrypoint
## 考慮過的替代方案
| 方案 | 優點 | 缺點 | 排除原因 |
|------|------|------|---------|
| **重頭設計 gRPC bidi stream** | 現代 RPC有 code-gen、型別安全 | 要重新定義所有訊息 schemaPOC 已用 HTTP semantics 讓搬遷成本低 | 雛形期不值得,未來可升級 |
| **HTTP/3 (QUIC) 原生多工** | 未來標準、天生多工、零 HOL blocking | Go 生態的 QUIC 客戶端 / 伺服器還在 beta反向代理成熟度低 | 還太新 |
| **單一 WebSocket 無多工(每請求新開 WS** | 最簡單 | 每請求一次 WS handshake~50-200ms串流無法疊加 | 效能差 |
| **SSH tunnel / WireGuard 等 VPN 方案** | 標準 | 需要使用者端安裝額外軟體、開特定埠、難穿越企業防火牆 | 使用者體驗差 |
| **純 TCP tunnel over TLS** | 簡單 | 通常被企業防火牆封鎖WebSocket 因為外觀像 HTTP 能穿透 | 穿透力差 |
## 後果 (Consequences)
### 正面影響
- **零移植成本**POC relay / tunnel 程式碼可直接搬到 `visionA-backend/internal/relay``visionA-backend/internal/tunnel`
- **已驗證 NAT 穿透**POC 測試過能穿越公司 NAT、家用路由器 NAT
- **已驗證 WebSocket-in-WebSocket**:瀏覽器 → Proxy → tunnel stream → local agent 的 WebSocket upgrade 流程POC 的 `proxyWebSocket` 已處理 Hijack 並雙向 pipe這個最難的部分已經能跑
- **已驗證 MJPEG streaming**POC 的 `handleProxy``http.Flusher` 處理串流回應visionA 的 camera stream 可直接用
- **擴展空間大**yamux 天生多工,一個 tunnel 可以同時跑 10+ 個 HTTP 請求不互相阻塞
### 負面影響(接受的取捨)
- **訊息格式是 HTTP 明文**yamux stream 內跑的是 raw HTTP request/response需靠 TLSWebSocket 外層保密Proxy 節點可以看到明文請求內容
- **yamux 依賴**hashicorp/yamux 已久無大版本更新,但 v0.x 穩定
- **單 WebSocket = 單 tunnel**:一個 local agent 一條 WebSocket若要跨地理區域 active-active需應用層路由
- **HOL blocking 風險低但存在**yamux 是多工,但底層單一 TCP connection若網路抖動整條都延遲
### 風險
- **WebSocket 升級失敗率**:企業防火牆 / 中間人代理可能阻擋 WebSocket upgrade。雛形先不處理 fallbackSSE / long-pollTODO
- **單條 WebSocket 頻寬極限**:若使用者端上傳 MJPEG 4K 60fps 會打滿一條 TCP。實測後若有問題再引入「多條 WebSocket + 分流」
- **yamux 的 keepalive**POC 用 default config是否足夠穿越企業 idle timeoutTODO 壓測驗證
## 合規性
- [x] Architect 確認
- [x] POC 實測驗證(`edge-ai-platform` 已部署到 EC2 + 本地測試)
- [ ] 未來壓測TODO
## 相關文件
- Design Doc §3資料流
- TDD §6Tunnel 協定)
- TDD §7Session 管理)
- POC 源碼:`edge-ai-platform/server/internal/{relay,tunnel}``edge-ai-platform/server/pkg/wsconn`

View File

@ -0,0 +1,191 @@
# ADR-003以 Pairing Token 取代 POC 的 SHA256(MAC) Token
## 狀態
Accepted — 2026-04-212026-04-22 修訂:明確兩階段 Token 流程 + 固定格式)
## 背景 (Context)
POC 的 tunnel 認證機制非常簡單:
- local agent 啟動時,計算 `SHA256(MAC address)[:16]`,得到 16 字元 hex 當 token
- 用 `?token=xxx``X-Relay-Token` header 連線 relay
- relay 以 `map[token]*yamux.Session` 記憶體儲存,**無任何驗證**——任何人拿到 token 就能連上並註冊 session
這個設計在 POC 階段可接受,但不適合正式產品:
| POC 現況 | 問題 |
|---------|------|
| Token = MAC 衍生值,**永不變** | 洩漏後無法撤銷;換電腦 token 也跟著換(身分無法追蹤) |
| 無 expiry | 一次洩漏,永久曝險 |
| 無 ownership | Token 沒綁到「使用者」概念,無法實現「我的裝置列表」 |
| 無 revoke | 沒有「登出所有裝置」能力 |
| 無 rotation | 無法定期輪換降低風險 |
| Token == Identity | Token 本身即是身分,無法與 User Account 解耦 |
正式產品的雲端版需要:
- 綁定到「某個使用者帳號」
- 可撤銷
- 有 expiry過期要 re-pair
- 可同一使用者有多台裝置(每台一個獨立 token
- 可由使用者介面 list / revoke
## 決策 (Decision)
引入 **兩階段 Token** 機制:**Pairing Token短期一次性** → 換取 **Session Token長期可撤銷**。其生命週期如下:
### Token 格式統一規範2026-04-22 修訂 M-1
```
Pairing TokenvAc_ + 32 字元 hex (總長 36 字元Admin-Credential
Session TokenvAs_ + 64 字元 hex (總長 68 字元Agent-Session
```
- 字元集:`[0-9a-f]` 小寫 hex前綴 `vAc_` / `vAs_`
- 產生方式:`crypto/rand.Read(16 bytes)``hex.EncodeToString(...)`pairing`crypto/rand.Read(32 bytes)`session
- **API 回傳值永遠是純字串,無空格、無分隔符**
- UI 顯示層可視情況每 8 字元插入空格提升可讀性(例:`vAc_a1b2c3d4 e5f6g7h8 ...`
- 使用者貼上時前端正規化 `.replace(/\s/g, '')`
- 前綴讓 log / debug 一眼看出類型;正則驗證:`^vAc_[0-9a-f]{32}$` / `^vAs_[0-9a-f]{64}$`
### 兩階段流程Phase 1 完整;雛形簡化)
#### Stage 1 — Pairing Token短期一次性15 分鐘 TTL
1. 使用者在雲端 Web 登入(`visionA-frontend`
2. 進入「Devices → Pair New Device」→ 前端呼叫 `POST /api/pairing/token`
3. `api-server` 產生 pairing token`vAc_` + 32 hex
- 寫入 DB`token_hash = sha256(plain)`, `kind='pairing'`, `expires_at = now + 15min`, `used_at = NULL`
- 回傳 token 明文(**只在此次回應中出現一次**
4. 使用者把 token 貼到 local agent 的設定 UI / config / CLI flag
5. local agent 持有此 **Pairing Token**,準備建立第一次連線
#### Stage 2 — Session Token長期90 天 TTL可撤銷
6. local agent 首次用 Pairing Token 連 `WS /tunnel/connect?token=vAc_xxx`
7. `remote-proxy`(透過 `api-server` 內部呼叫)處理:
- 查 DBtoken 是否 `kind='pairing'` 且未撤銷、未過期、未使用
- 有效 → **原子升級**
- 產生 Session Token`vAs_` + 64 hex
- 寫入新 DB row`kind='session'`, `parent_token = <pairing token_hash>`, `device_id = <new or resolved>`, `expires_at = now + 90d`
- 將 Pairing Token 標記 `used_at = now()`(作廢;無法再次升級)
- **在 WS upgrade response header****首個 yamux control frame** 回傳 Session Token 明文
- 建立 yamux session`SessionStore.Register(session_token_hash, handle)`
- 無效 → 拒絕並回 HTTP 401
8. local agent 收到 Session Token 後**持久化儲存**(取代 Pairing Token之後重連一律用 Session Token
9. Session Token 過期、被撤銷 → local agent 連線被拒 → 使用者需在 Web UI 重新發 Pairing Token 走一次 Stage 1
#### 狀態轉換時序Phase 1
```
使用者 Web UI api-server local agent remote-proxy / DB
│ │ │ │
│─ Pair New Device ────►│ │ │
│ │─ INSERT pairing ──►│ │
│ │ kind='pairing' │ │
│ │ TTL 15 min │ │
│◄── vAc_xxx (once) ────│ │ │
│ │
│─ 複製貼到 agent ────────────────────────────►│ │
│ │─ WS /tunnel/connect ─►│
│ │ ?token=vAc_xxx │
│ │ │─ Validate(pairing)
│ │ │─ 產生 vAs_yyy
│ │ │─ INSERT session
│ │ │ kind='session'
│ │ │ TTL 90 day
│ │ │─ mark pairing used
│ │◄─ upgrade + vAs_yyy ──│
│ │─ 持久化 vAs_yyy │
│ │ │
│ │─ 日後重連always ──►│
│ │ ?token=vAs_yyy │
│ │◄── 直接接受 ──────────│
│ │ │
│─ Revoke device ──────►│─ UPDATE session ──────────────────────────►│
│ │ revoked_at = now │
│ │◄── 下次連線 401 ──────│
```
10. 使用者可在「Devices」頁面 list / revoke 任一 device 的 Session Token`UPDATE revoked_at`
### 雛形版(當前要做的)
雛形**不接 DB / 不接 Auth**,但**要把介面切好**,未來替換實作即可:
```go
// internal/auth/pairing.go
type PairingStore interface {
Validate(ctx, token) (*PairingInfo, error)
MarkUsed(ctx, token, deviceID) error
Create(ctx, userID) (string, *PairingInfo, error)
Revoke(ctx, token) error
List(ctx, userID) ([]*PairingInfo, error)
}
type PairingInfo struct {
TokenHash string
UserID string
DeviceID string
CreatedAt time.Time
ExpiresAt *time.Time
UsedAt *time.Time
RevokedAt *time.Time
}
```
**雛形實作 `StaticPairingStore`**
- 從 env var 讀一組寫死的 token + 一個假 user_id`VISIONA_PAIRING_TOKEN=abc123...``VISIONA_PAIRING_USER_ID=demo-user`
- `Validate` 簡單比對;`Create/Revoke/List``ErrNotImplemented`
- local agent 啟動 config 寫這個 token或 CLI flag `--pairing-token=xxx`
未來替換為 `DBPairingStore`其他程式碼relay session、API handler完全不改。
### 雛形 vs Phase 1 的單/雙階段處理2026-04-22 修訂 M-2
| 階段 | 做法 |
|------|------|
| **雛形Phase 0** | 為簡化開發,**以單一 Pairing Token 形式比對**,不做升級步驟。`StaticPairingStore` 讀取 `VISIONA_PAIRING_TOKEN` env格式仍為 `vAc_` + 32 hexagent 直接用它連 tunnel。**無 TTL、無升級、無撤銷**。這是刻意妥協以加速驗證。 |
| **Phase 1** | **完整兩階段**見「兩階段流程」Pairing Token 15 min TTL + Session Token 90 d TTL + 撤銷機制 |
**雛形限制須明確告知使用者**:雛形的單一 token 等同「永不過期的 pairing + session 合併體」,僅適合 dev 環境。Phase 1 前**必須**切到兩階段。
## 考慮過的替代方案
| 方案 | 優點 | 缺點 | 排除原因 |
|------|------|------|---------|
| **沿用 SHA256(MAC)** | POC 已驗證 | 不安全、不可撤銷、無 ownership | 無法做成產品 |
| **mTLS 憑證client certificate** | 業界標準、極安全 | 憑證管理複雜、使用者端安裝門檻高、Web 產生憑證 UX 差 | 使用者體驗門檻過高 |
| **OAuth Device Flow** | 標準化、適合 headless | 需外部 IdPAuth0 / Cognito、實作複雜 | Phase 2 可以評估,雛形過度複雜 |
| **短期 JWT + refresh token** | 標準做法 | JWT 無法撤銷除非建黑名單refresh token 又變另一個長期 token | 仍需處理 revoke不如直接用 opaque token |
| **使用者密碼 / API key** | 簡單 | 無法限定 scope、難以細粒度撤銷 | 粗糙 |
## 後果 (Consequences)
### 正面影響
- **抽象清晰**`PairingStore` interface 把 token 驗證與 tunnel session 管理解耦
- **雛形到 Phase 1 零程式碼修改**:只換 store 實作
- **未來支援多種 token 形態**:無論是 JWT、opaque token、還是 DB-backed session token都實作同一個 interface
### 負面影響(接受的取捨)
- **雛形安全性差**env 寫死 token任何能讀 env 的人都能冒充。可以接受,因為雛形只會在開發環境跑
- **沒有 rate limit**雛形不做Phase 1 要做 `/tunnel/connect` 的 IP / token rate limit
- **無 logging of failed attempts**:雛形只記 error logPhase 1 要記到 audit log
### 風險
- **Token 儲存明文 vs hash**:產品版要把 `token_hash = sha256(token)` 存 DB不存明文。雛形 env 暫用明文
- **Token 透過 URL query 傳遞會進 access log**POC 已這樣做雛形保留Phase 1 評估改用 `Authorization: Bearer xxx` headerWebSocket 自訂 header 瀏覽器有限制,但 local agent 可以)
- **如果使用者把 token 貼到 public repo**:產品版需支援「自動撤銷洩漏 token」secret scanning雛形不處理
## 合規性
- [x] Architect 確認
- [ ] Security ReviewPhase 1 前必做
- [ ] 加入 `.gitignore` 保證 token 不進 Git工程師實作時落實
## 相關文件
- Design Doc §6安全架構
- TDD §5Pairing Token 協定)
- TDD `security.md`
- 相關 ADRADR-002Tunnel Protocol、ADR-005雛形不接 DB/Auth

View File

@ -0,0 +1,145 @@
# ADR-004模型儲存採用 S3-Compatible 介面
## 狀態
Accepted — 2026-04-21
## 背景 (Context)
visionA Cloud 需要儲存:
1. **使用者上傳的模型檔案**`.nef``.onnx` 等,幾 MB 幾百 MB
2. **模型 metadata**JSON幾 KB
3. **(未來)轉檔產物**kneron_model_converter 產生的 `.nef`
4. **未來推論紀錄、snapshots、截圖**
POCedge-ai-platform用本地檔案系統 + in-memory registry。local-tool 也一樣。這在單機環境 OK雲端不行
| 問題 | 本地檔案系統 | S3-compatible |
|------|------------|--------------|
| 多節點共用 | ❌ 各節點看不到彼此 | ✅ 共享 bucket |
| 持久性 | 單機磁碟壞 = 資料沒 | ✅ 11 個 9 的設計 |
| 容量彈性 | 單機磁碟有上限 | ✅ 幾乎無上限 |
| 成本效益 | 計算節點帶容量 = 貴 | 冷熱分層、便宜 |
| 直接給前端下載 | 要透過應用伺服器 | ✅ Presigned URL 直給 |
| Multipart upload | 自己實作 | ✅ 標準 |
同時,雛形階段不想立刻接真實 S3 / MinIO需架設、需 credentials、增加 setup 成本)。
## 決策 (Decision)
`internal/storage` 定義 **`Store` interface**,以 S3 的語義為基礎:
```go
package storage
import (
"context"
"io"
"time"
)
// Store 是模型 / 檔案儲存的抽象層。語義對齊 S3。
type Store interface {
// Put 上傳一個物件。size <0 表示未知大小streaming
Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error
// Get 下載一個物件,回傳 readercaller 要負責 Close
Get(ctx context.Context, key string) (io.ReadCloser, *ObjectInfo, error)
// Stat 取得物件資訊但不下載 body。
Stat(ctx context.Context, key string) (*ObjectInfo, error)
// Delete 刪除一個物件。
Delete(ctx context.Context, key string) error
// List 列出 prefix 下的物件。
List(ctx context.Context, prefix string) ([]*ObjectInfo, error)
// PresignedGetURL 產生讓使用者直接下載的簽名 URL。
PresignedGetURL(ctx context.Context, key string, ttl time.Duration) (string, error)
// PresignedPutURL 產生讓使用者直接上傳的簽名 URL。
PresignedPutURL(ctx context.Context, key string, ttl time.Duration) (string, error)
}
type ObjectInfo struct {
Key string
Size int64
ContentType string
LastModified time.Time
ETag string
Metadata map[string]string
}
```
### 雛形提供兩個實作
1. **`LocalFSStore`**:把物件存在本地 `data/storage/`
- Key 對應檔案路徑(`models/user-123/yolov5.nef``data/storage/models/user-123/yolov5.nef`
- Metadata 存在同名 `.meta.json`
- `PresignedGetURL` / `PresignedPutURL``http://localhost:PORT/storage/...` 這種由 api-server 代理的 URL雛形的「假簽名」
- 啟動最快,開發者零成本
2. **`S3Store`**(可選,雛形後期加):用 `aws-sdk-go-v2`
- 連真實 AWS S3、MinIO、Cloudflare R2、Backblaze B2 等
- `PresignedGetURL` / `PresignedPutURL` 是真的 S3 presigned URL
- 需要 `S3_ENDPOINT``S3_BUCKET``S3_REGION``S3_ACCESS_KEY``S3_SECRET_KEY` env
### 選擇由 config 決定
```yaml
storage:
backend: localfs # or "s3"
localfs:
root: ./data/storage
base_url: http://localhost:3001/storage # api-server 代理路徑
s3:
endpoint: "" # 空字串用 AWS 原生;填 MinIO / R2 等用自訂
bucket: visiona-models
region: us-east-1
access_key: ${S3_ACCESS_KEY}
secret_key: ${S3_SECRET_KEY}
```
## 考慮過的替代方案
| 方案 | 優點 | 缺點 | 排除原因 |
|------|------|------|---------|
| **直接用 AWS SDK 呼叫 S3** | 簡單 | 綁定 AWSMinIO / R2 要 workaround | 綁定單一雲 |
| **用 MinIO SDK** | 輕量 | MinIO SDK 寫的 code 去接 AWS S3 要微調;綁定特定 SDK | 換雲成本 |
| **自己做儲存服務** | 完全掌控 | 重新發明輪子;費時;達不到 S3 等級的持久性 | 不值得 |
| **資料庫存 blob** | 交易一致性 | DB 不適合儲存大檔;昂貴 | 技術錯配 |
| **只用 LocalFS 不抽象** | 簡單 | 未來替換 = 重寫所有 model handler | 違反抽象原則 |
## 後果 (Consequences)
### 正面影響
- **雛形零 setup**`docker-compose up` 直接跑,不需 S3 / MinIO
- **切雲端零改動**:換 config 的 `backend: s3` 即可
- **支援多家雲**endpoint 可指向 AWS / Cloudflare R2 / Backblaze B2 / MinIO / 自建 Ceph 等
- **介面 S3-native**:未來加 multipart upload、server-side encryption 等 S3 進階特性無需變介面
### 負面影響(接受的取捨)
- **`PresignedGetURL` 在 LocalFS 是假的**:需 api-server 提供 `/storage/...` 代理端點。這段代理程式碼要寫,但不複雜
- **LocalFS 不支援多節點**雛形階段只有單節點可接受Phase 1 遷到 S3 後自動解決
- **Interface 比純 SDK 包裝更抽象**:略多一層呼叫,效能無感(本地 disk IO 遠大於 interface method dispatch
### 風險
- **LocalFS 的 metadata 靠 sidecar `.meta.json`**不是原子的雛形忽略Phase 1 改 S3 自然原子
- **PresignedPutURL 在 LocalFS 實作要處理 CSRF / auth**雛形可暫時接受裸上傳Phase 1 上 S3 後由 S3 presigned signature 保護
- **大檔上傳 OOM**`Put` 使用 `io.Reader` 已是 streamingLocalFS 實作用 `io.Copy` 避免 buffer 整個 body需要 code review 把關
## 合規性
- [x] Architect 確認
- [ ] 雛形測試:確保 `LocalFSStore` 通過 interface conformance tests
- [ ] Phase 1確保 `S3Store` 通過相同 tests
- [ ] 加入 `.gitignore``data/storage/` 不進 Git
## 相關文件
- Design Doc §3.3(外部依賴)
- TDD §8儲存層介面
- 相關 ADRADR-005雛形不接 DB / Auth

View File

@ -0,0 +1,142 @@
# ADR-005雛形階段不接真實 DB 與 Auth
## 狀態
**Auth 部分Superseded by [ADR-011](./adr-011-supersede-adr-005.md)2026-04-26**
DB 部分Accepted — 2026-04-21仍有效雛形階段持續用 In-memory Repository
## Update2026-04-26
本 ADR 的 **Auth 部分已被 [ADR-011](./adr-011-supersede-adr-005.md) 推翻**
Phase 0.6 階段決定提早接 Innovedus Member Center 做 OIDC 登入:
- `StaticAuthProvider` / `StaticAuthService` 已從 codebase 移除OB5
- 唯一的認證路徑是 OAuth 2.0 Authorization Code + PKCE + BFF Pattern
- 詳見 [ADR-010](./adr-010-oidc-bff.md)(接入策略)+ [ADR-011](./adr-011-supersede-adr-005.md)(推翻決策)+ [oidc-tdd.md](../oidc-tdd.md)(實作細節)
**DB 部分仍有效**:雛形階段持續用 InMemoryRepositoryDevice / Model / PairingToken
Phase 1 才換 PostgresRepository。Repository interface 抽象維持不變。
下方原文保留作為決策歷史記錄。
---
## 背景 (Context)
visionA Cloud 最終會是多用戶雲端服務,必然需要:
- **使用者資料庫**User、Organization、Role、Subscription 等
- **業務資料庫**Device、Model、Cluster、InferenceJob、PairingToken 的持久化
- **Auth 機制**登入、session、RBAC、OAuth、SSO 等
但**雛形階段**`prototype / v0.1`)的目標是:
- 驗證「雲端端對端連通」**技術可行性**
- 建立完整專案骨架,讓後續開發者能快速上手
- 驗證 tunnel 協定在雲端部署下穩定性
如果這個階段就要接 PostgreSQL、跑 migration、接 Auth provider**會消耗大量時間在「不是核心驗證」的事情上**,而且錯誤的 DB schema 決策會在後面花更多成本改。
## 決策 (Decision)
雛形階段 **不接真實 DB、不接真實 Auth**,但**所有需要它們的地方都必須定義 interface**,未來替換實作即可,不改上層程式碼。
### 具體做法
#### 1. 不接真實 DB
- **實體 Go struct 先定義好**`internal/device/types.go``internal/model/types.go``internal/user/types.go` 等)
- 用「Repository interface」抽象資料存取
```go
type DeviceRepository interface {
Get(ctx, id) (*Device, error)
List(ctx, userID) ([]*Device, error)
Save(ctx, *Device) error
Delete(ctx, id) error
}
```
- 雛形實作:**`InMemoryDeviceRepository`**`map[id]*Device + sync.RWMutex`
- Phase 1 實作:`PostgresDeviceRepository`(換實作,上層無感)
- **struct 欄位一次設計到位**,包含未來會用到的欄位(`owner_user_id``created_at``updated_at``deleted_at`)——**雛形程式碼可以不填**,但欄位要在;未來加 DB schema 時直接映射
#### 2. 不接真實 Auth
- 定義 `AuthService` interface
```go
type AuthService interface {
Authenticate(ctx, req) (*UserContext, error) // 從 request 取出使用者身分
Authorize(ctx, userCtx, resource, action) error
}
type UserContext struct {
UserID string
Roles []string
OrgID string
}
```
- 雛形實作:**`StaticAuthService`**Q4 裁決 C 確認)
- 永遠回傳 `UserContext{UserID: "demo-user", Roles: []string{"admin"}}`
- `Authorize` 永遠回 `nil`
- **單一使用者** — 雛形所有操作在同一個 demo-user 底下,不處理多用戶情境
- 前端 `auth-store` 也 seed 同一個 demo-user不顯示登入頁或顯示但不能真正登入
- Middleware 在 api-server 套用:`authMiddleware``UserContext``gin.Context`
- Handler 程式碼用 `userCtx := auth.FromContext(c)` 取,看起來跟有 Auth 時一樣
- 登入 / 註冊 API endpoints **存在**`/api/auth/login``/api/auth/register`)但**回 `501 Not Implemented`**
#### 3. Pairing Token 也用這個模式
見 ADR-003`PairingStore` interface + `StaticPairingStore`env 寫死)實作。
### 為什麼「雛形」要花時間做 interface不直接 hardcode
- **替換成本**:現在 hardcode未來要接 DB 時要改的程式碼會散落在各 handler改動範圍不可控
- **API 契約**interface 強迫我們先思考「這個模組對外提供什麼能力」,避免 handler 直接摸 map
- **測試友善**:有 interface 才能注入 mock 做單元測試
- **成本極低**interface + in-memory 實作 = 多 20 分鐘,可忽略
## 考慮過的替代方案
| 方案 | 優點 | 缺點 | 排除原因 |
|------|------|------|---------|
| **雛形就接 PostgreSQL** | 早期驗證 schema | 雛形期 schema 一定會改migration 維護成本setup 慢 | 過早優化 |
| **雛形就接 SQLite** | 比 PG 輕 | 仍然要寫 migration跨節點不能用跟 PG SQL 方言不完全相容 | 跟最終架構不符 |
| **雛形 hardcode 無 interface** | 最快 | 未來接 DB 要改所有 handler | 技術債太重 |
| **雛形接 Firebase / Supabase** | 現成 | 綁定 vendor改 Go 架構的機會喪失 | 不符合 cloud-agnostic 原則 |
## 後果 (Consequences)
### 正面影響
- **開發極速**pull repo → `docker-compose up` → 立刻能跑,零外部依賴
- **Phase 1 切換無痛**:實作新的 Repository / Auth`main.go` 的 wiring 即可
- **測試友善**in-memory 實作本身就適合當 test fixture
- **文件先行**interface = 可讀的契約
### 負面影響(接受的取捨)
- **資料重啟會遺失**:雛形 process 重啟 → 所有 Device / Model 資料消失
- 緩解:`LocalFSStore` 保留模型檔案metadata 用 JSON 落盤(`data/state/*.json`)當備援
- 緩解seed 腳本快速重建測試資料
- **無真實多用戶**:所有操作都在 `demo-user` 底下
- 緩解程式碼結構已按多用戶設計Phase 1 替換 `StaticAuthService` 即可
- **Pairing Token 只能一組**:雛形 env 寫死
- 緩解dev 期間夠用Phase 1 替換 `StaticPairingStore`
### 風險
- **interface 設計不當 = 技術債**:設計不良的 interface漏了 ctx、漏了 pagination到 Phase 1 還是要重寫。緩解:交叉審閱把關、參考 POC 經驗
- **「暫時」會變成「永久」**:雛形 stub 的東西不接 = 永遠不接。緩解:把每個 stub 列入 TDD 的 TODO 清單明確追蹤Phase 1 的第一個 milestone 就是接 DB + Auth
- **無法測試某些場景**例如「token 過期」、「user 跨 org」雛形測不到Phase 1 再補
## 合規性
- [x] Architect 確認
- [x] 所有 TODO 記錄在 TDD §TODO 清單
- [x] 使用者 Q4 裁決 C雛形 `StaticAuthService` 永遠回 demo-user 單用戶2026-04-22
- [ ] Phase 1 Kick-off第一個 ticket 是「接真實 Auth + DB」
## 相關文件
- Design Doc §112-Factor 合規策略)
- TDD §2Backend 模組詳細)
- TDD §4資料模型
- 相關 ADRADR-003Pairing Token、ADR-004Storage Interface

View File

@ -0,0 +1,123 @@
# ADR-006雛形階段不引入 Redis
## 狀態
Accepted — 2026-04-22
## 背景 (Context)
本 ADR 修正先前 TDD / ADR-001 中的一個錯誤假設「Phase 1 會用 Redis 做多節點 session 共享」。
實際查證 POC`edge-ai-platform/edge-ai-platform/`
- `server/go.mod``server/go.sum` — **無 Redis / go-redis 依賴**
- `server/internal/relay/server.go` — session 直接用 `map[string]*yamux.Session` + `sync.RWMutex`
- `docker-compose*.yml``deployments/` — **無 Redis 容器**
- POC 架構:單一 `relay-server` binarysession 就是 in-memory
**POC 從未用過 Redis**。先前文件裡「雛形 in-memory → Phase 1 Redis」的演進路徑是架構師的假設不是 POC 驗證過的事實。
使用者產品負責人2026-04-22 裁決 Q1
> 選 C雛形就採雙 binary`api-server` 透過 internal HTTP 向 `remote-proxy` 查 session**不引入 Redis**。POC 也沒用過,雛形不需要預先引入。
## 決策 (Decision)
visionA Cloud **雛形階段Phase 0不引入 Redis**
具體做法:
1. **Session state 完全由 `remote-proxy` 持有in-memory**
- `remote-proxy``internal/session/InMemoryStore` 是唯一真實持有 `map[token]*yamux.Session` 的地方
- `remote-proxy` process 重啟 → 所有 tunnel session 遺失(使用者需重新 pair
2. **`api-server` 無狀態**
- 不持有任何 session / tunnel state
- `internal/session/ProxyClientStore` 是對 `remote-proxy` 的 internal HTTP client`http.Client` wrap
- 水平擴展無負擔:增加 api-server instance 無副作用
3. **兩 binary 之間透過 internal HTTP 溝通**(見 `api/api-internal.md`
- `GET /internal/session/:token` — 查 session 是否在線
- `POST /internal/forward/http` — 轉發非 WS 請求
- `GET /internal/forward/ws` — 轉發 WS upgrade
- `POST /internal/session/:token/close` — 強制關閉 tunnel
4. **Phase 1 多節點才評估共享 state 機制**
- **不預設採用 Redis**
- 屆時列入評估Redis / Consul / etcd / Gossip (memberlist) / Sticky routing / Fan-out query
- 評估結果會產出新 ADR暫稱 ADR-00X
## 考慮過的替代方案
| 方案 | 優點 | 缺點 | 排除原因 |
|------|------|------|---------|
| **雛形就引入 Redis** | 「未來感」,演進路徑顯眼 | POC 都不需要就不需要;多一個 service 要運維;雛形 ops cost 上升;預先最佳化 | POC 實測證明不需要YAGNI |
| **雛形用 PostgreSQL 當 session store** | 反正 Phase 1 要接 DB | 寫入頻率高(每個 tunnel connect/disconnect、DB 不適合做 hot state而且 ADR-005 已明示雛形不接 DB | 與 ADR-005 衝突;性質不符 |
| **單進程共享 map**all-in-one binary | 最簡單,`api-server``remote-proxy` 同 process 直接共用 map | `api-server` 被迫變有狀態;部署拓撲與 Production 不一致遺失「api-server 可獨立水平擴展」的 ADR-001 屬性 | 違反 ADR-001 初衷 |
| **gRPC 取代 internal HTTP** | 效能較好、型別安全 | 多一個 proto 定義與 codegenHTTP/1.1 已夠用localhost < 1msPOC 也是用 HTTP 模式 | 收益不大增加專案門檻 |
| **Unix domain socket / shared memory** | 效能最好 | 只能 localhost違反「雛形與 Production 拓撲一致」;部署限制多 | 不符合雲端部署 |
## 後果 (Consequences)
### 正面影響
- **雛形 ops 超簡單**`docker-compose up` 起兩個 serviceapi-server + remote-proxy即可不需要外部依賴
- **POC 立刻搬得動**POC 的 relay 邏輯(`map[string]*yamux.Session`)直接複製到 `remote-proxy`,幾乎零改動
- **部署拓撲 = Production 拓撲**Phase 0 就是雙 binary + 網路呼叫,不會出現「雛形運作 → Production 架構下壞了」的陷阱
- **Phase 1 演進空間大**:不強制選 Redis屆時可根據規模、運維偏好、一致性需求選最合適方案
- **開發/測試友善**`api-server` 無狀態 → 單元測試用 stub `ProxyClientStore` 即可;不需要 mock Redis
### 負面影響(接受的取捨)
- **`remote-proxy` 是雛形的有狀態單點**
- 進程重啟/崩潰 → 所有 tunnel session 遺失
- 緩解:雛形階段可接受;使用者需要重新 pairQ5 裁決沿用 POC 覆蓋語意pairing token 重連即可)
- Phase 1多節點部署 + 自動重連機制減緩影響
- **一次 internal HTTP hoplocalhost**
- 每個走 tunnel 的 request 多 ~0.1-1mslocalhost loopback
- 對比如 MJPEG 串流這種長連線hop 成本只有首次建立,可忽略
- 一般 API 請求(< 200ms 預算也可忽略
- **Phase 1 多節點時要解決的問題延後了**
- 多個 `remote-proxy` 間如何共享 session metadata雛形不決定
- 風險Phase 1 時可能需要非平凡的工程(例如實作 gossip
- 緩解:雛形階段列出候選方案(`tunnel.md` §5.4Phase 1 kick-off 時先選型
### 風險
- **「暫時」變「永久」**:雛形如果長期不到 Phase 1`remote-proxy` 的單點故障會成真問題
- 緩解Phase 1 的觸發條件(使用者數 / SLO明確寫入 Design Doc
- **Internal HTTP endpoint 安全**`/internal/*` 若誤暴露到公網會成為攻擊面
- 緩解:`remote-proxy` 的 internal port 綁定 `127.0.0.1`(單機)或 VPC 私網(雲端);雲端部署時靠 Security Group / NetworkPolicyPhase 1 加 mTLS / shared secret header
## 相關 POC 查證紀錄2026-04-22
```
$ grep -r "redis" /Users/jimchen/Innovedus/edge-ai-platform/edge-ai-platform/server/
# (無結果)
$ grep -r "go-redis\|redigo" /Users/jimchen/Innovedus/edge-ai-platform/edge-ai-platform/server/
# (無結果)
$ cat server/internal/relay/server.go | grep -A3 "sessions"
sessions map[string]*yamux.Session
mu sync.RWMutex
# 純 in-memory
```
POC 運作至今沒有遇到需要 Redis 的問題(單 relay instance 夠用)。
## 合規性
- [x] 使用者 Q1 裁決 C2026-04-22
- [x] POC 程式碼實測查證(無 Redis 依賴)
- [x] ADR-001 已連動更新移除「Phase 1 Redis」措辭
- [x] TDD / tunnel.md / api-internal.md 已連動更新
## 相關文件
- ADR-001雙 binary 結構)
- ADR-005雛形不接 DB / Auth
- `TDD.md` §2.3Session 模組 — 兩端實作差異)
- `tunnel.md` §5Session 管理 + Phase 1 候選方案)
- `api/api-internal.md`internal HTTP API 規格)

View File

@ -0,0 +1,80 @@
# ADR-007visionA Agent 採 fork local-tool 獨立演進(非改造 local-tool
## 狀態
Accepted — 2026-04-22
## 背景 (Context)
visionA 雲端版需要在使用者桌機上跑一個「local agent」使得雲端 Web UI 可以透過 tunnel 操作桌機上的 Kneron 裝置。
這個 local agent 本質上是:
- **完整的 local-tool server**KneronPLUS / camera / inference / device / model / Python runtime / ffmpeg 等邏輯完全一樣)
- **加上** tunnel client反向連 remote-proxy
- **加上** 3 頁極簡 UI狀態 / 配對 / 設定)
- **減去** local-tool 原本的裝置 / 模型 / 推論操作 UI這些交給雲端 web
關鍵選擇:
1. **改造 local-tool**:讓 local-tool 本身支援「雲端模式」開關,同一份程式碼兩用。
2. **fork local-tool → visionA Agent**:複製一份出來,獨立演進。
使用者在 `progress.md` 明確提出 4 大原則之一:「**local-tool 不要動,可以複製出來**」。這個 ADR 把這個原則落地並說明技術細節。
## 決策 (Decision)
**採方案 2fork local-tool 建立新專案 `/Users/jimchen/visionA/local-agent/`2026-04-22 fork 一次後獨立演進。**
### 實作細節
- **整包複製**`server/internal/*``server/pkg/*``vendor/``scripts/`、Makefile 結構、wails 配置都從 local-tool 整包搬
- **新增**`cmd/visiona-agent/`Wails app shell`internal/tunnel/``internal/pairing/``internal/tokenstore/``internal/agentconfig/``internal/autostart/``internal/connstate/``internal/logexport/``internal/httpserver/`
- **刪除 / 取代**local-tool frontendNext.js 多頁 UI整包刪除改為極簡 Vite + React + shadcn SPA3 頁)
- **改造**`wails.json`、Bundle ID、App name`visionA Agent``com.innovedus.visiona-agent`)、`go.mod``module visiona-agent`
### Sync 策略
2026-04-22 fork 一次後**不主動 sync local-tool 的改動**。若 local-tool 有重大修復,走**手動 cherry-pick**(人工 diff 檢視 → apply。這個決策在 `progress.md` visionA Agent 決策表中明列。
## 考慮過的替代方案
| 方案 | 優點 | 缺點 | 排除原因 |
|------|------|------|---------|
| **改造 local-tool 支援雲端模式**build tag / feature flag | 程式碼唯一來源、維護成本低 | 兩套 UI 分支讓 local-tool 複雜化;使用者測試需覆蓋兩種模式;違反使用者「不動 local-tool」原則 | **使用者原則** + 複雜度風險 |
| **做成 monorepo 兩個 app 共用 server 模組** | 代碼復用、一次修多處生效 | Go module 設定複雜、一處改動可能破兩個 app、測試矩陣加大 | 雛形階段不值得,收益不確定 |
| **純 headless CLI agent**(不用 Wails | 最輕量 | 使用者體驗差(無配對 UI、只能 CLI 貼 token、無法 Log 檢視 / 設定 | UX 差 |
| **抽象出獨立的 "agent-core" library** | 優雅 | 需要設計 API、進一步增加結構複雜度、現階段只有一個 consumer | 過度設計 |
## 後果 (Consequences)
### 正面影響
- **完全符合使用者「不動 local-tool」原則**local-tool 後續任何改動都不會直接影響 visionA Agent
- **獨立演進自由**visionA Agent 可以按照雲端整合需求自由改 handler、加欄位、刪 UI不擔心破壞 local-tool 使用者
- **共同起跑點清楚**2026-04-22 的 fork 時間戳記是明確的 baseline未來要追 local-tool 新功能時,知道從哪個 commit 開始 diff
- **打包獨立**:兩個產品各自有 installerBundle ID 不衝突,使用者可以同時裝兩個
### 負面影響(接受的取捨)
- **維護成本加倍**local-tool 若修了 Kneron driver bug需要手動 cherry-pick 到 visionA Agent
- **程式碼重複**:整個 `server/` 目錄存兩份;未來長出差異的機率高
- **Installer 體積重複**:同樣的 Python / wheels / ffmpeg bundle 各自打包一份(但反正用戶只裝一個)
### 風險
- **Cherry-pick 遺漏**:修復本 bug 卻忘了同步另一邊 → 建立明確的 review checklist當 local-tool 修 Kneron 相關或 security 相關 bug 時,必須評估是否 cherry-pick 到 agent
- **長出的差異難以回頭合併**:隨時間兩邊結構差異加大,未來若想合併可能已不現實 — 接受這個結果,本 ADR 即假設**不會合併**
- **使用者同一台電腦裝兩個可能衝突**port / config dir 需要明確隔離bundle ID、app name、data dir 全部不同已處理)
## 合規性
- [x] 使用者核心原則「local-tool 不要動」— 由 fork 策略保證
- [x] 與 Design spec 對齊Bundle ID `com.innovedus.visiona-agent` 獨立
- [ ] Cherry-pick SOP 文件TODOPhase 1 前補)
## 相關文件
- `progress.md` visionA 產品線 4 大核心原則
- `.autoflow/04-architecture/visiona-agent-tdd.md` §2-§3
- ADR-008tunnel client 複用策略)
- ADR-009token 儲存策略)

View File

@ -0,0 +1,147 @@
# ADR-008Tunnel Client 採「程式碼複製」策略在多個專案間共享
## 狀態
Accepted — 2026-04-22v1
**Updated** — 2026-04-22v2補充 §「visionA-backend 端 tunnel package 應刪除」)
## 背景 (Context)
Tunnel clientWebSocket + yamux邏輯會在以下地方出現
1. **POC edge-ai-platform**`server/internal/tunnel/client.go`(原始來源)
2. **visionA-backend**`internal/tunnel/`(雛形 Q2 決策:從 POC **複製**到此,獨立演進)
3. **visionA Agent**(本次規劃):也需要一份 tunnel client連雲端 remote-proxy
問題visionA Agent 的 tunnel client 要從哪來?有三個選項:
- A. **從 POC 再複製一份**(獨立於 visionA-backend
- B. **從 visionA-backend 複製**(已經搬過一次,從新的來源搬)
- C. **共用同一份**git submodule、go module replace、或抽成獨立 library
## 決策 (Decision)
**v2 修正:採方案 A從 POC 直接複製到 local-agent同時刪除 `visionA-backend/internal/tunnel/`。**
> ~~v1 原決策:方案 B 從 visionA-backend 複製到 local-agent~~已撤銷原因見下方「v2 修正visionA-backend 端 tunnel package 應刪除」)
具體做法:
- `cp -r edge-ai-platform/edge-ai-platform/server/internal/tunnel/ local-agent/internal/tunnel/`
- 調整 import path`edge-ai-platform/pkg/wsconn``visiona-agent/server/pkg/wsconn`(因為 agent 內已經從 local-tool 複製了 `server/pkg/wsconn`
- 參數化 `localAddr`:讓 `NewClient()` 可接受 `httpserver.Controller.LocalAddr()` 回傳的 random port
- 同步刪除 `visionA-backend/internal/tunnel/` 整個 package見下方說明
與 ADR-007 一致fork 後獨立演進,不主動 sync。
## v2 修正visionA-backend 端 tunnel package 應刪除(事實上已於 2026-04-21 刪除)
### 查證結論
經查證 visionA-backend 程式碼2026-04-22
1. **目錄已不存在**`visionA-backend/internal/tunnel/` 在 2026-04-21 已被刪除(見 `visionA-backend/README.md` §Known Issues
2. **歷史上從未被 import**:刪除前的版本,沒有任何 visionA-backend 程式碼 import 過這個 package
3. **README 已記錄此決策**,但 v0.1 版的 visiona-agent-tdd.md 仍然提到「從 visionA-backend 複製 tunnel client」需要更正
```bash
$ ls visionA-backend/internal/ | grep tunnel
(無任何結果 — 目錄不存在)
$ grep -r "internal/tunnel" visionA-backend/cmd/
(無任何結果 — 沒有任何 import
$ grep "internal/tunnel" visionA-backend/README.md
324: ... 已於 2026-04-21 刪除 ...
```
### 本 ADR v2 的角色
本次 v2 更新**不是觸發刪除動作**(刪除已發生),而是:
1. 把這個事實正式記錄進架構決策(之前只在 README
2. 修正 v1 ADR 的方向(原 v1 寫「從 visionA-backend 複製到 local-agent」現改為「從 POC 直接複製到 local-agent」
3. 提供「為什麼當初會被複製進去 / 為什麼後來要刪」的完整脈絡,避免未來有人想加回去
### 為什麼當初會被複製進來
B3 階段cmd/remote-proxy 實作時),為了「預留給未來 local-agent 用」而從 POC 複製到 visionA-backend。但實際上
1. **visionA-backend 自己不需要 tunnel client** — visionA-backend 是 tunnel 的**雲端伺服器端**,用 `internal/relay/` 接受 agent 的入站連線,職責與 tunnel clientagent 出站連線)完全相反
2. **local-agent 沒必要繞道** — local-agent 直接從 POC 複製就好,多一層 visionA-backend 中轉沒有任何好處POC 才是原始來源)
3. **保留會誤導未來維護者** — 看到 `visionA-backend/internal/tunnel/` 會以為 visionA-backend 有 tunnel client 角色,實際上沒有
### 決策
刪除 `visionA-backend/internal/tunnel/` 整個 package在 AB1 階段執行)。
### 職責對照(避免再混淆)
| Package | 位置 | 職責 | 連線方向 |
|---------|------|------|---------|
| `internal/relay/` | visionA-backend | tunnel **server**:接受 agent 入站 WSS 連線,管理 yamux session pool | inbound雲端被動接受|
| `internal/tunnel/` | local-agent**新位置,從 POC 複製**| tunnel **client**:主動撥 WSS 到雲端 relayaccept 反向 stream 並轉發到本機 HTTP server | outboundagent 主動連出)|
| ~~`internal/tunnel/`~~ | ~~visionA-backend~~ | ❌ **已於 2026-04-21 刪除**從未被使用B3 預留誤導)| — |
### 原則明確化
**程式碼不複製到不會用的地方。** 「未來可能會用到」不是複製的理由 — 真正要用時再從原始來源複製即可。預先複製會:
- 增加維護負擔(要 sync POC 變更到一個沒人用的 package
- 誤導維護者對架構的理解
- 違反 YAGNI
這個原則應該在所有 visionA 子專案間遵守(特別是 fork / 複製模式下)。
## 考慮過的替代方案
| 方案 | 優點 | 缺點 | 排除原因 |
|------|------|------|---------|
| **A. 從 POC 直接搬** | POC 是原始來源 | 代表 POC 要同時支援兩個下游,更新更分歧 | visionA-backend 已成為「雛形基準版」,從它搬更新 |
| **C1. git submodule** | 真共享、單一改動點 | submodule 在 monorepo 中難管理、CI 綁定複雜、協作者 clone 需額外步驟 | 過度複雜 |
| **C2. go module replace`replace` directive** | 語法乾淨 | 要求有共同的 moduleagent 和 backend module 不同;跨 go.mod 易造成 ghost dependency | 不自然 |
| **C3. 抽成獨立 `visiona-tunnel` repo** | 最乾淨的 library 模式 | 需要設計 stable API、發版、文件維護 3 個 repo 的版本矩陣;**只有 2 個 consumer**不值得 | 過度工程 |
| **C4. Monorepo root 放 `pkg/tunnel/`**agent + backend 皆 import | 真共享且管理集中 | 整個 visionA monorepo Go module 結構需要重組(目前是每個子專案一個 module對 local-tool 本來獨立的狀態有風險 | 風險大 |
| **B本決策code copy** | 簡單、無新工具鏈、符合既有 fork 模式 | 未來 tunnel 協定要升版時要改 3 個地方POC / backend / agent | **tunnel 協定已穩定,改動頻率低**,維護成本可接受 |
## 後果 (Consequences)
### 正面影響
- **無新工具鏈**:純 `go.mod` + 普通目錄複製,沒有 submodule / replace 黑魔法
- **一致性風格**:與 ADR-007 的 fork 策略一致
- **改動自由**visionA Agent 的 tunnel client 可以加專屬功能Agent-Version header、重連事件 hook而不影響 backend
### 負面影響(接受的取捨)
- **三份程式碼共存**POC / visionA-backend / visionA Agent 各一份 tunnel client
- **Bug 修復需要 3 處同步**:例如修了 reconnect 退避演算法3 邊都要 review 是否跟進
- **未來的協定升級(如 HTTP/3需要 3 次修改**
### 風險
- **遺忘同步導致差異累積**:明確的 mitigation — **tunnel 協定變更必須寫新 ADR同時 review 是否 cherry-pick**
- **POC 已經是死分支**:預期 POC 不會再大改;主要 sync 路徑是 visionA-backend ↔ visionA Agent
### Phase 1 重新評估條件
若出現以下任一情形,重新評估(可能抽成 library
- tunnel 協定有 breaking changeHTTP/3 / gRPC 切換 / E2E 加密)
- Consumer 增加到 4+ 個(例如出現 headless tunnel-only agent
- 實際出現 2+ 次的「bug 只修了 A 忘了修 B」事件
## 合規性
- [x] 與 Q2 決策POC → visionA-backend 複製)一致
- [x] 與 ADR-007fork 模式)一致
- [ ] 建立 tunnel 變更 checklistTODO在第一次實際跟進 local-tool 或 backend 變動時產出)
## 相關文件
- `.autoflow/04-architecture/tunnel.md`
- ADR-002沿用 POC tunnel 協定)
- ADR-007visionA Agent 架構)
- 相關程式碼:
- `/Users/jimchen/Innovedus/edge-ai-platform/edge-ai-platform/server/internal/tunnel/client.go`POC 原始 — 唯一存活來源)
- ~~`visionA-backend/internal/tunnel/`~~**v2 決策刪除**,從未被使用)
- `visionA-backend/internal/relay/`(保留 — 這是 tunnel **server** 端,與 client 不同職責)
- `local-agent/internal/tunnel/`(本次新增 — 從 POC 直接複製)

View File

@ -0,0 +1,136 @@
# ADR-009visionA Agent 的 Pairing / Session Token 儲存策略
## 狀態
Accepted — 2026-04-22
## 背景 (Context)
visionA Agent 需要持久化存放兩種 token
- **Pairing Token**`vAc_` + 32 hex使用者貼上的配對憑證15 分鐘內有效、一次性,僅短暫存在
- **Session Token**`vAs_` + 64 hex長期 90 天agent 日後重連都用它,**核心安全資產**
儲存要求:
- **機密性**token 等同連線權限,若明文保存被任何能讀檔的 malware 拿走 = 裝置被劫持
- **跨平台**macOS / Windows / Linux 都要能用
- **不依賴 root / sudo**:安裝必須是使用者權限
- **不依賴雲端**:離線時也要能讀 token 來嘗試重連
- **易於清除**:「重置所有設定」要能乾淨清除
### OS 原生方案
| 平台 | 原生方案 | 安全強度 |
|------|---------|---------|
| macOS | Keychain (Security Framework) | 高;解鎖鑰匙圈後僅當前使用者可讀 |
| Windows | Credential Manager (Wincred) / DPAPI | 高DPAPI 綁定使用者帳號 |
| Linux | Secret Service / libsecretGNOME Keyring / KWallet | 中;需要使用者 session 解鎖SSH 登入可能沒有 |
### Go 生態 keyring library
- `github.com/99designs/keyring` — 抽象 3 平台API 清爽,但有 CGO 依賴
- `github.com/zalando/go-keyring` — 純 Go但 Linux 只支援 Secret Service無 KWallet
- `github.com/keybase/go-keychain` — 只 macOS不跨平台
## 決策 (Decision)
### 雛形階段Phase 0
**採 AES-GCM encrypted file + OS machine ID 衍生 passphrase。不接任何 OS keychain**。
設計:
```go
// internal/tokenstore/encrypted_file.go
type EncryptedFileStore struct {
path string // ~/Library/Application Support/visionA Agent/tokens.enc 等
passphrase []byte // sha256(machineID || app_salt)
}
// 用 AES-256-GCMnonce 隨機,認證標籤保護完整性
// 明文結構:
type tokenBlob struct {
SchemaVersion int `json:"v"`
SessionToken string `json:"session_token,omitempty"`
PairingToken string `json:"pairing_token,omitempty"`
AccountEmail string `json:"account_email,omitempty"`
LastPairedAt time.Time `json:"last_paired_at,omitempty"`
}
```
`machineID` 來源:
| 平台 | 來源 |
|------|------|
| macOS | `ioreg -rd1 -c IOPlatformExpertDevice``IOPlatformUUID` |
| Windows | Registry `HKLM\SOFTWARE\Microsoft\Cryptography``MachineGuid` |
| Linux | `/etc/machine-id``/var/lib/dbus/machine-id` |
`app_salt` 是 build 時寫進的常數(非秘密,只是讓 passphrase 不只依賴 machineID
### Phase 1
切換到 OS 原生 keychain`github.com/99designs/keyring`CGO 可接受,因為 visionA Agent 本來就要編 Wails = CGO 大戶)。
`TokenStore` interface 設計讓 Phase 1 只需換實作:
```go
type Store interface {
GetSession() (string, error)
SetSession(token string) error
GetPairing() (string, error)
SetPairing(token string) error
Delete(kind string) error // "session" | "pairing" | "all"
SetMetadata(m Metadata) error
GetMetadata() (Metadata, error)
}
```
## 考慮過的替代方案
| 方案 | 優點 | 缺點 | 排除原因 |
|------|------|------|---------|
| **明文 JSON file** | 零成本 | malware / 同機其他使用者可讀 | 不可接受 |
| **一次做完 OS keychain 3 平台(雛形)** | 最終狀態 | 跨平台測試成本高Linux Secret Service 在 headless session 可能沒 daemonCGO 編譯複雜度 | 雛形先簡化,分階段 |
| **只做 macOS keychain**(因為雛形開發環境多半是 macOS | 開發順 | Windows / Linux 仍需 fallback等於還是要 encrypted file | 不如雛形就統一 encrypted file |
| **讓使用者輸入 passphrase** | 真正的 Zero-Knowledge | UX 慘 — 每次重啟 agent 都要輸入 | 違反「開了就忘掉它」定位 |
| **雲端存 token只讓 agent 本機用 refresh token 換)** | 洩漏立刻可撤銷 | 需要 agent 本地仍存 refresh token — 問題沒消失;離線情境壞掉 | 沒解決根本問題 |
## 後果 (Consequences)
### 正面影響
- **雛形開發快**encrypted file 純 Go沒有 CGO / OS API 依賴,所有平台一視同仁
- **抽象清楚**`TokenStore` interface 讓雛形 / Phase 1 切換零程式碼影響
- **完整性保護**AES-GCM 自帶 auth tag檔案被竄改會 decrypt 失敗
- **合理的威脅模型**malware 拿到檔案還得拿到 machineID且不同機器的檔案不互通跨機器複製檔案會解不開
### 負面影響(接受的取捨)
- **machineID 可被同機 malware 讀取**:已經 escalate 到能讀 user home 的 malware 幾乎等同於整台淪陷,此時 token 其實是次要損失
- **備份難題**:使用者備份了 `~/Library/Application Support/visionA Agent/` 到新機器 → token 無法 decrypt必須重新配對這其實是**安全特性**,不是 bug
- **Phase 1 遷移需要一次性資料遷移**:升級後第一次啟動時,讀舊 encrypted file + decrypt + 寫入 keychain + 刪舊檔
### 風險
- **machineID 取得失敗**:極端情況(沙盒 / 非標準 OS→ fallback 到「隨機產生一次、寫在明文 checksum file」此時檔案被竊 + passphrase 檔被竊才破(仍比單純明文好)
- **Linux 多使用者共用機器**machineID 相同但 file 在各自 `~/.config/`OK
- **跨 Linux distroWSL2 / FlatpakmachineID 取得差異**TODO 在 Phase 0 測試三大 distroUbuntu / Fedora / Arch
## 雛形的明確限制(寫給未來)
- 雛形不做 token rotationSession Token 90 天內一直用同一個;過期才換)
- 雛形不做「kill switch」強制撤銷所有 agent— 需 Phase 1 backend 支援
- 雛形不做使用者提示「你的 token 在這個檔案裡」— 太技術、嚇人Phase 1 再評估
## 合規性
- [x] 雛形不弱於明文檔案
- [ ] Security reviewPhase 1 上線前必做
- [ ] Phase 1 遷移工具TODO
## 相關文件
- `.autoflow/04-architecture/security.md` §9Secret 管理)
- `.autoflow/04-architecture/visiona-agent-tdd.md` §9Token 儲存策略)
- ADR-003Pairing Token 兩階段)

View File

@ -0,0 +1,186 @@
# ADR-010OIDC 接入策略 — Authorization Code + PKCE + BFF Pattern + Member Center
## 狀態
Accepted — 2026-04-26
## 背景 (Context)
Phase 0 雛形採 `StaticAuthProvider`(任何帳密都通過、永遠回 `demo-user`)— 由 ADR-005 規範。雛形已交付Phase 0 + Phase 0.5 全綠),現在進到 Phase 0.6
1. **真實使用者是 Phase 1 的前置條件** — 沒有真 user 就無法做多用戶測試、無法上線給內部 FAE 試用
2. **同期 Innovedus Member Center 已可用** — C# .NET Core + OpenIddict + PostgreSQL已實作 OAuth Authorization Code + PKCE + JWKS + OpenID Connect Discovery
3. **跨產品 SSO 是 Innovedus 集團方向** — visionA、kneron_model_converter、未來其他產品線共用一套帳號
Phase 0.6 必須決定:
- **接什麼 Auth** 自刻 / 第三方 vendor / Member Center
- **OAuth flow 怎麼跑?** SPA + PKCE / BFF / Implicit Flow
- **frontend 怎麼處理 token** localStorage / cookie / server-side session
- **dev 環境怎麼起?** mock OIDC / 真 Member Center
### 約束條件
- visionA-frontend 是 Next.jsApp Router目前 token 存 localStorage已標為 Phase 1 必還的安全債)
- visionA-backend 是 GoGin目前無 cookie session 機制
- Member Center 強制 OAuth client 必須是 confidential要求 client_secret— **這就排除了 SPA + PKCE**
- Member Center 雛形 OAuth client 註冊機制有 `usage` 欄位 limitation`web_app` 類型,需用 `webhook_outbound` 暫代)
- visionA Agentlocal-agent的 Pairing Token 流程已實作且驗證 OK**不應動到**
- 雛形階段使用者是內部開發者 + 內部 FAE可接受重啟即重登
## 決策 (Decision)
**OAuth 2.0 Authorization Code Grant + PKCE + BFF Pattern + Innovedus Member Center**
### 具體做法
#### 1. 流程Authorization Code + PKCE redirect
- 標準 OAuth 2.1PKCE 是 Authorization Code 的 RFC 7636 強化)
- `code_challenge_method=S256`
- 三個隨機值PKCE verifier、CSRF state、OIDC nonce由 backend 產生並存 server-side pending session
#### 2. PatternBFFBackend-for-Frontend
- visionA-backend 是 OIDC **confidential client**(持有 client_secret
- visionA-backend 處理完整 OIDC dance產 PKCE → 302 to MC → 接 callback → 換 token → 驗 id_token → 建 cookie session
- visionA-frontend **完全不接觸** access_token / id_token / refresh_token
- visionA-frontend 只看到一個 `visiona_session` cookieHttpOnly + Secure + SameSite=Lax
#### 3. Identity ProviderInnovedus Member Center
- 不用 Auth0 / Cognito / Clerk — 集團內部解,跨產品 SSO
- dev 環境:直接用真 Member Centerdocker-compose 一鍵起 postgres + member-center + visionA-backend + visionA-frontend
- 不寫 mock OIDC server — 多套程式碼維護,與真實環境差異會藏 bug
#### 4. Session 管理In-memory + Cookie
- 雛形:`InMemoryStore` 在 visionA-backend 進程內持有
- Cookie 內容是 `<session_id>.<HMAC-SHA256(session_id)>`server side 才能對應到 user / token
- 重啟即消失(雛形 Phase 0.6 可接受,內部測試者)
- Phase 1 換 Redis / DB接同 interface
#### 5. 完全取代 StaticAuthProvider
- `internal/auth/static.go` + `static_provider.go` 移除
- 不保留 dev fallback不做 `if env=dev then StaticAuth else OIDC` 切換)
- 開發者要登入就要起 Member Center避免 dev / staging / prod 行為分歧
- env var `VISIONA_AUTH_MODE=oidc` 預設;`static` 仍保留以備未來需要
#### 6. 不動 Pairing Token / Agent
- 既有 Pairing Token + Session Token + Agent 流程完全不動
- 改 OIDC 後 `UserContext.UserID``"demo-user"` 變成 OIDC `sub`UUID其他 handler 邏輯不變
- Agent 端不知道 user 是誰、不接 OIDC
#### 7. 順便清三個前端安全債
`security.md` §14.1 / §14.2 / §14.3 標記的三個雛形安全債localStorage token、無 refresh、WS querystring token— 改 OIDC 同時解掉:
- Token 在 backend不存 localStorage
- 雛形 Phase 0.6 不做 refresh token rotationMember Center 暫無),但因為 cookie 7 天 TTL + 24h idle使用者不會頻繁重登
- WS 連線 cookie 自動帶(同 domain不需 querystring
## 考慮過的替代方案
### 方案 ASPA + PKCEpublic clientfrontend 持 token
| 項目 | 評估 |
|------|------|
| 可行性 | ❌ Member Center 強制 confidential client無法做 |
| 優點 | backend 簡單;標準 SPA 模式 |
| 缺點 | Token 在 browser被 XSS 偷的風險高MC 不支援 |
| 排除原因 | **Member Center 不支援 public client硬性排除** |
### 方案 BAuth0 / Cognito / Clerk第三方 vendor
| 項目 | 評估 |
|------|------|
| 優點 | 現成 UI、社交登入、MFA、密碼重設都做好 |
| 缺點 | Vendor lock-inMAU 增加成本線性上升;跨 Innovedus 產品 SSO 需要他們的企業方案;資料外流到第三方 |
| 排除原因 | **與「跨 Innovedus 產品線統一 SSO」目標衝突長期成本與資料治理風險** |
### 方案 C自刻 OAuth + JWT
| 項目 | 評估 |
|------|------|
| 優點 | 完全掌控 |
| 缺點 | 要自己做密碼重設、email 驗證、2FA、暴力破解防禦維運成本高安全風險自承 |
| 排除原因 | **重複造輪子Member Center 已存在且專為此而生** |
### 方案 D繼續用 StaticAuthProvider
| 項目 | 評估 |
|------|------|
| 優點 | 不用做事 |
| 缺點 | 無法多用戶測試;無法進 Phase 1 |
| 排除原因 | **Phase 0.6 的目的就是要升級** |
### 方案 EImplicit FlowOAuth 2.0 舊版)
| 項目 | 評估 |
|------|------|
| 優點 | 簡單,無需 token endpoint |
| 缺點 | OAuth 2.1 已 deprecatedtoken 直接在 URL fragment安全性差 |
| 排除原因 | **業界共識:不要用 Implicit Flow** |
### 方案 FBFF 但 IdP 用 Keycloak / Ory Kratos 自架
| 項目 | 評估 |
|------|------|
| 優點 | 開源、標準、可控 |
| 缺點 | 多一套服務要維運;與 Member Center 重複定位 |
| 排除原因 | **Innovedus 已選定 Member Center沒理由再起一套** |
## 後果 (Consequences)
### 正面影響
- **跨 Innovedus 產品 SSO**:使用者一組帳號用所有 Innovedus 產品
- **安全性提升**BFF 把 token 守在 backend順便清三個前端安全債§14.1 / §14.2 / §14.3
- **frontend 簡化**:不用做 PKCE、不用管 token 刷新、不用處理 callback登入按鈕變成一個 `<a href>`
- **與業界對齊**BFF + Authorization Code + PKCE 是 OAuth 2.1 推薦做法
- **dev 環境真實**:直接接真 Member Center無 mock 與 prod 行為差異
- **Agent 流程零影響**Pairing / tunnel 完全不動
### 負面影響(接受的取捨)
- **Backend 多一塊責任**cookie session store + OIDC client + JWKS cache已寫進 `internal/oidc/` + `internal/usersession/`
- **dev 必須起 Member Center**:開發者第一次 setup 多一步(`docker compose up`);用 Makefile 一鍵化緩解
- **重啟即消失**:雛形 in-memory session 重啟 → 全使用者重登Phase 0.6 階段內部測試可接受Phase 1 上 Redis
- **依賴 Member Center 上線**MC 掛掉 visionA 也無法登入Phase 1 需考慮 MC 的 SLO + circuit breaker
- **Member Center `usage` limitation**:雛形暫用 `usage=webhook_outbound`,命名語意不對;需在 MC 開 issue 加 `usage=web_app`
### 風險
| 風險 | 緩解 |
|------|------|
| Member Center dev 環境不穩 → 影響 visionA 開發 | docker-compose 固定版本Member Center 團隊維持基礎可用性 |
| Member Center 改 schema / API → visionA 跟著爆 | 用 OIDC 標準介面discovery + JWKS— Member Center 改實作不改介面就 OK |
| Cookie SameSite 在某些瀏覽器舊版有兼容問題 | 雛形支援的瀏覽器是現代版 Chrome/Firefox/Safari/Edge無此問題 |
| OIDC client_secret 外洩 | env / Secrets Manager + 不 commit + log 不印 secret + 可隨時 rotate |
| Member Center webhook用戶刪除 / 停用通知)未實作 | 雛形不接收Phase 1 補(記入 TODO |
| `usage=webhook_outbound` 之後 MC 真的有 webhook 場景時撞名 | 開 issue 推 MC 加 `usage=web_app`;切換無需動 visionA 程式碼 |
## 合規性
- [x] Architect 確認
- [x] 使用者裁決 Q1接入路線 = BOAuth Redirect + Authorization Code + PKCE
- [x] 使用者裁決 Q4完全取代 StaticAuth不保留 dev fallback
- [x] 使用者裁決 Q5Agent 不動Pairing 流程不變)
- [x] 使用者裁決 Q6BFF Pattern
- [x] 使用者裁決 Q7dev 直接用真 Member Center + docker-compose
- [ ] OB6 任務:寫 ADR-011 + 更新 ADR-005標 Auth 部分被推翻)
- [ ] OB4 完成時做一次 demo 給使用者看 login flow
- [ ] 在 Member Center 專案開 issue請新增 `usage=web_app` OAuth client 類型
## 相關文件
- 上位:`adr/adr-005-no-db-auth-in-prototype.md`Auth 部分被本 ADR 推翻DB 部分仍有效)
- 同層:`adr/adr-011-supersede-static-auth.md`OB6 任務時建立;明確記錄推翻 ADR-005 Auth 部分)
- 詳細實作:`oidc-tdd.md`Phase 0.6 OIDC TDD 增補)
- 安全:`security.md` §2、§14前端安全債清單
- 既有 Auth 設計:`TDD.md` §2.2AuthService + AuthProvider 雙層 interface
## 版本記錄
| 日期 | 版本 | 變更 |
|------|------|------|
| 2026-04-26 | 1.0 | 初版 — 反映 Phase 0.6 七個議題的使用者裁決 |

View File

@ -0,0 +1,145 @@
# ADR-011推翻 ADR-005 — 雛形階段升級接 Innovedus Member Center
## 狀態
Accepted — 2026-04-26
## 推翻
[ADR-005](./adr-005-no-db-auth-in-prototype.md) — Auth 部分DB 部分仍有效)
## 背景 (Context)
ADR-0052026-04-21在 Phase 0 雛形階段決定**不接 auth**
- 用 `StaticAuthProvider` 任何帳密都通過、永遠回 `demo-user`
- `StaticAuthService` middleware 永遠注入 demo-user UserContext
- 雛形 PRD / TDD 中所有 user-bound 資源Device / Model / PairingToken都綁到 demo-user
當時的決策邏輯:
- 雛形階段目標是驗證「雲端端對端連通」技術可行性
- 把時間花在 Auth、user 系統、DB schema 上會延誤核心驗證
- 介面(`AuthProvider` / `AuthService`)已切乾淨,未來換實作零業務邏輯改動
到了 Phase 0.62026-04-26情況變了
- 雛形已交付Phase 0 + Phase 0.5 全綠),核心架構通過驗證
- 需要進入 Phase 1多用戶、上線給 FAE 試用)— 沒真實 user 寸步難行
- 同期間 Innovedus 集團另一條線 **Member Center 已可用**C# .NET Core + OpenIddict + PostgreSQL70% 完成度OAuth Authorization Code + PKCE + JWKS 都能跑)
- 跨產品 SSO 是 Innovedus 集團方向visionA / kneron_model_converter / 未來其他產品線)
繼續用 StaticAuth 的代價:
- 無法做多用戶測試(內部 FAE 試用無法區分使用者)
- 無法把雛形交給內部使用者(資料會混在一起)
- 之後要接 OIDC 還是要做 → 不如現在做完,後面少一輪改動
## 決策 (Decision)
**推翻 ADR-005 的 Auth 部分**Phase 0.6 起 visionA-backend 的唯一認證路徑改為 OIDC接 Innovedus Member Center。
### 具體做法
1. **拔除 StaticAuthProvider / StaticAuthService**
- 刪除 `internal/auth/static.go` + `static_test.go`
- `internal/api/middleware.go``AuthMiddleware` 拿掉雙模式分支,只剩 OIDC
- `internal/api/api.go``Deps` 拿掉 `AuthService` / `AuthProvider` 欄位;`validate()` 強制 `OIDCProvider` + `SessionManager` 必填(缺則啟動 panic
- `cmd/api-server/main.go` 拿掉 `cfg.OIDC.Enabled` 旗標分支OIDC 變成必須路徑
2. **OIDC 實作**OB1-OB4 已完成;本 ADR 只記錄決策)
- 採 OAuth 2.0 Authorization Code + PKCE + BFF Pattern詳見 ADR-010
- visionA-backend 是 OIDC confidential client持有 client_secret
- frontend 完全不接觸 token只看到 `visiona_session` cookie
- In-memory session store重啟即消失雛形階段可接受
3. **保留 AuthProvider / AuthService interface**
- 雖然 Static 實作已刪interface 本身仍保留在 `internal/auth/auth.go`
- 給未來新增備援 providerPhase 1 backup local auth、service-to-service token使用
- 介面代表 contract — 提早設計、提早被驗證的介面比 ad-hoc 重新發明便宜
4. **Pairing Token 流程不動**ADR-005 的另一個面向)
- PairingStore / SessionTokenStore 仍是 in-memory + interface 抽象
- 唯一改變:`UserContext.UserID` 從固定的 `"demo-user"` 變成 OIDC `sub`UUID自然穿透到 `PairingStore.Create` 的 user_id 參數
- Agent 端不知道 user 是誰、不接 OIDC
5. **dev 環境策略**
- 不寫 mock OIDC server測試例外 — 見 `internal/oidctest`
- 開發者要登入就得起 Member Centerdocker-compose 一鍵化)
- 為了讓 demo-user 流程繼續可用Member Center 端會 seed `demo@visionA.local / demo123` 帳號
### 仍維持 ADR-005 規範的部分
- **不接真實 DB**Device / Model / Pairing 仍用 InMemoryRepository
- **Repository interface 抽象**Phase 1 換 PostgresRepository 仍然零業務邏輯改動
- **資料重啟會遺失**:雛形 process 重啟 → user session、device、model、pairing token 全消失
## 考慮過的替代方案
| 方案 | 評估 | 排除原因 |
|------|------|---------|
| **繼續用 StaticAuth 到 Phase 1** | 不用做事 | 無法多用戶測試FAE 試用會撞牆;之後還是要接 |
| **保留 dev fallback 切換** | dev 環境不需起 Member Center | 各環境行為分歧難排查;且 Member Center docker-compose 一鍵起,成本不高 |
| **接 Auth0 / Clerk / Cognito** | 現成 | 跨 Innovedus 產品 SSO 需企業方案vendor lock-in資料外流 |
| **自刻 email + password + JWT** | 完全掌控 | 要做密碼重設、2FA、暴力破解防禦維運成本太高 |
詳細替代方案分析見 [ADR-010](./adr-010-oidc-bff.md)。
## 後果 (Consequences)
### 正面影響
- **跨 Innovedus 產品 SSO**:使用者一組帳號用所有 Innovedus 產品線
- **真實 user binding for Pairing token**:多使用者上線時不再混淆
- **進 Phase 1 的前置就緒**FAE 內部試用、多用戶測試、上線都有 Auth 基礎
- **frontend 安全性提升**Token 在 backend不存 localStorage順便清掉 security.md §14.1 / §14.2 / §14.3 三筆雛形安全債)
- **demo-user 流程繼續可用**Member Center seed 同名帳號,雛形 demo 體驗不變
- **程式碼更乾淨**:拔除「雙模式 if/else」之後 auth middleware 邏輯線性、容易追蹤
### 負面影響(接受的取捨)
- **dev 環境多一個依賴**:起 visionA 要先起 Member Center + Postgresdocker-compose 緩解)
- **對 Member Center dev 環境穩定性有依賴**MC 掛掉 visionA 也無法登入
- 緩解docker-compose 固定版本Member Center 團隊維持基礎可用性
- **session 重啟即消失**in-memory store雛形 Phase 0.6 可接受,內部測試者
- Phase 1 換 Redis/DB同 interface
### 風險
| 風險 | 緩解 |
|------|------|
| Member Center API 改動 → visionA 跟著爆 | 用 OIDC 標準介面discovery + JWKS — Member Center 改實作不改介面就 OK |
| Member Center webhookuser 刪除 / 停用)未實作 | 雛形不接收Phase 1 補(記入 oidc-tdd.md TODO |
| OIDC client_secret 外洩 | env / Secrets Manager + 不 commit + log 不印 secret + 可隨時 rotate |
| 既有 Pairing flow 對 user_id 變動的破壞 | OB5 整合測試 `TestOIDCE2E_PairingTokenBindsToOIDCUser` + `TestOIDCE2E_MultiUserIsolation` 驗證 user binding 正確 |
## 合規性
- [x] Architect 確認
- [x] 使用者裁決 Q1OIDC + PKCE redirect— 見 ADR-010
- [x] 使用者裁決 Q4完全取代 StaticAuth不保留 dev fallback— 見 ADR-010
- [x] OB5 任務完成StaticAuth 已從 codebase 移除
- [x] OB5 任務完成:所有測試(含原 build-tag 隔離的 oidc_e2e_test.go皆通過
- [ ] Phase 1 Kick-off補 user 表 + 接 DB取代 in-memory session store
## 相關文件
- 上位:[ADR-005](./adr-005-no-db-auth-in-prototype.md)(被本 ADR 推翻 Auth 部分DB 部分仍有效)
- 同層:[ADR-010](./adr-010-oidc-bff.md)OIDC 接入策略 — 為什麼選 BFF + Authorization Code + PKCE + Member Center
- 詳細實作:[oidc-tdd.md](../oidc-tdd.md)Phase 0.6 OIDC TDD 增補)
- 安全:[security.md](../security.md) §2、§14前端安全債清單
## 後記2026-05-01
Phase 0.7 stage 部署時發現 Innovedus stage Member Center 配給 visionA 的 login OAuth client 是 **public PKCE-only**(無 `client_secret`),與本 ADR §32 中「visionA-backend 是 OIDC confidential client持有 client_secret」的假設不符。
具體狀況:
- Stage MC 端為 redirect flow 配的是 public client`b8093fea1a504a5d8f0e04bee9f78f2e`,無 secret
- 另配一組 confidential client`<see stage .env.stage>` + secret給未來 client_credentials grant 用,**login flow 不用此組**
- 表示 ADR-010 §「MC 強制 confidential」前提對 redirect flow 不再成立(對 server-to-server flow 仍成立)
處理:
- visionA-backend 擴充為兩種 mode 都支援ClientSecret 從必填變選填runtime 判斷走哪條路)
- 詳細決策見 [ADR-013](./adr-013-oidc-public-pkce-client.md)
- 本 ADR 的核心結論OIDC 取代 StaticAuth、保留 AuthProvider/AuthService interface、Pairing 流程不動、in-memory session**全部仍有效**,只是 client 配置層多了一個維度
## 版本記錄
| 日期 | 版本 | 變更 |
|------|------|------|
| 2026-04-26 | 1.0 | 初版 — OB5 完成後正式記錄推翻 ADR-005 Auth 部分 |
| 2026-05-01 | 1.1 | 新增「後記」段stage 部署發現 MC login client 為 public PKCE-only擴充由 ADR-013 處理 |

View File

@ -0,0 +1,98 @@
# ADR-012Pending Session 與 Logged-in Session 共用同一個 Cookie
## 狀態
Accepted — 2026-04-21
## 上位文件
- [oidc-tdd.md §4.5](../oidc-tdd.md#45-handler-範例與-pending-session)
- [adr-010-oidc-bff.md](./adr-010-oidc-bff.md)
## 背景 (Context)
OIDC Authorization Code + PKCE flow 在 backend 端會產生兩種 server-side session 狀態:
1. **Pending session**OIDC dance 進行中):`/api/auth/login` 階段建立,存放 PKCE code_verifier、CSRF state、OIDC nonce、return_to。生命週期短≤ 10 分鐘,從使用者點 Login 到 IdP 完成同意UserID 為空。
2. **Logged-in session**(已登入):`/api/auth/callback` 完成後UserID 從 OIDC sub claim 填入pending 欄位清空。生命週期長(雛形 24hPhase 1 設計 7d
`oidc-tdd.md §4.5` 的原始設計示意了**兩個獨立 cookie**
- `visiona_pending_sid`(短 TTL10 分鐘)
- `visiona_session`(長 TTL登入後寫入
雛形實作OB2 / OB4為了減少 cookie 數量、簡化 handler 與 store 邏輯,**改採合一**:兩種 session 共用同一個 `visiona_session` cookie + 同一個 `usersession.Store` record`Session.UserID` 是否為空來區分階段。
OB5 review2026-04-21將此偏離標為 Major-3「technical debt 而非漏洞」,建議 Phase 1 上線前評估是否還原 TDD 原設計。本 ADR 在 review round 2 後做出最終決定。
## 決策 (Decision)
**保留合一設計**pending 與 logged-in 共用同一個 `visiona_session` cookie**不**還原 TDD §4.5 的兩個 cookie 設計。
### 配套防護機制
合一設計的潛在風險pending session 被當成 logged-in 用)由以下三道防線阻擋:
1. **AuthMiddleware 強制檢查 `UserID == ""` → 401 `session_not_authenticated`**
- `internal/api/middleware.go` AuthMiddleware 在拿到 session 後立刻判斷 UserID 是否為空
- 空 → 401pending session 訪問 protected endpoint 一律拒絕)
- 此檢查不可拿掉、不可放寬
- 對應測試:`TestOIDCMiddleware_Rejects_PendingSession`
2. **Callback 完成時 rotate session ID**Fix-A1 / ADR-012 配套)
- `internal/api/oidc_auth.go` callback handler 在驗 id_token 成功後立即呼叫 `usersession.Manager.RotateSessionID`
- 新 session 從一個全新的 random ID 開始,舊 pending session ID 從 store 中刪除
- 防護 session fixationOWASP ASVS V3.2.1
- 對應測試:`TestOIDCCallback_RotatesSessionID_PreventsFixation``TestManager_RotateSessionID_HappyPath`
3. **Pending state 在 callback 完成同一次 UpdateSession 中清空**
- OIDCState / OIDCNonce / OIDCCodeVerifier / Extra["return_to"] 在寫入 user info 的同一次 Update 中清掉
- 確保 logged-in session 不殘留 pending 欄位
## 理由 (Rationale)
### 為什麼維持合一
1. **Cookie 數量少對 frontend 簡單**frontend 只關心一個 cookie 是否存在,不需要區分 pending/logged-in 兩個 cookie 的狀態
2. **Handler 邏輯簡單**`oidcCallbackHandler` 不需要「讀 pending cookie → 建 new logged-in cookie → 清 pending cookie」三步操作改 RotateSessionID 一步搞定
3. **Store 操作減少**:每個 cookie 對應一個 store record兩個 cookie 等於 store 寫入次數加倍
4. **防護機制已到位**上述三道防線middleware UserID 檢查 + RotateSessionID + 同一次清理)涵蓋原本兩個 cookie 設計要解的問題
### 為什麼不拆兩個 cookie
1. **大改動**:要動 `usersession.Manager``oidc_auth.go` 兩處核心 handler、middleware、所有相關測試
2. **不解新風險**pending vs logged-in 的混淆風險已由現有防線阻擋;拆兩個 cookie 只是把同一個保護換個位置實作
3. **Phase 1 Redis 化更複雜**:兩個 cookie 對應兩個 store keyRedis 命名空間 / TTL 管理變複雜;合一設計直接共用一個 key
4. **TDD §4.5 是文件示意**:原設計是教學性的「直覺易懂」呈現,不是經過威脅模型分析的最佳化方案
## 取捨 (Trade-offs)
### 優點
- **Cookie 數量少**browser cookie jar 簡潔DevTools 偵錯清楚
- **邏輯路徑短**handler / middleware / store 都只處理一種 cookie + 一種 store record
- **Phase 1 換 Redis 影響面小**:同一個 Redis key 即可,不需要兩套 namespace
- **測試簡單**:不需要在每個 test 中模擬「兩個 cookie 同時存在 / 其一缺失」的所有組合
### 缺點
- **理論上 pending 與 logged-in 共用 store record**,若 middleware UserID 檢查被誤刪會立刻變漏洞
- **緩解**middleware 該行加註解明確警告不可拿掉CI 可加 grep 檢查
- **`Session` struct 同時有 OIDC pending 欄位 + user info 欄位**序列化時負擔略大Phase 1 Redis 化時感受得到)
- **緩解**Phase 1 評估是否分兩個 struct但仍共用同一個 Redis key + 不同的序列化 schema
- **與 oidc-tdd.md §4.5 文件示意不符**
- **緩解**TDD §4.5 已加註「實際採合一設計,詳見 ADR-012」後續更新
## 影響範圍
| 區塊 | 影響 |
|------|------|
| `internal/usersession/usersession.go` | `Session` struct 同時包含 OIDC pending + user info 欄位(已實作) |
| `internal/usersession/manager.go` | 新增 `RotateSessionID`Fix-A1做為 fixation 防護 |
| `internal/api/oidc_auth.go` | callback 流程state 比對 → ExchangeCode → VerifyIDToken → **RotateSessionID** → set user info → UpdateSession |
| `internal/api/middleware.go` | AuthMiddleware 強制檢查 `UserID == ""` → 401不可拿掉 |
| Phase 1 Redis 化 | pending 與 logged-in 共用同一個 Redis key + 同一個 schema不影響架構 |
## 關聯
- 推翻:無(補充 ADR-010 的實作細節決策)
- 相關:[ADR-010](./adr-010-oidc-bff.md)、[ADR-011](./adr-011-supersede-adr-005.md)
- 配套修復Fix-A1RotateSessionID、Fix-A2本 ADR + middleware 註解)
- 相關 review`.autoflow/05-implementation/review/oidc-G5-OB1-OB6-review.md` Major-3

View File

@ -0,0 +1,194 @@
# ADR-013OIDC 支援 Public PKCE-only ClientClientSecret 變選填)
## 狀態
Accepted — 2026-05-01
## 上位文件
- [ADR-010](./adr-010-oidc-bff.md)OIDC 接入策略 — BFF + Authorization Code + PKCE
- [ADR-011](./adr-011-supersede-adr-005.md)OB5 拔除 StaticAuth、強制 OIDC
- [oidc-tdd.md](../oidc-tdd.md) §13.1(環境變數)
## 推翻
無 — 本 ADR 是對 ADR-010 的**擴充**(在原 confidential client 路徑外新增 public client 路徑),不是推翻。
## 背景 (Context)
ADR-010 在 2026-04-26 決定 visionA-backend 是 OIDC **confidential client**(持有 `client_secret`),理由是:
> Member Center 強制 OAuth client 必須是 confidential要求 client_secret— 這就排除了 SPA + PKCE。
到了 2026-05-01 Phase 0.7 stage 部署時情況變了。Innovedus 集團的 stage Member Center`https://stage-9527.innovedus.com:7850/`)配給 visionA 的 OAuth client 實際是兩組:
| Client | Client ID | Secret | 用途 |
|--------|-----------|--------|------|
| Login**public PKCE-only**| `b8093fea1a504a5d8f0e04bee9f78f2e` | **無** | OAuth Authorization Code + PKCE redirect flow |
| Service-to-serviceconfidential| `<see stage .env.stage>` | `<see stage .env.stage, never commit>` | client_credentials grant — visionA-backend 直接打 MC APIPhase 1 才會用) |
> **🔒 Secret hygiene 註記**2026-05-01 補):本 ADR 初版誤將真實 stage service client 的 id 與 secret 寫死進此表格review 階段發現後立刻移除。原始 secret **未進 git 歷史**(檔案從未被 commit、git log 已確認),但仍視為「曾於工作目錄中存在」處理 — 請 MC 團隊將該 service client_secret rotate 後再啟用。Login client_id 是 OAuth public 資訊spec 不視為 secret保留可接受service client 兩個值真正注入只發生在 stage host 的 `.env.stage`(已加入 `.gitignore`)。
也就是說 ADR-010 的前提「MC 強制 confidential」**對 login flow 不再成立** — MC 為 redirect flow 配的是 public client靠 PKCE 防 code interception沒給 secret。
直接後果visionA-backend 啟動時會 panic — `internal/config/config.go``Validate()``OIDC.ClientSecret` 列為必填env 沒給就 fail-fast。要在 stage 跑起來必須改。
### 觸發條件
- **A1 任務**Phase 0.7)需要把 stage MC 給的 public PKCE-only login client 跑起來
- 同時要保留現有 dev / 未來 prod 可能用到的 confidential client mode向下相容
- 為未來 Phase 1 接 MC API用 client_credentials預留 config 鉤子,但不啟用實作
### 為什麼 PKCE-only public client 對 redirect flow 是夠的
OAuth 2.1RFC 9700 BCP+ RFC 8252 對「無法安全儲存 client_secret 的 client」SPA、native app、且本案 — 雖然 visionA-backend 是 server但 MC 把它分類為 redirect-flow client的標準做法
1. **PKCERFC 7636防 authorization code interception**:攻擊者就算攔截到 authorization code沒有 `code_verifier` 換不到 token
2. **`code_challenge_method=S256`** 強制visionA 本來就只用 S256
3. **`state` 防 CSRF**visionA pending session 已實作)
4. **`nonce` 防 id_token replay**visionA pending session 已實作)
OWASP / IETF OAuth WG 結論(截至 2026對 redirect flow**PKCE 是必備、client_secret 對 public client 不是必備**。confidential client 的 secret 主要是給「沒 user redirect」的 server-to-server flowclient_credentials、refresh_token 等)做 client authentication 用的redirect flow 有 PKCE 已經夠。
換句話說visionA login 路徑從 confidential 變 public**安全等級沒掉太多** — 主要差異在 token endpoint 不附 client_secret但 PKCE 守住了 code interception 這個主要威脅。
## 決策 (Decision)
`OIDC.ClientSecret` **從必填變選填**。visionA-backend 同時支援兩種 mode
| Mode | 觸發條件 | Token endpoint 行為 | 安全模型 |
|------|---------|---------------------|---------|
| **Public PKCE-only**stage、未來可能 prod | `VISIONA_OIDC_CLIENT_SECRET` 為空 | `oauth2.Config.ClientSecret = ""`golang.org/x/oauth2 lib 自動不送 client_secret | PKCE + state + nonce |
| **Confidential**dev、向下相容 | `VISIONA_OIDC_CLIENT_SECRET` 非空 | `oauth2.Config.ClientSecret = <secret>`,正常 client_secret_post / basic auth | PKCE + state + nonce + client_secret |
### 具體做法
#### 1. `internal/config/config.go`
- `OIDCConfig.ClientSecret` 欄位保留,但 godoc 改為「選填 — 為空時走 public PKCE-only mode」
- `Validate()``VISIONA_OIDC_CLIENT_SECRET` 從 missing 檢查清單移除
- 其他 OIDC 必填欄位IssuerURL / ClientID / RedirectURL / PostLoginURL / SessionSecret不變
#### 2. `internal/oidc/provider.go`
- `validateConfig()``ClientSecret` 從必填檢查移除
- `oauth2.Config.ClientSecret` 直接傳 `cfg.ClientSecret`(空字串時 oauth2 lib 會自動省略該欄位,行為符合 RFC 6749 §3.2.1「The client MAY omit the parameter」
- `IDTokenVerifier` 不變(它只看 ClientID 做 audience check與 secret 無關)
#### 3. 預留 service client config
新增兩個 config 欄位(**Phase 0.7 不實作 client_credentials flow**,只是預留 env 不讓使用者設了沒用):
```go
type OIDCConfig struct {
// ...既有欄位
// ServiceClientID / ServiceClientSecret 是 client_credentials grant 用的
// confidential client給 visionA-backend 主動打 Member Center API例如查
// user metadata、發 webhook。Phase 0.7 不實作config 欄位先佔位,避免未來
// 加新 env 時要再過一輪文件 review。
ServiceClientID string // VISIONA_OIDC_SERVICE_CLIENT_ID選填
ServiceClientSecret string // VISIONA_OIDC_SERVICE_CLIENT_SECRET選填
}
```
`Validate()` 不檢查這兩個欄位 — 兩個都空Phase 0.7 / stage 不接 MC API也合法。
#### 4. 文件更新
- `oidc-tdd.md` §13.1 把 `VISIONA_OIDC_CLIENT_SECRET` 標為「選填」
- `oidc-tdd.md` §13.1 新增 dev / stage / prod 三種 client 模式的 env 範例段
- `ADR-011` 後記補一段,註記 stage 部署時發現 MC 給 public client 的事實
#### 5. 向下相容
既有 dev docker-compose`docker-compose.dev.yml`seed `demo@visionA.local` 帳號 + confidential client 完全不動,繼續走 confidential mode。沒有 breaking change。
## 考慮過的替代方案
### 方案 A保留 confidential-only請 MC 改發 confidential client 給 stage
| 項目 | 評估 |
|------|------|
| 優點 | 不用動 visionA 程式碼 |
| 缺點 | 違反 MC 的 client 分類設計login client 就應該是 public PKCE-only要等 MC 排期改;阻塞 stage 部署 |
| 排除原因 | **MC 的設計是對的symmetric 的事 OIDC 標準也偏好 public client for redirect flow不該為了省一次 visionA 文件更新而扭曲 IdP 設計** |
### 方案 BvisionA 自帶一組 client_secret硬塞 MC
| 項目 | 評估 |
|------|------|
| 優點 | — |
| 缺點 | MC 端不認token endpoint 會 401unauthorized client |
| 排除原因 | **技術上跑不起來** |
### 方案 Cdev 用 confidential、stage 用 public、用 build flag 切換
| 項目 | 評估 |
|------|------|
| 優點 | 編譯期保證每個環境只有一條路徑可走 |
| 缺點 | `+build` tag 增加 CI 複雜度;本質上是 runtime 決定的事不該綁 build flag違反 12-factor「同一個 binary 跑各環境」 |
| 排除原因 | **過度工程ClientSecret 是否為空是天然的 runtime switch** |
### 方案 D刻意不向下相容全部改用 public client
| 項目 | 評估 |
|------|------|
| 優點 | 程式碼稍微簡單 |
| 缺點 | dev 既有 docker-compose seed 流程要全改;未來 prod 若要強化confidential + PKCE 雙保險做不到ADR-010 的決策被無故推翻 |
| 排除原因 | **本案是擴充不是推翻,沒理由減功能** |
### 方案 E採用ClientSecret 變選填runtime 判斷走哪條路
| 項目 | 評估 |
|------|------|
| 優點 | 同一份程式碼 / binary 跑 dev / stage / prod 三種環境最小改動oauth2 lib 原生支援空 secret向下相容 |
| 缺點 | 要在文件 + ADR 多寫一段說明何時用哪 mode |
| 採用原因 | **正確抽象confidential vs public 是 client 配置屬性而非系統屬性;應由 IdP 註冊時決定visionA-backend 跟隨** |
## 後果 (Consequences)
### 正面影響
- **stage 部署解鎖** — A1 改造後 visionA-backend 可直接吃 stage MC 給的 public PKCE-only client
- **同一份 binary 多環境** — dev confidential / stage public / prod 視 IT 配置 — 不需重 build
- **跟隨 OAuth 2.1 趨勢** — IETF / OWASP 對 redirect flow 偏好 public PKCE-onlyconfidential 主要留給 server-to-server
- **向下相容** — dev 既有流程零改動
- **預留 service client 鉤子** — Phase 1 接 MC APIclient_credentials時 config 欄位已經在,不必再改 ADR
### 負面影響(接受的取捨)
- **失去一層防護**public mode 沒了 client_secrettoken endpoint 對「冒充 visionA client_id 的攻擊者」沒防護。緩解PKCE 守住主要威脅code interceptionMember Center 端會用 redirect_uri 白名單加碼防護
- **文件複雜度上升**oidc-tdd.md §13.1 要多寫一段「dev / stage / prod 的 client mode 差異」
- **可能誤設**:開發者忘記設 `VISIONA_OIDC_CLIENT_SECRET` 但 MC 端註冊的是 confidential client → token exchange 會 401。緩解oidc-tdd.md §13.1 範例與啟動 log 要明確顯示「啟動時偵測到 ClientSecret 為空,走 public PKCE-only mode」
### 風險
| 風險 | 緩解 |
|------|------|
| Member Center 改變 stage client 類型public ↔ confidential 切換)導致 visionA 起不來 | env 兩種 mode 都支援;切換只需改 `.env.stage` 加 / 移除 `VISIONA_OIDC_CLIENT_SECRET`,不需重 build |
| 開發者誤把 dev 的 confidential secret 帶進 stage env | 透過 stage host 的 `.env.stage` 不進 git + secrets reviewsecret 本身對 public client 是無害冗餘MC 會忽略),不會立即出問題 |
| oauth2 lib 版本升級行為改變client_secret 空字串時行為) | 在 backend test 加 `TestExchangeCode_PublicClient` 確認空 ClientSecret 走通CI 跑得到 |
| 攻擊者拿到 stage public client_id 自己模擬 visionA → 但 redirect_uri 必須符合 MC 白名單,且 PKCE verifier 守住 | redirect_uri 白名單由 MC 管理(已是 stage 配置PKCE 由 visionA-backend 每次新產 |
| Phase 1 同時用 redirect public client + service confidential client兩組 secret 管理混淆 | config 欄位命名清楚(`ClientSecret` vs `ServiceClientSecret`ADR 後續更新時記錄職責邊界 |
## 合規性
- [x] Architect 確認2026-05-01
- [x] 使用者確認 stage MC 配給 visionA 的是 public PKCE-only clientprogress.md OIDC Client 配置表)
- [ ] A1-1 / A1-2 backend 改造從必填變選填、ClientSecret 空走 public mode
- [ ] A1-3 補測試public + confidential 兩種 mode 都綠
- [ ] A1-5 oidc-tdd.md §13.1 + `.env.dev.example` 文字更新
- [ ] A1-6 預留 ServiceClientID / ServiceClientSecret config 欄位(不啟用實作)
- [ ] V-4 stage 實際走完一次 OIDC flow確認 callback → cookie session 完整成立
## 相關文件
- 上位:[ADR-010](./adr-010-oidc-bff.md)(本 ADR 是其擴充,非推翻)
- 同層:[ADR-011](./adr-011-supersede-adr-005.md)(後記補上 2026-05-01 stage 發現)
- 詳細實作 / env 規格:[oidc-tdd.md](../oidc-tdd.md) §13.1
- 部署架構:[stage-deployment.md](../stage-deployment.md)(同期建立)
- OAuth 規格RFC 7636PKCE、RFC 9700OAuth 2.0 BCP、RFC 8252Native Apps BCP
## 版本記錄
| 日期 | 版本 | 變更 |
|------|------|------|
| 2026-05-01 | 1.0 | 初版 — 反映 stage MC public PKCE-only client 配給ClientSecret 變選填;預留 ServiceClient 欄位 |

View File

@ -0,0 +1,232 @@
# ADR-014visionA 端轉檔功能架構Phase 0.8
## 狀態
Accepted — 2026-04-30
## 上位 / 同層 ADR
- 沿用:[ADR-006](./adr-006-no-redis-in-prototype.md)in-memory state、[ADR-010](./adr-010-oidc-bff.md)OIDC BFF + confidential client、[ADR-011](./adr-011-supersede-adr-005.md)OIDC 取代 StaticAuth、[ADR-013](./adr-013-public-client.md)user OIDC client 為 public + PKCE-onlyservice client 仍為 confidential
- 並存:本 ADR 規範「visionA-backend 同時當 multipart streaming proxyupload+ delegated download token brokerdownload
## 背景 (Context)
Phase 0.8 要把 kneron_model_converter以下簡稱 converter整合進 visionA Cloud。雙方為各自獨立部署的後端
- **converter** 仍在公司內網 `192.168.0.130``POST /api/v1/jobs`multipart, 500MB cap/ `GET /api/v1/jobs/{id}` poll / `POST /api/v1/jobs/{id}/promote` 推 NEF 到 File Access Agent (FAA)
- **visionA-backend** 將部署到 AWSstage 已上 `https://stage-9527.innovedus.com:9527/`
- **FAA** 是 ASP.NET Core stateless 服務,存放 NEF支援 `GET /files/{key}?access_token=<delegated>` browser 直連
- **Innovedus Member Center (MC)** 是 OAuth/OIDC IdP同時負責簽 service-to-service token 與 delegated download token
整合上必須回答兩個問題:
1. **Upload轉檔 input** 怎麼進 converterbrowser 直連 vs visionA backend 中轉?
2. **Download轉檔結果** 怎麼出 FAAbrowser 直連 vs visionA backend 中轉?
並存的設計約束:
- visionA-backend 是 user 身份 / OIDC sub 注入 converter `user_id` 表單欄位的**唯一可信任點**converter 完全信任 caller 帶來的 user_id見 converter openapi.yaml `## user_id 與 trust boundary`
- converter 一個 `user_id` 同時間只能有 1 個 active job`409 user_has_active_job`
- FAA delegated download token TTL 短515 分鐘),可給 browser 直連
- Member Center service client`23605e14a2c64660abd97e29963d8d58`)已配置,需 4 個 scope`converter:job.write/read``files:download.read/delegate`
- `internal/config.OIDCConfig.ServiceClientID/Secret` 鉤子在 ADR-013 / Phase 0.7 已預埋但未啟用A1 階段)
Phase 0.8 MVP 範圍:上傳 → 轉檔 → 半自動處理user 完成後選「加到模型庫」or「下載」。**Non-Goals**:歷史 / 取消 / SSE 進度推送 / 同 user 多個 active job / 多 chip 同時轉。
## 決策 (Decision)
**Upload 走 visionA backend streaming proxy + Download 走 FAA delegated tokenbrowser 直連)** 的非對稱設計,並把 visionA-backend 同時當 **multipart streaming proxy** + **delegated download token broker**
### 1. Upload — 一次性 → visionA backend 中轉
```
Browser ──multipart──► visionA backend ──multipart streaming──► converter
(io.Pipe + multipart.Reader/Writer)
```
- 每個檔案只上傳「一次」,跨 internet 一次成本可接受500MB × 1 次 vs 500MB × N 次下載)
- 用 `io.Pipe` + goroutine一邊讀 client、一邊寫 converter — **不暫存 disk、不 buffer 全 RAM**
- visionA-backend 在這條路徑做的事:
1. 從 cookie session 取 `user_id`OIDC sub灌進 converter request 的 `user_id` 表單欄位
2. 跟 MC 取 service tokenscope `converter:job.write`),帶在 `Authorization: Bearer`
3. 透傳 model file + ref_images[] + 其他 form fieldstarget_chip / 各 enable_* flag
4. converter response 整形後回 frontend不直接洩 converter response shape
- converter **零修改** — 沿用既有 `POST /api/v1/jobs` multipart endpoint
### 2. Download — 多次性 → FAA delegated tokenserver-side 302 redirect → browser 直連 FAA
```
Browser ──GET /api/conversion/{job_id}/download──► visionA backend
ownership 檢查
MC POST /file-access/download-tokens
Browser ◄─── HTTP 302 Found, Location: https://faa/files/{key}?access_token=<delegated>
browser 自動 follow redirect
Browser ──直連 FAA──► GET /files/{key}?access_token=<delegated>
```
- 同 NEF 可能被同一 user 多次下載到不同 deviceN 次跨 internet 流量燒不起
- FAA 收到 token 後線上跟 MC validateFAA 自己跟 MC 對打visionA-backend 不參與)
- visionA-backend 在這條路徑做的事(單一 GET endpoint 內完成):
1. 既有 OIDC AuthMiddleware 驗 cookie session 拿 user_id
2. 確認該 user 對該 job 有權(從 visionA 內部記錄查 ownership**禁止讓 client 直接傳 object_key**
3. server-to-server 跟 MC 換 delegated tokenscope `files:download.delegate`
4. 組 download URL 後直接 `c.Redirect(http.StatusFound, downloadURL)` — 把 token 放在 Location header
- visionA-frontend 不需處理 token`<a href="/api/conversion/{job_id}/download" download>下載</a>``window.location.href = '/api/conversion/{job_id}/download'` 即可browser 自動 follow 302
- **Pattern 對齊**:仿 FAA TestSite `DownloadFileDirect` action`FileAccessAgent.TestSite/Controllers/HomeController.cs:255-282`)— 也是 server 端組 URL 後 `return Redirect(directUrl)`token 不過 frontend JS
**為什麼 302 redirect 比「frontend 拿 token + navigation」更安全**
| 面向 | 方案 Xfrontend 拿 token JSON| 方案 ✓server 302 redirect|
|------|-----|-----|
| Token 在 fetch response body | ✗ 在JS 看得到、可能進 console.log / Sentry / 第三方分析)| ✓ 不在(沒有 JSON response|
| Token 在 URL bar | ✗ 在(`window.location.href = url` 之後 URL bar 會短暫顯示)| △ 短暫302 的 final URL 仍會出現,但 browser navigation 完成後通常立即被 FAA download 流程取代;且 navigation 期間 history entry 可被 `Cache-Control: no-store` + 短 TTL 緩解)|
| Token 在 localStorage / sessionStorage | △ 視 frontend 實作(容易誤存)| ✓ 結構性不可能(沒入口)|
| 受 frontend XSS 影響 | ✗ XSS 可竊取 token | ✓ XSS 看不到302 在 fetch 場景會自動 followresponse body 為 FAA 內容;但 anchor / navigation 場景 JS 完全看不到)|
| 需要 FAA CORS 設定 | ✗ 需要fetch / XHR 受 CORS 限制) | ✓ 不需要CORS 只管 JS fetch / XHRserver-side 302 + browser navigation 走「navigation request」完全不適用 CORS|
| 跟 visionA OIDC cookie session 整合 | △ 額外 endpoint + JSON 流程 | ✓ 自然整合GET endpoint 走既有 AuthMiddleware|
| Frontend 程式碼複雜度 | 中fetch → 取 url → navigation | 低(一個 anchor tag / 一行 navigation|
**Token 仍需 4 個 scope**visionA-backend 為了跟 MC 換 delegated tokenservice token 仍需 `files:download.delegate` scope沒變。302 redirect 是「換到 token 後怎麼把它送進 browser」的差異不影響 token issuance 路徑。
### 3. 半自動 — converter 完成後使用者選擇路徑
job `completed` 後 frontend 詢問 user
| 動作 | 路徑 | 說明 |
|------|------|------|
| 「加到模型庫」 | visionA backend 跟 FAA pull NEFserver-to-serverscope `files:download.read`)→ 走既有 `/api/models/init` + `/api/models/finalize` 三段式 upload flow → 寫進 visionA `model.Model.Source="converted"` + `SourceJobID=<converter-job-id>` | 進 visionA storage 給後續 device load 用,這次走 backend 因為終點是 visionA storage |
| 「下載」 | 上述 §2 流程 | browser 直連 FAA |
兩者都先呼叫 converter `POST /api/v1/jobs/{id}/promote`promote response 含 `target_object_key`
### 4. 模組劃分 — 新增 `internal/conversion/`
不擴 `model.Model` schema`Source` / `SourceJobID` 欄位 ADR-005 / database.md 已預埋)。新增獨立 package
```
internal/conversion/
├── conversion.go # 對外 interface (Service)
├── converter_client.go # converter scheduler API client
├── faa_client.go # FAA API clientdelegated token + server-to-server pull
├── mc_token_client.go # MC client_credentials grant + token cache
└── flow.go # 整體 flow 協調init / poll / promote / pull / persist
```
`internal/conversion/` 依賴 `internal/model.Repository`(沿用既有 `/api/models/init+finalize` 邏輯,不繞過)。
### 5. Service token cache — 仿 converter scheduler 模式
- visionA backend 啟動時不主動取lazy第一次需要時才打 MC `POST {issuer}/oauth/token` (`grant_type=client_credentials`)
- token cache記憶體 + `sync.RWMutex``exp - 15s` 重取
- token request 失敗4xx 不重試log + 5xx response 給 client5xx 指數退避 max 2 次
- visionA-backend 預設 service-to-service token 共用converter:job.write / read / files:download.read / delegate 同一 client + 同一個 cache— MC 端發單一 token 含所有 4 個 scope
### 6. user_id 注入 + trust boundary
- **visionA backend 是唯一灌 user_id 的點**:從 cookie session 拿 OIDC sub → POST /jobs 時帶 user_id
- converter 信任 visionA backend 帶來的 user_idconverter 端的 trust boundary 設計詳見 converter openapi.yaml
- visionA-backend 必須確保:
1. 任何呼叫 converter 的 endpoint 一律先過 OIDC AuthMiddleware既有
2. job_id → user_id 的 mapping 記在 visionA 內部in-memory 或之後 DB每次 status / promote / download token 操作前 ownership 檢查
3. **絕不接受 client 直接傳 user_id / object_key** — 一律從 session 反查
### 7. 失敗模式 retry 矩陣
| 操作 | 重試策略 | 失敗回 frontend |
|------|---------|---------------|
| `POST /api/v1/jobs`init | 4xx 不重試5xx / network 退避 max 2 次 | 4xx 透傳 converter error code5xx 一律 `502 converter_unavailable` |
| `GET /api/v1/jobs/{id}`poll | 5xx / network 退避 max 3 次;各次 2s 內 timeout | 持續失敗 → frontend 視為 stuck提示重試 |
| `POST /promote` | 5xx / network 退避 max 2 次 | 失敗回 `502 promote_failed`job 留在 completed 狀態user 可重試 |
| FAA pull加到模型庫| 5xx / network 退避 max 2 次 | 失敗回 frontend `502 faa_unavailable`model record 不寫入 |
| MC token endpoint | 4xx fatal5xx 退避 max 2 次 | 失敗回 frontend `503 idp_unavailable` |
| MC delegated token | 4xx 透傳5xx 退避 max 2 次 | 失敗回 frontend `502 download_token_failed` |
### 8. 同 user active job 衝突409
converter 回 `409 user_has_active_job` → visionA-backend 透傳 `409 active_job_exists` + 既有 job 詳情給 frontend由 frontend 提示「你已有進行中的轉檔任務」。
## 考慮過的替代方案
### 方案 AUpload 也走 browser 直連converter 開放 CORS + 公網)
| 評估 | 內容 |
|------|------|
| 優點 | visionA-backend 不需處理 500MB streaming省記憶體與頻寬 |
| 缺點 | (1) converter 必須開公網或開 CORS安全表面變大(2) user_id trust boundary 失守browser 自己灌 user_id 等於沒驗);(3) converter 要新增 OIDC delegated upload token 機制converter 團隊額外工作量) |
| 排除原因 | **user_id 信任邊界守不住**converter 端要新增工作量。Upload 一次性,跨 internet 成本可接受 |
### 方案 BDownload 也走 visionA backend 中轉
| 評估 | 內容 |
|------|------|
| 優點 | visionA-backend 看得到所有下載流量、易做 audit |
| 缺點 | (1) 跨 internet 流量 N 倍(同 NEF 多次下載);(2) visionA-backend 變成 streaming bottleneck(3) FAA delegated token 機制(已實作)白做 |
| 排除原因 | **流量成本**FAA 已具備 delegated token不用浪費 |
### 方案 CUpload + Download 都走 backend 中轉(對稱設計)
| 評估 | 內容 |
|------|------|
| 排除原因 | 同方案 B 的流量成本問題 |
### 方案 D`model.Model` schema 加轉檔狀態
| 評估 | 內容 |
|------|------|
| 排除原因 | (1) 違反 SRP — model 應該只代表「已就緒可載入 device 的模型」;(2) job 狀態屬於 conversion 領域,不該污染 model 領域;(3) `model.Model.Source="converted" + SourceJobID` 已足夠表達來源關聯 |
## 後果 (Consequences)
### 正面影響
- **converter 零修改**:沿用既有 multipart endpoint
- **user_id 信任邊界乾淨**visionA-backend 是唯一灌入點,從 OIDC cookie session 拿,不可被偽造
- **流量成本最佳**upload 1× / download N× 的不對稱反映物流現實
- **Service token cache 可重用**:之後接 MC 其他 APIuser 組織查詢 / push 通知)零成本擴展
- **不破壞既有 model store**:沿用 `/api/models/init+finalize`conversion 只是「來源不同」
### 負面影響(接受的取捨)
- **visionA-backend 多一塊 streaming proxy 責任**:要寫好 `io.Pipe` + multipart streaming + context cancellation錯誤處理複雜
- **跨網路依賴增加**visionA-backend 失能 → 轉檔功能整個壞MC 失能 → token 無法簽,轉檔不可用
- **MVP 不做進度推送**user upload 完看 converter polling status沒 SSE → UX 較粗PRD Phase 0.8 接受)
- **Service token 集中失敗**:所有 4 個 scope 共用一個 cachetoken 失效會同時影響轉檔與下載MVP 階段可接受;後續可拆 cache
- **取消 job 不做**user 一旦 init 就要等到 converter 自己跑完或 timeoutconverter 端 expires_at 7 天)
### 風險
| 風險 | 緩解 |
|------|------|
| visionA-backend 處理 500MB upload 時記憶體爆 | 嚴格 streamingio.Pipe不暫存上線前壓測 1 個 + 2 個併發 upload若有問題降到 200MB cap |
| Service token endpoint 被打爆(過度頻繁取 token| token cache 確保 exp - 15s 內只取一次log 記每次 cache miss |
| FAA CORS 還沒加 | **不再阻擋**:採用 server-side 302 redirect 後browser navigation 不適用 CORS。Phase 1+ 若要改 fetch + Blob + a.click() 才需要 CORS例如要顯示下載進度條 |
| MC `usage=webhook_outbound` 命名不對(同 ADR-010| 不影響 visionA 程式碼MC 改 `web_app` 後只需改 admin 註冊欄位 |
| converter 在 visionA 上 AWS 後不可達(網路) | Phase 0.8 範圍visionA stage 仍可走 VPN 到 192.168.0.130prod 上線前需 converter 也上 AWS 或開 VPN tunnel — 列入 prod 上線 blocker |
| 同 user 多 tab 各 init 一個 job → converter 409 | frontend 在 init 前先打 visionA backend 查當前 user 有無 active jobbackend 直接拒絕第二次 init不打到 converter|
## 合規性
- [x] 與 PM Agent 確認:對齊 PRD Phase 0.8 範圍(半自動 / 模型 ≤500MB / ref_images ≤100×10MB / 同 user 1 active job / 不做歷史/取消/SSE/多 chip
- [x] 與 Architect 確認:模組切分(`internal/conversion/`)、不擴 model schema、沿用 `/api/models/init+finalize`
- [x] 使用者裁決upload 走 backend、download 走 delegated、半自動分流、不擴 schema
- [ ] DevOps 待確認visionA stage → 192.168.0.130 的網路可達性VPN / 直通)
- [x] FAA CORSPhase 0.8 採 server-side 302 redirect**不需要** CORS 設定(仿 FAA TestSite `DownloadFileDirect` pattern
- [ ] MC 待確認service client `23605e14a2c64660abd97e29963d8d58` 已授權 4 個 scope
## 相關文件
- 上位:`prd.md`Phase 0.8 轉檔功能 PRDPM 領地)
- 同層:`adr-006-no-redis-in-prototype.md`in-memory token cache 沿用)、`adr-010-oidc-bff.md`OIDC BFF`adr-011-supersede-adr-005.md``adr-013-public-client.md`service client 仍為 confidential
- 詳細實作:`conversion.md`(本 ADR 實作 spec`api/api-conversion.md`(對 frontend 的 API 規格)
- 安全:`security.md` §service-to-service token 流程(本次新增)
- 跨團隊整合:`/Users/jimchen/kneron_model_converter/docs/TODO-visionA-integration.md`
## 版本記錄
| 日期 | 版本 | 變更 |
|------|------|------|
| 2026-04-30 | 1.0 | 初版 — Phase 0.8 轉檔整合架構決策 |
| 2026-04-30 | 1.1 | Download flow 改為 server-side HTTP 302 redirect仿 FAA TestSite `DownloadFileDirect`token 不過 frontend JS、不需 FAA CORS |

View File

@ -0,0 +1,336 @@
# API — Conversion轉檔功能Phase 0.8
> **base URL**`https://stage-9527.innovedus.com:9527/`stage / `http://localhost:3721`dev
> **Auth**OIDC cookie session`visiona_session`),參見 `oidc-tdd.md`
> **同層**`api/api-spec.md`(總覽)、`conversion.md`(內部設計)、`adr/adr-014-conversion-integration.md`
> **角色**:給 visionA-frontend 實作時的 API 契約
---
## 通用約定
| 項目 | 值 |
|------|-----|
| 通用回應格式 | `{ "success": true, "data": {...} }` / `{ "success": false, "error": {code, message, details?} }` |
| Auth | 走 cookiefrontend 用 `credentials: "include"` |
| Request ID | header `X-Request-Id`visionA-backend 沒收到會自動產生) |
| Content-Type | 除 `init``multipart/form-data` 外,其他 JSON |
---
## 1. `POST /api/conversion/init`
啟動轉檔 job — 把 multipart body streaming proxy 到 converter。
### Request
```
POST /api/conversion/init HTTP/1.1
Cookie: visiona_session=...
Content-Type: multipart/form-data; boundary=----xyz
```
multipart fields**注意:不要帶 user_idbackend 會從 cookie 灌**
| Field | Type | 必填 | 說明 |
|-------|------|-----|------|
| `model` | file | ✓ | `.onnx` / `.tflite`,≤ 500MB |
| `ref_images[]` | file × N | — | 可 0100 張,每張 ≤ 10MB |
| `model_id` | text | ✓ | 165535使用者自訂編號converter 要求) |
| `version` | text | ✓ | 例 `v1.0.0` |
| `platform` | text | ✓ | `520` / `720` |
| `enable_evaluate` | text | — | `true`/`false`,預設 `false` |
| `enable_sim_fp` | text | — | 同上 |
| `enable_sim_fixed` | text | — | 同上 |
| `enable_sim_hw` | text | — | 同上 |
### Response 200
```json
{
"success": true,
"data": {
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "running",
"stage": "onnx",
"progress": 0,
"stage_progress": 0,
"created_at": "2026-04-30T12:00:00Z",
"expires_at": "2026-05-07T12:00:00Z"
}
}
```
> `expires_at` = `created_at + 7d`converter 7 天 GC 截止時間。frontend 用於顯示倒數與切「已過期」狀態。詳見 `conversion.md` §2.6.2。
### 錯誤
| HTTP | code | 來源 | 處理建議 |
|------|------|-----|---------|
| 400 | `validation_failed` | converter | 顯示 details.fields |
| 401 | `unauthorized` | visionA | redirect `/login` |
| 409 | `active_job_exists` | visionA pre-check / converter | 顯示「你已有進行中任務」+ details.job |
| 413 | `payload_too_large` | converter | 提示檔案大小限制 |
| 502 | `converter_unavailable` | visionA | 提示「轉檔服務暫時無法使用」+ 重試按鈕 |
| 503 | `idp_unavailable` / `service_busy` | visionA / converter | 提示稍後重試 |
---
## 2. `GET /api/conversion/{job_id}`
查 job 狀態。Frontend 用 polling建議間隔 2 秒。
### Response 200
```json
{
"success": true,
"data": {
"job_id": "550e8400-...",
"status": "running",
"stage": "bie",
"progress": 45,
"stage_progress": 60,
"created_at": "2026-04-30T12:00:00Z",
"updated_at": "2026-04-30T12:05:30Z",
"expires_at": "2026-05-07T12:00:00Z",
"source_filename": "yolov5s.onnx",
"target_chip": "720",
"error_code": null,
"error_message": null
}
}
```
`status` enum`created` / `running` / `completed` / `failed`
`stage` enum`onnx` / `bie` / `nef`
| 欄位 | 用途 |
|------|------|
| `expires_at` | `created_at + 7d`frontend 顯示倒數 |
| `source_filename` | 原始檔名(顯示用,例 wireframe success card 「yolov5s.onnx → yolov5s_kl720.nef」|
| `target_chip` | 從 init 時的 `platform` 欄回傳(`520` / `720` / `630` / `730` |
### 錯誤
| HTTP | code | 處理 |
|------|------|-----|
| 403 | `forbidden` | job 不屬於當前 user |
| 404 | `not_found` | job_id 不存在 / 已過期 |
| 502 | `converter_unavailable` | 持續失敗 → 提示重試 |
### Polling 建議
- Frontend 收到 `status=running` → 2s 後再 poll
- `status=completed` / `failed` → 停止 polling
- 連續 5 次 5xx → 停止 polling 並顯示錯誤
---
## 3. `POST /api/conversion/{job_id}/promote-to-models`
「加到模型庫」 — 完整流程promote → FAA pull → 寫進 visionA model store。
### Request
```json
POST /api/conversion/{job_id}/promote-to-models
Content-Type: application/json
{
"name": "yolov5s_kl720"
}
```
| Field | 必填 | 說明 |
|-------|-----|------|
| `name` | ✓ | 在 model 庫顯示的名字。Design Phase 0.8 wireframe §7.1 要求此欄位,預設 `{job.source_filename_stem}_{target_chip.lower()}` |
| `description` | — | Phase 0.8 不送,留 Phase 1— backend 接受但忽略Phase 1 開放 |
> **與 Design 對齊(議題 #4**Phase 0.8 wireframe §7.1 的 import Dialog **只有名稱欄位**不含描述backend Phase 0.8 也只用 `name``description` 雖在 schema 內但不顯示給使用者填寫。Phase 1 Design 開放描述欄位時 backend 已 ready無需改 API。
### Response 201
```json
{
"success": true,
"data": {
"model_id": "abc-123",
"source": "converted",
"source_job_id": "550e8400-...",
"name": "YOLOv5 Face KL520",
"target_chip": "kl520",
"file_size": 12345678,
"status": "ready",
"created_at": "2026-04-30T12:30:00Z"
}
}
```
> **格式註記**:這個 response 是既有 `internal/model.Model` schema沿用`target_chip``"kl520"` 小寫格式。
> 跟 §2 / §5 conversion job 的 `target_chip``"720"`converter `platform` enum**不同欄位、不同來源**
> - conversion job來自 converter scheduler 的 `platform` 欄位(`"520"` / `"630"` / `"720"` / `"730"`
> - model.target_chipvisionA 既有 model schema`"kl520"` / `"kl720"` / etc
>
> visionA-frontend 統一 normalize 成 UI 內部形式 `KL520` / `KL720` 顯示(見 `lib/api/conversion.ts` `normalizeTargetChip`)。
> Phase 1 評估是否值得在 backend 把兩邊統一(可能影響既有 model store 多處 caller動範圍大
### 錯誤
| HTTP | code | 處理 |
|------|------|-----|
| 403 | `forbidden` | 不是該 user 的 job |
| 404 | `not_found` | job_id 不存在 |
| 409 | `job_not_completed` | job 還沒 completed不能 promote |
| 502 | `converter_unavailable` | promote 失敗,可重試 |
| 502 | `faa_unavailable` | FAA pull 失敗,可重試 |
**冪等性**:對同一 `job_id` 重複呼叫;若已建過 model record回 200 + 既有 model 詳情(不重新建)。
---
## 4. `GET /api/conversion/{job_id}/download`
「下載」 — visionA-backend server-side HTTP 302 redirect 到 FAA delegated URL。**Token 永遠不過 frontend JS**。
仿 FAA TestSite `DownloadFileDirect``FileAccessAgent.TestSite/Controllers/HomeController.cs:255-282`pattern。
### Request
```
GET /api/conversion/{job_id}/download HTTP/1.1
Cookie: visiona_session=...
```
無 query string、無 body。
### Response 302成功
```
HTTP/1.1 302 Found
Location: http://192.168.0.130:5081/files/jobs/550e8400-.../result.nef?access_token=opaque-token-xxx
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
Pragma: no-cache
```
browser 自動 follow Location直連 FAA 下載 NEF。
### Frontend 使用方式
```html
<!-- 推薦anchor tagbrowser 原生處理 -->
<a href={`/api/conversion/${jobId}/download`} download>下載</a>
```
或:
```ts
// 程式化觸發
window.location.href = `/api/conversion/${jobId}/download`;
```
Frontend **不需要也看不到** download URL / token / object_key — 全在 server-side + browser navigation 中流轉。
**為什麼不需要 FAA CORS**browser navigation request包含 `<a href>` click 與 `window.location.href`)不適用 CORSCORS 只管 JS 發起的 fetch / XHR。Server-side 302 redirect + 同源 endpoint 完全在 CORS 範圍外。
### 錯誤(不 redirect依 Accept header 回 JSON 或 HTML 錯誤頁)
| HTTP | code | 處理 |
|------|------|-----|
| 401 | `unauthorized` | 沒登入redirect /login前端攔截|
| 403 | `forbidden` | 不是該 user 的 job |
| 404 | `not_found` | job_id 不存在 / 已過期 |
| 409 | `job_not_completed` | job 還沒 completed不能下載 |
| 502 | `converter_unavailable` | promote 失敗(首次下載且尚未 promote 過時可能發生)|
| 502 | `mc_token_unavailable` / `download_token_failed` | MC 換 delegated token 失敗,提示重試 |
**錯誤回應格式**:依 `Accept` header
- `Accept: application/json``{success:false, error:{code, message}}`
- `Accept: text/html`(一般 anchor 觸發) → HTML 錯誤頁browser 直接顯示
**注意**
- 每次「下載」按鈕都直接打 `/download` endpoint不要前端 cache 任何中間狀態
- Token TTL 短5 分鐘預設),不過反正 frontend 也碰不到 token
- 不會與 `promote-to-models` 衝突;兩者內部都會 ensurePromoted冪等兩條路徑都拿同一個 target_object_key
---
## 5. `GET /api/conversion/active`
查當前 user 是否有 active job — 給 frontend 在跳出「上傳」UI 前 pre-check。
### Response 200有 active
```json
{
"success": true,
"data": {
"has_active": true,
"job": {
"job_id": "550e8400-...",
"status": "running",
"stage": "bie",
"progress": 45,
"created_at": "2026-04-30T12:00:00Z",
"expires_at": "2026-05-07T12:00:00Z",
"source_filename": "yolov5s.onnx",
"target_chip": "720"
}
}
}
```
> 此 endpoint 與 `GET /api/conversion/{job_id}` 回傳同一個 `Job` shapewireframe §3.3、flow-conversion.md §5.1 依賴此 shape 做「進入頁面就直接落 processing 畫面」的恢復邏輯。
**重啟恢復行為Phase 0.8 強化)**:當 visionA-backend 重啟導致 in-memory ownership 丟失時,此 endpoint 會 fallback 對 converter 查 `GET /api/v1/jobs?user_id=<sub>&status=in_progress` 並重建 ownershiplazy rebuild。對 frontend 完全透明(同樣 endpoint、同樣 response shape。詳見 `conversion.md` §2.6.1。
### Response 200無 active
```json
{
"success": true,
"data": {
"has_active": false,
"job": null
}
}
```
### 用法
Frontend 在「轉檔」入口的 `/conversion` 頁載入時打這個 endpoint
- `has_active=true` → 顯示「你目前有進行中的任務」+ 跳轉到該 job 的進度頁
- `has_active=false` → 顯示上傳表單
---
## 錯誤碼總覽
對齊 `conversion.md` §6。前端 i18n key 統一 `conversion.error.<short-name>`
| code | HTTP | i18n key | 預設訊息zh-TW |
|------|------|----------|------------------|
| `validation_failed` | 400 | `conversion.error.validation` | 上傳的內容不符合要求 |
| `unauthorized` | 401 | `common.error.unauthorized` | 請先登入 |
| `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此任務 |
| `not_found` | 404 | `conversion.error.not_found` | 任務不存在 |
| `active_job_exists` | 409 | `conversion.error.active_job` | 你目前已有進行中的轉檔任務 |
| `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成(`promote-to-models``download` 共用) |
| `payload_too_large` | 413 | `conversion.error.too_large` | 檔案超過大小限制 |
| `converter_unavailable` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用 |
| `faa_unavailable` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用 |
| `download_token_failed` | 502 | `conversion.error.token_failed` | 無法取得下載授權MC 4xx|
| `mc_token_unavailable` | 502 | `conversion.error.token_failed` | 無法取得下載授權MC 5xx / 持續失敗) |
| `idp_unavailable` | 503 | `conversion.error.idp_down` | 認證服務暫時無法使用 |
| `service_busy` | 503 | `conversion.error.busy` | 系統繁忙,請稍後再試 |
---
## 版本記錄
| 日期 | 版本 | 變更 |
|------|------|------|
| 2026-04-30 | 0.1 | 初稿Phase 0.8 MVP 範圍) |
| 2026-04-30 | 0.2 | §4 download endpoint 從 `POST /{job}/download-token`(回 JSON `{download_url, expires_at}`)改為 `GET /{job}/download`HTTP 302 redirect仿 FAA TestSite `DownloadFileDirect` patterntoken 不過 frontend JS、不需 FAA CORS`job_not_completed` HTTP code 從 400 改為 409 + 補 `mc_token_unavailable` |
| 2026-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合Job response shape 補 `expires_at` / `source_filename` / `target_chip`(議題 #7`/api/conversion/active` 行為文件化 lazy rebuild 機制(議題 #2 重啟恢復);`promote-to-models` request body 對齊 Design 單欄位(議題 #4`description` 留 Phase 1 |

View File

@ -0,0 +1,262 @@
# Converter Integration Contract
> 本文件定義 **visionA-backend 呼叫 kneron_model_converter 的 API 契約**
> 目的:提前把介面定義清楚,讓 converter 團隊知道要實作什麼;同時讓 visionA-backend 雛形可先用 stub 開發。
---
## 1. 通訊方向
```
visionA-backend/api-server kneron_model_converter
│ │
│ 1. POST /v1/jobs提交轉檔
│ ────────────────────────────────────►│
│ │
│ ◄────── 202 + {job_id} ──────────────│
│ │
│ 2. GET /v1/jobs/{id}(輪詢 / 或等 webhook
│ ────────────────────────────────────►│
│ ◄────── 200 + {status, result_url}──│
│ │
│ 3. 下載產物GET result_url
│ ────────────────────────────────────►│
```
Converter 可選:
- **Pull 模式**visionA 輪詢 `/v1/jobs/{id}`
- **Push 模式**visionA 提供 webhook URLconverter 完成後回呼
雛形先用 **Pull 模式**Phase 1 評估 webhook。
---
## 2. 認證
**visionA → converter**
- 服務對服務,使用 API Key 或 mTLS
- Header`Authorization: Bearer <VISIONA_CONVERTER_API_KEY>`
API Key 由 converter 團隊簽發,放 `VISIONA_CONVERTER_API_KEY` env。
---
## 3. 端點
### 3.1 POST `/v1/jobs` — 提交轉檔
**Request**
```json
POST /v1/jobs
Authorization: Bearer ...
Content-Type: application/json
{
"source": {
"type": "url",
"url": "https://storage.visiona.cloud/converter/source/demo-user/job-xxx.onnx?signature=...",
"checksum_sha256": "abc123...",
"format": "onnx"
},
"target": {
"chip": "kl520",
"quantization": "int8",
"input_shape": [1, 3, 224, 224]
},
"callback": {
"webhook_url": null, // 雛形先 null
"idempotency_key": "<uuid>"
},
"client_job_id": "<visionA 這邊的 job id對應 converter_jobs.id>"
}
```
**欄位說明**
| 欄位 | 必要 | 說明 |
|------|-----|------|
| `source.type` | ✓ | `"url"` \| `"upload"`(雛形只支援 url |
| `source.url` | ✓ | Presigned GET URLconverter 自己下載 |
| `source.checksum_sha256` | ✓ | visionA 計算好的 sha256converter 下載後驗證 |
| `source.format` | ✓ | `"onnx"` \| `"keras"` \| `"tflite"` \| ... |
| `target.chip` | ✓ | `"kl520"` \| `"kl720"` |
| `target.quantization` | — | `"int8"` \| `"fp16"`,預設依 chip |
| `target.input_shape` | — | 若 source 不含 shape由此補 |
| `callback.webhook_url` | — | 未來 push 模式 |
| `callback.idempotency_key` | ✓ | 重試時避免重複執行 |
| `client_job_id` | ✓ | visionA 內部 job idconverter 回傳時要帶上 |
**Response 202**
```json
{
"success": true,
"data": {
"job_id": "cvt-abc-123", // converter 側的 id
"status": "queued",
"accepted_at": "2026-04-21T12:00:00Z",
"estimated_duration_seconds": 120
}
}
```
**Response 錯誤**
```json
{
"success": false,
"error": {
"code": "UNSUPPORTED_FORMAT" | "INVALID_CHECKSUM" | "SOURCE_UNREACHABLE" | "QUOTA_EXCEEDED",
"message": "..."
}
}
```
---
### 3.2 GET `/v1/jobs/{job_id}` — 查詢狀態
**Response 200**
```json
{
"success": true,
"data": {
"job_id": "cvt-abc-123",
"client_job_id": "<visionA >",
"status": "queued" | "running" | "succeeded" | "failed",
"progress": 0.65, // 0.0 - 1.0
"stage": "quantizing", // 可選
"accepted_at": "...",
"started_at": "...",
"completed_at": "...",
"result": { // status == succeeded 時才有
"url": "https://converter.cloud/result/....nef?signature=...",
"url_expires_at": "...",
"checksum_sha256": "...",
"size_bytes": 12345678,
"target_chip": "kl520"
},
"error": { // status == failed 時才有
"code": "QUANTIZATION_FAILED",
"message": "Layer ... not supported",
"details": {}
}
}
}
```
---
### 3.3 POST `/v1/jobs/{job_id}/cancel` — 取消
**Response**
```json
{ "success": true, "data": { "job_id": "...", "status": "cancelled" } }
```
若已 completed → `400 ALREADY_COMPLETED`;若 already cancelled → `200` idempotent。
---
### 3.4 Webhook未來
Converter push 到 visionA 的 webhook URL
**Requestconverter → visionA**
```json
POST https://api.visiona.cloud/webhooks/converter
X-Converter-Signature: sha256=<hmac of body using shared secret>
Content-Type: application/json
{
"event": "job.completed" | "job.failed",
"job_id": "cvt-abc-123",
"client_job_id": "<visionA >",
"status": "succeeded" | "failed",
"result": { ... },
"error": { ... },
"timestamp": "..."
}
```
**visionA 驗證 signature 後**
- 更新 `converter_jobs`
- 若 succeeded下載產物存到 `storage/converter/result/`
- 建立對應的 `models` recordsource=converted, source_job_id=<client_job_id>
- 回 200
**retry 約定**
- Webhook 失敗 converter 最多重試 5 次,指數退避
- visionA 必須 idempotent 處理(用 `event + job_id` 當 key
---
## 4. 雛形階段visionA 端 stub
```go
// internal/converter/stub.go
type StubClient struct {
jobs map[string]*Job
mu sync.Mutex
}
func (s *StubClient) SubmitConvert(ctx, req) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
jobID := "stub-job-" + uuid.NewString()
s.jobs[jobID] = &Job{
ID: jobID, Status: "queued", CreatedAt: time.Now(),
TargetChip: req.TargetChip,
}
// 雛形15 秒後「完成」,指向假 result URL
time.AfterFunc(15*time.Second, func() {
s.mu.Lock()
defer s.mu.Unlock()
if j, ok := s.jobs[jobID]; ok {
j.Status = "succeeded"
j.ResultKey = "stub-result-key"
j.CompletedAt = ptrTime(time.Now())
}
})
return jobID, nil
}
```
雛形期前端可以用這個 stub 走完整 UX但不會真的產生 `.nef`
---
## 5. Error 對應表
| Converter 回傳 | visionA 前端顯示 |
|---------------|----------------|
| `UNSUPPORTED_FORMAT` | 「目前不支援此格式」|
| `INVALID_CHECKSUM` | 「檔案下載驗證失敗,請重新上傳」|
| `QUOTA_EXCEEDED` | 「本月轉檔配額已滿」|
| `QUANTIZATION_FAILED` | 「模型轉檔失敗:[detail]」|
| 其他 | 「轉檔失敗,請聯絡支援」|
---
## 6. 相容性 / 版本
- URL 含 `/v1/` 前綴,日後升級 `/v2/` 可並存
- 欄位採「新增只是 optional」原則不破壞舊版
- visionA 用 env `VISIONA_CONVERTER_API_VERSION` 切換(預設 `v1`
---
## 7. 給 Converter 團隊的確認清單
- [ ] 同意採用此 API spec
- [ ] 確認 source.type `"url"` 的 presigned URL 長度 / TTL 要求
- [ ] 確認支援的 source format 清單
- [ ] 確認 webhook push 模式的實作意願 / 時程
- [ ] 確認 rate limit / quota 政策
- [ ] 確認產物儲存位置converter 自己的 bucket還是回 visionA bucket
- [ ] 提供測試 API key
---
**雛形實作**`internal/converter/stub.go` + 前端走 stub 驗流程。
**Phase 1**`internal/converter/http.go` 實作上述 API + webhook endpoint。

View File

@ -0,0 +1,232 @@
# Internal API — api-server ↔ remote-proxy
> **雛形Phase 0就要實作**2026-04-22 Q1 裁決)。
> 雛形採雙 binary 部署:`api-server``remote-proxy` 各自為獨立 process透過本文件定義的 HTTP 介面溝通。
> `api-server` 無狀態;`remote-proxy` 持有所有 tunnel sessionin-memory無 Redis見 ADR-006
---
## 背景
雛形(單一 remote-proxy instance
```
[瀏覽器] ─HTTPS─► [api-server] [remote-proxy] ←WS+yamux─ [local agent]
│ ▲
│ GET /internal/session/:token │
│ POST /internal/forward/http │
│ GET /internal/forward/ws │
└────────────────────────────────────┘
(internal HTTP走內網 / localhost)
```
- `api-server` **不持有** `*yamux.Session`;要走 tunnel 的請求一律轉發給 `remote-proxy`
- `remote-proxy` 持有 `map[token]*yamux.Session`,本機記憶體
Phase 1 當 `remote-proxy` 水平擴展到多節點時api-server 還需要有「找到對的 proxy」的能力`tunnel.md` §5.4),但此 HTTP 介面的形狀不變。
---
## 端點
> **雙軌設計**B3 Review Major 1 修復 / 2026-04-22
>
> api-server ↔ remote-proxy 的 HTTP forward 提供兩條路徑:
>
> | Endpoint | 封裝 | 支援 streaming | 支援 WS upgrade | 適合場景 |
> |----------|------|---------------|----------------|---------|
> | `POST /internal/forward/http` | JSON + base64 body | ❌ | ❌ | 一次性 JSON request/responseGET /healthz、POST /api/devices 等) |
> | `POST /internal/forward/raw` | Hijack 成 raw TCP | ✅MJPEG / SSE / chunked | ✅(只要 caller 按 HTTP 協定送 upgrade | `session.ProxyClient.OpenStream(ctx) net.Conn` 的底層B4 ProxyClient 預設走這條 |
>
> **兩者並存**是刻意為之 — JSON 版對簡單 API 好寫好測raw 版是 OpenStream 語意的真實底層B4 的 `session.RemoteHandle` 必須走這條)。
### POST `/internal/forward/http`JSON 版)
**用途**api-server 把一個一次性的 JSON request 轉發給 proxy 節點,由該節點透過 tunnel 打給 local agent。**不支援 streaming**;對 MJPEG / SSE / chunked body 應改走 `/internal/forward/raw`
**Request**
```
POST /internal/forward/http?<optional-token-in-query>
Content-Type: application/json
{
"session_token": "vAc_...",
"method": "GET",
"path": "/api/devices",
"headers": {"X-From-Api-Server": "value"},
"body": "<base64-encoded request body可省略>"
}
```
**Response**200 OK 或 502 Bad Gateway
```json
{
"status": 200,
"headers": { "Content-Type": ["application/json"] },
"body": "<base64-encoded response body>",
"error": null
}
```
失敗時:
```json
{
"error": { "code": "TUNNEL_DISCONNECTED", "message": "session not connected" }
}
```
**實作要點**proxy 端):
- `store.Lookup(token)` 找 handle
- `handle.OpenStream(ctx)` 開 yamux stream
- 組 `http.Request``req.Write(stream)`
- `http.ReadResponse(bufio.NewReader(stream), req)``io.ReadAll(body)` → base64 encode 回 JSON
### POST `/internal/forward/raw`raw bytes + Hijack 版B3 Review Major 1 修復)
**用途**:把 api-server 的 HTTP 連線接管成 raw TCP與 tunnel 裡的 yamux stream 雙向 pipe。支援任意 HTTP 流量streaming body、長連線、WS upgrade`session.ProxyClient.OpenStream(ctx) net.Conn` 語意的真實底層。
**Request**api-server 送):
```
POST /internal/forward/raw?token=<session-token> HTTP/1.1
Host: <proxy-internal-host>
Content-Length: 0
```
**Response 握手**proxy 在 session 找到後寫回):
```
HTTP/1.1 200 Connected
<空白行>
```
**此時 HTTP 協議結束**proxy 呼叫 `http.Hijacker.Hijack()` 把底層連線交出,與 yamux stream 做雙向 `io.Copy`。api-server 此後把這條連線當成 `net.Conn` 直接用:
```go
// api-server 端B4 實作,示意)
conn := dial(/internal/forward/raw?token=xxx)
readHandshake(conn) // 讀 "HTTP/1.1 200 Connected" + 空白行
// 從這裡開始 conn 就是一條通到 local agent 的 raw TCP stream透過 yamux
req.Write(conn) // 送完整 HTTP request
resp, _ := http.ReadResponse(bufio.NewReader(conn), req)
io.Copy(browserResponseWriter, resp.Body) // streaming friendly
```
**失敗處理**
- Session 不存在:在 hijack **之前**回 `502 JSON { error: TUNNEL_DISCONNECTED }`(一般 HTTP client 可讀)
- Hijacking 不支援:回 `500 JSON { error: HIJACK_UNSUPPORTED }`
- Hijack 後 `OpenStream` 失敗:在 hijacked 連線上寫回 `HTTP/1.1 502 Bad Gateway` + JSON body 再關閉caller 拿到的仍是合法 HTTP response
**為何用 CONNECT-style 握手而非直接雙向 pipe**
- 需要一個明確的「session ready」訊號避免 caller 不知道 session 是否存在就開始 write
- 與既有 HTTP 基礎設施相容middleware / LB 能正確路由與紀錄一次 request
- api-server 端 B4 實作時可以用標準 `http.Client` 發第一個 request拿到 underlying conn 後再切換成 raw 模式
**Flusher 支援**:不需要 — raw 模式下 io.Copy 天然支援任意 chunk / streaming 語意,由 yamux 負責在 TCP 上傳輸。
---
### GET `/internal/forward/ws`WebSocket upgrade
**用途**:與 `/http` 同,但用於 WebSocket。api-server 收到瀏覽器 WS → 升級 → 在內部開一個到 proxy 的 HTTP Connection Upgrade。
**Request**
```
GET /internal/forward/ws?token=<pairing-token>
Upgrade: websocket
... (原瀏覽器 WS upgrade request headers)
```
**Response**
```
101 Switching Protocols
... (yamux stream 裡 local agent 回來的 101 response)
```
proxy 拿到 upgrade request 後:
1. `session.Open()` 拿 stream
2. 把 upgrade request 寫進 stream給 local agent
3. 讀 stream 回來的 101從 local agent
4. Hijack caller 連線,與 stream 雙向 pipe
邏輯同 POC `proxyWebSocket`只是「caller」換成 api-serverinternal
---
### GET `/internal/session/:token`
**用途**proxy 自我查詢某個 token 的 session 狀態debug / health 用。
**Response**
```json
{
"token": "pk_xxx",
"connected": true,
"connected_at": "...",
"last_seen_at": "...",
"stream_open_count": 3
}
```
---
### POST `/internal/session/:token/close`
**用途**:後台運維強制斷某條 tunnel使用者 revoke token 後)。
---
## 安全
- 內部 API 只監聽 **internal network interface**VPC 私網、K8s ClusterIP
- 防禦:
- 網路層面security group / NetworkPolicy 只允許 api-server pod 連
- 應用層面Phase 1+mTLS 或 shared secret header `X-Internal-Auth: <bearer>`
- **絕不對外暴露**(公網 LB 白名單)
---
## 觀測
Metrics
- `proxy_internal_forward_total{endpoint, result}`
- `proxy_internal_forward_duration_seconds`
- `proxy_stream_open_total`
- `proxy_stream_error_total{reason}`
Log
- `event=forward path=/api/devices token_prefix=pk_abcd duration_ms=45 status=200`
---
## 雛形行為Phase 0 必做Q1 裁決)
**雛形就是雙 binary**,本文件的所有 endpoints 都是 Phase 0 必須實作:
- `remote-proxy` 提供這些 internal HTTP endpoints
- `api-server` 透過 `ProxyClientStore` 呼叫這些 endpoints
雛形 API handler 端的呼叫方式:
```go
// api-server 端
handle, err := sessionStore.Lookup(ctx, token) // 實際走 GET /internal/session/:token
if err != nil { return ErrTunnelDisconnected }
stream, err := handle.OpenStream(ctx) // RemoteHandle 實作POST /internal/forward/rawB3 Major 1 修復後)
// 寫 request、讀 response — 語義跟直接拿 yamux stream 一致
```
**B4 實作提醒**
- `RemoteHandle.OpenStream` 必須走 `POST /internal/forward/raw`hijack raw TCP不能走 JSON 版的 `/internal/forward/http`
- 對於簡單 JSON API 的同步呼叫(如 internal 健康檢查),仍可使用 JSON 版以簡化程式碼
Phase 1 多節點時:
- 介面完全不變
- `ProxyClientStore` 底層增加「找到正確 proxy 節點」的邏輯(見 `tunnel.md` §5.4
---
**總結**
- ✅ 雛形:`api-server``remote-proxy` 透過本文件定義的 HTTP 介面溝通(必做)
- ✅ Phase 1介面不變底層 routing 策略升級
- ❌ 雛形 **不**走「api-server 與 remote-proxy 同進程共用 map」的捷徑那條路會讓 api-server 變成有狀態,走不到 Phase 1

View File

@ -0,0 +1,335 @@
# API Spec — 對前端的 REST + WebSocket 端點
> **base URL**`https://api.visiona.cloud`Phase 1/ `http://localhost:3001`(雛形)
> **認證**`Authorization: Bearer <JWT>`(雛形可省略,走 `StaticAuthService`
> **通用回應格式**
> ```json
> { "success": true, "data": {...} }
> { "success": false, "error": { "code": "ERR_CODE", "message": "..." } }
> ```
---
## 1. Auth雛形 stub
### POST `/api/auth/login`
- 雛形:回 `501 { code: "NOT_IMPLEMENTED" }`
- Phase 1`{ email, password }``{ user, access_token, refresh_token }`
### POST `/api/auth/register`
- 同上
### POST `/api/auth/logout`
- Phase 1清 refresh token
### GET `/api/auth/me`
- 雛形:回 `demo-user` hard-coded
- Phase 1從 JWT 取
---
## 2. Pairing
### POST `/api/pairing/token`
- Auth required雛形靜默通過
- 雛形 Response
```json
{ "success": false, "error": { "code": "NOT_IMPLEMENTED", "message": "Dev uses env VISIONA_PAIRING_TOKEN" } }
```
- Phase 1 Response
```json
{
"success": true,
"data": {
"token": "pk_AbCd1234...",
"expires_at": "2026-04-21T13:00:00Z"
}
}
```
### GET `/api/pairing/status`
- 查詢當前 user 的 tunnel 連線狀態
- Response:
```json
{
"success": true,
"data": {
"connected": true,
"connected_at": "2026-04-21T12:00:00Z",
"last_seen_at": "2026-04-21T12:34:56Z",
"device_id": "dev-xxx",
"agent_version": "local-tool 1.2.3"
}
}
```
### GET `/api/pairing/tokens`
- List 當前 user 的所有 tokens
- Phase 1回 array of `{ id, device_id, kind, created_at, last_seen_at }`
### DELETE `/api/pairing/tokens/:id`
- 撤銷指定 token
- Phase 1 實作;雛形 501
---
## 3. Devices
以下大部分端點**會被轉發到 local agent**。api-server 行為:
1. 檢查 user 有 tunnel 連線
2. 若 device_id 有傳,檢查 ownership
3. 透過 tunnel forward 請求到 local agent沿用 POC `handleProxy`
4. 回傳 local agent 的 response
路徑與回應格式**與 local-tool 相同**,前端改 base URL 即可。
### GET `/api/devices` — 列出當前本地掃到的裝置
### POST `/api/devices/scan` — 觸發重掃
### GET `/api/devices/:id` — 單一裝置
### POST `/api/devices/:id/connect`
### POST `/api/devices/:id/disconnect`
### POST `/api/devices/:id/flash` — 燒韌體(透過 tunnel
### POST `/api/devices/:id/inference/start`
### POST `/api/devices/:id/inference/stop`
**雲端特有(非 tunnel forward**
### GET `/api/cloud/devices` — 列出「我在雲端綁過的 Device records」
- 與 `GET /api/devices` 不同:這個是查雲端 DB不問 local agent
- 雛形:從 `InMemoryDeviceRepository`
- Response`[{ id, name, device_type, serial_number, status, last_seen_at }]`
### POST `/api/cloud/devices/:id/rename`
- 改雲端上的 device name
---
## 4. Models
### GET `/api/models` — 列出 user 的 model
- 雲端模型(存 storage+ preset models硬編碼
- Response
```json
{
"success": true,
"data": [
{
"id": "abc-123",
"name": "YOLOv5 Face",
"target_chip": "kl520",
"file_size": 12345678,
"source": "uploaded",
"created_at": "..."
}
]
}
```
### GET `/api/models/:id`
- Model 詳情
### POST `/api/models/init` — 初始化上傳
- Request: `{ name, file_size, checksum, target_chip, description? }`
- Response:
```json
{
"success": true,
"data": {
"model_id": "new-id",
"upload_url": "https://...presigned-put-url...",
"upload_expires_at": "..."
}
}
```
### POST `/api/models/:id/finalize`
- 在 presigned PUT 成功後呼叫
- api-server 驗證檔案已存在、size / checksum 對 → status 改 "ready"
### DELETE `/api/models/:id`
### POST `/api/models/:id/load-to-device`
- Body`{ device_id }`
- api-server 產 presigned GET URL → 透過 tunnel 送 local agent 「下載並載入」
- 回傳 job status
---
## 5. Clusters從 POC 搬)
### GET `/api/clusters`
### POST `/api/clusters`
- Body: `{ name, device_ids: [...] }`
### GET `/api/clusters/:id`
### DELETE `/api/clusters/:id`
### POST `/api/clusters/:id/devices`
### DELETE `/api/clusters/:id/devices/:deviceId`
### PUT `/api/clusters/:id/devices/:deviceId/weight`
### POST `/api/clusters/:id/flash`
### POST `/api/clusters/:id/inference/start`
### POST `/api/clusters/:id/inference/stop`
---
## 6. Camera / Media
與 local-tool 相同,全部透過 tunnel forward
### GET `/api/camera/list`
### POST `/api/camera/start`
### POST `/api/camera/stop`
### GET `/api/camera/stream` — MJPEG透過 tunnel streaming
### POST `/api/media/upload/image`
### POST `/api/media/upload/video`
### POST `/api/media/upload/batch-images`
### GET `/api/media/batch-images/:index`
### POST `/api/media/seek`
---
## 7. System
### GET `/api/system/health`
- 雲端側:回 api-server 自己的健康 + tunnel 連線狀態
```json
{
"success": true,
"data": {
"api_server": "ok",
"tunnel_connected": true,
"agent_last_seen_at": "..."
}
}
```
### GET `/api/system/info`
- 版本資訊
---
## 8. Converter
### 8.1 Phase 1 stub既有保留
> 雛形 stub 路由Phase 0.8 的真實整合改走 §8.2 `/api/conversion/*`,下列路由保留為 placeholder 待 Phase 1 視需要 supersede。
#### POST `/api/converter/jobs`
- Body`{ source_model_key, target_chip, params? }`
- Response`{ job_id, status: "queued" }`
#### GET `/api/converter/jobs`
- List user 的 jobs
#### GET `/api/converter/jobs/:id`
- Job 狀態
#### GET `/api/converter/jobs/:id/download`
- 下載產物presigned URL redirect
**詳細契約** → [`api-converter-contract.md`](api-converter-contract.md)
### 8.2 Phase 0.8 — `/api/conversion/*`(轉檔功能整合)
正式對接 kneron_model_converter scheduler + FAA delegated download
- `POST /api/conversion/init` — multipart streaming proxy 到 converter建 job
- `GET /api/conversion/{job_id}` — 查狀態HTTP pollingfrontend 間隔 2s
- `POST /api/conversion/{job_id}/promote-to-models` — 「加到模型庫」
- `POST /api/conversion/{job_id}/download-token` — 換 browser 直連 FAA 的 delegated URL
- `GET /api/conversion/active` — 查當前 user 是否有 active job
**詳細契約** → [`api-conversion.md`](api-conversion.md)
**內部設計** → [`../conversion.md`](../conversion.md)
**ADR** → [`../adr/adr-014-conversion-integration.md`](../adr/adr-014-conversion-integration.md)
---
## 9. WebSocket
### WS `/ws/devices/events`
- 訂閱「裝置上下線」事件
- Server push
```json
{ "type": "device.connected", "device_id": "xxx", "at": "..." }
{ "type": "device.disconnected", "device_id": "xxx", "at": "..." }
```
### WS `/ws/devices/:id/flash-progress`
- 燒錄進度(透過 tunnel 從 local agent 取)
### WS `/ws/devices/:id/inference`
- 推論結果串流
### WS `/ws/server-logs`
- log broadcast沿用 local-tool 的 broadcaster
### WS `/ws/system`
- 系統事件server:shutdown-imminent 等)
### WS `/ws/clusters/:id/inference`
### WS `/ws/clusters/:id/flash-progress`
### WS `/ws/pairing/status`(新)
- 訂閱 tunnel 連線狀態變化
- Server push
```json
{ "type": "tunnel.connected", "connected_at": "..." }
{ "type": "tunnel.disconnected", "reason": "network_error", "at": "..." }
```
---
## 10. Storage雛形 LocalFS 代理)
### GET `/storage/*filepath?expires=...&signature=...`
- LocalFS 的假 presigned GET
- 驗簽後讀檔回傳
### PUT `/storage/*filepath?expires=...&signature=...`
- LocalFS 的假 presigned PUT
- 驗簽後收 body 寫檔
**Phase 1**:直接由 S3 提供,不走 api-server。
---
## 11. 錯誤碼清單
| Code | HTTP | 說明 |
|------|------|------|
| `UNAUTHORIZED` | 401 | 未認證或 token 無效 |
| `FORBIDDEN` | 403 | 權限不足 |
| `NOT_FOUND` | 404 | 資源不存在 |
| `VALIDATION_FAILED` | 400 | 輸入驗證失敗 |
| `TUNNEL_DISCONNECTED` | 502 | Local agent 未連線 |
| `TUNNEL_ERROR` | 502 | Tunnel 傳輸錯誤 |
| `NOT_IMPLEMENTED` | 501 | 雛形尚未實作 |
| `RATE_LIMITED` | 429 | 請求過快Phase 1|
| `INTERNAL_ERROR` | 500 | 未預期錯誤 |
---
## 12. Pagination
對會變大的 listmodels、devices、jobs用 cursor-based
```
GET /api/models?limit=50&cursor=...
Response:
{ "data": [...], "next_cursor": "..." | null }
```
雛形可先簡單回全部in-memoryPhase 1 接 DB 時實作 cursor。
---
**雛形 MVP 清單**(必須有):
- `GET /api/system/health`
- `GET /api/pairing/status`
- `GET /api/devices` + 透過 tunnel forward
- `GET /api/models` + `POST /api/models/init` + `/finalize`LocalFS
- `/storage/*` 代理
- WS `/ws/devices/events`
- WS `/ws/pairing/status`
其他可以先 501 或 stub。

View File

@ -0,0 +1,343 @@
# Build & Deploy
> 建置、本機開發、Docker 打包、部署的實務細節。
---
## 1. MakefilevisionA-backend
```makefile
.PHONY: build build-api build-proxy build-dev test clean docker-build docker-up docker-down run-dev
GO ?= go
GO_FLAGS ?= -ldflags="-s -w"
OUT_DIR ?= dist
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
build: build-api build-proxy
build-api:
$(GO) build $(GO_FLAGS) -o $(OUT_DIR)/api-server ./cmd/api-server
build-proxy:
$(GO) build $(GO_FLAGS) -o $(OUT_DIR)/remote-proxy ./cmd/remote-proxy
test:
$(GO) test -race -coverprofile=coverage.out ./...
lint:
$(GO) vet ./...
gofmt -l . | grep -v '^$$' && exit 1 || true
clean:
rm -rf $(OUT_DIR) coverage.out
# --- Docker ---
docker-build:
docker build -f docker/Dockerfile.api-server -t visiona/api-server:$(VERSION) .
docker build -f docker/Dockerfile.remote-proxy -t visiona/remote-proxy:$(VERSION) .
docker-up:
docker compose -f docker/docker-compose.yml up --build
docker-down:
docker compose -f docker/docker-compose.yml down
# --- Dev ---
# 本機同時啟動兩個 binary非雛形交付物僅開發便利
# 交付物定義見 design-doc.md §2.4 Non-Goal。
run-dev:
@trap 'kill 0' EXIT; \
$(GO) run ./cmd/remote-proxy & \
$(GO) run ./cmd/api-server & \
wait
```
---
## 2. Dockerfile.api-server
```dockerfile
# --- build stage ---
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/api-server ./cmd/api-server
# --- runtime stage ---
FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=build /out/api-server /app/api-server
USER nonroot:nonroot
EXPOSE 3001
ENTRYPOINT ["/app/api-server"]
```
## 3. Dockerfile.remote-proxy
```dockerfile
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/remote-proxy ./cmd/remote-proxy
FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=build /out/remote-proxy /app/remote-proxy
USER nonroot:nonroot
EXPOSE 3800 3801
ENTRYPOINT ["/app/remote-proxy"]
```
---
## 4. docker/docker-compose.yml
```yaml
services:
api-server:
build:
context: ../
dockerfile: docker/Dockerfile.api-server
image: visiona/api-server:dev
ports: ["3001:3001"]
environment:
VISIONA_API_PORT: "3001"
VISIONA_SESSION_BACKEND: inmemory
VISIONA_STORAGE_BACKEND: localfs
VISIONA_STORAGE_LOCALFS_ROOT: /data/storage
VISIONA_STORAGE_LOCALFS_BASE_URL: http://localhost:3001/storage
VISIONA_STORAGE_SIGNING_SECRET: dev-secret-do-not-use-in-prod
VISIONA_AUTH_MODE: static
VISIONA_STATIC_USER_ID: demo-user
VISIONA_PAIRING_MODE: static
VISIONA_PAIRING_TOKEN: "${VISIONA_PAIRING_TOKEN}"
VISIONA_CONVERTER_MODE: stub
volumes:
- storage-data:/data
remote-proxy:
build:
context: ../
dockerfile: docker/Dockerfile.remote-proxy
image: visiona/remote-proxy:dev
ports:
- "3800:3800" # tunnel
- "3801:3801" # internal
environment:
VISIONA_TUNNEL_PORT: "3800"
VISIONA_PROXY_INTERNAL_PORT: "3801"
VISIONA_SESSION_BACKEND: inmemory
VISIONA_PAIRING_MODE: static
VISIONA_PAIRING_TOKEN: "${VISIONA_PAIRING_TOKEN}"
# 雛形設計ADR-006 / Q1
# - remote-proxy 是唯一持有 yamux.Session 的 processin-memory
# - api-server 無狀態,透過 internal HTTP 向 remote-proxy 查詢 session
# - 兩 binary 之間用 VISIONA_PROXY_INTERNAL_URL 連結(下方 api-server 已設 env
volumes:
storage-data:
```
另外 api-server service 需要新增 env指向 remote-proxy 的 internal URL
```yaml
api-server:
environment:
# 上方所有 env 保留,新增:
VISIONA_SESSION_BACKEND: proxy-client
VISIONA_PROXY_INTERNAL_URL: http://remote-proxy:3801
```
### 4.1 雛形推薦的開發方式
```bash
# .env
VISIONA_PAIRING_TOKEN="vAc_$(openssl rand -hex 16)" # 格式見 security.md §1.3
# 方式 1本機 Makefile 平行跑兩個 binary開發便利工具
make run-dev
# 方式 2Docker Compose更接近 Production 拓撲)
make docker-up
```
**結論**:雛形交付物是雙 binary + docker-compose兩者皆可用於 demo`make run-dev` 僅為本機開發便利工具(**非交付物**,見 design-doc.md §2.4 Non-Goal
---
## 5. 前端建置
```bash
cd visionA-frontend
pnpm install
pnpm dev # 本機開發 http://localhost:3000
pnpm build # 產出 .next/
pnpm start # 生產模式跑 Next.js server
```
### 5.1 Next.js Build 模式
| 模式 | 用途 | 如何設定 |
|------|------|---------|
| `next build` + `next start` | SSR / ISR 支援 | 適合 Phase 1需要 Next.js runtime |
| `output: 'export'` | 靜態 export放 CDN | 若所有頁面都可靜態化,最便宜 |
**雛形建議**`next start` on Node方便 API rewrites / middlewarePhase 1 視需求切換。
### 5.2 環境變數
```
# visionA-frontend/.env.example
NEXT_PUBLIC_API_BASE=http://localhost:3001
NEXT_PUBLIC_WS_BASE=ws://localhost:3001
# 雛形開發用:
NEXT_PUBLIC_DEV_PAIRING_TOKEN=<same as VISIONA_PAIRING_TOKEN>
```
---
## 6. 本機完整開發流程
```bash
# Terminal 1 — Backend
cd visionA-backend
export VISIONA_PAIRING_TOKEN=$(openssl rand -hex 32)
export VISIONA_STATIC_USER_ID=demo-user
make run-dev
# api at :3001, tunnel at :3800
# Terminal 2 — local agent (local-tool 現有 + 開雲端模式,或 POC 的 edge-ai-server)
cd /Users/jimchen/Innovedus/edge-ai-platform/edge-ai-platform
./dist/edge-ai-server --relay-url=ws://localhost:3800/tunnel/connect --relay-token=$VISIONA_PAIRING_TOKEN
# Terminal 3 — Frontend
cd visionA-frontend
cp .env.example .env.local
# 編輯 .env.local 填入 PAIRING_TOKEN
pnpm dev
# http://localhost:3000
```
---
## 7. Phase 1 部署草圖
### 7.1 AWS ECS Fargate + Application Load Balancer
```
┌──────────────────────────────────────────────────────────────┐
│ Route 53 — api.visiona.cloud / proxy.visiona.cloud │
└─────────────────────┬────────────────────┬───────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌───────────────────┐
│ ALB │ │ NLB │
│ (HTTPS/WSS) │ │ (TCP passthrough)│
│ api.* │ │ proxy.* │
└────────┬────────┘ └─────────┬─────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ ECS Service: │ │ ECS Service: │
│ api-server │ │ remote-proxy │
│ (Fargate, 2+ tasks)│ │ (Fargate, 2+ tasks) │
└──────┬──────────────┘ └──────────┬──────────┘
│ │
└──────┬───────────────┬──────┘
│ │
▼ ▼
┌──────────────┐ ┌─────────────────┐
│ ElastiCache │ │ RDS │
│ Redis │ │ PostgreSQL │
└──────────────┘ └─────────────────┘
▲ ▲
│ │
└────────────────┘
┌──────────────┐
│ S3 bucket │
└──────────────┘
```
### 7.2 Kubernetes 方案
- api-server`Deployment` + `HorizontalPodAutoscaler`
- remote-proxy`Deployment` + HPA按 tunnel 數 metric
- Redis / Postgresmanaged service 或 StatefulSet
- Ingressnginx-ingress 或 cloud LB controller
**Cloud-agnostic 原則**Helm chart 不綁特定雲storage / DB 依 env 注入連線資訊。
---
## 8. CI/CDPhase 1 規劃)
### 8.1 GitHub Actions
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
backend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.26' }
- run: cd visionA-backend && make test
- run: cd visionA-backend && make lint
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'pnpm' }
- run: cd visionA-frontend && pnpm install --frozen-lockfile
- run: cd visionA-frontend && pnpm test
- run: cd visionA-frontend && pnpm build
```
### 8.2 Release PipelinePhase 1
1. PR merged to `main`
2. CI 跑 test + lint + build
3. 產出 Docker image → push to registryECR / GCR / GitHub Packages
4. Tag image 為 `main-<sha>`
5. 手動觸發 deploy或 GitOps 自動)→ ECS task definition 更新 / K8s rollout
---
## 9. 環境變數對照表(摘要)
| Env | 雛形 | Phase 1 |
|-----|------|--------|
| `VISIONA_AUTH_MODE` | `static` | `clerk` / `oidc` |
| `VISIONA_PAIRING_MODE` | `static` | `db` |
| `VISIONA_SESSION_BACKEND` | `inmemory` | `redis` |
| `VISIONA_STORAGE_BACKEND` | `localfs` | `s3` |
| `VISIONA_CONVERTER_MODE` | `stub` | `http` |
| `VISIONA_REDIS_URL` | — | `redis://...` |
| `VISIONA_DB_URL` | — | `postgres://...` |
| `VISIONA_S3_*` | — | 實際 credentials |
---
**雛形實作重點**
- `make run-dev`(單 binary 兩 listener
- 不需要 DockerDocker 檔案寫好以備 Phase 1
- 不需要 CI但 Makefile 有 `test` + `lint` 方便本機檢查
**Phase 1 必做**
- Docker image CI 產出
- K8s / ECS manifest
- Blue-green 或 rolling deploy
- Staging 環境

View File

@ -0,0 +1,937 @@
# Conversion — 轉檔功能整合Phase 0.8
> **角色**visionA-backend 端的「轉檔」實作 spec內部模組設計 + API + flow
> **上位文件**`adr/adr-014-conversion-integration.md``TDD.md``security.md`
> **同層文件**`api/api-conversion.md`(對 frontend 的 API 規格細節)
> **作者**Architect Agent
> **狀態**Draft待 PM / Backend / Frontend / DevOps 交叉審閱)
> **最後更新**2026-04-30
---
## 索引
1. [整體 flow端對端](#1-整體-flow端對端)
2. [模組設計 — `internal/conversion/`](#2-模組設計--internalconversion)
3. [新增 visionA-backend API](#3-新增-visiona-backend-api)
4. [Streaming proxy 設計upload](#4-streaming-proxy-設計upload)
5. [Service-to-service token 機制](#5-service-to-service-token-機制)
6. [錯誤碼 mapping + i18n key](#6-錯誤碼-mapping--i18n-key)
7. [user_id 注入與 trust boundary](#7-user_id-注入與-trust-boundary)
8. [Non-GoalsPhase 0.8 不做)](#8-non-goalsphase-08-不做)
9. [失敗模式 & retry 矩陣](#9-失敗模式--retry-矩陣)
10. [安全考量](#10-安全考量)
---
## 1. 整體 flow端對端
```mermaid
sequenceDiagram
participant B as Browser
participant V as visionA-backend
participant MC as Member Center
participant C as Converter
participant F as FAA
Note over B,F: Stage 1 — Init jobstreaming upload
B->>V: POST /api/conversion/init (multipart)
V->>V: AuthMiddleware → user_id (OIDC sub)
V->>V: 檢查同 user active job
V->>MC: POST /oauth/token (cache miss only)
MC-->>V: service token (4 scopes)
V->>C: POST /api/v1/jobs (streamed multipart, user_id 注入)
C-->>V: 201 {job_id, status:created, stage:onnx}
V->>V: 記錄 job_id ↔ user_id mapping
V-->>B: 200 {job_id, status:running, stage:onnx}
Note over B,F: Stage 2 — Poll status
loop 直到 completed / failed
B->>V: GET /api/conversion/{job_id}
V->>V: ownership 檢查
V->>C: GET /api/v1/jobs/{id} (cache 1-2s)
C-->>V: {status, stage, progress, ...}
V-->>B: 整形後 status
end
Note over B,F: Stage 3a — User 選「加到模型庫」
B->>V: POST /api/conversion/{job_id}/promote-to-models
V->>C: POST /api/v1/jobs/{id}/promote
C->>F: PUT /files/{key} (NEF)
C-->>V: {target_object_key}
V->>F: GET /files/{key} (Bearer service token, scope=files:download.read)
F-->>V: NEF stream
V->>V: /api/models/init → /api/models/finalize<br/>(Source=converted, SourceJobID=job_id)
V-->>B: 201 {model_id}
Note over B,F: Stage 3b — User 選「下載」server-side 302 redirect
B->>V: GET /api/conversion/{job_id}/download<br/>(<a href> 或 window.location.href)
V->>V: AuthMiddleware → user_id + ownership 檢查
V->>C: POST /api/v1/jobs/{id}/promote (若還沒 promote)
C->>F: PUT /files/{key}
C-->>V: {target_object_key}
V->>MC: POST /file-access/download-tokens<br/>(scope=files:download.delegate)
MC-->>V: opaque token
V-->>B: HTTP 302 Found<br/>Location: https://faa/files/{key}?access_token=...
Note over B: browser 自動 follow 302
B->>F: GET /files/{key}?access_token=... (browser direct)
F->>MC: validate token
MC-->>F: ok
F-->>B: NEF stream
```
**critical path 說明**
- visionA-backend 在 upload / download / promote 任一階段都先做 OIDC AuthMiddleware既有+ ownership 檢查
- promote 動作是冪等的converter 端對同一 job 重複 promote 接受visionA-backend 內部以 `job_id ↔ promoted_object_key` 記錄避免重複呼叫
- 加到模型庫流程promote → FAA pull → `/api/models/init` 取得 model_id + presigned PUT URL → visionA-backend 自己 PUT 到 storage → `/api/models/finalize`(這條路徑要重用既有 handler 邏輯,不繞過)
---
## 2. 模組設計 — `internal/conversion/`
```
internal/conversion/
├── conversion.go # Service interface + 對外暴露的 type
├── converter_client.go # converter scheduler API client
├── faa_client.go # FAA API clientpull NEF
├── mc_token_client.go # MC token endpoint (client_credentials) + cache
├── flow.go # 整體 flow 協調
├── types.go # request / response struct
└── errors.go # error code 定義
```
### 2.1 `conversion.go` — 對外 interface
```go
package conversion
import (
"context"
"io"
"time"
)
// Service 是 handler 層的單一進入點。
type Service interface {
// InitJob 把 client 的 multipart stream 透傳給 converter建立 job。
// bodyReader 必須是「上層 handler 已 wrap 好的 multipart.Reader」— 由 handler 解多 part
// 後重新組裝(見 §4避免 service 層關心 multipart.NewReader。
// 實際實作handler 直接拿 raw request body + content-type由 service 內部處理 streaming。
InitJob(ctx context.Context, in InitJobInput) (*Job, error)
// GetJob 查 converter statusownership 檢查後 cache 1-2s。
GetJob(ctx context.Context, userID, jobID string) (*Job, error)
// PromoteToModels — 「加到模型庫」流程promote → FAA pull → models repo finalize。
// 回傳新建的 model_id。
PromoteToModels(ctx context.Context, userID, jobID string) (modelID string, err error)
// DownloadRedirectURL — 「下載」流程promote (若需要) → MC delegated token → 組好的 FAA download URL。
// handler 拿到後直接 c.Redirect(http.StatusFound, url)token 不出現在任何 JSON response。
// 仿 FAA TestSite `DownloadFileDirect` pattern。
DownloadRedirectURL(ctx context.Context, userID, jobID string) (downloadURL string, err error)
// ActiveJob 查 user 當前是否有 active job給 frontend pre-check 用)。
ActiveJob(ctx context.Context, userID string) (*Job, error)
}
// InitJobInput 是 handler 傳給 service 的所有資料。
// MultipartBody 由 handler 從 request.Body 取得(已驗 content-typeservice 內部處理 streaming。
type InitJobInput struct {
UserID string
ContentType string // 含 boundary 的原始值
Body io.Reader // request.Body
ContentLength int64
TargetChip string // "520" / "720"
// 其他 form fieldsmodel_id, version, enable_*)由 handler 解多 part 後傳入
// — 實作上在 §4 streaming 處理時把這些 field 也透傳給 converter
}
type Job struct {
JobID string `json:"job_id"`
Status string `json:"status"` // created / running / completed / failed
Stage string `json:"stage"` // onnx / bie / nef
Progress int `json:"progress"` // 0-100
StageProgress int `json:"stage_progress"`// 0-100
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// DownloadGrant 是 mc_token_client 內部用的中間 struct從 MC 換 token 時的回傳)。
// **不對 frontend JSON 序列化** — Phase 0.8 起 download flow 走 server-side 302 redirect
// token 與 URL 永遠不出現在任何 visionA-backend → frontend 的 JSON response。
// 留 json tag 純粹給 mc_token_client 內部 unmarshal MC response 用。
type DownloadGrant struct {
DownloadURL string `json:"download_url"`
ExpiresAt time.Time `json:"expires_at"`
}
```
### 2.2 `converter_client.go`
```go
type ConverterClient struct {
baseURL string
httpClient *http.Client
tokens *MCTokenClient
}
// CreateJobStream 把 io.Reader 當作 multipart bodycontent-type 含 boundary透傳給 converter。
// caller 必須:
// 1. 已經把 user_id 透過 multipart.Writer 注入 body在 streaming 過程中)
// 2. content-type 是合法的 multipart/form-data; boundary=...
func (c *ConverterClient) CreateJobStream(ctx context.Context, contentType string, body io.Reader, contentLength int64) (*Job, error)
func (c *ConverterClient) GetJob(ctx context.Context, jobID string) (*Job, error)
func (c *ConverterClient) PromoteJob(ctx context.Context, jobID string) (targetObjectKey string, err error)
```
每個方法內部:
1. `c.tokens.Get(ctx)` 取 service token自動 cache
2. 帶 `Authorization: Bearer <service-token>` + `X-Request-Id` (從 ctx 取,沿用 visionA-backend 既有 request_id 中介層)
3. response 5xx / network error 走 retry§9
### 2.3 `faa_client.go`
```go
type FAAClient struct {
baseURL string
httpClient *http.Client
tokens *MCTokenClient
}
// Download server-to-server 拉檔(給「加到模型庫」流程用)。
// 用 service token (scope=files:download.read)。
func (c *FAAClient) Download(ctx context.Context, objectKey string) (io.ReadCloser, *DownloadMetadata, error)
type DownloadMetadata struct {
SizeBytes int64
ContentType string
Checksum string // optional
}
```
`DownloadGrant` 不在這裡產(在 `mc_token_client.go`,因為 token 是 MC 簽的不是 FAA 簽的)。
### 2.4 `mc_token_client.go`
```go
type MCTokenClient struct {
issuerURL string
clientID string
clientSecret string
httpClient *http.Client
mu sync.RWMutex
cachedToken string
cachedExp time.Time
}
// Get 取 service tokencache 直到 exp - 15s 內仍可用。
func (c *MCTokenClient) Get(ctx context.Context) (string, error) {
c.mu.RLock()
if c.cachedToken != "" && time.Until(c.cachedExp) > 15*time.Second {
defer c.mu.RUnlock()
return c.cachedToken, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
// double-check 避免併發重複取
if c.cachedToken != "" && time.Until(c.cachedExp) > 15*time.Second {
return c.cachedToken, nil
}
// POST {issuer}/oauth/token grant_type=client_credentials
// 失敗依 §9 retry
// ...
}
// IssueDelegatedDownload 跟 MC 換 browser 直連 FAA 用的 opaque token。
func (c *MCTokenClient) IssueDelegatedDownload(ctx context.Context, in DelegatedDownloadInput) (*DownloadGrant, error)
type DelegatedDownloadInput struct {
TenantID string
UserID string
ObjectKey string
Method string // "GET"
ExpiresInSeconds int // 預設 3005 分鐘)
}
```
**Tenant 處理**visionA 是 MC 的單一 tenant`tenant_id` 從 config 取(`VISIONA_OIDC_TENANT_ID` 或從 issuer / client metadata 推得)— 由 `mcConfig.TenantID` 注入。
### 2.5 `flow.go` — 流程協調
```go
type Flow struct {
converter *ConverterClient
faa *FAAClient
tokens *MCTokenClient
models model.Repository // 沿用既有 model store
storage storage.Store // 沿用既有 LocalFS / S3
ownership ownershipStore // job_id → user_id mapping (in-memory map)
statusCache *jobStatusCache // 1-2s short cache避免 frontend polling 直接打爆 converter
}
// 主要 method 對應 Service interface。
// PromoteToModels 內部:
// 1. ownership.Check(userID, jobID)
// 2. promotedKey, err := flow.ensurePromoted(ctx, jobID) // 冪等:若已 promote 過用 cache否則打 converter
// 3. reader, meta, err := faa.Download(ctx, promotedKey)
// 4. modelID, putURL, _ := callModelsInit(...) // 直接呼叫 internal/api 同 package 既有 helper不走 HTTP
// 5. PUT 到 storage或直接 io.Copy 到 storage.Put
// 6. callModelsFinalize(...)
// 7. 在 model record 補 Source="converted" + SourceJobID=jobID
// 8. 回 modelID
```
**冪等性**`flow.ensurePromoted(jobID)` 內部用 `sync.Map``job_id → target_object_key`;同 job 第二次 promote 直接回 cache不打 converter。
### 2.6 `ownership` storein-memory
```go
type ownershipStore interface {
Set(jobID, userID string, expiresAt time.Time) // 對齊 converter 7d 過期
Get(jobID string) (userID string, ok bool)
CleanupExpired() // background goroutine 每 60s
}
```
雛形 in-memoryvisionA-backend 重啟 → 所有「我的 job 列表」消失user 等同失去對未完成 job 的後續操作能力(接受的取捨 — converter 端用 user_id 仍可查到,但 visionA UX 上看不到。Phase 0.9 之後可改 DB persist。
#### 2.6.1 visionA-backend 重啟後的 cold start 恢復Phase 0.8 MVP 行為)
**問題**:使用者 A 上傳了一個 job正在 `processing`visionA-backend 重啟部署新版、crash recovery→ in-memory ownership store 全空;使用者 A 重新打開 `/conversion` 頁面,前端打 `GET /api/conversion/active` → backend 找不到任何 ownership → 回 `has_active=false` → 前端顯示「沒有進行中的轉檔」。
**結果**:使用者 A 看到一個假的「乾淨」狀態,認為什麼都沒發生;但 converter 端那個 job 仍在跑(且 converter 端「同 user 1 active job」邏輯仍生效使用者 A 重新 submit 會撞 409。
**Phase 0.8 MVP 的決策(接受的取捨)**
| 選項 | 描述 | 採用? |
|------|------|--------|
| A1 | 維持現狀:重啟即遺失。靠 converter 7 天 expires_at 自然兜底 | ✓ Phase 0.8 採用 |
| A2 | 啟動時對 converter 打 `GET /api/v1/jobs?status=in_progress` 重建所有 ownership | ✗ Phase 0.8 不做 |
| A3 | 把 ownership 寫進 DB / Redis | ✗ Phase 0.9+ 評估 |
| A4 | 啟動時對特定 user 才 lazy 重建(`GET /active` 時若 in-memory 沒有,去 converter 查該 user 的 active job | ✓ Phase 0.8 補上(**新增** |
**A4 實作Phase 0.8 補強)**
```go
// flow.go ActiveJob 內部:
func (f *Flow) ActiveJob(ctx context.Context, userID string) (*conversion.Job, error) {
// 1. 先查 in-memory ownership
if jobID, ok := f.ownership.GetByUser(userID); ok {
return f.GetJob(ctx, userID, jobID)
}
// 2. in-memory miss → fallback 對 converter 查lazy rebuild ownership
job, err := f.converter.ListActiveJobsByUser(ctx, userID)
if err != nil { return nil, err }
if job == nil { return nil, nil }
// 重建 ownership用 converter 回的 created_at + 7d 推算 expires_at
f.ownership.Set(job.JobID, userID, job.CreatedAt.Add(7*24*time.Hour))
return job, nil
}
```
**前提**converter Phase 1 的 `GET /api/v1/jobs?user_id=<id>&status=in_progress` 必須可用。見 §11 跨團隊依賴。
**為什麼選 A4 不選 A2**
- A2 啟動時批次掃所有 in_progress jobs對 converter 是 hammer重啟頻繁時尤甚且大部分 jobs 重啟期間使用者根本沒在等
- A4 是 lazy只有使用者主動進 `/conversion` 才查cost 對應 user 行為,不會打爆 converter
- 取捨:使用者進 `/conversion` 時多 1 次 round-trip< 200ms UX 可接受
**Wireframe / UX 對齊**Design wireframe §3.3 已 cover「進入頁面打 `/active`、有 active 直接落 processing」A4 行為對 frontend 完全透明(同樣 endpoint、同樣 response shape
#### 2.6.2 expires_at 的來源
| 屬性 | 規格 |
|------|------|
| 定義 | converter 端對 job 做 7 天 GC 的截止時間 |
| 來源 | converter 的 job record `created_at + 7 days`converter Phase 1 的 GC 邏輯) |
| 是否回傳給 visionA-frontend | ✓ 是 — `GET /api/conversion/{job_id}` response 與 `GET /api/conversion/active` response 都帶 `expires_at` |
| visionA-backend 怎麼知道 | 優先從 converter response 直接讀;若 converter 沒給Phase 1 的 OpenAPI spec 待確認visionA-backend 自行 `created_at + 7d` 推算 |
**待確認given to DevOps / Backend Agent**
- 確認 converter Phase 1 的 `GET /api/v1/jobs/{id}` 是否在 response 含 `expires_at` 欄位
- 若有 → `internal/conversion/converter_client.go``Job` struct 加 `ExpiresAt time.Time`,直接透傳
- 若無 → backend 在 `Job` 上補 `ExpiresAt = CreatedAt.Add(7 * 24 * time.Hour)`**前端永遠拿到 `expires_at`**(無論來源)
**Frontend 用途**
- `completed.success` 畫面顯示「6 天 21 小時後自動清除」倒數提示
- `expires_at - now() ≤ 0` 時切「已過期」狀態wireframe §8.2
---
## 3. 新增 visionA-backend API
詳細請求 / 回應 schema 見 `api/api-conversion.md`;這裡列總覽。
| Method | Path | Auth | 用途 |
|--------|------|------|------|
| `POST` | `/api/conversion/init` | OIDC cookie | 上傳 + 建 jobmultipart streaming |
| `GET` | `/api/conversion/{job_id}` | OIDC cookie | 查 job 狀態 |
| `POST` | `/api/conversion/{job_id}/promote-to-models` | OIDC cookie | 「加到模型庫」 |
| `GET` | `/api/conversion/{job_id}/download` | OIDC cookie | 「下載」— server-side HTTP 302 redirect 到 FAA delegated URL |
| `GET` | `/api/conversion/active` | OIDC cookie | 查當前 user 有無 active jobfrontend pre-checkin-memory miss 時 fallback 對 converter lazy rebuild§2.6.1 |
> **不對外暴露但內部使用的 converter endpoint**`GET /api/v1/jobs?user_id=&status=in_progress`§2.6.1 lazy rebuild。Phase 0.8 frontend 看不到「歷史列表」UI但後端會用此內部 endpoint 做韌性處理。
>
> **`POST /api/v1/jobs/{id}/cancel`**converter Phase 1 **尚未實作**(已驗證 routes/v1/jobs.js 與 openapi.yaml。Phase 0.8 失敗 cleanup 採「socket close 自然 abort」§4.3.2Phase 1 待 converter 補上 endpoint 後再升級為 best-effort 主動 cancel。
所有 endpoint 通用:
- 走既有 `AuthMiddleware``internal/api/middleware/auth.go`
- 從 `UserContextFrom(c)``uc.UserID`OIDC sub
- response 用既有 `WriteSuccess` / `WriteError` helper
- request_id 透傳給 converter`X-Request-Id` header
### 3.1 `GET /api/conversion/{job_id}/download` — server-side 302 redirect handler
仿 FAA TestSite `DownloadFileDirect``FileAccessAgent.TestSite/Controllers/HomeController.cs:255-282`
```go
// GET /api/conversion/{job_id}/download
func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
uc, _ := UserContextFrom(c) // AuthMiddleware 已驗
jobID := c.Param("job_id")
// service 內部完成ownership 檢查 → ensurePromoted → MC 換 delegated token → 組 URL
downloadURL, err := deps.Conversion.DownloadRedirectURL(c.Request.Context(), uc.UserID, jobID)
if err != nil {
// 錯誤情況不 redirect直接走既有 WriteError依 Accept header 回 JSON 或 HTML 錯誤頁)
// mapping 見 §6 + §12
writeConversionError(c, err)
return
}
// server-side HTTP 302 — token 在 Location header不過 frontend JS、不需 CORS
// 防快取:避免 browser 把 302 + Location 存進 history / disk cache
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Header("Pragma", "no-cache")
c.Redirect(http.StatusFound, downloadURL)
}
}
```
**為什麼用 GET 不用 POST**
- frontend 用 `<a href="..." download>` 觸發 — anchor tag 只能發 GET
- GET semantically 對應「拿一個資源」,符合「下載這個 job 的結果」語意
- 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session無 CSRF 風險沒有狀態變更promote 是冪等的)
**Frontend 使用範例**
```html
<!-- 推薦anchor tagbrowser 自動處理 navigation + 302 follow -->
<a href={`/api/conversion/${jobId}/download`} download>下載</a>
```
或:
```ts
// 程式化觸發:等同 anchor tag
window.location.href = `/api/conversion/${jobId}/download`;
```
Frontend **永遠看不到** download token 與 raw object_key — token 只活在 visionA-backend → browser 的 302 Location headerbrowser memoryJS 看不到,除非開 devtools network 面板)→ 馬上被 browser 用來打 FAA 後消失。
---
## 4. Streaming proxy 設計upload
### 4.1 為什麼要 streaming
- 模型上限 500MBref_images 100×10MB = 1GB 上限
- 全 buffer 在 RAM → 同時 N 個 user upload 直接 OOM
- 暫存 disk → 增加 IO 與磁碟空間需求;壞掉的 cleanup 麻煩
### 4.2 實作 pattern
```go
// handler 收到 request
func conversionInitHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
uc, _ := UserContextFrom(c)
userID := uc.UserID
ct := c.GetHeader("Content-Type")
if !strings.HasPrefix(ct, "multipart/form-data") {
WriteError(c, 400, ErrCodeValidationFailed, "expect multipart/form-data", nil)
return
}
// 同 user active job pre-check
if active, _ := deps.Conversion.ActiveJob(c.Request.Context(), userID); active != nil {
WriteError(c, 409, ErrCodeActiveJobExists,
"user has active job", map[string]any{"job": active})
return
}
// service 內部做 streaming
job, err := deps.Conversion.InitJob(c.Request.Context(), conversion.InitJobInput{
UserID: userID,
ContentType: ct,
Body: c.Request.Body,
ContentLength: c.Request.ContentLength,
})
// ... error handling
}
}
```
```go
// service 內部 (flow.go InitJob)
func (f *Flow) InitJob(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
pr, pw := io.Pipe()
mw := multipart.NewWriter(pw)
// goroutine解 client 的 multipart重新寫到 mw
errCh := make(chan error, 1)
go func() {
defer pw.Close()
defer mw.Close()
mr, err := readerFromContentType(in.Body, in.ContentType)
if err != nil { errCh <- err; return }
// 先寫 user_id重點visionA backend 灌的,不是 client 灌的)
if err := mw.WriteField("user_id", in.UserID); err != nil { errCh <- err; return }
for {
part, err := mr.NextPart()
if err == io.EOF { break }
if err != nil { errCh <- err; return }
name := part.FormName()
// 黑名單client 不允許自己塞 user_id
if name == "user_id" {
continue // 忽略,用我們自己灌的
}
if part.FileName() == "" {
// form field直接複製
fw, err := mw.CreateFormField(name)
if err != nil { errCh <- err; return }
if _, err := io.Copy(fw, part); err != nil { errCh <- err; return }
} else {
// file partstreaming copy
fw, err := mw.CreateFormFile(name, part.FileName())
if err != nil { errCh <- err; return }
if _, err := io.Copy(fw, part); err != nil { errCh <- err; return }
}
}
errCh <- nil
}()
// 同步 POST 到 converter
job, err := f.converter.CreateJobStream(ctx, mw.FormDataContentType(), pr, -1)
// 等 goroutine 結束
if goErr := <-errCh; goErr != nil && err == nil {
err = goErr
}
if err != nil { return nil, mapConverterError(err) }
f.ownership.Set(job.JobID, in.UserID, time.Now().Add(7*24*time.Hour))
return job, nil
}
```
**關鍵點**
1. `io.Pipe` 把「client 端 reader → converter 端 writer」串接期間記憶體只有 multipart.Reader 的 buffer≈ 64KB 預設)
2. 必須先寫 `user_id` field**順序**user_id 在 model file 之前,避免 converter multer 解析時 user_id 還沒到就拒絕)
3. **黑名單 user_id**:忽略 client 帶的 user_id永遠用 visionA-backend 自己灌的
4. context cancellationhandler 收到 client disconnect → ctx.Done() → goroutine 自動結束pw.Close 觸發 reader EOF
5. 不做 ContentLength forwardconverter 自己 multer 算)
### 4.3 進度 / 取消
#### 4.3.1 進度語意(重要:給 Frontend / Design 對齊)
XHR `upload.onprogress` 計算的是 **browser → visionA-backend** 的進度,**不是** browser → backend → converter 的端到端進度。在 streaming proxy 模式下,這兩者有時間差:
```
T0: browser 開始上傳
└─ XHR onprogress 持續更新loaded / total
T1: browser 已 send 完全部 bytesXHR 進度 100%
└─ 但 backend → converter 的 io.Pipe 可能還在繼續流buffer 內未消化)
T2: backend 把全部 bytes forward 完給 converter
└─ 這時候才拿到 converter 的 201 + job_id
T3: backend 200 回 frontend
```
**設計選擇Phase 0.8 MVP**visionA-backend 等到 T2converter 回 201才回 200**不 early-return**。
| 屬性 | 選項 A等 converter 201 才 200 ✓ 採用 | 選項 Bbrowser send 完就 200背景 forward |
|------|----------------------------------|------------------------------------------|
| Frontend 進度條精確度 | 100% 接近端到端真實狀態 | 進度 100% 後還有未知延遲 → 假象 |
| UX 延遲感 | 多等 1-3 秒io.Pipe drain| 立即切 processing 畫面 |
| backend 實作複雜度 | 低(直接同步等)| 高(需要 background goroutine + ownership 標 `upload_in_progress` + 額外狀態管理) |
| 失敗處理複雜度 | 低(同步錯誤直接回 frontend| 高(背景 forward 失敗時 frontend 已切 processing要額外 push 錯誤通知)|
**選項 A 的 UX 補償**:當 XHR `loaded === total` 但 backend 還沒回 200 時frontend 顯示 `即將完成…` / `伺服器處理中…`(對齊 flow-conversion.md §5.3。這明確告訴使用者「browser 端已送完,正在等 server 收尾」,不是欺騙性的進度條。
> **Phase 1 升級路徑**:若 Phase 1 量大需要更精準的端到端進度,可改成 SSE 推送 `upload_progress` 事件backend 主動報「已 forward N bytes 給 converter」但 Phase 0.8 MVP 不做。
#### 4.3.2 Cancel 與 Cleanup 鏈(重要)
**情境分類**
| 情境 | 觸發 | backend 行為 |
|------|------|-------------|
| C1使用者按「取消上傳」 | frontend `xhr.abort()` | TCP RST → backend `c.Request.Context().Done()` → goroutine cleanup見下 |
| C2使用者重新整理 / 關分頁 | browser 中斷 connection | 同 C1 |
| C3網路斷線 | TCP timeout | 同 C1 |
| C4backend 偵測 converter 拒絕4xx/5xx| converter response | 立即回 frontend不需特別 cleanupconverter 自己沒建 job |
**C1-C3 的 cleanup 鏈**
```
client disconnect
gin handler `c.Request.Context().Done()` 觸發
streaming goroutine 的 `pw.Close()` defer 執行 → io.Pipe reader 收到 EOF/error
converter HTTP request 的 body read 端拿到 EOF
converter multer 偵測 incomplete multipart → 拒絕收 job不會建 job_id
```
**但**上面的鏈在「backend 已經把 multipart header 寫進去、converter 已建 job_id、stream 還在 forward 中」這個區間斷線時,**converter 端可能已經建立 job 但收不完 body**。實測上 converterPhase 1的行為是
- 收不完 body → multer 拋錯 → 該 job 留在 `failed` 狀態 + error_code=`invalid_multipart`
- 該 user 的 active_job 邏輯converter 把 `failed` 視為 active job 結束,下次 init 不會撞 409
**為了避免「converter 視為 active 但 visionA 不知道」的孤立 job 風險**,依 converter 是否提供 `/cancel` endpoint 採不同策略:
> ### Phase 0.8 限制(重要 — 已驗證實作狀態)
>
> **converter Phase 1 並未實作 `POST /api/v1/jobs/{id}/cancel` endpoint**
>
> 已驗證範圍:`apps/task-scheduler/src/routes/v1/jobs.js` 只有以下路由:
> - `POST /api/v1/jobs/`(建立 job
> - `GET /api/v1/jobs/`list
> - `GET /api/v1/jobs/:id`(單一狀態)
> - `POST /api/v1/jobs/:id/download-tokens`issue download token
> - `DELETE /api/v1/jobs/:id`(刪 job — 是 hard delete 而不是 cancel running job
>
> openapi.yaml 也沒有 cancel 路徑或 example。
>
> 因此 **Phase 0.8 採「socket close 自然 abort」策略**
>
> ```
> client disconnect / streaming body 中斷
> ↓
> converter multer 拋 invalid_multipart
> ↓
> 該 job 留 failed + error_code=invalid_multipart
> ↓
> converter 對 active_job 邏輯視為已結束failed 不算 active
> ↓
> 下次 init 不會撞 409
> ```
>
> visionA-backend 在 InitJob 失敗時不主動發 cancel沒有對應 endpoint 可發);只在
> log 紀錄失敗事件,依靠 converter 自然 abort 收尾。
> ### Phase 1+ 升級路徑converter 補上 /cancel 之後)
>
> 當 converter 上線 `POST /api/v1/jobs/{id}/cancel`visionA-backend 升級為
> best-effort 主動 cancel
>
> ```go
> // flow.go InitJob 內部goroutine 結束後若 err != nil 且我們已拿到 job_id
> if goErr != nil && job != nil && job.JobID != "" {
> // 用獨立 timeout不繼承已 cancel 的 ctx失敗只 log
> cancelCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
> defer cancel()
> if cancelErr := f.converter.CancelJob(cancelCtx, job.JobID); cancelErr != nil {
> logger.Warn("best-effort cancel failed", "job_id", job.JobID, "err", cancelErr)
> }
> }
> ```
>
> 動工項:
> 1. T3 ConverterClient interface 新增 `CancelJob(ctx context.Context, jobID string) error`
> 2. flow.go InitJob 失敗路徑加上面的 best-effort cancel block
> 3. 補對應 unit testmock converter 收到 cancel call
> 4. 對齊 §9.1 retry 矩陣的「Converter `POST /jobs/{id}/cancel`(內部 cleanup」row
**C1 特別處理(使用者按「取消上傳」)**frontend 在 `xhr.abort()` 之前**不應**先打 cancel API多此一舉TCP RST 即已觸發 cleanup後端會自動處理。
#### 4.3.3 既有 4.3 內容(保留)
- **進度**visionA-backend 不做進度回報frontend 用 XHR `upload.onprogress` 自己顯示(既有前端模式)
- **取消**context.Cancelclient 斷線)→ 連帶 cancel converter request如上 cleanup 鏈converter 端 multer 收到 socket close 會自動 abort multipart parsing
### 4.4 Timeout
- handler 整體不設總 timeout500MB upload 可能 5-10 分鐘)
- 但每個 io.Copy 之間用 `http.Server.WriteTimeout`/`IdleTimeout` 控制 keep-alive具體值由 DevOps 在 Nginx / ingress 設定(建議 600s
---
## 5. Service-to-service token 機制
### 5.1 取得流程
```
visionA-backend 啟動
讀 cfg.OIDC.ServiceClientID/Secret (config 既有預埋)
lazy init MCTokenClient不主動取
[第一個轉檔請求進來]
flow → converter_client → tokens.Get(ctx)
cache miss → POST {issuer}/oauth/token
grant_type=client_credentials
client_id=<ServiceClientID>
client_secret=<ServiceClientSecret>
scope=converter:job.write converter:job.read files:download.read files:download.delegate
MC 回 {access_token, expires_in, scope}
cache (exp = now + expires_in - 15s)
return token
```
### 5.2 Cache 策略
- 單一 token cache 涵蓋 4 個 scopeMC 端發單一 token 含全部)
- `exp - 15s` 提前重取,避免下游使用時剛好過期
- 併發保護double-checked locking5.4 §2.4 範例)
- 重啟即清空in-memory無持久化
### 5.3 Config 對齊
`visionA-backend/internal/config/config.go` 已預埋 `OIDCConfig.ServiceClientID/Secret`A1 階段不啟用。Phase 0.8 啟用:
```diff
// OIDCConfig.Validate
func (c *Config) Validate() error {
...
+ // Phase 0.8 起 Service Client 啟用(轉檔功能依賴)
+ if c.OIDC.ServiceClientID == "" {
+ missing = append(missing, "VISIONA_OIDC_SERVICE_CLIENT_ID")
+ }
+ if c.OIDC.ServiceClientSecret == "" {
+ missing = append(missing, "VISIONA_OIDC_SERVICE_CLIENT_SECRET")
+ }
...
}
```
新增 env
```
VISIONA_OIDC_SERVICE_CLIENT_ID=23605e14a2c64660abd97e29963d8d58
VISIONA_OIDC_SERVICE_CLIENT_SECRET=<from MC, never commit>
VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501
VISIONA_FAA_BASE_URL=http://192.168.0.130:5081
VISIONA_OIDC_TENANT_ID=<visionA tenant id at MC>
```
---
## 6. 錯誤碼 mapping + i18n key
| Converter / FAA / MC error | visionA error code | HTTP | i18n key | user-friendly 訊息zh-TW |
|----------|--------------------|------|----------|--------------------------|
| converter `validation_error` | `validation_failed` | 400 | `conversion.error.validation` | 上傳的檔案不符合要求(請查看詳細欄位錯誤) |
| converter `invalid_multipart` | `validation_failed` | 400 | `conversion.error.invalid_multipart` | 上傳格式錯誤,請重新嘗試 |
| converter `user_has_active_job` | `active_job_exists` | 409 | `conversion.error.active_job` | 你目前已有進行中的轉檔任務 |
| converter `file_too_large` | `payload_too_large` | 413 | `conversion.error.too_large` | 檔案超過大小限制 |
| converter `service_busy` | `service_busy` | 503 | `conversion.error.busy` | 系統繁忙,請稍後再試 |
| converter `storage_unavailable` | `converter_unavailable` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用 |
| converter 5xx / network | `converter_unavailable` | 502 | `conversion.error.converter_down` | 同上 |
| FAA 5xx / network | `faa_unavailable` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用 |
| MC token 4xx | `idp_misconfigured` | 500 | `conversion.error.idp_misconfig` | 系統設定錯誤,請聯絡支援 |
| MC token 5xx | `idp_unavailable` | 503 | `conversion.error.idp_down` | 認證服務暫時無法使用 |
| MC delegated 4xx | `download_token_failed` | 502 | `conversion.error.token_failed` | 無法取得下載授權,請重試 |
| MC delegated 5xx / network 持續失敗 | `mc_token_unavailable` | 502 | `conversion.error.token_failed` | 無法取得下載授權,請重試 |
| job 不屬於當前 user | `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此轉檔任務 |
| job_id 不存在 | `not_found` | 404 | `conversion.error.not_found` | 轉檔任務不存在 |
| job 還沒 completed | `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成,請等轉檔完成再下載 |
i18n key 命名:`conversion.error.<short-name>`,前端 i18n 字典在 `visionA-frontend/messages/{zh-TW,en}.json` 補。
**`/download` endpoint 錯誤回應策略**GET + 302 redirect 場景):
由於 `GET /api/conversion/{job_id}/download` 採 server-side 302錯誤情況**不 redirect**,改用既有 `WriteError` helper 依 `Accept` header 回應:
- `Accept: application/json` → 回標準 visionA error JSON `{success:false, error:{code, message}}`(給 fetch / 程式化 retry 用)
- `Accept: text/html`(一般 anchor tag / window.location.href 觸發)→ 回 HTML 錯誤頁browser 直接顯示
- 其他 → 預設 JSON
frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在頁面上。若希望 inline 處理錯誤(例如 toast 提示),改用 `fetch(..., {redirect: 'manual'})` + 檢查 status code但這條路徑要小心 fetch 對 302 的處理)— Phase 0.8 不要求此 UX先用 anchor tag 觸發即可。
---
## 7. user_id 注入與 trust boundary
### 7.1 唯一可信任點
```
┌──────────────────────────────────────────────────────────────┐
│ visionA-backend conversion handler │
│ │
│ AuthMiddleware → UserContext (OIDC sub from cookie) │
│ ↓ │
│ conversion.Service.InitJob(InitJobInput{UserID: <sub>}) │
│ ↓ │
│ flow.go InitJob │
│ ├─ multipart streaming 重組(黑名單 client 帶的 user_id
│ ├─ mw.WriteField("user_id", <sub>) ← 唯一灌入點 │
│ └─ POST converter /api/v1/jobs │
└──────────────────────────────────────────────────────────────┘
```
### 7.2 Ownership 檢查
每個 GET / promote / download / models 操作都先檢查 `ownership.Get(jobID) == userCtx.UserID`,不符 → 403 `forbidden`
### 7.3 客戶端不可信原則
- frontend / browser 帶來的 user_id 永遠忽略streaming 重組時黑名單)
- frontend / browser 帶來的 object_key 永遠忽略GET /download 不接受 client 指定 object_key從 visionA 內部 promote 結果反查)
- frontend 只能告訴我們 `job_id`,其他都從 server side 推
---
## 8. Non-GoalsPhase 0.8 不做)
對齊 PRD Phase 0.8 邊界:
| 項目 | Phase 0.8 行為 | Phase 1+ 計畫 |
|------|--------------|--------------|
| SSE / WebSocket 進度推送 | frontend HTTP polling間隔 2s | SSE endpoint `/api/conversion/{id}/events` |
| 取消 job | 不提供user 等 converter 自己跑完或 7 日後 expires | `POST /api/conversion/{id}/cancel` |
| Job 歷史列表 | 不提供in-memory map 重啟即清 | DB persist + `GET /api/conversion/history` |
| 同 user 多個 active job | 強制 1 個pre-check + converter 409 透傳) | 沿用 converter 限制(短期內無計畫放寬) |
| 多 chip 同時轉 | 一次只能選一個 target_chip | Phase 1 後評估 |
| Webhookconverter 完成 push | 不接收 | converter Phase 2 才提供 |
| 大量批次 upload | 不支援 | 不在路線圖 |
---
## 9. 失敗模式 & retry 矩陣
### 9.1 retry 規則
| 操作 | 4xx | 5xx | network / timeout | max retry | 退避 |
|------|-----|-----|------------------|-----------|------|
| Converter `POST /jobs` | 透傳 | retry | retry | 2 | 1s, 2s |
| Converter `GET /jobs/{id}` | 透傳 | retry | retry | 3 | 0.5s, 1s, 2s |
| Converter `POST /jobs/{id}/cancel`(內部 cleanupPhase 1+| 不重試 | 不重試 | 不重試 | 0 | best-effort失敗只 log不影響主流程**Phase 0.8 converter 未實作此 endpoint靠 socket close 兜底**§4.3.2|
| Converter `GET /jobs?user_id=&status=in_progress`lazy rebuild| 透傳 | retry | retry | 1 | 0.5s |
| Converter `POST /promote` | 透傳 | retry | retry | 2 | 1s, 2s |
| FAA `GET /files/{key}`s2s | 透傳 | retry | retry | 2 | 1s, 2s |
| MC `POST /oauth/token` | 4xx → fatal | retry | retry | 2 | 1s, 2s |
| MC `POST /file-access/download-tokens` | 透傳 | retry | retry | 2 | 1s, 2s |
每次 retry 之間檢查 `ctx.Done()`ctx cancel → 立即 return ctx.Err()。
### 9.2 graceful degradation
| 場景 | 處理 |
|------|------|
| converter 完全不可達(持續 5xx | `502 converter_unavailable`UI 提示「轉檔服務暫時無法使用,請稍後再試」 |
| 完成後 promote 失敗converter 5xx | job 留在 completed 狀態FAA 上沒檔但 visionA 知道UI 給 user 「重試 promote」按鈕重打 promote-to-models / download |
| FAA pull 失敗(加到模型庫流程) | model record 不寫入UI 提示重試;不影響「下載」路徑(後者直接 browser ↔ FAA |
| MC delegated token 失敗 | UI 給 user 「改用『加到模型庫』流程」備援選項 |
| visionA-backend 重啟 | in-memory ownership 與 promoted_key cache 全失user 已建立的 job 在 frontend 看不到,但 converter 端仍存在 — 需 user 知道下次只能等 converter 自然 expire接受的取捨MVP 階段內部使用者) |
### 9.3 同 user active job 衝突
```
[Frontend init] → [visionA POST /api/conversion/init]
visionA pre-check (ownership store)
├── 有 active job → 409 active_job_exists不打 converter
└── 沒 → 透傳給 converter
├── converter 200 → 寫 ownership → return
└── converter 409 user_has_active_job → 透傳 frontend
罕見visionA 的 ownership 與 converter 不同步,
例如 visionA 重啟後遺失 mapping以 converter 為準)
```
---
## 10. 安全考量
### 10.1 visionA-backend 是 user_id 灌入唯一點
詳見 §7。任何繞過此原則的設計都必須先過 ADR review。
### 10.2 Delegated download token TTL
- 預設 5 分鐘300 秒),可由 `VISIONA_FAA_DELEGATED_TTL_SECONDS` env 調整(範圍 60-900
- TTL 越短越安全但 user UX 越差MVP 取 5 分鐘平衡
- visionA-frontend **不應快取** download_url每次「下載」都重新打 backend 換新 token
### 10.3 Service token 保護
- `VISIONA_OIDC_SERVICE_CLIENT_SECRET` 不可進 git既有 `.gitignore``.env`
- 部署用 AWS Secrets Manager / k8s Secret 注入
- log 永遠不印 secret 與 access token只印 token 前 8 字元前綴 `Bearer ey1234...`
- 若 secret 洩漏MC 端 rotate → 重新部署 visionA-backendin-memory cache 自然失效
### 10.4 Object key 與 download token 不暴露給 frontend JS
- visionA-backend 透過 HTTP 302 redirect 把含 token 的 download URL 放在 `Location` header**不回 JSON body、不放 URL bar 永久 history**
- Token 與 raw `object_key` **永遠不出現**在任何 visionA-backend → frontend 的 JSON response — frontend JS 對它們完全沒有 reference
- 唯一觀察點是 browser 自身的 navigationdevtools network 面板能看到 302 Location但這是 browser 本機的事,跟 server 把 token 寫進 JSON 給 JS 處理是不同的攻擊面)
- 防快取handler 設 `Cache-Control: no-store` + `Pragma: no-cache`,避免 browser 把 302 Location 寫入 disk cache
- 即使有人 capture URL例如從 devtools 複製貼出去),也只能在 5 分鐘 TTL 內用,且 method=GET 被綁死
- **不需 FAA CORS**browser navigation request 不適用 CORSCORS 只管 JS fetch / XHRserver-side 302 redirect 是 browser 原生 navigation 行為
### 10.5 Race condition
- 同 user 同時兩 tab init → 第一個成功寫 ownership / converter 接受;第二個 pre-check 通過但 converter 409
- 兩 tab 同時 promote-to-models → 第一個寫 model record 成功;第二個重複呼叫 ensurePromotedcache hit→ FAA pull 兩次接受的取捨FAA 端冪等)→ models repo 寫入時可能撞 model_id 衝突 — 改用 model_id 在 finalize 前 SELECT 檢查
### 10.6 DoS 防護最小集Phase 1 強化)
- 同 user 1 個 active job 的限制本身就是 DoS 防護user 不能 init 1000 個 job
- visionA-backend conversion endpoint 不額外 rate limitPhase 1 補;對齊 `security.md` §4
- converter 端有 process semaphoremax 5 concurrent upload保底
---
## 變更影響清單
實作此 spec 會動到的檔案(給 Backend Agent 參考Backend 自己拆任務):
- 新增:`visionA-backend/internal/conversion/*.go`(含 `_test.go`
- 新增:`visionA-backend/internal/api/conversion.go`handler
- 新增:`visionA-backend/internal/api/conversion_test.go`
- 修改:`visionA-backend/internal/config/config.go`(啟用 ServiceClientID/Secret + 新增 ConverterBaseURL / FAABaseURL / TenantID
- 修改:`visionA-backend/internal/api/api.go`Deps 加 `Conversion conversion.Service`、router 註冊 `/api/conversion/*`
- 修改:`visionA-backend/cmd/api-server/main.go`wire conversion.Flow
- 不動:`internal/model/*`schema 不變)
- 不動:`internal/api/models.go`(既有 init/finalize 不動flow.PromoteToModels 內部呼叫 helper
---
## 版本記錄
| 日期 | 版本 | 變更 |
|------|------|------|
| 2026-04-30 | 0.1 | 初稿 — Phase 0.8 轉檔整合 TDD |
| 2026-04-30 | 0.2 | Download flow 改為 server-side HTTP 302 redirectendpoint 從 `POST /{job}/download-token` 改為 `GET /{job}/download`、Service interface `DownloadToken``DownloadRedirectURL``DownloadGrant` 改為 mc_token_client 內部 struct不對外 JSON、補 §3.1 handler 範例、補 §10.4 token 不過 frontend JS 的安全分析、§6 補 `/download` 錯誤回應策略 |
| 2026-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合§2.6.1 補「visionA-backend 重啟後 lazy rebuild ownership」議題 #2A4 方案§2.6.2 補 expires_at 來源(議題 #7§4.3.1 streaming proxy 進度語意明確化(議題 #6,採選項 A等 converter 201 才回 200§4.3.2 補 cancel cleanup 鏈與 best-effort cancel converter議題 #5 |

View File

@ -0,0 +1,421 @@
# Database — 資料模型
> **雛形階段無真實 DB**(見 ADR-005。本文件定義 Go struct + 未來 DB schema 的映射,讓雛形程式碼直接以這些 struct 操作記憶體Phase 1 直接按這個結構建 PostgreSQL schema。
---
## 1. 核心實體 ER 概念圖
```
User ─┬─ owns ── Device ─ has ── PairingToken
├─ owns ── Model
├─ owns ── Cluster ─ contains ── Device
└─ has ── ConverterJob ─ produces ── Model
```
---
## 2. Go struct 定義
### 2.1 User雛形 stubPhase 1 實作)
```go
// internal/user/types.go
package user
import "time"
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
// Phase 1
PasswordHash string `json:"-"`
OrgID string `json:"orgId,omitempty"`
Roles []string `json:"roles,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt,omitempty"`
}
```
**雛形**`StaticAuthService` 永遠回 `User{ID: "demo-user", Email: "demo@visiona.local"}`,不落盤。
### 2.2 Device
```go
// internal/device/types.go
package device
import "time"
type Device struct {
ID string `json:"id"` // UUID
OwnerUserID string `json:"ownerUserId"`
Name string `json:"name"` // 使用者命名
DeviceType string `json:"deviceType"` // kl520 / kl720 等
SerialNumber string `json:"serialNumber"` // 從 local agent 上報
// -------- 雙狀態模型2026-04-22 Minor-3 新增 remoteStatus / lastSeenAt 語意釐清)--------
//
// Device 同時擁有兩組狀態欄位,代表不同觀察角度:
//
// 1. StatusUSB-level status既有
// - 由 local agent 直接觀察到的「USB 接了什麼」
// - 值online / offline / unknown
// - 來源local agent 呼叫 KL SDK 得到;透過 tunnel 上報雲端
// - 意義:「此刻使用者電腦上這個 KL device 插著且正常」
//
// 2. RemoteStatustunnel-level status新增
// - 由雲端remote-proxy / api-server對 tunnel 連線的觀察
// - 值online | offline | reconnecting | error
// - 來源SessionStore 狀態 + tunnel heartbeat見 tunnel.md §4.2
// - 意義:「雲端能不能透過 tunnel 觸達使用者電腦上的 agent」
//
// 兩者可能出現 4 種組合:
// | remoteStatus | status | 解讀 |
// |--------------|----------|------|
// | online | online | 正常:雲端可達 + USB 有裝置 |
// | online | offline | agent 連著但 USB 拔掉了 |
// | offline | * | agent 或網路斷雲端完全無法觸及status 顯示的是最後觀察值)|
// | reconnecting | * | tunnel 短暫斷線、local agent 正重連中 |
//
// 前端應優先顯示 remoteStatus雲端連線狀態次要顯示 statusUSB 狀態),
// 詳見 TDD §10 前端消費方式。
// tunnel-level 狀態2026-04-22 新增)
RemoteStatus string `json:"remoteStatus"` // online | offline | reconnecting | error
LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` // 最後一次收到 tunnel 心跳時間ISO 8601
LastConnectedAt *time.Time `json:"lastConnectedAt,omitempty"` // tunnel 最近一次建立時間
// USB-level 狀態(既有,保留)
Status string `json:"status"` // online / offline / unknownUSB
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt,omitempty"`
}
```
**注意**此「Device」記錄的是**抽象身分**(綁 user。當瀏覽器呼叫 `GET /api/devices` 時,實際「此時 USB 上接了哪些」要透過 tunnel 問 local agent 的 `/api/devices`。雲端這張表負責「我曾經綁過哪些裝置、它們的名字、擁有者」+ 雙狀態快照。
**更新時機**
- `remoteStatus``remote-proxy` 在 tunnel 事件發生時寫入connect → `online`、heartbeat timeout → `offline`、中間短暫斷線 → `reconnecting`、yamux 錯誤 → `error`
- `lastSeenAt``remote-proxy` 的 heartbeat 處理每 10s 更新一次(見 tunnel.md §4.2
- `status`USB由 local agent 上報,走既有 POC 邏輯
### 2.3 Model
```go
// internal/model/types.go
package model
import "time"
type Model struct {
ID string `json:"id"`
OwnerUserID string `json:"ownerUserId"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
// 檔案資訊
StorageKey string `json:"storageKey"` // 在 Store 裡的 keymodels/{user_id}/{id}.nef
FileSize int64 `json:"fileSize"`
FileChecksum string `json:"fileChecksum"` // sha256
// 模型 metadata
TargetChip string `json:"targetChip"` // kl520 / kl720
InputShape []int `json:"inputShape,omitempty"`
Classes []string `json:"classes,omitempty"`
Framework string `json:"framework,omitempty"` // onnx / keras 等(若有)
// 來源
Source string `json:"source"` // uploaded / converted / preset
SourceJobID string `json:"sourceJobId,omitempty"` // 若 source=converted
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt,omitempty"`
}
```
### 2.4 PairingToken見 ADR-003
```go
// internal/auth/types.go
package auth
import "time"
type PairingInfo struct {
TokenHash string `json:"-"` // sha256(plaintext token)DB 只存 hash
UserID string `json:"userId"`
DeviceID string `json:"deviceId,omitempty"` // 綁定後才有
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"` // pairing phase 有 15min TTL升級為 session token 後 nil 或長期
UsedAt *time.Time `json:"usedAt,omitempty"` // 首次被 local agent 使用的時間
RevokedAt *time.Time `json:"revokedAt,omitempty"`
RevokedBy string `json:"revokedBy,omitempty"`
LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
// Phase 1兩階段設計
Kind string `json:"kind"` // "pairing" | "session"
ParentToken string `json:"parentToken,omitempty"` // session 對應的 pairing
}
```
### 2.5 Cluster從 POC 搬)
```go
// internal/cluster/types.go
package cluster
type Cluster struct {
ID string `json:"id"`
OwnerUserID string `json:"ownerUserId"`
Name string `json:"name"`
Devices []DeviceMember `json:"devices"`
ModelID string `json:"modelId,omitempty"`
Status ClusterStatus `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt,omitempty"`
}
type DeviceMember struct {
DeviceID string `json:"deviceId"`
Weight int `json:"weight"`
Status MemberStatus `json:"status"`
DeviceName string `json:"deviceName,omitempty"`
DeviceType string `json:"deviceType,omitempty"`
}
type ClusterStatus string
const (
ClusterIdle ClusterStatus = "idle"
ClusterInferencing ClusterStatus = "inferencing"
ClusterDegraded ClusterStatus = "degraded"
)
```
### 2.6 ConverterJobstub
```go
// internal/converter/types.go
package converter
import "time"
type Job struct {
ID string `json:"id"`
OwnerUserID string `json:"ownerUserId"`
Status string `json:"status"` // queued / running / succeeded / failed
SourceKey string `json:"sourceKey"` // 原始 onnx / keras 在 storage 的 key
ResultKey string `json:"resultKey,omitempty"` // 產物(.nef的 key
TargetChip string `json:"targetChip"`
Params map[string]any `json:"params,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
```
### 2.7 Session**不落 DB**,但列出以對照)
```go
// internal/session/types.go
package session
import "time"
type SessionSummary struct {
Token string // caller 已知
UserID string
DeviceID string
ConnectedAt time.Time
LastSeenAt time.Time
ProxyNodeID string // Phase 1 多節點時使用
}
```
雛形 session 在 in-memory mapprocess 重啟就掉。Phase 1 考慮 Redis 存 summaryTTL + heartbeat
---
## 3. Repository Interfaces
```go
// internal/device/repository.go
package device
import "context"
type Repository interface {
Get(ctx context.Context, id string) (*Device, error)
GetBySerial(ctx context.Context, userID, serial string) (*Device, error)
List(ctx context.Context, userID string) ([]*Device, error)
Save(ctx context.Context, d *Device) error
Delete(ctx context.Context, id string) error
}
type InMemoryRepository struct {
devices map[string]*Device
mu sync.RWMutex
}
// ...
```
每個 domain 都有:
- `repository.go`interface + `InMemoryRepository` 實作
- `types.go`struct 定義
Phase 1新增 `postgres_repository.go`,實作同 interface。
---
## 4. Phase 1 PostgreSQL Schema預覽
雛形不建 DB但 struct 直接對應以下 schema 的欄位:
```sql
-- users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email CITEXT UNIQUE NOT NULL,
name TEXT,
password_hash TEXT,
org_id UUID,
roles TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- devices2026-04-22 Minor-3新增 remote_status + last_seen_at 雙狀態欄位)
CREATE TABLE devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id UUID NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
device_type TEXT,
serial_number TEXT,
-- tunnel-level 狀態(雲端觀察)
remote_status TEXT NOT NULL DEFAULT 'offline', -- online | offline | reconnecting | error
last_seen_at TIMESTAMPTZ, -- 最後 tunnel heartbeat 時間
last_connected_at TIMESTAMPTZ, -- 最近 tunnel 建立時間
-- USB-level 狀態local agent 上報)
status TEXT NOT NULL DEFAULT 'unknown', -- online | offline | unknown
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE (owner_user_id, serial_number)
);
CREATE INDEX ON devices (owner_user_id) WHERE deleted_at IS NULL;
CREATE INDEX ON devices (remote_status) WHERE deleted_at IS NULL;
-- pairing_tokens
CREATE TABLE pairing_tokens (
token_hash TEXT PRIMARY KEY, -- sha256(plaintext)
user_id UUID NOT NULL REFERENCES users(id),
device_id UUID REFERENCES devices(id),
kind TEXT NOT NULL DEFAULT 'pairing',
parent_token TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_by UUID,
last_seen_at TIMESTAMPTZ
);
CREATE INDEX ON pairing_tokens (user_id) WHERE revoked_at IS NULL;
CREATE INDEX ON pairing_tokens (device_id);
-- models
CREATE TABLE models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id UUID NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
description TEXT,
storage_key TEXT NOT NULL,
file_size BIGINT NOT NULL,
file_checksum TEXT,
target_chip TEXT,
input_shape INT[],
classes TEXT[],
framework TEXT,
source TEXT NOT NULL,
source_job_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX ON models (owner_user_id) WHERE deleted_at IS NULL;
-- clusters
CREATE TABLE clusters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id UUID NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
model_id UUID REFERENCES models(id),
status TEXT DEFAULT 'idle',
devices_json JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- converter_jobs
CREATE TABLE converter_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id UUID NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'queued',
source_key TEXT NOT NULL,
result_key TEXT,
target_chip TEXT NOT NULL,
params JSONB,
error_code TEXT,
error_msg TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
```
---
## 5. Migration 策略Phase 1
- 工具:`golang-migrate`
- 位置:`visionA-backend/migrations/*.sql`
- 命名:`YYYYMMDDHHMMSS_description.up.sql` / `.down.sql`
- 雛形:**不需要 migrations 目錄**Phase 1 第一個 migration 建立以上所有 table
---
## 6. 資料一致性考量
| 操作 | 需要的一致性 |
|------|-------------|
| 使用者建立 Device + PairingToken | 同一 transaction |
| 使用者刪除 Device | cascade 撤銷所有 PairingToken |
| Converter job 完成 → 建立 Model | transactional upsert + webhook idempotency |
雛形 in-memory 無交易Phase 1 用 `pgx` 的 tx。
---
**雛形實作 / 未來擴展**
- 雛形:所有 repository 用 `map + sync.RWMutex`struct 欄位按上表定義(多出的欄位留空或 zero value
- 未來:實作 `Postgres*Repository`;加上 migration處理 soft delete`WHERE deleted_at IS NULL`

View File

@ -0,0 +1,512 @@
# Design Doc — visionA Cloud
## Metadata
- **作者**Architect Agent
- **狀態**Draft待三方交叉審閱
- **最後更新**2026-04-21
- **範圍**visionA-frontend + visionA-backend 的雲端版架構定位為「POC 升格為產品」的雛形骨架
- **相關文件**
- 健檢:`.autoflow/00-onboarding/health-check.md`
- PRD`.autoflow/02-prd/PRD.md`PM 主導,平行產出中)
- 設計規格:`.autoflow/03-design/`Design 主導,平行產出中)
- TDD`.autoflow/04-architecture/TDD.md`(本文檔的姊妹檔,實作細節)
- ADRs`.autoflow/04-architecture/adr/adr-*.md`
---
## 1. 系統總覽High-Level Overview
visionA Cloud 把「使用者的 Kneron 裝置」透過雲端反向代理提供給瀏覽器操作。核心流向:
```
┌────────────────────────────────────────────────────────────────────────┐
│ 使用者瀏覽器 │
│ visionA-frontend (Next.js) │
└─────────────────┬────────────────────────────┬────────────────────────┘
│ HTTPS REST │ WSS (訂閱)
│ │
┌─────────────────▼────────────────┐ ┌─────────▼──────────────────────┐
│ visionA-backend / api-server │ │ visionA-backend / api-server │
│ (無狀態, 水平擴展) │ │ (WebSocket 訂閱, 同 binary) │
└─────────────────┬────────────────┘ └─────────┬──────────────────────┘
│ Session 查詢in-mem 同進程 / Redis 跨進程) │
▼ ▼
┌────────────────────────────────────────────────────────────────────────┐
│ 共享 Session Storerouting table
│ 雛形in-memory單進程 Phase 1Redis │
└─────────────────────────────┬──────────────────────────────────────────┘
│ 查到該 token 的 tunnel 在哪個 proxy 節點
┌────────────────────────────────────────────────────────────────────────┐
│ visionA-backend / remote-proxy (有狀態) │
│ 持有 yamux.Session map[token]→Session │
└────────────────────────────┬───────────────────────────────────────────┘
│ WebSocket + yamux長連線
┌────────────────────────────────────────────────────────────────────────┐
│ 使用者電腦 local agent │
│ (local-tool 或 tunnel-only client連 remote-proxy 出站) │
│ 本機 HTTP server :3721 │
└────────────────────────────┬───────────────────────────────────────────┘
│ USB
┌──────────────────┐
│ Kneron 裝置 │
│ KL520 / KL720 │
└──────────────────┘
外部依賴(均透過介面,雛形 stub
├─ kneron_model_converter (`/api/converter/*` stub, TODO)
├─ S3-compatible 物件儲存 (LocalFSStore for prototype)
└─ PostgreSQL + Auth provider (in-memory for prototype, ADR-005)
```
**關鍵概念**
- **Session key = pairing token**,一個 token 對應一個 yamux tunnel。
- **API Server 把收到的請求**,查 session store 找到對應 token 的 tunnel**轉發**到該 tunnel 上的 remote-proxy 節點,**由 proxy 打 yamux stream** 送進 local agent拿到 response 再回傳瀏覽器。
- **WebSocket 訂閱**同理api-server 升級 WS 後,在 session store 裡找到 tunnel把 WS 幀透過 yamux stream 送給 local agent 的 `/ws/*`
---
## 1.9 Non-Goals雛形明確不做的事2026-04-22 M-8
以下項目在 **Phase 0 雛形階段刻意不支援**,列出以避免誤解:
| # | Non-Goal | 原因 | 對應 Phase |
|---|----------|------|-----------|
| N1 | **多 instance 部署** — 雛形僅支援單一 `api-server` instance + 單一 `remote-proxy` instance | 無共享 state 機制(無 Redis、無 DB session metadata開兩個 `api-server` 會因為 session lookup 散到不同節點而壞掉;開兩個 `remote-proxy` 會讓 tunnel 註冊到 A 節點、但 api-server 的 `ProxyClientStore` 配置只連 B查無 session | Phase 1 |
| N2 | **真實使用者註冊 / 登入**`StaticAuthProvider` 無論帳密都回 demo-user | Auth 不是 POC → 產品驗證的核心風險;整合 Clerk / 自建 OIDC 需 2-4 週,放 Phase 1 | Phase 1 |
| N3 | **真實 DB 持久化** — 所有 repository 走 in-memory map | ADR-005 明定Phase 1 實作 `Postgres*Repository` | Phase 1 |
| N4 | **真實 Converter 整合**`/api/converter/*` 走 Stub | Converter 團隊 API 規格未定;見 `api/api-converter-contract.md` | Phase 1 |
| N5 | **Pairing Token 兩階段升級** — 雛形 Pairing 與 Session 合併為單一 token | 單 token 比對流程足以驗證 tunnel兩階段 DB transaction 需 Postgres | Phase 1必須在公開 release 前完成)|
| N6 | **HTTPS / WSS** — 雛形走明文 HTTP / WS | TLS termination 由 Phase 1 LB / reverse proxy 處理 | Phase 1 |
| N7 | **Rate limiting / Audit log** — 雛形無 | 雛形只跑 dev / 內測 | Phase 1 |
| N8 | **Observabilitymetrics / trace / alert** — 雛形只有 stdout log | 雛形不追求 SLO | Phase 1 |
| N9 | **Local agent 自己的 binaryvisionA/local-agent/** — 雛形用 POC `edge-ai-server` 當 tunnel client 暫代 | 讓雛形專注在 backend 協定正確性Phase 1 從 local-tool 複製起步 | Phase 1 |
| N10 | **`cmd/dev-all-in-one` 單進程合併模式** | 會讓部署拓撲與 Production 不一致,反而掩蓋 bug | 永不做 |
**重要後果**
- 開第二個 `api-server`(例如為了測試無狀態性)會因 `ProxyClientStore` 只配一個 `VISIONA_PROXY_INTERNAL_URL`,導致兩個 api-server 都指向同一個 remote-proxy這**可以**運作(真實多 instance 場景)。但開兩個 `remote-proxy` 會壞 — tunnel 連上的是 A但 api-server 可能查到 B視 LB 分配B 沒有 session 就回 404。
- 雛形的部署指引明確:**恰好 1 個 api-server + 恰好 1 個 remote-proxy**。
- Phase 1 解多 `remote-proxy` 節點時會補 session metadata 共享機制(見 `tunnel.md` §5.4 + 待產出的新 ADR
---
## 2. 系統邊界與組件
### 2.1 visionA-frontend客戶端
| 屬性 | 值 |
|------|-----|
| 技術 | Next.js 16 App Router + React 19 + TypeScript + Tailwind 4 + Radix + Zustand 5 + Lucide + Recharts + driver.js |
| 部署 | 靜態打包 + CDN雛形用 Next.js 本地 dev serverPhase 1 上 Vercel / Cloudflare Pages / S3+CloudFront|
| 狀態 | 無(純前端,所有資料 via API|
| Auth | JWT / session cookie雛形 stubPhase 1 真實) |
| 連線對象 | **只連 `visionA-backend/api-server`****不直連** remote-proxy 或 local agent |
**從 local-tool 搬來的內容**所有頁面、components、stores、i18n、UI styling改 API base URL移除 localhost hardcode。
**新增**
- `/login``/register`雛形骨架Auth stub
- `/account`(雛形骨架)
- `/devices/pair`(輸入 / 產生 pairing token
- `/clusters`(從 POC 搬)
### 2.2 visionA-backend / api-servercmd/api-server
| 屬性 | 值 |
|------|-----|
| Binary | `cmd/api-server/main.go` |
| 狀態 | **無狀態**(所有狀態在 DB / session store / 物件儲存)|
| 水平擴展 | ✅ 多實例 + 前置 L7 load balancer |
| 對外 | HTTP :3001雛形預設正式上 443 |
| 職責 | REST API、WebSocket 升級、Auth、權限、把請求 proxy 到 remote-proxy、Storage presigned URL、Converter 呼叫 |
### 2.3 visionA-backend / remote-proxycmd/remote-proxy
| 屬性 | 值 |
|------|-----|
| Binary | `cmd/remote-proxy/main.go` |
| 狀態 | **有狀態**(持有 yamux.Session|
| 水平擴展 | 可多實例session 路由交給 session store |
| 對外local agent | WS :3800POC 預設)|
| 對內api-server | HTTP :3801內部 API不對外|
| 職責 | 接受 local agent 的 tunnel 連線、維護 yamux session、轉發 api-server 送來的請求 |
### 2.4 共享狀態Session Store2026-04-22 Q1 裁決 C + ADR-006雛形即雙 binary無 Redis
| 雛形實作 | Phase 1 實作 |
|---------|-------------|
| Session state **完全由 remote-proxy 持有**in-memoryapi-server 無狀態,透過 internal HTTP 向 remote-proxy 查詢 | 多 remote-proxy 節點間的 metadata 共享機制**待 Phase 1 評估**Redis / gossip / sticky LB 等 — 不預設採用 Redis |
**雛形設計**
- `cmd/remote-proxy`**唯一**持有 `*yamux.Session` 的 process`InMemoryStore` 儲存
- `cmd/api-server`:無狀態;`internal/session` 載入 `ProxyClientStore`internal HTTP client
- 兩 binary 透過 `internal/forward/*``internal/session/*` endpoints 溝通(詳見 `api/api-internal.md`
**Non-Goal刻意不做**
- **不做 `cmd/dev-all-in-one` 單進程合併模式** — 會讓部署拓撲與 Production 不一致,反而讓 bug 在雛形階段無法暴露
- **不引入 Redis** — POC 從未用過(見 ADR-006
詳細見 TDD §2 與 `tunnel.md` §5。
### 2.5 local agent使用者電腦
三種形態:
1. **local-tool**(桌面版 Wails 應用):原本離線用,加一個 config 選項「啟用雲端模式」後,額外啟動 tunnel client 連 remote-proxy。**local-tool 本身不動**;雲端模式是新增 opt-in 模組,不影響離線使用。
2. **tunnel-only client**未來專為「headless / server 使用者」提供的輕量 Go binary只跑 tunnel client + 最小 HTTP server不含 Wails / UI。雛形不做。
3. **既有 edge-ai-platform**POC 的 tunnel client 可直接繼續用來做技術驗證。
### 2.6 外部依賴
| 依賴 | 雛形 | Phase 1 |
|------|------|--------|
| `kneron_model_converter` | Stub`/api/converter/*` 回假資料 | 真實打 converter API |
| 物件儲存 | `LocalFSStore`(本地檔案)| `S3Store`AWS S3 / Cloudflare R2 / MinIO|
| 資料庫 | in-memory repositories | PostgreSQL |
| Auth | `StaticAuthService`(永遠 demo-user| 真實 Auth |
| Observability | stdout log | Prometheus + Loki + OpenTelemetry |
---
## 3. 12-Factor 合規策略
| # | Factor | 雛形做法 | Phase 1 做法 |
|---|--------|---------|-------------|
| 1 | Codebase | ✅ Monorepo (`visionA/visionA-frontend` + `visionA/visionA-backend`) | 同 |
| 2 | Dependencies | ✅ `go.mod` + `package.json` 明確宣告;無系統隱式依賴 | 同 |
| 3 | Config | ✅ 全走 env vars`VISIONA_*` prefix`internal/config` 讀取 | 同 + 密鑰用 Secrets Manager |
| 4 | Backing services | ✅ Storage / DB / Auth 全走 interface | 同(實作 swap|
| 5 | Build / Release / Run | ⚠️ 雛形只有 `make build` + `docker-compose` | BuildCI 產出 imageReleasetag + manifestRunK8s / ECS deploy |
| 6 | Processes | ✅ api-server 無狀態state 全走 Store interface | 同 |
| 7 | Port binding | ✅ api-server `:3001`remote-proxy 對 agent `:3800`,對 api `:3801` | 同port 由 env 指定)|
| 8 | Concurrency | ✅ 單進程多 goroutine跨進程靠 SessionStore | 多實例 + auto-scale |
| 9 | Disposability | ⚠️ 雛形 SIGTERM graceful shutdowntunnel 斷線有重連 | 同 + drain period |
| 10 | Dev/Prod Parity | ✅ Docker 一致化 | 同 |
| 11 | Logs | ⚠️ 雛形 stdout已有 broadcaster WebSocket 看 log| 結構化 JSON log + 集中收集 |
| 12 | Admin processes | ⚠️ 雛形手動 | 後台 CLI / admin API |
**雛形妥協**log 未結構化、缺 metrics、缺 trace。記錄為 TODO不影響功能驗證。
---
## 4. 水平擴展策略
### 4.1 api-server無狀態
- 多實例,前置 L7 load balancerALB / nginx / Cloud Run 自動)
- Sticky session **不需要**(無狀態)
- 擴展訊號CPU > 60% 或 p95 延遲 > 300ms
### 4.2 remote-proxy有狀態
remote-proxy 的狀態是 `map[token]*yamux.Session`。擴展的挑戰:
- Local agent 連 proxy 時,會連到某一個**具體節點**(由 LB 分派ALB + IP hash 或 DNS round-robin
- 後續 api-server 要送請求到該 token 的 tunnel 時,需要**知道這條 tunnel 在哪個 proxy 節點**
**解決方案Session Store 記錄 routing table**
```
Session Store 儲存Redis Hash:
session:{token} = {
proxy_node_id: "proxy-2",
proxy_internal_url: "http://proxy-2.internal:3801",
connected_at: "...",
last_seen: "..."
}
```
api-server 送請求的流程:
```
1. 收到 API 請求GET /api/devices + X-Pairing-Token: xxx
2. 查 Session Storetoken → proxy_internal_url
3. 呼叫該 proxy 的 internal endpointPOST http://proxy-2.internal:3801/forward
- body = 原請求method, path, headers, body
4. proxy-2 收到後,從自己 in-memory map 取 yamux.Session
5. 開 stream傳 HTTP request
6. 等 response → 回給 api-server → 回給瀏覽器
```
### 4.3 雛形簡化(單節點)
雛形期 api-server 與 remote-proxy **同進程**SessionStore 就是本機 map**跳過跨節點轉發**
```
1. 查 SessionStorelocal map取 yamux.Session
2. 直接開 stream 送請求
```
Phase 1 時把 SessionStore 換 Redis、引入「proxy internal HTTP API」不改 API handler 程式碼。
### 4.4 擴展邊界
| 資源 | 估算 |
|------|------|
| remote-proxy 單節點 tunnel 數上限 | ~10K受檔案描述符 / 記憶體限制,每個 tunnel ~100KB|
| api-server 單節點 QPS | ~5KGin + Go 典型值)|
| Session StoreRedis單節點 QPS | ~100K |
雛形單節點即可撐 demo / 早期內測。Phase 1 再引入多節點。
---
## 5. 資料流
### 5.1 前端呼叫 REST API最常見情境
```
[瀏覽器] GET /api/devices
↓ HTTPS + Cookie/JWT
[api-server] authMiddleware 驗 user
↓ 從 user 找對應 pairing token雛形 env 寫死Phase 1 查 DB
[api-server] 查 SessionStore(token) 取 proxy location
(單節點雛形:直接拿到 yamux.Session
(多節點:轉發 http://proxy-X.internal:3801/forward
[remote-proxy] 從 session open yamux stream
↓ 把 HTTP request 寫進 stream沿用 POC 方法)
[local agent] tunnel client accept stream
↓ 轉發到本機 127.0.0.1:3721
[local HTTP server] 處理 /api/devices → 回 response
↓ 經 yamux stream 回到 remote-proxy
↓ remote-proxy 回 api-server
↓ api-server 回瀏覽器
[瀏覽器] 得到裝置列表
```
### 5.2 前端開 WebSocket 訂閱(例:裝置事件串流)
```
[瀏覽器] WS /ws/devices/events (connect)
[api-server] authMiddleware → 升級 WS
↓ 查 SessionStore → 取 yamux session
↓ 開 stream把 HTTP upgrade request 寫進 stream沿用 POC proxyWebSocket 邏輯)
[local agent] handleStream 偵測 upgrade → 連本機 raw TCP → 雙向 copy
[local HTTP server] /ws/devices/events 升級 → 開始推事件
↓ 事件 frame → yamux stream → api-server → 瀏覽器
```
POC 已完整實作此流程visionA 直接沿用。
### 5.3 模型上傳(**不走 tunnel**
```
[瀏覽器] 點「上傳模型」
[api-server] POST /api/models/upload-url
↓ 呼叫 Storage.PresignedPutURL → 回給瀏覽器
[瀏覽器] 直接 PUT 檔案到 S3 / LocalFS不走 tunnel省 tunnel 頻寬)
↓ 上傳完成後
[瀏覽器] POST /api/models/register (告知 api-server 上傳完成 + metadata
[api-server] 寫入 model repository
```
**為何不走 tunnel**
- 模型檔可達百 MB走 tunnel = 占用使用者本地頻寬兩次(上傳 → 雲端 → 下載)
- S3 presigned URL 讓瀏覽器直連scalable
- 需要時 api-server 再叫 local agent「下載這個 model 的 URL」由 local agent 用自己網路下載
### 5.4 轉檔呼叫Phase 1
```
[瀏覽器] POST /api/converter/convert {source_url, target_chip: "kl520"}
[api-server] 驗 user → 呼叫 kneron_model_converter API
↓ (非同步)取得 job_id
[api-server] 回 job_id
↓ 輪詢 /api/converter/jobs/{id} 或 WS 訂閱進度
```
雛形 api-server 提供端點,但回 stubjob_id 假的,狀態永遠 `queued`)。
---
## 6. 安全架構
### 6.1 通訊加密
- **瀏覽器 ↔ api-server**HTTPSPhase 1雛形開發用 HTTP
- **api-server ↔ remote-proxy 內部**:雛形同進程無需加密;多節點 Phase 1 考慮 mTLS 或 VPC-only
- **local agent ↔ remote-proxy**WSSPhase 1雛形 WS。TLS 由前端反代ALB / nginx終止
### 6.2 認證 / 授權
| 物件 | 雛形 | Phase 1 |
|------|------|--------|
| 使用者登入 | `StaticAuthService` 永遠過 | OIDC / SAML / 自建 |
| 前端 call API | 無 token 也接受stub| JWT / session cookie |
| local agent 連 proxy | `VISIONA_PAIRING_TOKEN` env 寫死 | DB-backed Pairing Token見 ADR-003|
| API handler 授權 | 一律通過 | RBAC`user 只能看自己的 device` |
### 6.3 CORS / CSRF
- **CORS**api-server 雛形設 `Access-Control-Allow-Origin: *`devPhase 1 白名單
- **CSRF**:用 JWT in header非 cookie可天然免疫若用 session cookie 則需 CSRF token
- **WebSocket Origin**:本 repo 的 `local-tool/server/internal/api/ws/origin.go` 已有成熟 allowlist搬過來用
### 6.4 Pairing Token 安全
詳見 ADR-003。重點
- 雛形env 寫死
- Phase 1DB 存 `sha256(token)`不存明文15min expiry可 revoke綁 user_id + device_id
### 6.5 輸入驗證
- 模型檔案格式驗證(雛形僅 extensionPhase 1 加 magic bytes + file size limit
- API body schema 驗證(用 struct tags `binding:"required"``go-playground/validator`
### 6.6 STRIDE 分析(重點項目)
| 威脅 | 防護 | 雛形 / Phase 1 |
|------|------|--------------|
| 偽冒Spoofing他人冒用 token | `sha256(token)` 存 DB + rate limit雛形 env 隔離 | Phase 1 必做 |
| 竄改Tampering中間人改 request | TLS | Phase 1 |
| 否認Repudiation | Audit log | Phase 1 |
| 資訊洩露Information Disclosure | TLS + least privilege log | Phase 1 |
| DoS | rate limit on `/tunnel/connect` + API | Phase 1 |
| 提權EoP | RBAC | Phase 1 |
**雛形期驗證技術,不預期公開部署**,以上多數 Phase 1 才補全。
---
## 7. 部署架構
### 7.1 雛形(開發 / 內測)
**方案 A本機一鍵啟動**
```
docker-compose up
```
`docker-compose.yml`
```
services:
api-server:
build: { context: ., dockerfile: docker/Dockerfile.api-server }
ports: ["3001:3001"]
environment:
VISIONA_SESSION_BACKEND: inmemory
VISIONA_STORAGE_BACKEND: localfs
VISIONA_AUTH_MODE: static
VISIONA_PAIRING_TOKEN: dev-token-abc123
volumes: ["./data:/data"]
remote-proxy:
build: { context: ., dockerfile: docker/Dockerfile.remote-proxy }
ports: ["3800:3800", "3801:3801"]
environment:
VISIONA_SESSION_BACKEND: inmemory
VISIONA_PAIRING_TOKEN: vAc_<32 hex> # 格式見 security.md §1.3
frontend:
build: { context: visionA-frontend }
ports: ["3000:3000"]
environment:
NEXT_PUBLIC_API_BASE: http://localhost:3001
```
**雛形交付物Phase 0**:上述雙 binary + docker-compose 即完整雛形。**不提供** `cmd/dev-all-in-one`(見 §2.4 Non-Goal
### 7.2 Phase 1正式部署草圖
```
┌──────────┐
│ CDN │ (Cloudflare / CloudFront)
└────┬─────┘
│ static frontend
┌───────────────┴────────────────┐
│ │
┌────▼─────┐ ┌──────▼────┐
│ ALB │ │ NLB │ (for WebSocket, TCP pass-through)
└────┬─────┘ └──────┬────┘
│ HTTPS /api/*, /ws/* │ WSS /tunnel/connect
│ │
┌────▼───────────────┐ ┌────▼──────────────────┐
│ api-server cluster │◄──────────►│ remote-proxy cluster │
│ (K8s / ECS) │ Redis │ (K8s / ECS, StatefulSet│
│ stateless, N pods │ Session │ or DaemonSet) │
└────┬───────────────┘ Store └────┬──────────────────┘
│ │
├─► PostgreSQL (RDS) │
├─► S3 / R2 (object store) │
└─► Converter API │
│ WSS + yamux
使用者電腦 local agent
```
**Cloud-agnostic**所有組件Redis、PG、S3都有對應的 interface可用 AWS / GCP / 自建。
### 7.3 CI/CD雛形目標
- `make build` → 兩個 binary
- `make docker-build` → 兩個 image
- `make docker-compose-up` → 本機跑起來
- GitHub Actions / GitLab CIPhase 1→ 自動 build + push registry + deploy
---
## 8. 觀測Observability
### 8.1 雛形
| 項目 | 做法 |
|------|------|
| Log | stdout沿用 local-tool 的 log broadcasterWS 看 log|
| Metrics | 無;`/api/system/health` 回 ok 即可 |
| Trace | 無 |
| Alert | 無 |
### 8.2 Phase 1TODO
| 項目 | 做法 |
|------|------|
| Log | 結構化 JSON → Loki / CloudWatch Logs |
| Metrics | Prometheus export`qps`, `p95_latency`, `tunnel_count`, `session_active_count` |
| Trace | OpenTelemetry SDK → Tempo / X-Ray |
| SLO | API 可用性 99.9%p95 < 500msTunnel 連線穩定度 99%7 天滾動|
| Alert | Grafana / PagerDuty |
---
## 9. ADR 索引
| 編號 | 標題 | 狀態 |
|------|------|------|
| [ADR-001](adr/adr-001-two-binaries.md) | 單一 Go 專案 / 雙 binary 結構 | Accepted |
| [ADR-002](adr/adr-002-tunnel-protocol.md) | 沿用 POC 的 Tunnel 協定WebSocket + yamux| Accepted |
| [ADR-003](adr/adr-003-pairing-token.md) | 以 Pairing Token 取代 SHA256(MAC) Token | Accepted |
| [ADR-004](adr/adr-004-storage-interface.md) | 模型儲存採用 S3-Compatible 介面 | Accepted |
| [ADR-005](adr/adr-005-no-db-auth-in-prototype.md) | 雛形階段不接真實 DB 與 Auth | Accepted |
---
## 10. 三方交叉審閱檢核清單
### 給 PM 審閱
- [ ] 所有 PRD 中列出的 P0 功能,是否在本 Design Doc 都有對應的技術路徑?
- [ ] 「雲端能操作使用者本地裝置」的核心價值主張是否由架構支撐?
- [ ] 雛形 vs Phase 1 的差異PM 能否向使用者解釋「現在能做什麼、未來會做什麼」?
### 給 Design 審閱
- [ ] 「pairing token 輸入」頁面是否技術可行API 已定義)?
- [ ] 模型上傳流程的 presigned URL 對 UX 是否清楚?
- [ ] tunnel 斷線時瀏覽器要怎麼顯示?(已在 §5.2 指出走 POC 同機制;需 Design 規劃 UI
### 給 Architect 自我檢查
- [x] 每個 ADR 都有 Context / Decision / Consequences
- [x] 所有雛形實作都有明確的 Phase 1 替換計畫
- [x] 擴展策略說明完整§4
- [x] 安全架構覆蓋 STRIDE§6.6
---
**下一步**:閱讀 `TDD.md`(實作細節),以及各 ADR。

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,140 @@
# Architect 交叉審閱 — PRD 與設計規格
| 項目 | 內容 |
|------|------|
| 審閱者 | Architect Agent交叉審閱實例 |
| 範圍 | `02-prd/*` + `03-design/*` |
| 審閱日期 | 2026-04-21 |
| 架構基準 | 雙 binaryapi-server + remote-proxy沿用 POC tunnel / yamux無 Redis、無 DB、StaticAuthService |
---
## 1. 總評
| 面向 | 評分5 級) | 說明 |
|------|------------|------|
| 需求與架構整體對齊 | **4** | PRD 的 P0 功能都與雙 binary 架構匹配few gaps 需標示 |
| 技術可行性 | **4** | 核心 P0登入 stub、Pairing、裝置列表、Camera 推論)都能在雛形範圍達成 |
| 效能 NFR 合理性 | **3** | 端到端推論 P95 < 500ms 偏樂觀Tunnel 建立 < 2s重連 < 5s 可達 |
| 隱藏依賴揭露 | **3** | 有若干設計項目預設後端能力未於 TDD 落地(見第 4 節) |
**整體結論****可進入雛形實作**,但 5 個 Major 與若干 Minor 需在建雛形前確認或調整。
---
## 2. P0 / 核心頁面可行性逐項檢查
| 項目 | 來源 | 可行性 | 備註 |
|------|------|--------|------|
| US-01/02/13 登入 / 註冊 / 登出 stub | PRD | **可行** | StaticAuthService 回 demo-user前端 form submit 後直跳 |
| US-03 裝置列表 | PRD + `pages.md §5` | **可行** | in-memory SessionStore 查詢即可 |
| US-04 取 Pairing Token | `feature-pairing.md` | **可行** | api-server 產生,存 in-memory map |
| US-05 local agent 用 token 建 tunnel | `feature-pairing.md` | **可行但有風險** | 雛形用 POC `edge-ai-server` 當 tunnel clientQ3 決策);需驗證 token 驗證路徑 |
| US-06 即時更新裝置列表 | `feature-pairing.md` | **可行** | 設計走 WebSocket 廣播;雛形也可退化為 3-5 秒 polling見 Major #2|
| US-07 裝置詳細 | `feature-device-management.md` | **可行** | 透過 tunnel 轉發到 agent |
| US-08/09 模型 7 預設 + 上傳 | `feature-model-management.md` | **可行** | LocalFSStorage 實作 ObjectStorage 介面 |
| US-10/11 Camera 推論 + MJPEG | `feature-inference.md` | **可行但需驗證** | MJPEG binary + yamux 吞吐量需 PoC 驗證(見 Major #3|
| US-12 i18n + Dark Mode | PRD | **可行** | 沿用 local-tool |
| `/devices/pair` Stepper | `flow-pairing.md` | **可行** | 三步驟流程無技術難點 |
| `/clusters`(從 POC 搬)| `pages.md §9` | **P1 / 非 P0**,可行 | 本次雛形不進 P0 |
| 離線降級遮罩 / Banner | `flow-offline-handling.md` | **可行** | 需後端提供 heartbeat 與狀態推送 |
---
## 3. 效能與 NFR 風險
| 指標 | PRD 目標 | Architect 評估 | 風險等級 |
|------|---------|--------------|---------|
| Camera 端到端延遲 P95 | < 500ms | 本機 150-250ms + WAN RTT 50-200ms + 雲端 proxy 5-10ms + 渲染 10-50ms**實測可能 400-550ms**雛形內網測試能過WAN 場景要驗證 | 🟡 |
| Tunnel 建立 < 2 | Phase 0 目標 | TLS handshake + yamux handshake + token 驗證若冷啟動可能到 2-3 | 🟢 |
| Tunnel 重連 < 5 | Phase 0 目標 | 指數退避第一次重連 1s 內可達**需注意退避演算法不要過保守** | 🟢 |
| Tunnel throughput ≥ 5 Mbps | Phase 0 | yamux 單 stream 足以MJPEG 480p ~2-5 Mbps**臨界值需實測** | 🟡 中 |
| 並發 yamux streams ≥ 32 | Phase 0 | POC 已驗證,可沿用 | 🟢 低 |
| API 簡單端點 P95 < 200ms | Phase 0 | in-memory 查詢 + 小邏輯輕鬆達標 | 🟢 |
| 多 tab 同看同一推論 | `feature-inference.md` 驗收 | MJPEG multipart 多 consumer + WebSocket 廣播,**需確認 remote-proxy fan-out 行為**Q5 決策是覆蓋,不是 fan-out| 🔴 高(見 Major #4|
---
## 4. 發現的問題(按嚴重度)
### 🔴 Critical — 0 項
_無阻擋雛形上路的問題。_
### 🟠 Major — 5 項
**M1.「裝置自動出現在雲端頁面」的即時推送機制未明確**PRD US-06、`feature-device-management.md §即時事件`
- PRD 預設走 WebSocket 廣播(`/ws/devices/events`Design 的離線處理也依賴 WebSocket push
- 但 Q5 裁決「同 token 後連覆蓋前連」、Q1 決策「不引入 Redis」**沒有跨 api-server instance 的 pub/sub**
- 雛形單機可走 process-internal event bus**但這限制必須寫進 TDD**,避免後續誤開第二個 api-server instance 時事件廣播失效
- **建議**TDD 明定「雛形單 api-server instance多 instance 擴展留 Phase 1」並在 Design Doc 加 Non-Goals
**M2.「Pairing Token 有效期」PRD 與 Design 不一致**
- `feature-pairing.md` 寫 **TTL 15 分鐘**
- `flow-pairing.md` §4.1 顯示 **「有效期72 小時」**§7.1 也寫 72 小時
- 兩個數字差了一個數量級,**必須裁決**
- **建議**Phase 0 雛形採 **15 分鐘**PRD 為準,安全性較好);如 UX 認為 15 分鐘太短,改 1-2 小時比 72 小時更合理
**M3. MJPEG binary stream 過 yamux 的吞吐量未驗證**`feature-inference.md` §4
- 設計預設 MJPEG 480p~2-5 Mbps能透過 yamux 單 stream 穩定傳輸
- POC 驗證的是低頻控制訊息,**未必涵蓋 sustained binary stream**
- **建議**:雛形實作前先做 1 天的 PoC — 用 POC 的 tunnel 跑 5 分鐘 MJPEG量測 throughput / drop rate
- 若不達標,退路是降 MJPEG 解析度到 360p 或改 H.264
**M4. 多 tab 同看同一推論的 fan-out 設計**`feature-inference.md` 驗收條件)
- 驗收條件要求「同一使用者開兩個 tab 看同一裝置推論 → 都能看」
- 但 Q5 決策是「同 token 後連覆蓋前連」,**這是在 tunnel 層**,不等於 MJPEG stream 層
- 目前設計未描述:同一個從 agent 來的 MJPEG streamapi-server 要 fan-out 給 N 個瀏覽器 client
- **建議**:雛形可以限制「一個裝置同時只能被一個 tab 開推論」,把 multi-tab 驗收條件降級為 P1若要 P0 支援TDD 必須加 stream multiplexer 設計
**M5. 設計預設後端回傳 `lastSeenAt``remoteStatus``hostName` 等欄位,但 API spec 未必齊**
- `flow-offline-handling.md §2` 定義 Device 介面新增欄位
- `design-spec.md §5.2` 已提醒 Architect但 TDD 要確實補上
- **建議**TDD 的 `/api/devices` response schema 明確包含:`remoteStatus``lastSeenAt``hostName``pairedAt``errorMessage`
### 🟡 Minor — 4 項
**m1. Pairing Token 長度 / 字首規範兩份文件寫法不一**
- `feature-pairing.md` 規範 `vAc_` 字首 + 32 hex
- `flow-pairing.md` / `wf-pairing.md` 範例顯示 **16 字元 hex 無字首**`a1b2c3d4e5f6g7h8`
- 健檢也確認「POC 是 16 字元 hex」
- **建議**Phase 0 先用 16 字元 hex 與 POC 一致,`vAc_` 字首留 Phase 1
**m2. 「連線品質圖表」、「Latency 顯示」 design 標 Phase 1`design-spec.md §5.1` 提到 Tunnel 延遲在雲端版是 P0 顯示**
- 對齊一下Phase 0 顯示「單次 RTT 數字」即可「歷史圖表」Phase 1
- 雛形 agent heartbeat 回傳 timestampapi-server 計算 now - lastSeenAt 即得 RTT 近似值
**m3. Storage 介面的 `GetUploadURL` / `GetDownloadURL` 在 LocalFSStorage 該如何實作未明**
- `feature-model-management.md §ObjectStorage 介面` 包含這兩個方法
- LocalFSStorage 雛形應該:`GetDownloadURL` 回傳 `/api/models/{id}/download` 走 api-server 串流;`GetUploadURL` 雛形可回 `error: not supported`(前端 fallback 到 multipart form upload
- **建議**TDD 裡明寫這個回退行為
**m4. `/workspace/[deviceId]` 掉線時「保留最後一幀」的實作位置**
- `flow-offline-handling.md §6.2` 要求 Camera stream 顯示最後一幀 + overlay「連線中斷」
- 實作上是前端 canvas 緩存,還是後端保留?
- **建議**:前端 canvas 自己緩存 last frame後端不需額外邏輯省事
### 🟢 Suggestion — 2 項
**s1. PRD Phase 0 SLOapi-server 95%、remote-proxy 99%)對單人開發雛形過嚴**
- 建議 Phase 0 直接標「SLO N/A — 內部驗證階段」,避免造成「要做 HA 才能達標」的錯覺
**s2. 「5 秒內裝置上線反映」(`feature-device-management.md` 驗收條件)對照 Q5「覆蓋前連」的決策**
- 覆蓋瞬間新 session 建立 → event bus 推播 → 前端更新,實測通常 < 2
- 可寫進 TDD 測試清單作為雛形驗收指標
---
## 5. 建議的下一步
1. **使用者裁決 M2**Pairing Token TTL15 分鐘 vs 72 小時)
2. **Architect TDD 補強 M5**Device API schema 明定 `remoteStatus` / `lastSeenAt` / `hostName`
3. **Architect TDD 寫入 Non-Goal單 api-server instance / 不跨節點事件廣播**M1
4. **雛形前置 PoC**:跑一次 MJPEG over yamux 吞吐量測試M31 天內可完成
5. **驗收條件降級**multi-tab 同看推論 → 改 P1M4
---
## 6. 整體判斷
PRD 與設計規格**結構完整、彼此呼應度高**Design Agent 已主動標註給 Architect 的提醒)。技術可行性上沒有阻擋雛形的問題,但有 **5 個 Major** 屬於「不處理會在實作時踩雷」的層級,建議在雛形骨架建立前用 1-2 天處理完成。

View File

@ -0,0 +1,480 @@
# Phase 0.8 三方交叉審閱 — Architect 視角
> **作者**Architect Agent
> **日期**2026-04-30
> **審閱對象**PM PRD`feature-converter-integration.md`、Design wireframe`wireframe-conversion.md`、Design flow`flow-conversion.md`
> **對照基準**Architect ADR-014 v1.1、`conversion.md` v0.3、`api/api-conversion.md` v0.3
---
## 摘要
| 對象 | 嚴重 | 中 | 小 / nit |
|------|-----|----|---------|
| PM PRD | 1 | 4 | 2 |
| Design wireframe | 1 | 2 | 2 |
| Design flow | 1 | 1 | 1 |
> **嚴重 = 直接導致實作有歧義或 frontend / backend 對不上**;中 = 文件不精確但可推測;小 = 命名 / 措辭。
**Architect 自己的 TDD 修訂**4 個章節(議題 #2 / #5 / #6 / #7)已寫進 `conversion.md` v0.3 + `api/api-conversion.md` v0.3。詳見 §3。
**需要使用者裁決的事項**1 件API endpoint 路徑命名 — `/api/conversion/*` vs `/api/converter/jobs/*`,目前三方文件不一致)。
---
## 1. 對 PM PRD 的審閱(`02-prd/features/feature-converter-integration.md`
### 1.1 ✅ 同意的部分
- **§1 概要 / §2 User Stories**MVP 範圍清楚,半自動設計理由明確
- **§3 F1F5 的功能定義**:與我 TDD 的 endpoint 行為對齊status enum、510s polling、500MB cap、ref images 上限、translated error messages
- **§4 非功能需求**500MB / 10MB / 100 張上限、polling 間隔、active job 限制 — 全部已在 TDD §4 / §9 / §10 cover
- **§5 Non-Goals**:完整且符合 MVP 邊界(不做歷史 / 取消 UI / SSE / 多 chip
- **§6 整合決策 D1D6**:與 ADR-014 完全一致streaming proxy / FAA 直連 / 半自動 / polling / converter API 不動 / sidebar 獨立 tab
- **§7 Dependency 一覽**:清楚標出 converter / FAA / MC / visionA-backend / visionA-frontend 各自要做什麼
- **§9 驗收條件**:可測、與 TDD 行為對齊
- **§11 給 Architect 的注意事項**streaming proxy 用 `io.Pipe` 不 buffer 全 RAM、user-scoped 授權、idempotent promote — 全部已落地
### 1.2 ⚠️ 嚴重 — API endpoint 路徑命名與 TDD 不一致
**位置**PRD §F6 / §F7
```
PRD §F6POST /api/converter/jobs/{id}/import-to-models
PRD §F7POST /api/converter/jobs/{id}/download-token
Wireframe §3.3 / Flow §3GET /api/converter/jobs/active
Wireframe §6.3GET /api/converter/jobs/{id}polling
TDDArchitect
POST /api/conversion/init
GET /api/conversion/{job_id}
POST /api/conversion/{job_id}/promote-to-models
GET /api/conversion/{job_id}/download ← 注意:是 GET、不是 POST `/download-token`
GET /api/conversion/active
```
**三個衝突點**
1. **path prefix**`/api/converter/jobs/*`PM/Designvs `/api/conversion/*`Architect
2. **action 命名**`import-to-models`PMvs `promote-to-models`Architect
3. **download endpoint method + 形式**PRD/Design 假設 `POST /download-token``{url, expires_at}` JSONTDD 已改為 `GET /download` server-side 302 redirectADR-014 v1.1 議題 #9 已決議的安全升級)
**建議改法**
採 Architect 的版本(`/api/conversion/*` + `promote-to-models` + `GET /download` 302— 理由:
- 路徑前綴 `/conversion/` 對應 visionA 內部模組名(`internal/conversion/`),命名一致
- `/converter/jobs/*` 容易讓人誤以為是「轉發給 converter 的 raw API」其實 visionA backend 有自己的 ownership / 半自動邏輯,不是純 proxy
- `promote-to-models` 對齊 converter 的 `/promote` 動詞、語意精確
- `GET /download` + 302 已在 ADR-014 v1.1 完成決策(安全考量壓倒 UX 微差)
**請 PM 修**(新增到 §1.3 PM 修訂建議清單):
- §F6 endpoint 改 `POST /api/conversion/{job_id}/promote-to-models`
- §F7 endpoint 改 `GET /api/conversion/{job_id}/download`server 302 redirect+ 移除「visionA backend 跟 Member Center 換 delegated download token」的描述這仍正確但藏在 server-side把「回傳 `{url, expires_at}`」改為「browser 自動 follow 302 直連 FAA」
**請 Design 修**(同節 §2.2
- wireframe §3.3、wireframe §6.3、flow §3 / §5.5 / §8.2 全部 `/api/converter/jobs/*``/api/conversion/*`
- flow §3 sequence diagram 的 download 那段 `POST /api/converter/jobs/{id}/download-token` + `200 { url, expires_at }``GET /api/conversion/{id}/download` + `302 Location: faa-url`
- wireframe §7.2「下載」流程描述:移除「拿到 `{ url, expires_at }`」,改為「按鈕觸發 anchor click 或 `window.location.href = '/api/conversion/{id}/download'`browser 自動處理 302」
### 1.3 ⚠️ 中 — Active job 端點未列入 PRD
**位置**PRD §F 段(功能需求)
PRD §F1F7 沒有列出「Active job pre-check」這個功能但 §3 F3 隱含提到「同 user 同時只能跑一個 active job」。Design wireframe §3.3 與 flow §5.1 明確使用 `GET /api/converter/jobs/active`(建議改名 `/api/conversion/active`)做 idle / 重新整理 / 多分頁的恢復邏輯。
**建議**PRD §F 段補一條 F0
```markdown
### F0進入「轉檔」頁面時的 active job 偵測
- 進入 /conversion 時前端打 GET /api/conversion/active
- 後端回應 `{ has_active: bool, job? }`
- has_active=true → 直接顯示「進行中」畫面(同 F3+ banner「您離開前的轉檔仍在進行中」
- has_active=false → 顯示空狀態 + CTA
```
理由:這個端點是 wireframe 的核心§3.3、§6.4 多分頁同步、§6.6 重啟頁面恢復),在 PRD 沒列就成了「設計依賴的隱形 contract」三方對齊時容易遺漏。
### 1.4 ⚠️ 中 — Streaming upload 進度語意未明示
**位置**PRD §F2「上傳行為」、§F3「轉檔執行與進度」
PRD §F2 寫「使用者看到的是『上傳到 visionA』的單一進度條XHR upload event」— 這裡有歧義:
- **語意 A**XHR `upload.onprogress` 顯示 browser → backend 的進度。當 browser send 完 100% 後backend 還在 forward 給 converter 的階段,前端顯示什麼?
- **語意 B**:「上傳到 visionA」= browser → backend 的單一進度,使用者看到 100% 就視為完成
實作上若採 B則 backend 需要 background goroutine + 額外 ownership 狀態(`upload_in_progress`)才能 early-return採 A 則 backend 等 converter 201 才回 200前端進度顯示 100% 後到實際切 processing 之間有 1-3 秒「即將完成…」/「伺服器處理中…」。
**TDD 採用語意 A**(見 `conversion.md` §4.3.1 v0.3 新增)— 理由:
- 100% 接近端到端真實狀態,使用者不會被欺騙
- 實作簡單,失敗處理路徑清楚
- 1-3 秒延遲對 500MB 上傳體感影響極小
**建議 PM 補在 §F2 / §F3**
```markdown
> **進度條語意說明**:上傳進度條顯示 browser → visionA-backend 的進度。當顯示 100% 但
> backend 還在轉發給 converter 時,文案會切為「即將完成…」/「伺服器處理中…」13 秒),
> 然後才切到「轉檔進行中」畫面。對使用者誠實 — 不謊報已完成。
```
### 1.5 ⚠️ 中 — Promote-to-models request body 對齊 Design
**位置**PRD §F6「加到模型庫」流程
PRD §F6 沒有提到「需要使用者輸入名稱」,但 Design wireframe §7.1 設計了 import Dialog 含名稱輸入欄。我這邊 API specv0.3)已對齊 Design 的「單欄位 name only」決議。
**建議 PM 補在 §F6**
```markdown
- 點「加到模型庫」開確認 Dialog單欄位
- 模型名稱(預設 `{source_filename_stem}_{target_chip.lower()}`,可改)
- 描述欄位 Phase 1 才開放
- 確認後呼叫 POST /api/conversion/{id}/promote-to-models
```
或在 §F6 末尾加註「Dialog UI 與欄位定義詳見設計規格 wireframe §7.1。」
### 1.6 ⚠️ 中 — visionA-backend 重啟後的 UX
**位置**PRD §11 給 Architect 的注意事項 / 應該補在 §6 整合決策或 §F0
PRD 沒有討論 visionA-backend 重啟部署、crash recovery後使用者 UX 該怎樣。我 TDD §2.6.1 v0.3 補了 lazy rebuild 機制A4 方案 — `/active` 端點 fallback 對 converter 查 `?user_id=&status=in_progress`),讓使用者重啟後仍能看到自己的 active job。
**這需要 converter Phase 1 提供 `GET /api/v1/jobs?user_id=&status=in_progress` endpoint**converter Phase 1 已實作,但 PRD §7 dependency 表未列入「visionA 會用此 endpoint」
**建議 PM 補在 §7 Dependency 表**
```markdown
| kneron_model_converter | ❌ 完全不用動 | 沿用既有 endpoint`POST /api/v1/jobs``GET /api/v1/jobs/{id}``POST /promote``GET /api/v1/jobs?user_id=&status=in_progress`visionA-backend 重啟後 lazy rebuild ownership 用)、`POST /api/v1/jobs/{id}/cancel`visionA-backend 偵測 client disconnect 時內部 cleanup 用) |
```
**也建議在 §6 整合決策表加一條 D7**
```markdown
| D7 | visionA-backend in-memory ownership 重啟遺失 | Phase 0.8 採 lazy rebuild`/active` fallback 對 converter 查Phase 0.9+ 評估 DB persist | converter 7 天 expires_at 兜底UX 上使用者重進頁面仍看得到 active job |
```
### 1.7 小 — KPI「上傳到拿 NEF P95 < 10 分鐘量測點
**位置**PRD §8 KPI
PRD §8 寫「visionA backend 在 import / download 觸發點 log timestamp」— 但 import/download 是「使用者拿到 NEF 後的下一步」,不是「拿到 NEF 的當下」。建議改:
- 量測點 = converter status 從 `running``succeeded` 的當下時間 - `init` 收到 request 的時間
- 這是 backend 視角的端到端時間,不依賴使用者按按鈕
非阻擋議題,建議調整。
### 1.8 小 — 4 個 chip 數量
PRD §F2 寫「目標 chipKL520 / KL630 / KL720 / KL730」4 個),但 TDD `api-conversion.md` §1 與 converter `platform` 欄位的 enum 寫的是 `520 / 720`2 個)。需確認:
- converter Phase 1 實際支援哪幾個 platform
- 如果只支援 520 / 720PRD 應改為 2 個(避免使用者選了 630/730 然後被 converter 拒絕)
- 如果 converter Phase 1 已擴充到 4 個TDD `platform` enum 應改為 `520 / 630 / 720 / 730`
**建議 PM**:請與 converter 團隊確認 Phase 1 實際支援的 chip listPRD 與 TDD 同步更新。
---
## 2. 對 Design 的審閱
### 2.1 對 Wireframe`03-design/wireframes/wireframe-conversion.md`
#### 2.1.1 ✅ 同意的部分
- **§0 設計對齊備註**:複用既有元件、不新增 Design Tokens、走 i18n — 全部對的方向
- **§1 Sidebar 進入點**放模型庫之後心智模型清楚Wand2 icon 選擇合理
- **§2 頁面狀態總覽**state 機切 4 種畫面,無 URL query state — 簡潔正確
- **§3.3 邊界 — 已有 active job**:用 `GET /jobs/active` 做進入恢復是正確的設計(解決多分頁 / 重新整理 / 重啟)
- **§4 Upload Dialog**階段切換select / uploading合理§4.4 上傳失敗的 4 種 case 完整
- **§5 主畫面 vs Dialog 進度**分開顯示避免使用者困惑、tab title 動態更新
- **§6 Processing 畫面**3-stage indicator 設計、indeterminate progress、polling 間隔5s 前 60 秒、之後 10s— 都與 TDD 對齊
- **§6.4 邊界情境**4 種情境(關分頁、多分頁、長排隊、長執行)有 cover
- **§7 Success 結果**:兩按鈕互不互斥 / 過期倒數 / 「開始新轉檔」放外面 — 與 PRD §F4 對齊
- **§8 Failed 狀態 + suggestions**:依 error code 切換建議很棒
- **§8.2 Job 已過期**:對 TDD `expires_at` 機制依賴正確
- **§9 響應式 / §11 i18n / §12 無障礙**:全部到位
#### 2.1.2 ⚠️ 嚴重 — endpoint 路徑與 TDD 不一致
同 §1.2。請改 `/api/converter/jobs/*``/api/conversion/*``POST /download-token` 形式 → `GET /download` + 302 redirect。
具體要改的位置:
- §3.3 邊界表「`GET /api/converter/jobs/active`」 → `GET /api/conversion/active`
- §6.3 polling 表「端點 `GET /api/converter/jobs/{id}`」 → `GET /api/conversion/{id}`
- §7.1 「呼叫 POST /api/converter/jobs/{id}/import-to-models」 → `POST /api/conversion/{id}/promote-to-models`
- §7.2 整段流程:去掉 `POST /download-token` + 拿 `{ url, expires_at }` → 改為 anchor tag 觸發 `GET /api/conversion/{id}/download`browser 自動 follow 302
§7.2 流程修改範例:
```diff
- 1. 點擊 → 按鈕進 loading 狀態spinner + 「準備下載…」)
- 2. 呼叫 POST /api/converter/jobs/{id}/download-token
- 3. 200 OK
- - 拿到 { url, expires_at }
- - 立即 window.location.href = url瀏覽器內建下載管理器接手
- - 按鈕回到原狀態(不變灰,使用者可重複下載)
+ 1. 點擊 → 觸發 anchor 行為 / window.location.href = '/api/conversion/{id}/download'
+ 2. visionA-backend server-side 302 → browser 自動 follow → 直連 FAA
+ 3. 按鈕不需 loading 狀態navigation 由 browser 接管)
+ 若需要錯誤處理(例 job_not_completed可改用 fetch + 檢 302 status
```
> **附註**:這個改動實際上「簡化」了 Design 的 §7.2 描述(不用處理 token、不用 fetch then redirect— 是 ADR-014 v1.1 帶來的好處。
#### 2.1.3 ⚠️ 中 — Stage indicator 對應實際狀態
**位置**wireframe §6.1 「Stage 對應」表
Design 把 converter 的 `running` 狀態切成兩個視覺 stage「解析模型」、「編譯 NEF」純粹是 UI 上的分段,不對應 converter 內部真實階段。
**問題**converter Phase 1 的 `GET /api/v1/jobs/{id}` response 其實有 `stage` 欄位(`onnx` / `bie` / `nef`,見 api-conversion.md §2 Job response這比 wireframe 假設的「單一 running 狀態」更精細。
**建議**
- **選項 A**wireframe 的 3-stage 對應到 converter `stage` 欄位(`onnx` → 解析、`bie` → 量化、`nef` → 編譯),這樣 stage indicator 是真實狀態而不是視覺 fake
- **選項 B**維持現狀fake stage— 簡單但不夠誠實
**Architect 建議選項 A**。理由converter `stage` 已經提供 — 不利用反而浪費。具體 mapping
| converter stage | UI stage 名稱 | 觸發條件 |
|----------------|------------|---------|
| `onnx` | 解析模型 | converter status=running, stage=onnx |
| `bie` | 量化模型 | converter status=running, stage=bie |
| `nef` | 編譯 NEF | converter status=running, stage=nef |
stage 名稱建議改為 3 個:「解析」/「量化」/「編譯」(更貼近 converter 實際做的事)。
**請 Design 對齊 + 改 §6.1**:把 stage 名稱與 converter `stage` 欄位 mapping並更新 wireframe §11.6 i18n key。
#### 2.1.4 ⚠️ 中 — Upload XHR 進度 100% 後的文案
**位置**wireframe §4.2 階段 B「上傳中」
wireframe §4.2 設計「進度條 + 預估剩餘」,但沒明確規定 XHR `loaded === total` 後但 backend 還沒回 200 的這 1-3 秒要顯示什麼。flow §5.3 有提到「即將完成…」/「伺服器處理中…」— 這需要在 wireframe 對齊:
**建議**wireframe §4.2 加註:
```markdown
**進度 100% 後但伺服器還沒回**
| XHR 狀態 | 文案 |
|---------|-----|
| progress < 100% | 已上傳 X / Y · 預估剩餘 N |
| progress = 100%, 等待 < 5 | 即將完成 |
| progress = 100%, 等待 ≥ 5 秒 | 伺服器處理中… |
```
對應 i18n key§11.5)已有 `conversion.uploading.almostDone`,建議再加 `conversion.uploading.serverProcessing`
#### 2.1.5 小 — 「加到模型庫」按鈕 vs Dialog 確認
wireframe §7.1 設計了確認 Dialog複用 import flow— 在 §14 給 PM 的補充第 1 條已正確標記為「待 PM 決定」。
**Architect 立場**:保留 Dialog 是對的model record 是永久資源、使用者控制名稱比較自然)。已在 TDD api-conversion.md v0.3 的 promote-to-models request body 對齊「單欄位 name only」。
#### 2.1.6 小 — Wand2 icon 替代
§10 給的替代方案合理FileCog / Replace不是 Architect 領地,由 Design / 使用者決定。
### 2.2 對 Flow`03-design/flows/flow-conversion.md`
#### 2.2.1 ✅ 同意的部分
- **§3 全景圖Mermaid**:完整且與 ADR-014 對齊,包含所有 5 個 stageactive check / select / uploading / processing / completed
- **§4 State Machine + 5 狀態保存策略**:「不持久化 jobId 全部由 backend `/active` 提供」是核心原則,正確
- **§5.4 Polling 策略**visibilitychange 暫停、退避、終止條件 — 全部與 TDD §9 對齊
- **§6 邊界情境**6.1 active job、6.2 上傳失敗、6.3 過期、6.4 多分頁、6.5/6.6 重新整理 — 全部 cover
- **§7 UX Writing 要點**:誠實呈現狀態、避免技術語 — 對齊 design-spec
- **§8.2 給 Architect 的補充**5 條建議,第 1/2/3/4/5 條我都已採納並補進 TDD謝謝
#### 2.2.2 ⚠️ 嚴重 — endpoint 路徑與 TDD 不一致
同 §1.2 / §2.1.2。具體位置:
- §3 全景圖 sequence diagram 中所有 `POST /api/converter/jobs` / `GET /api/converter/jobs/{id}` / `POST /api/converter/jobs/{id}/promote-to-models` / `POST /api/converter/jobs/{id}/download-token` → 全部改 `/api/conversion/*` 前綴
- §3 stage 3b sequence diagram「下載」那段 `POST /api/converter/jobs/{id}/download-token` + 「`{ url, expires_at }`」→ `GET /api/conversion/{id}/download` + `HTTP 302 Found, Location: faa-url`
- §5.5 「下載」分支步驟 1-4 改成 anchor 觸發、browser 自動 302 follow
- §8.2 給 Architect 第 1 條建議端點名稱
修法同 §2.1.2。
#### 2.2.3 ⚠️ 中 — Cancel 後 backend 對 converter 的 cleanup
**位置**flow §5.3「取消」末尾、§8.2 給 Architect 第 4 條
flow §5.3 寫「visionA backend 收到取消信號後**也要對 converter 發 cancel**(避免孤立 job」— 我在 TDD `conversion.md` §4.3.2 v0.3 補了完整的 cleanup 鏈分析C1C4 情境表 + best-effort cancel via `POST /api/v1/jobs/{id}/cancel`)。
Design 的這段描述沒問題,但**沒講清楚** converter 是否提供 cancel endpoint。事實上 converter Phase 1 已實作 `POST /jobs/{id}/cancel`PRD §5 N2 提到),但 PM Phase 0.8 Non-Goals 把「使用者主動取消」列在 N2。
**澄清**「使用者主動取消」PM 不做)≠ 「backend 內部 best-effort cancel 做 cleanup」TDD 做)。前者是 UI feature、後者是 reliability infra。兩者用同一個 converter endpoint 但語義不同。
**建議 Design**:在 flow §5.3「取消」末尾加註:
```markdown
> **澄清**:使用者按「取消上傳」是 frontend 端的 `xhr.abort()`TCP RST 觸發 backend
> cleanup 鏈backend 內部會 best-effort 對 converter 發 cancel。這個 cancel **是內部
> 韌性處理**,不暴露 UIPRD Phase 0.8 Non-Goals N2 講的是「進行中 job 的取消 UI」
> 兩者不衝突。
```
#### 2.2.4 小 — `expires_at` 來源 + frontend 顯示
flow §5.5 success 階段「過期提醒」描述:
> 計算 `expires_at - now()` → 顯示「6 天 21 小時後自動清除」
正確,且 TDD api-conversion.md v0.3 已確保 `expires_at` 在 Job response 必出現(無論 converter 給或 backend 推算)。✅ 對齊。
---
## 3. Architect 自己的 TDD 修訂(這次補上的)
### 3.1 議題 #2 — visionA backend 重啟後 ownership 全失
**修訂**`conversion.md` §2.6.1(新增)
採方案 **A4lazy rebuild**`/active` endpoint 在 in-memory miss 時fallback 對 converter 查 `GET /api/v1/jobs?user_id=<sub>&status=in_progress` 並重建 ownership。對 frontend 完全透明。
**為什麼選 A4 不選 A2啟動時批次掃**A2 對 converter 是 hammerA4 是 lazyuser 行為觸發cost 對應實際需求。
**新增依賴**converter Phase 1 的 `GET /api/v1/jobs?user_id=&status=in_progress` endpointconverter Phase 1 已實作)。已要求 PM 補進 §7 dependency 表§1.6)。
### 3.2 議題 #3`GET /api/conversion/active` 端點
**修訂**:原本 TDD 已有 `GET /api/conversion/active`,這次只是把它的 response shape 補上 `expires_at` / `source_filename` / `target_chip`並文件化「lazy rebuild」行為議題 #2)。
**修訂位置**`api/api-conversion.md` §5
**已對齊 Design 需求**wireframe §3.3 / flow §5.1 進入 `/conversion``/active` 直接落 processing 的設計,能拿到所有需要顯示的欄位(檔名、目標 chip、過期時間
### 3.3 議題 #5 — Cancel 清理鏈
**修訂**`conversion.md` §4.3.2(新增完整章節)
新增內容:
1. 4 種 cancel 觸發情境C1 使用者取消 / C2 重新整理 / C3 網路斷 / C4 converter 拒絕)
2. cleanup 鏈:`xhr.abort()` → TCP RST → `gin Context.Done()` → goroutine `pw.Close()` → io.Pipe EOF → converter multer abort
3. Best-effort `POST /api/v1/jobs/{id}/cancel`:當 backend 已拿到 job_id 但 streaming 失敗時,主動對 converter 發 cancel 避免孤立 job
4. §9 retry 表新增 `cancel` rowbest-effort、不重試
**重要區分**:「使用者主動取消 UI」PM Non-Goals N2Phase 1+ 才做vs 「backend 內部 best-effort cancel cleanup」TDD 此次補)— 兩者不衝突,後者是韌性 infra。
### 3.4 議題 #6 — Upload XHR 進度語意
**修訂**`conversion.md` §4.3.1(新增完整章節)
設計選擇表(選項 A vs B明確採選項 A**backend 等 converter 201 才回 200不 early-return**。理由:
- 進度 100% 接近端到端真實狀態
- 實作簡單(同步等)
- 失敗處理路徑清楚(同步錯誤直接回)
- 1-3 秒 io.Pipe drain 延遲對 500MB 上傳可接受
Frontend UX 補償100% 後到 backend 回 200 之間,文案切「即將完成…」/「伺服器處理中…」(已在 flow-conversion.md §5.3 提到)。已要求 Design 在 wireframe §4.2 對齊文案表§2.1.4)。
### 3.5 議題 #7`expires_at` 來源
**修訂**`conversion.md` §2.6.2(新增)+ `api/api-conversion.md` §1 / §2 / §5response shape 補欄位)
決策:
- 優先從 converter response 直接讀converter Phase 1 是否提供 — 給 Backend Agent 確認)
- 若 converter 沒給backend 自行 `created_at + 7d` 推算
- **Frontend 永遠拿到 `expires_at`**,無論來源
**Job response shape 統一補欄位**v0.3
- `expires_at`:必有
- `source_filename`:必有(給 success card 顯示「yolov5s.onnx → yolov5s_kl720.nef」
- `target_chip`:必有(給 wireframe §6 / §7 顯示「→ KL720」
### 3.6 同步影響的小修訂
- `conversion.md` §3 endpoint 表加註「不對外暴露但內部使用的 converter endpoint」cancel + lazy rebuild
- `conversion.md` §9 retry 表新增 cancel / lazy rebuild 兩 row
- `api/api-conversion.md` §3 promote-to-models request body 對齊 Design 單欄位(議題 #4
---
## 4. 給 Orchestrator 的決策清單
> 三方修訂大部分各自進行,以下是需要 Orchestrator 協調或使用者裁決的事項。
### 4.1 [使用者裁決] API endpoint 路徑命名統一
**問題**PM PRD 用 `/api/converter/jobs/*`、Design wireframe / flow 用 `/api/converter/jobs/*`、Architect TDD 用 `/api/conversion/*`,三方不一致。
**選項**
- **選項 A**Architect 建議):統一用 `/api/conversion/*``promote-to-models``GET /download` 302
- 優點:對齊 visionA 內部模組名(`internal/conversion/`ADR-014 v1.1 download 安全升級已採;命名語意清楚
- 改動PM PRD §F6 / F7、Design wireframe / flow多處
- **選項 B**:統一用 `/api/converter/jobs/*`
- 優點:與 converter 真實 path`/api/v1/jobs/*`)名字相近、有「轉發」直覺
- 缺點:誤導使用者以為是 raw proxy其實 backend 有 ownership / 半自動邏輯download flow 已決議用 GET 302不能維持 `POST /download-token`
**Architect 強烈建議選 A**。
**決策後**
- 選 A → PM 改 PRD §F6/F7、Design 改 wireframe / flow標記位置已在 §1.2 / §2.1.2 / §2.2.2 列出TDD 不動
- 選 B → Architect 改 TDD 全部(`internal/conversion/` package 名也要改)— 不建議
### 4.2 [PM 待補] 修訂建議清單(嚴重度)
| # | 嚴重度 | 修訂位置 | 內容 |
|---|--------|---------|------|
| P1 | 嚴重 | §F6 / §F7 | 對齊 endpoint 命名(見 §4.1|
| P2 | 中 | §F 段(新增 F0| 補「Active job pre-check」功能 |
| P3 | 中 | §F2 / §F3 | 補上傳進度 100% 後的文案語意說明 |
| P4 | 中 | §F6 | 補「加到模型庫」Dialog 含名稱欄位(對齊 Design|
| P5 | 中 | §6 + §7 | 補 D7重啟 lazy rebuild+ converter dependency 補 2 個內部用 endpoint |
| P6 | 小 | §8 KPI | 量測點改為 backend log job 完成時間 |
| P7 | 小 | §F2 | 確認 chip 數量4 個 vs 2 個),與 converter 對齊 |
### 4.3 [Design 待補] 修訂建議清單(嚴重度)
| # | 嚴重度 | 修訂位置 | 內容 |
|---|--------|---------|------|
| D1 | 嚴重 | wireframe §3.3 / §6.3 / §7.1 / §7.2、flow §3 / §5.5 / §8.2 | 對齊 endpoint 命名(見 §4.1|
| D2 | 中 | wireframe §6.1 | stage indicator 改用 converter `stage` 欄位onnx/bie/nef → 解析/量化/編譯)|
| D3 | 中 | wireframe §4.2、flow §5.3 | 補 100% 後等待文案表 + i18n key |
| D4 | 中 | flow §5.3 / §8.2 | 補「使用者取消」vs「backend 內部 cancel cleanup」的區分 |
| D5 | 小 | wireframe §10 | Wand2 icon 採用(不需改)|
### 4.4 [需與其他團隊確認]
- **Backend Agent**:確認 converter Phase 1 的 `GET /api/v1/jobs/{id}` response 是否含 `expires_at`,決定 backend 是直接透傳還是自行推算
- **DevOps**:確認 visionA stage → 192.168.0.130 converter 網路可達性ADR-014 合規清單仍未勾)+ MC service client 4 scope 已授權
- **Converter 團隊**:確認 Phase 1 `platform` 欄位實際支援哪幾個 chip enum`520 / 720``520 / 630 / 720 / 730`
---
## 5. 結論
**已落地Architect 端)**4 個議題(#2 / #5 / #6 / #7)已寫進 `conversion.md` v0.3 + `api/api-conversion.md` v0.3。
**待 Orchestrator 協調**
1. 1 個使用者裁決API endpoint 命名統一§4.1
2. 7 個 PM 修訂1 嚴重 + 4 中 + 2 小
3. 5 個 Design 修訂1 嚴重 + 3 中 + 1 小
4. 3 個跨團隊確認
**建議下一步**
1. Orchestrator 把 §4.1 提給使用者裁決(這是阻擋三方對齊的單一最大議題)
2. 使用者決定後PM / Design 同步修訂(兩邊都在改 endpoint可平行進行
3. PM / Design 第二輪產出後Architect 再做最終 review確認沒新衝突
4. 完成後進 Phase 0.8 實作階段
---
## 版本記錄
| 日期 | 版本 | 變更 |
|------|------|------|
| 2026-04-30 | 1.0 | 初版 — Phase 0.8 三方交叉審閱Architect 視角)|

View File

@ -0,0 +1,512 @@
# Security — 安全考量與 Pairing Token 協定
> 本文件彙整所有安全相關細節。部分對應 Design Doc §6。
---
## 1. Pairing Token 協定 {#pairing-token}
### 1.1 雛形版v0.1
最簡化單一 token
```bash
[開發者 setup]
# 產生符合正式格式的雛形 tokenvAc_ + 32 hex
export VISIONA_PAIRING_TOKEN="vAc_$(openssl rand -hex 16)"
export VISIONA_STATIC_USER_ID=demo-user
[local agent]
啟動時讀同一個 env或 config 檔 / CLI flag
連 ws://proxy:3800/tunnel/connect?token=$VISIONA_PAIRING_TOKEN
[remote-proxy]
PairingStore.Validate(token):
if token == env.VISIONA_PAIRING_TOKEN:
return {UserID: env.VISIONA_STATIC_USER_ID, TokenHash: sha256hex(token)}
else:
return ErrInvalidToken
```
**雛形限制**
- 單 token 單 user永不過期無法 revoke
- **不做 Stage 2 升級**pairing 和 session 合併為同一個 token端點收到任何 `vAc_*` 格式就接受)
- 只適合 devPhase 1 切入正式 DB-backed 前,必須先改為 Stage 1 + Stage 2 兩階段(見 §1.2、ADR-003
### 1.2 Phase 1DB-backed + 兩階段 Token
詳見 ADR-003。雛形 → Phase 1 流程定義如下:
#### Stage 1 — Pairing Token短期**15 分鐘 TTL**,一次性)
使用者於 Web 頁面點「Pair New Device」
```
POST /api/pairing/token
Auth: bearer <user JWT>
Response 200:
{
"token": "vAc_a1b2c3d4e5f6...", // 明文只此一次vAc_ + 32 hex共 36 字元)
"expires_at": "2026-04-21T13:15:00Z" // 15 分鐘後過期
}
DB 寫入 pairing_tokens:
token_hash = sha256(plain)
user_id = <from JWT>
kind = 'pairing'
expires_at = now + 15min
used_at = NULL
```
使用者把 token 貼到 local agent複製貼上、QR code、CLI flag 皆可)。
#### Stage 2 — Session Token長期**90 天 TTL**,可撤銷)
local agent 首次用 Pairing Token 連 proxy**遠端會換發** Session Token
```
WS /tunnel/connect?token=vAc_a1b2c3d4...
remote-proxy (經 PairingStore.Validate):
info := SELECT * FROM pairing_tokens WHERE token_hash = sha256($1)
AND kind = 'pairing'
AND revoked_at IS NULL
AND expires_at > now()
AND used_at IS NULL
IF info NOT FOUND → reject 401
# 原子升級 (single transaction)
BEGIN;
device_id := create_device_if_needed(info.user_id, agent_serial)
session_plain := "vAs_" + hex(crypto/rand(32)) # 64 hex chars
INSERT INTO pairing_tokens
(token_hash, user_id, device_id, kind, parent_token, expires_at, created_at)
VALUES
(sha256(session_plain), info.user_id, device_id,
'session', info.token_hash, now() + 90 days, now());
UPDATE pairing_tokens
SET used_at = now(), device_id = $device_id
WHERE token_hash = info.token_hash;
COMMIT;
# 在 WS upgrade response header 或首個 yamux control frame 回傳:
# { "session_token": "vAs_..." }
accept tunnel
```
local agent 收到後**持久化 Session Token**config / keychain**丟棄 Pairing Token**(已作廢)。日後重連一律用 Session Token`kind='session'` 的 token 重連只需:
```
info := SELECT * FROM pairing_tokens WHERE token_hash = sha256($1)
AND kind = 'session'
AND revoked_at IS NULL
AND expires_at > now()
IF info NOT FOUND → reject 401使用者需在 Web UI 重 Pair
ELSE → accept tunnel + UPDATE last_seen_at
```
**撤銷**:使用者在 Web UI「Devices → Revoke」→ `UPDATE pairing_tokens SET revoked_at = now() WHERE token_hash = ?`。下次連線驗證即被拒。
### 1.3 Token 格式2026-04-22 M-1 修訂:統一為 hex + 前綴)
```
vAc_[0-9a-f]{32} # Admin-Credentialpairing共 36 字元
vAs_[0-9a-f]{64} # Agent-Sessionsession共 68 字元
```
- 字元集:**小寫 hex + 底線前綴**(不用 base64url避免 URL-safe 混淆 / 大小寫歧義)
- 產生:`crypto/rand.Read``hex.EncodeToString`
- **API 回傳純字串,無空格、無分隔符**(前端 UI 顯示時可每 8 字元插空格提升可讀性;複製/貼上時前端需 `.replace(/\s/g, '')` 正規化)
- 正則驗證:
- Pairing`^vAc_[0-9a-f]{32}$`
- Session`^vAs_[0-9a-f]{64}$`
- 前綴讓 log / debug 一眼看出類型log 永遠只記前 8 字元前綴(例:`vAc_a1b2c3d4...`
### 1.4 Hand-shake 額外欄位Phase 1
WS upgrade 前local agent 可帶附加 header 宣告自己:
```
GET /tunnel/connect?token=vAc_a1b2c3d4... HTTP/1.1
X-Agent-Version: local-tool 1.2.3
X-Agent-OS: darwin/arm64
X-Agent-Serial: KL520-1234-ABCD
```
proxy 驗證 + 記錄到 `pairing_tokens.device_id` 綁定。
---
## 2. Auth使用者登入
### 2.0 介面雙層2026-04-22 新增 M-3
Auth 切分為兩層 interface對應不同生命週期 / 呼叫場景:
| 介面 | 層級 | 呼叫時機 | 方法 |
|------|------|---------|------|
| `AuthService` | **Middleware 層** | 每個 HTTP request 進來時middleware| `Authenticate(req) → UserContext``Authorize(ctx, resource, action)` |
| `AuthProvider` | **Handler 層** | 使用者登入 / 註冊 / 登出等明確動作 | `Register``Login``Logout``ValidateToken``GetUser` |
雙層並存原因:
- `AuthService` 著重「這個 request 代表哪位使用者」(狀態萃取),每個 request 都要跑
- `AuthProvider` 著重「使用者的 Auth 生命週期管理」(帳號 CRUD + token 簽發),只在登入登出等動作發生
實作時通常一個底層 providerClerk / OIDC client會同時被 `AuthProvider``AuthService` 包裝;但 interface 分開讓 handler 不需要知道 middleware 的細節。
```go
// internal/auth/service.gomiddleware 層)
type AuthService interface {
Authenticate(ctx context.Context, req *http.Request) (*UserContext, error)
Authorize(ctx context.Context, userCtx *UserContext, resource, action string) error
}
// internal/auth/provider.gohandler 層2026-04-22 新增)
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 LoginResult struct { User *User; AccessToken, RefreshToken string; ExpiresAt time.Time }
type RegisterRequest struct { Email, Password, Name string }
```
### 2.1 雛形
> **⚠️ 已被 OIDC 取代2026-04-26 / OB5**
>
> 本節描述的 `StaticAuthProvider` / `StaticAuthService` 已從 codebase 移除。
> Phase 0.6 起 visionA-backend 的唯一認證路徑是 OIDC接 Innovedus Member Center
> 詳見:
> - [adr-010-oidc-bff.md](./adr/adr-010-oidc-bff.md)(接入策略)
> - [adr-011-supersede-adr-005.md](./adr/adr-011-supersede-adr-005.md)(推翻決策)
> - [oidc-tdd.md](./oidc-tdd.md)(實作細節)
>
> demo-user 仍可用 — Member Center 端 seed `demo@visionA.local / demo123` 帳號。
**歷史紀錄(已過時)**
- `/api/auth/login``/api/auth/register` 曾由 `StaticAuthProvider` 實作:
- `Login(email, password)`**任何帳密都通過**,回 `demo-user` + 假的 access token`demo-access-token`
- `Register(...)` — stub`ErrNotImplemented`(前端顯示「即將推出」)
- `Logout(...)` — stub清前端 store 即可)
- `ValidateToken("demo-access-token")` → demo-user其他 token → `ErrInvalidToken`
- `GetUser("demo-user")` → demo-user struct其他 → `ErrNotFound`
- `StaticAuthService.Authenticate` 永遠回 `demo-user`middleware 層;與 provider 結果一致)
- 前端 login 頁面可填任意帳密、點下去直接進入應用(體驗接近真實但零 DB
### 2.2 Phase 1 選項
| 方案 | 優 | 劣 |
|------|----|----|
| 自建 email + password + JWT | 完全掌控 | 要自己做 email 驗證、密碼重設、暴力破解防禦 |
| Clerk / Auth0 / Supabase Auth | 現成、支援社交登入 | 綁定 vendor成本隨用戶增加 |
| OIDC 自建Keycloak / Ory Kratos| 開源、標準 | 維運成本 |
**建議**Phase 1 初期用 Clerk免費額度充足支援 Google/GitHub SSO、Magic Link、MFA成長後視成本再評估遷移。
### 2.3 Session 機制
- JWT`sub=user_id`, `exp`, `roles`)放 `Authorization: Bearer ...` header
- Refresh token 存 HttpOnly cookie避免 XSS 拿到)
- Logout清 cookie + JWT 黑名單Redis
### 2.4 Service-to-Service TokenPhase 0.8 啟用)
> 對齊 [`adr/adr-014-conversion-integration.md`](./adr/adr-014-conversion-integration.md) 與 [`conversion.md`](./conversion.md) §5。
visionA-backend 從 Phase 0.8 起除了「user 端的 OIDC BFF flow」§2.0),還會以**服務身份**呼叫 Innovedus Member Center / FAA / converter — 用 OAuth 2.0 `client_credentials` grant。
#### 2.4.1 流程
```
visionA-backend ──POST {issuer}/oauth/token──► Member Center
grant_type=client_credentials
client_id=<ServiceClientID> ← 預埋於 OIDCConfig.ServiceClientID
client_secret=<ServiceClientSecret> ← 預埋於 OIDCConfig.ServiceClientSecret
scope=converter:job.write converter:job.read files:download.read files:download.delegate
◄─────── { access_token, expires_in, scope }
cacheexp - 15s 內可重用)
```
#### 2.4.2 Cache 策略
- 單一 token cache 涵蓋全部 4 個 scopeMC 端發單一 token 含全部)
- cache 在 visionA-backend 進程記憶體(`sync.RWMutex` 保護)
- `exp - 15s` 提前重取,避免下游使用時剛好過期
- 併發保護double-checked locking第一次 cache miss 時其他 goroutine 等待)
- 重啟即清空in-memory無持久化下次需要時重新取
#### 2.4.3 失敗處理
| 場景 | 處理 |
|------|------|
| 4xxclient_id / scope 設定錯)| fatallog + 5xx response 給上游 caller**不重試** |
| 5xx / network | 指數退避 max 2 次1s, 2s|
| Token cache 命中 → 但 MC 已 invalidaterare | 下次 401 時 force refresh + 重打 |
#### 2.4.4 Secret 管理
- `VISIONA_OIDC_SERVICE_CLIENT_SECRET` **絕不可** commit 進 git`.gitignore``.env`
- prod 用 AWS Secrets Manager / k8s Secret 注入
- log 永遠不印完整 token只印前 8 字元前綴(`Bearer ey1234...`
- 若 secret 洩漏MC 端 rotate → 重新部署 visionA-backendin-memory cache 自然失效
#### 2.4.5 Delegated Download Token
不同於上面的 service tokenFAA delegated download token 是「visionA backend 用自己的 service token 跟 MC 換來、再轉發給 browser 直連 FAA 用的短期 opaque token」
```
visionA backend ──POST /file-access/download-tokens (Bearer service-token, scope=files:download.delegate)──► MC
body: { tenant_id, user_id, object_key, method:"GET", expires_in_seconds:300 }
◄─────── { token, url, expires_at }
visionA backend ──回 frontend──► { download_url, expires_at }
Browser ──GET /files/{key}?access_token=<delegated>──► FAA
└──► MC validate token
```
安全特性:
- TTL 短(預設 5 分鐘 / 範圍 60-900s`VISIONA_FAA_DELEGATED_TTL_SECONDS` 控制)
- 綁定 method=GET + 特定 object_keyFAA 拒絕其他 method 或 key
- visionA-backend 必須先做 ownership 檢查job_id ↔ user_id mapping才換 token
- frontend **不可快取** download_url每次「下載」按鈕重打 backend 換新 token
#### 2.4.6 Trust Boundary
| 邊界 | 信任方向 | 守護機制 |
|------|---------|---------|
| Browser → visionA-backend | visionA-backend **不信任** browser 帶的 user_id / object_key | 一律從 OIDC cookie session 取 user_idobject_key 從內部 mapping 反查 |
| visionA-backend → converter | converter **完全信任** visionA-backend 帶的 user_id | visionA-backend 是唯一灌 user_id 的點multipart streaming 重組時黑名單 client 帶的 user_id|
| visionA-backend → FAA | FAA 不認 visionA 而是線上跟 MC validate token | service token (s2s pull) + delegated token (browser direct) 都走 MC |
| visionA-backend ↔ MC | MC 用 client_secret 認 visionA 服務身份 | 標準 OAuth 2.0 client_credentials |
---
## 3. 傳輸加密
| 連線 | 加密 | 備註 |
|------|------|------|
| Browser → api-server | HTTPS | Phase 1雛形 HTTP |
| Browser → api-server WS | WSS | 同上 |
| api-server ↔ remote-proxy內部 HTTP| HTTPVPC-only | 如 public 需 mTLS |
| Local agent → remote-proxy | WSS | Phase 1雛形 WS |
| Local agent → Kneron USB | 無(硬體直連)| — |
---
## 4. Rate LimitingPhase 1
| 端點 | 限制 |
|------|------|
| `POST /api/auth/login` | 5 / min / IP |
| `POST /api/pairing/token` | 5 / min / user |
| `WS /tunnel/connect` | 10 / min / IPtoken 級限制另計 |
| 一般 `/api/*` | 100 / min / user |
**雛形不做**。記錄為 TODO。
---
## 5. CORS
```go
// internal/api/middleware/cors.go
func CORSMiddleware(allowedOrigins []string) gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
if allowed(origin, allowedOrigins) {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204); return }
c.Next()
}
}
```
**雛形**`allowedOrigins = ["*"]`(全放)
**Phase 1**:白名單 `["https://app.visiona.cloud"]`
---
## 6. WebSocket Origin Check
Browser 連 `/ws/*` 時,遵循 CORS origin checklocal-tool 的 `origin.go` 已成熟;搬過來)。
`/tunnel/connect` 是 local agent 連進來(非瀏覽器),**Origin check 跳過**(該 endpoint 僅 token 驗證)。
---
## 7. CSRF
- JWT-in-header非 cookie→ 天然免疫 CSRF
- 若 Phase 1 用 session cookie
- `SameSite=Lax` cookie
- `CSRF-Token` headerdouble-submit pattern
---
## 7.1 Cookie Session 設計取捨Phase 0.6 OIDC BFF
Phase 0.6 OIDC BFF 落地後OB1-OB6Cookie session 設計做出以下取捨:
### Pending 與 Logged-in Session 共用同一個 CookieADR-012
`oidc-tdd.md §4.5` 原設計示意了兩個 cookie`visiona_pending_sid` 短 TTL + `visiona_session` 長 TTL。實作時為了減少 cookie 數量與 handler 邏輯,改採**合一**:兩種狀態共用同一個 `visiona_session` cookie + 同一個 `usersession.Store` record`Session.UserID` 是否為空區分階段。
**安全防護三道線**
| # | 防線 | 位置 | 說明 |
|---|------|------|------|
| 1 | Middleware 強制檢查 UserID 空 → 401 | `internal/api/middleware.go` AuthMiddleware | Pending session 訪 protected endpoint 一律拒絕;該檔 169 行附「安全臨界檢查」註解明確警告不可拿掉 |
| 2 | Login 完成後 rotate session ID | `internal/usersession/manager.go` RotateSessionID + `internal/api/oidc_auth.go` callback | OWASP ASVS V3.2.1 — 防止攻擊者預先誘騙受害者使用攻擊者 cookie 走完 OIDC flow |
| 3 | Pending state 在 callback 同一次 UpdateSession 清空 | `internal/api/oidc_auth.go` callback | OIDCState / OIDCNonce / OIDCCodeVerifier / Extra["return_to"] 在寫 user info 同一次 commit 中清掉 |
詳見 `adr-012-pending-session-shared-cookie.md`
### Session ID Rotation 觸發點
| 事件 | 是否 rotate | 理由 |
|------|------------|------|
| 登入完成OIDC callback | ✅ rotate | OWASP ASVS V3.2.1,防 session fixation |
| 提權(雛形無此情境) | — | Phase 1 接 RBAC 後可能需要 |
| Logout | ❌(直接刪除) | 不需要 rotate整個 session record 從 store 消除 |
| 一般 API 請求 | ❌ | 維持 session ID 穩定,只刷 LastSeenAt |
### Cookie 屬性最小集(雛形 + Phase 1 共通)
| 屬性 | 值 | 理由 |
|------|----|----|
| `HttpOnly` | true | 阻止 JS 讀取XSS 防護) |
| `Secure` | trueprod/ falsedev HTTP | 只在 HTTPS 傳送 |
| `SameSite` | Lax | CSRF 基線防護Strict 會擋掉 OIDC callback redirect |
| `Path` | `/` | 全站可用 |
| `MaxAge` | 86400雛形 24h | Phase 1 將擴至 7d 並接 refresh token |
| HMAC-SHA256 簽章 | SigningKey ≥ 32 bytes | 防 cookie 偽造NewManager 啟動時強制檢查 |
---
## 8. 輸入驗證
- API body 用 `binding:"required"` + `validator.v10`
- 檔案上傳:
- Size 限制(雛形:模型 500MB 上限)
- Content-Type 白名單(`application/octet-stream`, `application/x-nef` 等)
- Magic bytes 檢查Phase 1
- Pairing token`^vAc_[0-9a-f]{32}$`
- Session token`^vAs_[0-9a-f]{64}$`
- 傳入前先 `.replace(/\s/g, '')` 正規化(允許使用者貼上含空格的顯示格式)
- SQL一律用參數化 query`$1` / prepared statement
---
## 9. Secret 管理
| Secret | 雛形 | Phase 1 |
|--------|------|--------|
| Pairing tokenstatic| env var | DBhash|
| Storage signing key | env var | env var + 定期 rotation |
| DB password | — | AWS Secrets Manager / Vault |
| JWT signing key | — | 同上 |
| S3 credentials | env vardev| IAM roleEC2 / K8s SA|
**禁止**:任何 secret 寫死進程式碼 / commit 到 Git。`.gitignore` 必須含 `.env``data/``*.pem`
---
## 10. 審計 LogPhase 1
關鍵事件記入 `audit_logs` 表:
- 使用者登入 / 登出 / 密碼變更
- Pairing token 建立 / 撤銷 / 使用
- Device 建立 / 刪除
- Model 上傳 / 刪除
- Cluster 建立 / 推論
欄位:`id, user_id, action, resource, details_json, ip, user_agent, created_at`
---
## 11. 第三方依賴安全
- `go mod audit`Go 1.26+)或 `govulncheck` 定期掃
- npm: `npm audit` / Dependabot
- Renovate / Dependabot 自動 PR
- CI 時 fail on known CVE
**雛形不強制**,但建議 CI 設定。
---
## 12. 威脅模型STRIDE 摘要)
| 威脅 | 雛形影響 | Phase 1 防護 |
|------|---------|------------|
| **Spoofing** — 冒用 pairing token | Dev 環境可接受 | DB-backed + 兩階段 token + rate limit |
| **Tampering** — 中間人改 payload | 無 TLS局網 OK公網不行 | 全程 TLS |
| **Repudiation** — 否認操作 | 無 audit log | audit_logs 表 |
| **Info Disclosure** — token / model 洩漏 | env 獨立隔離 | TLS + DB 加密 + presigned URL 有限 TTL |
| **DoS** — 灌爆 tunnel | 無防護 | rate limit + connection pool |
| **Elevation of Privilege** — 讀他人 device | OIDC 已實裝OB5 後 user_id = OIDC sub | RBAC + repository 查詢一律帶 `WHERE owner_user_id = ?` |
---
## 14. 前端雛形安全債Phase 1 必還)
F5F6 實作留下的**已知安全債**。雛形階段可接受開發者自用、local 後端),但 Phase 1 對外開放前必須逐項處理。
| # | 項目 | 現況(雛形) | Phase 1 處理 | 對應程式碼 |
|---|------|-------------|-------------|-----------|
| 14.1 | ~~Access token 存 localStorage~~ | ~~`visionA.auth.token` key 寫入 localStorage被同網域任何 XSS 讀走~~ **2026-04-26 已修OF2**Phase 0.6 OIDC BFF 後frontend 不再持有 token改用 `HttpOnly; Secure; SameSite=Lax``visiona_session` cookie + server-side sessionin-memory。auth-store 改為 me-based`GET /api/auth/me`api client 以 `credentials: 'include'` 自動帶 cookie。 | — | `src/stores/auth-store.ts``src/lib/api.ts`OF2 重寫) |
| 14.2 | Refresh token 未接上 | login response 的 `refresh_token` 目前**被丟棄**access token 過期使用者被迫重登 | 接 refresh 流程access token 短 TTL ~15min、refresh 長 TTL走 cookie | `src/stores/auth-store.ts:137-173` |
| 14.3 | WS token 走 querystring | `ws://.../ws/xxx?token=<JWT>` — 明文進 server access logNginx / LB / Cloudflare歷史 log 含 token 字串 | 改 `Sec-WebSocket-Protocol: Bearer,<token>` subprotocol或 HTTP 先換 ~60s ticket 再 `?ticket=...` | `src/hooks/use-websocket.ts:88-99` |
| 14.4 | Device session 識別與 pairing token 型別未分離 | session-store 的 `activeSessionToken` 命名容易混淆 pairing / session token 語意 | 拆為 `activeDeviceSessionId`(不是 token 而是 id明確區分「識別」與「密鑰」 | `src/stores/session-store.ts:47-52` |
| 14.5 | CSP 尚未設定 | Next.js 預設無 CSP header | 加 strict CSP`script-src 'self' 'nonce-xxx'`);禁止 inline eval | `next.config.ts`(尚未存在) |
| 14.6 | CSRF 防護 | 無(因尚未有瀏覽器端 cookie auth | 切到 cookie auth 時補上 double-submit token 或 SameSite=Strict | 全域 middleware |
**追蹤責任**:每一項在 Phase 1 Sprint Planning 前必須有對應 ticket不允許以「雛形 OK」為由直接上線。
---
## 13. 合規
雛形期**不處理 GDPR / CCPA**。Phase 1 上線前必做:
- [ ] Privacy Policy
- [ ] Terms of Service
- [ ] Cookie Policy
- [ ] 資料匯出 / 刪除GDPR right to access / erasure
- [ ] 若有歐洲用戶DPA with processorsAWS / S3 供應商 / Email 供應商)
---
**雛形實作重點**
- `StaticPairingStore` + env token
- ~~`StaticAuthService` + hard-coded demo user~~**已替換為 OIDC**Phase 0.6 / OB5見 oidc-tdd.md
- CORS 限定白名單OB5 已調整)
- HTTPdev/ HTTPSprod 必須)
**Phase 1 必做**
- 兩階段 token
- ~~真實 AuthClerk 或 自建)~~ → **已完成Member Center OIDC**
- HTTPS / WSSprod 強制)
- Rate limit
- Audit log
- Refresh token rotationMember Center 暫無 refresh雛形可接受
- Redis / DB-backed session store取代 in-memory

View File

@ -0,0 +1,431 @@
# Stage 部署架構 — visionA Cloud
## Metadata
- **作者**Architect Agent
- **狀態**DraftPhase 0.7A1 + B 任務組合的部署側設計)
- **最後更新**2026-05-01
- **文件角色**Phase 0.7 把 visionA Cloud 部到 Innovedus stage host 的部署架構說明
- **上位文件**[`design-doc.md`](./design-doc.md)、[`infra.md`](./infra.md)(如有)、[`build-deploy.md`](./build-deploy.md)
- **同期**[ADR-013](./adr/adr-013-oidc-public-pkce-client.md)OIDC public client 改造)
- **讀者**Backend / DevOps / Testing Agents
## 範圍邊界
| 在範圍內Phase 0.7 | 不在範圍內Phase 1+ |
|---------------------|-------------------|
| Stage 單機部署(`192.168.0.130`| k8s / 多節點 / 多 region |
| 單 container 多 process仿 edge-ai-platform| 拆 containerapi-server / remote-proxy / nginx 各自一個) |
| In-memory session / DB / storage | Redis / Postgres / S3 |
| HTTPS termination 委由公司 host nginx | 自管 TLS / 自簽 / 自架 LB |
| OIDC public PKCE-only clientstage MC 給的)| 自家 IdP / 多 IdP federation |
| Internal docker registry`192.168.0.130:5000`| 公開 registryDocker Hub / ECR |
---
## 1. Stage 環境概覽
### 1.1 Host 與網路
| 項目 | 值 | 來源 |
|------|---|------|
| Stage host | `192.168.0.130`Innovedus 內網)| IT 配給 |
| Docker daemon | `tcp://192.168.0.130:2375`(無 TLS內網信任| 同上 |
| 對外 domain | `stage-9527.innovedus.com` | 已配 DNS |
| 對外 port | `:9527`(與 host 名後綴一致)| IT 約定 |
| 對外協定 | HTTPSLet's Encrypt 自動續簽6/1 到期 → 公司有自動續簽機制)| openssl probe 確認 |
| Internal docker registry | `192.168.0.130:5000``registry:2` 容器)| `docker ps` 確認 |
| MC OIDC issuer | `https://stage-9527.innovedus.com:7850/`(含結尾斜線)| `/.well-known/openid-configuration` 確認 |
### 1.2 同一 host 上其他服務
stage host 是共用機器,目前跑了 18 個 container含 stage Member Center `:7850` / `:7880`、舊 POC `edge-ai-platform`、其他產品線測試版本。visionA 接入方式:
- **不接管 host**:不動其他服務、不改 host nginx 設定
- **不抓 :9527**:原本 `edge-ai-platform` 抓著 `:9527`2026-05-01 已 `docker stop edge-ai-platform`**保留容器**,必要時可 `docker start` 救回 POC
- **port 衝突檢查**visionA 啟用後,公司 nginx 會把 `:9527` 流量轉給 visionA container
---
## 2. HTTPS Termination 模型
### 2.1 整體流量路徑
```
[ Browser / Agent ]
│ HTTPS https://stage-9527.innovedus.com:9527/ (browser)
│ WSS wss://stage-9527.innovedus.com:9527/tunnel/connect (agent)
[ 公司 host-level nginx ] ← TLS terminationLet's Encrypt 證書,同一張覆蓋瀏覽器與 agent 流量)
│ HTTP :9527 → docker port mapping
[ visionA container ]
│ container 內 nginx :80 listenhost 對外只 publish :9527→:80
├── /api/* → 127.0.0.1:3721 (api-server)
├── /tunnel/connect → 127.0.0.1:3800 (remote-proxycontainer internal only)
├── /storage/* → 127.0.0.1:3721 (api-server)
└── / → 127.0.0.1:3000 (Next.js standalone server)
```
> **agent tunnel 不直連 :3800**agent 與 browser 走同一個對外 :9527由 container 內 nginx 把 `/tunnel/connect` 反代到 `127.0.0.1:3800`remote-proxy。host 對外**不**publish :3800因此不需要 IT 額外幫 :3800 設 TLS / 自動續簽。
### 2.2 為什麼這樣切
實測:停掉 `edge-ai-platform` 後,`https://stage-9527.innovedus.com:9527/` 仍回合法 LE 證書 → 證明 **TLS termination 在 host nginx 那層**,後面接 docker port mapping。
這是 Innovedus IT 既有約定。visionA 跟著 edge-ai-platform 的模式走,**container 內部純 HTTP**
- 不在 visionA container 裝證書(會跟 host nginx 重複,且 LE 續簽機制由公司管)
- 不開 :443公司 nginx 已經開了visionA 就在 :9527 後面)
- 整套部署期不接觸證書
### 2.3 假設與待驗證點(重要)
> **假設**:公司 host nginx 收到 `:9527` 的請求後upstream 設定指到 docker host 的 `:9527`(也就是我們 `0.0.0.0:9527 -> container:80` 的 port mapping
>
> **未驗證**:這個假設沒被 visionA 直接驗過 — 是看 edge-ai-platform 一直這樣跑、再停掉 `edge-ai-platform` 後 :9527 仍回 LE 證書(代表 host nginx 是真的指那邊)來推斷。
>
> **第一次部署時試打**deploy 完打 `curl -I https://stage-9527.innovedus.com:9527/`,若是 502 / 504 / connection refused**很可能**是 host nginx upstream 沒指到,要請 IT 確認。
---
## 3. Container 架構
### 3.1 設計選擇:單 container 多 process
仿 edge-ai-platform `docker/Dockerfile` + `entrypoint.sh` + `nginx.conf` 的模式(同期參考 — 細節見 §6 對照)。
```
┌────────────────────── visionA container ──────────────────────┐
│ │
│ /entrypoint.stage.sh │
│ ├── api-server & (listen 127.0.0.1:3721) │
│ ├── remote-proxy & (listen 127.0.0.1:3800 tunnel + 127.0.0.1:3801 internal RPC; container internal only)
│ ├── next start & (listen 127.0.0.1:3000Next.js standalone)
│ └── exec nginx -g 'daemon off;' (listen 0.0.0.0:80) │
│ │
│ binary/usr/local/bin/api-server, /usr/local/bin/remote-proxy│
│ Next.js standalone/app/.next/standalone/server.js │
│ │
└────────────────────────────────────────────────────────────────┘
```
> **遠端 :3800 已收回 container 內**:原計畫 host 對外 publish `0.0.0.0:3800` 給 agent 直連,但定案改走 nginx 反代後remote-proxy tunnel listener 只需 listen 127.0.0.1host 不再 publish :3800。
### 3.2 為什麼選單 container
| 選項 | 評估 |
|------|------|
| **單 container 多 process採用** | 仿 edge-ai-platformstage 階段最省事deploy 一個 image 就完整rollback 一個 image 就完整 |
| 多 containerapi-server / remote-proxy / nginx 各一)| docker-compose 配置乾淨、各 service 獨立 health check / restart但 stage 單機 + Phase 0.7 階段,多 container 帶來的彈性用不到,反而 CI / deploy 步驟多兩倍 |
| Single binary embed nginx | Go 內 reverse proxy 可行但不能像 nginx 那樣處理 static + cache + gzip要重做一輪 |
**Phase 1 視情況拆**:當 visionA 進到多用戶 / 多區域,再拆三個 container單一職責、各自 scale
### 3.3 Process 生命週期管理(重要的坑)
**問題**:單 container 多 process 的經典問題 — 「api-server 掛了nginx 還活著、health check 還回 200外部以為 visionA 健康但其實壞掉」。
**解法(採用 entrypoint.sh + `set -e` + `wait -n`**
```sh
#!/bin/sh
set -e
# 啟動 background processes
/usr/local/bin/api-server &
API_PID=$!
/usr/local/bin/remote-proxy &
PROXY_PID=$!
# 啟動 nginx注意daemon off 讓它在前景跑)
nginx -g 'daemon off;' &
NGINX_PID=$!
# 任何一個掛了就退出 → docker restart policy 重起整個 container
wait -n $API_PID $PROXY_PID $NGINX_PID
EXIT_CODE=$?
echo "[FATAL] One process exited with code $EXIT_CODE; killing siblings"
kill $API_PID $PROXY_PID $NGINX_PID 2>/dev/null || true
exit $EXIT_CODE
```
`wait -n` 是 POSIX shell 特性 — 等任一 background process 結束。配合 `docker run --restart unless-stopped` 形成「任一 process 死 → container 死 → docker 自動重啟整 container」。
**為什麼不用 supervisord**:多一層 dependencyentrypoint 5 行 shell 就能做完同樣的事;除錯更直覺。
---
## 4. Port Mapping
### 4.1 對外host 層)
```yaml
# docker-compose.stage.yml
services:
visiona:
ports:
- "0.0.0.0:9527:80" # ← 公司 nginx 反代到這;唯一對外 port
```
| Host port | Container port | 用途 | 是否經公司 nginx |
|-----------|---------------|------|----------------|
| `:9527` | `:80` (nginx) | 瀏覽器 HTTPS 入口 + agent tunnel WSS 入口(同一個 port、同一張 LE 證書) | ✅ |
> **agent tunnel 不另開 host port**agent 連 `wss://stage-9527.innovedus.com:9527/tunnel/connect`,由公司 nginx 反代到 container 的 `:9527→:80`,再由 container 內 nginx 把 `/tunnel/connect` 反代到 `127.0.0.1:3800` 的 remote-proxy。host 對外**不**publish :3800agent 看得到的只有 :9527。
>
> 好處:(1) 只用一張 LE 證書,續簽機制由公司 nginx 已涵蓋;(2) 不需要 IT 為 :3800 額外設定 TLS termination。
### 4.2 內部container 內部)
| 進程 | listen | 用途 |
|------|--------|------|
| nginx | `0.0.0.0:80` | 接 docker port mapping反代 api-server / remote-proxy / Next.js / 服務 static |
| api-server | `127.0.0.1:3721` | REST + WebSocket給瀏覽器、走 nginx|
| remote-proxy tunnel | `127.0.0.1:3800` | agent WebSocket由 container 內 nginx 反代 `/tunnel/connect` 進來(不對 host 公開) |
| remote-proxy internal | `127.0.0.1:3801` | api-server 內部 RPC不對外|
| Next.js standalone | `127.0.0.1:3000` | SSR / RSC server不對外由 nginx 反代 `/`|
---
## 5. OIDC 配置
### 5.1 Stage MC 配給的 client
A1 改造後 visionA-backend 支援 public PKCE-only client[ADR-013](./adr/adr-013-oidc-public-pkce-client.md)。Stage 採此 mode
```bash
# .env.stage
VISIONA_OIDC_ISSUER_URL=https://stage-9527.innovedus.com:7850/
VISIONA_OIDC_CLIENT_ID=b8093fea1a504a5d8f0e04bee9f78f2e
# VISIONA_OIDC_CLIENT_SECRET 不設 ← 走 public PKCE-only
VISIONA_OIDC_REDIRECT_URL=https://stage-9527.innovedus.com:9527/api/auth/callback
VISIONA_FRONTEND_URL=https://stage-9527.innovedus.com:9527
```
### 5.2 Service client預留stage 不啟用)
```bash
# Phase 1 才啟用 — stage 部署時 env 留空或註解掉
# VISIONA_OIDC_SERVICE_CLIENT_ID=<see stage .env.stage>
# VISIONA_OIDC_SERVICE_CLIENT_SECRET=<stage MC 給的 secret>
```
### 5.3 Callback URL 對齊
stage MC 端必須註冊:`https://stage-9527.innovedus.com:9527/api/auth/callback` 為 redirect_uri 白名單(使用者 2026-05-01 確認 MC 端已配合改)。
---
## 6. 環境變數對照dev vs stage
| 變數 | dev既有 `.env.dev.example`| stage`.env.stage`,本檔新增)|
|------|---|---|
| `VISIONA_OIDC_ISSUER_URL` | `http://localhost:5050` | `https://stage-9527.innovedus.com:7850/` |
| `VISIONA_OIDC_CLIENT_ID` | `visionA_dev_client` | `b8093fea1a504a5d8f0e04bee9f78f2e` |
| `VISIONA_OIDC_CLIENT_SECRET` | `visionA_dev_secret_change_in_prod`confidential| **不設**public PKCE-only|
| `VISIONA_OIDC_REDIRECT_URL` | `http://localhost:3721/api/auth/callback` | `https://stage-9527.innovedus.com:9527/api/auth/callback` |
| `VISIONA_FRONTEND_URL` | `http://localhost:3000` | `https://stage-9527.innovedus.com:9527` |
| `VISIONA_SESSION_SECRET` | `please-change-me-...`(範本)| `openssl rand -hex 32` 產的隨機值 |
| `VISIONA_SESSION_COOKIE_SECURE` | `false` | `true`HTTPS|
| `VISIONA_API_PORT` | `3721`host| `3721`container 內部 127.0.0.1 |
| `VISIONA_TUNNEL_PORT` | `3800` | `3800` |
| `VISIONA_PROXY_INTERNAL_URL` | `http://localhost:3801` | `http://127.0.0.1:3801`container 內部)|
| `VISIONA_STORAGE_BACKEND` | `localfs` | `localfs`(雛形不接 S3|
| `VISIONA_PAIRING_TOKEN` | 範例值 | `openssl rand -hex 16` 產,前綴 `vAc_` |
| `VISIONA_STORAGE_SIGNING_SECRET` | 範例值 | `openssl rand -hex 32` |
---
## 7. Image 流程
### 7.1 Build & Push
```bash
# 開發者本機macOS / Linux
cd /Users/jimchen/visionA
# Multi-stage build → linux/amd64stage host 是 amd64
docker buildx build \
--platform linux/amd64 \
-f docker/Dockerfile.stage \
-t 192.168.0.130:5000/visiona:stage \
-t 192.168.0.130:5000/visiona:stage-$(git rev-parse --short HEAD) \
--push \
.
```
兩個 tag
- `stage`always 指最新,給 stage host pull
- `stage-<sha>`:不變,給 rollback 用
### 7.2 Deploystage host pull + up
```bash
# 透過 DOCKER_HOST 直接打 stage daemon無 SSH 需求)
DOCKER_HOST=tcp://192.168.0.130:2375 docker compose \
-f docker-compose.stage.yml \
--env-file .env.stage \
pull
DOCKER_HOST=tcp://192.168.0.130:2375 docker compose \
-f docker-compose.stage.yml \
--env-file .env.stage \
up -d
```
`.env.stage` **不在 git不在 image 內**,存放在 stage host 的本地(如 `/opt/visiona/.env.stage`),由 `docker-compose.stage.yml``env_file` 引用 → 雙保險不會被人 commit 到 repo。
### 7.3 Insecure registry 設定
stage host 的 docker daemon 必須把 `192.168.0.130:5000` 加到 `insecure-registries`(內部 registry 無 TLS跟其他產品線共用配置。如果沒設過pull 會 fail。
---
## 8. Rollback 策略
### 8.1 立即 rollbackimage 切換)
```bash
# 切回前一版 image tag
DOCKER_HOST=tcp://192.168.0.130:2375 docker compose \
-f docker-compose.stage.yml \
--env-file .env.stage \
down
# 改 docker-compose.stage.yml 的 image: tag 為前一版(或用 env override
# 然後 up
DOCKER_HOST=tcp://192.168.0.130:2375 docker compose \
-f docker-compose.stage.yml \
--env-file .env.stage \
up -d
```
保留近 5 個 `stage-<sha>` image tag 在 internal registry給快速 rollback 用。
### 8.2 終極 fallback救回 POC
POC `edge-ai-platform` container 沒被刪,只是 stop。如果 visionA stage 完全壞掉、需要立刻有東西在 `:9527`
```bash
# 先停 visiona
DOCKER_HOST=tcp://192.168.0.130:2375 docker stop visiona
# 救回 POC
DOCKER_HOST=tcp://192.168.0.130:2375 docker start edge-ai-platform
```
這條路只是緊急用 — POC 跟 visionA 不共資料、不互通,救回後 stage 就「降級回 POC 狀態」,等 visionA 修好再切回。
---
## 9. 待解風險
### 9.1 公司 nginx upstream 假設未驗證
**風險**:「公司 nginx 收到 `:9527` 後 upstream 指到 docker host `:9527`」這個假設沒直接驗過。
**緩解**
- deploy 完第一件事 `curl -I https://stage-9527.innovedus.com:9527/`
- 502 / 504 / connection refused → 請 IT 確認 nginx upstream 配置
- agent tunnel 已收編走 `:9527/tunnel/connect`(同證書、不需另設 :3800故不再有「:3800 自動續簽」未確認項
**impact 等級**High — 不通就部不上線。
**likelihood**Low — edge-ai-platform 用同模式跑了很久。
### 9.2 LE 證書到期 6/1 — visionA 不掌握續簽
**風險**stage `:9527``:7850`MC兩張 LE 證書都 6/1 到期。公司有自動續簽機制(觀察到 edge-ai-platform 一路活著),但這個機制不在 visionA 團隊控制下。
**緩解**
- 6/1 前一週主動 ping IT 確認續簽機制健康
- 在 stage host log 加 cert expiry 監控(`openssl x509 -enddate`
- 列入 progress.md 與 deploy checklist
**impact 等級**High — 證書過期 = 整個 stage 站點掛掉。
**likelihood**Low — 公司有既有機制。
### 9.3 Container 內三 process 的健康度
**風險**entrypoint.sh `wait -n` 雖然能在任一 process 死亡時讓 container 整個死,但 **「process 還活著、邏輯卡死」** 這種 case 偵測不到api-server 在跑但 DB pool 死鎖)。
**緩解**
- nginx 加 `proxy_next_upstream_timeout` + container 層加 docker `HEALTHCHECK``curl -f http://localhost:80/healthz`
- api-server 的 `/healthz` 必須是 deep health測 internal HTTP 通向 remote-proxy 也能通)
- Phase 1 拆 container 後此風險自然消失(單一職責 = 單一 process
**impact 等級**Medium — 偽健康狀態會誤導 ops。
**likelihood**Low — 雛形 in-memory少有死鎖場景。
### 9.4 OIDC public client mode 的 redirect_uri 嚴格性
**風險**public client 沒了 client_secretOAuth 對攻擊者的防護主要靠 PKCE + redirect_uri 白名單。如果 stage MC 的 redirect_uri 白名單管理寬鬆(例如允許 wildcard `https://*.innovedus.com:9527/*`),攻擊者可能用同 wildcard 域名的其他子站做 redirect 攻擊。
**緩解**
- 與 MC 團隊確認 redirect_uri 白名單是 **exact match**,不接受 wildcard
- visionA-backend 啟動時 log 一次完整的 `VISIONA_OIDC_REDIRECT_URL` 值,方便 deploy 時人工核對
**impact 等級**Medium — 真被打中是 account takeover。
**likelihood**Low — Innovedus 內部 IdP攻擊者要先在內網有立足點。
### 9.5 `.env.stage` 散佈管理
**風險**`VISIONA_SESSION_SECRET` / `VISIONA_PAIRING_TOKEN` / `VISIONA_STORAGE_SIGNING_SECRET` 都是 `openssl rand` 產的,存 stage host 的 `.env.stage`。重新部署 / host 重灌時可能弄丟,導致所有現有 session 失效(使用者要重登)— 這在雛形階段可接受,但要有意識。
**緩解**
- `.env.stage` 在 stage host 加上 `chmod 600` + 備份到 IT 的 secret store如有
- Phase 1 上 Vault / AWS Secrets Manager 集中管理
**impact 等級**Low — 雛形重登可接受。
**likelihood**Medium — 第一次部署常忘記備份。
### 9.6 stage MC :7850 的 TLS 與 issuer URL 結尾斜線
**風險**MC issuer URL `https://stage-9527.innovedus.com:7850/` 的結尾斜線必要 — `coreos/go-oidc` discovery 會在 issuer 後接 `.well-known/openid-configuration`,如果 issuer 沒結尾斜線會變成 `https://stage-9527.innovedus.com:7850.well-known/...`(路徑錯誤)。
**緩解**
- `.env.stage.example` 寫死含結尾斜線的範例
- backend startup 加 sanity check`strings.HasSuffix(IssuerURL, "/")`,不是的話 log warn
**impact 等級**High — 拼錯就連不上 MC整個登入掛掉。
**likelihood**Medium — 容易踩。
---
## 10. 部署 checklist給 DevOps Agent B-7 寫 STAGE-DEPLOY.md 時用)
- [ ] stage host docker daemon 已將 `192.168.0.130:5000` 加入 insecure-registries
- [ ] `.env.stage` 已建立在 stage host含所有 §6 必填變數)
- [ ] `VISIONA_SESSION_SECRET` / `VISIONA_PAIRING_TOKEN` / `VISIONA_STORAGE_SIGNING_SECRET` 都用 `openssl rand` 產,**不重用 dev 值**
- [ ] `VISIONA_OIDC_CLIENT_SECRET` **不設**public PKCE-only
- [ ] `VISIONA_OIDC_ISSUER_URL` 含結尾斜線
- [ ] stage MC 端已確認 redirect_uri 白名單包含 `https://stage-9527.innovedus.com:9527/api/auth/callback`
- [ ] `docker buildx --platform linux/amd64` 已驗證 image 跑得起來
- [ ] 第一次 deploy 後 `curl -I https://stage-9527.innovedus.com:9527/` 確認回 200
- [ ] 走完一次 OIDC flow瀏覽器 login → MC → callback → cookie session 成立)
- [ ] 確認前一版 image tag 還在 internal registryrollback 用)
- [ ] 確認 POC `edge-ai-platform` container 仍以 stop 狀態保留(緊急 fallback 用)
- [ ] LE 證書到期日期已記錄在 progress.md6/1 前一週主動 ping IT
---
## 11. 相關檔案B 任務組產出)
| 檔案 | 對應任務 | 由誰寫 |
|------|---------|-------|
| `docker/Dockerfile.stage` | B-1 | DevOps Agent |
| `docker/nginx.stage.conf` | B-2 | DevOps Agent |
| `docker/entrypoint.stage.sh` | B-3 | DevOps Agent |
| `docker-compose.stage.yml` | B-4 | DevOps Agent |
| `.env.stage.example` | B-5 | DevOps Agent |
| `scripts/deploy-stage.sh` | B-6 | DevOps Agent |
| `docs/STAGE-DEPLOY.md` | B-7 | DevOps Agent |
| `internal/config/config.go` | A1-1 | Backend Agent |
| `internal/oidc/provider.go` | A1-2 | Backend Agent |
---
## 版本記錄
| 日期 | 版本 | 變更 |
|------|------|------|
| 2026-05-01 | 1.0 | 初版 — Phase 0.7 stage 部署架構(單 container 多 process 仿 edge-ai-platform、HTTPS termination 委由公司 nginx、OIDC public PKCE-only |

View File

@ -0,0 +1,239 @@
# Storage — 儲存層介面
> 詳見 ADR-004。本文件定義 `Store` interface 與實作細節。
---
## 1. Interface
```go
// internal/storage/store.go
package storage
import (
"context"
"io"
"time"
)
type Store interface {
Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error
Get(ctx context.Context, key string) (io.ReadCloser, *ObjectInfo, error)
Stat(ctx context.Context, key string) (*ObjectInfo, error)
// Exists 檢查 object 是否存在。
// 2026-04-22 Minor-4 新增PRD 明確要求 upload finalize 流程需一個明確的「存在嗎」入口。
// 語義true = 存在可用false = 不存在(非 error
// 其他錯誤(權限 / 網路)回傳 (false, err)。
// 實作上 LocalFS 可用 os.Stat + errors.Is(err, fs.ErrNotExist)S3 可用 HeadObject 404 判斷。
Exists(ctx context.Context, key string) (bool, error)
Delete(ctx context.Context, key string) error
List(ctx context.Context, prefix string) ([]*ObjectInfo, error)
PresignedGetURL(ctx context.Context, key string, ttl time.Duration) (string, error)
PresignedPutURL(ctx context.Context, key string, ttl time.Duration) (string, error)
}
type ObjectInfo struct {
Key string
Size int64
ContentType string
LastModified time.Time
ETag string
Metadata map[string]string
}
var (
ErrNotFound = errors.New("storage: object not found")
ErrAlreadyExists = errors.New("storage: object already exists")
)
```
---
## 2. Key 命名規範
統一使用 `/` 分隔的類 S3 路徑風格:
| 用途 | Key 模式 | 範例 |
|------|---------|------|
| 使用者模型 | `models/{user_id}/{model_id}.{ext}` | `models/demo-user/abc-123.nef` |
| 模型 metadata sidecarLocalFS only | 同上 + `.meta.json` | `models/demo-user/abc-123.nef.meta.json` |
| 轉檔源檔 | `converter/source/{user_id}/{job_id}.{ext}` | `converter/source/demo-user/job-xxx.onnx` |
| 轉檔產物 | `converter/result/{user_id}/{job_id}.nef` | `converter/result/demo-user/job-xxx.nef` |
| 推論 snapshot未來| `snapshots/{user_id}/{date}/{uuid}.jpg` | — |
**原則**:永遠含 `{user_id}`,便於未來做 IAM policy按 prefix 授權)。
---
## 3. LocalFSStore雛形
```go
// internal/storage/localfs.go
type LocalFSStore struct {
root string // 檔案根目錄,例:./data/storage
baseURL string // 用來做假 presigned URLhttp://localhost:3001/storage
signer *Signer // HMAC sign, see §3.3
}
func NewLocalFS(root, baseURL string) *LocalFSStore { /* ... */ }
func (s *LocalFSStore) Put(ctx, key, r, size, meta) error {
fullPath := filepath.Join(s.root, key)
os.MkdirAll(filepath.Dir(fullPath), 0755)
f, _ := os.Create(fullPath)
defer f.Close()
io.Copy(f, r)
if len(meta) > 0 {
metaPath := fullPath + ".meta.json"
json.NewEncoder(os.Create(metaPath)).Encode(map[string]any{
"metadata": meta,
"contentType": meta["content-type"],
})
}
return nil
}
func (s *LocalFSStore) PresignedGetURL(ctx, key, ttl) (string, error) {
expiresAt := time.Now().Add(ttl).Unix()
sig := s.signer.Sign(fmt.Sprintf("GET\n%s\n%d", key, expiresAt))
return fmt.Sprintf("%s/%s?expires=%d&signature=%s",
s.baseURL, url.PathEscape(key), expiresAt, sig), nil
}
```
### 3.1 Presigned URL 驗證LocalFS
api-server 對 `/storage/*filepath` 掛一個 handler
```go
router.GET("/storage/*filepath", func(c *gin.Context) {
key := strings.TrimPrefix(c.Param("filepath"), "/")
expires, _ := strconv.ParseInt(c.Query("expires"), 10, 64)
sig := c.Query("signature")
if time.Now().Unix() > expires {
c.AbortWithStatus(403); return
}
expected := signer.Sign(fmt.Sprintf("GET\n%s\n%d", key, expires))
if sig != expected {
c.AbortWithStatus(403); return
}
reader, info, err := storageStore.Get(ctx, key)
if err != nil { c.AbortWithStatus(404); return }
defer reader.Close()
c.Header("Content-Type", info.ContentType)
c.Header("Content-Length", strconv.FormatInt(info.Size, 10))
io.Copy(c.Writer, reader)
})
// PUT 版本類似,用於 upload
```
### 3.2 安全考量LocalFS
- Key 使用前必須走 `filepath.Clean` 並檢查 `!strings.HasPrefix(cleaned, root)`,防止 `../../etc/passwd`
- HMAC 簽名 secret 由 env `VISIONA_STORAGE_SIGNING_SECRET` 提供若未設定dev 模式用固定字串並印警告
### 3.3 Signer
```go
type Signer struct {
secret []byte
}
func (s *Signer) Sign(payload string) string {
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
```
---
## 4. S3Store雛形後期 / Phase 1
```go
// internal/storage/s3.go
type S3Store struct {
client *s3.Client
bucket string
}
func NewS3(cfg S3Config) (*S3Store, error) {
awsCfg, _ := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(cfg.Region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, "")),
)
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
if cfg.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.Endpoint)
o.UsePathStyle = true // MinIO 友善
}
})
return &S3Store{client: client, bucket: cfg.Bucket}, nil
}
func (s *S3Store) PresignedGetURL(ctx, key, ttl) (string, error) {
presigner := s3.NewPresignClient(s.client)
req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: &s.bucket, Key: &key,
}, s3.WithPresignExpires(ttl))
if err != nil { return "", err }
return req.URL, nil
}
```
### 4.1 支援的供應商
| 供應商 | Endpoint 設定 | 備註 |
|--------|-------------|------|
| AWS S3 | 空字串(用預設)| `UsePathStyle=false` |
| Cloudflare R2 | `https://xxx.r2.cloudflarestorage.com` | 零 egress cost |
| Backblaze B2 | `https://s3.us-west-000.backblazeb2.com` | 便宜 |
| MinIO自建 / 本地)| `http://minio:9000` | `UsePathStyle=true` |
| Google Cloud Storage | `https://storage.googleapis.com` | 需 XML API |
---
## 5. 使用場景
### 5.1 模型上傳
```
1. [瀏覽器] POST /api/models/init {name, size, checksum}
2. [api-server] 建 Model record (status="uploading") + 產 presigned PUT URL
3. [瀏覽器] PUT to presigned URL直接 S3 / LocalFS
4. [瀏覽器] POST /api/models/{id}/finalize
5. [api-server] **Exists(key)** 先確認物件存在PRD 要求),再 Stat 驗 size + checksum → 改 status="ready"
```
### 5.2 模型載入到裝置
```
1. [瀏覽器] POST /api/devices/:id/load-model {model_id}
2. [api-server] 產 presigned GET URLTTL 10 分鐘)
3. [api-server] 透過 tunnel 送 local agent: POST /api/models/download {url, checksum}
4. [local agent] 下載檔案到本機 → 載入 Kneron
5. [local agent] 回 200 OK
```
**為何不透過 tunnel 傳檔案?**
- 模型大(幾百 MB、tunnel 頻寬寶貴
- local agent 自己網路直連 S3 更快且不占 tunnel
---
## 6. 測試
- `storage_test.go` 定義一套 `testStoreConformance(t, store)`,對任何實作都跑同一組測試
- LocalFSStore 測試使用 `t.TempDir()` 根目錄
- S3Store 測試用 MinIO in docker可選CI 可 skip
---
**雛形實作**`LocalFSStore` + `signer` + api-server 的 `/storage/*` 代理 handler。
**未來擴展**`S3Store`、multipart upload、SSE-KMS 加密、bucket lifecycle冷資料下降費率

View File

@ -0,0 +1,463 @@
# Tunnel 協定與 Session 管理
> 本文件聚焦「local agent ↔ remote-proxy」的 tunnel 協定,以及 session 管理機制。
---
## 1. 架構回顧
```
[使用者電腦] [雲端 remote-proxy]
local agent (tunnel client) │ WS + yamux (長連線)
├─ dial WS ──────────────────────────────► │
├─ yamux.Client() ───────────────────────► │ yamux.Server()
└─ accept stream ◄──── stream opened by ── │ session.Open()
localhost:3721 (local agent HTTP server)
```
與 POC 完全相同。差異只在**認證機制**與**session 存放位置**。
---
## 2. Tunnel 建立流程
### 2.1 Local agent → Remote proxy
```
Step 1: local agent 讀 pairing tokenconfig / CLI flag / env
Step 2: dial wss://proxy.visiona.cloud/tunnel/connect?token=<TOKEN>
Step 3: remote-proxy.handleTunnel:
├─ 取 token = r.URL.Query().Get("token")
├─ 若無 token → 401
├─ PairingStore.Validate(ctx, token)
│ ├─ 雛形 StaticPairingStore比對 env
│ └─ Phase 1 PostgresPairingStore查 token_hash 是否存在 / 未撤銷 / 未過期
├─ Validate 失敗 → 401
├─ Upgrader.Upgrade → *websocket.Conn
├─ wsconn.New(conn) → net.Conn
├─ yamux.Server(netConn, DefaultConfig()) → *yamux.Session
├─ SessionStore.Register(ctx, token, sessionHandle)
├─ PairingStore.MarkUsed(ctx, token, deviceID)(若首次)
└─ 阻塞 <-session.CloseChan()等待斷線後清理
```
### 2.2 斷線處理
- **Local agent 主動斷**`Stop()` → close WS → server 端 `session.CloseChan()` 觸發 → `SessionStore.Unregister`
- **網路閃斷**local agent 的 `tunnel.Client.run()` 指數退避重連(初始 1s上限 30s
- **Proxy 重啟**:重新連接會走完整 Step 2舊 session 被覆蓋POC 邏輯沿用)
### 2.3 同 token 多次連線Q5 覆蓋行為 + Multi-tab 澄清2026-04-22 M-7
POC 邏輯:後來連線**覆蓋舊 session**(舊的直接關閉)。**雛形沿用此行為**Q5 裁決Phase 1 再重新設計為「拒絕後來連線」或「踢掉舊連線並通知使用者」以避免 race單裝置單 tunnel
#### Q5「後連覆蓋前連」適用層級澄清
**Q5 只描述 tunnel 這層remote-proxy ↔ local agent**
- 同一個 **Pairing/Session Token** 被兩個 **local agent 進程** 拿去連(例:使用者在兩台電腦裝了同一個 agent、都貼了同一組 token→ 後連的 agent 會踢掉前一個
- 正常使用情境下這不會發生Session Token 每裝置專屬Phase 1 升級機制會自動綁 device_id
- 雛形階段刻意貼同一個 `VISIONA_PAIRING_TOKEN` 到兩台電腦時會出現,這是 dev 行為不是產品情境
#### Multi-tab 觀看:屬於 API server ↔ browser 層,**不受 Q5 影響**
同一使用者在瀏覽器開多個分頁、都在看同一個裝置的推論結果 → 這完全不影響 tunnel
```
browser tab 1 ─┐ ┌─ api-server
│ │ (in-memory broadcaster or WS fan-out)
browser tab 2 ─┼─ WSS /ws/xxx ────►│
│ ├─ 內部只開一條 internal WS/HTTP forward
browser tab 3 ─┘ │ 經 remote-proxy 進 tunnel共用 session
└─ ↓
local agent
```
- 雛形階段API server 維護一個 **in-process fan-out** — 多個 browser WebSocket client 訂閱同一個 device/cluster 事件API server 只開**一條** yamux stream 從 local agent 取資料,再複製推給所有 browser 訂閱者
- 此機制完全在 API server 層,**不跨 tunnel session**,因此與 Q5 覆蓋行為無衝突
- Phase 1 若 api-server 水平擴展到多 instancemulti-tab 的 fan-out 需要額外機制pub/sub 或 sticky LB屆時另寫 ADR
**結論**
- **Q5** = tunnel 層「一個 device 只允許一條 active tunnel」
- **Multi-tab 觀看** = 應用層 fan-out與 Q5 互不干涉
- 雛形兩者皆支援tunnel 後連覆蓋 + 單 instance fan-outPhase 1 需重新設計的是**兩者都要**Q5 改為拒絕後連 + fan-out 跨 instance
雛形實作重點(`remote-proxy` `InMemoryStore.Register`
```go
func (s *InMemoryStore) Register(ctx, token, h) error {
s.mu.Lock()
defer s.mu.Unlock()
if old, ok := s.sessions[token]; ok {
old.Close() // 後連覆蓋前連POC 行為Q5 裁決沿用)
}
s.sessions[token] = h
return nil
}
```
---
## 3. 訊息格式
### 3.1 Tunnel 建立後
yamux session 建立完成後,**控制 frame 由 yamux 處理**,使用者資料透過 `session.Open()` / `session.Accept()` 產生的 stream 傳輸。
### 3.2 每個 yamux stream 的內容
**純 HTTP request/response 明文**,格式同 RFC 7230
Stream 開啟方proxy寫入
```
GET /api/devices HTTP/1.1
Host: 127.0.0.1:3721
Content-Length: 0
...
```
Stream 另一端local agent讀取後轉發到本機再把 response 寫回:
```
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 124
...
{"success":true,"data":[...]}
```
**WebSocket upgrade 特殊處理**
- Proxy 端偵測 `Upgrade: websocket` header → 切換成 raw bytes 雙向 pipePOC `proxyWebSocket`
- Local agent 端同步切換 → raw TCP to localhost + 雙向 pipePOC `handleWebSocket`
### 3.3 範例:瀏覽器 fetch `GET /api/devices`(雛形雙 binary 模式)
```
[瀏覽器] HTTPS → api-server
[api-server] 需要走 tunnel → 呼叫 internal HTTP
POST http://remote-proxy:3801/internal/forward/http?token=<tok>
body = 完整 HTTP request 原始 bytes
[remote-proxy] handleInternalForward
sessions[token] = *yamux.Session → OpenStream
把 request bytes 寫進 stream
從 stream 讀 response bytes
寫回 callerapi-server
[remote-proxy → local agent via yamux stream]
[local agent tunnel client] session.Accept() → stream
[local agent] http.ReadRequest(stream) → 送到 127.0.0.1:3721
[local agent] RoundTrip → response
[local agent] response.Write(stream)
[remote-proxy] 收 response bytes 原封寫回 api-server
[api-server] 收 response bytes → http.ReadResponse → 寫回 gin.Context.Writer
[瀏覽器] 收到 JSON
```
**關鍵差異vs POC 單 binary**api-server 與 remote-proxy 之間多一次 internal HTTP hoplocalhost 時 ~0.1ms,跨機 LAN ~1-5ms。此 hop 是 **雛形就存在**,不是 Phase 1 才引入 —— 這讓 api-server 保持無狀態,未來 Phase 1 只需評估多 proxy 節點間如何共享 session metadata見 ADR-006
---
## 4. 連線參數
### 4.1 WebSocket
- Binary frame`websocket.BinaryMessage`
- 無 compressionyamux 已是位元流,壓縮效益低且增加 CPU
- Origin check**remote-proxy 端接受任意 Origin**local agent 不跑在瀏覽器,不需 origin 防護Phase 1 加 token 驗證即可)
- Ping/Pong**使用 yamux 內建 keepalive**,統一心跳週期(見 §4.2
### 4.2 yamux Config2026-04-22 M-5 統一10s 心跳 / 30s 判定掉線)
| 參數 | 值 | 說明 |
|------|-----|-----|
| `AcceptBacklog` | 256 | POC default |
| `EnableKeepAlive` | `true` | — |
| `KeepAliveInterval` | **10s**(非 POC default 30s| 每 10s 送一次 yamux ping |
| `ConnectionWriteTimeout` | 10s | 單一寫入超時 |
| `MaxStreamWindowSize` | 256KB | Phase 1 壓測後可能調整(大串流場景)|
#### 心跳 / 掉線判定標準
- **心跳間隔**10 秒
- **判定掉線**:連續 **3 次**未收到對端 pong= 30 秒)→ 視為 tunnel 斷線
- **端點行為**
- `remote-proxy` 端偵測到斷線 → `yamux.Session.Close()``SessionStore.Unregister(token)` → 更新 Device 的 `remoteStatus = 'offline'` + `lastSeenAt`
- `local agent` 端偵測到斷線 → 進入重連迴圈(指數退避 1s → 30s 上限)
- **為何選 10s / 30s**
- 太短(< 5s 心跳流量過多尤其在手機網路下耗電
- 太長(> 30s→ 前端呈現「裝置離線」的反應延遲過大UX 差
- 10s + 3 次 ≈ 30 秒判定,跟一般雲端 LB idle timeout通常 60s留安全邊際
**雛形與 Phase 1 一致**:此參數從雛形就定死,不做區分,避免日後調整時產生回歸風險。
### 4.3 超時
| 場景 | 超時 | 處理 |
|------|------|------|
| WebSocket dial | 10s | local agent 退避重試 |
| Tunnel idle無 stream| 不超時yamux keepalive 即可)| — |
| Stream 建立 | 5s | 回 `502 Bad Gateway` |
| Stream 資料讀寫 | 使用 HTTP request context`context.WithTimeout` 由 api-server 層決定(預設 30s| — |
---
## 5. Session 管理
### 5.1 SessionStore Interface
```go
// internal/session/store.go
package session
import (
"context"
"net"
"time"
)
// Summary 是跨節點可序列化的 session 描述。
type Summary struct {
Token string
UserID string
DeviceID string
ConnectedAt time.Time
LastSeenAt time.Time
ProxyNodeID string // Phase 1 多節點時使用
ProxyInternalURL string // Phase 1 多節點時 api-server 要連的 URL
}
// Handle 是實際可操作的 session綁在某個 proxy 節點的記憶體)。
// 雛形單節點:直接 wrap *yamux.Session。
// Phase 1 多節點:可能是 local handle本地或 remote handle轉發到其他 proxy
type Handle interface {
OpenStream(ctx context.Context) (net.Conn, error)
Close() error
IsClosed() bool
Summary() *Summary
}
type Store interface {
Register(ctx context.Context, token string, h Handle) error
Unregister(ctx context.Context, token string) error
Lookup(ctx context.Context, token string) (Handle, error)
Exists(ctx context.Context, token string) (bool, error)
List(ctx context.Context) ([]*Summary, error)
Heartbeat(ctx context.Context, token string) error
// CleanupExpired 移除所有 LastSeenAt 超過 expireAfter 的 session判定為失聯
// 由 remote-proxy 的 background goroutine 每 30s 呼叫一次(對齊 §4.2 掉線判定)。
// 實作應 Close() 對應 Handle 以釋放 yamux.Session / WS conn。
// 回傳被清理的 session 數量observability 用)。
//
// 2026-04-22 Minor-4 新增:原本僅依賴 yamux keepalive 偵測斷線,
// 但若連線進入殭屍狀態TCP half-open未觸發 keepalive 錯誤,
// 需要主動清理,避免 SessionStore 留下無效 entry。
CleanupExpired(ctx context.Context, expireAfter time.Duration) (removed int, err error)
}
```
### 5.2 雛形InMemoryStore只在 remote-proxy 端)
```go
// internal/session/inmemory.go
// 只有 remote-proxy binary 載入此實作api-server 不持有 session state。
type InMemoryStore struct {
sessions map[string]Handle // token → Handle
mu sync.RWMutex
}
func (s *InMemoryStore) Register(ctx, token, h) error {
s.mu.Lock()
defer s.mu.Unlock()
// 覆蓋舊 sessionPOC 行為 / Q5 裁決沿用)
if old, ok := s.sessions[token]; ok {
old.Close()
}
s.sessions[token] = h
return nil
}
// Lookup / Unregister / Exists / List 為 trivial map 操作。
// Heartbeat 更新 h.Summary().LastSeenAt。
```
### 5.2.1 雛形ProxyClientStore只在 api-server 端)
```go
// internal/session/proxy_client.go
// 只有 api-server binary 載入。實質是 internal HTTP client代表「session 狀態在 remote-proxy 那邊」。
type ProxyClientStore struct {
proxyInternalURL string // 例http://localhost:3801 或 http://remote-proxy.internal:3801
http *http.Client
}
func (s *ProxyClientStore) Lookup(ctx, token) (Handle, error) {
// 呼叫 GET {proxyInternalURL}/internal/session/:token 確認 session 存在
// 回傳 RemoteHandleOpenStream 會再呼叫 /internal/forward/*
}
func (s *ProxyClientStore) Register(...) error { return ErrNotSupported } // register 只能在 remote-proxy
```
### 5.3 LocalHandle雛形
```go
// internal/session/local_handle.go
type LocalHandle struct {
yamuxSession *yamux.Session
summary *Summary
mu sync.Mutex
}
func (h *LocalHandle) OpenStream(ctx context.Context) (net.Conn, error) {
if h.yamuxSession.IsClosed() {
return nil, ErrSessionClosed
}
return h.yamuxSession.Open()
}
// ...
```
### 5.4 Phase 1多 remote-proxy 節點間的 metadata 共享
雛形只有 1 個 `remote-proxy` instanceapi-server 的 `ProxyClientStore` 直接指向那唯一的 URL。
Phase 1 當 `remote-proxy` 水平擴展到 N 個節點後,出現的問題:
```
[瀏覽器請求 tunnel X] → api-server-1 → 它要怎麼知道 tunnel X 連在 proxy-A 還是 proxy-B
```
**雛形不預設解法**(見 ADR-006。Phase 1 再評估以下候選:
| 候選 | 說明 | 優點 | 缺點 |
|------|------|------|------|
| Redis | 共享 `token → proxy_node_url` map | 簡單、成熟 | 引入新依賴、單點 |
| Consul / etcd | Service registry | 原生支援 watch / TTL | 運維複雜 |
| Gossip (memberlist) | proxy 節點互傳 session 清單 | 無中心點 | 資料一致性弱 |
| Sticky routing by L7 LB | token → 固定 proxy 節點 | 無需共享 state | LB 要會看 header |
| Fan-out query | api-server 問所有 proxy「tunnel X 在你那嗎?」 | 最簡單 | N 大時效率差 |
**採用的方案會產出新 ADR**,屆時 `ProxyClientStore` 可能替換或擴充(例如增加「先查 registry 再轉發」的邏輯)。
**雛形的好處**api-server 已經是無狀態、已經透過 HTTP 跟 proxy 溝通,所以 Phase 1 的改動只在「怎麼找到對的 proxy URL」這一層API handler 程式碼不變。
---
## 6. 跨節點路由的思考
Phase 1 多節點時,必須決定:
1. **當 proxy-A 剛好是 tunnel 所在節點** → 直接 `OpenStream`
2. **當收到的 api 請求在 api-server-X而 tunnel 在 proxy-Y** → api-server-X 透過 HTTP 呼叫 proxy-Y
單元 HTTP forward 會增加一次網路 hop~1-5ms LAN可接受。
**不採用的方案**RPCgRPC— 多一個 proto 定義,收益不大。
**不採用的方案**Redis pub/sub 轉發 request body — 非串流語義MJPEG 無法。
---
## 7. 雛形實作細節(給 Backend Agent
雛形即雙 binaryQ1 裁決,交付物)。本機開發便利工具 `make run-dev`(平行跑兩個 binary**非交付物**;不做 `cmd/dev-all-in-one`(見 `design-doc.md` §1.9 N10
### 7.1 `cmd/remote-proxy/main.go`
```go
func main() {
cfg := config.Load()
pairingStore := auth.NewStaticPairingStore(cfg.Pairing.Token, cfg.Auth.StaticUserID)
sessionStore := session.NewInMemoryStore() // 唯一持有 session state 的地方
// Tunnel listener面向 local agent
tunnelHandler := relay.NewServer(pairingStore, sessionStore).Handler()
// Internal HTTP listener面向 api-server
internalHandler := relay.NewInternalServer(sessionStore).Handler()
// 提供:
// GET /internal/session/:token
// POST /internal/forward/http?token=...
// GET /internal/forward/ws?token=...
// POST /internal/session/:token/close
go http.ListenAndServe(fmt.Sprintf(":%d", cfg.RemoteProxy.TunnelPort), tunnelHandler)
http.ListenAndServe(fmt.Sprintf(":%d", cfg.RemoteProxy.InternalPort), internalHandler)
}
```
### 7.2 `cmd/api-server/main.go`
```go
func main() {
cfg := config.Load()
authSvc := auth.NewStaticAuthService(cfg.Auth)
deviceRepo := device.NewInMemoryRepository()
modelRepo := model.NewInMemoryRepository()
storage := storage.NewLocalFS(cfg.Storage.LocalFSRoot, cfg.Storage.LocalFSBaseURL)
converterClient := converter.NewStubClient()
// Session store 是 remote-proxy 的 HTTP client無本地 state
sessionStore := session.NewProxyClientStore(cfg.Session.ProxyInternalURL, http.DefaultClient)
apiRouter := api.NewRouter(api.Deps{
Auth: authSvc, SessionStore: sessionStore,
DeviceRepo: deviceRepo, ModelRepo: modelRepo,
Storage: storage, Converter: converterClient,
})
http.ListenAndServe(fmt.Sprintf(":%d", cfg.APIServer.Port), apiRouter)
}
```
### 7.3 本機開發啟動
Makefile 提供 `make dev` 同時啟動兩個 binary`foreman` / `overmind` / 自製 shell
```makefile
dev:
@( ./bin/remote-proxy & ./bin/api-server & wait )
```
`docker-compose.yml` 定義兩個 service 同時起。
### 7.4 Phase 1 差異
- `remote-proxy` 仍然是唯一持有 `*yamux.Session` 實體的地方(無論多少節點)
- `api-server` 仍然用 `ProxyClientStore`,只是底層要能找到「該 session 在哪個 proxy 節點」(見 §5.4 方案評估)
---
## 8. 重連語意
從瀏覽器的角度,**tunnel 斷線期間的請求如何處理**
| 情境 | 處理 |
|------|------|
| 瀏覽器發 API request 時 tunnel 斷 | api-server 立刻回 `502 { code: TUNNEL_DISCONNECTED }`,前端顯示「裝置離線,請檢查 local-tool」 |
| 瀏覽器訂閱 WStunnel 斷 | api-server 關閉瀏覽器 WS前端自動重連 |
| Tunnel 重連後 | 瀏覽器下一次 API 請求直接成功 |
**不做 transparent retry**:若有 side-effect 的操作flash firmware期間 tunnel 斷,透明重試會導致重複執行。讓使用者明確看到錯誤並決定是否重試。
---
## 9. 觀測Phase 1
Metrics
- `tunnel_active_count{proxy_node}`
- `tunnel_connect_total{result}`result=success/auth_failed/upgrade_failed
- `tunnel_stream_opened_total`
- `tunnel_stream_duration_seconds` histogram
- `tunnel_disconnect_total{reason}`
Log 欄位:
- `token_prefix`(前 8 字元,不記完整 token
- `user_id`
- `device_id`
- `proxy_node_id`
- `event`connect / disconnect / stream_open / stream_error
---
**雛形實作Q1 裁決)**:雙 binary`remote-proxy``InMemoryStore` + internal HTTP endpoints`api-server``ProxyClientStore`。**不引入 Redis**(見 ADR-006
**未來擴展**Phase 1 多 proxy 節點時再評估 session metadata 共享機制§5.4 候選方案比較);壓測 yamux 參數。

View File

@ -0,0 +1,976 @@
# visionA Agent — Technical Design Document局部 TDD
## Metadata
| 項目 | 內容 |
|------|------|
| 文件角色 | **局部 TDD** — 只規範 visionA Agent 這一塊,不重寫 visionA-backend / visionA-frontend 的 TDD |
| 作者 | Architect Agent |
| 最後更新 | 2026-04-22v0.2 — 對齊使用者裁決 C1/C2 |
| 狀態 | Approved — frontend 改 Next.js沿用 local-tool / visionA-frontend stack|
| 上位文件 | `.autoflow/04-architecture/design-doc.md``.autoflow/04-architecture/TDD.md`(既有)、`.autoflow/03-design/visiona-agent-spec.md` |
| 下位文件 | `adr/adr-007-visiona-agent-architecture.md``adr/adr-008-tunnel-client-reuse.md``adr/adr-009-token-storage.md` |
| 讀者 | 要實作 visionA Agent 的 Backend Agent + Frontend Agent |
---
## 1. 產品定位回顧
visionA Agent 是 visionA 雲端版的 **local agent / tunnel bridge**,部署在使用者桌機。
### 1.1 它**是**什麼
- 一個完整的 **local-tool server**(沿用 KneronPLUS / camera / inference / device / model / Python runtime / ffmpeg 全部邏輯)
- 加上 **tunnel client**reverse tunnel 到雲端 remote-proxy
- 加上 **3 頁極簡配置 UI**(狀態 / 配對 / 設定)
- Wails v2 桌面應用macOS DMG / Windows EXE / Linux AppImage
### 1.2 它**不是**什麼
- 不是「純 tunnel bridge」它**必須**跑完整 server 邏輯,因為要操作 Kneron 裝置)
- 不是 local-tool 的修改版local-tool 不動visionA Agent 是 **fork 後獨立演進**
- 不是 headless CLI本次雛形是 Wails 桌面應用)
- 不保留 local-tool 原本的裝置 / 模型 / 推論操作 UI這些改由雲端 Web UI 負責)
### 1.3 與 local-tool 的職責差異(視覺化)
```
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ local-tool │ │ visionA Agent │
│ │ │ │
│ [前端 Next.js 完整 UI] │ │ [前端 Next.js 3 頁配置 UI] │
│ 裝置頁 / 模型頁 / 推論頁 │ │ 狀態 / 配對 / 設定 │
│ 工作區 / 儀表板 │ │ (output: 'export' static) │
│ ↓ HTTP │ │ ↓ Wails runtime │
│ [Gin server :3721] │ │ [Wails app.go] │
│ 所有業務 handler │ │ Pair / Disconnect /... │
│ │ │ ↓ │
│ │ │ [Tunnel client] │
│ │ │ WSS to remote-proxy │
│ │ │ ↓ 轉發進來的 HTTP │
│ │ │ [Gin server 127.0.0.1:RND] │
│ │ │ 同 local-tool 的 handler │
│ ↓ │ │ ↓ │
│ [Kneron / Camera / Python] │ │ [Kneron / Camera / Python] │
│ └─ 完全一樣 ────────────────────────────────────────┘ │
└─────────────────────────────┘ └─────────────────────────────┘
```
**結論server 邏輯 100% 一樣,差別在於「前端 UI 的職責」與「多了一個 tunnel client」**。
---
## 2. 整體架構
### 2.1 元件圖:雲端資料流中的 visionA Agent
```
┌──────────┐ HTTPS ┌─────────────┐ internal HTTP ┌──────────────┐
│ Browser │ ───────────────►│ api-server │────────────────►│ remote-proxy │
│ (cloud │ │ (stateless) │◄────────────────│ (stateful, │
│ web UI) │ └─────────────┘ │ yamux hub) │
└──────────┘ └──────┬───────┘
WSS + yamux (出站長連線)
┌────────────────────────────────────────┐
│ visionA Agent使用者桌機
│ │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ Tunnel │ │ 前端 SPA │ │
│ │ Client │ │ 3 頁配置 UI │ │
│ │ (yamux) │ │ (Wails WebView) │
│ └──────┬──────┘ └──────┬───────┘ │
│ │ 每個 stream │ Wails bind│
│ ▼ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ Internal HTTP Server │ │
│ │ 127.0.0.1:<random port> │ │
│ │ (Gin, 不對外;沿用 local-tool API)│ │
│ └─────────┬────────────────────────┘ │
│ │ │
│ ┌─────────▼────────────────────────┐ │
│ │ Reused server packages │ │
│ │ device / model / inference / │ │
│ │ camera / flash / deps │ │
│ └─────────┬────────────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ Python runtime + KneronPLUS │ │
│ │ (bundled) + ffmpeg │ │
│ └───────────┬────────────────────┘ │
└──────────────┼─────────────────────────┘
Kneron USB 裝置
```
### 2.2 與 local-tool 執行時期比較
| 元件 | local-tool | visionA Agent |
|------|-----------|---------------|
| 前端 Next.js 打包產物 | 多頁完整 UI`output: 'export'` static export | **取代為** 3 頁極簡 UI**同樣 Next.js + `output: 'export'`** |
| Wails app shell | 有(`visiona-local/` | 有(`visiona-agent/`),行為改造 |
| Gin HTTP server | 綁 `0.0.0.0:3721`(對外)| 綁 `127.0.0.1:<random>`(不對外) |
| Router | 所有 `/api/*`, `/ws/*` | **完全相同**(沿用 router.go |
| `internal/device`, `internal/model`, `internal/inference`, `internal/camera`, `internal/flash`, `internal/deps`, `internal/driver` | ✅ | **整包複製,不改** |
| Python runtime / wheels / ffmpeg | bundled | **整包複製,不改** |
| Tunnel client | 無 | **新增**sync 自 POC見 ADR-008 |
| 配對 / 設定 UI | 無 | **新增**3 頁,見 Design spec |
| 開機自啟管理 | 無 | **新增**3 平台實作) |
| 產品行銷 UIOnboarding / ServerDashboard / LogViewer| 有 | **移除或替換** |
---
## 3. 專案結構
**路徑:** `/Users/jimchen/visionA/local-agent/`
```
local-agent/ # 🆕 全新專案fork from local-tool @ 2026-04-22
├── wails.json # 改造name / productName / bundle ID
├── Makefile # 改造build targets 調整(見 §6
├── go.mod # 改造module visiona-agent非 visiona-local
├── go.sum
├── README.md # 🆕 新寫
├── .env.example # 🆕 VISIONA_AGENT_* env
├── cmd/ # 🆕 新增local-tool 沒 cmd/,它 main.go 在根)
│ └── visiona-agent/
│ ├── main.go # 🆕 Wails 入口(取代 local-tool/visiona-local/main.go
│ └── app.go # 🆕 Wails bindings + 生命週期
├── server/ # ✅ 整包複製自 local-tool/server/,不改
│ ├── main.go # ⚠️ 不用(改由 cmd/visiona-agent 內嵌啟動)
│ ├── go.mod
│ ├── internal/
│ │ ├── api/ # ✅ 整包複製
│ │ │ ├── router.go
│ │ │ ├── middleware.go
│ │ │ ├── handlers/ (system, model, device, camera...)
│ │ │ └── ws/ (device_events, inference, flash, system_ws, server_logs)
│ │ ├── camera/ # ✅ 整包複製
│ │ ├── config/ # ✅ 整包複製
│ │ ├── deps/ # ✅ 整包複製
│ │ ├── device/ # ✅ 整包複製
│ │ ├── driver/ # ✅ 整包複製
│ │ ├── flash/ # ✅ 整包複製
│ │ ├── inference/ # ✅ 整包複製
│ │ └── model/ # ✅ 整包複製
│ ├── pkg/
│ │ ├── logger/ # ✅ 整包複製
│ │ └── wsconn/ # ✅ 整包複製tunnel client 會用到)
│ └── web/ # ⚠️ 不用Wails 自己 embed 前端
├── internal/ # 🆕 Agent 專屬邏輯(非 server 邏輯)
│ ├── tunnel/ # 🆕 Tunnel client複製自 POC見 ADR-008
│ │ ├── client.go # 從 edge-ai-platform/server/internal/tunnel/client.go
│ │ ├── backoff.go # 退避演算法10s 心跳 / 30s 判定,對齊 TDD M-5
│ │ └── client_test.go
│ ├── pairing/ # 🆕 配對邏輯
│ │ ├── exchanger.go # 呼叫雲端 exchange endpoint 換 Session Token
│ │ ├── validator.go # `^vAc_[0-9a-f]{32}$` 格式驗證
│ │ └── exchanger_test.go
│ ├── tokenstore/ # 🆕 Pairing / Session Token 持久化(見 ADR-009
│ │ ├── store.go # TokenStore interface
│ │ ├── keychain_darwin.go # macOS Keychain
│ │ ├── keychain_windows.go # Windows Credential Manager
│ │ ├── keychain_linux.go # Secret Service (libsecret) or fallback
│ │ ├── encrypted_file.go # 雛形 fallbackAES-GCM + passphrase from OS
│ │ └── store_test.go
│ ├── agentconfig/ # 🆕 Agent 層級 config不同於 server/internal/config
│ │ ├── config.go # Relay URL / autostart / log level / reconnect strategy
│ │ ├── persist.go # 讀寫 YAML位置見 §4.3
│ │ └── config_test.go
│ ├── autostart/ # 🆕 開機自啟管理3 平台)
│ │ ├── autostart.go # AutoStart interface
│ │ ├── autostart_darwin.go # LaunchAgent plist
│ │ ├── autostart_windows.go # Registry Run key
│ │ ├── autostart_linux.go # ~/.config/autostart/*.desktop
│ │ └── autostart_test.go
│ ├── connstate/ # 🆕 連線狀態機 + 事件推送
│ │ ├── state.go # online / offline / reconnecting / notPaired / error
│ │ ├── broadcaster.go # Wails EventsEmit("connection:status")
│ │ └── log_buffer.go # 最近 10 筆連線事件(給 RecentLog
│ ├── logexport/ # 🆕 匯出 log打包最近 7 天)
│ │ ├── exporter.go
│ │ └── exporter_test.go
│ └── httpserver/ # 🆕 wrap local-tool server 的啟動 / 停止
│ ├── controller.go # sync.Once + random port + 127.0.0.1 綁定
│ └── controller_test.go
├── frontend/ # 🆕 Next.js 16 + React 19 + TS5 + Tailwind 4 + Radix UI + Zustand 5
│ │ # ⭐ 完全對齊 local-tool / visionA-frontend stackC1 裁決)
│ ├── package.json # 同 visionA-frontend 的 dependencies複製後刪掉用不到的
│ ├── next.config.mjs # `output: 'export'` + `images.unoptimized: true`
│ ├── tsconfig.json # ✅ 從 visionA-frontend 複製
│ ├── tailwind.config.ts # ✅ 從 visionA-frontend 複製Design Tokens 一致)
│ ├── postcss.config.mjs # ✅ 從 visionA-frontend 複製
│ ├── components.json # ✅ 從 visionA-frontend 複製shadcn 產生器設定,雛形不會再跑 CLI 但保留以利未來新增元件)
│ └── src/
│ ├── app/ # Next.js App Router
│ │ ├── layout.tsx # Root layoutHeader + ThemeProvider + Toaster
│ │ ├── page.tsx # 3 tabs state machine單頁切換不走多 route
│ │ ├── globals.css # ✅ 從 visionA-frontend 複製,不改一行
│ │ └── not-found.tsx # Wails 環境理論上不會走到,保留以避免 export 報錯
│ ├── components/
│ │ ├── layout/
│ │ │ ├── Header.tsx # h-14 + 3 tabs + ConnectionBadge
│ │ │ └── TabBar.tsx
│ │ ├── ui/ # ✅ 從 visionA-frontend `src/components/ui/` 複製18 個 Radix UI primitives 全部)
│ │ │ ├── button.tsx # 雛形實際只用約 10 個,但全複製以利未來擴充
│ │ │ ├── card.tsx # (複製成本 = 0有用就好
│ │ │ ├── dialog.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── radio-group.tsx
│ │ │ ├── select.tsx
│ │ │ ├── alert-dialog.tsx
│ │ │ ├── alert.tsx
│ │ │ ├── sonner.tsx
│ │ │ ├── tooltip.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── popover.tsx
│ │ │ └── dropdown-menu.tsx
│ │ ├── theme-provider.tsx # ✅ 從 visionA-frontend 複製next-themes wrapper
│ │ ├── status/
│ │ │ ├── StatusHero.tsx
│ │ │ ├── InfoCard.tsx
│ │ │ └── RecentLog.tsx
│ │ ├── pair/
│ │ │ └── PairForm.tsx
│ │ └── settings/
│ │ ├── SettingsConnection.tsx
│ │ ├── SettingsBehavior.tsx
│ │ ├── SettingsLog.tsx
│ │ ├── SettingsAbout.tsx
│ │ └── SettingsDanger.tsx
│ ├── views/ # 3 個 tab 對應的 view 元件(不是 Next.js page因為走單頁 tabs
│ │ ├── StatusView.tsx
│ │ ├── PairView.tsx
│ │ └── SettingsView.tsx
│ ├── hooks/
│ │ ├── use-connection-status.ts # Wails EventsOn + 初始拉取
│ │ ├── use-recent-log.ts
│ │ └── use-theme-sync.ts # ✅ 從 visionA-frontend 複製
│ ├── lib/
│ │ ├── bindings.ts # re-export Wails 自動產生的 TS binding位於 wailsjs/go/main/App.d.ts
│ │ ├── wails-runtime.ts # wrap @wailsapp/runtime EventsOn / EventsEmitSSR-safe雖然 export 不會 SSR`typeof window !== 'undefined'` guard
│ │ ├── i18n/
│ │ │ ├── index.ts # ✅ 結構從 visionA-frontend 複製i18n loader / OS locale 偵測)
│ │ │ ├── zh-TW.ts # 見 Design spec §10~135 keys文案重寫
│ │ │ └── en.ts # 同上
│ │ ├── utils.ts # ✅ 從 visionA-frontend 複製cn / clsx helper
│ │ └── format.ts # 遮蔽 session token / 時間格式
│ └── stores/
│ ├── connection-store.ts # zustand 5, 連線狀態
│ └── settings-store.ts # zustand 5, UI 層設定
├── visiona-agent/ # 🆕 Wails build dir類似 local-tool 的 visiona-local/
│ ├── wails.json # 見 §6.2
│ ├── payload/ # 執行時需要的 Python / wheels / ffmpeg
│ └── icon/ # 三平台 icon
├── vendor/ # Python runtime / wheels / ffmpeg同 local-tool 結構)
│ ├── python/
│ ├── wheels/
│ └── ffmpeg/
├── scripts/ # 三平台 bootstrap / installer 腳本
│ ├── bootstrap-macos.sh
│ ├── bootstrap-windows.ps1
│ └── bootstrap-linux.sh
└── build/ # Installer 打包輸入 + 中間產物
├── macos/ (dmgbuild / create-dmg 設定)
├── windows/ (Inno Setup .iss)
└── linux/ (AppImage recipe)
```
### 3.1 標記說明
- **✅ 整包複製**:從 local-tool或 visionA-frontend原樣拿**不改一行**
- **🆕 新增**visionA Agent 專屬
- **⚠️ 不用 / 取代**:原本的檔案不會被載入(例如 `server/main.go``server/web/embed.go`
### 3.2 Frontend 框架決策C1沿用 Next.js
**結論visionA Agent 前端用 Next.js 16 + `output: 'export'` 產出 static HTML/JS/CSS由 Wails `go:embed` 嵌入。**
這個決策對齊使用者的 4 大核心原則第 4 條「**雲端 web UI 先抄 local-tool**」與整個 visionA 產品線的 stack 一致性:
| 專案 | Frontend 框架 | 部署形態 |
|------|---------------|---------|
| **local-tool** | Next.js 16 + `output: 'export'` | Wails 嵌入 |
| **visionA-frontend** | Next.js 16 | Vercel / 雲端 |
| **visionA Agent**(本專案)| **Next.js 16 + `output: 'export'`** | **Wails 嵌入** |
**為什麼放棄 Vite + SPA修正前一版方案**
- ❌ 違反原則 4「先抄 local-tool」— local-tool 已經在 Wails 用 Next.js static export 跑得好好的,再引入 Vite 是發明問題
- ❌ 兩套 build pipeline / config 形態Vite vs Next.js增加團隊維護心智成本
- ❌ visionA-frontend 的元件 / utils / Tailwind config 已經是 Next.js 形態,搬到 Vite 反而要改 import / `'use client'` directive 處理
**沿用 Next.js 的好處:**
- ✅ **元件直接複製**`src/components/ui/*` 的 18 個 Radix UI primitives、`theme-provider.tsx``hooks/use-theme-sync.ts` 從 visionA-frontend 一行不改搬過來
- ✅ **Design Tokens 100% 一致**`globals.css` + `tailwind.config.ts` 直接複製
- ✅ **i18n 結構沿用**loader + OS locale 偵測邏輯複製,只改文案
- ✅ **Wails 友善已驗證**local-tool 已經跑了一年多,`output: 'export'` + `images.unoptimized: true` + 單頁 tabs不走 Next.js routing的模式穩定
**Next.js 在 Wails 環境的注意事項:**
| 議題 | 處理方式 |
|------|---------|
| SSR / SSG | 不用;`output: 'export'` 純 client side |
| Image Optimization | `images.unoptimized: true`local-tool 同樣設定) |
| Routing | **不走 Next.js routing**,用 useState 切 3 個 viewWails 環境 location.href 行為奇怪,避免之)|
| `'use client'` | 所有元件預設加(沒有 server component 概念)|
| `next/font` | 可用;字型會被 export 到 `out/_next/static/media/` |
| API Routes | **不用**Wails 不會跑 Next.js server|
| Hydration | 不會發生(無 SSR|
**Design spec §13.2 修正備註**:原本寫「不引入 Next.jsAgent 是純 SPA」是 v0.1 版的方案,使用者裁決後已對齊為 Next.js。Design Agent 不需要動文件spec 描述的是 UI 結構與 Design Tokens與框架選型無關只需要把 §13.2 的這句話視為 obsolete。
---
## 4. Tunnel 整合策略
### 4.1 Tunnel Client 從哪來
採用 **「程式碼複製」** 策略(見 ADR-008 完整分析):
- 從 `/Users/jimchen/visionA/visionA-backend/internal/tunnel/client.go` **複製**到 `local-agent/internal/tunnel/client.go`
- **不用** git submodule跨 repo 管理複雜、CI 綁定多)
- **不用** go module replace兩個專案 module 不同replace 語義勉強)
- **不用** 共用 library還沒有「共用 library repo」POC 也是每個專案各自複製)
對應 `progress.md` 原則 1「local-tool 不要動,可以複製出來」的精神。
### 4.2 啟動時序
```
Wails app.go: OnStartup(ctx)
├─ 1. 載入 Agent config (relay URL, reconnect strategy, log level)
├─ 2. 啟動 Internal HTTP Server (Gin)
│ - bind 127.0.0.1:0 (OS 分配 random port)
│ - 掛上 local-tool server/internal/api/router.go 的所有 handler
│ - 保留 port number 給後續 tunnel 用
├─ 3. 從 TokenStore 讀取已保存的 Session / Pairing Token
│ ├─ 有 Session Token → state = connecting → 進入步驟 4
│ ├─ 有 Pairing Token (上次配對未完成) → state = connecting → 進入步驟 4
│ └─ 無 → state = notPaired → 停在配對頁
├─ 4. 啟動 Tunnel Client
│ - 以 Session Token (或 fallback Pairing Token) 連 relayURL
│ - Accept 到的 stream → 轉發到 step 2 的 127.0.0.1:<randomPort>
│ - 指數退避重連1s → 2s → 4s → ... 上限 30s
│ - yamux KeepAlive 10s / 30s 判定掉線(對齊 §4 心跳規範)
└─ 5. 訂閱 tunnel 狀態變化 → 透過 Wails EventsEmit("connection:status") 推前端
```
**Shutdown 時序**`OnBeforeClose``tunnelClient.Stop()`grace 5s`httpServer.Shutdown(ctx)` → process exit。
### 4.3 配對流程(雛形 + Phase 1
#### 雛形流程(當前要實作的)
```
使用者在 Wails UI 貼上 Pairing Token (vAc_...)
agent 呼叫雲端: POST {relayHttpURL}/api/pairing/exchange
Body: { pairing_token: "vAc_..." }
雲端 api-server:
- StaticPairingStore.Validate(token) → 雛形 env 比對
- 生成 Session Token (vAs_ + 64 hex)
- 回傳 { session_token: "vAs_..." }
agent:
- TokenStore.SaveSession(session_token)
- TokenStore.DeletePairing() (雛形為了簡化可略)
- 啟動 tunnel client with session_token
tunnel client 連 WS /tunnel/connect?token=vAs_...
雲端 remote-proxy 接受 → tunnel 建立
agent 發事件 connection:status = "online"
前端 Toast "配對成功" + 0.5s 後切狀態頁
```
#### 雛形 vs 正式版 API 行為
| 事項 | 雛形 | 正式版 |
|------|------|--------|
| `/api/pairing/exchange` 端點 | **需要新增**(雛形 backend 還沒有)| 存在 |
| Pairing Token 來源 | 使用者從環境變數 / 雲端 web UI 取 `vAc_...`(雛形 web 已有 `/devices/pair`| 同左 |
| Session Token 驗證 | 比對 config env | DB 查詢(兩階段完整) |
| Token TTL | 無 | Pairing 15min / Session 90 days |
**雛形 backend 新增端點(同步通知 Backend Agent**
```
POST /api/pairing/exchange
Request body: { pairing_token: "vAc_[0-9a-f]{32}" }
Response body: { session_token: "vAs_[0-9a-f]{64}", expires_at: "2026-..." }
OR 401 { code: "token_invalid" | "token_expired" | "token_used" | "token_revoked" }
```
雛形 handler 實作:
```go
// visionA-backend/internal/api/handlers/pairing.go現有檔案新增 method
func (h *PairingHandler) Exchange(c *gin.Context) {
var req struct{ PairingToken string `json:"pairing_token" binding:"required"` }
if err := c.ShouldBindJSON(&req); err != nil { ... }
// 雛形 StaticPairingStore比對 env 值
if req.PairingToken != os.Getenv("VISIONA_PAIRING_TOKEN") {
c.JSON(401, gin.H{"code": "token_invalid"})
return
}
// 雛形沒 DB直接用同一個 pairing token 當 session token 用
// 或者回一個固定的 vAs_ prefixed token
c.JSON(200, gin.H{
"session_token": "vAs_" + strings.Repeat("0", 60) + strings.Repeat("f", 4),
"expires_at": time.Now().Add(90 * 24 * time.Hour).Format(time.RFC3339),
})
}
```
> **備註**:這個端點屬於 visionA-backend 側的**小型變更**S 級),等 Agent 要實作時再正式新增。TDD 只記錄契約。
### 4.4 重連策略
- **指數退避**1s → 2s → 4s → 8s → 16s → 30s上限
- **Max 5 次**後(設定頁可切「手動」)停止自動重試;使用者在狀態頁可點「立即重試」
- **Session Token 失效**remote-proxy 回 401`TokenStore.DeleteSession()` → state = notPaired → 前端切配對頁 + Toast「Session 已失效,請重新配對」
- **連線成功後**重置退避計數
### 4.5 心跳 / 掉線判定(對齊 TDD M-5
- yamux `KeepAliveInterval = 10s`
- 連續 3 次無 pong30s→ 判定掉線 → state = reconnecting
- 重連成功 → state = online
- 雛形與 Phase 1 一致。
---
## 5. 內部 HTTP Server 設計
### 5.1 綁定
- **框架**Gin沿用 local-tool/server/internal/api
- **位址**`127.0.0.1:0`OS 分配 random port不對外、不需要 firewall rule
- **認證****無**(綁 127.0.0.1 + tunnel 已是信任邊界;加 auth 反而複雜且無意義)
- **Router**:直接 import `server/internal/api.NewRouter()`local-tool 原本的組裝函式)
### 5.2 與 local-tool 的關係
- **不共用程式碼**(原則 3「server 邏輯一樣」 = **複製**,不 = **共享**
- 兩個專案各自持有一份 `server/internal/`2026-04-22 fork 後獨立演進
- 未來需要 cherry-pick 時,走 **手動** git 流程(對 diff 人工檢視 → apply
### 5.3 Handler 差異
雛形階段 handler **完全一致**Phase 1 可能出現 agent 專屬差異的地方:
| 可能差異點 | 說明 |
|-----------|------|
| system info endpoint | 回傳的內容 agent 版可能多幾個欄位relay URL、session status |
| `/api/system/shutdown-notify` | Agent 版可能直接退 applocal-tool 是退 server |
這些差異雛形**不處理**,等需求浮現再 fork。
### 5.4 與 tunnel 的配對
```
tunnel.Client (inbound yamux stream)
handleStream: 讀 HTTP request → 解析目標位址
將 req.URL.Host 改成 "127.0.0.1:<internalPort>"
http.DefaultTransport.RoundTrip(req) ← 打 Internal HTTP Server
resp.Write(stream) ← 回寫 yamux stream
```
與 POC / local-tool 行為完全一致,只是 `c.localAddr` 從 hardcoded 改成「啟動時 httpserver.Controller 告訴 tunnel client 的 port」。
---
## 6. 三個 UI 頁面對接
### 6.1 Wails BindingsGo → TS
`cmd/visiona-agent/app.go` 暴露給前端的方法:
```go
// App 是 Wails 綁定的主結構,前端透過 window.go.main.App.<Method>() 呼叫
type App struct {
ctx context.Context
config *agentconfig.Config
tokenStore tokenstore.Store
tunnel *tunnel.Client
httpSrv *httpserver.Controller
autostart autostart.Manager
connState *connstate.Broadcaster
logExport *logexport.Exporter
}
// Public methodsWails 自動產生 TS binding
func (a *App) GetStatus(ctx context.Context) (*StatusResponse, error) // 初始載入用
func (a *App) Pair(ctx context.Context, pairingToken string) error // 配對頁送出
func (a *App) Disconnect(ctx context.Context) error // 狀態頁「斷開」
func (a *App) Reconnect(ctx context.Context) error // 狀態頁「重新連線」
func (a *App) RepairFlow(ctx context.Context) error // 狀態頁「重新配對」(清 token
func (a *App) GetSettings(ctx context.Context) (*SettingsResponse, error)
func (a *App) UpdateSettings(ctx context.Context, patch SettingsPatch) error
func (a *App) TestRelay(ctx context.Context, url string) (*TestResult, error)
func (a *App) GetAutoStart(ctx context.Context) (bool, error)
func (a *App) SetAutoStart(ctx context.Context, enabled bool) error
func (a *App) GetRecentLog(ctx context.Context) ([]LogEntry, error)
func (a *App) ExportLog(ctx context.Context) (path string, err error) // 觸發 save dialog
func (a *App) OpenLogFolder(ctx context.Context) error
func (a *App) CheckForUpdates(ctx context.Context) (*UpdateResult, error) // 雛形 stub 回 up-to-date
func (a *App) ResetAll(ctx context.Context) error // 危險區域
func (a *App) GetVersion(ctx context.Context) string // 顯示於「關於」
```
### 6.2 Wails EventsGo → 前端事件)
| Event Name | Payload | 何時 emit |
|-----------|---------|-----------|
| `connection:status` | `{ state, error?, attemptNo?, relayUrl, account?, connectedSince?, sessionTokenPreview? }` | tunnel 狀態變化 |
| `connection:log` | `{ ts, icon, text }` | 新的連線事件(啟動/重連/錯誤) |
| `settings:updated` | `{}` | 設定有變,前端可重新拉 |
| `pairing:result` | `{ success: bool, error?: string }` | Pair() 結束 |
前端用 `EventsOn(name, handler)` 訂閱Wails runtime 內建)。
### 6.3 三頁面的對接重點
| 頁面 | 主要 bindings / events | Design spec 對應章節 |
|------|----------------------|---------------------|
| **狀態頁** | `GetStatus` 初始拉取 + 訂閱 `connection:status` / `connection:log`;按鈕 → `Disconnect` / `Reconnect` / `RepairFlow` | Design spec §4 |
| **配對頁** | `Pair(token)` → 結束觸發 `pairing:result`;成功 0.5s 後前端自動切狀態 tab | Design spec §5 |
| **設定頁** | `GetSettings` / `UpdateSettings``TestRelay``GetAutoStart` / `SetAutoStart``ExportLog``OpenLogFolder``CheckForUpdates` | Design spec §6 |
### 6.4 狀態機 (connstate)
```go
type State string
const (
StateNotPaired State = "notPaired"
StateConnecting State = "connecting" // = 配對中 / 首次連線中
StateOnline State = "online"
StateReconnecting State = "reconnecting"
StateOffline State = "offline" // 手動斷開 或 Max 重試後
StateError State = "error"
)
type Snapshot struct {
State State `json:"state"`
Error string `json:"error,omitempty"`
AttemptNo int `json:"attemptNo,omitempty"`
RelayURL string `json:"relayUrl"`
Account string `json:"account,omitempty"` // 雛形填 demo-user@innovedus.com
ConnectedSince *time.Time `json:"connectedSince,omitempty"`
SessionTokenPreview string `json:"sessionTokenPreview,omitempty"` // "vAs_a1b2c3d4 ··· e7f8"
}
```
Broadcaster 使用 `sync.Mutex` 保護 `current *Snapshot`,每次狀態變更同時:
1. 更新 snapshot
2. `runtime.EventsEmit(ctx, "connection:status", snapshot)` 推前端
3. `logBuffer.Append(...)` 記連線事件(最多 100 筆,前端只取 10 筆)
---
## 7. Build / Package 策略
### 7.1 Makefile 改造要點
**沿用 local-tool 結構**`vendor-sync``build-frontend``build-server``payload-*``wails-*``dmg/exe/appimage`),但:
| 項目 | local-tool | visionA Agent |
|------|-----------|---------------|
| `VERSION` / app name | `visiona-local` / `visionA Local` | `visiona-agent` / `visionA Agent` |
| `wails.json` 位置 | `visiona-local/` | `visiona-agent/` |
| `build-frontend` 命令 | `next build`(產 `out/`| **相同**`next build``frontend/out/` |
| `payload` 內容 | Python / wheels / ffmpeg / server binary | **相同** |
| `dmg / exe / appimage` 目標檔名 | `visiona-local-*` | `visiona-agent-*` |
| Bundle ID (macOS) | `com.innovedus.visiona-local` | **`com.innovedus.visiona-agent`** |
| NSIS App name (Windows) | `visionA Local` | **`visionA Agent`** |
| `.desktop` StartupWMClass (Linux) | `visiona-local` | **`visiona-agent`** |
**保留 `vendor-sync`Python / wheels / ffmpeg**:原則 3「server 邏輯一樣」暗示 Kneron 推論功能必須能跑,所以三者全部要 bundle。這是最大的 installer 體積來源macOS ~200MB、Windows ~250MB、Linux ~220MB但無法省。
### 7.2 wails.json差異
```json
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "visiona-agent",
"outputfilename": "visiona-agent",
"frontend:install": "npm --prefix ./frontend ci",
"frontend:build": "npm --prefix ./frontend run build",
"frontend:dev:watcher": "npm --prefix ./frontend run dev",
"frontend:dev:serverUrl": "http://localhost:3000",
"assetdir": "./frontend/out",
"author": {
"name": "Innovedus",
"email": "support@innovedus.com"
},
"info": {
"companyName": "Innovedus",
"productName": "visionA Agent",
"productVersion": "0.1.0",
"copyright": "Copyright 2026 Innovedus",
"comments": "Local agent for visionA cloud — connects your Kneron devices to the cloud"
}
}
```
### 7.3 三平台打包
| 平台 | 工具 | 同 local-tool | 差異點 |
|------|------|--------------|--------|
| macOS | `create-dmg`local-tool 現用)| ✅ | 換 icon、DMG 背景圖、volume name、bundle ID |
| Windows | Inno Setup`.iss`| ✅ | 換 AppId、AppName、DefaultDirName |
| Linux | appimagetool | ✅ | 換 `.desktop` Name / Icon / StartupWMClass |
### 7.4 簽章策略(同 local-tool 現況)
| 平台 | 雛形 | Phase 1 |
|------|------|---------|
| macOS | **Ad-hoc codesign**`codesign -s -`+ 使用者首次執行 Gatekeeper 彈窗 | Apple Developer ID + notarize |
| Windows | **不簽**SmartScreen 首次警告)| Code signing certEV / OV |
| Linux | **不簽**AppImage 本來就不簽)| — |
雛形就**不處理簽章成本**,跟 local-tool 一致。
### 7.5 版本與更新
- `info.productVersion``wails.json`+ `VERSION` Makefile 變數雙寫(建置腳本自動同步)
- 「檢查更新」按鈕 Phase 0stub 永遠回 `{ status: "up-to-date" }`(即使 disable 按鈕也行,見 Design spec §11.3
- Phase 1參考 POC `internal/update/`Gitea release API
---
## 8. 與 visionA-backend 的整合驗證
### 8.1 端對端測試路徑
```
[Browser]
↓ HTTPS
[visionA-frontend]
↓ /api/devices
[api-server :3001]
↓ internal HTTP POST /internal/forward/http?token=vAs_...
[remote-proxy :3801]
↓ yamux.Open(session[token]).Write(HTTP req bytes)
[WS wss://.../tunnel/connect?token=vAs_...] ← tunnel 內部 yamux stream
[visionA Agent 的 tunnel.Client.handleStream]
↓ http.DefaultTransport.RoundTrip(req with Host=127.0.0.1:<port>)
[visionA Agent 的 Internal HTTP Server (Gin)]
↓ /api/devices handler
[server/internal/device/manager] 操作 Kneron USB
↑ response 順著原路回
```
### 8.2 雛形整合測試腳本
在專案建立後,測試步驟:
1. 啟動 visionA-backend`cd visionA-backend && make dev`api-server + remote-proxy
2. 設 env`export VISIONA_PAIRING_TOKEN="vAc_$(openssl rand -hex 16)"`
3. 啟動 visionA Agent`cd local-agent && make dev` (啟動 Wails app
4. 在 Agent UI 配對頁貼 `$VISIONA_PAIRING_TOKEN`**需要新增 `/api/pairing/exchange` 端點**
5. Agent 取得 Session Token → 自動開 tunnel
6. 啟動 visionA-frontend`cd visionA-frontend && npm run dev`
7. 登入demo-user`/devices` → 看到 Agent 上報的 Kneron 裝置
8. 點 `Connect` → 能透過 tunnel 操作 KL520 / KL720
### 8.3 這個整合 fork 了什麼責任
| 責任 | 誰做 |
|------|------|
| `/api/pairing/exchange` 端點實作 | **visionA-backend小型變更 S 級)** |
| Tunnel client出站 WSS + yamux| visionA Agent |
| Tunnel server接入 WSS + session hub| visionA-backend `internal/relay` |
| Session Token 存取 | visionA Agent 本地 + visionA-backend DB雛形無 DB, env 比對) |
---
## 9. Token 儲存策略(摘要;詳見 ADR-009
| 平台 | 主要儲存 | Fallback |
|------|---------|---------|
| macOS | Keychain`github.com/keybase/go-keychain``github.com/99designs/keyring` | AES-GCM encrypted file |
| Windows | Credential Manager | AES-GCM encrypted file |
| Linux | Secret Service / libsecret | AES-GCM encrypted file |
**雛形策略**:如果 keyring 三平台整合太複雜(`CGO` 麻煩),**直接先用 encrypted file**TODO 標註 Phase 1 切 keychain。passphrase 從 OS machine ID 衍生(不讓使用者輸入)。
**存放位置**
| 平台 | 路徑 |
|------|------|
| macOS | `~/Library/Application Support/visionA Agent/tokens.enc` |
| Windows | `%APPDATA%\visionA Agent\tokens.enc` |
| Linux | `~/.config/visionA-agent/tokens.enc` |
**存什麼**
```json
{
"session_token": "vAs_...",
"pairing_token": "vAc_...", // 只在還沒換到 session 時才有
"account_email": "demo-user@innovedus.com",
"last_paired_at": "2026-04-22T14:30:00Z"
}
```
---
## 10. Agent Config 檔(`agentconfig/`
**存放位置**(跟 token 同目錄):`${dataDir}/config.yaml`
```yaml
# visionA Agent config — 使用者可讀,但主要由 UI 改
version: 1
relay:
url: wss://relay.visionA.cloud
reconnect: auto # auto | manual
behavior:
autostart: false
log:
level: info # debug | info | warn | error
```
Agent 啟動時讀UI 設定頁 `UpdateSettings` 寫回;每次變更立即 persist。
---
## 11. 跨平台實作要點
### 11.1 Autostart
```go
// internal/autostart/autostart.go
type Manager interface {
IsEnabled() (bool, error)
Enable() error
Disable() error
}
// 各平台實作:
// darwin: ~/Library/LaunchAgents/com.innovedus.visiona-agent.plist
// windows: HKCU\Software\Microsoft\Windows\CurrentVersion\Run (value="visionA Agent")
// linux: ~/.config/autostart/visiona-agent.desktop
```
三平台都是 **純檔案 / Registry 操作**,不需要 root / UAC。
### 11.2 Log 路徑
| 平台 | 路徑 |
|------|------|
| macOS | `~/Library/Application Support/visionA Agent/logs/` |
| Windows | `%APPDATA%\visionA Agent\logs\` |
| Linux | `~/.config/visionA-agent/logs/`XDG |
Log 檔名:`visiona-agent-YYYYMMDD.log`(每天 rotate
### 11.3 「開啟資料夾」/「開啟 URL」
用 Wails runtime 內建:
```go
wailsRuntime.BrowserOpenURL(ctx, "https://visionA.cloud/devices/pair")
// 開檔案總管darwin 用 `open`, windows 用 `explorer`, linux 用 `xdg-open`
```
### 11.4 Save File Dialog匯出 Log
```go
path, err := wailsRuntime.SaveFileDialog(ctx, wailsRuntime.SaveDialogOptions{
Title: "匯出 visionA Agent Log",
DefaultFilename: fmt.Sprintf("visiona-agent-log-%s.zip", time.Now().Format("20060102-150405")),
Filters: []wailsRuntime.FileFilter{{DisplayName: "Zip", Pattern: "*.zip"}},
})
```
---
## 12. 測試策略(雛形)
| 類型 | 範圍 | 工具 |
|------|------|------|
| Unit | `internal/tunnel` / `internal/tokenstore` / `internal/connstate` / `internal/autostart` | `go test` |
| Integration | App 起 → token 存 → tunnel 假 server 接受 → 狀態 online | 自寫 fake remote-proxy + `httptest` |
| E2E | 跑完整雲端 + agent從 web UI 操作裝置 | 手動(整合測試 §8.2 |
| Frontend | Radix UI 元件 + 狀態/配對/設定頁互動 | Vitest + RTL沿用 visionA-frontend 的測試慣例 + Next.js Jest preset 的等效 Vitest 設定) |
**雛形 MVP 覆蓋目標**
- tunnel reconnect / backoff至少 happy path + 3 次失敗 retry path
- pairing exchange 正確 / 失敗4 種 error code
- tokenstore encrypted file 讀寫
- connstate 狀態轉移5 狀態 × 常見事件)
---
## 13. TODOPhase 1 或之後再做)
### 功能層
- [ ] System Tray / Menu Bar 圖示(跨 Wails / Systray library待 UX 覆盤)
- [ ] 自動更新機制(參考 POC `internal/update/`,對接 Gitea / GitHub release
- [ ] Metrics export`expvar` / Prometheus
- [ ] 匯入 / 匯出 config 檔
- [ ] 國際化更多語言(只繁中 + English 就先)
### 安全 / 儲存
- [ ] Keychain / Credential Manager / Secret Service 真正接上(雛形用 encrypted file
- [ ] Session Token 過期 30 天前主動刷新Phase 1 + backend 支援)
- [ ] Pairing Token 首次使用後明確從本地清除(雛形為了簡化沒清)
### 建置 / 交付
- [ ] macOS notarize + hardened runtime
- [ ] Windows code signing
- [ ] Linux .deb / .rpm目前只 AppImage
- [ ] 自動化 release pipelineGitHub Actions matrix
- [ ] Silent install / MSI / .pkg 變體(企業佈署)
### 觀測
- [ ] 本地 metrics dashboardtunnel uptime, reconnect count
- [ ] Crash reporterSentry / Rollbar
- [ ] 匿名 usage telemetryopt-in
### 與雲端互動
- [ ] visionA-backend 新增 `/api/pairing/exchange`(雛形 backend S 級小改)
- [ ] Agent 提供自己的身分 headerAgent-Version / Agent-OS / Agent-Serial
- [ ] 雲端 web 顯示「哪些裝置來自哪個 agent」
### 與 local-tool 關係
- [ ] cherry-pick 策略文件(怎麼從 local-tool 搬修復到 agent
- [ ] Agent 與 local-tool 同一台電腦共存的 port / config 衝突檢查
---
## 14. ADR 一覽(新增)
- **ADR-007** visionA Agent 架構(為何 fork local-tool 而不是改造 local-tool
- **ADR-008** Tunnel client 複用策略(程式碼複製 vs submodule vs library
- **ADR-009** Token 儲存策略OS Keychain vs Encrypted file
詳見 `.autoflow/04-architecture/adr/adr-007-*.md` ~ `adr-009-*.md`
---
## 15. 開發任務拆分(給 Backend + Frontend Agent
### 15.1 Agent-BackendGo 部分)
| # | 任務 | 大小 | 依賴 |
|---|------|------|------|
| **AB1** | 專案初始化:建 `local-agent/` 目錄 + `go.mod` + Makefile 骨架(複製 local-tool Makefile改 app name / bundle id。**子任務**:確認 `visionA-backend/internal/tunnel/` 已不存在C2 裁決:實際上已於 2026-04-21 刪除README 也已記錄;本子任務只需 `ls visionA-backend/internal/ \| grep tunnel` 驗證無結果即可。如未來有人意外加回,立即刪除)| M | — |
| **AB2** | 複製 `server/` 整包local-tool → local-agent驗證 `go build ./server/...` 能過 | S | AB1 |
| **AB3** | 新建 `internal/httpserver/controller.go`:啟動/停止 Gin server 綁 127.0.0.1:0 | S | AB2 |
| **AB4** | 複製 tunnel client**從 POC `edge-ai-platform/server/internal/tunnel/`** → `local-agent/internal/tunnel/`,因為 visionA-backend 那份要刪)+ 參數化 localAddr | M | AB3 |
| **AB5** | 新建 `internal/agentconfig/` config 讀寫YAML | S | AB1 |
| **AB6** | 新建 `internal/tokenstore/`TokenStore interface + encrypted file 實作 | M | AB1 |
| **AB7** | 新建 `internal/pairing/exchanger.go`:呼叫雲端 `/api/pairing/exchange` | M | AB6 |
| **AB8** | 新建 `internal/connstate/`:狀態機 + broadcasterWails events | M | AB4 |
| **AB9** | 新建 `internal/autostart/` 三平台實作 | M | AB1 |
| **AB10** | 新建 `internal/logexport/`:壓縮最近 7 天 log 成 zip | S | AB2 |
| **AB11** | `cmd/visiona-agent/main.go` + `app.go`Wails 啟動 + 所有 bindings§6.1 | L | AB3-AB10 |
| **AB12** | 整合測試fake remote-proxy + agent 完整配對 + 連線 | L | AB11 |
| **AB13** | **visionA-backend 端 `/api/pairing/exchange`**S 級小改,另開任務) | S | — |
> ⚠️ AB1 子任務「確認 `visionA-backend/internal/tunnel/` 已刪除」說明:經查證 2026-04-22該目錄已不存在`visionA-backend/README.md` §Known Issues 已記錄 2026-04-21 刪除事實visionA-backend 也沒有任何程式碼 import 過此 package。當初它是 B3 階段為了「預留給未來 local-agent 用」而從 POC 複製進來的,但實際上 local-agent 直接從 POC 複製即可,不需要繞道 visionA-backend。保留這個未使用的 package 會誤導未來的維護者以為 visionA-backend 有 tunnel client 角色(實際上 visionA-backend 只有 `internal/relay/` = tunnel **server** 端,與 tunnel **client** 不同職責。AB1 子任務僅需驗證 + 必要時防止意外重新加入。詳見 ADR-008 v2 補充段落。
### 15.2 Agent-Frontend前端 3 頁 + 全域)
> **C1 裁決後重新估算**:因 frontend 改用 Next.js + 直接複製 visionA-frontend 大量資產18 個 Radix UI 元件、Design Tokens、theme provider、i18n 結構),任務從原本的 11 個壓縮為 **7 個**
| # | 任務 | 大小 | 依賴 | 備註 |
|---|------|------|------|------|
| **AF1** | 專案初始化 + 共用資產複製:建 `frontend/`Next.js 16 + `output: 'export'` + React 19 + TS5 + Tailwind 4 + Radix UI + Zustand 5同步從 visionA-frontend 複製 `tsconfig.json` / `tailwind.config.ts` / `postcss.config.mjs` / `app/globals.css` / `components/ui/*`18 個)/ `components/theme-provider.tsx` / `hooks/use-theme-sync.ts` / `lib/utils.ts` / i18n 結構loader + locale 偵測,文案重寫成 Agent 用)+ Wails build pipeline 設定(`assetdir: ./frontend/out`| L | — | 把原 AF1+AF2+AF3+AF9+AF10 合併(這些都是「複製不改」性質的工作,一個任務做完即可) |
| **AF2** | LayoutRoot layout + Header + 3-tab bar + ConnectionBadge + Toaster | M | AF1 | 原 AF4 |
| **AF3** | Wails bindings TS 型別 & 共用 hooks`use-connection-status``use-recent-log``use-settings`、SSR-safe wails-runtime wrapper | M | AB11 | 原 AF5 |
| **AF4** | 狀態 viewStatusHero + InfoCard + RecentLog + 按鈕互動Disconnect / Reconnect / RepairFlow | L | AF2, AF3 | 原 AF6 |
| **AF5** | 配對 viewPairForm格式驗證 + 配對送出 + Toast + 0.5s 自動切狀態 tab | M | AF2, AF3 | 原 AF7 |
| **AF6** | 設定 view連線 / 行為 / Log / 關於 / 危險區域 5 區塊 | L | AF2, AF3 | 原 AF8 |
| **AF7** | E2E從假 Agentmock Wails bindings起 → 走完配對 + 狀態 + 設定全流程 | M | AF4-AF6 | 原 AF11 |
**被合併 / 移除的任務說明:**
| 原任務 | 處理 | 原因 |
|--------|------|------|
| 原 AF2「複製 globals.css + shadcn」 | 併入新 AF1 | 純複製,與專案初始化合併效率高 |
| 原 AF3「i18n 骨架」 | 併入新 AF1 | i18n loader 結構從 visionA-frontend 直接複製,只剩文案重寫,不值得獨立 |
| 原 AF9「Dark Mode 跟隨 OS」 | 併入新 AF1 | `theme-provider.tsx` + `use-theme-sync.ts` 直接複製,零工作量 |
| 原 AF10「Wails build pipeline」 | 併入新 AF1 | 跟 wails.json `frontend:install/build/dev:*` 設定一起做 |
### 15.3 並行性建議
- AB1 / AF1 可**同時**開始
- AB2-AB10 / AF2 **可並行**
- AB11 需要 AB3-AB10 先完成AF3-AF7 需要 AB11 的 binding 確定AF3 可以先做 mock binding 平行)
- AB12 / AF7 是最後的收尾整合
### 15.4 時間估算
以 1 人 = 1 個任務 / 天熟手估計L 任務算 1.5 天M 算 1 天S 算 0.5 天):
- Backend 全部:約 **13 人日**AB1-AB13含刪除 visionA-backend tunnel 子任務,工作量極小不另計)
- Frontend 全部:約 **8 人日**AF1-AF7比原估 11 人日省 3 人日,因大量 visionA-frontend 資產可直接複製)
- 重疊後實際 wall-clock**約 2 週**1 人做 backend / 1 人做 frontend 並行;比原估 2-3 週縮短)
---
## 16. 使用者裁決紀錄2026-04-22
### 已決事項v0.2
| # | 問題 | 裁決 |
|---|------|------|
| **C1** | Frontend 框架 | **沿用 Next.js 16 + `output: 'export'`**(對齊原則 4「先抄 local-tool」與 visionA-frontend stack 一致性)|
| **C2** | Tunnel client 整合 | local-agent 從 **POC** 直接複製一份 + **刪除 `visionA-backend/internal/tunnel/`**(從未被任何 visionA-backend 程式碼 importB3 預留是誤導visionA-backend 只需要 `internal/relay/` = tunnel server 端)|
| **U-1** | `cmd/visiona-agent/` 子目錄 | **是**更乾淨Go 慣例)|
| **U-2** | Token 雛形儲存 | **encrypted file**machineID 衍生 passphrasePhase 1 換 OS keychain |
| **U-3** | `/api/pairing/exchange` 回傳 | **crypto/rand 產 `vAs_` + 64 hex 後 in-memory map 存**(對應正式行為)|
| **D1** | Wails 視窗大小 | 720×560記住上次 |
| **D2** | 配對成功轉場 | 0.5 秒後自動切狀態頁 + toast |
| **D3** | 「檢查更新」按鈕 | Phase 0 disable + 顯示「Phase 1 才支援」 |
---
## 版本記錄
| 日期 | 版本 | 變更 |
|------|------|------|
| 2026-04-22 | 0.1 | Architect Agent 初稿(局部 TDD單檔|
| 2026-04-22 | **0.2** | 對齊使用者裁決 C1 / C2frontend 改 Next.js + `output: 'export'`(取代原 Vite + SPA 方案ADR-008 補刪除 visionA-backend tunnel package§15 frontend 任務從 11 個壓縮為 7 個(複製 visionA-frontend 資產省 3 人日§3 frontend 結構改 Next.js App Router§7 wails.json `frontend:*` 改用 npm 啟動 next 命令、`assetdir``./frontend/out`AB1 加刪除子任務AB4 來源從 visionA-backend 改為 POC |

View File

@ -0,0 +1,184 @@
# Phase 0.6 OIDC 接入 — 交接摘要
> 日期2026-04-26
>
> 範圍visionA-backend StaticAuthProvider → Innovedus Member Center OIDCBFF Pattern + Authorization Code + PKCE
>
> 上位文件:`.autoflow/04-architecture/oidc-tdd.md``.autoflow/04-architecture/adr/adr-010-oidc-bff.md``.autoflow/04-architecture/adr/adr-011-supersede-static-auth.md`
>
> 整體交付:見 `.autoflow/07-delivery/project-summary.md`
---
## 1. Phase 0.6 完成項目
### BackendOB1-OB6
- **OB1** `internal/oidc/`coreos/go-oidc 包裝discovery + JWKS + token exchange + id_token 驗簽24 tests / 93.4% 覆蓋)
- **OB2** `internal/usersession/`in-memory session store + cookie HMAC 簽章24 tests
- **OB3+OB4** `internal/api/middleware/auth.go` + 4 個 OIDC handler`/api/auth/login` / `/callback` / `/logout` / `/me`14 tests
- **OB5** 完全移除 `StaticAuthProvider`、AuthenticatedClient fixture 統一17 packages 全綠 / oidc_e2e build tag 移除)
- **OB6** ADR-011 推翻 ADR-005 Auth 部分DB 部分仍有效)
### FrontendOF1-OF2
- **OF1** login 頁改 OIDC redirect 按鈕11 tests + i18n
- **OF2** API client `credentials: 'include'`、auth-store 拔 localStorage、改 cookie session107 tests 綠)
### FrontendOF3-OF4
- **OF3** register 頁移除、account 接 `/api/auth/me`(待補)
- **OF4** i18n 補齊(待補)
### DevOpsOD1
- 5 service docker-composepostgres / member-center / member-center-web / member-center-init / visiona-api / visiona-proxy全 healthy
- `docs/DEV-SETUP.md` 完整 setup + 故障排除(含 §7.6 OIDC flow 階段問題)
### DevOpsOD2
- `make dev-with-mc` Makefile target + `.env.example` 整理待補MC bug 修復後加 seed script
### TestingOT1
- `internal/oidc/oidctest/`fake OIDC server自簽 JWKS / 模擬 authorize / token endpoints
- `internal/api/handlers/oidc_e2e_test.go`6 個 e2e cases`PairingTokenBindsToOIDCUser`,驗 OIDC sub 真的有貫通到 PairingStore
- 17 packages 全綠 / build tag 已拿掉(合進主測試)
### TestingOT2範圍調整
- 因 MC 兩個 bug 阻擋自動 e2epassword grant 缺 sub claim、admin API 不接受 client_secret原訂「真 MC e2e」改為
- `docs/SMOKE-TEST.md`:完整 7 階段手動煙測 checklist
- `docs/DEV-SETUP.md` §7.6 補 OIDC flow 故障排除對照表
- 真 MC 自動 e2e 列入 Phase 1 TODO待 MC 修 bug
---
## 2. 跑通的 Demo
### Demo AOIDC Flow手動需先依 `docs/DEV-SETUP.md` §5 註冊 OAuth client
**端到端使用者旅程**(對應 `docs/SMOKE-TEST.md` 階段 3-7
```
[browser] http://localhost:3000/login
↓ 點「使用您的 Innovedus 帳號登入」
[backend] GET /api/auth/login
↓ 產 PKCE + state + nonce存 pending session10 分鐘 TTL
↓ Set-Cookie: visiona_pending_sid
↓ 302 to MC /oauth/authorize?response_type=code&...&code_challenge=...
[MC] http://localhost:5050/oauth/authorize
↓ 顯示登入頁 → demo@visiona.local / Demo12345!
↓ 同意授權
↓ 302 back to http://localhost:3721/api/auth/callback?code=...&state=...
[backend] GET /api/auth/callback
↓ 驗 stateCSRF
↓ POST MC /oauth/token (code + verifier + client_secret)
↓ 驗 id_tokeniss / aud / exp / nonce / JWKS 簽章)
↓ 建 visionA sessionuser_id = OIDC sub, 7d / 24h idle
↓ Set-Cookie: visiona_session
↓ 302 to http://localhost:3000{return_to}
[browser] dashboard 顯示已登入 demo user
↓ 後續 API request 自動帶 visiona_session cookie
[backend] /api/devices /api/models /api/auth/me 全帶 cookie 認證
```
### Demo BPairing Token 綁 OIDC user關鍵承諾驗證
**目的**:驗證取代 StaticAuth 後pairing token 不再綁 `demo-user` 而是真實 OIDC sub。
```
[browser] /devices/pair → 點「產生 Pairing Token」
[backend] POST /api/pairing/token (cookie: visiona_session)
↓ middleware 解 cookie → UserContext{UserID: <OIDC sub>}
↓ PairingStore.Create(userID=<OIDC sub>, token=vAc_xxx)
↓ log: "PairingStore Create userID=<UUID>"
[browser] 拿到 token vAc_xxx
↓ 餵給 local-tool agent
[agent] 連 visiona-proxy → 帶 pairing token → 換 session token
[backend] agent 連入 → 後端 binding user_id 是 OIDC sub與 step 2 一致)
```
### Demo Cfake OIDC e2e自動化
```
go test ./internal/api/handlers/... -run TestOIDCE2E
```
跑完含 6 個 case
- LoginRedirectsToProvider
- CallbackCreatesSession
- StateMismatchRejected
- NonceMismatchRejected
- ExpiredTokenRejected
- **PairingTokenBindsToOIDCUser**(最關鍵)
17 packages 全綠 / build tag 已合進主測試。
---
## 3. 手動 Demo Flow
完整步驟見 **`docs/SMOKE-TEST.md`**7 階段 / 預估 30 分鐘:
| 階段 | 內容 | 時間 |
|------|------|------|
| 1 | 基礎服務驗證5 service + frontend healthy| 5 min |
| 2 | MC 設定(建 tenant + OAuth client + demo user| 10 min |
| 3 | 完整 Login Flowredirect chain 走完)| 5 min |
| 4 | API 帶 Cookie 驗證(/me + /devices + /models| 3 min |
| 5 | Pairing Token 綁 OIDC user**關鍵承諾**| 5 min |
| 6 | 登出 + 重登 | 3 min |
| 7 | 跨頁 Refresh / 持久化 | 2 min |
每個階段都有對應的「故障排除 #N」可查;卡住先看 `docs/SMOKE-TEST.md` §故障排除 + `docs/DEV-SETUP.md` §7。
---
## 4. 已知 Limitation
| # | Limitation | 影響範圍 |
|---|-----------|---------|
| 1 | MC admin API 不能 seed OAuth clientpassword grant + client_secret 兩個 bug| §2 OAuth client 註冊必須手動走 Web UI自動 e2e blocked |
| 2 | MC 只支援 `usage=webhook_outbound` 帶 redirect_uris | 雛形借用,命名語意不對 |
| 3 | visionA-backend in-memory session store 重啟即消失 | 所有 user 重登 |
| 4 | 沒有 RP-initiated logout | 登出 visionA 不會把 MC session 也登出 |
| 5 | 沒有 refresh token rotation | 24h idle / 7d absolute 後必須重登 |
| 6 | host visiona-locallocal-tool佔 3721 與 docker compose 衝突 | 兩個只能擇一跑 |
---
## 5. Phase 1 TODO
### MC teamunblock visionA 自動化)
- [ ] **MC-1** 修 password grant Identity user 缺 sub claim → 500 bug
- [ ] **MC-2** admin API 接受 + 回傳 client_secret
- [ ] **MC-3** 新增 `usage=web_app` OAuth client 類型
### visionA Phase 1
- [ ] **OD2** `make dev-with-mc` + `make dev-seed` script依賴 MC-1 + MC-2
- [ ] **OT2 補完** 真 MC 自動 e2e用 testcontainers 起真 MC把 SMOKE-TEST §3-§7 全自動)
- [ ] **OF3 OF4** register 頁移除 + account 改造 + i18n 補齊
- [ ] **OB-Phase1-1** in-memory session store → Redis
- [ ] **OB-Phase1-2** RP-initiated logoutOIDC end_session_endpoint
- [ ] **OB-Phase1-3** Refresh token rotation依賴 MC 上 refresh token 支援)
- [ ] **OB-Phase1-4** Member Center webhookuser 刪除 / 停用通知)
---
## 6. 相關文件索引
| 主題 | 文件 |
|------|------|
| 架構決策 | `.autoflow/04-architecture/adr/adr-010-oidc-bff.md``adr/adr-011-supersede-static-auth.md` |
| TDD含時序圖、模組設計、env、安全考量| `.autoflow/04-architecture/oidc-tdd.md` |
| Dev 環境 setup | `docs/DEV-SETUP.md` |
| 手動煙測 | `docs/SMOKE-TEST.md` |
| 整體 visionA 交付 | `.autoflow/07-delivery/project-summary.md` |
---
## 7. 驗收狀態
- [x] 端到端 redirect chain 通fake OIDC e2e 自動驗)
- [x] Pairing token 綁 OIDC subOT1 PairingTokenBindsToOIDCUser ✅)
- [x] StaticAuthProvider 完全移除OB5 ✅)
- [x] Frontend 完全不接觸 tokenOF1 + OF2 ✅)
- [x] docker-compose 一鍵起 5 serviceOD1 ✅)
- [x] 手動煙測 checklist 完整OT2 ✅,本文件對應)
- [ ] OF3 + OF4 完成(待補;不阻擋 Phase 0.6 驗收,列入 Phase 1
- [ ] OD2 Makefile + seed script依賴 MC bug 修復;列入 Phase 1
- [ ] 真 MC 自動 e2e依賴 MC bug 修復;列入 Phase 1

View File

@ -0,0 +1,282 @@
# visionA 產品線 Phase 0 + Phase 0.5 交付總結
> **交付日期**2026-04-22
> **階段**Phase 0 雛形visionA 雲端版) + Phase 0.5visionA Agent
> **狀態**:✅ 全部完成
---
## 一、專案全景
visionA 是 Innovedus 的 Edge AI 開發平台,目前由 **4 個子專案**構成:
| 子專案 | 位置 | 角色 | 狀態 |
|--------|------|------|------|
| **local-tool** | `visionA/local-tool/` | 離線版桌面應用Wails + Next.js + Go| 既有,不動 |
| **visionA-frontend** | `visionA/visionA-frontend/` | 雲端 web 前端Next.js| Phase 0 雛形完成 |
| **visionA-backend** | `visionA/visionA-backend/` | 雲端後端Goapi-server + remote-proxy 雙 binary| Phase 0 雛形完成 |
| **visionA Agentlocal-agent** | `visionA/local-agent/` | 雲端版 local 端代理Wails + Next.js + Go | Phase 0.5 完成 |
未來還會加:
- **kneron_model_converter**(轉檔網站,獨立專案)
- 會員系統、Billing、Admin ConsolePhase 1+
---
## 二、產品定位
### 兩種使用模式並存
- **離線模式**:使用者裝 `local-tool` → 前端連 localhost server → 本機操作 Kneron 裝置
- **雲端模式**:使用者裝 `visionA Agent`local 端代理)+ 使用雲端 web UI瀏覽器開 `visionA-frontend`)→ 前端連雲端 `visionA-backend` → 經 remote-proxy tunnel 到 local-agent → 本機 Kneron 裝置
**兩個 Wails app 可同機共存**
- Bundle ID 獨立(`com.innovedus.visiona-local` vs `com.innovedus.visiona-agent`
- Data dir 獨立(`visiona-local/` vs `visiona-agent/`
- Port 不撞local-tool 用 `3721`Agent 內部綁 `127.0.0.1:random`,不對外)
---
## 三、4 大核心原則(使用者定)
1. **local-tool 完全不動**,需要時 fork
2. **雲端 vs 本機差異只在前端部署位置**(其他一樣)
3. **cloud agent 的 server ≈ local-tool 的 server**(差別在沒本機操作 UI + 多 tunnel client + 多配對 UI
4. **雲端 web UI 先抄 local-tool**Phase 0 不發明新 UI
---
## 四、技術堆疊
### Frontend共通
- Next.js 16.x + React 19.x + TypeScript 5
- Tailwind CSS 4 + Radix UI + Lucide
- Zustand 5狀態、自訂 i18n繁中 + English
- Vitest + React Testing Library
### BackendvisionA-backend
- Go 1.26 + Gin + gorilla/websocket + hashicorp/yamux
- 雙 binary`cmd/api-server`3721+ `cmd/remote-proxy`tunnel 3800 / internal 3801
- in-memory storePhase 1 換 Postgres/Redis
- S3-compat 抽象層LocalFS 雛形 / S3 / MinIO 未定)
### visionA Agent
- Wails v2 桌面外殼 + Next.jsoutput: 'export')靜態嵌入
- Tunnel client從 POC 複製backoff bug 已修)
- AES-GCM + scrypt + machineID-衍生 passphrase 的 Token storage
- YAML config + 結構化 log + zip 匯出
- 三平台安裝包macOS DMG / Windows EXE / Linux AppImage
---
## 五、架構圖
```
┌─────────────────────────┐
│ visionA-frontend │
│ (Next.js on CDN) │
└───────────┬─────────────┘
│ HTTPS
┌───────────▼─────────────┐
│ visionA-backend │
┌──────────┤ cmd/api-server :3721 │
│ │ (無狀態,可水平擴展) │
│ └───────────┬─────────────┘
│ │ internal HTTP :3801
│ │ (查 session / raw forward)
internal ──>│ ┌───────────▼─────────────┐
HTTP │ │ visionA-backend │
│ │ cmd/remote-proxy │
│ │ tunnel :3800 / int :3801│
│ │ (有狀態,持 session) │
│ └───────────┬─────────────┘
│ │ WebSocket + yamux
│ │ (tunnel)
│ │
│ ┌───────────▼─────────────┐
│ │ local-agent │
│ │ visiona-agent (Wails) │
│ │ ├─ tunnel client │
│ │ ├─ local HTTP :random │
│ │ │ (綁 127.0.0.1) │
│ │ └─ visiona-agent-server│
│ │ (Kneron/camera/...) │
│ └───────────┬─────────────┘
│ │ USB / IP
│ ┌───────────▼─────────────┐
│ │ Kneron 裝置 │
│ │ (KL520/KL720/KL730) │
│ └─────────────────────────┘
離線模式:使用者只用 local-tool前端連 localhost:3721 直接操作
```
---
## 六、交付物總覽
### 文件(`.autoflow/`
- **PRD**`02-prd/` — 21 個檔(索引 + 10 章節 + 10 features
- **設計規格**`03-design/` — 15 個檔(含 visionA Agent spec
- **架構 / TDD**`04-architecture/` — 14 個檔(含 visionA Agent TDD + 9 個 ADR
- **健檢 / Review**`00-onboarding/` + `05-implementation/review/` — 10+ 份
### 程式碼
- **visionA-frontend**Next.js 應用13 頁面、30+ 元件、94 tests、static export 就緒
- **visionA-backend**:雙 binary Go 應用20+ endpoints、100+ tests、Docker compose 就緒、E2E test 5 milestone 全綠
- **visionA Agent**Wails 桌面應用tunnel client + 3 個配置頁(狀態/配對/設定、90 tests、DMG 已 build160 MB
### 安裝包
- macOS DMG✅ 已驗160 MB本機 build
- Windows EXE`.github/workflows/build.yml` 就緒,由 CI 驗
- Linux AppImage同上
---
## 七、關鍵 Milestone & Integration Tests
### visionA-backend E2E
`cmd/api-server/b5_integration_test.go`5 段完整路徑
- **Browser → api-server → Forwarder → remote-proxy → yamux → fake tunnel client → fake local server**
### visionA-backend + visionA Agent E2EAB13
`cmd/api-server/e2e_full_flow_test.go`5 個 milestone
1. Pairing Exchange產 pairing → 兌換 session token
2. Tunnel Connectsession token 用 yamux 建立 tunnel
3. API Forward透過完整鏈路轉發業務請求
4. Token Reuse 防護(同 pairing token 再用 → 401 PAIRING_TOKEN_USED
5. Tunnel Drop Failoveragent 斷線 → 502 TUNNEL_DISCONNECTED
### visionA Agent 本機 E2EAB6
`local-agent/visiona-agent/internal/tunnel/integration_test.go`
- fake relay → yamux → agent handleStream → local server
- 20 並行 stream 無交錯
- 502 / 500 錯誤處理
---
## 八、使用者裁決紀錄15 個)
### Phase 0 雲端版雛形7 個)
| # | 議題 | 決策 |
|---|------|------|
| Q1 | 雛形進程模型 | C雙 binary + internal HTTP不引入 Redis |
| Q2 | POC 程式碼 | A複製到 visionA-backendPOC 保留 |
| Q3 | Local Agent | 本次雛形用 POC edge-ai-server 暫代Phase 0.5 做獨立 Agent |
| Q4 | 雛形 Auth | CStaticAuthService 回 demo-user |
| Q5 | Session 覆蓋 | A沿用 POC後連覆蓋前連 |
| Q6 | local-tool-only 元件 | A雛形隱藏 OnboardingDialog / ServerStatusDashboard / ServerLogViewer |
| Q7 | Pairing Token 顯示 | API 回純 hex前端顯示加空格 |
### Phase 0.5 visionA Agent8 個)
| # | 議題 | 決策 |
|---|------|------|
| A3 | Agent 範圍 | 純橋樑tunnel + 配對 UI + 設定,無本機操作 UI |
| — | App Name | visionA AgentBundle ID `com.innovedus.visiona-agent`|
| — | local-tool 關係 | 2026-04-22 fork獨立演進、不主動 sync |
| — | Tray UI | 不做(過去踩過坑)|
| — | 開機自啟 | 預設關閉 |
| — | Log 介面 | 基本 log + 匯出 zip |
| C1 | Frontend 框架 | Next.js 沿用(不換 Vite|
| C2 | Tunnel client | 從 POC 複製visionA-backend 的已刪)|
---
## 九、跨文件矛盾修訂紀錄Phase 0
三方審閱後修訂 12 項,關鍵:
- **M-1** Pairing Token 格式:`vAc_` + 32 hex / `vAs_` + 64 hex三方對齊
- **M-2** Token TTL兩階段Pairing 15 min + Session 90 天)
- **M-3** Auth 雙層AuthProviderhandler+ AuthServicemiddleware
- **M-4** Converter APIREST Resource 風格(`POST /v1/jobs`
- **M-5** 心跳10 秒心跳 + 30 秒判定掉線
- **M-8** Non-Goal雛形僅單 instance
---
## 十、Phase 1 TODO彙整
### 安全
- 真實 AuthClerk / OIDC / 自建),取代 StaticAuthProvider
- Agent token 改用 OS keychainmacOS Keychain / Windows Credential Manager / Linux Secret Service
- remote-proxy 向 api-server 驗證 session token不只是驗格式
- TLS 終止策略、rate limit、audit log
- macOS Notarization / Windows Authenticode code signing
### 資料持久化
- PostgreSQL + migrationgolang-migrate
- 所有 InMemory*Repository → Postgres*Repository
- S3 / MinIO 真實接入(取代 LocalFS
### 水平擴展
- 多 remote-proxy 節點 session metadata 共享
- api-server → proxy 跨節點路由
- 評估 Redis / Consul / Gossip
### 功能
- Converter 真實 API 對接(目前 stub契約已定
- Billing / 訂閱
- 多組織 Org / Team
- API key for programmatic access
- 裝置 multi-tab 同時觀看(目前 Q5 single session 限制)
- WebSocket proxy 真實實作(目前 501 stub
### Agent 專屬
- Log rotation
- 自動更新機制(從 POC update/ 搬)
- System Tray使用者過去踩過坑非高優先級
- SaveFileDialog目前 ExportLog 只回 temp path
### Observability
- 結構化 JSON log
- Prometheus metrics + Grafana
- OpenTelemetry tracing
- SLO dashboards + alert rules
### DevOps
- CI 真實跑通 Windows / Linux buildworkflow 已設定)
- Staging 環境、Blue-green / Canary deploy
- 備份 / DR 演練
---
## 十一、下一步建議
### A. 端到端跑通(立即)
```bash
# 1. 起 visionA-backend
cd visionA-backend
cp .env.example .env
make dev
# 2. 起 visionA-frontend另一個 terminal
cd visionA-frontend
cp .env.local.example .env.local
pnpm dev
# 3. 手動測 visionA Agent另一個 terminal
cd local-agent
make wails-macos # 或直接安裝 dist/visiona-agent.dmg
```
### B. CI 驗三平台 build
推 branch 觸發 `.github/workflows/build.yml`,驗 macOS/Windows/Linux 都能產出 artifact
### C. 進 Phase 1
根據 Phase 1 TODO 清單,選要先做的(建議優先序:真實 Auth → DB → S3 → 轉檔整合)
### D. 內部測試
乾淨機器安裝 local-tool + visionA Agent 驗共存、真實 Kneron 裝置跑一次配對 + 推論
---
## 十二、文件索引
- **產品**`.autoflow/02-prd/PRD.md`(雲端版)
- **設計**`.autoflow/03-design/design-spec.md`(雲端版)+ `visiona-agent-spec.md`Agent
- **架構**`.autoflow/04-architecture/design-doc.md` + `TDD.md`(雲端版)+ `visiona-agent-tdd.md`Agent
- **ADR**`.autoflow/04-architecture/adr/adr-001` ~ `adr-009`
- **健檢**`.autoflow/00-onboarding/health-check.md`
- **Review**`.autoflow/02-prd/review/` + `03-design/review/` + `04-architecture/review/` + `05-implementation/review/`
- **進度**`.autoflow/progress.md`
- **Build 驗證**`local-agent/docs/BUILD-VERIFICATION.md`

View File

@ -0,0 +1,185 @@
# Phase 0.7 — Stage 部署設定DevOps Agent 交付)
> 完成時間2026-05-01
> 對應任務:`.autoflow/progress.md` Phase 0.7 任務 B-1 ~ B-7
> 部署目標:`https://stage-9527.innovedus.com:9527/`
---
## 1. 產出檔案清單
| # | 檔案路徑 | 任務 | 用途 |
|---|---------|------|------|
| 1 | `docker/Dockerfile.stage` | B-1 | Multi-stage buildnode 22-alpinefrontend builder+ golang 1.26-alpinebackend builder x2 binary→ nginx:alpine runtime內含 nodejs/tini/bash/curl。最終 image 319 MB |
| 2 | `docker/nginx.stage.conf` | B-2 | Container 內 nginx :80 反代規則(/healthz / /api / /storage / /tunnel/connect / /_next/static / / |
| 3 | `docker/entrypoint.stage.sh` | B-3 | 4 process 啟動remote-proxy → api-server → node server.js → nginx`wait -n` + SIGTERM trap任一退出 → container die |
| 4 | `docker-compose.stage.yml` | B-4 | Stage host 上的單 service composeimage from `192.168.0.130:5000/visiona:stage``ports: "0.0.0.0:9527:80"`、volume `./data:/data` |
| 5 | `.env.stage.example` | B-5 | 環境變數範本OIDC public PKCE-only client + 預留 ServiceClient* + secrets placeholder + `openssl rand -hex 32` 產法) |
| 6 | `scripts/deploy-stage.sh` | B-6 | 一鍵部署腳本buildx push internal registry + remote `docker compose pull/up -d` + healthcheck poll|
| 7 | `docs/STAGE-DEPLOY.md` | B-7 | 部署手冊(架構圖 / 設計決策 / 第一次 step-by-step / 日常更新 / rollback / 7 種故障排除 / checklist |
| 附 | `.dockerignore` | — | 縮 build context 從 GB 級降到 686 KB |
| 附 | `.gitignore` | — | 加入 `.env.stage` 排除 |
---
## 2. 部署流程簡圖
```
[ 開發機 ] [ Internal Registry ] [ Stage Host 192.168.0.130 ]
192.168.0.130:5000
bash scripts/deploy-stage.sh
│ 0/5 pre-flight (docker buildx, git status warn)
│ 1/5 buildx --platform linux/amd64 --push
│ ──────────────────────────────────► visiona:stage
│ visiona:stage-<ts>-<sha>
│ 2/5 verify registry tags (HTTP API)
│ 3/5 DOCKER_HOST=tcp://...:2375 docker pull ────────────────────────────► docker daemon
│ 4/5 DOCKER_HOST=... docker compose up -d │
│ ▼
│ /opt/visiona/docker-compose.stage.yml
│ /opt/visiona/.env.stage (人手放上)
│ /opt/visiona/data/ (volume)
│ ┌──────────────────────────┐
│ │ container: visiona │
│ │ 9527:80 │
│ │ 4 processentrypoint
│ └──────────────────────────┘
│ 5/5 verify
│ - docker inspect Health.Status == "healthy"(最多 60s
│ - curl https://stage-9527.innovedus.com:9527/healthz
提示 rollback hintbash scripts/deploy-stage.sh --rollback stage-<ts>-<sha>
```
Container 內部:
```
公司 host nginx :9527 ──HTTP──→ container :80 nginx
┌─────────────┼──────────────┐
│ │ │
▼ ▼ ▼
:3721 :3800/3801 :3000
api-server remote-proxy node server.js
(Go) (Go) (Next.js standalone)
```
---
## 3. 重要設計決策(補充 progress.md S1-S8
### 3.1 Frontend 走 standalone + node runtime偏離 task 描述)
任務描述假設「frontend 是 static export → COPY out/ → nginx serve」。但實際 `visionA-frontend/next.config.ts``output: "standalone"`,且有 3 個動態 route`/devices/[id]`, `/models/[id]`, `/workspace/[deviceId]`build 時被標為 `ƒ Dynamic`
**決策**:保留 standalonecontainer 帶 nodejs runtime 跑 `node server.js`,不動 frontend code。
理由:
- 對齊產品線原則 4「雲端 web UI 先抄 local-toolPhase 0 不發明新 UI」
- 改成 export 模式需要為 3 個動態 route 加 `generateStaticParams()` 或重整 routes是 Frontend Agent 的工作
- Image 多 ~70MBnode + npm可接受
### 3.2 Multi-process container 用 bash `wait -n`(不用 supervisord
- `nginx:alpine` 預設 sh 不支援 `wait -n``apk add bash`
- 加 `tini` 當 PID 1正確 signal forwarding + zombie reap
- 任一 process 退出 → entrypoint exit → container die → `restart unless-stopped` 重啟
- 不用 supervisord避免它掩蓋真錯誤child 反覆崩但 supervisord 仍 healthy
### 3.3 Rollback 透過 retag非改 compose
`deploy-stage.sh --rollback stage-<ts>-<sha>` 會把 timestamp tag retag 為 `:stage` 重新 pushstage host 重 pull。
若 registry 啟用 immutable tag policy文件 §6.2 提供 fallback手改 compose file
### 3.4 Healthcheck 只看 nginx不看 backend
`/healthz` 由 nginx 直接 `return 200`,不打 backend。理由
- 若打 backendOIDC 暫時 down 會造成假紅
- 但 `wait -n` 會抓到 backend process 死亡 → container die
兩層配合「process 活著 = nginx 答得出 200」+「process 死掉 = wait -n exit = container restart」。
---
## 4. 已知限制(必須讓使用者知道)
| # | 限制 | 影響 | 緩解 |
|---|------|------|------|
| L1 | 公司 host nginx 是黑盒 | 反代規則timeoutbody size 需 IT 配合,無法我方完全控管 | STAGE-DEPLOY.md §3.2、§9 第一次 checklist 要求使用者向 IT 確認 |
| L2 | 4 process 共命運 | 任一掛 → 全 container 重啟 | Stage 階段可接受prod 應拆 service / k8s |
| L3 | LocalFS storage | 上傳模型存在 stage host `/opt/visiona/data/`,跨 host 移轉會丟 | 升級 S3 backend 後解除 |
| L4 | OIDC service-to-service client 未啟用 | Phase 0.7 不打 MC API | 已在 .env.stage.example 留欄位,未來啟用時不必改 schema |
| L5 | Internal registry 無 push 認證 | 內網任何能 reach 的人都可推 image | Stage 內網可接受prod 必須加 auth |
| L6 | Rollback retag 假設 registry mutable | 若 IT 改 immutable policy 會失敗 | STAGE-DEPLOY.md §6.2 結尾有手動 fallback |
| L7 | Stage 部署允許 dirty git | image 與 commit 對不上難以追蹤 | deploy script 會 warn + interactive 確認non-interactive 自動繼續 |
---
## 5. 第一次實際部署使用者需要做的事
`docs/STAGE-DEPLOY.md` §9 checklist
**開發機端(一次性):**
- [ ] Docker Desktop daemon.json 加 `"insecure-registries": ["192.168.0.130:5000"]`、Restart Docker Desktop
- [ ] 確認可 reach `tcp://192.168.0.130:2375``192.168.0.130:5000`
**請公司 IT一次性**
- [ ] Stage host nginx 加 `:9527 → localhost:9527` 反代 server block
- WS upgrade headers`Upgrade $http_upgrade``Connection $connection_upgrade`
- `proxy_read_timeout 86400s`visionA Agent tunnel 長連)
- `client_max_body_size 100M`(模型上傳)
- [ ] Stage host `/opt/visiona/` 目錄存在
- [ ] `:9527` 釋放(`edge-ai-platform` 容器已 stop ✓)
**MC 端(已確認):**
- [x] OIDC client `b8093fea1a504a5d8f0e04bee9f78f2e` redirect_uri 為 `https://stage-9527.innovedus.com:9527/api/auth/callback`
**部署當天:**
1. `cp .env.stage.example .env.stage`
2. 編輯 `.env.stage`
- `VISIONA_SESSION_SECRET``openssl rand -hex 32`
- `VISIONA_STORAGE_SIGNING_SECRET``openssl rand -hex 32`
- 其他保留範本值
3. 把 `.env.stage` + `docker-compose.stage.yml` 上傳到 stage host `/opt/visiona/`
4. `bash scripts/deploy-stage.sh`
5. 瀏覽器打 `https://stage-9527.innovedus.com:9527/` 驗證
6. 走完整 OIDC flowlogin → MC → callback → cookie session
---
## 6. 自我驗證紀錄(在我這台 Mac
| 檢查 | 結果 |
|------|------|
| `bash -n scripts/deploy-stage.sh` | ✓ 語法 OK |
| `bash -n docker/entrypoint.stage.sh` | ✓ 語法 OK |
| `docker buildx build --check` | ✓ Dockerfile lint 0 warning |
| `docker buildx build --target backend-builder` | ✓ 兩個 Go binary build 成功52.5s |
| `docker buildx build --target frontend-builder` | ✓ Next.js 16 standalone build 成功38.1s |
| `docker buildx build --load` 完整 build | ✓ 最終 image 319 MB含 nodejs / tini / bash / curl / 兩 binary / Next standalone |
| 修一個發現的 bug | `nodejs=~22` 在 alpine repo 已被 24 替代 → 改 `nodejs`(無 pin |
| Container `docker run` smoke | ✓ 4 process 全起來、nginx config OK、entrypoint signal 處理 OKOIDC discovery 失敗導致 api-server 退出 → entrypoint `wait -n` 抓到 → container die這是預期行為stage 環境接真 MC 即正常)|
---
## 7. 對 Reviewer / Testing 的提示
**Reviewer 看點:**
- Dockerfile.stage 的 multi-stage 邊界與 final layer 是否乾淨(無 build tools 殘留)
- nginx.stage.conf 的 X-Forwarded-Proto 是否正確(`https` hardcode 因為 termination 在外層)
- entrypoint.stage.sh 的 SIGTERM 處理 + 30s graceful deadline + force kill fallback
- deploy-stage.sh `--rollback` retag 邏輯
- .env.stage.example 與 backend `Validate()` 必填清單對齊
**Testing 看點:**
- 真實 OIDC flowlogin → MC :7850 → callback → cookie只能在 stage host 跑(需要真 MC + LE 證書)
- container 多 process 的失敗模式:手動 `docker exec visiona pkill -9 nginx` 看 wait -n 是否真的退出
- `client_max_body_size 100M` 邊界:上傳 99MB / 101MB 模型驗證
- `/tunnel/connect` WS 心跳能否撐過 1 小時(公司 host nginx timeout 是否真的設到 86400s
---
> 撰寫者DevOps Agent
> 文件層級:個人層(.autoflow/07-delivery/per-branch
> 共享文件:`docs/STAGE-DEPLOY.md`(部署手冊本身)