iT邦幫忙

2026 iThome 鐵人賽

0
Build on Google AI

2026年,如何利用 Antigravity CLI、Gemini、各項技能及 MCP Server 建構基於 Firebase 的 Angular 應用系列 第 32 篇

Day 32 - [成本最佳化] 節約型 AI:縮小影像酬載以大幅降低雲端成本

  • 分享至 

  • xImage
  •  

當選取大型圖片或相片時,直接將其轉換為行內 base64 資料會大幅膨脹酬載(payload)大小,並消耗大量的輸入 Token。透過在用戶端將圖片尺寸縮小至小於或等於 768 px 後再發送至 Firebase AI Logic,我們能將圖片輸入成本限制在僅 256 個 Token,進而大幅削減雲端帳單。Gemini 效能足夠強大,即使使用較小的圖片,也能準確產生替代文字(alt text)、標籤與推薦內容。

以下是我用來建置此功能的端對端開發工作流程:

  1. 使用 /grill-with-docs 進行規劃:建立計畫以起草新的 ADR、定義用於調整圖片大小的純工具函式(pure utility functions)、建立新的 Angular 使用量元件,並更新 VisionService 以在將圖片發送至 Firebase AI Logic 之前先行縮小尺寸。
  2. 更新 DESIGN.md:使用 /grill-with-docs 更新 DESIGN.md 的第 11 節(「Usage & Optimization Telemetry」)。
  3. 使用 Stitch MCP 產生畫面:呼叫 Stitch MCP 伺服器以產生納入第 11 節變更的新桌面端與行動端畫面,同時保持其餘部分不變。隨後在 Stitch UI 中檢視畫面並移除過期的畫面。
  4. 更新樣式:提示 Gemini 根據新的 Stitch 畫面在 styles.css 中產生新的主題 Token、自訂 CSS 變數與工具類別(utility classes)。
  5. 實作程式碼:使用 /implement 技能撰寫調整尺寸的工具函式、以 Angular Signals 建置新的使用量元件,並更新 VisionService。
  6. 審查與重構:重構程式碼以確保零 ESLint 錯誤,並完全符合 AGENTS.md 和 GEMINI.md 中的規範。

產生新的 ADR

我們提示 Gemini 產生一份新的 ADR,內容關於在將壓縮後的圖片編碼為行內 base64 資料並提交給 Firebase AI Logic 進行影像分析之前,先在用戶端對選取的圖片進行預處理。

新 ADR 的檔案路徑為 docs/adr/0011-client-side-image-preprocessing-for-vision-ai.md,/implement 技能可以檢閱該檔案以產生新的程式碼與元件。

更新 DESIGN.md 並設計新的 Stitch 畫面

### 11. Usage & Optimization Telemetry (`UsageMetricsComponent` & `ThoughtSummaryComponent`)

- **Telemetry Container (`.metrics-container`)**:
  - Full-width wrapper with responsive layout (`w-full flex flex-col md:flex-row gap-4 mt-6`).
- **Telemetry Card Surface (`.metrics-card`)**:
  - Surface card (`bg-slate-700/50 p-4 rounded-lg border border-slate-600 flex-1`).
- **Text Generation Token Usage Card**:
  - **Heading (`.section-title`)**: `"Text Generation Token Usage"` (`text-lg font-semibold text-slate-300 mb-3`).
  - **Container (`.usage-grid`)**: Responsive statistics row (`flex flex-wrap justify-around gap-2`).
  - **Items (`.usage-item`)**: 4 statistics formatted as `text-slate-200 italic`:
    - `Input: 412`
    - `Output: 168`
    - `Thought: 84`
    - `Total: 664`
- **Image Optimization Efficiency Card** (when `optimizationMetrics` present):
  - **Heading (`.section-title`)**: `"Image Optimization Efficiency"` (`text-lg font-semibold text-slate-300 mb-3`).
  - **Container (`.usage-grid`)**: Responsive statistics row (`flex flex-wrap justify-around gap-2`).
  - **Items (`.usage-item`)**: Statistics formatted as `text-slate-200 italic`:
    - `Original Size: 4.8 MB`
    - `Optimized Size: 56 KB`
    - `Payload Saved: 98.8%`
    - `Tokens Saved: ~5,934`

DESIGN.md 的第 11 節描述了一個新的 UsageMetricsComponent,它以卡片版面配置展示「文字生成 Token 使用量(Text Generation Token Usage)」與「圖片最佳化效率(Image Optimization Efficiency)」。

接著,Gemini 呼叫 Stitch MCP 伺服器中的工具,根據更新後的章節產生了兩個新畫面。由於 Stitch MCP 伺服器的限制,我從 Stitch UI 中手動刪除了過期的 Stitch 首頁畫面。

桌面檢視區中更新後的指標區塊

行動裝置檢視區中更新後的指標區塊

我在 Stitch UI 上驗證了畫面,確保正確顯示新的最佳化指標卡片。

接下來,我們使用 /implement 技能,根據 ADR、DESIGN.md 以及新的 Stitch 畫面來實作最佳化。

實作新介面與圖片工具檔案

/implement the new feature based on <prd>, <adr>, and DESIGN.md

我要求 Gemini 使用 /implement 技能來產生程式碼,同時由 PRD、ADR 和 DESIGN.md 提供上下文。

Gemini 建立了用於圖片最佳化的新介面與工具檔案。

export interface Dimensions {
  width: number;
  height: number;
}

export interface OptimizationMetricsParams {
  originalSizeBytes: number;
  optimizedSizeBytes: number;
  originalDimensions: Dimensions;
  optimizedDimensions: Dimensions;
}

export interface ImageOptimizationMetrics extends OptimizationMetricsParams {
  readonly estimatedOriginalTokens: number;
  readonly actualImageTokens: number;
  readonly tokensSaved: number;
  readonly bytesSavedPercent: number;
}

export interface ImageProcessingResult {
  data: string;
  mimeType: string;
  optimizationMetrics: ImageOptimizationMetrics;
}

image-processing.interface.ts 封裝了 image.util.ts 將會匯入的新介面。

function calculateTargetDimensions(dimensions: Dimensions, maxDim: number = 768): Dimensions {
  const { width, height } = dimensions;
  if (width <= 0 || height <= 0) {
    return { width: maxDim, height: maxDim };
  }

  if (width <= maxDim && height <= maxDim) {
    return { width, height };
  }

  if (width >= height) {
    return {
      width: maxDim,
      height: Math.round(maxDim * (height / width)),
    };
  }

  return {
    width: Math.round(maxDim * (width / height)),
    height: maxDim,
  };
}

calculateTargetDimensions 函式嘗試將大圖縮小至小於或等於 768px。如果圖片本身尺寸已較小,則會直接返回原始尺寸。

function calculateImageTokens(dimensions: Dimensions): number {
  const horizontalTiles = Math.max(1, Math.ceil(dimensions.width / GEMINI_TILE_SIZE));
  const verticalTiles = Math.max(1, Math.ceil(dimensions.height / GEMINI_TILE_SIZE));
  return horizontalTiles * verticalTiles * TOKENS_PER_IMAGE_TILE;
}

calculateImageTokens 估算圖片 Token 的數量,以便我們計算節省的 Token 數。

export async function preprocessImageForVision(file: File, win?: Window | null): Promise<ImageProcessingResult> {
  const { bitmap, dimensions: originalDimensions } = await decodeSourceBitmap(file, win);
  const optimizedDimensions: Dimensions = calculateTargetDimensions(originalDimensions);
  const { blob: optimizedBlob, mimeType } = await compressBitmapToBlob(bitmap, {
    dimensions: optimizedDimensions,
    fallbackFile: file,
    win,
  });

  const optimizationMetrics: ImageOptimizationMetrics = calculateOptimizationMetrics({
    originalSizeBytes: file.size,
    optimizedSizeBytes: optimizedBlob.size,
    originalDimensions,
    optimizedDimensions,
  });

  return {
    data: await blobToBase64(optimizedBlob),
    mimeType,
    optimizationMetrics,
  };
}

preprocessImageForVision 首先壓縮原始圖片,並將壓縮後的圖片編碼為行內 base64 資料。接著,它呼叫 calculateOptimizationMetrics 來計算節省的圖片 Token 數量以及檔案大小縮減的百分比。

更新 Vision Service

generateAltText 方法呼叫 preprocessImageForVision 以取得行內資料、MIME 類型以及最佳化後的圖片指標。隨後,Firebase AI Logic 接收縮小圖片後的資料與 MIME 類型,以產生替代文字、標籤與推薦內容。

async generateAltText(image: File): Promise<ImageAnalysisResponse> {
    const { data, mimeType, optimizationMetrics } = await preprocessImageForVision(image, this.#window);
    const imagePart = {
      inlineData: {
        data,
        mimeType,
      },
    };

    const altTextPrompt = `...text prompt...`;
    const result = await aiModel.generateContent([altTextPrompt, imagePart]);

    ... the rest is the same ...
}

新增 Usage Metrics 元件

@Component({
  selector: 'app-usage-metrics',
  styleUrl: './app-usage-metrics.component.css',
  templateUrl: './app-usage-metrics.component.html',
})
export class AppUsageMetricsComponent {
  readonly tokenUsage = input<TokenUsage | undefined>(undefined);
  readonly optimizationMetrics = input<ImageOptimizationMetrics | undefined>(undefined);

  readonly formattedMetrics = computed(() => {
    const metrics = this.optimizationMetrics();
    return {
      originalSize: metrics ? formatFileSize(metrics.originalSizeBytes) : '0 B',
      optimizedSize: metrics ? formatFileSize(metrics.optimizedSizeBytes) : '0 B',
      payloadSaved: metrics ? `${metrics.bytesSavedPercent}%` : '0%',
      tokensSaved: metrics ? `~${metrics.tokensSaved.toLocaleString()}` : '0',
    };
  });
}

AppUsageMetricsComponent 在 HTML 範本中顯示 Token 使用量與最佳化後的圖片指標。formattedMetrics 是一個 computed signal,它會返回原始檔案大小、新檔案大小、檔案縮減百分比以及節省的圖片 Token 數。

<div class="metrics-container">
  ...  token usage ....

  @if (optimizationMetrics()) {
    @let metrics = formattedMetrics();
    <div class="metrics-card">
      <h3 class="section-title">Image Optimization Efficiency</h3>
      <div class="usage-grid">
        <p class="usage-item">
          <span>Original Size: </span><span>{{ metrics.originalSize }}</span>
        </p>
        <p class="usage-item">
          <span>Optimized Size: </span><span>{{ metrics.optimizedSize }}</span>
        </p>
        <p class="usage-item">
          <span>Payload Saved: </span><span>{{ metrics.payloadSaved }}</span>
        </p>
        <p class="usage-item">
          <span>Tokens Saved: </span><span>{{ metrics.tokensSaved }}</span>
        </p>
      </div>
    </div>
  }
</div>

在儀表板中延遲載入 Usage Metrics 元件

DashboardComponent 延遲載入(@defer)AppUsageMetricsComponent,使其不會被載入至初始打包組合(initial bundle)中。只有在 Token 使用量或最佳化圖片指標可用時,該元件才會被下載到瀏覽器中。

@let imageAnalysis = analysis();
@defer (when imageAnalysis?.tokenUsage || imageAnalysis?.optimizationMetrics) {
  <app-usage-metrics
    [tokenUsage]="imageAnalysis?.tokenUsage"
    [optimizationMetrics]="imageAnalysis?.optimizationMetrics"
  />
}

此功能已開發完成,並且可以在儀表板上查看相關指標。

最佳化圖片指標

對編程會話進行回顧(Retrospective)

retro 是我為此功能安裝的新技能。它負責在完成實作後對編程會話(coding session)進行回顧。過去在 AI 輔助配對編程活動中,我常常感到挫折,因為總是要不斷指出差異並指導 Gemini 如何寫出乾淨的程式碼。我讓 Gemini 使用此技能來分析編程會話,並列出可改進的項目。

/retro

回饋的結果促成了 AGENTS.md 的更新。

問題 1:未強制執行 ESLint 規則

8. **Service & Pure Utility Design Boundaries**:
   - **Core Data Services (`src/app/core/services/`)**: Thin orchestrators for SDK/backend interactions and atomic snapshot Signals (e.g. `readonly activeAudio = this.#activeAudio.asReadonly()`). Inject platform tokens using native `#` private state (`readonly #window = inject(WINDOW);`). Never access DOM `document` or global `window` directly, and never store presentation-only UI state or pass-through getters.
   - **Pure Web Utilities (`src/app/core/utils/`)**: Stateless functions for CPU-heavy transformations, canvas operations, token arithmetic, and format conversions. Never hold state or inject Angular services; accept platform references explicitly (e.g. `win?: Window | null`).
   - **Strict `eslint.config.mjs` Compliance**: All authored code must comply on initial generation (max 3 parameters using typed `options` objects for $\ge 3$, max 40 lines per function, complexity $\le$ 10, no magic numbers, and absolute `@/` imports).

Gemini 忽略了 GEMINI.md 與 eslint.config.mjs,產生了一個長達 150 行且具有 4 個以上參數的工具函式。有些工具函式甚至直接存取 document 和 window,而不是從 VisionService 接收 window 執行個體。

Gemini 和我進行了深入的溝通來拆解這個龐大的函式,並將參數列表轉換為介面。

問題 2:未套用自訂 CSS 變數

### Pre-Implementation Discovery Protocol

Before authoring new utilities, styles, or services, inspect existing project declarations first:

- **Design Tokens**: Inspect `@theme` in `src/styles.css` for existing tokens (e.g. `text-(--color-text-secondary)`) instead of using raw Tailwind palette classes.

元件的 CSS 類別使用了 text-slate-200,而不是自訂 CSS 變數 --color-text-secondary。因此,Gemini 在 AGENTS.md 中新增了一條規則:在元件 CSS 檔案中產生 CSS 類別之前,必須先檢查 styles.css。

問題 3:忽略 GEMINI.md 與 ADR

我們自訂了 GEMINI.md,要求注入 DOCUMENT 或 WINDOW 代替全域 document 或 window,以避免 Angular SSR 當機。然而,產生的程式碼中卻包含了 typeof document 和 typeof window 檢查。

ADR 中已經定義了介面,但產生的程式碼並未嚴格遵循。

- **Platform Injections**: Inspect `src/app/core/constants/navigator.const.ts` for existing injection tokens (`WINDOW`, `NAVIGATOR`) instead of writing ad-hoc `typeof` checks.
- **Architectural Decisions**: Inspect `docs/adr/` for relevant domain specifications and design decisions.

我們新增了規範,以引導 Gemini 採用 Angular 最佳實踐,並在程式碼生成期間嚴格遵循 ADR。

第 32 天的內容就到此告一段落!明天,我將學習如何新增 Firebase Auth,以保護儀表板免受未經授權的存取。

相關資源


上一篇
Day 31 - 降低雲端成本與延遲:AI 語音合成的響應式快取
系列文
2026年,如何利用 Antigravity CLI、Gemini、各項技能及 MCP Server 建構基於 Firebase 的 Angular 應用 共 32 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言