使用 Ollama + Flutter 开发本地跨平台聊天机器人

前言

Ollama 是一个基于 Go 语言开发的可以本地运行大模型的开源框架。Flutter 是由 Google 开发的开源移动跨平台开发框架。基于这两个开源项目,我们可以开发一个 极简 的完全在本地运行的聊天机器人。其中 Ollama 提供的模型能力作为服务端。客户端使用 Flutter 开发,支持 iOS、Android、macOS 等平台。

效果演示

macOS 的运行效果: macOS 运行效果

Android 的运行效果: 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 (pub.dev/packages/ht…) 这个库,一次性请求数据比较简单,就是一个标准的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/>


总结

有了这个基本的模版,可以在此基础上扩展消息类型以支持文生图、文生视频等基于大模型的应用的快速实现。

如何学习AI大模型?

我在一线互联网企业工作十余年里,指导过不少同行后辈。帮助很多人得到了学习和成长。

我意识到有很多经验和知识值得分享给大家,也可以通过我们的能力和经验解答大家在人工智能学习中的很多困惑,所以在工作繁忙的情况下还是坚持各种整理和分享。但苦于知识传播途径有限,很多互联网行业朋友无法获得正确的资料得到学习提升,故此将并将重要的AI大模型资料包括AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频免费分享出来。

在这里插入图片描述

第一阶段: 从大模型系统设计入手,讲解大模型的主要方法;

第二阶段: 在通过大模型提示词工程从Prompts角度入手更好发挥模型的作用;

第三阶段: 大模型平台应用开发借助阿里云PAI平台构建电商领域虚拟试衣系统;

第四阶段: 大模型知识库应用开发以LangChain框架为例,构建物流行业咨询智能问答系统;

第五阶段: 大模型微调开发借助以大健康、新零售、新媒体领域构建适合当前领域大模型;

第六阶段: 以SD多模态大模型为主,搭建了文生图小程序案例;

第七阶段: 以大模型平台应用与开发为主,通过星火大模型,文心大模型等成熟大模型构建大模型行业应用。

在这里插入图片描述

👉学会后的收获:👈
• 基于大模型全栈工程实现(前端、后端、产品经理、设计、数据分析等),通过这门课可获得不同能力;

• 能够利用大模型解决相关实际项目需求: 大数据时代,越来越多的企业和机构需要处理海量数据,利用大模型技术可以更好地处理这些数据,提高数据分析和决策的准确性。因此,掌握大模型应用开发技能,可以让程序员更好地应对实际项目需求;

• 基于大模型和企业数据AI应用开发,实现大模型理论、掌握GPU算力、硬件、LangChain开发框架和项目实战技能, 学会Fine-tuning垂直训练大模型(数据准备、数据蒸馏、大模型部署)一站式掌握;

• 能够完成时下热门大模型垂直领域模型训练能力,提高程序员的编码能力: 大模型应用开发需要掌握机器学习算法、深度学习框架等技术,这些技术的掌握可以提高程序员的编码能力和分析能力,让程序员更加熟练地编写高质量的代码。

在这里插入图片描述

1.AI大模型学习路线图
2.100套AI大模型商业化落地方案
3.100集大模型视频教程
4.200本大模型PDF书籍
5.LLM面试题合集
6.AI产品经理资源合集

👉获取方式:
😝有需要的小伙伴,可以保存图片到wx扫描二v码免费领取【保证100%免费】🆓

在这里插入图片描述

  • 21
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值