前言
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/