iT邦幫忙

0

使用 Angular、Firebase AI Logic 與 Gemini Nano 打造混合式影像分析應用程式

  • 分享至 

  • xImage
  •  

本教學將示範如何使用 Angular、Firebase AI Logic 與 Gemini 打造混合式影像分析應用程式。使用者只需上傳圖片、點擊按鈕,並等待 Gemini Nano 或 Gemini 3.5 Flash 模型產生標籤、替代文字(Alt-text)以及推薦建議。

Firebase AI Logic 的強大之處在於其混合與裝置端網頁 SDK(Hybrid and On-device Web SDK)能自動決定要使用裝置端本機或是雲端 AI 模型。在此範例中,當在 Chrome 瀏覽器中執行時,SDK 會下載並使用 Gemini Nano。當在其他瀏覽器中執行時,則會由 Gemini 3.5 Flash 模型處理影像分析任務。底層所使用的模型對使用者而言完全是透明的,且不論後端為何,都能獲得高品質的影像分析回應。

1. 前提條件

  • Angular 22
  • TailwindCSS
  • Node 24
  • gemini-3.5-flash
  • Firebase AI Logic
  • Firebase Remote Config
  • Firebase Local Emulator Suite
  • Firebase App Hosting
  • Firebase App Check

雖然 Angular 和 CSS 提供了使用者介面與樣式,但此範例的 AI 功能是由 Firebase 服務(例如 Firebase AI Logic 和 Firebase Remote Config)所提供。

npm i -g firebase-tools

使用 npm 全域安裝 firebase-tools

npm i --save-exact firebase tailwindcss postcss @tailwindcss/postcss jsonrepair
npm i --save-exact --save-dev angular-eslint firebase-tools husky lint-staged serve

安裝 Firebase AI Logic、CSS 樣式以及 JSON 回應串流所需的其他依賴套件。安裝開發依賴套件(dev dependencies)是為了偵測程式碼異味(code smells)、確保程式碼品質,並呼叫 Firebase CLI 執行特定任務。

2. 原始碼

此專案的完整原始碼已託管於 NG Firebase Image Analyzer;然而,以下章節將說明 Firebase 專案的設定,並提供 Firebase 混合與裝置端網頁 SDK 的程式碼範例。

以下章節將逐步引導您進行 Firebase 專案設定。

3. 在 Angular 中設定 Firebase 專案

在任何 Angular 服務向 Firebase AI Logic 發送請求以執行 AI 任務之前,必須先在 Angular 專案中設定好 Firebase 專案。

3.1 存取與管理 Firebase 專案

firebase logout
firebase login

注意: 建議在設定新專案之前,先登出並重新登入 Firebase 以重新整理驗證認證。

建立一個 firebase 資料夾來儲存 .firebasesrc.firebase.json 這兩個 Firebase 設定檔。您可以使用 Antigravity CLI 與 Gemini 在該資料夾中產生這些設定檔。Antigravity CLI 是 Google 開發的編碼代理程式(coding agent),在命令列中使用 AI 模型來解決複雜問題。您可以在此頁面找到相關資訊。

在我的提示詞中,我指示了 AI 模型預設的 Firebase 專案 ID 以及要安裝的服務。此範例利用了 Firebase AI Logic、用於生產環境部署的 App Hosting,以及用於模擬託管的 Firebase Local Emulator Suite,以此加速開發週期。

以下是一個非常簡單的 firebase/.firebasesrc

{
  "projects": {
    "default": "<Firebase Project ID>"
  }
}

以下是完整的設定檔 firebase/firebase.json

{
  "apphosting": [
    {
      "backendId": "ng-firebase-image-analyzer",
      "rootDir": "..",
      "ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log", "functions"]
    }
  ],
  "emulators": {
    "apphosting": {
      "port": 5005,
      "rootDirectory": "..",
      "startCommand": "npm run start"
    },
    "ui": {
      "enabled": true
    }
  }
}

apphosting 區塊顯示了 Angular 程式碼的根目錄位於父資料夾,並在排除上傳的清單中忽略了一系列資料夾與檔案。

emulators 區塊啟用了 App Hosting 模擬,Angular 應用程式會執行在連接埠 5005,URL 為 http://localhost:5005

以下是簡單的 apphosting.yaml

runConfig:
  minInstances: 0
  maxInstances: 2

此 YAML 檔案將活動中執行個體限制在 0 到 2 之間,以防止因無限擴充而產生預期之外的雲端成本。

接下來,建立一個新的 firebase/scripts 資料夾來儲存 Node.js 指令碼,以利 Firebase 與 Angular 的整合。

mkdir -p firebase/scripts

3.2 使用 Firebase CLI 獲取預設 Firebase Remote Config

我並非手動從 Firebase 控制台下載預設的 Firebase Remote Config JSON 檔案,而是指示 Antigravity CLI 與 Gemini 下載 Firebase Remote Config 數值、將回應轉換為 JSON 鍵值對(key-value pairs),然後將檔案寫入至 ../public/remote-config-defaults.json。如此一來,Angular 應用程式便可在開發期間與生產環境建置中,透過程式碼存取該 JSON 檔案。

const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');

const firebaseDir = path.resolve(__dirname, '..');

const output = execSync('npx firebase remoteconfig:get --json --project default', {
  encoding: 'utf-8',
  cwd: firebaseDir,
});
const config = JSON.parse(output);
const parameters = config?.result?.parameters;

const defaults = {};
if (parameters) {
  for (const [key, paramObj] of Object.entries(parameters)) {
    if (paramObj.defaultValue && paramObj.defaultValue.value !== undefined) {
      defaults[key] = paramObj.defaultValue.value;
    }
  }
}

const outputPath = path.join(firebaseDir, '..', 'public', 'remote-config-defaults.json');
fs.writeFileSync(outputPath, `${JSON.stringify(defaults, null, 2)}\n`, 'utf-8');
console.log(`Successfully wrote ${outputPath}`);

package.json 中新增指令碼來執行該 Node.js 檔案。

"scripts": {
    "config:fetch": "node firebase/scripts/get-firebase-remote-config.js"
}

輸出結果如下所示:

{
  "geminiModelName": "gemini-3.5-flash",
  "vertexAILocation": "global",
  "thinkingLevel": "LOW"
}

完整程式碼可在這個原始碼檔案中找到。

3.3 產生用於應用程式初始化的 Firebase App 物件

Angular 應用程式在向 Firebase AI Logic 發送任何請求之前,必須先在啟動(bootstrap)過程中初始化 Firebase App 與 App Check。我們使用 Node.js 指令碼從 .env 讀取並產生 Firebase App 設定物件、reCAPTCHA Enterprise 金鑰與 App Check 偵錯權杖(Debug Token),然後將其寫入 public/firebase.config.json。我已將此檔案加入至 .gitignore 中,因為 Recaptcha Enterprise 金鑰與 App Check 偵錯權杖都是高度敏感的資訊,絕不能外洩。

複製 .env.example 並重新命名為 .env,然後填入有效資訊。

FIREBASE_API_KEY="<Firebase API Key>"
FIREBASE_AUTH_DOMAIN="<Firebase Auth Domain>"
FIREBASE_PROJECT_ID="<Firebase Project ID>"
FIREBASE_STORAGE_BUCKET="<Firebase Storage Bucket>"
FIREBASE_MESSAGING_SENDER_ID="<Firebase Messaging Sender ID>"
FIREBASE_APP_ID="<Firebase App ID>"
FIREBASE_RECAPTCHA_ENTERPRISE_KEY="<Recaptcha Enterprise Key>"
FIREBASE_APPCHECK_DEBUG_TOKEN="<App Check debug token>"
const fs = require('fs');
const path = require('path');

const envPath = path.resolve(__dirname, '../.env');
process.loadEnvFile(envPath);

const app = {
  apiKey: process.env.FIREBASE_API_KEY,
  authDomain: process.env.FIREBASE_AUTH_DOMAIN,
  projectId: process.env.FIREBASE_PROJECT_ID,
  storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.FIREBASE_APP_ID,
};

// Check if recaptcha enterprise key is missing
if (!process.env.FIREBASE_RECAPTCHA_ENTERPRISE_KEY) {
  console.warn('Warning: Recaptcha Enterprise key is missing');
}

const config = {
  app,
  recaptchaEnterpriseKey: process.env.FIREBASE_RECAPTCHA_ENTERPRISE_KEY,
  appCheckDebugToken: process.env.FIREBASE_APPCHECK_DEBUG_TOKEN || '',
};

const outputPath = path.resolve(__dirname, '../../public/firebase.config.json');
fs.writeFileSync(outputPath, JSON.stringify(config, null, 2), 'utf-8');
console.log(`Successfully generated ${outputPath} from .env`);

package.json 中新增指令碼來執行該 Node.js 檔案。

"scripts": {
  "config:generate": "node firebase/scripts/generate-firebase-config.js"
}

JSON 物件如下所示:

{
  "app": {
    "apiKey": "firebase api key",
    "authDomain": "auth domain",
    "projectId": "project key",
    "storageBucket": "storage bucket",
    "messagingSenderId": "message sender id",
    "appId": "app id"
  },
  "recaptchaEnterpriseKey": "<Recaptcha key>",
  "appCheckDebugToken": "<app check debug key>"
}

完整程式碼可在這個原始碼檔案中找到。

3.4 App Hosting

App Hosting 是在 Firebase 主控台中進行設定。

  1. 選擇您的網頁專案
  2. 點擊左側選單的 App Hosting
  3. 連結您的 GitHub 儲存庫並選擇您的區域(例如 asia-east1)。
  4. 點擊 Settings (設定)
  5. 點擊 Domain (網域) 項目以檢查要發行部署的 GitHub 帳號、儲存庫和實際執行的分支。 部署螢幕截圖
  6. 點擊 Environment (環境) 項目以輸入環境變數。這些環境變數與 .env 中的環境變數相同。 環境變數螢幕截圖
  7. 點擊 Rollouts (版本推出) 項目以保持預設設定。 版本推出螢幕截圖
  8. 點擊 Automatic base image updates (自動基礎映像檔更新) 項目,並選擇最新的 Node.js 執行階段,即 Node 24。 執行階段螢幕截圖

4. 架構

Angular 應用程式會初始化 Firebase App,並搭配雲端架構的 Gemini 模型、裝置端參數、雲端端參數、系統提示詞以及安全設定來建構出 HybridParams。接著,使用者上傳影像與使用者提示詞,指示模型執行影像分析以產生回應。

Firebase AI Logic Hybrid 與 On-device Web SDK 會判斷瀏覽器是否支援 Gemini Nano 來處理影像轉文字(image-to-text)任務。如果支援 Gemini Nano,便會使用它在裝置端分析影像以產生標籤、替代文字和推薦建議。否則,該請求將會被發送至雲端,並由 Gemini 3.5 Flash 模型進行處理。

Angular 與 Firebase 混合式 AI 架構

接下來,我們將準備好透過初始化 Firebase 應用程式、獲取 Remote Config,以及透過 App Check 啟用安全性功能,將 Firebase 整合至 Angular。

5. 在 Angular 中初始化 Firebase 應用程式

Angular 會在啟動(bootstrap)期間初始化 Firebase 應用程式。我們宣告幾個相依注入權杖(Injection Tokens)以注入 navigator、window 和 Firebase AI 執行個體。接著,我們定義封裝了 Firebase SDK 的 Angular 服務,以初始化 Firebase App、獲取並啟用 Firebase Remote Config 數值,並透過 App Check 保護 AI 資源免受濫用。

5.1. 相依注入權杖 (Injection Tokens)

import { isPlatformBrowser } from '@angular/common';
import { inject, InjectionToken, PLATFORM_ID } from '@angular/core';
import { AI } from 'firebase/ai';

export const FIREBASE_AI = new InjectionToken<AI>('FirebaseAI');

export const WINDOW = new InjectionToken<Window | null>('WINDOW', {
  providedIn: 'root',
  factory: () => {
    const platformId = inject(PLATFORM_ID);
    return isPlatformBrowser(platformId) ? window : null;
  },
});

export const NAVIGATOR = new InjectionToken<Navigator | null>('NAVIGATOR', {
  providedIn: 'root',
  factory: () => {
    const win = inject(WINDOW);
    return win ? win.navigator : null;
  },
});

當執行平台為瀏覽器時,處理工廠函式(factory functions)會分別傳回 Window 與 Navigator 物件。

5.2. 提供 Firebase AI 模型

import { FIREBASE_AI } from '@/features/ai/constants/ai.const';
import { ConfigService } from '@/features/ai/services/config.service';
import { EnvironmentProviders, inject, makeEnvironmentProviders } from '@angular/core';
import { getAI, VertexAIBackend } from 'firebase/ai';
import { getValue } from 'firebase/remote-config';

export function provideFirebaseAI(): EnvironmentProviders {
  return makeEnvironmentProviders([
    {
      provide: FIREBASE_AI,
      useFactory: () => {
        const configService = inject(ConfigService);
        const location = getValue(configService.RemoteConfig, 'vertexAILocation').asString() || 'global';
        return getAI(configService.firebaseApp, {
          backend: new VertexAIBackend(location),
        });
      },
    },
  ]);
}

provideFirebaseAI 提供者(provider)使用 FIREBASE_AI 注入權杖來建構以 Vertex AI 作為後端的 Firebase AI。在此架構中之所以採用 Vertex AI,是因為其端點(endpoints)可在香港等地區直接存取,而標準的 Gemini API 則需要 VPN。

5.3. 設定服務 (Config Service)

ConfigService 的核心方法是 initialize(),其會初始化 Firebase App、設定 App Check 執行個體,並獲取與啟用 Firebase Remote Config 數值。

import { inject, InjectionToken, isDevMode } from '@angular/core';
import { WINDOW, NAVIGATOR } from '@/core/constants/navigator.const';

export const LOCAL_DOMAINS = new InjectionToken<string[]>('LOCAL_DOMAINS', {
  providedIn: 'root',
  factory: () => ['localhost', '127.0.0.1', '::1', '[::1]'],
});

export function injectIsLocalhost(): () => boolean {
  const win = inject(WINDOW);
  const localDomains = inject(LOCAL_DOMAINS);

  return () => !!win && localDomains.includes(win.location.hostname);
}

export function configureAppCheckDebugToken(configToken?: string, isLocalhost?: boolean): void {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  (globalThis as any).FIREBASE_APPCHECK_DEBUG_TOKEN = configToken || isDevMode() || !!isLocalhost;
}

injectIsLocalhost 建立並傳回一個高階函式(higher-order function),用於判斷應用程式是否在 localhost 本地端執行。

export function injectOnlineStatus(): () => boolean {
  const navigator = inject(NAVIGATOR);
  return () => navigator?.onLine ?? true;
}

另一方面,injectOnlineStatus 則傳回一個高階函式,用以判斷應用程式目前是在線上(online)還是離線(offline)狀態執行。

import firebaseConfig from '@/public/firebase.config.json';
import remoteConfigDefaults from '@/public/remote-config-defaults.json';
import { isDevMode, Service } from '@angular/core';
import { FirebaseApp, FirebaseOptions, initializeApp } from 'firebase/app';
import { AppCheck, initializeAppCheck, ReCaptchaEnterpriseProvider } from 'firebase/app-check';
import { fetchAndActivate, getRemoteConfig, RemoteConfig } from 'firebase/remote-config';

@Service()
export class ConfigService {
  #app: FirebaseApp | undefined = undefined;
  #remoteConfig: RemoteConfig | undefined = undefined;
  #isOnline = injectOnlineStatus();
  #isLocalhost = injectIsLocalhost();

  protected initializeAppCheckInstance(app: FirebaseApp, key: string): AppCheck {
    return initializeAppCheck(app, {
      provider: new ReCaptchaEnterpriseProvider(key),
      isTokenAutoRefreshEnabled: true,
    });
  }

  protected setupRemoteConfig(app: FirebaseApp): RemoteConfig {
    const rc = getRemoteConfig(app);
    rc.defaultConfig = remoteConfigDefaults;
    rc.settings.minimumFetchIntervalMillis = isDevMode() ? 0 : 3600000;
    return rc;
  }

  async initialize(): Promise<void> {
    this.#app = this.initializeApp(firebaseConfig.app);

    const isOnline = this.#isOnline();
    const isLocalhost = this.#isLocalhost();

    if (isOnline && firebaseConfig.recaptchaEnterpriseKey) {
      configureAppCheckDebugToken(firebaseConfig.appCheckDebugToken, isLocalhost);
      this.initializeAppCheckInstance(this.#app, firebaseConfig.recaptchaEnterpriseKey);
    }

    this.#remoteConfig = this.setupRemoteConfig(this.#app);

    if (isOnline) {
      await this.fetchAndActivate(this.#remoteConfig);
    }
  }
}

在 Firebase App 初始化之後,會檢查連線狀態以及 Enterprise reCAPTCHA 金鑰。當這兩個條件都滿足時,便會初始化 Firebase App Check 的執行個體。最後,獲取並啟用 Remote Config 以覆寫預設值。

提示
Angular 22 架構最佳實踐:
Angular 團隊現在推薦針對全域單例(global singletons)使用 @Service() 裝飾器,而非 @Injectable({ providedIn: 'root' })。因為它更乾淨、包含更少的樣板程式碼(boilerplate),且是專門為了與現代 inject() 函式型相依注入搭配使用而設計。
注意:如果您需要基於建構子的相依注入(constructor-based dependency injection)或限定範圍的提供者(scoped providers,例如元件層級的 scope),@Injectable() 仍完全受到支援且是必要的。

5.4. 啟動應用程式

import { provideFirebaseAI } from '@/features/ai/providers/ai.provider';
import { ConfigService } from '@/features/ai/services/config.service';
import { ApplicationConfig, inject } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    ... other providers ... 
    provideAppInitializer(() => inject(ConfigService).initialize()),
    provideFirebaseAI(),
  ],
};

provideFirebaseAIprovideAppInitializer 包含在 appConfigproviders 陣列中。

import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';

bootstrapApplication(App, appConfig).catch((err) => console.error(err));

呼叫 bootstrapApplication 可確保 Firebase AI 模型、Firebase 應用程式、App Check 以及 Firebase Remote Config 的數值在啟動期間正確地初始化。

Firebase 整合完成後,我們將焦點轉向 Angular AI 服務以及 Angular 元件中的 JSON 回應串流。

6. Angular 中的影像分析服務

6.1. 支援 JSON 回應串流的 AI 服務

混合式影像分析應用程式會提示 Gemini Nano 或 Gemini 3.5 Flash 模型傳回結構化輸出,即一個 JSON 物件。因此,應用程式會串流傳輸 JSON 回應,並使用 jsonrepair 程式庫將不完整的 JSON 物件修復成有效的局部 JSON 物件。

import { EnhancedGenerateContentResponse } from 'firebase/ai';

export interface PartialResponse<T> {
  partialData: Partial<T>;
  response?: EnhancedGenerateContentResponse;
}
import { inject, Service } from '@angular/core';
import { GenerativeModel, SchemaRequest, TypedSchema } from 'firebase/ai';
import { jsonrepair } from 'jsonrepair';
import { GenerateContentParams, PartialResponse } from '../types/ai.types';
import { AiModelCacheService } from './ai-model-cache.service';

@Service()
export class AiService {
  #cacheService = inject(AiModelCacheService);

  private getCachedModel({ schema, systemInstruction }: GenerateContentParams) {
    const model = this.#cacheService.getOrCreateModel({
      schema,
      systemInstruction,
    });

    return model;
  }

  private parseStreamJSONResponse<T>(text: string, schema?: TypedSchema | SchemaRequest) {
    if (schema) {
      try {
        return JSON.parse(text) as Partial<T>;
      } catch {
        return JSON.parse(jsonrepair(text)) as Partial<T>;
      }
    }

    return { alternativeTexts: [text] } as unknown as Partial<T>;
  }

  private async downloadDeviceModel(model: GenerativeModel) {
    if (model) {
      await model.initializeDeviceModel((val) => console.log(`Download progress: ${Math.round(val * 10000) / 100}%`));
    }
  }

  private constructRequest(params: GenerateContentParams) {
    return typeof params.contents === 'string' || Array.isArray(params.contents) ? params.contents : [params.contents];
  }

  async *generateContentStream<T>(params: GenerateContentParams): AsyncGenerator<PartialResponse<T>> {
    const model = this.getCachedModel(params);
    const request = this.constructRequest(params);

    await this.downloadDeviceModel(model);

    const result = await model.generateContentStream(request);
    let accumulatedText = '';

    for await (const chunk of result.stream) {
      accumulatedText = accumulatedText + chunk.text();
      const parsed = this.parseStreamJSONResponse<T>(accumulatedText, params.schema);
      yield {
        partialData: parsed,
      };
    }

    const finalResponse = await result.response;
    const finalParsed = this.parseStreamJSONResponse<T>(finalResponse.text(), params.schema);
    yield {
      partialData: finalParsed,
      response: finalResponse,
    };
  }
}

downloadDeviceModel 輔助方法會將 Gemini Nano 模型下載至 Chrome 瀏覽器。當使用者請求影像分析任務時,它會直接在裝置本機端執行,而不是在雲端伺服器上。敏感的使用者資料不會離開本地電腦,且雲端 AI 的使用成本可保持為零。

generateContentStream 產生器(generator)會產出局部 JSON 物件與最終回應。

6.2. 影像分析服務、提示詞與結構化輸出

ImageAnalysisService 會將影像分析任務委派給 AiService,以獲取產生的標籤、替代文字和推薦建議。

export async function fileToGenerativePart(file: File | Blob, mimeType?: string): Promise<Part> {
  const resolvedMimeType = mimeType || file.type;
  if (!resolvedMimeType) {
    throw new Error('MIME type must be specified or present on the File/Blob.');
  }

  const dataUrl = await readFileAsDataURL(file);
  const base64Data = dataUrl.split(',')[1];

  return {
    inlineData: {
      data: base64Data,
      mimeType: resolvedMimeType,
    },
  };
}

fileToGenerativePart 實用工具函式會讀取上傳的影像,並將其轉換為行內(inline)Base64 資料。該函式會傳回 Gemini 模型所需的資料和 MIME 類型。

export const SYSTEM_INSTRUCTION = `You are an expert image analyst, accessibility specialist, and professional photo editor.

Your behavior rules:
1. Always output strictly in raw JSON matching the provided schema, with no markdown formatting or conversational text.
2. For descriptions, prioritize screen-reader accessibility guidelines.
3. For tags, always provide an objective visual reason and platform-specific variants.`;

export const IMAGE_ANALYSIS_USER_PROMPT =
  'Analyze the attached image and generate the alternative descriptions, tags, and styling suggestions according to your defined system instructions and the response schema.';

SYSTEM_INSTRUCTION 是系統提示詞,而 IMAGE_ANALYSIS_USER_PROMPT 則是使用者提示詞。

系統提示詞定義了 AI 作為專業相片編輯器的角色,而使用者提示詞則描述了輸入的影像,並指定預期傳回的結構化資料。

import { Schema } from 'firebase/ai';
import { AltTextsSchema } from './alt-texts.schema';
import { RecommendationsSchema } from './recommendation.schema';
import { TagsSchema } from './tag.schema';

export const ImageAnalysisSchema = Schema.object({
  properties: {
    alternativeTexts: AltTextsSchema,
    tags: TagsSchema,
    recommendations: RecommendationsSchema,
  }
});

ImageAnalysisSchema 為使用者提示詞的結構化輸出,由替代文字、標籤與推薦建議組成。

import { fileToGenerativePart } from '@/core/utils/base64.util';
import { AiService } from '@/features/ai/services/ai.service';
import { ImageAnalysisSchema } from '@/features/image-analysis/schemas/image-analysis.schema';
import {
  ImageAnalysisResponse,
  StreamingAnalysisWithMetadata,
} from '@/features/image-analysis/types/image-analysis-metadata.type';
import { inject, Service } from '@angular/core';
import { IMAGE_ANALYSIS_USER_PROMPT, SYSTEM_INSTRUCTION } from '../prompts/image-analysis.prompt';

@Service()
export class ImageAnalysisService {
  #aiService = inject(AiService);

  async *analyzeImageStream(file: File | Blob, customPrompt?: string): AsyncGenerator<StreamingAnalysisWithMetadata> {
    const imagePart = await fileToGenerativePart(file);
    const userPrompt = customPrompt ? customPrompt : IMAGE_ANALYSIS_USER_PROMPT;

    const generator = await this.#aiService.generateContentStream<ImageAnalysisResponse>({
      systemInstruction: SYSTEM_INSTRUCTION,
      contents: [userPrompt, imagePart],
      schema: ImageAnalysisSchema,
    });

    for await (const update of generator) {
      const partialData = update.partialData;
      const analysis = {
        alternativeTexts: partialData?.alternativeTexts,
        tags: partialData?.tags,
        recommendations: partialData?.recommendations,
      };

      yield {
        analysis,
      };
    }
  }
}

analyzeImageStream 是一個產生器,會傳回影像分析的輸出結果。

7. Angular 中的影像分析使用者介面

ImageAnalysis 元件包含了一個影像上傳器,用以選擇要分析的影像。點擊按鈕時,它會呼叫 triggerAnalysis 來串流傳輸影像分析的回應,並設定 analysisData 訊號(signal)的數值。

import { ImageAnalysisService } from '@/features/image-analysis/services/image-analysis';
import { StreamingAnalysisWithMetadata } from '@/features/image-analysis/types/image-analysis-metadata.type';
import { ImageUploader } from '@/shared/ui/image-uploader/image-uploader';
import { Component, computed, inject, signal } from '@angular/core';
import { ImageAnalysisPanel } from './image-analysis-panel/image-analysis-panel';
import { TagList } from './tag-list/tag-list';
import { ImageTag } from './tag-list/types/image-tag.type';

@Component({
  selector: 'app-image-analysis',
  imports: [ImageUploader, TagList, ImageAnalysisPanel],
  templateUrl: './image-analysis.html',
  styleUrl: './image-analysis.css',
})
export default class ImageAnalysis {
  imageAnalysisService = inject(ImageAnalysisService);

  // Local reactive states
  imageUrl = signal<string | null>(null);
  selectedFile = signal<File | null>(null);
  analysisData = signal<StreamingAnalysisWithMetadata | null>(null);

  tags = computed<ImageTag[]>(() => {
    const data = this.analysisData();
    if (!data) {
      return [];
    }
    return (data.analysis.tags || []).map((t) => {
      return {
        label: t.name,
        tooltip: t.sentence,
      };
    });
  });

  onFileSelected(file: File) {
    this.selectedFile.set(file);
    this.analysisData.set(null);
    this.errorMessage.set('');
  }

  async triggerAnalysis() {
    const file = this.selectedFile();
    if (!file) {
      return;
    }

    const stream = this.imageAnalysisService.analyzeImageStream(file);
    for await (const update of stream) {
      this.analysisData.set(update);
    }
  }

  onImageRemoved() {
    this.selectedFile.set(null);
    this.analysisData.set(null);
    this.errorMessage.set('');
  }
}

analysisData 更新時,元件的 HTML 範本便會顯示最新的數值。

<div class="workspace-container">
  <app-image-uploader
    [(imageUrl)]="imageUrl"
    (fileSelected)="onFileSelected($event)"
    (imageRemoved)="onImageRemoved()"
  ></app-image-uploader>

  @if (imageUrl() && !isLoading()) {
    <div class="action-container">
      <button (click)="triggerAnalysis()" class="primary-action-btn">
        <span class="material-symbols-outlined">psychology</span>
        Analyze Image ({{ seconds() }}s)
      </button>
    </div>
  }

  @if (tags().length > 0) {
    <!-- Semantic Tag Cloud -->
    <div class="tag-cloud-section animate-fade-in">
      <h4 class="section-subtitle">Detected Classifications</h4>
      <app-tag-list [tags]="tags()"></app-tag-list>
    </div>

    @if (analysisData()) {
      <!-- Advanced Analysis Results Panel -->
      <app-image-analysis-panel
        class="animate-fade-in block"
        [data]="analysisData()"
        [imageUrl]="imageUrl()"
        [source]="source()"
      ></app-image-analysis-panel>
    }
  }
</div>

此範本將輸入(inputs)傳送至 app-tag-listapp-image-analysis-panel 元件。

<div class="tag-list-wrapper">
  <div class="tag-list-grid">
    @for (tag of tags(); track tag.label) {
      <app-badge [label]="tag.label" [tooltip]="tag.tooltip" />
    }
  </div>
</div>

以上是標籤清單元件的 HTML 範本。此元件渲染出標籤清單。當滑鼠懸停於標籤上時,會顯示工具提示(tooltip)以說明選擇該標籤的原因。

@if (data(); as analysis) {
  @let innerAnalysis = analysis.analysis;
  @let alternativeTexts = innerAnalysis?.alternativeTexts;
  @let recommendations = innerAnalysis?.recommendations;

  <div class="analysis-panel-container">
    <app-tab-group>
      <app-tab id="thought" label="Thought Summary">
        <app-thought-summary
          [alternativeTexts]="alternativeTexts"
        />
      </app-tab>

      <app-tab id="detailed" label="Recommendations">
        <app-image-recommendation [recommendations]="recommendations" />
      </app-tab>
    </app-tab-group>
  </div>
}

app-image-analysis-panel 在不同的分頁(頁籤)中渲染替代文字與推薦建議,供使用者檢視。

8. 結論

這為使用 Angular、Firebase AI Logic Hybrid and On-device Web SDK 以及 Gemini 模型建置混合式影像分析應用程式的旅程劃下了句點。

工程師可以建置在本地端執行、並在 Chrome 瀏覽器中使用 Gemini Nano 模型的 AI 應用程式。當網路不穩定時,此應用程式也能在離線狀態下正常工作,因為 AI 處理過程絕不會離開本地電腦、傳送到雲端基礎架構中。

相關資源


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言