Electron Forge【实战】桌面应用 —— AI聊天(中)

发布于:2025-04-19 ⋅ 阅读:(22) ⋅ 点赞:(0)

系列教程

添加本地存储 Dexie.js

用 IndexedDB 实现,Dexie.js 库简化操作

npm i dexie

新建文件 src\db.ts

import Dexie, { type EntityTable } from "dexie";

import { ProviderProps, ConversationProps, MessageProps } from "./types";

export const db = new Dexie("AI_chatDatabase") as Dexie & {
  providers: EntityTable<ProviderProps, "id">;
  conversations: EntityTable<ConversationProps, "id">;
  messages: EntityTable<MessageProps, "id">;
};

db.version(1).stores({
  providers: "++id, name",
  conversations: "++id, providerId",
  messages: "++id, conversationId",
});

添加状态管理 Pinia

npm i pinia

src\renderer.ts 中新增

import { createPinia } from "pinia";
app.use(createPinia());

AI 模型下拉列表

在这里插入图片描述
在这里插入图片描述

方案一:从本地存储中加载

优点:可通过配置动态添加 AI 模型

1. 本地存储中没有数据时,对数据进行初始化

src\App.vue

import { db } from "./db";
import { providers } from "./initData";
const initProviders = async () => {
  const count = await db.providers.count();
  if (count === 0) {
    db.providers.bulkAdd(providers);
  }
};
onMounted(async () => {
  // 1.初始化 AI 模型列表
  await initProviders();
});

src\initData.ts

import { ProviderProps } from "./types";
export const providers: ProviderProps[] = [
  {
    id: 1,
    name: "qianfan",
    title: "百度千帆",
    desc: "文心一言 百度出品的大模型",
    models: ["ERNIE-Speed-128K"],
    avatar:
      "https://aip-static.cdn.bcebos.com/landing/product/ernie-bote321e5.png",
    createdAt: "2024-07-03",
    updatedAt: "2024-07-03",
  },
  {
    id: 2,
    name: "deepseek",
    title: "DeepSeek",
    desc: "DeepSeek",
    // https://api-docs.deepseek.com/zh-cn/
    models: ["deepseek-chat"],
    avatar:
      "https://qph.cf2.poecdn.net/main-thumb-pb-4981273-200-phhqenmywlkiybehuaqvsxpfekviajex.jpeg",
    createdAt: "2024-12-27",
    updatedAt: "2024-12-27",
  },
];

2. 从本地存储中获取数据,存入 Pinia

src\App.vue

import { useProviderStore } from "./stores/provider";
const provdierStore = useProviderStore();
onMounted(async () => {
  // 1.初始化 AI 模型列表
  await initProviders();
  // 2.加载 AI 模型列表
  provdierStore.fetchProviders();
});

src\stores\provider.ts

import { defineStore } from "pinia";
import { db } from "../db";
import { ProviderProps } from "../types";

export interface ProviderStore {
  items: ProviderProps[];
}

export const useProviderStore = defineStore("provider", {
  state: (): ProviderStore => {
    return {
      items: [],
    };
  },
  actions: {
    async fetchProviders() {
      const items = await db.providers.toArray();
      this.items = items;
    },
  },
  getters: {
    getProviderById: (state) => (id: number) => {
      return state.items.find((item) => item.id === id);
    },
  },
});

3. 从 Pinia 中获取数据,传入组件

src\views\Home.vue

import { useProviderStore } from "../stores/provider";
const providerStore = useProviderStore();
const providers = computed(() => providerStore.items);
<ProviderSelect :items="providers" v-model="currentProvider" />

方案二:直接从配置文件加载

代码简洁,但无法动态添加 AI 模型

src/views/Home.vue

import { providers } from "../initData";

AI 聊天的核心业务流程【详解】含图

下拉选择AI模型
输入第一个问题
点击发送按钮
创建新会话
调用 AI 模型接口
AI 的答案替换 loading 答案
输入更多问题 -- 点发送按钮
点击新建聊天按钮

点击新建聊天按钮

在这里插入图片描述

路由跳转 / ,打开创建聊天页(首页)

在这里插入图片描述

src/App.vue

    <RouterLink to="/">
      <Button icon-name="radix-icons:chat-bubble" class="w-full">
        新建聊天
      </Button>
    </RouterLink>

下拉选择AI模型

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

src/views/Home.vue

<ProviderSelect :items="providers" v-model="currentProvider" />

currentProvider 获取到选择的AI模型信息,值的格式为:AI模型提供商名称/AI模型名称

src/components/ProviderSelect.vue

:value="`${provider.name}/${model}`"

输入第一个问题

在这里插入图片描述
src/views/Home.vue

      <MessageInput
        @create="createConversation"
        :disabled="currentProvider === ''"
      />

src/components/MessageInput.vue

      <input
        class="outline-none border-0 flex-1 bg-white focus:ring-0"
        type="text"
        v-model="model"
        :disabled="disabled"
        :placeholder="tip"
        @keydown.enter="onCreate"
      />

点击发送按钮

在这里插入图片描述
src/components/MessageInput.vue

      <Button
        icon-name="radix-icons:paper-plane"
        @click="onCreate"
        :disabled="disabled"
      >
        发送
      </Button>
const onCreate = () => {
  if (model.value && model.value.trim() !== "") {
    emit("create", model.value, selectedImage?.path || undefined);
    selectedImage = null;
    imagePreview.value = "";
  } else {
    tip.value = "请输入问题";
  }
};

触发自定义事件 create,父组件对应执行 createConversation 方法

src/views/Home.vue

      <MessageInput
        @create="createConversation"
        :disabled="currentProvider === ''"
      />

创建新会话

  • 将第一个问题设置为新会话的标题(可优化为用户输入会话标题,豆包的设计是将第一个AI 的回答内容总结为会话标题)
  • 会话信息中携带 AI 模型提供商的名称和 AI 模型名称
  • 会话的消息列表中,第一条消息为第一个问题,第二条消息为 loading 状态的回答
  • 在数据库中创建新会话,得到新会话 id
  • 在 pinia 的会话列表中,追加新会话(可优化为在会话列表的顶部插入,以便最新的会话,在最顶部)
  • 跳转到会话详情页

在这里插入图片描述

src/views/Home.vue

const createConversation = async (question: string) => {
  const [AI_providerName, AI_modelName] = currentProvider.value.split("/");

  // 用 dayjs 得到格式化的当前时间字符串
  const currentTime = dayjs().format("YYYY-MM-DD HH:mm:ss");

  // pinia 中新建会话,得到新的会话id
  const conversationId = await conversationStore.createConversation({
    title: question,
    AI_providerName,
    AI_modelName,
    createdAt: currentTime,
    updatedAt: currentTime,
    msgList: [
      {
        type: "question",
        content: question,
        createdAt: currentTime,
        updatedAt: currentTime,
      },
      {
        type: "answer",
        content: "",
        status: "loading",
        createdAt: currentTime,
        updatedAt: currentTime,
      },
    ],
  });

  // 更新当前选中的会话
  conversationStore.selectedId = conversationId;

  // 右侧界面--跳转到会话页面 -- 带参数 init 为新创建的会话的第一条消息id
  router.push(`/conversation/${conversationId}?type=new`);
};

src/stores/conversation.ts

import { defineStore } from "pinia";
import { db } from "../db";
import { ConversationProps } from "../types";

export interface ConversationStore {
  items: ConversationProps[];
  selectedId: number;
}

export const useConversationStore = defineStore("conversation", {
  state: (): ConversationStore => {
    return {
      items: [],
      selectedId: -1,
    };
  },
  actions: {
    // 从本地存储中查询出会话列表
    async fetchConversations() {
      const items = await db.conversations.toArray();
      this.items = items;
    },
    async createConversation(createdData: Omit<ConversationProps, "id">) {
      // 本地存储中新增会话
      const newCId = await db.conversations.add(createdData);
      //   pinia 中新增会话
      this.items.push({
        id: newCId,
        ...createdData,
      });
      //   返回新增会话的id
      return newCId;
    },
    async deleteConversation(id: number) {
      // 本地存储中删除会话
      await db.conversations.delete(id);
      //   pinia 中删除会话
      const index = this.items.findIndex((item) => item.id === id);
      if (index > -1) {
        this.items.splice(index, 1);
      }
    },
  },
  getters: {
    // 根据会话id,从 pinia 的会话列表中筛选出目标会话
    getConversationById: (state) => (id: number) => {
      return state.items.find((item) => item.id === id);
    },
  },
});

调用 AI 模型接口

编辑中

AI 的答案替换 loading 答案

src/views/Conversation.vue 的 onMounted 中

  // 初次加载会话时,清空流式消息的内容
  let streamContent = "";

  // 监听 AI 模型的返回
  (window as any).electronAPI.onUpdateMessage(
    async (streamData: { messageId: any; data: any }) => {

      // 从 AI 的返回中解构出 messageId 和 data
      const { messageId, data } = streamData;

      // AI 的回答为消息流,每次接收到新的流式消息,都将其追加到 streamContent 中
      streamContent += data.result;

      // 函数封装 -- 解析返回值,格式化为消息的状态
      const getMessageStatus = (data: any): MessageStatus => {
        if (data.is_error) {
          return "error";
        } else if (data.is_end) {
          return "finished";
        } else {
          return "streaming";
        }
      };

      // 根据消息id, 获取到 loading 状态的消息
      let msg = convsersation.value!.msgList[messageId];
      // 将 AI 回答的流式消息替换掉 loading 状态的消息
      msg.content = streamContent;
      // 根据 AI 的返回,更新消息的状态
      msg.status = getMessageStatus(data);
      // 用 dayjs 得到格式化的当前时间字符串
      msg.updatedAt = dayjs().format("YYYY-MM-DD HH:mm:ss");

      // 本次回答结束后,清空流式消息的内容
      if (data.is_end) {
        streamContent = "";
      }
    }
  );

输入更多问题 – 点发送按钮

在这里插入图片描述

此时使用的是会话详情页的消息输入

src/views/Conversation.vue

    <MessageInput
      ref="dom_MessageInput"
      @create="sendNewMessage"
      v-model="inputValue"
    />

触发自定义事件 create,执行父组件中的 sendNewMessage 方法

const sendNewMessage = async (question: string) => {
  // 获取格式化的当前时间
  let currentTime = dayjs().format("YYYY-MM-DD HH:mm:ss");

  // 向消息列表中追加新的问题
  convsersation.value!.msgList.push({
    type: "question",
    content: question,
    createdAt: currentTime,
    updatedAt: currentTime,
  });

  // 向消息列表中追加 loading 状态的回答
  let new_msgList_length = convsersation.value!.msgList.push({
    type: "answer",
    content: "",
    createdAt: currentTime,
    updatedAt: currentTime,
    status: "loading",
  });

  // 消息列表的最后一条消息为 loading 状态的回答,其id为消息列表的长度 - 1
  let loading_msg_id = new_msgList_length - 1

  // 访问 AI 模型获取答案,参数为 loading 状态的消息的id
  get_AI_answer(loading_msg_id);

  // 清空问题输入框
  inputValue.value = "";

  // 自动聚焦到问题输入框
  if (dom_MessageInput.value) {
    dom_MessageInput.value.focus();
  }
};

网站公告

今日签到

点亮在社区的每一天
去签到