最终效果
定义默认配置
src/initData.ts
ts
export const DEFAULT_CONFIG: AppConfig = {
language: "zh",
fontSize: 14,
providerConfigs: {},
};
src/types.ts
ts
export interface AppConfig {
language: 'zh' | 'en'
fontSize: number
providerConfigs: Record<string, Record<string, string>>
}
从本地加载配置
因读取配置文件需要时间,在创建主窗口前,便开始加载
src/main.ts
ts
import { configManager } from './config'
ts
const createWindow = async () => {
// 加载配置
await configManager.load();
src/config.ts
ts
import { app } from "electron";
import path from "path";
import fs from "fs/promises";
import { AppConfig } from "./types";
import { DEFAULT_CONFIG } from "./initData";
// 配置文件路径,在windows 中是 C:\Users\用户名\AppData\Roaming\项目名\config.json
const configPath = path.join(app.getPath("userData"), "config.json");
let config = { ...DEFAULT_CONFIG };
export const configManager = {
async load() {
try {
const data = await fs.readFile(configPath, "utf-8");
config = { ...DEFAULT_CONFIG, ...JSON.parse(data) };
} catch {
await this.save();
}
return config;
},
async save() {
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
return config;
},
async update(newConfig: Partial<AppConfig>) {
config = { ...config, ...newConfig };
await this.save();
return config;
},
get() {
return config;
},
};
主进程中使用配置
直接调用 configManager 的 get 方法即可
src/providers/createProvider.ts
ts
import { configManager } from "../config";
ts
const config = configManager.get();
const providerConfig = config.providerConfigs[providerName] || {};
渲染进程中使用配置
需借助 electron 的 IPC 通信从主进程中获取
src/views/Settings.vue
ts
onMounted(async () => {
const config = await (window as any).electronAPI.getConfig();
});
src/preload.ts
ts
contextBridge.exposeInMainWorld("electronAPI", {
startChat: (data: CreateChatProps) => ipcRenderer.send("start-chat", data),
onUpdateMessage: (callback: OnUpdatedCallback) =>
ipcRenderer.on("update-message", (_event, value) => callback(value)),
// 获取配置
getConfig: () => ipcRenderer.invoke("get-config"),
// 更新配置
updateConfig: (config: Partial<AppConfig>) =>
ipcRenderer.invoke("update-config", config),
});
src/ipc.ts
ts
import { ipcMain, BrowserWindow } from "electron";
import { configManager } from './config'
ts
export function setupIPC(mainWindow: BrowserWindow) {
ipcMain.handle('get-config', () => {
return configManager.get()
})
src/main.ts
ts
import { setupIPC } from "./ipc";
ts
setupIPC(mainWindow);
配置页更新配置
- 配置页深度监听配置变量,当页面配置发生改变时,触发 electron 的 updateConfig 事件,将新配置传给主进程
- 主进程将新配置写入本地文件
src/views/Settings.vue
深度监听配置变量,当页面配置发生改变时,触发 electron 的 updateConfig 事件,将新配置传给主进程
ts
// 深度监听配置变化并自动保存
watch(
currentConfig,
async (newConfig) => {
// 创建一个普通对象来传递配置
const configToSave = {
language: newConfig.language,
fontSize: newConfig.fontSize,
providerConfigs: JSON.parse(JSON.stringify(newConfig.providerConfigs)),
};
// 由于 TypeScript 提示 window 上不存在 electronAPI 属性,我们可以使用类型断言来解决这个问题
await (window as any).electronAPI.updateConfig(configToSave);
// 更新界面语言
locale.value = newConfig.language;
},
{ deep: true }
);
src/preload.ts
ts
contextBridge.exposeInMainWorld("electronAPI", {
startChat: (data: CreateChatProps) => ipcRenderer.send("start-chat", data),
onUpdateMessage: (callback: OnUpdatedCallback) =>
ipcRenderer.on("update-message", (_event, value) => callback(value)),
// 获取配置
getConfig: () => ipcRenderer.invoke("get-config"),
// 更新配置
updateConfig: (config: Partial<AppConfig>) =>
ipcRenderer.invoke("update-config", config),
});
src/ipc.ts
ts
import { ipcMain, BrowserWindow } from "electron";
import { configManager } from './config'
ts
export function setupIPC(mainWindow: BrowserWindow) {
ipcMain.handle("update-config", async (event, newConfig) => {
const updatedConfig = await configManager.update(newConfig);
return updatedConfig;
});
src/config.ts
完整代码见上文,此处仅截取更新配置的代码
ts
async update(newConfig: Partial<AppConfig>) {
config = { ...config, ...newConfig };
await this.save();
return config;
},
async save() {
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
return config;
},
配置页完整代码
src/views/Settings.vue
html
<template>
<div class="w-[80%] mx-auto p-8">
<h1 class="text-2xl font-bold mb-8">{{ t("settings.title") }}</h1>
<TabsRoot v-model="activeTab" class="w-full">
<TabsList class="flex border-b border-gray-200 mb-6">
<TabsTrigger
value="general"
class="px-4 py-2 -mb-[1px] text-sm font-medium text-gray-600 hover:text-gray-800 data-[state=active]:text-green-600 data-[state=active]:border-b-2 data-[state=active]:border-green-600"
>
{{ t("settings.general") }}
</TabsTrigger>
<TabsTrigger
value="models"
class="px-4 py-2 -mb-[1px] text-sm font-medium text-gray-600 hover:text-gray-800 data-[state=active]:text-green-600 data-[state=active]:border-b-2 data-[state=active]:border-green-600"
>
{{ t("settings.models") }}
</TabsTrigger>
</TabsList>
<TabsContent value="general" class="space-y-6 max-w-[500px]">
<!-- Language Setting -->
<div class="setting-item flex items-center gap-8">
<label class="text-sm font-medium text-gray-700 w-24">
{{ t("settings.language") }}
</label>
<SelectRoot v-model="currentConfig.language" class="w-[160px]">
<SelectTrigger
class="inline-flex items-center justify-between rounded-md px-3 py-2 text-sm gap-1 bg-white border border-gray-300"
>
<SelectValue :placeholder="t('settings.selectLanguage')" />
<SelectIcon>
<Icon icon="radix-icons:chevron-down" />
</SelectIcon>
</SelectTrigger>
<SelectPortal>
<SelectContent class="bg-white rounded-md shadow-lg border">
<SelectViewport class="p-2">
<SelectGroup>
<SelectItem
value="zh"
class="relative flex items-center px-8 py-2 text-sm text-gray-700 rounded-md cursor-default hover:bg-gray-100"
>
<SelectItemText>{{ t("common.chinese") }}</SelectItemText>
<SelectItemIndicator
class="absolute left-2 inline-flex items-center"
>
<Icon icon="radix-icons:check" />
</SelectItemIndicator>
</SelectItem>
<SelectItem
value="en"
class="relative flex items-center px-8 py-2 text-sm text-gray-700 rounded-md cursor-default hover:bg-gray-100"
>
<SelectItemText>{{ t("common.english") }}</SelectItemText>
<SelectItemIndicator
class="absolute left-2 inline-flex items-center"
>
<Icon icon="radix-icons:check" />
</SelectItemIndicator>
</SelectItem>
</SelectGroup>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</div>
<!-- Font Size Setting -->
<div class="setting-item flex items-center gap-8">
<label class="text-sm font-medium text-gray-700 w-24">
{{ t("settings.fontSize") }}
</label>
<NumberFieldRoot
v-model="currentConfig.fontSize"
class="inline-flex w-[100px]"
>
<NumberFieldDecrement
class="px-2 border border-r-0 border-gray-300 rounded-l-md hover:bg-gray-100 focus:outline-none"
>
<Icon icon="radix-icons:minus" />
</NumberFieldDecrement>
<NumberFieldInput
class="w-10 px-2 py-2 border border-gray-300 focus:outline-none focus:ring-1 focus:ring-green-500 text-center"
:min="12"
:max="20"
/>
<NumberFieldIncrement
class="px-2 border border-l-0 border-gray-300 rounded-r-md hover:bg-gray-100 focus:outline-none"
>
<Icon icon="radix-icons:plus" />
</NumberFieldIncrement>
</NumberFieldRoot>
</div>
</TabsContent>
<TabsContent value="models" class="space-y-4">
<AccordionRoot type="single" collapsible>
<AccordionItem
v-for="provider in providers"
:key="provider.id"
:value="provider.name"
class="border rounded-lg mb-2"
>
<AccordionTrigger
class="flex items-center justify-between w-full p-4 text-left"
>
<div class="flex items-center gap-2">
<img
:src="provider.avatar"
:alt="provider.name"
class="w-6 h-6 rounded"
/>
<span class="font-medium">{{ provider.title }}</span>
</div>
<Icon
icon="radix-icons:chevron-down"
class="transform transition-transform duration-200 ease-in-out data-[state=open]:rotate-180"
/>
</AccordionTrigger>
<AccordionContent class="p-4 pt-0">
<div class="space-y-4">
<div
v-for="config in getProviderConfig(provider.name)"
:key="config.key"
class="flex items-center gap-4"
>
<label class="text-sm font-medium text-gray-700 w-24">{{
config.label
}}</label>
<input
:type="config.type"
:placeholder="config.placeholder"
:required="config.required"
:value="config.value"
@input="(e) => updateProviderConfig(provider.name, config.key, (e.target as HTMLInputElement).value)"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-green-500"
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</AccordionRoot>
</TabsContent>
</TabsRoot>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, watch, ref, computed } from "vue";
import { Icon } from "@iconify/vue";
import { useI18n } from "vue-i18n";
import { AppConfig } from "../types";
import { useProviderStore } from "../stores/provider";
import { providerConfigs, ProviderConfigItem } from "../config/providerConfig";
import {
SelectContent,
SelectGroup,
SelectIcon,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport,
NumberFieldRoot,
NumberFieldInput,
NumberFieldIncrement,
NumberFieldDecrement,
TabsRoot,
TabsList,
TabsTrigger,
TabsContent,
AccordionRoot,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "radix-vue";
const { t, locale } = useI18n();
const activeTab = ref("general");
const providerStore = useProviderStore();
const providers = computed(() => providerStore.items);
const currentConfig = reactive<AppConfig>({
language: "zh",
fontSize: 14,
providerConfigs: {},
});
onMounted(async () => {
const config = await (window as any).electronAPI.getConfig();
Object.assign(currentConfig, config);
});
// 深度监听配置变化并自动保存
watch(
currentConfig,
async (newConfig) => {
// 创建一个普通对象来传递配置
const configToSave = {
language: newConfig.language,
fontSize: newConfig.fontSize,
providerConfigs: JSON.parse(JSON.stringify(newConfig.providerConfigs)),
};
// 由于 TypeScript 提示 window 上不存在 electronAPI 属性,我们可以使用类型断言来解决这个问题
await (window as any).electronAPI.updateConfig(configToSave);
// 更新界面语言
locale.value = newConfig.language;
},
{ deep: true }
);
// 获取provider对应的配置项
const getProviderConfig = (providerName: string): ProviderConfigItem[] => {
const configs = providerConfigs[providerName] || [];
// 确保配置值被初始化
if (!currentConfig.providerConfigs[providerName]) {
currentConfig.providerConfigs[providerName] = {};
}
return configs.map((config) => ({
...config,
value:
currentConfig.providerConfigs[providerName][config.key] || config.value,
}));
};
// 更新provider配置值
const updateProviderConfig = (
providerName: string,
key: string,
value: string
) => {
if (!currentConfig.providerConfigs[providerName]) {
currentConfig.providerConfigs[providerName] = {};
}
currentConfig.providerConfigs[providerName][key] = value;
};
</script>
src/config/providerConfig.ts
ts
export interface ProviderConfigItem {
key: string;
label: string;
value: string;
type: 'text' | 'password' | 'number';
required?: boolean;
placeholder?: string;
}
// 百度文心一言配置
export const qianfanConfig: ProviderConfigItem[] = [
{
key: 'accessKey',
label: 'Access Key',
value: '',
type: 'text',
required: true,
placeholder: '请输入Access Key'
},
{
key: 'secretKey',
label: 'Secret Key',
value: '',
type: 'password',
required: true,
placeholder: '请输入Secret Key'
}
];
// API Key + Base URL 通用配置模板
export const apiKeyBaseUrlConfig: ProviderConfigItem[] = [
{
key: 'apiKey',
label: 'API Key',
value: '',
type: 'password',
required: true,
placeholder: '请输入API Key'
},
{
key: 'baseUrl',
label: 'Base URL',
value: '',
type: 'text',
required: false,
placeholder: '请输入API基础URL'
}
];
// 所有Provider的配置映射
export const providerConfigs: Record<string, ProviderConfigItem[]> = {
qianfan: qianfanConfig,
aliyun: apiKeyBaseUrlConfig,
deepseek: apiKeyBaseUrlConfig,
openai: apiKeyBaseUrlConfig
};
src/stores/provider.ts
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)
}
}
})
src/db.ts
ts
import Dexie, { type EntityTable } from "dexie";
import { ConversationProps, ProviderProps } from "./types";
import { providers } from "./initData";
export const db = new Dexie("AI_chatDatabase") as Dexie & {
conversations: EntityTable<ConversationProps, "id">;
providers: EntityTable<ProviderProps, "id">;
};
db.version(1).stores({
// 主键为id,且自增
// 新增updatedAt字段,用于排序
conversations: "++id, updatedAt",
providers: "++id, name",
});
export const initProviders = async () => {
const count = await db.providers.count();
if (count === 0) {
db.providers.bulkAdd(providers);
}
};