系列教程
添加本地存储 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 聊天的核心业务流程【详解】含图
点击新建聊天按钮
路由跳转 /
,打开创建聊天页(首页)
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();
}
};