mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +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,
|
fallback: partial.websearch?.fallback ?? defaults.websearch?.fallback ?? true,
|
||||||
webSearchPrimeUrl:
|
webSearchPrimeUrl:
|
||||||
partial.websearch?.webSearchPrimeUrl ?? defaults.websearch?.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;
|
enabled: boolean;
|
||||||
timeout: number;
|
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();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
return {
|
return {
|
||||||
@@ -340,5 +359,8 @@ export function getWebSearchConfig(): {
|
|||||||
enabled: config.websearch?.gemini?.enabled ?? true,
|
enabled: config.websearch?.gemini?.enabled ?? true,
|
||||||
timeout: config.websearch?.gemini?.timeout ?? 55,
|
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;
|
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.
|
* WebSearch configuration.
|
||||||
* Controls MCP web-search auto-configuration for third-party profiles.
|
* 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 in seconds for Gemini CLI (default: 55) */
|
||||||
timeout?: number;
|
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 nothing was added, return false
|
||||||
if (addedMcps.length === 0) {
|
if (addedMcps.length === 0) {
|
||||||
if (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
@@ -485,6 +506,15 @@ export function getWebSearchHookEnv(): Record<string, string> {
|
|||||||
env.CCS_GEMINI_TIMEOUT = String(wsConfig.gemini.timeout);
|
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;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
loadUnifiedConfig,
|
loadUnifiedConfig,
|
||||||
saveUnifiedConfig,
|
saveUnifiedConfig,
|
||||||
getConfigFormat,
|
getConfigFormat,
|
||||||
|
getConfigYamlPath,
|
||||||
} from '../config/unified-config-loader';
|
} from '../config/unified-config-loader';
|
||||||
import {
|
import {
|
||||||
needsMigration,
|
needsMigration,
|
||||||
@@ -931,6 +932,23 @@ apiRoutes.get('/config', (_req: Request, res: Response): void => {
|
|||||||
res.json(config);
|
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
|
* PUT /api/config - Update unified config
|
||||||
*/
|
*/
|
||||||
@@ -1422,10 +1440,19 @@ apiRoutes.get('/websearch', (_req: Request, res: Response): void => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/websearch - Update WebSearch configuration
|
* 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 => {
|
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
|
// Validate enabled
|
||||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||||
@@ -1454,6 +1481,27 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
|
|||||||
return;
|
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 {
|
try {
|
||||||
// Load existing config and update websearch section
|
// Load existing config and update websearch section
|
||||||
const existingConfig = loadUnifiedConfig();
|
const existingConfig = loadUnifiedConfig();
|
||||||
@@ -1462,12 +1510,16 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge updates
|
// Merge updates - preserve all existing fields
|
||||||
existingConfig.websearch = {
|
existingConfig.websearch = {
|
||||||
enabled: enabled ?? existingConfig.websearch?.enabled ?? true,
|
enabled: enabled ?? existingConfig.websearch?.enabled ?? true,
|
||||||
provider: provider ?? existingConfig.websearch?.provider ?? 'auto',
|
provider: provider ?? existingConfig.websearch?.provider ?? 'auto',
|
||||||
fallback: fallback ?? existingConfig.websearch?.fallback ?? true,
|
fallback: fallback ?? existingConfig.websearch?.fallback ?? true,
|
||||||
webSearchPrimeUrl: webSearchPrimeUrl ?? existingConfig.websearch?.webSearchPrimeUrl,
|
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);
|
saveUnifiedConfig(existingConfig);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@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-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-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=="],
|
"@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-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-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=="],
|
"@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": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@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
|
* 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 { useState, useEffect } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
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 {
|
interface WebSearchConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
|
provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
|
||||||
fallback: boolean;
|
fallback: boolean;
|
||||||
|
gemini?: {
|
||||||
|
enabled: boolean;
|
||||||
|
timeout: number;
|
||||||
|
};
|
||||||
|
mode?: 'sequential' | 'parallel';
|
||||||
|
selectedProviders?: string[];
|
||||||
|
customMcp?: CustomMcpConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebSearchStatus {
|
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() {
|
export function SettingsPage() {
|
||||||
const [config, setConfig] = useState<WebSearchConfig | null>(null);
|
const [config, setConfig] = useState<WebSearchConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -48,11 +107,20 @@ export function SettingsPage() {
|
|||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [status, setStatus] = useState<WebSearchStatus | null>(null);
|
const [status, setStatus] = useState<WebSearchStatus | null>(null);
|
||||||
const [statusLoading, setStatusLoading] = useState(true);
|
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
|
// Load config and status on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
|
fetchRawConfig();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchConfig = async () => {
|
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>) => {
|
const saveConfig = async (updates: Partial<WebSearchConfig>) => {
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
|
|
||||||
|
// Optimistic update - apply changes immediately to local state
|
||||||
|
const optimisticConfig = { ...config, ...updates };
|
||||||
|
setConfig(optimisticConfig);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(false);
|
|
||||||
|
|
||||||
const res = await fetch('/api/websearch', {
|
const res = await fetch('/api/websearch', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...config, ...updates }),
|
body: JSON.stringify(optimisticConfig),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -105,278 +373,372 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setConfig(data.websearch);
|
setConfig(data.websearch);
|
||||||
|
// Quick flash of success (shorter duration, less intrusive)
|
||||||
setSuccess(true);
|
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) {
|
} catch (err) {
|
||||||
|
// Revert optimistic update on error
|
||||||
|
setConfig(config);
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeProviders = config ? getActiveProviders() : [];
|
||||||
|
const activeCount = activeProviders.length;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto">
|
<div className="h-[calc(100vh-100px)] flex items-center justify-center">
|
||||||
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
<span className="text-lg">Loading configuration...</span>
|
||||||
Loading configuration...
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
<div className="h-[calc(100vh-100px)]">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<PanelGroup direction="horizontal" className="h-full">
|
||||||
|
{/* Left Panel - WebSearch Controls */}
|
||||||
{error && (
|
<Panel defaultSize={40} minSize={30} maxSize={55}>
|
||||||
<Alert variant="destructive">
|
<div className="h-full border-r flex flex-col bg-muted/30 relative">
|
||||||
<AlertCircle className="h-4 w-4" />
|
{/* Header */}
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<div className="p-5 border-b bg-background">
|
||||||
</Alert>
|
<div className="flex items-center gap-3">
|
||||||
)}
|
<Globe className="w-6 h-6 text-primary" />
|
||||||
|
<div>
|
||||||
{success && (
|
<h1 className="text-lg font-semibold">WebSearch</h1>
|
||||||
<Alert className="border-green-500/50 bg-green-500/5">
|
<p className="text-sm text-muted-foreground">
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
Toggle providers • Multiple = parallel
|
||||||
<AlertDescription className="text-green-600">
|
</p>
|
||||||
Settings saved successfully
|
</div>
|
||||||
</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>
|
</div>
|
||||||
) : status ? (
|
</div>
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Overall Readiness */}
|
{/* Toast-style alerts - absolute positioned, no layout shift */}
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
{status.readiness.status === 'ready' && (
|
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
error || success
|
||||||
)}
|
? 'opacity-100 translate-y-0'
|
||||||
{status.readiness.status === 'mcp-only' && (
|
: 'opacity-0 -translate-y-2 pointer-events-none'
|
||||||
<Info className="w-4 h-4 text-blue-600" />
|
}`}
|
||||||
)}
|
>
|
||||||
{status.readiness.status === 'unavailable' && (
|
{error && (
|
||||||
<AlertCircle className="w-4 h-4 text-red-600" />
|
<Alert variant="destructive" className="py-2 shadow-lg">
|
||||||
)}
|
<AlertCircle className="h-4 w-4" />
|
||||||
<span className="font-medium">{status.readiness.message}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Gemini CLI Status */}
|
{/* Built-in Providers */}
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
<div className="space-y-3">
|
||||||
<div
|
<h3 className="text-base font-medium">Providers</h3>
|
||||||
className={`w-2 h-2 rounded-full mt-2 ${
|
<div className="space-y-2">
|
||||||
status.geminiCli.installed ? 'bg-green-500' : 'bg-gray-400'
|
{BUILTIN_PROVIDERS.map((provider) => {
|
||||||
}`}
|
const isActive = activeProviders.includes(provider.id);
|
||||||
/>
|
const Icon = provider.icon;
|
||||||
<div className="flex-1">
|
const isGemini = provider.id === 'gemini';
|
||||||
<p className="font-medium">Gemini CLI</p>
|
const isInstalled = isGemini
|
||||||
{status.geminiCli.installed ? (
|
? status?.geminiCli.installed
|
||||||
<p className="text-sm text-muted-foreground">
|
: provider.isMcp
|
||||||
Installed {status.geminiCli.version && `(${status.geminiCli.version})`}
|
? status?.mcpServers.ccsManaged.includes(provider.id) ||
|
||||||
</p>
|
status?.mcpServers.userAdded.includes(provider.id)
|
||||||
) : (
|
: true;
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
<p>Not installed</p>
|
return (
|
||||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
<div
|
||||||
npm install -g @google/gemini-cli
|
key={provider.id}
|
||||||
</code>
|
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MCP Servers */}
|
{/* Custom MCPs */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-medium">MCP Servers</p>
|
<div className="flex items-center justify-between">
|
||||||
{status.mcpServers.ccsManaged.length === 0 &&
|
<h3 className="text-base font-medium">Custom MCPs</h3>
|
||||||
status.mcpServers.userAdded.length === 0 ? (
|
<Button
|
||||||
<p className="text-sm text-muted-foreground">No web search MCP configured</p>
|
variant="outline"
|
||||||
) : (
|
size="sm"
|
||||||
<div className="space-y-1">
|
onClick={() => setShowAddMcp(!showAddMcp)}
|
||||||
{status.mcpServers.ccsManaged.map((name) => (
|
disabled={saving}
|
||||||
<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">
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
Managed by CCS
|
Add
|
||||||
</span>
|
</Button>
|
||||||
<span>{name}</span>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
{showAddMcp && (
|
||||||
{status.mcpServers.userAdded.map((name) => (
|
<div className="space-y-3 p-4 rounded-lg border bg-background">
|
||||||
<div key={name} className="flex items-center gap-2 text-sm">
|
<div>
|
||||||
<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">
|
<Label htmlFor="mcpJson" className="text-sm font-medium">
|
||||||
User-added
|
Paste MCP Server JSON
|
||||||
</span>
|
</Label>
|
||||||
<span>{name}</span>
|
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||||
</div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</ScrollArea>
|
||||||
<p className="text-sm text-muted-foreground">Status unavailable</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Footer */}
|
||||||
{/* Enable/Disable */}
|
<div className="p-4 border-t bg-background">
|
||||||
<div className="flex items-center justify-between">
|
<Button
|
||||||
<div>
|
variant="outline"
|
||||||
<Label htmlFor="enabled" className="text-base">
|
size="sm"
|
||||||
Auto-Configure MCP WebSearch
|
onClick={() => {
|
||||||
</Label>
|
fetchConfig();
|
||||||
<p className="text-sm text-muted-foreground">
|
fetchRawConfig();
|
||||||
Automatically add web search MCP servers for third-party profiles
|
}}
|
||||||
</p>
|
disabled={loading || saving}
|
||||||
</div>
|
className="w-full"
|
||||||
<Select
|
|
||||||
value={config?.enabled ? 'enabled' : 'disabled'}
|
|
||||||
onValueChange={(value) => saveConfig({ enabled: value === 'enabled' })}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-32">
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
<SelectValue />
|
Refresh
|
||||||
</SelectTrigger>
|
</Button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{/* Provider Info */}
|
{/* Resize Handle */}
|
||||||
<div className="pt-4 border-t">
|
<PanelResizeHandle className="w-2 bg-border hover:bg-primary/20 transition-colors cursor-col-resize flex items-center justify-center group">
|
||||||
<h4 className="font-medium mb-3">Available Providers</h4>
|
<GripVertical className="w-3 h-3 text-muted-foreground group-hover:text-primary" />
|
||||||
<div className="grid gap-3">
|
</PanelResizeHandle>
|
||||||
<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" />
|
{/* 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>
|
<div>
|
||||||
<p className="font-medium">web-search-prime</p>
|
<h2 className="font-semibold">config.yaml</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">~/.ccs/config.yaml</p>
|
||||||
Requires z.ai coding plan subscription. Primary fallback option.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
<div className="flex gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-yellow-500 mt-2" />
|
<Button variant="outline" size="sm" onClick={copyToClipboard} disabled={!rawConfig}>
|
||||||
<div>
|
{copied ? (
|
||||||
<p className="font-medium">Brave Search</p>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">
|
<Check className="w-4 h-4 mr-1" />
|
||||||
Free tier: 15k queries/month. Set <code className="text-xs">BRAVE_API_KEY</code>{' '}
|
Copied
|
||||||
env var.
|
</>
|
||||||
</p>
|
) : (
|
||||||
</div>
|
<>
|
||||||
</div>
|
<Copy className="w-4 h-4 mr-1" />
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
Copy
|
||||||
<div className="w-2 h-2 rounded-full bg-blue-500 mt-2" />
|
</>
|
||||||
<div>
|
)}
|
||||||
<p className="font-medium">Tavily</p>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground">
|
<Button
|
||||||
AI-optimized search (paid). Set <code className="text-xs">TAVILY_API_KEY</code>{' '}
|
variant="outline"
|
||||||
env var.
|
size="sm"
|
||||||
</p>
|
onClick={fetchRawConfig}
|
||||||
</div>
|
disabled={rawConfigLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${rawConfigLoading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
{/* Config Content - scrollable */}
|
||||||
<Button variant="outline" onClick={fetchConfig} disabled={loading || saving}>
|
<div className="flex-1 overflow-auto">
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
{rawConfigLoading ? (
|
||||||
Refresh
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</Panel>
|
||||||
</Card>
|
</PanelGroup>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user