私がTauri × Gemini APIの組み合わせを試そうと思ったのは、ある小さな不満からでした。Gemini APIを使ったWebアプリを作るとき、APIキーをどこで管理するかという問題が必ずついて回ります。バックエンドを別途用意するのは個人開発にはオーバースペックで、かといってブラウザ側にキーを持たせるのはリスクが高いと感じていました。その中間をうまく埋める選択肢を探していたときに、Tauriのアーキテクチャが視野に入ってきました。
Tauri 2.0では、Rustで書いたバックエンドレイヤーが完全にAPIキーを管理し、フロントエンドのJavaScriptからは一切触れない設計になっています。Electronよりもバンドルサイズはずっとコンパクトで、起動も速いのです。公式ドキュメントを読みながら最初のプロトタイプを動かしてみたとき、「これは配布まで見据えたデスクトップAIアプリの現実的な選択肢だ」と確信しました。
このガイドでは、Gemini APIをTauri 2.0のRustレイヤーから呼び出し、ストリーミングレスポンスをリアルタイムでフロントエンドに配信するAIデスクトップアシスタントの実装を、実際に動くコードとともに解説していきます。
ElectronよりTauri 2.0を選ぶ理由
Tauri vs Electronの比較は「軽量か否か」で語られがちですが、AIアプリ開発で特に重要な差はセキュリティモデルにあります。
バンドルサイズの差 は顕著です。Electronはnode.jsとChromiumを同梱するため、最小でも60〜100MB程度になります。TauriはOSのブラウザエンジン(macOSならWKWebView、WindowsならWebView2、LinuxならWebKitGTK)を使うため、最小バンドルサイズが6〜15MB で済みます。
セキュリティの差 がAIアプリ開発で特に効いてきます。Tauriはsrc-tauri/capabilities/フォルダで各機能へのアクセスを明示的に許可する仕組みになっており、デフォルトでフロントエンドJavaScriptはOSの機能にアクセスできません。APIキーはRustバックエンドだけが知っており、フロントエンドからは決して見えません。
Rustバックエンドの性能 も見逃せません。大量の並行API呼び出しをtokioの非同期ランタイムで処理すると、Node.jsと比べてCPU使用率が低く、メモリ効率も優れています。
Tauri 2.0プロジェクトのセットアップ
まず前提条件を確認します。rustup経由でインストールしたRust(1.77以上)、Node.js 20+、npmまたはpnpmが必要です。macOSではXcode Command Line Tools(xcode-select --install)も必要です。
# Tauri CLIのインストール
npm install --global @tauri-apps/cli@latest
# 新規プロジェクト作成(React + TypeScriptテンプレート)
npm create tauri-app@latest gemini-desktop -- --template react-ts
cd gemini-desktop
# フロントエンド依存関係のインストール
npm install
次にsrc-tauri/Cargo.tomlを編集して必要なRustクレートを追加します:
[ package ]
name = "gemini-desktop"
version = "0.1.0"
edition = "2021"
[ dependencies ]
tauri = { version = "2" , features = [ "protocol-asset" ] }
tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
tauri-plugin-clipboard-manager = "2"
serde = { version = "1" , features = [ "derive" ] }
serde_json = "1"
reqwest = { version = "0.12" , features = [ "json" , "stream" ] }
futures-util = "0.3"
tokio = { version = "1" , features = [ "full" ] }
dotenvy = "0.15" # 開発時の .env 読み込み用(main.rs から呼ぶため [dependencies] に置きます)
[ build-dependencies ]
tauri-build = { version = "2" , features = [] }
開発用の.envファイルをsrc-tauri/ディレクトリに作成します(.gitignoreに追加するのを忘れずに):
# src-tauri/.env
GEMINI_API_KEY = YOUR_GEMINI_API_KEY
RustバックエンドでAPIキーを安全に管理する
Tauriのセキュリティモデルの核心は、フロントエンドJavaScriptがAPIキーに触れない設計 です。APIキーはRustバックエンドだけが知り、フロントエンドはinvoke()でRustの関数を呼ぶだけです。
src-tauri/src/main.rsを以下のように実装します:
// src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows" )]
use reqwest :: Client ;
use serde :: { Deserialize , Serialize };
use std :: sync :: OnceLock ;
// アプリ全体でHTTPクライアントを再利用(接続プールの活用)
static HTTP_CLIENT : OnceLock < Client > = OnceLock :: new ();
fn get_client () -> & ' static Client {
HTTP_CLIENT . get_or_init ( || {
Client :: builder ()
. timeout ( std :: time :: Duration :: from_secs ( 120 ))
. build ()
. expect ( "HTTPクライアントの初期化に失敗しました" )
})
}
fn get_api_key () -> Result < String , String > {
std :: env :: var ( "GEMINI_API_KEY" )
. map_err ( | _ | {
"GEMINI_API_KEY が設定されていません。\
.env ファイルを src-tauri/ に配置してください。" . to_string ()
})
}
fn main () {
// 開発ビルド時のみ .env を読み込む
// 本番バイナリには .env ファイルは含まれない
#[cfg(debug_assertions)]
dotenvy :: dotenv () . ok ();
tauri :: Builder :: default ()
. plugin ( tauri_plugin_shell :: init ())
. plugin ( tauri_plugin_notification :: init ())
. plugin ( tauri_plugin_clipboard_manager :: init ())
. invoke_handler ( tauri :: generate_handler! [
generate_text,
generate_text_stream,
])
. run ( tauri :: generate_context! ())
. expect ( "Tauriアプリの起動に失敗しました" );
}
本番向けのAPIキー管理には2つのアプローチがあります。①ユーザーが自分のAPIキーを設定する (自分用ツールや技術者向けアプリに適している)か、②自前のプロキシサーバーを使う (一般配布向け。Cloudflare WorkersなどでAPIキーを管理し、アプリからはそこにリクエストする)かです。後者の場合はRustコードのurl変数を自前のエンドポイントに変更するだけです。
テキスト生成コマンドの実装
#[tauri::command]アトリビュートを付けた非同期関数がフロントエンドから呼び出せる関数になります。エラーはRustのResult<T, String>で返し、フロントエンドではinvoke()が返すPromiseでキャッチします。
#[derive( Serialize , Deserialize )]
struct GeminiRequest {
contents : Vec < GeminiContent >,
}
#[derive( Serialize , Deserialize )]
struct GeminiContent {
parts : Vec < GeminiPart >,
}
#[derive( Serialize , Deserialize )]
struct GeminiPart {
text : String ,
}
#[tauri :: command]
async fn generate_text (prompt : String ) -> Result < String , String > {
let api_key = get_api_key () ? ;
let client = get_client ();
let url = format! (
"https://generativelanguage.googleapis.com/v1beta/\
models/gemini-3.8-flash:generateContent"
);
let request_body = GeminiRequest {
contents : vec! [ GeminiContent {
parts : vec! [ GeminiPart { text : prompt }],
}],
};
let response = client
. post ( & url)
. header ( "x-goog-api-key" , & api_key)
. json ( & request_body)
. send ()
.await
. map_err ( | e | format! ( "ネットワークエラー: {}" , e . without_url ())) ? ;
if ! response . status () . is_success () {
let status = response . status ();
let body = response . text () .await. unwrap_or_default ();
return Err ( format! ( "Gemini APIエラー (HTTP {}): {}" , status, body));
}
let json : serde_json :: Value = response
. json ()
.await
. map_err ( | e | format! ( "レスポンスのJSONパースに失敗: {}" , e)) ? ;
// セーフティフィルターによるブロックを確認
if let Some (reason) = json[ "promptFeedback" ][ "blockReason" ] . as_str () {
return Err ( format! ( "コンテンツがブロックされました: {}" , reason));
}
let text = json[ "candidates" ][ 0 ][ "content" ][ "parts" ][ 0 ][ "text" ]
. as_str ()
. ok_or_else ( || "レスポンスにテキストが含まれていません" . to_string ()) ?
. to_string ();
Ok (text)
}
APIキーをURLのクエリ(?key=)ではなく、x-goog-api-key ヘッダーで渡している点に目を留めていただければと思います。reqwest のエラーは、失敗したリクエストのURLを文字列に含めます。キーをクエリに載せたまま format!("{}", e) でフロントエンドへ返すと、Rust側で守っているはずのキーが、エラーメッセージという裏口から画面へ運ばれてしまうのです。ヘッダーに移したうえで、念のため e.without_url() を通してからメッセージに載せています。
キーは URL にもログにもエラー文にも載せないようにしています。 隠す場所を一つ決めるより、通り道を一つずつ塞ぐほうが確実だと、私は考えています。
フロントエンド(TypeScript)からの呼び出し方:
import { invoke } from "@tauri-apps/api/core" ;
async function askGemini ( prompt : string ) : Promise < string > {
try {
// Rustの generate_text コマンドを呼び出す
const response = await invoke < string >( "generate_text" , { prompt });
return response;
} catch (error) {
// Rustから返ってきたエラーメッセージをそのまま表示
throw new Error ( typeof error === "string" ? error : "不明なエラー" );
}
}
ストリーミングレスポンスをリアルタイムで配信する
Gemini APIのストリーミング(Server-Sent Events)をTauriで扱うには、Tauriのイベントシステム (window.emit())を使ってRustからフロントエンドにチャンクを送ります。「Rustがストリームを受け取り → フロントエンドにイベントとして送る」という流れです。
use futures_util :: StreamExt ;
#[tauri :: command]
async fn generate_text_stream (
window : tauri :: Window ,
prompt : String ,
) -> Result <(), String > {
let api_key = get_api_key () ? ;
let client = get_client ();
let url = format! (
"https://generativelanguage.googleapis.com/v1beta/\
models/gemini-3.8-flash:streamGenerateContent?alt=sse"
);
let request_body = GeminiRequest {
contents : vec! [ GeminiContent {
parts : vec! [ GeminiPart { text : prompt }],
}],
};
let response = client
. post ( & url)
. header ( "x-goog-api-key" , & api_key)
. json ( & request_body)
. send ()
.await
. map_err ( | e | format! ( "リクエスト失敗: {}" , e . without_url ())) ? ;
if ! response . status () . is_success () {
let error = response . text () .await. unwrap_or_default ();
window . emit ( "stream-error" , & error) . ok ();
return Err (error);
}
let mut byte_stream = response . bytes_stream ();
let mut line_buffer = String :: new ();
while let Some (chunk) = byte_stream . next () .await {
match chunk {
Ok (bytes) => {
line_buffer . push_str ( & String :: from_utf8_lossy ( & bytes));
// SSEは改行でイベントを区切る。バッファから完全な行だけを処理する
while let Some (newline_pos) = line_buffer . find ( ' \n ' ) {
let line = line_buffer[ .. newline_pos] . trim () . to_string ();
line_buffer = line_buffer[newline_pos + 1 .. ] . to_string ();
if let Some (data) = line . strip_prefix ( "data: " ) {
if data == "[DONE]" {
window . emit ( "stream-done" , ()) . ok ();
return Ok (());
}
if let Ok (json) = serde_json :: from_str :: < serde_json :: Value >(data) {
if let Some (text) = json[ "candidates" ][ 0 ][ "content" ][ "parts" ][ 0 ][ "text" ] . as_str () {
if ! text . is_empty () {
window . emit ( "stream-chunk" , text) . ok ();
}
}
}
}
}
}
Err (e) => {
let msg = e . to_string ();
window . emit ( "stream-error" , & msg) . ok ();
return Err (msg);
}
}
}
window . emit ( "stream-done" , ()) . ok ();
Ok (())
}
フロントエンド側でイベントを受け取るカスタムフック:
// src/hooks/useGeminiStream.ts
import { invoke } from "@tauri-apps/api/core" ;
import { listen, type UnlistenFn } from "@tauri-apps/api/event" ;
import { useState, useCallback } from "react" ;
export function useGeminiStream () {
const [ response , setResponse ] = useState ( "" );
const [ isStreaming , setIsStreaming ] = useState ( false );
const [ error , setError ] = useState < string | null >( null );
const sendMessage = useCallback (
async ( prompt : string ) => {
if ( ! prompt. trim () || isStreaming) return ;
setResponse ( "" );
setError ( null );
setIsStreaming ( true );
const unlisteners : UnlistenFn [] = [];
try {
// ⚠️ イベントリスナーを invoke() より前に設定する
// 後から設定すると最初のチャンクを取りこぼす
unlisteners. push (
await listen < string >( "stream-chunk" , ( event ) => {
setResponse (( prev ) => prev + event.payload);
})
);
unlisteners. push (
await listen ( "stream-done" , () => {
setIsStreaming ( false );
})
);
unlisteners. push (
await listen < string >( "stream-error" , ( event ) => {
setError ( `ストリームエラー: ${ event . payload }` );
setIsStreaming ( false );
})
);
await invoke ( "generate_text_stream" , { prompt });
} catch (err) {
setError (err instanceof Error ? err.message : String (err));
setIsStreaming ( false );
} finally {
// 必ずリスナーを解除してメモリリークを防ぐ
unlisteners. forEach (( fn ) => fn ());
}
},
[isStreaming]
);
const reset = useCallback (() => {
setResponse ( "" );
setError ( null );
}, []);
return { response, isStreaming, error, sendMessage, reset };
}
このフックを使ったチャットUIの実装:
// src/App.tsx
import { useState } from "react" ;
import { useGeminiStream } from "./hooks/useGeminiStream" ;
export default function App () {
const [ prompt , setPrompt ] = useState ( "" );
const { response , isStreaming , error , sendMessage , reset } = useGeminiStream ();
const handleSubmit = async ( e : React . FormEvent ) => {
e. preventDefault ();
if ( ! prompt. trim ()) return ;
await sendMessage (prompt);
setPrompt ( "" );
};
return (
< div className = "app" >
< div className = "response-area" >
{ error ? (
< div className = "error" > {error} </ div >
) : (
< div className = "response" >
{ response }
{ isStreaming && < span className = "cursor" > ▋ </ span > }
</ div >
)}
</ div >
< form onSubmit = {handleSubmit} >
< textarea
value = {prompt}
onChange = {(e) => setPrompt (e.target.value)}
placeholder = "Geminiに質問してみてください..."
disabled = {isStreaming}
onKeyDown = {(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleSubmit (e);
}
}}
/>
< div className = "buttons" >
< button type = "submit" disabled = {isStreaming || !prompt.trim()} >
{ isStreaming ? "生成中..." : "送信 (⌘+Enter)" }
</ button >
< button type = "button" onClick = {reset} disabled = {isStreaming} >
クリア
</ button >
</ div >
</ form >
</ div >
);
}
デスクトップネイティブ機能との連携
Webアプリとの最大の差別化ポイントが、クリップボード・通知・ファイルシステムなどのOS機能との連携です。まずsrc-tauri/capabilities/default.jsonに必要な権限を追加します:
{
"identifier" : "default" ,
"description" : "デフォルト権限" ,
"windows" : [ "main" ],
"permissions" : [
"core:default" ,
"clipboard-manager:allow-read-text" ,
"clipboard-manager:allow-write-text" ,
"notification:allow-send-notification" ,
"shell:allow-open"
]
}
クリップボードのテキストをGeminiで要約する実装例 :
import { readText, writeText } from "@tauri-apps/plugin-clipboard-manager" ;
import { sendNotification } from "@tauri-apps/plugin-notification" ;
import { invoke } from "@tauri-apps/api/core" ;
async function summarizeClipboard () {
const clipboardText = await readText ();
if ( ! clipboardText?. trim ()) {
await sendNotification ({
title: "Gemini AI" ,
body: "クリップボードにテキストがありません。" ,
});
return ;
}
const prompt = `以下のテキストを要点3つに絞って箇条書きで要約してください: \n\n ${ clipboardText }` ;
try {
const summary = await invoke < string >( "generate_text" , { prompt });
// 要約結果をクリップボードに書き戻す
await writeText (summary);
await sendNotification ({
title: "Gemini AI — 要約完了" ,
body: "結果をクリップボードにコピーしました。" ,
});
} catch (err) {
await sendNotification ({
title: "Gemini AI — エラー" ,
body: String (err),
});
}
}
このパターンを使うと、グローバルキーボードショートカット(Tauriのtauri-plugin-global-shortcut)と組み合わせて、「何かを選択してショートカットを押すと即座にAI処理」という、アプリ独立した体験が作れます。Webアプリではできない、デスクトップアプリならではの価値です。
よくある落とし穴と解決策
落とし穴1:ストリームの最初のチャンクを取りこぼす
フロントエンドでinvoke("generate_text_stream")を呼んだ直後にイベントリスナーを設定すると、Rustがすでに送り始めた最初のチャンクを受け取れません。必ずリスナーを先に設定してからinvoke()を呼ぶ順序にしておくのが安全です。上記のカスタムフックではawait listen()を先に実行する設計になっています。
落とし穴2:reqwestのfeatureフラグ不足
JSONレスポンスとストリーミングの両方を使う場合、features = ["json", "stream"]の両方がCargo.tomlに必要です。streamを付け忘れるとbytes_stream()がコンパイルエラーになります。また、Tauri 2.0ではreqwestのblockingフィーチャーは使えません(Tokioランタイムとの相性問題のため)。
落とし穴3:Capabilities設定の漏れ
Tauri 2.0では、プラグインを使う際にsrc-tauri/capabilities/フォルダで明示的な権限が必要です。プラグインをCargo.tomlに追加しただけでは使えず、capabilities/default.jsonにも対応する権限を追加する必要があります。「機能が呼び出せない」「permissionエラーが出る」場合は、まずここをご確認ください。
落とし穴4:クロスコンパイルは原則できない
macOSからWindowsバイナリを直接ビルドしようとしても、多くの場合うまくいきません。マルチプラットフォーム対応には、GitHub ActionsでOS別のランナーを使うCI/CDが最も現実的です:
# .github/workflows/release.yml(主要部分)
jobs :
build :
strategy :
matrix :
os : [ windows-latest , macos-14 , ubuntu-22.04 ]
runs-on : ${{ matrix.os }}
steps :
- uses : actions/checkout@v4
- uses : dtolnay/rust-toolchain@stable
- run : npm ci
- run : npm run tauri build
落とし穴5:Windowsでのwindows_subsystem = "windows"の扱い
main.rsの冒頭に#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]を入れておかないと、本番ビルドのWindowsアプリ起動時にコンソールウィンドウが表示されます。上記のコードにはすでに含まれていますが、自分でmain.rsを書き直す際は、忘れずに追加していただければと思います。
本番ビルドとコード署名
開発中はnpm run tauri devでホットリロードしながら開発できます。本番ビルドは以下のコマンドで生成されます:
npm run tauri build
# ビルド成果物の場所
# macOS: src-tauri/target/release/bundle/dmg/*.dmg
# src-tauri/target/release/bundle/macos/*.app
# Windows: src-tauri/target/release/bundle/msi/*.msi
# src-tauri/target/release/bundle/nsis/*.exe
# Linux: src-tauri/target/release/bundle/deb/*.deb
# src-tauri/target/release/bundle/rpm/*.rpm
macOSのコード署名と公証 について。Apple Developer Program(年間$99)に加入している場合、tauri.conf.jsonのbundle.macOSセクションにsigningIdentityとproviderShortNameを設定するだけで、tauri build実行時に自動的にコード署名と公証が行われます。公証なしで配布すると、Gatekeeperが「悪意のあるソフトウェア」として警告を出し、一般ユーザーには起動できません。
Windowsは開発者向け配布であれば署名なしでも動きますが、SmartScreen警告が表示されます。法人向け配布を想定する場合は、EV証明書の取得をご検討ください。
Gemini APIのストリーミングに関する詳細な実装パターンについては、Gemini APIでストリーミング応答とマルチターン会話を実装するも参考になります。Function Callingと組み合わせることで、より高機能なデスクトップAIエージェントも実現できます(Gemini API Function Calling 完全入門ガイドも合わせてご覧ください)。
複数のコマンドで状態を共有する:Tauri の State
画面が一つのうちは、コマンドごとに get_client() を呼んで済みます。会話履歴を持たせたくなった途端、「履歴をどこに置くか」という問いが出てきます。フロントエンドの React の状態に持たせると、毎回すべての履歴をRustへ送り直すことになり、キーを隠した意味が薄れていきます(履歴にも機密が含まれうるためです)。履歴もRust側に置くのが、この構成では素直だと私は判断しています。
Tauri には manage() で登録し、コマンドの引数 tauri::State で受け取る仕組みがあります。会話履歴を扱うには role(user と model の交互)が要るので、前半で使った GeminiContent に role を足します。
use std :: sync :: Mutex ;
#[derive( Serialize , Deserialize , Clone )]
struct GeminiContent {
role : String ,
parts : Vec < GeminiPart >,
}
struct AppState {
api_key : String ,
client : reqwest :: Client ,
history : Mutex < Vec < GeminiContent >>,
}
fn main () {
#[cfg(debug_assertions)]
dotenvy :: dotenv () . ok ();
let api_key = std :: env :: var ( "GEMINI_API_KEY" )
. expect ( "GEMINI_API_KEY が設定されていません" );
let client = reqwest :: Client :: builder ()
. timeout ( std :: time :: Duration :: from_secs ( 120 ))
. build ()
. expect ( "HTTPクライアントの初期化に失敗しました" );
tauri :: Builder :: default ()
. manage ( AppState { api_key, client, history : Mutex :: new ( Vec :: new ()) })
. invoke_handler ( tauri :: generate_handler! [send_chat_message, reset_conversation])
. run ( tauri :: generate_context! ())
. expect ( "Tauriアプリの起動に失敗しました" );
}
コマンド側では、履歴への追加とコピーを短いブロックで閉じ、await をまたいでロックを持たないようにします。
const HISTORY_LIMIT : usize = 20 ; // 直近10往復だけ残す
#[tauri :: command]
async fn send_chat_message (
message : String ,
state : tauri :: State <' _ , AppState >,
) -> Result < String , String > {
// ① ユーザー発言を追加し、送信用にコピーを取る(ロックはここで解放)
let contents = {
let mut h = state . history . lock () . map_err ( | _ | "履歴のロックに失敗しました" . to_string ()) ? ;
h . push ( GeminiContent {
role : "user" . into (),
parts : vec! [ GeminiPart { text : message }],
});
h . clone ()
};
let url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:generateContent" ;
let response = state . client
. post (url)
. header ( "x-goog-api-key" , & state . api_key)
. json ( & serde_json :: json! ({ "contents" : contents }))
. send ()
.await
. map_err ( | e | format! ( "ネットワークエラー: {}" , e . without_url ())) ? ;
let json : serde_json :: Value = response
. json ()
.await
. map_err ( | e | format! ( "レスポンスの解析に失敗: {}" , e . without_url ())) ? ;
let reply = json[ "candidates" ][ 0 ][ "content" ][ "parts" ][ 0 ][ "text" ]
. as_str ()
. ok_or_else ( || "レスポンスにテキストが含まれていません" . to_string ()) ?
. to_string ();
// ② モデルの返答を追加し、古い発言から切り捨てる
let mut h = state . history . lock () . map_err ( | _ | "履歴のロックに失敗しました" . to_string ()) ? ;
h . push ( GeminiContent { role : "model" . into (), parts : vec! [ GeminiPart { text : reply . clone () }] });
if h . len () > HISTORY_LIMIT {
let excess = h . len () - HISTORY_LIMIT ;
h . drain ( 0 .. excess);
}
Ok (reply)
}
#[tauri :: command]
fn reset_conversation (state : tauri :: State <' _ , AppState >) -> Result <(), String > {
state . history . lock () . map_err ( | _ | "履歴のロックに失敗しました" . to_string ()) ?. clear ();
Ok (())
}
ここで押さえておきたいのは、ロックを持ったまま .await しないことです。std::sync::Mutex のガードを保持したまま await をまたぐと、コマンドの Future が Send でなくなり、コンパイルの段階で止められます。上のコードは、ガードを { } の中で閉じてから通信に入る形にしてあります。コンパイラの指摘に沿って tokio::sync::Mutex へ替える方法もありますが、会話が一つしかないデスクトップアプリでは、std 版を短く使うほうが読みやすいと感じています。
履歴を20件で切るのは、トークン量を一定の範囲に収めるための線引きです。先頭の user 発言が切り捨てられて model 発言から始まる履歴になることがあるので、実運用では偶数件単位で落とすか、先頭が user になるよう揃えると安心です。
Rust の関数単体でテストする
ロジックを Rust 側に寄せる利点のひとつは、GUI を起動せずに検証できることです。通信そのものではなく、「レスポンスからテキストを取り出す」「ブロックされた応答を見分ける」「履歴を切り詰める」といった判定を、純粋な関数として切り出しておきます。
fn extract_text (json : & serde_json :: Value ) -> Result < String , String > {
if let Some (reason) = json[ "promptFeedback" ][ "blockReason" ] . as_str () {
return Err ( format! ( "コンテンツがブロックされました: {}" , reason));
}
json[ "candidates" ][ 0 ][ "content" ][ "parts" ][ 0 ][ "text" ]
. as_str ()
. map ( | s | s . to_string ())
. ok_or_else ( || "レスポンスにテキストが含まれていません" . to_string ())
}
fn trim_history (h : &mut Vec < GeminiContent >, limit : usize ) {
if h . len () > limit {
let excess = h . len () - limit;
h . drain ( 0 .. excess);
}
}
#[cfg(test)]
mod tests {
use super ::* ;
use serde_json :: json;
#[test]
fn extracts_text () {
let r = json! ({ "candidates" : [{ "content" : { "parts" : [{ "text" : "こんにちは" }] } }] });
assert_eq! ( extract_text ( & r) . unwrap (), "こんにちは" );
}
#[test]
fn detects_blocked_response () {
let r = json! ({ "promptFeedback" : { "blockReason" : "SAFETY" }, "candidates" : [] });
assert! ( extract_text ( & r) . unwrap_err () . contains ( "SAFETY" ));
}
#[test]
fn trims_oldest_messages () {
let mut h : Vec < GeminiContent > = ( 0 .. 25 )
. map ( | i | GeminiContent {
role : if i % 2 == 0 { "user" . into () } else { "model" . into () },
parts : vec! [ GeminiPart { text : format! ( "message {}" , i) }],
})
. collect ();
trim_history ( &mut h, 20 );
assert_eq! (h . len (), 20 );
assert_eq! (h[ 0 ] . parts[ 0 ] . text, "message 5" );
}
}
src-tauri/ で cargo test を実行すれば、数秒で結果が返ってきます。先に示した generate_text の中で同じ判定を書いている場合は、extract_text を呼ぶ形に寄せておくと、本番のコードそのものをテストできます。テストの中でレスポンスを組み立てるときは、実際に受け取ったJSONの形を一度だけ手で確認してから json! に写すようにしてください。想像で書いた形に対して通るテストは、安心だけを増やしてしまいます。
配布の前に決めておくこと
ビルドが通ってからの作業は、意外と多く残っています。AIアプリ特有のところだけを挙げます。
自分用のツールなら、キー入力画面とローカルのログで十分です。一般の方へ配る場合は、先ほどのプロキシ方式(②)に寄せ、キーをアプリに持たせない設計を検討していただければと思います。
Tauri を選ばないほうがよい場面
ここまで Tauri の良い面を書いてきましたが、向かない場面もあります。チームの経験が Web 中心で Rust にほとんど触れていない場合、学習の時間が先に膨らみます。そのときは Electron のほうが、資料も人手も揃っていて現実的です。ブラウザが標準で持つ機能の範囲で足りるなら、PWA で済ませる手もあります。
一方で、秘密をクライアントに置きたくない、配布物を小さくしたい、ブラウザが意図して塞いでいるOS連携が要る、という条件が重なるなら、Tauri の出番だと思います。ローカル文書の解析、クリップボードの要約、システム全体を対象にした要約ツールのような、Gemini API を使った小さなデスクトップツールは、この条件にうまく収まります。
個人開発者の視点から(実体験メモ)
最初に組んだ試作では、.env を src-tauri/ に置いて cargo tauri dev を回し、ストリーミングが流れた時点で「これで完成だ」と思い込んでおりました。ところが、リリースビルドを友人に渡す前に起動確認をしてみると、画面には「GEMINI_API_KEY が設定されていません」と出るだけでした。デバッグビルドでしか .env を読まない設計にしていたのですから、当然の結果です。気づいたのは、渡す直前の夜でした。
もう一つ、手が止まったことがあります。dotenvy を [dev-dependencies] に書いたままだと、main.rs から呼んだ時点でビルドが通らない場合があります。[dev-dependencies] はテストやサンプル用の依存で、通常のビルドには含まれないためです。本稿のコードでは [dependencies] に置いています。
この二つから引いた線引きは、次のとおりです。開発で動いたキーの経路は、配布物では存在しないものとして設計します。 配布前には、リリースビルドを一度だけ素の状態(.env なし)で起動し、キー未設定のメッセージが想定どおり出るか、ユーザーの入力画面かプロキシへ切り替わるかを確かめるようにしています。
全体を振り返って
Tauri 2.0 × Gemini APIの組み合わせは、「APIキーをどこで管理するか」という問題をRustバックエンドで解決しながら、ElectronよりはるかにコンパクトなデスクトップAIアプリが作れる構成です。ストリーミングはTauriのイベントシステムを使えば想像より素直に実装でき、クリップボードや通知との連携でWebアプリにはできない体験が作れます。
まず動かすための最短ルートとして、このガイドのRustコードとReactフックをそのまま使い、ストリーミングが動くことを確認するところから始めていただければと思います。次の一歩は、.env を外したリリースビルドの起動確認です。