`)。
+ * - backend 回的 code(model_not_found / forbidden / upload_not_supported / sign_failed …)原樣透出。
+ */
+
+import { ApiError, api } from "@/lib/api";
+
+/* -------------------------------------------------------------------------- */
+/* Types */
+/* -------------------------------------------------------------------------- */
+
+/** `GET /api/models/:id/download` 正規化後的回傳。 */
+export interface ModelDownloadGrant {
+ /** FAA 直連下載 URL(跨 origin,如 https://stage-9527...:5081/files/models/{userID}/{jobID}.nef) */
+ downloadUrl: string;
+ /** MC 簽的 opaque delegated download token(`fdt_...`);只用於下一步的 Authorization header */
+ token: string;
+ /** ISO 8601 — token 過期時間 */
+ expiresAt: string;
+}
+
+/* -------------------------------------------------------------------------- */
+/* Error class */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * 模型下載專用錯誤。store / UI 用 `error.code` 對應 i18n key(`models.download.error.`)。
+ *
+ * code 統一全小寫(對齊 conversion.ts `ConversionAPIError` 的命名規範,避免 UI 端做大小寫處理)。
+ * 常見 code:
+ * - `model_not_found`(404)/ `forbidden`(403)/ `upload_not_supported`(501,第一階段不支援上傳類)
+ * - `sign_failed`(502,MC 簽 token 失敗)
+ * - `download_failed`(FAA GET 非 2xx;可能含 CORS 被擋 → network_error)
+ * - `network_error` / `timeout` / `aborted` / `parse_error`(client 端網路層)
+ */
+export class ModelDownloadError extends Error {
+ readonly status: number;
+ readonly code: string;
+ readonly requestId?: string;
+
+ constructor(status: number, code: string, message: string, requestId?: string) {
+ super(message);
+ this.name = "ModelDownloadError";
+ this.status = status;
+ this.code = code;
+ this.requestId = requestId;
+ if (typeof Error.captureStackTrace === "function") {
+ Error.captureStackTrace(this, ModelDownloadError);
+ }
+ }
+}
+
+/** 把底層 `ApiError` / 一般 Error 包成 `ModelDownloadError`(code 統一小寫)。 */
+function wrapError(err: unknown): ModelDownloadError {
+ if (err instanceof ModelDownloadError) return err;
+ if (err instanceof ApiError) {
+ return new ModelDownloadError(err.status, err.code.toLowerCase(), err.message);
+ }
+ if (err instanceof Error) {
+ const maybeCode = (err as unknown as { code?: unknown }).code;
+ const code =
+ typeof maybeCode === "string" ? maybeCode.toLowerCase() : "network_error";
+ return new ModelDownloadError(0, code, err.message);
+ }
+ return new ModelDownloadError(0, "unknown", String(err));
+}
+
+/* -------------------------------------------------------------------------- */
+/* 1. GET /api/models/:id/download — 取 FAA 下載授權 */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * 向 visionA backend 取得模型的 FAA delegated download 授權。
+ *
+ * 走既有 `api.get` wrapper(自動帶 cookie session、解 envelope、ApiError mapping)。
+ * 寬容讀取 snake_case / camelCase(download_url / downloadUrl 等)。
+ *
+ * @throws {ModelDownloadError} 404 model_not_found / 403 forbidden /
+ * 501 upload_not_supported / 502 sign_failed / 其他網路層錯誤
+ */
+export async function getModelDownload(modelId: string): Promise {
+ if (!modelId) {
+ throw new ModelDownloadError(0, "validation_failed", "modelId is required");
+ }
+ try {
+ const raw = await api.get>(
+ `/api/models/${encodeURIComponent(modelId)}/download`,
+ );
+ const r = raw ?? {};
+ const downloadUrl = String(r.download_url ?? r.downloadUrl ?? "");
+ const token = String(r.token ?? "");
+ const expiresAt = String(r.expires_at ?? r.expiresAt ?? "");
+ if (!downloadUrl || !token) {
+ throw new ModelDownloadError(
+ 500,
+ "parse_error",
+ "download: missing download_url or token in response",
+ );
+ }
+ return { downloadUrl, token, expiresAt };
+ } catch (err) {
+ throw wrapError(err);
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+/* 2. fetch FAA + Bearer header + blob → 觸發瀏覽器下載 */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * 帶 `Authorization: Bearer {token}` 跨 origin 直連 FAA 下載檔案,並觸發瀏覽器存檔。
+ *
+ * 為什麼不能用 ``:browser navigation 無法帶自訂 header(Authorization),
+ * 而 FAA `TryReadAccessToken` 只認 `Authorization: Bearer`(不認 query / 自訂 header)。
+ * → 必須 fetch → response.blob() → URL.createObjectURL → 動態 anchor click → revokeObjectURL。
+ *
+ * CORS 註記:FAA 端需允許 visionA 前端 origin(ADR-017 決策 2 Q3)。若 FAA 未設 CORS,
+ * 跨 origin fetch 會 throw `TypeError: Failed to fetch` → 落到 `network_error`(UI 顯示下載失敗)。
+ * 這是 FAA 端設定問題,**前端 code 不為 CORS 改**。
+ *
+ * @param downloadUrl FAA 絕對 URL(來自 getModelDownload)
+ * @param token opaque `fdt_` token(只用於 Authorization header,不寫進 URL / log)
+ * @param filename 存檔檔名(如 `{jobID}.nef`)
+ * @throws {ModelDownloadError} download_failed(FAA 非 2xx)/ network_error / aborted
+ */
+export async function downloadModelFile(
+ downloadUrl: string,
+ token: string,
+ filename: string,
+ signal?: AbortSignal,
+): Promise {
+ if (!downloadUrl || !token) {
+ throw new ModelDownloadError(0, "validation_failed", "downloadUrl and token are required");
+ }
+
+ let res: Response;
+ try {
+ res = await fetch(downloadUrl, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${token}` },
+ // 跨 origin 直連 FAA:不帶 visionA cookie(FAA 用 delegated token 認證,cookie 無意義)
+ credentials: "omit",
+ signal,
+ });
+ } catch (err) {
+ if (err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message))) {
+ throw new ModelDownloadError(0, "aborted", "Download aborted");
+ }
+ // TypeError: Failed to fetch — 通常是 CORS 被擋 / 連不上 FAA
+ throw new ModelDownloadError(
+ 0,
+ "network_error",
+ `Failed to reach FAA: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
+ if (!res.ok) {
+ throw new ModelDownloadError(
+ res.status,
+ "download_failed",
+ `FAA download failed: HTTP ${res.status}`,
+ res.headers.get("X-Request-Id") ?? undefined,
+ );
+ }
+
+ const blob = await res.blob();
+ triggerBlobDownload(blob, filename);
+}
+
+/**
+ * 建立 object URL + 動態 anchor click 觸發瀏覽器存檔,完成後 revoke 避免記憶體洩漏。
+ *
+ * 抽成獨立函式:便於測試(驗 anchor 屬性 / revoke 被呼叫)、也讓上面 fetch 流程更乾淨。
+ */
+export function triggerBlobDownload(blob: Blob, filename: string): void {
+ const objectUrl = URL.createObjectURL(blob);
+ try {
+ const anchor = document.createElement("a");
+ anchor.href = objectUrl;
+ anchor.download = filename;
+ anchor.rel = "noopener";
+ // 不掛進 DOM 也能 click(現代瀏覽器支援),但部分 Firefox 版本需 append;保險起見 append + remove
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+ } finally {
+ // 立即 revoke 可能在某些瀏覽器中斷下載,延遲一拍再 revoke
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+/* Helper */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * 從 FAA download URL 推導存檔檔名(`.../models/{userID}/{jobID}.nef` → `{jobID}.nef`)。
+ *
+ * 回退:URL 解析失敗 / 無副檔名時用 `{modelName}.nef`(modelName 已去除非法字元)。
+ */
+export function deriveDownloadFilename(downloadUrl: string, modelName: string): string {
+ try {
+ // downloadUrl 可能含 query string;取 pathname 最後一段
+ const path = new URL(downloadUrl).pathname;
+ const last = path.split("/").filter(Boolean).pop() ?? "";
+ if (last && /\.[a-z0-9]+$/i.test(last)) {
+ return decodeURIComponent(last);
+ }
+ } catch {
+ // URL 解析失敗 → 走 fallback
+ }
+ const safe = (modelName || "model").replace(/[^\w.\-]+/g, "_");
+ return safe.endsWith(".nef") ? safe : `${safe}.nef`;
+}
diff --git a/visionA-frontend/src/lib/i18n/dictionaries/en.ts b/visionA-frontend/src/lib/i18n/dictionaries/en.ts
index ae1720a..082bc6e 100644
--- a/visionA-frontend/src/lib/i18n/dictionaries/en.ts
+++ b/visionA-frontend/src/lib/i18n/dictionaries/en.ts
@@ -221,6 +221,29 @@ export const en: Dictionary = {
"models.upload.error.urlFailed": "Server is busy, please try again.",
"models.upload.error.networkLost": "Network lost, upload paused.",
"models.upload.toast.uploaded": "Model \"{name}\" uploaded.",
+ // ── Model download (Phase 0.9 FAA delegated download) ──
+ "models.action.download": "Download",
+ "models.action.downloading": "Downloading…",
+ "models.action.download.aria": "Download the model file to your device",
+ "models.action.download.unsupportedTooltip":
+ "Only converted models are downloadable in this phase",
+ "models.download.toast.start": "Download started",
+ "models.download.toast.hint":
+ "If you don't see a download prompt, check your browser settings",
+ "models.download.error.title": "Download failed",
+ "models.download.error.model_not_found": "Model not found.",
+ "models.download.error.forbidden": "You don't have permission to download this model.",
+ "models.download.error.upload_not_supported":
+ "Uploaded models are not downloadable in this phase.",
+ "models.download.error.sign_failed":
+ "Failed to obtain a download grant, please try again later.",
+ "models.download.error.download_failed":
+ "Failed to download from the file server, please try again later.",
+ "models.download.error.network_error":
+ "Network error, please check your connection and retry.",
+ "models.download.error.timeout": "Download timed out, please try again later.",
+ "models.download.error.busy": "A download is already in progress, please wait.",
+ "models.download.error.unknown": "Download failed, please try again later.",
// ── Workspace ──
"workspace.title": "Workspace",
diff --git a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts
index 9c42e31..c363ef1 100644
--- a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts
+++ b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts
@@ -219,6 +219,24 @@ export const zhHant: Dictionary = {
"models.upload.error.urlFailed": "伺服器忙碌,請稍後再試",
"models.upload.error.networkLost": "網路中斷,上傳已暫停",
"models.upload.toast.uploaded": "模型「{name}」已上傳",
+ // ── 模型下載(Phase 0.9 FAA delegated download)──
+ "models.action.download": "下載",
+ "models.action.downloading": "下載中…",
+ "models.action.download.aria": "下載模型檔到本機",
+ "models.action.download.unsupportedTooltip": "第一階段僅支援轉檔產生的模型下載",
+ "models.download.toast.start": "下載已開始",
+ "models.download.toast.hint": "若沒看到下載提示,請檢查瀏覽器設定",
+ "models.download.error.title": "下載失敗",
+ // 各 error code(對齊 backend:model_not_found / forbidden / upload_not_supported / sign_failed)
+ "models.download.error.model_not_found": "找不到此模型",
+ "models.download.error.forbidden": "你沒有權限下載此模型",
+ "models.download.error.upload_not_supported": "第一階段尚不支援上傳類模型下載",
+ "models.download.error.sign_failed": "取得下載授權失敗,請稍後再試",
+ "models.download.error.download_failed": "從檔案伺服器下載失敗,請稍後再試",
+ "models.download.error.network_error": "網路連線失敗,請檢查網路後重試",
+ "models.download.error.timeout": "下載逾時,請稍後再試",
+ "models.download.error.busy": "已有下載進行中,請稍候",
+ "models.download.error.unknown": "下載失敗,請稍後再試",
// ── Workspace ──
"workspace.title": "推論工作區",
diff --git a/visionA-frontend/src/stores/model-store.test.ts b/visionA-frontend/src/stores/model-store.test.ts
index 60df337..f27969a 100644
--- a/visionA-frontend/src/stores/model-store.test.ts
+++ b/visionA-frontend/src/stores/model-store.test.ts
@@ -1,11 +1,45 @@
-import { beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { useModelStore } from "./model-store";
+import { ModelDownloadError } from "@/lib/api/model-download";
+
+import { isModelDownloadable, useModelStore, type ModelSummary } from "./model-store";
+
+// mock 下載 API client(store 的 downloadModel action 依賴它)
+vi.mock("@/lib/api/model-download", async () => {
+ const actual = await vi.importActual(
+ "@/lib/api/model-download",
+ );
+ return {
+ ...actual,
+ getModelDownload: vi.fn(),
+ downloadModelFile: vi.fn(),
+ };
+});
+
+import { downloadModelFile, getModelDownload } from "@/lib/api/model-download";
+
+const mockGetModelDownload = vi.mocked(getModelDownload);
+const mockDownloadModelFile = vi.mocked(downloadModelFile);
function reset() {
- useModelStore.setState({ models: [], selectedModel: null, isLoading: false });
+ useModelStore.setState({
+ models: [],
+ selectedModel: null,
+ isLoading: false,
+ downloadingId: null,
+ });
}
+const convertedReady: ModelSummary = {
+ id: "m1",
+ name: "YOLOv5s",
+ targetChip: "kl520",
+ fileSize: 1024,
+ source: "converted",
+ status: "ready",
+ createdAt: "2026-01-01T00:00:00Z",
+};
+
describe("model-store(F5 骨架)", () => {
beforeEach(reset);
@@ -36,3 +70,103 @@ describe("model-store(F5 骨架)", () => {
await expect(useModelStore.getState().fetchModels()).resolves.toBeUndefined();
});
});
+
+describe("isModelDownloadable(Phase 0.9)", () => {
+ it("converted + ready → 可下載", () => {
+ expect(isModelDownloadable({ source: "converted", status: "ready" })).toBe(true);
+ });
+
+ it("uploaded(即使 ready)→ 不可下載", () => {
+ expect(isModelDownloadable({ source: "uploaded", status: "ready" })).toBe(false);
+ });
+
+ it("converted 但非 ready(scanning)→ 不可下載", () => {
+ expect(isModelDownloadable({ source: "converted", status: "scanning" })).toBe(false);
+ });
+
+ it("preset → 不可下載", () => {
+ expect(isModelDownloadable({ source: "preset", status: "ready" })).toBe(false);
+ });
+});
+
+describe("downloadModel action(Phase 0.9)", () => {
+ beforeEach(() => {
+ reset();
+ vi.clearAllMocks();
+ });
+ afterEach(() => vi.restoreAllMocks());
+
+ it("happy path:取授權 → 下載 → 回 { ok: true },過程中 downloadingId = 該 model,結束清空", async () => {
+ mockGetModelDownload.mockResolvedValue({
+ downloadUrl: "https://faa/files/u/m1.nef",
+ token: "fdt_tok",
+ expiresAt: "2026-06-07T12:00:00Z",
+ });
+ mockDownloadModelFile.mockResolvedValue(undefined);
+
+ const promise = useModelStore.getState().downloadModel(convertedReady);
+ // action 同步階段已設 downloadingId
+ expect(useModelStore.getState().downloadingId).toBe("m1");
+
+ const result = await promise;
+ expect(result).toEqual({ ok: true });
+ expect(mockGetModelDownload).toHaveBeenCalledWith("m1");
+ // 檔名從 URL path 推導(m1.nef)
+ expect(mockDownloadModelFile).toHaveBeenCalledWith(
+ "https://faa/files/u/m1.nef",
+ "fdt_tok",
+ "m1.nef",
+ );
+ // 結束後清空 loading
+ expect(useModelStore.getState().downloadingId).toBeNull();
+ });
+
+ it("不可下載的 model(uploaded)→ 不打 API,回 upload_not_supported", async () => {
+ const result = await useModelStore.getState().downloadModel({
+ ...convertedReady,
+ source: "uploaded",
+ });
+ expect(result).toEqual({
+ ok: false,
+ code: "upload_not_supported",
+ message: expect.any(String),
+ });
+ expect(mockGetModelDownload).not.toHaveBeenCalled();
+ });
+
+ it("已有下載進行中 → 回 busy,不重複打 API", async () => {
+ useModelStore.setState({ downloadingId: "other" });
+ const result = await useModelStore.getState().downloadModel(convertedReady);
+ expect(result).toMatchObject({ ok: false, code: "busy" });
+ expect(mockGetModelDownload).not.toHaveBeenCalled();
+ });
+
+ it("getModelDownload 拋 ModelDownloadError → 回對應 code,並清空 downloadingId", async () => {
+ mockGetModelDownload.mockRejectedValue(
+ new ModelDownloadError(403, "forbidden", "no"),
+ );
+ const result = await useModelStore.getState().downloadModel(convertedReady);
+ expect(result).toMatchObject({ ok: false, code: "forbidden" });
+ expect(useModelStore.getState().downloadingId).toBeNull();
+ });
+
+ it("downloadModelFile 拋 network_error(CORS)→ 回 network_error", async () => {
+ mockGetModelDownload.mockResolvedValue({
+ downloadUrl: "https://faa/files/u/m1.nef",
+ token: "fdt_tok",
+ expiresAt: "2026-06-07T12:00:00Z",
+ });
+ mockDownloadModelFile.mockRejectedValue(
+ new ModelDownloadError(0, "network_error", "Failed to fetch"),
+ );
+ const result = await useModelStore.getState().downloadModel(convertedReady);
+ expect(result).toMatchObject({ ok: false, code: "network_error" });
+ expect(useModelStore.getState().downloadingId).toBeNull();
+ });
+
+ it("非 ModelDownloadError 的例外 → 退化成 unknown", async () => {
+ mockGetModelDownload.mockRejectedValue(new Error("weird"));
+ const result = await useModelStore.getState().downloadModel(convertedReady);
+ expect(result).toMatchObject({ ok: false, code: "unknown" });
+ });
+});
diff --git a/visionA-frontend/src/stores/model-store.ts b/visionA-frontend/src/stores/model-store.ts
index 62236ca..1d37dce 100644
--- a/visionA-frontend/src/stores/model-store.ts
+++ b/visionA-frontend/src/stores/model-store.ts
@@ -19,6 +19,12 @@
import { create } from "zustand";
import { ApiError, api } from "@/lib/api";
+import {
+ deriveDownloadFilename,
+ downloadModelFile,
+ getModelDownload,
+ ModelDownloadError,
+} from "@/lib/api/model-download";
/* -------------------------------------------------------------------------- */
/* Types — 對齊 api-spec.md §4 */
@@ -116,12 +122,31 @@ function normalizeInitResult(raw: unknown): UploadInitResult {
/* Store */
/* -------------------------------------------------------------------------- */
+/** download action 的回傳:成功 / 失敗(帶 i18n code 給 UI 顯示 toast / inline 錯誤)。 */
+export type DownloadResult =
+ | { ok: true }
+ | { ok: false; code: string; message: string };
+
+/**
+ * 判斷一個 model 是否可下載(Phase 0.9 第一階段:只支援轉檔 promote 的 model)。
+ *
+ * 為什麼只有 `converted`:第一階段 FAA delegated download 只覆蓋「轉檔→promote」類 model
+ * (ADR-017 §10.4 B1 object_key 斷層)。上傳類 model 在 visionA 自己的 storage、沒有 FAA object_key,
+ * backend 會回 501 upload_not_supported。UI 依此條件隱藏下載按鈕、避免使用者點了才吃 501。
+ */
+export function isModelDownloadable(model: Pick): boolean {
+ return model.source === "converted" && model.status === "ready";
+}
+
interface ModelState {
models: ModelSummary[];
selectedModel: Model | null;
isLoading: boolean;
error: string | null;
+ /** 正在下載中的 model id(同時間只允許一個下載;null = 無)。供 UI 顯示 per-card loading。 */
+ downloadingId: string | null;
+
/** 呼叫 `GET /api/models` */
fetchModels: () => Promise;
/** 呼叫 `GET /api/models/:id` */
@@ -138,6 +163,11 @@ interface ModelState {
finalizeUpload: (modelId: string, etag: string | null) => Promise;
/** 呼叫 `DELETE /api/models/:id` */
deleteModel: (id: string) => Promise;
+ /**
+ * 下載模型檔(兩步:`GET /api/models/:id/download` 取 FAA 授權 → 帶 Bearer token 直連 FAA 取 blob)。
+ * 回 `DownloadResult`,由 UI 決定顯示 toast / inline 錯誤(用 `code` 對應 i18n)。
+ */
+ downloadModel: (model: ModelSummary) => Promise;
/** 測試 / 雛形用 */
_setModels: (models: ModelSummary[]) => void;
@@ -149,6 +179,7 @@ export const useModelStore = create()((set, get) => ({
selectedModel: null,
isLoading: false,
error: null,
+ downloadingId: null,
fetchModels: async () => {
set({ isLoading: true, error: null });
@@ -218,6 +249,39 @@ export const useModelStore = create()((set, get) => ({
}
},
+ downloadModel: async (model) => {
+ // 防呆:不可下載的 model(非 converted / 非 ready)直接回對應錯誤、不打 API。
+ if (!isModelDownloadable(model)) {
+ return {
+ ok: false,
+ code: "upload_not_supported",
+ message: "model is not downloadable",
+ };
+ }
+ // 同時間只允許一個下載中(避免重複點擊 / 多檔同時拉爆頻寬)。
+ if (get().downloadingId) {
+ return { ok: false, code: "busy", message: "another download is in progress" };
+ }
+
+ set({ downloadingId: model.id });
+ try {
+ const grant = await getModelDownload(model.id);
+ const filename = deriveDownloadFilename(grant.downloadUrl, model.name);
+ await downloadModelFile(grant.downloadUrl, grant.token, filename);
+ return { ok: true };
+ } catch (err) {
+ // ModelDownloadError 帶 code(i18n 用);其他 Error 退化成 unknown。
+ if (err instanceof ModelDownloadError) {
+ return { ok: false, code: err.code, message: err.message };
+ }
+ const message = err instanceof Error ? err.message : String(err);
+ return { ok: false, code: "unknown", message };
+ } finally {
+ // 無論成功 / 失敗都要清掉 loading 狀態(避免按鈕卡在 disabled)。
+ set({ downloadingId: null });
+ }
+ },
+
_setModels: (models) => set({ models }),
_setSelected: (selectedModel) => set({ selectedModel }),
}));