前言
Ollama 是一个基于 Go 语言开发的可以本地运行大模型的开源框架。Flutter 是由 Google 开发的开源移动跨平台开发框架。基于这两个开源项目,我们可以开发一个 极简 的完全在本地运行的聊天机器人。其中 Ollama 提供的模型能力作为服务端。客户端使用 Flutter 开发,支持 iOS、Android、macOS 等平台。
效果演示
macOS 的运行效果:
Android 的运行效果:
Ollama 本地运行大模型
关于如何 Ollama 本地运行大模型,需要阅读这篇文章:Ollama:本地大模型运行指南。
我选择 gemma:2b
作为大模型提供后端接口。
聊天机器人开发
基于 Ollama 的本地大模型运行起来后,接口就算准备好了。本文不会对 Flutter 代码进行全部的讲解,如果需要查看全部代码,可以在 Github:https://github.com/yangpeng7/flutter_ollama_chat/ 下载。也不会介绍 Flutter 环境的搭建过程。只会讲解在开发一个类似的聊天机器人中可能会遇到的注意点。因此我们会重点关注下面几个问题:
聊天页面布局
数据倒序显示
加载更多数据
一次性请求数据处理
流式数据处理
SQLite 数据库存储
项目结构

├── lib
│ ├── components /// 组件
│ │ ├── answer.dart /// 回复
│ │ └── question.dart /// 问题
│ ├── config.dart /// 配置
│ ├── db
│ │ └── database_helper.dart /// 数据存储
│ ├── main.dart /// 应用入口
│ ├── model
│ │ ├── message.dart /// 消息结构
│ │ └── message_type.dart /// 消息类型
│ └── pages
│ └── chat_page.dart /// 聊天页面
聊天页面布局
聊天机器人和通常的 IM 软件(可以一问多答或者多问一答)不同。我们要开发的机器人是一问一答的模式,一个问题对应一个答案,同时也不涉及群组。这样就可以简单处理,通过 chat.sender 来判断,如果是你发出的问题就显示 Question 这个 Widget,如果是大模型给的回复就显示 Answer 这个 Widget。
ListView.separated(
separatorBuilder: (_, __) => const SizedBox(
height: 12,
),
padding: EdgeInsets.only(bottom: 10),
itemBuilder: (ctx, index) {
Message chat = chatList[index];
return Column(
children: <Widget>[
const SizedBox(
height: 10,
),
chat.sender == Config.yourName
? Question(chat: chat)
: Answer(chat: chat)
],
);
},
controller: _scrollController,
reverse: true,
shrinkWrap: true,
itemCount: chatList.length,
physics: const BouncingScrollPhysics(),
),
数据倒序显示
这块比较简单,只需要设置 ListView 属性 reverse: true
即可,设置后如下图,会把 ListView 反转。

但此时还有一个问题,数据较少时比如只有 2 条,reverse: true
后会出现下图的状态:

虽然数据反转了,但是当不满一屏时,应该在最上方显示数据,因此还需要设置 shrinkWrap: true
。

shrinkWrap 是一个用于滚动视图(例如ListView、GridView等)的属性。设置 shrinkWrap: true 的作用是使滚动视图根据其子项的总高度来确定自身的高度,而不是尽可能地占据父容器提供的所有空间。
加载更多数据
void onScroll() {
_focusNode.unfocus();
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent) {
debugPrint("load more");
if (_isLoading) return;
_isLoading = true;
_loadMessages();
}
}
scrollController.position.pixels:获取当前滚动位置,以像素为单位。
scrollController.position.maxScrollExtent:获取滚动视图的最大滚动范围。
这个条件判断的意思是:如果当前滚动位置大于或等于最大滚动范围,则加载更多。
一次性请求数据处理
网络请求依赖 http (https://pub.dev/packages/http) 这个库,一次性请求数据比较简单,就是一个标准的POST请求。
Future<void> _getBotAnswer(String question) async {
final requestBody = {
"model": "gemma:2b",
"prompt": question,
"stream": false
};
final response = await http.post(
Uri.parse("${Config.url}/api/generate"),
body: jsonEncode(requestBody),
);
Map<String, dynamic> responseData =
json.decode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200) {
String content = responseData["response"];
// 将数据添加到流中
await DatabaseHelper().insertMessage(Message(
message: content,
type: MessageType.text,
sender: Config.botName,
receiver: Config.yourName,
// time: DateTime.now().subtract(const Duration(minutes: 15)),
));
_refreshMessages();
} else {
debugPrint("request error");
}
}
流式数据处理
Future<void> _getBotAnswerStream(String question) async {
final requestBody = {
"model": "gemma:2b",
"prompt": question,
"stream": true
};
var request = http.Request("POST", Uri.parse("${Config.url}/api/generate"));
request.body = jsonEncode(requestBody);
http.Client().send(request).then((response) {
String showContent = "";
final stream = response.stream.transform(utf8.decoder);
chatList.insert(
0,
Message(
message: showContent,
type: MessageType.text,
sender: Config.botName,
receiver: Config.yourName,
// time: DateTime.now().subtract(const Duration(minutes: 15)),
));
stream.listen(
(data) async {
Map<String, dynamic> resp = json.decode(data);
debugPrint("data${resp["response"]}");
chatList[0] = Message(
message: "${chatList[0].message}${resp["response"]}",
type: MessageType.text,
sender: Config.botName,
receiver: Config.yourName,
// time: DateTime.now().subtract(const Duration(minutes: 15)),
);
if (resp["done"]) {
await DatabaseHelper().insertMessage(chatList[0]);
_refreshMessages();
}
setState(() {});
},
onDone: () {
debugPrint("onDone");
},
onError: (error) {
debugPrint("onError");
},
);
});
}
定义异步函数
Future<void> _getBotAnswerStream(String question) async {
定义一个异步函数,接受一个问题字符串question作为参数,并返回一个Future。
构建请求体
final requestBody = {
"model": "gemma:2b",
"prompt": question,
"stream": true
};
构建一个包含模型名称、提示问题和流模式的请求体。
创建HTTP请求
var request = http.Request("POST", Uri.parse("${Config.url}/api/generate"));
request.body = jsonEncode(requestBody);
创建一个HTTP POST请求,目标URL由Config.url指定,并将请求体编码为JSON格式。
发送请求并处理响应
http.Client().send(request).then((response) {
String showContent = "";
final stream = response.stream.transform(utf8.decoder);
chatList.insert(
0,
Message(
message: showContent,
type: MessageType.text,
sender: Config.botName,
receiver: Config.yourName,
));
发送请求,并通过then方法处理响应。
初始化一个空字符串showContent,用于存储流数据。
将响应流转换为UTF-8解码后的字符串流。
在聊天列表chatList中插入一个新的消息,内容为空字符串,位置在列表的顶部(索引0)。
监听流数据
stream.listen(
(data) async {
Map<String, dynamic> resp = json.decode(data);
debugPrint("data${resp["response"]}");
chatList[0] = Message(
message: "${chatList[0].message}${resp["response"]}",
type: MessageType.text,
sender: Config.botName,
receiver: Config.yourName,
);
if (resp["done"]) {
await DatabaseHelper().insertMessage(chatList[0]);
_refreshMessages();
}
setState(() {});
},
onDone: () {
debugPrint("onDone");
},
onError: (error) {
debugPrint("onError");
},
);
监听流数据,并对每个数据块执行以下操作:
将数据解析为JSON格式。
打印接收到的数据。
更新聊天列表中第一个消息的内容,附加接收到的响应部分。
如果响应指示完成(resp["done"]),则将消息插入到数据库,并刷新消息列表。
调用setState()更新UI。
在流结束时,打印onDone。在流发生错误时,打印错误信息。
SQLite 数据库存储
class DatabaseHelper {
Future<Database> createDatabase() async {
final database = openDatabase(join(await getDatabasesPath(), 'ping.db'),
onCreate: ((db, version) async {
await createMessagesTable(db, 'messages');
}), version: 1);
return database;
}
Future<void> createMessagesTable(Database db, String tableName) async {
await db.execute('''
CREATE TABLE $tableName (
id INTEGER PRIMARY KEY,
type INTEGER NOT NULL,
sender TEXT NOT NULL,
receiver TEXT NOT NULL,
message TEXT,
img TEXT,
audio TEXT,
video TEXT
)
''');
}
Future<void> insertMessage(Message message) async {
final Database db = await createDatabase();
await db.insert('messages', message.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<List<Message>> getMessages(int limit, int offset) async {
final Database db = await createDatabase();
final List<Map<String, dynamic>> maps = await db.query('messages',
orderBy: 'id DESC', limit: limit, offset: offset);
return List.generate(maps.length, (i) {
return Message(
type: MessageType.fromCode(maps[i]['type']),
message: maps[i]['message'],
sender: maps[i]['sender'],
receiver: maps[i]['receiver'],
img: maps[i]['img'],
audio: maps[i]['audio'],
video: maps[i]['video'],
// time: maps[i]['time'],
);
});
}
}
创建数据库
Future<Database> createDatabase() async {
final database = openDatabase(join(await getDatabasesPath(), 'ping.db'),
onCreate: ((db, version) async {
await createMessagesTable(db, 'messages');
}), version: 1);
return database;
}
createDatabase 方法用于创建并打开一个名为 ping.db 的 SQLite 数据库。
使用 openDatabase 方法打开数据库,如果数据库不存在,会创建一个新的数据库。
getDatabasesPath 获取默认的数据库路径。
在 onCreate 回调中,调用 createMessagesTable 方法创建 messages 表。
数据库版本号为 1。
创建表
Future<void> createMessagesTable(Database db, String tableName) async {
await db.execute('''
CREATE TABLE $tableName (
id INTEGER PRIMARY KEY,
type INTEGER NOT NULL,
sender TEXT NOT NULL,
receiver TEXT NOT NULL,
message TEXT,
img TEXT,
audio TEXT,
video TEXT
)
''');
}
createMessagesTable 方法用于创建一个名为 tableName 的表。 表包含以下列:
id: 主键,整数类型。
type: 整数类型,不允许为空,表示消息类型。
sender: 文本类型,不允许为空,表示发送者。
receiver: 文本类型,不允许为空,表示接收者。
message: 文本类型,可为空,表示消息内容。
img: 文本类型,可为空,表示图片路径。
audio: 文本类型,可为空,表示音频路径。
video: 文本类型,可为空,表示视频路径。
插入消息
Future<void> insertMessage(Message message) async {
final Database db = await createDatabase();
await db.insert('messages', message.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
}
insertMessage 方法用于插入一条新的消息记录。
先调用 createDatabase 获取数据库实例。
使用 db.insert 方法插入消息,若主键冲突,则替换现有记录。
获取消息
Future<List<Message>> getMessages(int limit, int offset) async {
final Database db = await createDatabase();
final List<Map<String, dynamic>> maps = await db.query('messages',
orderBy: 'id DESC', limit: limit, offset: offset);
return List.generate(maps.length, (i) {
return Message(
type: MessageType.fromCode(maps[i]['type']),
message: maps[i]['message'],
sender: maps[i]['sender'],
receiver: maps[i]['receiver'],
img: maps[i]['img'],
audio: maps[i]['audio'],
video: maps[i]['video'],
// time: maps[i]['time'],
);
});
}
getMessages 方法用于分页获取消息记录。
调用 createDatabase 获取数据库实例。
使用 db.query 方法查询 messages 表,按 id 降序排序,限制返回记录数和偏移量。
将查询结果转换为 Message 对象列表。
其他
macOS 访问网络报错
flutter mac Unhandled Exception: ClientException with SocketException: Connection failed (OS Error: Operation not permitted, errno = 1)
解决办法
macos/Runner/DebugProfile.entitlements 和 macos/Runner/Release.entitlements 添加如下代码,然后重启:
<key>com.apple.security.network.client</key>
<true/>
总结
有了这个基本的模版,可以在此基础上扩展消息类型以支持文生图、文生视频等基于大模型的应用的快速实现。
资源
项目源码:https://github.com/yangpeng7/flutter_ollama_chat/
Flutter 官网:https://flutter.cn/
Ollama 官网:https://ollama.com/
- END -
如果您关注前端+AI 相关领域可以扫码加群交流
关于奇舞团
奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。