写在前面

成品展示

看起来卡顿是因为抽帧了。

Flutter 相关依赖

提前安装好下列 Flutter 插件,直接使用最新版即可,其中使用了 GetX 作为状态管理,如果你喜欢其他的方案比如 BolcProvider 等,请自行修改相关部分的代码。网络请求库 dio 同理。

  • dio
  • crypto
  • flutter_markdown
  • get

申请腾讯混元大模型

官网链接:腾讯混元大模型

进入官网后点击立即接入页面的创建密钥即可,验证身份后会得到一个 SecretIdSecretKey ,注意妥善保存,不要泄漏给别人。

获取密钥

腾讯混元大模型的 lite 版本是免费的,其他版本的价格也不高。如果你喜欢其他厂商的大模型,比如文心一言,通义千问,等等。需要自行编写签名方法。

准备工作

创建项目

在创建好 Flutter 项目后,管理好项目目录结构是很重要的,这里推荐下面的目录结构。

目录结构

封装网络请求

utils 目录下新建一个 http.dart 文件,用于封装网络请求,使用 dart 中的单例模式,利用工厂函数创建一个全局实例,这样可以更加方便维护,但是会破坏类的单一职责原则,如果你不喜欢,那就不喜欢吧。由于只用到了 post 方法,所以就没有对其他的方法进行更细致的封装。

class Http {
  static final Http _instance = Http._();

  factory Http() => _instance;

  static final Dio dio = Dio(BaseOptions(connectTimeout: const Duration(seconds: 3)));

  Http._() {
    dio.interceptors.add(InterceptorsWrapper(
      onRequest: (RequestOptions options, RequestInterceptorHandler handler) {
        return handler.next(options);
      },
      onResponse: (Response response, ResponseInterceptorHandler handler) {
        return handler.next(response);
      },
      onError: (DioException error, ErrorInterceptorHandler handler) {
        return handler.next(error);
      },
    ));
  }

  Future<Stream<String>?> postStream(String path, {Map<String, dynamic>? header, Object? data}) async {
    Response<ResponseBody> response =
        await dio.post(path, options: Options(responseType: ResponseType.stream, headers: header), data: data);
    StreamTransformer<Uint8List, List<int>> transformer = StreamTransformer.fromHandlers(handleData: (data, sink) {
      sink.add(List<int>.from(data));
    });
    return response.data?.stream.transform(transformer).transform(const Utf8Decoder()).transform(const LineSplitter());
  }
}

稍微解释一下这里的方法,由于是一个聊天接口,所以返回的是一个流式的响应,需要注意的就是最后处理流时的 LineSplitter() ,因为有可能接口会返回多条数据,如果不使用行分割的话,会导致丢失一些文字。

为请求及响应创建 Model

参考腾讯云官网文档,在 common/model 目录下创建一个 hunyuan.dart ,这里手动编写了序列化方法,如果你使用了其他的自动序列化框架,自行修改。

class PublicHeader {
  late String action;
  late int timestamp;
  late String version;
  late String authorization;

  Map<String, dynamic> toMap() {
    return {
      'X-TC-Action': action,
      'X-TC-Timestamp': timestamp,
      'X-TC-Version': version,
      'Authorization': authorization,
    };
  }

  PublicHeader.fromMap(Map<String, dynamic> map) {
    action = map['X-TC-Action'];
    timestamp = map['X-TC-Timestamp'];
    version = map['X-TC-Version'];
    authorization = map['Authorization'];
  }

  PublicHeader(this.action, this.timestamp, this.version, this.authorization);
}

class Message {
  late String role;
  late String content;

  Message(this.role, this.content);

  Map<String, dynamic> toMap() {
    return {'Role': role, 'Content': content};
  }

  Message.fromMap(Map<String, dynamic> map) {
    role = map['Role'];
    content = map['Content'];
  }
}

class HunyuanResponse {
  late String note;
  late List<Choices> choices;
  late int created;
  late String id;
  late Usage usage;

  HunyuanResponse(
      {required this.note, required this.choices, required this.created, required this.id, required this.usage});

  HunyuanResponse.fromJson(Map<String, dynamic> json) {
    note = json['Note'];
    if (json['Choices'] != null) {
      choices = <Choices>[];
      json['Choices'].forEach((v) {
        choices.add(Choices.fromJson(v));
      });
    }
    created = json['Created'];
    id = json['Id'];
    usage = (json['Usage'] != null ? Usage.fromJson(json['Usage']) : null)!;
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = <String, dynamic>{};
    data['Note'] = note;
    data['Choices'] = choices.map((v) => v.toJson()).toList();
    data['Created'] = created;
    data['Id'] = id;
    data['Usage'] = usage.toJson();
    return data;
  }
}

class Choices {
  late Delta delta;
  late String finishReason;

  Choices({required this.delta, required this.finishReason});

  Choices.fromJson(Map<String, dynamic> json) {
    delta = (json['Delta'] != null ? Delta.fromJson(json['Delta']) : null)!;
    finishReason = json['FinishReason'];
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = <String, dynamic>{};
    data['Delta'] = delta.toJson();
    data['FinishReason'] = finishReason;
    return data;
  }
}

class Delta {
  late String role;
  late String content;

  Delta({required this.role, required this.content});

  Delta.fromJson(Map<String, dynamic> json) {
    role = json['Role'];
    content = json['Content'];
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = <String, dynamic>{};
    data['Role'] = role;
    data['Content'] = content;
    return data;
  }
}

class Usage {
  late int promptTokens;
  late int completionTokens;
  late int totalTokens;

  Usage({required this.promptTokens, required this.completionTokens, required this.totalTokens});

  Usage.fromJson(Map<String, dynamic> json) {
    promptTokens = json['PromptTokens'];
    completionTokens = json['CompletionTokens'];
    totalTokens = json['TotalTokens'];
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = <String, dynamic>{};
    data['PromptTokens'] = promptTokens;
    data['CompletionTokens'] = completionTokens;
    data['TotalTokens'] = totalTokens;
    return data;
  }
}

编写签名方法

在正常项目中,签名过程需要放到后端进行,这里为了演示放在了客户端,谨慎在生产环境使用

第三方的 API 调用都需要对方法进行签名,这里用的是腾讯混元大模型 签名方法 v3

String sha256HexToLowercase(String input) {
  return sha256.convert(utf8.encode(input)).toString().toLowerCase();
}
List<int> hmacSha256(List<int> key, List<int> data) {
  return Hmac(sha256, key).convert(data).bytes;
}
//生成腾讯云签名
String generateSignature(int timestamp, body) {
  String dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp, isUtc: true).toString().split(' ')[0];
  //拼接规范请求串
  var canonicalRequest =
      'POST\n/\n\ncontent-type:application/json\nhost:hunyuan.tencentcloudapi.com\nx-tc-action:chatcompletions\n\ncontent-type;host;x-tc-action\n${sha256HexToLowercase(jsonEncode(body))}';
  //待签名字符串
  var stringToSign =
      'TC3-HMAC-SHA256\n${timestamp ~/ 1000}\n$dateTime/hunyuan/tc3_request\n${sha256HexToLowercase(canonicalRequest)}';
  var id = '你的id';
  var key = '你的key';
  var date = hmacSha256(utf8.encode('TC3$key'), utf8.encode(dateTime));
  var service = hmacSha256(date, utf8.encode('hunyuan'));
  var signing = hmacSha256(service, utf8.encode('tc3_request'));
  var signature = hmacSha256(signing, utf8.encode(stringToSign));
  var authorization =
      'TC3-HMAC-SHA256 Credential=$id/$dateTime/hunyuan/tc3_request, SignedHeaders=content-type;host;x-tc-action, Signature=${hex(signature).toLowerCase()}';
  return authorization;
}
double calculateValue(double x, double cardNumber, double maxValue, double minValue, double steepness) {
  return maxValue * exp(-steepness * pow((x - cardNumber), 2)) + minValue;
}

utils 目录下创建一个 utils.dart 文件,也可以使用单例模式创建一个实例。将上面代码中的 idkey 换成之前申请的即可。

调用接口

有了签名我们就可以调用接口了,在 api 目录下新建一个 api.dart 文件,这里存放我们真正的请求。

class Api {
  Api._();

  static final Api _instance = Api._();

  factory Api() => _instance;

  Future<Stream<String>?> getHunYuan(List<Message> messages, int model) async {
    //获取时间戳
    var timestamp = DateTime.now().millisecondsSinceEpoch;
    var hunyuanModel = switch (model) {
      0 => 'hunyuan-lite',
      1 => 'hunyuan-standard',
      2 => 'hunyuan-pro',
      _ => 'hunyuan-lite',
    };
    //请求正文
    var body = {
      'Model': hunyuanModel,
      'Messages': messages.map((value) => value.toMap()).toList(),
      'Stream': true,
    };

    //获取签名
    var authorization = Utils().generateSignature(timestamp, body);
    //构造请求头
    var header = PublicHeader('ChatCompletions', timestamp ~/ 1000, '2023-09-01', authorization);
    //发起请求
    return await Http().postStream('https://hunyuan.tencentcloudapi.com', header: header.toMap(), data: body);
  }
}

调用的方式非常简单,第一个参数是对话的上下文,第二个参数为调用的模型类型。对话上下文的类型是一个 List<Message>Message 的定义在上面的 hunyuan.dart 文件中,有两个属性 role 和 content ,分别代表角色和内容,role 的可选值有 system、user、assistant。其中,system 角色可选,如存在则必须位于列表的最开始。user 和 assistant 需交替出现(一问一答),以 user 提问开始和结束,且 content 不能为空。role 的顺序示例:[system(可选) user assistant user assistant user ...]。content 就是对话的文本内容。

编写界面

新建文件夹

编写页面之前肯定要新建文件夹,在 page 目录下新建一个 assistant 文件夹,使用 Getx 的架构,分别创建三个文件 assistant_logic.dartassistant_state.dartassistant_view.dart 代表逻辑,状态,和视图。

定义 State

打开 assistant_state.dart ,编写待会需要用到的状态。

class AssistantState {
  //聚焦对象
  late FocusNode focusNode;

  //输入框控制器
  late TextEditingController textEditingController;

  //ListView控制器
  late ScrollController scrollController;

  //对话上下文
  late List<Message> messages;

  //模型版本
  late int modelVersion;

  AssistantState() {
    focusNode = FocusNode();
    textEditingController = TextEditingController();
    messages = [];
    scrollController = ScrollController();
    modelVersion = 0;

    ///Initialize variables
  }
}

编写 View

之后我们就可以开始编写界面了,首先分析一下布局,在 Scaffold 的基础上,我们需要一个位于底部的输入框,以及中间可以无限滚动的列表。

如何实现这样的效果呢,如果直接使用 ListView 会发现输入框无法固定在屏幕底部,如果使用 Scaffold 中的 bottomNavigationBar ,会发现输入框无法被键盘顶上去,虽然 Scaffold 会自动规避键盘,但是只对 body 部分有效,解决方法就是用 Flexible 组件包裹 ListView ,就可以实现这样的效果。

class AssistantPage extends StatelessWidget {
  const AssistantPage({super.key});

  @override
  Widget build(BuildContext context) {
    final logic = Get.find<AssistantLogic>();
    final state = Get.find<AssistantLogic>().state;
    final colorScheme = Theme.of(context).colorScheme;
    final modelName = ['hunyuan-lite', 'hunyuan-standard', 'hunyuan-pro'];
    return GetBuilder<AssistantLogic>(
      init: logic,
      assignId: true,
      builder: (logic) {
        return GestureDetector(
          onTap: () {
            logic.unFocus();
          },
          child: Scaffold(
            appBar: AppBar(
              title: const Text('AI 助手'),
              leading: IconButton(
                onPressed: () {
                  logic.handleBack();
                },
                icon: const Icon(Icons.arrow_back_outlined),
              ),
              actions: [
                IconButton(
                    onPressed: () {
                      logic.newChat();
                    },
                    icon: const Icon(Icons.refresh)),
              ],
            ),
            body: Column(
              children: [
                Flexible(
                    child: state.messages.isEmpty
                        ? const Column(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children: [
                              Text('暂无对话'),
                            ],
                          )
                        : ListView.builder(
                            controller: state.scrollController,
                            itemBuilder: (context, index) {
                              if (state.messages[index].role == 'user') {
                                return Padding(
                                  padding: const EdgeInsets.symmetric(horizontal: 10.0),
                                  child: Text(
                                    state.messages[index].content,
                                    style: TextStyle(color: colorScheme.primary),
                                  ),
                                );
                              } else {
                                return Padding(
                                  padding: const EdgeInsets.all(10.0),
                                  child: Container(
                                    padding: const EdgeInsets.all(10.0),
                                    decoration: BoxDecoration(
                                        color: colorScheme.surfaceContainer,
                                        borderRadius: const BorderRadius.all(Radius.circular(10.0))),
                                    child: MarkdownBody(
                                      selectable: true,
                                      data: state.messages[index].content,
                                    ),
                                  ),
                                );
                              }
                            },
                            itemCount: state.messages.length,
                          )),
                Padding(
                  padding: const EdgeInsets.all(10.0),
                  child: Column(
                    children: [
                      Row(
                        children: [
                          Text('当前模型:${modelName[state.modelVersion]}'),
                          IconButton(
                              onPressed: () {
                                logic.changeModel();
                              },
                              icon: const Icon(Icons.change_circle_outlined))
                        ],
                      ),
                      Row(
                        crossAxisAlignment: CrossAxisAlignment.end,
                        children: [
                          Expanded(
                              child: TextField(
                            focusNode: state.focusNode,
                            controller: state.textEditingController,
                            maxLines: null,
                            decoration: InputDecoration(
                              fillColor: colorScheme.surfaceContainerHighest,
                              filled: true,
                              suffixIcon: IconButton(
                                onPressed: () {
                                  logic.clearText();
                                },
                                icon: const Icon(Icons.cancel),
                              ),
                              hintText: '消息',
                              enabledBorder: const OutlineInputBorder(
                                borderSide: BorderSide.none,
                                borderRadius: BorderRadius.all(Radius.circular(20.0)),
                              ),
                              focusedBorder: const OutlineInputBorder(
                                borderSide: BorderSide.none,
                                borderRadius: BorderRadius.all(Radius.circular(20.0)),
                              ),
                            ),
                          )),
                          IconButton.filled(
                              onPressed: () {
                                logic.checkGetAi();
                              },
                              icon: const Icon(Icons.arrow_upward))
                        ],
                      ),
                    ],
                  ),
                )
              ],
            ),
          ),
        );
      },
    );
  }
}

这里用 GestureDetector 包裹 Scaffold 是为了实现点击空白部分失去焦点。

编写 Logic

最重要的就是逻辑了,对话的过程就是,用户在文本框输入后,在对话上下文中添加一个用户提问上下文,之后监听响应流,将响应流拼接为响应结果,之后将结果作为回答上下文添加。

class AssistantLogic extends GetxController {
  final AssistantState state = AssistantState();

  @override
  void onReady() {
    // TODO: implement onReady
    super.onReady();
  }

  @override
  void onClose() {
    // TODO: implement onClose
    super.onClose();
  }

  void handleBack() {
    if (state.focusNode.hasFocus) {
      unFocus();
      Future.delayed(const Duration(seconds: 1), () {
        Get.back();
      });
    } else {
      Get.back();
    }
  }

  void unFocus() {
    state.focusNode.unfocus();
  }

  void newChat() {
    state.messages = [];
    update();
  }

  void clearText() {
    state.textEditingController.clear();
  }

  //对话
  Future<void> getAi(String ask) async {
    //清空输入框
    clearText();
    //失去焦点
    unFocus();
    //拿到用户提问后,对话上下文中增加一项用户提问
    state.messages.add(Message('user', ask));
    update();
    //带着上下文请求
    var stream = await Api().getHunYuan(state.messages, state.modelVersion);
    //如果收到了请求,添加一个回答上下文
    state.messages.add(Message('assistant', ''));
    update();
    //接收stream
    stream?.listen((content) {
      Utils().printLog(content);
      if (content != '' && content.contains('data')) {
        HunyuanResponse result = HunyuanResponse.fromJson(jsonDecode(content.split('data: ')[1]));
        state.messages.last.content += result.choices.first.delta.content;
        update();
      }
    }).onDone(() {
      toBottom();
    });
  }

  void toBottom() {
    state.scrollController.jumpTo(state.scrollController.position.maxScrollExtent);
  }

  String getText() {
    return state.textEditingController.text;
  }

  Future<void> checkGetAi() async {
    var text = getText();
    if (text != '') {
      if (state.modelVersion != 0 && state.messages.length > 4) {
        Utils().showSnackBar('请使用 hunyuan-lite 模型');
      } else {
        await getAi(text);
      }
    } else {
      Utils().showSnackBar('还没有输入问题');
    }
  }

  Future<void> changeModel() async {
    await showDialog(
        context: Get.context!,
        builder: (context) {
          return SimpleDialog(
            title: const Text('选择模型'),
            children: [
              const SimpleDialogOption(
                child: Text('尽量使用 hunyuan-lite 模型,其他两个模型需要收费'),
              ),
              SimpleDialogOption(
                child: Row(
                  children: [
                    const Text('hunyuan-lite'),
                    if (state.modelVersion == 0) ...[const Icon(Icons.check)]
                  ],
                ),
                onPressed: () {
                  state.modelVersion = 0;
                  state.messages = [];
                  update();
                  Get.back();
                },
              ),
              SimpleDialogOption(
                child: Row(
                  children: [
                    const Text('hunyuan-standard'),
                    if (state.modelVersion == 1) ...[const Icon(Icons.check)]
                  ],
                ),
                onPressed: () {
                  state.modelVersion = 1;
                  state.messages = [];
                  update();
                  Get.back();
                },
              ),
              SimpleDialogOption(
                child: Row(
                  children: [
                    const Text('hunyuan-pro'),
                    if (state.modelVersion == 2) ...[const Icon(Icons.check)]
                  ],
                ),
                onPressed: () {
                  state.modelVersion = 2;
                  state.messages = [];
                  update();
                  Get.back();
                },
              )
            ],
          );
        });
  }
}

这里还为 ListView 添加了控制器,在收到完整的请求后,会自动滚动到底部,如果你不喜欢这样,可以去掉。