mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-07 18:16:48 +00:00
refactor(ui): modularize settings page into directory structure
- split settings.tsx (1,781 lines) into modular components - extract hooks into separate files (context-hooks, use-*-config) - create sections: websearch, proxy, globalenv with subcomponents - add tab-navigation and section-skeleton components - add settings-context.ts for state management
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Section Skeleton Component
|
||||||
|
* Loading placeholder for lazy-loaded settings sections
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export function SectionSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 p-5 space-y-6">
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<div className="p-4 rounded-lg border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-10 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-20 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-20 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-20 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Tab Navigation Component
|
||||||
|
* Settings page tab switcher with icons
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Globe, Settings2, Server } from 'lucide-react';
|
||||||
|
import type { SettingsTab } from '../types';
|
||||||
|
|
||||||
|
interface TabNavigationProps {
|
||||||
|
activeTab: SettingsTab;
|
||||||
|
onTabChange: (tab: SettingsTab) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
||||||
|
return (
|
||||||
|
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="websearch" className="flex-1 gap-2">
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
WebSearch
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="globalenv" className="flex-1 gap-2">
|
||||||
|
<Settings2 className="w-4 h-4" />
|
||||||
|
Global Env
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="proxy" className="flex-1 gap-2">
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
Proxy
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Settings Provider Component
|
||||||
|
* React component that provides settings context
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useReducer, type ReactNode } from 'react';
|
||||||
|
import { SettingsContext, settingsReducer, initialSettingsState } from './settings-context';
|
||||||
|
|
||||||
|
interface SettingsProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||||
|
const [state, dispatch] = useReducer(settingsReducer, initialSettingsState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContext.Provider value={{ state, dispatch }}>{children}</SettingsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Settings Hooks - Re-export from hooks directory
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
useSettingsContext,
|
||||||
|
useSettingsActions,
|
||||||
|
useSettingsTab,
|
||||||
|
useWebSearchConfig,
|
||||||
|
useGlobalEnvConfig,
|
||||||
|
useProxyConfig,
|
||||||
|
useRawConfig,
|
||||||
|
} from './hooks/index';
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Context Hooks
|
||||||
|
* Hooks for accessing and updating settings context state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useContext } from 'react';
|
||||||
|
import { SettingsContext } from '../settings-context';
|
||||||
|
import type {
|
||||||
|
WebSearchConfig,
|
||||||
|
GlobalEnvConfig,
|
||||||
|
CliproxyServerConfig,
|
||||||
|
WebSearchStatus,
|
||||||
|
RemoteProxyStatus,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export function useSettingsContext() {
|
||||||
|
const context = useContext(SettingsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSettingsContext must be used within a SettingsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettingsActions() {
|
||||||
|
const { dispatch } = useSettingsContext();
|
||||||
|
|
||||||
|
const setWebSearchConfig = useCallback(
|
||||||
|
(config: WebSearchConfig | null) => dispatch({ type: 'SET_WEBSEARCH_CONFIG', payload: config }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setWebSearchStatus = useCallback(
|
||||||
|
(status: WebSearchStatus | null) => dispatch({ type: 'SET_WEBSEARCH_STATUS', payload: status }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setWebSearchLoading = useCallback(
|
||||||
|
(loading: boolean) => dispatch({ type: 'SET_WEBSEARCH_LOADING', payload: loading }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setWebSearchStatusLoading = useCallback(
|
||||||
|
(loading: boolean) => dispatch({ type: 'SET_WEBSEARCH_STATUS_LOADING', payload: loading }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setWebSearchSaving = useCallback(
|
||||||
|
(saving: boolean) => dispatch({ type: 'SET_WEBSEARCH_SAVING', payload: saving }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setWebSearchError = useCallback(
|
||||||
|
(error: string | null) => dispatch({ type: 'SET_WEBSEARCH_ERROR', payload: error }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setWebSearchSuccess = useCallback(
|
||||||
|
(success: boolean) => dispatch({ type: 'SET_WEBSEARCH_SUCCESS', payload: success }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setGlobalEnvConfig = useCallback(
|
||||||
|
(config: GlobalEnvConfig | null) => dispatch({ type: 'SET_GLOBALENV_CONFIG', payload: config }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setGlobalEnvLoading = useCallback(
|
||||||
|
(loading: boolean) => dispatch({ type: 'SET_GLOBALENV_LOADING', payload: loading }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setGlobalEnvSaving = useCallback(
|
||||||
|
(saving: boolean) => dispatch({ type: 'SET_GLOBALENV_SAVING', payload: saving }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setGlobalEnvError = useCallback(
|
||||||
|
(error: string | null) => dispatch({ type: 'SET_GLOBALENV_ERROR', payload: error }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setGlobalEnvSuccess = useCallback(
|
||||||
|
(success: boolean) => dispatch({ type: 'SET_GLOBALENV_SUCCESS', payload: success }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setProxyConfig = useCallback(
|
||||||
|
(config: CliproxyServerConfig | null) =>
|
||||||
|
dispatch({ type: 'SET_PROXY_CONFIG', payload: config }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setProxyLoading = useCallback(
|
||||||
|
(loading: boolean) => dispatch({ type: 'SET_PROXY_LOADING', payload: loading }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setProxySaving = useCallback(
|
||||||
|
(saving: boolean) => dispatch({ type: 'SET_PROXY_SAVING', payload: saving }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setProxyError = useCallback(
|
||||||
|
(error: string | null) => dispatch({ type: 'SET_PROXY_ERROR', payload: error }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setProxySuccess = useCallback(
|
||||||
|
(success: boolean) => dispatch({ type: 'SET_PROXY_SUCCESS', payload: success }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setProxyTestResult = useCallback(
|
||||||
|
(result: RemoteProxyStatus | null) =>
|
||||||
|
dispatch({ type: 'SET_PROXY_TEST_RESULT', payload: result }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setProxyTesting = useCallback(
|
||||||
|
(testing: boolean) => dispatch({ type: 'SET_PROXY_TESTING', payload: testing }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setRawConfig = useCallback(
|
||||||
|
(config: string | null) => dispatch({ type: 'SET_RAW_CONFIG', payload: config }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setRawConfigLoading = useCallback(
|
||||||
|
(loading: boolean) => dispatch({ type: 'SET_RAW_CONFIG_LOADING', payload: loading }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
setWebSearchConfig,
|
||||||
|
setWebSearchStatus,
|
||||||
|
setWebSearchLoading,
|
||||||
|
setWebSearchStatusLoading,
|
||||||
|
setWebSearchSaving,
|
||||||
|
setWebSearchError,
|
||||||
|
setWebSearchSuccess,
|
||||||
|
setGlobalEnvConfig,
|
||||||
|
setGlobalEnvLoading,
|
||||||
|
setGlobalEnvSaving,
|
||||||
|
setGlobalEnvError,
|
||||||
|
setGlobalEnvSuccess,
|
||||||
|
setProxyConfig,
|
||||||
|
setProxyLoading,
|
||||||
|
setProxySaving,
|
||||||
|
setProxyError,
|
||||||
|
setProxySuccess,
|
||||||
|
setProxyTestResult,
|
||||||
|
setProxyTesting,
|
||||||
|
setRawConfig,
|
||||||
|
setRawConfigLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Settings Hooks Barrel Export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useSettingsContext, useSettingsActions } from './context-hooks';
|
||||||
|
export { useSettingsTab } from './use-settings-tab';
|
||||||
|
export { useWebSearchConfig } from './use-websearch-config';
|
||||||
|
export { useGlobalEnvConfig } from './use-globalenv-config';
|
||||||
|
export { useProxyConfig } from './use-proxy-config';
|
||||||
|
export { useRawConfig } from './use-raw-config';
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* GlobalEnv Config Hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useSettingsContext, useSettingsActions } from './context-hooks';
|
||||||
|
import type { GlobalEnvConfig } from '../types';
|
||||||
|
|
||||||
|
export function useGlobalEnvConfig() {
|
||||||
|
const { state } = useSettingsContext();
|
||||||
|
const actions = useSettingsActions();
|
||||||
|
const [newEnvKey, setNewEnvKey] = useState('');
|
||||||
|
const [newEnvValue, setNewEnvValue] = useState('');
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
actions.setGlobalEnvLoading(true);
|
||||||
|
actions.setGlobalEnvError(null);
|
||||||
|
const res = await fetch('/api/global-env');
|
||||||
|
if (!res.ok) throw new Error('Failed to load Global Env config');
|
||||||
|
const data = await res.json();
|
||||||
|
actions.setGlobalEnvConfig(data);
|
||||||
|
} catch (err) {
|
||||||
|
actions.setGlobalEnvError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
actions.setGlobalEnvLoading(false);
|
||||||
|
}
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const saveConfig = useCallback(
|
||||||
|
async (updates: Partial<GlobalEnvConfig>) => {
|
||||||
|
const config = state.globalEnvConfig;
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const optimisticConfig = { ...config, ...updates };
|
||||||
|
actions.setGlobalEnvConfig(optimisticConfig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
actions.setGlobalEnvSaving(true);
|
||||||
|
actions.setGlobalEnvError(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/global-env', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(optimisticConfig),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'Failed to save');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
actions.setGlobalEnvConfig(data.config);
|
||||||
|
actions.setGlobalEnvSuccess(true);
|
||||||
|
setTimeout(() => actions.setGlobalEnvSuccess(false), 1500);
|
||||||
|
} catch (err) {
|
||||||
|
actions.setGlobalEnvConfig(config);
|
||||||
|
actions.setGlobalEnvError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
actions.setGlobalEnvSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.globalEnvConfig, actions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addEnvVar = useCallback(() => {
|
||||||
|
if (!newEnvKey.trim() || !state.globalEnvConfig) return;
|
||||||
|
const newEnv = { ...state.globalEnvConfig.env, [newEnvKey.trim()]: newEnvValue };
|
||||||
|
saveConfig({ env: newEnv });
|
||||||
|
setNewEnvKey('');
|
||||||
|
setNewEnvValue('');
|
||||||
|
}, [newEnvKey, newEnvValue, state.globalEnvConfig, saveConfig]);
|
||||||
|
|
||||||
|
const removeEnvVar = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
if (!state.globalEnvConfig) return;
|
||||||
|
const newEnv = { ...state.globalEnvConfig.env };
|
||||||
|
delete newEnv[key];
|
||||||
|
saveConfig({ env: newEnv });
|
||||||
|
},
|
||||||
|
[state.globalEnvConfig, saveConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: state.globalEnvConfig,
|
||||||
|
loading: state.globalEnvLoading,
|
||||||
|
saving: state.globalEnvSaving,
|
||||||
|
error: state.globalEnvError,
|
||||||
|
success: state.globalEnvSuccess,
|
||||||
|
newEnvKey,
|
||||||
|
setNewEnvKey,
|
||||||
|
newEnvValue,
|
||||||
|
setNewEnvValue,
|
||||||
|
fetchConfig,
|
||||||
|
saveConfig,
|
||||||
|
addEnvVar,
|
||||||
|
removeEnvVar,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Proxy Config Hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import { useSettingsContext, useSettingsActions } from './context-hooks';
|
||||||
|
import type { CliproxyServerConfig } from '../types';
|
||||||
|
|
||||||
|
export function useProxyConfig() {
|
||||||
|
const { state } = useSettingsContext();
|
||||||
|
const actions = useSettingsActions();
|
||||||
|
const [editedHost, setEditedHost] = useState<string | null>(null);
|
||||||
|
const [editedPort, setEditedPort] = useState<string | null>(null);
|
||||||
|
const [editedAuthToken, setEditedAuthToken] = useState<string | null>(null);
|
||||||
|
const [editedLocalPort, setEditedLocalPort] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
actions.setProxyLoading(true);
|
||||||
|
actions.setProxyError(null);
|
||||||
|
const data = await api.cliproxyServer.get();
|
||||||
|
actions.setProxyConfig(data);
|
||||||
|
} catch (err) {
|
||||||
|
actions.setProxyError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
actions.setProxyLoading(false);
|
||||||
|
}
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const saveConfig = useCallback(
|
||||||
|
async (updates: Partial<CliproxyServerConfig>) => {
|
||||||
|
const config = state.proxyConfig;
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const optimisticConfig = {
|
||||||
|
remote: { ...config.remote, ...updates.remote },
|
||||||
|
fallback: { ...config.fallback, ...updates.fallback },
|
||||||
|
local: { ...config.local, ...updates.local },
|
||||||
|
};
|
||||||
|
actions.setProxyConfig(optimisticConfig);
|
||||||
|
actions.setProxyTestResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
actions.setProxySaving(true);
|
||||||
|
actions.setProxyError(null);
|
||||||
|
|
||||||
|
const data = await api.cliproxyServer.update(updates);
|
||||||
|
actions.setProxyConfig(data);
|
||||||
|
actions.setProxySuccess(true);
|
||||||
|
setTimeout(() => actions.setProxySuccess(false), 1500);
|
||||||
|
} catch (err) {
|
||||||
|
actions.setProxyConfig(config);
|
||||||
|
actions.setProxyError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
actions.setProxySaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.proxyConfig, actions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const testConnection = useCallback(
|
||||||
|
async (params: {
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
protocol: 'http' | 'https';
|
||||||
|
authToken: string;
|
||||||
|
}) => {
|
||||||
|
const { host, port, protocol, authToken } = params;
|
||||||
|
if (!host) {
|
||||||
|
actions.setProxyError('Host is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
actions.setProxyTesting(true);
|
||||||
|
actions.setProxyError(null);
|
||||||
|
actions.setProxyTestResult(null);
|
||||||
|
|
||||||
|
const portNum = port ? parseInt(port, 10) : undefined;
|
||||||
|
const result = await api.cliproxyServer.test({
|
||||||
|
host,
|
||||||
|
port: portNum || undefined,
|
||||||
|
protocol,
|
||||||
|
authToken: authToken || undefined,
|
||||||
|
});
|
||||||
|
actions.setProxyTestResult(result);
|
||||||
|
} catch (err) {
|
||||||
|
actions.setProxyError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
actions.setProxyTesting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[actions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: state.proxyConfig,
|
||||||
|
loading: state.proxyLoading,
|
||||||
|
saving: state.proxySaving,
|
||||||
|
error: state.proxyError,
|
||||||
|
success: state.proxySuccess,
|
||||||
|
testResult: state.proxyTestResult,
|
||||||
|
testing: state.proxyTesting,
|
||||||
|
editedHost,
|
||||||
|
setEditedHost,
|
||||||
|
editedPort,
|
||||||
|
setEditedPort,
|
||||||
|
editedAuthToken,
|
||||||
|
setEditedAuthToken,
|
||||||
|
editedLocalPort,
|
||||||
|
setEditedLocalPort,
|
||||||
|
fetchConfig,
|
||||||
|
saveConfig,
|
||||||
|
testConnection,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Raw Config Hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useSettingsContext, useSettingsActions } from './context-hooks';
|
||||||
|
|
||||||
|
export function useRawConfig() {
|
||||||
|
const { state } = useSettingsContext();
|
||||||
|
const actions = useSettingsActions();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const fetchRawConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
actions.setRawConfigLoading(true);
|
||||||
|
const res = await fetch('/api/config/raw');
|
||||||
|
if (!res.ok) {
|
||||||
|
actions.setRawConfig(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
actions.setRawConfig(text);
|
||||||
|
} catch {
|
||||||
|
actions.setRawConfig(null);
|
||||||
|
} finally {
|
||||||
|
actions.setRawConfigLoading(false);
|
||||||
|
}
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const copyToClipboard = useCallback(async () => {
|
||||||
|
if (!state.rawConfig) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(state.rawConfig);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
}, [state.rawConfig]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawConfig: state.rawConfig,
|
||||||
|
loading: state.rawConfigLoading,
|
||||||
|
copied,
|
||||||
|
fetchRawConfig,
|
||||||
|
copyToClipboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Settings Tab URL Sync Hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import type { SettingsTab } from '../types';
|
||||||
|
|
||||||
|
export function useSettingsTab() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const tabParam = searchParams.get('tab');
|
||||||
|
const activeTab: SettingsTab =
|
||||||
|
tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch';
|
||||||
|
|
||||||
|
const setActiveTab = useCallback(
|
||||||
|
(tab: SettingsTab) => {
|
||||||
|
setSearchParams({ tab }, { replace: true });
|
||||||
|
},
|
||||||
|
[setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { activeTab, setActiveTab };
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* WebSearch Config Hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useSettingsContext, useSettingsActions } from './context-hooks';
|
||||||
|
import type { WebSearchConfig } from '../types';
|
||||||
|
|
||||||
|
export function useWebSearchConfig() {
|
||||||
|
const { state } = useSettingsContext();
|
||||||
|
const actions = useSettingsActions();
|
||||||
|
const [geminiModelInput, setGeminiModelInput] = useState('');
|
||||||
|
const [opencodeModelInput, setOpencodeModelInput] = useState('');
|
||||||
|
const [geminiModelSaved, setGeminiModelSaved] = useState(false);
|
||||||
|
const [opencodeModelSaved, setOpencodeModelSaved] = useState(false);
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
actions.setWebSearchLoading(true);
|
||||||
|
actions.setWebSearchError(null);
|
||||||
|
const res = await fetch('/api/websearch');
|
||||||
|
if (!res.ok) throw new Error('Failed to load WebSearch config');
|
||||||
|
const data = await res.json();
|
||||||
|
actions.setWebSearchConfig(data);
|
||||||
|
} catch (err) {
|
||||||
|
actions.setWebSearchError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
actions.setWebSearchLoading(false);
|
||||||
|
}
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
actions.setWebSearchStatusLoading(true);
|
||||||
|
const res = await fetch('/api/websearch/status');
|
||||||
|
if (!res.ok) throw new Error('Failed to load status');
|
||||||
|
const data = await res.json();
|
||||||
|
actions.setWebSearchStatus(data);
|
||||||
|
} catch {
|
||||||
|
// Silent fail for status
|
||||||
|
} finally {
|
||||||
|
actions.setWebSearchStatusLoading(false);
|
||||||
|
}
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const saveConfig = useCallback(
|
||||||
|
async (updates: Partial<WebSearchConfig>) => {
|
||||||
|
const config = state.webSearchConfig;
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const optimisticConfig = { ...config, ...updates };
|
||||||
|
actions.setWebSearchConfig(optimisticConfig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
actions.setWebSearchSaving(true);
|
||||||
|
actions.setWebSearchError(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/websearch', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(optimisticConfig),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'Failed to save');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
actions.setWebSearchConfig(data.websearch);
|
||||||
|
actions.setWebSearchSuccess(true);
|
||||||
|
setTimeout(() => actions.setWebSearchSuccess(false), 1500);
|
||||||
|
} catch (err) {
|
||||||
|
actions.setWebSearchConfig(config);
|
||||||
|
actions.setWebSearchError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
actions.setWebSearchSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.webSearchConfig, actions]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync model inputs with config
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.webSearchConfig) {
|
||||||
|
setGeminiModelInput(state.webSearchConfig.providers?.gemini?.model ?? 'gemini-2.5-flash');
|
||||||
|
setOpencodeModelInput(
|
||||||
|
state.webSearchConfig.providers?.opencode?.model ?? 'opencode/grok-code'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [state.webSearchConfig]);
|
||||||
|
|
||||||
|
const saveGeminiModel = useCallback(async () => {
|
||||||
|
const currentModel = state.webSearchConfig?.providers?.gemini?.model ?? 'gemini-2.5-flash';
|
||||||
|
if (geminiModelInput !== currentModel) {
|
||||||
|
const providers = state.webSearchConfig?.providers || {};
|
||||||
|
await saveConfig({
|
||||||
|
providers: { ...providers, gemini: { ...providers.gemini, model: geminiModelInput } },
|
||||||
|
});
|
||||||
|
setGeminiModelSaved(true);
|
||||||
|
setTimeout(() => setGeminiModelSaved(false), 2000);
|
||||||
|
}
|
||||||
|
}, [geminiModelInput, state.webSearchConfig, saveConfig]);
|
||||||
|
|
||||||
|
const saveOpencodeModel = useCallback(async () => {
|
||||||
|
const currentModel = state.webSearchConfig?.providers?.opencode?.model ?? 'opencode/grok-code';
|
||||||
|
if (opencodeModelInput !== currentModel) {
|
||||||
|
const providers = state.webSearchConfig?.providers || {};
|
||||||
|
await saveConfig({
|
||||||
|
providers: { ...providers, opencode: { ...providers.opencode, model: opencodeModelInput } },
|
||||||
|
});
|
||||||
|
setOpencodeModelSaved(true);
|
||||||
|
setTimeout(() => setOpencodeModelSaved(false), 2000);
|
||||||
|
}
|
||||||
|
}, [opencodeModelInput, state.webSearchConfig, saveConfig]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: state.webSearchConfig,
|
||||||
|
status: state.webSearchStatus,
|
||||||
|
loading: state.webSearchLoading,
|
||||||
|
statusLoading: state.webSearchStatusLoading,
|
||||||
|
saving: state.webSearchSaving,
|
||||||
|
error: state.webSearchError,
|
||||||
|
success: state.webSearchSuccess,
|
||||||
|
geminiModelInput,
|
||||||
|
setGeminiModelInput,
|
||||||
|
opencodeModelInput,
|
||||||
|
setOpencodeModelInput,
|
||||||
|
geminiModelSaved,
|
||||||
|
opencodeModelSaved,
|
||||||
|
fetchConfig,
|
||||||
|
fetchStatus,
|
||||||
|
saveConfig,
|
||||||
|
saveGeminiModel,
|
||||||
|
saveOpencodeModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Settings Page
|
||||||
|
* Main entry point with lazy-loaded sections and URL tab persistence
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { lazy, Suspense, startTransition, useEffect } from 'react';
|
||||||
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw, FileCode, Copy, Check, GripVertical } from 'lucide-react';
|
||||||
|
import { CodeEditor } from '@/components/shared/code-editor';
|
||||||
|
import { SettingsProvider } from './context';
|
||||||
|
import { useSettingsTab, useRawConfig } from './hooks';
|
||||||
|
import { TabNavigation } from './components/tab-navigation';
|
||||||
|
import { SectionSkeleton } from './components/section-skeleton';
|
||||||
|
import type { SettingsTab } from './types';
|
||||||
|
|
||||||
|
// Lazy-loaded sections
|
||||||
|
const WebSearchSection = lazy(() => import('./sections/websearch'));
|
||||||
|
const GlobalEnvSection = lazy(() => import('./sections/globalenv-section'));
|
||||||
|
const ProxySection = lazy(() => import('./sections/proxy'));
|
||||||
|
|
||||||
|
// Inner component that uses context
|
||||||
|
function SettingsPageInner() {
|
||||||
|
const { activeTab, setActiveTab } = useSettingsTab();
|
||||||
|
const {
|
||||||
|
rawConfig,
|
||||||
|
loading: rawConfigLoading,
|
||||||
|
copied,
|
||||||
|
fetchRawConfig,
|
||||||
|
copyToClipboard,
|
||||||
|
} = useRawConfig();
|
||||||
|
|
||||||
|
// Fetch raw config on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRawConfig();
|
||||||
|
}, [fetchRawConfig]);
|
||||||
|
|
||||||
|
const handleTabChange = (tab: SettingsTab) => {
|
||||||
|
startTransition(() => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-100px)]">
|
||||||
|
<PanelGroup direction="horizontal" className="h-full">
|
||||||
|
{/* Left Panel - Settings Controls */}
|
||||||
|
<Panel defaultSize={40} minSize={30} maxSize={55}>
|
||||||
|
<div className="h-full border-r flex flex-col bg-muted/30 relative">
|
||||||
|
{/* Header with Tabs */}
|
||||||
|
<div className="p-5 border-b bg-background">
|
||||||
|
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<Suspense fallback={<SectionSkeleton />}>
|
||||||
|
{activeTab === 'websearch' && <WebSearchSection />}
|
||||||
|
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||||
|
{activeTab === 'proxy' && <ProxySection />}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Resize Handle */}
|
||||||
|
<PanelResizeHandle className="w-2 bg-border hover:bg-primary/20 transition-colors cursor-col-resize flex items-center justify-center group">
|
||||||
|
<GripVertical className="w-3 h-3 text-muted-foreground group-hover:text-primary" />
|
||||||
|
</PanelResizeHandle>
|
||||||
|
|
||||||
|
{/* Right Panel - Config Viewer */}
|
||||||
|
<Panel defaultSize={60} minSize={35}>
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b bg-background flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileCode className="w-5 h-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">config.yaml</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">~/.ccs/config.yaml</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={copyToClipboard} disabled={!rawConfig}>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4 mr-1" />
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-4 h-4 mr-1" />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchRawConfig}
|
||||||
|
disabled={rawConfigLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${rawConfigLoading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config Content - scrollable */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{rawConfigLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : rawConfig ? (
|
||||||
|
<CodeEditor
|
||||||
|
value={rawConfig}
|
||||||
|
onChange={() => {}}
|
||||||
|
language="yaml"
|
||||||
|
readonly
|
||||||
|
minHeight="auto"
|
||||||
|
className="min-h-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<FileCode className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>Config file not found</p>
|
||||||
|
<code className="text-sm bg-muted px-2 py-1 rounded mt-2 inline-block">
|
||||||
|
ccs migrate
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</PanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main export with context provider
|
||||||
|
export function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<SettingsProvider>
|
||||||
|
<SettingsPageInner />
|
||||||
|
</SettingsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* GlobalEnv Section
|
||||||
|
* Settings section for global environment variables
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { RefreshCw, CheckCircle2, AlertCircle, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useGlobalEnvConfig, useRawConfig } from '../hooks';
|
||||||
|
|
||||||
|
export default function GlobalEnvSection() {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
newEnvKey,
|
||||||
|
setNewEnvKey,
|
||||||
|
newEnvValue,
|
||||||
|
setNewEnvValue,
|
||||||
|
fetchConfig,
|
||||||
|
saveConfig,
|
||||||
|
addEnvVar,
|
||||||
|
removeEnvVar,
|
||||||
|
} = useGlobalEnvConfig();
|
||||||
|
|
||||||
|
const { fetchRawConfig } = useRawConfig();
|
||||||
|
|
||||||
|
// Load data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
fetchRawConfig();
|
||||||
|
}, [fetchConfig, fetchRawConfig]);
|
||||||
|
|
||||||
|
const toggleGlobalEnv = () => {
|
||||||
|
saveConfig({ enabled: !config?.enabled });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Toast-style alerts */}
|
||||||
|
<div
|
||||||
|
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
|
||||||
|
error || success
|
||||||
|
? 'opacity-100 translate-y-0'
|
||||||
|
: 'opacity-0 -translate-y-2 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="py-2 shadow-lg">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="text-sm font-medium">Saved</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-5 space-y-6">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Environment variables injected into all non-Claude subscription profiles (gemini, codex,
|
||||||
|
agy, copilot, etc.)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Enable/Disable Toggle */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{config?.enabled ? 'Global Env enabled' : 'Global Env disabled'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{config?.enabled
|
||||||
|
? 'Env vars will be injected into third-party profiles'
|
||||||
|
: 'Env vars will not be injected'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={config?.enabled ?? true} onCheckedChange={toggleGlobalEnv} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Environment Variables */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-base font-medium">Environment Variables</h3>
|
||||||
|
|
||||||
|
{config?.env && Object.keys(config.env).length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(config.env).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-2 p-3 rounded-lg border bg-background"
|
||||||
|
>
|
||||||
|
<code className="flex-1 font-mono text-sm truncate">{key}</code>
|
||||||
|
<span className="text-muted-foreground">=</span>
|
||||||
|
<code className="font-mono text-sm px-2 py-1 bg-muted rounded">{value}</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeEnvVar(key)}
|
||||||
|
disabled={saving}
|
||||||
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 rounded-lg border border-dashed text-center text-muted-foreground">
|
||||||
|
<p>No environment variables configured</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add New Variable */}
|
||||||
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
|
<h4 className="text-sm font-medium mb-3">Add New Variable</h4>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={newEnvKey}
|
||||||
|
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
|
||||||
|
placeholder="KEY_NAME"
|
||||||
|
className="flex-1 font-mono text-sm h-9"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<span className="flex items-center text-muted-foreground">=</span>
|
||||||
|
<Input
|
||||||
|
value={newEnvValue}
|
||||||
|
onChange={(e) => setNewEnvValue(e.target.value)}
|
||||||
|
placeholder="value"
|
||||||
|
className="flex-1 font-mono text-sm h-9"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={addEnvVar}
|
||||||
|
disabled={saving || !newEnvKey.trim()}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Common Variables Quick Add */}
|
||||||
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
|
<h4 className="text-sm font-medium mb-3">Quick Add Common Variables</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
{ key: 'DISABLE_BUG_COMMAND', value: '1' },
|
||||||
|
{ key: 'DISABLE_ERROR_REPORTING', value: '1' },
|
||||||
|
{ key: 'DISABLE_TELEMETRY', value: '1' },
|
||||||
|
].map(
|
||||||
|
({ key, value }) =>
|
||||||
|
!config?.env?.[key] && (
|
||||||
|
<Button
|
||||||
|
key={key}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setNewEnvKey(key);
|
||||||
|
setNewEnvValue(value);
|
||||||
|
}}
|
||||||
|
className="text-xs font-mono"
|
||||||
|
>
|
||||||
|
+ {key}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{config?.env &&
|
||||||
|
['DISABLE_BUG_COMMAND', 'DISABLE_ERROR_REPORTING', 'DISABLE_TELEMETRY'].every(
|
||||||
|
(k) => config.env[k]
|
||||||
|
) && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
All common variables are configured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t bg-background">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
fetchConfig();
|
||||||
|
fetchRawConfig();
|
||||||
|
}}
|
||||||
|
disabled={loading || saving}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* Proxy Section
|
||||||
|
* Settings section for CLIProxyAPI configuration (local/remote)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { RefreshCw, CheckCircle2, AlertCircle, Laptop, Cloud } from 'lucide-react';
|
||||||
|
import { useProxyConfig, useRawConfig } from '../../hooks';
|
||||||
|
import { LocalProxyCard } from './local-proxy-card';
|
||||||
|
import { RemoteProxyCard } from './remote-proxy-card';
|
||||||
|
|
||||||
|
export default function ProxySection() {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
testResult,
|
||||||
|
testing,
|
||||||
|
editedHost,
|
||||||
|
setEditedHost,
|
||||||
|
editedPort,
|
||||||
|
setEditedPort,
|
||||||
|
editedAuthToken,
|
||||||
|
setEditedAuthToken,
|
||||||
|
editedLocalPort,
|
||||||
|
setEditedLocalPort,
|
||||||
|
fetchConfig,
|
||||||
|
saveConfig,
|
||||||
|
testConnection,
|
||||||
|
} = useProxyConfig();
|
||||||
|
|
||||||
|
const { fetchRawConfig } = useRawConfig();
|
||||||
|
|
||||||
|
// Load data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
fetchRawConfig();
|
||||||
|
}, [fetchConfig, fetchRawConfig]);
|
||||||
|
|
||||||
|
if (loading || !config) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRemoteMode = config.remote.enabled ?? false;
|
||||||
|
const remoteConfig = config.remote;
|
||||||
|
const fallbackConfig = config.fallback;
|
||||||
|
|
||||||
|
// Get display values (edited or from config)
|
||||||
|
const hostInput = config.remote.host ?? '';
|
||||||
|
const portInput = config.remote.port !== undefined ? config.remote.port.toString() : '';
|
||||||
|
const authTokenInput = config.remote.auth_token ?? '';
|
||||||
|
const localPortInput = (config.local.port ?? 8317).toString();
|
||||||
|
|
||||||
|
const displayHost = editedHost ?? hostInput;
|
||||||
|
const displayPort = editedPort ?? portInput;
|
||||||
|
const displayAuthToken = editedAuthToken ?? authTokenInput;
|
||||||
|
const displayLocalPort = editedLocalPort ?? localPortInput;
|
||||||
|
|
||||||
|
// Save functions for blur events
|
||||||
|
const saveHost = () => {
|
||||||
|
const value = editedHost ?? displayHost;
|
||||||
|
if (value !== config.remote.host) {
|
||||||
|
saveConfig({ remote: { ...remoteConfig, host: value } });
|
||||||
|
}
|
||||||
|
setEditedHost(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePort = () => {
|
||||||
|
const portStr = editedPort ?? displayPort;
|
||||||
|
const port = portStr === '' ? undefined : parseInt(portStr, 10);
|
||||||
|
const effectivePort = port && !isNaN(port) && port > 0 ? port : undefined;
|
||||||
|
|
||||||
|
if (effectivePort !== config.remote.port) {
|
||||||
|
saveConfig({ remote: { ...remoteConfig, port: effectivePort } });
|
||||||
|
}
|
||||||
|
setEditedPort(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAuthToken = () => {
|
||||||
|
const value = editedAuthToken ?? displayAuthToken;
|
||||||
|
if (value !== config.remote.auth_token) {
|
||||||
|
saveConfig({ remote: { ...remoteConfig, auth_token: value } });
|
||||||
|
}
|
||||||
|
setEditedAuthToken(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveLocalPort = () => {
|
||||||
|
const port = parseInt(editedLocalPort ?? displayLocalPort, 10);
|
||||||
|
if (!isNaN(port) && port !== config.local.port) {
|
||||||
|
saveConfig({ local: { ...config.local, port } });
|
||||||
|
}
|
||||||
|
setEditedLocalPort(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConnection = () => {
|
||||||
|
testConnection({
|
||||||
|
host: displayHost,
|
||||||
|
port: displayPort,
|
||||||
|
protocol: config.remote.protocol || 'http',
|
||||||
|
authToken: displayAuthToken,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Toast-style alerts */}
|
||||||
|
<div
|
||||||
|
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
|
||||||
|
error || success
|
||||||
|
? 'opacity-100 translate-y-0'
|
||||||
|
: 'opacity-0 -translate-y-2 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="py-2 shadow-lg">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="text-sm font-medium">Saved</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-5 space-y-6">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configure local or remote CLIProxyAPI connection for proxy-based profiles
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Mode Toggle - Card based selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-base font-medium">Connection Mode</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* Local Mode Card */}
|
||||||
|
<button
|
||||||
|
onClick={() => saveConfig({ remote: { ...remoteConfig, enabled: false } })}
|
||||||
|
disabled={saving}
|
||||||
|
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
||||||
|
!isRemoteMode
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-muted-foreground/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Laptop
|
||||||
|
className={`w-5 h-5 ${!isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">Local</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Run CLIProxyAPI binary on this machine
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Remote Mode Card */}
|
||||||
|
<button
|
||||||
|
onClick={() => saveConfig({ remote: { ...remoteConfig, enabled: true } })}
|
||||||
|
disabled={saving}
|
||||||
|
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
||||||
|
isRemoteMode
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-muted-foreground/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Cloud
|
||||||
|
className={`w-5 h-5 ${isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">Remote</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Connect to a remote CLIProxyAPI server
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remote Settings - Show when remote mode is enabled */}
|
||||||
|
{isRemoteMode && (
|
||||||
|
<RemoteProxyCard
|
||||||
|
config={config}
|
||||||
|
saving={saving}
|
||||||
|
testing={testing}
|
||||||
|
testResult={testResult}
|
||||||
|
displayHost={displayHost}
|
||||||
|
displayPort={displayPort}
|
||||||
|
displayAuthToken={displayAuthToken}
|
||||||
|
setEditedHost={setEditedHost}
|
||||||
|
setEditedPort={setEditedPort}
|
||||||
|
setEditedAuthToken={setEditedAuthToken}
|
||||||
|
onSaveHost={saveHost}
|
||||||
|
onSavePort={savePort}
|
||||||
|
onSaveAuthToken={saveAuthToken}
|
||||||
|
onSaveConfig={saveConfig}
|
||||||
|
onTestConnection={handleTestConnection}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fallback Settings */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-base font-medium">Fallback Settings</h3>
|
||||||
|
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
|
||||||
|
{/* Enable Fallback */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Enable fallback to local</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use local proxy if remote is unreachable
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={fallbackConfig.enabled ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
saveConfig({ fallback: { ...fallbackConfig, enabled: checked } })
|
||||||
|
}
|
||||||
|
disabled={saving || !isRemoteMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-start on fallback */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Auto-start local proxy</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Automatically start local proxy on fallback
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={fallbackConfig.auto_start ?? false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
saveConfig({ fallback: { ...fallbackConfig, auto_start: checked } })
|
||||||
|
}
|
||||||
|
disabled={saving || !isRemoteMode || !fallbackConfig.enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local Proxy Settings - Only show in Local mode */}
|
||||||
|
{!isRemoteMode && (
|
||||||
|
<LocalProxyCard
|
||||||
|
config={config}
|
||||||
|
saving={saving}
|
||||||
|
displayLocalPort={displayLocalPort}
|
||||||
|
setEditedLocalPort={setEditedLocalPort}
|
||||||
|
onSaveLocalPort={saveLocalPort}
|
||||||
|
onSaveConfig={saveConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t bg-background">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
fetchConfig();
|
||||||
|
fetchRawConfig();
|
||||||
|
}}
|
||||||
|
disabled={loading || saving}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Local Proxy Card
|
||||||
|
* Configuration card for local CLIProxyAPI settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import type { CliproxyServerConfig } from '../../types';
|
||||||
|
|
||||||
|
interface LocalProxyCardProps {
|
||||||
|
config: CliproxyServerConfig;
|
||||||
|
saving: boolean;
|
||||||
|
displayLocalPort: string;
|
||||||
|
setEditedLocalPort: (value: string | null) => void;
|
||||||
|
onSaveLocalPort: () => void;
|
||||||
|
onSaveConfig: (updates: Partial<CliproxyServerConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalProxyCard({
|
||||||
|
config,
|
||||||
|
saving,
|
||||||
|
displayLocalPort,
|
||||||
|
setEditedLocalPort,
|
||||||
|
onSaveLocalPort,
|
||||||
|
onSaveConfig,
|
||||||
|
}: LocalProxyCardProps) {
|
||||||
|
const localConfig = config.local;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-base font-medium">Local Proxy</h3>
|
||||||
|
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
|
||||||
|
{/* Port */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm text-muted-foreground">Port</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={displayLocalPort}
|
||||||
|
onChange={(e) => setEditedLocalPort(e.target.value)}
|
||||||
|
onBlur={onSaveLocalPort}
|
||||||
|
placeholder="8317"
|
||||||
|
className="font-mono max-w-32"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-start */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Auto-start</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Start local proxy automatically when needed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={localConfig.auto_start ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onSaveConfig({ local: { ...localConfig, auto_start: checked } })
|
||||||
|
}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Remote Proxy Card
|
||||||
|
* Configuration card for remote CLIProxyAPI settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Cloud, RefreshCw, Wifi, WifiOff, CheckCircle2 } from 'lucide-react';
|
||||||
|
import type { CliproxyServerConfig, RemoteProxyStatus } from '../../types';
|
||||||
|
|
||||||
|
interface RemoteProxyCardProps {
|
||||||
|
config: CliproxyServerConfig;
|
||||||
|
saving: boolean;
|
||||||
|
testing: boolean;
|
||||||
|
testResult: RemoteProxyStatus | null;
|
||||||
|
displayHost: string;
|
||||||
|
displayPort: string;
|
||||||
|
displayAuthToken: string;
|
||||||
|
setEditedHost: (value: string | null) => void;
|
||||||
|
setEditedPort: (value: string | null) => void;
|
||||||
|
setEditedAuthToken: (value: string | null) => void;
|
||||||
|
onSaveHost: () => void;
|
||||||
|
onSavePort: () => void;
|
||||||
|
onSaveAuthToken: () => void;
|
||||||
|
onSaveConfig: (updates: Partial<CliproxyServerConfig>) => void;
|
||||||
|
onTestConnection: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoteProxyCard({
|
||||||
|
config,
|
||||||
|
saving,
|
||||||
|
testing,
|
||||||
|
testResult,
|
||||||
|
displayHost,
|
||||||
|
displayPort,
|
||||||
|
displayAuthToken,
|
||||||
|
setEditedHost,
|
||||||
|
setEditedPort,
|
||||||
|
setEditedAuthToken,
|
||||||
|
onSaveHost,
|
||||||
|
onSavePort,
|
||||||
|
onSaveAuthToken,
|
||||||
|
onSaveConfig,
|
||||||
|
onTestConnection,
|
||||||
|
}: RemoteProxyCardProps) {
|
||||||
|
const remoteConfig = config.remote;
|
||||||
|
|
||||||
|
// HTTP defaults to 8317 (CLIProxyAPI default), HTTPS to 443 (standard SSL)
|
||||||
|
const getDefaultPort = (protocol: 'http' | 'https') => (protocol === 'https' ? 443 : 8317);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4 rounded-lg border bg-muted/30">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Cloud className="w-4 h-4" />
|
||||||
|
Remote Server Configuration
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Host */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm text-muted-foreground">Host</label>
|
||||||
|
<Input
|
||||||
|
value={displayHost}
|
||||||
|
onChange={(e) => setEditedHost(e.target.value)}
|
||||||
|
onBlur={onSaveHost}
|
||||||
|
placeholder="192.168.1.100 or proxy.example.com"
|
||||||
|
className="font-mono"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Port and Protocol */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm text-muted-foreground">
|
||||||
|
Port{' '}
|
||||||
|
<span className="text-xs opacity-70">
|
||||||
|
(default: {getDefaultPort(config.remote.protocol || 'http')})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={displayPort}
|
||||||
|
onChange={(e) => setEditedPort(e.target.value.replace(/\D/g, ''))}
|
||||||
|
onBlur={onSavePort}
|
||||||
|
placeholder={`Leave empty for ${getDefaultPort(config.remote.protocol || 'http')}`}
|
||||||
|
className="font-mono"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm text-muted-foreground">Protocol</label>
|
||||||
|
<Select
|
||||||
|
value={config.remote.protocol || 'http'}
|
||||||
|
onValueChange={(value: 'http' | 'https') =>
|
||||||
|
onSaveConfig({ remote: { ...remoteConfig, protocol: value } })
|
||||||
|
}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="http">HTTP</SelectItem>
|
||||||
|
<SelectItem value="https">HTTPS</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auth Token */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm text-muted-foreground">Auth Token (optional)</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={displayAuthToken}
|
||||||
|
onChange={(e) => setEditedAuthToken(e.target.value)}
|
||||||
|
onBlur={onSaveAuthToken}
|
||||||
|
placeholder="Bearer token for authentication"
|
||||||
|
className="font-mono"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Connection */}
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<Button
|
||||||
|
onClick={onTestConnection}
|
||||||
|
disabled={testing || !displayHost}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Testing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Wifi className="w-4 h-4 mr-2" />
|
||||||
|
Test Connection
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Test Result */}
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-md ${
|
||||||
|
testResult.reachable
|
||||||
|
? 'bg-green-50 border border-green-200 dark:bg-green-900/20 dark:border-green-900/50'
|
||||||
|
: 'bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-900/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{testResult.reachable ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-sm font-medium text-green-700 dark:text-green-300">
|
||||||
|
Connected ({testResult.latencyMs}ms)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<WifiOff className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||||
|
<span className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||||
|
{testResult.error || 'Connection failed'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* WebSearch Section
|
||||||
|
* Settings section for WebSearch providers (Gemini, OpenCode, Grok)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { RefreshCw, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
|
import { useWebSearchConfig, useRawConfig } from '../../hooks';
|
||||||
|
import { ProviderCard } from './provider-card';
|
||||||
|
|
||||||
|
export default function WebSearchSection() {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
status,
|
||||||
|
loading,
|
||||||
|
statusLoading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
geminiModelInput,
|
||||||
|
setGeminiModelInput,
|
||||||
|
opencodeModelInput,
|
||||||
|
setOpencodeModelInput,
|
||||||
|
geminiModelSaved,
|
||||||
|
opencodeModelSaved,
|
||||||
|
fetchConfig,
|
||||||
|
fetchStatus,
|
||||||
|
saveConfig,
|
||||||
|
saveGeminiModel,
|
||||||
|
saveOpencodeModel,
|
||||||
|
} = useWebSearchConfig();
|
||||||
|
|
||||||
|
const { fetchRawConfig } = useRawConfig();
|
||||||
|
|
||||||
|
// Collapsible install hints state
|
||||||
|
const [showGeminiHint, setShowGeminiHint] = useState(false);
|
||||||
|
const [showOpencodeHint, setShowOpencodeHint] = useState(false);
|
||||||
|
const [showGrokHint, setShowGrokHint] = useState(false);
|
||||||
|
|
||||||
|
// Load data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
fetchStatus();
|
||||||
|
fetchRawConfig();
|
||||||
|
}, [fetchConfig, fetchStatus, fetchRawConfig]);
|
||||||
|
|
||||||
|
const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false;
|
||||||
|
const isGrokEnabled = config?.providers?.grok?.enabled ?? false;
|
||||||
|
const isOpenCodeEnabled = config?.providers?.opencode?.enabled ?? false;
|
||||||
|
|
||||||
|
const toggleGemini = () => {
|
||||||
|
const providers = config?.providers || {};
|
||||||
|
const currentState = providers.gemini?.enabled ?? false;
|
||||||
|
saveConfig({
|
||||||
|
enabled: !currentState || isGrokEnabled || isOpenCodeEnabled,
|
||||||
|
providers: { ...providers, gemini: { ...providers.gemini, enabled: !currentState } },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGrok = () => {
|
||||||
|
const providers = config?.providers || {};
|
||||||
|
const currentState = providers.grok?.enabled ?? false;
|
||||||
|
saveConfig({
|
||||||
|
enabled: isGeminiEnabled || !currentState || isOpenCodeEnabled,
|
||||||
|
providers: { ...providers, grok: { ...providers.grok, enabled: !currentState } },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOpenCode = () => {
|
||||||
|
const providers = config?.providers || {};
|
||||||
|
const currentState = providers.opencode?.enabled ?? false;
|
||||||
|
saveConfig({
|
||||||
|
enabled: isGeminiEnabled || isGrokEnabled || !currentState,
|
||||||
|
providers: { ...providers, opencode: { ...providers.opencode, enabled: !currentState } },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Toast-style alerts */}
|
||||||
|
<div
|
||||||
|
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
|
||||||
|
error || success
|
||||||
|
? 'opacity-100 translate-y-0'
|
||||||
|
: 'opacity-0 -translate-y-2 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="py-2 shadow-lg">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="text-sm font-medium">Saved</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-5 space-y-6">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
CLI-based web search for third-party profiles (gemini, codex, agy, etc.)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Status Summary */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'}
|
||||||
|
</p>
|
||||||
|
{statusLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Checking status...</p>
|
||||||
|
) : status?.readiness ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{status.readiness.message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={fetchStatus} disabled={statusLoading}>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${statusLoading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CLI Providers */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-base font-medium">Providers</h3>
|
||||||
|
|
||||||
|
<ProviderCard
|
||||||
|
name="gemini"
|
||||||
|
label="Google Gemini CLI (1000 req/day free)"
|
||||||
|
badge="FREE"
|
||||||
|
badgeColor="green"
|
||||||
|
enabled={isGeminiEnabled}
|
||||||
|
installed={status?.geminiCli?.installed ?? false}
|
||||||
|
statusLoading={statusLoading}
|
||||||
|
saving={saving}
|
||||||
|
onToggle={toggleGemini}
|
||||||
|
modelInput={geminiModelInput}
|
||||||
|
setModelInput={setGeminiModelInput}
|
||||||
|
onModelBlur={saveGeminiModel}
|
||||||
|
modelSaved={geminiModelSaved}
|
||||||
|
modelPlaceholder="gemini-2.5-flash"
|
||||||
|
showHint={showGeminiHint}
|
||||||
|
setShowHint={setShowGeminiHint}
|
||||||
|
installCmd="npm install -g @google/gemini-cli"
|
||||||
|
docsUrl="https://github.com/google-gemini/gemini-cli"
|
||||||
|
hintColor="amber"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProviderCard
|
||||||
|
name="opencode"
|
||||||
|
label="OpenCode (web search via Zen)"
|
||||||
|
badge="FREE"
|
||||||
|
badgeColor="green"
|
||||||
|
enabled={isOpenCodeEnabled}
|
||||||
|
installed={status?.opencodeCli?.installed ?? false}
|
||||||
|
statusLoading={statusLoading}
|
||||||
|
saving={saving}
|
||||||
|
onToggle={toggleOpenCode}
|
||||||
|
modelInput={opencodeModelInput}
|
||||||
|
setModelInput={setOpencodeModelInput}
|
||||||
|
onModelBlur={saveOpencodeModel}
|
||||||
|
modelSaved={opencodeModelSaved}
|
||||||
|
modelPlaceholder="opencode/grok-code"
|
||||||
|
showHint={showOpencodeHint}
|
||||||
|
setShowHint={setShowOpencodeHint}
|
||||||
|
installCmd="curl -fsSL https://opencode.ai/install | bash"
|
||||||
|
docsUrl="https://github.com/sst/opencode"
|
||||||
|
hintColor="purple"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProviderCard
|
||||||
|
name="grok"
|
||||||
|
label="xAI Grok CLI (web + X search)"
|
||||||
|
badge="GROK_API_KEY"
|
||||||
|
badgeColor="blue"
|
||||||
|
enabled={isGrokEnabled}
|
||||||
|
installed={status?.grokCli?.installed ?? false}
|
||||||
|
statusLoading={statusLoading}
|
||||||
|
saving={saving}
|
||||||
|
onToggle={toggleGrok}
|
||||||
|
showHint={showGrokHint}
|
||||||
|
setShowHint={setShowGrokHint}
|
||||||
|
installCmd="npm install -g @vibe-kit/grok-cli"
|
||||||
|
docsUrl="https://github.com/superagent-ai/grok-cli"
|
||||||
|
hintColor="blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t bg-background">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
fetchConfig();
|
||||||
|
fetchRawConfig();
|
||||||
|
}}
|
||||||
|
disabled={loading || saving}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Provider Card Component
|
||||||
|
* Reusable card for CLI provider configuration in WebSearch section
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface ProviderCardProps {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
badge: string;
|
||||||
|
badgeColor: 'green' | 'blue' | 'purple';
|
||||||
|
enabled: boolean;
|
||||||
|
installed: boolean;
|
||||||
|
statusLoading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
modelInput?: string;
|
||||||
|
setModelInput?: (v: string) => void;
|
||||||
|
onModelBlur?: () => void;
|
||||||
|
modelSaved?: boolean;
|
||||||
|
modelPlaceholder?: string;
|
||||||
|
showHint: boolean;
|
||||||
|
setShowHint: (v: boolean) => void;
|
||||||
|
installCmd: string;
|
||||||
|
docsUrl: string;
|
||||||
|
hintColor: 'amber' | 'blue' | 'purple';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderCard({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
badge,
|
||||||
|
badgeColor,
|
||||||
|
enabled,
|
||||||
|
installed,
|
||||||
|
statusLoading,
|
||||||
|
saving,
|
||||||
|
onToggle,
|
||||||
|
modelInput,
|
||||||
|
setModelInput,
|
||||||
|
onModelBlur,
|
||||||
|
modelSaved,
|
||||||
|
modelPlaceholder,
|
||||||
|
showHint,
|
||||||
|
setShowHint,
|
||||||
|
installCmd,
|
||||||
|
docsUrl,
|
||||||
|
hintColor,
|
||||||
|
}: ProviderCardProps) {
|
||||||
|
const badgeClass =
|
||||||
|
badgeColor === 'green'
|
||||||
|
? 'bg-green-500/10 text-green-600'
|
||||||
|
: badgeColor === 'blue'
|
||||||
|
? 'bg-blue-500/10 text-blue-600'
|
||||||
|
: 'bg-purple-500/10 text-purple-600';
|
||||||
|
|
||||||
|
const hintBgClass =
|
||||||
|
hintColor === 'amber'
|
||||||
|
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300'
|
||||||
|
: hintColor === 'blue'
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300';
|
||||||
|
|
||||||
|
const hintCodeClass =
|
||||||
|
hintColor === 'amber'
|
||||||
|
? 'bg-amber-100 dark:bg-amber-900/40'
|
||||||
|
: hintColor === 'blue'
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/40'
|
||||||
|
: 'bg-purple-100 dark:bg-purple-900/40';
|
||||||
|
|
||||||
|
const hintTextClass =
|
||||||
|
hintColor === 'amber'
|
||||||
|
? 'text-amber-600 dark:text-amber-400'
|
||||||
|
: hintColor === 'blue'
|
||||||
|
? 'text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-purple-600 dark:text-purple-400';
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
name === 'opencode' ? 'OpenCode' : name.charAt(0).toUpperCase() + name.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border transition-colors ${
|
||||||
|
enabled ? 'border-primary border-l-4' : 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Terminal className={`w-5 h-5 ${enabled ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-mono font-medium">{name}</p>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${badgeClass}`}>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
{installed ? (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
||||||
|
installed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
|
||||||
|
not installed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch checked={enabled} onCheckedChange={onToggle} disabled={saving || !installed} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model input when enabled */}
|
||||||
|
{enabled && modelInput !== undefined && setModelInput && onModelBlur && (
|
||||||
|
<div className="px-4 pb-4 pt-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-muted-foreground whitespace-nowrap">Model:</label>
|
||||||
|
<Input
|
||||||
|
value={modelInput}
|
||||||
|
onChange={(e) => setModelInput(e.target.value)}
|
||||||
|
onBlur={onModelBlur}
|
||||||
|
placeholder={modelPlaceholder}
|
||||||
|
className="h-8 text-sm font-mono"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
{modelSaved && (
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-xs animate-in fade-in duration-200">
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Installation hint when not installed */}
|
||||||
|
{!installed && !statusLoading && (
|
||||||
|
<div className="px-4 pb-4 pt-0 border-t border-border/50">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHint(!showHint)}
|
||||||
|
className={`flex items-center gap-2 text-sm hover:underline w-full py-2 ${hintTextClass}`}
|
||||||
|
>
|
||||||
|
{showHint ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
How to install {displayName} CLI
|
||||||
|
</button>
|
||||||
|
{showHint && (
|
||||||
|
<div className={`mt-2 p-3 rounded-md text-sm ${hintBgClass}`}>
|
||||||
|
<p className="mb-2">
|
||||||
|
Install globally{' '}
|
||||||
|
{badge === 'GROK_API_KEY' ? '(requires xAI API key)' : '(FREE tier available)'}:
|
||||||
|
</p>
|
||||||
|
<code className={`text-sm px-2 py-1 rounded font-mono block mb-2 ${hintCodeClass}`}>
|
||||||
|
{installCmd}
|
||||||
|
</code>
|
||||||
|
<a
|
||||||
|
href={docsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
View documentation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Settings Context Definition
|
||||||
|
* Context and types for settings page state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, type Dispatch } from 'react';
|
||||||
|
import type {
|
||||||
|
WebSearchConfig,
|
||||||
|
GlobalEnvConfig,
|
||||||
|
CliproxyServerConfig,
|
||||||
|
WebSearchStatus,
|
||||||
|
RemoteProxyStatus,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// === State ===
|
||||||
|
|
||||||
|
export interface SettingsState {
|
||||||
|
// WebSearch state
|
||||||
|
webSearchConfig: WebSearchConfig | null;
|
||||||
|
webSearchStatus: WebSearchStatus | null;
|
||||||
|
webSearchLoading: boolean;
|
||||||
|
webSearchStatusLoading: boolean;
|
||||||
|
webSearchSaving: boolean;
|
||||||
|
webSearchError: string | null;
|
||||||
|
webSearchSuccess: boolean;
|
||||||
|
// GlobalEnv state
|
||||||
|
globalEnvConfig: GlobalEnvConfig | null;
|
||||||
|
globalEnvLoading: boolean;
|
||||||
|
globalEnvSaving: boolean;
|
||||||
|
globalEnvError: string | null;
|
||||||
|
globalEnvSuccess: boolean;
|
||||||
|
// Proxy state
|
||||||
|
proxyConfig: CliproxyServerConfig | null;
|
||||||
|
proxyLoading: boolean;
|
||||||
|
proxySaving: boolean;
|
||||||
|
proxyError: string | null;
|
||||||
|
proxySuccess: boolean;
|
||||||
|
proxyTestResult: RemoteProxyStatus | null;
|
||||||
|
proxyTesting: boolean;
|
||||||
|
// Raw config
|
||||||
|
rawConfig: string | null;
|
||||||
|
rawConfigLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialSettingsState: SettingsState = {
|
||||||
|
webSearchConfig: null,
|
||||||
|
webSearchStatus: null,
|
||||||
|
webSearchLoading: true,
|
||||||
|
webSearchStatusLoading: true,
|
||||||
|
webSearchSaving: false,
|
||||||
|
webSearchError: null,
|
||||||
|
webSearchSuccess: false,
|
||||||
|
globalEnvConfig: null,
|
||||||
|
globalEnvLoading: true,
|
||||||
|
globalEnvSaving: false,
|
||||||
|
globalEnvError: null,
|
||||||
|
globalEnvSuccess: false,
|
||||||
|
proxyConfig: null,
|
||||||
|
proxyLoading: true,
|
||||||
|
proxySaving: false,
|
||||||
|
proxyError: null,
|
||||||
|
proxySuccess: false,
|
||||||
|
proxyTestResult: null,
|
||||||
|
proxyTesting: false,
|
||||||
|
rawConfig: null,
|
||||||
|
rawConfigLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Actions ===
|
||||||
|
|
||||||
|
export type SettingsAction =
|
||||||
|
| { type: 'SET_WEBSEARCH_CONFIG'; payload: WebSearchConfig | null }
|
||||||
|
| { type: 'SET_WEBSEARCH_STATUS'; payload: WebSearchStatus | null }
|
||||||
|
| { type: 'SET_WEBSEARCH_LOADING'; payload: boolean }
|
||||||
|
| { type: 'SET_WEBSEARCH_STATUS_LOADING'; payload: boolean }
|
||||||
|
| { type: 'SET_WEBSEARCH_SAVING'; payload: boolean }
|
||||||
|
| { type: 'SET_WEBSEARCH_ERROR'; payload: string | null }
|
||||||
|
| { type: 'SET_WEBSEARCH_SUCCESS'; payload: boolean }
|
||||||
|
| { type: 'SET_GLOBALENV_CONFIG'; payload: GlobalEnvConfig | null }
|
||||||
|
| { type: 'SET_GLOBALENV_LOADING'; payload: boolean }
|
||||||
|
| { type: 'SET_GLOBALENV_SAVING'; payload: boolean }
|
||||||
|
| { type: 'SET_GLOBALENV_ERROR'; payload: string | null }
|
||||||
|
| { type: 'SET_GLOBALENV_SUCCESS'; payload: boolean }
|
||||||
|
| { type: 'SET_PROXY_CONFIG'; payload: CliproxyServerConfig | null }
|
||||||
|
| { type: 'SET_PROXY_LOADING'; payload: boolean }
|
||||||
|
| { type: 'SET_PROXY_SAVING'; payload: boolean }
|
||||||
|
| { type: 'SET_PROXY_ERROR'; payload: string | null }
|
||||||
|
| { type: 'SET_PROXY_SUCCESS'; payload: boolean }
|
||||||
|
| { type: 'SET_PROXY_TEST_RESULT'; payload: RemoteProxyStatus | null }
|
||||||
|
| { type: 'SET_PROXY_TESTING'; payload: boolean }
|
||||||
|
| { type: 'SET_RAW_CONFIG'; payload: string | null }
|
||||||
|
| { type: 'SET_RAW_CONFIG_LOADING'; payload: boolean };
|
||||||
|
|
||||||
|
export function settingsReducer(state: SettingsState, action: SettingsAction): SettingsState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_WEBSEARCH_CONFIG':
|
||||||
|
return { ...state, webSearchConfig: action.payload };
|
||||||
|
case 'SET_WEBSEARCH_STATUS':
|
||||||
|
return { ...state, webSearchStatus: action.payload };
|
||||||
|
case 'SET_WEBSEARCH_LOADING':
|
||||||
|
return { ...state, webSearchLoading: action.payload };
|
||||||
|
case 'SET_WEBSEARCH_STATUS_LOADING':
|
||||||
|
return { ...state, webSearchStatusLoading: action.payload };
|
||||||
|
case 'SET_WEBSEARCH_SAVING':
|
||||||
|
return { ...state, webSearchSaving: action.payload };
|
||||||
|
case 'SET_WEBSEARCH_ERROR':
|
||||||
|
return { ...state, webSearchError: action.payload };
|
||||||
|
case 'SET_WEBSEARCH_SUCCESS':
|
||||||
|
return { ...state, webSearchSuccess: action.payload };
|
||||||
|
case 'SET_GLOBALENV_CONFIG':
|
||||||
|
return { ...state, globalEnvConfig: action.payload };
|
||||||
|
case 'SET_GLOBALENV_LOADING':
|
||||||
|
return { ...state, globalEnvLoading: action.payload };
|
||||||
|
case 'SET_GLOBALENV_SAVING':
|
||||||
|
return { ...state, globalEnvSaving: action.payload };
|
||||||
|
case 'SET_GLOBALENV_ERROR':
|
||||||
|
return { ...state, globalEnvError: action.payload };
|
||||||
|
case 'SET_GLOBALENV_SUCCESS':
|
||||||
|
return { ...state, globalEnvSuccess: action.payload };
|
||||||
|
case 'SET_PROXY_CONFIG':
|
||||||
|
return { ...state, proxyConfig: action.payload };
|
||||||
|
case 'SET_PROXY_LOADING':
|
||||||
|
return { ...state, proxyLoading: action.payload };
|
||||||
|
case 'SET_PROXY_SAVING':
|
||||||
|
return { ...state, proxySaving: action.payload };
|
||||||
|
case 'SET_PROXY_ERROR':
|
||||||
|
return { ...state, proxyError: action.payload };
|
||||||
|
case 'SET_PROXY_SUCCESS':
|
||||||
|
return { ...state, proxySuccess: action.payload };
|
||||||
|
case 'SET_PROXY_TEST_RESULT':
|
||||||
|
return { ...state, proxyTestResult: action.payload };
|
||||||
|
case 'SET_PROXY_TESTING':
|
||||||
|
return { ...state, proxyTesting: action.payload };
|
||||||
|
case 'SET_RAW_CONFIG':
|
||||||
|
return { ...state, rawConfig: action.payload };
|
||||||
|
case 'SET_RAW_CONFIG_LOADING':
|
||||||
|
return { ...state, rawConfigLoading: action.payload };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Context ===
|
||||||
|
|
||||||
|
export interface SettingsContextValue {
|
||||||
|
state: SettingsState;
|
||||||
|
dispatch: Dispatch<SettingsAction>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsContext = createContext<SettingsContextValue | null>(null);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Settings Page Types
|
||||||
|
* Type definitions for WebSearch, GlobalEnv, and Proxy configurations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client';
|
||||||
|
|
||||||
|
// === WebSearch Types ===
|
||||||
|
|
||||||
|
export interface ProviderConfig {
|
||||||
|
enabled?: boolean;
|
||||||
|
model?: string;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSearchProvidersConfig {
|
||||||
|
gemini?: ProviderConfig;
|
||||||
|
grok?: ProviderConfig;
|
||||||
|
opencode?: ProviderConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSearchConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
providers?: WebSearchProvidersConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliStatus {
|
||||||
|
installed: boolean;
|
||||||
|
path: string | null;
|
||||||
|
version: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSearchStatus {
|
||||||
|
geminiCli: CliStatus;
|
||||||
|
grokCli: CliStatus;
|
||||||
|
opencodeCli: CliStatus;
|
||||||
|
readiness: {
|
||||||
|
status: 'ready' | 'unavailable';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// === GlobalEnv Types ===
|
||||||
|
|
||||||
|
export interface GlobalEnvConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
env: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tab Types ===
|
||||||
|
|
||||||
|
export type SettingsTab = 'websearch' | 'globalenv' | 'proxy';
|
||||||
|
|
||||||
|
// === Re-exports from api-client ===
|
||||||
|
|
||||||
|
export type { CliproxyServerConfig, RemoteProxyStatus };
|
||||||
Reference in New Issue
Block a user