建设官方网站e路护航成都最新疫情
官网 https://www.electronforge.io/
技术栈:Vue3.5+Electron
本期最终效果预览
创建并启动项目
配置国内下载源
- 打开用户目录
C:\Users\60309
(60309 改成自己电脑的用户名) - 打开
.npmrc
文件 - 添加国内下载源
registry=https://registry.npmmirror.com/
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
在目标目录(如 E:\编程\electron
)下创建项目
npm init electron-app@latest electron-vue3-AIchat -- --template=vite-typescript
electron-vue3-AIchat
为自定义的项目名称
打开空值校验,在 tsconfig.json 中添加
"strictNullChecks": true
用 vscode 打开,并运行项目
得到
集成必要的依赖
集成 vue3
npm install vue
npm install --save-dev @vitejs/plugin-vue
- vite.renderer.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";export default defineConfig({plugins: [vue()],
});
- 新建 src\App.vue
<template><div class="bg-amber-500">vue3</div>
</template><script setup></script>
- src\renderer.ts
import { createApp } from "vue";
import App from "./App.vue";import "./index.css";createApp(App).mount("#app");
- index.html
<!DOCTYPE html>
<html><head><meta charset="UTF-8" /><title>AI聊天</title></head><body><div id="app"></div><script type="module" src="/src/renderer.ts"></script></body>
</html>
重启项目,效果如下
集成 tailwindcss
npm install tailwindcss @tailwindcss/vite
将 vite.renderer.config.ts
改名为 vite.renderer.config.mts
,内容修改为
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({plugins: [vue(), tailwindcss()],
});
forge.config.ts 中 ,将 vite.renderer.config.ts
改名为 vite.renderer.config.mts
src\index.css 中添加
@import "tailwindcss";
vscode 安装插件
Tailwind CSS IntelliSense
重启项目,效果如下
集成 iconify
npm install --save-dev @iconify/vue
src\App.vue 改为
<template><div class="bg-amber-500">vue3</div><Icon icon="mdi-light:home" />
</template><script setup>
import { Icon } from "@iconify/vue";
</script>
效果如下
集成 reka-ui
官网 https://www.radix-vue.com/
npm add radix-vue
集成 vue-router4
npm install vue-router@4
src\renderer.ts 改为
import { createApp } from "vue";
import App from "./App.vue";
import "./index.css";// 因没有地址栏,此处使用 createMemoryHistory 模式
import { createRouter, createMemoryHistory } from "vue-router";
import Home from "./views/Home.vue";
import Conversation from "./views/Conversation.vue";
import Settings from "./views/Settings.vue";const routes = [{ path: "/", component: Home },{ path: "/conversation/:id", component: Conversation },{ path: "/settings", component: Settings },
];
const router = createRouter({history: createMemoryHistory(),routes,
});const app = createApp(App);
app.use(router);
app.mount("#app");
src\App.vue 改为
<template><div class="flex items-center justify-between h-screen"><div class="w-[300px] bg-gray-200 h-full border-r border-gray-300"><div class="h-[90%] overflow-y-auto"></div><div class="h-[10%] grid grid-cols-2 gap-2 p-2"><RouterLink to="/"><Button icon-name="radix-icons:chat-bubble" class="w-full">新建聊天</Button></RouterLink><RouterLink to="/settings"><Button icon-name="radix-icons:gear" plain class="w-full">应用配置</Button></RouterLink></div></div><div class="h-full flex-1"><RouterView /></div></div>
</template><script setup>
import { Icon } from "@iconify/vue";
import Button from "./components/Button.vue";
</script>
新建 src\components\Button.vue
<template><buttonclass="vk-button shadow-sm inline-flex items-center justify-center disabled:opacity-50 disabled:pointer-events-none":class="[colorClasses, sizeClasses]":disabled="disabled || loading"><Icon :icon="iconWithLoading" class="mr-2" v-if="iconWithLoading" /><slot></slot></button>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { Icon } from "@iconify/vue";export type ButtonColor = "green" | "purple";
export type ButtonSize = "large" | "small";export interface ButtonProps {color?: ButtonColor;size?: ButtonSize;plain?: boolean;disabled?: boolean;loading?: boolean;iconName?: string;
}defineOptions({name: "VkButton",
});const props = withDefaults(defineProps<ButtonProps>(), {color: "green",
});
const colorVariants: Record<ButtonColor, any> = {green: {plain:"bg-green-50 text-green-700 hover:bg-green-700 border border-green-700 hover:text-white",normal:"bg-green-700 text-white hover:bg-green-700/90 border border-green-700",},purple: {plain:"bg-purple-50 text-purple-700 hover:bg-purple-700 border border-purple-700 hover:text-white",normal:"bg-purple-700 text-white hover:bg-purple-700/90 border border-purple-700",},
};
const iconWithLoading = computed(() => {if (props.loading) {return "line-md:loading-loop";} else {return props.iconName;}
});
const colorClasses = computed(() => {if (props.plain) {return colorVariants[props.color].plain;} else {return colorVariants[props.color].normal;}
});
const sizeClasses = computed(() => {if (!props.size) {return "h-[32px] py-[8px] px-[15px] text-sm rounded-[4px]";} else {if (props.size === "large") {return "h-[40px] py-[12px] px-[19px] rounded-[4px] text-base";} else {return "h-[24px] py-[11px] px-[5px] rounded-[3px] text-xs";}}
});
</script>
新建 src\views\Conversation.vue 内容为 对话
新建 src\views\Home.vue 内容为 首页
新建 src\views\Settings.vue 内容为 设置
<script lang="ts" setup></script><template><div>设置</div>
</template><style scoped></style>
重启项目,效果如下
点击按钮应用设置(可见右侧内容发生了路由切换)
前置准备
类型定义
src\types.ts
export interface ConversationProps {id: number;title: string;selectedModel: string;createdAt: string;updatedAt: string;providerId: number;
}
export interface ProviderProps {id: number;name: string;title?: string;desc?: string;avatar?: string;createdAt: string;updatedAt: string;models: string[];
}
export type MessageStatus = "loading" | "streaming" | "finished" | "error";export interface MessageProps {id: number;content: string;type: "question" | "answer";conversationId: number;status?: MessageStatus;createdAt: string;updatedAt: string;imagePath?: string;
}
测试数据
src\testData.ts
import { MessageProps, ConversationProps } from "./types";
export const messages: MessageProps[] = [{id: 1,content: "什么是光合作用",createdAt: "2024-07-03",updatedAt: "2024-07-03",type: "question",conversationId: 1,},{id: 2,content: "你的说法很请正确,理解的很不错,你的说法很请正确,理解的很不错",createdAt: "2024-07-03",updatedAt: "2024-07-03",type: "answer",conversationId: 1,},{id: 3,content: "还有更多的信息吗",createdAt: "2024-07-03",type: "question",updatedAt: "2024-07-03",conversationId: 1,},{id: 4,content: "",createdAt: "2024-07-03",updatedAt: "2024-07-03",type: "answer",status: "loading",conversationId: 1,},{id: 7,content: "2 什么是光合作用",createdAt: "2024-07-03",updatedAt: "2024-07-03",type: "question",conversationId: 2,},{id: 8,content: "你的说法很请正确",createdAt: "2024-07-03",updatedAt: "2024-07-03",type: "answer",conversationId: 2,},{id: 9,content: "请告诉我更多",createdAt: "2024-07-03",updatedAt: "2024-07-03",type: "question",conversationId: 2,},{id: 10,content: "你的说法很请正确,理解的很不错,你的说法很请正确,理解的很不错",createdAt: "2024-07-03",updatedAt: "2024-07-03",type: "answer",conversationId: 2,},
];
export const conversations: ConversationProps[] = [{id: 1,selectedModel: "GPT-3.5-Turbo",title: "1 什么是光合作用",createdAt: "2024-07-03",updatedAt: "2024-07-03",providerId: 1,},{id: 2,selectedModel: "GPT-3.5-Turbo",title: "2 什么是光合作用",createdAt: "2024-07-03",updatedAt: "2024-07-03",providerId: 1,},
];export const providers: ProviderProps[] = [{id: 1,name: "qianfan",title: "百度千帆",desc: "文心一言 百度出品的大模型",models: ["ERNIE-4.0-8K", "ERNIE-3.5-8K", "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: "dashscope",title: "阿里灵积",desc: "通义千问",// https://help.aliyun.com/zh/dashscope/developer-reference/api-details?spm=a2c4g.11186623.0.0.5bf41507xgULX5#b148acc634pfcmodels: ["qwen-turbo", "qwen-plus", "qwen-max", "qwen-vl-plus"],avatar:"https://qph.cf2.poecdn.net/main-thumb-pb-4160791-200-qlqunomdvkyitpedtghnhsgjlutapgfl.jpeg",createdAt: "2024-07-03",updatedAt: "2024-07-03",},{id: 3,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",},
];
核心组件封装
AI模型选择 ConversationList.vue
src\components\ProviderSelect.vue
<template><div class="provider-select w-full"><SelectRoot v-model="currentModel"><SelectTriggerclass="flex w-full items-center justify-between rounded-md py-1.5 px-3 shadow-sm border outline-none data-[placeholder]:text-gray-400"><SelectValue placeholder="请选择AI模型" /><Icon icon="radix-icons:chevron-down" class="h-5 w-5" /></SelectTrigger><SelectPortal><SelectContent class="bg-white rounded-md shadow-md z-[100] border"><SelectViewport class="p-2"><div v-for="provider in items"><SelectLabel class="flex items-center px-6 h-7 text-gray-500"><img:src="provider.avatar":alt="provider.name"class="h-5 w-5 mr-2 rounded"/>{{ provider.title }}</SelectLabel><SelectGroup><SelectItemv-for="(model, index) in provider.models":key="index":value="`${provider.id}/${model}`"class="outline-none rounded flex items-center h-7 px-6 relative text-green-700 cursor-pointer data-[highlighted]:bg-green-700 data-[highlighted]:text-white"><SelectItemIndicator class="absolute left-2 w-6"><Icon icon="radix-icons:check" /></SelectItemIndicator><SelectItemText>{{ model }}</SelectItemText></SelectItem></SelectGroup><SelectSeparator class="h-[1px] my-2 bg-gray-300" /></div></SelectViewport></SelectContent></SelectPortal></SelectRoot></div>
</template><script lang="ts" setup>
import {SelectContent,SelectGroup,SelectItem,SelectItemIndicator,SelectItemText,SelectLabel,SelectPortal,SelectRoot,SelectSeparator,SelectTrigger,SelectValue,SelectViewport,
} from "radix-vue";
import { Icon } from "@iconify/vue";import { ProviderProps } from "../types";defineProps<{ items: ProviderProps[] }>();
const currentModel = defineModel<string>();
</script>
会话列表 ConversationList.vue
src\components\ConversationList.vue
<template><div class="conversation-list"><divclass="item border-gray-300 border-t cursor-pointer p-2":class="{'bg-gray-100 hover:bg-gray-300': selectedId === item.id,'bg-white hover:bg-gray-200': selectedId !== item.id,}"v-for="item in items":key="item.id"><a @click.prevent="goToConversation(item.id)"><divclass="flex justify-between items-center text-sm leading-5 text-gray-500"><span>{{ item.selectedModel }}</span><span>{{ item.updatedAt }}</span></div><h2 class="font-semibold leading-6 text-gray-900 truncate">{{ item.title }}</h2></a></div></div>
</template><script lang="ts" setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { ConversationProps } from "../types";defineProps<{ items: ConversationProps[] }>();
const router = useRouter();const selectedId = ref(0);const goToConversation = (id: number) => {router.push({ path: `/conversation/${id}` });selectedId.value = id;
};
</script>
聊天记录 MessageList.vue
src\components\MessageList.vue
<template><div class="message-list" ref="_ref"><divclass="message-item mb-3"v-for="message in messages":key="message.id"><div class="flex" :class="{ 'justify-end': message.type === 'question' }"><div><divclass="text-sm text-gray-500 mb-2":class="{ 'text-right': message.type === 'question' }">{{ message.createdAt }}</div><divclass="message-question bg-green-700 text-white p-2 rounded-md"v-if="message.type === 'question'"><imgv-if="message.imagePath":src="`safe-file://${message.imagePath}`"alt="Message image"class="h-24 w-24 object-cover rounded block"/>{{ message.content }}</div><divclass="message-answer p-2 rounded-md"v-else:class="{'bg-red-100 text-red-700': message.status === 'error','bg-gray-200 text-gray-700': message.status !== 'error',}"><template v-if="message.status === 'loading'"><Icon icon="eos-icons:three-dots-loading"></Icon></template><template v-else-if="message.status === 'error'"><span>{{ message.content }}</span></template><divv-elseclass="prose prose-slate prose-headings:my-2 prose-li:my-0 prose-ul:my-1 prose-p:my-1 prose-pre:p-0">{{ message.content }}</div></div></div></div></div></div>
</template><script lang="ts" setup>
import { ref } from "vue";
import { Icon } from "@iconify/vue";type MessageStatus = "loading" | "streaming" | "finished" | "error";interface MessageProps {id: number;content: string;type: "question" | "answer";conversationId: number;status?: MessageStatus;createdAt: string;updatedAt: string;imagePath?: string;
}defineProps<{ messages: MessageProps[] }>();
</script>
发送消息 MessageInput.vue
<template><divclass="message-input w-full shadow-sm border rounded-lg border-gray-300 py-1 px-2 focus-within:border-green-700"><div v-if="imagePreview" class="mb-2 relative flex items-center"><img:src="imagePreview"alt="Preview"class="h-24 w-24 object-cover rounded"/></div><div class="flex items-center"><inputtype="file"accept="image/*"ref="fileInput"class="hidden"@change="handleImageUpload"/><Iconicon="radix-icons:image"width="24"height="24":class="['mr-2',disabled? 'text-gray-300 cursor-not-allowed': 'text-gray-400 cursor-pointer hover:text-gray-600',]"@click="triggerFileInput"/><inputclass="outline-none border-0 flex-1 bg-white focus:ring-0"type="text"v-model="model":disabled="disabled"/><Buttonicon-name="radix-icons:paper-plane"@click="onCreate":disabled="disabled">发送</Button></div></div>
</template><script lang="ts" setup>
import { ref } from "vue";
import { Icon } from "@iconify/vue";import Button from "./Button.vue";const props = defineProps<{disabled?: boolean;
}>();
const emit = defineEmits<{create: [value: string, imagePath?: string];
}>();
const model = defineModel<string>();
const fileInput = ref<HTMLInputElement | null>(null);
const imagePreview = ref("");
const triggerFileInput = () => {if (!props.disabled) {fileInput.value?.click();}
};
let selectedImage: File | null = null;
const handleImageUpload = (event: Event) => {const target = event.target as HTMLInputElement;if (target.files && target.files.length > 0) {selectedImage = target.files[0];const reader = new FileReader();reader.onload = (e) => {imagePreview.value = e.target?.result as string;};reader.readAsDataURL(selectedImage);}
};
const onCreate = () => {if (model.value && model.value.trim() !== "") {emit("create", model.value, selectedImage?.path || undefined);selectedImage = null;imagePreview.value = "";}
};
</script>
页面中使用
src\App.vue
引入 ConversationList.vue
<template><div class="flex items-center justify-between h-screen"><div class="w-[300px] bg-gray-200 h-full border-r border-gray-300"><div class="h-[90%] overflow-y-auto"><ConversationList :items="conversations" /></div><div class="h-[10%] grid grid-cols-2 gap-2 p-2"><RouterLink to="/"><Button icon-name="radix-icons:chat-bubble" class="w-full">新建聊天</Button></RouterLink><RouterLink to="/settings"><Button icon-name="radix-icons:gear" plain class="w-full">应用配置</Button></RouterLink></div></div><div class="h-full flex-1"><RouterView /></div></div>
</template><script setup>
import { Icon } from "@iconify/vue";
import Button from "./components/Button.vue";
import ConversationList from "./components/ConversationList.vue";
import { conversations } from "./testData";
</script>
src\views\Home.vue
引入ProviderSelect.vue 和 MessageInput.vue
<template><div class="w-[80%] mx-auto h-full"><div class="flex items-center h-[85%]"><ProviderSelect :items="providers" v-model="currentProvider" /></div><div class="flex items-center h-[15%]"><MessageInput@create="createConversation":disabled="currentProvider === ''"/></div></div>
</template>
<script lang="ts" setup>
import ProviderSelect from "../components/ProviderSelect.vue";
import MessageInput from "../components/MessageInput.vue";
import { ref } from "vue";
import { providers } from "../testData";const currentProvider = ref("");
const createConversation = async (question: string, imagePath?: string) => {};
</script><style scoped></style>
src\views\Conversation.vue
引入 MessageList.vue 和 MessageInput.vue
<template><divclass="h-[10%] bg-gray-200 border-b border-gray-300 flex items-center px-3 justify-between"v-if="convsersation"><h3 class="font-semibold text-gray-900">{{ convsersation.title }}</h3><span class="text-sm text-gray-500">{{ convsersation.updatedAt }}</span></div><div class="w-[80%] mx-auto h-[75%] overflow-y-auto pt-2"><MessageList :messages="filteredMessages" /></div><div class="w-[80%] mx-auto h-[15%] flex items-center"><MessageInput @create="sendNewMessage" v-model="inputValue" /></div>
</template><script lang="ts" setup>
import MessageList from "../components/MessageList.vue";
import MessageInput from "../components/MessageInput.vue";
import { messages, conversations } from "../testData";
import { ref, computed, watch } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
let conversationId = ref(parseInt(route.params.id as string));const convsersation = computed(() =>conversations.find((item) => item.id === conversationId.value)
);
const filteredMessages = computed(() =>messages.filter((message) => message.conversationId === conversationId.value)
);watch(() => route.params.id,async (newId: string) => {conversationId.value = parseInt(newId);}
);const sendNewMessage = async (question: string, imagePath?: string) => {};const inputValue = ref("");
</script><style scoped></style>