掘金人工智能本月最热 2024年06月09日
使用 Ollama + Flutter 开发本地跨平台聊天机器人
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

前言

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 环境的搭建过程。只会讲解在开发一个类似的聊天机器人中可能会遇到的注意点。因此我们会重点关注下面几个问题:

项目结构

    ├── 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();    }  }

这个条件判断的意思是:如果当前滚动位置大于或等于最大滚动范围,则加载更多。

一次性请求数据处理

网络请求依赖 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,      ));

监听流数据

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");  },);

监听流数据,并对每个数据块执行以下操作:

在流结束时,打印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;}

创建表

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 的表。表包含以下列:

插入消息

Future<void> insertMessage(Message message) async {  final Database db = await createDatabase();  await db.insert('messages', message.toMap(),      conflictAlgorithm: ConflictAlgorithm.replace);}

insertMessage 方法用于插入一条新的消息记录。

获取消息

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 方法用于分页获取消息记录。

将查询结果转换为 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/

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

相关文章