feat: enhance theme management and image caching functionality

This commit is contained in:
DEV_DSW
2026-04-16 15:43:35 +08:00
parent 411f4f3421
commit b1f589a674
13 changed files with 74 additions and 183 deletions

View File

@@ -1,6 +1,6 @@
"use strict";
require("electron");
require("./main-3jJaZPgE.js");
require("./main-BekteP6H.js");
require("electron-squirrel-startup");
require("electron-log");
require("bytenode");

View File

@@ -3,6 +3,7 @@ import { CONFIG_KEYS } from '@lib/constants'
import { setupMainWindow } from './wins';
import started from 'electron-squirrel-startup'
import configManager from '@electron/service/config-service'
import themeManager from '@electron/service/theme-service'
import { runTaskOperationService } from '@electron/process/runTaskOperationService'
import { initScriptStoreService } from '@electron/service/script-store-service'
import log from 'electron-log';
@@ -154,6 +155,7 @@ if (started) {
app.whenReady().then(async () => {
await configManager.init();
await themeManager.init();
gatewayManager.init();

View File

@@ -14,6 +14,7 @@ const DEFAULT_CONFIG: IConfig = {
[CONFIG_KEYS.PROVIDER]: '',
[CONFIG_KEYS.DEFAULT_MODEL]: null,
[CONFIG_KEYS.SELECTED_CHANNELS]: [],
[CONFIG_KEYS.IMAGE_CACHE]: [],
}
export class ConfigService {

View File

@@ -8,15 +8,17 @@ class ThemeService {
private _isDark: boolean = nativeTheme.shouldUseDarkColors;
constructor() {
const themeMode = configManager.get(CONFIG_KEYS.THEME_MODE);
if (themeMode) {
nativeTheme.themeSource = themeMode;
this._isDark = nativeTheme.shouldUseDarkColors;
}
this._setupIpcEvent();
logManager.info('ThemeService initialized successfully.');
}
async init() {
const themeMode = configManager.get(CONFIG_KEYS.THEME_MODE);
nativeTheme.themeSource = themeMode;
this._isDark = nativeTheme.shouldUseDarkColors;
logManager.info('ThemeService async init completed.');
}
private _setupIpcEvent() {
ipcMain.handle(IPC_EVENTS.SET_THEME_MODE, (_e, mode: ThemeMode) => {
nativeTheme.themeSource = mode;
@@ -25,7 +27,7 @@ class ThemeService {
});
ipcMain.handle(IPC_EVENTS.GET_THEME_MODE, () => {
return nativeTheme.themeSource;
return configManager.get(CONFIG_KEYS.THEME_MODE);
});
ipcMain.handle(IPC_EVENTS.IS_DARK_THEME, () => {

View File

@@ -29,24 +29,26 @@ const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const useCustomTitleBar = isWindows;
const SHARED_WINDOW_OPTIONS = {
frame: isMac || !useCustomTitleBar,
titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default',
trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
show: false,
title: 'NIANXX',
darkTheme: themeManager.isDark,
backgroundColor: themeManager.isDark ? '#2C2C2C' : '#FFFFFF',
webPreferences: {
nodeIntegration: false, // 禁用 Node.js 集成,提高安全性
contextIsolation: true, // 启用上下文隔离,防止渲染进程访问主进程 API
sandbox: true, // 启用沙箱模式,进一步增强安全性
backgroundThrottling: false,
preload: MAIN_WINDOW_VITE_DEV_SERVER_URL
? path.join(process.cwd(), 'dist-electron/preload/preload.js')
: path.join(__dirname, 'preload.js'),
},
} as BrowserWindowConstructorOptions;
function getSharedWindowOptions(): BrowserWindowConstructorOptions {
return {
frame: isMac || !useCustomTitleBar,
titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default',
trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
show: false,
title: 'NIANXX',
darkTheme: themeManager.isDark,
backgroundColor: themeManager.isDark ? '#2C2C2C' : '#FFFFFF',
webPreferences: {
nodeIntegration: false, // 禁用 Node.js 集成,提高安全性
contextIsolation: true, // 启用上下文隔离,防止渲染进程访问主进程 API
sandbox: true, // 启用沙箱模式,进一步增强安全性
backgroundThrottling: false,
preload: MAIN_WINDOW_VITE_DEV_SERVER_URL
? path.join(process.cwd(), 'dist-electron/preload/preload.js')
: path.join(__dirname, 'preload.js'),
},
};
}
class WindowService {
private static _instance: WindowService;
@@ -218,7 +220,7 @@ class WindowService {
return this._isHiddenWin(name)
? this._winStates[name].instance as BrowserWindow
: new BrowserWindow({
...SHARED_WINDOW_OPTIONS,
...getSharedWindowOptions(),
icon: this._logo,
...opts,
});

View File

@@ -1,6 +1,6 @@
import { createI18n, type I18n, type I18nOptions } from 'vue-i18n';
import { SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES, NAMESPACES, type LanguageCode } from './constants';
import { resolveSupportedLanguage, detectSystemLanguage } from './resolver';
import { resolveSupportedLanguage } from './resolver';
// 使用 import.meta.glob 动态加载所有语言文件
// 文件路径模式:./locales/{语言}/{命名空间}.json
@@ -34,32 +34,12 @@ function buildResources() {
return resources;
}
// 获取持久化的语言设置(稍后由 Pinia store 提供)
function getPersistedLanguage(): LanguageCode | null {
try {
const saved = localStorage.getItem('zn-language');
return saved && SUPPORTED_LANGUAGE_CODES.includes(saved as LanguageCode) ? saved as LanguageCode : null;
} catch {
return null;
}
}
// 确定初始语言:持久化设置 > 系统语言 > 默认中文
function determineInitialLocale(): LanguageCode {
const persisted = getPersistedLanguage();
if (persisted) return persisted;
const systemLang = detectSystemLanguage();
return systemLang;
}
async function createI18nInstance() {
const resources = buildResources();
const initialLocale = determineInitialLocale();
const options: I18nOptions = {
legacy: false,
locale: initialLocale,
locale: 'zh',
fallbackLocale: 'zh',
messages: resources, // 使用构建的资源对象
availableLocales: SUPPORTED_LANGUAGE_CODES,
@@ -85,11 +65,6 @@ export async function setLanguage(lang: LanguageCode, _i18n?: I18n) {
}
(__i18n.global.locale as unknown as { value: LanguageCode }).value = lang;
// 持久化到 localStorage稍后由 Pinia store 处理)
try {
localStorage.setItem('zn-language', lang);
} catch {}
}
export function getLanguage() {

View File

@@ -103,6 +103,7 @@ export enum CONFIG_KEYS {
AUTO_CHECK_UPDATE = 'autoCheckUpdate',
AUTO_DOWNLOAD_UPDATE = 'autoDownloadUpdate',
SELECTED_CHANNELS = 'selectedChannels',
IMAGE_CACHE = 'imageCache',
}
export enum MENU_IDS {

View File

@@ -20,6 +20,8 @@ export interface IConfig {
[CONFIG_KEYS.DEFAULT_MODEL]?: string | null;
// 选中的渠道
[CONFIG_KEYS.SELECTED_CHANNELS]: Array<{ id: string; channelName: string; channelUrl: string }>;
// 图片缓存
[CONFIG_KEYS.IMAGE_CACHE]: Array<[string, any]>;
}
export interface Provider {

View File

@@ -9,6 +9,7 @@ import { extractText, isToolOnlyMessage, isToolResultRole, isInternalMessage } f
import { hostApiFetch } from '@lib/host-api'
import { gatewayRpc, onGatewayEvent } from '@lib/gateway-client'
import { useProviderStore } from '@stores/providers'
import { IPC_EVENTS, CONFIG_KEYS } from '@lib/constants'
// ── Constants ───────────────────────────────────────────────────
const DEFAULT_SESSION_KEY = 'agent:main:main'
@@ -32,7 +33,6 @@ let _lastLoadSessionsAt = 0
const _historyLoadInFlight = new Map<string, Promise<void>>()
const _lastHistoryLoadAtBySession = new Map<string, number>()
const _chatEventDedupe = new Map<string, number>()
const IMAGE_CACHE_KEY = 'zn-ai:image-cache'
const IMAGE_CACHE_MAX = 100
// ── Helpers: Timers ─────────────────────────────────────────────
@@ -90,29 +90,35 @@ function isDuplicateChatEvent(eventState: string, event: Record<string, unknown>
}
// ── Helpers: Image Cache ────────────────────────────────────────
function loadImageCache(): Map<string, AttachedFileMeta> {
let _imageCache: Map<string, AttachedFileMeta> = new Map()
let _imageCacheInitialized = false
async function initImageCache(): Promise<void> {
if (_imageCacheInitialized) return
_imageCache = await loadImageCache()
_imageCacheInitialized = true
}
async function loadImageCache(): Promise<Map<string, AttachedFileMeta>> {
try {
const raw = localStorage.getItem(IMAGE_CACHE_KEY)
if (raw) {
const entries = JSON.parse(raw) as Array<[string, AttachedFileMeta]>
return new Map(entries)
const raw = await window.api.invoke(IPC_EVENTS.GET_CONFIG, CONFIG_KEYS.IMAGE_CACHE)
if (Array.isArray(raw)) {
return new Map(raw as Array<[string, AttachedFileMeta]>)
}
} catch { /* ignore */ }
return new Map()
}
function saveImageCache(cache: Map<string, AttachedFileMeta>): void {
async function saveImageCache(cache: Map<string, AttachedFileMeta>): Promise<void> {
try {
const entries = Array.from(cache.entries())
const trimmed = entries.length > IMAGE_CACHE_MAX
? entries.slice(entries.length - IMAGE_CACHE_MAX)
: entries
localStorage.setItem(IMAGE_CACHE_KEY, JSON.stringify(trimmed))
await window.api.invoke(IPC_EVENTS.SET_CONFIG, CONFIG_KEYS.IMAGE_CACHE, trimmed)
} catch { /* ignore */ }
}
const _imageCache = loadImageCache()
// ── Helpers: Timestamp ──────────────────────────────────────────
function toMs(ts: number): number {
return ts < 1e12 ? ts * 1000 : ts
@@ -918,6 +924,7 @@ export const useChatStore = defineStore('chat', {
try {
// Cache image attachments
if (attachments && attachments.length > 0) {
await initImageCache()
for (const a of attachments) {
_imageCache.set(a.stagedPath, {
fileName: a.fileName,
@@ -926,7 +933,7 @@ export const useChatStore = defineStore('chat', {
preview: a.preview,
})
}
saveImageCache(_imageCache)
await saveImageCache(_imageCache)
}
let messageContent = trimmed

View File

@@ -2,9 +2,7 @@ import { defineStore } from 'pinia';
import { i18n, setLanguage, getLanguage, type LanguageCode } from '@src/i18n';
import { SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_CODES } from '@src/i18n/constants';
import { resolveSupportedLanguage, detectSystemLanguage } from '@src/i18n/resolver';
// 持久化键
const STORAGE_KEY = 'zn-language';
import { IPC_EVENTS, CONFIG_KEYS } from '@lib/constants';
interface LocaleState {
language: LanguageCode;
@@ -37,8 +35,8 @@ export const useLocaleStore = defineStore('locale', {
if (this.initialized) return;
try {
// 1. 尝试从 localStorage 读取持久化设置
const saved = localStorage.getItem(STORAGE_KEY);
// 1. 尝试从 electron-store 读取持久化设置
const saved = await window.api.invoke(IPC_EVENTS.GET_CONFIG, CONFIG_KEYS.LANGUAGE);
let lang: LanguageCode = 'zh';
if (saved && SUPPORTED_LANGUAGE_CODES.includes(saved as LanguageCode)) {
@@ -63,12 +61,12 @@ export const useLocaleStore = defineStore('locale', {
/**
* 设置语言
* @param language 目标语言代码
* @param persist 是否持久化到 localStorage默认为 true
* @param persist 是否持久化到 electron-store默认为 true
*/
async setLanguage(language: LanguageCode, persist: boolean = true) {
// 验证语言代码有效性
const resolvedLang = resolveSupportedLanguage(language);
if (resolvedLang === this.language) {
return; // 语言未变化
}
@@ -80,14 +78,14 @@ export const useLocaleStore = defineStore('locale', {
// 2. 更新 store 状态
this.language = resolvedLang;
// 3. 持久化到 localStorage
// 3. 持久化到 electron-store
if (persist) {
localStorage.setItem(STORAGE_KEY, resolvedLang);
await window.api.invoke(IPC_EVENTS.SET_CONFIG, CONFIG_KEYS.LANGUAGE, resolvedLang);
}
// 4. 触发语言变化事件(供其他组件监听)
window.dispatchEvent(new CustomEvent('language-changed', {
detail: { language: resolvedLang }
window.dispatchEvent(new CustomEvent('language-changed', {
detail: { language: resolvedLang }
}));
} catch (error) {
console.error('Failed to set language:', error);

View File

@@ -42,18 +42,12 @@ const applyThemeToDom = (theme: 'light' | 'dark') => {
};
export const useThemeStore = defineStore('zn-ai-theme', {
state: (): ThemeState => {
// 从 localStorage 恢复缓存的主题,确保在初始化前 UI 不会闪烁
const cachedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('zn-ai-theme-cache') as Theme : null;
const initialTheme = (cachedTheme === 'light' || cachedTheme === 'dark' || cachedTheme === 'system') ? cachedTheme : 'system';
return {
theme: initialTheme,
isDark: false,
systemTheme: 'light',
initialized: false,
};
},
state: (): ThemeState => ({
theme: 'system',
isDark: false,
systemTheme: 'light',
initialized: false,
}),
actions: {
async init() {
@@ -83,12 +77,7 @@ export const useThemeStore = defineStore('zn-ai-theme', {
this.isDark = isDark;
this.systemTheme = systemTheme;
this.initialized = true;
// 缓存到 localStorage
if (typeof localStorage !== 'undefined') {
localStorage.setItem('zn-ai-theme-cache', savedTheme);
}
// 6. 监听系统主题变化
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
@@ -159,12 +148,7 @@ export const useThemeStore = defineStore('zn-ai-theme', {
// 4. 更新 store 状态
this.theme = theme;
this.isDark = isDark;
// 5. 缓存到 localStorage
if (typeof localStorage !== 'undefined') {
localStorage.setItem('zn-ai-theme-cache', theme);
}
console.log('Theme changed:', { theme, appliedTheme, isDark });
} catch (error) {
console.error('Failed to set theme:', error);

View File

@@ -37,52 +37,9 @@ const sessionCache = {
}
}
const localCache = {
set (key: string, value: string) {
if (!localStorage) {
return
}
if (key != null && value != null) {
localStorage.setItem(key, value)
}
},
get (key: string) {
if (!localStorage) {
return null
}
if (key == null) {
return null
}
return localStorage.getItem(key)
},
setJSON (key: string, jsonValue: any) {
if (jsonValue != null) {
this.set(key, JSON.stringify(jsonValue))
}
},
getJSON (key: string) {
const value = this.get(key)
if (value != null) {
return JSON.parse(value)
}
return null
},
remove (key: string) {
localStorage.removeItem(key)
}
}
export default {
/**
* 会话级缓存
*/
session: sessionCache,
/**
* 本地缓存
*/
local: localCache
session: sessionCache
}

View File

@@ -1,41 +1,5 @@
import Cookies from 'js-cookie';
/**
* window.localStorage 浏览器永久缓存
* @method set 设置永久缓存
* @method get 获取永久缓存
* @method remove 移除永久缓存
* @method clear 移除全部永久缓存
*/
export const Local = {
// 查看 v2.4.3版本更新日志
setKey(key: string) {
// @ts-ignore
return `${__NEXT_NAME__}:${key}`;
},
// 设置永久缓存
set<T>(key: string, val: T) {
window.localStorage.setItem(Local.setKey(key), JSON.stringify(val));
},
// 获取永久缓存
get(key: string) {
let json = <string>window.localStorage.getItem(Local.setKey(key));
return JSON.parse(json);
},
// 移除永久缓存
remove(key: string) {
window.localStorage.removeItem(Local.setKey(key));
},
// 移除全部永久缓存
clear() {
window.localStorage.clear();
},
};
/**
* window.sessionStorage 浏览器临时缓存
* @method set 设置临时缓存
@@ -81,8 +45,4 @@ export const Session = {
return this.get('token');
},
// 获取当前的租户
getTenant() {
return Local.get('tenantId') ? Local.get('tenantId') : 1;
},
};