feat(cli): add browser automation commands

This commit is contained in:
Tam Nhu Tran
2026-04-16 18:49:24 -04:00
parent b6aef885ad
commit 4e30c9b080
42 changed files with 3028 additions and 32 deletions
@@ -139,6 +139,11 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
Export <code>CLIPROXY_API_KEY</code> before launching native Codex.
</li>
</ol>
<p>
CCS-managed browser tooling belongs to <code>Settings &gt; Browser</code>. Do not edit
the <code>ccs_browser</code> entry from the generic MCP card unless you are
intentionally overriding the managed path in raw TOML.
</p>
</CardContent>
</Card>
@@ -36,6 +36,8 @@ const EMPTY_MCP_SERVER_DRAFT: CodexMcpServerEntry = {
toolTimeoutSec: null,
enabledTools: [],
disabledTools: [],
isCcsManaged: false,
managementSurface: null,
};
function toCsv(value: string[]) {
@@ -70,9 +72,16 @@ function McpServerEditor({
}: McpServerEditorProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<CodexMcpServerEntry>(initialDraft);
const reservedManagedBrowserDraft = isNew && draft.name.trim() === 'ccs_browser';
return (
<>
{reservedManagedBrowserDraft ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">
<strong>ccs_browser</strong> is reserved for the CCS-managed browser tooling path.
Configure it from <code>Settings &gt; Browser</code> instead of creating it here.
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<Input
value={draft.name}
@@ -216,7 +225,9 @@ function McpServerEditor({
disabledTools: draft.disabledTools,
})
}
disabled={disabled || saving || draft.name.trim().length === 0}
disabled={
disabled || saving || draft.name.trim().length === 0 || reservedManagedBrowserDraft
}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{/* TODO i18n: missing key codex.saveMcpServer */}
@@ -244,6 +255,8 @@ export function CodexMcpServersCard({
);
const draftSeed = selectedEntry ?? EMPTY_MCP_SERVER_DRAFT;
const draftKey = JSON.stringify(draftSeed);
const selectedEntryIsManagedBrowser =
selectedEntry?.isCcsManaged && selectedEntry.managementSurface === 'browser-settings';
return (
<CodexConfigCardShell
@@ -255,6 +268,12 @@ export function CodexMcpServersCard({
description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML."
disabledReason={disabledReason}
>
{selectedEntryIsManagedBrowser ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">
<strong>{selectedEntry?.name}</strong> is CCS-managed. Configure browser tooling from{' '}
<code>Settings &gt; Browser</code>; the generic MCP editor is read-only for this entry.
</div>
) : null}
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
{/* TODO i18n: missing key codex.selectMcpServer */}
@@ -264,7 +283,7 @@ export function CodexMcpServersCard({
<SelectItem value="new">{t('codex.createNewMcpServer')}</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
{entry.isCcsManaged ? `${entry.name} (CCS managed)` : entry.name}
</SelectItem>
))}
</SelectContent>
@@ -273,9 +292,9 @@ export function CodexMcpServersCard({
key={draftKey}
initialDraft={draftSeed}
isNew={selectedName === 'new'}
disabled={disabled}
disabled={disabled || Boolean(selectedEntryIsManagedBrowser)}
saving={saving}
canDelete={selectedEntry !== null}
canDelete={selectedEntry !== null && !selectedEntryIsManagedBrowser}
onDelete={async () => {
if (!selectedEntry) return;
await onDelete(selectedEntry.name);
+71
View File
@@ -10,6 +10,7 @@ import type {
UpsertAiProviderEntryInput,
} from '../../../src/cliproxy/ai-providers';
import type { ProviderEntitlementEvidence } from '../../../src/cliproxy/provider-entitlement-types';
import type { BrowserRuntimeEnv } from '../../../src/utils/browser/chrome-reuse';
export const API_BASE_URL = '/api';
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
@@ -233,6 +234,67 @@ export interface UpdateImageAnalysisSettingsPayload {
profileBackends?: Record<string, string>;
}
export interface BrowserSettingsConfig {
claude: {
enabled: boolean;
userDataDir: string;
devtoolsPort: number;
};
codex: {
enabled: boolean;
};
}
export interface BrowserLaunchCommands {
darwin: string;
linux: string;
win32: string;
}
export interface ClaudeBrowserStatus {
enabled: boolean;
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
overrideActive: boolean;
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
title: string;
detail: string;
nextStep: string;
effectiveUserDataDir: string;
recommendedUserDataDir: string;
devtoolsPort: number;
managedMcpServerName: string;
managedMcpServerPath: string;
launchCommands: BrowserLaunchCommands;
runtimeEnv?: BrowserRuntimeEnv;
}
export interface CodexBrowserStatus {
enabled: boolean;
state: 'disabled' | 'enabled' | 'unsupported_build';
title: string;
detail: string;
nextStep: string;
serverName: string;
supportsConfigOverrides: boolean;
binaryPath: string | null;
version?: string;
}
export interface BrowserStatusPayload {
claude: ClaudeBrowserStatus;
codex: CodexBrowserStatus;
}
export interface BrowserDashboardPayload {
config: BrowserSettingsConfig;
status: BrowserStatusPayload;
}
export interface UpdateBrowserSettingsPayload {
claude?: Partial<BrowserSettingsConfig['claude']>;
codex?: Partial<BrowserSettingsConfig['codex']>;
}
export interface Profile {
name: string;
settingsPath: string;
@@ -1090,6 +1152,15 @@ export const api = {
body: JSON.stringify(data),
}),
},
browser: {
get: () => request<BrowserDashboardPayload>('/browser'),
getStatus: () => request<BrowserStatusPayload>('/browser/status'),
update: (data: UpdateBrowserSettingsPayload) =>
request<{ success: boolean; browser: BrowserDashboardPayload }>('/browser', {
method: 'PUT',
body: JSON.stringify(data),
}),
},
logs: {
getConfig: () => request<{ logging: LogsConfig }>('/logs/config'),
updateConfig: (data: UpdateLogsConfigPayload) =>
+4
View File
@@ -45,6 +45,8 @@ export interface CodexMcpServerEntry {
toolTimeoutSec: number | null;
enabledTools: string[];
disabledTools: string[];
isCcsManaged: boolean;
managementSurface: 'browser-settings' | null;
}
export interface CodexFeatureCatalogEntry {
@@ -239,6 +241,8 @@ export function readCodexMcpServers(config: Record<string, unknown> | null): Cod
toolTimeoutSec: asNumber(server.tool_timeout_sec),
enabledTools: asStringArray(server.enabled_tools),
disabledTools: asStringArray(server.disabled_tools),
isCcsManaged: name === 'ccs_browser',
managementSurface: name === 'ccs_browser' ? 'browser-settings' : null,
};
})
.filter((entry): entry is CodexMcpServerEntry => entry !== null)
+220
View File
@@ -873,6 +873,7 @@ const resources = {
configFileNotFound: 'Config file not found',
},
settingsTabs: {
browser: 'Browser',
web: 'Web',
image: 'Image',
channels: 'Channels',
@@ -2424,6 +2425,61 @@ const resources = {
description: 'Configure image analysis settings.',
loading: 'Loading image settings...',
},
browserSection: {
title: 'Browser',
description: 'Primary setup surface for managed browser automation in CCS.',
primaryTitle: 'Settings > Browser is the primary browser setup surface.',
primaryDescription:
'Configure Claude Browser Attach and Codex Browser Tools here, then use the guidance below to launch or verify each lane.',
readiness: 'Readiness',
nextStep: 'Next step',
actions: {
saveClaude: 'Save Claude settings',
saveCodex: 'Save Codex settings',
testConnection: 'Test connection',
copyLaunchCommand: 'Copy launch command',
},
messages: {
statusRefreshed: 'Browser status refreshed',
launchCommandCopied: 'Launch command copied',
},
claude: {
title: 'Claude Browser Attach',
description:
'Attach Claude-target launches to a managed Chrome session with remote debugging enabled.',
enabledLabel: 'Enable Claude Browser Attach',
enabledDescription:
'When enabled, CCS prepares the managed browser MCP runtime for Claude-target launches.',
userDataDir: 'Chrome user-data directory',
userDataDirHint:
'Use a dedicated Chrome profile directory so attach-mode state stays isolated from your daily browser.',
devtoolsPort: 'DevTools port',
devtoolsPortHint: 'Use the same port when you launch Chrome in attach mode.',
devtoolsPortInvalid: 'Enter a valid port between 1 and 65535.',
effectivePath: 'Effective attach path',
recommendedPath: 'Recommended path',
managedRuntime: 'Managed browser runtime',
overrideMessage:
'An environment override is currently active from {{source}}. This dashboard remains the source of truth once that override is removed.',
launchGuidance: 'Launch guidance',
launchGuidanceHint:
'Start Chrome with remote debugging enabled, then rerun Test connection if needed.',
},
codex: {
title: 'Codex Browser Tools',
description:
'Controls the managed Playwright MCP override path for Codex-target launches.',
enabledLabel: 'Enable Codex Browser Tools',
enabledDescription:
'Keep this on to make the Browser page the first-class setup surface for Codex browser tooling.',
serverName: 'Managed server name',
overrideSupport: 'Config override support',
overrideSupported: 'Supported',
overrideUnsupported: 'Not supported',
binary: 'Detected Codex binary',
notDetected: 'Not detected',
},
},
},
codexPage: {
title: 'Codex',
@@ -3284,6 +3340,7 @@ const resources = {
configFileNotFound: '未找到配置文件',
},
settingsTabs: {
browser: '浏览器',
web: '网页',
image: '图片',
channels: '频道',
@@ -4781,6 +4838,55 @@ const resources = {
description: '配置图片分析设置。',
loading: '加载图片设置中...',
},
browserSection: {
title: '浏览器',
description: 'CCS 中托管浏览器自动化的主设置入口。',
primaryTitle: 'Settings > Browser 是主要的浏览器设置界面。',
primaryDescription:
'在这里配置 Claude Browser Attach 和 Codex Browser Tools,然后按下方指引启动或验证每条路径。',
readiness: '就绪状态',
nextStep: '下一步',
actions: {
saveClaude: '保存 Claude 设置',
saveCodex: '保存 Codex 设置',
testConnection: '测试连接',
copyLaunchCommand: '复制启动命令',
},
messages: {
statusRefreshed: '浏览器状态已刷新',
launchCommandCopied: '启动命令已复制',
},
claude: {
title: 'Claude Browser Attach',
description: '将 Claude 目标会话附加到启用了远程调试的 Chrome 会话。',
enabledLabel: '启用 Claude Browser Attach',
enabledDescription: '启用后,CCS 会为 Claude 目标会话准备托管浏览器 MCP 运行时。',
userDataDir: 'Chrome 用户数据目录',
userDataDirHint: '建议使用单独的 Chrome 配置目录,避免自动化状态污染日常浏览器。',
devtoolsPort: 'DevTools 端口',
devtoolsPortHint: '必须与 Chrome attach 模式实际启动时使用的端口一致。',
devtoolsPortInvalid: '请输入 1 到 65535 之间的有效端口。',
effectivePath: '当前生效的 attach 路径',
recommendedPath: '推荐路径',
managedRuntime: '托管浏览器运行时',
overrideMessage:
'当前存在来自 {{source}} 的环境变量覆盖。移除该覆盖后,Dashboard 配置将重新成为唯一来源。',
launchGuidance: '启动指引',
launchGuidanceHint: '使用远程调试启动 Chrome,如有需要再重新执行 Test connection。',
},
codex: {
title: 'Codex Browser Tools',
description: '控制 Codex 目标会话中的托管 Playwright MCP 浏览器路径。',
enabledLabel: '启用 Codex Browser Tools',
enabledDescription: '保持开启可让 Browser 页面成为 Codex 浏览器工具的主设置入口。',
serverName: '托管服务名称',
overrideSupport: '配置覆盖支持',
overrideSupported: '支持',
overrideUnsupported: '不支持',
binary: '检测到的 Codex 可执行文件',
notDetected: '未检测到',
},
},
},
codexPage: {
title: 'Codex',
@@ -5716,6 +5822,7 @@ const resources = {
configFileNotFound: 'Không tìm thấy tập tin cấu hình',
},
settingsTabs: {
browser: 'Trình duyệt',
web: 'Web',
image: 'Hình ảnh',
channels: 'Kênh',
@@ -7246,6 +7353,62 @@ const resources = {
description: 'Cấu hình cài đặt phân tích hình ảnh.',
loading: 'Đang tải cài đặt hình ảnh...',
},
browserSection: {
title: 'Trình duyệt',
description: 'Bề mặt thiết lập chính cho tự động hóa trình duyệt được CCS quản lý.',
primaryTitle: 'Settings > Browser là nơi thiết lập trình duyệt chính.',
primaryDescription:
'Cấu hình Claude Browser Attach và Codex Browser Tools tại đây, rồi dùng hướng dẫn bên dưới để khởi chạy hoặc kiểm tra từng luồng.',
readiness: 'Trạng thái sẵn sàng',
nextStep: 'Bước tiếp theo',
actions: {
saveClaude: 'Lưu cấu hình Claude',
saveCodex: 'Lưu cấu hình Codex',
testConnection: 'Kiểm tra kết nối',
copyLaunchCommand: 'Sao chép lệnh khởi chạy',
},
messages: {
statusRefreshed: 'Đã làm mới trạng thái trình duyệt',
launchCommandCopied: 'Đã sao chép lệnh khởi chạy',
},
claude: {
title: 'Claude Browser Attach',
description:
'Gắn các phiên chạy Claude-target vào một phiên Chrome có bật remote debugging.',
enabledLabel: 'Bật Claude Browser Attach',
enabledDescription:
'Khi bật, CCS sẽ chuẩn bị runtime MCP trình duyệt được quản lý cho các phiên chạy Claude-target.',
userDataDir: 'Thư mục dữ liệu người dùng Chrome',
userDataDirHint:
'Nên dùng thư mục profile Chrome riêng để trạng thái attach được tách khỏi trình duyệt hằng ngày.',
devtoolsPort: 'Cổng DevTools',
devtoolsPortHint:
'Phải khớp với cổng thực tế dùng khi khởi chạy Chrome trong attach mode.',
devtoolsPortInvalid: 'Nhập cổng hợp lệ trong khoảng 1 đến 65535.',
effectivePath: 'Đường dẫn attach đang có hiệu lực',
recommendedPath: 'Đường dẫn khuyến nghị',
managedRuntime: 'Runtime trình duyệt được quản lý',
overrideMessage:
'Hiện có override môi trường từ {{source}}. Khi bỏ override này, Dashboard sẽ lại là nguồn cấu hình chính.',
launchGuidance: 'Hướng dẫn khởi chạy',
launchGuidanceHint:
'Khởi chạy Chrome với remote debugging, rồi chạy lại Test connection nếu cần.',
},
codex: {
title: 'Codex Browser Tools',
description:
'Điều khiển đường dẫn trình duyệt Playwright MCP được quản lý cho các phiên chạy Codex-target.',
enabledLabel: 'Bật Codex Browser Tools',
enabledDescription:
'Giữ bật để trang Browser là nơi cấu hình chính cho công cụ trình duyệt của Codex.',
serverName: 'Tên dịch vụ được quản lý',
overrideSupport: 'Hỗ trợ config override',
overrideSupported: 'Được hỗ trợ',
overrideUnsupported: 'Không được hỗ trợ',
binary: 'Binary Codex được phát hiện',
notDetected: 'Không phát hiện',
},
},
},
codexPage: {
title: 'Codex',
@@ -8180,6 +8343,7 @@ const resources = {
configFileNotFound: '設定ファイルが見つかりません',
},
settingsTabs: {
browser: 'ブラウザ',
web: 'Web検索',
image: '画像',
channels: 'チャンネル',
@@ -9611,6 +9775,62 @@ const resources = {
description: '画像分析の設定。',
loading: '画像設定を読み込み中...',
},
browserSection: {
title: 'ブラウザ',
description: 'CCS が管理するブラウザ自動化の主要な設定画面です。',
primaryTitle: 'Settings > Browser がブラウザ設定の主入口です。',
primaryDescription:
'ここで Claude Browser Attach と Codex Browser Tools を設定し、各レーンの起動や確認は下の案内を使ってください。',
readiness: '準備状況',
nextStep: '次の手順',
actions: {
saveClaude: 'Claude 設定を保存',
saveCodex: 'Codex 設定を保存',
testConnection: '接続を確認',
copyLaunchCommand: '起動コマンドをコピー',
},
messages: {
statusRefreshed: 'ブラウザ状態を更新しました',
launchCommandCopied: '起動コマンドをコピーしました',
},
claude: {
title: 'Claude Browser Attach',
description:
'リモートデバッグを有効にした Chrome セッションへ Claude-target の起動を接続します。',
enabledLabel: 'Claude Browser Attach を有効にする',
enabledDescription:
'有効にすると、CCS は Claude-target 起動向けに管理済みブラウザ MCP ランタイムを準備します。',
userDataDir: 'Chrome ユーザーデータディレクトリ',
userDataDirHint:
'日常利用のブラウザと状態を分離するため、専用の Chrome プロファイルディレクトリを使うことを推奨します。',
devtoolsPort: 'DevTools ポート',
devtoolsPortHint:
'Chrome を attach モードで起動したときに使う実際のポートと一致させてください。',
devtoolsPortInvalid: '1 から 65535 の有効なポート番号を入力してください。',
effectivePath: '現在有効な attach パス',
recommendedPath: '推奨パス',
managedRuntime: '管理済みブラウザランタイム',
overrideMessage:
'{{source}} から環境変数オーバーライドが有効です。これを外すと Dashboard の設定が再び主ソースになります。',
launchGuidance: '起動ガイダンス',
launchGuidanceHint:
'リモートデバッグ付きで Chrome を起動し、必要なら Test connection を再実行してください。',
},
codex: {
title: 'Codex Browser Tools',
description:
'Codex-target 起動向けの管理済み Playwright MCP ブラウザパスを制御します。',
enabledLabel: 'Codex Browser Tools を有効にする',
enabledDescription:
'有効のままにすると、Browser ページが Codex ブラウザツールの主設定画面になります。',
serverName: '管理対象サーバー名',
overrideSupport: 'config override 対応',
overrideSupported: '対応',
overrideUnsupported: '非対応',
binary: '検出された Codex バイナリ',
notDetected: '未検出',
},
},
},
setupWizard: {
title: 'クイックセットアップウィザード',
@@ -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" />
+1
View File
@@ -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,
+1
View File
@@ -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,
};
}
+31 -1
View File
@@ -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>
);
}
+38
View File
@@ -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':
+11 -1
View File
@@ -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;