feat: added i18n support; added Simplified Chinese support

This commit is contained in:
Xiaohan-Tian
2026-05-30 20:02:55 -07:00
parent c17d169dc6
commit 957f6db950
58 changed files with 3354 additions and 520 deletions
+9 -7
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { FaTimes } from 'react-icons/fa';
import type { SettingsSection } from './SettingsPanel';
import { useI18n } from '../../i18n/useI18n';
interface SettingsSidebarProps {
activeSection: SettingsSection;
@@ -13,22 +14,23 @@ const SettingsSidebar: React.FC<SettingsSidebarProps> = ({
onSectionChange,
onClose
}) => {
const { t } = useI18n();
const sections = [
{ id: 'general' as SettingsSection, label: 'General' },
{ id: 'audio_io' as SettingsSection, label: 'Audio I/O' },
{ id: 'behavior' as SettingsSection, label: 'Behavior' },
{ id: 'templates' as SettingsSection, label: 'Templates' },
{ id: 'chord_guide' as SettingsSection, label: 'Chord Guide' }
{ id: 'general' as SettingsSection, label: t('settings.sidebar.general') },
{ id: 'audio_io' as SettingsSection, label: t('settings.sidebar.audioIo') },
{ id: 'behavior' as SettingsSection, label: t('settings.sidebar.behavior') },
{ id: 'templates' as SettingsSection, label: t('settings.sidebar.templates') },
{ id: 'chord_guide' as SettingsSection, label: t('settings.sidebar.chordGuide') }
];
return (
<div className="settings-sidebar">
<div className="settings-sidebar-header">
<h2>Settings</h2>
<h2>{t('settings.sidebar.title')}</h2>
<button
className="settings-close-btn"
onClick={onClose}
title="Close Settings"
title={t('settings.sidebar.close')}
>
<FaTimes />
</button>
@@ -2,6 +2,8 @@ import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import GeneralSettings from './GeneralSettings';
import { I18nContext } from '../../../i18n/I18nProvider';
import { translate } from '../../../i18n/translate';
const { localSeparatorModelCacheMock } = vi.hoisted(() => ({
localSeparatorModelCacheMock: {
@@ -11,6 +13,7 @@ const { localSeparatorModelCacheMock } = vi.hoisted(() => ({
}));
const configState = new Map<string, unknown>([
['general.language', 'auto'],
['general.llm_provider', 'local_browser'],
['general.persist_api_keys_non_localhost', false],
['general.openai.api_key', ''],
@@ -86,7 +89,23 @@ vi.mock('../../../util/local-separator/modelCache', () => ({
}));
describe('GeneralSettings', () => {
const renderSettings = () => render(
<I18nContext.Provider
value={{
languageSetting: 'auto',
resolvedLocale: 'en_us',
setLanguageSetting: async (value) => {
await configManagerMock.set('general.language', value);
},
t: (key, params) => translate(key, params, 'en_us'),
}}
>
<GeneralSettings />
</I18nContext.Provider>,
);
beforeEach(() => {
configState.set('general.language', 'auto');
configState.set('general.local_browser.context_length', 65536);
configManagerMock.get.mockClear();
configManagerMock.set.mockClear();
@@ -111,7 +130,7 @@ describe('GeneralSettings', () => {
});
it('renders the local context length selector and VRAM hint', async () => {
render(<GeneralSettings />);
renderSettings();
expect(await screen.findByText('Gemma 4 E4B Local Runtime')).toBeTruthy();
expect(screen.getByLabelText('Context Length')).toBeTruthy();
@@ -119,14 +138,14 @@ describe('GeneralSettings', () => {
});
it('initializes the local context length from config', async () => {
render(<GeneralSettings />);
renderSettings();
const select = await screen.findByLabelText('Context Length');
expect((select as HTMLSelectElement).value).toBe('65536');
});
it('persists local context length changes', async () => {
render(<GeneralSettings />);
renderSettings();
const select = await screen.findByLabelText('Context Length');
fireEvent.change(select, { target: { value: '131072' } });
@@ -137,7 +156,7 @@ describe('GeneralSettings', () => {
});
it('renders and persists local runtime download URLs', async () => {
render(<GeneralSettings />);
renderSettings();
expect(await screen.findByDisplayValue('https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task')).toBeTruthy();
expect(screen.getByDisplayValue('https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx')).toBeTruthy();
@@ -176,7 +195,7 @@ describe('GeneralSettings', () => {
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false);
render(<GeneralSettings />);
renderSettings();
expect(await screen.findByText('UVR5 Web Runtime')).toBeTruthy();
@@ -219,10 +238,24 @@ describe('GeneralSettings', () => {
reason: 'This host may not support the local browser runtime reliably because cross-origin isolation or SharedArrayBuffer is unavailable. COOP/COEP headers may be missing.',
};
render(<GeneralSettings />);
renderSettings();
expect(await screen.findByText('Gemma 4 E4B Local Runtime')).toBeTruthy();
expect(screen.queryByText(/may not support the local browser runtime reliably/i)).toBeNull();
expect(screen.getByText(/The local model downloads automatically/i)).toBeTruthy();
});
it('renders language first and persists language changes', async () => {
renderSettings();
const languageSelect = await screen.findByLabelText('Language');
expect(languageSelect).toBeTruthy();
expect(screen.getAllByRole('combobox')[0]).toBe(languageSelect);
fireEvent.change(languageSelect, { target: { value: 'zh_cn' } });
await waitFor(() => {
expect(configManagerMock.set).toHaveBeenCalledWith('general.language', 'zh_cn');
});
});
});
@@ -2,6 +2,8 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { ConfigManager } from '../../../core/config/ConfigManager';
import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager';
import { LocalSeparatorModelCache } from '../../../util/local-separator/modelCache';
import { useI18n } from '../../../i18n/useI18n';
import type { LanguageSetting } from '../../../i18n/types';
import {
formatLocalLLMContextLength,
LOCAL_LLM_CONTEXT_LENGTH_OPTIONS,
@@ -18,6 +20,8 @@ import {
} from '../../../util/local-separator/config';
const GeneralSettings: React.FC = () => {
const { t, setLanguageSetting } = useI18n();
const [language, setLanguage] = useState<LanguageSetting>('auto');
const [llmProvider, setLlmProvider] = useState<string>(LOCAL_LLM_PROVIDER_KEY);
const [openaiKey, setOpenaiKey] = useState<string>('');
const [openaiModel, setOpenaiModel] = useState<string>('');
@@ -90,6 +94,7 @@ const GeneralSettings: React.FC = () => {
await configManager.initialize();
}
setLanguage(((configManager.get('general.language') as LanguageSetting | undefined) ?? 'auto'));
setLlmProvider((configManager.get('general.llm_provider') as string) || LOCAL_LLM_PROVIDER_KEY);
setOpenaiKey((configManager.get('general.openai.api_key') as string) || '');
setOpenaiModel((configManager.get('general.openai.model') as string) || '');
@@ -153,6 +158,15 @@ const GeneralSettings: React.FC = () => {
}
};
const handleLanguageChange = async (value: LanguageSetting) => {
setLanguage(value);
try {
await setLanguageSetting(value);
} catch (error) {
console.error('Failed to save language:', error);
}
};
const handleOpenaiKeyChange = (value: string) => {
setOpenaiKey(value);
debouncedSave('general.openai.api_key', value);
@@ -322,45 +336,66 @@ const GeneralSettings: React.FC = () => {
return (
<div className="settings-section">
<div className="settings-section-header">
<h3>General</h3>
<h3>{t('settings.general.title')}</h3>
</div>
<div className="settings-section-content">
<div className="settings-group">
<h4>LLM Provider</h4>
<div className="settings-item">
<label className="settings-label" htmlFor="general-language-select">
{t('settings.general.language.label')}
</label>
<select
id="general-language-select"
className="settings-select"
value={language}
onChange={(e) => void handleLanguageChange(e.target.value as LanguageSetting)}
>
<option value="auto">{t('settings.general.language.auto')}</option>
<option value="en_us">{t('settings.general.language.en_us')}</option>
<option value="zh_cn">{t('settings.general.language.zh_cn')}</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{t('settings.general.language.help')}
</div>
</div>
</div>
<div className="settings-group">
<h4>{t('settings.general.llmProvider.section')}</h4>
<div className="settings-item">
<label className="settings-label">
LLM Provider
{t('settings.general.llmProvider.label')}
</label>
<select
className="settings-select"
value={llmProvider}
onChange={(e) => handleLlmProviderChange(e.target.value)}
>
<option value={LOCAL_LLM_PROVIDER_KEY}>Local LLM (Browser)</option>
<option value="openai">OpenAI</option>
<option value={LOCAL_LLM_PROVIDER_KEY}>{t('settings.general.llmProvider.local')}</option>
<option value="openai">{t('settings.general.llmProvider.openai')}</option>
{/* <option value="gemini">Gemini</option>
<option value="claude">Claude</option> */}
<option value="claude_openrouter">Claude (via OpenRouter)</option>
<option value="openai_compatible">OpenAI Compatible (e.g. OpenRouter, Ollama)</option>
<option value="claude_openrouter">{t('settings.general.llmProvider.claudeOpenRouter')}</option>
<option value="openai_compatible">{t('settings.general.llmProvider.openaiCompatible')}</option>
</select>
</div>
<div className="settings-item">
<label className="settings-label">
Persist API Keys on Non-Localhost
{t('settings.general.persistKeys.label')}
</label>
<select
className="settings-select"
value={persistApiKeysNonLocalhost ? 'yes' : 'no'}
onChange={(e) => handlePersistApiKeysNonLocalhostChange(e.target.value)}
>
<option value="no">No</option>
<option value="yes">Yes</option>
<option value="no">{t('settings.no')}</option>
<option value="yes">{t('settings.yes')}</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
When enabled, API keys will be saved to browser storage even on non-localhost environments. Warning: This may increase security vulnerability to XSS attacks.
{t('settings.general.persistKeys.help')}
</div>
</div>
</div>
@@ -376,20 +411,20 @@ const GeneralSettings: React.FC = () => {
<div className="settings-item">
<label className="settings-label">
Cached Model Status
{t('settings.general.localRuntime.cachedStatus')}
</label>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{localModelState.isChecking
? 'Checking local model cache...'
? t('settings.general.localRuntime.cacheChecking')
: localModelState.isCached
? 'Downloaded in browser cache.'
: 'Not downloaded yet.'}
? t('settings.general.localRuntime.cacheDownloaded')
: t('settings.general.localRuntime.cacheMissing')}
</div>
</div>
<div className="settings-item">
<label className="settings-label" htmlFor="local-llm-context-length">
Context Length
{t('settings.general.localRuntime.contextLength')}
</label>
<select
id="local-llm-context-length"
@@ -404,13 +439,13 @@ const GeneralSettings: React.FC = () => {
))}
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Larger context lengths require more VRAM and may also reduce performance as conversations become longer.
{t('settings.general.localRuntime.contextHelp')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Download URL
{t('settings.general.localRuntime.downloadUrl')}
</label>
<input
type="text"
@@ -420,7 +455,7 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleLocalModelUrlChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Changing this URL may break downloads or point to an incompatible model file.{' '}
{t('settings.general.localRuntime.downloadHelp')}{' '}
<a
href="#"
onClick={(e) => {
@@ -429,14 +464,14 @@ const GeneralSettings: React.FC = () => {
}}
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
>
Restore default
{t('settings.restoreDefault')}
</a>
</div>
</div>
{!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
The local model downloads automatically the next time you chat with `Local LLM (Browser)`.
{t('settings.general.localRuntime.autoDownload')}
</div>
)}
@@ -470,17 +505,17 @@ const GeneralSettings: React.FC = () => {
onClick={() => void handleDeleteLocalModel()}
disabled={localModelState.isDeleting || localModelState.isDownloading || !localModelState.isCached}
>
{localModelState.isDeleting ? 'Deleting...' : 'Delete Cached Model'}
{localModelState.isDeleting ? t('settings.deleting') : t('settings.deleteCachedModel')}
</button>
</div>
</div>
<div className="settings-group">
<h4>UVR5 Web Runtime</h4>
<h4>{t('settings.general.uvr5.section')}</h4>
<div className="settings-item">
<label className="settings-label">
UVR-MDX-NET-Inst_HQ_3 Download URL
{t('settings.general.uvr5.downloadUrl')}
</label>
<input
type="text"
@@ -490,7 +525,7 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleUvr5ModelUrlChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Changing this URL may break downloads or point to an incompatible model file.{' '}
{t('settings.general.modelUrl.help')}{' '}
<a
href="#"
onClick={(e) => {
@@ -501,7 +536,7 @@ const GeneralSettings: React.FC = () => {
}}
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
>
Restore default
{t('settings.restoreDefault')}
</a>
</div>
</div>
@@ -513,13 +548,13 @@ const GeneralSettings: React.FC = () => {
onClick={() => void handleDeleteUvr5Model()}
disabled={isCheckingUvr5ModelCache || isDeletingUvr5Model || !isUvr5ModelCached}
>
{isDeletingUvr5Model ? 'Deleting...' : 'Delete Cached Model'}
{isDeletingUvr5Model ? t('settings.deleting') : t('settings.deleteCachedModel')}
</button>
</div>
<div className="settings-item">
<label className="settings-label">
htdemucs_4s Download URL
{t('settings.general.htdemucs.downloadUrl')}
</label>
<input
type="text"
@@ -529,7 +564,7 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleHtdemucsModelUrlChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Changing this URL may break downloads or point to an incompatible model file.{' '}
{t('settings.general.modelUrl.help')}{' '}
<a
href="#"
onClick={(e) => {
@@ -540,7 +575,7 @@ const GeneralSettings: React.FC = () => {
}}
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
>
Restore default
{t('settings.restoreDefault')}
</a>
</div>
</div>
@@ -552,37 +587,37 @@ const GeneralSettings: React.FC = () => {
onClick={() => void handleDeleteHtdemucsModel()}
disabled={isCheckingUvr5ModelCache || isDeletingHtdemucsModel || !isHtdemucsModelCached}
>
{isDeletingHtdemucsModel ? 'Deleting...' : 'Delete Cached Model'}
{isDeletingHtdemucsModel ? t('settings.deleting') : t('settings.deleteCachedModel')}
</button>
</div>
</div>
<div className="settings-group">
<h4>OpenAI</h4>
<h4>{t('settings.general.openai.section')}</h4>
<div className="settings-item">
<label className="settings-label">
Key
{t('settings.general.openai.key')}
</label>
<input
type="password"
className="settings-input"
placeholder="Enter your OpenAI API key"
placeholder={t('settings.general.openai.keyPlaceholder')}
value={openaiKey}
onChange={(e) => handleOpenaiKeyChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{isLocalEnvironment
? 'Keys are persisted locally (the IndexedDB in your browser).'
? t('settings.general.keys.persisted')
: persistApiKeysNonLocalhost
? 'Keys are persisted locally (the IndexedDB in your browser).'
: 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.'}
? t('settings.general.keys.persisted')
: t('settings.general.keys.sessionOnly')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Model
{t('settings.general.openai.model')}
</label>
<select
className="settings-select"
@@ -601,18 +636,18 @@ const GeneralSettings: React.FC = () => {
<div className="settings-item">
<label className="settings-label">
Flex Mode
{t('settings.general.openai.flexMode')}
</label>
<select
className="settings-select"
value={openaiFlex ? 'yes' : 'no'}
onChange={(e) => handleOpenaiFlexChange(e.target.value)}
>
<option value="no">No</option>
<option value="yes">Yes</option>
<option value="no">{t('settings.no')}</option>
<option value="yes">{t('settings.yes')}</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Flex Mode uses OpenAI's flexible service tier. Pros: potential cost savings and higher throughput during busy periods. Cons: variable latency and possible queueing/deprioritization. Applies only to the OpenAI provider; no effect for OpenAI Compatible servers.
{t('settings.general.openai.flexHelp')}
</div>
</div>
</div>
@@ -678,31 +713,31 @@ const GeneralSettings: React.FC = () => {
</div> */}
<div className="settings-group">
<h4>Anthropic Claude (via OpenRouter)</h4>
<h4>{t('settings.general.claudeOpenRouter.section')}</h4>
<div className="settings-item">
<label className="settings-label">
Key
{t('settings.general.openai.key')}
</label>
<input
type="password"
className="settings-input"
placeholder="Enter your Claude API key"
placeholder={t('settings.general.claudeOpenRouter.keyPlaceholder')}
value={claudeOpenRouterKey}
onChange={(e) => handleClaudeOpenRouterKeyChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{isLocalEnvironment
? 'Keys are persisted locally (the IndexedDB in your browser).'
? t('settings.general.keys.persisted')
: persistApiKeysNonLocalhost
? 'Keys are persisted locally (the IndexedDB in your browser).'
: 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.'}
? t('settings.general.keys.persisted')
: t('settings.general.keys.sessionOnly')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Base URL
{t('settings.general.baseUrl')}
</label>
<input
type="text"
@@ -712,13 +747,13 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleClaudeOpenRouterBaseUrlChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
This is the base URL for the OpenRouter API. Please do not change this unless you know what you are doing.
{t('settings.general.claudeOpenRouter.baseUrlHelp')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Model
{t('settings.general.openai.model')}
</label>
<select
className="settings-select"
@@ -736,41 +771,41 @@ const GeneralSettings: React.FC = () => {
</div>
<div className="settings-group">
<h4>OpenAI Compatible Server</h4>
<h4>{t('settings.general.openaiCompatible.section')}</h4>
<div className="settings-item">
<label className="settings-label">
Key
{t('settings.general.openai.key')}
</label>
<input
type="password"
className="settings-input"
placeholder="Enter your API key"
placeholder={t('settings.general.openaiCompatible.keyPlaceholder')}
value={compatibleKey}
onChange={(e) => handleCompatibleKeyChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{isLocalEnvironment
? 'Keys are persisted locally (the IndexedDB in your browser).'
? t('settings.general.keys.persisted')
: persistApiKeysNonLocalhost
? 'Keys are persisted locally (the IndexedDB in your browser).'
: 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.'}
? t('settings.general.keys.persisted')
: t('settings.general.keys.sessionOnly')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Base URL
{t('settings.general.baseUrl')}
</label>
<input
type="text"
className="settings-input"
placeholder="e.g. https://openrouter.ai/api/v1"
placeholder={t('settings.general.openaiCompatible.baseUrlPlaceholder')}
value={compatibleBaseUrl}
onChange={(e) => handleCompatibleBaseUrlChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Quick presets:{' '}
{t('settings.general.openaiCompatible.baseUrlHelp')}{' '}
<a
href="#"
onClick={(e) => {
@@ -819,12 +854,12 @@ const GeneralSettings: React.FC = () => {
<div className="settings-item">
<label className="settings-label">
Model
{t('settings.general.openai.model')}
</label>
<input
type="text"
className="settings-input"
placeholder="e.g. qwen3:30b"
placeholder={t('settings.general.openaiCompatible.modelPlaceholder')}
value={compatibleModel}
onChange={(e) => handleCompatibleModelChange(e.target.value)}
/>
@@ -832,17 +867,17 @@ const GeneralSettings: React.FC = () => {
</div>
<div className="settings-group">
<h4>Soundfont Settings</h4>
<h4>{t('settings.general.soundfont.section')}</h4>
{soundfontServerManaged && (
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
Soundfont configuration is managed by the server (kgone-server.json). Settings are read-only.
{t('settings.general.soundfont.managed')}
</div>
)}
<div className="settings-item">
<label className="settings-label">
Base URL
{t('settings.general.soundfont.baseUrl')}
</label>
<input
type="text"
@@ -853,7 +888,7 @@ const GeneralSettings: React.FC = () => {
disabled={soundfontServerManaged}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Changing this URL to an incompatible soundfont source may cause some instruments to sound wrong or not play.{' '}
{t('settings.general.soundfont.baseUrlHelp')}{' '}
<a
href="#"
onClick={(e) => {
@@ -862,24 +897,24 @@ const GeneralSettings: React.FC = () => {
}}
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
>
Restore default
{t('settings.restoreDefault')}
</a>
</div>
</div>
</div>
<div className="settings-group">
<h4>K.G.One Settings</h4>
<h4>{t('settings.general.kgone.section')}</h4>
{kgoneServerManaged && (
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
K.G.One configuration is managed by the server (kgone-server.json). Settings are read-only.
{t('settings.general.kgone.managed')}
</div>
)}
<div className="settings-item">
<label className="settings-label">
Enable K.G.One Integration
{t('settings.general.kgone.enabled')}
</label>
<select
className="settings-select"
@@ -887,14 +922,14 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleKgoneEnabledChange(e.target.value === 'true')}
disabled={kgoneServerManaged}
>
<option value="false">Disabled</option>
<option value="true">Enabled</option>
<option value="false">{t('settings.general.kgone.disabled')}</option>
<option value="true">{t('settings.general.kgone.enabledOption')}</option>
</select>
</div>
<div className="settings-item">
<label className="settings-label">
Server Base URL
{t('settings.general.kgone.serverBaseUrl')}
</label>
<input
type="text"
@@ -905,7 +940,7 @@ const GeneralSettings: React.FC = () => {
disabled={kgoneServerManaged}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Base URL of a running K.G.One Music Studio server. Used for full-song generation, clip generation, and stem separation.
{t('settings.general.kgone.serverBaseUrlHelp')}
</div>
</div>
</div>