0
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 で「好きなAIを、好きなペルソナで使い分けられる」OSSチャットクライアントを作ってApp Storeにリリースした話

0
Posted at

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

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

という動機で、Flutter製のOSSマルチプロバイダーAIチャットクライアント PolyMind を開発し、iOS版をApp Storeでリリースしました。リリース後も機能追加を続けており、直近では「用途別のAIペルソナを切り替えられるカスタムエージェント機能」を追加しています。

本記事では、単なる機能紹介ではなく、複数のLLMプロバイダーを破綻なく共存させる設計 と、後から追加した「エージェント機能」を既存アーキテクチャに矛盾なく組み込んだ設計判断 を中心に書きます。

プリインストール済みのAIエージェント一覧 エージェントを使ったチャット ダークモード

PolyMindとは

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

  • OpenAI / Google Gemini / Anthropic Claude / Ollama(ローカルLLM)/ その他OpenAI互換エンドポイント に対応
  • カスタムAIエージェント — 名前・絵文字・システムプロンプトを保存し、会話ごとに使い分け可能。文章校正・コードレビュー・翻訳・要約・アイデア出し・やさしい解説の6つがプリインストール済み
  • 「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({required List<ChatMessage> history, String? systemPrompt});
  Future<String> generate({required List<ChatMessage> history, String? systemPrompt});
  Future<String> generateImage({required String prompt});
  bool get supportsImageGeneration;
  Future<List<String>> listModels();
}

現在5種類のプロバイダー(OpenAiRepository / GeminiRepository / ClaudeRepository / OllamaRepository、そして後述の「Other」)はすべてこのインターフェースを実装し、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!);
    case LlmProvider.other:
      // OpenAI API互換のエンドポイントとして扱う
      return OpenAiRepository(_config!);
  }
}

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

実際、最近追加した「Other(OpenAI互換)」プロバイダーは、セルフホストやサードパーティのOpenAI互換ゲートウェイに接続する用途向けですが、新規Repository実装を一切書かずに済みました。OpenAI API互換という前提そのままに、既存の OpenAiRepository をswitch文で再利用するだけで対応できたのは、抽象化がちゃんと効いている証拠だと感じています。

カスタムエージェント機能:後付けでも設計を壊さないために

複数プロバイダー対応が一段落したあと、「ChatGPTのCustom GPTsのように、用途別のAIペルソナを切り替えたい」という要望を実装しました。これは既存の設計に新しい軸(システムプロンプト)を割り込ませる作業だったので、慎重に設計判断をしています。

システムプロンプトは「会話作成時にスナップショットする」

エージェント(AgentConfig:名前・絵文字・システムプロンプト)はSQLiteの agents テーブルに保存し、会話を新規作成する際に選んだエージェントの id と、その時点での systemPrompt両方conversations テーブルに書き込みます。

ALTER TABLE conversations ADD COLUMN agent_id TEXT;
ALTER TABLE conversations ADD COLUMN system_prompt_snapshot TEXT;

一見、agent_id だけ持たせて都度 agents テーブルとJOINすれば正規化されてスマートに見えます。しかしこれをやると、後からエージェントの中身を編集したときに、過去の会話の「再生成」結果が静かに変わってしまうという問題が起きます。再生成やメッセージ編集は既存の会話に対して何度でも呼び出せる機能なので、エージェント編集が過去ログの挙動に影響するのは驚き最小の原則に反します。

そこで、実際に生成に使うのは常に system_prompt_snapshot(会話作成時点のコピー)とし、agent_id はUI表示(絵文字・名前の解決)専用のソフト参照に留めました。外部キー制約もあえて付けていません。エージェントを削除しても、そのエージェントを使っていた過去の会話は一切壊れず、そのまま再生成できます。

static Future<List<lc.ChatMessage>> _buildPrompt(
  List<ChatMessage> history, {
  String? systemPrompt,
}) async {
  final prompt = <lc.ChatMessage>[];
  if (systemPrompt != null && systemPrompt.trim().isNotEmpty) {
    prompt.add(lc.ChatMessage.system(systemPrompt.trim()));
  }
  for (final message in history) {
    // ...既存の履歴構築
  }
  return prompt;
}

この「作成時点の値をコピーして持つ」という考え方自体は、実は既存の conversations テーブルがすでに採用していたパターンでした(provider/model も会話作成時点で固定)。新機能を作るときに「このアプリの中で似た問題を過去どう解決したか」を先に探すと、変な独自ルールを増やさずに済むという良い実例だったと思います。

プリインストールエージェントは「ユーザーの改変を上書きしない」

エージェント機能には、文章校正・コードレビュー・翻訳・要約・アイデア出し・やさしい解説の6つを初回起動時にプリインストールしています。ここで気を付けたのは、アプリをアップデートしたユーザーの agents テーブルにすでにデータがある場合に、勝手に上書きしないことです。

if (oldVersion < 4) {
  final count = Sqflite.firstIntValue(
    await db.rawQuery('SELECT COUNT(*) FROM agents'),
  ) ?? 0;
  // 既にエージェントが存在する場合(ユーザーが自作済み)は上書きしない
  if (count == 0) {
    await _seedDefaultAgents(db);
  }
}

新規インストールでは常に6件をシードしつつ、既存ユーザーのマイグレーションでは「まだ誰もエージェントを作っていない場合のみ」シードする、という条件分岐にしています。

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側の既存実装も同時に書き換える必要がありましたが、結果的に全プロバイダーとも同じ新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つのアプリでまとめて使いたい」と思ったことがあれば、ぜひ試してみてください。

0
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
0
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?