mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-27 14:15:26 +08:00
Improve Browser settings load and managed MCP display
This commit is contained in:
@@ -66,10 +66,13 @@ type RuntimeReadiness = {
|
|||||||
installMessage: string | null;
|
installMessage: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
|
||||||
|
|
||||||
const sessions = new Map<string, BrowserUseSession>();
|
const sessions = new Map<string, BrowserUseSession>();
|
||||||
const handles = new Map<string, RuntimeHandle>();
|
const handles = new Map<string, RuntimeHandle>();
|
||||||
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
|
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
|
||||||
let lastInstallMessage: string | null = null;
|
let lastInstallMessage: string | null = null;
|
||||||
|
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: BrowserUseSettings = {
|
const DEFAULT_SETTINGS: BrowserUseSettings = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -78,6 +81,7 @@ const AGENT_OWNER_ID = 'agent';
|
|||||||
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
|
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
|
||||||
const MCP_SERVER_NAME = 'cloudcli-browser';
|
const MCP_SERVER_NAME = 'cloudcli-browser';
|
||||||
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
|
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
|
||||||
|
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
function getRuntime(): BrowserUseRuntime {
|
function getRuntime(): BrowserUseRuntime {
|
||||||
return IS_PLATFORM ? 'cloud' : 'local';
|
return IS_PLATFORM ? 'cloud' : 'local';
|
||||||
@@ -190,15 +194,13 @@ function getProfilePath(profileName: string): string {
|
|||||||
return path.join(PROFILE_ROOT, safeName);
|
return path.join(PROFILE_ROOT, safeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRuntimeReadiness(): RuntimeReadiness {
|
function probeRuntime(): RuntimeProbe {
|
||||||
const playwright = getPlaywright();
|
const playwright = getPlaywright();
|
||||||
const readiness: RuntimeReadiness = {
|
const readiness: RuntimeProbe = {
|
||||||
playwright,
|
playwright,
|
||||||
playwrightInstalled: Boolean(playwright),
|
playwrightInstalled: Boolean(playwright),
|
||||||
chromiumInstalled: false,
|
chromiumInstalled: false,
|
||||||
chromiumExecutablePath: null,
|
chromiumExecutablePath: null,
|
||||||
installInProgress: Boolean(installPromise),
|
|
||||||
installMessage: lastInstallMessage,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!playwright) {
|
if (!playwright) {
|
||||||
@@ -216,6 +218,26 @@ function getRuntimeReadiness(): RuntimeReadiness {
|
|||||||
return readiness;
|
return readiness;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRuntimeReadiness(options: { force?: boolean } = {}): RuntimeReadiness {
|
||||||
|
const now = Date.now();
|
||||||
|
const cachedProbe = runtimeProbeCache;
|
||||||
|
const canUseCache = !options.force
|
||||||
|
&& !installPromise
|
||||||
|
&& cachedProbe
|
||||||
|
&& now - cachedProbe.updatedAt < RUNTIME_READINESS_CACHE_TTL_MS;
|
||||||
|
const probe = canUseCache ? cachedProbe.value : probeRuntime();
|
||||||
|
|
||||||
|
if (!canUseCache && !installPromise) {
|
||||||
|
runtimeProbeCache = { value: probe, updatedAt: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...probe,
|
||||||
|
installInProgress: Boolean(installPromise),
|
||||||
|
installMessage: lastInstallMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
|
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
|
||||||
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
|
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
|
||||||
10,
|
10,
|
||||||
@@ -276,6 +298,7 @@ async function installRuntime(): Promise<{ success: boolean; message: string }>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||||
|
runtimeProbeCache = null;
|
||||||
installPromise = (async () => {
|
installPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
lastInstallMessage = 'Installing Playwright package...';
|
lastInstallMessage = 'Installing Playwright package...';
|
||||||
@@ -301,6 +324,7 @@ async function installRuntime(): Promise<{ success: boolean; message: string }>
|
|||||||
return await installPromise;
|
return await installPromise;
|
||||||
} finally {
|
} finally {
|
||||||
installPromise = null;
|
installPromise = null;
|
||||||
|
runtimeProbeCache = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,80 +182,92 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
|
|||||||
<div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div>
|
<div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{servers.map((server) => (
|
{servers.map((server) => {
|
||||||
<div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4">
|
const managed = isManagedServer(server);
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
return (
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
<div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4">
|
||||||
{getTransportIcon(server.transport)}
|
<div className="flex items-start justify-between">
|
||||||
<span className="font-medium text-foreground">{server.name}</span>
|
<div className="min-w-0 flex-1">
|
||||||
<Badge variant="outline" className="text-xs">
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
{server.transport || 'stdio'}
|
{!managed && getTransportIcon(server.transport)}
|
||||||
</Badge>
|
<span className="font-medium text-foreground">{server.name}</span>
|
||||||
<Badge variant="outline" className="text-xs">
|
{!managed && (
|
||||||
{getScopeLabel(server.scope)}
|
<>
|
||||||
</Badge>
|
<Badge variant="outline" className="text-xs">
|
||||||
{server.projectDisplayName && (
|
{server.transport || 'stdio'}
|
||||||
<Badge variant="outline" className="max-w-full truncate text-xs">
|
</Badge>
|
||||||
{server.projectDisplayName}
|
<Badge variant="outline" className="text-xs">
|
||||||
</Badge>
|
{getScopeLabel(server.scope)}
|
||||||
)}
|
</Badge>
|
||||||
{isManagedServer(server) && (
|
{server.projectDisplayName && (
|
||||||
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
|
<Badge variant="outline" className="max-w-full truncate text-xs">
|
||||||
<Lock className="h-3 w-3" />
|
{server.projectDisplayName}
|
||||||
{t('mcpServers.managed.badge', { defaultValue: 'Managed' })}
|
</Badge>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
|
{managed && (
|
||||||
|
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
|
||||||
|
<Lock className="h-3 w-3" />
|
||||||
|
{t('mcpServers.managed.badge', { defaultValue: 'Managed' })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
{!managed && (
|
||||||
|
<>
|
||||||
|
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
|
||||||
|
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
|
||||||
|
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
|
||||||
|
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
|
||||||
|
{server.env && Object.keys(server.env).length > 0 && (
|
||||||
|
<ConfigLine label={t('mcpServers.config.environment')}>
|
||||||
|
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
|
||||||
|
</ConfigLine>
|
||||||
|
)}
|
||||||
|
{server.envVars && server.envVars.length > 0 && (
|
||||||
|
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{managed && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('mcpServers.managed.hint', {
|
||||||
|
defaultValue: 'Managed by CloudCLI.',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
{!managed && (
|
||||||
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
|
<div className="ml-4 flex items-center gap-2">
|
||||||
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
|
<Button
|
||||||
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
|
onClick={() => openForm(server)}
|
||||||
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
|
variant="ghost"
|
||||||
{server.env && Object.keys(server.env).length > 0 && (
|
size="sm"
|
||||||
<ConfigLine label={t('mcpServers.config.environment')}>
|
className="text-muted-foreground hover:text-foreground"
|
||||||
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
|
title={t('mcpServers.actions.edit')}
|
||||||
</ConfigLine>
|
>
|
||||||
)}
|
<Edit3 className="h-4 w-4" />
|
||||||
{server.envVars && server.envVars.length > 0 && (
|
</Button>
|
||||||
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
|
<Button
|
||||||
)}
|
onClick={() => deleteServer(server)}
|
||||||
{isManagedServer(server) && (
|
variant="ghost"
|
||||||
<div className="pt-1 text-xs italic text-muted-foreground">
|
size="sm"
|
||||||
{t('mcpServers.managed.hint', {
|
className="text-red-600 hover:text-red-700"
|
||||||
defaultValue: 'Managed by CloudCLI — control it from the feature\'s settings toggle.',
|
title={t('mcpServers.actions.delete')}
|
||||||
})}
|
>
|
||||||
</div>
|
<Trash2 className="h-4 w-4" />
|
||||||
)}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isManagedServer(server) && (
|
|
||||||
<div className="ml-4 flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => openForm(server)}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
title={t('mcpServers.actions.edit')}
|
|
||||||
>
|
|
||||||
<Edit3 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => deleteServer(server)}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-red-600 hover:text-red-700"
|
|
||||||
title={t('mcpServers.actions.delete')}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
|
|
||||||
{!isLoading && !isLoadingProjectScopes && servers.length === 0 && (
|
{!isLoading && !isLoadingProjectScopes && servers.length === 0 && (
|
||||||
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
||||||
|
|||||||
@@ -30,31 +30,39 @@ async function readJson<T>(response: Response): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BrowserUseSettingsTab() {
|
export default function BrowserUseSettingsTab() {
|
||||||
const [settings, setSettings] = useState<BrowserUseSettings>({ enabled: false });
|
const [settings, setSettings] = useState<BrowserUseSettings | null>(null);
|
||||||
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
|
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isSettingsLoading, setIsSettingsLoading] = useState(true);
|
||||||
|
const [isStatusLoading, setIsStatusLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isInstalling, setIsInstalling] = useState(false);
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadState = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
setError(null);
|
const settingsResponse = await authenticatedFetch('/api/browser-use/settings');
|
||||||
const [settingsResponse, statusResponse] = await Promise.all([
|
|
||||||
authenticatedFetch('/api/browser-use/settings'),
|
|
||||||
authenticatedFetch('/api/browser-use/status'),
|
|
||||||
]);
|
|
||||||
const settingsData = await readJson<{ data: { settings: BrowserUseSettings } }>(settingsResponse);
|
const settingsData = await readJson<{ data: { settings: BrowserUseSettings } }>(settingsResponse);
|
||||||
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
|
|
||||||
setSettings(settingsData.data.settings);
|
setSettings(settingsData.data.settings);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadStatus = useCallback(async () => {
|
||||||
|
const statusResponse = await authenticatedFetch('/api/browser-use/status');
|
||||||
|
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
|
||||||
setStatus(statusData.data);
|
setStatus(statusData.data);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setError(null);
|
||||||
void loadState()
|
setIsSettingsLoading(true);
|
||||||
|
setIsStatusLoading(true);
|
||||||
|
|
||||||
|
void loadSettings()
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser settings'))
|
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser settings'))
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsSettingsLoading(false));
|
||||||
}, [loadState]);
|
|
||||||
|
void loadStatus()
|
||||||
|
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser status'))
|
||||||
|
.finally(() => setIsStatusLoading(false));
|
||||||
|
}, [loadSettings, loadStatus]);
|
||||||
|
|
||||||
const updateSettings = async (nextSettings: Partial<BrowserUseSettings>) => {
|
const updateSettings = async (nextSettings: Partial<BrowserUseSettings>) => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@@ -67,10 +75,12 @@ export default function BrowserUseSettingsTab() {
|
|||||||
const data = await readJson<{ data: { settings: BrowserUseSettings } }>(response);
|
const data = await readJson<{ data: { settings: BrowserUseSettings } }>(response);
|
||||||
setSettings(data.data.settings);
|
setSettings(data.data.settings);
|
||||||
window.dispatchEvent(new Event('browserUseSettingsChanged'));
|
window.dispatchEvent(new Event('browserUseSettingsChanged'));
|
||||||
await loadState();
|
setIsStatusLoading(true);
|
||||||
|
await loadStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to save Browser settings');
|
setError(err instanceof Error ? err.message : 'Failed to save Browser settings');
|
||||||
} finally {
|
} finally {
|
||||||
|
setIsStatusLoading(false);
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -81,15 +91,24 @@ export default function BrowserUseSettingsTab() {
|
|||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' });
|
const response = await authenticatedFetch('/api/browser-use/runtime/install', { method: 'POST' });
|
||||||
await readJson(response);
|
await readJson(response);
|
||||||
await loadState();
|
setIsStatusLoading(true);
|
||||||
|
await loadStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to install browser runtime');
|
setError(err instanceof Error ? err.message : 'Failed to install browser runtime');
|
||||||
} finally {
|
} finally {
|
||||||
|
setIsStatusLoading(false);
|
||||||
setIsInstalling(false);
|
setIsInstalling(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const needsBrowserBinaries = Boolean(settings.enabled && status && (!status.playwrightInstalled || !status.chromiumInstalled));
|
const browserEnabled = settings?.enabled === true;
|
||||||
|
const needsBrowserBinaries = Boolean(browserEnabled && status && (!status.playwrightInstalled || !status.chromiumInstalled));
|
||||||
|
const runtimeLabel = (installed?: boolean) => {
|
||||||
|
if (isStatusLoading && !status) {
|
||||||
|
return 'checking...';
|
||||||
|
}
|
||||||
|
return installed ? 'installed' : 'missing';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -102,24 +121,28 @@ export default function BrowserUseSettingsTab() {
|
|||||||
label="Enable Browser"
|
label="Enable Browser"
|
||||||
description="Registers Browser for supported agents. Agents can create browser sessions; you can watch, stop, and delete them."
|
description="Registers Browser for supported agents. Agents can create browser sessions; you can watch, stop, and delete them."
|
||||||
>
|
>
|
||||||
<SettingsToggle
|
{isSettingsLoading && !settings ? (
|
||||||
checked={settings.enabled}
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
onChange={(value) => void updateSettings({ enabled: value })}
|
) : (
|
||||||
ariaLabel="Enable Browser"
|
<SettingsToggle
|
||||||
disabled={isLoading || isSaving}
|
checked={browserEnabled}
|
||||||
/>
|
onChange={(value) => void updateSettings({ enabled: value })}
|
||||||
|
ariaLabel="Enable Browser"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<div className="space-y-4 px-4 py-4">
|
<div className="space-y-4 px-4 py-4">
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
<span className="rounded-md border border-border px-2 py-1">
|
<span className="rounded-md border border-border px-2 py-1">
|
||||||
Playwright: {status?.playwrightInstalled ? 'installed' : 'missing'}
|
Playwright: {runtimeLabel(status?.playwrightInstalled)}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-md border border-border px-2 py-1">
|
<span className="rounded-md border border-border px-2 py-1">
|
||||||
Chromium: {status?.chromiumInstalled ? 'installed' : 'missing'}
|
Chromium: {runtimeLabel(status?.chromiumInstalled)}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-md border border-border px-2 py-1">
|
<span className="rounded-md border border-border px-2 py-1">
|
||||||
Status: {status?.available ? 'ready' : settings.enabled ? 'setup required' : 'disabled'}
|
Status: {isStatusLoading && !status ? 'checking...' : status?.available ? 'ready' : browserEnabled ? 'setup required' : 'disabled'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -453,7 +453,7 @@
|
|||||||
},
|
},
|
||||||
"managed": {
|
"managed": {
|
||||||
"badge": "Managed",
|
"badge": "Managed",
|
||||||
"hint": "Managed by CloudCLI — control it from the feature's settings toggle."
|
"hint": "Managed by CloudCLI."
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "About Codex MCP",
|
"title": "About Codex MCP",
|
||||||
|
|||||||
Reference in New Issue
Block a user