mirror of
https://github.com/xxnuo/MTranServer.git
synced 2026-09-03 06:35:20 +08:00
feat(desktop): init
This commit is contained in:
59
.github/workflows/build.yml
vendored
59
.github/workflows/build.yml
vendored
@@ -42,8 +42,44 @@ jobs:
|
||||
name: binaries
|
||||
path: dist/mtranserver-*
|
||||
|
||||
build_desktop:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Sync version from tag
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
else
|
||||
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||
fi
|
||||
echo "Syncing version to ${VERSION}"
|
||||
bun run bump ${VERSION}
|
||||
|
||||
- name: Build Desktop
|
||||
run: bun run build:electron
|
||||
|
||||
- name: Upload Desktop Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.os }}
|
||||
path: release/mtranserver-desktop-*
|
||||
|
||||
release:
|
||||
needs: build
|
||||
needs: [build, build_desktop]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -57,6 +93,13 @@ jobs:
|
||||
name: binaries
|
||||
path: release-assets
|
||||
|
||||
- name: Download Desktop Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: desktop-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Determine Release Tag
|
||||
id: tag
|
||||
run: |
|
||||
@@ -105,6 +148,20 @@ jobs:
|
||||
| Windows | x64 | [mtranserver-${VERSION}-windows-amd64.exe](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-windows-amd64.exe) |
|
||||
| Windows | x64 (legacy) | [mtranserver-${VERSION}-windows-amd64-legacy.exe](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-${VERSION}-windows-amd64-legacy.exe) |
|
||||
|
||||
## Desktop Client
|
||||
|
||||
| Platform | Architecture | Binary |
|
||||
|----------|-------------|--------|
|
||||
| Linux | x64 | [mtranserver-desktop-${VERSION}-linux-x64.AppImage](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-x64.AppImage) |
|
||||
| Linux | arm64 | [mtranserver-desktop-${VERSION}-linux-arm64.AppImage](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-arm64.AppImage) |
|
||||
| Linux | x64 (deb) | [mtranserver-desktop-${VERSION}-linux-x64.deb](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-x64.deb) |
|
||||
| Linux | arm64 (deb) | [mtranserver-desktop-${VERSION}-linux-arm64.deb](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-arm64.deb) |
|
||||
| Linux | x64 (rpm) | [mtranserver-desktop-${VERSION}-linux-x64.rpm](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-x64.rpm) |
|
||||
| Linux | arm64 (rpm) | [mtranserver-desktop-${VERSION}-linux-arm64.rpm](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-linux-arm64.rpm) |
|
||||
| macOS | x64 | [mtranserver-desktop-${VERSION}-mac-x64.dmg](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-mac-x64.dmg) |
|
||||
| macOS | arm64 | [mtranserver-desktop-${VERSION}-mac-arm64.dmg](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-mac-arm64.dmg) |
|
||||
| Windows | x64 | [mtranserver-desktop-${VERSION}-win-x64.exe](https://github.com/${REPO}/releases/download/${TAG}/mtranserver-desktop-${VERSION}-win-x64.exe) |
|
||||
|
||||
## Note
|
||||
|
||||
**legacy**: 仅适用于使用普通版本会遇到如 'Illegal instruction' 这样问题的旧硬件(无 AVX2 指令集的 CPU),大多数人不需要这个,因为它更慢。
|
||||
|
||||
268
desktop/app.js
Normal file
268
desktop/app.js
Normal file
@@ -0,0 +1,268 @@
|
||||
import { app, dialog, nativeImage, shell, Menu } from 'electron';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { loadDesktopConfig, saveDesktopConfig, resetDesktopConfig } from './config.js';
|
||||
import { resolveLocale, getMessages } from './i18n.js';
|
||||
import { getFreePort, isPortAvailable } from './ports.js';
|
||||
import { getServerStatus, startServerWithConfig, stopServerInstance } from './server.js';
|
||||
import {
|
||||
createMainWindow,
|
||||
createSettingsWindow,
|
||||
showMainWindow,
|
||||
showSettingsWindow,
|
||||
updateWindowUrls
|
||||
} from './windows.js';
|
||||
import { createTray, updateTrayMenu } from './tray.js';
|
||||
import { registerIpcHandlers } from './ipc.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
let desktopConfig = null;
|
||||
let locale = 'en';
|
||||
let messages = getMessages(locale);
|
||||
let tray = null;
|
||||
const repoUrl = 'https://github.com/xxnuo/MTranServer';
|
||||
let portCheckPromise = null;
|
||||
|
||||
function getLocalHost(host) {
|
||||
if (!host || host === '0.0.0.0') return '127.0.0.1';
|
||||
return host;
|
||||
}
|
||||
|
||||
function getUiUrl(server) {
|
||||
return `http://${getLocalHost(server.host)}:${server.port}/ui/`;
|
||||
}
|
||||
|
||||
function getDocsUrl(server) {
|
||||
return `http://${getLocalHost(server.host)}:${server.port}/docs/`;
|
||||
}
|
||||
|
||||
function getSettingsUrl(server) {
|
||||
return `http://${getLocalHost(server.host)}:${server.port}/ui/settings`;
|
||||
}
|
||||
|
||||
function getStatusLabel() {
|
||||
const status = getServerStatus();
|
||||
if (status === 'running') {
|
||||
return `${messages.trayServiceRunning} (${desktopConfig.server.port})`;
|
||||
}
|
||||
if (status === 'starting') return messages.trayServiceRunning;
|
||||
return messages.trayServiceStopped;
|
||||
}
|
||||
|
||||
function updateTray() {
|
||||
if (!tray) return;
|
||||
updateTrayMenu({
|
||||
messages,
|
||||
statusLabel: getStatusLabel(),
|
||||
versionLabel: app.getVersion(),
|
||||
onOpenMain: showMainWindow,
|
||||
onOpenBrowserUi: () => shell.openExternal(getUiUrl(desktopConfig.server)),
|
||||
onOpenBrowserDocs: () => shell.openExternal(getDocsUrl(desktopConfig.server)),
|
||||
onOpenRepo: () => shell.openExternal(repoUrl),
|
||||
onOpenSettings: showSettingsWindow,
|
||||
onRestart: restartServer,
|
||||
onOpenModels: () => shell.openPath(desktopConfig.server.modelDir),
|
||||
onOpenConfig: () => shell.openPath(desktopConfig.server.configDir),
|
||||
onQuit: quitApp
|
||||
});
|
||||
}
|
||||
|
||||
async function ensurePortAvailable() {
|
||||
if (portCheckPromise) return portCheckPromise;
|
||||
portCheckPromise = (async () => {
|
||||
const host = desktopConfig.server.host || '0.0.0.0';
|
||||
const available = await isPortAvailable(desktopConfig.server.port, host);
|
||||
if (available) return true;
|
||||
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
buttons: [messages.portInUseUseRandom, messages.portInUseQuit],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
message: messages.portInUseTitle,
|
||||
detail: messages.portInUseDetail.replace('{port}', String(desktopConfig.server.port))
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
const newPort = await getFreePort();
|
||||
desktopConfig.server.port = newPort;
|
||||
desktopConfig = await saveDesktopConfig(desktopConfig);
|
||||
return true;
|
||||
}
|
||||
await quitApp();
|
||||
return false;
|
||||
})();
|
||||
try {
|
||||
return await portCheckPromise;
|
||||
} finally {
|
||||
portCheckPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const ok = await ensurePortAvailable();
|
||||
if (!ok) return false;
|
||||
try {
|
||||
await startServerWithConfig(desktopConfig.server);
|
||||
return true;
|
||||
} catch {
|
||||
dialog.showMessageBox({
|
||||
type: 'error',
|
||||
message: messages.serverStartFailed,
|
||||
detail: messages.serverStartFailedDetail
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
try {
|
||||
await stopServerInstance();
|
||||
const ok = await startServer();
|
||||
if (!ok) return false;
|
||||
updateWindowUrls({
|
||||
mainUrl: getUiUrl(desktopConfig.server),
|
||||
settingsUrl: getSettingsUrl(desktopConfig.server)
|
||||
});
|
||||
updateTray();
|
||||
return true;
|
||||
} catch {
|
||||
dialog.showMessageBox({
|
||||
type: 'error',
|
||||
message: messages.serverRestartFailed
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function quitApp() {
|
||||
app.isQuitting = true;
|
||||
await stopServerInstance();
|
||||
app.quit();
|
||||
}
|
||||
|
||||
function updateLocale(nextLocale) {
|
||||
locale = resolveLocale(nextLocale, app.getLocale());
|
||||
messages = getMessages(locale);
|
||||
updateTray();
|
||||
}
|
||||
|
||||
function getLoadingUrl() {
|
||||
const html = `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${messages.appName}</title>
|
||||
<style>
|
||||
html,body{margin:0;height:100%;font-family:system-ui,Segoe UI,Arial,sans-serif;background:#f5f5f5;color:#1f2937}
|
||||
.wrap{height:100%;display:flex;align-items:center;justify-content:center}
|
||||
.card{padding:24px 32px;border:1px solid #e5e7eb;border-radius:16px;background:#fff;box-shadow:0 12px 30px rgba(0,0,0,0.08);text-align:center;min-width:260px}
|
||||
.title{font-size:18px;font-weight:600;margin-bottom:8px}
|
||||
.desc{font-size:14px;color:#6b7280}
|
||||
.dot{display:inline-block;animation:blink 1.2s infinite}
|
||||
@keyframes blink{0%,100%{opacity:.2}50%{opacity:1}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<div class="title">${messages.appName}</div>
|
||||
<div class="desc">${messages.trayServiceStatus}: ${messages.trayServiceRunning} <span class="dot">...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
|
||||
}
|
||||
|
||||
export async function startDesktop() {
|
||||
app.isQuitting = false;
|
||||
Menu.setApplicationMenu(null);
|
||||
desktopConfig = await loadDesktopConfig();
|
||||
updateLocale(desktopConfig.locale);
|
||||
|
||||
const preloadPath = path.join(__dirname, 'preload.cjs');
|
||||
const loadingUrl = getLoadingUrl();
|
||||
const mainWindow = createMainWindow({ url: loadingUrl, preload: preloadPath });
|
||||
createSettingsWindow({ url: loadingUrl, preload: preloadPath, parent: mainWindow });
|
||||
mainWindow.once('ready-to-show', () => mainWindow.show());
|
||||
|
||||
const iconPath = path.join(__dirname, '..', 'images', 'icons', 'icon@16px.png');
|
||||
const trayIcon = nativeImage.createFromPath(iconPath);
|
||||
tray = createTray({
|
||||
icon: trayIcon,
|
||||
tooltip: messages.appName,
|
||||
messages,
|
||||
statusLabel: getStatusLabel(),
|
||||
versionLabel: app.getVersion(),
|
||||
onOpenMain: showMainWindow,
|
||||
onOpenBrowserUi: () => shell.openExternal(getUiUrl(desktopConfig.server)),
|
||||
onOpenBrowserDocs: () => shell.openExternal(getDocsUrl(desktopConfig.server)),
|
||||
onOpenRepo: () => shell.openExternal(repoUrl),
|
||||
onOpenSettings: showSettingsWindow,
|
||||
onRestart: restartServer,
|
||||
onOpenModels: () => shell.openPath(desktopConfig.server.modelDir),
|
||||
onOpenConfig: () => shell.openPath(desktopConfig.server.configDir),
|
||||
onQuit: quitApp
|
||||
});
|
||||
|
||||
const started = await startServer();
|
||||
if (!started) {
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
updateWindowUrls({
|
||||
mainUrl: getUiUrl(desktopConfig.server),
|
||||
settingsUrl: getSettingsUrl(desktopConfig.server)
|
||||
});
|
||||
|
||||
registerIpcHandlers({
|
||||
getConfig: async () => ({
|
||||
config: desktopConfig,
|
||||
status: getServerStatus(),
|
||||
version: app.getVersion()
|
||||
}),
|
||||
applyConfig: async (config) => {
|
||||
desktopConfig = await saveDesktopConfig(config);
|
||||
updateLocale(desktopConfig.locale);
|
||||
const ok = await restartServer();
|
||||
if (!ok) return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
|
||||
return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
|
||||
},
|
||||
resetConfig: async () => {
|
||||
desktopConfig = await resetDesktopConfig();
|
||||
updateLocale(desktopConfig.locale);
|
||||
const ok = await restartServer();
|
||||
if (!ok) return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
|
||||
return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
|
||||
},
|
||||
restartServer: async () => {
|
||||
const ok = await restartServer();
|
||||
if (!ok) return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
|
||||
return { config: desktopConfig, status: getServerStatus(), version: app.getVersion() };
|
||||
},
|
||||
getStatus: async () => ({ status: getServerStatus() }),
|
||||
openExternal: async (url) => shell.openExternal(url),
|
||||
openPath: async (targetPath) => shell.openPath(targetPath)
|
||||
});
|
||||
|
||||
app.on('activate', () => showMainWindow());
|
||||
app.on('before-quit', (event) => {
|
||||
if (app.isQuitting) return;
|
||||
event.preventDefault();
|
||||
quitApp();
|
||||
});
|
||||
app.on('window-all-closed', (event) => event.preventDefault());
|
||||
|
||||
updateTray();
|
||||
}
|
||||
|
||||
export function focusMainWindow() {
|
||||
showMainWindow();
|
||||
}
|
||||
109
desktop/config.js
Normal file
109
desktop/config.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import fs from 'fs';
|
||||
import fsPromises from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import YAML from 'yaml';
|
||||
|
||||
const configRoot = path.join(os.homedir(), '.config', 'mtran');
|
||||
const serverConfigDir = path.join(configRoot, 'server');
|
||||
const desktopConfigPath = path.join(serverConfigDir, 'desktop.yml');
|
||||
|
||||
function getDefaultModelDir() {
|
||||
const localModelsDir = path.join(process.cwd(), 'models');
|
||||
if (fs.existsSync(localModelsDir)) {
|
||||
return localModelsDir;
|
||||
}
|
||||
return path.join(configRoot, 'models');
|
||||
}
|
||||
|
||||
export function getDesktopConfigPath() {
|
||||
return desktopConfigPath;
|
||||
}
|
||||
|
||||
export function getDesktopConfigDir() {
|
||||
return serverConfigDir;
|
||||
}
|
||||
|
||||
export function getDefaultDesktopConfig() {
|
||||
return {
|
||||
locale: 'system',
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 8989,
|
||||
logLevel: 'warn',
|
||||
enableWebUI: true,
|
||||
enableOfflineMode: false,
|
||||
workerIdleTimeout: 60,
|
||||
workersPerLanguage: 1,
|
||||
apiToken: '',
|
||||
logDir: path.join(configRoot, 'logs'),
|
||||
logToFile: false,
|
||||
logConsole: true,
|
||||
logRequests: false,
|
||||
maxLengthBreak: 128,
|
||||
checkUpdate: true,
|
||||
cacheSize: 1000,
|
||||
modelDir: getDefaultModelDir(),
|
||||
configDir: serverConfigDir
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConfig(input) {
|
||||
const defaults = getDefaultDesktopConfig();
|
||||
const server = input?.server || {};
|
||||
return {
|
||||
locale: input?.locale || defaults.locale,
|
||||
server: {
|
||||
host: server.host ?? defaults.server.host,
|
||||
port: Number.isFinite(Number(server.port)) ? Number(server.port) : defaults.server.port,
|
||||
logLevel: server.logLevel ?? defaults.server.logLevel,
|
||||
enableWebUI: typeof server.enableWebUI === 'boolean' ? server.enableWebUI : defaults.server.enableWebUI,
|
||||
enableOfflineMode: typeof server.enableOfflineMode === 'boolean' ? server.enableOfflineMode : defaults.server.enableOfflineMode,
|
||||
workerIdleTimeout: Number.isFinite(Number(server.workerIdleTimeout))
|
||||
? Number(server.workerIdleTimeout)
|
||||
: defaults.server.workerIdleTimeout,
|
||||
workersPerLanguage: Number.isFinite(Number(server.workersPerLanguage))
|
||||
? Number(server.workersPerLanguage)
|
||||
: defaults.server.workersPerLanguage,
|
||||
apiToken: server.apiToken ?? defaults.server.apiToken,
|
||||
logDir: server.logDir ?? defaults.server.logDir,
|
||||
logToFile: typeof server.logToFile === 'boolean' ? server.logToFile : defaults.server.logToFile,
|
||||
logConsole: typeof server.logConsole === 'boolean' ? server.logConsole : defaults.server.logConsole,
|
||||
logRequests: typeof server.logRequests === 'boolean' ? server.logRequests : defaults.server.logRequests,
|
||||
maxLengthBreak: Number.isFinite(Number(server.maxLengthBreak))
|
||||
? Number(server.maxLengthBreak)
|
||||
: defaults.server.maxLengthBreak,
|
||||
checkUpdate: typeof server.checkUpdate === 'boolean' ? server.checkUpdate : defaults.server.checkUpdate,
|
||||
cacheSize: Number.isFinite(Number(server.cacheSize)) ? Number(server.cacheSize) : defaults.server.cacheSize,
|
||||
modelDir: server.modelDir ?? defaults.server.modelDir,
|
||||
configDir: server.configDir ?? defaults.server.configDir
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDesktopConfig() {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(desktopConfigPath, 'utf8');
|
||||
const parsed = YAML.parse(raw);
|
||||
return normalizeConfig(parsed);
|
||||
} catch (error) {
|
||||
const defaults = getDefaultDesktopConfig();
|
||||
await saveDesktopConfig(defaults);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDesktopConfig(config) {
|
||||
const normalized = normalizeConfig(config);
|
||||
await fsPromises.mkdir(serverConfigDir, { recursive: true });
|
||||
const data = YAML.stringify(normalized);
|
||||
await fsPromises.writeFile(desktopConfigPath, data, 'utf8');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function resetDesktopConfig() {
|
||||
const defaults = getDefaultDesktopConfig();
|
||||
await saveDesktopConfig(defaults);
|
||||
return defaults;
|
||||
}
|
||||
94
desktop/i18n.js
Normal file
94
desktop/i18n.js
Normal file
@@ -0,0 +1,94 @@
|
||||
const messages = {
|
||||
en: {
|
||||
trayOpenMain: 'Open Main Window',
|
||||
trayOpenBrowser: 'Open in Browser',
|
||||
trayOpenUi: 'Main UI',
|
||||
trayOpenDocs: 'API Docs',
|
||||
trayOpenRepo: 'Repository',
|
||||
trayServiceStatus: 'Service Status',
|
||||
trayServiceRunning: 'Running',
|
||||
trayServiceStopped: 'Stopped',
|
||||
trayServiceManagement: 'Service Management',
|
||||
trayOpenSettings: 'Settings',
|
||||
trayRestart: 'Restart',
|
||||
trayData: 'Data',
|
||||
trayOpenModels: 'Open Model Folder',
|
||||
trayOpenConfig: 'Open Config Folder',
|
||||
trayVersion: 'Version',
|
||||
trayQuit: 'Quit',
|
||||
appName: 'MTranServer',
|
||||
portInUseTitle: 'Port is in use',
|
||||
portInUseDetail: 'Port {port} is already in use. Close the process occupying it, or use a random port to start the server.',
|
||||
portInUseUseRandom: 'Use Random Port',
|
||||
portInUseQuit: 'Quit',
|
||||
serverStartFailed: 'Failed to start server',
|
||||
serverStartFailedDetail: 'Unable to start MTranServer. Please check configuration and try again.',
|
||||
serverRestartFailed: 'Failed to restart server'
|
||||
},
|
||||
zh: {
|
||||
trayOpenMain: '打开主界面',
|
||||
trayOpenBrowser: '浏览器打开',
|
||||
trayOpenUi: '主界面',
|
||||
trayOpenDocs: 'API 文档',
|
||||
trayOpenRepo: '仓库地址',
|
||||
trayServiceStatus: '服务状态',
|
||||
trayServiceRunning: '运行中',
|
||||
trayServiceStopped: '已停止',
|
||||
trayServiceManagement: '服务管理',
|
||||
trayOpenSettings: '设置',
|
||||
trayRestart: '重启',
|
||||
trayData: '数据',
|
||||
trayOpenModels: '打开模型目录',
|
||||
trayOpenConfig: '打开配置目录',
|
||||
trayVersion: '版本号',
|
||||
trayQuit: '退出程序',
|
||||
appName: 'MTranServer',
|
||||
portInUseTitle: '端口被占用',
|
||||
portInUseDetail: '端口 {port} 已被占用,请关闭占用该端口的进程,或使用随机端口启动服务。',
|
||||
portInUseUseRandom: '使用随机端口',
|
||||
portInUseQuit: '退出',
|
||||
serverStartFailed: '服务启动失败',
|
||||
serverStartFailedDetail: '无法启动 MTranServer,请检查配置后重试。',
|
||||
serverRestartFailed: '服务重启失败'
|
||||
},
|
||||
ja: {
|
||||
trayOpenMain: 'メイン画面を開く',
|
||||
trayOpenBrowser: 'ブラウザで開く',
|
||||
trayOpenUi: 'メイン画面',
|
||||
trayOpenDocs: 'API ドキュメント',
|
||||
trayOpenRepo: 'リポジトリ',
|
||||
trayServiceStatus: 'サービス状態',
|
||||
trayServiceRunning: '稼働中',
|
||||
trayServiceStopped: '停止中',
|
||||
trayServiceManagement: 'サービス管理',
|
||||
trayOpenSettings: '設定',
|
||||
trayRestart: '再起動',
|
||||
trayData: 'データ',
|
||||
trayOpenModels: 'モデルフォルダを開く',
|
||||
trayOpenConfig: '設定フォルダを開く',
|
||||
trayVersion: 'バージョン',
|
||||
trayQuit: '終了',
|
||||
appName: 'MTranServer',
|
||||
portInUseTitle: 'ポートが使用中です',
|
||||
portInUseDetail: 'ポート {port} は使用中です。使用中のプロセスを終了するか、ランダムポートで起動してください。',
|
||||
portInUseUseRandom: 'ランダムポートを使用',
|
||||
portInUseQuit: '終了',
|
||||
serverStartFailed: 'サーバーの起動に失敗しました',
|
||||
serverStartFailedDetail: 'MTranServer を起動できません。設定を確認してください。',
|
||||
serverRestartFailed: 'サーバーの再起動に失敗しました'
|
||||
}
|
||||
};
|
||||
|
||||
export function resolveLocale(configLocale, appLocale) {
|
||||
if (configLocale && configLocale !== 'system') {
|
||||
return messages[configLocale] ? configLocale : 'en';
|
||||
}
|
||||
const lower = (appLocale || 'en').toLowerCase();
|
||||
if (lower.startsWith('zh')) return 'zh';
|
||||
if (lower.startsWith('ja')) return 'ja';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
export function getMessages(locale) {
|
||||
return messages[locale] || messages.en;
|
||||
}
|
||||
11
desktop/ipc.js
Normal file
11
desktop/ipc.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
export function registerIpcHandlers(handlers) {
|
||||
ipcMain.handle('desktop:get-config', async () => handlers.getConfig());
|
||||
ipcMain.handle('desktop:apply-config', async (_event, config) => handlers.applyConfig(config));
|
||||
ipcMain.handle('desktop:reset-config', async () => handlers.resetConfig());
|
||||
ipcMain.handle('desktop:restart-server', async () => handlers.restartServer());
|
||||
ipcMain.handle('desktop:get-status', async () => handlers.getStatus());
|
||||
ipcMain.handle('desktop:open-external', async (_event, url) => handlers.openExternal(url));
|
||||
ipcMain.handle('desktop:open-path', async (_event, targetPath) => handlers.openPath(targetPath));
|
||||
}
|
||||
32
desktop/ports.js
Normal file
32
desktop/ports.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import net from 'net';
|
||||
|
||||
export async function isPortAvailable(port, host) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.once('error', reject);
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (address && typeof address !== 'string') {
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
} else {
|
||||
reject(new Error('Failed to get port'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
12
desktop/preload.cjs
Normal file
12
desktop/preload.cjs
Normal file
@@ -0,0 +1,12 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('mtranDesktop', {
|
||||
isDesktop: true,
|
||||
getConfig: () => ipcRenderer.invoke('desktop:get-config'),
|
||||
applyConfig: (config) => ipcRenderer.invoke('desktop:apply-config', config),
|
||||
resetConfig: () => ipcRenderer.invoke('desktop:reset-config'),
|
||||
restartServer: () => ipcRenderer.invoke('desktop:restart-server'),
|
||||
getStatus: () => ipcRenderer.invoke('desktop:get-status'),
|
||||
openExternal: (url) => ipcRenderer.invoke('desktop:open-external', url),
|
||||
openPath: (targetPath) => ipcRenderer.invoke('desktop:open-path', targetPath)
|
||||
});
|
||||
106
desktop/server.js
Normal file
106
desktop/server.js
Normal file
@@ -0,0 +1,106 @@
|
||||
let currentServer = null;
|
||||
let stopServer = null;
|
||||
let status = 'stopped';
|
||||
let serverModulePromise = null;
|
||||
|
||||
const valueFlags = new Set([
|
||||
'--host',
|
||||
'--port',
|
||||
'--log-level',
|
||||
'--config-dir',
|
||||
'--model-dir',
|
||||
'--log-dir',
|
||||
'--worker-idle-timeout',
|
||||
'--workers-per-language',
|
||||
'--api-token',
|
||||
'--max-length-break',
|
||||
'--cache-size'
|
||||
]);
|
||||
|
||||
const booleanFlags = new Set([
|
||||
'--ui',
|
||||
'--no-ui',
|
||||
'--offline',
|
||||
'--log-to-file',
|
||||
'--log-console',
|
||||
'--no-log-console',
|
||||
'--log-requests',
|
||||
'--check-update',
|
||||
'--no-check-update'
|
||||
]);
|
||||
|
||||
function stripServerArgs() {
|
||||
const nextArgs = [];
|
||||
for (let i = 0; i < process.argv.length; i += 1) {
|
||||
const arg = process.argv[i];
|
||||
if (valueFlags.has(arg)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const flagMatch = arg.split('=')[0];
|
||||
if (valueFlags.has(flagMatch) || booleanFlags.has(flagMatch)) {
|
||||
continue;
|
||||
}
|
||||
if (booleanFlags.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
nextArgs.push(arg);
|
||||
}
|
||||
process.argv = nextArgs;
|
||||
}
|
||||
|
||||
async function loadServerModule() {
|
||||
if (!serverModulePromise) {
|
||||
serverModulePromise = import('../dist/desktop-server.js');
|
||||
}
|
||||
return serverModulePromise;
|
||||
}
|
||||
|
||||
function applyConfigToEnv(config) {
|
||||
stripServerArgs();
|
||||
process.env.MT_HOST = config.host;
|
||||
process.env.MT_PORT = String(config.port);
|
||||
process.env.MT_LOG_LEVEL = config.logLevel;
|
||||
process.env.MT_ENABLE_UI = String(config.enableWebUI);
|
||||
process.env.MT_OFFLINE = String(config.enableOfflineMode);
|
||||
process.env.MT_WORKER_IDLE_TIMEOUT = String(config.workerIdleTimeout);
|
||||
process.env.MT_WORKERS_PER_LANGUAGE = String(config.workersPerLanguage);
|
||||
process.env.MT_API_TOKEN = config.apiToken || '';
|
||||
process.env.MT_LOG_DIR = config.logDir;
|
||||
process.env.MT_LOG_TO_FILE = String(config.logToFile);
|
||||
process.env.MT_LOG_CONSOLE = String(config.logConsole);
|
||||
process.env.MT_LOG_REQUESTS = String(config.logRequests);
|
||||
process.env.MT_MAX_LENGTH_BREAK = String(config.maxLengthBreak);
|
||||
process.env.MT_CHECK_UPDATE = String(config.checkUpdate);
|
||||
process.env.MT_CACHE_SIZE = String(config.cacheSize);
|
||||
process.env.MT_MODEL_DIR = config.modelDir;
|
||||
process.env.MT_CONFIG_DIR = config.configDir;
|
||||
}
|
||||
|
||||
export async function startServerWithConfig(config) {
|
||||
applyConfigToEnv(config);
|
||||
const serverModule = await loadServerModule();
|
||||
serverModule.resetConfig();
|
||||
status = 'starting';
|
||||
const result = await serverModule.startServer({ handleSignals: false });
|
||||
currentServer = result.server;
|
||||
stopServer = result.stop;
|
||||
status = 'running';
|
||||
return currentServer;
|
||||
}
|
||||
|
||||
export async function stopServerInstance() {
|
||||
if (!stopServer) {
|
||||
status = 'stopped';
|
||||
return;
|
||||
}
|
||||
status = 'stopping';
|
||||
await stopServer();
|
||||
currentServer = null;
|
||||
stopServer = null;
|
||||
status = 'stopped';
|
||||
}
|
||||
|
||||
export function getServerStatus() {
|
||||
return status;
|
||||
}
|
||||
100
desktop/tray.js
Normal file
100
desktop/tray.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Menu, Tray } from 'electron';
|
||||
|
||||
let tray = null;
|
||||
|
||||
export function createTray({
|
||||
icon,
|
||||
tooltip,
|
||||
messages,
|
||||
statusLabel,
|
||||
versionLabel,
|
||||
onOpenMain,
|
||||
onOpenBrowserUi,
|
||||
onOpenBrowserDocs,
|
||||
onOpenRepo,
|
||||
onOpenSettings,
|
||||
onRestart,
|
||||
onOpenModels,
|
||||
onOpenConfig,
|
||||
onQuit
|
||||
}) {
|
||||
if (tray) return tray;
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip(tooltip);
|
||||
tray.on('click', () => onOpenMain());
|
||||
updateTrayMenu({
|
||||
messages,
|
||||
statusLabel,
|
||||
versionLabel,
|
||||
onOpenMain,
|
||||
onOpenBrowserUi,
|
||||
onOpenBrowserDocs,
|
||||
onOpenRepo,
|
||||
onOpenSettings,
|
||||
onRestart,
|
||||
onOpenModels,
|
||||
onOpenConfig,
|
||||
onQuit
|
||||
});
|
||||
return tray;
|
||||
}
|
||||
|
||||
export function updateTrayMenu({
|
||||
messages,
|
||||
statusLabel,
|
||||
versionLabel,
|
||||
onOpenMain,
|
||||
onOpenBrowserUi,
|
||||
onOpenBrowserDocs,
|
||||
onOpenRepo,
|
||||
onOpenSettings,
|
||||
onRestart,
|
||||
onOpenModels,
|
||||
onOpenConfig,
|
||||
onQuit
|
||||
}) {
|
||||
if (!tray) return;
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: messages.trayOpenMain,
|
||||
click: onOpenMain
|
||||
},
|
||||
{
|
||||
label: messages.trayOpenBrowser,
|
||||
submenu: [
|
||||
{ label: messages.trayOpenUi, click: onOpenBrowserUi },
|
||||
{ label: messages.trayOpenDocs, click: onOpenBrowserDocs },
|
||||
{ label: messages.trayOpenRepo, click: onOpenRepo }
|
||||
]
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: `${messages.trayServiceStatus}: ${statusLabel}`,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
label: messages.trayServiceManagement,
|
||||
submenu: [
|
||||
{ label: messages.trayOpenSettings, click: onOpenSettings },
|
||||
{ label: messages.trayRestart, click: onRestart }
|
||||
]
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: messages.trayData,
|
||||
submenu: [
|
||||
{ label: messages.trayOpenModels, click: onOpenModels },
|
||||
{ label: messages.trayOpenConfig, click: onOpenConfig }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: `${messages.trayVersion}: ${versionLabel}`,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
label: messages.trayQuit,
|
||||
click: onQuit
|
||||
}
|
||||
]);
|
||||
tray.setContextMenu(contextMenu);
|
||||
}
|
||||
107
desktop/windows.js
Normal file
107
desktop/windows.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import { app, BrowserWindow, Menu } from 'electron';
|
||||
|
||||
let mainWindow = null;
|
||||
let settingsWindow = null;
|
||||
|
||||
function attachContextMenu(window) {
|
||||
window.webContents.on('context-menu', () => {
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'selectAll' }
|
||||
]);
|
||||
menu.popup({ window });
|
||||
});
|
||||
}
|
||||
|
||||
export function createMainWindow({ url, preload }) {
|
||||
if (mainWindow) return mainWindow;
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.loadURL(url);
|
||||
attachContextMenu(mainWindow);
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!app.isQuitting && !mainWindow?.isDestroyed()) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
});
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
export function createSettingsWindow({ url, preload, parent }) {
|
||||
if (settingsWindow) return settingsWindow;
|
||||
settingsWindow = new BrowserWindow({
|
||||
width: 960,
|
||||
height: 540,
|
||||
show: false,
|
||||
resizable: true,
|
||||
parent,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload
|
||||
}
|
||||
});
|
||||
|
||||
settingsWindow.loadURL(url);
|
||||
attachContextMenu(settingsWindow);
|
||||
|
||||
settingsWindow.on('close', (event) => {
|
||||
if (!app.isQuitting && !settingsWindow?.isDestroyed()) {
|
||||
event.preventDefault();
|
||||
settingsWindow.hide();
|
||||
}
|
||||
});
|
||||
|
||||
return settingsWindow;
|
||||
}
|
||||
|
||||
export function showMainWindow() {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
export function showSettingsWindow() {
|
||||
if (!settingsWindow) return;
|
||||
if (settingsWindow.isMinimized()) settingsWindow.restore();
|
||||
settingsWindow.show();
|
||||
settingsWindow.focus();
|
||||
}
|
||||
|
||||
export function getMainWindow() {
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
export function destroySettingsWindow() {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.destroy();
|
||||
}
|
||||
settingsWindow = null;
|
||||
}
|
||||
|
||||
export function updateWindowUrls({ mainUrl, settingsUrl }) {
|
||||
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents) {
|
||||
mainWindow.loadURL(mainUrl);
|
||||
}
|
||||
if (settingsWindow && !settingsWindow.isDestroyed() && settingsWindow.webContents) {
|
||||
settingsWindow.loadURL(settingsUrl);
|
||||
}
|
||||
}
|
||||
127
electron-main.js
127
electron-main.js
@@ -1,125 +1,16 @@
|
||||
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { app } from 'electron';
|
||||
import { focusMainWindow, startDesktop } from './desktop/app.js';
|
||||
|
||||
// --- ESM 兼容性处理 ---
|
||||
// 在 ESM 模式下无法直接使用 __dirname,需要通过 import.meta.url 手动生成
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// --- 1. 引入你的 Express 入口文件 ---
|
||||
// 使用 import 语法直接导入,这会执行 ./dist/main.js 中的代码(启动服务器)
|
||||
import './dist/main.js';
|
||||
|
||||
const PORT = 8989;
|
||||
|
||||
let mainWindow;
|
||||
let tray;
|
||||
|
||||
// 防止应用多开
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
// 如果用户尝试打开第二个实例,则聚焦当前窗口
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
if (!mainWindow.isVisible()) mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
app.on('second-instance', () => {
|
||||
focusMainWindow();
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
createTray();
|
||||
});
|
||||
app.whenReady().then(() => {
|
||||
startDesktop();
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1024,
|
||||
height: 768,
|
||||
show: false, // 启动时不直接显示窗口
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
|
||||
// 加载 Express 服务地址
|
||||
mainWindow.loadURL(`http://localhost:${PORT}`);
|
||||
|
||||
// 监听关闭事件:点击 X 不退出,而是隐藏到托盘
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!app.isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
// 2. 准备图标
|
||||
// 确保你的路径下有 images/icon.png
|
||||
const iconPath = path.join(__dirname, "images", "icons", 'icon@16px.png');
|
||||
|
||||
// 增加一个容错,防止图标路径不对导致报错
|
||||
let trayIcon;
|
||||
try {
|
||||
trayIcon = nativeImage.createFromPath(iconPath);
|
||||
if (trayIcon.isEmpty()) {
|
||||
console.warn('警告: 托盘图标为空,请检查路径:', iconPath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载图标失败:', e);
|
||||
trayIcon = nativeImage.createEmpty();
|
||||
}
|
||||
|
||||
tray = new Tray(trayIcon);
|
||||
tray.setToolTip('我的 Express 项目');
|
||||
|
||||
// 3. 定义托盘右键菜单
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: '显示主界面',
|
||||
click: () => mainWindow.show()
|
||||
},
|
||||
{
|
||||
label: '重启页面', // 注意:这里只是刷新页面,不是重启后台服务
|
||||
click: () => {
|
||||
mainWindow.reload();
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
click: () => {
|
||||
app.isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
// 点击托盘图标切换显示/隐藏
|
||||
tray.on('click', () => {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// macOS 特殊处理
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
14
package.json
14
package.json
@@ -19,7 +19,8 @@
|
||||
"dev": "bun src/main.ts --log-level debug --port 8990 --model-dir ~/.config/mtran/models",
|
||||
"dev2": "bun src/main.ts --log-level debug --port 8989 --model-dir ~/.config/mtran/models",
|
||||
"bump": "bun scripts/bump.ts",
|
||||
"electron": "electron ./electron-main.js",
|
||||
"electron": "bun run build:lib && electron ./electron-main.js",
|
||||
"electron:dev": "bun run scripts/electron-dev.ts",
|
||||
"prepublishOnly": "bun scripts/build.ts --lib",
|
||||
"push": "npm publish --registry=https://registry.npmjs.org",
|
||||
"build": "bun scripts/build.ts --single",
|
||||
@@ -56,7 +57,8 @@
|
||||
"express-validator": "^7.3.1",
|
||||
"fzstd": "^0.1.1",
|
||||
"lru-cache": "^11.2.4",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -68,7 +70,9 @@
|
||||
"extraMetadata": {
|
||||
"main": "electron-main.js"
|
||||
},
|
||||
"artifactName": "mtranserver-desktop-${version}-${os}-${arch}.${ext}",
|
||||
"files": [
|
||||
"desktop/**/*",
|
||||
"electron-main.js",
|
||||
"dist/**/*",
|
||||
"images/**/*",
|
||||
@@ -87,7 +91,11 @@
|
||||
"icon": "images/icon.png"
|
||||
},
|
||||
"linux": {
|
||||
"target": "AppImage",
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb",
|
||||
"rpm"
|
||||
],
|
||||
"icon": "images/icon.png"
|
||||
},
|
||||
"nsis": {
|
||||
|
||||
@@ -53,7 +53,7 @@ await $`bun tsoa spec-and-routes`;
|
||||
|
||||
if (isLib) {
|
||||
console.log("Building library...");
|
||||
await $`bun build src/index.ts src/main.ts --outdir dist --target node --format esm --sourcemap --external zstd-wasm-decoder --external express`;
|
||||
await $`bun build src/index.ts src/main.ts src/desktop-server.ts --outdir dist --target node --format esm --sourcemap --external zstd-wasm-decoder --external express`;
|
||||
await $`tsc -p tsconfig.lib.json`;
|
||||
console.log("Build complete!");
|
||||
process.exit(0);
|
||||
|
||||
116
scripts/electron-dev.ts
Normal file
116
scripts/electron-dev.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { spawn } from 'child_process'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const root = process.cwd()
|
||||
const electronBin = path.join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron')
|
||||
const bunBin = process.execPath
|
||||
|
||||
function run(command: string, args: string[]) {
|
||||
return spawn(command, args, { stdio: 'inherit', env: process.env })
|
||||
}
|
||||
|
||||
function runOnce(command: string, args: string[]) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const proc = run(command, args)
|
||||
proc.on('exit', (code) => {
|
||||
if (code === 0) resolve()
|
||||
else reject(new Error(`exit ${code}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runBuildOnce() {
|
||||
await runOnce(bunBin, ['run', 'build:lib'])
|
||||
}
|
||||
|
||||
function startBuildWatch() {
|
||||
const args = [
|
||||
'build',
|
||||
'src/index.ts',
|
||||
'src/main.ts',
|
||||
'src/desktop-server.ts',
|
||||
'--outdir',
|
||||
'dist',
|
||||
'--target',
|
||||
'node',
|
||||
'--format',
|
||||
'esm',
|
||||
'--sourcemap',
|
||||
'--external',
|
||||
'zstd-wasm-decoder',
|
||||
'--external',
|
||||
'express',
|
||||
'--watch'
|
||||
]
|
||||
return run(bunBin, args)
|
||||
}
|
||||
|
||||
let electronProcess: ReturnType<typeof run> | null = null
|
||||
let restartTimer: NodeJS.Timeout | null = null
|
||||
let restarting = false
|
||||
|
||||
function startElectron() {
|
||||
if (electronProcess) return
|
||||
electronProcess = run(electronBin, ['./electron-main.js'])
|
||||
electronProcess.on('exit', () => {
|
||||
electronProcess = null
|
||||
})
|
||||
}
|
||||
|
||||
async function stopElectron() {
|
||||
if (!electronProcess) return
|
||||
const proc = electronProcess
|
||||
await new Promise<void>((resolve) => {
|
||||
proc.once('exit', () => resolve())
|
||||
proc.kill('SIGTERM')
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
proc.kill('SIGKILL')
|
||||
}
|
||||
}, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleRestart() {
|
||||
if (restartTimer) clearTimeout(restartTimer)
|
||||
restartTimer = setTimeout(async () => {
|
||||
if (restarting) return
|
||||
restarting = true
|
||||
await stopElectron()
|
||||
startElectron()
|
||||
restarting = false
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function watchPath(target: string) {
|
||||
try {
|
||||
fs.watch(target, () => scheduleRestart())
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function setupWatchers() {
|
||||
watchPath(path.join(root, 'dist'))
|
||||
watchPath(path.join(root, 'desktop'))
|
||||
watchPath(path.join(root, 'electron-main.js'))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await runBuildOnce()
|
||||
const buildWatcher = startBuildWatch()
|
||||
startElectron()
|
||||
setupWatchers()
|
||||
|
||||
const cleanup = async () => {
|
||||
await stopElectron()
|
||||
buildWatcher.kill('SIGTERM')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGINT', cleanup)
|
||||
process.on('SIGTERM', cleanup)
|
||||
}
|
||||
|
||||
main().catch(() => process.exit(1))
|
||||
@@ -130,3 +130,7 @@ export function setConfig(config: Partial<Config>) {
|
||||
const current = getConfig();
|
||||
globalConfig = { ...current, ...config };
|
||||
}
|
||||
|
||||
export function resetConfig() {
|
||||
globalConfig = null;
|
||||
}
|
||||
|
||||
2
src/desktop-server.ts
Normal file
2
src/desktop-server.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { startServer } from '@/server/index.js';
|
||||
export { resetConfig } from '@/config/index.js';
|
||||
@@ -14,7 +14,7 @@ import { swaggerStatic } from '@/middleware/swagger.js';
|
||||
import { checkForUpdate } from '@/utils/update-checker.js';
|
||||
import { VERSION } from '@/version';
|
||||
|
||||
export async function run() {
|
||||
export async function startServer({ handleSignals = true } = {}) {
|
||||
const config = getConfig();
|
||||
|
||||
logger.info('Initializing MTranServer...');
|
||||
@@ -69,24 +69,35 @@ export async function run() {
|
||||
}
|
||||
});
|
||||
|
||||
const shutdown = async () => {
|
||||
const stop = async () => {
|
||||
logger.info('Shutting down server...');
|
||||
|
||||
cleanupAllEngines();
|
||||
|
||||
server.close(() => {
|
||||
logger.info('Server shutdown complete');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
logger.error('Forced shutdown after timeout');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
logger.info('Server shutdown complete');
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
if (handleSignals) {
|
||||
let shuttingDown = false;
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
const timeout = setTimeout(() => {
|
||||
logger.error('Forced shutdown after timeout');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
await stop();
|
||||
clearTimeout(timeout);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
return { server, stop };
|
||||
}
|
||||
|
||||
export async function run() {
|
||||
const { server } = await startServer({ handleSignals: true });
|
||||
return server;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -8,8 +8,15 @@ import { SettingsMenu } from '@/components/SettingsMenu'
|
||||
import { HistorySheet } from '@/components/HistorySheet'
|
||||
import { TranslationPanel } from '@/components/TranslationPanel'
|
||||
import { useHistory } from '@/hooks/use-history'
|
||||
import { DesktopSettingsPage } from '@/components/DesktopSettingsPage'
|
||||
|
||||
function App() {
|
||||
function getRoute(pathname: string) {
|
||||
const normalized = pathname.replace(/\/+$/, '')
|
||||
if (normalized.endsWith('/settings')) return 'settings'
|
||||
return 'main'
|
||||
}
|
||||
|
||||
function MainPage() {
|
||||
const { t } = useTranslation()
|
||||
const [languages, setLanguages] = useState<string[]>([])
|
||||
const [loadingLanguages, setLoadingLanguages] = useState(true)
|
||||
@@ -242,4 +249,12 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const route = useMemo(() => getRoute(window.location.pathname), [])
|
||||
if (route === 'settings') {
|
||||
return <DesktopSettingsPage />
|
||||
}
|
||||
return <MainPage />
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
405
ui/src/components/DesktopSettingsPage.tsx
Normal file
405
ui/src/components/DesktopSettingsPage.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
applyDesktopConfig,
|
||||
getDesktopConfig,
|
||||
isDesktop,
|
||||
resetDesktopConfig,
|
||||
restartDesktopServer
|
||||
} from '@/lib/desktop'
|
||||
import type { DesktopConfig } from '@/lib/desktop'
|
||||
|
||||
type FormState = {
|
||||
locale: string
|
||||
server: {
|
||||
host: string
|
||||
port: string
|
||||
logLevel: string
|
||||
enableWebUI: boolean
|
||||
enableOfflineMode: boolean
|
||||
workerIdleTimeout: string
|
||||
workersPerLanguage: string
|
||||
apiToken: string
|
||||
logDir: string
|
||||
logToFile: boolean
|
||||
logConsole: boolean
|
||||
logRequests: boolean
|
||||
maxLengthBreak: string
|
||||
checkUpdate: boolean
|
||||
cacheSize: string
|
||||
modelDir: string
|
||||
configDir: string
|
||||
}
|
||||
}
|
||||
|
||||
function toForm(config: DesktopConfig): FormState {
|
||||
return {
|
||||
locale: config.locale,
|
||||
server: {
|
||||
host: config.server.host,
|
||||
port: String(config.server.port),
|
||||
logLevel: config.server.logLevel,
|
||||
enableWebUI: config.server.enableWebUI,
|
||||
enableOfflineMode: config.server.enableOfflineMode,
|
||||
workerIdleTimeout: String(config.server.workerIdleTimeout),
|
||||
workersPerLanguage: String(config.server.workersPerLanguage),
|
||||
apiToken: config.server.apiToken,
|
||||
logDir: config.server.logDir,
|
||||
logToFile: config.server.logToFile,
|
||||
logConsole: config.server.logConsole,
|
||||
logRequests: config.server.logRequests,
|
||||
maxLengthBreak: String(config.server.maxLengthBreak),
|
||||
checkUpdate: config.server.checkUpdate,
|
||||
cacheSize: String(config.server.cacheSize),
|
||||
modelDir: config.server.modelDir,
|
||||
configDir: config.server.configDir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toNumber(value: string, fallback: number) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
export function DesktopSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const desktopAvailable = isDesktop()
|
||||
const [config, setConfig] = useState<DesktopConfig | null>(null)
|
||||
const [form, setForm] = useState<FormState | null>(null)
|
||||
const [status, setStatus] = useState('')
|
||||
const [version, setVersion] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!desktopAvailable) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
getDesktopConfig()
|
||||
.then((response) => {
|
||||
if (!response) return
|
||||
setConfig(response.config)
|
||||
setForm(toForm(response.config))
|
||||
setStatus(response.status)
|
||||
setVersion(response.version)
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t('loadingConfigFailed'))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [desktopAvailable, t])
|
||||
|
||||
const serverStatusLabel = useMemo(() => {
|
||||
if (!status) return ''
|
||||
return status === 'running' ? t('serverStatusRunning') : t('serverStatusStopped')
|
||||
}, [status, t])
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!form || !config) return
|
||||
setSaving(true)
|
||||
const nextConfig: DesktopConfig = {
|
||||
locale: form.locale,
|
||||
server: {
|
||||
...config.server,
|
||||
host: form.server.host,
|
||||
port: toNumber(form.server.port, config.server.port),
|
||||
logLevel: form.server.logLevel,
|
||||
enableWebUI: form.server.enableWebUI,
|
||||
enableOfflineMode: form.server.enableOfflineMode,
|
||||
workerIdleTimeout: toNumber(form.server.workerIdleTimeout, config.server.workerIdleTimeout),
|
||||
workersPerLanguage: toNumber(form.server.workersPerLanguage, config.server.workersPerLanguage),
|
||||
apiToken: form.server.apiToken,
|
||||
logDir: form.server.logDir,
|
||||
logToFile: form.server.logToFile,
|
||||
logConsole: form.server.logConsole,
|
||||
logRequests: form.server.logRequests,
|
||||
maxLengthBreak: toNumber(form.server.maxLengthBreak, config.server.maxLengthBreak),
|
||||
checkUpdate: form.server.checkUpdate,
|
||||
cacheSize: toNumber(form.server.cacheSize, config.server.cacheSize),
|
||||
modelDir: form.server.modelDir,
|
||||
configDir: form.server.configDir
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await applyDesktopConfig(nextConfig)
|
||||
if (response) {
|
||||
setConfig(response.config)
|
||||
setForm(toForm(response.config))
|
||||
setStatus(response.status)
|
||||
setVersion(response.version)
|
||||
toast.success(t('settingsApplied'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('settingsApplyFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestart = async () => {
|
||||
if (!desktopAvailable) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await restartDesktopServer()
|
||||
if (response) {
|
||||
setConfig(response.config)
|
||||
setForm(toForm(response.config))
|
||||
setStatus(response.status)
|
||||
setVersion(response.version)
|
||||
toast.success(t('serverRestarted'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('serverRestartFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!desktopAvailable) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await resetDesktopConfig()
|
||||
if (response) {
|
||||
setConfig(response.config)
|
||||
setForm(toForm(response.config))
|
||||
setStatus(response.status)
|
||||
setVersion(response.version)
|
||||
toast.success(t('settingsReset'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('settingsResetFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!desktopAvailable) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Alert>
|
||||
<AlertTitle>{t('desktopOnly')}</AlertTitle>
|
||||
<AlertDescription>{t('desktopOnlyDesc')}</AlertDescription>
|
||||
</Alert>
|
||||
<div className="mt-6">
|
||||
<Button onClick={() => (window.location.href = '/ui/')}>{t('backToMain')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading || !form || !config) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="mx-auto max-w-3xl text-muted-foreground">{t('loadingConfig')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-3 sm:p-4 md:p-6">
|
||||
<div className="mx-auto max-w-4xl space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('desktopSettings')}</h1>
|
||||
<p className="text-xs text-muted-foreground">{t('desktopSettingsDesc')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t('serverStatus')}: {serverStatusLabel}</span>
|
||||
{version && <span>{t('version')}: v{version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="gap-4 py-4">
|
||||
<CardHeader className="px-4 pb-3">
|
||||
<CardTitle>{t('generalSettings')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('host')}</label>
|
||||
<Input value={form.server.host} onChange={(e) => setForm({ ...form, server: { ...form.server, host: e.target.value } })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('port')}</label>
|
||||
<Input value={form.server.port} onChange={(e) => setForm({ ...form, server: { ...form.server, port: e.target.value } })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('logLevel')}</label>
|
||||
<Select value={form.server.logLevel} onValueChange={(value) => setForm({ ...form, server: { ...form.server, logLevel: value } })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="debug">debug</SelectItem>
|
||||
<SelectItem value="info">info</SelectItem>
|
||||
<SelectItem value="warn">warn</SelectItem>
|
||||
<SelectItem value="error">error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('locale')}</label>
|
||||
<Select value={form.locale} onValueChange={(value) => setForm({ ...form, locale: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">{t('system')}</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="zh">中文</SelectItem>
|
||||
<SelectItem value="ja">日本語</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('enableWebUI')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('enableWebUIDesc')}</div>
|
||||
</div>
|
||||
<Switch checked={form.server.enableWebUI} onCheckedChange={(checked) => setForm({ ...form, server: { ...form.server, enableWebUI: checked } })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('enableOfflineMode')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('enableOfflineModeDesc')}</div>
|
||||
</div>
|
||||
<Switch checked={form.server.enableOfflineMode} onCheckedChange={(checked) => setForm({ ...form, server: { ...form.server, enableOfflineMode: checked } })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('checkUpdate')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('checkUpdateDesc')}</div>
|
||||
</div>
|
||||
<Switch checked={form.server.checkUpdate} onCheckedChange={(checked) => setForm({ ...form, server: { ...form.server, checkUpdate: checked } })} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="gap-4 py-4">
|
||||
<CardHeader className="px-4 pb-3">
|
||||
<CardTitle>{t('storageSettings')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('modelDir')}</label>
|
||||
<Input value={form.server.modelDir} onChange={(e) => setForm({ ...form, server: { ...form.server, modelDir: e.target.value } })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('configDir')}</label>
|
||||
<Input value={form.server.configDir} onChange={(e) => setForm({ ...form, server: { ...form.server, configDir: e.target.value } })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('logDir')}</label>
|
||||
<Input value={form.server.logDir} onChange={(e) => setForm({ ...form, server: { ...form.server, logDir: e.target.value } })} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="gap-4 py-4">
|
||||
<CardHeader className="px-4 pb-3">
|
||||
<CardTitle>{t('securitySettings')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('apiToken')}</label>
|
||||
<Input value={form.server.apiToken} onChange={(e) => setForm({ ...form, server: { ...form.server, apiToken: e.target.value } })} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="gap-4 py-4">
|
||||
<CardHeader className="px-4 pb-3">
|
||||
<CardTitle>{t('performanceSettings')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('workerIdleTimeout')}</label>
|
||||
<Input value={form.server.workerIdleTimeout} onChange={(e) => setForm({ ...form, server: { ...form.server, workerIdleTimeout: e.target.value } })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('workersPerLanguage')}</label>
|
||||
<Input value={form.server.workersPerLanguage} onChange={(e) => setForm({ ...form, server: { ...form.server, workersPerLanguage: e.target.value } })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('maxLengthBreak')}</label>
|
||||
<Input value={form.server.maxLengthBreak} onChange={(e) => setForm({ ...form, server: { ...form.server, maxLengthBreak: e.target.value } })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('cacheSize')}</label>
|
||||
<Input value={form.server.cacheSize} onChange={(e) => setForm({ ...form, server: { ...form.server, cacheSize: e.target.value } })} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="gap-4 py-4">
|
||||
<CardHeader className="px-4 pb-3">
|
||||
<CardTitle>{t('loggingSettings')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('logToFile')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('logToFileDesc')}</div>
|
||||
</div>
|
||||
<Switch checked={form.server.logToFile} onCheckedChange={(checked) => setForm({ ...form, server: { ...form.server, logToFile: checked } })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('logConsole')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('logConsoleDesc')}</div>
|
||||
</div>
|
||||
<Switch checked={form.server.logConsole} onCheckedChange={(checked) => setForm({ ...form, server: { ...form.server, logConsole: checked } })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('logRequests')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('logRequestsDesc')}</div>
|
||||
</div>
|
||||
<Switch checked={form.server.logRequests} onCheckedChange={(checked) => setForm({ ...form, server: { ...form.server, logRequests: checked } })} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button variant="outline" onClick={() => (window.location.href = '/ui/')}>
|
||||
{t('backToMain')}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={handleReset} disabled={saving}>
|
||||
{t('resetDefaults')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleRestart} disabled={saving}>
|
||||
{t('restart')}
|
||||
</Button>
|
||||
<Button onClick={handleApply} disabled={saving}>
|
||||
{saving ? t('saving') : t('apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
ui/src/desktop.d.ts
vendored
Normal file
11
ui/src/desktop.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
interface DesktopApi {
|
||||
isDesktop: boolean
|
||||
getConfig: () => Promise<import('./lib/desktop').DesktopConfigResponse>
|
||||
applyConfig: (config: import('./lib/desktop').DesktopConfig) => Promise<import('./lib/desktop').DesktopConfigResponse>
|
||||
resetConfig: () => Promise<import('./lib/desktop').DesktopConfigResponse>
|
||||
restartServer: () => Promise<import('./lib/desktop').DesktopConfigResponse>
|
||||
}
|
||||
|
||||
interface Window {
|
||||
mtranDesktop?: DesktopApi
|
||||
}
|
||||
@@ -64,7 +64,56 @@ const resources = {
|
||||
languageSearchPlaceholder: 'Search languages...',
|
||||
languageRecent: 'Recent',
|
||||
languageAll: 'All Languages',
|
||||
followSystemLanguage: 'Follow System'
|
||||
followSystemLanguage: 'Follow System',
|
||||
desktopSettings: 'Desktop Settings',
|
||||
desktopSettingsDesc: 'Manage server configuration and desktop preferences',
|
||||
serverStatus: 'Service Status',
|
||||
serverStatusRunning: 'Running',
|
||||
serverStatusStopped: 'Stopped',
|
||||
generalSettings: 'General',
|
||||
storageSettings: 'Storage',
|
||||
securitySettings: 'Security',
|
||||
performanceSettings: 'Performance',
|
||||
loggingSettings: 'Logging',
|
||||
host: 'Host',
|
||||
port: 'Port',
|
||||
logLevel: 'Log Level',
|
||||
locale: 'Language',
|
||||
enableWebUI: 'Web UI',
|
||||
enableWebUIDesc: 'Enable the built-in web UI',
|
||||
enableOfflineMode: 'Offline Mode',
|
||||
enableOfflineModeDesc: 'Disable online features and updates',
|
||||
checkUpdate: 'Check Updates',
|
||||
checkUpdateDesc: 'Check for new versions at startup',
|
||||
modelDir: 'Model Directory',
|
||||
configDir: 'Config Directory',
|
||||
logDir: 'Log Directory',
|
||||
workerIdleTimeout: 'Worker Idle Timeout (s)',
|
||||
workersPerLanguage: 'Workers Per Language',
|
||||
maxLengthBreak: 'Max Length Break',
|
||||
cacheSize: 'Cache Size',
|
||||
logToFile: 'Log to File',
|
||||
logToFileDesc: 'Write logs to file',
|
||||
logConsole: 'Log to Console',
|
||||
logConsoleDesc: 'Output logs to console',
|
||||
logRequests: 'Log Requests',
|
||||
logRequestsDesc: 'Record each request',
|
||||
apply: 'Apply',
|
||||
restart: 'Restart',
|
||||
resetDefaults: 'Reset to Defaults',
|
||||
backToMain: 'Back to Main',
|
||||
loadingConfig: 'Loading configuration...',
|
||||
loadingConfigFailed: 'Failed to load configuration',
|
||||
settingsApplied: 'Settings applied',
|
||||
settingsApplyFailed: 'Failed to apply settings',
|
||||
serverRestarted: 'Server restarted',
|
||||
serverRestartFailed: 'Failed to restart server',
|
||||
settingsReset: 'Defaults restored',
|
||||
settingsResetFailed: 'Failed to reset defaults',
|
||||
desktopOnly: 'Desktop only',
|
||||
desktopOnlyDesc: 'Desktop settings are only available in the desktop client',
|
||||
version: 'Version',
|
||||
saving: 'Saving...'
|
||||
}
|
||||
},
|
||||
zh: {
|
||||
@@ -128,7 +177,56 @@ const resources = {
|
||||
languageSearchPlaceholder: '搜索语言...',
|
||||
languageRecent: '最近使用',
|
||||
languageAll: '全部语言',
|
||||
followSystemLanguage: '跟随系统'
|
||||
followSystemLanguage: '跟随系统',
|
||||
desktopSettings: '桌面端设置',
|
||||
desktopSettingsDesc: '管理服务配置与桌面偏好',
|
||||
serverStatus: '服务状态',
|
||||
serverStatusRunning: '运行中',
|
||||
serverStatusStopped: '已停止',
|
||||
generalSettings: '常规',
|
||||
storageSettings: '存储',
|
||||
securitySettings: '安全',
|
||||
performanceSettings: '性能',
|
||||
loggingSettings: '日志',
|
||||
host: '主机',
|
||||
port: '端口',
|
||||
logLevel: '日志级别',
|
||||
locale: '语言',
|
||||
enableWebUI: 'Web UI',
|
||||
enableWebUIDesc: '启用内置 Web UI',
|
||||
enableOfflineMode: '离线模式',
|
||||
enableOfflineModeDesc: '禁用在线功能与更新检查',
|
||||
checkUpdate: '检查更新',
|
||||
checkUpdateDesc: '启动时检查新版本',
|
||||
modelDir: '模型目录',
|
||||
configDir: '配置目录',
|
||||
logDir: '日志目录',
|
||||
workerIdleTimeout: '空闲超时(秒)',
|
||||
workersPerLanguage: '每语言工作数',
|
||||
maxLengthBreak: '最大断句长度',
|
||||
cacheSize: '缓存大小',
|
||||
logToFile: '写入文件',
|
||||
logToFileDesc: '将日志写入文件',
|
||||
logConsole: '控制台日志',
|
||||
logConsoleDesc: '在控制台输出日志',
|
||||
logRequests: '请求日志',
|
||||
logRequestsDesc: '记录每个请求',
|
||||
apply: '应用',
|
||||
restart: '重启',
|
||||
resetDefaults: '恢复默认值',
|
||||
backToMain: '返回主界面',
|
||||
loadingConfig: '正在加载配置...',
|
||||
loadingConfigFailed: '加载配置失败',
|
||||
settingsApplied: '设置已应用',
|
||||
settingsApplyFailed: '应用设置失败',
|
||||
serverRestarted: '服务已重启',
|
||||
serverRestartFailed: '服务重启失败',
|
||||
settingsReset: '默认值已恢复',
|
||||
settingsResetFailed: '恢复默认值失败',
|
||||
desktopOnly: '仅限桌面端',
|
||||
desktopOnlyDesc: '桌面端设置仅在桌面客户端可用',
|
||||
version: '版本',
|
||||
saving: '保存中...'
|
||||
}
|
||||
},
|
||||
ja: {
|
||||
@@ -192,7 +290,56 @@ const resources = {
|
||||
languageSearchPlaceholder: '言語を検索...',
|
||||
languageRecent: '最近',
|
||||
languageAll: 'すべての言語',
|
||||
followSystemLanguage: 'システムに従う'
|
||||
followSystemLanguage: 'システムに従う',
|
||||
desktopSettings: 'デスクトップ設定',
|
||||
desktopSettingsDesc: 'サーバー設定とデスクトップ設定を管理',
|
||||
serverStatus: 'サービス状態',
|
||||
serverStatusRunning: '稼働中',
|
||||
serverStatusStopped: '停止中',
|
||||
generalSettings: '一般',
|
||||
storageSettings: 'ストレージ',
|
||||
securitySettings: 'セキュリティ',
|
||||
performanceSettings: 'パフォーマンス',
|
||||
loggingSettings: 'ログ',
|
||||
host: 'ホスト',
|
||||
port: 'ポート',
|
||||
logLevel: 'ログレベル',
|
||||
locale: '言語',
|
||||
enableWebUI: 'Web UI',
|
||||
enableWebUIDesc: '内蔵 Web UI を有効化',
|
||||
enableOfflineMode: 'オフラインモード',
|
||||
enableOfflineModeDesc: 'オンライン機能と更新確認を無効化',
|
||||
checkUpdate: '更新チェック',
|
||||
checkUpdateDesc: '起動時に新しいバージョンを確認',
|
||||
modelDir: 'モデルディレクトリ',
|
||||
configDir: '設定ディレクトリ',
|
||||
logDir: 'ログディレクトリ',
|
||||
workerIdleTimeout: 'アイドルタイムアウト(秒)',
|
||||
workersPerLanguage: '言語ごとのワーカー数',
|
||||
maxLengthBreak: '最大分割長',
|
||||
cacheSize: 'キャッシュサイズ',
|
||||
logToFile: 'ファイルに記録',
|
||||
logToFileDesc: 'ログをファイルに書き込む',
|
||||
logConsole: 'コンソール出力',
|
||||
logConsoleDesc: 'コンソールにログを出力',
|
||||
logRequests: 'リクエストログ',
|
||||
logRequestsDesc: '各リクエストを記録',
|
||||
apply: '適用',
|
||||
restart: '再起動',
|
||||
resetDefaults: 'デフォルトに戻す',
|
||||
backToMain: 'メインに戻る',
|
||||
loadingConfig: '設定を読み込み中...',
|
||||
loadingConfigFailed: '設定の読み込みに失敗しました',
|
||||
settingsApplied: '設定を適用しました',
|
||||
settingsApplyFailed: '設定の適用に失敗しました',
|
||||
serverRestarted: 'サーバーを再起動しました',
|
||||
serverRestartFailed: 'サーバーの再起動に失敗しました',
|
||||
settingsReset: 'デフォルトを復元しました',
|
||||
settingsResetFailed: 'デフォルトの復元に失敗しました',
|
||||
desktopOnly: 'デスクトップ専用',
|
||||
desktopOnlyDesc: 'デスクトップ設定はデスクトップクライアントのみ利用できます',
|
||||
version: 'バージョン',
|
||||
saving: '保存中...'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
ui/src/lib/desktop.ts
Normal file
52
ui/src/lib/desktop.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type DesktopServerConfig = {
|
||||
host: string
|
||||
port: number
|
||||
logLevel: string
|
||||
enableWebUI: boolean
|
||||
enableOfflineMode: boolean
|
||||
workerIdleTimeout: number
|
||||
workersPerLanguage: number
|
||||
apiToken: string
|
||||
logDir: string
|
||||
logToFile: boolean
|
||||
logConsole: boolean
|
||||
logRequests: boolean
|
||||
maxLengthBreak: number
|
||||
checkUpdate: boolean
|
||||
cacheSize: number
|
||||
modelDir: string
|
||||
configDir: string
|
||||
}
|
||||
|
||||
export type DesktopConfig = {
|
||||
locale: string
|
||||
server: DesktopServerConfig
|
||||
}
|
||||
|
||||
export type DesktopConfigResponse = {
|
||||
config: DesktopConfig
|
||||
status: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export const isDesktop = () => Boolean(window.mtranDesktop?.isDesktop)
|
||||
|
||||
export async function getDesktopConfig() {
|
||||
if (!window.mtranDesktop) return null
|
||||
return window.mtranDesktop.getConfig()
|
||||
}
|
||||
|
||||
export async function applyDesktopConfig(config: DesktopConfig) {
|
||||
if (!window.mtranDesktop) return null
|
||||
return window.mtranDesktop.applyConfig(config)
|
||||
}
|
||||
|
||||
export async function resetDesktopConfig() {
|
||||
if (!window.mtranDesktop) return null
|
||||
return window.mtranDesktop.resetConfig()
|
||||
}
|
||||
|
||||
export async function restartDesktopServer() {
|
||||
if (!window.mtranDesktop) return null
|
||||
return window.mtranDesktop.restartServer()
|
||||
}
|
||||
Reference in New Issue
Block a user