为了不吃撑,我用通义千问创建了一个AI应用

326 阅读7分钟

这是一个什么AI应用?

笔者目前正在挑战:用国内的大模型开发 10 款有趣、有用的 AI 应用(基于 web)。

这 10 款 AI 应用的开发过程我都会同步发布到 B 站 @卡皮 AI,并且都会附带体验地址 详细教程 + 源码。

这期的产品名称叫「7分饱」,开发这个小工具的原因是因为我是一个胃食管反流患者,每顿饭如果吃太撑胃就会很难受,会反酸烧心。所以我在想是不是可以开发一个AI应用,能在我吃饭的时候提醒我不要吃太撑呢?

这个需求就是这个产品的灵感,我大致介绍一下这个产品,总共有4个页面:

  1. 基本信息页

新用户首次进入时,输入昵称和飞书的webhook链接。webhook链接对应的是发送到飞书消息的地址。

  1. AI角色选择页面

新用户首次进入时,用户可以选择对应的AI提醒角色风格,不同角色对应不同的消息风格。

  1. 首页

用户首页,用户可以在该页面查看最近的就餐历史记录。并且进行「就餐」的操作。(该按钮因为fixed布局,导致截图不完全)

  1. 就餐页面

用户可以进入到就餐页面中,此时会后台创建一个总时长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个后端接口:

  1. 创建定时任务: 用户点击「就餐」按钮,后端node.js创建定时任务,在指定时间调用通义千问的API生成提醒消息到用户的webhook链接当中。

  2. 用户结束就餐: 用户输入自己这段饭吃到了几分饱,然后点击「结束就餐」按钮,后端调用通义千问的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实现。

目前的后端实现逻辑是:

  1. 创建对应执行时间点的数据,并插入到数据库

  2. 后端创建定时任务,每30s扫一次数据库是否有需要执行的任务

  3. 如果扫到有需要执行的任务,那么就更改该任务状态并执行,请求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;
  }
}

最后

这个项目只是我个人使用,也欢迎访问体验,你也可以基于我的源码修改,变成自己的独立项目进行商业化。相关链接如下: