跳到主要内容使用 Ollama + Flutter 开发本地跨平台聊天机器人 | 极客日志DartAI大前端
使用 Ollama + Flutter 开发本地跨平台聊天机器人
综述由AI生成基于 Ollama 本地运行大模型并结合 Flutter 框架开发跨平台聊天机器人。项目采用一问一答模式,通过 ListView 实现消息倒序显示与流式加载。后端接口调用 Ollama API 支持非流式和流式响应处理,前端使用 SQLite 存储聊天记录。包含 macOS 网络权限配置解决方案,并提供了完整的项目结构与核心代码逻辑,便于扩展文生图等能力。
星云37 浏览 前言
Ollama 是一个基于 Go 语言开发的可以本地运行大模型的开源框架。Flutter 是由 Google 开发的开源移动跨平台开发框架。基于这两个开源项目,我们可以开发一个极简的完全在本地运行的聊天机器人。其中 Ollama 提供的模型能力作为服务端。客户端使用 Flutter 开发,支持 iOS、Android、macOS 等平台。
效果演示
macOS 的运行效果:

Android 的运行效果:

环境依赖
在开始之前,请确保已安装 Flutter SDK 和 Dart。项目需要以下主要依赖(pubspec.yaml):
dependencies:
flutter:
sdk: flutter
http: ^1.1.0
sqflite: ^2.3.0
path_provider: ^2.1.1
path: ^1.8.3
json_annotation: ^4.8.1
Ollama 本地运行大模型
关于如何在本地运行 Ollama 大模型,请参考官方文档或相关指南。本文假设 Ollama 服务已在本地启动。
我选择 gemma:2b 作为大模型提供后端接口。
聊天机器人开发
基于 Ollama 的本地大模型运行起来后,接口就算准备好了。本文重点讲解在开发一个类似的聊天机器人中可能会遇到的注意点。
项目结构
├── 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 库,一次性请求数据比较简单,就是一个标准的 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,
));
_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,
));
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");
},
);
});
}
定义异步函数
定义一个异步函数,接受一个问题字符串 question 作为参数,并返回一个 Future。
构建请求体
构建一个包含模型名称、提示问题和流模式的请求体。
创建 HTTP 请求
创建一个 HTTP POST 请求,目标 URL 由 Config.url 指定,并将请求体编码为 JSON 格式。
发送请求并处理响应
发送请求,并通过 then 方法处理响应。初始化一个空字符串 showContent,用于存储流数据。将响应流转换为 UTF-8 解码后的字符串流。在聊天列表 chatList 中插入一个新的消息,内容为空字符串,位置在列表的顶部(索引 0)。
监听流数据
监听流数据,并对每个数据块执行以下操作:
- 将数据解析为 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'],
);
});
}
}
创建数据库
createDatabase 方法用于创建并打开一个名为 ping.db 的 SQLite 数据库。使用 openDatabase 方法打开数据库,如果数据库不存在,会创建一个新的数据库。getDatabasesPath 获取默认的数据库路径。在 onCreate 回调中,调用 createMessagesTable 方法创建 messages 表。数据库版本号为 1。
创建表
createMessagesTable 方法用于创建一个名为 tableName 的表。表包含以下列:
- id: 主键,整数类型。
- type: 整数类型,不允许为空,表示消息类型。
- sender: 文本类型,不允许为空,表示发送者。
- receiver: 文本类型,不允许为空,表示接收者。
- message: 文本类型,可为空,表示消息内容。
- img: 文本类型,可为空,表示图片路径。
- audio: 文本类型,可为空,表示音频路径。
- video: 文本类型,可为空,表示视频路径。
插入消息
insertMessage 方法用于插入一条新的消息记录。先调用 createDatabase 获取数据库实例。使用 db.insert 方法插入消息,若主键冲突,则替换现有记录。
获取消息
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/>
总结
本教程展示了如何利用 Ollama 本地部署的大模型与 Flutter 结合,构建一个支持多端运行的本地聊天机器人。核心实现包括消息流的实时渲染、SQLite 本地持久化存储以及基础的网络请求封装。开发者可在此基础上扩展更多功能,如文生图、文件上传或语音交互。通过合理设计项目结构和状态管理,能够保证应用在保持轻量级的同时具备良好的可扩展性。
相关免费在线工具
- RSA密钥对生成器
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
- Mermaid 预览与可视化编辑
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
- 随机西班牙地址生成器
随机生成西班牙地址(支持马德里、加泰罗尼亚、安达卢西亚、瓦伦西亚筛选),支持数量快捷选择、显示全部与下载。 在线工具,随机西班牙地址生成器在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online