メインコンテンツまでスキップ
バージョン: 1.0.4

カメラ画像 + RAGのAI作業ガイドアプリ

WARNING

本ページに記載の環境は本記事執筆時点(2025/12/19)のものです。
アップデートによって変更が生じている可能性がある点ご留意ください。

はじめに

「拡張された”目”による製造DX」 ——つまり、「人間が遠隔支援する」段階から、「AIが現場作業者を支援する」段階(Real Support 2.0)への進化には、ただLLMに情報を投げるだけでなく、作業のコンテキストをデータに残し、RAGを行うことでAIをカスタマイズすることが重要だと考えています。

今回は、それに関連する検証として最新のマルチモーダルAI「Gemini」とRAGのAPIサービス「Google File API」を用いた作業ガイドアプリを作ってみました!

本記事では、Snapdragon Spaces対応XRグラス**「MiRZA®(ミルザ)」Gemini API/File API**を組み合わせた開発方法についてまとめます!

開発の背景:RAGとグラスの必要性

現在の**「NTT XR Real Support」**は、熟練者が遠隔から若手を支援するソリューションとして多くのお客様にご利用いただいています。しかし、労働力不足が深刻化する未来において、「人の支援からAI支援への移行」 は避けて通れません。

目指すのは 「誰でもグラスをかければ仕事ができる」 世界。 そのために必要なのは、現場のデータをAIが理解し、リアルタイムにガイドする技術が必要だと考えています。この「現場のデータを理解させる」ために、RAGという技術が重要になります。

しかし、この「現場のデータ」が非常に厄介で、現実には作業マニュアルのような「形式知」はデータとして存在しているが、現場の様子やコツなどの「暗黙知」こそ重要です。所謂デジタライゼーションが必要だという話ですが、ここが実現出来ている企業はなかなかいないのではないでしょうか。

そこで、まず暗黙知のデータ化のために、XRグラスによって日常の業務をラクにしながら自動的にデータを収集し、その先はXRグラス上でAIからのガイドをリアルタイムに表示する、という形がAIの現場活用の理想形だと考えています。

MiRZA(ミルザ)とは

Snapdragon Spaces Technologyを採用したメガネ型のXRグラスです。国産で、NTTコノキューとSHARP社による合弁会社であるNTTコノキューデバイス社にて開発・製造をしています。

スマートフォンとグラスを接続するタイプのデバイスで、アプリの実行はAndroidスマホ側で行われます。グラス側はセンサ・ディスプレイを持っているという形です。

RAG/Google File APIとは

RAG(Retrieval-Augmented Generation, 検索拡張生成)は、AIに外部知識(社内マニュアルなど)を与えて回答させる技術です。AIに知識を与えるためには、簡単に言えばDBを作ってそれを参照させる、、というだけです。しかし、実装は非常に手間がかかります。

従来のRAG実装:

  1. PDFをテキスト抽出
  2. チャンク(分割)処理
  3. ベクトル化(Embedding)
  4. ベクトルDBへの保存
  5. 検索処理の実装...

チャンキングや検索処理の実装が非常に重要で、チャンク化の粒度によって検索精度が変わり、検索手法もベクトル検索からナレッジグラフの活用など発展途上です。

しかし、Gemini File API を使えば、これらをすべてスキップできます。

本題

ということで、今回の記事ではXRグラス「MiRZA」でカメラ画像+RAGで作業ガイドしてくれるAIアシスタントを作ってみたので、以下にまとめます!

今回作ったものは、XRグラス「MiRZA」を装着し、特定の操作(グラスタップ)をするだけで、事前に読み込ませたファイルをAIが理解。その後、現場の様子を見ると、「今見ている様子」と「参照ファイルの内容」を照らし合わせ、作業内容をガイドしてくれるアプリです。

技術スタック

  • Hardware: MiRZA (Snapdragon Spaces対応XRグラス) & スマートフォン(AQUOS R9)
  • Platform: Android (Unity 2022.3.26f1)
  • AI Model: Google Gemini 2.5 Flash / 3 Pro
  • マニュアルPDFや参考動画のアップロード・解析: File API

全体の流れ

  1. 環境構築
  2. QONOQサンプルをベースにGemini API利用
  3. File API利用
  4. GlassのTouchイベントを利用

コード全体の構成としては以下です。

1. UnityでMiRZA開発環境を構築

今回は以下の弊社が提供している機能サンプル「カメラ画像認識」をベースに開発します。

ということで環境構築含めて、↑に従い進めます。

2. QONOQサンプルをベースにGemini API利用

サンプルはOpenAI APIを利用するものなので、Gemini APIを利用するように書き換えます。 Geminiのモデルはこちらから確認可能です。最新の3 Proが良いですが、無料枠の都合上基本的には2.5 Flashを使っています。

APIはこちらを参照します。

サンプルはJSONに画像ファイルを埋め込む形式でしたが、今回はこの画像ファイルの送信からFile APIを利用します。(File APIのコードは3.にて)

//Gemini API呼び出しのための宣言
[SerializeField] private string gemini_APIKey;
[Tooltip("APIキーが記述されたテキストファイル(任意)")]
[SerializeField] private TextAsset gemini_APIKey_Text;
[SerializeField] private string modelName = "gemini-2.5-flash";

//モデル等を指定してGenerateUrlを用意
private const string BASE_URL = "https://generativelanguage.googleapis.com";
private string UploadUrl => $"{BASE_URL}/upload/v1beta/files";
private string GenerateUrl => $"{BASE_URL}/v1beta/models/{modelName}:generateContent";

//カメラ画像を取得
private async UniTaskVoid CaptureAndAnalyzeCameraWithReferenceAsync()
{
if (analyzeRawImage == null || analyzeRawImage.texture == null)
{
UpdateStatus("カメラ映像なし");
return;
}

isProcessing = true;
UpdateStatus("画像をキャプチャ中...");

string tempPath = "";
try
{
var tex = analyzeRawImage.texture;
var texture2D = new Texture2D(tex.width, tex.height, TextureFormat.RGBA32, false);

var currentRT = RenderTexture.active;
var rt = new RenderTexture(tex.width, tex.height, 32);
Graphics.Blit(tex, rt);
RenderTexture.active = rt;
texture2D.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
texture2D.Apply();
RenderTexture.active = currentRT;
GameObject.Destroy(rt);

byte[] bytes = texture2D.EncodeToPNG();
GameObject.Destroy(texture2D);

tempPath = Path.Combine(Application.persistentDataPath, $"capture_{DateTime.Now:yyyyMMdd_HHmmss}.png");
await File.WriteAllBytesAsync(tempPath, bytes);

await AnalyzeMultiModalPipelineAsync(tempPath, cameraImagePrompt);
}
catch (Exception ex)
{
Debug.LogError($"[Camera Error] {ex}");
UpdateStatus("画像取得エラー");
}
finally
{
if(File.Exists(tempPath)) File.Delete(tempPath);
isProcessing = false;
}
}

//カメラ画像をFile APIにアップして解析
private async UniTask AnalyzeMultiModalPipelineAsync(string cameraImagePath, string prompt)
{
UpdateStatus("カメラ画像をアップロード中...");

try
{
string camMime = "image/png";
var cameraFile = await UploadFileToGeminiAsync(cameraImagePath, camMime);
cameraFile.mimeType = camMime;

var filesToSend = new List<FileData>();

if (currentReferenceFiles != null && currentReferenceFiles.Count > 0)
{
filesToSend.AddRange(currentReferenceFiles);
}
filesToSend.Add(cameraFile);

UpdateStatus("AI解析中...");
string aiResponse = await GenerateContentWithFilesAsync(filesToSend, prompt);

UpdateStatus("解析完了");
if (resultText) resultText.text = aiResponse;
}
catch (Exception ex)
{
Debug.LogError($"[Analyze Error] {ex}");
UpdateStatus($"解析エラー: {ex.Message}");
}
}

書き換えたら、早速API Keyを入れていきます。 API KeyはGoogle AI Studioから取得します。無料枠もあるので手軽に試すことが出来ます。

API Keyはサンプルアプリを踏襲しているので直打ちかテキストファイルでのアップが可能です。 また、プロンプトは若干工夫しています。

3. File API利用

File APIはこちらを参考に進めます。

File APIを利用するためには、「推論(GenerateContent)」の前に「アップロード(Upload)」のステップが必要です。 いきなり「写真は何?」と聞くのではなく、まず UnityWebRequest でファイルをPOSTし、返ってきた file.uri を保持します。 また、画像やPDFのバイナリデータを UploadHandlerRaw でそのまま送信します。

//File APIへのアップロード処理
private async UniTask<FileData> UploadFileToGeminiAsync(string filePath, string mimeType)
{
byte[] fileData = await File.ReadAllBytesAsync(filePath);
long numBytes = fileData.Length;
string url = $"{UploadUrl}?key={gemini_APIKey}";

using (UnityWebRequest www = new UnityWebRequest(url, "POST"))
{
www.uploadHandler = new UploadHandlerRaw(fileData);
www.downloadHandler = new DownloadHandlerBuffer();

www.SetRequestHeader("X-Goog-Upload-Protocol", "raw");
www.SetRequestHeader("X-Goog-Upload-Command", "start, upload, finalize");
www.SetRequestHeader("X-Goog-Upload-Header-Content-Length", numBytes.ToString());
www.SetRequestHeader("Content-Type", mimeType);

string originalFileName = Path.GetFileName(filePath);
string safeFileName = Regex.Replace(originalFileName, @"[^\u0020-\u007E]", "_");

www.SetRequestHeader("X-Goog-Upload-Header-Content-Disposition", $"filename=\"{safeFileName}\"");

await www.SendWebRequest().ToUniTask();

if (www.result != UnityWebRequest.Result.Success)
throw new Exception($"Upload Failed: {www.error} - {www.downloadHandler.text}");

var response = JsonConvert.DeserializeObject<FileUploadResponse>(www.downloadHandler.text);
return response.file;
}
}

//Geminiにカメラ画像・ファイルデータを渡して解析結果を受け取る
private async UniTask<string> GenerateContentWithFilesAsync(List<FileData> files, string prompt)
{
var partsList = new List<object>();
partsList.Add(new { text = prompt });

foreach (var file in files)
{
partsList.Add(new
{
file_data = new
{
mime_type = file.mimeType,
file_uri = file.uri
}
});
}

var requestBody = new
{
contents = new[]
{
new
{
role = "user",
parts = partsList.ToArray()
}
}
};

string jsonBody = JsonConvert.SerializeObject(requestBody);
string url = $"{GenerateUrl}?key={gemini_APIKey}";

using (UnityWebRequest www = new UnityWebRequest(url, "POST"))
{
byte[] jsonToSend = new UTF8Encoding().GetBytes(jsonBody);
www.uploadHandler = new UploadHandlerRaw(jsonToSend);
www.downloadHandler = new DownloadHandlerBuffer();
www.SetRequestHeader("Content-Type", "application/json");

await www.SendWebRequest().ToUniTask();

if (www.result != UnityWebRequest.Result.Success)
throw new Exception($"Generate Failed: {www.error} - {www.downloadHandler.text}");

var response = JsonConvert.DeserializeObject<GeminiResponse>(www.downloadHandler.text);
return response?.candidates?[0]?.content?.parts?[0]?.text ?? "No result";
}
}

4. GlassのTouchイベントを利用

サンプルアプリと同様に、MiRZAの右側面にあるタッチバーをタップすることでAIの呼び出しを行えるようにします。 グラス1回タップ→AnalyzeCameraImage() グラス2回タップ→AnalyzeFromFile() を呼び出す形とします。サンプル同様、GlassesTouchSensorEventTriggerを使ってイベントを割り当てます。

その他ポイント

今回、File APIを利用するにあたってアプリでのファイル選択に苦戦しました。Unity Editor上では普通にPCのフォルダが表示され、ファイル選択が出来ましたが、Spacesの仕様上表示させるには結構手を入れる必要がありそうでした。XR_SESSIONをバックグラウンドに回せないため、そのままではフォルダ選択の画面遷移が出来ないようです。ということで、当初はNative File Pickerを利用しようとしていましたが、今回はアプリ固有領域を指定することで解決・・・としています。
まあ、手軽に試す分にはこれで十分ですね。

// 読み込み対象のパス(アプリ固有領域)
// Android実機では: /storage/emulated/0/Android/data/<PackageName>/files/RagTest
private string TargetDirectory => Path.Combine(Application.persistentDataPath, targetFolderName);

また、無料枠のGemini APIは結構すぐに制限がかかってしまうので、やや手間でした。3Proは数回(2-3回?)でダメになり、2.5Flushを基本使ってデバッグしていました。(あるある)

実際に試してみる!

AI / XRに関しては、ただ実装しただけでは全然意味が無いので、試してみてうまく動くか?がキモです。ということでやってみました。 今回がお試しとして「MiRZAの使い方ガイドAI」を作ります。

  1. MiRZA向けのNTT XR Real Supportアプリを使ってMiRZAを箱から開ける様子を録画
  2. ファイルをFile APIでアップロード
  3. MiRZAを装着して、MiRZAの箱を見てガイドを起動

という流れになります。

1. MiRZA向けのNTT XR Real Supportアプリを使ってMiRZAを箱から開ける様子を録画

RealSupportでは、作業レコード機能によって録画や時系列の作業履歴を自動で残すことが可能です。 事前にMiRZAを箱から出してボタンを確認する作業を録画しました。

2. ファイルをFile APIでアップロード

1.の録画データとMiRZAクイックスタートガイド(PDF)をアップロードします。 File APIに流すために、まずはアプリ固有領域にファイルをアップ。

グラス2回タップをして、File APIでアップロード!

読みづらいですが、、、良い感じに内容を把握されているようです。動画もちゃんと「MiRZAの開封・装着動画」と認識されています。

3. MiRZAを装着して、MiRZAの箱を見てガイドを起動

いよいよラストです! キャプチャ画像なのでめっちゃ画質悪いですが、MiRZAの箱を撮影してアップロードします。

しっかりMiRZAの箱だということが認識され、クイックスタートガイドを見ながら装着しようとガイドしてくれました!👏

補足:RAG無しだとどうなる?

ちなみに、ファイルアップをする前にMiRZAの箱を撮影すると、かなり頓珍漢な回答が返ってきました。

MiRZAの箱は「SHURE製品のケース」と認識されています。SHUREってマイクとか作ってるメーカーさんとのことで、まさにそれっぽい回答が返ってきてるだけ、という感じです。 そして、MiRZAの箱はクイックスタートガイドには絵は一切なく、説明もありません。

つまり、箱からMiRZAを取り出している動画がしっかりソースとして機能している、ということがわかります。

最後に

今回の開発を通じて、Geminiのマルチモーダル性能・File APIの手軽さが、現場DXのPoCにおいて非常に有益だと確認できました。特に、「ファイル種別やサイズを意識せず扱える」のは非常に手軽で良いですね。 XRアプリ開発をメインにしながらRAG構築・・・まではなかなかハードルが高いので、とてもありがたいです。

実際にはアプリ起動時にファイルアップロードでは時間がかかるのでいろいろ工夫が必要そうですが、「NTT XR Real Support 2.0」 の実現に向けてクラウド連携やRAG(検索拡張生成)の統合を進め、製造業やインフラ業の皆様の労働力不足解決に貢献していきたいと思います!

☆ちなみに、今回の記事自体も当然ながらAIフル活用です。コードはもちろん、図解・記事文案含めてAI生成でクイックに行いました。どんな業務でもAIを踏まえた新しいやり方を模索していきたいですね。

実装コード

using System;
using System.IO;
using System.Collections.Generic;
using System.Text;
using System.Linq;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
using Newtonsoft.Json;
using Cysharp.Threading.Tasks;
using TMPro;

namespace NTTQONOQ.Android.MiRZA.SDK.Samples
{
/// <summary>
/// Gemini API (File API) V3
/// 権限不要の「アプリ固有領域 (PersistentDataPath)」を使用するバージョン。
/// PCからスマホアプリの所定のフォルダにファイルを置くことで動作します。
/// </summary>
public class GeminiMultimodalAnalyzerV3 : MonoBehaviour
{
[Header("API Settings")]
[SerializeField] private string gemini_APIKey;
[Tooltip("APIキーが記述されたテキストファイル(任意)")]
[SerializeField] private TextAsset gemini_APIKey_Text;
[SerializeField] private string modelName = "gemini-2.5-flash";

[Header("File Load Settings")]
[Tooltip("読み込むサブフォルダ名。PersistentDataPath直下に作成されます。")]
[SerializeField] private string targetFolderName = "RagTest";

[Header("Interaction Settings")]
[SerializeField, TextArea(3, 10)] private string defaultPrompt = "これらのファイルの内容を要約して、重要なポイントを教えてください。文書は100文字いないで簡潔に日本語で行なってください。";
[SerializeField, TextArea(3, 10)] private string cameraImagePrompt = "添付の資料(もしあれば)と、このカメラ画像を踏まえて、ユーザーが今何をすべきかをアドバイスしてください。アドバイスは100文字以内で簡潔に日本語で行なってください。";

[Header("Camera Capture Settings")]
[SerializeField] private RawImage analyzeRawImage;

[Header("UI References")]
[SerializeField] private TextMeshProUGUI statusText;
[SerializeField] private TextMeshProUGUI resultText;
[SerializeField] private Button analyzeFileButton;

[Header("External Components")]
[SerializeField] private GoogleTextToSpeechConverter googleTextToSpeechConverter;

private const string BASE_URL = "https://generativelanguage.googleapis.com";
private string UploadUrl => $"{BASE_URL}/upload/v1beta/files";
private string GenerateUrl => $"{BASE_URL}/v1beta/models/{modelName}:generateContent";

private bool isProcessing = false;
private List<FileData> currentReferenceFiles = new List<FileData>();

// 読み込み対象のパス(アプリ固有領域)
// Android実機では: /storage/emulated/0/Android/data/<PackageName>/files/RagTest
private string TargetDirectory => Path.Combine(Application.persistentDataPath, targetFolderName);

private void Start()
{
if (gemini_APIKey_Text != null && string.IsNullOrEmpty(gemini_APIKey))
{
gemini_APIKey = gemini_APIKey_Text.text.Trim();
}

if (string.IsNullOrEmpty(gemini_APIKey))
{
UpdateStatus("設定エラー: API Keyがありません");
return;
}

if (analyzeFileButton != null)
{
analyzeFileButton.onClick.AddListener(AnalyzeFromFile);
}

// フォルダの準備(なければ作成)
if (!Directory.Exists(TargetDirectory))
{
try {
Directory.CreateDirectory(TargetDirectory);
Debug.Log($"[Gemini] Created directory: {TargetDirectory}");
} catch (Exception e) {
Debug.LogError($"[Gemini] Failed to create directory: {e.Message}");
}
}

// パスをログと画面に表示(PCからの配置場所確認用)
Debug.Log($"[Gemini] Target Directory: {TargetDirectory}");

// 画面案内
string displayPath = TargetDirectory;
// Androidの場合、パスが長すぎるので見やすく加工
if (displayPath.Contains("/Android/data/"))
{
var parts = displayPath.Split(new string[] { "/Android/data/" }, StringSplitOptions.None);
if (parts.Length > 1) displayPath = ".../Android/data/" + parts[1];
}

if (resultText)
{
resultText.text = "【準備手順】\n" +
"1. スマホをPCに接続\n" +
"2. 以下のフォルダにPDF等を配置:\n" +
$"{displayPath}\n" +
"3. グラスを長押しで読み込み";
}

UpdateStatus("準備完了");
}

#region Public Methods

public void AnalyzeFromFile()
{
if (isProcessing) return;
LoadAndUploadFilesFromFolderAsync().Forget();
}

public void AnalyzeCameraImage()
{
if (isProcessing) return;
CaptureAndAnalyzeCameraWithReferenceAsync().Forget();
}

#endregion

#region Core Logic

private async UniTaskVoid LoadAndUploadFilesFromFolderAsync()
{
UpdateStatus("フォルダを確認中...");

string path = TargetDirectory;
if (!Directory.Exists(path))
{
UpdateStatus("フォルダが見つかりません");
if (resultText) resultText.text = $"フォルダ未作成:\n{path}";
return;
}

// 対象拡張子
string[] extensions = { ".pdf", ".png", ".jpg", ".jpeg", ".mp4", ".mov" };

var files = Directory.GetFiles(path)
.Where(f => extensions.Contains(Path.GetExtension(f).ToLower()))
.ToArray();

if (files.Length == 0)
{
UpdateStatus("ファイルなし");
if (resultText) resultText.text = $"フォルダにファイルがありません。\nPCからファイルを置いてください。\n\n場所:\n{path}";
//if (googleTextToSpeechConverter) googleTextToSpeechConverter.SynthesizeAndPlay("フォルダにファイルが見つかりません。");
return;
}

//if (googleTextToSpeechConverter) googleTextToSpeechConverter.SynthesizeAndPlay($"{files.Length}件のファイルが見つかりました。");

await UploadReferencesPipelineAsync(files);
}

private async UniTaskVoid CaptureAndAnalyzeCameraWithReferenceAsync()
{
if (analyzeRawImage == null || analyzeRawImage.texture == null)
{
UpdateStatus("カメラ映像なし");
return;
}

isProcessing = true;
UpdateStatus("画像をキャプチャ中...");

string tempPath = "";
try
{
var tex = analyzeRawImage.texture;
var texture2D = new Texture2D(tex.width, tex.height, TextureFormat.RGBA32, false);

var currentRT = RenderTexture.active;
var rt = new RenderTexture(tex.width, tex.height, 32);
Graphics.Blit(tex, rt);
RenderTexture.active = rt;
texture2D.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
texture2D.Apply();
RenderTexture.active = currentRT;
GameObject.Destroy(rt);

byte[] bytes = texture2D.EncodeToPNG();
GameObject.Destroy(texture2D);

tempPath = Path.Combine(Application.persistentDataPath, $"capture_{DateTime.Now:yyyyMMdd_HHmmss}.png");
await File.WriteAllBytesAsync(tempPath, bytes);

await AnalyzeMultiModalPipelineAsync(tempPath, cameraImagePrompt);
}
catch (Exception ex)
{
Debug.LogError($"[Camera Error] {ex}");
UpdateStatus("画像取得エラー");
}
finally
{
if(File.Exists(tempPath)) File.Delete(tempPath);
isProcessing = false;
}
}

private async UniTask UploadReferencesPipelineAsync(string[] filePaths)
{
isProcessing = true;
UpdateStatus($"ファイル準備中... ({filePaths.Length}件)");

currentReferenceFiles.Clear();

try
{
foreach (var filePath in filePaths)
{
string mimeType = GetMimeType(filePath);
string fileName = Path.GetFileName(filePath);

UpdateStatus($"アップロード中: {fileName}");
var uploadedFile = await UploadFileToGeminiAsync(filePath, mimeType);

if (IsProcessingRequired(mimeType))
{
UpdateStatus($"処理待機中: {fileName}...");
await WaitForFileProcessingAsync(uploadedFile.name);
}

uploadedFile.mimeType = mimeType;
uploadedFile.displayName = fileName;
currentReferenceFiles.Add(uploadedFile);
}

UpdateStatus($"参照セット完了 ({currentReferenceFiles.Count}件)");

string fileListStr = string.Join("\n", currentReferenceFiles.Select(f => "・" + f.displayName));
if (resultText != null)
{
resultText.text = $"[参照中ファイル]\n{fileListStr}\n\n(AIが内容を要約しています...)";
}

UpdateStatus("内容を解析中...");
string summary = await GenerateContentWithFilesAsync(currentReferenceFiles, defaultPrompt);
UpdateStatus("解析完了");
Debug.Log($"[Gemini Summary] {summary}");

if (resultText != null)
{
resultText.text = $"[参照中ファイル]\n{fileListStr}\n\n[要約]\n{summary}\n\n(タップしてカメラ画像と一緒に解析できます)";
}
}
catch (Exception ex)
{
Debug.LogError($"[Upload Error] {ex}");
UpdateStatus($"エラー: {ex.Message}");
if (resultText) resultText.text += $"\n\n[Error]\n{ex.Message}";
}
finally
{
isProcessing = false;
}
}

private async UniTask AnalyzeMultiModalPipelineAsync(string cameraImagePath, string prompt)
{
UpdateStatus("カメラ画像をアップロード中...");

try
{
string camMime = "image/png";
var cameraFile = await UploadFileToGeminiAsync(cameraImagePath, camMime);
cameraFile.mimeType = camMime;

var filesToSend = new List<FileData>();

if (currentReferenceFiles != null && currentReferenceFiles.Count > 0)
{
filesToSend.AddRange(currentReferenceFiles);
}
filesToSend.Add(cameraFile);

UpdateStatus("AI解析中...");
string aiResponse = await GenerateContentWithFilesAsync(filesToSend, prompt);

UpdateStatus("解析完了");
if (resultText) resultText.text = aiResponse;

if (googleTextToSpeechConverter)
{
googleTextToSpeechConverter.SynthesizeAndPlay(aiResponse);
}
}
catch (Exception ex)
{
Debug.LogError($"[Analyze Error] {ex}");
UpdateStatus($"解析エラー: {ex.Message}");
}
}

#endregion

#region Internal Logic (API Communication)

private async UniTask<FileData> UploadFileToGeminiAsync(string filePath, string mimeType)
{
byte[] fileData = await File.ReadAllBytesAsync(filePath);
long numBytes = fileData.Length;
string url = $"{UploadUrl}?key={gemini_APIKey}";

using (UnityWebRequest www = new UnityWebRequest(url, "POST"))
{
www.uploadHandler = new UploadHandlerRaw(fileData);
www.downloadHandler = new DownloadHandlerBuffer();

www.SetRequestHeader("X-Goog-Upload-Protocol", "raw");
www.SetRequestHeader("X-Goog-Upload-Command", "start, upload, finalize");
www.SetRequestHeader("X-Goog-Upload-Header-Content-Length", numBytes.ToString());
www.SetRequestHeader("Content-Type", mimeType);

string originalFileName = Path.GetFileName(filePath);
string safeFileName = Regex.Replace(originalFileName, @"[^\u0020-\u007E]", "_");

www.SetRequestHeader("X-Goog-Upload-Header-Content-Disposition", $"filename=\"{safeFileName}\"");

await www.SendWebRequest().ToUniTask();

if (www.result != UnityWebRequest.Result.Success)
throw new Exception($"Upload Failed: {www.error} - {www.downloadHandler.text}");

var response = JsonConvert.DeserializeObject<FileUploadResponse>(www.downloadHandler.text);
return response.file;
}
}

private async UniTask WaitForFileProcessingAsync(string fileName)
{
string url = $"{BASE_URL}/v1beta/{fileName}?key={gemini_APIKey}";
float timeOutSeconds = 300f;
float startTime = Time.realtimeSinceStartup;

while (Time.realtimeSinceStartup - startTime < timeOutSeconds)
{
using (UnityWebRequest www = UnityWebRequest.Get(url))
{
await www.SendWebRequest().ToUniTask();
if (www.result != UnityWebRequest.Result.Success) throw new Exception($"Status Check Failed: {www.error}");

var response = JsonConvert.DeserializeObject<FileStatusResponse>(www.downloadHandler.text);
if (response.state == "ACTIVE") return;
if (response.state == "FAILED") throw new Exception("File processing failed.");
}
await UniTask.Delay(2000);
}
throw new Exception("Timeout waiting for processing.");
}

private async UniTask<string> GenerateContentWithFilesAsync(List<FileData> files, string prompt)
{
var partsList = new List<object>();
partsList.Add(new { text = prompt });

foreach (var file in files)
{
partsList.Add(new
{
file_data = new
{
mime_type = file.mimeType,
file_uri = file.uri
}
});
}

var requestBody = new
{
contents = new[]
{
new
{
role = "user",
parts = partsList.ToArray()
}
}
};

string jsonBody = JsonConvert.SerializeObject(requestBody);
string url = $"{GenerateUrl}?key={gemini_APIKey}";

using (UnityWebRequest www = new UnityWebRequest(url, "POST"))
{
byte[] jsonToSend = new UTF8Encoding().GetBytes(jsonBody);
www.uploadHandler = new UploadHandlerRaw(jsonToSend);
www.downloadHandler = new DownloadHandlerBuffer();
www.SetRequestHeader("Content-Type", "application/json");

await www.SendWebRequest().ToUniTask();

if (www.result != UnityWebRequest.Result.Success)
throw new Exception($"Generate Failed: {www.error} - {www.downloadHandler.text}");

var response = JsonConvert.DeserializeObject<GeminiResponse>(www.downloadHandler.text);
return response?.candidates?[0]?.content?.parts?[0]?.text ?? "No result";
}
}

#endregion

#region Utilities & Data Classes

private void UpdateStatus(string msg)
{
if (statusText) statusText.text = msg;
Debug.Log($"[Gemini Status] {msg}");
}

private string GetMimeType(string filePath)
{
string ext = Path.GetExtension(filePath).ToLower();
return ext switch
{
".png" => "image/png",
".jpg" => "image/jpeg",
".jpeg" => "image/jpeg",
".mp4" => "video/mp4",
".mov" => "video/quicktime",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
}

private bool IsProcessingRequired(string mimeType) => mimeType.StartsWith("video");

[Serializable] private class FileUploadResponse { public FileData file; }

[Serializable]
private class FileData
{
public string name;
public string uri;
public string state;
public string mimeType;
public string displayName;
}

[Serializable] private class FileStatusResponse { public string state; }
[Serializable] private class GeminiResponse { public Candidate[] candidates; }
[Serializable] private class Candidate { public Content content; }
[Serializable] private class Content { public Part[] parts; }
[Serializable] private class Part { public string text; }

#endregion
}
}