mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 04:17:38 +00:00
feat(cli): add browser automation commands
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
Brain,
|
||||
Archive,
|
||||
MessageSquare,
|
||||
Monitor,
|
||||
} from 'lucide-react';
|
||||
import type { SettingsTab } from '../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -25,6 +26,7 @@ interface TabNavigationProps {
|
||||
export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
||||
const { t } = useTranslation();
|
||||
const tabs = [
|
||||
{ value: 'browser' as const, label: t('settingsTabs.browser'), icon: Monitor },
|
||||
{ value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe },
|
||||
{ value: 'image' as const, label: t('settingsTabs.image'), icon: ImageIcon },
|
||||
{ value: 'channels' as const, label: t('settingsTabs.channels'), icon: MessageSquare },
|
||||
@@ -37,7 +39,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
|
||||
<TabsList className="grid w-full grid-cols-8">
|
||||
<TabsList className="grid w-full grid-cols-9">
|
||||
{tabs.map(({ value, label, icon: Icon }) => (
|
||||
<TabsTrigger key={value} value={value} className="gap-1.5 px-2 text-xs">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
export {
|
||||
useBrowserConfig,
|
||||
useSettingsContext,
|
||||
useSettingsActions,
|
||||
useSettingsTab,
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { SettingsContext } from '../settings-context';
|
||||
import type {
|
||||
BrowserConfig,
|
||||
BrowserStatus,
|
||||
WebSearchConfig,
|
||||
GlobalEnvConfig,
|
||||
CliproxyServerConfig,
|
||||
@@ -24,6 +26,41 @@ export function useSettingsContext() {
|
||||
export function useSettingsActions() {
|
||||
const { dispatch } = useSettingsContext();
|
||||
|
||||
const setBrowserConfig = useCallback(
|
||||
(config: BrowserConfig | null) => dispatch({ type: 'SET_BROWSER_CONFIG', payload: config }),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setBrowserStatus = useCallback(
|
||||
(status: BrowserStatus | null) => dispatch({ type: 'SET_BROWSER_STATUS', payload: status }),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setBrowserLoading = useCallback(
|
||||
(loading: boolean) => dispatch({ type: 'SET_BROWSER_LOADING', payload: loading }),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setBrowserStatusLoading = useCallback(
|
||||
(loading: boolean) => dispatch({ type: 'SET_BROWSER_STATUS_LOADING', payload: loading }),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setBrowserSaving = useCallback(
|
||||
(saving: boolean) => dispatch({ type: 'SET_BROWSER_SAVING', payload: saving }),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setBrowserError = useCallback(
|
||||
(error: string | null) => dispatch({ type: 'SET_BROWSER_ERROR', payload: error }),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setBrowserSuccess = useCallback(
|
||||
(success: boolean) => dispatch({ type: 'SET_BROWSER_SUCCESS', payload: success }),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setWebSearchConfig = useCallback(
|
||||
(config: WebSearchConfig | null) => dispatch({ type: 'SET_WEBSEARCH_CONFIG', payload: config }),
|
||||
[dispatch]
|
||||
@@ -133,6 +170,13 @@ export function useSettingsActions() {
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
setBrowserConfig,
|
||||
setBrowserStatus,
|
||||
setBrowserLoading,
|
||||
setBrowserStatusLoading,
|
||||
setBrowserSaving,
|
||||
setBrowserError,
|
||||
setBrowserSuccess,
|
||||
setWebSearchConfig,
|
||||
setWebSearchStatus,
|
||||
setWebSearchLoading,
|
||||
@@ -156,6 +200,13 @@ export function useSettingsActions() {
|
||||
setRawConfigLoading,
|
||||
}),
|
||||
[
|
||||
setBrowserConfig,
|
||||
setBrowserStatus,
|
||||
setBrowserLoading,
|
||||
setBrowserStatusLoading,
|
||||
setBrowserSaving,
|
||||
setBrowserError,
|
||||
setBrowserSuccess,
|
||||
setWebSearchConfig,
|
||||
setWebSearchStatus,
|
||||
setWebSearchLoading,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export { useSettingsContext, useSettingsActions } from './context-hooks';
|
||||
export { useSettingsTab } from './use-settings-tab';
|
||||
export { useBrowserConfig } from './use-browser-config';
|
||||
export { useWebSearchConfig } from './use-websearch-config';
|
||||
export { useGlobalEnvConfig } from './use-globalenv-config';
|
||||
export { useProxyConfig } from './use-proxy-config';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback } from 'react';
|
||||
import { api } from '@/lib/api-client';
|
||||
import { useSettingsActions, useSettingsContext } from './context-hooks';
|
||||
import type { BrowserConfig, BrowserSavePayload } from '../types';
|
||||
|
||||
function mergeConfig(current: BrowserConfig, updates: BrowserSavePayload): BrowserConfig {
|
||||
return {
|
||||
claude: {
|
||||
...current.claude,
|
||||
...updates.claude,
|
||||
},
|
||||
codex: {
|
||||
...current.codex,
|
||||
...updates.codex,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useBrowserConfig() {
|
||||
const { state } = useSettingsContext();
|
||||
const actions = useSettingsActions();
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
actions.setBrowserLoading(true);
|
||||
actions.setBrowserError(null);
|
||||
const payload = await api.browser.get();
|
||||
actions.setBrowserConfig(payload.config);
|
||||
actions.setBrowserStatus(payload.status);
|
||||
} catch (err) {
|
||||
actions.setBrowserError((err as Error).message);
|
||||
} finally {
|
||||
actions.setBrowserLoading(false);
|
||||
actions.setBrowserStatusLoading(false);
|
||||
}
|
||||
}, [actions]);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
actions.setBrowserStatusLoading(true);
|
||||
actions.setBrowserError(null);
|
||||
const status = await api.browser.getStatus();
|
||||
actions.setBrowserStatus(status);
|
||||
return status;
|
||||
} catch (err) {
|
||||
actions.setBrowserError((err as Error).message);
|
||||
return null;
|
||||
} finally {
|
||||
actions.setBrowserStatusLoading(false);
|
||||
}
|
||||
}, [actions]);
|
||||
|
||||
const saveConfig = useCallback(
|
||||
async (updates: BrowserSavePayload) => {
|
||||
const currentConfig = state.browserConfig;
|
||||
if (!currentConfig) return false;
|
||||
|
||||
const optimisticConfig = mergeConfig(currentConfig, updates);
|
||||
actions.setBrowserConfig(optimisticConfig);
|
||||
|
||||
try {
|
||||
actions.setBrowserSaving(true);
|
||||
actions.setBrowserError(null);
|
||||
const response = await api.browser.update(updates);
|
||||
actions.setBrowserConfig(response.browser.config);
|
||||
actions.setBrowserStatus(response.browser.status);
|
||||
actions.setBrowserSuccess(true);
|
||||
window.setTimeout(() => actions.setBrowserSuccess(false), 1500);
|
||||
return true;
|
||||
} catch (err) {
|
||||
actions.setBrowserConfig(currentConfig);
|
||||
actions.setBrowserError((err as Error).message);
|
||||
return false;
|
||||
} finally {
|
||||
actions.setBrowserSaving(false);
|
||||
}
|
||||
},
|
||||
[actions, state.browserConfig]
|
||||
);
|
||||
|
||||
return {
|
||||
config: state.browserConfig,
|
||||
status: state.browserStatus,
|
||||
loading: state.browserLoading,
|
||||
statusLoading: state.browserStatusLoading,
|
||||
saving: state.browserSaving,
|
||||
error: state.browserError,
|
||||
success: state.browserSuccess,
|
||||
fetchConfig,
|
||||
fetchStatus,
|
||||
saveConfig,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ComponentType,
|
||||
} from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, FileCode, Copy, Check, GripVertical, AlertCircle } from 'lucide-react';
|
||||
import { CodeEditor } from '@/components/shared/code-editor';
|
||||
@@ -48,6 +49,7 @@ function lazyWithRetry<T extends ComponentType<unknown>>(importFn: () => Promise
|
||||
|
||||
// Lazy-loaded sections with retry capability
|
||||
const WebSearchSection = lazyWithRetry(() => import('./sections/websearch'));
|
||||
const BrowserSection = lazyWithRetry(() => import('./sections/browser'));
|
||||
const ImageAnalysisSection = lazyWithRetry(() => import('./sections/image-analysis'));
|
||||
const ChannelsSection = lazyWithRetry(() => import('./sections/channels'));
|
||||
const GlobalEnvSection = lazyWithRetry(() => import('./sections/globalenv-section'));
|
||||
@@ -99,10 +101,36 @@ class SectionErrorBoundary extends Component<
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSettingsTab(tabParam: string | null | undefined): SettingsTab {
|
||||
switch (tabParam?.toLowerCase()) {
|
||||
case 'browser':
|
||||
return 'browser';
|
||||
case 'imageanalysis':
|
||||
case 'image':
|
||||
return 'image';
|
||||
case 'channels':
|
||||
return 'channels';
|
||||
case 'globalenv':
|
||||
return 'globalenv';
|
||||
case 'proxy':
|
||||
return 'proxy';
|
||||
case 'auth':
|
||||
return 'auth';
|
||||
case 'thinking':
|
||||
return 'thinking';
|
||||
case 'backups':
|
||||
return 'backups';
|
||||
default:
|
||||
return 'websearch';
|
||||
}
|
||||
}
|
||||
|
||||
// Inner component that uses context
|
||||
function SettingsPageInner() {
|
||||
const { t } = useTranslation();
|
||||
const { activeTab, setActiveTab } = useSettingsTab();
|
||||
const { setActiveTab } = useSettingsTab();
|
||||
const [searchParams] = useSearchParams();
|
||||
const activeTab = resolveSettingsTab(searchParams.get('tab'));
|
||||
const {
|
||||
rawConfig,
|
||||
loading: rawConfigLoading,
|
||||
@@ -131,6 +159,7 @@ function SettingsPageInner() {
|
||||
</div>
|
||||
<SectionErrorBoundary>
|
||||
<Suspense fallback={<SectionSkeleton />}>
|
||||
{activeTab === 'browser' && <BrowserSection />}
|
||||
{activeTab === 'websearch' && <WebSearchSection />}
|
||||
{activeTab === 'image' && <ImageAnalysisSection />}
|
||||
{activeTab === 'channels' && <ChannelsSection />}
|
||||
@@ -156,6 +185,7 @@ function SettingsPageInner() {
|
||||
{/* Tab Content */}
|
||||
<SectionErrorBoundary>
|
||||
<Suspense fallback={<SectionSkeleton />}>
|
||||
{activeTab === 'browser' && <BrowserSection />}
|
||||
{activeTab === 'websearch' && <WebSearchSection />}
|
||||
{activeTab === 'image' && <ImageAnalysisSection />}
|
||||
{activeTab === 'channels' && <ChannelsSection />}
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
SearchCheck,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useBrowserConfig, useRawConfig } from '../../hooks';
|
||||
import type { BrowserConfig } from '../../types';
|
||||
|
||||
function getPlatformKey(): 'darwin' | 'linux' | 'win32' {
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
if (platform.includes('mac')) return 'darwin';
|
||||
if (platform.includes('win')) return 'win32';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
function parsePortDraft(value: string): number | null {
|
||||
if (!/^\d+$/.test(value.trim())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = Number.parseInt(value.trim(), 10);
|
||||
if (port < 1 || port > 65535) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
function statusTone(state: string) {
|
||||
if (state === 'ready' || state === 'enabled') {
|
||||
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
|
||||
}
|
||||
if (state === 'disabled') {
|
||||
return 'border-slate-500/20 bg-slate-500/10 text-slate-700 dark:text-slate-300';
|
||||
}
|
||||
return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300';
|
||||
}
|
||||
|
||||
function stateLabel(state: string) {
|
||||
return state.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function buildLaunchCommand(
|
||||
userDataDir: string,
|
||||
devtoolsPort: number,
|
||||
platform: 'darwin' | 'linux' | 'win32'
|
||||
): string {
|
||||
const quotedPath = JSON.stringify(userDataDir);
|
||||
if (platform === 'darwin') {
|
||||
return `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
|
||||
}
|
||||
return `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
|
||||
}
|
||||
|
||||
function updateClaudeDraft(
|
||||
source: BrowserConfig,
|
||||
updates: Partial<BrowserConfig['claude']>
|
||||
): BrowserConfig {
|
||||
return {
|
||||
...source,
|
||||
claude: {
|
||||
...source.claude,
|
||||
...updates,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function updateCodexDraft(
|
||||
source: BrowserConfig,
|
||||
updates: Partial<BrowserConfig['codex']>
|
||||
): BrowserConfig {
|
||||
return {
|
||||
...source,
|
||||
codex: {
|
||||
...source.codex,
|
||||
...updates,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function BrowserSection() {
|
||||
const { t } = useTranslation();
|
||||
const { fetchRawConfig } = useRawConfig();
|
||||
const {
|
||||
config,
|
||||
status,
|
||||
loading,
|
||||
statusLoading,
|
||||
saving,
|
||||
error,
|
||||
success,
|
||||
fetchConfig,
|
||||
fetchStatus,
|
||||
saveConfig,
|
||||
} = useBrowserConfig();
|
||||
|
||||
const [draft, setDraft] = useState<BrowserConfig | null>(null);
|
||||
const [claudePortDraft, setClaudePortDraft] = useState<string | null>(null);
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionMessage && !success) return;
|
||||
const timer = window.setTimeout(() => setActionMessage(null), 2500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [actionMessage, success]);
|
||||
|
||||
const effectiveConfig = draft ?? config;
|
||||
const preferredLaunchCommand = useMemo(() => {
|
||||
if (!effectiveConfig) return '';
|
||||
return buildLaunchCommand(
|
||||
effectiveConfig.claude.userDataDir,
|
||||
effectiveConfig.claude.devtoolsPort,
|
||||
getPlatformKey()
|
||||
);
|
||||
}, [effectiveConfig]);
|
||||
|
||||
const displayedClaudePort =
|
||||
claudePortDraft ?? String(effectiveConfig?.claude.devtoolsPort ?? 9222);
|
||||
const claudePort = parsePortDraft(displayedClaudePort);
|
||||
const claudePortInvalid = displayedClaudePort.trim().length > 0 && claudePort === null;
|
||||
|
||||
const hasClaudeChanges =
|
||||
config !== null &&
|
||||
effectiveConfig !== null &&
|
||||
(config.claude.enabled !== effectiveConfig.claude.enabled ||
|
||||
config.claude.userDataDir !== effectiveConfig.claude.userDataDir ||
|
||||
config.claude.devtoolsPort !== claudePort);
|
||||
const hasCodexChanges =
|
||||
config !== null &&
|
||||
effectiveConfig !== null &&
|
||||
config.codex.enabled !== effectiveConfig.codex.enabled;
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
setActionMessage(null);
|
||||
setDraft(null);
|
||||
setClaudePortDraft(null);
|
||||
await Promise.all([fetchConfig(), fetchRawConfig()]);
|
||||
}, [fetchConfig, fetchRawConfig]);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
const nextStatus = await fetchStatus();
|
||||
if (nextStatus) {
|
||||
setActionMessage(t('settingsPage.browserSection.messages.statusRefreshed'));
|
||||
}
|
||||
}, [fetchStatus, t]);
|
||||
|
||||
const saveClaudeSettings = useCallback(async () => {
|
||||
if (!effectiveConfig || claudePort === null) return;
|
||||
|
||||
const saved = await saveConfig({
|
||||
claude: {
|
||||
enabled: effectiveConfig.claude.enabled,
|
||||
userDataDir: effectiveConfig.claude.userDataDir.trim(),
|
||||
devtoolsPort: claudePort,
|
||||
},
|
||||
});
|
||||
|
||||
if (saved) {
|
||||
await fetchRawConfig();
|
||||
setActionMessage(null);
|
||||
setDraft(null);
|
||||
setClaudePortDraft(null);
|
||||
}
|
||||
}, [claudePort, effectiveConfig, fetchRawConfig, saveConfig]);
|
||||
|
||||
const saveCodexSettings = useCallback(async () => {
|
||||
if (!effectiveConfig) return;
|
||||
|
||||
const saved = await saveConfig({
|
||||
codex: {
|
||||
enabled: effectiveConfig.codex.enabled,
|
||||
},
|
||||
});
|
||||
|
||||
if (saved) {
|
||||
await fetchRawConfig();
|
||||
setActionMessage(null);
|
||||
setDraft(null);
|
||||
setClaudePortDraft(null);
|
||||
}
|
||||
}, [effectiveConfig, fetchRawConfig, saveConfig]);
|
||||
|
||||
const copyLaunchCommand = useCallback(async () => {
|
||||
if (!preferredLaunchCommand) return;
|
||||
await navigator.clipboard.writeText(preferredLaunchCommand);
|
||||
setActionMessage(t('settingsPage.browserSection.messages.launchCommandCopied'));
|
||||
}, [preferredLaunchCommand, t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<RefreshCw className="h-5 w-5 animate-spin" />
|
||||
<span>{t('settings.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config || !status || !effectiveConfig) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{error ?? t('settingsPage.browserSection.description')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="mt-4">
|
||||
<Button variant="outline" size="sm" onClick={refreshAll}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('sharedPage.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out',
|
||||
error || success || actionMessage
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'pointer-events-none -translate-y-2 opacity-0'
|
||||
)}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2 shadow-lg">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!error && (success || actionMessage) && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-emerald-700 shadow-lg dark:border-emerald-900/50 dark:bg-emerald-950/80 dark:text-emerald-300">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm font-medium">
|
||||
{actionMessage ?? t('commonToast.settingsSaved')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-5 p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t('settingsPage.browserSection.title')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settingsPage.browserSection.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={refreshAll} disabled={saving || loading}>
|
||||
<RefreshCw className={cn('mr-2 h-4 w-4', statusLoading && 'animate-spin')} />
|
||||
{t('settings.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Wrench className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<span className="font-medium">{t('settingsPage.browserSection.primaryTitle')}</span>{' '}
|
||||
{t('settingsPage.browserSection.primaryDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>{t('settingsPage.browserSection.claude.title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('settingsPage.browserSection.claude.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className={statusTone(status.claude.state)}>
|
||||
{stateLabel(status.claude.state)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_auto] md:items-center">
|
||||
<div>
|
||||
<Label htmlFor="browser-claude-enabled">
|
||||
{t('settingsPage.browserSection.claude.enabledLabel')}
|
||||
</Label>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('settingsPage.browserSection.claude.enabledDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="browser-claude-enabled"
|
||||
checked={effectiveConfig.claude.enabled}
|
||||
onCheckedChange={(next) =>
|
||||
setDraft((current) =>
|
||||
updateClaudeDraft(current ?? effectiveConfig, { enabled: next })
|
||||
)
|
||||
}
|
||||
aria-label={t('settingsPage.browserSection.claude.enabledLabel')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="browser-claude-user-data-dir">
|
||||
{t('settingsPage.browserSection.claude.userDataDir')}
|
||||
</Label>
|
||||
<Input
|
||||
id="browser-claude-user-data-dir"
|
||||
value={effectiveConfig.claude.userDataDir}
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
updateClaudeDraft(current ?? effectiveConfig, {
|
||||
userDataDir: event.target.value,
|
||||
})
|
||||
)
|
||||
}
|
||||
placeholder={status.claude.recommendedUserDataDir}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settingsPage.browserSection.claude.userDataDirHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="browser-claude-devtools-port">
|
||||
{t('settingsPage.browserSection.claude.devtoolsPort')}
|
||||
</Label>
|
||||
<Input
|
||||
id="browser-claude-devtools-port"
|
||||
value={displayedClaudePort}
|
||||
onChange={(event) => setClaudePortDraft(event.target.value)}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground',
|
||||
claudePortInvalid && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{claudePortInvalid
|
||||
? t('settingsPage.browserSection.claude.devtoolsPortInvalid')
|
||||
: t('settingsPage.browserSection.claude.devtoolsPortHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border bg-muted/30 p-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('settingsPage.browserSection.readiness')}
|
||||
</p>
|
||||
<p className="mt-1 font-medium">{status.claude.title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{status.claude.detail}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('settingsPage.browserSection.nextStep')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{status.claude.nextStep}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('settingsPage.browserSection.claude.effectivePath')}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-xs">
|
||||
{status.claude.effectiveUserDataDir}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('settingsPage.browserSection.claude.recommendedPath')}:{' '}
|
||||
{status.claude.recommendedUserDataDir}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('settingsPage.browserSection.claude.managedRuntime')}
|
||||
</p>
|
||||
<p className="mt-1">{status.claude.managedMcpServerName}</p>
|
||||
<p className="mt-2 break-all font-mono text-xs text-muted-foreground">
|
||||
{status.claude.managedMcpServerPath}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status.claude.overrideActive ? (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t('settingsPage.browserSection.claude.overrideMessage', {
|
||||
source: status.claude.source,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t('settingsPage.browserSection.claude.launchGuidance')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settingsPage.browserSection.claude.launchGuidanceHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyLaunchCommand}
|
||||
disabled={!preferredLaunchCommand}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{t('settingsPage.browserSection.actions.copyLaunchCommand')}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
||||
<code>{preferredLaunchCommand}</code>
|
||||
</pre>
|
||||
{status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
DevTools: {status.claude.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={saveClaudeSettings}
|
||||
disabled={saving || claudePortInvalid || !hasClaudeChanges}
|
||||
>
|
||||
{saving ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('settingsPage.browserSection.actions.saveClaude')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={refreshStatus}
|
||||
disabled={saving || statusLoading || hasClaudeChanges || claudePortInvalid}
|
||||
>
|
||||
<SearchCheck className="mr-2 h-4 w-4" />
|
||||
{t('settingsPage.browserSection.actions.testConnection')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>{t('settingsPage.browserSection.codex.title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('settingsPage.browserSection.codex.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className={statusTone(status.codex.state)}>
|
||||
{stateLabel(status.codex.state)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_auto] md:items-center">
|
||||
<div>
|
||||
<Label htmlFor="browser-codex-enabled">
|
||||
{t('settingsPage.browserSection.codex.enabledLabel')}
|
||||
</Label>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('settingsPage.browserSection.codex.enabledDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="browser-codex-enabled"
|
||||
checked={effectiveConfig.codex.enabled}
|
||||
onCheckedChange={(next) =>
|
||||
setDraft((current) =>
|
||||
updateCodexDraft(current ?? effectiveConfig, { enabled: next })
|
||||
)
|
||||
}
|
||||
aria-label={t('settingsPage.browserSection.codex.enabledLabel')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border bg-muted/30 p-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('settingsPage.browserSection.readiness')}
|
||||
</p>
|
||||
<p className="mt-1 font-medium">{status.codex.title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{status.codex.detail}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('settingsPage.browserSection.nextStep')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{status.codex.nextStep}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-sm md:grid-cols-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('settingsPage.browserSection.codex.serverName')}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xs">{status.codex.serverName}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('settingsPage.browserSection.codex.overrideSupport')}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{status.codex.supportsConfigOverrides
|
||||
? t('settingsPage.browserSection.codex.overrideSupported')
|
||||
: t('settingsPage.browserSection.codex.overrideUnsupported')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('settingsPage.browserSection.codex.binary')}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-xs">
|
||||
{status.codex.binaryPath ?? t('settingsPage.browserSection.codex.notDetected')}
|
||||
</p>
|
||||
{status.codex.version ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{status.codex.version}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={saveCodexSettings} disabled={saving || !hasCodexChanges}>
|
||||
{saving ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('settingsPage.browserSection.actions.saveCodex')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { createContext, type Dispatch } from 'react';
|
||||
import type {
|
||||
BrowserConfig,
|
||||
BrowserStatus,
|
||||
WebSearchConfig,
|
||||
GlobalEnvConfig,
|
||||
CliproxyServerConfig,
|
||||
@@ -15,6 +17,14 @@ import type {
|
||||
// === State ===
|
||||
|
||||
export interface SettingsState {
|
||||
// Browser state
|
||||
browserConfig: BrowserConfig | null;
|
||||
browserStatus: BrowserStatus | null;
|
||||
browserLoading: boolean;
|
||||
browserStatusLoading: boolean;
|
||||
browserSaving: boolean;
|
||||
browserError: string | null;
|
||||
browserSuccess: boolean;
|
||||
// WebSearch state
|
||||
webSearchConfig: WebSearchConfig | null;
|
||||
webSearchStatus: WebSearchStatus | null;
|
||||
@@ -43,6 +53,13 @@ export interface SettingsState {
|
||||
}
|
||||
|
||||
export const initialSettingsState: SettingsState = {
|
||||
browserConfig: null,
|
||||
browserStatus: null,
|
||||
browserLoading: true,
|
||||
browserStatusLoading: true,
|
||||
browserSaving: false,
|
||||
browserError: null,
|
||||
browserSuccess: false,
|
||||
webSearchConfig: null,
|
||||
webSearchStatus: null,
|
||||
webSearchLoading: true,
|
||||
@@ -69,6 +86,13 @@ export const initialSettingsState: SettingsState = {
|
||||
// === Actions ===
|
||||
|
||||
export type SettingsAction =
|
||||
| { type: 'SET_BROWSER_CONFIG'; payload: BrowserConfig | null }
|
||||
| { type: 'SET_BROWSER_STATUS'; payload: BrowserStatus | null }
|
||||
| { type: 'SET_BROWSER_LOADING'; payload: boolean }
|
||||
| { type: 'SET_BROWSER_STATUS_LOADING'; payload: boolean }
|
||||
| { type: 'SET_BROWSER_SAVING'; payload: boolean }
|
||||
| { type: 'SET_BROWSER_ERROR'; payload: string | null }
|
||||
| { type: 'SET_BROWSER_SUCCESS'; payload: boolean }
|
||||
| { type: 'SET_WEBSEARCH_CONFIG'; payload: WebSearchConfig | null }
|
||||
| { type: 'SET_WEBSEARCH_STATUS'; payload: WebSearchStatus | null }
|
||||
| { type: 'SET_WEBSEARCH_LOADING'; payload: boolean }
|
||||
@@ -93,6 +117,20 @@ export type SettingsAction =
|
||||
|
||||
export function settingsReducer(state: SettingsState, action: SettingsAction): SettingsState {
|
||||
switch (action.type) {
|
||||
case 'SET_BROWSER_CONFIG':
|
||||
return { ...state, browserConfig: action.payload };
|
||||
case 'SET_BROWSER_STATUS':
|
||||
return { ...state, browserStatus: action.payload };
|
||||
case 'SET_BROWSER_LOADING':
|
||||
return { ...state, browserLoading: action.payload };
|
||||
case 'SET_BROWSER_STATUS_LOADING':
|
||||
return { ...state, browserStatusLoading: action.payload };
|
||||
case 'SET_BROWSER_SAVING':
|
||||
return { ...state, browserSaving: action.payload };
|
||||
case 'SET_BROWSER_ERROR':
|
||||
return { ...state, browserError: action.payload };
|
||||
case 'SET_BROWSER_SUCCESS':
|
||||
return { ...state, browserSuccess: action.payload };
|
||||
case 'SET_WEBSEARCH_CONFIG':
|
||||
return { ...state, webSearchConfig: action.payload };
|
||||
case 'SET_WEBSEARCH_STATUS':
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
* Type definitions for WebSearch, GlobalEnv, and Proxy configurations
|
||||
*/
|
||||
|
||||
import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client';
|
||||
import type {
|
||||
BrowserSettingsConfig,
|
||||
BrowserStatusPayload,
|
||||
CliproxyServerConfig,
|
||||
RemoteProxyStatus,
|
||||
UpdateBrowserSettingsPayload,
|
||||
} from '@/lib/api-client';
|
||||
|
||||
// === WebSearch Types ===
|
||||
|
||||
@@ -162,6 +168,7 @@ export interface OfficialChannelsStatus {
|
||||
// === Tab Types ===
|
||||
|
||||
export type SettingsTab =
|
||||
| 'browser'
|
||||
| 'websearch'
|
||||
| 'image'
|
||||
| 'channels'
|
||||
@@ -192,3 +199,6 @@ export interface ThinkingConfig {
|
||||
// === Re-exports from api-client ===
|
||||
|
||||
export type { CliproxyServerConfig, RemoteProxyStatus };
|
||||
export type BrowserConfig = BrowserSettingsConfig;
|
||||
export type BrowserStatus = BrowserStatusPayload;
|
||||
export type BrowserSavePayload = UpdateBrowserSettingsPayload;
|
||||
|
||||
Reference in New Issue
Block a user