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

发布于:2025-03-23 ⋅ 阅读:(29) ⋅ 点赞:(0)

官网 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>
  <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>

网站公告

今日签到

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