Loading... <div class="tip share">请注意,本文编写于 275 天前,最后修改于 222 天前,其中某些信息可能已经过时。</div> ## 写在前面 ### 成品展示 看起来卡顿是因为抽帧了。  ### Flutter 相关依赖 提前安装好下列 `Flutter` 插件,直接使用最新版即可,其中使用了 `GetX` 作为状态管理,如果你喜欢其他的方案比如 `Bolc`,`Provider` 等,请自行修改相关部分的代码。网络请求库 `dio` 同理。 * dio * crypto * flutter_markdown * get ### 申请腾讯混元大模型 <div class="tip inlineBlock info"> 官网链接:[腾讯混元大模型](https://cloud.tencent.com/product/hunyuan) </div> 进入官网后点击立即接入页面的创建密钥即可,验证身份后会得到一个 `SecretId` 和 `SecretKey` ,注意妥善保存,不要泄漏给别人。  腾讯混元大模型的 lite 版本是免费的,其他版本的价格也不高。如果你喜欢其他厂商的大模型,比如文心一言,通义千问,等等。需要自行编写签名方法。 ## 准备工作 ### 创建项目 在创建好 `Flutter` 项目后,管理好项目目录结构是很重要的,这里推荐下面的目录结构。  ### 封装网络请求 在 `utils` 目录下新建一个 `http.dart` 文件,用于封装网络请求,使用 `dart` 中的单例模式,利用工厂函数创建一个全局实例,这样可以更加方便维护,但是会破坏类的单一职责原则,如果你不喜欢,那就不喜欢吧。由于只用到了 `post` 方法,所以就没有对其他的方法进行更细致的封装。 ```dart 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` ,这里手动编写了序列化方法,如果你使用了其他的自动序列化框架,自行修改。 ```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; } } ``` ### 编写签名方法 <div class="tip inlineBlock error"> 在正常项目中,签名过程需要放到后端进行,这里为了演示放在了客户端,谨慎在生产环境使用 </div> 第三方的 API 调用都需要对方法进行签名,这里用的是[腾讯混元大模型 签名方法 v3](https://cloud.tencent.com/document/product/1729/101843)。 ```dart 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` 文件,也可以使用单例模式创建一个实例。将上面代码中的 `id` 和 `key` 换成之前申请的即可。 ### 调用接口 有了签名我们就可以调用接口了,在 `api` 目录下新建一个 `api.dart` 文件,这里存放我们真正的请求。 ```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.dart` 、`assistant_state.dart` 、`assistant_view.dart` 代表逻辑,状态,和视图。 ### 定义 State 打开 `assistant_state.dart` ,编写待会需要用到的状态。 ```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` 的基础上,我们需要一个位于底部的输入框,以及中间可以无限滚动的列表。 <div class="flex-column"><div class="flex-block" style="flex:auto">  </div><div class="flex-block" style="flex:auto">  </div></div> 如何实现这样的效果呢,如果直接使用 `ListView` 会发现输入框无法固定在屏幕底部,如果使用 `Scaffold` 中的 `bottomNavigationBar` ,会发现输入框无法被键盘顶上去,虽然 `Scaffold` 会自动规避键盘,但是只对 `body` 部分有效,解决方法就是用 `Flexible` 组件包裹 `ListView` ,就可以实现这样的效果。 ```dart 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 最重要的就是逻辑了,对话的过程就是,用户在文本框输入后,在对话上下文中添加一个用户提问上下文,之后监听响应流,将响应流拼接为响应结果,之后将结果作为回答上下文添加。 ```dart 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` 添加了控制器,在收到完整的请求后,会自动滚动到底部,如果你不喜欢这样,可以去掉。 © 允许规范转载 打赏 赞赏作者 微信 赞