feat(settings): first-class remote-core connection setting + live status (GH-4396) (#4406)

Co-authored-by: M3gA-Mind <elvin@mahadao.com>
This commit is contained in:
Mega Mind
2026-07-03 11:18:40 -07:00
committed by GitHub
co-authored by M3gA-Mind
parent ae41fcb402
commit 1ee92b0bc3
18 changed files with 991 additions and 0 deletions
@@ -0,0 +1,494 @@
/**
* CoreConnectionPanel — Settings → Core connection.
*
* Promotes the pre-existing "cloud" core mode (a persisted remote-core RPC
* URL + bearer token, previously reachable only from the pre-router
* BootCheckGate picker) into a first-class, in-app setting, and adds a live
* connect/failure status indicator.
*
* This deliberately reuses the existing cloud-mode plumbing — the `coreMode`
* Redux slice, `configPersistence` storage keys, and
* `testCoreRpcConnection` — rather than introducing a second remote-core
* mechanism. The shell-level env-var attach path (`OPENHUMAN_CORE_REUSE_EXISTING`,
* `OPENHUMAN_CORE_TOKEN`) is intentionally left as a documented dev-only
* override and is not surfaced here (GH-4396).
*
* Boot-gate hard-fail/fallback semantics are unchanged: switching mode here
* persists the choice and restarts the app so the normal BootCheckGate flow
* re-runs against the new mode. This panel only *surfaces* connection state;
* it does not change what happens when a configured core is unreachable.
*/
import { invoke } from '@tauri-apps/api/core';
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
clearCoreRpcTokenCache,
clearCoreRpcUrlCache,
testCoreRpcConnection,
} from '../../../services/coreRpcClient';
import { type CoreMode, setCoreMode } from '../../../store/coreModeSlice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { CORE_RPC_URL } from '../../../utils/config';
import {
clearStoredCoreToken,
isLocalOrPrivateNetworkHost,
isTauriEnvironment,
normalizeRpcUrl,
storeCoreMode,
storeCoreToken,
storeRpcUrl,
} from '../../../utils/configPersistence';
import { restartApp } from '../../../utils/tauriCommands/core';
import Button from '../../ui/Button';
import { SettingsRow, SettingsSection, SettingsSwitch, SettingsTextField } from '../controls';
import SettingsPanel from '../layout/SettingsPanel';
const log = debug('settings:core');
/** Live reachability of the currently-active core. */
type LiveStatus =
| { kind: 'checking' }
| { kind: 'connected' }
| { kind: 'authFailed' }
| { kind: 'unreachable'; reason: string };
/** Result of a one-shot "Test connection" against the typed remote inputs. */
type TestStatus =
| { kind: 'idle' }
| { kind: 'testing' }
| { kind: 'ok' }
| { kind: 'auth' }
| { kind: 'unreachable'; reason: string };
/**
* Resolve the URL the active core is actually reachable at. Cloud mode stores
* the user's chosen URL in Redux; local mode picks a dynamic port at launch,
* so the authoritative value lives in the Tauri shell (`core_rpc_url`).
*/
async function resolveActiveCoreUrl(coreMode: CoreMode): Promise<string | null> {
if (coreMode.kind === 'cloud') return coreMode.url;
if (!isTauriEnvironment()) return CORE_RPC_URL;
try {
return await invoke<string>('core_rpc_url');
} catch (err) {
log('resolveActiveCoreUrl: core_rpc_url invoke failed: %o', err);
return null;
}
}
const CONNECTION_TEST_TIMEOUT_MS = 10_000;
/**
* `testCoreRpcConnection` with a bounded deadline. `testCoreRpcConnection`
* supports an `AbortSignal` but the callers didn't pass one, so a non-responsive
* endpoint left the live status / Test button stuck in `checking`/`testing`
* until the platform socket timeout (minutes). Abort after
* CONNECTION_TEST_TIMEOUT_MS so the UI resolves to "unreachable" promptly.
*/
async function testCoreRpcConnectionWithTimeout(url: string, token?: string): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CONNECTION_TEST_TIMEOUT_MS);
try {
return await testCoreRpcConnection(url, token, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
const CoreConnectionPanel = () => {
const { t } = useT();
const dispatch = useAppDispatch();
const coreMode = useAppSelector(state => state.coreMode.mode);
// A non-Tauri web build cannot start a local core (no `start_core_process`),
// so the boot picker forces cloud mode there; keep that invariant here so a
// web user can't persist `local` and brick the next boot.
const canUseLocal = isTauriEnvironment();
// ── Editable form state ────────────────────────────────────────────────
// Seeded from the persisted cloud-mode config so the panel reflects the
// current setting on open.
const [useRemote, setUseRemote] = useState(!canUseLocal || coreMode.kind === 'cloud');
const [url, setUrl] = useState(coreMode.kind === 'cloud' ? coreMode.url : '');
const [token, setToken] = useState(coreMode.kind === 'cloud' ? (coreMode.token ?? '') : '');
const [formError, setFormError] = useState<string | null>(null);
const [testStatus, setTestStatus] = useState<TestStatus>({ kind: 'idle' });
const [saving, setSaving] = useState(false);
const [showToken, setShowToken] = useState(false);
// ── Live status indicator (against the currently-active core) ───────────
const [liveStatus, setLiveStatus] = useState<LiveStatus>({ kind: 'checking' });
const [activeUrl, setActiveUrl] = useState<string | null>(null);
const checkSeq = useRef(0);
const runLiveCheck = useCallback(async () => {
const seq = ++checkSeq.current;
setLiveStatus({ kind: 'checking' });
log('runLiveCheck: mode=%s', coreMode.kind);
const resolved = await resolveActiveCoreUrl(coreMode);
if (seq !== checkSeq.current) return; // superseded by a newer check
setActiveUrl(resolved);
if (!resolved) {
setLiveStatus({ kind: 'unreachable', reason: t('settings.about.serverUrlUnavailable') });
return;
}
try {
const response = await testCoreRpcConnectionWithTimeout(resolved);
if (seq !== checkSeq.current) return;
if (response.status === 401 || response.status === 403) {
log('runLiveCheck: auth failed (status=%d)', response.status);
setLiveStatus({ kind: 'authFailed' });
return;
}
if (!response.ok) {
log('runLiveCheck: HTTP %d', response.status);
setLiveStatus({ kind: 'unreachable', reason: `HTTP ${response.status}` });
return;
}
// Drain the body so the connection can be reused; a JSON-RPC error body
// on a 200 does not disprove reachability.
try {
await response.json();
} catch {
/* non-JSON body is unusual but still reachable */
}
log('runLiveCheck: connected');
setLiveStatus({ kind: 'connected' });
} catch (err) {
if (seq !== checkSeq.current) return;
const reason = err instanceof Error ? err.message : 'Connection failed';
log('runLiveCheck: errored: %o', err);
setLiveStatus({ kind: 'unreachable', reason });
}
}, [coreMode, t]);
useEffect(() => {
// runLiveCheck flips the status to `checking` synchronously; that is the
// intended entry transition for the live probe (also used by Recheck), not
// a cascading render.
// eslint-disable-next-line react-hooks/set-state-in-effect
void runLiveCheck();
}, [runLiveCheck]);
// ── Validation (mirrors the BootCheckGate cloud picker) ─────────────────
const validate = (): { url: string; token: string } | null => {
const rawUrl = url.trim();
if (!rawUrl) {
setFormError(t('bootCheck.invalidUrl'));
return null;
}
const normalized = normalizeRpcUrl(rawUrl);
try {
const parsed = new URL(normalized);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
setFormError(t('bootCheck.urlMustStartWith'));
return null;
}
// The separate token field is the only credential path; a
// `user:pass@host` URL would be persisted and echoed back in the active-URL
// description, leaking a secret. Reject it.
if (parsed.username || parsed.password) {
setFormError(t('bootCheck.validUrlRequired'));
return null;
}
} catch {
setFormError(t('bootCheck.validUrlRequired'));
return null;
}
const trimmedToken = token.trim();
if (!trimmedToken) {
setFormError(t('bootCheck.tokenRequired'));
return null;
}
setFormError(null);
return { url: normalized, token: trimmedToken };
};
const httpWarning = (() => {
if (!useRemote) return null;
const trimmed = url.trim();
if (!trimmed) return null;
try {
const parsed = new URL(normalizeRpcUrl(trimmed));
if (parsed.protocol === 'http:' && !isLocalOrPrivateNetworkHost(parsed.hostname)) {
return t('bootCheck.httpPublicWarning');
}
} catch {
/* validate() surfaces parse errors on save */
}
return null;
})();
const handleTest = async () => {
const validated = validate();
if (!validated) return;
setTestStatus({ kind: 'testing' });
log('handleTest: url=%s tokenLen=%d', validated.url, validated.token.length);
try {
const response = await testCoreRpcConnectionWithTimeout(validated.url, validated.token);
if (response.status === 401 || response.status === 403) {
setTestStatus({ kind: 'auth' });
return;
}
if (!response.ok) {
setTestStatus({ kind: 'unreachable', reason: `HTTP ${response.status}` });
return;
}
try {
await response.json();
} catch {
/* reachable regardless of body shape */
}
setTestStatus({ kind: 'ok' });
} catch (err) {
const reason = err instanceof Error ? err.message : 'Connection failed';
setTestStatus({ kind: 'unreachable', reason });
}
};
// ── Dirty detection ─────────────────────────────────────────────────────
// Enable Save only when the desired mode differs from the persisted one.
const isDirty = (() => {
if (!useRemote) return coreMode.kind !== 'local';
if (coreMode.kind !== 'cloud') return true;
return (
normalizeRpcUrl(url.trim() || '') !== coreMode.url || token.trim() !== (coreMode.token ?? '')
);
})();
const handleSave = async () => {
if (saving) return;
if (useRemote) {
const validated = validate();
if (!validated) return;
log(
'handleSave: switching to remote core url=%s tokenLen=%d',
validated.url,
validated.token.length
);
setSaving(true);
// NOTE: the bearer is persisted in plain localStorage via storeCoreToken,
// matching the existing cloud-mode picker. A renderer XSS could read it
// (security audit U3). Migrating this to the OS keychain is a known
// follow-up tracked with the rest of cloud-mode token storage; this panel
// intentionally does not block on it (GH-4396 scope decision).
storeRpcUrl(validated.url);
storeCoreToken(validated.token);
storeCoreMode('cloud');
clearCoreRpcUrlCache();
clearCoreRpcTokenCache();
dispatch(setCoreMode({ kind: 'cloud', url: validated.url, token: validated.token }));
} else {
log('handleSave: switching to local core');
setSaving(true);
storeRpcUrl('');
clearStoredCoreToken();
storeCoreMode('local');
clearCoreRpcUrlCache();
clearCoreRpcTokenCache();
dispatch(setCoreMode({ kind: 'local' }));
}
// Restart so BootCheckGate re-runs against the new mode (unchanged
// boot-gate semantics). In dev this is a renderer reload. The mode is
// already persisted + dispatched above, so on restart failure recover the
// button instead of wedging it in `saving` forever.
try {
await restartApp();
} catch (err) {
log('handleSave: restartApp failed: %o', err);
setSaving(false);
setFormError(t('common.error'));
}
};
// ── Live status rendering ───────────────────────────────────────────────
const statusText = (() => {
switch (liveStatus.kind) {
case 'checking':
return t('settings.core.statusChecking');
case 'connected':
return coreMode.kind === 'cloud'
? t('settings.core.statusConnectedRemote')
: t('settings.core.statusConnectedLocal');
case 'authFailed':
return t('settings.core.statusAuthFailed');
case 'unreachable':
return `${t('settings.core.statusUnreachable')}${liveStatus.reason}`;
}
})();
const statusDotClass = (() => {
switch (liveStatus.kind) {
case 'connected':
return 'bg-sage-500';
case 'checking':
return 'bg-amber-400 animate-pulse';
default:
return 'bg-coral-500';
}
})();
return (
<SettingsPanel description={t('settings.core.menuDesc')}>
{/* Live status indicator */}
<SettingsSection title={t('settings.about.connection')}>
<SettingsRow
label={statusText}
description={activeUrl ?? undefined}
control={
<div className="flex items-center gap-2">
<span
className={`inline-block h-2.5 w-2.5 rounded-full flex-shrink-0 ${statusDotClass}`}
aria-hidden="true"
data-testid="core-status-dot"
/>
<Button
variant="secondary"
size="xs"
onClick={() => void runLiveCheck()}
disabled={liveStatus.kind === 'checking'}>
{t('settings.core.recheck')}
</Button>
</div>
}
/>
<div className="px-4 py-3" data-testid="core-status-text">
<p className="text-[11px] text-content-muted leading-relaxed">
{coreMode.kind === 'cloud'
? t('settings.about.connectionHelperCloud')
: t('settings.about.connectionHelperLocal')}
</p>
</div>
</SettingsSection>
{/* Remote-core toggle + config */}
<SettingsSection>
<SettingsRow
label={t('settings.core.useRemoteToggle')}
description={t('settings.core.useRemoteToggleDesc')}
control={
<SettingsSwitch
id="core-use-remote"
checked={useRemote}
disabled={!canUseLocal}
onCheckedChange={next => {
// Web builds can't run a local core — refuse to switch remote off.
if (!canUseLocal && !next) return;
setUseRemote(next);
setTestStatus({ kind: 'idle' });
setFormError(null);
}}
aria-label={t('settings.core.useRemoteToggle')}
data-testid="core-use-remote-toggle"
/>
}
/>
{useRemote && (
<div className="flex flex-col gap-3 px-4 py-4">
<div className="flex flex-col gap-1">
<label
htmlFor="core-remote-url"
className="text-xs font-medium text-content-secondary">
{t('bootCheck.coreRpcUrl')}
</label>
<SettingsTextField
id="core-remote-url"
type="url"
placeholder={t('bootCheck.rpcUrlPlaceholder')}
value={url}
onChange={e => {
setUrl(e.target.value);
setFormError(null);
setTestStatus({ kind: 'idle' });
}}
/>
{httpWarning && (
<p className="text-xs text-amber-600 dark:text-amber-500">{httpWarning}</p>
)}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label
htmlFor="core-remote-token"
className="text-xs font-medium text-content-secondary">
{t('bootCheck.authToken')} (
<code className="text-[10px]">OPENHUMAN_CORE_TOKEN</code>)
</label>
<button
type="button"
className="text-[11px] text-content-muted hover:text-content-secondary"
onClick={() => setShowToken(s => !s)}
data-testid="core-token-reveal">
{showToken ? t('settings.search.hide') : t('settings.search.show')}
</button>
</div>
<SettingsTextField
id="core-remote-token"
type={showToken ? 'text' : 'password'}
mono
autoComplete="off"
spellCheck={false}
data-1p-ignore
data-lpignore="true"
placeholder={t('bootCheck.bearerTokenPlaceholder')}
value={token}
onChange={e => {
setToken(e.target.value);
setFormError(null);
setTestStatus({ kind: 'idle' });
}}
/>
<p className="text-[11px] text-content-muted leading-snug">
{t('bootCheck.storedLocally')} <code>Authorization: Bearer </code>{' '}
{t('bootCheck.rpcAuthSuffix')}
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="secondary"
size="sm"
onClick={handleTest}
disabled={testStatus.kind === 'testing'}>
{testStatus.kind === 'testing'
? t('bootCheck.testing')
: t('bootCheck.testConnection')}
</Button>
{testStatus.kind === 'ok' && (
<span className="text-xs text-sage-600" data-testid="core-test-ok">
{t('bootCheck.connectedOk')}
</span>
)}
{testStatus.kind === 'auth' && (
<span className="text-xs text-coral-600" data-testid="core-test-auth">
{t('bootCheck.authFailed')}
</span>
)}
{testStatus.kind === 'unreachable' && (
<span className="text-xs text-coral-600" data-testid="core-test-unreachable">
{t('bootCheck.unreachablePrefix')} {testStatus.reason}
</span>
)}
</div>
</div>
)}
{formError && <p className="px-4 pb-2 text-xs text-coral-600">{formError}</p>}
<div className="flex items-center justify-between gap-3 px-4 py-3">
<p className="text-[11px] text-content-muted leading-snug">
{t('settings.core.applyRestartNote')}
</p>
<Button onClick={handleSave} disabled={!isDirty || saving} data-testid="core-save-btn">
{t('settings.core.save')}
</Button>
</div>
</SettingsSection>
</SettingsPanel>
);
};
export default CoreConnectionPanel;
@@ -0,0 +1,266 @@
/**
* Tests for CoreConnectionPanel (GH-4396) — the first-class Settings surface
* that promotes cloud-mode remote-core config and adds a live status
* indicator. Covers: live status rendering per mode, the remote toggle
* revealing the URL/token form, and the save flow persisting + dispatching +
* restarting.
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
const hoisted = vi.hoisted(() => ({
testCoreRpcConnection: vi.fn(),
clearCoreRpcUrlCache: vi.fn(),
clearCoreRpcTokenCache: vi.fn(),
restartApp: vi.fn(),
// Default to a desktop (Tauri) context so local mode is available; the
// web-build test flips this to false to assert the toggle is gated off.
isTauriEnvironment: vi.fn(() => true),
invoke: vi.fn(async () => 'http://127.0.0.1:7788/rpc'),
}));
vi.mock('../../../../services/coreRpcClient', () => ({
testCoreRpcConnection: hoisted.testCoreRpcConnection,
clearCoreRpcUrlCache: hoisted.clearCoreRpcUrlCache,
clearCoreRpcTokenCache: hoisted.clearCoreRpcTokenCache,
}));
vi.mock('../../../../utils/tauriCommands/core', () => ({ restartApp: hoisted.restartApp }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invoke }));
// Keep the real configPersistence (storeCoreMode/localStorage etc.) but control
// isTauriEnvironment so the desktop-vs-web-build gating is testable.
vi.mock('../../../../utils/configPersistence', async importOriginal => {
const actual = await importOriginal<typeof import('../../../../utils/configPersistence')>();
return { ...actual, isTauriEnvironment: hoisted.isTauriEnvironment };
});
function okResponse() {
return { ok: true, status: 200, json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }) };
}
/** A Response-shaped stub for an arbitrary HTTP status. */
function statusResponse(status: number) {
return { ok: status >= 200 && status < 300, status, json: async () => ({}) };
}
const CLOUD_STATE = {
coreMode: { mode: { kind: 'cloud', url: 'https://core.example.com/rpc', token: 'tok-123456' } },
};
async function importPanel() {
const mod = await import('../CoreConnectionPanel');
return mod.default;
}
describe('CoreConnectionPanel', () => {
beforeEach(() => {
vi.resetModules();
hoisted.testCoreRpcConnection.mockReset();
hoisted.clearCoreRpcUrlCache.mockReset();
hoisted.clearCoreRpcTokenCache.mockReset();
hoisted.restartApp.mockReset();
hoisted.restartApp.mockResolvedValue(undefined);
hoisted.isTauriEnvironment.mockReset();
hoisted.isTauriEnvironment.mockReturnValue(true);
hoisted.invoke.mockReset();
hoisted.invoke.mockResolvedValue('http://127.0.0.1:7788/rpc');
localStorage.clear();
});
test('local mode shows the local connected status once the live check passes', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: { coreMode: { mode: { kind: 'local' } } } });
await waitFor(() => expect(screen.getByText('Connected to local core')).toBeInTheDocument());
// Remote toggle is off in local mode → no URL field.
expect(screen.queryByLabelText(/Runtime URL/i)).not.toBeInTheDocument();
});
test('cloud mode surfaces the remote URL and remote connected status', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
const Panel = await importPanel();
renderWithProviders(<Panel />, {
preloadedState: {
coreMode: {
mode: { kind: 'cloud', url: 'https://core.example.com/rpc', token: 'tok-123456' },
},
},
});
await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument());
// Toggle on → the URL field is pre-filled with the persisted value.
expect(screen.getByDisplayValue('https://core.example.com/rpc')).toBeInTheDocument();
});
test('unreachable core surfaces the failure status', async () => {
hoisted.testCoreRpcConnection.mockRejectedValue(new Error('boom'));
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: { coreMode: { mode: { kind: 'local' } } } });
await waitFor(() => expect(screen.getByText(/Cannot reach the core/i)).toBeInTheDocument());
});
test('switching to remote core persists, dispatches, and restarts', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
const Panel = await importPanel();
const { store } = renderWithProviders(<Panel />, {
preloadedState: { coreMode: { mode: { kind: 'local' } } },
});
await waitFor(() => expect(screen.getByText('Connected to local core')).toBeInTheDocument());
// Flip the remote toggle on to reveal the form.
fireEvent.click(screen.getByTestId('core-use-remote-toggle'));
fireEvent.change(screen.getByLabelText(/Runtime URL/i), {
target: { value: 'https://core.example.com/rpc' },
});
fireEvent.change(screen.getByLabelText(/Auth Token/i), {
target: { value: 'remote-token-xyz' },
});
fireEvent.click(screen.getByTestId('core-save-btn'));
await waitFor(() => expect(hoisted.restartApp).toHaveBeenCalledTimes(1));
// Redux is now in cloud mode with the typed URL + token.
const mode = store.getState().coreMode.mode as { kind: string; url?: string; token?: string };
expect(mode.kind).toBe('cloud');
expect(mode.url).toBe('https://core.example.com/rpc');
expect(mode.token).toBe('remote-token-xyz');
// Persisted synchronously to localStorage (mirrors the cloud-mode picker).
expect(localStorage.getItem('openhuman_core_mode')).toBe('cloud');
expect(localStorage.getItem('openhuman_core_rpc_url')).toBe('https://core.example.com/rpc');
expect(localStorage.getItem('openhuman_core_rpc_token')).toBe('remote-token-xyz');
// Caches cleared so the new endpoint takes effect on restart.
expect(hoisted.clearCoreRpcUrlCache).toHaveBeenCalled();
expect(hoisted.clearCoreRpcTokenCache).toHaveBeenCalled();
});
test('a rejected token surfaces the token-rejected live status', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(statusResponse(401));
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: CLOUD_STATE });
await waitFor(() => expect(screen.getByText(/the token was rejected/i)).toBeInTheDocument());
});
test('a non-ok response surfaces the unreachable live status with the HTTP code', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(statusResponse(503));
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: CLOUD_STATE });
await waitFor(() =>
expect(screen.getByText(/Cannot reach the core — HTTP 503/i)).toBeInTheDocument()
);
});
test('Test connection reports success for the typed remote inputs', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: CLOUD_STATE });
// Wait for the mount live-check to settle first.
await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument());
fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => expect(screen.getByTestId('core-test-ok')).toBeInTheDocument());
});
test('Test connection reports an auth failure', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(statusResponse(403));
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: CLOUD_STATE });
fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => expect(screen.getByTestId('core-test-auth')).toBeInTheDocument());
});
test('Test connection reports an unreachable endpoint', async () => {
hoisted.testCoreRpcConnection.mockRejectedValue(new Error('network down'));
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: CLOUD_STATE });
fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => expect(screen.getByTestId('core-test-unreachable')).toBeInTheDocument());
});
test('validation blocks the form when the URL is empty and when the token is missing', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: { coreMode: { mode: { kind: 'local' } } } });
await waitFor(() => expect(screen.getByText('Connected to local core')).toBeInTheDocument());
// Reveal the empty remote form.
fireEvent.click(screen.getByTestId('core-use-remote-toggle'));
// Empty URL → invalid-URL error.
fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => expect(screen.getByText(/enter a runtime URL/i)).toBeInTheDocument());
// Valid URL but empty token → token-required error.
fireEvent.change(screen.getByLabelText(/Runtime URL/i), {
target: { value: 'https://core.example.com/rpc' },
});
fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() =>
expect(screen.getByText(/need an auth token to connect/i)).toBeInTheDocument()
);
// The typed connection was never attempted (validation short-circuits).
expect(hoisted.testCoreRpcConnection).not.toHaveBeenCalledWith(
'https://core.example.com/rpc',
''
);
});
test('web build (non-Tauri) forces remote and disables the local toggle', async () => {
hoisted.isTauriEnvironment.mockReturnValue(false);
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
const Panel = await importPanel();
renderWithProviders(<Panel />, {
preloadedState: {
coreMode: {
mode: { kind: 'cloud', url: 'https://core.example.com/rpc', token: 'tok-123456' },
},
},
});
await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument());
// A browser can't start a local core, so the toggle is forced on + disabled.
const toggle = screen.getByTestId('core-use-remote-toggle');
expect(toggle).toBeDisabled();
expect(screen.getByDisplayValue('https://core.example.com/rpc')).toBeInTheDocument();
});
test('switching from remote back to local clears persistence, dispatches, and restarts', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
// Seed persisted cloud values so we can assert they are cleared.
localStorage.setItem('openhuman_core_mode', 'cloud');
localStorage.setItem('openhuman_core_rpc_url', 'https://core.example.com/rpc');
localStorage.setItem('openhuman_core_rpc_token', 'tok-123456');
const Panel = await importPanel();
const { store } = renderWithProviders(<Panel />, { preloadedState: CLOUD_STATE });
await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument());
// Flip remote off → local, then save.
fireEvent.click(screen.getByTestId('core-use-remote-toggle'));
fireEvent.click(screen.getByTestId('core-save-btn'));
await waitFor(() => expect(hoisted.restartApp).toHaveBeenCalledTimes(1));
expect(store.getState().coreMode.mode.kind).toBe('local');
expect(localStorage.getItem('openhuman_core_mode')).toBe('local');
expect(localStorage.getItem('openhuman_core_rpc_url')).toBeNull();
expect(localStorage.getItem('openhuman_core_rpc_token')).toBeNull();
});
});
@@ -18,6 +18,7 @@ import AutocompletePanel from './panels/AutocompletePanel';
import BillingPanel from './panels/BillingPanel';
import CompanionPanel from './panels/CompanionPanel';
import ComposioTriagePanel from './panels/ComposioTriagePanel';
import CoreConnectionPanel from './panels/CoreConnectionPanel';
import CronJobsPanel from './panels/CronJobsPanel';
import DesktopAgentPanel from './panels/DesktopAgentPanel';
import DeveloperOptionsPanel from './panels/DeveloperOptionsPanel';
@@ -154,6 +155,9 @@ export function settingsRouteElements(): ReactNode {
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
{/* ── System ──────────────────────────────────────────────── */}
{/* Core connection — promotes cloud-mode remote-core config into a
first-class setting with a live status indicator (GH-4396). */}
<Route path="core" element={wrapSettingsPage(<CoreConnectionPanel />)} />
<Route path="keyboard-shortcuts" element={wrapSettingsPage(<KeyboardShortcutsPanel />)} />
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
<Route path="token-usage" element={wrapSettingsPage(<TokenUsagePanel />)} />
@@ -286,6 +286,30 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [
navGroup: 'general',
navOrder: 98,
},
{
// Core connection — promotes cloud-mode remote-core config (persisted
// RPC URL + token) into a first-class setting plus a live status
// indicator (GH-4396). Sits just above About in General.
id: 'core',
titleKey: 'settings.core.title',
descriptionKey: 'settings.core.menuDesc',
section: 'home',
searchKeywords: [
'core',
'remote',
'rpc',
'url',
'token',
'cloud',
'local',
'connection',
'server',
'attach',
'self-hosted',
],
navGroup: 'general',
navOrder: 97,
},
{
id: 'about',
titleKey: 'settings.about',
+14
View File
@@ -17,6 +17,20 @@ const messages: TranslationMap = {
'privacy.mode.saveError': 'تعذّر تحديث وضع الخصوصية.',
'skills.recallCalendar.title': 'تقويم Google',
'skills.recallCalendar.description': 'الانضمام تلقائيًا إلى مكالمات Google Meet عبر Recall.ai',
// Core connection panel (GH-4396)
'settings.core.title': 'اتصال النواة',
'settings.core.menuDesc': 'استخدم النواة المحلية المدمجة أو اتصل بنواة بعيدة.',
'settings.core.useRemoteToggle': 'استخدام نواة بعيدة',
'settings.core.useRemoteToggleDesc':
'الاتصال بنواة بعيدة عبر HTTP بدلاً من النواة المحلية المدمجة.',
'settings.core.statusConnectedRemote': 'متصل بالنواة البعيدة',
'settings.core.statusConnectedLocal': 'متصل بالنواة المحلية',
'settings.core.statusChecking': 'جارٍ التحقق من الاتصال…',
'settings.core.statusAuthFailed': 'يمكن الوصول إليه، لكن تم رفض الرمز',
'settings.core.statusUnreachable': 'تعذّر الوصول إلى النواة',
'settings.core.recheck': 'إعادة التحقق',
'settings.core.save': 'حفظ وإعادة التشغيل',
'settings.core.applyRestartNote': 'يؤدي الحفظ إلى إعادة تشغيل OpenHuman لإعادة الاتصال.',
// Cross-host vault (#4278)
'crossHostVault.title': 'الخزنة موجودة على مضيف النواة.',
'crossHostVault.message':
+15
View File
@@ -18,6 +18,21 @@ const messages: TranslationMap = {
'skills.recallCalendar.title': 'Google Calendar',
'skills.recallCalendar.description':
'Recall.ai-এর মাধ্যমে Google Meet কলে স্বয়ংক্রিয়ভাবে যোগ দিন',
// Core connection panel (GH-4396)
'settings.core.title': 'কোর সংযোগ',
'settings.core.menuDesc':
'অন্তর্নির্মিত স্থানীয় কোর ব্যবহার করুন বা একটি রিমোট কোরে সংযোগ করুন।',
'settings.core.useRemoteToggle': 'রিমোট কোর ব্যবহার করুন',
'settings.core.useRemoteToggleDesc':
'অন্তর্নির্মিত স্থানীয় কোরের পরিবর্তে HTTP-এর মাধ্যমে একটি রিমোট কোরে সংযোগ করুন।',
'settings.core.statusConnectedRemote': 'রিমোট কোরে সংযুক্ত',
'settings.core.statusConnectedLocal': 'স্থানীয় কোরে সংযুক্ত',
'settings.core.statusChecking': 'সংযোগ পরীক্ষা করা হচ্ছে…',
'settings.core.statusAuthFailed': 'পৌঁছানো যায়, তবে টোকেন প্রত্যাখ্যাত হয়েছে',
'settings.core.statusUnreachable': 'কোরে পৌঁছানো যাচ্ছে না',
'settings.core.recheck': 'পুনরায় পরীক্ষা করুন',
'settings.core.save': 'সংরক্ষণ করে পুনরায় চালু করুন',
'settings.core.applyRestartNote': 'সংরক্ষণ করলে পুনরায় সংযোগের জন্য OpenHuman পুনরায় চালু হয়।',
// Cross-host vault (#4278)
'crossHostVault.title': 'ভল্টটি কোর হোস্টে রয়েছে।',
'crossHostVault.message':
+16
View File
@@ -19,6 +19,22 @@ const messages: TranslationMap = {
'privacy.mode.saveError': 'Datenschutzmodus konnte nicht aktualisiert werden.',
'skills.recallCalendar.title': 'Google Kalender',
'skills.recallCalendar.description': 'Google Meet-Anrufen automatisch über Recall.ai beitreten',
// Core connection panel (GH-4396)
'settings.core.title': 'Core-Verbindung',
'settings.core.menuDesc':
'Den integrierten lokalen Core verwenden oder mit einem Remote-Core verbinden.',
'settings.core.useRemoteToggle': 'Remote-Core verwenden',
'settings.core.useRemoteToggleDesc':
'Über HTTP mit einem Remote-Core verbinden statt den integrierten lokalen Core zu nutzen.',
'settings.core.statusConnectedRemote': 'Mit Remote-Core verbunden',
'settings.core.statusConnectedLocal': 'Mit lokalem Core verbunden',
'settings.core.statusChecking': 'Verbindung wird geprüft…',
'settings.core.statusAuthFailed': 'Erreichbar, aber das Token wurde abgelehnt',
'settings.core.statusUnreachable': 'Core nicht erreichbar',
'settings.core.recheck': 'Erneut prüfen',
'settings.core.save': 'Speichern & neu starten',
'settings.core.applyRestartNote':
'Beim Speichern startet OpenHuman neu, um die Verbindung herzustellen.',
// Cross-host vault (#4278)
'crossHostVault.title': 'Der Vault liegt auf dem Core-Host.',
'crossHostVault.message':
+14
View File
@@ -1494,6 +1494,20 @@ const en: TranslationMap = {
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
'settings.about.connectionHelperCloud':
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
// Core connection panel (GH-4396)
'settings.core.title': 'Core connection',
'settings.core.menuDesc': 'Use the built-in local core or connect to a remote core.',
'settings.core.useRemoteToggle': 'Use remote core',
'settings.core.useRemoteToggleDesc':
'Connect to a remote core over HTTP instead of the built-in local core.',
'settings.core.statusConnectedRemote': 'Connected to remote core',
'settings.core.statusConnectedLocal': 'Connected to local core',
'settings.core.statusChecking': 'Checking connection…',
'settings.core.statusAuthFailed': 'Reachable, but the token was rejected',
'settings.core.statusUnreachable': 'Cannot reach the core',
'settings.core.recheck': 'Recheck',
'settings.core.save': 'Save & restart',
'settings.core.applyRestartNote': 'Saving restarts OpenHuman to reconnect.',
'settings.heartbeat.title': 'Heartbeat & loops',
'settings.usage.title': 'Usage & Limits',
'settings.usage.menuDesc': 'Costs, token usage, budgets, and background activity',
+14
View File
@@ -20,6 +20,20 @@ const messages: TranslationMap = {
'skills.recallCalendar.title': 'Google Calendar',
'skills.recallCalendar.description':
'Unirse automáticamente a las llamadas de Google Meet con Recall.ai',
// Core connection panel (GH-4396)
'settings.core.title': 'Conexión del core',
'settings.core.menuDesc': 'Usa el core local integrado o conéctate a un core remoto.',
'settings.core.useRemoteToggle': 'Usar core remoto',
'settings.core.useRemoteToggleDesc':
'Conéctate a un core remoto por HTTP en lugar del core local integrado.',
'settings.core.statusConnectedRemote': 'Conectado al core remoto',
'settings.core.statusConnectedLocal': 'Conectado al core local',
'settings.core.statusChecking': 'Comprobando la conexión…',
'settings.core.statusAuthFailed': 'Accesible, pero el token fue rechazado',
'settings.core.statusUnreachable': 'No se puede conectar con el core',
'settings.core.recheck': 'Volver a comprobar',
'settings.core.save': 'Guardar y reiniciar',
'settings.core.applyRestartNote': 'Al guardar, OpenHuman se reinicia para volver a conectarse.',
// Cross-host vault (#4278)
'crossHostVault.title': 'El vault está en el host del core.',
'crossHostVault.message':
+14
View File
@@ -21,6 +21,20 @@ const messages: TranslationMap = {
'skills.recallCalendar.title': 'Google Agenda',
'skills.recallCalendar.description':
'Rejoindre automatiquement les appels Google Meet via Recall.ai',
// Core connection panel (GH-4396)
'settings.core.title': 'Connexion au core',
'settings.core.menuDesc': 'Utilisez le core local intégré ou connectez-vous à un core distant.',
'settings.core.useRemoteToggle': 'Utiliser un core distant',
'settings.core.useRemoteToggleDesc':
'Se connecter à un core distant via HTTP au lieu du core local intégré.',
'settings.core.statusConnectedRemote': 'Connecté au core distant',
'settings.core.statusConnectedLocal': 'Connecté au core local',
'settings.core.statusChecking': 'Vérification de la connexion…',
'settings.core.statusAuthFailed': 'Accessible, mais le jeton a été refusé',
'settings.core.statusUnreachable': 'Core inaccessible',
'settings.core.recheck': 'Revérifier',
'settings.core.save': 'Enregistrer et redémarrer',
'settings.core.applyRestartNote': 'Enregistrer redémarre OpenHuman pour se reconnecter.',
// Cross-host vault (#4278)
'crossHostVault.title': "Le coffre se trouve sur l'hôte du cœur.",
'crossHostVault.message':
+16
View File
@@ -17,6 +17,22 @@ const messages: TranslationMap = {
'privacy.mode.saveError': 'गोपनीयता मोड अपडेट नहीं किया जा सका।',
'skills.recallCalendar.title': 'Google Calendar',
'skills.recallCalendar.description': 'Recall.ai के ज़रिए Google Meet कॉल में अपने-आप शामिल हों',
// Core connection panel (GH-4396)
'settings.core.title': 'कोर कनेक्शन',
'settings.core.menuDesc':
'अंतर्निहित स्थानीय कोर का उपयोग करें या किसी रिमोट कोर से कनेक्ट करें।',
'settings.core.useRemoteToggle': 'रिमोट कोर का उपयोग करें',
'settings.core.useRemoteToggleDesc':
'अंतर्निहित स्थानीय कोर के बजाय HTTP के माध्यम से किसी रिमोट कोर से कनेक्ट करें।',
'settings.core.statusConnectedRemote': 'रिमोट कोर से कनेक्ट हो गया',
'settings.core.statusConnectedLocal': 'स्थानीय कोर से कनेक्ट हो गया',
'settings.core.statusChecking': 'कनेक्शन जाँचा जा रहा है…',
'settings.core.statusAuthFailed': 'पहुँच योग्य, लेकिन टोकन अस्वीकृत हो गया',
'settings.core.statusUnreachable': 'कोर तक नहीं पहुँचा जा सकता',
'settings.core.recheck': 'फिर से जाँचें',
'settings.core.save': 'सहेजें और पुनरारंभ करें',
'settings.core.applyRestartNote':
'सहेजने पर OpenHuman फिर से कनेक्ट होने के लिए पुनरारंभ होता है।',
// Cross-host vault (#4278)
'crossHostVault.title': 'वॉल्ट कोर होस्ट पर है।',
'crossHostVault.message':
+15
View File
@@ -19,6 +19,21 @@ const messages: TranslationMap = {
'skills.recallCalendar.title': 'Google Kalender',
'skills.recallCalendar.description':
'Bergabung otomatis ke panggilan Google Meet melalui Recall.ai',
// Core connection panel (GH-4396)
'settings.core.title': 'Koneksi core',
'settings.core.menuDesc': 'Gunakan core lokal bawaan atau hubungkan ke core jarak jauh.',
'settings.core.useRemoteToggle': 'Gunakan core jarak jauh',
'settings.core.useRemoteToggleDesc':
'Hubungkan ke core jarak jauh melalui HTTP alih-alih core lokal bawaan.',
'settings.core.statusConnectedRemote': 'Terhubung ke core jarak jauh',
'settings.core.statusConnectedLocal': 'Terhubung ke core lokal',
'settings.core.statusChecking': 'Memeriksa koneksi…',
'settings.core.statusAuthFailed': 'Dapat dijangkau, tetapi token ditolak',
'settings.core.statusUnreachable': 'Tidak dapat menjangkau core',
'settings.core.recheck': 'Periksa ulang',
'settings.core.save': 'Simpan & mulai ulang',
'settings.core.applyRestartNote':
'Menyimpan akan memulai ulang OpenHuman untuk menyambung kembali.',
// Cross-host vault (#4278)
'crossHostVault.title': 'Vault berada di host core.',
'crossHostVault.message':
+14
View File
@@ -19,6 +19,20 @@ const messages: TranslationMap = {
'skills.recallCalendar.title': 'Google Calendar',
'skills.recallCalendar.description':
'Partecipa automaticamente alle chiamate Google Meet tramite Recall.ai',
// Core connection panel (GH-4396)
'settings.core.title': 'Connessione al core',
'settings.core.menuDesc': 'Usa il core locale integrato o connettiti a un core remoto.',
'settings.core.useRemoteToggle': 'Usa core remoto',
'settings.core.useRemoteToggleDesc':
'Connettiti a un core remoto via HTTP invece del core locale integrato.',
'settings.core.statusConnectedRemote': 'Connesso al core remoto',
'settings.core.statusConnectedLocal': 'Connesso al core locale',
'settings.core.statusChecking': 'Verifica della connessione…',
'settings.core.statusAuthFailed': 'Raggiungibile, ma il token è stato rifiutato',
'settings.core.statusUnreachable': 'Impossibile raggiungere il core',
'settings.core.recheck': 'Ricontrolla',
'settings.core.save': 'Salva e riavvia',
'settings.core.applyRestartNote': 'Il salvataggio riavvia OpenHuman per riconnettersi.',
// Cross-host vault (#4278)
'crossHostVault.title': "Il vault è sull'host del core.",
'crossHostVault.message':
+13
View File
@@ -18,6 +18,19 @@ const messages: TranslationMap = {
'privacy.mode.saveError': '개인정보 보호 모드를 업데이트할 수 없습니다.',
'skills.recallCalendar.title': 'Google 캘린더',
'skills.recallCalendar.description': 'Recall.ai를 통해 Google Meet 통화에 자동 참여',
// Core connection panel (GH-4396)
'settings.core.title': '코어 연결',
'settings.core.menuDesc': '내장 로컬 코어를 사용하거나 원격 코어에 연결하세요.',
'settings.core.useRemoteToggle': '원격 코어 사용',
'settings.core.useRemoteToggleDesc': '내장 로컬 코어 대신 HTTP를 통해 원격 코어에 연결합니다.',
'settings.core.statusConnectedRemote': '원격 코어에 연결됨',
'settings.core.statusConnectedLocal': '로컬 코어에 연결됨',
'settings.core.statusChecking': '연결 확인 중…',
'settings.core.statusAuthFailed': '접근 가능하지만 토큰이 거부되었습니다',
'settings.core.statusUnreachable': '코어에 연결할 수 없습니다',
'settings.core.recheck': '다시 확인',
'settings.core.save': '저장 후 재시작',
'settings.core.applyRestartNote': '저장하면 다시 연결하기 위해 OpenHuman이 재시작됩니다.',
// Cross-host vault (#4278)
'crossHostVault.title': '보관소가 코어 호스트에 있습니다.',
'crossHostVault.message':
+15
View File
@@ -20,6 +20,21 @@ const messages: TranslationMap = {
'skills.recallCalendar.title': 'Kalendarz Google',
'skills.recallCalendar.description':
'Automatyczne dołączanie do połączeń Google Meet przez Recall.ai',
// Core connection panel (GH-4396)
'settings.core.title': 'Połączenie z core',
'settings.core.menuDesc': 'Użyj wbudowanego lokalnego core lub połącz się ze zdalnym core.',
'settings.core.useRemoteToggle': 'Użyj zdalnego core',
'settings.core.useRemoteToggleDesc':
'Połącz się ze zdalnym core przez HTTP zamiast wbudowanego lokalnego core.',
'settings.core.statusConnectedRemote': 'Połączono ze zdalnym core',
'settings.core.statusConnectedLocal': 'Połączono z lokalnym core',
'settings.core.statusChecking': 'Sprawdzanie połączenia…',
'settings.core.statusAuthFailed': 'Osiągalny, ale token został odrzucony',
'settings.core.statusUnreachable': 'Nie można połączyć się z core',
'settings.core.recheck': 'Sprawdź ponownie',
'settings.core.save': 'Zapisz i uruchom ponownie',
'settings.core.applyRestartNote':
'Zapisanie uruchamia ponownie OpenHuman, aby połączyć się ponownie.',
// Cross-host vault (#4278)
'crossHostVault.title': 'Skarbiec znajduje się na hoście rdzenia.',
'crossHostVault.message':
+14
View File
@@ -19,6 +19,20 @@ const messages: TranslationMap = {
'skills.recallCalendar.title': 'Google Agenda',
'skills.recallCalendar.description':
'Entrar automaticamente nas chamadas do Google Meet via Recall.ai',
// Core connection panel (GH-4396)
'settings.core.title': 'Conexão do core',
'settings.core.menuDesc': 'Use o core local integrado ou conecte-se a um core remoto.',
'settings.core.useRemoteToggle': 'Usar core remoto',
'settings.core.useRemoteToggleDesc':
'Conecte-se a um core remoto por HTTP em vez do core local integrado.',
'settings.core.statusConnectedRemote': 'Conectado ao core remoto',
'settings.core.statusConnectedLocal': 'Conectado ao core local',
'settings.core.statusChecking': 'Verificando a conexão…',
'settings.core.statusAuthFailed': 'Acessível, mas o token foi rejeitado',
'settings.core.statusUnreachable': 'Não é possível acessar o core',
'settings.core.recheck': 'Verificar novamente',
'settings.core.save': 'Salvar e reiniciar',
'settings.core.applyRestartNote': 'Salvar reinicia o OpenHuman para reconectar.',
// Cross-host vault (#4278)
'crossHostVault.title': 'O vault está no host do core.',
'crossHostVault.message':
+16
View File
@@ -19,6 +19,22 @@ const messages: TranslationMap = {
'skills.recallCalendar.title': 'Google Календарь',
'skills.recallCalendar.description':
'Автоматически подключаться к звонкам Google Meet через Recall.ai',
// Core connection panel (GH-4396)
'settings.core.title': 'Подключение к ядру',
'settings.core.menuDesc':
'Используйте встроенное локальное ядро или подключитесь к удалённому ядру.',
'settings.core.useRemoteToggle': 'Использовать удалённое ядро',
'settings.core.useRemoteToggleDesc':
'Подключиться к удалённому ядру по HTTP вместо встроенного локального ядра.',
'settings.core.statusConnectedRemote': 'Подключено к удалённому ядру',
'settings.core.statusConnectedLocal': 'Подключено к локальному ядру',
'settings.core.statusChecking': 'Проверка подключения…',
'settings.core.statusAuthFailed': 'Доступно, но токен отклонён',
'settings.core.statusUnreachable': 'Не удаётся подключиться к ядру',
'settings.core.recheck': 'Проверить снова',
'settings.core.save': 'Сохранить и перезапустить',
'settings.core.applyRestartNote':
'После сохранения OpenHuman перезапустится для повторного подключения.',
// Cross-host vault (#4278)
'crossHostVault.title': 'Хранилище находится на хосте ядра.',
'crossHostVault.message':
+13
View File
@@ -16,6 +16,19 @@ const messages: TranslationMap = {
'privacy.mode.saveError': '无法更新隐私模式。',
'skills.recallCalendar.title': 'Google 日历',
'skills.recallCalendar.description': '通过 Recall.ai 自动加入 Google Meet 通话',
// Core connection panel (GH-4396)
'settings.core.title': '核心连接',
'settings.core.menuDesc': '使用内置的本地核心,或连接到远程核心。',
'settings.core.useRemoteToggle': '使用远程核心',
'settings.core.useRemoteToggleDesc': '通过 HTTP 连接到远程核心,而不是使用内置的本地核心。',
'settings.core.statusConnectedRemote': '已连接到远程核心',
'settings.core.statusConnectedLocal': '已连接到本地核心',
'settings.core.statusChecking': '正在检查连接…',
'settings.core.statusAuthFailed': '可访问,但令牌被拒绝',
'settings.core.statusUnreachable': '无法连接到核心',
'settings.core.recheck': '重新检查',
'settings.core.save': '保存并重启',
'settings.core.applyRestartNote': '保存后 OpenHuman 将重启以重新连接。',
// Cross-host vault (#4278)
'crossHostVault.title': '记忆库位于核心主机上。',
'crossHostVault.message':