From 6c7d215ecc7d2a986e922400eb6787f1b402931d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 15:26:05 -0400 Subject: [PATCH] feat(websearch): add real provider chain --- README.md | 40 +- docs/project-overview-pdr.md | 11 +- docs/websearch.md | 247 ++-- lib/hooks/websearch-transformer.cjs | 1010 +++++++++-------- src/config/unified-config-loader.ts | 81 +- src/config/unified-config-types.ts | 88 +- src/utils/websearch-manager.ts | 9 +- src/utils/websearch/hook-env.ts | 24 +- src/utils/websearch/status.ts | 229 ++-- src/utils/websearch/types.ts | 69 +- src/web-server/health/websearch-checks.ts | 20 +- src/web-server/routes/websearch-routes.ts | 65 +- .../unit/hooks/websearch-transformer.test.ts | 54 + tests/unit/utils/websearch/status.test.ts | 127 +++ .../settings/hooks/use-websearch-config.ts | 61 +- .../settings/sections/websearch/index.tsx | 796 ++++++++++--- .../sections/websearch/provider-card.tsx | 352 +++--- ui/src/pages/settings/types.ts | 25 +- 18 files changed, 2105 insertions(+), 1203 deletions(-) create mode 100644 tests/unit/hooks/websearch-transformer.test.ts create mode 100644 tests/unit/utils/websearch/status.test.ts diff --git a/README.md b/README.md index 905f8e36..a2f11dbd 100644 --- a/README.md +++ b/README.md @@ -539,24 +539,26 @@ Without Developer Mode, CCS falls back to copying directories. ## WebSearch -Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically provides web search via CLI tools with automatic fallback. +Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS intercepts those requests and resolves them through real local search backends instead of depending on another model CLI to do the search. ### How It Works | Profile Type | WebSearch Method | |--------------|------------------| | Claude (native) | Anthropic WebSearch API | -| Third-party profiles | CLI Tool Fallback Chain | +| Third-party profiles | Local Search Backend Chain | -### CLI Tool Fallback Chain +### Local Search Backend Chain -CCS intercepts WebSearch requests and routes them through available CLI tools: +CCS intercepts WebSearch requests and routes them through deterministic search providers: -| Priority | Tool | Auth | Install | -|----------|------|------|---------| -| 1st | Gemini CLI | OAuth (free) | `npm install -g @google/gemini-cli` | -| 2nd | OpenCode | OAuth (free) | `curl -fsSL https://opencode.ai/install \| bash` | -| 3rd | Grok CLI | API Key | `npm install -g @vibe-kit/grok-cli` | +| Priority | Provider | Setup | Notes | +|----------|----------|-------|-------| +| 1st | Exa | `EXA_API_KEY` | API-backed search with extracted content | +| 2nd | Tavily | `TAVILY_API_KEY` | Agent-oriented search API | +| 3rd | Brave Search | `BRAVE_API_KEY` | Cleaner API-backed results | +| 4th | DuckDuckGo | None | Built-in default fallback | +| 5th | Gemini / OpenCode / Grok | Optional | Legacy compatibility fallback only | ### Configuration @@ -565,17 +567,21 @@ Configure via dashboard (**Settings** page) or `~/.ccs/config.yaml`: ```yaml websearch: enabled: true # Enable/disable (default: true) - gemini: - enabled: true # Use Gemini CLI (default: true) - model: gemini-2.5-flash # Model to use - opencode: - enabled: true # Use OpenCode as fallback - grok: - enabled: false # Requires XAI_API_KEY + providers: + exa: + enabled: false # Enable when EXA_API_KEY is set + tavily: + enabled: false # Enable when TAVILY_API_KEY is set + duckduckgo: + enabled: true # Built-in zero-setup fallback + brave: + enabled: false # Enable when BRAVE_API_KEY is set + gemini: + enabled: false # Optional legacy fallback ``` > [!TIP] -> **Gemini CLI** is recommended - free OAuth authentication with 1000 requests/day. Just run `gemini` once to authenticate via browser. +> **DuckDuckGo** still works out of the box. Add **Exa**, **Tavily**, or **Brave Search** if you want API-backed results, then keep Gemini/OpenCode/Grok only if you explicitly want legacy fallback behavior. See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting. diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 2849246f..9f698758 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -35,7 +35,7 @@ CCS provides: 3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families 4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API 5. **Visual Dashboard**: React SPA for configuration management -6. **Automatic WebSearch**: MCP fallback for third-party providers +6. **Automatic WebSearch**: Real backend fallback chain for third-party providers 7. **Usage Analytics**: Token tracking, cost analysis, model breakdown --- @@ -92,8 +92,9 @@ CCS provides: - Validate symlinks and permissions ### FR-007: WebSearch Fallback -- Auto-configure MCP web search for third-party profiles -- Support Gemini CLI, OpenCode, Grok providers +- Intercept WebSearch for third-party profiles that cannot reach Anthropic's native tool +- Support Exa, Tavily, Brave, and DuckDuckGo real search backends +- Keep Gemini CLI, OpenCode, and Grok as optional legacy fallback - Graceful fallback chain ### FR-008: Remote CLIProxy Support @@ -169,8 +170,8 @@ CCS provides: ### TR-002: Optional Dependencies - CLIProxyAPI binary (auto-managed) -- Gemini CLI for WebSearch -- Additional MCP servers +- Exa/Tavily/Brave API keys for higher-quality WebSearch +- Gemini CLI for legacy WebSearch fallback ### TR-003: Configuration - YAML-based config (`~/.ccs/config.yaml`) diff --git a/docs/websearch.md b/docs/websearch.md index c1b44841..6a6803c9 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -1,97 +1,71 @@ # WebSearch Configuration Guide -Last Updated: 2026-02-26 +Last Updated: 2026-03-23 -CCS provides automatic web search capability for all profiles, including third-party providers that cannot access Anthropic's native WebSearch API. +CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API. ## How WebSearch Works ### Native Claude Accounts -When using a native Claude subscription account, WebSearch is handled by Anthropic's server-side API ($10/1000 searches, usage-based billing). +Native Claude subscription accounts still use Anthropic's server-side WebSearch directly. ### Third-Party Profiles -Third-party profiles (OAuth and API-based) cannot use Anthropic's WebSearch because: -- Claude Code CLI executes tools locally -- CLIProxyAPI only receives conversation messages -- Tool execution never reaches the third-party backend - -CCS solves this with a hybrid fallback approach: - -1. **Gemini CLI Transformer** (Primary) - Uses positional Gemini prompt mode (with legacy `-p` fallback) and `google_web_search` tool -2. **MCP Fallback Chain** (Secondary) - MCP-based web search servers +Third-party profiles cannot execute Anthropic's server-side WebSearch because the tool never reaches their backend. CCS now solves that by intercepting WebSearch and running real local search providers directly. ## Architecture ``` ┌──────────────────────────────────────────────────────────────┐ -│ Claude Code CLI │ -│ │ -│ WebSearch Tool Request │ -│ │ │ -│ ├── Native Claude Account? → Anthropic WebSearch API │ -│ │ ($10/1000 searches) │ -│ │ │ -│ └── Third-party Profile? → PreToolUse Hook │ -│ │ │ -│ ├── 1. Gemini CLI │ -│ │ (google_web_search) │ -│ │ No API key needed! │ -│ │ │ -│ └── 2. MCP Fallback Chain │ -│ ├── web-search-prime │ -│ ├── Brave Search │ -│ └── Tavily │ +│ Claude Code CLI │ +│ │ +│ WebSearch Tool Request │ +│ │ │ +│ ├── Native Claude Account? → Anthropic WebSearch API │ +│ │ │ +│ └── Third-party Profile? → PreToolUse Hook │ +│ │ │ +│ ├── 1. Exa Search API │ +│ ├── 2. Tavily Search API │ +│ ├── 3. Brave Search API │ +│ ├── 4. DuckDuckGo HTML │ +│ └── 5. Legacy CLI fallback │ +│ (Gemini/OpenCode/Grok) │ └──────────────────────────────────────────────────────────────┘ ``` -## Gemini CLI Integration (Primary) +## Why This Changed -The **ultimate solution** for third-party WebSearch. Uses `gemini` CLI with OAuth authentication - **no API key needed!** +The previous design asked another model CLI to perform web search and summarize the answer. That was brittle: -### How It Works +- CLI syntax changed upstream +- auth state varied per tool +- prompt/tool behavior drifted across releases -1. A PreToolUse hook intercepts WebSearch tool calls -2. Executes `gemini ""` (positional mode) with explicit google_web_search instruction -3. Returns search results directly to Claude via the hook's deny reason -4. Claude receives full search results and continues the conversation +The new flow matches the `goclaw` model more closely: web search is treated as a first-class deterministic capability, not an LLM-to-LLM workaround. -### Requirements +## Providers -- `gemini` CLI installed and authenticated (run `gemini` to authenticate via browser) -- OAuth authentication (no GEMINI_API_KEY needed) - -### Installation - -The Gemini CLI is installed via npm: -```bash -npm install -g @google/gemini-cli -``` - -Then authenticate by running gemini once (opens browser): -```bash -gemini -``` - -## MCP Providers - -| Provider | Type | Cost | API Key Required | Notes | -|----------|------|------|------------------|-------| -| web-search-prime | HTTP MCP | z.ai subscription | No | Requires z.ai coding plan | -| Brave Search | stdio MCP | Free tier | `BRAVE_API_KEY` | 15k queries/month | -| Tavily | stdio MCP | Paid | `TAVILY_API_KEY` | AI-optimized search | +| Provider | Type | Setup | Default | Notes | +|----------|------|-------|---------|-------| +| Exa | HTTP API | `EXA_API_KEY` | No | High-quality API search with extracted content | +| Tavily | HTTP API | `TAVILY_API_KEY` | No | Agent-oriented search API | +| DuckDuckGo | HTML fetch | None | Yes | Built-in zero-setup fallback | +| Brave Search | HTTP API | `BRAVE_API_KEY` | No | Cleaner snippets and metadata | +| Gemini CLI | Legacy CLI | `npm i -g @google/gemini-cli` | No | Optional compatibility fallback | +| OpenCode | Legacy CLI | `curl -fsSL https://opencode.ai/install \| bash` | No | Optional compatibility fallback | +| Grok CLI | Legacy CLI | `npm i -g @vibe-kit/grok-cli` + `GROK_API_KEY` | No | Optional compatibility fallback | ## Configuration ### Via Dashboard -1. Open dashboard: `ccs config` -2. Navigate to **Settings** page -3. Configure WebSearch options: - - **Enable/Disable**: Toggle auto-configuration - - **Provider**: Choose preferred provider - - **Fallback**: Enable/disable fallback chain +Open `ccs config` → `Settings` → `WebSearch`. + +- Enable Exa, Tavily, Brave, or DuckDuckGo in the backend chain +- Export the matching API key first for Exa, Tavily, or Brave +- Review whether any legacy fallback CLIs are still enabled in config ### Via Config File @@ -99,111 +73,74 @@ Edit `~/.ccs/config.yaml`: ```yaml websearch: - enabled: true # Enable auto-config (default: true) - provider: auto # auto | web-search-prime | brave | tavily - fallback: true # Enable fallback chain (default: true) - webSearchPrimeUrl: "https://..." # Optional: custom endpoint - - # Gemini CLI configuration (new!) - gemini: - enabled: true # Use Gemini CLI for WebSearch (default: true) - timeout: 55 # Timeout in seconds (default: 55) + enabled: true + providers: + exa: + enabled: false + max_results: 5 + tavily: + enabled: false + max_results: 5 + duckduckgo: + enabled: true + max_results: 5 + brave: + enabled: false + max_results: 5 + gemini: + enabled: false + model: gemini-2.5-flash + timeout: 55 + opencode: + enabled: false + model: opencode/grok-code + timeout: 90 + grok: + enabled: false + timeout: 55 ``` -### Environment Variables +## Environment Variables -The WebSearch hook also respects these environment variables: - -| Variable | Description | Default | -|----------|-------------|---------| -| `CCS_WEBSEARCH_SKIP` | Skip WebSearch hook entirely | `0` | -| `CCS_GEMINI_SKIP` | Skip Gemini CLI, use MCP only | `0` | -| `CCS_GEMINI_TIMEOUT` | Gemini CLI timeout (seconds) | `55` | -| `CCS_DEBUG` | Enable debug output | `0` | - -### Provider Options - -- **auto** (default): Uses web-search-prime, adds Brave/Tavily if API keys available -- **web-search-prime**: Requires z.ai coding plan subscription -- **brave**: Requires `BRAVE_API_KEY` env var -- **tavily**: Requires `TAVILY_API_KEY` env var - -## Setting Up Optional Providers - -### Brave Search (Free Tier) - -1. Get API key: [brave.com/search/api](https://brave.com/search/api) -2. Set environment variable: - ```bash - export BRAVE_API_KEY="your-api-key" - ``` -3. Restart CCS - Brave will be added to fallback chain - -**Free tier limits**: 15,000 queries/month, 1 query/second - -### Tavily (AI-Optimized) - -1. Get API key: [tavily.com](https://tavily.com) -2. Set environment variable: - ```bash - export TAVILY_API_KEY="your-api-key" - ``` -3. Restart CCS - Tavily will be added to fallback chain - -## MCP Configuration - -CCS writes MCP configuration to `~/.claude/.mcp.json`. Example: - -```json -{ - "mcpServers": { - "web-search-prime": { - "type": "http", - "url": "https://api.z.ai/api/mcp/web_search_prime/mcp", - "headers": {} - }, - "brave-search": { - "type": "stdio", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-brave-search"], - "env": { "BRAVE_API_KEY": "..." } - } - } -} -``` +| Variable | Description | +|----------|-------------| +| `EXA_API_KEY` | Enables Exa when `providers.exa.enabled: true` | +| `TAVILY_API_KEY` | Enables Tavily when `providers.tavily.enabled: true` | +| `BRAVE_API_KEY` | Enables Brave Search when `providers.brave.enabled: true` | +| `GROK_API_KEY` | Required only for legacy Grok CLI fallback | +| `CCS_WEBSEARCH_SKIP` | Skip hook entirely | +| `CCS_DEBUG` | Verbose hook logging | ## Troubleshooting -### Gemini CLI Issues +### WebSearch says "Ready (DuckDuckGo)" -1. **Not installed**: Install with `npm install -g @google/gemini-cli` -2. **Not authenticated**: Run `gemini` to open browser for OAuth login -3. **Timeout**: Increase timeout in config or via `CCS_GEMINI_TIMEOUT=90` -4. **Skip Gemini**: Set `CCS_GEMINI_SKIP=1` to use MCP fallback only +That is expected. DuckDuckGo is the default zero-setup backend. -### WebSearch Not Working +### Exa, Tavily, or Brave is enabled but not ready -1. **Check config**: Ensure `websearch.enabled: true` in config -2. **Verify MCP**: Check `~/.claude/.mcp.json` exists -3. **Debug mode**: Run with `CCS_DEBUG=1 ccs gemini` for verbose output +Export the matching API key in the environment that launches CCS, then refresh status: -### MCP Server Errors +```bash +export EXA_API_KEY="your-api-key" +# or: export TAVILY_API_KEY="your-api-key" +# or: export BRAVE_API_KEY="your-api-key" +ccs config +``` -1. **Network issues**: web-search-prime requires internet access -2. **npx failures**: Brave/Tavily require Node.js and npx -3. **API key issues**: Verify env vars are set correctly +### I still want Gemini/OpenCode/Grok fallback -### Existing MCP Config +Those providers remain supported, but they are no longer the primary path. Enable them explicitly in `config.yaml` if you want them as last-resort fallback. -CCS respects existing web search MCP configuration. If you have manually configured web search MCPs, CCS will not overwrite them. +### WebSearch returns no results -To reset: -1. Remove web search entries from `~/.claude/.mcp.json` -2. Run any CCS third-party profile to regenerate +1. Check `websearch.enabled: true` +2. Keep DuckDuckGo enabled unless you have a strong reason to disable it +3. If using Exa, Tavily, or Brave, verify the matching API key +4. Run with `CCS_DEBUG=1` for hook logs ## Security Considerations -- API keys are stored in environment variables only +- API keys stay in environment variables, not in dashboard state - Never commit API keys to version control -- Use `.env` files with proper permissions (chmod 600) -- Dashboard settings are stored in `~/.ccs/config.yaml` (no API keys) +- Use shell profile or `.env` tooling with proper permissions diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 8d8a99ab..0e09d48f 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -1,51 +1,32 @@ #!/usr/bin/env node /** - * CCS WebSearch Hook - CLI Tool Executor with Fallback Chain + * CCS WebSearch Hook - deterministic search backends with legacy CLI fallback * - * Intercepts Claude's WebSearch tool and executes search via CLI tools. - * Respects provider enabled states from config.yaml. - * Supports automatic fallback: Gemini CLI → OpenCode → Grok CLI + * Primary providers: + * - Exa Search API + * - Tavily Search API + * - Brave Search API + * - DuckDuckGo HTML search * - * Environment Variables (set by CCS): - * CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (for official Claude) - * CCS_WEBSEARCH_ENABLED=1 - Enable WebSearch (default: 1) - * CCS_WEBSEARCH_TIMEOUT=55 - Timeout in seconds (default: 55) - * CCS_WEBSEARCH_GEMINI=1 - Enable Gemini CLI provider - * CCS_WEBSEARCH_GEMINI_MODEL - Gemini model (default: gemini-2.5-flash) - * CCS_WEBSEARCH_OPENCODE=1 - Enable OpenCode provider - * CCS_WEBSEARCH_GROK=1 - Enable Grok CLI provider - * CCS_WEBSEARCH_OPENCODE_MODEL - OpenCode model (default: opencode/grok-code) - * CCS_DEBUG=1 - Enable debug output - * - * Exit codes: - * 0 - Allow tool (pass-through to native WebSearch) - * 2 - Block tool (deny with results/message) - * - * @module hooks/websearch-transformer + * Legacy compatibility fallback: + * - Gemini CLI + * - OpenCode + * - Grok CLI */ const { spawnSync } = require('child_process'); -// ============================================================================ -// PLATFORM DETECTION -// ============================================================================ - -/** - * Windows platform flag - used for shell option in spawnSync calls. - * On Windows, globally installed CLI tools via npm/pnpm are .cmd/.bat files, - * which require shell: true to execute properly. - * @see https://github.com/kaitranntt/ccs/issues/378 - */ const isWindows = process.platform === 'win32'; +const DEFAULT_TIMEOUT_SEC = 55; +const DEFAULT_RESULT_COUNT = 5; +const MIN_VALID_RESPONSE_LENGTH = 20; +const EXA_URL = 'https://api.exa.ai/search'; +const TAVILY_URL = 'https://api.tavily.com/search'; +const DDG_URL = 'https://html.duckduckgo.com/html/'; +const BRAVE_URL = 'https://api.search.brave.com/res/v1/web/search'; +const USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; -// ============================================================================ -// CONFIGURATION - Edit these for prompt engineering -// ============================================================================ - -/** - * SHARED INSTRUCTIONS - Applied to ALL providers - * Edit here to change behavior across all CLI tools at once. - */ const SHARED_INSTRUCTIONS = `Instructions: 1. Search the web for current, up-to-date information 2. Provide a comprehensive summary of the search results @@ -55,119 +36,45 @@ const SHARED_INSTRUCTIONS = `Instructions: 6. If results conflict, note the discrepancy 7. Format output clearly with sections if the topic is complex`; -/** - * PROVIDER-SPECIFIC CONFIG - Only tool-use differences and quirks - * Each provider may have unique capabilities or invocation methods. - */ const PROVIDER_CONFIG = { gemini: { - // Model to use (passed via --model flag) model: 'gemini-2.5-flash', - // Alternative free models: gemini-2.0-flash, gemini-1.5-flash - - // Provider-specific: How to invoke web search (Gemini has google_web_search tool) toolInstruction: 'Use the google_web_search tool to find current information.', - - // Optional quirks (null if none) quirks: null, }, - opencode: { - // Model to use (can be overridden via CCS_WEBSEARCH_OPENCODE_MODEL env var) model: 'opencode/grok-code', - // Alternative models: opencode/gpt-4o, opencode/claude-3.5-sonnet, opencode/gpt-5-nano - - // Provider-specific: OpenCode has built-in web search via Zen toolInstruction: 'Search the web using your built-in capabilities.', - - // Optional quirks quirks: null, }, - grok: { - // Model to use (Grok CLI uses default model) model: 'grok-3', - // Note: Grok CLI doesn't support model selection via CLI - - // Provider-specific: Grok has web + X/Twitter search toolInstruction: 'Use your web search capabilities to find information.', - - // Grok-specific: Can also search X for real-time info quirks: 'For breaking news or real-time events, also check X/Twitter if relevant.', }, }; -/** - * Build the complete prompt for a provider - * Combines: query + tool instruction + shared instructions + quirks - */ -function buildPrompt(providerId, query) { - const config = PROVIDER_CONFIG[providerId]; - const parts = [ - `Search the web for: ${query}`, - '', - config.toolInstruction, - '', - SHARED_INSTRUCTIONS, - ]; +const ddgLinkRe = /]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g; +const ddgSnippetRe = /([\s\S]*?)<\/a>/g; +const htmlTagRe = /<[^>]+>/g; - if (config.quirks) { - parts.push('', `Note: ${config.quirks}`); +function debug(message) { + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] ${message}`); } - - return parts.join('\n'); } -// Minimum response length to consider valid -const MIN_VALID_RESPONSE_LENGTH = 20; - -// Default timeout in seconds -const DEFAULT_TIMEOUT_SEC = 55; - -// ============================================================================ -// HOOK LOGIC - Generally no need to edit below -// ============================================================================ - -/** - * Determine if hook should skip and pass through to native WebSearch. - * Returns true for native Claude accounts where WebSearch works server-side. - */ function shouldSkipHook() { - // Explicit skip signal (set by CCS for account profiles) if (process.env.CCS_WEBSEARCH_SKIP === '1') return true; - - // Account/default profiles - use native WebSearch const profileType = process.env.CCS_PROFILE_TYPE; if (profileType === 'account' || profileType === 'default') return true; - - // Explicit disable if (process.env.CCS_WEBSEARCH_ENABLED === '0') return true; - return false; } -// Read input from stdin -let input = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', (chunk) => { - input += chunk; -}); -process.stdin.on('end', () => { - processHook(); -}); - -// Handle stdin not being available -process.stdin.on('error', () => { - process.exit(0); -}); - -/** - * Check if a CLI tool is available - */ function isCliAvailable(cmd) { try { const whichCmd = isWindows ? 'where.exe' : 'which'; - const result = spawnSync(whichCmd, [cmd], { encoding: 'utf8', timeout: 2000, @@ -179,124 +86,330 @@ function isCliAvailable(cmd) { } } -/** - * Check if provider is enabled via environment variable - */ function isProviderEnabled(provider) { - const envVar = `CCS_WEBSEARCH_${provider.toUpperCase()}`; - const value = process.env[envVar]; - - // If env var not set, provider is disabled by default - // This ensures we respect config.yaml settings - return value === '1'; + return process.env[`CCS_WEBSEARCH_${provider.toUpperCase()}`] === '1'; } -/** - * Main hook processing logic with fallback chain - */ -async function processHook() { - try { - // Skip for native accounts (account, default profiles) or explicit disable - if (shouldSkipHook()) { - process.exit(0); +function hasEnvValue(name) { + return (process.env[name] || '').trim().length > 0; +} + +function getFirstEnvValue(names) { + for (const name of names) { + if (hasEnvValue(name)) { + return process.env[name].trim(); } + } + return ''; +} - const data = JSON.parse(input); - - // Only handle WebSearch tool - if (data.tool_name !== 'WebSearch') { - process.exit(0); - } - - const query = data.tool_input?.query || ''; - - if (!query) { - process.exit(0); - } - - const timeout = parseInt(process.env.CCS_WEBSEARCH_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); - - // Fallback chain: Gemini → OpenCode → Grok - // Only include providers that are BOTH installed AND enabled in config - const providers = [ - { name: 'Gemini CLI', cmd: 'gemini', id: 'gemini', fn: tryGeminiSearch }, - { name: 'OpenCode', cmd: 'opencode', id: 'opencode', fn: tryOpenCodeSearch }, - { name: 'Grok CLI', cmd: 'grok', id: 'grok', fn: tryGrokSearch }, - ]; - - // Filter to only enabled AND available providers - const enabledProviders = providers.filter((p) => { - const enabled = isProviderEnabled(p.id); - const available = isCliAvailable(p.cmd); - - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] ${p.name}: enabled=${enabled}, available=${available}`); - } - - return enabled && available; - }); - - const errors = []; - - if (process.env.CCS_DEBUG) { - const names = enabledProviders.map((p) => p.name).join(', ') || 'none'; - console.error(`[CCS Hook] Enabled providers: ${names}`); - } - - // Try each enabled provider in order - for (const provider of enabledProviders) { - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] Trying ${provider.name}...`); - } - - const result = provider.fn(query, timeout); - - if (result.success) { - outputSuccess(query, result.content, provider.name); - return; - } - - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] ${provider.name} failed: ${result.error}`); - } - - errors.push({ provider: provider.name, error: result.error }); - } - - // All providers failed or none enabled - if (enabledProviders.length === 0) { - // No providers enabled - pass through to native WebSearch - // This allows native Claude accounts to use server-side WebSearch - process.exit(0); - } else { - outputAllFailedMessage(query, errors); - } - } catch (err) { - if (process.env.CCS_DEBUG) { - console.error('[CCS Hook] Parse error:', err.message); - } - process.exit(0); +function getProviderApiKey(providerId) { + switch (providerId) { + case 'brave': + return getFirstEnvValue(['BRAVE_API_KEY', 'CCS_WEBSEARCH_BRAVE_API_KEY']); + case 'exa': + return getFirstEnvValue(['EXA_API_KEY', 'CCS_WEBSEARCH_EXA_API_KEY']); + case 'tavily': + return getFirstEnvValue(['TAVILY_API_KEY', 'CCS_WEBSEARCH_TAVILY_API_KEY']); + default: + return ''; } } -/** - * Execute search via Gemini CLI - */ -function shouldRetryGeminiWithLegacyPrompt(errorMessage) { - const lowerError = (errorMessage || '').toLowerCase(); +function getResultCount(provider) { + const raw = process.env[`CCS_WEBSEARCH_${provider.toUpperCase()}_MAX_RESULTS`]; + const parsed = Number.parseInt(raw || '', 10); + return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 10) : DEFAULT_RESULT_COUNT; +} +function buildPrompt(providerId, query) { + const config = PROVIDER_CONFIG[providerId]; + const parts = [ + `Search the web for: ${query}`, + '', + config.toolInstruction, + '', + SHARED_INSTRUCTIONS, + ]; + if (config.quirks) { + parts.push('', `Note: ${config.quirks}`); + } + return parts.join('\n'); +} + +function decodeHtml(value) { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +function compactText(value, maxLength = 280) { + const text = String(value || '') + .replace(/\s+/g, ' ') + .trim(); + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength - 3).trimEnd()}...`; +} + +function extractDuckDuckGoResults(html, count) { + const links = [...html.matchAll(ddgLinkRe)].slice(0, count + 5); + const snippets = [...html.matchAll(ddgSnippetRe)].slice(0, count + 5); + + return links.slice(0, count).map((match, index) => { + let url = match[1]; + if (url.includes('uddg=')) { + try { + const decoded = decodeURIComponent(url); + const uddgIndex = decoded.indexOf('uddg='); + if (uddgIndex !== -1) { + url = decoded.slice(uddgIndex + 5).split('&')[0]; + } + } catch { + // keep original url + } + } + + return { + title: decodeHtml(match[2].replace(htmlTagRe, '').trim()), + url, + description: decodeHtml((snippets[index]?.[1] || '').replace(htmlTagRe, '').trim()), + }; + }); +} + +function formatStructuredSearchResults(query, providerName, results) { + if (!results.length) { + return `No search results found for "${query}" via ${providerName}.`; + } + + const lines = [`Search results for "${query}" via ${providerName}:`, '']; + for (const [index, result] of results.entries()) { + lines.push(`${index + 1}. ${result.title}`); + lines.push(` ${result.url}`); + if (result.description) { + lines.push(` ${result.description}`); + } + lines.push(''); + } + lines.push('Use these results to answer the user directly.'); + return lines.join('\n'); +} + +async function fetchWithTimeout(url, options, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + const apiKey = getProviderApiKey('brave'); + if (!apiKey) { + return { success: false, error: 'BRAVE_API_KEY is not set' }; + } + + const params = new URLSearchParams({ + q: query, + count: String(getResultCount('brave')), + }); + + try { + const response = await fetchWithTimeout( + `${BRAVE_URL}?${params.toString()}`, + { + headers: { + Accept: 'application/json', + 'User-Agent': USER_AGENT, + 'X-Subscription-Token': apiKey, + }, + }, + timeoutSec * 1000 + ); + + if (!response.ok) { + const body = await response.text(); + return { + success: false, + error: `Brave Search returned ${response.status}: ${body.slice(0, 160)}`, + }; + } + + const body = await response.json(); + const results = (body.web?.results || []).map((result) => ({ + title: result.title || 'Untitled', + url: result.url || '', + description: result.description || '', + })); + + return { + success: true, + content: formatStructuredSearchResults(query, 'Brave Search', results), + }; + } catch (error) { + return { + success: false, + error: error.name === 'AbortError' ? 'Brave Search timed out' : error.message, + }; + } +} + +async function tryExaSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + const apiKey = getProviderApiKey('exa'); + if (!apiKey) { + return { success: false, error: 'EXA_API_KEY is not set' }; + } + + try { + const response = await fetchWithTimeout( + EXA_URL, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, + 'x-api-key': apiKey, + }, + body: JSON.stringify({ + query, + type: 'auto', + numResults: getResultCount('exa'), + text: true, + }), + }, + timeoutSec * 1000 + ); + + if (!response.ok) { + const body = await response.text(); + return { success: false, error: `Exa returned ${response.status}: ${body.slice(0, 160)}` }; + } + + const body = await response.json(); + const results = (body.results || []).map((result) => ({ + title: compactText(result.title || result.url || 'Untitled', 120), + url: result.url || '', + description: compactText(result.text || result.summary || '', 240), + })); + + return { + success: true, + content: formatStructuredSearchResults(query, 'Exa', results), + }; + } catch (error) { + return { + success: false, + error: error.name === 'AbortError' ? 'Exa timed out' : error.message, + }; + } +} + +async function tryTavilySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + const apiKey = getProviderApiKey('tavily'); + if (!apiKey) { + return { success: false, error: 'TAVILY_API_KEY is not set' }; + } + + try { + const response = await fetchWithTimeout( + TAVILY_URL, + { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, + }, + body: JSON.stringify({ + query, + search_depth: 'basic', + max_results: getResultCount('tavily'), + include_answer: false, + include_raw_content: false, + }), + }, + timeoutSec * 1000 + ); + + if (!response.ok) { + const body = await response.text(); + return { success: false, error: `Tavily returned ${response.status}: ${body.slice(0, 160)}` }; + } + + const body = await response.json(); + const results = (body.results || []).map((result) => ({ + title: compactText(result.title || result.url || 'Untitled', 120), + url: result.url || '', + description: compactText(result.content || '', 240), + })); + + return { + success: true, + content: formatStructuredSearchResults(query, 'Tavily', results), + }; + } catch (error) { + return { + success: false, + error: error.name === 'AbortError' ? 'Tavily timed out' : error.message, + }; + } +} + +async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + try { + const params = new URLSearchParams({ q: query }); + const response = await fetchWithTimeout( + `${DDG_URL}?${params.toString()}`, + { + headers: { + Accept: 'text/html', + 'User-Agent': USER_AGENT, + }, + }, + timeoutSec * 1000 + ); + + if (!response.ok) { + return { success: false, error: `DuckDuckGo returned ${response.status}` }; + } + + const html = await response.text(); + const results = extractDuckDuckGoResults(html, getResultCount('duckduckgo')); + return { + success: true, + content: formatStructuredSearchResults(query, 'DuckDuckGo', results), + }; + } catch (error) { + return { + success: false, + error: error.name === 'AbortError' ? 'DuckDuckGo timed out' : error.message, + }; + } +} + +function shouldRetryGeminiWithLegacyPrompt(errorMessage) { + const lower = (errorMessage || '').toLowerCase(); return ( - lowerError.includes('unknown option') || - lowerError.includes('unknown argument') || - lowerError.includes('unrecognized option') || - lowerError.includes('usage: gemini') || - lowerError.includes('use --prompt') || - lowerError.includes('using the --prompt option') + lower.includes('unknown option') || + lower.includes('unknown argument') || + lower.includes('unrecognized option') || + lower.includes('usage: gemini') || + lower.includes('use --prompt') || + lower.includes('using the --prompt option') ); } function runGeminiCommand(args, timeoutMs) { - const spawnResult = spawnSync('gemini', args, { + const result = spawnSync('gemini', args, { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 1024 * 1024 * 2, @@ -304,241 +417,130 @@ function runGeminiCommand(args, timeoutMs) { shell: isWindows, }); - if (spawnResult.error) { - if (spawnResult.error.code === 'ENOENT') { + if (result.error) { + if (result.error.code === 'ENOENT') return { success: false, error: 'Gemini CLI not installed' }; - } - throw spawnResult.error; + throw result.error; } - - if (spawnResult.status !== 0) { - const stderr = (spawnResult.stderr || '').trim(); + if (result.status !== 0) { return { success: false, - error: stderr || `Gemini CLI exited with code ${spawnResult.status}`, + error: (result.stderr || '').trim() || `Gemini CLI exited with code ${result.status}`, }; } - const result = (spawnResult.stdout || '').trim(); - - if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) { + const output = (result.stdout || '').trim(); + if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) { return { success: false, error: 'Empty or too short response from Gemini' }; } - - const lowerResult = result.toLowerCase(); - if ( - lowerResult.includes('error:') || - lowerResult.includes('failed to') || - lowerResult.includes('authentication required') - ) { - return { success: false, error: `Gemini returned error: ${result.substring(0, 100)}` }; - } - - return { success: true, content: result }; + return { success: true, content: output }; } function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { try { const timeoutMs = timeoutSec * 1000; - const config = PROVIDER_CONFIG.gemini; + const model = process.env.CCS_WEBSEARCH_GEMINI_MODEL || PROVIDER_CONFIG.gemini.model; const prompt = buildPrompt('gemini', query); - - // Allow model override via env var - const model = process.env.CCS_WEBSEARCH_GEMINI_MODEL || config.model; const baseArgs = ['--model', model, '--yolo']; - const positionalArgs = [...baseArgs, prompt]; - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] Executing: gemini --model ${model} --yolo "..."`); - } - - // Current Gemini CLI prefers positional prompts and deprecates -p/--prompt. - // Retry once with -p for legacy CLIs that still require it. - const positionalResult = runGeminiCommand(positionalArgs, timeoutMs); - if (positionalResult.success) { + debug(`Executing Gemini legacy fallback with model ${model}`); + const positionalResult = runGeminiCommand([...baseArgs, prompt], timeoutMs); + if (positionalResult.success || !shouldRetryGeminiWithLegacyPrompt(positionalResult.error)) { return positionalResult; } - if (!shouldRetryGeminiWithLegacyPrompt(positionalResult.error)) { - return positionalResult; - } - - if (process.env.CCS_DEBUG) { - console.error('[CCS Hook] Positional Gemini prompt failed; retrying with -p for legacy CLI'); - console.error(`[CCS Hook] Executing: gemini --model ${model} --yolo -p "..."`); - } - - const legacyPromptArgs = [...baseArgs, '-p', prompt]; - return runGeminiCommand(legacyPromptArgs, timeoutMs); - } catch (err) { - if (err.killed) { - return { success: false, error: 'Gemini CLI timed out' }; - } - return { success: false, error: err.message || 'Unknown Gemini error' }; + return runGeminiCommand([...baseArgs, '-p', prompt], timeoutMs); + } catch (error) { + return { + success: false, + error: error.killed ? 'Gemini CLI timed out' : error.message || 'Unknown Gemini error', + }; } } -/** - * Execute search via OpenCode CLI - */ function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { try { - const timeoutMs = timeoutSec * 1000; - const config = PROVIDER_CONFIG.opencode; - - // Allow model override via env var - const model = process.env.CCS_WEBSEARCH_OPENCODE_MODEL || config.model; - const prompt = buildPrompt('opencode', query); - - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] Executing: opencode run --model ${model} "..."`); - } - - const spawnResult = spawnSync('opencode', ['run', prompt, '--model', model], { - encoding: 'utf8', - timeout: timeoutMs, - maxBuffer: 1024 * 1024 * 2, - stdio: ['pipe', 'pipe', 'pipe'], - shell: isWindows, - }); - - if (spawnResult.error) { - if (spawnResult.error.code === 'ENOENT') { - return { success: false, error: 'OpenCode not installed' }; + const model = process.env.CCS_WEBSEARCH_OPENCODE_MODEL || PROVIDER_CONFIG.opencode.model; + const result = spawnSync( + 'opencode', + ['run', buildPrompt('opencode', query), '--model', model], + { + encoding: 'utf8', + timeout: timeoutSec * 1000, + maxBuffer: 1024 * 1024 * 2, + stdio: ['pipe', 'pipe', 'pipe'], + shell: isWindows, } - throw spawnResult.error; - } + ); - if (spawnResult.status !== 0) { - const stderr = (spawnResult.stderr || '').trim(); + if (result.error) { + if (result.error.code === 'ENOENT') + return { success: false, error: 'OpenCode not installed' }; + throw result.error; + } + if (result.status !== 0) { return { success: false, - error: stderr || `OpenCode exited with code ${spawnResult.status}`, + error: (result.stderr || '').trim() || `OpenCode exited with code ${result.status}`, }; } - const result = (spawnResult.stdout || '').trim(); - - if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) { + const output = (result.stdout || '').trim(); + if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) { return { success: false, error: 'Empty or too short response from OpenCode' }; } - - const lowerResult = result.toLowerCase(); - if ( - lowerResult.includes('error:') || - lowerResult.includes('failed to') || - lowerResult.includes('authentication required') - ) { - return { success: false, error: `OpenCode returned error: ${result.substring(0, 100)}` }; - } - - return { success: true, content: result }; - } catch (err) { - if (err.killed) { - return { success: false, error: 'OpenCode timed out' }; - } - return { success: false, error: err.message || 'Unknown OpenCode error' }; + return { success: true, content: output }; + } catch (error) { + return { + success: false, + error: error.killed ? 'OpenCode timed out' : error.message || 'Unknown OpenCode error', + }; } } -/** - * Execute search via Grok CLI - */ function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { try { - const timeoutMs = timeoutSec * 1000; - const prompt = buildPrompt('grok', query); - - if (process.env.CCS_DEBUG) { - console.error('[CCS Hook] Executing: grok "..."'); - } - - const spawnResult = spawnSync('grok', [prompt], { + const result = spawnSync('grok', [buildPrompt('grok', query)], { encoding: 'utf8', - timeout: timeoutMs, + timeout: timeoutSec * 1000, maxBuffer: 1024 * 1024 * 2, stdio: ['pipe', 'pipe', 'pipe'], shell: isWindows, }); - if (spawnResult.error) { - if (spawnResult.error.code === 'ENOENT') { + if (result.error) { + if (result.error.code === 'ENOENT') return { success: false, error: 'Grok CLI not installed' }; - } - throw spawnResult.error; + throw result.error; } - - if (spawnResult.status !== 0) { - const stderr = (spawnResult.stderr || '').trim(); + if (result.status !== 0) { return { success: false, - error: stderr || `Grok CLI exited with code ${spawnResult.status}`, + error: (result.stderr || '').trim() || `Grok CLI exited with code ${result.status}`, }; } - const result = (spawnResult.stdout || '').trim(); - - if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) { + const output = (result.stdout || '').trim(); + if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) { return { success: false, error: 'Empty or too short response from Grok' }; } - - const lowerResult = result.toLowerCase(); - if ( - lowerResult.includes('error:') || - lowerResult.includes('failed to') || - lowerResult.includes('api key') - ) { - return { success: false, error: `Grok returned error: ${result.substring(0, 100)}` }; - } - - return { success: true, content: result }; - } catch (err) { - if (err.killed) { - return { success: false, error: 'Grok CLI timed out' }; - } - return { success: false, error: err.message || 'Unknown Grok error' }; + return { success: true, content: output }; + } catch (error) { + return { + success: false, + error: error.killed ? 'Grok CLI timed out' : error.message || 'Unknown Grok error', + }; } } -/** - * Format search results for Claude - */ -function formatSearchResults(query, content, providerName) { - return [ - `[WebSearch Result via ${providerName}]`, - '', - `Query: "${query}"`, - '', - content, - '', - '---', - 'Use this information to answer the user.', - ].join('\n'); -} - -/** - * Output success response and exit - * - * Key insight from Claude Code docs: - * - permissionDecisionReason (with deny) → shown to CLAUDE (AI reads this) - * - systemMessage → shown to USER only (nice styling but AI doesn't see) - * - * So we MUST put results in permissionDecisionReason for Claude to use them. - * systemMessage provides the nice UI for the user. - */ function outputSuccess(query, content, providerName) { - const formattedResults = formatSearchResults(query, content, providerName); - const output = { decision: 'block', - reason: `WebSearch completed via ${providerName}`, - // Nice message for user (shows as "says:" - info style) - systemMessage: `[WebSearch via ${providerName}] Results retrieved successfully. See below.`, + reason: `WebSearch handled via ${providerName}`, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', - // Full results here - Claude reads this - permissionDecisionReason: formattedResults, + permissionDecisionReason: `[WebSearch Result via ${providerName}]\n\nQuery: "${query}"\n\n${content}`, }, }; @@ -546,113 +548,137 @@ function outputSuccess(query, content, providerName) { process.exit(2); } -/** - * Output error message - */ -function outputError(query, error, providerName) { - const message = [ - `[WebSearch - ${providerName} Error]`, - '', - `Error: ${error}`, - '', - `Query: "${query}"`, - '', - 'Troubleshooting:', - ' - Check ~/.gemini/oauth_creds.json exists', - ' - Re-authenticate: run `gemini` (opens browser)', - ].join('\n'); - - const output = { - decision: 'block', - reason: `WebSearch failed: ${error}`, - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: message, - }, - }; - - console.log(JSON.stringify(output)); - process.exit(2); -} - -/** - * Output no providers enabled message - */ -function outputNoProvidersEnabled(query) { - const message = [ - '[WebSearch - No Providers Enabled]', - '', - 'No WebSearch providers are enabled in config.', - '', - 'To enable: Run `ccs config` and enable a provider.', - '', - 'Or install one of the following CLI tools:', - '', - '1. Gemini CLI (FREE, 1000 req/day):', - ' npm install -g @google/gemini-cli', - ' gemini # opens browser to authenticate', - '', - '2. OpenCode (FREE via Zen):', - ' curl -fsSL https://opencode.ai/install | bash', - '', - '3. Grok CLI (requires XAI_API_KEY):', - ' npm install -g @vibe-kit/grok-cli', - '', - `Query: "${query}"`, - ].join('\n'); - - const output = { - decision: 'block', - reason: 'WebSearch unavailable - no providers enabled', - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: message, - }, - }; - - console.log(JSON.stringify(output)); - process.exit(2); -} - -/** - * Output no tools message (legacy - kept for backwards compatibility) - */ -function outputNoToolsMessage(query) { - outputNoProvidersEnabled(query); -} - -/** - * Output all providers failed message - */ function outputAllFailedMessage(query, errors) { - const errorDetails = errors.map((e) => ` - ${e.provider}: ${e.error}`).join('\n'); - - const message = [ - '[WebSearch - All Providers Failed]', - '', - 'Tried all enabled CLI tools but all failed:', - errorDetails, - '', - `Query: "${query}"`, - '', - 'Troubleshooting:', - ' - Gemini: run `gemini` to authenticate (opens browser)', - ' - OpenCode: opencode --version', - ' - Grok: Check XAI_API_KEY environment variable', - ].join('\n'); - + const detail = errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | '); const output = { decision: 'block', - reason: 'WebSearch failed - all providers failed', + reason: 'WebSearch fallback failed', hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', - permissionDecisionReason: message, + permissionDecisionReason: `WebSearch could not be completed for "${query}". ${detail}`, }, }; console.log(JSON.stringify(output)); process.exit(2); } + +async function processHook(input) { + try { + if (shouldSkipHook()) { + process.exit(0); + } + + const data = JSON.parse(input); + if (data.tool_name !== 'WebSearch') { + process.exit(0); + } + + const query = data.tool_input?.query || ''; + if (!query) { + process.exit(0); + } + + const timeout = Number.parseInt( + process.env.CCS_WEBSEARCH_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`, + 10 + ); + const providers = [ + { + name: 'Exa', + id: 'exa', + available: () => isProviderEnabled('exa') && Boolean(getProviderApiKey('exa')), + fn: tryExaSearch, + }, + { + name: 'Tavily', + id: 'tavily', + available: () => isProviderEnabled('tavily') && Boolean(getProviderApiKey('tavily')), + fn: tryTavilySearch, + }, + { + name: 'Brave Search', + id: 'brave', + available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')), + fn: tryBraveSearch, + }, + { + name: 'DuckDuckGo', + id: 'duckduckgo', + available: () => isProviderEnabled('duckduckgo'), + fn: tryDuckDuckGoSearch, + }, + { + name: 'Gemini CLI', + id: 'gemini', + available: () => isProviderEnabled('gemini') && isCliAvailable('gemini'), + fn: tryGeminiSearch, + }, + { + name: 'OpenCode', + id: 'opencode', + available: () => isProviderEnabled('opencode') && isCliAvailable('opencode'), + fn: tryOpenCodeSearch, + }, + { + name: 'Grok CLI', + id: 'grok', + available: () => isProviderEnabled('grok') && isCliAvailable('grok'), + fn: tryGrokSearch, + }, + ]; + + const activeProviders = providers.filter((provider) => provider.available()); + debug( + `Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}` + ); + + if (activeProviders.length === 0) { + process.exit(0); + } + + const errors = []; + for (const provider of activeProviders) { + debug(`Trying ${provider.name}`); + const result = await provider.fn(query, timeout); + if (result.success) { + outputSuccess(query, result.content, provider.name); + return; + } + errors.push({ provider: provider.name, error: result.error }); + } + + outputAllFailedMessage(query, errors); + } catch (error) { + debug(`Hook error: ${error.message}`); + process.exit(0); + } +} + +function startFromStdin() { + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + input += chunk; + }); + process.stdin.on('end', () => { + processHook(input); + }); + process.stdin.on('error', () => { + process.exit(0); + }); +} + +if (require.main === module) { + startFromStdin(); +} + +module.exports = { + extractDuckDuckGoResults, + formatStructuredSearchResults, + tryExaSearch, + tryTavilySearch, + tryDuckDuckGoSearch, + tryBraveSearch, +}; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 108f9f20..a95cb2c5 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -337,11 +337,27 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { websearch: { enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true, providers: { + exa: { + enabled: partial.websearch?.providers?.exa?.enabled ?? false, + max_results: partial.websearch?.providers?.exa?.max_results ?? 5, + }, + tavily: { + enabled: partial.websearch?.providers?.tavily?.enabled ?? false, + max_results: partial.websearch?.providers?.tavily?.max_results ?? 5, + }, + duckduckgo: { + enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true, + max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5, + }, + brave: { + enabled: partial.websearch?.providers?.brave?.enabled ?? false, + max_results: partial.websearch?.providers?.brave?.max_results ?? 5, + }, gemini: { enabled: partial.websearch?.providers?.gemini?.enabled ?? partial.websearch?.gemini?.enabled ?? // Legacy fallback - true, + false, model: partial.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash', timeout: partial.websearch?.providers?.gemini?.timeout ?? @@ -618,25 +634,25 @@ function generateYamlWithComments(config: UnifiedConfig): string { // WebSearch section if (config.websearch) { lines.push('# ----------------------------------------------------------------------------'); - lines.push('# WebSearch: CLI-based web search for third-party profiles'); + lines.push('# WebSearch: real search backends for third-party profiles'); lines.push('# Dashboard (`ccs config`) is the source of truth for provider selection.'); lines.push('#'); lines.push('# Third-party providers (gemini, codex, agy, etc.) do not have access to'); - lines.push("# Anthropic's WebSearch tool. These CLI tools provide fallback web search."); - lines.push('#'); - lines.push('# Fallback chain: Gemini -> OpenCode -> Grok (tries in order until success)'); + lines.push("# Anthropic's WebSearch tool. CCS intercepts that tool and runs local search."); lines.push('#'); lines.push( - '# Gemini models: gemini-2.5-flash (default), gemini-2.5-pro, gemini-2.5-flash-lite' - ); - lines.push( - '# OpenCode models: opencode/grok-code (default), opencode/gpt-4o, opencode/claude-3.5-sonnet' + '# Priority: Exa -> Tavily -> Brave -> DuckDuckGo -> optional legacy AI CLI fallbacks' ); lines.push('#'); - lines.push('# Install commands:'); - lines.push('# gemini: npm i -g @google/gemini-cli (FREE - 1000 req/day)'); - lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash (FREE via Zen)'); - lines.push('# grok: npm i -g @vibe-kit/grok-cli (requires GROK_API_KEY)'); + lines.push('# Exa requires EXA_API_KEY in your environment.'); + lines.push('# Tavily requires TAVILY_API_KEY in your environment.'); + lines.push('# Brave requires BRAVE_API_KEY in your environment.'); + lines.push('# DuckDuckGo works with zero extra setup and is enabled by default.'); + lines.push('#'); + lines.push('# Legacy LLM fallbacks remain optional if you still want them:'); + lines.push('# gemini: npm i -g @google/gemini-cli'); + lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash'); + lines.push('# grok: npm i -g @vibe-kit/grok-cli'); lines.push('# ----------------------------------------------------------------------------'); lines.push( yaml @@ -978,6 +994,10 @@ export interface GeminiWebSearchInfo { export function getWebSearchConfig(): { enabled: boolean; providers?: { + exa?: { enabled?: boolean; max_results?: number }; + tavily?: { enabled?: boolean; max_results?: number }; + duckduckgo?: { enabled?: boolean; max_results?: number }; + brave?: { enabled?: boolean; max_results?: number }; gemini?: GeminiWebSearchInfo; opencode?: { enabled?: boolean; model?: string; timeout?: number }; grok?: { enabled?: boolean; timeout?: number }; @@ -988,9 +1008,29 @@ export function getWebSearchConfig(): { const config = loadOrCreateUnifiedConfig(); // Build provider configs + const exaConfig = { + enabled: config.websearch?.providers?.exa?.enabled ?? false, + max_results: config.websearch?.providers?.exa?.max_results ?? 5, + }; + + const tavilyConfig = { + enabled: config.websearch?.providers?.tavily?.enabled ?? false, + max_results: config.websearch?.providers?.tavily?.max_results ?? 5, + }; + + const duckDuckGoConfig = { + enabled: config.websearch?.providers?.duckduckgo?.enabled ?? true, + max_results: config.websearch?.providers?.duckduckgo?.max_results ?? 5, + }; + + const braveConfig = { + enabled: config.websearch?.providers?.brave?.enabled ?? false, + max_results: config.websearch?.providers?.brave?.max_results ?? 5, + }; + const geminiConfig: GeminiWebSearchInfo = { enabled: - config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? true, + config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? false, model: config.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash', timeout: config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55, @@ -1008,12 +1048,23 @@ export function getWebSearchConfig(): { }; // Auto-enable master switch if ANY provider is enabled - const anyProviderEnabled = geminiConfig.enabled || opencodeConfig.enabled || grokConfig.enabled; + const anyProviderEnabled = + exaConfig.enabled || + tavilyConfig.enabled || + duckDuckGoConfig.enabled || + braveConfig.enabled || + geminiConfig.enabled || + opencodeConfig.enabled || + grokConfig.enabled; const enabled = anyProviderEnabled && (config.websearch?.enabled ?? true); return { enabled, providers: { + exa: exaConfig, + tavily: tavilyConfig, + duckduckgo: duckDuckGoConfig, + brave: braveConfig, gemini: geminiConfig, opencode: opencodeConfig, grok: grokConfig, diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 89ed7d9f..bf96e4a3 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -22,8 +22,10 @@ import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; * Version 6 = Customizable auth tokens (API key and management secret) * Version 7 = Quota management for hybrid auto+manual account control * Version 8 = Thinking/reasoning budget configuration + * Version 9 = Real WebSearch backends (DuckDuckGo/Brave) with legacy CLI fallback + * Version 10 = Exa + Tavily WebSearch backends */ -export const UNIFIED_CONFIG_VERSION = 8; +export const UNIFIED_CONFIG_VERSION = 10; /** * Supported CLIProxy providers. @@ -235,11 +237,51 @@ export interface PreferencesConfig { auto_update?: boolean; } +/** + * DuckDuckGo WebSearch configuration. + */ +export interface DuckDuckGoWebSearchConfig { + /** Enable DuckDuckGo HTML search fallback (default: true) */ + enabled?: boolean; + /** Number of results to fetch (default: 5) */ + max_results?: number; +} + +/** + * Brave WebSearch configuration. + */ +export interface BraveWebSearchConfig { + /** Enable Brave Search when BRAVE_API_KEY is available (default: false) */ + enabled?: boolean; + /** Number of results to fetch (default: 5) */ + max_results?: number; +} + +/** + * Exa WebSearch configuration. + */ +export interface ExaWebSearchConfig { + /** Enable Exa Search when EXA_API_KEY is available (default: false) */ + enabled?: boolean; + /** Number of results to fetch (default: 5) */ + max_results?: number; +} + +/** + * Tavily WebSearch configuration. + */ +export interface TavilyWebSearchConfig { + /** Enable Tavily Search when TAVILY_API_KEY is available (default: false) */ + enabled?: boolean; + /** Number of results to fetch (default: 5) */ + max_results?: number; +} + /** * Gemini CLI WebSearch configuration. */ export interface GeminiWebSearchConfig { - /** Enable Gemini CLI for WebSearch (default: true) */ + /** Enable Gemini CLI legacy fallback (default: false) */ enabled?: boolean; /** Model to use (default: gemini-2.5-flash) */ model?: string; @@ -251,7 +293,7 @@ export interface GeminiWebSearchConfig { * Grok CLI WebSearch configuration. */ export interface GrokWebSearchConfig { - /** Enable Grok CLI for WebSearch (default: false - requires GROK_API_KEY) */ + /** Enable Grok CLI legacy fallback (default: false - requires GROK_API_KEY) */ enabled?: boolean; /** Timeout in seconds (default: 55) */ timeout?: number; @@ -261,7 +303,7 @@ export interface GrokWebSearchConfig { * OpenCode CLI WebSearch configuration. */ export interface OpenCodeWebSearchConfig { - /** Enable OpenCode CLI for WebSearch (default: false) */ + /** Enable OpenCode CLI legacy fallback (default: false) */ enabled?: boolean; /** Model to use (default: opencode/grok-code) */ model?: string; @@ -271,14 +313,22 @@ export interface OpenCodeWebSearchConfig { /** * WebSearch providers configuration. - * Supports Gemini CLI, Grok CLI, and OpenCode. + * Uses deterministic search backends first, with optional legacy CLI fallback. */ export interface WebSearchProvidersConfig { - /** Gemini CLI - uses google_web_search tool (FREE tier: 1000 req/day) */ + /** Exa Search API - API-backed search with strong relevance and content extraction */ + exa?: ExaWebSearchConfig; + /** Tavily Search API - API-backed search optimized for agent/tool usage */ + tavily?: TavilyWebSearchConfig; + /** DuckDuckGo HTML search - zero setup default backend */ + duckduckgo?: DuckDuckGoWebSearchConfig; + /** Brave Search API - higher quality results when BRAVE_API_KEY is set */ + brave?: BraveWebSearchConfig; + /** Gemini CLI - optional legacy LLM fallback */ gemini?: GeminiWebSearchConfig; - /** Grok CLI - xAI web search (requires GROK_API_KEY) */ + /** Grok CLI - optional legacy LLM fallback */ grok?: GrokWebSearchConfig; - /** OpenCode - built-in web search (FREE via OpenCode Zen) */ + /** OpenCode - optional legacy LLM fallback */ opencode?: OpenCodeWebSearchConfig; } @@ -441,8 +491,8 @@ export const DEFAULT_GLOBAL_ENV: Record = { /** * WebSearch configuration. - * Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles. - * Third-party providers don't have server-side WebSearch access. + * Uses deterministic local backends for third-party profiles. + * Legacy AI CLI fallbacks remain available for compatibility only. */ export interface WebSearchConfig { /** Master switch - enable/disable WebSearch (default: true) */ @@ -825,8 +875,24 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { websearch: { enabled: true, providers: { - gemini: { + exa: { + enabled: false, + max_results: 5, + }, + tavily: { + enabled: false, + max_results: 5, + }, + duckduckgo: { enabled: true, + max_results: 5, + }, + brave: { + enabled: false, + max_results: 5, + }, + gemini: { + enabled: false, model: 'gemini-2.5-flash', timeout: 55, }, diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index 88a916b5..cb2a4569 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -3,12 +3,13 @@ * * WebSearch is a server-side tool executed by Anthropic's API. * Third-party providers (gemini, agy, codex, qwen) don't have access. - * This manager installs a hook that uses CLI tools (Gemini CLI) as fallback. + * This manager installs a hook that uses deterministic local search backends, + * with legacy AI CLI tools kept only as optional fallback. * * Simplified Architecture: - * - No MCP complexity - * - Uses CLI tools (currently Gemini CLI) - * - Easy to extend for future CLI tools (opencode, etc.) + * - No MCP dependency for the default path + * - Uses real search providers first (DuckDuckGo, Brave) + * - Keeps Gemini/OpenCode/Grok as compatibility fallback only * * @module utils/websearch-manager */ diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index ee3cf2b9..ac0e9e1b 100644 --- a/src/utils/websearch/hook-env.ts +++ b/src/utils/websearch/hook-env.ts @@ -29,7 +29,29 @@ export function getWebSearchHookEnv(): Record { env.CCS_WEBSEARCH_ENABLED = '1'; // Pass individual provider enabled states - // Hook will only use providers that are BOTH enabled AND installed + // Hook will only use providers that are BOTH enabled AND ready. + if (wsConfig.providers?.exa?.enabled) { + env.CCS_WEBSEARCH_EXA = '1'; + env.CCS_WEBSEARCH_EXA_MAX_RESULTS = String(wsConfig.providers.exa.max_results || 5); + } + + if (wsConfig.providers?.tavily?.enabled) { + env.CCS_WEBSEARCH_TAVILY = '1'; + env.CCS_WEBSEARCH_TAVILY_MAX_RESULTS = String(wsConfig.providers.tavily.max_results || 5); + } + + if (wsConfig.providers?.duckduckgo?.enabled) { + env.CCS_WEBSEARCH_DUCKDUCKGO = '1'; + env.CCS_WEBSEARCH_DUCKDUCKGO_MAX_RESULTS = String( + wsConfig.providers.duckduckgo.max_results || 5 + ); + } + + if (wsConfig.providers?.brave?.enabled) { + env.CCS_WEBSEARCH_BRAVE = '1'; + env.CCS_WEBSEARCH_BRAVE_MAX_RESULTS = String(wsConfig.providers.brave.max_results || 5); + } + if (wsConfig.providers?.gemini?.enabled) { env.CCS_WEBSEARCH_GEMINI = '1'; if (wsConfig.providers.gemini.model) { diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index 95ddea17..4594b4ad 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -8,70 +8,164 @@ import { ok, warn, fail, info } from '../ui'; import { getWebSearchConfig } from '../../config/unified-config-loader'; -import { getGeminiCliStatus, hasGeminiCli, isGeminiAuthenticated } from './gemini-cli'; -import { getGrokCliStatus, hasGrokCli } from './grok-cli'; -import { getOpenCodeCliStatus, hasOpenCodeCli } from './opencode-cli'; +import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli'; +import { getGrokCliStatus } from './grok-cli'; +import { getOpenCodeCliStatus } from './opencode-cli'; import type { WebSearchCliInfo, WebSearchStatus } from './types'; -/** - * Get all WebSearch CLI providers with their status - */ -export function getWebSearchCliProviders(): WebSearchCliInfo[] { +function hasEnvValue(name: string): boolean { + return (process.env[name] || '').trim().length > 0; +} + +function hasAnyEnvValue(names: string[]): boolean { + return names.some((name) => hasEnvValue(name)); +} + +function getLegacyProviderStatuses(): WebSearchCliInfo[] { + const wsConfig = getWebSearchConfig(); const geminiStatus = getGeminiCliStatus(); const grokStatus = getGrokCliStatus(); const opencodeStatus = getOpenCodeCliStatus(); + const geminiAuthed = geminiStatus.installed && isGeminiAuthenticated(); return [ { id: 'gemini', + kind: 'legacy-cli', name: 'Gemini CLI', command: 'gemini', - installed: geminiStatus.installed, + enabled: wsConfig.providers?.gemini?.enabled ?? false, + available: geminiAuthed, version: geminiStatus.version ?? null, installCommand: 'npm install -g @google/gemini-cli', docsUrl: 'https://github.com/google-gemini/gemini-cli', requiresApiKey: false, - description: 'Google Gemini with web search (FREE tier: 1000 req/day)', - freeTier: true, + description: 'Optional legacy LLM fallback with Google web search.', + detail: geminiStatus.installed + ? geminiAuthed + ? 'Authenticated' + : "Run 'gemini' to login" + : 'Not installed', }, { id: 'opencode', + kind: 'legacy-cli', name: 'OpenCode', command: 'opencode', - installed: opencodeStatus.installed, + enabled: wsConfig.providers?.opencode?.enabled ?? false, + available: opencodeStatus.installed, version: opencodeStatus.version ?? null, installCommand: 'curl -fsSL https://opencode.ai/install | bash', docsUrl: 'https://github.com/sst/opencode', requiresApiKey: false, - description: 'OpenCode with built-in web search (FREE via Zen)', - freeTier: true, + description: 'Optional legacy LLM fallback via OpenCode.', + detail: opencodeStatus.installed ? 'Installed' : 'Not installed', }, { id: 'grok', + kind: 'legacy-cli', name: 'Grok CLI', command: 'grok', - installed: grokStatus.installed, + enabled: wsConfig.providers?.grok?.enabled ?? false, + available: grokStatus.installed && hasEnvValue('GROK_API_KEY'), version: grokStatus.version ?? null, installCommand: 'npm install -g @vibe-kit/grok-cli', docsUrl: 'https://github.com/superagent-ai/grok-cli', requiresApiKey: true, apiKeyEnvVar: 'GROK_API_KEY', - description: 'xAI Grok CLI with AI coding agent capabilities', - freeTier: false, + description: 'Optional legacy LLM fallback with xAI Grok.', + detail: grokStatus.installed + ? hasEnvValue('GROK_API_KEY') + ? 'Ready' + : 'Set GROK_API_KEY' + : 'Not installed', }, ]; } /** - * Check if any WebSearch CLI is available + * Get all WebSearch providers with their current status. */ -export function hasAnyWebSearchCli(): boolean { - return hasGeminiCli() || hasGrokCli() || hasOpenCodeCli(); +export function getWebSearchCliProviders(): WebSearchCliInfo[] { + const wsConfig = getWebSearchConfig(); + const providers: WebSearchCliInfo[] = [ + { + id: 'exa', + kind: 'backend', + name: 'Exa', + enabled: wsConfig.providers?.exa?.enabled ?? false, + available: + (wsConfig.providers?.exa?.enabled ?? false) && + hasAnyEnvValue(['EXA_API_KEY', 'CCS_WEBSEARCH_EXA_API_KEY']), + version: null, + docsUrl: 'https://docs.exa.ai/reference/search', + requiresApiKey: true, + apiKeyEnvVar: 'EXA_API_KEY', + description: 'API-backed search with strong relevance and content extraction.', + detail: hasAnyEnvValue(['EXA_API_KEY', 'CCS_WEBSEARCH_EXA_API_KEY']) + ? `API key detected (${wsConfig.providers?.exa?.max_results ?? 5} results)` + : 'Set EXA_API_KEY', + }, + { + id: 'tavily', + kind: 'backend', + name: 'Tavily', + enabled: wsConfig.providers?.tavily?.enabled ?? false, + available: + (wsConfig.providers?.tavily?.enabled ?? false) && + hasAnyEnvValue(['TAVILY_API_KEY', 'CCS_WEBSEARCH_TAVILY_API_KEY']), + version: null, + docsUrl: 'https://docs.tavily.com/documentation/api-reference/endpoint/search', + requiresApiKey: true, + apiKeyEnvVar: 'TAVILY_API_KEY', + description: 'Search API optimized for agent workflows and concise web result synthesis.', + detail: hasAnyEnvValue(['TAVILY_API_KEY', 'CCS_WEBSEARCH_TAVILY_API_KEY']) + ? `API key detected (${wsConfig.providers?.tavily?.max_results ?? 5} results)` + : 'Set TAVILY_API_KEY', + }, + { + id: 'duckduckgo', + kind: 'backend', + name: 'DuckDuckGo', + enabled: wsConfig.providers?.duckduckgo?.enabled ?? true, + available: wsConfig.providers?.duckduckgo?.enabled ?? true, + version: null, + docsUrl: 'https://duckduckgo.com', + requiresApiKey: false, + description: 'Default built-in HTML search backend. Zero setup.', + detail: `Built-in (${wsConfig.providers?.duckduckgo?.max_results ?? 5} results)`, + }, + { + id: 'brave', + kind: 'backend', + name: 'Brave Search', + enabled: wsConfig.providers?.brave?.enabled ?? false, + available: + (wsConfig.providers?.brave?.enabled ?? false) && + hasAnyEnvValue(['BRAVE_API_KEY', 'CCS_WEBSEARCH_BRAVE_API_KEY']), + version: null, + docsUrl: 'https://brave.com/search/api/', + requiresApiKey: true, + apiKeyEnvVar: 'BRAVE_API_KEY', + description: 'API-backed web search with cleaner result metadata.', + detail: hasAnyEnvValue(['BRAVE_API_KEY', 'CCS_WEBSEARCH_BRAVE_API_KEY']) + ? `API key detected (${wsConfig.providers?.brave?.max_results ?? 5} results)` + : 'Set BRAVE_API_KEY', + }, + ]; + + return [...providers, ...getLegacyProviderStatuses()]; } /** - * Get install hints for CLI-only users when no WebSearch CLI is installed - * Returns raw message strings (without indicator prefix) for display + * Check if any WebSearch provider is currently ready. + */ +export function hasAnyWebSearchCli(): boolean { + return getWebSearchCliProviders().some((provider) => provider.enabled && provider.available); +} + +/** + * Get setup hints when no providers are ready. */ export function getCliInstallHints(): string[] { if (hasAnyWebSearchCli()) { @@ -79,95 +173,58 @@ export function getCliInstallHints(): string[] { } return [ - 'WebSearch: No CLI tools installed', - ' Gemini CLI (FREE): npm i -g @google/gemini-cli', - ' OpenCode (FREE): curl -fsSL https://opencode.ai/install | bash', - ' Grok CLI (paid): npm i -g @vibe-kit/grok-cli', + 'WebSearch: no ready providers', + ' Enable DuckDuckGo in Settings > WebSearch for zero-setup search', + ' Or export EXA_API_KEY, TAVILY_API_KEY, or BRAVE_API_KEY for API-backed search', + ' Optional legacy fallback: npm i -g @google/gemini-cli', ]; } /** - * Get WebSearch readiness status for display - * - * Called on third-party profile startup to inform user. - * Checks both installation AND authentication status for Gemini CLI. + * Get WebSearch readiness status for display. */ export function getWebSearchReadiness(): WebSearchStatus { const wsConfig = getWebSearchConfig(); + const providers = getWebSearchCliProviders(); - // Check if WebSearch is disabled entirely if (!wsConfig.enabled) { return { readiness: 'unavailable', - geminiCli: false, - geminiAuthenticated: false, - grokCli: false, - opencodeCli: false, message: 'Disabled in config', + providers, }; } - // Check all CLIs - const geminiInstalled = hasGeminiCli(); - const geminiAuthed = geminiInstalled && isGeminiAuthenticated(); - const grokInstalled = hasGrokCli(); - const opencodeInstalled = hasOpenCodeCli(); + const enabledProviders = providers.filter((provider) => provider.enabled); + const readyProviders = enabledProviders.filter((provider) => provider.available); - // Build message based on installed + authenticated CLIs - const readyClis: string[] = []; - const needsAuthClis: string[] = []; - - // Gemini requires auth check - if (geminiInstalled) { - if (geminiAuthed) { - readyClis.push('Gemini'); - } else { - needsAuthClis.push('Gemini'); - } - } - - // Other CLIs don't require auth check (for now) - if (grokInstalled) readyClis.push('Grok'); - if (opencodeInstalled) readyClis.push('OpenCode'); - - // Determine overall status - if (readyClis.length > 0) { + if (readyProviders.length > 0) { return { readiness: 'ready', - geminiCli: geminiInstalled, - geminiAuthenticated: geminiAuthed, - grokCli: grokInstalled, - opencodeCli: opencodeInstalled, - message: `Ready (${readyClis.join(' + ')})`, + message: `Ready (${readyProviders.map((provider) => provider.name).join(' + ')})`, + providers, }; } - if (needsAuthClis.length > 0) { + if (enabledProviders.length > 0) { return { - readiness: 'needs_auth', - geminiCli: geminiInstalled, - geminiAuthenticated: false, - grokCli: grokInstalled, - opencodeCli: opencodeInstalled, - message: `Gemini: run 'gemini' to login`, + readiness: 'needs_setup', + message: enabledProviders + .map((provider) => `${provider.name}: ${provider.detail}`) + .join(' | '), + providers, }; } return { readiness: 'unavailable', - geminiCli: false, - geminiAuthenticated: false, - grokCli: false, - opencodeCli: false, - message: 'Install: npm i -g @google/gemini-cli', + message: 'Enable at least one provider in Settings > WebSearch', + providers, }; } /** - * Display WebSearch status (single line, equilibrium UX) - * - * Only call for third-party profiles. - * Shows detailed install hints when no CLI is installed. + * Display WebSearch status (single line, equilibrium UX). */ export function displayWebSearchStatus(): void { const status = getWebSearchReadiness(); @@ -176,21 +233,13 @@ export function displayWebSearchStatus(): void { case 'ready': console.error(ok(`WebSearch: ${status.message}`)); break; - case 'needs_auth': + case 'needs_setup': console.error(warn(`WebSearch: ${status.message}`)); break; case 'unavailable': console.error(fail(`WebSearch: ${status.message}`)); - const hints = getCliInstallHints(); - if (hints.length > 0) { - // First line gets [i] prefix, rest are continuation (indented, no prefix) - for (let i = 0; i < hints.length; i++) { - if (i === 0) { - console.error(info(hints[i])); - } else { - console.error(hints[i]); - } - } + for (const [index, hint] of getCliInstallHints().entries()) { + console.error(index === 0 ? info(hint) : hint); } break; } diff --git a/src/utils/websearch/types.ts b/src/utils/websearch/types.ts index b8ff474c..8d737617 100644 --- a/src/utils/websearch/types.ts +++ b/src/utils/websearch/types.ts @@ -1,7 +1,7 @@ /** * WebSearch Type Definitions * - * Contains all type definitions for WebSearch CLI providers and status. + * Contains all type definitions for WebSearch providers and status. * * @module utils/websearch/types */ @@ -29,46 +29,64 @@ export type OpenCodeCliStatus = ComponentStatus; /** * WebSearch availability status for third-party profiles */ -export type WebSearchReadiness = 'ready' | 'needs_auth' | 'unavailable'; +export type WebSearchReadiness = 'ready' | 'needs_setup' | 'unavailable'; /** - * WebSearch status for display + * WebSearch provider identifier */ -export interface WebSearchStatus { - readiness: WebSearchReadiness; - geminiCli: boolean; - geminiAuthenticated: boolean; - grokCli: boolean; - opencodeCli: boolean; - message: string; -} +export type WebSearchProviderId = + | 'exa' + | 'tavily' + | 'duckduckgo' + | 'brave' + | 'gemini' + | 'grok' + | 'opencode'; /** - * WebSearch CLI provider information for health checks and UI + * Provider execution class. + */ +export type WebSearchProviderKind = 'backend' | 'legacy-cli'; + +/** + * WebSearch provider information for health checks and UI */ export interface WebSearchCliInfo { /** Provider ID */ - id: 'gemini' | 'grok' | 'opencode'; + id: WebSearchProviderId; + /** Backend vs legacy CLI */ + kind: WebSearchProviderKind; /** Display name */ name: string; - /** CLI command name */ - command: string; - /** Whether CLI is installed */ - installed: boolean; - /** CLI version if installed */ + /** Command name for legacy providers */ + command?: string; + /** Whether the provider is enabled in config */ + enabled: boolean; + /** Whether the provider is ready right now */ + available: boolean; + /** CLI version if applicable */ version: string | null; - /** Install command */ - installCommand: string; + /** Install or setup command when applicable */ + installCommand?: string; /** Docs URL */ - docsUrl: string; + docsUrl?: string; /** Whether this provider requires an API key */ requiresApiKey: boolean; /** API key environment variable name */ apiKeyEnvVar?: string; /** Brief description */ description: string; - /** Free tier available? */ - freeTier: boolean; + /** Summary detail shown in status UIs */ + detail: string; +} + +/** + * WebSearch status for display + */ +export interface WebSearchStatus { + readiness: WebSearchReadiness; + message: string; + providers: WebSearchCliInfo[]; } /** @@ -78,6 +96,7 @@ export interface WebSearchProviderConfig { enabled?: boolean; model?: string; timeout?: number; + max_results?: number; } /** @@ -86,6 +105,10 @@ export interface WebSearchProviderConfig { export interface WebSearchConfig { enabled: boolean; providers?: { + exa?: WebSearchProviderConfig; + tavily?: WebSearchProviderConfig; + duckduckgo?: WebSearchProviderConfig; + brave?: WebSearchProviderConfig; gemini?: WebSearchProviderConfig; opencode?: WebSearchProviderConfig; grok?: WebSearchProviderConfig; diff --git a/src/web-server/health/websearch-checks.ts b/src/web-server/health/websearch-checks.ts index 89a7c2cb..3a4faa7d 100644 --- a/src/web-server/health/websearch-checks.ts +++ b/src/web-server/health/websearch-checks.ts @@ -1,7 +1,7 @@ /** - * WebSearch CLI Health Checks + * WebSearch Health Checks * - * Check WebSearch CLI providers (Gemini CLI, Grok CLI). + * Check WebSearch providers (real backends + legacy fallback). */ import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../../utils/websearch-manager'; @@ -15,37 +15,35 @@ export function checkWebSearchClis(): HealthCheck[] { const checks: HealthCheck[] = []; for (const provider of providers) { - if (provider.installed) { - const freeTag = provider.freeTier ? ' (FREE)' : ''; + if (provider.enabled && provider.available) { checks.push({ id: `websearch-${provider.id}`, name: provider.name, status: 'ok', - message: `v${provider.version || 'unknown'}${freeTag}`, + message: provider.detail, details: provider.description, }); } else { - const keyNote = provider.requiresApiKey ? ` (needs ${provider.apiKeyEnvVar})` : ' (FREE)'; checks.push({ id: `websearch-${provider.id}`, name: provider.name, status: 'info', - message: `Not installed${keyNote}`, + message: provider.enabled ? provider.detail : 'Disabled', fix: provider.installCommand, details: provider.description, }); } } - // Add summary check if no providers installed + // Add summary check if no providers are ready if (!hasAnyWebSearchCli()) { checks.push({ id: 'websearch-summary', name: 'WebSearch Status', status: 'warning', - message: 'No CLI tools installed', - fix: 'npm install -g @google/gemini-cli (FREE)', - details: 'Install a WebSearch CLI for real-time web access', + message: 'No ready provider', + fix: 'Enable DuckDuckGo or set EXA_API_KEY, TAVILY_API_KEY, or BRAVE_API_KEY', + details: 'Third-party profiles need a local WebSearch backend.', }); } diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index ae8e9054..d53e2735 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -5,18 +5,13 @@ import { Router, Request, Response } from 'express'; import { mutateUnifiedConfig, getWebSearchConfig } from '../../config/unified-config-loader'; import type { WebSearchConfig } from '../../config/unified-config-types'; -import { - getWebSearchReadiness, - getGeminiCliStatus, - getGrokCliStatus, - getOpenCodeCliStatus, -} from '../../utils/websearch-manager'; +import { getWebSearchReadiness, getWebSearchCliProviders } from '../../utils/websearch-manager'; const router = Router(); /** * GET /api/websearch - Get WebSearch configuration - * Returns: WebSearchConfig with enabled, provider, fallback + * Returns: normalized WebSearch configuration */ router.get('/', (_req: Request, res: Response): void => { try { @@ -30,7 +25,6 @@ router.get('/', (_req: Request, res: Response): void => { /** * PUT /api/websearch - Update WebSearch configuration * Body: WebSearchConfig fields (enabled, providers) - * Dashboard is the source of truth for provider selection. */ router.put('/', (req: Request, res: Response): void => { const { enabled, providers } = req.body as Partial; @@ -53,9 +47,40 @@ router.put('/', (req: Request, res: Response): void => { enabled: enabled ?? config.websearch?.enabled ?? true, providers: providers ? { + exa: { + enabled: providers.exa?.enabled ?? config.websearch?.providers?.exa?.enabled ?? false, + max_results: + providers.exa?.max_results ?? config.websearch?.providers?.exa?.max_results ?? 5, + }, + tavily: { + enabled: + providers.tavily?.enabled ?? config.websearch?.providers?.tavily?.enabled ?? false, + max_results: + providers.tavily?.max_results ?? + config.websearch?.providers?.tavily?.max_results ?? + 5, + }, + duckduckgo: { + enabled: + providers.duckduckgo?.enabled ?? + config.websearch?.providers?.duckduckgo?.enabled ?? + true, + max_results: + providers.duckduckgo?.max_results ?? + config.websearch?.providers?.duckduckgo?.max_results ?? + 5, + }, + brave: { + enabled: + providers.brave?.enabled ?? config.websearch?.providers?.brave?.enabled ?? false, + max_results: + providers.brave?.max_results ?? + config.websearch?.providers?.brave?.max_results ?? + 5, + }, gemini: { enabled: - providers.gemini?.enabled ?? config.websearch?.providers?.gemini?.enabled ?? true, + providers.gemini?.enabled ?? config.websearch?.providers?.gemini?.enabled ?? false, model: providers.gemini?.model ?? config.websearch?.providers?.gemini?.model ?? @@ -99,31 +124,15 @@ router.put('/', (req: Request, res: Response): void => { /** * GET /api/websearch/status - Get WebSearch status - * Returns: { geminiCli, grokCli, opencodeCli, readiness } + * Returns: provider readiness + normalized provider status list */ router.get('/status', (_req: Request, res: Response): void => { try { - const geminiCli = getGeminiCliStatus(); - const grokCli = getGrokCliStatus(); - const opencodeCli = getOpenCodeCliStatus(); const readiness = getWebSearchReadiness(); + const providers = getWebSearchCliProviders(); res.json({ - geminiCli: { - installed: geminiCli.installed, - path: geminiCli.path, - version: geminiCli.version, - }, - grokCli: { - installed: grokCli.installed, - path: grokCli.path, - version: grokCli.version, - }, - opencodeCli: { - installed: opencodeCli.installed, - path: opencodeCli.path, - version: opencodeCli.version, - }, + providers, readiness: { status: readiness.readiness, message: readiness.message, diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts new file mode 100644 index 00000000..ee91863f --- /dev/null +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'bun:test'; + +const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { + extractDuckDuckGoResults: (html: string, count: number) => Array<{ + title: string; + url: string; + description: string; + }>; + formatStructuredSearchResults: ( + query: string, + providerName: string, + results: Array<{ title: string; url: string; description: string }> + ) => string; +}; + +describe('websearch-transformer hook helpers', () => { + it('extracts DuckDuckGo results and unwraps uddg redirect URLs', () => { + const html = ` + Example title + Example snippet + Second title + Second snippet + `; + + const results = hook.extractDuckDuckGoResults(html, 2); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + title: 'Example title', + url: 'https://example.com/article', + description: 'Example snippet', + }); + expect(results[1]).toEqual({ + title: 'Second title', + url: 'https://second.example.com/post', + description: 'Second snippet', + }); + }); + + it('formats structured search results for hook deny output', () => { + const formatted = hook.formatStructuredSearchResults('ccs websearch', 'DuckDuckGo', [ + { + title: 'Result title', + url: 'https://example.com', + description: 'Result snippet', + }, + ]); + + expect(formatted).toContain('Search results for "ccs websearch" via DuckDuckGo'); + expect(formatted).toContain('1. Result title'); + expect(formatted).toContain('https://example.com'); + expect(formatted).toContain('Result snippet'); + }); +}); diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts new file mode 100644 index 00000000..4c79f83b --- /dev/null +++ b/tests/unit/utils/websearch/status.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getWebSearchReadiness } from '../../../../src/utils/websearch/status'; + +function writeWebSearchConfig(tempRoot: string, lines: string[]): void { + fs.writeFileSync(path.join(tempRoot, '.ccs', 'config.yaml'), lines.join('\n'), 'utf8'); +} + +describe('websearch readiness', () => { + const originalCcsHome = process.env.CCS_HOME; + const originalBraveKey = process.env.BRAVE_API_KEY; + const originalExaKey = process.env.EXA_API_KEY; + const originalTavilyKey = process.env.TAVILY_API_KEY; + let tempRoot = ''; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-status-')); + process.env.CCS_HOME = tempRoot; + delete process.env.BRAVE_API_KEY; + delete process.env.EXA_API_KEY; + delete process.env.TAVILY_API_KEY; + fs.mkdirSync(path.join(tempRoot, '.ccs'), { recursive: true }); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalBraveKey !== undefined) process.env.BRAVE_API_KEY = originalBraveKey; + else delete process.env.BRAVE_API_KEY; + + if (originalExaKey !== undefined) process.env.EXA_API_KEY = originalExaKey; + else delete process.env.EXA_API_KEY; + + if (originalTavilyKey !== undefined) process.env.TAVILY_API_KEY = originalTavilyKey; + else delete process.env.TAVILY_API_KEY; + + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('is ready by default because DuckDuckGo is enabled', () => { + const readiness = getWebSearchReadiness(); + + expect(readiness.readiness).toBe('ready'); + expect(readiness.message).toContain('DuckDuckGo'); + }); + + it('reports setup required when only Tavily is enabled without an API key', () => { + writeWebSearchConfig(tempRoot, [ + 'version: 10', + 'websearch:', + ' enabled: true', + ' providers:', + ' exa:', + ' enabled: false', + ' max_results: 5', + ' tavily:', + ' enabled: true', + ' max_results: 5', + ' duckduckgo:', + ' enabled: false', + ' max_results: 5', + ' brave:', + ' enabled: false', + ' max_results: 5', + ' gemini:', + ' enabled: false', + ' model: "gemini-2.5-flash"', + ' timeout: 55', + ' opencode:', + ' enabled: false', + ' model: "opencode/grok-code"', + ' timeout: 90', + ' grok:', + ' enabled: false', + ' timeout: 55', + '', + ]); + + const readiness = getWebSearchReadiness(); + + expect(readiness.readiness).toBe('needs_setup'); + expect(readiness.message).toContain('Tavily'); + expect(readiness.message).toContain('TAVILY_API_KEY'); + }); + + it('prefers API-backed readiness when Exa is enabled and configured', () => { + process.env.EXA_API_KEY = 'exa-test-key'; + writeWebSearchConfig(tempRoot, [ + 'version: 10', + 'websearch:', + ' enabled: true', + ' providers:', + ' exa:', + ' enabled: true', + ' max_results: 5', + ' tavily:', + ' enabled: false', + ' max_results: 5', + ' duckduckgo:', + ' enabled: false', + ' max_results: 5', + ' brave:', + ' enabled: false', + ' max_results: 5', + ' gemini:', + ' enabled: false', + ' model: "gemini-2.5-flash"', + ' timeout: 55', + ' opencode:', + ' enabled: false', + ' model: "opencode/grok-code"', + ' timeout: 90', + ' grok:', + ' enabled: false', + ' timeout: 55', + '', + ]); + + const readiness = getWebSearchReadiness(); + + expect(readiness.readiness).toBe('ready'); + expect(readiness.message).toContain('Exa'); + }); +}); diff --git a/ui/src/pages/settings/hooks/use-websearch-config.ts b/ui/src/pages/settings/hooks/use-websearch-config.ts index 6fc5e05b..9d5a381c 100644 --- a/ui/src/pages/settings/hooks/use-websearch-config.ts +++ b/ui/src/pages/settings/hooks/use-websearch-config.ts @@ -2,17 +2,13 @@ * WebSearch Config Hook */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback } from 'react'; import { useSettingsContext, useSettingsActions } from './context-hooks'; import type { WebSearchConfig } from '../types'; export function useWebSearchConfig() { const { state } = useSettingsContext(); const actions = useSettingsActions(); - const [geminiModelInput, setGeminiModelInput] = useState(''); - const [opencodeModelInput, setOpencodeModelInput] = useState(''); - const [geminiModelSaved, setGeminiModelSaved] = useState(false); - const [opencodeModelSaved, setOpencodeModelSaved] = useState(false); const fetchConfig = useCallback(async () => { try { @@ -46,9 +42,13 @@ export function useWebSearchConfig() { const saveConfig = useCallback( async (updates: Partial) => { const config = state.webSearchConfig; - if (!config) return; + if (!config) return false; - const optimisticConfig = { ...config, ...updates }; + const optimisticConfig = { + ...config, + ...updates, + providers: { ...config.providers, ...updates.providers }, + }; actions.setWebSearchConfig(optimisticConfig); try { @@ -68,52 +68,21 @@ export function useWebSearchConfig() { const data = await res.json(); actions.setWebSearchConfig(data.websearch); + await fetchStatus(); actions.setWebSearchSuccess(true); setTimeout(() => actions.setWebSearchSuccess(false), 1500); + return true; } catch (err) { actions.setWebSearchConfig(config); actions.setWebSearchError((err as Error).message); + return false; } finally { actions.setWebSearchSaving(false); } }, - [state.webSearchConfig, actions] + [state.webSearchConfig, actions, fetchStatus] ); - // Sync model inputs with config - useEffect(() => { - if (state.webSearchConfig) { - setGeminiModelInput(state.webSearchConfig.providers?.gemini?.model ?? 'gemini-2.5-flash'); - setOpencodeModelInput( - state.webSearchConfig.providers?.opencode?.model ?? 'opencode/grok-code' - ); - } - }, [state.webSearchConfig]); - - const saveGeminiModel = useCallback(async () => { - const currentModel = state.webSearchConfig?.providers?.gemini?.model ?? 'gemini-2.5-flash'; - if (geminiModelInput !== currentModel) { - const providers = state.webSearchConfig?.providers || {}; - await saveConfig({ - providers: { ...providers, gemini: { ...providers.gemini, model: geminiModelInput } }, - }); - setGeminiModelSaved(true); - setTimeout(() => setGeminiModelSaved(false), 2000); - } - }, [geminiModelInput, state.webSearchConfig, saveConfig]); - - const saveOpencodeModel = useCallback(async () => { - const currentModel = state.webSearchConfig?.providers?.opencode?.model ?? 'opencode/grok-code'; - if (opencodeModelInput !== currentModel) { - const providers = state.webSearchConfig?.providers || {}; - await saveConfig({ - providers: { ...providers, opencode: { ...providers.opencode, model: opencodeModelInput } }, - }); - setOpencodeModelSaved(true); - setTimeout(() => setOpencodeModelSaved(false), 2000); - } - }, [opencodeModelInput, state.webSearchConfig, saveConfig]); - return { config: state.webSearchConfig, status: state.webSearchStatus, @@ -122,16 +91,8 @@ export function useWebSearchConfig() { saving: state.webSearchSaving, error: state.webSearchError, success: state.webSearchSuccess, - geminiModelInput, - setGeminiModelInput, - opencodeModelInput, - setOpencodeModelInput, - geminiModelSaved, - opencodeModelSaved, fetchConfig, fetchStatus, saveConfig, - saveGeminiModel, - saveOpencodeModel, }; } diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index 537b38fa..f8a92803 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -1,16 +1,329 @@ /** * WebSearch Section - * Settings section for WebSearch providers (Gemini, OpenCode, Grok) + * Settings section for real WebSearch backends and legacy fallbacks. */ -import { useState, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; +import { useEffect, useMemo, useState } from 'react'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react'; -import { useWebSearchConfig, useRawConfig } from '../../hooks'; -import { ProviderCard } from './provider-card'; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronUp, + Globe, + KeyRound, + RefreshCw, +} from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; +import { useRawConfig, useWebSearchConfig } from '../../hooks'; +import type { CliStatus, WebSearchProvidersConfig } from '../../types'; +import { ProviderCard, type ProviderFieldConfig } from './provider-card'; + +type ProviderId = 'exa' | 'tavily' | 'brave' | 'duckduckgo' | 'gemini' | 'opencode' | 'grok'; +type ProviderFieldKey = 'model' | 'timeout' | 'max_results'; + +interface ProviderFieldDefinition { + key: ProviderFieldKey; + label: string; + type: 'text' | 'number'; + placeholder: string; + helpText: string; + defaultValue: string | number; + min?: number; + max?: number; +} + +interface ProviderDefinition { + id: ProviderId; + title: string; + description: string; + badge: string; + badgeTone: 'green' | 'blue' | 'amber' | 'cyan' | 'slate'; + defaultEnabled: boolean; + fallbackDetail: string; + footerNote: string; + fields?: ProviderFieldDefinition[]; +} + +const CHAIN_STEPS = [ + { id: 'exa', title: 'Exa', defaultEnabled: false }, + { id: 'tavily', title: 'Tavily', defaultEnabled: false }, + { id: 'brave', title: 'Brave', defaultEnabled: false }, + { id: 'duckduckgo', title: 'DuckDuckGo', defaultEnabled: true }, + { id: 'legacy', title: 'Legacy CLI', defaultEnabled: false }, +] as const; + +const BACKEND_PROVIDERS: ProviderDefinition[] = [ + { + id: 'exa', + title: 'Exa', + description: + 'Highest-priority API backend when enabled. Best for strong relevance and extracted text.', + badge: 'EXA_API_KEY', + badgeTone: 'amber', + defaultEnabled: false, + fallbackDetail: 'Set EXA_API_KEY', + footerNote: 'Runs before every other provider in the chain when enabled and ready.', + fields: [ + { + key: 'max_results', + label: 'Max results', + type: 'number', + placeholder: '5', + helpText: 'Clamp between 1 and 10 results.', + defaultValue: 5, + min: 1, + max: 10, + }, + ], + }, + { + id: 'tavily', + title: 'Tavily', + description: 'Agent-oriented API backend with concise web result content.', + badge: 'TAVILY_API_KEY', + badgeTone: 'cyan', + defaultEnabled: false, + fallbackDetail: 'Set TAVILY_API_KEY', + footerNote: 'Runs after Exa and before Brave when enabled and ready.', + fields: [ + { + key: 'max_results', + label: 'Max results', + type: 'number', + placeholder: '5', + helpText: 'Clamp between 1 and 10 results.', + defaultValue: 5, + min: 1, + max: 10, + }, + ], + }, + { + id: 'brave', + title: 'Brave Search', + description: 'API-backed search with clean metadata and broad web coverage.', + badge: 'BRAVE_API_KEY', + badgeTone: 'blue', + defaultEnabled: false, + fallbackDetail: 'Set BRAVE_API_KEY', + footerNote: 'Runs after Exa and Tavily, before DuckDuckGo.', + fields: [ + { + key: 'max_results', + label: 'Max results', + type: 'number', + placeholder: '5', + helpText: 'Clamp between 1 and 10 results.', + defaultValue: 5, + min: 1, + max: 10, + }, + ], + }, + { + id: 'duckduckgo', + title: 'DuckDuckGo', + description: 'Zero-setup floor. Keep this on unless you want no built-in fallback at all.', + badge: 'DEFAULT', + badgeTone: 'green', + defaultEnabled: true, + fallbackDetail: 'Built-in', + footerNote: 'Last real backend in the chain. No API key needed.', + fields: [ + { + key: 'max_results', + label: 'Max results', + type: 'number', + placeholder: '5', + helpText: 'Clamp between 1 and 10 results.', + defaultValue: 5, + min: 1, + max: 10, + }, + ], + }, +]; + +const LEGACY_PROVIDERS: ProviderDefinition[] = [ + { + id: 'gemini', + title: 'Gemini CLI', + description: 'Optional legacy LLM fallback. Used only if every enabled real backend fails.', + badge: 'LEGACY', + badgeTone: 'slate', + defaultEnabled: false, + fallbackDetail: 'Optional fallback', + footerNote: 'Legacy path. Useful for compatibility, not the preferred search backend.', + fields: [ + { + key: 'model', + label: 'Model', + type: 'text', + placeholder: 'gemini-2.5-flash', + helpText: 'CLI model passed to Gemini when this fallback runs.', + defaultValue: 'gemini-2.5-flash', + }, + { + key: 'timeout', + label: 'Timeout (seconds)', + type: 'number', + placeholder: '55', + helpText: 'Clamp between 5 and 300 seconds.', + defaultValue: 55, + min: 5, + max: 300, + }, + ], + }, + { + id: 'opencode', + title: 'OpenCode', + description: 'Optional legacy LLM fallback via OpenCode.', + badge: 'LEGACY', + badgeTone: 'slate', + defaultEnabled: false, + fallbackDetail: 'Optional fallback', + footerNote: 'Legacy path. Runs after Gemini in the fallback portion of the chain.', + fields: [ + { + key: 'model', + label: 'Model', + type: 'text', + placeholder: 'opencode/grok-code', + helpText: 'OpenCode model passed to the CLI runner.', + defaultValue: 'opencode/grok-code', + }, + { + key: 'timeout', + label: 'Timeout (seconds)', + type: 'number', + placeholder: '90', + helpText: 'Clamp between 5 and 300 seconds.', + defaultValue: 90, + min: 5, + max: 300, + }, + ], + }, + { + id: 'grok', + title: 'Grok CLI', + description: 'Optional legacy xAI fallback. Requires the Grok CLI and GROK_API_KEY.', + badge: 'LEGACY', + badgeTone: 'slate', + defaultEnabled: false, + fallbackDetail: 'Optional fallback', + footerNote: 'Last step in the full fallback chain.', + fields: [ + { + key: 'timeout', + label: 'Timeout (seconds)', + type: 'number', + placeholder: '55', + helpText: 'Clamp between 5 and 300 seconds.', + defaultValue: 55, + min: 5, + max: 300, + }, + ], + }, +]; + +function getStatusTone( + provider: CliStatus | undefined, + enabled: boolean +): 'ready' | 'setup' | 'idle' { + if (enabled && provider?.available) { + return 'ready'; + } + if (enabled) { + return 'setup'; + } + return 'idle'; +} + +function getStatusLabel(provider: CliStatus | undefined, enabled: boolean): string { + if (enabled && provider?.available) { + return 'Ready'; + } + if (enabled) { + return 'Needs setup'; + } + return 'Disabled'; +} + +function getToneChipClass(tone: 'ready' | 'setup' | 'idle'): string { + switch (tone) { + case 'ready': + return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200'; + case 'setup': + return 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200'; + case 'idle': + return 'border-border/80 bg-background/85 text-muted-foreground'; + } +} + +function getToneIndicatorClass(tone: 'ready' | 'setup' | 'idle'): string { + switch (tone) { + case 'ready': + return 'bg-emerald-500'; + case 'setup': + return 'bg-amber-500'; + case 'idle': + return 'bg-border'; + } +} + +function getToneRailDotClass(tone: 'ready' | 'setup' | 'idle'): string { + switch (tone) { + case 'ready': + return 'bg-emerald-500 shadow-[0_0_0_6px_rgba(16,185,129,0.12)]'; + case 'setup': + return 'bg-amber-500 shadow-[0_0_0_6px_rgba(245,158,11,0.12)]'; + case 'idle': + return 'bg-border'; + } +} + +function getToneLineClass(tone: 'ready' | 'setup' | 'idle'): string { + switch (tone) { + case 'ready': + return 'bg-gradient-to-b from-emerald-500/55 to-border'; + case 'setup': + return 'bg-gradient-to-b from-amber-500/55 to-border'; + case 'idle': + return 'bg-border'; + } +} + +function normalizeFieldValue(field: ProviderFieldDefinition, rawValue: string): string | number { + if (field.type === 'number') { + const parsed = Number.parseInt(rawValue.trim(), 10); + if (!Number.isFinite(parsed)) { + return field.defaultValue; + } + + const min = field.min ?? parsed; + const max = field.max ?? parsed; + return Math.min(max, Math.max(min, parsed)); + } + + const trimmed = rawValue.trim(); + return trimmed || String(field.defaultValue); +} + +function getConfiguredValue( + config: WebSearchProvidersConfig | undefined, + providerId: ProviderId, + field: ProviderFieldDefinition +): string { + const configured = config?.[providerId]?.[field.key]; + return String(configured ?? field.defaultValue); +} export default function WebSearchSection() { const { t } = useTranslation(); @@ -22,69 +335,136 @@ export default function WebSearchSection() { saving, error, success, - geminiModelInput, - setGeminiModelInput, - opencodeModelInput, - setOpencodeModelInput, - geminiModelSaved, - opencodeModelSaved, fetchConfig, fetchStatus, saveConfig, - saveGeminiModel, - saveOpencodeModel, } = useWebSearchConfig(); - const { fetchRawConfig } = useRawConfig(); + const [fieldDrafts, setFieldDrafts] = useState>({}); + const [savedFieldId, setSavedFieldId] = useState(null); + const [legacyExpanded, setLegacyExpanded] = useState(false); - // Collapsible install hints state - const [showGeminiHint, setShowGeminiHint] = useState(false); - const [showOpencodeHint, setShowOpencodeHint] = useState(false); - const [showGrokHint, setShowGrokHint] = useState(false); - - // Load data on mount useEffect(() => { fetchConfig(); fetchStatus(); fetchRawConfig(); }, [fetchConfig, fetchStatus, fetchRawConfig]); - const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false; - const isGrokEnabled = config?.providers?.grok?.enabled ?? false; - const isOpenCodeEnabled = config?.providers?.opencode?.enabled ?? false; + useEffect(() => { + if (!config?.providers) { + return; + } - const toggleGemini = () => { - const providers = config?.providers || {}; - const currentState = providers.gemini?.enabled ?? false; - saveConfig({ - enabled: !currentState || isGrokEnabled || isOpenCodeEnabled, - providers: { ...providers, gemini: { ...providers.gemini, enabled: !currentState } }, + const nextDrafts: Record = {}; + for (const provider of [...BACKEND_PROVIDERS, ...LEGACY_PROVIDERS]) { + for (const field of provider.fields ?? []) { + nextDrafts[`${provider.id}.${field.key}`] = getConfiguredValue( + config.providers, + provider.id, + field + ); + } + } + setFieldDrafts(nextDrafts); + }, [config]); + + const providerStatus = useMemo( + () => new Map((status?.providers ?? []).map((provider) => [provider.id, provider])), + [status?.providers] + ); + const legacyEnabled = LEGACY_PROVIDERS.filter( + (provider) => config?.providers?.[provider.id]?.enabled ?? provider.defaultEnabled + ); + const legacyReady = legacyEnabled.some((provider) => providerStatus.get(provider.id)?.available); + + const legacySummary = + legacyEnabled.length === 0 + ? 'Off' + : legacyEnabled.length === 1 + ? `${legacyEnabled[0].title} enabled` + : `${legacyEnabled.length} enabled`; + + const toggleProvider = async (providerId: ProviderId, enabled: boolean) => { + const currentProviders = (config?.providers ?? {}) as WebSearchProvidersConfig; + await saveConfig({ + providers: { + ...currentProviders, + [providerId]: { + ...currentProviders[providerId], + enabled: !enabled, + }, + }, }); }; - const toggleGrok = () => { - const providers = config?.providers || {}; - const currentState = providers.grok?.enabled ?? false; - saveConfig({ - enabled: isGeminiEnabled || !currentState || isOpenCodeEnabled, - providers: { ...providers, grok: { ...providers.grok, enabled: !currentState } }, - }); + const updateDraft = (providerId: ProviderId, fieldKey: ProviderFieldKey, value: string) => { + setFieldDrafts((current) => ({ ...current, [`${providerId}.${fieldKey}`]: value })); }; - const toggleOpenCode = () => { - const providers = config?.providers || {}; - const currentState = providers.opencode?.enabled ?? false; - saveConfig({ - enabled: isGeminiEnabled || isGrokEnabled || !currentState, - providers: { ...providers, opencode: { ...providers.opencode, enabled: !currentState } }, + const commitField = async (providerId: ProviderId, field: ProviderFieldDefinition) => { + if (!config) { + return; + } + + const fieldId = `${providerId}.${field.key}`; + const currentProviders = (config.providers ?? {}) as WebSearchProvidersConfig; + const currentProviderConfig = currentProviders[providerId] ?? {}; + const currentValue = currentProviderConfig[field.key] ?? field.defaultValue; + const normalized = normalizeFieldValue(field, fieldDrafts[fieldId] ?? String(currentValue)); + + setFieldDrafts((current) => ({ ...current, [fieldId]: String(normalized) })); + + if (String(currentValue) === String(normalized)) { + return; + } + + const saved = await saveConfig({ + providers: { + ...currentProviders, + [providerId]: { + ...currentProviderConfig, + [field.key]: normalized, + }, + }, }); + + if (saved) { + setSavedFieldId(fieldId); + setTimeout(() => { + setSavedFieldId((current) => (current === fieldId ? null : current)); + }, 1200); + } }; + const buildFields = (provider: ProviderDefinition): ProviderFieldConfig[] => + (provider.fields ?? []).map((field) => { + const fieldId = `${provider.id}.${field.key}`; + + return { + id: fieldId, + label: field.label, + value: fieldDrafts[fieldId] ?? String(field.defaultValue), + placeholder: field.placeholder, + type: field.type, + helpText: field.helpText, + saved: savedFieldId === fieldId, + onChange: (value) => updateDraft(provider.id, field.key, value), + onBlur: () => { + void commitField(provider.id, field); + }, + onKeyDown: (event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }, + }; + }); + if (loading) { return ( -
+
- + {t('settings.loading')}
@@ -93,12 +473,11 @@ export default function WebSearchSection() { return ( <> - {/* Toast-style alerts */}
{error && ( @@ -108,133 +487,248 @@ export default function WebSearchSection() { )} {success && ( -
+
{t('settings.saved')}
)}
- {/* Scrollable Content */} -
-

{t('settingsWebsearch.description')}

- - {/* Status Summary */} -
-
-

- {isGeminiEnabled ? t('settingsWebsearch.enabled') : t('settingsWebsearch.disabled')} -

- {statusLoading ? ( -

{t('settingsWebsearch.checking')}

- ) : status?.readiness ? ( -

{status.readiness.message}

- ) : null} -
- -
- - {/* CLI Providers */} -
-

{t('settingsWebsearch.providers')}

- - {/* Empty state when no providers available */} - {!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && ( -
- -

- {t('settingsWebsearch.noneConfigured')} -

-

- {t('settingsWebsearch.noneConfiguredHint')} -

-
+
+ {CHAIN_STEPS.map((step, index) => { + const stepEnabled = + step.id === 'legacy' + ? legacyEnabled.length > 0 + : (config?.providers?.[step.id]?.enabled ?? step.defaultEnabled); + const tone = + step.id === 'legacy' + ? legacyReady + ? 'ready' + : legacyEnabled.length > 0 + ? 'setup' + : 'idle' + : getStatusTone(providerStatus.get(step.id), stepEnabled); + const label = + step.id === 'legacy' + ? legacySummary + : getStatusLabel(providerStatus.get(step.id), stepEnabled); + + return ( +
+
+ + + {step.title} · {label} + +
+ {index < CHAIN_STEPS.length - 1 && ( + + )} +
+ ); + })} +
+
+
+ +
+
+
+
+
+ +
+
+

Primary backends

+

+ Real backends run top-down before any legacy CLI fallback. +

+
+
+ +
+ {BACKEND_PROVIDERS.map((provider, index) => { + const currentStatus = providerStatus.get(provider.id); + const enabled = + config?.providers?.[provider.id]?.enabled ?? provider.defaultEnabled; + const tone = getStatusTone(currentStatus, enabled); + + return ( +
+
+
+ + {index < BACKEND_PROVIDERS.length - 1 && ( + + )} +
+
+ { + void toggleProvider(provider.id, enabled); + }} + fields={buildFields(provider)} + /> +
+ ); + })} +
+
+
+ +
+ + + {legacyExpanded && ( +
+
+
+ {LEGACY_PROVIDERS.map((provider, index) => { + const currentStatus = providerStatus.get(provider.id); + const enabled = + config?.providers?.[provider.id]?.enabled ?? provider.defaultEnabled; + const tone = getStatusTone(currentStatus, enabled); + + return ( +
+
+
+ + {index < LEGACY_PROVIDERS.length - 1 && ( + + )} +
+
+ { + void toggleProvider(provider.id, enabled); + }} + fields={buildFields(provider)} + docsUrl={currentStatus?.docsUrl} + installCommand={currentStatus?.installCommand} + footerNote={provider.footerNote} + /> +
+ ); + })} +
+
)} - - - - - -
- {/* Footer */} -
+
diff --git a/ui/src/pages/settings/sections/websearch/provider-card.tsx b/ui/src/pages/settings/sections/websearch/provider-card.tsx index c28977de..5e16d51e 100644 --- a/ui/src/pages/settings/sections/websearch/provider-card.tsx +++ b/ui/src/pages/settings/sections/websearch/provider-card.tsx @@ -1,176 +1,240 @@ -/** - * Provider Card Component - * Reusable card for CLI provider configuration in WebSearch section - */ - -import { Switch } from '@/components/ui/switch'; +import type { KeyboardEvent } from 'react'; +import { ExternalLink } from 'lucide-react'; import { Input } from '@/components/ui/input'; -import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; + +export interface ProviderFieldConfig { + id: string; + label: string; + value: string; + placeholder?: string; + type?: 'text' | 'number'; + helpText?: string; + saved?: boolean; + onBlur: () => void; + onChange: (value: string) => void; + onKeyDown?: (event: KeyboardEvent) => void; +} export interface ProviderCardProps { - name: string; - label: string; + title: string; + description: string; + detail: string; badge: string; - badgeColor: 'green' | 'blue' | 'purple'; + badgeTone: 'green' | 'blue' | 'amber' | 'cyan' | 'slate'; + statusLabel: string; + statusTone: 'ready' | 'setup' | 'idle'; enabled: boolean; - installed: boolean; - statusLoading: boolean; saving: boolean; onToggle: () => void; - modelInput?: string; - setModelInput?: (v: string) => void; - onModelBlur?: () => void; - modelSaved?: boolean; - modelPlaceholder?: string; - showHint: boolean; - setShowHint: (v: boolean) => void; - installCmd: string; - docsUrl: string; - hintColor: 'amber' | 'blue' | 'purple'; + fields?: ProviderFieldConfig[]; + docsUrl?: string; + installCommand?: string; + footerNote?: string; +} + +const PROVIDER_TONE_STYLES = { + green: { + accent: 'from-emerald-500 via-lime-400 to-transparent', + badge: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200', + glow: 'bg-emerald-500/18 dark:bg-emerald-500/22', + surface: + 'border-emerald-500/30 bg-gradient-to-br from-emerald-500/10 via-background to-background dark:from-emerald-500/14', + switchFrame: 'border-emerald-500/20 bg-emerald-500/10', + }, + blue: { + accent: 'from-sky-500 via-blue-500 to-transparent', + badge: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200', + glow: 'bg-sky-500/18 dark:bg-sky-500/22', + surface: + 'border-sky-500/30 bg-gradient-to-br from-sky-500/10 via-background to-background dark:from-sky-500/14', + switchFrame: 'border-sky-500/20 bg-sky-500/10', + }, + amber: { + accent: 'from-amber-500 via-orange-500 to-transparent', + badge: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200', + glow: 'bg-amber-500/18 dark:bg-amber-500/22', + surface: + 'border-amber-500/30 bg-gradient-to-br from-amber-500/10 via-background to-background dark:from-amber-500/14', + switchFrame: 'border-amber-500/20 bg-amber-500/10', + }, + cyan: { + accent: 'from-cyan-500 via-teal-400 to-transparent', + badge: 'border-cyan-500/25 bg-cyan-500/10 text-cyan-800 dark:text-cyan-200', + glow: 'bg-cyan-500/18 dark:bg-cyan-500/22', + surface: + 'border-cyan-500/30 bg-gradient-to-br from-cyan-500/10 via-background to-background dark:from-cyan-500/14', + switchFrame: 'border-cyan-500/20 bg-cyan-500/10', + }, + slate: { + accent: 'from-slate-500 via-slate-400 to-transparent', + badge: 'border-slate-500/20 bg-slate-500/10 text-slate-700 dark:text-slate-300', + glow: 'bg-slate-500/18 dark:bg-slate-500/22', + surface: + 'border-slate-400/30 bg-gradient-to-br from-slate-500/8 via-background to-background dark:from-slate-500/12', + switchFrame: 'border-slate-400/20 bg-slate-500/10', + }, +} as const; + +function getStatusToneStyles(tone: ProviderCardProps['statusTone']) { + switch (tone) { + case 'ready': + return { + chip: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200', + dot: 'bg-emerald-500', + }; + case 'setup': + return { + chip: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200', + dot: 'bg-amber-500', + }; + case 'idle': + return { + chip: 'border-border/80 bg-muted/60 text-muted-foreground', + dot: 'bg-muted-foreground/70', + }; + } } export function ProviderCard({ - name, - label, + title, + description, + detail, badge, - badgeColor, + badgeTone, + statusLabel, + statusTone, enabled, - installed, - statusLoading, saving, onToggle, - modelInput, - setModelInput, - onModelBlur, - modelSaved, - modelPlaceholder, - showHint, - setShowHint, - installCmd, + fields = [], docsUrl, - hintColor, + installCommand, + footerNote, }: ProviderCardProps) { - const { t } = useTranslation(); - const badgeClass = - badgeColor === 'green' - ? 'bg-green-500/10 text-green-600' - : badgeColor === 'blue' - ? 'bg-blue-500/10 text-blue-600' - : 'bg-purple-500/10 text-purple-600'; - - const hintBgClass = - hintColor === 'amber' - ? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300' - : hintColor === 'blue' - ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300' - : 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300'; - - const hintCodeClass = - hintColor === 'amber' - ? 'bg-amber-100 dark:bg-amber-900/40' - : hintColor === 'blue' - ? 'bg-blue-100 dark:bg-blue-900/40' - : 'bg-purple-100 dark:bg-purple-900/40'; - - const hintTextClass = - hintColor === 'amber' - ? 'text-amber-600 dark:text-amber-400' - : hintColor === 'blue' - ? 'text-blue-600 dark:text-blue-400' - : 'text-purple-600 dark:text-purple-400'; - - const displayName = - name === 'opencode' ? 'OpenCode' : name.charAt(0).toUpperCase() + name.slice(1); + const tone = PROVIDER_TONE_STYLES[badgeTone]; + const status = getStatusToneStyles(statusTone); return (
-
-
- -
-
-

{name}

- - {badge} - - {installed ? ( - - {t('settingsWebsearch.installed')} - - ) : ( - - {t('settingsWebsearch.notInstalled')} - +
+
+
+ +
+
+
+

{title}

+ -

{label}

+ > + {badge} +
+ + + {statusLabel} + +
+
+

{description}

+ {detail &&

{detail}

}
- +
+ +
- {/* Model input when enabled */} - {enabled && modelInput !== undefined && setModelInput && onModelBlur && ( -
-
- - setModelInput(e.target.value)} - onBlur={onModelBlur} - placeholder={modelPlaceholder} - className="h-8 text-sm font-mono" - disabled={saving} - /> - {modelSaved && ( - - - {t('settings.saved')} - - )} + {enabled && fields.length > 0 && ( +
+
+ {fields.map((field) => ( +
+
+ + {field.saved && ( + + Saved + + )} +
+ field.onChange(event.target.value)} + onKeyDown={field.onKeyDown} + className="h-8 border-border/70 bg-background/80 text-sm" + disabled={saving} + /> + {field.helpText && ( +

{field.helpText}

+ )} +
+ ))}
)} - {/* Installation hint when not installed */} - {!installed && !statusLoading && ( -
- - {showHint && ( -
-

- {t('settingsWebsearch.installGlobally')}{' '} - {badge === 'GROK_API_KEY' - ? t('settingsWebsearch.requiresKey') - : t('settingsWebsearch.freeTier')} - : -

- - {installCmd} - - - - {t('settingsWebsearch.viewDocs')} - -
+ {(footerNote || installCommand || docsUrl) && ( +
+ {footerNote &&

{footerNote}

} + {installCommand && ( + + {installCommand} + + )} + {docsUrl && ( + + + View docs + )}
)} diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index c6ea66b9..606d3c00 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -11,9 +11,14 @@ export interface ProviderConfig { enabled?: boolean; model?: string; timeout?: number; + max_results?: number; } export interface WebSearchProvidersConfig { + exa?: ProviderConfig; + tavily?: ProviderConfig; + duckduckgo?: ProviderConfig; + brave?: ProviderConfig; gemini?: ProviderConfig; grok?: ProviderConfig; opencode?: ProviderConfig; @@ -25,17 +30,25 @@ export interface WebSearchConfig { } export interface CliStatus { - installed: boolean; - path: string | null; + id: 'exa' | 'tavily' | 'duckduckgo' | 'brave' | 'gemini' | 'grok' | 'opencode'; + kind: 'backend' | 'legacy-cli'; + name?: string; + enabled: boolean; + available: boolean; + command?: string; version: string | null; + installCommand?: string; + docsUrl?: string; + requiresApiKey: boolean; + apiKeyEnvVar?: string; + description: string; + detail: string; } export interface WebSearchStatus { - geminiCli: CliStatus; - grokCli: CliStatus; - opencodeCli: CliStatus; + providers: CliStatus[]; readiness: { - status: 'ready' | 'unavailable'; + status: 'ready' | 'needs_setup' | 'unavailable'; message: string; }; }