项目简介
与风景对话——交互式旅游推荐系统
本项目旨在基于大型语言模型(LLM)开发一个旅行推荐与攻略生成系统。通过LangChain框架搭建整体架构,实现用户输入到数据库再到LLM模型最后输出的全流程连接。
我负责的部分
我主要负责项目的前端部分,尤其时桌面端和移动端的开发,着重要处理的是用户交互和后端通信。而更具体的前端样式、交互逻辑等将在后文分析。
每周工作
第一周
本周主要是对于前端的需求进行分析,并初步搭建好前端的构架。
前端需求分析
首先是对前端展示内容的分析:我们的项目作为一个交互式旅游推荐系统,主要需要向用户展示如下功能:
- 用户对话。这个功能主要是因为我们这是个交互式问答系统,需要用户提出问题我们来回答,根据用户的问题推荐合适的旅游地点。这个界面类似于各个在线大模型(比如文心一言、ChatGPT)的对话界面。
- 景点的详细信息。为了方便用户了解目的地的信息,我们计划在用户对话的部分里,在大模型返回的句子中提取出景点名称,并让景点名称的文本可以点击,用户点击后在右侧显示出景点的详细信息,并给出相应的配图给用户参考。
具体而言前端核心界面如下图所示:
其中左半部分是用户对话界面,右边是景点详情。在用户点击景点名称之前,不会显示景点详情部分,用户对话部分居中显示;当用户点击景点名称之后,用户对话部分平移到左边,并弹出景点详情界面,如果用户此时继续对话,那景点详情会自动折叠。
用户对话界面的样式类似于ChatGPT,其中上面会一条条显示用户与模型的对话,而底部有一个输入框,用户在此处提问。对话的句子里的景点名称会识别并标注出来,可供用户点击。
景点详情界面仅在用户点击景点名称时弹出,在用户对话时收回。底部会显示景点的详细精准的介绍,顶部会展示该景点的风景,如果有多张的还会轮播展示。
整个界面应该还会有一个顶栏,放上用户登录等相关导航按钮,此外还有用户登录、注册、信息管理等相关页面,这些部分较为宽泛通用,等到需要时再讨论。
框架选择
这个项目我选择了flutter框架来写前端,Flutter 是 Google 开源的应用开发框架,仅通过一套代码库,就能构建精美的、原生平台编译的多平台应用。因为它有着良好的跨平台特性,非常适合同时开发移动端和桌面端的程序,并且也可以编译为web程序,在必要时嵌入网页端中。
通常我应该在这里详细介绍Flutter,并详细且细致地描述搭配开发环境的过程,但是由于我之前写过flutter的项目,因此本地已经有完善的开发环境:
- Dart SDK 3.4.2
- Flutter SDK 3.19
- IntelliJ IDEA Ultimate 2024.1 ,安装有flutter和dart插件
初始化项目
新建项目并进行配置
flutter的字形和语言需要自己配置,代码如下
locale: const Locale("zh", "CN"),
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale("zh", "CN"),
],
最终代码如下
void main() async{
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '大模型旅游推荐系统',
locale: const Locale("zh", "CN"),
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale("zh", "CN"),
],
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange).copyWith(
background: Colors.grey.shade300
),
fontFamily: "Microsoft Yahei",
useMaterial3: true,
),
home: MyHomePage(),
);
}
}
第二周
本周主要实现了用户对话的界面部分的编写。
用户对话部分有两个主要组件,一个是下方的输入框,一个是上方的对话栏。输入框组件flutter已经提供了,而对话栏需要自己组装。
对话栏
在对话栏中需要保存两个状态:历史对话的数据、当前是否正在生成。我使用了GetX包进行状态管理。
RxList<String> msg=RxList();
var gening=false.obs;
对话栏需要有以下方法:
1. chat(String msg):用户说了一句话,这个时候直接将这句话放在历史对话数据的末尾即可,并将当前列表滚动到末尾。
void chat(String msg){
c.msg.add(msg);
Future.delayed(Duration(milliseconds: 50),(){listCtrl.animateTo(listCtrl.position.maxScrollExtent, duration: Duration(milliseconds: 100), curve: Curves.easeInOut);});
}
2. gen(String msg):模型生成了一段话,由于模型可能一次不能完全返回完整的句子,可能分成了许多段,因此我需要判断最后一句话是不是模型说的,如果是那么直接拼接上就行,负责将句子放入末尾。由于是用户说一句,模型答一句,所以判断是谁说的只需要判断奇偶性即可。
void gen(String msg){
if(c.msg.length%2==1){
c.msg.add("");
}
c.gening.value=true;
c.msg.last+=msg;
Future.delayed(Duration(milliseconds: 50),(){listCtrl.animateTo(listCtrl.position.maxScrollExtent, duration: Duration(milliseconds: 100), curve: Curves.easeInOut);});
}
3. finishGen():表明模型已经完成了生成,用户可以继续输入了
void finishGen(){
c.gening.value=false;
}
整个对话栏是一个ListView,里面每一项根据说话的人是谁选择不同的展现方式
@override
Widget build(BuildContext context) {
return Obx(() => ListView(
controller: listCtrl,
children: [
for(int i=0;i<c.msg.length;i++)
if(i%2==0)
Padding(padding: EdgeInsets.all(16),child: Row(children:[
Padding(padding: EdgeInsets.fromLTRB(0, 0, 16, 0),child: Icon(Icons.account_circle_outlined),),
Text(c.msg[i])
]),)
else
Padding(padding: EdgeInsets.all(16),child: Row(children:[
Padding(padding: EdgeInsets.fromLTRB(0, 0, 16, 0),child:
Obx(()=>i==c.msg.length-1&&c.gening.value?SizedBox(child:CircularProgressIndicator(),width: 24,height: 24,):Icon(Icons.link))
,),
Expanded(child:Container(child:SelectableText(c.msg[i]),decoration: BoxDecoration(color: Colors.white,borderRadius: BorderRadius.all(Radius.circular(8))),padding: EdgeInsets.all(8),)),
]),)
,
],
));
}
输入框
首先设置输入框的样式,输入框是多行的无边框的文本输入框,当模型正在生成时应该禁用
maxLines: null,
keyboardType: TextInputType.text,
controller: c.inputController,
decoration: InputDecoration(
border: InputBorder.none
),
enabled: c.gen.isFalse,
当输入完成时调用对话栏的chat方法,并调用后端(现在还没有)与模型对话
onSubmitted: (str)async{
if(str.isEmpty)return;
chater.chat(str);
c.inputController.text="";
c.gen.value=true;
chater.gen("");
var msg="这是模型说的";
chater.gen(msg.substring(i,i+1));
chater.finishGen();
c.gen.value=false;
},
最终运行效果如下:
第三周
本周主要设计了与后端交互的抽象接口,并实现了对话时模型逐字逐句往外蹦的效果。
交互接口设计
与模型交互时主要需要以下操作:
- 初始化:这一步了包含了链接模型、验证用户身份等基础操作,需要在程序开始时执行。
- 开始新对话:访问模型的接口开始一轮新的对话,并清除本地保存的上一轮对话的信息。
- 继续对话:将用户的对话提交给模型,然后将模型的回答返回给用户;需要注意保存好当前对话的上下文,来保证对话的连贯性。
因此我定义模型对话的接口如下:
mixin LLMAPI{
Future init(); //初始化
Future<String> getMessage(String msg); //继续对话
Future reset(); //新对话
static LLMAPI? _instance;
static LLMAPI get instance;
}
如下是一个简单的本地的接口测试用实现,这个实现只会返回同一句话
import '../LLMAPI.dart';
class LLMAPITXT with LLMAPI{
@override
Future<String> getMessage(String msg) async{
return "没有后端\n临时测试文本";
}
@override
Future init() async{
}
@override
Future reset() async{
}
}
逐字逐句蹦出来
从日常的大模型使用过程中可以看到,大模型通常是生成一部分就输出一部分,有种逐字逐句蹦出来的感觉。为了实现这种感觉,我将接口获取的数据分割开来,一点一点地加入进界面。
结合上一部分加入的接口,现在将输入框输入完后的代码改为如下:
onSubmitted: (str)async{
if(str.isEmpty)return;
log(str);
chater.chat(str);
c.inputController.text="";
c.gen.value=true;
chater.gen("");
var msg=await c.api.getMessage(str);
for(int i=0;i<msg.length;i++){
chater.gen(msg.substring(i,i+1));
await Future.delayed(Duration(milliseconds: 50));
}
chater.finishGen();
c.gen.value=false;
},
最终效果如下:
第四周
由于我们的模型还没有微调好,为了方便后续开发,我先接入百度千帆API进行测试。
创建千帆应用
要使用千帆大模型,首先要注册账号并创建应用;创建应用的地址是:百度智能云千帆大模型平台
创建好后如下所示:
其中的API Key和Secret Key两项是访问应用的凭证,创建新对话时需要用到。
接入API
我在测试中使用了ERNIE-3.5-8K模型,使用方法在:ERNIE-3.5-8K - 千帆大模型平台 | 百度智能云文档
可以看到需要鉴权才能访问API。
因为flutter使用的语言是dart,而百度并没有提供dart的库文件,因此需要手动访问,因此我选择了实现方便的access_token方式鉴权。
获取access_token这一步属于初始化的部分,应该放在上一周设计的接口的init部分,其代码如下:
@override
Future init() async{
dio.options.contentType="application/json";
var response=await dio.post("https://aip.baidubce.com/oauth/2.0/token",
queryParameters: {
"grant_type":"client_credentials",
"client_id":"这是API Key",
"client_secret":"这是Secret Key"
}
);
access_token=response.data["access_token"];
message=[];
}
理论上获取access_token不应该放在前端进行,因为用户可能通过解包获取到我的Key,但是我这里只是作为测试用,因此使用了这种不严谨的危险写法。
对话时根据文档上的要求传入对应参数,然后获取到回答返回给用户:
@override
Future<String> getMessage(String msg) async{
message.add(_Message("user", msg));
log(jsonEncode(message));
var response=await dio.post(
"https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/ernie-lite-8k",
queryParameters: {
"access_token":access_token
},
data:{
"messages":message
}
);
message.add(_Message("assistant", response.data["result"]));
return response.data["result"];
}
由于上周将接口抽象出来,因此不需要再修改其他地方的代码,只需要完成接口的实现就行了。
最终效果如下:
第五周
终于放五一了
第六周
本周我们在讨论的过程中提到,后端可能会返回一些HTML给我,因为我们的后端是用Python写的,里面很多现成的库能够实现非常美观的视觉效果,但是生成的格式是HTML。
为了在程序中渲染HTML,需要使用到WebView。在移动平台上,操作系统基本上都提供了WebView;在Windows上,微软在Win10及以上的系统中也提供了WebView。
flutter中也有许多包提供了调用webview的功能,其中移动端使用webview_flutter,这一方面已经有很多文章详细说过了。我主要详细说明Windows上的WebView。
经过测试,Windows上较新的好用的包主要是webview_windows,使用方法如下:
在Widget里定义一个WebviewController的对象controller,在初始化时执行以下代码初始化WebView:
await controller.initialize();
await controller.setBackgroundColor(Colors.transparent);
await controller.setPopupWindowPolicy(WebviewPopupWindowPolicy.deny);
之后调用 controller.loadUrl 来加载网页。可以通过 controller.loadingState 来判断是否加载完成。
渲染的效果如下:右半部分是加载的HTML,是我数据可视化的个人实验使用的网页
第七周
本周我尝试将我的程序嵌入到同学的Web端之中,因为Web端虽然各种包非常多,但是想要自己实现部分内容较为困难,而且我已经写好的内容如果他再实现一遍类似的功能会造成人力的浪费,因此,如果能将我的程序嵌入Web端能省下许多工作量。
flutter也能够打包为web的程序,使用如下命令生成:
flutter build web
这样能直接生成index.html以及一些其他的资源文件,但是在集成的过程中遇到了下面几个问题:
报错找不到xxx.js
这是由于集成之后,程序放入了子文件夹中,不是在web端的根目录下。比如放入了chat目录中,如果此时尝试访问xxxx.js,我希望访问到的是localhost/chat/xxxx.js,但是实际访问的是localhost/xxxx.js。
解决方法是:在index.html里,<head>下有一个子项 <base href="/"> ,将其改为 <base href="chat/"> 即可,此时所有通过这个html的调用都会在chat目录下。
跨域被浏览器拦截
这是由于我目前测试使用了百度的API,访问了外部域名的网站。但是当后端上线以后,我的程序会由后端的地址发给用户,此时我访问的API是后端的API,在同一个域名下,就不会出现跨域的问题。
字体加载有延迟
这个问题还在研究怎么解决,目前强制flutter使用html渲染界面可以部分解决这个问题,但是还需要改善。
第八周
本周末当CCPC济南邀请赛志愿者,整个周末全天都被占满了,因此只在原本的基础上有些很小的调整。
未来工作
- 使用富文本组件渲染对话栏的文字,以支持点击操作
- 完成景点详情部分
- 后端完成后与后端的接口对接
- 完成用户个人界面