1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutter × Riverpod × LangChain で「OpenAI / Gemini / Claude / Ollama を1つのアプリで使い分けられる」OSSチャットクライアントを作ってApp Storeにリリースした話

1
Posted at

ChatGPT・Gemini・Claude…と便利なAIチャットサービスが増える一方で、「今日はどのAIに聞こうか」でアプリを何個も行き来するのが地味にストレスでした。

使いたいAIを自分で選べて、自分のAPIキーで、自分のデータは自分の端末に閉じたまま使えるクライアントが欲しい。

という動機で、Flutter製のOSSマルチプロバイダーAIチャットクライアント PolyMind を開発し、iOS版をApp Storeでリリースしました。

本記事では、単なる機能紹介ではなく、4つのLLMプロバイダーを破綻なく共存させるためにどう設計したか という技術的な部分を中心に書きます。

プロバイダー切り替え画面 コードのシンタックスハイライト ダークモード

PolyMindとは

Flutter × Riverpod × LangChain Dart で構築した、OSSのマルチプロバイダーAIチャットクライアントです。

  • OpenAI / Google Gemini / Anthropic Claude / Ollama(ローカルLLM) に対応
  • 「Bring Your Own Key」方式 — 自分のAPIキーで直接プロバイダーと通信し、開発者のサーバーは存在しない
  • チャット履歴はSQLiteで端末内に永続化、APIキーは flutter_secure_storage でKeychain/EncryptedSharedPreferencesに暗号化保存
  • リアルタイムストリーミング応答、Vision入力、画像生成(/image コマンド)、Markdownレンダリング、コードシンタックスハイライト、ダークモードなどに対応

無料でダウンロードでき、ソースコードもMITライセンスで公開しています。

なぜマルチプロバイダー対応は「後付け」だと破綻するか

最初はOpenAIのみに対応したシンプルな実装から始めましたが、Gemini・Claudeを追加する際に素直に「OpenAI用の分岐をif文で増やす」実装にすると、以下の問題が起きます。

  • UIレイヤーがプロバイダーの違い(レスポンス形式、対応機能の有無)を意識し始める
  • プロバイダーを1つ追加するたびに、既存コードの複数箇所に手を入れる必要が出る
  • 「このプロバイダーは画像生成に対応していない」といった機能差分の分岐がアプリ全体に散らばる

そこで、以下の抽象を導入しました。

abstract class LlmRepository {
  Stream<String> stream(List<ChatMessage> history);
  Future<String> generate(List<ChatMessage> history);
  Future<String> generateImage(String prompt);
  bool get supportsImageGeneration;
  Future<List<String>> listModels();
}

4つのプロバイダー(OpenAiRepository / GeminiRepository / ClaudeRepository / OllamaRepository)はすべてこのインターフェースを実装し、ChatModel._buildRepository() が設定に応じて実装を差し替えます。

LlmRepository _buildRepository() {
  switch (_config!.provider) {
    case LlmProvider.openai:
      return OpenAiRepository(_config!);
    case LlmProvider.gemini:
      return GeminiRepository(_config!);
    case LlmProvider.claude:
      return ClaudeRepository(_config!);
    case LlmProvider.ollama:
      return OllamaRepository(_config!);
  }
}

この結果、新しいプロバイダーを追加する作業は「LlmProvider enumに値を足す → LlmRepository を実装したクラスを1つ追加する → switch文に1行足す」だけ になりました。Dartの switch はenumの網羅性チェックをコンパイル時にしてくれるため、対応漏れがあればビルドが落ちる、という安全性も地味に効いています。

画像生成のようにプロバイダーによって対応有無が分かれる機能は supportsImageGeneration のようなケーパビリティフラグとして表現し、UI側は「この機能に対応しているかどうか」だけを見て、プロバイダーの種類そのものを条件分岐しません。

LangChain Dartの「block-native」移行で苦労した話

裏側のLLM通信にはLangChain Dartを使っています。開発途中で langchain_core が0.5系にアップデートされ、メッセージのcontentが String から content block の List に変わる破壊的変更が入りました。

// Before(〜0.4系)
final delta = chunk.output.content; // String

// After(0.5系〜、block-native)
final delta = chunk.output.contentAsString; // 可視テキストのみを取り出すアクセサ

アシスタント側の履歴構築も同様に変わります。

// Before
lc.ChatMessage.ai(message.text)

// After
lc.ChatMessage.aiText(message.text)

Gemini・Claude対応の追加はこのアップデートとセットで行ったため、OpenAI・Ollama側の既存実装も同時に書き換える必要がありましたが、結果的に4プロバイダーとも同じ新APIに統一でき、実装のブレがなくなりました。

ストリーミング応答の「確実に止まる」中断設計

チャット生成中に停止ボタンを押したのに応答が止まらない、という不具合が開発中に発覚しました。原因は典型的なアンチパターンで、「フラグを立てて、ストリームのチャンクを受け取るたびにフラグをチェックする」実装になっていたことです。

// アンチパターン: チャンクが来ないと判定されない
bool _shouldStop = false;

await for (final chunk in stream) {
  if (_shouldStop) break; // 次のチャンクが来るまで止まらない
  ...
}

接続が詰まってチャンクがしばらく来ない状況では、このチェック自体が実行されないため、ユーザーが停止ボタンを押しても永遠に反応しません。

これを、StreamSubscription を直接キャンセルする方式に変更しました。

StreamSubscription<String>? _streamSubscription;
Object? _generationToken;

void stopGenerating() {
  _streamSubscription?.cancel(); // チャンク到着を待たずに即座に切断
  _completeAsStopped();
}

あわせて、「今アクティブな生成がどれか」を識別するための不透明なトークン(_generationToken)を導入し、キャンセル後に古い生成のコールバックが紛れ込んでUIを上書きしないようにしています。ポーリングよりわずかに設計コストはかかりますが、低速な接続やスタックした接続でも確実に中断できるようになりました。

そのほかの設計判断

  • 会話の遅延永続化 — 「New Chat」をタップした時点ではSQLiteに何も書き込まず、最初のメッセージが送信された時点で初めて会話レコードを挿入します。これにより、何も書かずに閉じた空の会話が履歴を汚染しません。
  • ThemeExtension によるカラーシステム — AppColors extends ThemeExtension<AppColors> としてライト/ダーク配色を定義し、Widget側は context.colors.xxx で参照します。Color リテラルのハードコードを禁止するルールをコードベース全体で徹底しました。ダークモードのコードブロックで地の文字が見えなくなる不具合(配色をライト固定で持っていたのが原因)もこの仕組みのおかげで原因特定・修正が一箇所で済みました。

技術スタック

カテゴリ パッケージ
状態管理 flutter_riverpod
LLM連携 langchain, langchain_openai, langchain_ollama, langchain_google, langchain_anthropic
UI / Markdown flutter_markdown, cached_network_image
シンタックスハイライト highlight
ストレージ sqflite, flutter_secure_storage, path_provider
画像入力 image_picker

まとめ

「複数のAIプロバイダーに対応する」というと機能追加の話に見えますが、実際にやってみると

  • インターフェース設計(プロバイダーの違いを1箇所に閉じ込める)
  • 破壊的なライブラリアップデートへの追従
  • 「止まるはずなのに止まらない」非同期処理の落とし穴

といった、地味だけれど効いてくる設計判断の積み重ねでした。ソースコードはすべてGitHubで公開しているので、興味のある方はぜひ覗いてみてください。フィードバック・Issue・PRも歓迎です。

もし「自分の好きなAIを自分のAPIキーで、1つのアプリでまとめて使いたい」と思ったことがあれば、ぜひ試してみてください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?