feat(websearch): add advanced configuration and custom MCP support

This commit is contained in:
kaitranntt
2025-12-16 05:56:45 -05:00
parent f7a1a40b42
commit cadd2e8241
9 changed files with 803 additions and 250 deletions
+22
View File
@@ -121,6 +121,14 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
fallback: partial.websearch?.fallback ?? defaults.websearch?.fallback ?? true,
webSearchPrimeUrl:
partial.websearch?.webSearchPrimeUrl ?? defaults.websearch?.webSearchPrimeUrl,
gemini: {
enabled: partial.websearch?.gemini?.enabled ?? defaults.websearch?.gemini?.enabled ?? true,
timeout: partial.websearch?.gemini?.timeout ?? defaults.websearch?.gemini?.timeout ?? 55,
},
mode: partial.websearch?.mode ?? defaults.websearch?.mode ?? 'sequential',
selectedProviders:
partial.websearch?.selectedProviders ?? defaults.websearch?.selectedProviders ?? [],
customMcp: partial.websearch?.customMcp ?? defaults.websearch?.customMcp ?? [],
},
};
}
@@ -329,6 +337,17 @@ export function getWebSearchConfig(): {
enabled: boolean;
timeout: number;
};
mode: 'sequential' | 'parallel';
selectedProviders: string[];
customMcp: Array<{
name: string;
type: 'http' | 'stdio';
url?: string;
headers?: Record<string, string>;
command?: string;
args?: string[];
env?: Record<string, string>;
}>;
} {
const config = loadOrCreateUnifiedConfig();
return {
@@ -340,5 +359,8 @@ export function getWebSearchConfig(): {
enabled: config.websearch?.gemini?.enabled ?? true,
timeout: config.websearch?.gemini?.timeout ?? 55,
},
mode: config.websearch?.mode ?? 'sequential',
selectedProviders: config.websearch?.selectedProviders ?? [],
customMcp: config.websearch?.customMcp ?? [],
};
}
+26
View File
@@ -100,6 +100,26 @@ export interface PreferencesConfig {
auto_update?: boolean;
}
/**
* Custom MCP server configuration for BYOM (Bring Your Own MCP)
*/
export interface CustomMcpConfig {
/** Unique name for this MCP server */
name: string;
/** Server type: HTTP endpoint or stdio command */
type: 'http' | 'stdio';
/** URL for HTTP-based MCP servers */
url?: string;
/** Headers for HTTP requests */
headers?: Record<string, string>;
/** Command for stdio-based MCP servers */
command?: string;
/** Arguments for stdio command */
args?: string[];
/** Environment variables for the server */
env?: Record<string, string>;
}
/**
* WebSearch configuration.
* Controls MCP web-search auto-configuration for third-party profiles.
@@ -124,6 +144,12 @@ export interface WebSearchConfig {
/** Timeout in seconds for Gemini CLI (default: 55) */
timeout?: number;
};
/** Search mode: sequential (default) or parallel */
mode?: 'sequential' | 'parallel';
/** Selected providers for parallel mode */
selectedProviders?: string[];
/** Custom MCP servers (BYOM - Bring Your Own MCP) */
customMcp?: CustomMcpConfig[];
}
/**
+30
View File
@@ -220,6 +220,27 @@ export function ensureMcpWebSearch(): boolean {
}
}
// Add custom MCPs from config (BYOM)
if (wsConfig.customMcp && wsConfig.customMcp.length > 0) {
const servers = config.mcpServers as Record<string, McpServerConfig>;
for (const custom of wsConfig.customMcp) {
const serverConfig: McpServerConfig = {
type: custom.type,
_managedBy: 'ccs',
};
if (custom.type === 'http') {
serverConfig.url = custom.url;
serverConfig.headers = custom.headers || {};
} else {
serverConfig.command = custom.command;
serverConfig.args = custom.args || [];
serverConfig.env = custom.env || {};
}
servers[custom.name] = serverConfig;
addedMcps.push(custom.name);
}
}
// If nothing was added, return false
if (addedMcps.length === 0) {
if (process.env.CCS_DEBUG) {
@@ -485,6 +506,15 @@ export function getWebSearchHookEnv(): Record<string, string> {
env.CCS_GEMINI_TIMEOUT = String(wsConfig.gemini.timeout);
}
// Set search mode (sequential or parallel)
if (wsConfig.mode === 'parallel') {
env.CCS_WEBSEARCH_MODE = 'parallel';
// Pass selected providers for parallel mode
if (wsConfig.selectedProviders && wsConfig.selectedProviders.length > 0) {
env.CCS_WEBSEARCH_PROVIDERS = wsConfig.selectedProviders.join(',');
}
}
return env;
}
+55 -3
View File
@@ -45,6 +45,7 @@ import {
loadUnifiedConfig,
saveUnifiedConfig,
getConfigFormat,
getConfigYamlPath,
} from '../config/unified-config-loader';
import {
needsMigration,
@@ -931,6 +932,23 @@ apiRoutes.get('/config', (_req: Request, res: Response): void => {
res.json(config);
});
/**
* GET /api/config/raw - Return raw YAML content for display
*/
apiRoutes.get('/config/raw', (_req: Request, res: Response): void => {
const yamlPath = getConfigYamlPath();
if (!fs.existsSync(yamlPath)) {
res.status(404).json({ error: 'Config file not found' });
return;
}
try {
const content = fs.readFileSync(yamlPath, 'utf8');
res.type('text/plain').send(content);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
/**
* PUT /api/config - Update unified config
*/
@@ -1422,10 +1440,19 @@ apiRoutes.get('/websearch', (_req: Request, res: Response): void => {
/**
* PUT /api/websearch - Update WebSearch configuration
* Body: { enabled?: boolean, provider?: string, fallback?: boolean }
* Body: WebSearchConfig fields (enabled, provider, fallback, gemini, mode, selectedProviders, customMcp)
*/
apiRoutes.put('/websearch', (req: Request, res: Response): void => {
const { enabled, provider, fallback, webSearchPrimeUrl } = req.body as Partial<WebSearchConfig>;
const {
enabled,
provider,
fallback,
webSearchPrimeUrl,
gemini,
mode,
selectedProviders,
customMcp,
} = req.body as Partial<WebSearchConfig>;
// Validate enabled
if (enabled !== undefined && typeof enabled !== 'boolean') {
@@ -1454,6 +1481,27 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
return;
}
// Validate mode if specified
const validModes = ['sequential', 'parallel'];
if (mode && !validModes.includes(mode)) {
res.status(400).json({
error: `Invalid mode. Must be one of: ${validModes.join(', ')}`,
});
return;
}
// Validate selectedProviders if specified
if (selectedProviders !== undefined && !Array.isArray(selectedProviders)) {
res.status(400).json({ error: 'Invalid value for selectedProviders. Must be an array.' });
return;
}
// Validate customMcp if specified
if (customMcp !== undefined && !Array.isArray(customMcp)) {
res.status(400).json({ error: 'Invalid value for customMcp. Must be an array.' });
return;
}
try {
// Load existing config and update websearch section
const existingConfig = loadUnifiedConfig();
@@ -1462,12 +1510,16 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
return;
}
// Merge updates
// Merge updates - preserve all existing fields
existingConfig.websearch = {
enabled: enabled ?? existingConfig.websearch?.enabled ?? true,
provider: provider ?? existingConfig.websearch?.provider ?? 'auto',
fallback: fallback ?? existingConfig.websearch?.fallback ?? true,
webSearchPrimeUrl: webSearchPrimeUrl ?? existingConfig.websearch?.webSearchPrimeUrl,
gemini: gemini ?? existingConfig.websearch?.gemini ?? { enabled: true, timeout: 55 },
mode: mode ?? existingConfig.websearch?.mode ?? 'sequential',
selectedProviders: selectedProviders ?? existingConfig.websearch?.selectedProviders ?? [],
customMcp: customMcp ?? existingConfig.websearch?.customMcp ?? [],
};
saveUnifiedConfig(existingConfig);
+6
View File
@@ -6,6 +6,7 @@
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -15,6 +16,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
@@ -209,6 +211,8 @@
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
@@ -255,6 +259,8 @@
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
+2
View File
@@ -17,6 +17,7 @@
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -26,6 +27,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
+27
View File
@@ -0,0 +1,27 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+26
View File
@@ -0,0 +1,26 @@
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+609 -247
View File
@@ -1,26 +1,53 @@
/**
* Settings Page - WebSearch Configuration
* Configure MCP-based web search for third-party profiles
* Simplified toggle-based UI: enable providers you want, we handle the rest
*/
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Globe, RefreshCw, CheckCircle2, AlertCircle, Info } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Globe,
RefreshCw,
CheckCircle2,
AlertCircle,
FileCode,
Copy,
Check,
Plus,
Trash2,
GripVertical,
Zap,
Terminal,
Server,
} from 'lucide-react';
import { CodeEditor } from '@/components/code-editor';
interface CustomMcpConfig {
name: string;
type: 'http' | 'stdio';
url?: string;
headers?: Record<string, string>;
command?: string;
args?: string[];
env?: Record<string, string>;
}
interface WebSearchConfig {
enabled: boolean;
provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
fallback: boolean;
gemini?: {
enabled: boolean;
timeout: number;
};
mode?: 'sequential' | 'parallel';
selectedProviders?: string[];
customMcp?: CustomMcpConfig[];
}
interface WebSearchStatus {
@@ -40,6 +67,38 @@ interface WebSearchStatus {
};
}
// Built-in providers with code names
const BUILTIN_PROVIDERS = [
{
id: 'gemini',
name: 'gemini',
desc: 'Google Gemini CLI (OAuth)',
icon: Terminal,
isMcp: false,
},
{
id: 'web-search-prime',
name: 'web-search-prime',
desc: 'z.ai MCP',
icon: Zap,
isMcp: true,
},
{
id: 'brave-search',
name: 'brave-search',
desc: 'Brave Search MCP',
icon: Globe,
isMcp: true,
},
{
id: 'tavily',
name: 'tavily',
desc: 'Tavily MCP',
icon: Globe,
isMcp: true,
},
];
export function SettingsPage() {
const [config, setConfig] = useState<WebSearchConfig | null>(null);
const [loading, setLoading] = useState(true);
@@ -48,11 +107,20 @@ export function SettingsPage() {
const [success, setSuccess] = useState(false);
const [status, setStatus] = useState<WebSearchStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
// Config viewer state
const [rawConfig, setRawConfig] = useState<string | null>(null);
const [rawConfigLoading, setRawConfigLoading] = useState(false);
const [copied, setCopied] = useState(false);
// BYOM MCP form state - JSON paste
const [mcpJsonInput, setMcpJsonInput] = useState('');
const [mcpJsonError, setMcpJsonError] = useState<string | null>(null);
const [showAddMcp, setShowAddMcp] = useState(false);
// Load config and status on mount
useEffect(() => {
fetchConfig();
fetchStatus();
fetchRawConfig();
}, []);
const fetchConfig = async () => {
@@ -84,18 +152,218 @@ export function SettingsPage() {
}
};
const fetchRawConfig = async () => {
try {
setRawConfigLoading(true);
const res = await fetch('/api/config/raw');
if (!res.ok) {
setRawConfig(null);
return;
}
const text = await res.text();
setRawConfig(text);
} catch (err) {
console.error('Failed to fetch raw config:', err);
setRawConfig(null);
} finally {
setRawConfigLoading(false);
}
};
const copyToClipboard = async () => {
if (!rawConfig) return;
try {
await navigator.clipboard.writeText(rawConfig);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
// Get active providers from config
const getActiveProviders = (): string[] => {
const active: string[] = [];
if (config?.gemini?.enabled !== false) active.push('gemini');
if (config?.selectedProviders) {
active.push(...config.selectedProviders.filter((p) => p !== 'gemini'));
}
return active;
};
// Toggle a provider
const toggleProvider = (providerId: string) => {
const current = getActiveProviders();
let updated: string[];
if (current.includes(providerId)) {
updated = current.filter((p) => p !== providerId);
} else {
updated = [...current, providerId];
}
// Separate gemini from MCP providers
const geminiEnabled = updated.includes('gemini');
const mcpProviders = updated.filter((p) => p !== 'gemini');
// Determine mode: parallel if multiple providers, sequential otherwise
const mode = updated.length > 1 ? 'parallel' : 'sequential';
saveConfig({
gemini: {
enabled: geminiEnabled,
timeout: config?.gemini?.timeout ?? 55,
},
selectedProviders: updated,
mode,
// Auto-enable if any provider is on
enabled: updated.length > 0,
// Set primary provider
provider: (mcpProviders[0] as WebSearchConfig['provider']) || 'auto',
});
};
// Parse JSON input and add custom MCP
const addCustomMcp = () => {
if (!mcpJsonInput.trim()) return;
setMcpJsonError(null);
// Try to parse JSON
let parsed: unknown;
try {
parsed = JSON.parse(mcpJsonInput);
} catch (_err) {
// Check for common JSON issues
const input = mcpJsonInput.trim();
if (input.includes('//')) {
setMcpJsonError('Remove comments (// ...) from JSON before pasting');
} else if (!input.startsWith('{')) {
setMcpJsonError('JSON must start with { - check for extra characters');
} else if (!input.endsWith('}')) {
setMcpJsonError('JSON must end with } - check for missing closing brace');
} else {
setMcpJsonError('Invalid JSON syntax. Check for missing quotes, commas, or braces.');
}
return;
}
// Validate it's an object
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
setMcpJsonError('Expected a JSON object { ... }, not an array or primitive');
return;
}
const obj = parsed as Record<string, unknown>;
const serversToAdd: CustomMcpConfig[] = [];
// Format 1: { "mcpServers": { "name": { ... } } } - Full Claude/CCS format
if (obj.mcpServers && typeof obj.mcpServers === 'object') {
for (const [name, serverConfig] of Object.entries(obj.mcpServers as Record<string, unknown>)) {
const cfg = serverConfig as Record<string, unknown>;
if (cfg && typeof cfg === 'object') {
serversToAdd.push({
name,
type: (cfg.type as 'http' | 'stdio') || 'http',
url: cfg.url as string,
headers: cfg.headers as Record<string, string>,
command: cfg.command as string,
args: cfg.args as string[],
env: cfg.env as Record<string, string>,
});
}
}
}
// Format 2: { "type": "http", "url": "..." } - Single server without name
else if (obj.type && (obj.url || obj.command)) {
setMcpJsonError(
'Missing server name. Use format:\n{ "your-server-name": { "type": "http", "url": "..." } }'
);
return;
}
// Format 3: { "name": { ... } } - Direct server object (partial paste)
else {
for (const [name, value] of Object.entries(obj)) {
const cfg = value as Record<string, unknown>;
if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) {
// Check if this looks like a server config
if (cfg.type || cfg.url || cfg.command) {
serversToAdd.push({
name,
type: (cfg.type as 'http' | 'stdio') || 'http',
url: cfg.url as string,
headers: cfg.headers as Record<string, string>,
command: cfg.command as string,
args: cfg.args as string[],
env: cfg.env as Record<string, string>,
});
}
}
}
}
// Validate we found servers
if (serversToAdd.length === 0) {
setMcpJsonError(
'No valid MCP server found. Expected format:\n{ "server-name": { "type": "http", "url": "..." } }'
);
return;
}
// Validate each server
for (const server of serversToAdd) {
if (!server.name) {
setMcpJsonError('Server name is required');
return;
}
if (server.type === 'http' && !server.url) {
setMcpJsonError(`"${server.name}" is missing "url" field`);
return;
}
if (server.type === 'stdio' && !server.command) {
setMcpJsonError(`"${server.name}" is missing "command" field`);
return;
}
}
// Check for duplicates
const existing = config?.customMcp || [];
const duplicates = serversToAdd.filter((s) => existing.some((e) => e.name === s.name));
if (duplicates.length > 0) {
setMcpJsonError(`Server "${duplicates[0].name}" already exists`);
return;
}
// Success - add the servers
saveConfig({ customMcp: [...existing, ...serversToAdd] });
setMcpJsonInput('');
setShowAddMcp(false);
};
const removeCustomMcp = (name: string) => {
const current = config?.customMcp || [];
// Also remove from selectedProviders
const selected = config?.selectedProviders || [];
saveConfig({
customMcp: current.filter((m) => m.name !== name),
selectedProviders: selected.filter((p) => p !== name),
});
};
const saveConfig = async (updates: Partial<WebSearchConfig>) => {
if (!config) return;
// Optimistic update - apply changes immediately to local state
const optimisticConfig = { ...config, ...updates };
setConfig(optimisticConfig);
try {
setSaving(true);
setError(null);
setSuccess(false);
const res = await fetch('/api/websearch', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...config, ...updates }),
body: JSON.stringify(optimisticConfig),
});
if (!res.ok) {
@@ -105,278 +373,372 @@ export function SettingsPage() {
const data = await res.json();
setConfig(data.websearch);
// Quick flash of success (shorter duration, less intrusive)
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
setTimeout(() => setSuccess(false), 1500);
// Silently refresh raw config without loading state
fetch('/api/config/raw')
.then((r) => r.ok ? r.text() : null)
.then((text) => text && setRawConfig(text))
.catch(() => {});
} catch (err) {
// Revert optimistic update on error
setConfig(config);
setError((err as Error).message);
} finally {
setSaving(false);
}
};
const activeProviders = config ? getActiveProviders() : [];
const activeCount = activeProviders.length;
if (loading) {
return (
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
<div className="flex items-center gap-2 text-muted-foreground">
<RefreshCw className="w-4 h-4 animate-spin" />
Loading configuration...
<div className="h-[calc(100vh-100px)] flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span className="text-lg">Loading configuration...</span>
</div>
</div>
);
}
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
<h1 className="text-2xl font-bold">Settings</h1>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert className="border-green-500/50 bg-green-500/5">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-600">
Settings saved successfully
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="w-5 h-5" />
WebSearch Configuration
</CardTitle>
<CardDescription>
Configure MCP-based web search for third-party profiles (gemini, agy, codex, qwen, etc.)
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Alert className="border-blue-500/50 bg-blue-500/5">
<Info className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-blue-600">
Third-party profiles cannot use Anthropic&apos;s native WebSearch. CCS automatically
configures MCP web search servers as a fallback.
</AlertDescription>
</Alert>
{/* WebSearch Status Panel */}
<div className="space-y-4 pb-4 border-b">
<h4 className="font-medium flex items-center gap-2">
Status
<Button variant="ghost" size="sm" onClick={fetchStatus} disabled={statusLoading}>
<RefreshCw className={`w-3 h-3 ${statusLoading ? 'animate-spin' : ''}`} />
</Button>
</h4>
{statusLoading ? (
<div className="flex items-center gap-2 text-muted-foreground">
<RefreshCw className="w-4 h-4 animate-spin" />
Checking status...
<div className="h-[calc(100vh-100px)]">
<PanelGroup direction="horizontal" className="h-full">
{/* Left Panel - WebSearch Controls */}
<Panel defaultSize={40} minSize={30} maxSize={55}>
<div className="h-full border-r flex flex-col bg-muted/30 relative">
{/* Header */}
<div className="p-5 border-b bg-background">
<div className="flex items-center gap-3">
<Globe className="w-6 h-6 text-primary" />
<div>
<h1 className="text-lg font-semibold">WebSearch</h1>
<p className="text-sm text-muted-foreground">
Toggle providers Multiple = parallel
</p>
</div>
</div>
) : status ? (
<div className="space-y-3">
{/* Overall Readiness */}
<div className="flex items-center gap-2">
{status.readiness.status === 'ready' && (
<CheckCircle2 className="w-4 h-4 text-green-600" />
)}
{status.readiness.status === 'mcp-only' && (
<Info className="w-4 h-4 text-blue-600" />
)}
{status.readiness.status === 'unavailable' && (
<AlertCircle className="w-4 h-4 text-red-600" />
)}
<span className="font-medium">{status.readiness.message}</span>
</div>
{/* Toast-style alerts - absolute positioned, no layout shift */}
<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">
{/* Status Summary */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium">
{activeCount === 0 && 'No providers enabled'}
{activeCount === 1 && '1 provider active'}
{activeCount > 1 && `${activeCount} providers (parallel)`}
</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>
{/* Gemini CLI Status */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div
className={`w-2 h-2 rounded-full mt-2 ${
status.geminiCli.installed ? 'bg-green-500' : 'bg-gray-400'
}`}
/>
<div className="flex-1">
<p className="font-medium">Gemini CLI</p>
{status.geminiCli.installed ? (
<p className="text-sm text-muted-foreground">
Installed {status.geminiCli.version && `(${status.geminiCli.version})`}
</p>
) : (
<div className="text-sm text-muted-foreground">
<p>Not installed</p>
<code className="text-xs bg-muted px-1 py-0.5 rounded">
npm install -g @google/gemini-cli
</code>
</div>
)}
{/* Built-in Providers */}
<div className="space-y-3">
<h3 className="text-base font-medium">Providers</h3>
<div className="space-y-2">
{BUILTIN_PROVIDERS.map((provider) => {
const isActive = activeProviders.includes(provider.id);
const Icon = provider.icon;
const isGemini = provider.id === 'gemini';
const isInstalled = isGemini
? status?.geminiCli.installed
: provider.isMcp
? status?.mcpServers.ccsManaged.includes(provider.id) ||
status?.mcpServers.userAdded.includes(provider.id)
: true;
return (
<div
key={provider.id}
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
isActive
? 'border-primary/50 bg-primary/5'
: 'border-border bg-background'
}`}
>
<div className="flex items-center gap-3">
<Icon
className={`w-5 h-5 ${isActive ? 'text-primary' : 'text-muted-foreground'}`}
/>
<div>
<div className="flex items-center gap-2">
<p className="font-mono font-medium">{provider.name}</p>
{isInstalled ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
configured
</span>
) : (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
not configured
</span>
)}
</div>
<p className="text-sm text-muted-foreground">{provider.desc}</p>
</div>
</div>
<Switch
checked={isActive}
onCheckedChange={() => toggleProvider(provider.id)}
disabled={saving || !isInstalled}
/>
</div>
);
})}
</div>
</div>
{/* MCP Servers */}
<div className="space-y-2">
<p className="text-sm font-medium">MCP Servers</p>
{status.mcpServers.ccsManaged.length === 0 &&
status.mcpServers.userAdded.length === 0 ? (
<p className="text-sm text-muted-foreground">No web search MCP configured</p>
) : (
<div className="space-y-1">
{status.mcpServers.ccsManaged.map((name) => (
<div key={name} className="flex items-center gap-2 text-sm">
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
Managed by CCS
</span>
<span>{name}</span>
</div>
))}
{status.mcpServers.userAdded.map((name) => (
<div key={name} className="flex items-center gap-2 text-sm">
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
User-added
</span>
<span>{name}</span>
</div>
))}
{/* Custom MCPs */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium">Custom MCPs</h3>
<Button
variant="outline"
size="sm"
onClick={() => setShowAddMcp(!showAddMcp)}
disabled={saving}
>
<Plus className="w-4 h-4 mr-1" />
Add
</Button>
</div>
{showAddMcp && (
<div className="space-y-3 p-4 rounded-lg border bg-background">
<div>
<Label htmlFor="mcpJson" className="text-sm font-medium">
Paste MCP Server JSON
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Paste the JSON config from your MCP provider docs
</p>
<textarea
id="mcpJson"
placeholder={`// Full format (from Claude settings):
{
"mcpServers": {
"my-mcp": {
"type": "http",
"url": "https://api.example.com/mcp"
}
}
}
// Or just the server part:
{
"my-mcp": {
"type": "http",
"url": "https://..."
}
}`}
value={mcpJsonInput}
onChange={(e) => {
setMcpJsonInput(e.target.value);
setMcpJsonError(null);
}}
className="w-full min-h-[160px] max-h-[400px] p-3 font-mono text-sm border rounded-md bg-muted/50 resize-y focus:outline-none focus:ring-2 focus:ring-primary"
/>
{mcpJsonError && (
<p className="text-sm text-destructive mt-2">{mcpJsonError}</p>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={addCustomMcp}
disabled={!mcpJsonInput.trim() || saving}
>
Add
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowAddMcp(false);
setMcpJsonInput('');
setMcpJsonError(null);
}}
>
Cancel
</Button>
</div>
</div>
)}
{/* Custom MCP list */}
{config?.customMcp && config.customMcp.length > 0 && (
<div className="space-y-2">
{config.customMcp.map((mcp) => {
const isActive = activeProviders.includes(mcp.name);
return (
<div
key={mcp.name}
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
isActive
? 'border-primary/50 bg-primary/5'
: 'border-border bg-background'
}`}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<Server
className={`w-5 h-5 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground'}`}
/>
<div className="min-w-0 flex-1">
<p className="font-mono font-medium truncate">{mcp.name}</p>
<p className="text-sm text-muted-foreground truncate">{mcp.url}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch
checked={isActive}
onCheckedChange={() => toggleProvider(mcp.name)}
disabled={saving}
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeCustomMcp(mcp.name)}
disabled={saving}
className="h-8 w-8 p-0"
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Status unavailable</p>
)}
</div>
</ScrollArea>
<div className="space-y-4">
{/* Enable/Disable */}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="enabled" className="text-base">
Auto-Configure MCP WebSearch
</Label>
<p className="text-sm text-muted-foreground">
Automatically add web search MCP servers for third-party profiles
</p>
</div>
<Select
value={config?.enabled ? 'enabled' : 'disabled'}
onValueChange={(value) => saveConfig({ enabled: value === 'enabled' })}
disabled={saving}
{/* Footer */}
<div className="p-4 border-t bg-background">
<Button
variant="outline"
size="sm"
onClick={() => {
fetchConfig();
fetchRawConfig();
}}
disabled={loading || saving}
className="w-full"
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
</div>
{/* Provider Selection */}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="provider" className="text-base">
Preferred Provider
</Label>
<p className="text-sm text-muted-foreground">Primary web search provider to use</p>
</div>
<Select
value={config?.provider || 'auto'}
onValueChange={(value) =>
saveConfig({
provider: value as WebSearchConfig['provider'],
})
}
disabled={saving || !config?.enabled}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto (Recommended)</SelectItem>
<SelectItem value="web-search-prime">web-search-prime</SelectItem>
<SelectItem value="brave">Brave Search</SelectItem>
<SelectItem value="tavily">Tavily</SelectItem>
</SelectContent>
</Select>
</div>
{/* Fallback Enable */}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="fallback" className="text-base">
Enable Fallback Chain
</Label>
<p className="text-sm text-muted-foreground">
Add backup providers when primary is unavailable
</p>
</div>
<Select
value={config?.fallback ? 'enabled' : 'disabled'}
onValueChange={(value) => saveConfig({ fallback: value === 'enabled' })}
disabled={saving || !config?.enabled}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
</Panel>
{/* Provider Info */}
<div className="pt-4 border-t">
<h4 className="font-medium mb-3">Available Providers</h4>
<div className="grid gap-3">
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div className="w-2 h-2 rounded-full bg-green-500 mt-2" />
{/* 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>
<p className="font-medium">web-search-prime</p>
<p className="text-sm text-muted-foreground">
Requires z.ai coding plan subscription. Primary fallback option.
</p>
<h2 className="font-semibold">config.yaml</h2>
<p className="text-sm text-muted-foreground">~/.ccs/config.yaml</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div className="w-2 h-2 rounded-full bg-yellow-500 mt-2" />
<div>
<p className="font-medium">Brave Search</p>
<p className="text-sm text-muted-foreground">
Free tier: 15k queries/month. Set <code className="text-xs">BRAVE_API_KEY</code>{' '}
env var.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div className="w-2 h-2 rounded-full bg-blue-500 mt-2" />
<div>
<p className="font-medium">Tavily</p>
<p className="text-sm text-muted-foreground">
AI-optimized search (paid). Set <code className="text-xs">TAVILY_API_KEY</code>{' '}
env var.
</p>
</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>
</div>
<div className="flex justify-end">
<Button variant="outline" onClick={fetchConfig} disabled={loading || saving}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
{/* 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>
</CardContent>
</Card>
</Panel>
</PanelGroup>
</div>
);
}