Dart言語でデスクトップ向けのAIチャットアプリを作ってみたいと思います。
実装する機能
今回は以下のような要件で実装します。
- TUIアプリケーションにする
- オンデバイスLLMでチャットできるようにする
- 画像生成できるようにする
- マークダウン記法の一部をサポートする
実行画面
まず、結果のスクリーンショットを記載しておきます。
画面下部に入力フィールドがあり、その上はスクロール可能なメッセージ表示欄になっています。
チャットで入力した内容
- 「ローマ帝国の勃興と滅亡の歴史を、すごく簡単に教えてください」
- 「もしアウグストゥスがゴールデンリバーだったら、という設定で画像を作ってください」
主な使用パッケージ
TUIにはnocterm、Ollamaの利用にはollama_dartパッケージを使用します。
nocterm
Flutterに近い感覚でTUIを構築するためのフレームワークです。以前Qiitaに記事を書いているので、興味があれば見てみてください。
ollama_dart
Ollamaは、LLMをローカル環境(オンデバイス)で手軽に動かすためのツールです。
dart用にはollama_dart パッケージを使います。ollamaというパッケージもありますが、ollama_dartのほうが新しく機能も多いようです。
ソースコードについて
今回のソースコードはGitHubにホストしてあります。
ファイルは以下のようになっています。
| パス | 役割 |
|---|---|
lib/main.dart |
エントリポイント(引数解析→UI起動) |
lib/src/cli_options.dart |
コマンドライン引数の解析 |
lib/src/chat_app.dart |
noctermによるチャットUI |
lib/src/chat_service.dart |
Ollamaとのチャット送受信(ストリーミング・ツール呼び出しの集約) |
lib/src/generate_image_tool.dart |
画像生成ツール(生成と保存) |
lib/src/markdown_util.dart |
Markdownをターミナル表示用のRichTextに変換 |
lib/src/system_prompt.dart |
LLMに与えるシステムプロンプト |
使用するモデルやOllamaのホストはコマンドラインオプションで変更できます。
# デフォルト(gemma4:latest / x/z-image-turbo:latest)で起動
dart run lib/main.dart
# モデルを指定して起動
dart run lib/main.dart --model gemma3:latest --image-model x/flux.1:latest
実装のポイント
テキストモデルと画像生成モデルを分けて使う
チャットできてツール対応していて画像生成もできるモデルというのはあまり多くありません。ですので、今回はテキスト用と画像生成用の2つのLLMを使うようにしました。
| 用途 | モデル名(デフォルト) | 備考 |
|---|---|---|
| テキスト用 | gemma4:latest | tool対応モデルであること |
| 画像生成用 | x/z-image-turbo:latest | imageモデル |
テキスト用のモデルは、画像生成をツール呼び出し(function calling)で依頼するため、tool対応のモデルである必要があります。
チャットから画像生成までの流れは以下のようになっています。
toolの処理
画像生成は、LLMに以下のようなツール定義を渡しておき、LLMがツール呼び出しを返してきたら画像生成モデルを起動する、という流れで実現しています。
static const definition = ToolDefinition(
type: ToolType.function,
function: ToolFunction(
name: 'generate image',
description: 'tool for generating image',
parameters: {
'type': 'object',
'properties': {
'prompt': {
'type': 'string',
'description': 'prompt for generating image',
},
},
'required': ['prompt'],
},
),
);
ストリーミング中はテキストの差分とツール呼び出しの両方が届く可能性があるので、ChatServiceでツール呼び出しを集約しておき、応答の完了後にまとめて実行しています。
await for (final chunk in stream) {
final message = chunk.message;
if (message == null) continue;
if (message.toolCalls?.isNotEmpty ?? false) {
toolCalls.addAll(message.toolCalls!);
} else if (message.content != null) {
buffer.write(message.content);
onDelta(message.content!);
}
}
なお、Ollamaの仕様では、同じレスポンスでチャットとツール呼び出しのデータが返ることがあるようです。
しかし、今回の実装では、通常のチャットとツール呼び出しとを排他的に処理しています(ツール呼び出しがあれば最終メッセージはツールの結果だけになります)。
排他的でなく同時に処理することはそれほど難しくありません。
マークダウンの表示
LLMの応答はマークダウンで返ってくることが多いので、そのまま表示すると ** や # などの記号だらけになってしまいます。
今回は簡易的なパーサーを実装して、noctermのRichText(TextSpanのツリー)に変換して表示するようにしました。
サポートしているのは以下の記法です。
- ヘッダー(
# 見出し)→ シアンの太字 - リスト(
- item/1. item)→ 記号を•に置き換え - ボールド(
**text**)→ 太字 - リンク(
[label](url))→ ラベルに下線+URLを併記 - インラインコード(
`code`)→ 色付き表示
行単位でヘッダー・リストを判定し、行の中は正規表現でボールド・リンク・インラインコードを拾う、という素朴な実装ですが、チャット表示用途にはこれで十分でした。
まとめ
nocterm + ollama_dartの組み合わせで、ストリーミング表示・画像生成・マークダウン表示まで含めたAIチャットアプリを、少ないコード量で実装できました。
さまざまなチャットクライアントが既にあるので自作する動機はあまりないかもしれませんが、
- ローカル完結なのでプライバシーの心配がない
- ツール呼び出しで独自の機能(今回は画像生成)を自由に組み込める
- Flutterの知識がそのまま活かせる
といった点で、Dartでの自作も選択肢になると思います。
最後に
株式会社ボトルキューブではFlutterを使ったお仕事を募集中です。
お問い合わせフォームからご連絡ください。
また、一緒に働く仲間も募集しています。
詳細は採用情報ページをご覧ください。

