mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user