官网 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
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>
<button
class="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#b148acc634pfc
models: ["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">
<SelectTrigger
class="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>
<SelectItem
v-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">
<div
class="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)">
<div
class="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">
<div
class="message-item mb-3"
v-for="message in messages"
:key="message.id"
>
<div class="flex" :class="{ 'justify-end': message.type === 'question' }">
<div>
<div
class="text-sm text-gray-500 mb-2"
:class="{ 'text-right': message.type === 'question' }"
>
{{ message.createdAt }}
</div>
<div
class="message-question bg-green-700 text-white p-2 rounded-md"
v-if="message.type === 'question'"
>
<img
v-if="message.imagePath"
:src="`safe-file://${message.imagePath}`"
alt="Message image"
class="h-24 w-24 object-cover rounded block"
/>
{{ message.content }}
</div>
<div
class="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>
<div
v-else
class="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>
<div
class="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">
<input
type="file"
accept="image/*"
ref="fileInput"
class="hidden"
@change="handleImageUpload"
/>
<Icon
icon="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"
/>
<input
class="outline-none border-0 flex-1 bg-white focus:ring-0"
type="text"
v-model="model"
:disabled="disabled"
/>
<Button
icon-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>
<div
class="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>