mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
feat(websearch): add advanced configuration and custom MCP support
This commit is contained in:
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user