當選取大型圖片或相片時,直接將其轉換為行內 base64 資料會大幅膨脹酬載(payload)大小,並消耗大量的輸入 Token。透過在用戶端將圖片尺寸縮小至小於或等於 768 px 後再發送至 Firebase AI Logic,我們能將圖片輸入成本限制在僅 256 個 Token,進而大幅削減雲端帳單。Gemini 效能足夠強大,即使使用較小的圖片,也能準確產生替代文字(alt text)、標籤與推薦內容。
以下是我用來建置此功能的端對端開發工作流程:
/grill-with-docs 進行規劃:建立計畫以起草新的 ADR、定義用於調整圖片大小的純工具函式(pure utility functions)、建立新的 Angular 使用量元件,並更新 VisionService 以在將圖片發送至 Firebase AI Logic 之前先行縮小尺寸。DESIGN.md:使用 /grill-with-docs 更新 DESIGN.md 的第 11 節(「Usage & Optimization Telemetry」)。styles.css 中產生新的主題 Token、自訂 CSS 變數與工具類別(utility classes)。/implement 技能撰寫調整尺寸的工具函式、以 Angular Signals 建置新的使用量元件,並更新 VisionService。AGENTS.md 和 GEMINI.md 中的規範。我們提示 Gemini 產生一份新的 ADR,內容關於在將壓縮後的圖片編碼為行內 base64 資料並提交給 Firebase AI Logic 進行影像分析之前,先在用戶端對選取的圖片進行預處理。
新 ADR 的檔案路徑為 docs/adr/0011-client-side-image-preprocessing-for-vision-ai.md,/implement 技能可以檢閱該檔案以產生新的程式碼與元件。
### 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 數量以及檔案大小縮減的百分比。
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 ...
}
@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>
DashboardComponent 延遲載入(@defer)AppUsageMetricsComponent,使其不會被載入至初始打包組合(initial bundle)中。只有在 Token 使用量或最佳化圖片指標可用時,該元件才會被下載到瀏覽器中。
@let imageAnalysis = analysis();
@defer (when imageAnalysis?.tokenUsage || imageAnalysis?.optimizationMetrics) {
<app-usage-metrics
[tokenUsage]="imageAnalysis?.tokenUsage"
[optimizationMetrics]="imageAnalysis?.optimizationMetrics"
/>
}
此功能已開發完成,並且可以在儀表板上查看相關指標。

retro 是我為此功能安裝的新技能。它負責在完成實作後對編程會話(coding session)進行回顧。過去在 AI 輔助配對編程活動中,我常常感到挫折,因為總是要不斷指出差異並指導 Gemini 如何寫出乾淨的程式碼。我讓 Gemini 使用此技能來分析編程會話,並列出可改進的項目。
/retro
回饋的結果促成了 AGENTS.md 的更新。
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 和我進行了深入的溝通來拆解這個龐大的函式,並將參數列表轉換為介面。
### 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。
我們自訂了 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,以保護儀表板免受未經授權的存取。