写在前面
成品展示
看起来卡顿是因为抽帧了。
Flutter 相关依赖
提前安装好下列 Flutter
插件,直接使用最新版即可,其中使用了 GetX
作为状态管理,如果你喜欢其他的方案比如 Bolc
,Provider
等,请自行修改相关部分的代码。网络请求库 dio
同理。
- dio
- crypto
- flutter_markdown
- get
申请腾讯混元大模型
进入官网后点击立即接入页面的创建密钥即可,验证身份后会得到一个 SecretId
和 SecretKey
,注意妥善保存,不要泄漏给别人。
腾讯混元大模型的 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
文件,也可以使用单例模式创建一个实例。将上面代码中的 id
和 key
换成之前申请的即可。
调用接口
有了签名我们就可以调用接口了,在 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.dart
、assistant_state.dart
、assistant_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
添加了控制器,在收到完整的请求后,会自动滚动到底部,如果你不喜欢这样,可以去掉。