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:
parent
7575d4f8ee
commit
fb7da5d180
151
docs/autoflow/02-prd/PRD.md
Normal file
151
docs/autoflow/02-prd/PRD.md
Normal file
@ -0,0 +1,151 @@
|
||||
# visionA Cloud — 產品需求文件(PRD)索引
|
||||
|
||||
| 項目 | 內容 |
|
||||
|------|------|
|
||||
| 產品名稱 | visionA Cloud |
|
||||
| 產品代號 | visionA-frontend / visionA-backend |
|
||||
| 文件版本 | v0.1(Phase 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 Stories(RICE 排序)
|
||||
├── 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 Stories(RICE 排序)](user-stories.md)
|
||||
|
||||
所有 User Story 按 RICE 評分排序,P0(雛形必做)、P1(Phase 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 |
|
||||
| 會員系統 | P2(TODO) | [feature-auth.md](features/feature-auth.md) | 雛形只定介面 |
|
||||
| 轉檔整合 | P0(Phase 0.8 MVP) | [feature-converter-integration.md](features/feature-converter-integration.md) | upload→轉檔→半自動處理;converter / FAA / MC 已就緒 |
|
||||
| Billing | P2(TODO) | [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` 的「驗收條件」段落
|
||||
200
docs/autoflow/02-prd/features/feature-auth.md
Normal file
200
docs/autoflow/02-prd/features/feature-auth.md
Normal file
@ -0,0 +1,200 @@
|
||||
# Feature:會員系統(P0 介面;實作 TODO → Phase 1)
|
||||
|
||||
> 父文件:[PRD.md](../PRD.md) | 對應 User Stories:US-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 再換成真實 Auth(JWT / OAuth + DB)。
|
||||
|
||||
---
|
||||
|
||||
## 範圍說明
|
||||
|
||||
| Phase 0 做 | Phase 1 做 |
|
||||
|-----------|-----------|
|
||||
| 登入頁 / 註冊頁 UI | 真實 Auth 後端 |
|
||||
| POST /api/auth/login / register 的 stub handler | JWT 簽發 / 驗證 |
|
||||
| 簡易 session cookie(in-memory) | Refresh token rotation |
|
||||
| 個人設定頁 UI 骨架 | 密碼重設流程 |
|
||||
| 登出功能(清 cookie) | Email 驗證 |
|
||||
| — | OAuth(Google / GitHub) |
|
||||
| — | 2FA |
|
||||
| — | 權限 / Role 系統 |
|
||||
|
||||
---
|
||||
|
||||
## 使用者行為(Phase 0)
|
||||
|
||||
### 登入頁(`/login`)
|
||||
|
||||
- Email + 密碼輸入
|
||||
- 「登入」按鈕
|
||||
- 「還沒有帳號?註冊」連結
|
||||
- (Phase 1):忘記密碼連結、OAuth 按鈕
|
||||
|
||||
### 註冊頁(`/register`)
|
||||
|
||||
- Email + 密碼 + 確認密碼
|
||||
- 「建立帳號」按鈕
|
||||
- Phase 0:submit 後直接登入(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 1:RefreshToken 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**:換 JWTAuthProvider(Phase 1 核心)
|
||||
- **TODO-AUTH-02**:DB 層(PostgreSQL schema for users、sessions)
|
||||
- **TODO-AUTH-03**:Email 驗證流程(需 email 服務,SendGrid / SES)
|
||||
- **TODO-AUTH-04**:密碼重設流程
|
||||
- **TODO-AUTH-05**:OAuth(Google、GitHub)
|
||||
- **TODO-AUTH-06**:2FA(TOTP)
|
||||
- **TODO-AUTH-07**:密碼強度規則
|
||||
- **TODO-AUTH-08**:Rate limiting(防暴力)
|
||||
- **TODO-AUTH-09**:Account 刪除(含所有裝置、模型、叢集清理)
|
||||
- **TODO-AUTH-10**:個人設定頁完整功能
|
||||
- **TODO-AUTH-11**:Role / Permission 系統(Phase 2,for 企業版)
|
||||
- **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)
|
||||
77
docs/autoflow/02-prd/features/feature-billing.md
Normal file
77
docs/autoflow/02-prd/features/feature-billing.md
Normal file
@ -0,0 +1,77 @@
|
||||
# Feature:Billing(P2;Phase 0 不做)
|
||||
|
||||
> 父文件:[PRD.md](../PRD.md) | 對應 User Stories:US-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 免費
|
||||
|
||||
### 模式 C:Freemium + 轉檔計費
|
||||
|
||||
- 本體免費使用
|
||||
- 轉檔服務按次計費($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)
|
||||
102
docs/autoflow/02-prd/features/feature-cluster-inference.md
Normal file
102
docs/autoflow/02-prd/features/feature-cluster-inference.md
Normal file
@ -0,0 +1,102 @@
|
||||
# Feature:叢集推論(P1)
|
||||
|
||||
> 父文件:[PRD.md](../PRD.md) | 對應 User Stories:US-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 存 DB(Phase 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)
|
||||
246
docs/autoflow/02-prd/features/feature-converter-integration.md
Normal file
246
docs/autoflow/02-prd/features/feature-converter-integration.md
Normal file
@ -0,0 +1,246 @@
|
||||
# Feature:轉檔功能整合(P0 — Phase 0.8 MVP)
|
||||
|
||||
> 父文件:[PRD.md](../PRD.md) | 對應 User Stories:US-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` 已有完整轉檔 service(Phase 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 Stories(Phase 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 能力決定)
|
||||
- **必填欄位**:
|
||||
- 來源檔案(拖拽或選擇)
|
||||
- 目標 chip:KL520 / KL630 / KL720 / KL730(單選)
|
||||
- **可選欄位**:
|
||||
- Reference images(多張,給 converter 做精度校準用)
|
||||
- 任務名稱(顯示用,預設用檔名)
|
||||
- **檔案大小限制**:
|
||||
- 模型檔:≤ 500 MB(converter 端的限制;應透過 config 對齊,建議 `CONVERTER_MAX_MODEL_SIZE_MB`)
|
||||
- Reference images:每張 ≤ 10 MB、總數 ≤ 100 張
|
||||
- **上傳行為**:upload 走 **visionA backend streaming proxy**(見 §6 整合決策 D1),browser → backend → converter,使用者看到的是「上傳到 visionA」的單一進度條(XHR upload event)
|
||||
|
||||
### F3:轉檔執行與進度
|
||||
|
||||
- 上傳完成後自動切到「轉檔進度頁」(同一個 tab,不開新分頁)
|
||||
- 進度顯示用 **polling**(前端每 5–10 秒打一次 `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 完全不適用 CORS(FAA owner 2026-05-02 確認 + FAA TestSite `DownloadFileDirect` 範例驗證)
|
||||
- Browser **直連 FAA**(透過 302 跳轉),不經 visionA backend 中轉檔案內容,避免 N 次跨 internet 流量
|
||||
|
||||
---
|
||||
|
||||
## 4. 非功能需求
|
||||
|
||||
| 類別 | 需求 |
|
||||
|---|---|
|
||||
| 大小上限 | 模型 ≤ 500 MB;ref image 每張 ≤ 10 MB、總計 ≤ 100 張 |
|
||||
| 上傳體驗 | 上傳進度條(XHR `upload.progress` 事件);上傳期間禁止離開頁面(`beforeunload` warning) |
|
||||
| 並行限制 | 同 user 同時最多 1 個 active job(converter enforce 409 `user_has_active_job`) |
|
||||
| 任務保留 | 7 天後 converter 自動 GC;UI 在結果頁顯示倒數提醒 |
|
||||
| 安全 | 所有 visionA → converter 的呼叫帶 service account JWT;使用者不直接接觸 converter 認證 |
|
||||
| 可觀測性 | visionA backend log 每個 job 的 lifecycle(submit、poll status change、import、download token issued) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Non-Goals(Phase 0.8 不做)
|
||||
|
||||
| # | 不做的事 | 原因 / 後續 |
|
||||
|---|---------|-----------|
|
||||
| N1 | 轉檔歷史清單(`/converter/jobs`)| Phase 0.8 只支援「眼前這個 job」,不做 list;converter 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 純 polling;webhook 在 converter Phase 1 已實作但 visionA 暫不接,避免在 stage 環境管理 webhook URL / 簽章 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 整合決策(Phase 0.8 確認)
|
||||
|
||||
| # | 議題 | 決策 | 理由 |
|
||||
|---|------|------|------|
|
||||
| D1 | Upload 流量路徑 | Browser → visionA backend → converter(streaming 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 5–10 秒一次 | 簡單可靠;轉檔本身耗時 1–10 分鐘,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 模式不需要 CORS(FAA 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 images,e2e 成功
|
||||
- [ ] 上傳進度條正確顯示(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 → browser,stage 環境跑通
|
||||
- [ ] `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 的 wireframe(upload → progress → result)需獨立設計
|
||||
- 失敗狀態的視覺處理(顏色 / icon)參考既有錯誤模式
|
||||
- 「加到模型庫」與「下載」兩個按鈕的視覺平衡(不要讓使用者覺得有預設答案)
|
||||
- 進度條設計要區分「上傳階段」(0–100% 精確)與「轉檔階段」(不確定百分比)
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
61
docs/autoflow/02-prd/features/feature-dashboard.md
Normal file
61
docs/autoflow/02-prd/features/feature-dashboard.md
Normal file
@ -0,0 +1,61 @@
|
||||
# Feature:儀表板(P1)
|
||||
|
||||
> 父文件:[PRD.md](../PRD.md) | 對應 User Stories:US-14
|
||||
>
|
||||
> **Phase 0 基本版**:對應 local-tool 的首頁 `/`,顯示裝置列表 + 簡易統計。
|
||||
> **Phase 1 完整版**:加上活動時間軸、詳細統計卡片。
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
`/` 根目錄頁面,使用者登入後看到的第一個畫面。
|
||||
|
||||
對比 local-tool:local-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**:活動時間軸(需 DB,Phase 1)
|
||||
- **TODO**:詳細統計卡片(需 DB 埋點,Phase 1)
|
||||
- **TODO**:自訂儀表板佈局(Phase 2)
|
||||
|
||||
---
|
||||
|
||||
## 連結
|
||||
|
||||
- 回:[PRD 索引](../PRD.md)
|
||||
120
docs/autoflow/02-prd/features/feature-device-management.md
Normal file
120
docs/autoflow/02-prd/features/feature-device-management.md
Normal file
@ -0,0 +1,120 @@
|
||||
# Feature:裝置管理(P0)
|
||||
|
||||
> 父文件:[PRD.md](../PRD.md) | 對應 User Stories:US-03、US-06、US-07
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
使用者能在雲端瀏覽自己所有已配對的 Kneron 裝置、查看狀態、連接 / 斷開、查看韌體資訊。
|
||||
|
||||
對比 local-tool:UI 幾乎一樣,差別在「裝置來源」— local-tool 掃本機 USB,visionA 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 顯示 disabled,Phase 1 實作)
|
||||
|
||||
---
|
||||
|
||||
## 連結
|
||||
|
||||
- 回:[PRD 索引](../PRD.md)
|
||||
- 相關:[Pairing 流程](feature-pairing.md)、[推論操作](feature-inference.md)
|
||||
133
docs/autoflow/02-prd/features/feature-inference.md
Normal file
133
docs/autoflow/02-prd/features/feature-inference.md
Normal file
@ -0,0 +1,133 @@
|
||||
# Feature:推論操作(P0:Camera;P1:Image / Video / Batch)
|
||||
|
||||
> 父文件:[PRD.md](../PRD.md) | 對應 User Stories:US-10、US-11、US-15
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
使用者選裝置 + 模型 + 來源後,能啟動推論並即時看到結果(bounding box / 分類結果)。
|
||||
|
||||
這是**visionA Cloud 最核心的價值體驗**。推論結果要即時、低延遲、UI 和 local-tool 一致。
|
||||
|
||||
---
|
||||
|
||||
## 使用者行為
|
||||
|
||||
### Workspace 進入點(`/workspace` 或 `/workspace/[deviceId]`)
|
||||
|
||||
三段式選擇:
|
||||
|
||||
1. **選裝置**:從已配對的裝置列表挑一個
|
||||
2. **選模型**:從模型庫挑一個(需和裝置型號相容)
|
||||
3. **選來源**:
|
||||
- **Camera(Phase 0)**:使用者 agent 端的 USB / IP camera
|
||||
- **Image(Phase 1)**:從瀏覽器上傳單張圖片
|
||||
- **Video(Phase 1)**:從瀏覽器上傳影片(MP4/AVI/MOV 等)
|
||||
- **Batch Images(Phase 1)**:批次上傳多張圖片
|
||||
|
||||
選完後進推論工作區。
|
||||
|
||||
### 推論工作區
|
||||
|
||||
**UI 沿用 local-tool 的設計**:
|
||||
|
||||
- 左上:即時 MJPEG 畫面 + overlay(bounding box + label + confidence)
|
||||
- 右側:控制面板
|
||||
- 信心度門檻 slider
|
||||
- 推論 FPS / 延遲顯示
|
||||
- 開始 / 停止按鈕
|
||||
- 底下:分類結果 / 偵測框列表
|
||||
- 右上 header:Tunnel 延遲顯示(visionA Cloud 獨有)
|
||||
|
||||
### Camera 推論流程(Phase 0 重點)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Camera 推論資料流 │
|
||||
│ │
|
||||
│ 1. Browser 呼叫 api-server:POST /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-server,api-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 推論 | ~30ms(model 而異)|
|
||||
| local agent → remote-proxy(tunnel,經 WAN)| ~50-200ms RTT |
|
||||
| remote-proxy → api-server | ~5ms(同機房)|
|
||||
| api-server → Browser | ~10-50ms |
|
||||
| **端到端總延遲(P95 目標)** | **< 500ms** |
|
||||
|
||||
(local-tool 端到端 ~150-250ms,多的部分主要是 tunnel 的 WAN RTT)
|
||||
|
||||
---
|
||||
|
||||
## 驗收條件(Phase 0,Camera 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 agent,agent 做完回傳結果。
|
||||
|
||||
技術挑戰:
|
||||
- 大檔案傳輸的 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**:推論結果下載 / export(Phase 1)
|
||||
- **TODO 5**:Camera 來源的使用者選擇 UI — local agent 可能有多個 camera,要讓使用者選
|
||||
- **TODO 6**:推論 logging / analytics(Phase 2,用於叢集指標追蹤)
|
||||
|
||||
---
|
||||
|
||||
## 連結
|
||||
|
||||
- 回:[PRD 索引](../PRD.md)
|
||||
- 相關:[叢集推論](feature-cluster-inference.md)、[工作區](feature-workspace.md)、[非功能性需求 — 效能](../nonfunctional.md)
|
||||
145
docs/autoflow/02-prd/features/feature-model-management.md
Normal file
145
docs/autoflow/02-prd/features/feature-model-management.md
Normal file
@ -0,0 +1,145 @@
|
||||
# Feature:模型管理(P0)
|
||||
|
||||
> 父文件:[PRD.md](../PRD.md) | 對應 User Stories:US-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 0:seed 到 backend 的 local fs
|
||||
- Phase 1:seed 到 S3 的 `public/` prefix
|
||||
|
||||
2. **我的模型**(使用者上傳的)
|
||||
- 空狀態顯示 CTA「上傳模型」
|
||||
- 每個模型顯示:名稱、大小、上傳時間、支援硬體、上傳者
|
||||
|
||||
3. **組織模型**(Phase 2,留介面)
|
||||
- 如果使用者在團隊 workspace,能看到團隊共享模型
|
||||
|
||||
**篩選**:按任務類型(classification / object_detection)、硬體(KL520 / KL720)、關鍵字。
|
||||
|
||||
### 上傳模型
|
||||
|
||||
1. 點「上傳模型」→ 拖拽 or 選檔
|
||||
2. 檔案類型:`.nef`
|
||||
3. 檔案大小限制:**Phase 0 上限 100MB;Phase 1 上限 500MB(後續依付費方案調整)**
|
||||
- 此限制必須落在 config(env var 或 config file),**不可硬編碼**,方便跨 Phase 調整
|
||||
- 建議 config key:`MODEL_UPLOAD_MAX_SIZE_MB`(預設 100)
|
||||
- 前端與後端都要同步使用這個值(前端顯示錯誤提示、後端做實際拒絕)
|
||||
4. 上傳進度 bar
|
||||
5. 後端儲存到 ObjectStorage 介面實作
|
||||
6. 上傳完成後解析 metadata(檔案 header)
|
||||
7. Phase 0:metadata 存 in-memory map;Phase 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 1:presigned 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 0:Multipart form upload 直接進 api-server,api-server 再寫進 local fs
|
||||
- 限制:100MB 以下
|
||||
- api-server 的記憶體與磁碟 I/O 壓力
|
||||
- Phase 1:前端直接用 presigned URL 上傳到 S3,不過 api-server
|
||||
- 需要 CORS 設定
|
||||
- 上傳完成後通知 api-server 更新 metadata
|
||||
|
||||
### 預設模型 seed
|
||||
|
||||
Phase 0:backend 啟動時,檢查 `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)
|
||||
181
docs/autoflow/02-prd/features/feature-pairing.md
Normal file
181
docs/autoflow/02-prd/features/feature-pairing.md
Normal file
@ -0,0 +1,181 @@
|
||||
# Feature:Pairing 流程(P0,雛形必做)
|
||||
|
||||
> 父文件:[PRD.md](../PRD.md) | 對應 User Stories:US-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-tool,local-tool 存到本機 config
|
||||
|
||||
---
|
||||
|
||||
## Token 設計
|
||||
|
||||
### Pairing Token(一次性、短期)
|
||||
|
||||
| 屬性 | 值 |
|
||||
|------|---|
|
||||
| 格式 | `vAc_` + 32 字元 hex(範例:`vAc_7f3c8e2a9b1d0f5e...`)|
|
||||
| 產生者 | api-server |
|
||||
| 儲存 | Phase 0:in-memory map;Phase 1:DB |
|
||||
| TTL | 預設 15 分鐘(可調整)|
|
||||
| 使用次數 | 1 次 |
|
||||
| 綁定 | user_id(產生者)|
|
||||
|
||||
### Session Token(長期、可撤銷)
|
||||
|
||||
| 屬性 | 值 |
|
||||
|------|---|
|
||||
| 格式 | `vAs_` + 64 字元 hex |
|
||||
| 產生者 | remote-proxy(首次 pairing 成功後發)|
|
||||
| 儲存 | Phase 0:in-memory map;Phase 1:DB<br>local-tool 端:寫入 config 檔 |
|
||||
| TTL | **90 天**(到期或使用者主動撤銷時失效)|
|
||||
| 使用次數 | 無限(TTL 內每次重連共用)|
|
||||
| 綁定 | user_id + device_id |
|
||||
|
||||
> **兩階段 TTL 設計**:
|
||||
> - **Pairing Token 階段**:使用者在雲端產 token、貼到 local agent,15 分鐘內要完成首次連線,一次性使用
|
||||
> - **Session Token 階段**:local agent 首次連上 remote-proxy 後,由 remote-proxy 發 Session Token 寫入 local config;之後 tunnel 斷線重連、跨 session 維持長連線都用這把 token,90 天內有效
|
||||
>
|
||||
> Phase 0 先做固定 90 天 TTL;Phase 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
|
||||
- [ ] TODO:Phase 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(到期需重新 pairing);Phase 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)
|
||||
50
docs/autoflow/02-prd/features/feature-workspace.md
Normal file
50
docs/autoflow/02-prd/features/feature-workspace.md
Normal file
@ -0,0 +1,50 @@
|
||||
# Feature:工作區(P0)
|
||||
|
||||
> 父文件:[PRD.md](../PRD.md) | 對應 User Stories:US-10
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
工作區是使用者做推論的主要介面,對應 `/workspace` 和 `/workspace/[deviceId]` 頁面。
|
||||
|
||||
對比 local-tool:UI 幾乎完全一致。
|
||||
|
||||
---
|
||||
|
||||
## 使用者行為
|
||||
|
||||
### `/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)
|
||||
330
docs/autoflow/02-prd/interface-contracts.md
Normal file
330
docs/autoflow/02-prd/interface-contracts.md
Normal 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 接 stub,Phase 1 接真實 Auth。
|
||||
|
||||
### 必須提供的能力
|
||||
|
||||
| 能力 | 說明 | Phase 0 | Phase 1 |
|
||||
|------|------|---------|---------|
|
||||
| Register | 建立新 user(email + 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-user(Architect 決定)
|
||||
- **Phase 1 方法**(RefreshToken / RequestPasswordReset / ConfirmPasswordReset / Delete):stub,直接回 `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 memory);Phase 1 用 Redis 跨節點共享。
|
||||
|
||||
### 必須提供的能力
|
||||
|
||||
| 能力 | 說明 |
|
||||
|------|------|
|
||||
| CreatePairingToken | 建立一次性 pairing token,綁 user_id,15 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 起兩個 server)OR
|
||||
- **共享 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 | 取得下載 URL(Phase 0 走自己的 API,Phase 1 用 presigned)|
|
||||
| GetUploadURL | (Phase 1)presigned 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 0:100 MB(記憶體限制)
|
||||
- Phase 1:500 MB(model)、5 GB(video upload)
|
||||
|
||||
---
|
||||
|
||||
## 8.5 ConverterClient 介面
|
||||
|
||||
### 目的
|
||||
|
||||
呼叫 kneron_model_converter 服務做格式轉檔。Phase 0 / 1 用 stub,Phase 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 0:visionA-backend 定義 `ConverterClient` interface + `StubConverterClient` 實作
|
||||
2. Phase 0:PM Agent 把 API spec(`feature-converter-integration.md`)正式交付給 converter 團隊
|
||||
3. Phase 0-1:converter 團隊依 spec 實作 API
|
||||
4. Phase 2:visionA-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 | 建立結帳 session(hosted 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)
|
||||
166
docs/autoflow/02-prd/market-analysis.md
Normal file
166
docs/autoflow/02-prd/market-analysis.md
Normal 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 約 $10B,2030 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、公開 IP,local agent 主動連雲端 WebSocket
|
||||
3. **叢集推論** → 加權 RR,多裝置並行,Edge Impulse / SenseCraft 都沒有
|
||||
4. **和 local-tool 互補** → 同一個使用者可同時使用,UI 一致
|
||||
|
||||
### 3.3.2 護城河分析
|
||||
|
||||
| 護城河 | 強度 | 可維持多久 | 說明 |
|
||||
|-------|------|-----------|------|
|
||||
| Kneron 生態系整合 | 高 | 2-3 年 | 我們是 Innovedus(Kneron 自家),官方渠道優勢 |
|
||||
| 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 0(2026 Q2)
|
||||
|
||||
- 對象:Kneron 內部 FAE + Innovedus 團隊
|
||||
- 通路:內部公告、直接拉人測試
|
||||
- 目標:技術驗證,拿到 5-10 位深度回饋
|
||||
|
||||
### Phase 1 MVP(2026 Q3)
|
||||
|
||||
- 對象:Kneron 外部生態系中**已知的早期採用者**(從 local-tool / POC 用戶名單找)
|
||||
- 通路:Email 邀請、1:1 onboarding
|
||||
- 目標:100 個 Pairing,50 個 WAD
|
||||
|
||||
### Phase 2(2026 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)
|
||||
284
docs/autoflow/02-prd/nonfunctional.md
Normal file
284
docs/autoflow/02-prd/nonfunctional.md
Normal 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-250ms,visionA 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)|
|
||||
| 頁面切換 | < 300ms(App 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 header(Phase 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-memory);Phase 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
|
||||
- 漏洞回應 SLA:Critical 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 metrics:WAD、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-agnostic(AWS / GCP / self-hosted 皆可)
|
||||
- 加 ALB / Cloud LB
|
||||
|
||||
**Phase 2**:
|
||||
- Kubernetes(如有需要多節點)
|
||||
- Multi-region
|
||||
|
||||
### 7.7.2 CI/CD
|
||||
|
||||
- Phase 0:手動 build + deploy(GitHub Actions on main push 可選)
|
||||
- Phase 1:GitHub Actions,每 PR 跑測試,merge 自動 deploy 到 staging
|
||||
- Phase 2:Blue/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)
|
||||
167
docs/autoflow/02-prd/product-positioning.md
Normal file
167
docs/autoflow/02-prd/product-positioning.md
Normal 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 |
|
||||
|------|-----------|---------------------|---------------|
|
||||
| 產品形態 | 桌面 App(Wails 打包)| 桌面 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 WebView,API 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 沒有 Auth,cloud 有 Auth — Auth 相關元件用 feature flag 隱藏
|
||||
- local-tool 顯示 ServerStatusDashboard(Wails 控制台),cloud 不該顯示 — 用 feature flag
|
||||
|
||||
**Design Agent 需留意**:設計規格需要標注哪些元件「只在 cloud 顯示」、哪些「只在 local 顯示」。
|
||||
|
||||
---
|
||||
|
||||
## 2.4 使用情境對照
|
||||
|
||||
### 情境 A:FAE 阿哲做客戶 demo
|
||||
|
||||
| 步驟 | 傳統做法(local-tool) | visionA Cloud 做法 |
|
||||
|------|---------------------|-------------------|
|
||||
| 準備 | 帶筆電 + Kneron 裝置到客戶現場 | 客戶現場先寄一台筆電 + 裝置過去(或客戶自備),裝 local agent 並 pairing |
|
||||
| Demo 中 | 筆電螢幕分享給客戶 | 阿哲自己筆電開 visionA Cloud,畫面分享給客戶(或客戶自己用瀏覽器打開)|
|
||||
| Demo 後 | 收回筆電 | 裝置留在客戶那,阿哲之後繼續用 cloud 管理 |
|
||||
|
||||
### 情境 B:SI Sarah 管多個客戶現場
|
||||
|
||||
| 步驟 | 傳統做法 | visionA Cloud 做法 |
|
||||
|------|---------|-------------------|
|
||||
| 佈署 | 每個客戶一台筆電跑 local-tool,Sarah 要飛去每家 | 每個客戶佈署一台 local agent + pairing,Sarah 在辦公室集中管理 |
|
||||
| 監控 | 打電話問客戶「現在裝置狀態?」 | 打開儀表板看所有裝置即時狀態 |
|
||||
| 叢集 | 做不到 | 把多客戶的裝置組叢集,做加權 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 建好再接)
|
||||
- ❌ Observability(Prometheus / 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)
|
||||
118
docs/autoflow/02-prd/risks.md
Normal file
118
docs/autoflow/02-prd/risks.md
Normal 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 16(App 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 library(sendgrid 或 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)
|
||||
230
docs/autoflow/02-prd/roadmap.md
Normal file
230
docs/autoflow/02-prd/roadmap.md
Normal 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 跑得過)
|
||||
|
||||
**明確不做**:
|
||||
|
||||
- 真 Auth(JWT / OAuth / DB)
|
||||
- 真 DB(PostgreSQL)
|
||||
- 真 S3 / MinIO
|
||||
- 叢集推論 API(只搬 `internal/cluster/` 模組)
|
||||
- 儀表板時間軸與統計(只做快速開始版)
|
||||
- 圖片 / 影片 / Batch 推論(只做 Camera)
|
||||
- 轉檔 API 真實對接
|
||||
- Billing
|
||||
- Observability(Prometheus / 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-02:DB schema(users / sessions)
|
||||
- TODO-AUTH-03:Email 驗證流程
|
||||
- TODO-AUTH-04:密碼重設流程
|
||||
- TODO-AUTH-05:OAuth(Google / GitHub)
|
||||
- TODO-AUTH-06:2FA
|
||||
- TODO-AUTH-07:密碼強度規則
|
||||
- TODO-AUTH-08:Rate limiting
|
||||
- TODO-AUTH-09:Account 刪除
|
||||
- TODO-AUTH-10:個人設定頁完整功能
|
||||
- TODO-AUTH-11:Role / Permission
|
||||
- TODO-AUTH-12:API Key 管理
|
||||
|
||||
**Storage 相關**:
|
||||
|
||||
- TODO-STO-01:S3/MinIO 實作
|
||||
- TODO-STO-02:Presigned URL 上傳
|
||||
- TODO-STO-03:模型版本管理
|
||||
- TODO-STO-04:檔案掃毒
|
||||
|
||||
**Session / Tunnel 相關**:
|
||||
|
||||
- TODO-SESS-01:Redis SessionStore 實作
|
||||
- TODO-SESS-02:Session Token rotation
|
||||
- TODO-SESS-03:Session 撤銷功能
|
||||
- TODO-SESS-04:多節點 remote-proxy + consistent hashing
|
||||
- TODO-SESS-05:Tunnel 斷線事件通知優化
|
||||
|
||||
**Pairing 相關**:
|
||||
|
||||
- TODO-PAIR-01:local-tool 內建 Pairing UI
|
||||
- TODO-PAIR-02:QR code 生成
|
||||
- TODO-PAIR-03:Pairing 成功後的使用者引導
|
||||
|
||||
**Converter 相關**:
|
||||
|
||||
- TODO-CONV-01:正式對接 converter API
|
||||
- TODO-CONV-02:Webhook 簽章驗證
|
||||
- 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-01:Prometheus metrics
|
||||
- TODO-OBS-02:Grafana dashboard
|
||||
- TODO-OBS-03:OpenTelemetry tracing
|
||||
- TODO-OBS-04:Log 聚合(ELK / Loki)
|
||||
|
||||
**部署**:
|
||||
|
||||
- TODO-DEP-01:真的雲端部署(staging + production)
|
||||
- TODO-DEP-02:TLS 憑證
|
||||
- TODO-DEP-03:DNS / Subdomain
|
||||
- TODO-DEP-04:CDN(前端)
|
||||
- TODO-DEP-05:CI/CD pipeline
|
||||
|
||||
**商業化(Phase 2)**:
|
||||
|
||||
- TODO-BIZ-01:Billing 介面 + Stripe 整合
|
||||
- TODO-BIZ-02:定價方案設計
|
||||
- TODO-BIZ-03:Terms / Privacy Policy
|
||||
- TODO-BIZ-04:GDPR / 台灣個資法合規
|
||||
|
||||
---
|
||||
|
||||
## 10.3 Phase 1 MVP(2026 Q3,外部早期採用者)
|
||||
|
||||
### 10.3.1 目標
|
||||
|
||||
- 接真 Auth(JWT + DB)
|
||||
- 接真 Storage(S3 或 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 真實對接)
|
||||
- Billing(Stripe)
|
||||
- 多租戶 / 團隊功能
|
||||
- Multi-region 部署
|
||||
- 公開 API(for Mike 這類獨立開發者)
|
||||
- Mobile app(read-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
|
||||
- 叢集推論產品化
|
||||
- 部署到雲端
|
||||
|
||||
### Later(6-12 個月,Phase 2+)
|
||||
|
||||
- 商業化 Billing
|
||||
- 轉檔整合
|
||||
- Mobile + 公開 API
|
||||
- 多區域
|
||||
|
||||
---
|
||||
|
||||
## 連結
|
||||
|
||||
- 上一章:[成功指標](success-metrics.md)
|
||||
- 下一章:[風險與相依](risks.md)
|
||||
- 跳回:[PRD 索引](PRD.md)
|
||||
174
docs/autoflow/02-prd/strategy.md
Normal file
174
docs/autoflow/02-prd/strategy.md
Normal 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 有什麼不同?**
|
||||
A:local-tool 是離線桌面 app,適合網路鎖死的 demo 場景;visionA Cloud 是雲端 SaaS,適合需要遠端操作、跨裝置管理的場景。**兩者共用同一套 UI 與核心功能**,使用者可以自由選擇或同時使用。
|
||||
|
||||
- **Q:和 Edge Impulse、SenseCraft 比?**
|
||||
A:他們是「訓練模型 + 部署到裝置」的通用平台,我們是「Kneron 專用 + 雲端遠端存取」的操作平台。我們不做模型訓練,只做已訓練好的 `.nef` 模型的部署與推論操作(但有提供對接 kneron_model_converter 的介面)。
|
||||
|
||||
- **Q:定價如何?**
|
||||
A:Phase 0 雛形階段免費內部使用。商業模式(訂閱 / 按推論次數 / freemium)在 Phase 2 規劃,Phase 0 只定介面不接金流。
|
||||
|
||||
- **Q:什麼時候可以用?**
|
||||
A:Phase 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 核心問題與價值主張
|
||||
|
||||
### 問題(用戶視角)
|
||||
|
||||
| 問題 | 現況 |
|
||||
|------|------|
|
||||
| P1:Kneron 裝置必須連實體筆電才能操作 | local-tool 是桌面 app,使用者人不在時沒辦法用 |
|
||||
| P2:多裝置沒有集中式管理 | 一個 FAE 手上可能有 3-5 台機台,要一台一台開 local-tool |
|
||||
| P3:POC 的叢集推論沒產品化 | 加權 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 OKR(Phase 0 雛形階段)
|
||||
|
||||
### Objective(O):建立 visionA Cloud 的技術基座與產品框架
|
||||
|
||||
- **KR1**:Phase 0 雛形 2026 Q2 完成,包含以下可驗收產出
|
||||
- visionA-frontend 所有 P0 頁面可打開、可操作(接 mock 或真 API)
|
||||
- visionA-backend 兩個 binary(api-server + remote-proxy)可起得來
|
||||
- 至少一個 local-tool 可透過 Pairing Token 連上 visionA Cloud,並跑通一次端到端推論
|
||||
- **KR2**:Phase 0 內部測試,至少 5 位 Kneron FAE 完成 Pairing + 推論測試
|
||||
- **KR3**:介面契約文件(interface-contracts.md)完成,與 converter 團隊對齊一次 API spec
|
||||
|
||||
### Objective(O):為 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 / 開發者工具,WAU(User)容易膨脹(人來註冊就算),但 WAD(Device)代表**真正在用**
|
||||
- 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)
|
||||
178
docs/autoflow/02-prd/success-metrics.md
Normal file
178
docs/autoflow/02-prd/success-metrics.md
Normal file
@ -0,0 +1,178 @@
|
||||
# 9. 成功指標 — visionA Cloud
|
||||
|
||||
> 父文件:[PRD.md](PRD.md)
|
||||
|
||||
---
|
||||
|
||||
## 9.1 北極星指標
|
||||
|
||||
### 長期(Phase 2+):每週活躍裝置數(WAD)
|
||||
|
||||
**定義**:過去 7 天內至少完成一次成功推論的已配對裝置數量。
|
||||
|
||||
**為什麼選這個**:
|
||||
|
||||
- B2B 開發者工具,WAU 容易膨脹(人來註冊就算),WAD(Device)代表真正有在用 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 |
|
||||
| 資料隔離 bug(A 看到 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)
|
||||
139
docs/autoflow/02-prd/user-research.md
Normal file
139
docs/autoflow/02-prd/user-research.md
Normal file
@ -0,0 +1,139 @@
|
||||
# 4. 用戶研究 — visionA Cloud
|
||||
|
||||
> 父文件:[PRD.md](PRD.md)
|
||||
|
||||
---
|
||||
|
||||
## 4.1 用戶 Persona
|
||||
|
||||
### Persona 1:阿哲 — Kneron FAE(主要用戶)
|
||||
|
||||
| 項目 | 內容 |
|
||||
|------|------|
|
||||
| 角色 | Kneron FAE(Field 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 2:Sarah — SI 系統整合商(主要用戶)
|
||||
|
||||
| 項目 | 內容 |
|
||||
|------|------|
|
||||
| 角色 | SI 技術主管(系統整合商)|
|
||||
| 年齡 / 背景 | 38 歲,電機本科,資深工程師轉管理 |
|
||||
| 工作內容 | 把 Kneron 導入客戶專案(例如零售店頭人流分析、工廠瑕疵檢測)|
|
||||
| 目標 | 讓客戶的 Kneron 部署順利運轉,減少現場支援 |
|
||||
| 痛點 | 1. 一個專案 3-10 台 Kneron 佈在不同地點,沒統一畫面<br>2. 客戶回報「裝置怪怪的」只能派人去現場<br>3. 模型改版要逐台更新 |
|
||||
| 行為模式 | 每週管 2-5 個專案,每個專案生命週期 3-12 個月 |
|
||||
| 技術素養 | 高,自己帶一個 3-5 人的工程團隊 |
|
||||
| 願意付費 | 願意(公司成本),但要有明顯 ROI(省出差費 / 人力)|
|
||||
| 一句話描述 | 「我希望能**一個儀表板看到所有客戶現場的 Kneron 狀態**,這樣我就可以**少派工程師出差**。」 |
|
||||
|
||||
### Persona 3:Mike — 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 1:local-tool 要內建「Pairing」UI;Phase 0:TODO,手動編輯 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 pricing(Phase 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」)
|
||||
|
||||
### 洞察 3:Pairing 是最大摩擦點
|
||||
|
||||
從「註冊」到「首次推論」的轉換漏斗中,**Pairing 那一步最容易掉用戶**。使用者要跨兩個介面(瀏覽器 + 筆電 local-tool),要複製貼上 token。
|
||||
|
||||
**Phase 0 的妥協**:允許 token 手動編輯 local-tool config 檔(給技術高素養用戶)。
|
||||
|
||||
**Phase 1 的理想**:local-tool 內建「配對到 visionA Cloud」按鈕,瀏覽器 callback 自動帶 token 過去。
|
||||
|
||||
### 洞察 4:SI 最在意的是「少派人出差」
|
||||
|
||||
對阿哲(FAE),核心價值是「自己少累一點」。對 Sarah(SI),核心價值是「團隊少派工程師出差」,這是**可量化的成本節省**。
|
||||
|
||||
**Phase 1 行銷素材**可以用這個角度:「每月省下 X 次出差 = 省 Y 元 + Z 天工程師時間」。
|
||||
|
||||
### 洞察 5:Mike 是次要但不能忽視的用戶
|
||||
|
||||
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)
|
||||
120
docs/autoflow/02-prd/user-stories.md
Normal file
120
docs/autoflow/02-prd/user-stories.md
Normal file
@ -0,0 +1,120 @@
|
||||
# 5. User Stories(RICE 排序)— 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 Stories(RICE 排序)
|
||||
|
||||
### 🔴 P0 — Phase 0 雛形必做
|
||||
|
||||
| # | Story | Persona | Reach | Impact | Conf. | Effort | RICE | 備註 |
|
||||
|---|-------|---------|-------|--------|-------|--------|------|------|
|
||||
| US-01 | 作為開發者,我要**在瀏覽器打開 visionA Cloud 並看到登入頁** | 全部 | 100 | 3 | 100% | 0.5 | 600 | Phase 0 雛形:UI only,auth stub |
|
||||
| US-02 | 作為開發者,我要**註冊帳號(雛形只要 email + 密碼)** | 全部 | 100 | 3 | 100% | 0.3 | 1000 | Phase 0:in-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 |
|
||||
|
||||
### 🔵 雛形介面 TODO(Phase 0 只做介面不接實作)
|
||||
|
||||
| # | Story | 雛形做什麼 | 未來要做什麼 |
|
||||
|---|-------|-----------|------------|
|
||||
| US-TODO-01 | 會員註冊 | 前端頁面 + 後端 stub handler | 接真 Auth(JWT / 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 stories(US-01 ~ US-13)
|
||||
|
||||
**明確排除**:
|
||||
|
||||
- 真實 Auth(用 stub)
|
||||
- 真實 DB(用 in-memory)
|
||||
- 真實 S3(用 local fs 實作 ObjectStorage 介面)
|
||||
- 叢集功能(Phase 1)
|
||||
- 儀表板(Phase 1)
|
||||
- 圖片/影片推論(Phase 1,Phase 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)
|
||||
452
docs/autoflow/03-design/components.md
Normal file
452
docs/autoflow/03-design/components.md
Normal file
@ -0,0 +1,452 @@
|
||||
# 元件庫清單與規格 — visionA Cloud
|
||||
|
||||
> **本元件庫 100% 沿用 `local-tool/frontend/src/components/`**,Frontend Agent 實作時直接搬(或整併到 monorepo shared package)。本文件整理既有元件清單與用途,並定義雲端版**新增的 4 個元件**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 元件分類總覽
|
||||
|
||||
| 類別 | 數量 | 來源 | 狀態 |
|
||||
|------|------|------|------|
|
||||
| 基礎 UI(Shadcn 風)| 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 variants(default / destructive / outline / secondary / ghost / link),8 sizes(xs / 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-14,border-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 DropdownMenu(shadcn 封裝)
|
||||
- Trigger:Button variant=ghost,size=default
|
||||
- Avatar:40px 圓形(`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(總長 36),TTL **15 分鐘**,一次性使用
|
||||
- Token 顯示區:`bg-muted font-mono text-xl tracking-wider p-4 rounded-md select-all`
|
||||
- 視覺切兩行(第 1 行 `vAc_` + 16 hex,第 2 行 16 hex);Mobile 降為 `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。
|
||||
225
docs/autoflow/03-design/design-review.md
Normal file
225
docs/autoflow/03-design/design-review.md
Normal 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 按鈕 | 加 Tooltip(shadcn 有 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 Sheet,Mobile 時漢堡選單開啟 |
|
||||
| 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 可考慮建立語義 typography(heading / body / caption)|
|
||||
| m6 | 沒有 Focus Trap 機制在自訂 Modal(shadcn 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 繞過 i18n(C3 / 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 階段補。
|
||||
309
docs/autoflow/03-design/design-spec.md
Normal file
309
docs/autoflow/03-design/design-spec.md
Normal file
@ -0,0 +1,309 @@
|
||||
# visionA Cloud — 設計規格(Design Spec)索引
|
||||
|
||||
| 項目 | 內容 |
|
||||
|------|------|
|
||||
| 專案代號 | visionA Cloud(`visionA-frontend`) |
|
||||
| 文件版本 | v0.1(Phase 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 ← 轉檔頁面 wireframe(Phase 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 設計範圍外(不做的事)
|
||||
|
||||
- 高保真 prototype(Figma 或 HTML)— 版型沿用 local-tool,沒有價值
|
||||
- 行銷 Landing Page — 不在雛形範圍
|
||||
- Onboarding Wizard — 雲端版的 onboarding 等 Phase 1 再細化
|
||||
- Mobile 響應式完整版 — 雛形只保證 Desktop,Tablet 可讀,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)
|
||||
|
||||
**基礎 UI(Shadcn 風)22 個**:Button、Card、Dialog、AlertDialog、Tabs、Input、Select、Badge、Slider、Progress、Checkbox、Label、Separator、ScrollArea、Sonner(toast)、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 字元 hex,TTL **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 加「轉檔」tab(Wand2 icon),單頁 `/conversion` 用 state 機切 `idle / uploading / processing / completed.success / completed.failed` 五個畫面。上傳走 visionA backend streaming proxy(XHR + 進度條),轉檔以 polling(5–10 秒)追狀態,完成後**半自動**讓使用者選「加到模型庫」或「下載」(兩按鈕互不互斥)。失敗時顯示翻譯後的錯誤訊息(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/ui(Radix 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 | **降級顯示**:能讀但不建議操作,顯示提示「建議使用桌面版」於關鍵頁面(如 Pairing、Workspace) |
|
||||
| Tablet | 640 – 1024px | **可用**:Sidebar 折疊為 drawer,主內容區單欄 |
|
||||
| Desktop | 1024 – 1440px | **主要目標**:Sidebar 展開(w-60),主內容區自適應 |
|
||||
| Wide | > 1440px | 最大寬度限制 max-w-7xl(1280px)內容置中 |
|
||||
|
||||
**核心決策**:**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 關閉 Modal(Dialog 自動處理)
|
||||
- Skip link(全新頁面需要加)
|
||||
|
||||
### 8.3 ARIA 與語意
|
||||
|
||||
- 使用語意化 HTML(`<nav>`、`<main>`、`<aside>`、`<header>`)
|
||||
- 動態內容使用 `aria-live` 通知 Screen Reader(如 toast、裝置連線狀態變化)
|
||||
- 圖示只做裝飾時加 `aria-hidden="true"`
|
||||
- 表單 label 與 input 綁定
|
||||
|
||||
### 8.4 觸控目標(即使是桌面版)
|
||||
|
||||
- 最小點擊區域 32×32(shadcn 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` | 文字版 wireframe(5 個新增頁面) | ✅ 完成 |
|
||||
| `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 相容。
|
||||
272
docs/autoflow/03-design/design-tokens.md
Normal file
272
docs/autoflow/03-design/design-tokens.md
Normal 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`(14px),Button 預設 `rounded-md`(8px),Badge 預設 `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`
|
||||
- Button(shadcn):`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 toast(Sonner 內建) |
|
||||
| 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`
|
||||
- 持續時間:預設 Tailwind(150ms),無明確定義
|
||||
|
||||
---
|
||||
|
||||
## 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 Dialog(Modal)
|
||||
|
||||
- 背景:`--background`
|
||||
- 邊框:`--border`
|
||||
- 圓角:`rounded-lg`
|
||||
- 最大寬度:依 context,一般 `max-w-lg`(512px)
|
||||
- Overlay:黑色半透明(shadcn 預設)
|
||||
|
||||
### 3.6 Sidebar(layout)
|
||||
|
||||
- 寬度: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 hex(36 字元),視覺切兩行,複製為完整字串;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 自動切換。
|
||||
429
docs/autoflow/03-design/flows/flow-auth.md
Normal file
429
docs/autoflow/03-design/flows/flow-auth.md
Normal 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 串接 | ❌ 不做 | 做 |
|
||||
| OAuth(Google / 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 layout(ThemeSync / 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 state(Phase 1+)
|
||||
- 無 error state(Phase 1+)
|
||||
- 送出直接跳 `/`
|
||||
|
||||
### 3.4 鍵盤行為
|
||||
|
||||
- Tab 順序:Email → Password → 登入 → 建立新帳號 → 忘記密碼 → 語言
|
||||
- Email 或 Password focused 時 Enter → 提交表單
|
||||
- 「建立新帳號」:Enter / Space 導航到 `/register`
|
||||
- 語言切換:Space 開啟 Select,Arrow 選擇,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 最小化)
|
||||
|
||||
- Email:HTML5 `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 必須:
|
||||
|
||||
- 密碼 hash:bcrypt / argon2
|
||||
- Token:JWT(short-lived access + refresh)或 session cookie(httpOnly + 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/blocks(Authentication 分類)
|
||||
|
||||
**選擇 `auth-01` 或 `auth-04` 風格**(左側表單 + 右側品牌區或全螢幕置中)。雛形建議**全螢幕置中**(更簡潔)。
|
||||
|
||||
Phase 1 可升級為左右分欄 + 右側行銷訊息 / 圖片。
|
||||
|
||||
---
|
||||
|
||||
## 12. Phase 1+ TODO(完整清單)
|
||||
|
||||
| 項目 | 重要性 |
|
||||
|------|-------|
|
||||
| OAuth(Google、GitHub、Microsoft) | 高 |
|
||||
| Email 驗證流程 | 高 |
|
||||
| 密碼重設(Email 連結 → 重設頁)| 高 |
|
||||
| 密碼強度指示器 | 高 |
|
||||
| ToS / Privacy Policy 勾選 | 高(法規) |
|
||||
| Remember me 選項 | 中 |
|
||||
| 防機器人(reCAPTCHA / Turnstile) | 中 |
|
||||
| Rate limit 提示 UI | 中 |
|
||||
| 2FA(TOTP / WebAuthn / SMS)| 中(Phase 2)|
|
||||
| SSO for 企業(SAML)| 低(Phase 2+)|
|
||||
| Magic link(passwordless)| 低(Phase 2)|
|
||||
| 登入行為分析 / 異常偵測 | 低(Phase 2+)|
|
||||
550
docs/autoflow/03-design/flows/flow-conversion.md
Normal file
550
docs/autoflow/03-design/flows/flow-conversion.md
Normal 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 5–10 秒一次 | 不做 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 A:idle ──
|
||||
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 C:uploading(XHR 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 D:processing(polling)──
|
||||
loop 每 5–10 秒(分頁可見時)
|
||||
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 E:completed.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 token(server-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 0–100%)
|
||||
│
|
||||
├── 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,可改)
|
||||
- 選 chip(4 個 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; // 0–60 秒
|
||||
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
|
||||
- 顯示成功 Card(wireframe §7)
|
||||
- toast「轉檔完成」+ action「下載 .nef」(使用者直接 toast 點下載也 OK)
|
||||
|
||||
**「加到模型庫」分支:**
|
||||
|
||||
```
|
||||
1. 點按鈕 → 開 AlertDialog(含名稱輸入欄)
|
||||
- 預設 name = job.name 或 source filename stem + "_" + chip(lowercased)
|
||||
- 例: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 / 5xx(backend 還沒到 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 job(converter 自己 7 天 GC)
|
||||
|
||||
### 5.6 completed.failed — 顯示錯誤 + 重試引導
|
||||
|
||||
**進入時:**
|
||||
- 停止 polling
|
||||
- 顯示 failed Card(wireframe §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 job(409)
|
||||
|
||||
PRD §F3 D1:converter 端 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 imported;B 的 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/s):ETA 顯示「估計 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 的負擔**:5–10 秒/次/user。建議在 visionA backend 對同一個 jobId 做 2–3 秒 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; // 0–100
|
||||
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 狀態 attach;processing 不需要(離開不會中斷後端)。
|
||||
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、有 5–10 秒延遲(人眼可接受)|
|
||||
| 進階參數(FP16 等)| Phase 1 | Upload Dialog 沒有「進階」摺疊區 |
|
||||
| 模型版本 / A/B | Phase 2 | model.SourceJobID 已預埋,可追溯但 UI 不展示 |
|
||||
| Webhook(converter → visionA push)| Phase 0.8 純 polling | backend 不訂閱 |
|
||||
| 上傳離開頁面繼續跑 | Phase 1 | Dialog 關閉 = 上傳取消 |
|
||||
|
||||
---
|
||||
|
||||
## 10. KPI / 驗收與設計的對應
|
||||
|
||||
| KPI(PRD §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**。
|
||||
411
docs/autoflow/03-design/flows/flow-model-upload.md
Normal file
411
docs/autoflow/03-design/flows/flow-model-upload.md
Normal file
@ -0,0 +1,411 @@
|
||||
# 模型上傳流程 — visionA Cloud
|
||||
|
||||
> 雲端版新增流程。使用者上傳 Kneron 編譯後的 `.nef` 模型檔到雲端 object storage,之後才能選模型燒錄到遠端裝置。
|
||||
>
|
||||
> **技術背景**(給 Design 協作者):模型檔可能達 100MB,若走 tunnel / API server 會拖慢服務。Architect 決策走 **presigned PUT**:前端向後端要一組有期限的 PUT URL,前端直接把檔案 PUT 到 object storage(S3 / 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 URL(TTL 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) │
|
||||
│ │
|
||||
│ 模型即將進入安全掃描,完成後可燒錄 │
|
||||
│ │
|
||||
│ [完成] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 按「完成」關 Dialog,toast「✓ 模型 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 MB(62%) │
|
||||
│ │
|
||||
│ [取消] [重試] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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` 新增狀態 badge(Phase 0 簡單版,Phase 1 強化):
|
||||
|
||||
| 狀態 | Badge | 可操作 |
|
||||
|------|-------|--------|
|
||||
| uploading(僅上傳期間)| 🔵 上傳中 | 不顯示於列表(Dialog 內)|
|
||||
| scanning | 🟡 掃描中 | 不可燒錄 |
|
||||
| ready | 🟢 可用 | 可燒錄 |
|
||||
| rejected | 🔴 檢測失敗 | 可刪除、不可燒錄 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 流程 vs 頁面 — Dialog vs Page 決策
|
||||
|
||||
**採用:Dialog**(在 `/models` 觸發)
|
||||
|
||||
理由:
|
||||
- 雛形只支援單檔上傳,Dialog 夠用
|
||||
- 保留使用者所在頁面上下文,上傳完可以立刻看到列表
|
||||
- 獨立頁面 `/models/upload` 留到 Phase 1 支援多檔 / 佇列時再做
|
||||
|
||||
URL 不變(不用 router);Dialog 的開關用 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. TODO(Phase 1+)
|
||||
|
||||
| 項目 | 時機 |
|
||||
|------|------|
|
||||
| 多檔佇列上傳 | Phase 1 |
|
||||
| 分段上傳(resumable, > 100MB)| Phase 1 |
|
||||
| 背景上傳(Service Worker 持續)| Phase 2 |
|
||||
| 獨立 `/models/upload` 頁 | Phase 1(有佇列時)|
|
||||
| 上傳歷史 / 失敗重試列表 | Phase 2 |
|
||||
| 拖曳排序上傳順序 | Phase 2 |
|
||||
| 病毒掃描詳細進度 | Phase 1 |
|
||||
364
docs/autoflow/03-design/flows/flow-offline-handling.md
Normal file
364
docs/autoflow/03-design/flows/flow-offline-handling.md
Normal 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:28(2 分鐘前) │
|
||||
│ 所在電腦: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 interval:local agent ↔ 雲端 **10 秒 / 次**(全棧統一)
|
||||
- 掉線判定閾值:**3 次未收到心跳(30 秒)** 才標記為 offline,避免短暫抖動誤判
|
||||
- UI 呈現對應時機:
|
||||
- 第 1 次心跳 miss(10s 起):不做任何變化,避免閃爍
|
||||
- 第 2 次心跳 miss(20s 起):裝置狀態切為 🟡 `reconnecting`(重連中),卡片 `opacity-90`;**不**發 toast
|
||||
- 第 3 次心跳 miss(30s 達成):標記為 ⚪ `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+ |
|
||||
504
docs/autoflow/03-design/flows/flow-pairing.md
Normal file
504
docs/autoflow/03-design/flows/flow-pairing.md
Normal 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 Token(vAc_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 hex,TTL 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 → 複製下方 token,15 分鐘內到 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 Token(15 分鐘)。Local agent 一旦換到 Session Token(90 天),後續連線不受影響,使用者看不到這層
|
||||
|
||||
### 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 字元 hex,TTL 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 |
|
||||
518
docs/autoflow/03-design/pages.md
Normal file
518
docs/autoflow/03-design/pages.md
Normal file
@ -0,0 +1,518 @@
|
||||
# 頁面結構規格 — visionA Cloud
|
||||
|
||||
> 本文件逐頁列出所有頁面的版型、主要區塊、互動重點與雲端版改動。
|
||||
>
|
||||
> - **沿用頁面**:版型與行為 100% 沿用 local-tool,只改 API base URL 與新增遠端狀態顯示
|
||||
> - **雲端新增 stub**:Phase 0 雛形只做低保真骨架,Phase 1 完整化
|
||||
> - **雲端新增完整**:本次完整設計,工程師可直接實作
|
||||
|
||||
---
|
||||
|
||||
## 頁面總覽
|
||||
|
||||
| 路由 | 類型 | 主要元件 | Phase 0 狀態 |
|
||||
|------|------|---------|-------------|
|
||||
| `/` | 沿用 | Dashboard(Stats + 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)
|
||||
|
||||
**類型**:新增 stub(Phase 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 待辦
|
||||
|
||||
- OAuth(Google / 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)
|
||||
|
||||
**類型**:新增 stub(Phase 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 │ │
|
||||
│ └──────────────────────┘ └──────────────────────┘ │
|
||||
│ DeviceSettingsCard(alias / 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-compat),UI 不變
|
||||
- 空狀態使用既有 `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 runtime);Phase 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`):
|
||||
- 叢集名稱 + 狀態 Badge(idle / 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 (640–1024) | Desktop (≥ 1024) |
|
||||
|------|-----------------|-------------------|-----------------|
|
||||
| `/` | 降級(單欄)| 單欄 | 4 欄 Stats + 2 欄主內容 |
|
||||
| `/login` / `/register` | 可用 | 可用 | 可用 |
|
||||
| `/devices` / `/models` / `/clusters` | 單欄卡片 | 2 欄 | 3 欄 |
|
||||
| `/devices/pair` | 可用(步驟堆疊)| 可用 | 可用(步驟橫排)|
|
||||
| `/workspace/[deviceId]` | **不支援**(顯示提示「請用桌面版」)| 簡化(上下排)| 左右分欄 |
|
||||
| `/settings` | 單欄 Tab | 單欄 | 單欄(Tab 橫排)|
|
||||
|
||||
**雛形 Mobile 策略**:
|
||||
- Sidebar 改 Sheet(drawer),漢堡選單觸發
|
||||
- 非關鍵頁面能用,但不保證完整體驗
|
||||
- `/workspace/[deviceId]` 這類需要大畫面的,顯示:「建議使用桌面版以獲得最佳體驗」
|
||||
|
||||
---
|
||||
|
||||
## 12. 頁面載入策略
|
||||
|
||||
所有頁面:
|
||||
|
||||
| 載入階段 | 顯示 |
|
||||
|---------|------|
|
||||
| 0–200ms | 什麼都不顯示(避免閃爍) |
|
||||
| 200ms–資料回傳 | Skeleton(灰色塊,shimmer 動畫可選)|
|
||||
| 資料回傳 | 內容顯示 |
|
||||
| 資料錯誤 | 錯誤狀態 + 重試按鈕 |
|
||||
| 資料為空 | EmptyState |
|
||||
|
||||
Skeleton 使用 shadcn 慣例 `bg-muted animate-pulse`,已在 `device-detail-client.tsx` 示範。
|
||||
121
docs/autoflow/03-design/review/design-review-of-prd-tdd.md
Normal file
121
docs/autoflow/03-design/review/design-review-of-prd-tdd.md
Normal file
@ -0,0 +1,121 @@
|
||||
# Design 對 PRD + TDD 的交叉審閱
|
||||
|
||||
| 項目 | 內容 |
|
||||
|------|------|
|
||||
| 審閱者 | Design Agent |
|
||||
| 審閱日期 | 2026-04-21 |
|
||||
| 審閱對象 | PRD(v0.1)+ Design Doc / TDD(v0.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 < 500ms、heartbeat 30s(yamux);但 `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...`(前綴 + base62?),PRD 用 `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 key,TDD / 前端改造章節(§10)未提及 i18n 擴充工作。需要在前端任務清單加一項「新增 i18n key」。
|
||||
|
||||
---
|
||||
|
||||
## 4. 設計規格 vs PRD / TDD 的矛盾點
|
||||
|
||||
| # | 類別 | Design 規格寫的 | PRD / TDD 寫的 | 建議解法 |
|
||||
|---|------|----------------|--------------|---------|
|
||||
| **C1** | Token 格式 | 16 字元 hex,`a1b2c3d4 e5f6g7h8` 顯示 | PRD:`vAc_` + 32 hex;TDD:`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.md:30s 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]** 設計規格缺大檔上傳的進度 / 失敗 / 重試 UI(feature-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)與 PRD(32 hex)長度不同,導致 QR Code 尺寸需重估
|
||||
- **[m3]** TDD §10 未提 i18n key 擴充任務
|
||||
- **[m4]** WebSocket `/ws/pairing/status` 設計規格沒用,還在 polling(Step 3),建議改用 WS 即時
|
||||
- **[m5]** Reduce Motion 在 flow-offline-handling 的「重連倒數」需特別測試(不要閃爍)
|
||||
|
||||
---
|
||||
|
||||
## 6. 設計規格需修改清單
|
||||
|
||||
| 檔案 | 修改 |
|
||||
|------|------|
|
||||
| `flow-pairing.md` §4.1 / §4.5 | Token 格式改 `vAc_` + 32 hex,TTL 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. 通過後再進入雛形骨架
|
||||
|
||||
完整度達標才進入開發,可避免雛形一跑起來就遇到「規格對不上」的尷尬。
|
||||
721
docs/autoflow/03-design/visiona-agent-spec.md
Normal file
721
docs/autoflow/03-design/visiona-agent-spec.md
Normal 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-frontend(Web)| 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)| Icon(Lucide)|
|
||||
|------|---------------------|---------------|
|
||||
| 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 品牌 logo(3 平台各別規格) | — |
|
||||
|
||||
---
|
||||
|
||||
## 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 的圓,置中
|
||||
- **內容**:
|
||||
- 頂層:狀態 Icon(Lucide,`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 顯示 error,InfoCard 最下方紅色 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)
|
||||
- Loading:spinner
|
||||
- 成功:`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 dialog(Wails runtime `SaveFileDialog`)
|
||||
- 預設檔名:`visionA-agent-log-YYYYMMDD-HHmmss.zip`
|
||||
- 內容:壓縮最近 7 天的 log 檔
|
||||
- 匯出中:按鈕 loading;完成 toast「✓ 已匯出到 {path}」
|
||||
|
||||
#### 6.2.4 關於
|
||||
|
||||
- **版本**:`visionA Agent {version}`(從 `runtime.Environment` 取)
|
||||
- **檢查更新**按鈕:
|
||||
- Phase 0 stub:call 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 預設 Menu(App / 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 bar(Windows 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 | 依 DE(GNOME / KDE / XFCE)走預設 |
|
||||
| 檔案對話框 | GTK / Qt(Wails 依系統偵測) |
|
||||
| 啟動項 | `~/.config/autostart/visiona-agent.desktop` |
|
||||
| Log 路徑 | `~/.config/visionA-agent/logs/`(XDG 規範)|
|
||||
| 打包 | AppImage |
|
||||
|
||||
### 7.4 共通原則
|
||||
|
||||
- **不用** hover-only 操作(觸控筆電也要能用 — 所有資訊 hover 才顯示的 tooltip,都必須 focus 也顯示)
|
||||
- **不用** OS 特殊動畫 API(避免 Linux 裝了 compositor 死機)
|
||||
- **不用** 特殊字型(全跟 Geist,Wails 會 bundle)
|
||||
- **檔案路徑**顯示時一律 `font-mono text-xs`,避免字型不同導致錯位
|
||||
|
||||
---
|
||||
|
||||
## 8. Dark Mode
|
||||
|
||||
- 跟隨 OS:macOS `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:1;UI 元件 ≥ 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.js(Agent 是純 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」定位。**
|
||||
110
docs/autoflow/03-design/wireframes/wf-account.md
Normal file
110
docs/autoflow/03-design/wireframes/wf-account.md
Normal file
@ -0,0 +1,110 @@
|
||||
# Wireframe — `/account`
|
||||
|
||||
> 文字版 wireframe。Phase 0 單頁 stub,Phase 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 0(Mock 資料):**
|
||||
- `user.email`:從 `useAuthStore` 讀
|
||||
- `user.displayName`:從 `localStorage.displayName` 讀(若無,預設 Email 前綴)
|
||||
- 配對裝置列表:從 `useDeviceStore` 讀
|
||||
|
||||
**Phase 1:**
|
||||
- 改為 `GET /api/account/me` 拉真實資料
|
||||
- 裝置列表 `GET /api/devices?owner=me`
|
||||
|
||||
---
|
||||
|
||||
## 響應式
|
||||
|
||||
- Desktop:Card 單欄 `max-w-3xl`
|
||||
- Tablet:同 Desktop
|
||||
- Mobile:Card 撐滿,`px-4`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 TODO
|
||||
|
||||
拆成以下子路由:
|
||||
|
||||
| 子路由 | 內容 |
|
||||
|-------|------|
|
||||
| `/account/profile` | Email、顯示名稱、頭像、密碼變更 |
|
||||
| `/account/devices` | 已配對裝置管理 |
|
||||
| `/account/api-keys` | Personal Access Tokens(for CLI / API 使用) |
|
||||
| `/account/sessions` | 登入過的裝置 / 瀏覽器,可撤銷 |
|
||||
| `/account/preferences` | 語言、主題、通知偏好(與 /settings 合併?)|
|
||||
| `/account/billing` | 訂閱方案、付款方式、發票 |
|
||||
| `/account/danger` | 刪除帳號、匯出資料(GDPR) |
|
||||
|
||||
導航:Tabs 或 Sidebar 副選單皆可。
|
||||
177
docs/autoflow/03-design/wireframes/wf-clusters.md
Normal file
177
docs/autoflow/03-design/wireframes/wf-clusters.md
Normal 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 台裝置
|
||||
```
|
||||
110
docs/autoflow/03-design/wireframes/wf-login.md
Normal file
110
docs/autoflow/03-design/wireframes/wf-login.md
Normal 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 | 聚焦顯示 ring;Tab 移到 Password |
|
||||
| Password Input | 聚焦顯示 ring;Enter 提交表單 |
|
||||
| 登入 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 link:Phase 1+ 再加
|
||||
|
||||
---
|
||||
|
||||
## 連結到的 i18n
|
||||
|
||||
見 `flows/flow-auth.md` 第 3.6 節。
|
||||
246
docs/autoflow/03-design/wireframes/wf-pairing.md
Normal file
246
docs/autoflow/03-design/wireframes/wf-pairing.md
Normal 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 清單。
|
||||
77
docs/autoflow/03-design/wireframes/wf-register.md
Normal file
77
docs/autoflow/03-design/wireframes/wf-register.md
Normal 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 節。
|
||||
924
docs/autoflow/03-design/wireframes/wireframe-conversion.md
Normal file
924
docs/autoflow/03-design/wireframes/wireframe-conversion.md
Normal 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. 設計對齊備註
|
||||
|
||||
- **版型**:沿用既有 AppShell(Sidebar + 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**:所有文案都會走 i18n(zh-TW + en),key 命名空間 `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 進度條 0–100%) (兩種分支) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 狀態 | 觸發進入 | 觸發離開 |
|
||||
|------|---------|---------|
|
||||
| `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 天內可下載結果,過期自動清除 │ │
|
||||
│ │ • 轉檔約耗時 1–10 分鐘,依模型大小而定 │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 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` 畫面(見 §6),banner 加註「您離開前的轉檔仍在進行中」|
|
||||
|
||||
> 這個檢查也涵蓋「使用者開了第二個分頁」「重新整理」「離開後再回來」三種情境,**不需要前端額外狀態保存**。
|
||||
|
||||
---
|
||||
|
||||
## 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) [移除全部] │ │
|
||||
│ │ ▸ 縮圖 grid(hover 顯示 ✕ 個別移除) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ 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 │ │
|
||||
│ │ 通常需要 1–10 分鐘 · 你可以離開此頁面,回來時會自動更新進度 │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ℹ 你可以放著不管 │ │
|
||||
│ │ text-sm text-muted-foreground │ │
|
||||
│ │ • 我們會在背景持續查詢進度(每 5–10 秒一次) │ │
|
||||
│ │ • 完成後分頁標題會通知你 │ │
|
||||
│ │ • 此頁面關掉也沒關係,回來時會自動恢復 │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 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 秒 checksum:sha256: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. 點擊 → 按鈕短暫進 loading(spinner + 「準備下載…」)
|
||||
2. 觸發 navigation:window.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 / 5xx(backend 還沒到 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 檢查回 404(converter 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-full、CTA 全寬;「關於轉檔」摺疊 | Dialog 改 fullscreen-on-mobile(shadcn 預設行為)| stage indicator 改縱向堆疊(icon 圓點 + 標籤一行);Card padding `p-4` | 兩個 action card 改縱向堆疊(grid-cols-1) |
|
||||
| Tablet (640–1024) | 居中、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 → 轉檔約耗時 1–10 分鐘,依模型大小而定
|
||||
```
|
||||
|
||||
### 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 → 通常需要 1–10 分鐘 · 你可以離開此頁面,回來時會自動更新進度
|
||||
conversion.processing.background.title → 你可以放著不管
|
||||
conversion.processing.background.l1 → 我們會在背景持續查詢進度(每 5–10 秒一次)
|
||||
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:1(OK)
|
||||
- failed card:`bg-destructive/5 text-foreground` → > 10:1(OK)
|
||||
- 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 時(5–10 秒/次/user),記得 cache 個 2–3 秒避免 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 1),UI 位置我會放在 `processing` 畫面右上角的 menu(kebab)— 但這個 wireframe 不畫,避免雛形階段引入複雜度。
|
||||
|
||||
### 給 Frontend
|
||||
|
||||
1. 「上傳中」分頁標題更新(`conversion.uploading.tabTitle`)需要 `document.title = ...` 動態改;上傳完成或頁面卸載要還原。建議寫個小 hook `usePageTitle(title)`。
|
||||
2. Indeterminate progress bar:shadcn `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}
|
||||
每 5–10 秒一次
|
||||
分頁不可見時暫停
|
||||
end note
|
||||
note right of idle
|
||||
進入頁面先打 GET /jobs/active
|
||||
若有 active 直接落 processing
|
||||
end note
|
||||
```
|
||||
741
docs/autoflow/04-architecture/TDD.md
Normal file
741
docs/autoflow/04-architecture/TDD.md
Normal 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)
|
||||
- 內部 API(api-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/ # 共用的 types(request/response schema)
|
||||
│ ├── docker/
|
||||
│ │ ├── Dockerfile.api-server
|
||||
│ │ ├── Dockerfile.remote-proxy
|
||||
│ │ └── docker-compose.yml
|
||||
│ ├── scripts/
|
||||
│ │ └── dev.sh
|
||||
│ └── .env.example
|
||||
│
|
||||
├── docs/ # 共用文件(選配)
|
||||
├── .autoflow/ # 文件管理(本文件所在)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### 1.2 Go module
|
||||
|
||||
- `module visiona-backend`(簡短、不含 github 路徑,避免未來搬 repo 要改)
|
||||
- Go 1.26(與 POC 一致)
|
||||
- 主要依賴:
|
||||
|
||||
```
|
||||
github.com/gin-gonic/gin
|
||||
github.com/gorilla/websocket
|
||||
github.com/hashicorp/yamux
|
||||
github.com/go-playground/validator/v10
|
||||
github.com/google/uuid
|
||||
github.com/aws/aws-sdk-go-v2 (可選)
|
||||
```
|
||||
|
||||
### 1.3 Frontend Node
|
||||
|
||||
- Node >= 20
|
||||
- npm / pnpm(沿用 local-tool 偏好)
|
||||
- 主要依賴沿用 local-tool:Next.js 16、React 19、TypeScript 5、Tailwind 4、Radix UI、Zustand 5、Lucide、Recharts、driver.js、Vitest + RTL
|
||||
|
||||
---
|
||||
|
||||
## §2 Backend 模組詳細
|
||||
|
||||
### 2.1 `internal/api`(api-server 用)
|
||||
|
||||
| 檔案 | 職責 |
|
||||
|------|------|
|
||||
| `router.go` | Gin router 組裝 |
|
||||
| `middleware/auth.go` | 從 request 解析身分並注入 `UserContext` 到 `gin.Context` |
|
||||
| `middleware/cors.go` | CORS(沿用 local-tool)|
|
||||
| `middleware/logger.go` | broadcaster logger(沿用 local-tool)|
|
||||
| `handlers/device.go` | `/api/devices/*` — 大多轉發到 local agent |
|
||||
| `handlers/model.go` | `/api/models/*` — 雲端存模型 + 轉發到 local agent 載入 |
|
||||
| `handlers/cluster.go` | `/api/clusters/*` — 從 POC 搬 |
|
||||
| `handlers/inference.go` | `/api/devices/:id/inference/*` |
|
||||
| `handlers/pairing.go` | `/api/pairing/*` |
|
||||
| `handlers/auth.go` | `/api/auth/*`(雛形 501)|
|
||||
| `handlers/converter.go` | `/api/converter/*`(雛形 stub)|
|
||||
| `handlers/storage.go` | `/storage/*`(雛形 LocalFS 代理)|
|
||||
| `ws/devices.go`, `ws/inference.go` 等 | WebSocket upgrade + 轉發到 tunnel |
|
||||
| `forward.go` | 核心:把瀏覽器請求透過 tunnel 送到 local agent(參考 POC `handleProxy`)|
|
||||
|
||||
### 2.2 `internal/auth`(2026-04-22 M-3 修訂:介面雙層 AuthService + AuthProvider)
|
||||
|
||||
Auth 切分為兩層 interface,對應不同呼叫場景(詳見 `security.md` §2.0):
|
||||
|
||||
| 介面 | 層級 | 檔案 |
|
||||
|------|------|------|
|
||||
| `AuthService` | Middleware 層(每個 request 進來時解析身分)| `service.go` |
|
||||
| `AuthProvider` | Handler 層(登入 / 註冊 / 登出 / token 驗證)| `provider.go` |
|
||||
|
||||
```go
|
||||
// service.go(middleware 層 — 既有)
|
||||
package auth
|
||||
|
||||
type AuthService interface {
|
||||
Authenticate(ctx context.Context, req *http.Request) (*UserContext, error)
|
||||
Authorize(ctx context.Context, userCtx *UserContext, resource, action string) error
|
||||
}
|
||||
|
||||
type UserContext struct {
|
||||
UserID string
|
||||
Email string
|
||||
Roles []string
|
||||
OrgID string
|
||||
}
|
||||
|
||||
// static.go — 雛形(⚠️ OB5 已移除;以下程式碼僅作歷史紀錄)
|
||||
// 取代為 OIDC + cookie session,見 oidc-tdd.md §4
|
||||
type StaticAuthService struct {
|
||||
DefaultUser UserContext
|
||||
}
|
||||
func (s *StaticAuthService) Authenticate(...) { return &s.DefaultUser, nil }
|
||||
func (s *StaticAuthService) Authorize(...) { return nil }
|
||||
|
||||
|
||||
// provider.go(handler 層 — 2026-04-22 新增)
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
type AuthProvider interface {
|
||||
Register(ctx context.Context, req *RegisterRequest) (*User, error)
|
||||
Login(ctx context.Context, req *LoginRequest) (*LoginResult, error)
|
||||
Logout(ctx context.Context, token string) error
|
||||
ValidateToken(ctx context.Context, token string) (*UserContext, error)
|
||||
GetUser(ctx context.Context, userID string) (*User, error)
|
||||
}
|
||||
|
||||
type LoginRequest struct { Email, Password string }
|
||||
type RegisterRequest struct { Email, Password, Name string }
|
||||
type LoginResult struct {
|
||||
User *User
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
|
||||
// static_provider.go — 雛形(⚠️ OB5 已移除;以下程式碼僅作歷史紀錄)
|
||||
// 取代為 OIDC + cookie session,見 oidc-tdd.md §4
|
||||
type StaticAuthProvider struct {
|
||||
DemoUser *User // 預設 demo-user,User struct 見 database.md §2.1
|
||||
}
|
||||
|
||||
func (p *StaticAuthProvider) Login(ctx context.Context, req *LoginRequest) (*LoginResult, error) {
|
||||
// 無論 email / password 是什麼都通過
|
||||
return &LoginResult{
|
||||
User: p.DemoUser,
|
||||
AccessToken: "demo-access-token",
|
||||
RefreshToken: "demo-refresh-token",
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}, nil
|
||||
}
|
||||
func (p *StaticAuthProvider) Register(ctx, req) (*User, error) { return nil, ErrNotImplemented }
|
||||
func (p *StaticAuthProvider) Logout(ctx, token) error { return nil }
|
||||
func (p *StaticAuthProvider) ValidateToken(ctx, token) (*UserContext, error) {
|
||||
if token == "demo-access-token" {
|
||||
return &UserContext{UserID: p.DemoUser.ID, Email: p.DemoUser.Email}, nil
|
||||
}
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
func (p *StaticAuthProvider) GetUser(ctx, userID) (*User, error) {
|
||||
if userID == p.DemoUser.ID { return p.DemoUser, nil }
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
|
||||
// pairing.go(不變)
|
||||
type PairingStore interface {
|
||||
Validate(ctx context.Context, token string) (*PairingInfo, error)
|
||||
MarkUsed(ctx context.Context, token string, deviceID string) error
|
||||
Create(ctx context.Context, userID string, ttl time.Duration) (plain string, info *PairingInfo, err error)
|
||||
Revoke(ctx context.Context, token string) error
|
||||
List(ctx context.Context, userID string) ([]*PairingInfo, error)
|
||||
}
|
||||
|
||||
type PairingInfo struct {
|
||||
TokenHash string
|
||||
UserID string
|
||||
DeviceID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
UsedAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
}
|
||||
|
||||
// static_pairing.go — 雛形
|
||||
type StaticPairingStore struct {
|
||||
Token string // 格式:vAc_[0-9a-f]{32}(見 security.md §1.3)
|
||||
UserID string
|
||||
}
|
||||
func (s *StaticPairingStore) Validate(ctx, token) (*PairingInfo, error) {
|
||||
if token != s.Token { return nil, ErrInvalidToken }
|
||||
return &PairingInfo{UserID: s.UserID, TokenHash: sha256hex(token)}, nil
|
||||
}
|
||||
func (s *StaticPairingStore) Create(...) (..., error) { return "", nil, ErrNotImplemented }
|
||||
```
|
||||
|
||||
**雛形實作**:
|
||||
- ~~`StaticAuthService`(middleware)永遠回 demo-user~~ → **OB5 已移除**,AuthMiddleware 只走 OIDC(cookie session → UserContext),詳見 [oidc-tdd.md](./oidc-tdd.md)
|
||||
- ~~`StaticAuthProvider`(handler)Login 任何帳密都通過~~ → **OB5 已移除**;POST /api/auth/login 一律 410 Gone,使用者改用 GET /api/auth/login redirect 到 Member Center
|
||||
- `StaticPairingStore` 對 env `VISIONA_PAIRING_TOKEN` 比對(格式必須是 `vAc_` + 32 hex)— 仍有效
|
||||
|
||||
**已完成**(OB1-OB5):OIDC 接 Innovedus Member Center 取代 StaticAuth。
|
||||
**未來擴展**:`PostgresPairingStore`、Redis-backed user session store(取代 in-memory)、Member Center webhook 接收 user 刪除/停用通知。
|
||||
|
||||
---
|
||||
|
||||
### 2.2.1 Pairing → Session Token 時序(M-2)
|
||||
|
||||
Phase 1 兩階段 token 完整流程見 `security.md` §1.2 與 `adr/adr-003-pairing-token.md`。關鍵原則:
|
||||
|
||||
- **Pairing Token**(`vAc_` + 32 hex):Web UI 產生,15 min TTL,**一次性使用**
|
||||
- **Session Token**(`vAs_` + 64 hex):agent 首次連 tunnel 時由 remote-proxy 換發,90 天 TTL,可撤銷
|
||||
- 換發發生在 `WS /tunnel/connect` 的 upgrade 階段(一次 DB transaction 完成 insert session + mark pairing used)
|
||||
- local agent 從首個 yamux control frame 收到 Session Token 後持久化儲存,之後**一律用 Session Token**
|
||||
|
||||
雛形階段為簡化:只用 Pairing Token 單階段比對,無 TTL、無升級、無撤銷,**僅適合 dev**。
|
||||
|
||||
### 2.3 `internal/session`
|
||||
|
||||
參見 [`tunnel.md`](tunnel.md#session-management)。核心 interface(2026-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 實作
|
||||
- 雛形不做 ORM,InMemory 用 `map + sync.RWMutex`
|
||||
- Phase 1:新增 `postgres_repository.go` 實作同 interface
|
||||
|
||||
### 2.5 `internal/relay`
|
||||
|
||||
- 從 POC `edge-ai-platform/server/internal/relay/server.go` 搬過來
|
||||
- 修改:
|
||||
- 把 `sessions map[string]*yamux.Session` 抽到 `SessionStore`
|
||||
- `handleTunnel` 先呼叫 `PairingStore.Validate(token)` 驗證後才接受
|
||||
- `handleProxy` 從 `SessionStore.Lookup(token)` 取 session(而非 map 直接取)
|
||||
- 不修改的部分:
|
||||
- `proxyWebSocket` 的 Hijack 邏輯
|
||||
- `isWebSocketUpgrade` 判斷
|
||||
- MJPEG streaming 的 `http.Flusher` 處理
|
||||
|
||||
### 2.6 `internal/tunnel`
|
||||
|
||||
- 從 POC `edge-ai-platform/server/internal/tunnel/client.go` 搬
|
||||
- **在雛形中這個模組主要是給測試 / 未來 headless tunnel-only agent 用**
|
||||
- local-tool 既有的雲端模式 opt-in 也會 import 這個 package(未來)
|
||||
- POC code 不大改;把 `localAddr` 等參數化
|
||||
|
||||
### 2.7 `internal/converter`
|
||||
|
||||
```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 token,pairing 是 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-level(local agent 上報)
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
前端顯示邏輯:
|
||||
|
||||
| 畫面位置 | 顯示依據 |
|
||||
|---------|---------|
|
||||
| Device 卡片主狀態 badge | **`remoteStatus`**(決定「雲端能不能操作它」)|
|
||||
| Device 卡片副狀態 | `status`(USB 是否插著)— 僅在 `remoteStatus === 'online'` 時顯示有意義 |
|
||||
| 「最後上線」時間 | `lastSeenAt`(tunnel 最後心跳) |
|
||||
| Inference / Flash 按鈕啟用條件 | `remoteStatus === 'online' && status === 'online'` |
|
||||
| 「重連中」loading | `remoteStatus === 'reconnecting'` |
|
||||
| 「離線,請檢查 local-tool」提示 | `remoteStatus === 'offline'` |
|
||||
|
||||
邏輯總結:前端永遠先看 `remoteStatus`(雲端是否可達),再看 `status`(USB 是否接著)。兩者獨立更新,前端要訂閱兩種事件:
|
||||
- `remoteStatus` 變化 → `WS /ws/devices/:id/remote-status`(來自 remote-proxy 轉發)
|
||||
- `status` 變化 → `WS /ws/devices/:id/status`(來自 local agent 的 USB 監聽事件)
|
||||
|
||||
### 10.6 local-tool-only 元件:雛形隱藏(Q6 裁決)
|
||||
|
||||
以下元件在 local-tool 有意義(它就是本機 server),在 visionA Cloud 無意義。**雛形階段前端隱藏**:
|
||||
|
||||
| 元件 | 原因 |
|
||||
|------|------|
|
||||
| `OnboardingDialog` | local-tool 首次啟動引導本機 server 設定;雲端版不需要 |
|
||||
| `ServerStatusDashboard` | 顯示本機 server 健康(port / process);雲端版換成 tunnel 連線狀態 |
|
||||
| `ServerLogViewer` | 顯示本機 server log;雲端版換成 device log(透過 tunnel 抓) |
|
||||
|
||||
**實作方式**:
|
||||
- 沿用 local-tool 同名元件但用 feature flag 控制:`NEXT_PUBLIC_APP_MODE=cloud` 時不掛載
|
||||
- Phase 1 視需求決定要替換、重寫,還是徹底移除
|
||||
|
||||
---
|
||||
|
||||
## §13 測試策略
|
||||
|
||||
### 13.1 雛形期
|
||||
|
||||
| 類型 | 範圍 | 工具 |
|
||||
|------|------|------|
|
||||
| Unit | `internal/*` 各 package | `go test` |
|
||||
| Integration | api-server + remote-proxy(兩 binary)+ stub tunnel client | `go test` + httptest |
|
||||
| E2E | 瀏覽器 → api-server → remote-proxy ← POC edge-ai-server(tunnel client) | 手動 + 簡易 Playwright |
|
||||
|
||||
**雛形端對端驗證路徑**(Q3 決策):
|
||||
|
||||
```
|
||||
┌────────────────────┐ HTTPS ┌────────────────┐ internal HTTP ┌────────────────┐
|
||||
│ visionA-frontend │ ──────────────► │ api-server │ ──────────────► │ remote-proxy │
|
||||
│ (browser) │ │ (stateless) │ ◄────────────── │ (stateful) │
|
||||
└────────────────────┘ └────────────────┘ └────────┬───────┘
|
||||
│ WS + yamux
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ POC │
|
||||
│ edge-ai-server │
|
||||
│ (暫代 tunnel │
|
||||
│ client) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
雛形 **不實作** `visionA/local-agent/`。未來(Phase 1)建立時將從 `local-tool/` 複製起步。
|
||||
|
||||
雛形 MVP 覆蓋目標:
|
||||
- `internal/auth`, `internal/session`(含 `InMemoryStore` + `ProxyClientStore`), `internal/storage`(LocalFS):80% 行覆蓋
|
||||
- `internal/relay`(tunnel accept + internal forward)、`internal/tunnel`:整合測試為主
|
||||
- api handlers:至少 happy path + error path
|
||||
- **Internal HTTP API**(`/internal/session/:token`、`/internal/forward/*`):雛形必須有整合測試
|
||||
|
||||
### 13.2 Phase 1
|
||||
|
||||
- E2E:Playwright 跑主要使用者旅程
|
||||
- 壓測:vegeta 打 api-server;自製 tunnel client 模擬 N 條連線打 remote-proxy
|
||||
- 混沌測試:隨機斷開 tunnel 驗證重連
|
||||
|
||||
---
|
||||
|
||||
## §14 TODO 清單(雛形暫不實作,但已記錄)
|
||||
|
||||
### Auth / Security
|
||||
- [ ] 真實 Auth(OIDC / 自建)
|
||||
- [ ] JWT / refresh token 機制
|
||||
- [ ] `/tunnel/connect` IP + token rate limit
|
||||
- [ ] Pairing Token DB-backed 實作(`PostgresPairingStore`)
|
||||
- [ ] 兩階段 token(pairing short-lived + session long-lived)
|
||||
- [ ] Audit log
|
||||
- [ ] TLS 終止策略決定(ALB / Caddy / nginx)
|
||||
|
||||
### Data / Persistence
|
||||
- [ ] PostgreSQL + migration 機制(golang-migrate)
|
||||
- [ ] 所有 `InMemory*Repository` → `Postgres*Repository` 實作
|
||||
- [ ] 資料備援策略
|
||||
- [ ] Soft delete 行為標準化(`deleted_at`)
|
||||
|
||||
### Storage
|
||||
- [ ] `S3Store` 實作 + interface conformance tests
|
||||
- [ ] Multipart upload for > 100MB 檔案
|
||||
- [ ] Server-side encryption
|
||||
|
||||
### Scaling(Phase 1)
|
||||
- [ ] **多 `remote-proxy` 節點間的 session metadata 共享機制**(評估 Redis / gossip / service discovery — 雛形不預設方案;見 ADR-006)
|
||||
- [ ] api-server → proxy 跨節點路由邏輯(當一個 api-server 面對多個 proxy 節點時,需找到 session 所在節點)
|
||||
- [ ] mTLS / shared secret 保護 internal HTTP endpoints(目前僅靠內網隔離)
|
||||
|
||||
### 雛形 Phase 0 必做(為清楚起見明列)
|
||||
- [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 pipeline(lint, test, build, push)
|
||||
- [ ] Kubernetes Helm chart / Terraform module
|
||||
- [ ] Staging 環境
|
||||
- [ ] Blue/green or canary deploy
|
||||
- [ ] 備份與災難恢復演練
|
||||
|
||||
### Product
|
||||
- [ ] Billing / 訂閱(PRD 範疇)
|
||||
- [ ] 使用量統計
|
||||
- [ ] 多組織(Org / Team)支援
|
||||
- [ ] API key for programmatic access
|
||||
|
||||
### 待 PM / Design 確認
|
||||
- [ ] pairing token UX 細節(QR code?複製貼上?WebUSB?)
|
||||
- [ ] 未連線時前端呈現方式
|
||||
- [ ] 模型上傳上限(容量 / 個數)
|
||||
- [ ] 裝置離線時「離線模式」fallback 設計
|
||||
|
||||
---
|
||||
|
||||
## 版本記錄
|
||||
| 日期 | 版本 | 變更 |
|
||||
|------|------|------|
|
||||
| 2026-04-21 | 0.1 | Architect Agent 初稿(待三方交叉審閱)|
|
||||
| 2026-04-22 | 0.2 | 反映三方審閱 7 項使用者裁決 + ADR-006 |
|
||||
| 2026-04-22 | 0.3 | 反映三方交叉審閱結果:M-1 Token 格式統一 `vAc_`/`vAs_` hex、M-2 Token 兩階段 TTL 明確化 + 時序圖、M-3 Auth 介面雙層(AuthService + AuthProvider)、M-4 Converter API 確認 REST、M-5 心跳 10s/30s 統一、M-7 Q5 vs Multi-tab 澄清、M-8 Non-Goal 章節、Minor-2 dev-all-in-one 定位修正、Minor-3 Device 雙狀態 struct、Minor-4 SessionStore.CleanupExpired + ObjectStorage.Exists |
|
||||
96
docs/autoflow/04-architecture/adr/adr-001-two-binaries.md
Normal file
96
docs/autoflow/04-architecture/adr/adr-001-two-binaries.md
Normal file
@ -0,0 +1,96 @@
|
||||
# ADR-001:visionA-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 開一條 tunnel,session 綁在該 proxy 節點
|
||||
- 流量特性:長連線(小時~天)、資料量差異大(控制指令 vs MJPEG / 推論串流)
|
||||
- 負載模式:長連線 cost 主要在記憶體和 file descriptor,不是 CPU
|
||||
|
||||
POC(edge-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)
|
||||
|
||||
## 考慮過的替代方案
|
||||
|
||||
| 方案 | 優點 | 缺點 | 排除原因 |
|
||||
|------|------|------|---------|
|
||||
| **單 binary(API + Proxy 同一進程)** | 最簡單,session 直接記憶體共用 | 無狀態 API 被迫與有狀態 Proxy 綁死;API 無法獨立水平擴展;擴容成本與 tunnel 連線耦合 | 無法水平擴展 API,Production 不可行 |
|
||||
| **雙 module(兩個獨立 Go 專案)** | 完全隔離 | 共用程式碼(types、protocol、session interface)要拆到第三個 module 或手動複製 | 維護兩倍 go.mod,共用改動成本高,POC 已走過這條路 |
|
||||
| **三 binary(API + 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 Run),Remote Proxy 放在支援長連線的 stateful 環境
|
||||
- **擴展獨立**:API 隨流量 auto-scale;Proxy 隨 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 HTTP(2026-04-22)
|
||||
- [ ] 成本影響已評估(Phase 1 多節點時 session metadata 共享方案定案後再估)
|
||||
|
||||
## 相關文件
|
||||
- Design Doc §2(系統邊界與組件)
|
||||
- Design Doc §4(水平擴展策略)
|
||||
- TDD §1(專案骨架)、§2.3(session 模組)、§7(雛形啟動)
|
||||
- ADR-006(雛形不引入 Redis)
|
||||
- `api/api-internal.md`(internal HTTP API 規格)
|
||||
91
docs/autoflow/04-architecture/adr/adr-002-tunnel-protocol.md
Normal file
91
docs/autoflow/04-architecture/adr/adr-002-tunnel-protocol.md
Normal 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 特性
|
||||
- **可重連**:網路短暫斷線後自動恢復
|
||||
|
||||
POC(edge-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` adapter(binary 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、型別安全 | 要重新定義所有訊息 schema;POC 已用 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,需靠 TLS(WebSocket 外層)保密;Proxy 節點可以看到明文請求內容
|
||||
- **yamux 依賴**:hashicorp/yamux 已久無大版本更新,但 v0.x 穩定
|
||||
- **單 WebSocket = 單 tunnel**:一個 local agent 一條 WebSocket;若要跨地理區域 active-active,需應用層路由
|
||||
- **HOL blocking 風險低但存在**:yamux 是多工,但底層單一 TCP connection,若網路抖動整條都延遲
|
||||
|
||||
### 風險
|
||||
|
||||
- **WebSocket 升級失敗率**:企業防火牆 / 中間人代理可能阻擋 WebSocket upgrade。雛形先不處理 fallback(SSE / long-poll),TODO
|
||||
- **單條 WebSocket 頻寬極限**:若使用者端上傳 MJPEG 4K 60fps 會打滿一條 TCP。實測後若有問題再引入「多條 WebSocket + 分流」
|
||||
- **yamux 的 keepalive**:POC 用 default config,是否足夠穿越企業 idle timeout?TODO 壓測驗證
|
||||
|
||||
## 合規性
|
||||
|
||||
- [x] Architect 確認
|
||||
- [x] POC 實測驗證(`edge-ai-platform` 已部署到 EC2 + 本地測試)
|
||||
- [ ] 未來壓測:TODO
|
||||
|
||||
## 相關文件
|
||||
- Design Doc §3(資料流)
|
||||
- TDD §6(Tunnel 協定)
|
||||
- TDD §7(Session 管理)
|
||||
- POC 源碼:`edge-ai-platform/server/internal/{relay,tunnel}`、`edge-ai-platform/server/pkg/wsconn`
|
||||
191
docs/autoflow/04-architecture/adr/adr-003-pairing-token.md
Normal file
191
docs/autoflow/04-architecture/adr/adr-003-pairing-token.md
Normal file
@ -0,0 +1,191 @@
|
||||
# ADR-003:以 Pairing Token 取代 POC 的 SHA256(MAC) Token
|
||||
|
||||
## 狀態
|
||||
Accepted — 2026-04-21(2026-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 Token:vAc_ + 32 字元 hex (總長 36 字元;Admin-Credential)
|
||||
Session Token:vAs_ + 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` 內部呼叫)處理:
|
||||
- 查 DB:token 是否 `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 hex),agent 直接用它連 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 | 需外部 IdP(Auth0 / 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 log;Phase 1 要記到 audit log
|
||||
|
||||
### 風險
|
||||
|
||||
- **Token 儲存明文 vs hash**:產品版要把 `token_hash = sha256(token)` 存 DB,不存明文。雛形 env 暫用明文
|
||||
- **Token 透過 URL query 傳遞會進 access log**:POC 已這樣做;雛形保留;Phase 1 評估改用 `Authorization: Bearer xxx` header(WebSocket 自訂 header 瀏覽器有限制,但 local agent 可以)
|
||||
- **如果使用者把 token 貼到 public repo**:產品版需支援「自動撤銷洩漏 token」(secret scanning);雛形不處理
|
||||
|
||||
## 合規性
|
||||
|
||||
- [x] Architect 確認
|
||||
- [ ] Security Review:Phase 1 前必做
|
||||
- [ ] 加入 `.gitignore` 保證 token 不進 Git:工程師實作時落實
|
||||
|
||||
## 相關文件
|
||||
- Design Doc §6(安全架構)
|
||||
- TDD §5(Pairing Token 協定)
|
||||
- TDD `security.md`
|
||||
- 相關 ADR:ADR-002(Tunnel Protocol)、ADR-005(雛形不接 DB/Auth)
|
||||
145
docs/autoflow/04-architecture/adr/adr-004-storage-interface.md
Normal file
145
docs/autoflow/04-architecture/adr/adr-004-storage-interface.md
Normal 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、截圖**
|
||||
|
||||
POC(edge-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 下載一個物件,回傳 reader(caller 要負責 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** | 簡單 | 綁定 AWS;MinIO / 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` 已是 streaming,LocalFS 實作用 `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(儲存層介面)
|
||||
- 相關 ADR:ADR-005(雛形不接 DB / Auth)
|
||||
@ -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)
|
||||
|
||||
## Update(2026-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 部分仍有效**:雛形階段持續用 InMemoryRepository(Device / 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 §1(12-Factor 合規策略)
|
||||
- TDD §2(Backend 模組詳細)
|
||||
- TDD §4(資料模型)
|
||||
- 相關 ADR:ADR-003(Pairing Token)、ADR-004(Storage Interface)
|
||||
@ -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` binary,session 就是 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 定義與 codegen;HTTP/1.1 已夠用(localhost < 1ms);POC 也是用 HTTP 模式 | 收益不大,增加專案門檻 |
|
||||
| **Unix domain socket / shared memory** | 效能最好 | 只能 localhost;違反「雛形與 Production 拓撲一致」;部署限制多 | 不符合雲端部署 |
|
||||
|
||||
## 後果 (Consequences)
|
||||
|
||||
### 正面影響
|
||||
|
||||
- **雛形 ops 超簡單**:`docker-compose up` 起兩個 service(api-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 遺失
|
||||
- 緩解:雛形階段可接受;使用者需要重新 pair(Q5 裁決沿用 POC 覆蓋語意,pairing token 重連即可)
|
||||
- Phase 1:多節點部署 + 自動重連機制減緩影響
|
||||
|
||||
- **一次 internal HTTP hop(localhost)**
|
||||
- 每個走 tunnel 的 request 多 ~0.1-1ms(localhost loopback)
|
||||
- 對比如 MJPEG 串流這種長連線,hop 成本只有首次建立,可忽略
|
||||
- 一般 API 請求(< 200ms 預算)也可忽略
|
||||
|
||||
- **Phase 1 多節點時要解決的問題延後了**
|
||||
- 多個 `remote-proxy` 間如何共享 session metadata,雛形不決定
|
||||
- 風險:Phase 1 時可能需要非平凡的工程(例如實作 gossip)
|
||||
- 緩解:雛形階段列出候選方案(`tunnel.md` §5.4),Phase 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 / NetworkPolicy;Phase 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 裁決 C(2026-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.3(Session 模組 — 兩端實作差異)
|
||||
- `tunnel.md` §5(Session 管理 + Phase 1 候選方案)
|
||||
- `api/api-internal.md`(internal HTTP API 規格)
|
||||
@ -0,0 +1,80 @@
|
||||
# ADR-007:visionA 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)
|
||||
|
||||
**採方案 2:fork 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 frontend(Next.js 多頁 UI)整包刪除,改為極簡 Vite + React + shadcn SPA(3 頁)
|
||||
- **改造**:`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
|
||||
- **打包獨立**:兩個產品各自有 installer,Bundle 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 文件:TODO(Phase 1 前補)
|
||||
|
||||
## 相關文件
|
||||
|
||||
- `progress.md` visionA 產品線 4 大核心原則
|
||||
- `.autoflow/04-architecture/visiona-agent-tdd.md` §2-§3
|
||||
- ADR-008(tunnel client 複用策略)
|
||||
- ADR-009(token 儲存策略)
|
||||
147
docs/autoflow/04-architecture/adr/adr-008-tunnel-client-reuse.md
Normal file
147
docs/autoflow/04-architecture/adr/adr-008-tunnel-client-reuse.md
Normal file
@ -0,0 +1,147 @@
|
||||
# ADR-008:Tunnel Client 採「程式碼複製」策略在多個專案間共享
|
||||
|
||||
## 狀態
|
||||
Accepted — 2026-04-22(v1)
|
||||
**Updated** — 2026-04-22(v2,補充 §「visionA-backend 端 tunnel package 應刪除」)
|
||||
|
||||
## 背景 (Context)
|
||||
|
||||
Tunnel client(WebSocket + 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 client(agent 出站連線)完全相反
|
||||
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 到雲端 relay,accept 反向 stream 並轉發到本機 HTTP server | outbound(agent 主動連出)|
|
||||
| ~~`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)** | 語法乾淨 | 要求有共同的 module,agent 和 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 change(HTTP/3 / gRPC 切換 / E2E 加密)
|
||||
- Consumer 增加到 4+ 個(例如出現 headless tunnel-only agent)
|
||||
- 實際出現 2+ 次的「bug 只修了 A 忘了修 B」事件
|
||||
|
||||
## 合規性
|
||||
|
||||
- [x] 與 Q2 決策(POC → visionA-backend 複製)一致
|
||||
- [x] 與 ADR-007(fork 模式)一致
|
||||
- [ ] 建立 tunnel 變更 checklist:TODO(在第一次實際跟進 local-tool 或 backend 變動時產出)
|
||||
|
||||
## 相關文件
|
||||
|
||||
- `.autoflow/04-architecture/tunnel.md`
|
||||
- ADR-002(沿用 POC tunnel 協定)
|
||||
- ADR-007(visionA 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 直接複製)
|
||||
136
docs/autoflow/04-architecture/adr/adr-009-token-storage.md
Normal file
136
docs/autoflow/04-architecture/adr/adr-009-token-storage.md
Normal file
@ -0,0 +1,136 @@
|
||||
# ADR-009:visionA 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 / libsecret(GNOME 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-GCM:nonce 隨機,認證標籤保護完整性
|
||||
// 明文結構:
|
||||
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 可能沒 daemon;CGO 編譯複雜度 | 雛形先簡化,分階段 |
|
||||
| **只做 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 distro(WSL2 / Flatpak)machineID 取得差異**:TODO 在 Phase 0 測試三大 distro(Ubuntu / Fedora / Arch)
|
||||
|
||||
## 雛形的明確限制(寫給未來)
|
||||
|
||||
- 雛形不做 token rotation(Session Token 90 天內一直用同一個;過期才換)
|
||||
- 雛形不做「kill switch」(強制撤銷所有 agent)— 需 Phase 1 backend 支援
|
||||
- 雛形不做使用者提示「你的 token 在這個檔案裡」— 太技術、嚇人;Phase 1 再評估
|
||||
|
||||
## 合規性
|
||||
|
||||
- [x] 雛形不弱於明文檔案
|
||||
- [ ] Security review:Phase 1 上線前必做
|
||||
- [ ] Phase 1 遷移工具:TODO
|
||||
|
||||
## 相關文件
|
||||
|
||||
- `.autoflow/04-architecture/security.md` §9(Secret 管理)
|
||||
- `.autoflow/04-architecture/visiona-agent-tdd.md` §9(Token 儲存策略)
|
||||
- ADR-003(Pairing Token 兩階段)
|
||||
186
docs/autoflow/04-architecture/adr/adr-010-oidc-bff.md
Normal file
186
docs/autoflow/04-architecture/adr/adr-010-oidc-bff.md
Normal file
@ -0,0 +1,186 @@
|
||||
# ADR-010:OIDC 接入策略 — 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.js(App Router),目前 token 存 localStorage(已標為 Phase 1 必還的安全債)
|
||||
- visionA-backend 是 Go(Gin),目前無 cookie session 機制
|
||||
- Member Center 強制 OAuth client 必須是 confidential(要求 client_secret)— **這就排除了 SPA + PKCE**
|
||||
- Member Center 雛形 OAuth client 註冊機制有 `usage` 欄位 limitation(無 `web_app` 類型,需用 `webhook_outbound` 暫代)
|
||||
- visionA Agent(local-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.1(PKCE 是 Authorization Code 的 RFC 7636 強化)
|
||||
- `code_challenge_method=S256`
|
||||
- 三個隨機值(PKCE verifier、CSRF state、OIDC nonce)由 backend 產生並存 server-side pending session
|
||||
|
||||
#### 2. Pattern:BFF(Backend-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` cookie(HttpOnly + Secure + SameSite=Lax)
|
||||
|
||||
#### 3. Identity Provider:Innovedus Member Center
|
||||
|
||||
- 不用 Auth0 / Cognito / Clerk — 集團內部解,跨產品 SSO
|
||||
- dev 環境:直接用真 Member Center(docker-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 rotation(Member Center 暫無),但因為 cookie 7 天 TTL + 24h idle,使用者不會頻繁重登
|
||||
- WS 連線 cookie 自動帶(同 domain),不需 querystring
|
||||
|
||||
## 考慮過的替代方案
|
||||
|
||||
### 方案 A:SPA + PKCE(public client,frontend 持 token)
|
||||
|
||||
| 項目 | 評估 |
|
||||
|------|------|
|
||||
| 可行性 | ❌ Member Center 強制 confidential client,無法做 |
|
||||
| 優點 | backend 簡單;標準 SPA 模式 |
|
||||
| 缺點 | Token 在 browser,被 XSS 偷的風險高;MC 不支援 |
|
||||
| 排除原因 | **Member Center 不支援 public client,硬性排除** |
|
||||
|
||||
### 方案 B:Auth0 / Cognito / Clerk(第三方 vendor)
|
||||
|
||||
| 項目 | 評估 |
|
||||
|------|------|
|
||||
| 優點 | 現成 UI、社交登入、MFA、密碼重設都做好 |
|
||||
| 缺點 | Vendor lock-in;MAU 增加成本線性上升;跨 Innovedus 產品 SSO 需要他們的企業方案;資料外流到第三方 |
|
||||
| 排除原因 | **與「跨 Innovedus 產品線統一 SSO」目標衝突;長期成本與資料治理風險** |
|
||||
|
||||
### 方案 C:自刻 OAuth + JWT
|
||||
|
||||
| 項目 | 評估 |
|
||||
|------|------|
|
||||
| 優點 | 完全掌控 |
|
||||
| 缺點 | 要自己做密碼重設、email 驗證、2FA、暴力破解防禦;維運成本高;安全風險自承 |
|
||||
| 排除原因 | **重複造輪子;Member Center 已存在且專為此而生** |
|
||||
|
||||
### 方案 D:繼續用 StaticAuthProvider
|
||||
|
||||
| 項目 | 評估 |
|
||||
|------|------|
|
||||
| 優點 | 不用做事 |
|
||||
| 缺點 | 無法多用戶測試;無法進 Phase 1 |
|
||||
| 排除原因 | **Phase 0.6 的目的就是要升級** |
|
||||
|
||||
### 方案 E:Implicit Flow(OAuth 2.0 舊版)
|
||||
|
||||
| 項目 | 評估 |
|
||||
|------|------|
|
||||
| 優點 | 簡單,無需 token endpoint |
|
||||
| 缺點 | OAuth 2.1 已 deprecated;token 直接在 URL fragment,安全性差 |
|
||||
| 排除原因 | **業界共識:不要用 Implicit Flow** |
|
||||
|
||||
### 方案 F:BFF 但 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(接入路線 = B:OAuth Redirect + Authorization Code + PKCE)
|
||||
- [x] 使用者裁決 Q4(完全取代 StaticAuth,不保留 dev fallback)
|
||||
- [x] 使用者裁決 Q5(Agent 不動,Pairing 流程不變)
|
||||
- [x] 使用者裁決 Q6(BFF Pattern)
|
||||
- [x] 使用者裁決 Q7(dev 直接用真 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.2(AuthService + AuthProvider 雙層 interface)
|
||||
|
||||
## 版本記錄
|
||||
|
||||
| 日期 | 版本 | 變更 |
|
||||
|------|------|------|
|
||||
| 2026-04-26 | 1.0 | 初版 — 反映 Phase 0.6 七個議題的使用者裁決 |
|
||||
145
docs/autoflow/04-architecture/adr/adr-011-supersede-adr-005.md
Normal file
145
docs/autoflow/04-architecture/adr/adr-011-supersede-adr-005.md
Normal 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-005(2026-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.6(2026-04-26),情況變了:
|
||||
- 雛形已交付(Phase 0 + Phase 0.5 全綠),核心架構通過驗證
|
||||
- 需要進入 Phase 1(多用戶、上線給 FAE 試用)— 沒真實 user 寸步難行
|
||||
- 同期間 Innovedus 集團另一條線 **Member Center 已可用**(C# .NET Core + OpenIddict + PostgreSQL,70% 完成度,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`
|
||||
- 給未來新增備援 provider(Phase 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 Center(docker-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 + Postgres(docker-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 webhook(user 刪除 / 停用)未實作 | 雛形不接收;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] 使用者裁決 Q1(OIDC + 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 處理 |
|
||||
@ -0,0 +1,98 @@
|
||||
# ADR-012:Pending 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 欄位清空。生命週期長(雛形 24h,Phase 1 設計 7d)。
|
||||
|
||||
`oidc-tdd.md §4.5` 的原始設計示意了**兩個獨立 cookie**:
|
||||
- `visiona_pending_sid`(短 TTL,10 分鐘)
|
||||
- `visiona_session`(長 TTL,登入後寫入)
|
||||
|
||||
雛形實作(OB2 / OB4)為了減少 cookie 數量、簡化 handler 與 store 邏輯,**改採合一**:兩種 session 共用同一個 `visiona_session` cookie + 同一個 `usersession.Store` record,由 `Session.UserID` 是否為空來區分階段。
|
||||
|
||||
OB5 review(2026-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 是否為空
|
||||
- 空 → 401(pending 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 fixation(OWASP 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 key,Redis 命名空間 / 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-A1(RotateSessionID)、Fix-A2(本 ADR + middleware 註解)
|
||||
- 相關 review:`.autoflow/05-implementation/review/oidc-G5-OB1-OB6-review.md` Major-3
|
||||
@ -0,0 +1,194 @@
|
||||
# ADR-013:OIDC 支援 Public PKCE-only Client(ClientSecret 變選填)
|
||||
|
||||
## 狀態
|
||||
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-service(confidential)| `<see stage .env.stage>` | `<see stage .env.stage, never commit>` | client_credentials grant — visionA-backend 直接打 MC API(Phase 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.1(RFC 9700 BCP)+ RFC 8252 對「無法安全儲存 client_secret 的 client」(SPA、native app、且本案 — 雖然 visionA-backend 是 server,但 MC 把它分類為 redirect-flow client)的標準做法:
|
||||
|
||||
1. **PKCE(RFC 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 flow(client_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 設計** |
|
||||
|
||||
### 方案 B:visionA 自帶一組 client_secret,硬塞 MC
|
||||
|
||||
| 項目 | 評估 |
|
||||
|------|------|
|
||||
| 優點 | — |
|
||||
| 缺點 | MC 端不認;token endpoint 會 401(unauthorized client) |
|
||||
| 排除原因 | **技術上跑不起來** |
|
||||
|
||||
### 方案 C:dev 用 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-only(confidential 主要留給 server-to-server)
|
||||
- **向下相容** — dev 既有流程零改動
|
||||
- **預留 service client 鉤子** — Phase 1 接 MC API(client_credentials)時 config 欄位已經在,不必再改 ADR
|
||||
|
||||
### 負面影響(接受的取捨)
|
||||
|
||||
- **失去一層防護**:public mode 沒了 client_secret,token endpoint 對「冒充 visionA client_id 的攻擊者」沒防護。緩解:PKCE 守住主要威脅(code interception);Member 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 review;secret 本身對 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 client(progress.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 7636(PKCE)、RFC 9700(OAuth 2.0 BCP)、RFC 8252(Native Apps BCP)
|
||||
|
||||
## 版本記錄
|
||||
|
||||
| 日期 | 版本 | 變更 |
|
||||
|------|------|------|
|
||||
| 2026-05-01 | 1.0 | 初版 — 反映 stage MC public PKCE-only client 配給;ClientSecret 變選填;預留 ServiceClient 欄位 |
|
||||
@ -0,0 +1,232 @@
|
||||
# ADR-014:visionA 端轉檔功能架構(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-only;service client 仍為 confidential)
|
||||
- 並存:本 ADR 規範「visionA-backend 同時當 multipart streaming proxy(upload)+ delegated download token broker(download)」
|
||||
|
||||
## 背景 (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** 將部署到 AWS(stage 已上 `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)** 怎麼進 converter?browser 直連 vs visionA backend 中轉?
|
||||
2. **Download(轉檔結果)** 怎麼出 FAA?browser 直連 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 短(5–15 分鐘),可給 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 token(browser 直連)** 的非對稱設計,並把 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 token(scope `converter:job.write`),帶在 `Authorization: Bearer`
|
||||
3. 透傳 model file + ref_images[] + 其他 form fields(target_chip / 各 enable_* flag)
|
||||
4. converter response 整形後回 frontend(不直接洩 converter response shape)
|
||||
- converter **零修改** — 沿用既有 `POST /api/v1/jobs` multipart endpoint
|
||||
|
||||
### 2. Download — 多次性 → FAA delegated token(server-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 多次下載到不同 device,N 次跨 internet 流量燒不起
|
||||
- FAA 收到 token 後線上跟 MC validate(FAA 自己跟 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 token(scope `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」更安全**
|
||||
|
||||
| 面向 | 方案 X(frontend 拿 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 場景會自動 follow,response body 為 FAA 內容;但 anchor / navigation 場景 JS 完全看不到)|
|
||||
| 需要 FAA CORS 設定 | ✗ 需要(fetch / XHR 受 CORS 限制) | ✓ 不需要(CORS 只管 JS fetch / XHR;server-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 token,service token 仍需 `files:download.delegate` scope(沒變)。302 redirect 是「換到 token 後怎麼把它送進 browser」的差異,不影響 token issuance 路徑。
|
||||
|
||||
### 3. 半自動 — converter 完成後使用者選擇路徑
|
||||
|
||||
job `completed` 後 frontend 詢問 user:
|
||||
|
||||
| 動作 | 路徑 | 說明 |
|
||||
|------|------|------|
|
||||
| 「加到模型庫」 | visionA backend 跟 FAA pull NEF(server-to-server,scope `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 client(delegated 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 給 client);5xx 指數退避 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_id(converter 端的 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 code;5xx 一律 `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 fatal;5xx 退避 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 提示「你已有進行中的轉檔任務」。
|
||||
|
||||
## 考慮過的替代方案
|
||||
|
||||
### 方案 A:Upload 也走 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 成本可接受 |
|
||||
|
||||
### 方案 B:Download 也走 visionA backend 中轉
|
||||
|
||||
| 評估 | 內容 |
|
||||
|------|------|
|
||||
| 優點 | visionA-backend 看得到所有下載流量、易做 audit |
|
||||
| 缺點 | (1) 跨 internet 流量 N 倍(同 NEF 多次下載);(2) visionA-backend 變成 streaming bottleneck;(3) FAA delegated token 機制(已實作)白做 |
|
||||
| 排除原因 | **流量成本**;FAA 已具備 delegated token,不用浪費 |
|
||||
|
||||
### 方案 C:Upload + 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 其他 API(user 組織查詢 / 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 共用一個 cache,token 失效會同時影響轉檔與下載(MVP 階段可接受;後續可拆 cache)
|
||||
- **取消 job 不做**:user 一旦 init 就要等到 converter 自己跑完或 timeout(converter 端 expires_at 7 天)
|
||||
|
||||
### 風險
|
||||
|
||||
| 風險 | 緩解 |
|
||||
|------|------|
|
||||
| visionA-backend 處理 500MB upload 時記憶體爆 | 嚴格 streaming(io.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.130;prod 上線前需 converter 也上 AWS 或開 VPN tunnel — 列入 prod 上線 blocker |
|
||||
| 同 user 多 tab 各 init 一個 job → converter 409 | frontend 在 init 前先打 visionA backend 查當前 user 有無 active job;backend 直接拒絕第二次 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 CORS:Phase 0.8 採 server-side 302 redirect,**不需要** CORS 設定(仿 FAA TestSite `DownloadFileDirect` pattern)
|
||||
- [ ] MC 待確認:service client `23605e14a2c64660abd97e29963d8d58` 已授權 4 個 scope
|
||||
|
||||
## 相關文件
|
||||
|
||||
- 上位:`prd.md`(Phase 0.8 轉檔功能 PRD,PM 領地)
|
||||
- 同層:`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 |
|
||||
336
docs/autoflow/04-architecture/api/api-conversion.md
Normal file
336
docs/autoflow/04-architecture/api/api-conversion.md
Normal 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 | 走 cookie;frontend 用 `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_id,backend 會從 cookie 灌**):
|
||||
|
||||
| Field | Type | 必填 | 說明 |
|
||||
|-------|------|-----|------|
|
||||
| `model` | file | ✓ | `.onnx` / `.tflite`,≤ 500MB |
|
||||
| `ref_images[]` | file × N | — | 可 0–100 張,每張 ≤ 10MB |
|
||||
| `model_id` | text | ✓ | 1–65535,使用者自訂編號(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_chip:visionA 既有 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 tag,browser 原生處理 -->
|
||||
<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`)不適用 CORS;CORS 只管 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` shape;wireframe §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` 並重建 ownership(lazy 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` pattern;token 不過 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) |
|
||||
262
docs/autoflow/04-architecture/api/api-converter-contract.md
Normal file
262
docs/autoflow/04-architecture/api/api-converter-contract.md
Normal 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 URL,converter 完成後回呼
|
||||
|
||||
雛形先用 **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 URL,converter 自己下載 |
|
||||
| `source.checksum_sha256` | ✓ | visionA 計算好的 sha256,converter 下載後驗證 |
|
||||
| `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 id,converter 回傳時要帶上 |
|
||||
|
||||
**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:
|
||||
|
||||
**Request(converter → 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` record(source=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。
|
||||
232
docs/autoflow/04-architecture/api/api-internal.md
Normal file
232
docs/autoflow/04-architecture/api/api-internal.md
Normal 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 session(in-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/response(GET /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-server(internal)。
|
||||
|
||||
---
|
||||
|
||||
### 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/raw(B3 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)
|
||||
335
docs/autoflow/04-architecture/api/api-spec.md
Normal file
335
docs/autoflow/04-architecture/api/api-spec.md
Normal 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 polling,frontend 間隔 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
|
||||
|
||||
對會變大的 list(models、devices、jobs)用 cursor-based:
|
||||
|
||||
```
|
||||
GET /api/models?limit=50&cursor=...
|
||||
Response:
|
||||
{ "data": [...], "next_cursor": "..." | null }
|
||||
```
|
||||
|
||||
雛形可先簡單回全部(in-memory);Phase 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。
|
||||
343
docs/autoflow/04-architecture/build-deploy.md
Normal file
343
docs/autoflow/04-architecture/build-deploy.md
Normal file
@ -0,0 +1,343 @@
|
||||
# Build & Deploy
|
||||
|
||||
> 建置、本機開發、Docker 打包、部署的實務細節。
|
||||
|
||||
---
|
||||
|
||||
## 1. Makefile(visionA-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 的 process(in-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
|
||||
|
||||
# 方式 2:Docker 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 / middleware;Phase 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 / Postgres:managed service 或 StatefulSet
|
||||
- Ingress:nginx-ingress 或 cloud LB controller
|
||||
|
||||
**Cloud-agnostic 原則**:Helm chart 不綁特定雲,storage / DB 依 env 注入連線資訊。
|
||||
|
||||
---
|
||||
|
||||
## 8. CI/CD(Phase 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 Pipeline(Phase 1)
|
||||
|
||||
1. PR merged to `main`
|
||||
2. CI 跑 test + lint + build
|
||||
3. 產出 Docker image → push to registry(ECR / 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)
|
||||
- 不需要 Docker;Docker 檔案寫好以備 Phase 1
|
||||
- 不需要 CI;但 Makefile 有 `test` + `lint` 方便本機檢查
|
||||
|
||||
**Phase 1 必做**:
|
||||
- Docker image CI 產出
|
||||
- K8s / ECS manifest
|
||||
- Blue-green 或 rolling deploy
|
||||
- Staging 環境
|
||||
937
docs/autoflow/04-architecture/conversion.md
Normal file
937
docs/autoflow/04-architecture/conversion.md
Normal 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-Goals(Phase 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 job(streaming 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 client(pull 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 status;ownership 檢查後 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-type),service 內部處理 streaming。
|
||||
type InitJobInput struct {
|
||||
UserID string
|
||||
ContentType string // 含 boundary 的原始值
|
||||
Body io.Reader // request.Body
|
||||
ContentLength int64
|
||||
TargetChip string // "520" / "720"
|
||||
// 其他 form fields(model_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 body(content-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 token;cache 直到 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 // 預設 300(5 分鐘)
|
||||
}
|
||||
```
|
||||
|
||||
**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` store(in-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-memory;visionA-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 | 上傳 + 建 job(multipart 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 job(frontend pre-check);in-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.2);Phase 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 tag,browser 自動處理 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 header(browser memory,JS 看不到,除非開 devtools network 面板)→ 馬上被 browser 用來打 FAA 後消失。
|
||||
|
||||
---
|
||||
|
||||
## 4. Streaming proxy 設計(upload)
|
||||
|
||||
### 4.1 為什麼要 streaming
|
||||
|
||||
- 模型上限 500MB;ref_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 part:streaming 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 cancellation:handler 收到 client disconnect → ctx.Done() → goroutine 自動結束(pw.Close 觸發 reader EOF)
|
||||
5. 不做 ContentLength forward(converter 自己 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 完全部 bytes(XHR 進度 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 等到 T2(converter 回 201)才回 200,**不 early-return**。
|
||||
|
||||
| 屬性 | 選項 A:等 converter 201 才 200 ✓ 採用 | 選項 B:browser 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 |
|
||||
| C4:backend 偵測 converter 拒絕(4xx/5xx)| converter response | 立即回 frontend,不需特別 cleanup(converter 自己沒建 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**。實測上 converter(Phase 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 test(mock 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.Cancel(client 斷線)→ 連帶 cancel converter request(如上 cleanup 鏈);converter 端 multer 收到 socket close 會自動 abort multipart parsing
|
||||
|
||||
### 4.4 Timeout
|
||||
|
||||
- handler 整體不設總 timeout(500MB 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 個 scope(MC 端發單一 token 含全部)
|
||||
- `exp - 15s` 提前重取,避免下游使用時剛好過期
|
||||
- 併發保護:double-checked locking(5.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-Goals(Phase 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 後評估 |
|
||||
| Webhook(converter 完成 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`(內部 cleanup,Phase 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-backend;in-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 自身的 navigation(devtools 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 不適用 CORS(CORS 只管 JS fetch / XHR);server-side 302 redirect 是 browser 原生 navigation 行為
|
||||
|
||||
### 10.5 Race condition
|
||||
|
||||
- 同 user 同時兩 tab init → 第一個成功寫 ownership / converter 接受;第二個 pre-check 通過但 converter 409
|
||||
- 兩 tab 同時 promote-to-models → 第一個寫 model record 成功;第二個重複呼叫 ensurePromoted(cache 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 limit(Phase 1 補;對齊 `security.md` §4)
|
||||
- converter 端有 process semaphore(max 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 redirect:endpoint 從 `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」(議題 #2,A4 方案);§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) |
|
||||
421
docs/autoflow/04-architecture/database.md
Normal file
421
docs/autoflow/04-architecture/database.md
Normal 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(雛形 stub,Phase 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. Status(USB-level status,既有)
|
||||
// - 由 local agent 直接觀察到的「USB 接了什麼」
|
||||
// - 值:online / offline / unknown
|
||||
// - 來源:local agent 呼叫 KL SDK 得到;透過 tunnel 上報雲端
|
||||
// - 意義:「此刻使用者電腦上這個 KL device 插著且正常」
|
||||
//
|
||||
// 2. RemoteStatus(tunnel-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(雲端連線狀態),次要顯示 status(USB 狀態),
|
||||
// 詳見 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 / unknown(USB)
|
||||
|
||||
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 裡的 key(例:models/{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 ConverterJob(stub)
|
||||
|
||||
```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 map,process 重啟就掉。Phase 1 考慮 Redis 存 summary(TTL + 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
|
||||
);
|
||||
|
||||
-- devices(2026-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`)
|
||||
512
docs/autoflow/04-architecture/design-doc.md
Normal file
512
docs/autoflow/04-architecture/design-doc.md
Normal 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 Store(routing table) │
|
||||
│ 雛形:in-memory(單進程) / Phase 1:Redis │
|
||||
└─────────────────────────────┬──────────────────────────────────────────┘
|
||||
│ 查到該 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 | **Observability(metrics / trace / alert)** — 雛形只有 stdout log | 雛形不追求 SLO | Phase 1 |
|
||||
| N9 | **Local agent 自己的 binary(visionA/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 server;Phase 1 上 Vercel / Cloudflare Pages / S3+CloudFront)|
|
||||
| 狀態 | 無(純前端,所有資料 via API)|
|
||||
| Auth | JWT / session cookie(雛形 stub;Phase 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-server(cmd/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-proxy(cmd/remote-proxy)
|
||||
|
||||
| 屬性 | 值 |
|
||||
|------|-----|
|
||||
| Binary | `cmd/remote-proxy/main.go` |
|
||||
| 狀態 | **有狀態**(持有 yamux.Session)|
|
||||
| 水平擴展 | 可多實例;session 路由交給 session store |
|
||||
| 對外(local agent) | WS :3800(POC 預設)|
|
||||
| 對內(api-server) | HTTP :3801(內部 API,不對外)|
|
||||
| 職責 | 接受 local agent 的 tunnel 連線、維護 yamux session、轉發 api-server 送來的請求 |
|
||||
|
||||
### 2.4 共享狀態:Session Store(2026-04-22 Q1 裁決 C + ADR-006:雛形即雙 binary,無 Redis)
|
||||
|
||||
| 雛形實作 | Phase 1 實作 |
|
||||
|---------|-------------|
|
||||
| Session state **完全由 remote-proxy 持有**(in-memory);api-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` | Build:CI 產出 image;Release:tag + manifest;Run:K8s / 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 shutdown;tunnel 斷線有重連 | 同 + 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 balancer(ALB / 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 Store:token → proxy_internal_url
|
||||
3. 呼叫該 proxy 的 internal endpoint:POST 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. 查 SessionStore(local 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 | ~5K(Gin + Go 典型值)|
|
||||
| Session Store(Redis)單節點 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 提供端點,但回 stub(job_id 假的,狀態永遠 `queued`)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 安全架構
|
||||
|
||||
### 6.1 通訊加密
|
||||
|
||||
- **瀏覽器 ↔ api-server**:HTTPS(Phase 1);雛形開發用 HTTP
|
||||
- **api-server ↔ remote-proxy 內部**:雛形同進程無需加密;多節點 Phase 1 考慮 mTLS 或 VPC-only
|
||||
- **local agent ↔ remote-proxy**:WSS(Phase 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: *`(dev);Phase 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 1:DB 存 `sha256(token)`,不存明文;15min expiry;可 revoke;綁 user_id + device_id
|
||||
|
||||
### 6.5 輸入驗證
|
||||
|
||||
- 模型檔案格式驗證(雛形僅 extension;Phase 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 CI(Phase 1)→ 自動 build + push registry + deploy
|
||||
|
||||
---
|
||||
|
||||
## 8. 觀測(Observability)
|
||||
|
||||
### 8.1 雛形
|
||||
|
||||
| 項目 | 做法 |
|
||||
|------|------|
|
||||
| Log | stdout;沿用 local-tool 的 log broadcaster(WS 看 log)|
|
||||
| Metrics | 無;`/api/system/health` 回 ok 即可 |
|
||||
| Trace | 無 |
|
||||
| Alert | 無 |
|
||||
|
||||
### 8.2 Phase 1(TODO)
|
||||
|
||||
| 項目 | 做法 |
|
||||
|------|------|
|
||||
| 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 < 500ms;Tunnel 連線穩定度 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。
|
||||
1812
docs/autoflow/04-architecture/oidc-tdd.md
Normal file
1812
docs/autoflow/04-architecture/oidc-tdd.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,140 @@
|
||||
# Architect 交叉審閱 — PRD 與設計規格
|
||||
|
||||
| 項目 | 內容 |
|
||||
|------|------|
|
||||
| 審閱者 | Architect Agent(交叉審閱實例) |
|
||||
| 範圍 | `02-prd/*` + `03-design/*` |
|
||||
| 審閱日期 | 2026-04-21 |
|
||||
| 架構基準 | 雙 binary(api-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 client(Q3 決策);需驗證 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 stream,api-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 回傳 timestamp,api-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 SLO(api-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 TTL:15 分鐘 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 吞吐量測試(M3),1 天內可完成
|
||||
5. **驗收條件降級**:multi-tab 同看推論 → 改 P1(M4)
|
||||
|
||||
---
|
||||
|
||||
## 6. 整體判斷
|
||||
|
||||
PRD 與設計規格**結構完整、彼此呼應度高**(Design Agent 已主動標註給 Architect 的提醒)。技術可行性上沒有阻擋雛形的問題,但有 **5 個 Major** 屬於「不處理會在實作時踩雷」的層級,建議在雛形骨架建立前用 1-2 天處理完成。
|
||||
480
docs/autoflow/04-architecture/review/phase-0.8-cross-review.md
Normal file
480
docs/autoflow/04-architecture/review/phase-0.8-cross-review.md
Normal 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 F1–F5 的功能定義**:與我 TDD 的 endpoint 行為對齊(status enum、5–10s 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 整合決策 D1–D6**:與 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 §F6:POST /api/converter/jobs/{id}/import-to-models
|
||||
PRD §F7:POST /api/converter/jobs/{id}/download-token
|
||||
|
||||
Wireframe §3.3 / Flow §3:GET /api/converter/jobs/active
|
||||
Wireframe §6.3:GET /api/converter/jobs/{id}(polling)
|
||||
|
||||
TDD(Architect):
|
||||
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/Design)vs `/api/conversion/*`(Architect)
|
||||
2. **action 命名**:`import-to-models`(PM)vs `promote-to-models`(Architect)
|
||||
3. **download endpoint method + 形式**:PRD/Design 假設 `POST /download-token` 回 `{url, expires_at}` JSON;TDD 已改為 `GET /download` server-side 302 redirect(ADR-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 §F1–F7 沒有列出「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 時,文案會切為「即將完成…」/「伺服器處理中…」(1–3 秒),
|
||||
> 然後才切到「轉檔進行中」畫面。對使用者誠實 — 不謊報已完成。
|
||||
```
|
||||
|
||||
### 1.5 ⚠️ 中 — Promote-to-models request body 對齊 Design
|
||||
|
||||
**位置**:PRD §F6「加到模型庫」流程
|
||||
|
||||
PRD §F6 沒有提到「需要使用者輸入名稱」,但 Design wireframe §7.1 設計了 import Dialog 含名稱輸入欄。我這邊 API spec(v0.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 寫「目標 chip:KL520 / KL630 / KL720 / KL730」(4 個),但 TDD `api-conversion.md` §1 與 converter `platform` 欄位的 enum 寫的是 `520 / 720`(2 個)。需確認:
|
||||
|
||||
- converter Phase 1 實際支援哪幾個 platform?
|
||||
- 如果只支援 520 / 720,PRD 應改為 2 個(避免使用者選了 630/730 然後被 converter 拒絕)
|
||||
- 如果 converter Phase 1 已擴充到 4 個,TDD `platform` enum 應改為 `520 / 630 / 720 / 730`
|
||||
|
||||
**建議 PM**:請與 converter 團隊確認 Phase 1 實際支援的 chip list,PRD 與 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 個 stage(active 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 鏈分析(C1–C4 情境表 + 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 **是內部
|
||||
> 韌性處理**,不暴露 UI;PRD 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(新增)
|
||||
|
||||
採方案 **A4:lazy rebuild**:`/active` endpoint 在 in-memory miss 時,fallback 對 converter 查 `GET /api/v1/jobs?user_id=<sub>&status=in_progress` 並重建 ownership。對 frontend 完全透明。
|
||||
|
||||
**為什麼選 A4 不選 A2(啟動時批次掃)**:A2 對 converter 是 hammer;A4 是 lazy(user 行為觸發),cost 對應實際需求。
|
||||
|
||||
**新增依賴**:converter Phase 1 的 `GET /api/v1/jobs?user_id=&status=in_progress` endpoint(converter 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` row(best-effort、不重試)
|
||||
|
||||
**重要區分**:「使用者主動取消 UI」(PM Non-Goals N2,Phase 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 / §5(response 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 視角)|
|
||||
512
docs/autoflow/04-architecture/security.md
Normal file
512
docs/autoflow/04-architecture/security.md
Normal file
@ -0,0 +1,512 @@
|
||||
# Security — 安全考量與 Pairing Token 協定
|
||||
|
||||
> 本文件彙整所有安全相關細節。部分對應 Design Doc §6。
|
||||
|
||||
---
|
||||
|
||||
## 1. Pairing Token 協定 {#pairing-token}
|
||||
|
||||
### 1.1 雛形版(v0.1)
|
||||
|
||||
最簡化單一 token:
|
||||
|
||||
```bash
|
||||
[開發者 setup]
|
||||
# 產生符合正式格式的雛形 token:vAc_ + 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_*` 格式就接受)
|
||||
- 只適合 dev;Phase 1 切入正式 DB-backed 前,必須先改為 Stage 1 + Stage 2 兩階段(見 §1.2、ADR-003)
|
||||
|
||||
### 1.2 Phase 1:DB-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-Credential(pairing),共 36 字元
|
||||
vAs_[0-9a-f]{64} # Agent-Session(session),共 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 簽發),只在登入登出等動作發生
|
||||
|
||||
實作時通常一個底層 provider(例:Clerk / OIDC client)會同時被 `AuthProvider` 與 `AuthService` 包裝;但 interface 分開讓 handler 不需要知道 middleware 的細節。
|
||||
|
||||
```go
|
||||
// internal/auth/service.go(middleware 層)
|
||||
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.go(handler 層,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 Token(Phase 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 }
|
||||
|
||||
cache(exp - 15s 內可重用)
|
||||
```
|
||||
|
||||
#### 2.4.2 Cache 策略
|
||||
|
||||
- 單一 token cache 涵蓋全部 4 個 scope(MC 端發單一 token 含全部)
|
||||
- cache 在 visionA-backend 進程記憶體(`sync.RWMutex` 保護)
|
||||
- `exp - 15s` 提前重取,避免下游使用時剛好過期
|
||||
- 併發保護:double-checked locking(第一次 cache miss 時其他 goroutine 等待)
|
||||
- 重啟即清空(in-memory,無持久化);下次需要時重新取
|
||||
|
||||
#### 2.4.3 失敗處理
|
||||
|
||||
| 場景 | 處理 |
|
||||
|------|------|
|
||||
| 4xx(client_id / scope 設定錯)| fatal:log + 5xx response 給上游 caller,**不重試** |
|
||||
| 5xx / network | 指數退避 max 2 次(1s, 2s)|
|
||||
| Token cache 命中 → 但 MC 已 invalidate(rare) | 下次 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-backend;in-memory cache 自然失效
|
||||
|
||||
#### 2.4.5 Delegated Download Token
|
||||
|
||||
不同於上面的 service token,FAA 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_key(FAA 拒絕其他 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_id;object_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)| HTTP(VPC-only) | 如 public 需 mTLS |
|
||||
| Local agent → remote-proxy | WSS | Phase 1;雛形 WS |
|
||||
| Local agent → Kneron USB | 無(硬體直連)| — |
|
||||
|
||||
---
|
||||
|
||||
## 4. Rate Limiting(Phase 1)
|
||||
|
||||
| 端點 | 限制 |
|
||||
|------|------|
|
||||
| `POST /api/auth/login` | 5 / min / IP |
|
||||
| `POST /api/pairing/token` | 5 / min / user |
|
||||
| `WS /tunnel/connect` | 10 / min / IP;token 級限制另計 |
|
||||
| 一般 `/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 check(local-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` header(double-submit pattern)
|
||||
|
||||
---
|
||||
|
||||
## 7.1 Cookie Session 設計取捨(Phase 0.6 OIDC BFF)
|
||||
|
||||
Phase 0.6 OIDC BFF 落地後(OB1-OB6),Cookie session 設計做出以下取捨:
|
||||
|
||||
### Pending 與 Logged-in Session 共用同一個 Cookie(ADR-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` | true(prod)/ false(dev 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 token(static)| env var | DB(hash)|
|
||||
| Storage signing key | env var | env var + 定期 rotation |
|
||||
| DB password | — | AWS Secrets Manager / Vault |
|
||||
| JWT signing key | — | 同上 |
|
||||
| S3 credentials | env var(dev)| IAM role(EC2 / K8s SA)|
|
||||
|
||||
**禁止**:任何 secret 寫死進程式碼 / commit 到 Git。`.gitignore` 必須含 `.env`、`data/`、`*.pem`。
|
||||
|
||||
---
|
||||
|
||||
## 10. 審計 Log(Phase 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 必還)
|
||||
|
||||
F5–F6 實作留下的**已知安全債**。雛形階段可接受(開發者自用、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 session(in-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 log(Nginx / 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 processors(AWS / S3 供應商 / Email 供應商)
|
||||
|
||||
---
|
||||
|
||||
**雛形實作重點**:
|
||||
- `StaticPairingStore` + env token
|
||||
- ~~`StaticAuthService` + hard-coded demo user~~ → **已替換為 OIDC**(Phase 0.6 / OB5;見 oidc-tdd.md)
|
||||
- CORS 限定白名單(OB5 已調整)
|
||||
- HTTP(dev)/ HTTPS(prod 必須)
|
||||
|
||||
**Phase 1 必做**:
|
||||
- 兩階段 token
|
||||
- ~~真實 Auth(Clerk 或 自建)~~ → **已完成(Member Center OIDC)**
|
||||
- HTTPS / WSS(prod 強制)
|
||||
- Rate limit
|
||||
- Audit log
|
||||
- Refresh token rotation(Member Center 暫無 refresh,雛形可接受)
|
||||
- Redis / DB-backed session store(取代 in-memory)
|
||||
431
docs/autoflow/04-architecture/stage-deployment.md
Normal file
431
docs/autoflow/04-architecture/stage-deployment.md
Normal file
@ -0,0 +1,431 @@
|
||||
# Stage 部署架構 — visionA Cloud
|
||||
|
||||
## Metadata
|
||||
- **作者**:Architect Agent
|
||||
- **狀態**:Draft(Phase 0.7;A1 + 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)| 拆 container(api-server / remote-proxy / nginx 各自一個) |
|
||||
| In-memory session / DB / storage | Redis / Postgres / S3 |
|
||||
| HTTPS termination 委由公司 host nginx | 自管 TLS / 自簽 / 自架 LB |
|
||||
| OIDC public PKCE-only client(stage MC 給的)| 自家 IdP / 多 IdP federation |
|
||||
| Internal docker registry(`192.168.0.130:5000`)| 公開 registry(Docker 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 約定 |
|
||||
| 對外協定 | HTTPS(Let'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 termination(Let's Encrypt 證書,同一張覆蓋瀏覽器與 agent 流量)
|
||||
│ HTTP :9527 → docker port mapping
|
||||
▼
|
||||
[ visionA container ]
|
||||
│ container 內 nginx :80 listen(host 對外只 publish :9527→:80)
|
||||
├── /api/* → 127.0.0.1:3721 (api-server)
|
||||
├── /tunnel/connect → 127.0.0.1:3800 (remote-proxy;container 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:3000,Next.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.1,host 不再 publish :3800。
|
||||
|
||||
### 3.2 為什麼選單 container
|
||||
|
||||
| 選項 | 評估 |
|
||||
|------|------|
|
||||
| **單 container 多 process(採用)** | 仿 edge-ai-platform;stage 階段最省事;deploy 一個 image 就完整;rollback 一個 image 就完整 |
|
||||
| 多 container(api-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**:多一層 dependency;entrypoint 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 :3800,agent 看得到的只有 :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/amd64(stage 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 Deploy(stage 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 立即 rollback(image 切換)
|
||||
|
||||
```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_secret,OAuth 對攻擊者的防護主要靠 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 registry(rollback 用)
|
||||
- [ ] 確認 POC `edge-ai-platform` container 仍以 stop 狀態保留(緊急 fallback 用)
|
||||
- [ ] LE 證書到期日期已記錄在 progress.md,6/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) |
|
||||
239
docs/autoflow/04-architecture/storage.md
Normal file
239
docs/autoflow/04-architecture/storage.md
Normal 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 sidecar(LocalFS 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 URL,例:http://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 URL(TTL 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(冷資料下降費率)。
|
||||
463
docs/autoflow/04-architecture/tunnel.md
Normal file
463
docs/autoflow/04-architecture/tunnel.md
Normal 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 token(config / 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 水平擴展到多 instance,multi-tab 的 fan-out 需要額外機制(pub/sub 或 sticky LB),屆時另寫 ADR
|
||||
|
||||
**結論**:
|
||||
- **Q5** = tunnel 層「一個 device 只允許一條 active tunnel」
|
||||
- **Multi-tab 觀看** = 應用層 fan-out,與 Q5 互不干涉
|
||||
- 雛形兩者皆支援(tunnel 後連覆蓋 + 單 instance fan-out);Phase 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 雙向 pipe(POC `proxyWebSocket`)
|
||||
- Local agent 端同步切換 → raw TCP to localhost + 雙向 pipe(POC `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
|
||||
寫回 caller(api-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 hop(localhost 時 ~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`)
|
||||
- 無 compression(yamux 已是位元流,壓縮效益低且增加 CPU)
|
||||
- Origin check:**remote-proxy 端接受任意 Origin**(local agent 不跑在瀏覽器,不需 origin 防護;Phase 1 加 token 驗證即可)
|
||||
- Ping/Pong:**使用 yamux 內建 keepalive**,統一心跳週期(見 §4.2)
|
||||
|
||||
### 4.2 yamux Config(2026-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()
|
||||
// 覆蓋舊 session(POC 行為 / 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 存在
|
||||
// 回傳 RemoteHandle(OpenStream 會再呼叫 /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` instance,api-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),可接受。
|
||||
|
||||
**不採用的方案**:RPC(gRPC)— 多一個 proto 定義,收益不大。
|
||||
**不採用的方案**:Redis pub/sub 轉發 request body — 非串流語義,MJPEG 無法。
|
||||
|
||||
---
|
||||
|
||||
## 7. 雛形實作細節(給 Backend Agent)
|
||||
|
||||
雛形即雙 binary(Q1 裁決,交付物)。本機開發便利工具 `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」 |
|
||||
| 瀏覽器訂閱 WS,tunnel 斷 | 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 參數。
|
||||
976
docs/autoflow/04-architecture/visiona-agent-tdd.md
Normal file
976
docs/autoflow/04-architecture/visiona-agent-tdd.md
Normal file
@ -0,0 +1,976 @@
|
||||
# visionA Agent — Technical Design Document(局部 TDD)
|
||||
|
||||
## Metadata
|
||||
|
||||
| 項目 | 內容 |
|
||||
|------|------|
|
||||
| 文件角色 | **局部 TDD** — 只規範 visionA Agent 這一塊,不重寫 visionA-backend / visionA-frontend 的 TDD |
|
||||
| 作者 | Architect Agent |
|
||||
| 最後更新 | 2026-04-22(v0.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 平台實作) |
|
||||
| 產品行銷 UI(Onboarding / 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 # 雛形 fallback:AES-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 stack(C1 裁決)
|
||||
│ ├── 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 layout:Header + 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 / EventsEmit;SSR-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 個 view(Wails 環境 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.js(Agent 是純 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 次無 pong(30s)→ 判定掉線 → 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 版可能直接退 app;local-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 Bindings(Go → 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 methods(Wails 自動產生 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 Events(Go → 前端事件)
|
||||
|
||||
| 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 cert(EV / OV) |
|
||||
| Linux | **不簽**(AppImage 本來就不簽)| — |
|
||||
|
||||
雛形就**不處理簽章成本**,跟 local-tool 一致。
|
||||
|
||||
### 7.5 版本與更新
|
||||
|
||||
- `info.productVersion`(`wails.json`)+ `VERSION` Makefile 變數雙寫(建置腳本自動同步)
|
||||
- 「檢查更新」按鈕 Phase 0:stub 永遠回 `{ 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. TODO(Phase 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 pipeline(GitHub Actions matrix)
|
||||
- [ ] Silent install / MSI / .pkg 變體(企業佈署)
|
||||
|
||||
### 觀測
|
||||
- [ ] 本地 metrics dashboard(tunnel uptime, reconnect count)
|
||||
- [ ] Crash reporter(Sentry / Rollbar)
|
||||
- [ ] 匿名 usage telemetry(opt-in)
|
||||
|
||||
### 與雲端互動
|
||||
- [ ] visionA-backend 新增 `/api/pairing/exchange`(雛形 backend S 級小改)
|
||||
- [ ] Agent 提供自己的身分 header(Agent-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-Backend(Go 部分)
|
||||
|
||||
| # | 任務 | 大小 | 依賴 |
|
||||
|---|------|------|------|
|
||||
| **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/`:狀態機 + broadcaster(Wails 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** | Layout:Root 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** | 狀態 view:StatusHero + InfoCard + RecentLog + 按鈕互動(Disconnect / Reconnect / RepairFlow) | L | AF2, AF3 | 原 AF6 |
|
||||
| **AF5** | 配對 view:PairForm(格式驗證 + 配對送出 + Toast + 0.5s 自動切狀態 tab) | M | AF2, AF3 | 原 AF7 |
|
||||
| **AF6** | 設定 view:連線 / 行為 / Log / 關於 / 危險區域 5 區塊 | L | AF2, AF3 | 原 AF8 |
|
||||
| **AF7** | E2E:從假 Agent(mock 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 程式碼 import,B3 預留是誤導;visionA-backend 只需要 `internal/relay/` = tunnel server 端)|
|
||||
| **U-1** | `cmd/visiona-agent/` 子目錄 | **是**(更乾淨,Go 慣例)|
|
||||
| **U-2** | Token 雛形儲存 | **encrypted file**(machineID 衍生 passphrase),Phase 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 / C2:frontend 改 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 |
|
||||
184
docs/autoflow/07-delivery/phase-0.6-handover.md
Normal file
184
docs/autoflow/07-delivery/phase-0.6-handover.md
Normal file
@ -0,0 +1,184 @@
|
||||
# Phase 0.6 OIDC 接入 — 交接摘要
|
||||
|
||||
> 日期:2026-04-26
|
||||
>
|
||||
> 範圍:visionA-backend StaticAuthProvider → Innovedus Member Center OIDC(BFF 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 完成項目
|
||||
|
||||
### Backend(OB1-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 部分仍有效)
|
||||
|
||||
### Frontend(OF1-OF2)✅
|
||||
- **OF1** login 頁改 OIDC redirect 按鈕(11 tests + i18n)
|
||||
- **OF2** API client `credentials: 'include'`、auth-store 拔 localStorage、改 cookie session(107 tests 綠)
|
||||
|
||||
### Frontend(OF3-OF4)⏳
|
||||
- **OF3** register 頁移除、account 接 `/api/auth/me`(待補)
|
||||
- **OF4** i18n 補齊(待補)
|
||||
|
||||
### DevOps(OD1)✅
|
||||
- 5 service docker-compose(postgres / member-center / member-center-web / member-center-init / visiona-api / visiona-proxy)全 healthy
|
||||
- `docs/DEV-SETUP.md` 完整 setup + 故障排除(含 §7.6 OIDC flow 階段問題)
|
||||
|
||||
### DevOps(OD2)⏳
|
||||
- `make dev-with-mc` Makefile target + `.env.example` 整理(待補;MC bug 修復後加 seed script)
|
||||
|
||||
### Testing(OT1)✅
|
||||
- `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 已拿掉(合進主測試)
|
||||
|
||||
### Testing(OT2)✅(範圍調整)
|
||||
- 因 MC 兩個 bug 阻擋自動 e2e(password 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 A:OIDC 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 session(10 分鐘 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
|
||||
↓ 驗 state(CSRF)
|
||||
↓ POST MC /oauth/token (code + verifier + client_secret)
|
||||
↓ 驗 id_token(iss / aud / exp / nonce / JWKS 簽章)
|
||||
↓ 建 visionA session(user_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 B:Pairing 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 C:fake 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 Flow(redirect 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 client(password 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-local(local-tool)佔 3721 與 docker compose 衝突 | 兩個只能擇一跑 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 1 TODO
|
||||
|
||||
### MC team(unblock 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 logout(OIDC end_session_endpoint)
|
||||
- [ ] **OB-Phase1-3** Refresh token rotation(依賴 MC 上 refresh token 支援)
|
||||
- [ ] **OB-Phase1-4** Member Center webhook(user 刪除 / 停用通知)
|
||||
|
||||
---
|
||||
|
||||
## 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 sub(OT1 PairingTokenBindsToOIDCUser ✅)
|
||||
- [x] StaticAuthProvider 完全移除(OB5 ✅)
|
||||
- [x] Frontend 完全不接觸 token(OF1 + OF2 ✅)
|
||||
- [x] docker-compose 一鍵起 5 service(OD1 ✅)
|
||||
- [x] 手動煙測 checklist 完整(OT2 ✅,本文件對應)
|
||||
- [ ] OF3 + OF4 完成(待補;不阻擋 Phase 0.6 驗收,列入 Phase 1)
|
||||
- [ ] OD2 Makefile + seed script(依賴 MC bug 修復;列入 Phase 1)
|
||||
- [ ] 真 MC 自動 e2e(依賴 MC bug 修復;列入 Phase 1)
|
||||
282
docs/autoflow/07-delivery/project-summary.md
Normal file
282
docs/autoflow/07-delivery/project-summary.md
Normal file
@ -0,0 +1,282 @@
|
||||
# visionA 產品線 Phase 0 + Phase 0.5 交付總結
|
||||
|
||||
> **交付日期**:2026-04-22
|
||||
> **階段**:Phase 0 雛形(visionA 雲端版) + Phase 0.5(visionA 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/` | 雲端後端(Go;api-server + remote-proxy 雙 binary)| Phase 0 雛形完成 |
|
||||
| **visionA Agent(local-agent)** | `visionA/local-agent/` | 雲端版 local 端代理(Wails + Next.js + Go) | Phase 0.5 完成 |
|
||||
|
||||
未來還會加:
|
||||
- **kneron_model_converter**(轉檔網站,獨立專案)
|
||||
- 會員系統、Billing、Admin Console(Phase 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
|
||||
|
||||
### Backend(visionA-backend)
|
||||
- Go 1.26 + Gin + gorilla/websocket + hashicorp/yamux
|
||||
- 雙 binary:`cmd/api-server`(3721)+ `cmd/remote-proxy`(tunnel 3800 / internal 3801)
|
||||
- in-memory store(Phase 1 換 Postgres/Redis)
|
||||
- S3-compat 抽象層(LocalFS 雛形 / S3 / MinIO 未定)
|
||||
|
||||
### visionA Agent
|
||||
- Wails v2 桌面外殼 + Next.js(output: '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 已 build(160 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 E2E(AB13)
|
||||
`cmd/api-server/e2e_full_flow_test.go`:5 個 milestone
|
||||
1. Pairing Exchange(產 pairing → 兌換 session token)
|
||||
2. Tunnel Connect(session token 用 yamux 建立 tunnel)
|
||||
3. API Forward(透過完整鏈路轉發業務請求)
|
||||
4. Token Reuse 防護(同 pairing token 再用 → 401 PAIRING_TOKEN_USED)
|
||||
5. Tunnel Drop Failover(agent 斷線 → 502 TUNNEL_DISCONNECTED)
|
||||
|
||||
### visionA Agent 本機 E2E(AB6)
|
||||
`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-backend,POC 保留 |
|
||||
| Q3 | Local Agent | 本次雛形用 POC edge-ai-server 暫代,Phase 0.5 做獨立 Agent |
|
||||
| Q4 | 雛形 Auth | C:StaticAuthService 回 demo-user |
|
||||
| Q5 | Session 覆蓋 | A:沿用 POC,後連覆蓋前連 |
|
||||
| Q6 | local-tool-only 元件 | A:雛形隱藏 OnboardingDialog / ServerStatusDashboard / ServerLogViewer |
|
||||
| Q7 | Pairing Token 顯示 | API 回純 hex,前端顯示加空格 |
|
||||
|
||||
### Phase 0.5 visionA Agent(8 個)
|
||||
| # | 議題 | 決策 |
|
||||
|---|------|------|
|
||||
| A3 | Agent 範圍 | 純橋樑:tunnel + 配對 UI + 設定,無本機操作 UI |
|
||||
| — | App Name | visionA Agent(Bundle 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 雙層:AuthProvider(handler)+ AuthService(middleware)
|
||||
- **M-4** Converter API:REST Resource 風格(`POST /v1/jobs`)
|
||||
- **M-5** 心跳:10 秒心跳 + 30 秒判定掉線
|
||||
- **M-8** Non-Goal:雛形僅單 instance
|
||||
|
||||
---
|
||||
|
||||
## 十、Phase 1 TODO(彙整)
|
||||
|
||||
### 安全
|
||||
- 真實 Auth(Clerk / OIDC / 自建),取代 StaticAuthProvider
|
||||
- Agent token 改用 OS keychain(macOS 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 + migration(golang-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 build(workflow 已設定)
|
||||
- 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`
|
||||
185
docs/autoflow/07-delivery/stage-deployment-setup.md
Normal file
185
docs/autoflow/07-delivery/stage-deployment-setup.md
Normal 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 build:node 22-alpine(frontend builder)+ golang 1.26-alpine(backend 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 compose(image 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 process(entrypoint) │
|
||||
│ └──────────────────────────┘
|
||||
│ 5/5 verify
|
||||
│ - docker inspect Health.Status == "healthy"(最多 60s)
|
||||
│ - curl https://stage-9527.innovedus.com:9527/healthz
|
||||
▼
|
||||
提示 rollback hint:bash 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`。
|
||||
|
||||
**決策**:保留 standalone,container 帶 nodejs runtime 跑 `node server.js`,不動 frontend code。
|
||||
|
||||
理由:
|
||||
- 對齊產品線原則 4「雲端 web UI 先抄 local-tool,Phase 0 不發明新 UI」
|
||||
- 改成 export 模式需要為 3 個動態 route 加 `generateStaticParams()` 或重整 routes,是 Frontend Agent 的工作
|
||||
- Image 多 ~70MB(node + 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` 重新 push,stage host 重 pull。
|
||||
若 registry 啟用 immutable tag policy,文件 §6.2 提供 fallback(手改 compose file)。
|
||||
|
||||
### 3.4 Healthcheck 只看 nginx,不看 backend
|
||||
|
||||
`/healthz` 由 nginx 直接 `return 200`,不打 backend。理由:
|
||||
- 若打 backend,OIDC 暫時 down 會造成假紅
|
||||
- 但 `wait -n` 會抓到 backend process 死亡 → container die
|
||||
|
||||
兩層配合:「process 活著 = nginx 答得出 200」+「process 死掉 = wait -n exit = container restart」。
|
||||
|
||||
---
|
||||
|
||||
## 4. 已知限制(必須讓使用者知道)
|
||||
|
||||
| # | 限制 | 影響 | 緩解 |
|
||||
|---|------|------|------|
|
||||
| L1 | 公司 host nginx 是黑盒 | 反代規則/timeout/body 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 flow(login → 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 處理 OK;OIDC 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 flow(login → 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`(部署手冊本身)
|
||||
Loading…
x
Reference in New Issue
Block a user