
はじめに
お久しぶりです!
のりさんです。
以前、Flutterで Gemini Pro (APIキー使用) を使ったアプリを作りましたが
今回はローカルLLMを使ったアプリを作ってみたいと思います。
■以前の記事はこちら
https://core-tech.jp/blog/tech_log/7198/
環境
- Windows10 Pro
- Visual Studio Code
- Flutter 3.32.5 / Dart 3.8.1
- Docker Desktop
準備
実装をはじめる前に
ローカルLLM環境(軽量モデル使用)を作成します。
今回はDocker版のOllamaを使いたいと思います。
① Ollamaの起動
まず以下のdockerコマンドでollamaのコンテナを作成します。
docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama
② モデルのダウンロード
今回は軽量な「microai/suzume-llama3」を使用します。
以下のコマンドでDockerコンテナに入り、モデル(今回5GBくらいでした)をダウンロードします。
docker exec -it ollama bash
ollama pull microai/suzume-llama3:latest
新規プロジェクトの作成
①Flutter/Dartのバージョンを最新化
自分が使用している環境は、少し古いバージョンだったので最新版にアップデートしました。
(今回Flutter:3.32.5・Dart:3.8.1でアプリ開発をします)
②Flutter新規プロジェクトの作成
以下のflutterコマンドを実行してFlutterプロジェクトを作成します。
flutter create ollama_client
パッケージの追加
プロジェクト作成が終わったら、必要なパッケージを追加して実装していきます。
- ollama_dart:Ollama Dartクライアント
- grouped_list:グループ化リスト
- flutter_spinkit:待ち時間アニメーション
- intl:日付書式設定
flutter pub add ollama_dart grouped_list flutter_spinkit intl
完成アプリ
完成したアプリは以下のようになりました!
Windowsアプリとして実行しています。

下にあるテキストボックスに質問を入力して
ローカルLLMにメッセージを送信できるようになりました。
それではソースコードをみていきたいと思います。
ソースコード
今回作成したファイルは以下3つです。
import 'package:flutter/material.dart';
import 'package:sample/chat_user.dart';
@immutable
class ChatMessage implements Comparable {
final ChatUser user;
final Widget message;
final DateTime sendDate;
const ChatMessage({
required this.user,
required this.message,
required this.sendDate,
});
@override
int compareTo(other) {
return sendDate.compareTo(other.sendDate);
}
}
import 'package:flutter/material.dart';
@immutable
class ChatUser {
final String name;
final Color color;
final Color messageColor;
final Color messageBackgroundColor;
final Icon? icon;
const ChatUser({
required this.name,
required this.color,
required this.messageColor,
required this.messageBackgroundColor,
this.icon,
});
}
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:grouped_list/grouped_list.dart';
import 'package:intl/intl.dart';
import 'package:ollama_dart/ollama_dart.dart';
import 'package:sample/chat_message.dart';
import 'package:sample/chat_user.dart';
void main() {
runApp(const MyApp());
}
const myUser = ChatUser(
name: "ユーザー",
color: Colors.white,
messageColor: Colors.black,
messageBackgroundColor: Color.fromARGB(255, 251, 255, 214),
icon: Icon(Icons.person, color: Color.fromARGB(255, 56, 143, 82))
);
const partnerUser = ChatUser(
name: "llama3",
color: Colors.white,
messageColor: Colors.black,
messageBackgroundColor: Color.fromARGB(255, 239, 251, 248),
icon: Icon(Icons.person, color: Color.fromARGB(189, 52, 132, 236))
);
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final _scrollController = ScrollController();
final _textController = TextEditingController();
late final _client = OllamaClient();
@override
void initState() {
super.initState();
}
final List<ChatMessage> _messages = [];
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Ollama Client Demo',
theme: ThemeData(primarySwatch: Colors.red, canvasColor: Colors.transparent),
home: Scaffold(
appBar: AppBar(title: const Text('Ollama Client Demo')),
backgroundColor: const Color.fromARGB(189, 52, 132, 236),
body: Column(
children: [
Expanded(
child: Scrollbar(
controller: _scrollController,
thumbVisibility: true,
child: GroupedListView<ChatMessage, DateTime>(
controller: _scrollController,
elements: _messages,
order: GroupedListOrder.DESC,
sort: true,
reverse: true,
floatingHeader: true,
useStickyGroupSeparators: true,
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
groupBy: (ChatMessage m) => DateTime(m.sendDate.year, m.sendDate.month, m.sendDate.day),
groupHeaderBuilder: (ChatMessage m) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: DecoratedBox(
decoration: const BoxDecoration(color: Color.fromARGB(190, 18, 87, 177), borderRadius: BorderRadius.all(Radius.circular(8.0))),
child: Padding(
padding:const EdgeInsets.all(6),
child: Text(
DateFormat('yyyy年MM月dd日').format(m.sendDate),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
),
)
]
);
},
itemBuilder: (context, m) {
var isPartner = (m.user == partnerUser);
return Row(
mainAxisAlignment: isPartner ? MainAxisAlignment.start : MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: isPartner ? CrossAxisAlignment.start : CrossAxisAlignment.end,
children: [
Row(
children: [
if(m.user.icon != null) CircleAvatar(radius: 10, backgroundColor: Colors.white, child: Icon(m.user.icon!.icon, size: 16, color: m.user.icon!.color)),
Padding(padding: const EdgeInsets.only(left: 3, right: 3), child: Text(m.user.name, style: TextStyle(color: m.user.color))),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
elevation: 3,
color: m.user.messageBackgroundColor,
shadowColor: Colors.black45,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(18.0))),
margin: const EdgeInsets.symmetric(horizontal:0, vertical: 0),
child: Padding(
padding: const EdgeInsets.only(top: 6.0, bottom: 6.0, left: 15.0, right: 15.0),
child: m.message,
),
),
),
],
),
],
),
],
);
},
)
),
),
Container(
color: Colors.grey[200],
padding: const EdgeInsets.only(bottom: 15, left: 50, right: 50, top: 15),
child: TextField(
controller: _textController,
minLines: 1,
maxLines: 5,
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
border: OutlineInputBorder( borderSide: BorderSide.none, borderRadius: BorderRadius.circular(24)),
focusedBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(24)), borderSide: BorderSide(color: Colors.blue, width: 2)),
suffixIcon: IconButton(
onPressed: () async {
if (_textController.text.isEmpty) return;
final sendText = _textController.text;
setState(() {
final message = ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width - 100),
child: SelectableText(sendText)
);
_messages.add(ChatMessage(user: myUser, message: message, sendDate: DateTime.now()));
_textController.clear();
});
var stream = _client.generateCompletionStream(
request: GenerateCompletionRequest(
model: 'microai/suzume-llama3',
prompt: sendText,
),
);
await Future.delayed(const Duration(milliseconds: 100));
setState(() {
final spinkit = SpinKitThreeBounce(
itemBuilder: (context, index) => const DecoratedBox(decoration: BoxDecoration(color: Color.fromARGB(255, 28, 108, 212))),
size: 16,
);
var data = "";
final builder = StreamBuilder(
stream: stream.asBroadcastStream(),
builder: (context, snapshot) {
if (snapshot.hasData) {
data += (snapshot.data?.response ?? "").replaceAll("。", "。\n").replaceAll("\n\n", "\n");
return Row(children: [
Text(data),
if (snapshot.connectionState != ConnectionState.done) spinkit
]);
}
if (snapshot.connectionState == ConnectionState.done) {
return Text(data.trimRight());
}
return spinkit;
},
);
_messages.add(ChatMessage(user: partnerUser, message: builder, sendDate: DateTime.now()));
});
},
icon: const Icon(Icons.send, color: Colors.blue),
),
),
),
),
],
),
),
);
}
}
おわりに
今回はFlutterからローカルLLMを利用してみました。
Gemini Proの時と同じく、簡単に作れたのでアプリに組み込みやすそうです。
それではまた!