visionA/docs/autoflow/03-design/wireframes/wireframe-conversion.md
jim800121chen fb7da5d180 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)。
2026-05-04 16:55:55 +08:00

925 lines
64 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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