diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..484a647 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# 变更日志(CHANGELOG) + +## 2025-08-21 +本次为次版本更新,增加局域网部署体验,新增应用内设置能力与多语言完善。 + +- [Added] + - 应用内“设置”页:支持读取/保存配置并“一键保存并重启”。 + - 路由:`/settings`(`src/renderer/src/views/settings/index.vue`) + - 预加载桥接:`window.settings.get/save/restart`(`src/preload/index.js`) + - 主进程 IPC:`settings/get`、`settings/save`、`settings/restart`(`src/main/service/settings.js`) + - 持久化配置:从 `app.getPath('userData')/config.json` 读取/写入;优先级:config.json > 环境变量 > 默认值(`src/main/config/config.js`)。 + - 顶部“设置”按钮整合“应用设置”入口,直达“设置”页(`src/renderer/src/components/AppHeader.vue`)。 + - i18n 补齐:`common.menu.settings`、`common.setting.appSettingsText`(`src/renderer/src/i18n/components/common.js`)。 + +- [Changed] + - 默认 LAN 配置更新(Windows UNC 路径已正确转义): + - `HEYGEM_HOST=192.168.110.22` + - `HEYGEM_TTS_PORT=18180`,`HEYGEM_VIDEO_PORT=8383` + - `HEYGEM_MODEL_DIR=\\\\192.168.110.22\\heygem_data\\face2face\\temp` + - `HEYGEM_TTS_PRODUCT_DIR=\\\\192.168.110.22\\heygem_data\\face2face\\temp` + - `HEYGEM_TTS_ROOT=\\\\192.168.110.22\\heygem_data\\voice\\data` + - `HEYGEM_TTS_TRAIN_DIR=\\\\192.168.110.22\\heygem_data\\voice\\data\\origin_audio` + - 侧边栏菜单仅保留“首页”,移除“设置”入口(`src/renderer/src/components/menuLIst.vue`),避免重复入口。 + - 菜单文案改为 `t(item.key)` 渲染,语言切换即时生效(`menuLIst.vue`)。 + +- [Fixed] + - 修复 UNC 默认路径字符串转义错误导致的语法/打包告警(`src/main/config/config.js`)。 + - 多语言缺失键导致显示 key 文本的问题(`common.js`)。 + +- [Deployment Notes] + - 首次安装或升级后,建议进入“应用设置”保存参数并自动重启,以写入 `config.json`。 + - 局域网部署请确保 SMB 共享与读写权限:`\\192.168.110.22\heygem_data\...`。 + - 根据实际需求修改`192.168.110.22`为实际IP地址。 + +- [Upgrade Guide] + 1) 卸载旧版或直接覆盖安装。 + 2) 打开应用 → 右上角“设置” → “应用设置” → 填写并保存 → 自动重启生效。 + 3) 多人环境:确认各客户端均能访问 SMB 共享与服务端口。 diff --git a/src/main/config/config.js b/src/main/config/config.js index 8037bfb..40ac6a2 100644 --- a/src/main/config/config.js +++ b/src/main/config/config.js @@ -1,25 +1,70 @@ import path from 'path' import os from 'os' +import fs from 'fs' +import { app } from 'electron' const isDev = process.env.NODE_ENV === 'development' const isWin = process.platform === 'win32' +function loadUserConfig() { + try { + // Electron user-specific config dir + const userDataDir = app && app.getPath ? app.getPath('userData') : null + let cfgPath + if (userDataDir) { + cfgPath = path.join(userDataDir, 'config.json') + } else { + // Fallback for non-Electron contexts + const fallbackBase = isWin + ? path.join(os.homedir(), 'AppData', 'Roaming', 'HeyGem') + : path.join(os.homedir(), '.config', 'HeyGem') + cfgPath = path.join(fallbackBase, 'config.json') + } + if (fs.existsSync(cfgPath)) { + const raw = fs.readFileSync(cfgPath, 'utf-8') + return JSON.parse(raw) + } + } catch (e) { + // ignore parse or fs errors and fallback to env/defaults + } + return {} +} + +const CFG = loadUserConfig() + +// Allow overriding service host/ports via environment variables for LAN deployment +// HEYGEM_HOST: target host or IP (e.g., your LAN IP like 192.168.1.100) +// HEYGEM_TTS_PORT: TTS service host port (default 18180) +// HEYGEM_VIDEO_PORT: Face2Face service host port (default 8383) +const HEYGEM_HOST = CFG.HEYGEM_HOST || process.env.HEYGEM_HOST || '192.168.110.22' +const HEYGEM_TTS_PORT = CFG.HEYGEM_TTS_PORT || process.env.HEYGEM_TTS_PORT || '18180' +const HEYGEM_VIDEO_PORT = CFG.HEYGEM_VIDEO_PORT || process.env.HEYGEM_VIDEO_PORT || '8383' + export const serviceUrl = { - face2face: isDev ? 'http://192.168.4.204:8383/easy' : 'http://127.0.0.1:8383/easy', - tts: isDev ? 'http://192.168.4.204:18180' : 'http://127.0.0.1:18180' + face2face: `http://${HEYGEM_HOST}:${HEYGEM_VIDEO_PORT}/easy`, + tts: `http://${HEYGEM_HOST}:${HEYGEM_TTS_PORT}` } export const assetPath = { - model: isWin - ? path.join('D:', 'heygem_data', 'face2face', 'temp') - : path.join(os.homedir(), 'heygem_data', 'face2face', 'temp'), // 模特视频 - ttsProduct: isWin - ? path.join('D:', 'heygem_data', 'face2face', 'temp') - : path.join(os.homedir(), 'heygem_data', 'face2face', 'temp'), // TTS 产物 - ttsRoot: isWin - ? path.join('D:', 'heygem_data', 'voice', 'data') - : path.join(os.homedir(), 'heygem_data', 'voice', 'data'), // TTS服务根目录 - ttsTrain: isWin - ? path.join('D:', 'heygem_data', 'voice', 'data', 'origin_audio') - : path.join(os.homedir(), 'heygem_data', 'voice', 'data', 'origin_audio') // TTS 训练产物 + // Allow overriding asset directories to support LAN shared folders + model: + CFG.HEYGEM_MODEL_DIR || process.env.HEYGEM_MODEL_DIR || + (isWin + ? '\\\\192.168.110.22\\heygem_data\\face2face\\temp' + : path.join(os.homedir(), 'heygem_data', 'face2face', 'temp')), // 模特视频 + ttsProduct: + CFG.HEYGEM_TTS_PRODUCT_DIR || process.env.HEYGEM_TTS_PRODUCT_DIR || + (isWin + ? '\\\\192.168.110.22\\heygem_data\\face2face\\temp' + : path.join(os.homedir(), 'heygem_data', 'face2face', 'temp')), // TTS 产物 + ttsRoot: + CFG.HEYGEM_TTS_ROOT || process.env.HEYGEM_TTS_ROOT || + (isWin + ? '\\\\192.168.110.22\\heygem_data\\voice\\data' + : path.join(os.homedir(), 'heygem_data', 'voice', 'data')), // TTS服务根目录 + ttsTrain: + CFG.HEYGEM_TTS_TRAIN_DIR || process.env.HEYGEM_TTS_TRAIN_DIR || + (isWin + ? '\\\\192.168.110.22\\heygem_data\\voice\\data\\origin_audio' + : path.join(os.homedir(), 'heygem_data', 'voice', 'data', 'origin_audio')) // TTS 训练产物 } diff --git a/src/main/service/index.js b/src/main/service/index.js index 83133bb..2375d61 100644 --- a/src/main/service/index.js +++ b/src/main/service/index.js @@ -2,9 +2,11 @@ import { init as videoResult } from './video.js' import { init as model } from './model.js' import { init as context } from './context.js' import { init as voice } from './voice.js' +import { init as settings } from './settings.js' export function registerHandler() { videoResult() model() context() voice() + settings() } diff --git a/src/main/service/settings.js b/src/main/service/settings.js new file mode 100644 index 0000000..e4b5e4d --- /dev/null +++ b/src/main/service/settings.js @@ -0,0 +1,54 @@ +import { ipcMain, app } from 'electron' +import fs from 'fs' +import path from 'path' + +const MODEL_NAME = 'settings' + +function getConfigPath() { + const userDataDir = app.getPath('userData') + return path.join(userDataDir, 'config.json') +} + +function ensureDirExists(filePath) { + const dir = path.dirname(filePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } +} + +function readConfig() { + const cfgPath = getConfigPath() + try { + if (fs.existsSync(cfgPath)) { + const raw = fs.readFileSync(cfgPath, 'utf-8') + return JSON.parse(raw) + } + } catch (e) { + // ignore and return empty + } + return {} +} + +function writeConfig(cfg) { + const cfgPath = getConfigPath() + ensureDirExists(cfgPath) + fs.writeFileSync(cfgPath, JSON.stringify(cfg ?? {}, null, 2), 'utf-8') + return true +} + +function restartApp() { + app.relaunch() + app.exit(0) +} + +export function init() { + ipcMain.handle(MODEL_NAME + '/get', () => { + return readConfig() + }) + ipcMain.handle(MODEL_NAME + '/save', (event, cfg) => { + return writeConfig(cfg) + }) + ipcMain.handle(MODEL_NAME + '/restart', () => { + restartApp() + }) +} diff --git a/src/main/service/voice.js b/src/main/service/voice.js index 69478f8..16d22f7 100644 --- a/src/main/service/voice.js +++ b/src/main/service/voice.js @@ -22,12 +22,23 @@ export async function train(path, lang = 'zh') { lang }) log.debug('~ train ~ res:', res) + // Ensure response is valid + if (!res || typeof res !== 'object') { + throw new Error('TTS preprocess failed: empty response') + } if (res.code !== 0) { - return false - } else { - const { asr_format_audio_url, reference_audio_text } = res - return insert({ origin_audio_path: path, lang, asr_format_audio_url, reference_audio_text }) + const msg = res.message || res.msg || 'unknown error' + throw new Error(`TTS preprocess failed: code=${res.code}, message=${msg}`) + } + const { asr_format_audio_url, reference_audio_text } = res + if (!asr_format_audio_url || !reference_audio_text) { + throw new Error('TTS preprocess failed: missing required fields asr_format_audio_url/reference_audio_text') + } + const voiceId = insert({ origin_audio_path: path, lang, asr_format_audio_url, reference_audio_text }) + if (typeof voiceId !== 'number' && typeof voiceId !== 'bigint') { + throw new Error('Insert voice failed: invalid voiceId returned') } + return voiceId } export function makeAudio4Video({voiceId, text}) { diff --git a/src/preload/index.js b/src/preload/index.js index f79a7cc..e39fc6c 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -5,6 +5,11 @@ import { exposeWebHandles } from '../main/handlers/index' // Custom APIs for renderer const client = exposeWebHandles(electronAPI) +const settingsAPI = { + get: () => electronAPI.ipcRenderer.invoke('settings/get'), + save: (cfg) => electronAPI.ipcRenderer.invoke('settings/save', cfg), + restart: () => electronAPI.ipcRenderer.invoke('settings/restart') +} // Use `contextBridge` APIs to expose Electron APIs to // renderer only if context isolation is enabled, otherwise @@ -13,10 +18,12 @@ if (process.contextIsolated) { try { contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('client', client) + contextBridge.exposeInMainWorld('settings', settingsAPI) } catch (error) { console.error(error) } } else { window.electron = electronAPI window.client = client + window.settings = settingsAPI } diff --git a/src/renderer/src/api/index.js b/src/renderer/src/api/index.js index a492688..d33ac75 100644 --- a/src/renderer/src/api/index.js +++ b/src/renderer/src/api/index.js @@ -64,3 +64,16 @@ export function saveContext(key, val) { export function audition(voiceId, text) { return window.electron.ipcRenderer.invoke('voice/audition', voiceId, text) } + +// Settings wrappers +export function getSettings() { + return window.settings.get() +} + +export function saveSettings(cfg) { + return window.settings.save(cfg) +} + +export function restartApp() { + return window.settings.restart() +} diff --git a/src/renderer/src/components/AppHeader.vue b/src/renderer/src/components/AppHeader.vue index 5ccf878..e9122a5 100644 --- a/src/renderer/src/components/AppHeader.vue +++ b/src/renderer/src/components/AppHeader.vue @@ -55,8 +55,10 @@ import { useHomeStore } from '@renderer/stores/home.js' import { useI18n } from 'vue-i18n' import { saveContext } from '@renderer/api/index.js' import { lang_ } from '@renderer/utils/const.js' +import { useRouter } from 'vue-router' const { locale, t } = useI18n() const home = useHomeStore() +const router = useRouter() const state = reactive({ isMaximized: false, menuList: [ @@ -70,6 +72,11 @@ const state = reactive({ key: 'common.setting.tab.openLogText', value: 'openLog' }, + { + content: '应用设置', + key: 'common.setting.appSettingsText', + value: 'appSettings' + }, { content: '语言切换', value: 'languageSwitch', @@ -117,6 +124,8 @@ const action = { home.setAgreementVisible(true) } else if (value === 'openLog') { Client.app.openLog() + } else if (value === 'appSettings') { + router.push('/settings') } else { if (value === 'languageSwitch') return window.localStorage.setItem('language', value) diff --git a/src/renderer/src/components/menuLIst.vue b/src/renderer/src/components/menuLIst.vue index a87314a..f5448be 100644 --- a/src/renderer/src/components/menuLIst.vue +++ b/src/renderer/src/components/menuLIst.vue @@ -4,7 +4,7 @@