mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(websearch): add real provider chain
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
+92
-155
@@ -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 "<prompt>"` (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
|
||||
|
||||
+518
-492
File diff suppressed because it is too large
Load Diff
@@ -337,11 +337,27 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): 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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -29,7 +29,29 @@ export function getWebSearchHookEnv(): Record<string, string> {
|
||||
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) {
|
||||
|
||||
+139
-90
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WebSearchConfig>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = `
|
||||
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
|
||||
<a class="result__snippet">Example snippet</a>
|
||||
<a class="result__a" href="https://second.example.com/post">Second title</a>
|
||||
<a class="result__snippet">Second snippet</a>
|
||||
`;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<WebSearchConfig>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<Record<string, string>>({});
|
||||
const [savedFieldId, setSavedFieldId] = useState<string | null>(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<string, string> = {};
|
||||
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 (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
<RefreshCw className="h-5 w-5 animate-spin" />
|
||||
<span>{t('settings.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,12 +473,11 @@ export default function WebSearchSection() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toast-style alerts */}
|
||||
<div
|
||||
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
|
||||
error || success
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-2 pointer-events-none'
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'pointer-events-none -translate-y-2 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{error && (
|
||||
@@ -108,133 +487,248 @@ export default function WebSearchSection() {
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||
<div className="flex items-center gap-2 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm font-medium">{t('settings.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-5 space-y-6">
|
||||
<p className="text-sm text-muted-foreground">{t('settingsWebsearch.description')}</p>
|
||||
|
||||
{/* Status Summary */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{isGeminiEnabled ? t('settingsWebsearch.enabled') : t('settingsWebsearch.disabled')}
|
||||
</p>
|
||||
{statusLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{t('settingsWebsearch.checking')}</p>
|
||||
) : status?.readiness ? (
|
||||
<p className="text-sm text-muted-foreground">{status.readiness.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={fetchStatus} disabled={statusLoading}>
|
||||
<RefreshCw className={`w-4 h-4 ${statusLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* CLI Providers */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-medium">{t('settingsWebsearch.providers')}</h3>
|
||||
|
||||
{/* Empty state when no providers available */}
|
||||
{!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && (
|
||||
<div className="flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg text-center bg-muted/30">
|
||||
<Package className="w-12 h-12 text-muted-foreground mb-3 opacity-30" />
|
||||
<p className="font-medium text-foreground mb-1">
|
||||
{t('settingsWebsearch.noneConfigured')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('settingsWebsearch.noneConfiguredHint')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={fetchStatus}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t('settingsWebsearch.checkProviders')}
|
||||
<div className="space-y-6 p-5">
|
||||
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-background via-background to-muted/30 p-4 shadow-sm">
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-primary/70 via-primary/20 to-transparent" />
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.035]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(0deg, transparent, transparent 2px, currentColor 2px, currentColor 3px)',
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="mb-3 flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-primary/20 bg-primary/8 text-primary">
|
||||
<Globe className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold tracking-tight">Execution chain</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{statusLoading ? t('settingsWebsearch.checking') : status?.readiness.message}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={fetchStatus} disabled={statusLoading}>
|
||||
<RefreshCw className={`h-4 w-4 ${statusLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{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 (
|
||||
<div key={step.id} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[11px] font-medium',
|
||||
getToneChipClass(tone)
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn('h-1.5 w-1.5 rounded-full', getToneIndicatorClass(tone))}
|
||||
/>
|
||||
<span>
|
||||
{step.title} · {label}
|
||||
</span>
|
||||
</div>
|
||||
{index < CHAIN_STEPS.length - 1 && (
|
||||
<span className="h-px w-4 rounded-full bg-border/80" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-background via-background to-muted/25 p-4 shadow-sm">
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-sky-500/70 via-emerald-500/25 to-transparent" />
|
||||
<div className="relative space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-300">
|
||||
<Globe className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold tracking-tight">Primary backends</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Real backends run top-down before any legacy CLI fallback.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="grid grid-cols-[20px_minmax(0,1fr)] items-start gap-3"
|
||||
>
|
||||
<div className="flex h-full justify-center">
|
||||
<div className="flex h-full flex-col items-center">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-5 h-3 w-3 rounded-full ring-4 ring-background',
|
||||
getToneRailDotClass(tone)
|
||||
)}
|
||||
/>
|
||||
{index < BACKEND_PROVIDERS.length - 1 && (
|
||||
<span className={cn('mt-1 w-px flex-1', getToneLineClass(tone))} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ProviderCard
|
||||
title={provider.title}
|
||||
description={provider.description}
|
||||
detail={currentStatus?.detail ?? provider.fallbackDetail}
|
||||
badge={provider.badge}
|
||||
badgeTone={provider.badgeTone}
|
||||
statusLabel={getStatusLabel(currentStatus, enabled)}
|
||||
statusTone={getStatusTone(currentStatus, enabled)}
|
||||
enabled={enabled}
|
||||
saving={saving}
|
||||
onToggle={() => {
|
||||
void toggleProvider(provider.id, enabled);
|
||||
}}
|
||||
fields={buildFields(provider)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLegacyExpanded((current) => !current)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-3 rounded-xl px-1 py-1 text-left transition-colors',
|
||||
'hover:bg-muted/10'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-400/20 bg-slate-500/10 text-slate-700 dark:text-slate-300">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold tracking-tight">Legacy CLI fallbacks</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Runs only after every enabled real backend fails.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-border/80 bg-background/85 px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{legacySummary}
|
||||
</span>
|
||||
{legacyExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{legacyExpanded && (
|
||||
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-background via-background to-muted/20 p-3 shadow-sm">
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-slate-500/60 via-slate-400/18 to-transparent" />
|
||||
<div className="relative space-y-3">
|
||||
{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 (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="grid grid-cols-[20px_minmax(0,1fr)] items-start gap-3"
|
||||
>
|
||||
<div className="flex h-full justify-center">
|
||||
<div className="flex h-full flex-col items-center">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-5 h-3 w-3 rounded-full ring-4 ring-background',
|
||||
getToneRailDotClass(tone)
|
||||
)}
|
||||
/>
|
||||
{index < LEGACY_PROVIDERS.length - 1 && (
|
||||
<span className={cn('mt-1 w-px flex-1', getToneLineClass(tone))} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ProviderCard
|
||||
title={provider.title}
|
||||
description={provider.description}
|
||||
detail={currentStatus?.detail ?? provider.fallbackDetail}
|
||||
badge={provider.badge}
|
||||
badgeTone={provider.badgeTone}
|
||||
statusLabel={getStatusLabel(currentStatus, enabled)}
|
||||
statusTone={getStatusTone(currentStatus, enabled)}
|
||||
enabled={enabled}
|
||||
saving={saving}
|
||||
onToggle={() => {
|
||||
void toggleProvider(provider.id, enabled);
|
||||
}}
|
||||
fields={buildFields(provider)}
|
||||
docsUrl={currentStatus?.docsUrl}
|
||||
installCommand={currentStatus?.installCommand}
|
||||
footerNote={provider.footerNote}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProviderCard
|
||||
name="gemini"
|
||||
label="Google Gemini CLI (1000 req/day free)"
|
||||
badge="FREE"
|
||||
badgeColor="green"
|
||||
enabled={isGeminiEnabled}
|
||||
installed={status?.geminiCli?.installed ?? false}
|
||||
statusLoading={statusLoading}
|
||||
saving={saving}
|
||||
onToggle={toggleGemini}
|
||||
modelInput={geminiModelInput}
|
||||
setModelInput={setGeminiModelInput}
|
||||
onModelBlur={saveGeminiModel}
|
||||
modelSaved={geminiModelSaved}
|
||||
modelPlaceholder="gemini-2.5-flash"
|
||||
showHint={showGeminiHint}
|
||||
setShowHint={setShowGeminiHint}
|
||||
installCmd="npm install -g @google/gemini-cli"
|
||||
docsUrl="https://github.com/google-gemini/gemini-cli"
|
||||
hintColor="amber"
|
||||
/>
|
||||
|
||||
<ProviderCard
|
||||
name="opencode"
|
||||
label="OpenCode (web search via Zen)"
|
||||
badge="FREE"
|
||||
badgeColor="green"
|
||||
enabled={isOpenCodeEnabled}
|
||||
installed={status?.opencodeCli?.installed ?? false}
|
||||
statusLoading={statusLoading}
|
||||
saving={saving}
|
||||
onToggle={toggleOpenCode}
|
||||
modelInput={opencodeModelInput}
|
||||
setModelInput={setOpencodeModelInput}
|
||||
onModelBlur={saveOpencodeModel}
|
||||
modelSaved={opencodeModelSaved}
|
||||
modelPlaceholder="opencode/grok-code"
|
||||
showHint={showOpencodeHint}
|
||||
setShowHint={setShowOpencodeHint}
|
||||
installCmd="curl -fsSL https://opencode.ai/install | bash"
|
||||
docsUrl="https://github.com/sst/opencode"
|
||||
hintColor="purple"
|
||||
/>
|
||||
|
||||
<ProviderCard
|
||||
name="grok"
|
||||
label="xAI Grok CLI (web + X search)"
|
||||
badge="GROK_API_KEY"
|
||||
badgeColor="blue"
|
||||
enabled={isGrokEnabled}
|
||||
installed={status?.grokCli?.installed ?? false}
|
||||
statusLoading={statusLoading}
|
||||
saving={saving}
|
||||
onToggle={toggleGrok}
|
||||
showHint={showGrokHint}
|
||||
setShowHint={setShowGrokHint}
|
||||
installCmd="npm install -g @vibe-kit/grok-cli"
|
||||
docsUrl="https://github.com/superagent-ai/grok-cli"
|
||||
hintColor="blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t bg-background">
|
||||
<div className="border-t bg-background p-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
fetchConfig();
|
||||
fetchStatus();
|
||||
fetchRawConfig();
|
||||
}}
|
||||
disabled={loading || saving}
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
{t('settings.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLInputElement>) => 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 (
|
||||
<div
|
||||
className={`rounded-lg border transition-colors ${
|
||||
enabled ? 'border-primary border-l-4' : 'border-border'
|
||||
}`}
|
||||
className={cn(
|
||||
'group relative overflow-hidden rounded-2xl border px-4 py-4 shadow-sm transition-all duration-200',
|
||||
enabled
|
||||
? tone.surface
|
||||
: 'border-border/70 bg-gradient-to-br from-background via-background to-muted/25',
|
||||
enabled && 'shadow-[0_18px_40px_-28px_rgba(15,23,42,0.55)]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className={`w-5 h-5 ${enabled ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-mono font-medium">{name}</p>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${badgeClass}`}>
|
||||
{badge}
|
||||
</span>
|
||||
{installed ? (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
||||
{t('settingsWebsearch.installed')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
|
||||
{t('settingsWebsearch.notInstalled')}
|
||||
</span>
|
||||
<div className={cn('absolute inset-x-0 top-0 h-px bg-gradient-to-r', tone.accent)} />
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute -right-6 -top-8 h-28 w-28 rounded-full blur-3xl',
|
||||
tone.glow
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.045]"
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)',
|
||||
backgroundSize: '22px 22px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<p className="text-sm font-semibold tracking-tight">{title}</p>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em]',
|
||||
tone.badge
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-medium',
|
||||
status.chip
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', status.dot)} />
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm leading-6 text-foreground/90">{description}</p>
|
||||
{detail && <p className="text-xs leading-5 text-muted-foreground">{detail}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={onToggle} disabled={saving || !installed} />
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border bg-background/75 backdrop-blur-sm',
|
||||
enabled ? tone.switchFrame : 'border-border/70'
|
||||
)}
|
||||
>
|
||||
<Switch checked={enabled} onCheckedChange={onToggle} disabled={saving} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model input when enabled */}
|
||||
{enabled && modelInput !== undefined && setModelInput && onModelBlur && (
|
||||
<div className="px-4 pb-4 pt-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{t('settingsWebsearch.model')}
|
||||
</label>
|
||||
<Input
|
||||
value={modelInput}
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onBlur={onModelBlur}
|
||||
placeholder={modelPlaceholder}
|
||||
className="h-8 text-sm font-mono"
|
||||
disabled={saving}
|
||||
/>
|
||||
{modelSaved && (
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-xs animate-in fade-in duration-200">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t('settings.saved')}
|
||||
</span>
|
||||
)}
|
||||
{enabled && fields.length > 0 && (
|
||||
<div className="relative mt-4 rounded-xl border border-border/65 bg-background/70 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] backdrop-blur-sm">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{fields.map((field) => (
|
||||
<div key={field.id} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label
|
||||
htmlFor={field.id}
|
||||
className="text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
{field.label}
|
||||
</Label>
|
||||
{field.saved && (
|
||||
<span className="text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id={field.id}
|
||||
value={field.value}
|
||||
type={field.type ?? 'text'}
|
||||
placeholder={field.placeholder}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(event) => field.onChange(event.target.value)}
|
||||
onKeyDown={field.onKeyDown}
|
||||
className="h-8 border-border/70 bg-background/80 text-sm"
|
||||
disabled={saving}
|
||||
/>
|
||||
{field.helpText && (
|
||||
<p className="text-[11px] text-muted-foreground">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installation hint when not installed */}
|
||||
{!installed && !statusLoading && (
|
||||
<div className="px-4 pb-4 pt-0 border-t border-border/50">
|
||||
<button
|
||||
onClick={() => setShowHint(!showHint)}
|
||||
className={`flex items-center gap-2 text-sm hover:underline w-full py-2 ${hintTextClass}`}
|
||||
>
|
||||
{showHint ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{t('settingsWebsearch.howInstall', { name: displayName })}
|
||||
</button>
|
||||
{showHint && (
|
||||
<div className={`mt-2 p-3 rounded-md text-sm ${hintBgClass}`}>
|
||||
<p className="mb-2">
|
||||
{t('settingsWebsearch.installGlobally')}{' '}
|
||||
{badge === 'GROK_API_KEY'
|
||||
? t('settingsWebsearch.requiresKey')
|
||||
: t('settingsWebsearch.freeTier')}
|
||||
:
|
||||
</p>
|
||||
<code className={`text-sm px-2 py-1 rounded font-mono block mb-2 ${hintCodeClass}`}>
|
||||
{installCmd}
|
||||
</code>
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{t('settingsWebsearch.viewDocs')}
|
||||
</a>
|
||||
</div>
|
||||
{(footerNote || installCommand || docsUrl) && (
|
||||
<div className="relative mt-4 space-y-2 rounded-xl border border-border/65 bg-muted/30 p-3">
|
||||
{footerNote && <p className="text-xs text-muted-foreground">{footerNote}</p>}
|
||||
{installCommand && (
|
||||
<code className="block rounded-lg border border-border/60 bg-background/85 px-2.5 py-2 text-[11px] text-muted-foreground">
|
||||
{installCommand}
|
||||
</code>
|
||||
)}
|
||||
{docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View docs
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user