この記事は Gemini Live Agent Challenge ハッカソンに参加する目的で作成されました。 #GeminiLiveAgentChallenge
スライダーを一切触れずに写真を編集するバージョンがあります。画像の変更したい部分をクリックし、何を望むかを声に出して言い、それが起こるのを見ます。それが Say Edit。
ここ数週間で Say Edit — 声を優先する AI ワークスペースを構築しました。それは Gemini Live API、Gemini 画像生成、および Google Cloud Run 上で動作します。この記事は、それをどう作ったか、何が壊れたか、そして私を驚かせたことの舞台裏です。
中核となるアイデア
ほとんどの AI ツールは入力を求めます。チャットウィンドウを開き、望むものを説明し、返答を待ち、それをどこかにコピーして、繰り返します。私は、私が実際に痛いと感じた二つのユースケースについて、それらのステップをすべて排除したいと考えました:
- 写真の編集 — 変更したい内容は正確に分かっていますが、そこへ到達するにはメニュー、マスク、スライダーを探し回らなければなりません。
- 密度の高い文書の読み取り — 質問はあるが、正確な箇所を見つけるには 80 ページをスクロールする必要があります。
両方の答えは同じです:継続的にリスニングし、あなたの意図を理解し、即座に行動する持続的な音声セッションです。
ボイスループ — Gemini Live API
Say Edit の基盤は、Gemini Live API(gemini-2.5-flash-native-audio-preview-12-2025)との双方向 WebSocket です。これはプッシュ・トゥー・トークのボタンではありません。ワークスペースにいる間、モデルは常にリスニングしています。
ブラウザ側のオーディオ・パイプラインがどう見えるか:
const response = await ai.models.generateContent({ model: 'gemini-3.1-flash-image-preview', contents: [ { inlineData: { mimeType: file.type, data: base64 } }, { text: `Edit: "${editPrompt}". Focus around pixel (x: ${x}, y: ${y}). Return ONLY the edited image.` } ], config: { responseModalities: [ 'IMAGE', 'TEXT' ] } });
すべての編集は非破壊的です — 履歴スタックに追加され、完全な元へ戻す/やり直し機能と、元の画像へ戻すために長押しして比較するボタンを備えています。
陳腐化したクロージャの問題
この問題にはしばらく悩まされました。ライブ API の onmessage コールバックはセッション開始時に一度だけ登録されます。クロージャが参照する React の状態は即座に陳腐化します — つまり history[historyIndex] は、適用された編集の数に関係なく常に元の画像を指してしまいます。
解決策は、状態を鏡像する参照を維持し、コールバック内でそれらから常に読み取ることでした:
const historyRef = useRef([initialFile]);
const historyIndexRef = useRef(0);
historyRef.current = history; // kept in sync on every render
historyIndexRef.current = historyIndex;
// Inside the async tool handler — always gets the live value
const imageFile = historyRef.current[historyIndexRef.current];
これは現在、Live API の上に何かを構築する際の標準的な手法となっています。
ドキュメントナビゲーション — 空間検索 + ライブハイライト
ドキュメントワークスペースは別の領域です。質問をすると、AI は単に答えるだけでなく、回答が文書のどこにあるかを正確に示すべきです。
パイプライン:
1. インジェスト(Google Cloud Run 上の NestJS バックエンド)
PDF をアップロードすると、バックエンドはそれを pdfjs-dist を介して処理し、変換行列を持つテキスト項目を抽出し、Y 座標の近接性で単語を行に、行を文レベルのチャンクに結合します。各チャンクはテキストとともに、ぴったりとした境界ボックス [x, y, width, height] を保持します。
ひとつの注意点: PDF の座標系は左下原点ですが、React PDF ビューアは左上を原点とします。読み込み時にはすべての Y 座標が反転します:
y = pageHeight - transform[5] - (height || 10)
各チャンクは gemini-embedding-001 で埋め込みされ、Supabase pgvector インデックスに保存され、高速なコサイン類似検索のための HNSW インデックスを用います。
2. Live セッション中の検索
質問をすると、Live セッションは search_document を呼び出します。フロントエンドはバックエンドの /query エンドポイントにアクセスし、クエリを埋め込み、文書のチャンクに対してコサイン類似検索を実行します。 上位の結果にはページ番号と境界ボックスが返されます。
3. 空間ハイライト
モデルは、ページと境界ボックス座標の配列を使って focus_document_section を呼び出します。フロントエンドは PDF ビューアを正しいページにジャンプし、関連する文の正確なピクセル位置に黄色のハイライトオーバーレイを描画します:
// Convert stored PDF-point bboxes to percentage overlays
const highlight = {
pageIndex,
left: (bx / pageWidthPx) * 100,
top: (by / pageHeightPx) * 100,
width: (bw / pageWidthPx) * 100,
height: (bh / pageHeightPx) * 100,
};
回答を聞くと同時に、画面上でハイライトされているのが見えます。
コンポーズスタジオ
単一画像の編集を超えて、Say Edit には音声で2枚の写真を合成する Compose Studio があります:
"A の人物を B の衣装で着せる。"
"B の背景に商品を置く。"
両方の画像は base64 にエンコードされ、Gemini の画像生成へ一緒に送られます:
const response = await ai.models.generateContent({
model: \"gemini-3.1-flash-image-preview\",
contents: [
{ inlineData: { mimeType: imgA.type, data: base64A } },
{ inlineData: { mimeType: imgB.type, data: base64B } },
{ text: `Composition request: \"${prompt}\". Return ONLY the composed image.` }
],
config: { responseModalities: [\"IMAGE\", \"TEXT\"] }
});
結果は通常の編集と同じ履歴スタックに入ります — そのため、2つの画像を組み合わせてから、声で結果をさらに洗練させていくことができます。
Infrastructure — Google Cloud Run
フロントエンド(React + Vite)とバックエンド(NestJS)はどちらもコンテナ化され、Google Cloud Run にデプロイされています。フロントエンドの Dockerfile は、ビルド時に ARG インジェクションを使用して Vite の環境変数を埋め込みます:
ARG VITE_GEMINI_API_KEY
ARG VITE_BACKEND_URL
RUN echo \"VITE_GEMINI_API_KEY=$VITE_GEMINI_API_KEY\" > .env
RUN echo \"VITE_BACKEND_URL=$VITE_BACKEND_URL\" >> .env
RUN npx vite build
デプロイ方法:
gcloud run deploy say-edit \\
--source . \\
--region us-central1 \\
--allow-unauthenticated \\
--set-build-env-vars "VITE_GEMINI_API_KEY=...,VITE_BACKEND_URL=..."
What I Learned
ライブ API の中断モデルがゲームのすべてである。 私が使ってきた他の音声インターフェースは待たされるだけだった。文の途中で中断できる能力 — クライアントサイドのハックではなく、適切なサーバーサイドの信号に支えられている — が、Say Edit を実際の会話のように感じさせる要因だ。
ツール設計は UX デザインである。 get_current_hotspot ツールは、座標を再度言い直さずにユーザーが「これを編集して」と言えるようにするためだけに存在する。ツールのスキーマを正しく決定すること — モデルが何を呼ぶか、いつ、どんな引数を渡すか — は、UI 要素よりも対話の質を左右する。
Live API コールバックでは参照 (Refs) が必須です。 セッション開始時に登録された非同期コールバックは、旧い React 状態を取り込む。ref-mirror パターンは譲れない。
PDF 抽出は思ったより難しい。 pdfjs-dist は変換行列を提供するが、文は返さない。グルーピングとチャンク化のパイプラインが、全体の中で最も過小評価されている部分である。
Try It
ライブデモ: https://glyph-client-229364276486.us-central1.run.app/
クライアントリポジトリ: https://github.com/greatsage-raphael/say_edit
サーバーリポジトリ: https://github.com/greatsage-raphael/say_edit_server
Gemini Live API、NaNo Banana、Google Cloud Run、そして Supabase を用いて構築。
この記事は Gemini Live Agent Challenge のハッカソンに参加する目的で作成されました。#GeminiLiveAgentChallenge

