使用 Ollama + Flutter 开发本地跨平台聊天机器人
基于 Ollama 本地运行大模型并结合 Flutter 框架开发跨平台聊天机器人。项目采用一问一答模式,通过 ListView 实现消息倒序显示与流式加载。后端接口调用 Ollama API 支持非流式和流式响应处理,前端使用 SQLite 存储聊天记录。包含 macOS 网络权限配置解决方案,并提供了完整的项目结构与核心代码逻辑,便于扩展文生图等能力。

基于 Ollama 本地运行大模型并结合 Flutter 框架开发跨平台聊天机器人。项目采用一问一答模式,通过 ListView 实现消息倒序显示与流式加载。后端接口调用 Ollama API 支持非流式和流式响应处理,前端使用 SQLite 存储聊天记录。包含 macOS 网络权限配置解决方案,并提供了完整的项目结构与核心代码逻辑,便于扩展文生图等能力。

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 服务已在本地启动。
我选择 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)。
监听流数据 监听流数据,并对每个数据块执行以下操作:
在流结束时,打印 onDone。在流发生错误时,打印错误信息。
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 的表。表包含以下列:
插入消息 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 本地持久化存储以及基础的网络请求封装。开发者可在此基础上扩展更多功能,如文生图、文件上传或语音交互。通过合理设计项目结构和状态管理,能够保证应用在保持轻量级的同时具备良好的可扩展性。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online