Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ tauri-plugin-global-shortcut = { workspace = true }
log = { workspace = true }
rusqlite = { workspace = true }
chrono = "0.4"
tracing-subscriber = { workspace = true }
tracing-subscriber = { workspace = true }
futures = { workspace = true }
232 changes: 231 additions & 1 deletion apps/desktop/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,36 @@
}
.field-error { color: var(--danger); }
.field-warning { color: var(--warning); }
.connection-test-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
margin-top: 2px;
}
.connection-test-copy {
min-width: 0;
flex: 1;
}
.connection-test-status {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
line-height: 1.45;
}
.connection-test-status.success { color: var(--success); }
.connection-test-status.error { color: var(--danger); }
.connection-test-status.testing { color: var(--warning); }
.connection-test-time {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
line-height: 1.45;
}
input[aria-invalid="true"],
textarea[aria-invalid="true"],
select[aria-invalid="true"] {
Expand Down Expand Up @@ -1197,6 +1227,13 @@ <h1 class="page-title" data-i18n="settings.page_title"></h1>
<input id="asr-language" type="text" placeholder="en, zh, ja..." aria-describedby="asr-language-help" />
<div class="field-help" id="asr-language-help" data-i18n="settings.asr_language_help"></div>
</div>
<div class="connection-test-row">
<div class="connection-test-copy">
<div class="connection-test-status" id="asr-test-status" role="status" aria-live="polite"></div>
<div class="connection-test-time" id="asr-test-time"></div>
</div>
<button class="btn-action" type="button" id="asr-test-connection" data-i18n="settings.test_connection"></button>
</div>
</div>

<div class="settings-subsection">
Expand Down Expand Up @@ -1250,6 +1287,13 @@ <h1 class="page-title" data-i18n="settings.page_title"></h1>
<input id="llm-model" type="text" placeholder="gpt-4o-mini" aria-describedby="llm-model-help" />
<div class="field-help" id="llm-model-help" data-i18n="settings.llm_model_help"></div>
</div>
<div class="connection-test-row">
<div class="connection-test-copy">
<div class="connection-test-status" id="llm-test-status" role="status" aria-live="polite"></div>
<div class="connection-test-time" id="llm-test-time"></div>
</div>
<button class="btn-action" type="button" id="llm-test-connection" data-i18n="settings.test_connection"></button>
</div>
</div>
<div class="field-help" id="llm-disabled-help" data-i18n="settings.llm_disabled_advanced"></div>
</div>
Expand Down Expand Up @@ -1440,6 +1484,7 @@ <h1 class="page-title" data-i18n="settings.page_title"></h1>
});
// Refresh data when switching to these views
if (view === 'history') loadHistory();
if (view === 'settings') refreshConnectionTestTimes();
if (view === 'about') loadSystemInfo();
}

Expand Down Expand Up @@ -1600,6 +1645,7 @@ <h1 class="page-title" data-i18n="settings.page_title"></h1>
document.getElementById('asr-api-key').value = connection.api_key || '';
document.getElementById('asr-model').value = connection.model || '';
document.getElementById('asr-language').value = connection.language || '';
loadConnectionTestStatus('asr');
}

function loadLlmConnectionFields(connection) {
Expand All @@ -1609,6 +1655,7 @@ <h1 class="page-title" data-i18n="settings.page_title"></h1>
document.getElementById('llm-endpoint').value = connection.endpoint || '';
document.getElementById('llm-api-key').value = connection.api_key || '';
document.getElementById('llm-model').value = connection.model || '';
loadConnectionTestStatus('llm');
}

function saveAsrConnectionFields(connectionId) {
Expand Down Expand Up @@ -1659,6 +1706,178 @@ <h1 class="page-title" data-i18n="settings.page_title"></h1>
return !asrEndpointInvalid && !llmEndpointInvalid;
}

function getConnectionTestConnection(kind) {
return kind === 'asr' ? getActiveAsrConnection() : getActiveLlmConnection();
}

function getConnectionTestId(kind) {
const connection = getConnectionTestConnection(kind);
return connection?.id || 'default';
}

function getAsrConnectionDraft() {
return {
connection_id: getConnectionTestId('asr'),
provider: document.getElementById('asr-provider').value || 'mock',
endpoint: document.getElementById('asr-endpoint').value.trim() || null,
api_key: document.getElementById('asr-api-key').value.trim() || null,
model: document.getElementById('asr-model').value.trim() || null,
language: document.getElementById('asr-language').value.trim() || null,
};
}

function getLlmConnectionDraft() {
return {
connection_id: getConnectionTestId('llm'),
provider: document.getElementById('llm-provider').value || 'mock',
endpoint: document.getElementById('llm-endpoint').value.trim() || null,
api_key: document.getElementById('llm-api-key').value.trim() || null,
model: document.getElementById('llm-model').value.trim() || null,
language: null,
};
}

function formatRelativeTestTime(timestamp) {
if (!timestamp) return typexI18n.t('settings.connection_not_tested');
const time = typeof timestamp === 'number' ? timestamp : Date.parse(timestamp);
if (Number.isNaN(time)) return typexI18n.t('settings.connection_not_tested');
const elapsedMs = Date.now() - time;
if (!Number.isFinite(elapsedMs) || elapsedMs < 60_000) {
return typexI18n.t('settings.connection_last_tested_just_now');
}
Comment on lines +1740 to +1747

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If timestamp is an invalid date string, Date.parse(timestamp) returns NaN. Since Number.isFinite(NaN) is false, the condition !Number.isFinite(elapsedMs) evaluates to true, causing the function to incorrectly return "Last tested just now" instead of handling the parsing failure. Adding a check for Number.isNaN(time) prevents this bug.

Suggested change
function formatRelativeTestTime(timestamp) {
if (!timestamp) return typexI18n.t('settings.connection_not_tested');
const time = typeof timestamp === 'number' ? timestamp : Date.parse(timestamp);
const elapsedMs = Date.now() - time;
if (!Number.isFinite(elapsedMs) || elapsedMs < 60_000) {
return typexI18n.t('settings.connection_last_tested_just_now');
}
function formatRelativeTestTime(timestamp) {
if (!timestamp) return typexI18n.t('settings.connection_not_tested');
const time = typeof timestamp === 'number' ? timestamp : Date.parse(timestamp);
if (Number.isNaN(time)) return typexI18n.t('settings.connection_not_tested');
const elapsedMs = Date.now() - time;
if (elapsedMs < 60_000) {
return typexI18n.t('settings.connection_last_tested_just_now');
}

const minutes = Math.floor(elapsedMs / 60_000);
if (minutes < 60) {
const key = minutes === 1
? 'settings.connection_last_tested_minute_ago'
: 'settings.connection_last_tested_minutes_ago';
return typexI18n.t(key).replace('{0}', minutes);
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
const key = hours === 1
? 'settings.connection_last_tested_hour_ago'
: 'settings.connection_last_tested_hours_ago';
return typexI18n.t(key).replace('{0}', hours);
}
const date = new Date(time);
return typexI18n.t('settings.connection_last_tested_at').replace('{0}', date.toLocaleString());
}

function setConnectionTestStatus(kind, state, message, timestamp, messageKey = '') {
const statusEl = document.getElementById(`${kind}-test-status`);
const timeEl = document.getElementById(`${kind}-test-time`);
const button = document.getElementById(`${kind}-test-connection`);
if (!statusEl || !timeEl || !button) return;

statusEl.classList.remove('success', 'error', 'testing');
if (state) statusEl.classList.add(state);
statusEl.dataset.messageKey = messageKey || '';
statusEl.dataset.fallbackMessage = message || '';
statusEl.textContent = messageKey
? typexI18n.t(messageKey)
: (message || typexI18n.t('settings.connection_not_tested'));
timeEl.dataset.timestamp = timestamp || '';
timeEl.textContent = formatRelativeTestTime(timestamp);
button.disabled = state === 'testing';
button.textContent = state === 'testing'
? typexI18n.t('settings.connection_testing_short')
: typexI18n.t('settings.test_connection');
}

async function loadConnectionTestStatus(kind) {
try {
const status = await invoke('get_provider_connection_status', {
kind,
connectionId: getConnectionTestId(kind),
});
renderConnectionTestStatus(kind, status);
} catch (e) {
console.warn('Failed to load connection test status:', e);
setConnectionTestStatus(kind, null, typexI18n.t('settings.connection_not_tested'), null);
}
}

function connectionTestMessage(result) {
if (result?.message_key) return typexI18n.t(result.message_key);
if (result?.ok) return typexI18n.t('settings.connection_success');
return result?.message || typexI18n.t('settings.connection_failed');
}

function renderConnectionTestStatus(kind, status) {
if (!status) {
setConnectionTestStatus(kind, null, typexI18n.t('settings.connection_not_tested'), null);
return;
}
setConnectionTestStatus(
kind,
status.ok ? 'success' : 'error',
connectionTestMessage(status),
status.tested_at,
status.message_key || ''
);
}

function refreshConnectionTestTimes() {
['asr', 'llm'].forEach(kind => {
const statusEl = document.getElementById(`${kind}-test-status`);
const timeEl = document.getElementById(`${kind}-test-time`);
const button = document.getElementById(`${kind}-test-connection`);
if (!statusEl || !timeEl || !button) return;

const messageKey = statusEl.dataset.messageKey || '';
const fallbackMessage = statusEl.dataset.fallbackMessage || '';
statusEl.textContent = messageKey
? typexI18n.t(messageKey)
: (fallbackMessage || typexI18n.t('settings.connection_not_tested'));
timeEl.textContent = formatRelativeTestTime(timeEl.dataset.timestamp || null);
if (!button.disabled) button.textContent = typexI18n.t('settings.test_connection');
});
}

async function testAsrConnection() {
if (!validateSettings()) return;
setConnectionTestStatus('asr', 'testing', typexI18n.t('settings.connection_testing'), new Date().toISOString());
const saved = await saveConfig({ reloadConnectionStatus: false });
if (!saved) {
await loadConnectionTestStatus('asr');
return;
}
setConnectionTestStatus('asr', 'testing', typexI18n.t('settings.connection_testing'), new Date().toISOString());
try {
const result = await invoke('test_asr_connection', { request: getAsrConnectionDraft() });
renderConnectionTestStatus('asr', result);
} catch (e) {
renderConnectionTestStatus('asr', {
ok: false,
provider: document.getElementById('asr-provider').value || 'mock',
message: String(e || typexI18n.t('settings.connection_failed')),
tested_at: new Date().toISOString(),
});
}
}
Comment on lines +1837 to +1857

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Await saveConfig() before running the connection test. This ensures that the draft settings are persisted to disk and any stale status is correctly reset before the test runs, preventing UI state mismatches.

Suggested change
async function testAsrConnection() {
if (!validateSettings()) return;
saveAsrConnectionFields();
setConnectionTestStatus('asr', 'testing', typexI18n.t('settings.connection_testing'), new Date().toISOString());
try {
const result = await invoke('test_asr_connection', { request: getAsrConnectionDraft() });
renderConnectionTestStatus('asr', result);
} catch (e) {
renderConnectionTestStatus('asr', {
ok: false,
provider: document.getElementById('asr-provider').value || 'mock',
message: String(e || typexI18n.t('settings.connection_failed')),
tested_at: new Date().toISOString(),
});
}
}
async function testAsrConnection() {
if (!validateSettings()) return;
if (!await saveConfig()) return;
setConnectionTestStatus('asr', 'testing', typexI18n.t('settings.connection_testing'), new Date().toISOString());
try {
const result = await invoke('test_asr_connection', { request: getAsrConnectionDraft() });
renderConnectionTestStatus('asr', result);
} catch (e) {
renderConnectionTestStatus('asr', {
ok: false,
provider: document.getElementById('asr-provider').value || 'mock',
message: String(e || typexI18n.t('settings.connection_failed')),
tested_at: new Date().toISOString(),
});
}
}


async function testLlmConnection() {
if (!validateSettings()) return;
setConnectionTestStatus('llm', 'testing', typexI18n.t('settings.connection_testing'), new Date().toISOString());
const saved = await saveConfig({ reloadConnectionStatus: false });
if (!saved) {
await loadConnectionTestStatus('llm');
return;
}
setConnectionTestStatus('llm', 'testing', typexI18n.t('settings.connection_testing'), new Date().toISOString());
try {
const result = await invoke('test_llm_connection', { request: getLlmConnectionDraft() });
renderConnectionTestStatus('llm', result);
} catch (e) {
renderConnectionTestStatus('llm', {
ok: false,
provider: document.getElementById('llm-provider').value || 'mock',
message: String(e || typexI18n.t('settings.connection_failed')),
tested_at: new Date().toISOString(),
});
}
}
Comment on lines +1859 to +1879

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Await saveConfig() before running the connection test. This ensures that the draft settings are persisted to disk and any stale status is correctly reset before the test runs, preventing UI state mismatches.

Suggested change
async function testLlmConnection() {
if (!validateSettings()) return;
saveLlmConnectionFields();
setConnectionTestStatus('llm', 'testing', typexI18n.t('settings.connection_testing'), new Date().toISOString());
try {
const result = await invoke('test_llm_connection', { request: getLlmConnectionDraft() });
renderConnectionTestStatus('llm', result);
} catch (e) {
renderConnectionTestStatus('llm', {
ok: false,
provider: document.getElementById('llm-provider').value || 'mock',
message: String(e || typexI18n.t('settings.connection_failed')),
tested_at: new Date().toISOString(),
});
}
}
async function testLlmConnection() {
if (!validateSettings()) return;
if (!await saveConfig()) return;
setConnectionTestStatus('llm', 'testing', typexI18n.t('settings.connection_testing'), new Date().toISOString());
try {
const result = await invoke('test_llm_connection', { request: getLlmConnectionDraft() });
renderConnectionTestStatus('llm', result);
} catch (e) {
renderConnectionTestStatus('llm', {
ok: false,
provider: document.getElementById('llm-provider').value || 'mock',
message: String(e || typexI18n.t('settings.connection_failed')),
tested_at: new Date().toISOString(),
});
}
}


function applyTheme(theme) {
const value = ['system', 'light', 'dark'].includes(theme) ? theme : 'system';
document.documentElement.dataset.theme = value;
Expand Down Expand Up @@ -1737,9 +1956,11 @@ <h1 class="page-title" data-i18n="settings.page_title"></h1>
applyDynamicLabels();
setSaveStatus(null, 'settings.save_idle');
validateSettings();
refreshConnectionTestTimes();
}

async function saveConfig() {
async function saveConfig(options = {}) {
const reloadConnectionStatus = options.reloadConnectionStatus !== false;
if (!currentConfig) return false;
if (!validateSettings()) {
setSaveStatus('error', 'settings.save_invalid');
Expand Down Expand Up @@ -1771,6 +1992,10 @@ <h1 class="page-title" data-i18n="settings.page_title"></h1>

try {
await invoke('save_config', { config: currentConfig });
if (reloadConnectionStatus) {
loadConnectionTestStatus('asr');
loadConnectionTestStatus('llm');
}
if (seq === saveSeq) setSaveStatus('saved', 'settings.save_saved');
return true;
} catch (e) {
Expand All @@ -1792,6 +2017,7 @@ <h1 class="page-title" data-i18n="settings.page_title"></h1>
// loadConfig will also call applyAll, but we want immediate UI refresh
if (currentConfig) currentConfig.ui.language = newLang;
applyDynamicLabels();
refreshConnectionTestTimes();
if (seq === saveSeq) setSaveStatus('saved', 'settings.save_saved');
} catch (e) {
console.error('Failed to set language:', e);
Expand Down Expand Up @@ -1883,6 +2109,10 @@ <h1 class="page-title" data-i18n="settings.page_title"></h1>
saveConfig();
});

document.getElementById('asr-test-connection').addEventListener('click', testAsrConnection);
document.getElementById('llm-test-connection').addEventListener('click', testLlmConnection);
setInterval(refreshConnectionTestTimes, 60_000);

// Auto-save on input for text/password/number/textarea fields
document.querySelectorAll('#view-settings input[type="text"], #view-settings input[type="password"], #view-settings input[type="number"], #view-settings textarea').forEach(el => {
el.addEventListener('input', () => {
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@
"settings.advanced_desc": "System prompt, plugin pipeline, and experimental performance options.",
"settings.llm_provider_settings": "AI Provider Settings",
"settings.llm_disabled_advanced": "Enable AI Polish above to configure provider, endpoint, model, and prompt.",
"settings.test_connection": "Test",
"settings.connection_testing": "Testing connection...",
"settings.connection_testing_short": "Testing...",
"settings.connection_success": "Connection OK",
"settings.connection_failed": "Connection failed",
"settings.mock_asr_ok": "Mock ASR is available.",
"settings.mock_llm_ok": "Mock LLM is available.",
"settings.connection_not_tested": "Not tested yet",
"settings.connection_last_tested_just_now": "Last tested just now",
"settings.connection_last_tested_minute_ago": "Last tested 1 minute ago",
"settings.connection_last_tested_minutes_ago": "Last tested {0} minutes ago",
"settings.connection_last_tested_hour_ago": "Last tested 1 hour ago",
"settings.connection_last_tested_hours_ago": "Last tested {0} hours ago",
"settings.connection_last_tested_at": "Last tested at {0}",
"aria.navigation": "Primary navigation",
"aria.minimize": "Minimize window",
"aria.close": "Close window",
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/frontend/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@
"settings.advanced_desc": "系统 Prompt、插件流水线和实验性性能选项。",
"settings.llm_provider_settings": "AI Provider 设置",
"settings.llm_disabled_advanced": "先在上方启用 AI 润色,再配置 provider、endpoint、model 和 prompt。",
"settings.test_connection": "测试",
"settings.connection_testing": "正在测试连接...",
"settings.connection_testing_short": "测试中...",
"settings.connection_success": "连接正常",
"settings.connection_failed": "连接失败",
"settings.mock_asr_ok": "Mock ASR 可用。",
"settings.mock_llm_ok": "Mock LLM 可用。",
"settings.connection_not_tested": "尚未测试",
"settings.connection_last_tested_just_now": "上次测试:刚刚",
"settings.connection_last_tested_minute_ago": "上次测试:1 分钟前",
"settings.connection_last_tested_minutes_ago": "上次测试:{0} 分钟前",
"settings.connection_last_tested_hour_ago": "上次测试:1 小时前",
"settings.connection_last_tested_hours_ago": "上次测试:{0} 小时前",
"settings.connection_last_tested_at": "上次测试:{0}",
"aria.navigation": "主导航",
"aria.minimize": "最小化窗口",
"aria.close": "关闭窗口",
Expand Down
Loading
Loading