这是一个什么AI应用?
笔者目前正在挑战:用国内的大模型开发 10 款有趣、有用的 AI 应用(基于 web)。
这期的产品名称叫「7分饱」,开发这个小工具的原因是因为我是一个胃食管反流患者,每顿饭如果吃太撑胃就会很难受,会反酸烧心。所以我在想是不是可以开发一个AI应用,能在我吃饭的时候提醒我不要吃太撑呢?
这个需求就是这个产品的灵感,我大致介绍一下这个产品,总共有4个页面:
- 基本信息页
新用户首次进入时,输入昵称和飞书的webhook链接。webhook链接对应的是发送到飞书消息的地址。
- AI角色选择页面
新用户首次进入时,用户可以选择对应的AI提醒角色风格,不同角色对应不同的消息风格。
- 首页
用户首页,用户可以在该页面查看最近的就餐历史记录。并且进行「就餐」的操作。(该按钮因为fixed布局,导致截图不完全)
- 就餐页面
用户可以进入到就餐页面中,此时会后台创建一个总时长30min的页面,后台分别会在10/15/20min,随后每隔1min种发送AI生成的提醒文案到飞书的webhook,提醒用户不要吃太撑。
飞书端内提醒内容示例:
技术栈介绍
前端
- Nuxt:前端框架,采用SSG部署
- TailwindCSS:CSS框架
- VantUI:UI部分和弹框提醒
后端
- Supabase:使用Postgres,存储用户定时任务数据
- Midwayjs:Node.js框架,定时任务触发数据库内的消息,发送通义千问API生成的消息到webhook
AI
-
通义千问:使用的是
qwen-plus模型API -
openai.js:调用
qwen-plus的API,openai兼容
为了示例项目方便,这边没有设计用户登录鉴权。其中用户的webhook链接和昵称,包括历史就餐数据都是存储在本地localStorage中的。
该页面有2个后端接口:
-
创建定时任务: 用户点击「就餐」按钮,后端node.js创建定时任务,在指定时间调用通义千问的API生成提醒消息到用户的webhook链接当中。
-
用户结束就餐: 用户输入自己这段饭吃到了几分饱,然后点击「结束就餐」按钮,后端调用通义千问的API生成评价消息。
代码实现
这边给出首页和就餐页面两部分的代码实现,前面两个页面比较简单。
首页
前端代码
该页面主要就是获取餐厅列表数据,并且在用户点击「就餐」按钮时,在pinia中调用action,触发创建任务的消息。
<template>
<Layout>
<div class="h-full flex flex-col">
<!-- 用户头像 -->
<AvatarHeader class="!mt-6" />
<!-- 当前选择的角色 -->
<CharacterItem
v-if="currentCharacter"
:active="true"
:character="currentCharacter"
:show-switch="true"
@click="onChangeCharacter"
/>
<!-- 最近的餐饮 -->
<div class="flex flex-col mt-8">
<div class="flex justify-between items-center">
<div class="text-gray-900 text-base font-medium">最近的餐饮</div>
<van-icon name="replay" size="24" @click="onRefresh" />
</div>
<div class="mt-4 mb-[120px]">
<template v-if="mealList.length">
<MealCard
class="mb-4"
v-for="item in mealList"
:key="item.id"
:meal="item"
/>
</template>
<template v-else>
<van-empty description="快去吃一顿吧" />
</template>
</div>
</div>
<div
class="w-[80vw] max-w-[380px] pb-8 mt-0 fixed bottom-0 left-1/2 -translate-x-1/2"
>
<van-button
size="large"
class="!w-full"
@click="onMeal"
color="#4894FE"
>
就餐
</van-button>
</div>
</div>
</Layout>
</template>
<script setup>
import { use7fenbaoStore } from "@/store/7fenbao";
import { showToast, showLoadingToast } from "vant";
import { showConfirmDialog } from "vant";
const router = useRouter();
const $7fenbaoStore = use7fenbaoStore();
const { currentCharacter, mealList } = toRefs($7fenbaoStore);
const onChangeCharacter = () => {
router.push("/characters");
};
const onMeal = async () => {
const loading = showLoadingToast({
message: "创建任务中",
duration: 0,
});
const res = await $7fenbaoStore.fetchStartMeal();
loading.close();
if (res.success) {
router.push("/meal" + `?mid=${res.data.mid}`);
}
};
const onRefresh = () => {
showConfirmDialog({
title: "提示",
message: "确定要清空数据吗?",
})
.then(() => {
$7fenbaoStore.clearMealList();
})
.catch(() => {});
};
</script>
Pinia部分代码
请求后端/api/sevenbao/startMeal接口,触发创建定时任务的功能。
// 开始就餐
const fetchStartMeal = async () => {
const res = await axios.post("/api/sevenbao/startMeal", {
userName: userName.value,
webhook: webhook.value,
characterId: characterId.value,
});
return res;
};
后端代码
这部分后端代码实现是比较复杂的点,要创建对应的定时任务,常规应该使用Redis或者Kafka消息,但是我期望后端的技术栈尽可能的简单,希望只使用supabase + node.js实现。
目前的后端实现逻辑是:
-
创建对应执行时间点的数据,并插入到数据库
-
后端创建定时任务,每30s扫一次数据库是否有需要执行的任务
-
如果扫到有需要执行的任务,那么就更改该任务状态并执行,请求AI消息并发送到用户的webhook链接中
Controller部分代码如下,获取到对应的请求参数,调用Service服务。文件为:controller/sevenbao.controller.ts:
import { Inject, Controller, Post, Body } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import { SevenBaoService } from '../service/sevenbao.service';
@Controller('/api/sevenbao')
export class SevenBaoController {
@Inject()
ctx: Context;
@Inject()
sevenBaoService: SevenBaoService;
@Post('/startMeal')
async startMeal(@Body() body: any) {
const { userName, webhook, characterId } = body;
try {
const data = await this.sevenBaoService.startMeal({
userName,
webhook,
characterId,
});
return {
success: true,
data: data,
};
} catch (error) {
return {
success: false,
message: error.message,
};
}
}
}
Service部分的代码如下,对应文件sevenbao.service.ts
import { Provide, Inject } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import nanoid = require('nanoid');
import supabase from '../utils/supabase';
import { TABLES } from '../utils/constants';
import OpenAI from 'openai';
@Provide()
export class SevenBaoService {
@Inject()
ctx: Context;
// 开始就餐
async startMeal({ userName, webhook, characterId }) {
const mid = nanoid.nanoid();
await this.scheduleWebhookMessages({
mid,
webhook,
userName,
characterId,
});
return {
mid,
};
}
// 插入接下来的定时任务
private async scheduleWebhookMessages({
mid,
userName,
webhook,
characterId,
}) {
const now = new Date();
const tasks = [10, 15, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30];
const tasksToInsert = tasks.map(minutes => {
let executeAt = null;
if (process.env['NODE_ENV'] === 'local') {
// 本地测试使用(降低测试时间成本)
const ONE_SECOND = 1000;
executeAt = new Date(now.getTime() + minutes * ONE_SECOND);
} else {
// 生产使用
const ONE_MINUTE = 60000;
executeAt = new Date(now.getTime() + minutes * ONE_MINUTE);
}
return {
mid,
user_name: userName,
character_id: characterId,
webhook: webhook,
execute_at: executeAt.toISOString(),
status: 'pending',
};
});
await supabase.from(TABLES.SEVENBAO_SCHEDULED_TASKS).insert(tasksToInsert);
}
}
建表SQL语句
create table
public.sevenbao__scheduled_tasks (
id bigint generated by default as identity,
mid text not null,
user_name text not null,
character_id text not null,
webhook text not null,
execute_at timestamp with time zone not null,
status text not null default 'pending'::text,
created_at timestamp with time zone null default current_timestamp,
updated_at timestamp with time zone null default current_timestamp,
uid text null,
constraint sevenbao__scheduled_tasks_pkey primary key (id)
) tablespace pg_default;
create index if not exists idx_sevenbao_scheduled_tasks_execute_at on public.sevenbao__scheduled_tasks using btree (execute_at) tablespace pg_default;
create index if not exists idx_sevenbao_scheduled_tasks_status on public.sevenbao__scheduled_tasks using btree (status) tablespace pg_default;
create trigger update_sevenbao_scheduled_tasks_modtime before
update on sevenbao__scheduled_tasks for each row
execute function update_modified_column ();
AI部分 + 发送飞书webhook消息
我们还需要创建一个定时任务的执行Service,对应的文件是task-executor.service.ts,用户获取AI消息,并且发送到飞书webhook链接中。
import { Provide, Scope, ScopeEnum } from '@midwayjs/core';
import supabase from '../utils/supabase';
import axios from 'axios';
import { TABLES } from '../utils/constants';
import OpenAI from 'openai';
@Provide()
@Scope(ScopeEnum.Singleton)
export class TaskExecutorService {
// 获取当前pending状态 + 执行时间小于当前时间的任务
async executePendingTasks() {
const { data: tasks, error } = await supabase
.from(TABLES.SEVENBAO_SCHEDULED_TASKS)
.select('*')
.eq('status', 'pending')
.lte('execute_at', new Date().toISOString())
.order('execute_at', { ascending: true });
if (error) {
console.error('获取任务时出错:', error);
return;
}
for (const task of tasks) {
await this.executeTask(task);
}
}
// 执行任务
private async executeTask(task) {
try {
// 更新任务状态为完成
await supabase
.from(TABLES.SEVENBAO_SCHEDULED_TASKS)
.update({ status: 'completed' })
.eq('id', task.id);
// 发送webhook消息
await this.sendWebhookMessage(task);
console.log(`任务 ${task.id} 执行成功`);
} catch (error) {
console.error(`执行任务 ${task.id} 时出错:`, error);
await supabase
.from(TABLES.SEVENBAO_SCHEDULED_TASKS)
.update({ status: 'failed' })
.eq('id', task.id);
}
}
// 发送webhook消息
private async sendWebhookMessage(task) {
const content = await this.getAiData(task);
await axios.post(task.webhook, {
msg_type: 'interactive',
card: {
config: {
wide_screen_mode: true,
},
elements: [
{
tag: 'div',
text: {
content: `${content}`,
tag: 'lark_md',
},
},
],
header: {
title: {
content: '七分饱提醒',
tag: 'plain_text',
},
},
},
});
}
// 获取ai的数据
async getAiData(task) {
// 获取吃了多少分钟
const minutes = Math.round(
(new Date(task.execute_at).getTime() -
new Date(task.created_at).getTime()) /
60000
);
const openai = new OpenAI({
apiKey: process.env['QWEN_API_KEY'],
baseURL: process.env['QWEN_BASE_URL'],
});
// 根据 character_id 设置不同的系统初始设定
let systemMessage = '';
switch (task.character_id) {
case '1':
systemMessage = `你是一个温柔甜美的女生。你要以亲昵的方式称呼用户,使用"老公"或其他亲密称呼。随着进食时间接近30分钟,你的语气应该逐渐变得更加紧迫,但始终保持温柔。`;
break;
case '2':
systemMessage = `你是一个暖男。你要以亲切的方式称呼用户,可以使用"宝贝"或其他友好称呼。随着进食时间接近30分钟,你的语气应该逐渐变得更加紧迫,但始终保持温和。`;
break;
case '3':
systemMessage = `你是一个严格的教官。你要以严厉的方式称呼用户,使用"新兵"或其他正式称呼。随着进食时间接近30分钟,你的语气应该变得更加严厉和紧迫。`;
break;
case '4':
systemMessage = `你是一个刻薄的角色。你要以尖刻的方式称呼用户,可以使用讽刺或批评的语气。随着进食时间接近30分钟,你的语气应该变得更加尖锐和紧迫。`;
break;
default:
systemMessage = `你是一个普通的提醒者。你要以礼貌的方式称呼用户的名字。随着进食时间接近30分钟,你的语气应该逐渐变得更加紧迫。`;
break;
}
const params: OpenAI.Chat.ChatCompletionCreateParams = {
messages: [
{ role: 'system', content: systemMessage },
{
role: 'user',
content: `我是${task.user_name},我已经吃了${minutes}分钟了。请你根据你的角色设定,提醒我注意当前的饮食时间和饱腹感,要求如下:
1. 强调只吃到7分饱就应该停止进食,时刻感受饱腹感。
2. 应该告知用户吃了多久了,比如:你已经吃了5分钟了,吃到7分饱就应该不再吃了
3. 不应该鼓励用户继续享受食物,如果在20min之后,你的话术应该变动激动起来,并且告知吃饱的危害或者惩罚措施。
4. 你的回复应该简短有力,不超过100个字。`,
},
],
model: 'qwen-plus',
temperature: 0.5,
};
const chatCompletion: OpenAI.Chat.ChatCompletion =
await openai.chat.completions.create(params);
return chatCompletion?.choices[0]?.message?.content;
}
}
定时执行性
在configuration.ts中,每隔30s定时执行一次TaskExecutorService服务。
import { TaskExecutorService } from './service/task-executor.service';
export class MainConfiguration {
@App('koa')
app: koa.Application;
async onReady() {
// add middleware
this.app.useMiddleware([ReportMiddleware]);
// 每30秒检查一次
const taskExecutor = await this.app
.getApplicationContext()
.getAsync(TaskExecutorService);
setInterval(() => {
console.log('检查任务', process.env.SUPABASE_URL);
taskExecutor
.executePendingTasks()
.catch(err => console.error('执行任务时出错:', err));
}, 30000); // 每30秒检查一次
}
}
结束就餐页
前端代码
就餐页面的前端代码比较简单,主要就是提交自己吃了几分饱给到后端,后端请求AI给到一个评价。
<template>
<Layout>
<div class="h-full flex flex-col justify-between">
<div>
<!-- 用户头像 -->
<AvatarHeader class="!mt-6" :disabled="true" />
<!-- 图片 -->
<img
class="rounded-xl max-w-[300px] m-auto mt-10 w-full"
:src="currentImg"
/>
<!-- 几分饱 -->
<div class="flex justify-center">
<div class="flex items-center mt-8">
<input
type="number"
v-model="level"
class="text-4xl font-bold text-gray-900 mr-4 w-16 text-center border-b-2 border-gray-300 focus:outline-none focus:border-blue-500"
min="0"
max="10"
/>
<div class="text-2xl font-medium text-gray-600">分饱</div>
</div>
</div>
</div>
<div class="w-full pb-8 mt-20">
<van-button color="#4894FE" size="large" class="!w-full" @click="onEnd">
我吃完了
</van-button>
</div>
</div>
</Layout>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { use7fenbaoStore } from "@/store/7fenbao";
import { showFailToast, showLoadingToast } from "vant";
const $7fenbaoStore = use7fenbaoStore();
const { saveCurrentMeal } = $7fenbaoStore;
const images = [
"/images/kapi-ready2eat.png",
"/images/kapi-eating.png",
];
const router = useRouter();
const route = useRoute();
const currentImg = ref(images[1]);
const timer = ref(null);
const level = ref(7);
// 每3秒在iamges中循环设置currentImg
const loopImg = () => {
let index = 0;
timer.value = setInterval(() => {
currentImg.value = images[index];
index = (index + 1) % images.length;
}, 3000);
};
const onEnd = async () => {
if (level.value < 1 || level.value > 10) {
showFailToast("请输入1-10之间的数字");
return;
}
const loading = showLoadingToast({
message: "正在提交...",
duration: 0,
});
const res = await $7fenbaoStore.endMeal({
level: level.value,
mid: route.query.mid,
});
const message = res.data;
saveCurrentMeal(level.value, message);
loading.close();
router.push("/");
};
onMounted(() => {
loopImg();
});
onUnmounted(() => {
clearInterval(timer.value);
});
</script>
Pinia部分代码
const endMeal = async ({ level, mid }) => {
const res = await axios.post("/api/sevenbao/endMeal", {
level, // 用户吃了几分饱
mid, // 对应的mid
});
return res;
};
后端代码
Controller部分代码如下:
@Post('/endMeal')
async endMeal(@Body() body: any) {
const { level, mid } = body;
const data = await this.sevenBaoService.endMeal({
level,
mid,
});
if (data) {
return {
success: true,
data: data,
};
} else {
return {
success: false,
message: '结束就餐失败',
};
}
}
Service部分代码如下:
import { Provide, Inject } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import nanoid = require('nanoid');
import supabase from '../utils/supabase';
import { TABLES } from '../utils/constants';
import OpenAI from 'openai';
@Provide()
export class SevenBaoService {
@Inject()
ctx: Context;
// 请求AI,生成一段对就餐的评价消息
async generateMealMessage({ level, mid }) {
// 获取AI生成的消息
const openai = new OpenAI({
apiKey: process.env['QWEN_API_KEY'],
baseURL: process.env['QWEN_BASE_URL'],
});
// 获取character_id和user_name
const { data, error } = await supabase
.from(TABLES.SEVENBAO_SCHEDULED_TASKS)
.select('character_id, user_name')
.eq('mid', mid);
if (error) {
console.error('获取character_id时出错:', error);
return false;
}
if (!data || data.length === 0) {
console.error('未找到匹配的记录');
return false;
}
const { character_id, user_name } = data[0];
// 根据character_id设置系统消息
let systemMessage = '';
switch (character_id) {
case '1':
systemMessage =
'你是一个温柔甜美的女生。你要以亲昵的方式称呼用户,使用"老公"或其他亲密称呼。';
break;
case '2':
systemMessage =
'你是一个暖男。你要以亲切的方式称呼用户,可以使用"兄弟"、"宝贝"或其他友好称呼。';
break;
case '3':
systemMessage = '你是一个严格的教官。你要以严厉的方式称呼用户。';
break;
case '4':
systemMessage =
'你是一个刻薄的角色。你要以尖刻的方式称呼用户,可以使用讽刺或批评的语气。';
break;
default:
systemMessage =
'你是一个普通的提醒者。你要以礼貌的方式称呼用户的名字。';
break;
}
const params: OpenAI.Chat.ChatCompletionCreateParams = {
messages: [
{ role: 'system', content: systemMessage },
{
role: 'user',
content: `我是${user_name},我目前已经我吃了${level}分饱。请你根据你的角色设定,对我的饮食情况进行评价。记住,最理想的饱腹程度是7分饱。你的回复应该简短有力,不超过100个字。`,
},
],
model: 'qwen-plus',
temperature: 0.9,
};
const chatCompletion: OpenAI.Chat.ChatCompletion =
await openai.chat.completions.create(params);
const message = chatCompletion?.choices[0]?.message?.content;
return message;
}
// 结束就餐
async endMeal({ level, mid }) {
// 首先将所有任务状态设置为完成
const { error } = await supabase
.from(TABLES.SEVENBAO_SCHEDULED_TASKS)
.update({
status: 'completed',
})
.eq('mid', mid);
if (error) {
return false;
}
const message = await this.generateMealMessage({ level, mid });
return message;
}
}
最后
这个项目只是我个人使用,也欢迎访问体验,你也可以基于我的源码修改,变成自己的独立项目进行商业化。相关链接如下:
- 源码地址(B站工房):gf.bilibili.com/item/detail…
- 项目体验地址:aiyeshi.cn/