diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 909e81aa..7df75034 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -121,6 +121,14 @@ function mergeWithDefaults(partial: Partial): 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; + command?: string; + args?: string[]; + env?: Record; + }>; } { 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 ?? [], }; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 08949c6d..96e4a781 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -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; + /** Command for stdio-based MCP servers */ + command?: string; + /** Arguments for stdio command */ + args?: string[]; + /** Environment variables for the server */ + env?: Record; +} + /** * 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[]; } /** diff --git a/src/utils/mcp-manager.ts b/src/utils/mcp-manager.ts index 25c14071..99d684d8 100644 --- a/src/utils/mcp-manager.ts +++ b/src/utils/mcp-manager.ts @@ -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; + 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 { 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; } diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 708132b7..7b2146c1 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -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; + const { + enabled, + provider, + fallback, + webSearchPrimeUrl, + gemini, + mode, + selectedProviders, + customMcp, + } = req.body as Partial; // 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); diff --git a/ui/bun.lock b/ui/bun.lock index 4a0d6dae..2bfbf4c6 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -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=="], diff --git a/ui/package.json b/ui/package.json index e44e696d..5438d4c2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/components/ui/checkbox.tsx b/ui/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..ae5a2796 --- /dev/null +++ b/ui/src/components/ui/checkbox.tsx @@ -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) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/ui/src/components/ui/switch.tsx b/ui/src/components/ui/switch.tsx new file mode 100644 index 00000000..09603b92 --- /dev/null +++ b/ui/src/components/ui/switch.tsx @@ -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) { + return ( + + + + ); +} + +export { Switch }; diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 5e5d3d23..23fa978f 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -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; + command?: string; + args?: string[]; + env?: Record; +} 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(null); const [loading, setLoading] = useState(true); @@ -48,11 +107,20 @@ export function SettingsPage() { const [success, setSuccess] = useState(false); const [status, setStatus] = useState(null); const [statusLoading, setStatusLoading] = useState(true); + // Config viewer state + const [rawConfig, setRawConfig] = useState(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(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; + 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)) { + const cfg = serverConfig as Record; + 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, + command: cfg.command as string, + args: cfg.args as string[], + env: cfg.env as Record, + }); + } + } + } + // 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; + 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, + command: cfg.command as string, + args: cfg.args as string[], + env: cfg.env as Record, + }); + } + } + } + } + + // 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) => { 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 ( -
-

Settings

-
- - Loading configuration... +
+
+ + Loading configuration...
); } return ( -
-

Settings

- - {error && ( - - - {error} - - )} - - {success && ( - - - - Settings saved successfully - - - )} - - - - - - WebSearch Configuration - - - Configure MCP-based web search for third-party profiles (gemini, agy, codex, qwen, etc.) - - - - - - - Third-party profiles cannot use Anthropic's native WebSearch. CCS automatically - configures MCP web search servers as a fallback. - - - - {/* WebSearch Status Panel */} -
-

- Status - -

- - {statusLoading ? ( -
- - Checking status... +
+ + {/* Left Panel - WebSearch Controls */} + +
+ {/* Header */} +
+
+ +
+

WebSearch

+

+ Toggle providers • Multiple = parallel +

+
- ) : status ? ( -
- {/* Overall Readiness */} -
- {status.readiness.status === 'ready' && ( - - )} - {status.readiness.status === 'mcp-only' && ( - - )} - {status.readiness.status === 'unavailable' && ( - - )} - {status.readiness.message} +
+ + {/* Toast-style alerts - absolute positioned, no layout shift */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+ {/* Status Summary */} +
+
+

+ {activeCount === 0 && 'No providers enabled'} + {activeCount === 1 && '1 provider active'} + {activeCount > 1 && `${activeCount} providers (parallel)`} +

+ {statusLoading ? ( +

Checking status...

+ ) : status?.readiness ? ( +

{status.readiness.message}

+ ) : null} +
+
- {/* Gemini CLI Status */} -
-
-
-

Gemini CLI

- {status.geminiCli.installed ? ( -

- Installed {status.geminiCli.version && `(${status.geminiCli.version})`} -

- ) : ( -
-

Not installed

- - npm install -g @google/gemini-cli - -
- )} + {/* Built-in Providers */} +
+

Providers

+
+ {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 ( +
+
+ +
+
+

{provider.name}

+ {isInstalled ? ( + + configured + + ) : ( + + not configured + + )} +
+

{provider.desc}

+
+
+ toggleProvider(provider.id)} + disabled={saving || !isInstalled} + /> +
+ ); + })}
- {/* MCP Servers */} -
-

MCP Servers

- {status.mcpServers.ccsManaged.length === 0 && - status.mcpServers.userAdded.length === 0 ? ( -

No web search MCP configured

- ) : ( -
- {status.mcpServers.ccsManaged.map((name) => ( -
- - Managed by CCS - - {name} -
- ))} - {status.mcpServers.userAdded.map((name) => ( -
- - User-added - - {name} -
- ))} + {/* Custom MCPs */} +
+
+

Custom MCPs

+ +
+ + {showAddMcp && ( +
+
+ +

+ Paste the JSON config from your MCP provider docs +

+