mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 12:14:19 +00:00
Merge pull request #117 from kaitranntt/dev
feat(release): WebSearch CLI Fallback & Auth Monitor
This commit is contained in:
39 files changed
+5315
-342
No files matched your search
@@ -195,6 +195,45 @@ Without Developer Mode, CCS falls back to copying directories.
|
||||
|
||||
<br>
|
||||
|
||||
## WebSearch
|
||||
|
||||
Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically configures MCP-based web search as a fallback.
|
||||
|
||||
### How It Works
|
||||
|
||||
| Profile Type | WebSearch Method |
|
||||
|--------------|------------------|
|
||||
| Claude (native) | Anthropic WebSearch API |
|
||||
| OAuth providers | MCP web-search-prime (auto-configured) |
|
||||
| API profiles | MCP web-search-prime (auto-configured) |
|
||||
|
||||
### Configuration
|
||||
|
||||
Configure via dashboard (**Settings** page) or `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
websearch:
|
||||
enabled: true # Enable/disable auto-config
|
||||
provider: auto # auto | web-search-prime | brave | tavily
|
||||
fallback: true # Enable fallback chain
|
||||
```
|
||||
|
||||
### Optional API Keys
|
||||
|
||||
For additional search providers, set environment variables:
|
||||
|
||||
```bash
|
||||
export BRAVE_API_KEY="your-key" # Free tier: 15k queries/month
|
||||
export TAVILY_API_KEY="your-key" # AI-optimized search (paid)
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `web-search-prime` works without API keys. Brave/Tavily are optional fallbacks.
|
||||
|
||||
See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting.
|
||||
|
||||
<br>
|
||||
|
||||
## Documentation
|
||||
|
||||
| Topic | Link |
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# WebSearch Configuration Guide
|
||||
|
||||
CCS provides automatic web search capability for all profiles, including third-party providers 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).
|
||||
|
||||
### 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 `gemini -p` with `google_web_search` tool
|
||||
2. **MCP Fallback Chain** (Secondary) - MCP-based web search servers
|
||||
|
||||
## 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 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Gemini CLI Integration (Primary)
|
||||
|
||||
The **ultimate solution** for third-party WebSearch. Uses `gemini` CLI with OAuth authentication - **no API key needed!**
|
||||
|
||||
### How It Works
|
||||
|
||||
1. A PreToolUse hook intercepts WebSearch tool calls
|
||||
2. Executes `gemini -p` 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
|
||||
|
||||
### Requirements
|
||||
|
||||
- `gemini` CLI installed and authenticated (`gemini auth login`)
|
||||
- OAuth authentication (no GEMINI_API_KEY needed)
|
||||
|
||||
### Installation
|
||||
|
||||
The Gemini CLI is typically installed via:
|
||||
```bash
|
||||
pip install google-generativeai
|
||||
# or
|
||||
pipx install google-generativeai
|
||||
```
|
||||
|
||||
Then authenticate:
|
||||
```bash
|
||||
gemini auth login
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
||||
## 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
|
||||
|
||||
### Via Config File
|
||||
|
||||
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)
|
||||
```
|
||||
|
||||
### 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": "..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Gemini CLI Issues
|
||||
|
||||
1. **Not installed**: Install with `pip install google-generativeai`
|
||||
2. **Not authenticated**: Run `gemini auth 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
|
||||
|
||||
### WebSearch Not Working
|
||||
|
||||
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
|
||||
|
||||
### MCP Server Errors
|
||||
|
||||
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
|
||||
|
||||
### Existing MCP Config
|
||||
|
||||
CCS respects existing web search MCP configuration. If you have manually configured web search MCPs, CCS will not overwrite them.
|
||||
|
||||
To reset:
|
||||
1. Remove web search entries from `~/.claude/.mcp.json`
|
||||
2. Run any CCS third-party profile to regenerate
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- API keys are stored in environment variables only
|
||||
- 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)
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CCS WebSearch Blocking Hook
|
||||
*
|
||||
* Blocks Claude's native WebSearch tool and redirects to MCP alternative.
|
||||
* This is a PreToolUse hook that runs BEFORE the tool is executed.
|
||||
*
|
||||
* WebSearch is a server-side tool executed by Anthropic's API.
|
||||
* Third-party providers (gemini, agy, codex, qwen) don't have access.
|
||||
*
|
||||
* Usage:
|
||||
* Configured in ~/.claude/settings.json:
|
||||
* {
|
||||
* "hooks": {
|
||||
* "PreToolUse": [{
|
||||
* "matcher": "WebSearch",
|
||||
* "hooks": [{
|
||||
* "type": "command",
|
||||
* "command": "node ~/.ccs/hooks/block-websearch.cjs",
|
||||
* "timeout": 5
|
||||
* }]
|
||||
* }]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 - Allow tool (pass-through)
|
||||
* 2 - Block tool (deny with message)
|
||||
*
|
||||
* @module hooks/block-websearch
|
||||
*/
|
||||
|
||||
// Read input from stdin
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
input += chunk;
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(input);
|
||||
|
||||
// Only block WebSearch tool
|
||||
if (data.tool_name === 'WebSearch') {
|
||||
const query = data.tool_input?.query || '';
|
||||
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: 'WebSearch unavailable with current provider',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: `WebSearch is not available with third-party providers. Use mcp__web-search-prime__webSearchPrime tool instead with the same query: "${query}"`,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2); // Exit code 2 = block
|
||||
}
|
||||
|
||||
// Allow all other tools
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
// Don't block on parse errors - allow tool to proceed
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] Parse error:', err.message);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stdin not being available
|
||||
process.stdin.on('error', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,600 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CCS WebSearch Hook - CLI Tool Executor with Fallback Chain
|
||||
*
|
||||
* Intercepts Claude's WebSearch tool and executes search via CLI tools.
|
||||
* Respects provider enabled states from config.yaml.
|
||||
* Supports automatic fallback: Gemini CLI → OpenCode → Grok CLI
|
||||
*
|
||||
* Environment Variables (set by CCS):
|
||||
* CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (for official Claude)
|
||||
* CCS_WEBSEARCH_ENABLED=1 - Enable WebSearch (default: 1)
|
||||
* CCS_WEBSEARCH_TIMEOUT=55 - Timeout in seconds (default: 55)
|
||||
* CCS_WEBSEARCH_GEMINI=1 - Enable Gemini CLI provider
|
||||
* CCS_WEBSEARCH_GEMINI_MODEL - Gemini model (default: gemini-2.5-flash)
|
||||
* CCS_WEBSEARCH_OPENCODE=1 - Enable OpenCode provider
|
||||
* CCS_WEBSEARCH_GROK=1 - Enable Grok CLI provider
|
||||
* CCS_WEBSEARCH_OPENCODE_MODEL - OpenCode model (default: opencode/grok-code)
|
||||
* CCS_DEBUG=1 - Enable debug output
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 - Allow tool (pass-through to native WebSearch)
|
||||
* 2 - Block tool (deny with results/message)
|
||||
*
|
||||
* @module hooks/websearch-transformer
|
||||
*/
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION - Edit these for prompt engineering
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SHARED INSTRUCTIONS - Applied to ALL providers
|
||||
* Edit here to change behavior across all CLI tools at once.
|
||||
*/
|
||||
const SHARED_INSTRUCTIONS = `Instructions:
|
||||
1. Search the web for current, up-to-date information
|
||||
2. Provide a comprehensive summary of the search results
|
||||
3. Include relevant URLs/sources when available
|
||||
4. Be concise but thorough - prioritize key facts
|
||||
5. Focus on factual information from reliable sources
|
||||
6. If results conflict, note the discrepancy
|
||||
7. Format output clearly with sections if the topic is complex`;
|
||||
|
||||
/**
|
||||
* PROVIDER-SPECIFIC CONFIG - Only tool-use differences and quirks
|
||||
* Each provider may have unique capabilities or invocation methods.
|
||||
*/
|
||||
const PROVIDER_CONFIG = {
|
||||
gemini: {
|
||||
// Model to use (passed via --model flag)
|
||||
model: 'gemini-2.5-flash',
|
||||
// Alternative free models: gemini-2.0-flash, gemini-1.5-flash
|
||||
|
||||
// Provider-specific: How to invoke web search (Gemini has google_web_search tool)
|
||||
toolInstruction: 'Use the google_web_search tool to find current information.',
|
||||
|
||||
// Optional quirks (null if none)
|
||||
quirks: null,
|
||||
},
|
||||
|
||||
opencode: {
|
||||
// Model to use (can be overridden via CCS_WEBSEARCH_OPENCODE_MODEL env var)
|
||||
model: 'opencode/grok-code',
|
||||
// Alternative models: opencode/gpt-4o, opencode/claude-3.5-sonnet, opencode/gpt-5-nano
|
||||
|
||||
// Provider-specific: OpenCode has built-in web search via Zen
|
||||
toolInstruction: 'Search the web using your built-in capabilities.',
|
||||
|
||||
// Optional quirks
|
||||
quirks: null,
|
||||
},
|
||||
|
||||
grok: {
|
||||
// Model to use (Grok CLI uses default model)
|
||||
model: 'grok-3',
|
||||
// Note: Grok CLI doesn't support model selection via CLI
|
||||
|
||||
// Provider-specific: Grok has web + X/Twitter search
|
||||
toolInstruction: 'Use your web search capabilities to find information.',
|
||||
|
||||
// Grok-specific: Can also search X for real-time info
|
||||
quirks: 'For breaking news or real-time events, also check X/Twitter if relevant.',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the complete prompt for a provider
|
||||
* Combines: query + tool instruction + shared instructions + quirks
|
||||
*/
|
||||
function buildPrompt(providerId, query) {
|
||||
const config = PROVIDER_CONFIG[providerId];
|
||||
const parts = [
|
||||
`Search the web for: ${query}`,
|
||||
'',
|
||||
config.toolInstruction,
|
||||
'',
|
||||
SHARED_INSTRUCTIONS,
|
||||
];
|
||||
|
||||
if (config.quirks) {
|
||||
parts.push('', `Note: ${config.quirks}`);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// Minimum response length to consider valid
|
||||
const MIN_VALID_RESPONSE_LENGTH = 20;
|
||||
|
||||
// Default timeout in seconds
|
||||
const DEFAULT_TIMEOUT_SEC = 55;
|
||||
|
||||
// ============================================================================
|
||||
// HOOK LOGIC - Generally no need to edit below
|
||||
// ============================================================================
|
||||
|
||||
// Read input from stdin
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
input += chunk;
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
processHook();
|
||||
});
|
||||
|
||||
// Handle stdin not being available
|
||||
process.stdin.on('error', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if a CLI tool is available
|
||||
*/
|
||||
function isCliAvailable(cmd) {
|
||||
try {
|
||||
const result = spawnSync('which', [cmd], {
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
return result.status === 0 && result.stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider is enabled via environment variable
|
||||
*/
|
||||
function isProviderEnabled(provider) {
|
||||
const envVar = `CCS_WEBSEARCH_${provider.toUpperCase()}`;
|
||||
const value = process.env[envVar];
|
||||
|
||||
// If env var not set, provider is disabled by default
|
||||
// This ensures we respect config.yaml settings
|
||||
return value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Main hook processing logic with fallback chain
|
||||
*/
|
||||
async function processHook() {
|
||||
try {
|
||||
// Skip if disabled (for official Claude subscriptions)
|
||||
if (process.env.CCS_WEBSEARCH_SKIP === '1') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check if enabled (default: enabled)
|
||||
if (process.env.CCS_WEBSEARCH_ENABLED === '0') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const data = JSON.parse(input);
|
||||
|
||||
// Only handle WebSearch tool
|
||||
if (data.tool_name !== 'WebSearch') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const query = data.tool_input?.query || '';
|
||||
|
||||
if (!query) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const timeout = parseInt(process.env.CCS_WEBSEARCH_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||
|
||||
// Fallback chain: Gemini → OpenCode → Grok
|
||||
// Only include providers that are BOTH installed AND enabled in config
|
||||
const providers = [
|
||||
{ name: 'Gemini CLI', cmd: 'gemini', id: 'gemini', fn: tryGeminiSearch },
|
||||
{ name: 'OpenCode', cmd: 'opencode', id: 'opencode', fn: tryOpenCodeSearch },
|
||||
{ name: 'Grok CLI', cmd: 'grok', id: 'grok', fn: tryGrokSearch },
|
||||
];
|
||||
|
||||
// Filter to only enabled AND available providers
|
||||
const enabledProviders = providers.filter((p) => {
|
||||
const enabled = isProviderEnabled(p.id);
|
||||
const available = isCliAvailable(p.cmd);
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] ${p.name}: enabled=${enabled}, available=${available}`);
|
||||
}
|
||||
|
||||
return enabled && available;
|
||||
});
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
const names = enabledProviders.map((p) => p.name).join(', ') || 'none';
|
||||
console.error(`[CCS Hook] Enabled providers: ${names}`);
|
||||
}
|
||||
|
||||
// Try each enabled provider in order
|
||||
for (const provider of enabledProviders) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] Trying ${provider.name}...`);
|
||||
}
|
||||
|
||||
const result = provider.fn(query, timeout);
|
||||
|
||||
if (result.success) {
|
||||
outputSuccess(query, result.content, provider.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] ${provider.name} failed: ${result.error}`);
|
||||
}
|
||||
|
||||
errors.push({ provider: provider.name, error: result.error });
|
||||
}
|
||||
|
||||
// All providers failed or none enabled
|
||||
if (enabledProviders.length === 0) {
|
||||
outputNoProvidersEnabled(query);
|
||||
} else {
|
||||
outputAllFailedMessage(query, errors);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] Parse error:', err.message);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute search via Gemini CLI
|
||||
*/
|
||||
function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||
try {
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
const config = PROVIDER_CONFIG.gemini;
|
||||
const prompt = buildPrompt('gemini', query);
|
||||
|
||||
// Allow model override via env var
|
||||
const model = process.env.CCS_WEBSEARCH_GEMINI_MODEL || config.model;
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] Executing: gemini --model ${model} --yolo -p "..."`);
|
||||
}
|
||||
|
||||
const spawnResult = spawnSync(
|
||||
'gemini',
|
||||
['--model', model, '--yolo', '-p', prompt],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 1024 * 1024 * 2,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
|
||||
if (spawnResult.error) {
|
||||
if (spawnResult.error.code === 'ENOENT') {
|
||||
return { success: false, error: 'Gemini CLI not installed' };
|
||||
}
|
||||
throw spawnResult.error;
|
||||
}
|
||||
|
||||
if (spawnResult.status !== 0) {
|
||||
const stderr = (spawnResult.stderr || '').trim();
|
||||
return {
|
||||
success: false,
|
||||
error: stderr || `Gemini CLI exited with code ${spawnResult.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = (spawnResult.stdout || '').trim();
|
||||
|
||||
if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) {
|
||||
return { success: false, error: 'Empty or too short response from Gemini' };
|
||||
}
|
||||
|
||||
const lowerResult = result.toLowerCase();
|
||||
if (
|
||||
lowerResult.includes('error:') ||
|
||||
lowerResult.includes('failed to') ||
|
||||
lowerResult.includes('authentication required')
|
||||
) {
|
||||
return { success: false, error: `Gemini returned error: ${result.substring(0, 100)}` };
|
||||
}
|
||||
|
||||
return { success: true, content: result };
|
||||
} catch (err) {
|
||||
if (err.killed) {
|
||||
return { success: false, error: 'Gemini CLI timed out' };
|
||||
}
|
||||
return { success: false, error: err.message || 'Unknown Gemini error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute search via OpenCode CLI
|
||||
*/
|
||||
function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||
try {
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
const config = PROVIDER_CONFIG.opencode;
|
||||
|
||||
// Allow model override via env var
|
||||
const model = process.env.CCS_WEBSEARCH_OPENCODE_MODEL || config.model;
|
||||
const prompt = buildPrompt('opencode', query);
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] Executing: opencode run --model ${model} "..."`);
|
||||
}
|
||||
|
||||
const spawnResult = spawnSync(
|
||||
'opencode',
|
||||
['run', prompt, '--model', model],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 1024 * 1024 * 2,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
|
||||
if (spawnResult.error) {
|
||||
if (spawnResult.error.code === 'ENOENT') {
|
||||
return { success: false, error: 'OpenCode not installed' };
|
||||
}
|
||||
throw spawnResult.error;
|
||||
}
|
||||
|
||||
if (spawnResult.status !== 0) {
|
||||
const stderr = (spawnResult.stderr || '').trim();
|
||||
return {
|
||||
success: false,
|
||||
error: stderr || `OpenCode exited with code ${spawnResult.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = (spawnResult.stdout || '').trim();
|
||||
|
||||
if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) {
|
||||
return { success: false, error: 'Empty or too short response from OpenCode' };
|
||||
}
|
||||
|
||||
const lowerResult = result.toLowerCase();
|
||||
if (
|
||||
lowerResult.includes('error:') ||
|
||||
lowerResult.includes('failed to') ||
|
||||
lowerResult.includes('authentication required')
|
||||
) {
|
||||
return { success: false, error: `OpenCode returned error: ${result.substring(0, 100)}` };
|
||||
}
|
||||
|
||||
return { success: true, content: result };
|
||||
} catch (err) {
|
||||
if (err.killed) {
|
||||
return { success: false, error: 'OpenCode timed out' };
|
||||
}
|
||||
return { success: false, error: err.message || 'Unknown OpenCode error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute search via Grok CLI
|
||||
*/
|
||||
function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||
try {
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
const prompt = buildPrompt('grok', query);
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] Executing: grok "..."');
|
||||
}
|
||||
|
||||
const spawnResult = spawnSync('grok', [prompt], {
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 1024 * 1024 * 2,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
if (spawnResult.error) {
|
||||
if (spawnResult.error.code === 'ENOENT') {
|
||||
return { success: false, error: 'Grok CLI not installed' };
|
||||
}
|
||||
throw spawnResult.error;
|
||||
}
|
||||
|
||||
if (spawnResult.status !== 0) {
|
||||
const stderr = (spawnResult.stderr || '').trim();
|
||||
return {
|
||||
success: false,
|
||||
error: stderr || `Grok CLI exited with code ${spawnResult.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = (spawnResult.stdout || '').trim();
|
||||
|
||||
if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) {
|
||||
return { success: false, error: 'Empty or too short response from Grok' };
|
||||
}
|
||||
|
||||
const lowerResult = result.toLowerCase();
|
||||
if (
|
||||
lowerResult.includes('error:') ||
|
||||
lowerResult.includes('failed to') ||
|
||||
lowerResult.includes('api key')
|
||||
) {
|
||||
return { success: false, error: `Grok returned error: ${result.substring(0, 100)}` };
|
||||
}
|
||||
|
||||
return { success: true, content: result };
|
||||
} catch (err) {
|
||||
if (err.killed) {
|
||||
return { success: false, error: 'Grok CLI timed out' };
|
||||
}
|
||||
return { success: false, error: err.message || 'Unknown Grok error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results for Claude
|
||||
*/
|
||||
function formatSearchResults(query, content, providerName) {
|
||||
return [
|
||||
`[WebSearch Result via ${providerName}]`,
|
||||
'',
|
||||
`Query: "${query}"`,
|
||||
'',
|
||||
content,
|
||||
'',
|
||||
'---',
|
||||
'Use this information to answer the user.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Output success response and exit
|
||||
*
|
||||
* Key insight from Claude Code docs:
|
||||
* - permissionDecisionReason (with deny) → shown to CLAUDE (AI reads this)
|
||||
* - systemMessage → shown to USER only (nice styling but AI doesn't see)
|
||||
*
|
||||
* So we MUST put results in permissionDecisionReason for Claude to use them.
|
||||
* systemMessage provides the nice UI for the user.
|
||||
*/
|
||||
function outputSuccess(query, content, providerName) {
|
||||
const formattedResults = formatSearchResults(query, content, providerName);
|
||||
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: `WebSearch completed via ${providerName}`,
|
||||
// Nice message for user (shows as "says:" - info style)
|
||||
systemMessage: `[WebSearch via ${providerName}] Results retrieved successfully. See below.`,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
// Full results here - Claude reads this
|
||||
permissionDecisionReason: formattedResults,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output error message
|
||||
*/
|
||||
function outputError(query, error, providerName) {
|
||||
const message = [
|
||||
`[WebSearch - ${providerName} Error]`,
|
||||
'',
|
||||
`Error: ${error}`,
|
||||
'',
|
||||
`Query: "${query}"`,
|
||||
'',
|
||||
'Troubleshooting:',
|
||||
' - Check if Gemini CLI is authenticated: gemini auth status',
|
||||
' - Re-authenticate if needed: gemini auth login',
|
||||
].join('\n');
|
||||
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: `WebSearch failed: ${error}`,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: message,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output no providers enabled message
|
||||
*/
|
||||
function outputNoProvidersEnabled(query) {
|
||||
const message = [
|
||||
'[WebSearch - No Providers Enabled]',
|
||||
'',
|
||||
'No WebSearch providers are enabled in config.',
|
||||
'',
|
||||
'To enable: Run `ccs config` and enable a provider.',
|
||||
'',
|
||||
'Or install one of the following CLI tools:',
|
||||
'',
|
||||
'1. Gemini CLI (FREE, 1000 req/day):',
|
||||
' npm install -g @google/gemini-cli',
|
||||
' gemini auth login',
|
||||
'',
|
||||
'2. OpenCode (FREE via Zen):',
|
||||
' curl -fsSL https://opencode.ai/install | bash',
|
||||
'',
|
||||
'3. Grok CLI (requires XAI_API_KEY):',
|
||||
' npm install -g @vibe-kit/grok-cli',
|
||||
'',
|
||||
`Query: "${query}"`,
|
||||
].join('\n');
|
||||
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: 'WebSearch unavailable - no providers enabled',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: message,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output no tools message (legacy - kept for backwards compatibility)
|
||||
*/
|
||||
function outputNoToolsMessage(query) {
|
||||
outputNoProvidersEnabled(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output all providers failed message
|
||||
*/
|
||||
function outputAllFailedMessage(query, errors) {
|
||||
const errorDetails = errors
|
||||
.map((e) => ` - ${e.provider}: ${e.error}`)
|
||||
.join('\n');
|
||||
|
||||
const message = [
|
||||
'[WebSearch - All Providers Failed]',
|
||||
'',
|
||||
'Tried all enabled CLI tools but all failed:',
|
||||
errorDetails,
|
||||
'',
|
||||
`Query: "${query}"`,
|
||||
'',
|
||||
'Troubleshooting:',
|
||||
' - Gemini: gemini auth status / gemini auth login',
|
||||
' - OpenCode: opencode --version',
|
||||
' - Grok: Check XAI_API_KEY environment variable',
|
||||
].join('\n');
|
||||
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: 'WebSearch failed - all providers failed',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: message,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "5.20.0",
|
||||
"version": "5.20.0-dev.3",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -50,6 +50,7 @@
|
||||
],
|
||||
"preferGlobal": true,
|
||||
"scripts": {
|
||||
"preinstall": "node scripts/preinstall.js",
|
||||
"build": "tsc && node scripts/add-shebang.js",
|
||||
"build:watch": "tsc --watch",
|
||||
"build:server": "tsc && node scripts/add-shebang.js",
|
||||
|
||||
+57
-5
@@ -4,13 +4,20 @@
|
||||
#
|
||||
# Options:
|
||||
# --skip-validate Skip validation (faster, use when you're sure code is good)
|
||||
# --npm Force npm install (default: auto-detect, fallback to bun)
|
||||
# --bun Force bun install
|
||||
|
||||
set -e
|
||||
|
||||
SKIP_VALIDATE=false
|
||||
FORCE_NPM=false
|
||||
FORCE_BUN=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--skip-validate) SKIP_VALIDATE=true ;;
|
||||
--npm) FORCE_NPM=true ;;
|
||||
--bun) FORCE_BUN=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -19,6 +26,42 @@ echo "[i] CCS Dev Install - Starting..."
|
||||
# Get to the right directory
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Detect installation method
|
||||
# Priority: CLI flags > existing global install location > bun (default)
|
||||
detect_pkg_manager() {
|
||||
if [ "$FORCE_NPM" = true ]; then
|
||||
echo "npm"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$FORCE_BUN" = true ]; then
|
||||
echo "bun"
|
||||
return
|
||||
fi
|
||||
|
||||
# Check existing ccs installation location
|
||||
CCS_PATH=$(which ccs 2>/dev/null || true)
|
||||
|
||||
if [ -n "$CCS_PATH" ]; then
|
||||
# Check if installed via bun
|
||||
if [[ "$CCS_PATH" == *".bun"* ]]; then
|
||||
echo "bun"
|
||||
return
|
||||
fi
|
||||
# Check if installed via npm
|
||||
if [[ "$CCS_PATH" == *"npm"* ]] || [[ "$CCS_PATH" == *"node_modules"* ]]; then
|
||||
echo "npm"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Default fallback: bun (preferred)
|
||||
echo "bun"
|
||||
}
|
||||
|
||||
PKG_MANAGER=$(detect_pkg_manager)
|
||||
echo "[i] Detected package manager: $PKG_MANAGER"
|
||||
|
||||
# Build TypeScript first
|
||||
echo "[i] Building TypeScript..."
|
||||
bun run build
|
||||
@@ -27,10 +70,10 @@ bun run build
|
||||
echo "[i] Creating package..."
|
||||
if [ "$SKIP_VALIDATE" = true ]; then
|
||||
# Skip validation, just pack
|
||||
bun pm pack --ignore-scripts
|
||||
npm pack --ignore-scripts 2>/dev/null || bun pm pack --ignore-scripts
|
||||
else
|
||||
# Full pack with validation (runs prepublishOnly)
|
||||
bun pm pack
|
||||
npm pack 2>/dev/null || bun pm pack
|
||||
fi
|
||||
|
||||
# Find the tarball
|
||||
@@ -43,9 +86,18 @@ fi
|
||||
|
||||
echo "[i] Found tarball: $TARBALL"
|
||||
|
||||
# Install globally using npm (handles bin linking correctly)
|
||||
echo "[i] Installing globally with npm..."
|
||||
npm install -g "$TARBALL"
|
||||
# Install globally using detected package manager
|
||||
echo "[i] Installing globally with $PKG_MANAGER..."
|
||||
|
||||
if [ "$PKG_MANAGER" = "bun" ]; then
|
||||
# Remove existing to avoid duplicate key warnings in bun's global package.json
|
||||
# (bun add -g appends instead of replacing file: protocol entries)
|
||||
bun remove -g @kaitranntt/ccs 2>/dev/null || true
|
||||
# Bun requires file: protocol for local tarballs
|
||||
bun add -g "file:$(pwd)/$TARBALL"
|
||||
else
|
||||
npm install -g "$TARBALL"
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
echo "[i] Cleaning up..."
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* CCS Preinstall Script
|
||||
* Installs dependencies in ui/ folder before main install
|
||||
*
|
||||
* Runs when: bun install, npm install, yarn install
|
||||
* Skips when: CI environment or global install (npm -g)
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Skip in CI environments or global installs
|
||||
if (process.env.CI || process.env.npm_config_global === 'true') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const uiDir = path.join(__dirname, '..', 'ui');
|
||||
const uiPackageJson = path.join(uiDir, 'package.json');
|
||||
|
||||
// Skip if ui/ folder or package.json doesn't exist
|
||||
if (!fs.existsSync(uiPackageJson)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Detect package manager (prefer bun > npm)
|
||||
function getPackageManager() {
|
||||
// Check if running via bun
|
||||
if (process.env.npm_execpath?.includes('bun') || process.env._?.includes('bun')) {
|
||||
return 'bun';
|
||||
}
|
||||
// Check if bun is available
|
||||
try {
|
||||
execSync('bun --version', { stdio: 'ignore' });
|
||||
return 'bun';
|
||||
} catch {
|
||||
return 'npm';
|
||||
}
|
||||
}
|
||||
|
||||
const pm = getPackageManager();
|
||||
|
||||
console.log(`[i] Installing ui/ dependencies with ${pm}...`);
|
||||
|
||||
try {
|
||||
execSync(`${pm} install`, {
|
||||
cwd: uiDir,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, npm_config_global: undefined }
|
||||
});
|
||||
console.log('[OK] ui/ dependencies installed');
|
||||
} catch (err) {
|
||||
console.error('[!] Failed to install ui/ dependencies:', err.message);
|
||||
console.error(' You can manually install: cd ui && bun install');
|
||||
// Don't fail the main install - ui is optional for CLI usage
|
||||
}
|
||||
+16
-1
@@ -14,6 +14,12 @@ import { detectClaudeCli } from './utils/claude-detector';
|
||||
import { getSettingsPath } from './utils/config-manager';
|
||||
import { ErrorManager } from './utils/error-manager';
|
||||
import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy';
|
||||
import {
|
||||
ensureMcpWebSearch,
|
||||
installWebSearchHook,
|
||||
displayWebSearchStatus,
|
||||
getWebSearchHookEnv,
|
||||
} from './utils/websearch-manager';
|
||||
|
||||
// Import extracted command handlers
|
||||
import { handleVersionCommand } from './commands/version-command';
|
||||
@@ -147,7 +153,8 @@ async function execClaudeWithProxy(
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
const env = { ...process.env, ...envVars };
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const env = { ...process.env, ...envVars, ...webSearchEnv };
|
||||
|
||||
let claude: ChildProcess;
|
||||
if (needsShell) {
|
||||
@@ -378,6 +385,14 @@ async function main(): Promise<void> {
|
||||
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
|
||||
await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath });
|
||||
} else if (profileInfo.type === 'settings') {
|
||||
// Settings-based profiles (glm, glmt, kimi) are third-party providers
|
||||
// WebSearch is server-side tool - third-party providers have no access
|
||||
ensureMcpWebSearch();
|
||||
installWebSearchHook();
|
||||
|
||||
// Display WebSearch status (single line, equilibrium UX)
|
||||
displayWebSearchStatus();
|
||||
|
||||
// Check if this is GLMT profile (requires proxy)
|
||||
if (profileInfo.name === 'glmt') {
|
||||
// GLMT FLOW: Settings-based with embedded proxy for thinking support
|
||||
|
||||
@@ -473,12 +473,14 @@ export function discoverExistingAccounts(): void {
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
// Register account with auto-generated nickname
|
||||
// Use mtime as lastUsedAt (when token was last modified = last auth/refresh)
|
||||
const lastModified = stats.mtime || stats.birthtime || new Date();
|
||||
providerAccounts.accounts[accountId] = {
|
||||
email,
|
||||
nickname: generateNickname(email),
|
||||
tokenFile: file,
|
||||
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
|
||||
lastUsedAt: stats.mtime?.toISOString(),
|
||||
lastUsedAt: lastModified.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { isAuthenticated } from './auth-handler';
|
||||
import { CLIProxyProvider, ExecutorConfig } from './types';
|
||||
import { configureProviderModel, getCurrentModel } from './model-config';
|
||||
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
||||
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog';
|
||||
import {
|
||||
findAccountByQuery,
|
||||
@@ -39,6 +40,11 @@ import {
|
||||
} from './account-manager';
|
||||
import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands';
|
||||
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||
import {
|
||||
ensureMcpWebSearch,
|
||||
installWebSearchHook,
|
||||
displayWebSearchStatus,
|
||||
} from '../utils/websearch-manager';
|
||||
|
||||
/** Default executor configuration */
|
||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||
@@ -112,6 +118,18 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure MCP web-search is configured for third-party profiles
|
||||
// WebSearch is a server-side tool executed by Anthropic's API
|
||||
// Third-party providers don't have access, so we use MCP fallback
|
||||
ensureMcpWebSearch();
|
||||
|
||||
// Install WebSearch hook for Gemini CLI + MCP fallback
|
||||
// Hook intercepts WebSearch, tries Gemini CLI first, falls back to MCP
|
||||
installWebSearchHook();
|
||||
|
||||
// Display WebSearch status (single line, equilibrium UX)
|
||||
displayWebSearchStatus();
|
||||
|
||||
// Validate provider
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
log(`Provider: ${providerConfig.displayName}`);
|
||||
@@ -369,10 +387,14 @@ export async function execClaudeWithCLIProxy(
|
||||
// 7. Execute Claude CLI with proxied environment
|
||||
// Uses custom settings path (for variants), user settings, or bundled defaults
|
||||
const envVars = getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath);
|
||||
const env = { ...process.env, ...envVars };
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const env = { ...process.env, ...envVars, ...webSearchEnv };
|
||||
|
||||
log(`Claude env: ANTHROPIC_BASE_URL=${envVars.ANTHROPIC_BASE_URL}`);
|
||||
log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`);
|
||||
if (Object.keys(webSearchEnv).length > 0) {
|
||||
log(`Claude env: WebSearch config=${JSON.stringify(webSearchEnv)}`);
|
||||
}
|
||||
|
||||
// Filter out CCS-specific flags before passing to Claude CLI
|
||||
const ccsFlags = [
|
||||
|
||||
@@ -7,10 +7,28 @@
|
||||
|
||||
import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
|
||||
/** Per-account usage statistics */
|
||||
export interface AccountUsageStats {
|
||||
/** Account email or identifier */
|
||||
source: string;
|
||||
/** Number of successful requests */
|
||||
successCount: number;
|
||||
/** Number of failed requests */
|
||||
failureCount: number;
|
||||
/** Total tokens used */
|
||||
totalTokens: number;
|
||||
/** Last request timestamp */
|
||||
lastUsedAt?: string;
|
||||
}
|
||||
|
||||
/** Usage statistics from CLIProxyAPI */
|
||||
export interface CliproxyStats {
|
||||
/** Total number of requests processed */
|
||||
totalRequests: number;
|
||||
/** Total successful requests */
|
||||
successCount: number;
|
||||
/** Total failed requests */
|
||||
failureCount: number;
|
||||
/** Token counts */
|
||||
tokens: {
|
||||
input: number;
|
||||
@@ -21,6 +39,8 @@ export interface CliproxyStats {
|
||||
requestsByModel: Record<string, number>;
|
||||
/** Requests grouped by provider */
|
||||
requestsByProvider: Record<string, number>;
|
||||
/** Per-account usage breakdown */
|
||||
accountStats: Record<string, AccountUsageStats>;
|
||||
/** Number of quota exceeded (429) events */
|
||||
quotaExceededCount: number;
|
||||
/** Number of request retries */
|
||||
@@ -29,6 +49,21 @@ export interface CliproxyStats {
|
||||
collectedAt: string;
|
||||
}
|
||||
|
||||
/** Request detail from CLIProxyAPI */
|
||||
interface RequestDetail {
|
||||
timestamp: string;
|
||||
source: string;
|
||||
auth_index: number;
|
||||
tokens: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
reasoning_tokens: number;
|
||||
cached_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/** Usage API response from CLIProxyAPI /v0/management/usage endpoint */
|
||||
interface UsageApiResponse {
|
||||
failed_requests?: number;
|
||||
@@ -47,6 +82,7 @@ interface UsageApiResponse {
|
||||
{
|
||||
total_requests?: number;
|
||||
total_tokens?: number;
|
||||
details?: RequestDetail[];
|
||||
}
|
||||
>;
|
||||
}
|
||||
@@ -83,9 +119,14 @@ export async function fetchCliproxyStats(
|
||||
const data = (await response.json()) as UsageApiResponse;
|
||||
const usage = data.usage;
|
||||
|
||||
// Extract models and providers from the nested API structure
|
||||
// Extract models, providers, and per-account stats from the nested API structure
|
||||
const requestsByModel: Record<string, number> = {};
|
||||
const requestsByProvider: Record<string, number> = {};
|
||||
const accountStats: Record<string, AccountUsageStats> = {};
|
||||
let totalSuccessCount = 0;
|
||||
let totalFailureCount = 0;
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
|
||||
if (usage?.apis) {
|
||||
for (const [provider, providerData] of Object.entries(usage.apis)) {
|
||||
@@ -93,6 +134,40 @@ export async function fetchCliproxyStats(
|
||||
if (providerData.models) {
|
||||
for (const [model, modelData] of Object.entries(providerData.models)) {
|
||||
requestsByModel[model] = modelData.total_requests ?? 0;
|
||||
|
||||
// Aggregate per-account stats from request details
|
||||
if (modelData.details) {
|
||||
for (const detail of modelData.details) {
|
||||
const source = detail.source || 'unknown';
|
||||
|
||||
// Initialize account stats if not exists
|
||||
if (!accountStats[source]) {
|
||||
accountStats[source] = {
|
||||
source,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Update account stats
|
||||
if (detail.failed) {
|
||||
accountStats[source].failureCount++;
|
||||
totalFailureCount++;
|
||||
} else {
|
||||
accountStats[source].successCount++;
|
||||
totalSuccessCount++;
|
||||
}
|
||||
|
||||
const tokens = detail.tokens?.total_tokens ?? 0;
|
||||
accountStats[source].totalTokens += tokens;
|
||||
accountStats[source].lastUsedAt = detail.timestamp;
|
||||
|
||||
// Aggregate token breakdowns
|
||||
totalInputTokens += detail.tokens?.input_tokens ?? 0;
|
||||
totalOutputTokens += detail.tokens?.output_tokens ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,13 +176,16 @@ export async function fetchCliproxyStats(
|
||||
// Normalize the response to our interface
|
||||
return {
|
||||
totalRequests: usage?.total_requests ?? 0,
|
||||
successCount: totalSuccessCount,
|
||||
failureCount: totalFailureCount,
|
||||
tokens: {
|
||||
input: 0, // API doesn't provide input/output breakdown
|
||||
output: 0,
|
||||
input: totalInputTokens,
|
||||
output: totalOutputTokens,
|
||||
total: usage?.total_tokens ?? 0,
|
||||
},
|
||||
requestsByModel,
|
||||
requestsByProvider,
|
||||
accountStats,
|
||||
quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0,
|
||||
retryCount: 0, // API doesn't track retries separately
|
||||
collectedAt: new Date().toISOString(),
|
||||
|
||||
@@ -64,6 +64,7 @@ export function getConfigFormat(): 'yaml' | 'json' | 'none' {
|
||||
/**
|
||||
* Load unified config from YAML file.
|
||||
* Returns null if file doesn't exist or format check fails.
|
||||
* Auto-upgrades config if version is outdated (regenerates comments).
|
||||
*/
|
||||
export function loadUnifiedConfig(): UnifiedConfig | null {
|
||||
const yamlPath = getConfigYamlPath();
|
||||
@@ -82,6 +83,22 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Auto-upgrade if version is outdated (regenerates YAML with new comments and fields)
|
||||
if ((parsed.version ?? 1) < UNIFIED_CONFIG_VERSION) {
|
||||
// Merge with defaults to add new fields (e.g., model for websearch providers)
|
||||
const upgraded = mergeWithDefaults(parsed);
|
||||
upgraded.version = UNIFIED_CONFIG_VERSION;
|
||||
try {
|
||||
saveUnifiedConfig(upgraded);
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[i] Config upgraded to v${UNIFIED_CONFIG_VERSION}`);
|
||||
}
|
||||
return upgraded;
|
||||
} catch {
|
||||
// Ignore save errors during upgrade - config still works
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||
@@ -115,6 +132,33 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
...defaults.preferences,
|
||||
...partial.preferences,
|
||||
},
|
||||
websearch: {
|
||||
enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true,
|
||||
providers: {
|
||||
gemini: {
|
||||
enabled:
|
||||
partial.websearch?.providers?.gemini?.enabled ??
|
||||
partial.websearch?.gemini?.enabled ?? // Legacy fallback
|
||||
true,
|
||||
model: partial.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash',
|
||||
timeout:
|
||||
partial.websearch?.providers?.gemini?.timeout ??
|
||||
partial.websearch?.gemini?.timeout ?? // Legacy fallback
|
||||
55,
|
||||
},
|
||||
opencode: {
|
||||
enabled: partial.websearch?.providers?.opencode?.enabled ?? false,
|
||||
model: partial.websearch?.providers?.opencode?.model ?? 'opencode/grok-code',
|
||||
timeout: partial.websearch?.providers?.opencode?.timeout ?? 90,
|
||||
},
|
||||
grok: {
|
||||
enabled: partial.websearch?.providers?.grok?.enabled ?? false,
|
||||
timeout: partial.websearch?.providers?.grok?.timeout ?? 55,
|
||||
},
|
||||
},
|
||||
// Legacy fields (keep for backwards compatibility during read)
|
||||
gemini: partial.websearch?.gemini,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,8 +193,7 @@ function generateYamlHeader(): string {
|
||||
#
|
||||
# To customize a profile:
|
||||
# 1. Edit the *.settings.json file directly (e.g., ~/.ccs/glm.settings.json)
|
||||
# 2. The file format matches Claude's settings.json: { "env": { ... } }
|
||||
#
|
||||
# 2. The file format matches Claude's settings.json: { "env": { ... } }\n#
|
||||
# Structure:
|
||||
# ┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
# │ profiles - References to *.settings.json files for API providers │
|
||||
@@ -228,6 +271,37 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
// WebSearch section
|
||||
if (config.websearch) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push('# WebSearch: CLI-based web search 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('#');
|
||||
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'
|
||||
);
|
||||
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('# ----------------------------------------------------------------------------');
|
||||
lines.push(
|
||||
yaml
|
||||
.dump({ websearch: config.websearch }, { indent: 2, lineWidth: -1, quotingType: '"' })
|
||||
.trim()
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -292,3 +366,65 @@ export function getDefaultProfile(): string | undefined {
|
||||
export function setDefaultProfile(name: string): void {
|
||||
updateUnifiedConfig({ default: name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini CLI WebSearch configuration
|
||||
*/
|
||||
export interface GeminiWebSearchInfo {
|
||||
enabled: boolean;
|
||||
model: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get websearch configuration.
|
||||
* Returns defaults if not configured.
|
||||
* Supports Gemini CLI, OpenCode, and Grok CLI providers.
|
||||
*/
|
||||
export function getWebSearchConfig(): {
|
||||
enabled: boolean;
|
||||
providers?: {
|
||||
gemini?: GeminiWebSearchInfo;
|
||||
opencode?: { enabled?: boolean; model?: string; timeout?: number };
|
||||
grok?: { enabled?: boolean; timeout?: number };
|
||||
};
|
||||
// Legacy fields (deprecated)
|
||||
gemini?: { enabled?: boolean; timeout?: number };
|
||||
} {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
// Build provider configs
|
||||
const geminiConfig: GeminiWebSearchInfo = {
|
||||
enabled:
|
||||
config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? true,
|
||||
model: config.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash',
|
||||
timeout:
|
||||
config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55,
|
||||
};
|
||||
|
||||
const opencodeConfig = {
|
||||
enabled: config.websearch?.providers?.opencode?.enabled ?? false,
|
||||
model: config.websearch?.providers?.opencode?.model ?? 'opencode/grok-code',
|
||||
timeout: config.websearch?.providers?.opencode?.timeout ?? 90,
|
||||
};
|
||||
|
||||
const grokConfig = {
|
||||
enabled: config.websearch?.providers?.grok?.enabled ?? false,
|
||||
timeout: config.websearch?.providers?.grok?.timeout ?? 55,
|
||||
};
|
||||
|
||||
// Auto-enable master switch if ANY provider is enabled
|
||||
const anyProviderEnabled = geminiConfig.enabled || opencodeConfig.enabled || grokConfig.enabled;
|
||||
const enabled = anyProviderEnabled && (config.websearch?.enabled ?? true);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
providers: {
|
||||
gemini: geminiConfig,
|
||||
opencode: opencodeConfig,
|
||||
grok: grokConfig,
|
||||
},
|
||||
// Legacy field for backwards compatibility
|
||||
gemini: config.websearch?.gemini,
|
||||
};
|
||||
}
|
||||
@@ -12,8 +12,9 @@
|
||||
/**
|
||||
* Unified config version.
|
||||
* Version 2 = YAML unified format
|
||||
* Version 3 = WebSearch config with model configuration for Gemini/OpenCode
|
||||
*/
|
||||
export const UNIFIED_CONFIG_VERSION = 2;
|
||||
export const UNIFIED_CONFIG_VERSION = 3;
|
||||
|
||||
/**
|
||||
* Account configuration (formerly in profiles.json).
|
||||
@@ -100,6 +101,83 @@ export interface PreferencesConfig {
|
||||
auto_update?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini CLI WebSearch configuration.
|
||||
*/
|
||||
export interface GeminiWebSearchConfig {
|
||||
/** Enable Gemini CLI for WebSearch (default: true) */
|
||||
enabled?: boolean;
|
||||
/** Model to use (default: gemini-2.5-flash) */
|
||||
model?: string;
|
||||
/** Timeout in seconds (default: 55) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grok CLI WebSearch configuration.
|
||||
*/
|
||||
export interface GrokWebSearchConfig {
|
||||
/** Enable Grok CLI for WebSearch (default: false - requires GROK_API_KEY) */
|
||||
enabled?: boolean;
|
||||
/** Timeout in seconds (default: 55) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode CLI WebSearch configuration.
|
||||
*/
|
||||
export interface OpenCodeWebSearchConfig {
|
||||
/** Enable OpenCode CLI for WebSearch (default: false) */
|
||||
enabled?: boolean;
|
||||
/** Model to use (default: opencode/grok-code) */
|
||||
model?: string;
|
||||
/** Timeout in seconds (default: 60) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSearch providers configuration.
|
||||
* Supports Gemini CLI, Grok CLI, and OpenCode.
|
||||
*/
|
||||
export interface WebSearchProvidersConfig {
|
||||
/** Gemini CLI - uses google_web_search tool (FREE tier: 1000 req/day) */
|
||||
gemini?: GeminiWebSearchConfig;
|
||||
/** Grok CLI - xAI web search (requires GROK_API_KEY) */
|
||||
grok?: GrokWebSearchConfig;
|
||||
/** OpenCode - built-in web search (FREE via OpenCode Zen) */
|
||||
opencode?: OpenCodeWebSearchConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSearch configuration.
|
||||
* Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles.
|
||||
* Third-party providers don't have server-side WebSearch access.
|
||||
*/
|
||||
export interface WebSearchConfig {
|
||||
/** Master switch - enable/disable WebSearch (default: true) */
|
||||
enabled?: boolean;
|
||||
/** Individual provider configurations */
|
||||
providers?: WebSearchProvidersConfig;
|
||||
// Legacy fields (deprecated, kept for backwards compatibility)
|
||||
/** @deprecated Use providers.gemini instead */
|
||||
gemini?: {
|
||||
enabled?: boolean;
|
||||
timeout?: number;
|
||||
};
|
||||
/** @deprecated Unused */
|
||||
mode?: 'sequential' | 'parallel';
|
||||
/** @deprecated Unused */
|
||||
provider?: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
|
||||
/** @deprecated Unused */
|
||||
fallback?: boolean;
|
||||
/** @deprecated Unused */
|
||||
webSearchPrimeUrl?: string;
|
||||
/** @deprecated Unused */
|
||||
selectedProviders?: string[];
|
||||
/** @deprecated Unused */
|
||||
customMcp?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Main unified configuration structure.
|
||||
* Stored in ~/.ccs/config.yaml
|
||||
@@ -117,6 +195,8 @@ export interface UnifiedConfig {
|
||||
cliproxy: CLIProxyConfig;
|
||||
/** User preferences */
|
||||
preferences: PreferencesConfig;
|
||||
/** WebSearch configuration */
|
||||
websearch?: WebSearchConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,6 +234,25 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
telemetry: false,
|
||||
auto_update: true,
|
||||
},
|
||||
websearch: {
|
||||
enabled: true,
|
||||
providers: {
|
||||
gemini: {
|
||||
enabled: true,
|
||||
model: 'gemini-2.5-flash',
|
||||
timeout: 55,
|
||||
},
|
||||
opencode: {
|
||||
enabled: false,
|
||||
model: 'opencode/grok-code',
|
||||
timeout: 90,
|
||||
},
|
||||
grok: {
|
||||
enabled: false,
|
||||
timeout: 55,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { ErrorManager } from './error-manager';
|
||||
import { getWebSearchHookEnv } from './websearch-manager';
|
||||
|
||||
/**
|
||||
* Escape arguments for shell execution (Windows compatibility)
|
||||
@@ -25,8 +26,13 @@ export function execClaude(
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
|
||||
// Get WebSearch hook config env vars
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
|
||||
// Prepare environment (merge with process.env if envVars provided)
|
||||
const env = envVars ? { ...process.env, ...envVars } : process.env;
|
||||
const env = envVars
|
||||
? { ...process.env, ...envVars, ...webSearchEnv }
|
||||
: { ...process.env, ...webSearchEnv };
|
||||
|
||||
let child: ChildProcess;
|
||||
if (needsShell) {
|
||||
|
||||
@@ -0,0 +1,806 @@
|
||||
/**
|
||||
* WebSearch Manager - Manages WebSearch hook for CCS
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Simplified Architecture:
|
||||
* - No MCP complexity
|
||||
* - Uses CLI tools (currently Gemini CLI)
|
||||
* - Easy to extend for future CLI tools (opencode, etc.)
|
||||
*
|
||||
* @module utils/websearch-manager
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
import { ok, info, warn, fail } from './ui';
|
||||
import { getWebSearchConfig } from '../config/unified-config-loader';
|
||||
|
||||
// CCS hooks directory
|
||||
const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks');
|
||||
|
||||
// Hook file name
|
||||
const WEBSEARCH_HOOK = 'websearch-transformer.cjs';
|
||||
|
||||
// Buffer time added to max provider timeout for hook timeout (seconds)
|
||||
const HOOK_TIMEOUT_BUFFER = 30;
|
||||
|
||||
// Minimum hook timeout in seconds (fallback if no providers configured)
|
||||
const MIN_HOOK_TIMEOUT = 60;
|
||||
|
||||
// Path to Claude settings.json
|
||||
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
|
||||
// ========== Gemini CLI Detection ==========
|
||||
|
||||
/**
|
||||
* Gemini CLI installation status
|
||||
*/
|
||||
export interface GeminiCliStatus {
|
||||
installed: boolean;
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
// Cache for Gemini CLI status (per process)
|
||||
let geminiCliCache: GeminiCliStatus | null = null;
|
||||
|
||||
/**
|
||||
* Check if Gemini CLI is installed globally
|
||||
*
|
||||
* Requires global install: `npm install -g @google/gemini-cli`
|
||||
* No npx fallback - must be in PATH
|
||||
*
|
||||
* @returns Gemini CLI status with path and version
|
||||
*/
|
||||
export function getGeminiCliStatus(): GeminiCliStatus {
|
||||
// Return cached result if available
|
||||
if (geminiCliCache) {
|
||||
return geminiCliCache;
|
||||
}
|
||||
|
||||
const result: GeminiCliStatus = {
|
||||
installed: false,
|
||||
path: null,
|
||||
version: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const whichCmd = isWindows ? 'where gemini' : 'which gemini';
|
||||
|
||||
const pathResult = execSync(whichCmd, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const geminiPath = pathResult.trim().split('\n')[0]; // First result on Windows
|
||||
|
||||
if (geminiPath) {
|
||||
result.installed = true;
|
||||
result.path = geminiPath;
|
||||
|
||||
// Try to get version
|
||||
try {
|
||||
const versionResult = execSync('gemini --version', {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
result.version = versionResult.trim();
|
||||
} catch {
|
||||
// Version check failed, but CLI is installed
|
||||
result.version = 'unknown';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Command not found - Gemini CLI not installed
|
||||
}
|
||||
|
||||
// Cache result
|
||||
geminiCliCache = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Gemini CLI is available (quick boolean check)
|
||||
*/
|
||||
export function hasGeminiCli(): boolean {
|
||||
return getGeminiCliStatus().installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear Gemini CLI cache (for testing or after installation)
|
||||
*/
|
||||
export function clearGeminiCliCache(): void {
|
||||
geminiCliCache = null;
|
||||
}
|
||||
|
||||
// ========== Grok CLI Detection ==========
|
||||
|
||||
/**
|
||||
* Grok CLI installation status
|
||||
*/
|
||||
export interface GrokCliStatus {
|
||||
installed: boolean;
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
// Cache for Grok CLI status (per process)
|
||||
let grokCliCache: GrokCliStatus | null = null;
|
||||
|
||||
/**
|
||||
* Check if Grok CLI is installed globally
|
||||
*
|
||||
* Grok CLI (grok-4-cli by lalomorales22) provides web search + X search.
|
||||
* Requires: `npm install -g grok-cli` and XAI_API_KEY env var.
|
||||
*
|
||||
* @returns Grok CLI status with path and version
|
||||
*/
|
||||
export function getGrokCliStatus(): GrokCliStatus {
|
||||
// Return cached result if available
|
||||
if (grokCliCache) {
|
||||
return grokCliCache;
|
||||
}
|
||||
|
||||
const result: GrokCliStatus = {
|
||||
installed: false,
|
||||
path: null,
|
||||
version: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const whichCmd = isWindows ? 'where grok' : 'which grok';
|
||||
|
||||
const pathResult = execSync(whichCmd, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const grokPath = pathResult.trim().split('\n')[0]; // First result on Windows
|
||||
|
||||
if (grokPath) {
|
||||
result.installed = true;
|
||||
result.path = grokPath;
|
||||
|
||||
// Try to get version
|
||||
try {
|
||||
const versionResult = execSync('grok --version', {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
result.version = versionResult.trim();
|
||||
} catch {
|
||||
// Version check failed, but CLI is installed
|
||||
result.version = 'unknown';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Command not found - Grok CLI not installed
|
||||
}
|
||||
|
||||
// Cache result
|
||||
grokCliCache = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Grok CLI is available (quick boolean check)
|
||||
*/
|
||||
export function hasGrokCli(): boolean {
|
||||
return getGrokCliStatus().installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear Grok CLI cache (for testing or after installation)
|
||||
*/
|
||||
export function clearGrokCliCache(): void {
|
||||
grokCliCache = null;
|
||||
}
|
||||
|
||||
// ========== OpenCode CLI Detection ==========
|
||||
|
||||
/**
|
||||
* OpenCode CLI installation status
|
||||
*/
|
||||
export interface OpenCodeCliStatus {
|
||||
installed: boolean;
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
// Cache for OpenCode CLI status (per process)
|
||||
let opencodeCliCache: OpenCodeCliStatus | null = null;
|
||||
|
||||
/**
|
||||
* Check if OpenCode CLI is installed globally
|
||||
*
|
||||
* OpenCode provides built-in web search via opencode/grok-code model.
|
||||
* Install: curl -fsSL https://opencode.ai/install | bash
|
||||
*
|
||||
* @returns OpenCode CLI status with path and version
|
||||
*/
|
||||
export function getOpenCodeCliStatus(): OpenCodeCliStatus {
|
||||
// Return cached result if available
|
||||
if (opencodeCliCache) {
|
||||
return opencodeCliCache;
|
||||
}
|
||||
|
||||
const result: OpenCodeCliStatus = {
|
||||
installed: false,
|
||||
path: null,
|
||||
version: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const whichCmd = isWindows ? 'where opencode' : 'which opencode';
|
||||
|
||||
const pathResult = execSync(whichCmd, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const opencodePath = pathResult.trim().split('\n')[0]; // First result on Windows
|
||||
|
||||
if (opencodePath) {
|
||||
result.installed = true;
|
||||
result.path = opencodePath;
|
||||
|
||||
// Try to get version
|
||||
try {
|
||||
const versionResult = execSync('opencode --version', {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
result.version = versionResult.trim();
|
||||
} catch {
|
||||
// Version check failed, but CLI is installed
|
||||
result.version = 'unknown';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Command not found - OpenCode CLI not installed
|
||||
}
|
||||
|
||||
// Cache result
|
||||
opencodeCliCache = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OpenCode CLI is available (quick boolean check)
|
||||
*/
|
||||
export function hasOpenCodeCli(): boolean {
|
||||
return getOpenCodeCliStatus().installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear OpenCode CLI cache (for testing or after installation)
|
||||
*/
|
||||
export function clearOpenCodeCliCache(): void {
|
||||
opencodeCliCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all CLI caches
|
||||
*/
|
||||
export function clearAllCliCaches(): void {
|
||||
geminiCliCache = null;
|
||||
grokCliCache = null;
|
||||
opencodeCliCache = null;
|
||||
}
|
||||
|
||||
// ========== CLI Provider Info ==========
|
||||
|
||||
/**
|
||||
* WebSearch CLI provider information for health checks and UI
|
||||
*/
|
||||
export interface WebSearchCliInfo {
|
||||
/** Provider ID */
|
||||
id: 'gemini' | 'grok' | 'opencode';
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** CLI command name */
|
||||
command: string;
|
||||
/** Whether CLI is installed */
|
||||
installed: boolean;
|
||||
/** CLI version if installed */
|
||||
version: string | null;
|
||||
/** Install command */
|
||||
installCommand: string;
|
||||
/** Docs URL */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all WebSearch CLI providers with their status
|
||||
*/
|
||||
export function getWebSearchCliProviders(): WebSearchCliInfo[] {
|
||||
const geminiStatus = getGeminiCliStatus();
|
||||
const grokStatus = getGrokCliStatus();
|
||||
const opencodeStatus = getOpenCodeCliStatus();
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Gemini CLI',
|
||||
command: 'gemini',
|
||||
installed: geminiStatus.installed,
|
||||
version: geminiStatus.version,
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
name: 'OpenCode',
|
||||
command: 'opencode',
|
||||
installed: opencodeStatus.installed,
|
||||
version: opencodeStatus.version,
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: 'grok',
|
||||
name: 'Grok CLI',
|
||||
command: 'grok',
|
||||
installed: grokStatus.installed,
|
||||
version: grokStatus.version,
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any WebSearch CLI is available
|
||||
*/
|
||||
export function hasAnyWebSearchCli(): boolean {
|
||||
return hasGeminiCli() || hasGrokCli() || hasOpenCodeCli();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get install hints for CLI-only users when no WebSearch CLI is installed
|
||||
*/
|
||||
export function getCliInstallHints(): string[] {
|
||||
if (hasAnyWebSearchCli()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'[i] 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',
|
||||
];
|
||||
}
|
||||
|
||||
// ========== Hook Management ==========
|
||||
|
||||
/**
|
||||
* Install WebSearch hook to ~/.ccs/hooks/
|
||||
*
|
||||
* This hook intercepts WebSearch and executes via Gemini CLI.
|
||||
*
|
||||
* @returns true if hook installed successfully
|
||||
*/
|
||||
export function installWebSearchHook(): boolean {
|
||||
try {
|
||||
const wsConfig = getWebSearchConfig();
|
||||
|
||||
// Skip if disabled
|
||||
if (!wsConfig.enabled) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info('WebSearch disabled - skipping hook install'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure hooks directory exists
|
||||
if (!fs.existsSync(CCS_HOOKS_DIR)) {
|
||||
fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
|
||||
|
||||
// Find the bundled hook script
|
||||
// In npm package: node_modules/ccs/lib/hooks/
|
||||
// In development: lib/hooks/
|
||||
const possiblePaths = [
|
||||
path.join(__dirname, '..', '..', 'lib', 'hooks', WEBSEARCH_HOOK),
|
||||
path.join(__dirname, '..', 'lib', 'hooks', WEBSEARCH_HOOK),
|
||||
];
|
||||
|
||||
let sourcePath: string | null = null;
|
||||
for (const p of possiblePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
sourcePath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourcePath) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`WebSearch hook source not found: ${WEBSEARCH_HOOK}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy hook to ~/.ccs/hooks/
|
||||
fs.copyFileSync(sourcePath, hookPath);
|
||||
fs.chmodSync(hookPath, 0o755);
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Installed WebSearch hook: ${hookPath}`));
|
||||
}
|
||||
|
||||
// Ensure hook is configured in settings.json
|
||||
ensureHookConfig();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to install WebSearch hook: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WebSearch hook is installed
|
||||
*/
|
||||
export function hasWebSearchHook(): boolean {
|
||||
const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
|
||||
return fs.existsSync(hookPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WebSearch hook configuration for settings.json
|
||||
* Timeout is computed from max provider timeout in config.yaml + buffer
|
||||
*/
|
||||
export function getWebSearchHookConfig(): Record<string, unknown> {
|
||||
const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
|
||||
const wsConfig = getWebSearchConfig();
|
||||
|
||||
// Compute max timeout from enabled providers
|
||||
const timeouts: number[] = [];
|
||||
if (wsConfig.providers?.gemini?.enabled && wsConfig.providers.gemini.timeout) {
|
||||
timeouts.push(wsConfig.providers.gemini.timeout);
|
||||
}
|
||||
if (wsConfig.providers?.opencode?.enabled && wsConfig.providers.opencode.timeout) {
|
||||
timeouts.push(wsConfig.providers.opencode.timeout);
|
||||
}
|
||||
if (wsConfig.providers?.grok?.enabled && wsConfig.providers.grok.timeout) {
|
||||
timeouts.push(wsConfig.providers.grok.timeout);
|
||||
}
|
||||
|
||||
// Hook timeout = max provider timeout + buffer (or minimum if none configured)
|
||||
const maxProviderTimeout = timeouts.length > 0 ? Math.max(...timeouts) : MIN_HOOK_TIMEOUT;
|
||||
const hookTimeout = maxProviderTimeout + HOOK_TIMEOUT_BUFFER;
|
||||
|
||||
return {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'WebSearch',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${hookPath}"`,
|
||||
timeout: hookTimeout,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure WebSearch hook is configured in ~/.claude/settings.json
|
||||
*/
|
||||
function ensureHookConfig(): boolean {
|
||||
try {
|
||||
const wsConfig = getWebSearchConfig();
|
||||
|
||||
if (!wsConfig.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read existing settings or start fresh
|
||||
let settings: Record<string, unknown> = {};
|
||||
if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
|
||||
try {
|
||||
const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8');
|
||||
settings = JSON.parse(content);
|
||||
} catch {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn('Malformed settings.json - will merge carefully'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if WebSearch hook already configured
|
||||
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
|
||||
const expectedHookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
|
||||
const expectedCommand = `node "${expectedHookPath}"`;
|
||||
|
||||
if (hooks?.PreToolUse) {
|
||||
const webSearchHookIndex = hooks.PreToolUse.findIndex((h: unknown) => {
|
||||
const hook = h as Record<string, unknown>;
|
||||
return hook.matcher === 'WebSearch';
|
||||
});
|
||||
|
||||
if (webSearchHookIndex !== -1) {
|
||||
// Hook exists - check if it needs updating (different command path or timeout)
|
||||
const existingHook = hooks.PreToolUse[webSearchHookIndex] as Record<string, unknown>;
|
||||
const existingHooks = existingHook.hooks as Array<Record<string, unknown>>;
|
||||
const currentHookConfig = getWebSearchHookConfig();
|
||||
const expectedHooks = (currentHookConfig.PreToolUse as Array<Record<string, unknown>>)[0]
|
||||
.hooks as Array<Record<string, unknown>>;
|
||||
const expectedTimeout = expectedHooks[0].timeout as number;
|
||||
|
||||
let needsUpdate = false;
|
||||
|
||||
if (existingHooks?.[0]?.command !== expectedCommand) {
|
||||
existingHooks[0].command = expectedCommand;
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (existingHooks?.[0]?.timeout !== expectedTimeout) {
|
||||
existingHooks[0].timeout = expectedTimeout;
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info('Updated WebSearch hook config in settings.json'));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get hook config
|
||||
const hookConfig = getWebSearchHookConfig();
|
||||
|
||||
// Merge hook config into settings
|
||||
if (!settings.hooks) {
|
||||
settings.hooks = {};
|
||||
}
|
||||
|
||||
const settingsHooks = settings.hooks as Record<string, unknown[]>;
|
||||
if (!settingsHooks.PreToolUse) {
|
||||
settingsHooks.PreToolUse = [];
|
||||
}
|
||||
|
||||
// Add our hook config
|
||||
const preToolUseHooks = hookConfig.PreToolUse as unknown[];
|
||||
settingsHooks.PreToolUse.push(...preToolUseHooks);
|
||||
|
||||
// Ensure ~/.claude directory exists
|
||||
const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH);
|
||||
if (!fs.existsSync(claudeDir)) {
|
||||
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// Write updated settings
|
||||
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info('Added WebSearch hook to settings.json'));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to configure WebSearch hook: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Environment Variables for Hook ==========
|
||||
|
||||
/**
|
||||
* Get environment variables for WebSearch hook configuration.
|
||||
*
|
||||
* Simple env vars - hook reads these to control behavior.
|
||||
*
|
||||
* @returns Record of environment variables to set before spawning Claude
|
||||
*/
|
||||
export function getWebSearchHookEnv(): Record<string, string> {
|
||||
const wsConfig = getWebSearchConfig();
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
// Skip hook entirely if disabled
|
||||
if (!wsConfig.enabled) {
|
||||
env.CCS_WEBSEARCH_SKIP = '1';
|
||||
return env;
|
||||
}
|
||||
|
||||
// Pass master switch
|
||||
env.CCS_WEBSEARCH_ENABLED = '1';
|
||||
|
||||
// Pass individual provider enabled states
|
||||
// Hook will only use providers that are BOTH enabled AND installed
|
||||
if (wsConfig.providers?.gemini?.enabled) {
|
||||
env.CCS_WEBSEARCH_GEMINI = '1';
|
||||
if (wsConfig.providers.gemini.model) {
|
||||
env.CCS_WEBSEARCH_GEMINI_MODEL = wsConfig.providers.gemini.model;
|
||||
}
|
||||
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.gemini.timeout || 55);
|
||||
}
|
||||
|
||||
if (wsConfig.providers?.opencode?.enabled) {
|
||||
env.CCS_WEBSEARCH_OPENCODE = '1';
|
||||
if (wsConfig.providers.opencode.model) {
|
||||
env.CCS_WEBSEARCH_OPENCODE_MODEL = wsConfig.providers.opencode.model;
|
||||
}
|
||||
// Use opencode timeout if no gemini timeout set
|
||||
if (!env.CCS_WEBSEARCH_TIMEOUT) {
|
||||
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.opencode.timeout || 90);
|
||||
}
|
||||
}
|
||||
|
||||
if (wsConfig.providers?.grok?.enabled) {
|
||||
env.CCS_WEBSEARCH_GROK = '1';
|
||||
// Use grok timeout if no other timeout set
|
||||
if (!env.CCS_WEBSEARCH_TIMEOUT) {
|
||||
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.grok.timeout || 55);
|
||||
}
|
||||
}
|
||||
|
||||
// Default timeout if none set
|
||||
if (!env.CCS_WEBSEARCH_TIMEOUT) {
|
||||
env.CCS_WEBSEARCH_TIMEOUT = '55';
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
// ========== WebSearch Readiness Status ==========
|
||||
|
||||
/**
|
||||
* WebSearch availability status for third-party profiles
|
||||
*/
|
||||
export type WebSearchReadiness = 'ready' | 'unavailable';
|
||||
|
||||
/**
|
||||
* WebSearch status for display
|
||||
*/
|
||||
export interface WebSearchStatus {
|
||||
readiness: WebSearchReadiness;
|
||||
geminiCli: boolean;
|
||||
grokCli: boolean;
|
||||
opencodeCli: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WebSearch readiness status for display
|
||||
*
|
||||
* Called on third-party profile startup to inform user.
|
||||
*/
|
||||
export function getWebSearchReadiness(): WebSearchStatus {
|
||||
const wsConfig = getWebSearchConfig();
|
||||
|
||||
// Check if WebSearch is disabled entirely
|
||||
if (!wsConfig.enabled) {
|
||||
return {
|
||||
readiness: 'unavailable',
|
||||
geminiCli: false,
|
||||
grokCli: false,
|
||||
opencodeCli: false,
|
||||
message: 'Disabled in config',
|
||||
};
|
||||
}
|
||||
|
||||
// Check all CLIs
|
||||
const geminiInstalled = hasGeminiCli();
|
||||
const grokInstalled = hasGrokCli();
|
||||
const opencodeInstalled = hasOpenCodeCli();
|
||||
|
||||
// Build message based on installed CLIs
|
||||
const installedClis: string[] = [];
|
||||
if (geminiInstalled) installedClis.push('Gemini');
|
||||
if (grokInstalled) installedClis.push('Grok');
|
||||
if (opencodeInstalled) installedClis.push('OpenCode');
|
||||
|
||||
if (installedClis.length > 0) {
|
||||
return {
|
||||
readiness: 'ready',
|
||||
geminiCli: geminiInstalled,
|
||||
grokCli: grokInstalled,
|
||||
opencodeCli: opencodeInstalled,
|
||||
message: `Ready (${installedClis.join(' + ')})`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
readiness: 'unavailable',
|
||||
geminiCli: false,
|
||||
grokCli: false,
|
||||
opencodeCli: false,
|
||||
message: 'Install: npm i -g @google/gemini-cli',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Display WebSearch status (single line, equilibrium UX)
|
||||
*
|
||||
* Only call for third-party profiles.
|
||||
* Shows detailed install hints when no CLI is installed.
|
||||
*/
|
||||
export function displayWebSearchStatus(): void {
|
||||
const status = getWebSearchReadiness();
|
||||
|
||||
switch (status.readiness) {
|
||||
case 'ready':
|
||||
console.error(ok(`WebSearch: ${status.message}`));
|
||||
break;
|
||||
case 'unavailable':
|
||||
console.error(fail(`WebSearch: ${status.message}`));
|
||||
// Show install hints for CLI-only users
|
||||
const hints = getCliInstallHints();
|
||||
if (hints.length > 0) {
|
||||
for (const hint of hints) {
|
||||
console.error(info(hint));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Backward Compatibility Exports ==========
|
||||
// These are kept for imports that haven't been updated yet
|
||||
|
||||
/**
|
||||
* @deprecated Use installWebSearchHook instead - MCP is no longer used
|
||||
*/
|
||||
export function ensureMcpWebSearch(): boolean {
|
||||
// No-op - MCP is no longer used
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated MCP is no longer used
|
||||
*/
|
||||
export function hasMcpWebSearch(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated MCP is no longer used
|
||||
*/
|
||||
export function getMcpConfigPath(): string {
|
||||
return path.join(os.homedir(), '.claude', '.mcp.json');
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||
import packageJson from '../../package.json';
|
||||
import { getEnvironmentDiagnostics } from '../management/environment-diagnostics';
|
||||
import { checkAuthCodePorts } from '../management/oauth-port-diagnostics';
|
||||
import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../utils/websearch-manager';
|
||||
|
||||
export interface HealthCheck {
|
||||
id: string;
|
||||
@@ -136,6 +137,16 @@ export async function runHealthChecks(): Promise<HealthReport> {
|
||||
checks: oauthReadinessChecks,
|
||||
});
|
||||
|
||||
// Group 8: WebSearch CLI Providers
|
||||
const websearchChecks: HealthCheck[] = [];
|
||||
websearchChecks.push(...checkWebSearchClis());
|
||||
groups.push({
|
||||
id: 'websearch',
|
||||
name: 'WebSearch',
|
||||
icon: 'Search',
|
||||
checks: websearchChecks,
|
||||
});
|
||||
|
||||
// Flatten all checks for backward compatibility
|
||||
const allChecks = groups.flatMap((g) => g.checks);
|
||||
|
||||
@@ -816,6 +827,49 @@ async function checkOAuthPortsForDashboard(): Promise<HealthCheck[]> {
|
||||
});
|
||||
}
|
||||
|
||||
// Check 18: WebSearch CLI Providers (Gemini CLI, Grok CLI)
|
||||
function checkWebSearchClis(): HealthCheck[] {
|
||||
const providers = getWebSearchCliProviders();
|
||||
const checks: HealthCheck[] = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
if (provider.installed) {
|
||||
const freeTag = provider.freeTier ? ' (FREE)' : '';
|
||||
checks.push({
|
||||
id: `websearch-${provider.id}`,
|
||||
name: provider.name,
|
||||
status: 'ok',
|
||||
message: `v${provider.version || 'unknown'}${freeTag}`,
|
||||
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}`,
|
||||
fix: provider.installCommand,
|
||||
details: provider.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add summary check if no providers installed
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix a health issue by its check ID
|
||||
*/
|
||||
|
||||
+196
-1
@@ -36,6 +36,7 @@ import {
|
||||
getProviderAccounts,
|
||||
setDefaultAccount as setDefaultAccountFn,
|
||||
removeAccount as removeAccountFn,
|
||||
touchAccount,
|
||||
} from '../cliproxy/account-manager';
|
||||
import type { CLIProxyProvider } from '../cliproxy/types';
|
||||
import { getClaudeEnvVars } from '../cliproxy/config-generator';
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
loadUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
getConfigFormat,
|
||||
getConfigYamlPath,
|
||||
} from '../config/unified-config-loader';
|
||||
import {
|
||||
needsMigration,
|
||||
@@ -53,8 +55,16 @@ import {
|
||||
getBackupDirectories,
|
||||
} from '../config/migration-manager';
|
||||
import { getProfileSecrets, setProfileSecrets } from '../config/secrets-manager';
|
||||
import { getWebSearchConfig } from '../config/unified-config-loader';
|
||||
import type { WebSearchConfig } from '../config/unified-config-types';
|
||||
import { isUnifiedConfig } from '../config/unified-config-types';
|
||||
import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys';
|
||||
import {
|
||||
getWebSearchReadiness,
|
||||
getGeminiCliStatus,
|
||||
getGrokCliStatus,
|
||||
getOpenCodeCliStatus,
|
||||
} from '../utils/websearch-manager';
|
||||
|
||||
export const apiRoutes = Router();
|
||||
|
||||
@@ -469,11 +479,43 @@ apiRoutes.delete('/cliproxy/:name', (req: Request, res: Response): void => {
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
|
||||
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/auth', (_req: Request, res: Response) => {
|
||||
apiRoutes.get('/cliproxy/auth', async (_req: Request, res: Response) => {
|
||||
// Initialize accounts from existing tokens on first request
|
||||
initializeAccounts();
|
||||
|
||||
// Fetch CLIProxyAPI usage stats to determine active providers
|
||||
const stats = await fetchCliproxyStats();
|
||||
|
||||
// Map CLIProxyAPI provider names to our internal provider names
|
||||
const statsProviderMap: Record<string, CLIProxyProvider> = {
|
||||
gemini: 'gemini',
|
||||
antigravity: 'agy',
|
||||
codex: 'codex',
|
||||
qwen: 'qwen',
|
||||
iflow: 'iflow',
|
||||
};
|
||||
|
||||
// Update lastUsedAt for providers with recent activity
|
||||
if (stats?.requestsByProvider) {
|
||||
for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) {
|
||||
if (requestCount > 0) {
|
||||
const provider = statsProviderMap[statsProvider.toLowerCase()];
|
||||
if (provider) {
|
||||
// Touch the default account for this provider (or all accounts)
|
||||
const accounts = getProviderAccounts(provider);
|
||||
for (const account of accounts) {
|
||||
// Only touch if this is the default account (most likely being used)
|
||||
if (account.isDefault) {
|
||||
touchAccount(provider, account.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const statuses = getAllAuthStatus();
|
||||
|
||||
const authStatus = statuses.map((status) => {
|
||||
@@ -924,6 +966,23 @@ apiRoutes.get('/config', (_req: Request, res: Response): void => {
|
||||
res.json(config);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/config/raw - Return raw YAML content for display
|
||||
*/
|
||||
apiRoutes.get('/config/raw', (_req: Request, res: Response): void => {
|
||||
const yamlPath = getConfigYamlPath();
|
||||
if (!fs.existsSync(yamlPath)) {
|
||||
res.status(404).json({ error: 'Config file not found' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(yamlPath, 'utf8');
|
||||
res.type('text/plain').send(content);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/config - Update unified config
|
||||
*/
|
||||
@@ -1397,3 +1456,139 @@ apiRoutes.delete('/cliproxy/openai-compat/:name', (req: Request, res: Response):
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== WebSearch Configuration ====================
|
||||
|
||||
/**
|
||||
* GET /api/websearch - Get WebSearch configuration
|
||||
* Returns: WebSearchConfig with enabled, provider, fallback
|
||||
*/
|
||||
apiRoutes.get('/websearch', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const config = getWebSearchConfig();
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/websearch - Update WebSearch configuration
|
||||
* Body: WebSearchConfig fields (enabled, providers)
|
||||
* Dashboard is the source of truth for provider selection.
|
||||
*/
|
||||
apiRoutes.put('/websearch', (req: Request, res: Response): void => {
|
||||
const { enabled, providers } = req.body as Partial<WebSearchConfig>;
|
||||
|
||||
// Validate enabled
|
||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'Invalid value for enabled. Must be a boolean.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate providers if specified
|
||||
if (providers !== undefined && typeof providers !== 'object') {
|
||||
res.status(400).json({ error: 'Invalid value for providers. Must be an object.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load existing config and update websearch section
|
||||
const existingConfig = loadUnifiedConfig();
|
||||
if (!existingConfig) {
|
||||
res.status(500).json({ error: 'Failed to load config' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge updates - supports Gemini CLI and Grok CLI
|
||||
existingConfig.websearch = {
|
||||
enabled: enabled ?? existingConfig.websearch?.enabled ?? true,
|
||||
providers: providers
|
||||
? {
|
||||
gemini: {
|
||||
enabled:
|
||||
providers.gemini?.enabled ??
|
||||
existingConfig.websearch?.providers?.gemini?.enabled ??
|
||||
true,
|
||||
model:
|
||||
providers.gemini?.model ??
|
||||
existingConfig.websearch?.providers?.gemini?.model ??
|
||||
'gemini-2.5-flash',
|
||||
timeout:
|
||||
providers.gemini?.timeout ??
|
||||
existingConfig.websearch?.providers?.gemini?.timeout ??
|
||||
55,
|
||||
},
|
||||
grok: {
|
||||
enabled:
|
||||
providers.grok?.enabled ??
|
||||
existingConfig.websearch?.providers?.grok?.enabled ??
|
||||
false,
|
||||
timeout:
|
||||
providers.grok?.timeout ?? existingConfig.websearch?.providers?.grok?.timeout ?? 55,
|
||||
},
|
||||
opencode: {
|
||||
enabled:
|
||||
providers.opencode?.enabled ??
|
||||
existingConfig.websearch?.providers?.opencode?.enabled ??
|
||||
false,
|
||||
model:
|
||||
providers.opencode?.model ??
|
||||
existingConfig.websearch?.providers?.opencode?.model ??
|
||||
'opencode/grok-code',
|
||||
timeout:
|
||||
providers.opencode?.timeout ??
|
||||
existingConfig.websearch?.providers?.opencode?.timeout ??
|
||||
60,
|
||||
},
|
||||
}
|
||||
: existingConfig.websearch?.providers,
|
||||
};
|
||||
|
||||
saveUnifiedConfig(existingConfig);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
websearch: existingConfig.websearch,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/websearch/status - Get WebSearch status
|
||||
* Returns: { geminiCli, grokCli, opencodeCli, readiness }
|
||||
*/
|
||||
apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const geminiCli = getGeminiCliStatus();
|
||||
const grokCli = getGrokCliStatus();
|
||||
const opencodeCli = getOpenCodeCliStatus();
|
||||
const readiness = getWebSearchReadiness();
|
||||
|
||||
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,
|
||||
},
|
||||
readiness: {
|
||||
status: readiness.readiness,
|
||||
message: readiness.message,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
+10
-20
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* Graceful Shutdown Handler
|
||||
* Shutdown Handler
|
||||
*
|
||||
* Handles SIGINT/SIGTERM signals to gracefully close WebSocket connections
|
||||
* and HTTP server before process exit.
|
||||
* Handles SIGINT/SIGTERM signals to close server and exit immediately.
|
||||
* No graceful waiting - config dashboard doesn't need it.
|
||||
*/
|
||||
|
||||
import { Server as HTTPServer } from 'http';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { ok, info, warn } from '../utils/ui';
|
||||
|
||||
const SHUTDOWN_TIMEOUT = 10_000; // 10 seconds
|
||||
import { ok } from '../utils/ui';
|
||||
|
||||
/**
|
||||
* Setup graceful shutdown handlers for SIGINT and SIGTERM
|
||||
* Setup shutdown handlers for SIGINT and SIGTERM
|
||||
*/
|
||||
export function setupGracefulShutdown(
|
||||
server: HTTPServer,
|
||||
@@ -20,24 +18,16 @@ export function setupGracefulShutdown(
|
||||
cleanup?: () => void
|
||||
): void {
|
||||
const shutdown = () => {
|
||||
console.log('\n' + info('Shutting down gracefully...'));
|
||||
console.log('\n' + ok('Shutting down...'));
|
||||
|
||||
// Run cleanup first (closes file watchers + WebSocket clients)
|
||||
// Run cleanup (closes file watchers + WebSocket clients)
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
// Close HTTP server
|
||||
server.close(() => {
|
||||
console.log(ok('Server closed'));
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force shutdown if graceful shutdown takes too long
|
||||
setTimeout(() => {
|
||||
console.log(warn('Force shutdown (timeout exceeded)'));
|
||||
process.exit(1);
|
||||
}, SHUTDOWN_TIMEOUT);
|
||||
// Close server and exit immediately
|
||||
server.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Unit tests for MCP Manager module
|
||||
*
|
||||
* Tests web search MCP configuration logic without filesystem dependencies.
|
||||
* These tests focus on the pure logic functions that can be tested without
|
||||
* modifying the actual config files.
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Test helper: Simulate hasWebSearch detection logic
|
||||
* This matches the logic in mcp-manager.ts hasMcpWebSearch()
|
||||
*/
|
||||
function detectWebSearchMcp(mcpServers: Record<string, unknown>): boolean {
|
||||
return Object.keys(mcpServers).some((key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
return (
|
||||
lowerKey.includes('web-search') ||
|
||||
lowerKey.includes('websearch') ||
|
||||
lowerKey.includes('tavily') ||
|
||||
lowerKey.includes('brave')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test helper: Simulate provider configuration logic
|
||||
* Returns which providers would be added given config and env
|
||||
*/
|
||||
function getProvidersToAdd(
|
||||
wsConfig: { enabled: boolean; provider: string; fallback: boolean },
|
||||
hasExistingWebSearch: boolean,
|
||||
apiKeys: { brave?: string; tavily?: string }
|
||||
): string[] {
|
||||
if (!wsConfig.enabled) return [];
|
||||
if (hasExistingWebSearch) return [];
|
||||
|
||||
const providers: string[] = [];
|
||||
|
||||
if (wsConfig.provider === 'auto') {
|
||||
providers.push('web-search-prime');
|
||||
if (wsConfig.fallback) {
|
||||
if (apiKeys.brave) providers.push('brave-search');
|
||||
if (apiKeys.tavily) providers.push('tavily');
|
||||
}
|
||||
} else if (wsConfig.provider === 'web-search-prime') {
|
||||
providers.push('web-search-prime');
|
||||
} else if (wsConfig.provider === 'brave') {
|
||||
if (apiKeys.brave) {
|
||||
providers.push('brave-search');
|
||||
} else if (wsConfig.fallback) {
|
||||
// Fallback chain
|
||||
providers.push('web-search-prime');
|
||||
if (apiKeys.tavily) providers.push('tavily');
|
||||
}
|
||||
} else if (wsConfig.provider === 'tavily') {
|
||||
if (apiKeys.tavily) {
|
||||
providers.push('tavily');
|
||||
} else if (wsConfig.fallback) {
|
||||
// Fallback chain
|
||||
providers.push('web-search-prime');
|
||||
if (apiKeys.brave) providers.push('brave-search');
|
||||
}
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
describe('mcp-manager logic', () => {
|
||||
describe('web search detection', () => {
|
||||
it('should detect web-search-prime', () => {
|
||||
const servers = { 'web-search-prime': { type: 'http' } };
|
||||
expect(detectWebSearchMcp(servers)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect brave-search', () => {
|
||||
const servers = { 'brave-search': { type: 'stdio' } };
|
||||
expect(detectWebSearchMcp(servers)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect tavily', () => {
|
||||
const servers = { tavily: { type: 'stdio' } };
|
||||
expect(detectWebSearchMcp(servers)).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
expect(detectWebSearchMcp({ 'WebSearch-Custom': {} })).toBe(true);
|
||||
expect(detectWebSearchMcp({ 'WEB-SEARCH': {} })).toBe(true);
|
||||
expect(detectWebSearchMcp({ TAVILY: {} })).toBe(true);
|
||||
expect(detectWebSearchMcp({ 'Brave-Search': {} })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect unrelated MCPs', () => {
|
||||
expect(detectWebSearchMcp({ 'my-custom-mcp': {} })).toBe(false);
|
||||
expect(detectWebSearchMcp({ 'filesystem': {} })).toBe(false);
|
||||
expect(detectWebSearchMcp({ 'github-copilot': {} })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty servers', () => {
|
||||
expect(detectWebSearchMcp({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider selection logic', () => {
|
||||
const defaultConfig = { enabled: true, provider: 'auto', fallback: true };
|
||||
const noApiKeys = {};
|
||||
const braveOnly = { brave: 'test-key' };
|
||||
const tavilyOnly = { tavily: 'test-key' };
|
||||
const bothKeys = { brave: 'brave-key', tavily: 'tavily-key' };
|
||||
|
||||
it('should add web-search-prime in auto mode', () => {
|
||||
const providers = getProvidersToAdd(defaultConfig, false, noApiKeys);
|
||||
expect(providers).toContain('web-search-prime');
|
||||
});
|
||||
|
||||
it('should add brave-search when API key available in auto mode', () => {
|
||||
const providers = getProvidersToAdd(defaultConfig, false, braveOnly);
|
||||
expect(providers).toContain('web-search-prime');
|
||||
expect(providers).toContain('brave-search');
|
||||
});
|
||||
|
||||
it('should add tavily when API key available in auto mode', () => {
|
||||
const providers = getProvidersToAdd(defaultConfig, false, tavilyOnly);
|
||||
expect(providers).toContain('web-search-prime');
|
||||
expect(providers).toContain('tavily');
|
||||
});
|
||||
|
||||
it('should add all providers when both API keys available', () => {
|
||||
const providers = getProvidersToAdd(defaultConfig, false, bothKeys);
|
||||
expect(providers).toContain('web-search-prime');
|
||||
expect(providers).toContain('brave-search');
|
||||
expect(providers).toContain('tavily');
|
||||
});
|
||||
|
||||
it('should not add fallbacks when fallback=false', () => {
|
||||
const config = { enabled: true, provider: 'auto', fallback: false };
|
||||
const providers = getProvidersToAdd(config, false, bothKeys);
|
||||
expect(providers).toEqual(['web-search-prime']);
|
||||
});
|
||||
|
||||
it('should skip when disabled', () => {
|
||||
const config = { enabled: false, provider: 'auto', fallback: true };
|
||||
const providers = getProvidersToAdd(config, false, bothKeys);
|
||||
expect(providers).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip when web search already exists', () => {
|
||||
const providers = getProvidersToAdd(defaultConfig, true, bothKeys);
|
||||
expect(providers).toEqual([]);
|
||||
});
|
||||
|
||||
it('should use specific provider when configured', () => {
|
||||
const config = { enabled: true, provider: 'brave', fallback: false };
|
||||
const providers = getProvidersToAdd(config, false, braveOnly);
|
||||
expect(providers).toEqual(['brave-search']);
|
||||
});
|
||||
|
||||
it('should fallback when specific provider not available', () => {
|
||||
const config = { enabled: true, provider: 'tavily', fallback: true };
|
||||
// No tavily key, should fallback
|
||||
const providers = getProvidersToAdd(config, false, braveOnly);
|
||||
expect(providers).toContain('web-search-prime');
|
||||
expect(providers).toContain('brave-search');
|
||||
expect(providers).not.toContain('tavily');
|
||||
});
|
||||
|
||||
it('should return empty when provider unavailable and fallback=false', () => {
|
||||
const config = { enabled: true, provider: 'brave', fallback: false };
|
||||
// No brave key and no fallback
|
||||
const providers = getProvidersToAdd(config, false, noApiKeys);
|
||||
expect(providers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP server config structures', () => {
|
||||
it('should define correct web-search-prime structure', () => {
|
||||
const webSearchPrimeConfig = {
|
||||
type: 'http',
|
||||
url: 'https://api.z.ai/api/mcp/web_search_prime/mcp',
|
||||
headers: {},
|
||||
};
|
||||
|
||||
expect(webSearchPrimeConfig.type).toBe('http');
|
||||
expect(webSearchPrimeConfig.url).toContain('web_search_prime');
|
||||
});
|
||||
|
||||
it('should define correct brave-search structure', () => {
|
||||
const braveConfig = {
|
||||
type: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-brave-search'],
|
||||
env: { BRAVE_API_KEY: 'test-key' },
|
||||
};
|
||||
|
||||
expect(braveConfig.type).toBe('stdio');
|
||||
expect(braveConfig.command).toBe('npx');
|
||||
expect(braveConfig.args).toContain('@modelcontextprotocol/server-brave-search');
|
||||
expect(braveConfig.env.BRAVE_API_KEY).toBe('test-key');
|
||||
});
|
||||
|
||||
it('should define correct tavily structure', () => {
|
||||
const tavilyConfig = {
|
||||
type: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', '@tavily/mcp-server'],
|
||||
env: { TAVILY_API_KEY: 'test-key' },
|
||||
};
|
||||
|
||||
expect(tavilyConfig.type).toBe('stdio');
|
||||
expect(tavilyConfig.args).toContain('@tavily/mcp-server');
|
||||
expect(tavilyConfig.env.TAVILY_API_KEY).toBe('test-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook configuration', () => {
|
||||
it('should define correct PreToolUse hook structure', () => {
|
||||
const hookConfig = {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'WebSearch',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/path/to/hook.cjs"',
|
||||
timeout: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(hookConfig.PreToolUse).toBeDefined();
|
||||
expect(hookConfig.PreToolUse[0].matcher).toBe('WebSearch');
|
||||
expect(hookConfig.PreToolUse[0].hooks[0].type).toBe('command');
|
||||
expect(hookConfig.PreToolUse[0].hooks[0].timeout).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP config file path', () => {
|
||||
it('should be located in .claude directory', () => {
|
||||
// The MCP config path should follow this pattern
|
||||
const expectedPathPattern = '.claude/.mcp.json';
|
||||
const testPath = '/home/user/.claude/.mcp.json';
|
||||
expect(testPath).toContain(expectedPathPattern);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebSearch config defaults', () => {
|
||||
it('should have correct default values', () => {
|
||||
const defaults = {
|
||||
enabled: true,
|
||||
provider: 'auto',
|
||||
fallback: true,
|
||||
};
|
||||
|
||||
expect(defaults.enabled).toBe(true);
|
||||
expect(defaults.provider).toBe('auto');
|
||||
expect(defaults.fallback).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate provider options', () => {
|
||||
const validProviders = ['auto', 'web-search-prime', 'brave', 'tavily'];
|
||||
expect(validProviders).toContain('auto');
|
||||
expect(validProviders).toContain('web-search-prime');
|
||||
expect(validProviders).toContain('brave');
|
||||
expect(validProviders).toContain('tavily');
|
||||
});
|
||||
});
|
||||
});
|
||||
+74
-2
@@ -5,7 +5,10 @@
|
||||
"name": "ui",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@nivo/core": "^0.99.0",
|
||||
"@nivo/sankey": "^0.99.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -15,6 +18,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
@@ -201,6 +205,20 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="],
|
||||
|
||||
"@nivo/core": ["@nivo/core@0.99.0", "", { "dependencies": { "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-shape": "^3.1.6", "d3-color": "^3.1.0", "d3-format": "^1.4.4", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "d3-shape": "^3.2.0", "d3-time-format": "^3.0.0", "lodash": "^4.17.21", "react-virtualized-auto-sizer": "^1.0.26", "use-debounce": "^10.0.4" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g=="],
|
||||
|
||||
"@nivo/legends": ["@nivo/legends@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-scale": "^4.0.8", "d3-scale": "^4.0.2" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA=="],
|
||||
|
||||
"@nivo/sankey": ["@nivo/sankey@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-sankey": "^0.11.2", "@types/d3-shape": "^3.1.6", "d3-sankey": "^0.12.3", "d3-shape": "^3.2.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw=="],
|
||||
|
||||
"@nivo/text": ["@nivo/text@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ=="],
|
||||
|
||||
"@nivo/theming": ["@nivo/theming@0.99.0", "", { "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg=="],
|
||||
|
||||
"@nivo/tooltip": ["@nivo/tooltip@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
@@ -209,6 +227,8 @@
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
|
||||
|
||||
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
@@ -255,6 +275,8 @@
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||
|
||||
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
|
||||
@@ -279,6 +301,18 @@
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@react-spring/animated": ["@react-spring/animated@9.4.5", "", { "dependencies": { "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-KWqrtvJSMx6Fj9nMJkhTwM9r6LIriExDRV6YHZV9HKQsaolUFppgkOXpC+rsL1JEtEvKv6EkLLmSqHTnuYjiIA=="],
|
||||
|
||||
"@react-spring/core": ["@react-spring/core@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/rafz": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-83u3FzfQmGMJFwZLAJSwF24/ZJctwUkWtyPD7KYtNagrFeQKUH1I05ZuhmCmqW+2w1KDW1SFWQ43RawqfXKiiQ=="],
|
||||
|
||||
"@react-spring/rafz": ["@react-spring/rafz@9.4.5", "", {}, "sha512-swGsutMwvnoyTRxvqhfJBtGM8Ipx6ks0RkIpNX9F/U7XmyPvBMGd3GgX/mqxZUpdlsuI1zr/jiYw+GXZxAlLcQ=="],
|
||||
|
||||
"@react-spring/shared": ["@react-spring/shared@9.4.5", "", { "dependencies": { "@react-spring/rafz": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-JhMh3nFKsqyag0KM5IIM8BQANGscTdd0mMv3BXsUiMZrcjQTskyfnv5qxEeGWbJGGar52qr5kHuBHtCjQOzniA=="],
|
||||
|
||||
"@react-spring/types": ["@react-spring/types@9.4.5", "", {}, "sha512-mpRIamoHwql0ogxEUh9yr4TP0xU5CWyZxVQeccGkHHF8kPMErtDXJlxyo0lj+telRF35XNihtPTWoflqtyARmg=="],
|
||||
|
||||
"@react-spring/web": ["@react-spring/web@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/core": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0", "react-dom": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-NGAkOtKmOzDEctL7MzRlQGv24sRce++0xAY7KlcxmeVkR7LRSGkoXHaIfm9ObzxPMcPHQYQhf3+X9jepIFNHQA=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="],
|
||||
@@ -383,8 +417,12 @@
|
||||
|
||||
"@types/d3-path": ["@types/d3-path@1.0.11", "", {}, "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw=="],
|
||||
|
||||
"@types/d3-sankey": ["@types/d3-sankey@0.11.2", "", { "dependencies": { "@types/d3-shape": "^1" } }, "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ=="],
|
||||
|
||||
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||
|
||||
"@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
|
||||
|
||||
"@types/d3-shape": ["@types/d3-shape@1.3.12", "", { "dependencies": { "@types/d3-path": "^1" } }, "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q=="],
|
||||
|
||||
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||
@@ -479,19 +517,23 @@
|
||||
|
||||
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||
|
||||
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
|
||||
"d3-format": ["d3-format@1.4.5", "", {}, "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ=="],
|
||||
|
||||
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||
|
||||
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||
|
||||
"d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="],
|
||||
|
||||
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||
|
||||
"d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
|
||||
|
||||
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||
|
||||
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||
|
||||
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||
"d3-time-format": ["d3-time-format@3.0.0", "", { "dependencies": { "d3-time": "1 - 2" } }, "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
@@ -725,6 +767,8 @@
|
||||
|
||||
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
|
||||
|
||||
"react-virtualized-auto-sizer": ["react-virtualized-auto-sizer@1.0.26", "", { "peerDependencies": { "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A=="],
|
||||
|
||||
"react-virtuoso": ["react-virtuoso@4.17.0", "", { "peerDependencies": { "react": ">=16 || >=17 || >= 18 || >= 19", "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w=="],
|
||||
|
||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
@@ -783,6 +827,8 @@
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-debounce": ["use-debounce@10.0.6", "", { "peerDependencies": { "react": "*" } }, "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||
@@ -807,6 +853,10 @@
|
||||
|
||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
"@nivo/core/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
|
||||
|
||||
"@nivo/sankey/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
@@ -845,12 +895,34 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
|
||||
|
||||
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
|
||||
|
||||
"d3-scale/d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
|
||||
|
||||
"d3-scale/d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||
|
||||
"d3-time-format/d3-time": ["d3-time@2.1.1", "", { "dependencies": { "d3-array": "2" } }, "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"victory-vendor/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
|
||||
|
||||
"@nivo/core/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||
|
||||
"@nivo/sankey/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
||||
|
||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||
|
||||
"d3-time-format/d3-time/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
|
||||
|
||||
"victory-vendor/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||
|
||||
"d3-time-format/d3-time/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@nivo/core": "^0.99.0",
|
||||
"@nivo/sankey": "^0.99.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -26,6 +29,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* Account Flow Visualization
|
||||
* Custom SVG bezier curve visualization showing request flow from accounts to providers
|
||||
* Inspired by modern dark theme design with glass panels and glow effects
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ProviderIcon } from '@/components/provider-icon';
|
||||
import { PROVIDER_COLORS } from '@/lib/provider-config';
|
||||
import { STATUS_COLORS } from '@/lib/utils';
|
||||
import {
|
||||
ChevronRight,
|
||||
X,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Activity,
|
||||
GripVertical,
|
||||
} from 'lucide-react';
|
||||
|
||||
/** Position offset for draggable cards */
|
||||
interface DragOffset {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface AccountData {
|
||||
id: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
lastUsedAt?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ProviderData {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
totalRequests: number;
|
||||
accounts: AccountData[];
|
||||
}
|
||||
|
||||
interface AccountFlowVizProps {
|
||||
providerData: ProviderData;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
interface ConnectionEvent {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
accountEmail: string;
|
||||
status: 'success' | 'failed' | 'pending';
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
/** Generate connection events from real account data */
|
||||
function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] {
|
||||
const events: ConnectionEvent[] = [];
|
||||
|
||||
accounts.forEach((account) => {
|
||||
// Only show events for accounts that have actual request data
|
||||
const hasActivity = account.successCount > 0 || account.failureCount > 0;
|
||||
if (!hasActivity) return;
|
||||
|
||||
// Create a single consolidated event per account showing its current status
|
||||
const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date();
|
||||
const hasFailures = account.failureCount > 0;
|
||||
|
||||
events.push({
|
||||
id: `${account.id}-status`,
|
||||
timestamp: lastUsed,
|
||||
accountEmail: account.email,
|
||||
status: hasFailures && account.failureCount > account.successCount ? 'failed' : 'success',
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
return events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
}
|
||||
|
||||
/** Format timestamp for timeline display */
|
||||
function formatTimelineTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'now';
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
return `${Math.floor(diffHours / 24)}d`;
|
||||
}
|
||||
|
||||
/** Connection Timeline Component - right sidebar panel */
|
||||
function ConnectionTimeline({ events }: { events: ConnectionEvent[] }) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center rounded-xl bg-muted/20 dark:bg-zinc-900/40 border border-border/30 dark:border-white/[0.05]">
|
||||
<div className="text-xs text-muted-foreground font-mono">No recent connections</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3 px-2">
|
||||
<Activity className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Connection Timeline
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline container */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 rounded-xl p-4 overflow-y-auto',
|
||||
'bg-muted/20 dark:bg-zinc-900/40 backdrop-blur-sm',
|
||||
'border border-border/30 dark:border-white/[0.05]'
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-border/50 dark:bg-white/[0.08]" />
|
||||
|
||||
{/* Events */}
|
||||
<div className="space-y-3">
|
||||
{events.slice(0, 8).map((event) => {
|
||||
const statusColor =
|
||||
event.status === 'success'
|
||||
? STATUS_COLORS.success
|
||||
: event.status === 'failed'
|
||||
? STATUS_COLORS.failed
|
||||
: STATUS_COLORS.degraded;
|
||||
|
||||
return (
|
||||
<div key={event.id} className="relative flex items-start gap-3 pl-1">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 w-3.5 h-3.5 rounded-full flex-shrink-0 mt-0.5',
|
||||
'ring-2 ring-background dark:ring-zinc-950'
|
||||
)}
|
||||
style={{ backgroundColor: statusColor }}
|
||||
/>
|
||||
|
||||
{/* Event content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-mono text-foreground truncate">
|
||||
{cleanEmail(event.accountEmail)}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground font-mono flex-shrink-0">
|
||||
{formatTimelineTime(event.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span
|
||||
className="text-[9px] font-medium uppercase"
|
||||
style={{ color: statusColor }}
|
||||
>
|
||||
{event.status}
|
||||
</span>
|
||||
{event.latencyMs && (
|
||||
<span className="text-[9px] text-muted-foreground font-mono">
|
||||
{event.latencyMs}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Show more indicator */}
|
||||
{events.length > 8 && (
|
||||
<div className="mt-3 pt-2 border-t border-border/30 dark:border-white/[0.05]">
|
||||
<span className="text-[9px] text-muted-foreground font-mono">
|
||||
+{events.length - 8} more events
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Strip common email domains for cleaner display */
|
||||
function cleanEmail(email: string): string {
|
||||
return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, '');
|
||||
}
|
||||
|
||||
function getTimeAgo(dateStr?: string): string {
|
||||
if (!dateStr) return 'never';
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return 'unknown';
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
if (diffMs < 0) return 'just now';
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [hoveredAccount, setHoveredAccount] = useState<number | null>(null);
|
||||
const [selectedAccount, setSelectedAccount] = useState<AccountData | null>(null);
|
||||
const [paths, setPaths] = useState<string[]>([]);
|
||||
|
||||
// Drag state for all cards (account IDs + 'provider')
|
||||
const [dragOffsets, setDragOffsets] = useState<Record<string, DragOffset>>({});
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Pulse state: account IDs that are currently pulsing
|
||||
const [pulsingAccounts, setPulsingAccounts] = useState<Set<string>>(new Set());
|
||||
// Store previous counts to detect changes
|
||||
const [prevCounts, setPrevCounts] = useState<Record<string, number>>({});
|
||||
|
||||
const { accounts } = providerData;
|
||||
const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1);
|
||||
const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0);
|
||||
|
||||
// Detect new activity and trigger pulse (runs when accounts data changes)
|
||||
useEffect(() => {
|
||||
const newPulsing = new Set<string>();
|
||||
const newCounts: Record<string, number> = {};
|
||||
|
||||
accounts.forEach((account) => {
|
||||
const currentCount = account.successCount + account.failureCount;
|
||||
newCounts[account.id] = currentCount;
|
||||
const prev = prevCounts[account.id] ?? 0;
|
||||
|
||||
if (currentCount > prev && prev > 0) {
|
||||
newPulsing.add(account.id);
|
||||
}
|
||||
});
|
||||
|
||||
setPrevCounts(newCounts);
|
||||
|
||||
if (newPulsing.size > 0) {
|
||||
setPulsingAccounts(newPulsing);
|
||||
// Clear pulse after animation completes (match CSS animation duration)
|
||||
const timer = setTimeout(() => setPulsingAccounts(new Set()), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accounts]);
|
||||
|
||||
// Generate connection events for timeline
|
||||
const connectionEvents = useMemo(() => generateConnectionEvents(accounts), [accounts]);
|
||||
|
||||
// Calculate SVG paths for bezier curves
|
||||
const calculatePaths = useCallback(() => {
|
||||
if (!containerRef.current || !svgRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const svg = svgRef.current;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
|
||||
const destEl = container.querySelector('[data-provider-node]');
|
||||
if (!destEl) return;
|
||||
const destRect = destEl.getBoundingClientRect();
|
||||
|
||||
const newPaths: string[] = [];
|
||||
|
||||
accounts.forEach((_, i) => {
|
||||
const sourceEl = container.querySelector(`[data-account-index="${i}"]`);
|
||||
if (!sourceEl) return;
|
||||
const sourceRect = sourceEl.getBoundingClientRect();
|
||||
|
||||
// Determine if this account is on the right side
|
||||
const isRightSide = sourceEl.hasAttribute('data-right-side');
|
||||
|
||||
let startX: number, startY: number, destX: number, destY: number;
|
||||
|
||||
if (isRightSide) {
|
||||
// Right side account: connect from left edge to right edge of provider
|
||||
startX = sourceRect.left - svgRect.left;
|
||||
startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
|
||||
destX = destRect.right - svgRect.left;
|
||||
destY = destRect.top + destRect.height / 2 - svgRect.top;
|
||||
} else {
|
||||
// Left side account: connect from right edge to left edge of provider
|
||||
startX = sourceRect.right - svgRect.left;
|
||||
startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
|
||||
destX = destRect.left - svgRect.left;
|
||||
destY = destRect.top + destRect.height / 2 - svgRect.top;
|
||||
}
|
||||
|
||||
// Bezier control points
|
||||
const cp1X = startX + (destX - startX) * 0.5;
|
||||
const cp1Y = startY;
|
||||
const cp2X = destX - (destX - startX) * 0.5;
|
||||
const cp2Y = destY;
|
||||
|
||||
newPaths.push(`M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${destX} ${destY}`);
|
||||
});
|
||||
|
||||
setPaths(newPaths);
|
||||
}, [accounts]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial calculation after render
|
||||
const timer = setTimeout(calculatePaths, 50);
|
||||
window.addEventListener('resize', calculatePaths);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('resize', calculatePaths);
|
||||
};
|
||||
}, [calculatePaths]);
|
||||
|
||||
const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280';
|
||||
|
||||
// Split accounts into left and right groups (when > 1 account, balance both sides)
|
||||
const { leftAccounts, rightAccounts } = useMemo(() => {
|
||||
if (accounts.length <= 1) {
|
||||
return { leftAccounts: accounts, rightAccounts: [] };
|
||||
}
|
||||
const mid = Math.ceil(accounts.length / 2);
|
||||
return {
|
||||
leftAccounts: accounts.slice(0, mid),
|
||||
rightAccounts: accounts.slice(mid),
|
||||
};
|
||||
}, [accounts]);
|
||||
|
||||
const hasRightAccounts = rightAccounts.length > 0;
|
||||
|
||||
// Drag handlers
|
||||
const handlePointerDown = useCallback(
|
||||
(id: string, e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
const offset = dragOffsets[id] || { x: 0, y: 0 };
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY, offsetX: offset.x, offsetY: offset.y };
|
||||
setDraggingId(id);
|
||||
},
|
||||
[dragOffsets]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!draggingId || !dragStartRef.current) return;
|
||||
const start = dragStartRef.current;
|
||||
const dx = e.clientX - start.x;
|
||||
const dy = e.clientY - start.y;
|
||||
setDragOffsets((prev) => ({
|
||||
...prev,
|
||||
[draggingId]: {
|
||||
x: start.offsetX + dx,
|
||||
y: start.offsetY + dy,
|
||||
},
|
||||
}));
|
||||
// Recalculate paths during drag
|
||||
requestAnimationFrame(calculatePaths);
|
||||
},
|
||||
[draggingId, calculatePaths]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setDraggingId(null);
|
||||
dragStartRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Get offset for a card
|
||||
const getOffset = (id: string): DragOffset => dragOffsets[id] || { x: 0, y: 0 };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" ref={containerRef}>
|
||||
{/* Back button */}
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="self-start flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-3 h-3 rotate-180" />
|
||||
<span>Back to providers</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Main visualization area - 3 column layout: Left Accounts | Provider | Right Accounts + Timeline */}
|
||||
<div className="min-h-[320px] flex gap-4 px-4 py-6">
|
||||
{/* Flow visualization section */}
|
||||
<div className="relative flex-1 flex items-center justify-between px-4">
|
||||
{/* SVG Canvas (Background) */}
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="absolute inset-0 w-full h-full pointer-events-none z-0 overflow-visible"
|
||||
>
|
||||
<defs>
|
||||
<filter id="flow-glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
{paths.map((d, i) => {
|
||||
const account = accounts[i];
|
||||
const total = account.successCount + account.failureCount;
|
||||
const strokeWidth = Math.max(2, (total / maxRequests) * 10);
|
||||
const isHovered = hoveredAccount === i;
|
||||
const isDimmed = hoveredAccount !== null && hoveredAccount !== i;
|
||||
const isPulsing = pulsingAccounts.has(account.id);
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
{/* Base path - static connection line */}
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={account.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeOpacity={isHovered ? 0.9 : isDimmed ? 0.05 : 0.2}
|
||||
strokeLinecap="round"
|
||||
filter={isHovered ? 'url(#flow-glow)' : undefined}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
{/* Pulse layer - only shows when new activity detected */}
|
||||
{isPulsing && (
|
||||
<>
|
||||
{/* Glowing path pulse */}
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={account.color}
|
||||
strokeWidth={strokeWidth * 2}
|
||||
strokeLinecap="round"
|
||||
filter="url(#flow-glow)"
|
||||
className="animate-request-pulse"
|
||||
/>
|
||||
{/* Traveling dot along path */}
|
||||
<circle
|
||||
r={6}
|
||||
fill={account.color}
|
||||
filter="url(#flow-glow)"
|
||||
style={{
|
||||
offsetPath: `path('${d}')`,
|
||||
offsetDistance: '0%',
|
||||
animation: 'travel-dot 1.5s cubic-bezier(0.4, 0, 0.2, 1) forwards',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Left Accounts */}
|
||||
<div className="flex flex-col gap-3 z-10 w-48 justify-center flex-shrink-0">
|
||||
{leftAccounts.map((account) => {
|
||||
const originalIndex = accounts.findIndex((a) => a.id === account.id);
|
||||
const total = account.successCount + account.failureCount;
|
||||
const isHovered = hoveredAccount === originalIndex;
|
||||
const isDragging = draggingId === account.id;
|
||||
const offset = getOffset(account.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
data-account-index={originalIndex}
|
||||
onClick={() => !isDragging && setSelectedAccount(account)}
|
||||
onMouseEnter={() => setHoveredAccount(originalIndex)}
|
||||
onMouseLeave={() => setHoveredAccount(null)}
|
||||
onPointerDown={(e) => handlePointerDown(account.id, e)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
className={cn(
|
||||
'group/card relative rounded-lg p-3 pr-6 cursor-grab transition-shadow duration-200',
|
||||
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
||||
'border border-border/50 dark:border-white/[0.08]',
|
||||
'border-l-2 select-none touch-none',
|
||||
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
|
||||
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
|
||||
)}
|
||||
style={{
|
||||
borderLeftColor: account.color,
|
||||
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
|
||||
}}
|
||||
>
|
||||
{/* Drag handle indicator */}
|
||||
<GripVertical className="absolute top-1/2 left-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" />
|
||||
<div className="flex justify-between items-start mb-1 ml-3">
|
||||
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]">
|
||||
{cleanEmail(account.email)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 text-muted-foreground transition-opacity',
|
||||
isHovered ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{total.toLocaleString()} reqs
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{account.failureCount > 0 && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
|
||||
)}
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Connector Dot - Right side */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2 z-20 transition-colors border',
|
||||
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
|
||||
isHovered && 'bg-foreground dark:bg-white border-transparent'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Center Provider */}
|
||||
<div className="z-10 w-52 flex items-center flex-shrink-0">
|
||||
{(() => {
|
||||
const isDragging = draggingId === 'provider';
|
||||
const offset = getOffset('provider');
|
||||
return (
|
||||
<div
|
||||
data-provider-node
|
||||
onPointerDown={(e) => handlePointerDown('provider', e)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
className={cn(
|
||||
'group relative w-full rounded-xl p-4 cursor-grab transition-shadow duration-200',
|
||||
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
||||
'border-2 border-border/50 dark:border-white/[0.08]',
|
||||
// Idle animations: float + border glow (disabled when dragging)
|
||||
!isDragging && 'animate-subtle-float animate-border-glow',
|
||||
'select-none touch-none',
|
||||
hoveredAccount !== null && 'scale-[1.02]',
|
||||
isDragging && 'cursor-grabbing shadow-2xl scale-105 z-50'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--glow-color': `${providerColor}60`,
|
||||
borderColor: hoveredAccount !== null ? `${providerColor}80` : undefined,
|
||||
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
|
||||
|
||||
{/* Animated glow background */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-xl animate-glow-pulse pointer-events-none"
|
||||
style={{ '--glow-color': `${providerColor}30` } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
{/* Left Connector Point */}
|
||||
<div
|
||||
className="absolute top-1/2 -left-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
|
||||
style={{
|
||||
backgroundColor: providerColor,
|
||||
boxShadow: `0 0 0 4px var(--background)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Right Connector Point - only show if there are right accounts */}
|
||||
{hasRightAccounts && (
|
||||
<div
|
||||
className="absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
|
||||
style={{
|
||||
backgroundColor: providerColor,
|
||||
boxShadow: `0 0 0 4px var(--background)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mb-4 relative z-10">
|
||||
{/* Provider icon with breathing animation */}
|
||||
<div className="animate-icon-breathe">
|
||||
<ProviderIcon provider={providerData.provider} size={36} withBackground />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground tracking-tight">
|
||||
{providerData.displayName}
|
||||
</h3>
|
||||
<p className="text-[10px] text-muted-foreground font-medium uppercase">
|
||||
Provider
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 relative z-10">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Total Requests</span>
|
||||
<span className="text-foreground font-mono">
|
||||
{totalRequests.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Accounts</span>
|
||||
<span className="text-foreground font-mono">{accounts.length}</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(100, (totalRequests / (maxRequests * accounts.length)) * 100)}%`,
|
||||
backgroundColor: providerColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Right Accounts */}
|
||||
{hasRightAccounts && (
|
||||
<div className="flex flex-col gap-3 z-10 w-44 justify-center">
|
||||
{rightAccounts.map((account) => {
|
||||
const originalIndex = accounts.findIndex((a) => a.id === account.id);
|
||||
const total = account.successCount + account.failureCount;
|
||||
const isHovered = hoveredAccount === originalIndex;
|
||||
const isDragging = draggingId === account.id;
|
||||
const offset = getOffset(account.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
data-account-index={originalIndex}
|
||||
data-right-side
|
||||
onClick={() => !isDragging && setSelectedAccount(account)}
|
||||
onMouseEnter={() => setHoveredAccount(originalIndex)}
|
||||
onMouseLeave={() => setHoveredAccount(null)}
|
||||
onPointerDown={(e) => handlePointerDown(account.id, e)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
className={cn(
|
||||
'group/card relative rounded-lg p-3 pl-6 cursor-grab transition-shadow duration-200',
|
||||
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
||||
'border border-border/50 dark:border-white/[0.08]',
|
||||
'border-r-2 select-none touch-none',
|
||||
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
|
||||
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
|
||||
)}
|
||||
style={{
|
||||
borderRightColor: account.color,
|
||||
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
|
||||
}}
|
||||
>
|
||||
{/* Drag handle indicator */}
|
||||
<GripVertical className="absolute top-1/2 right-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" />
|
||||
<div className="flex justify-between items-start mb-1 mr-3">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 text-muted-foreground transition-opacity rotate-180',
|
||||
isHovered ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]">
|
||||
{cleanEmail(account.email)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
|
||||
{account.failureCount > 0 && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{total.toLocaleString()} reqs
|
||||
</span>
|
||||
</div>
|
||||
{/* Connector Dot - Left side */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-1/2 -left-1.5 w-3 h-3 rounded-full transform -translate-y-1/2 z-20 transition-colors border',
|
||||
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
|
||||
isHovered && 'bg-foreground dark:bg-white border-transparent'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Section: Connection Timeline - Fixed compact width */}
|
||||
<div className="w-56 flex-shrink-0">
|
||||
<ConnectionTimeline events={connectionEvents} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Panel - slides in from bottom, pushes content */}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-300 ease-out',
|
||||
selectedAccount ? 'max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className={cn('bg-card dark:bg-zinc-950 border-t border-border', 'p-4')}>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setSelectedAccount(null)}
|
||||
className="absolute top-0 right-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{selectedAccount && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{/* Account Info */}
|
||||
<div className="border-r border-border pr-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: selectedAccount.color }}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-foreground tracking-tight truncate">
|
||||
{cleanEmail(selectedAccount.email)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||
Source Account
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="bg-muted/30 dark:bg-zinc-900/50 rounded-lg p-3 border border-border">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-700 dark:text-emerald-500" />
|
||||
<span>SUCCESSFUL</span>
|
||||
</div>
|
||||
<div className="text-xl font-mono text-emerald-700 dark:text-emerald-500 tracking-tighter">
|
||||
{selectedAccount.successCount.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 dark:bg-zinc-900/50 rounded-lg p-3 border border-border">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
|
||||
<XCircle className="w-3 h-3 text-red-700 dark:text-red-500" />
|
||||
<span>FAILED</span>
|
||||
</div>
|
||||
<div className="text-xl font-mono text-red-700 dark:text-red-500 tracking-tighter">
|
||||
{selectedAccount.failureCount.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 dark:bg-zinc-900/50 rounded-lg p-3 border border-border">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>LAST SYNC</span>
|
||||
</div>
|
||||
<div className="text-sm font-mono text-foreground mt-1">
|
||||
{getTimeAgo(selectedAccount.lastUsedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* Auth Monitor Component with Account Flow Visualization
|
||||
* Shows request flow from accounts to providers using custom SVG bezier curves
|
||||
* Uses glass panel aesthetic with hover interactions and glow effects
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats';
|
||||
import { cn, STATUS_COLORS } from '@/lib/utils';
|
||||
import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ProviderIcon } from '@/components/provider-icon';
|
||||
import { AccountFlowViz } from '@/components/account-flow-viz';
|
||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||
import { Activity, CheckCircle2, XCircle, ChevronRight, Radio } from 'lucide-react';
|
||||
|
||||
interface AccountRow {
|
||||
id: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
displayName: string;
|
||||
isDefault: boolean;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
lastUsedAt?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ProviderStats {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
totalRequests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
accountCount: number;
|
||||
accounts: AccountRow[];
|
||||
}
|
||||
|
||||
function getSuccessRate(success: number, failure: number): number {
|
||||
const total = success + failure;
|
||||
if (total === 0) return 100;
|
||||
return Math.round((success / total) * 100);
|
||||
}
|
||||
|
||||
/** Strip common email domains for cleaner display */
|
||||
function cleanEmail(email: string): string {
|
||||
return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, '');
|
||||
}
|
||||
|
||||
// Vibrant colors for account segments - darker for light theme contrast
|
||||
const ACCOUNT_COLORS = [
|
||||
'#1e6091', // Deep Cerulean (was #277da1)
|
||||
'#2d8a6e', // Deep Seaweed (was #43aa8b)
|
||||
'#d4a012', // Dark Tuscan (was #f9c74f)
|
||||
'#c92a2d', // Deep Strawberry (was #f94144)
|
||||
'#c45a1a', // Deep Pumpkin (was #f3722c)
|
||||
'#6b9c4d', // Dark Willow (was #90be6d)
|
||||
'#3d5a73', // Deep Blue Slate (was #577590)
|
||||
'#cc7614', // Dark Carrot (was #f8961e)
|
||||
'#3a7371', // Deep Cyan (was #4d908e)
|
||||
'#7c5fc4', // Deep Purple (was #a78bfa)
|
||||
];
|
||||
|
||||
/** Enhanced live pulse indicator with multi-ring animation */
|
||||
function LivePulse() {
|
||||
return (
|
||||
<div className="relative flex items-center justify-center w-5 h-5">
|
||||
{/* Outer ping ring */}
|
||||
<div
|
||||
className="absolute w-4 h-4 rounded-full animate-ping opacity-20"
|
||||
style={{ backgroundColor: STATUS_COLORS.success }}
|
||||
/>
|
||||
{/* Middle pulse ring */}
|
||||
<div
|
||||
className="absolute w-3 h-3 rounded-full animate-pulse opacity-40"
|
||||
style={{ backgroundColor: STATUS_COLORS.success }}
|
||||
/>
|
||||
{/* Inner solid dot */}
|
||||
<div
|
||||
className="relative w-2 h-2 rounded-full z-10"
|
||||
style={{ backgroundColor: STATUS_COLORS.success }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline success/failure badge for provider cards */
|
||||
function InlineStatsBadge({ success, failure }: { success: number; failure: number }) {
|
||||
if (success === 0 && failure === 0) {
|
||||
return <span className="text-[9px] text-muted-foreground/50 font-mono">no activity</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-700 dark:text-emerald-500" />
|
||||
<span className="text-[10px] font-mono font-medium text-emerald-700 dark:text-emerald-500">
|
||||
{success.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{failure > 0 && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<XCircle className="w-3 h-3 text-red-700 dark:text-red-500" />
|
||||
<span className="text-[10px] font-mono font-medium text-red-700 dark:text-red-500">
|
||||
{failure.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthMonitor() {
|
||||
const { data, isLoading, error } = useCliproxyAuth();
|
||||
const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats();
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
|
||||
const [timeSinceUpdate, setTimeSinceUpdate] = useState('');
|
||||
|
||||
// Live countdown showing time since last data update
|
||||
useEffect(() => {
|
||||
if (!dataUpdatedAt) return;
|
||||
const updateTime = () => {
|
||||
const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000);
|
||||
if (diff < 60) {
|
||||
setTimeSinceUpdate(`${diff}s ago`);
|
||||
} else {
|
||||
setTimeSinceUpdate(`${Math.floor(diff / 60)}m ago`);
|
||||
}
|
||||
};
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [dataUpdatedAt]);
|
||||
|
||||
// Build a map of account email -> usage stats from CLIProxy
|
||||
const accountStatsMap = useMemo(() => {
|
||||
if (!statsData?.accountStats) return new Map<string, AccountUsageStats>();
|
||||
return new Map(Object.entries(statsData.accountStats));
|
||||
}, [statsData?.accountStats]);
|
||||
|
||||
// Transform auth status data into account rows
|
||||
const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => {
|
||||
if (!data?.authStatus) {
|
||||
return {
|
||||
accounts: [],
|
||||
totalSuccess: 0,
|
||||
totalFailure: 0,
|
||||
totalRequests: 0,
|
||||
providerStats: [],
|
||||
};
|
||||
}
|
||||
|
||||
const accountsList: AccountRow[] = [];
|
||||
const providerMap = new Map<
|
||||
string,
|
||||
{ success: number; failure: number; accounts: AccountRow[] }
|
||||
>();
|
||||
let tSuccess = 0;
|
||||
let tFailure = 0;
|
||||
let colorIndex = 0;
|
||||
|
||||
data.authStatus.forEach((status: AuthStatus) => {
|
||||
const providerKey = status.provider;
|
||||
if (!providerMap.has(providerKey)) {
|
||||
providerMap.set(providerKey, { success: 0, failure: 0, accounts: [] });
|
||||
}
|
||||
const providerData = providerMap.get(providerKey);
|
||||
if (!providerData) return;
|
||||
|
||||
status.accounts?.forEach((account: OAuthAccount) => {
|
||||
// Get real stats from CLIProxy - try email first, then id
|
||||
const accountEmail = account.email || account.id;
|
||||
const realStats = accountStatsMap.get(accountEmail);
|
||||
const success = realStats?.successCount ?? 0;
|
||||
const failure = realStats?.failureCount ?? 0;
|
||||
tSuccess += success;
|
||||
tFailure += failure;
|
||||
providerData.success += success;
|
||||
providerData.failure += failure;
|
||||
|
||||
const row: AccountRow = {
|
||||
id: account.id,
|
||||
email: account.email || account.id,
|
||||
provider: status.provider,
|
||||
displayName: status.displayName,
|
||||
isDefault: account.isDefault,
|
||||
successCount: success,
|
||||
failureCount: failure,
|
||||
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
|
||||
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
|
||||
};
|
||||
accountsList.push(row);
|
||||
providerData.accounts.push(row);
|
||||
colorIndex++;
|
||||
});
|
||||
});
|
||||
|
||||
// Build provider stats array
|
||||
const providerStatsArr: ProviderStats[] = [];
|
||||
providerMap.forEach((pData, provider) => {
|
||||
if (pData.accounts.length === 0) return;
|
||||
providerStatsArr.push({
|
||||
provider,
|
||||
displayName: getProviderDisplayName(provider),
|
||||
totalRequests: pData.success + pData.failure,
|
||||
successCount: pData.success,
|
||||
failureCount: pData.failure,
|
||||
accountCount: pData.accounts.length,
|
||||
accounts: pData.accounts,
|
||||
});
|
||||
});
|
||||
providerStatsArr.sort((a, b) => b.totalRequests - a.totalRequests);
|
||||
|
||||
return {
|
||||
accounts: accountsList,
|
||||
totalSuccess: tSuccess,
|
||||
totalFailure: tFailure,
|
||||
totalRequests: tSuccess + tFailure,
|
||||
providerStats: providerStatsArr,
|
||||
};
|
||||
}, [data?.authStatus, accountStatsMap]);
|
||||
|
||||
const overallSuccessRate =
|
||||
totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100;
|
||||
|
||||
// Get selected provider data for detail view
|
||||
const selectedProviderData = selectedProvider
|
||||
? providerStats.find((ps) => ps.provider === selectedProvider)
|
||||
: null;
|
||||
|
||||
if (isLoading || statsLoading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex gap-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-16 flex-1 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data?.authStatus || accounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] text-foreground bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
|
||||
{/* Enhanced Live Header with gradient glow */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-gradient-to-r from-emerald-500/5 via-transparent to-transparent dark:from-emerald-500/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<LivePulse />
|
||||
<span className="text-xs font-semibold tracking-tight text-foreground">LIVE</span>
|
||||
<span className="text-[10px] text-muted-foreground">Account Monitor</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-[10px] text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Radio className="w-3 h-3 animate-pulse" />
|
||||
<span>Updated {timeSinceUpdate || 'now'}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span>{accounts.length} accounts</span>
|
||||
<span className="font-mono">{totalRequests.toLocaleString()} req</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats Row */}
|
||||
<div className="grid grid-cols-4 gap-3 p-4 border-b border-border bg-muted/20 dark:bg-zinc-900/30">
|
||||
<SummaryCard
|
||||
icon={<Activity className="w-4 h-4" />}
|
||||
label="Accounts"
|
||||
value={accounts.length}
|
||||
color="var(--accent)"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<CheckCircle2 className="w-4 h-4" />}
|
||||
label="Success"
|
||||
value={totalSuccess.toLocaleString()}
|
||||
color={STATUS_COLORS.success}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<XCircle className="w-4 h-4" />}
|
||||
label="Failed"
|
||||
value={totalFailure.toLocaleString()}
|
||||
color={totalFailure > 0 ? STATUS_COLORS.failed : undefined}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={<Activity className="w-4 h-4" />}
|
||||
label="Success Rate"
|
||||
value={`${overallSuccessRate}%`}
|
||||
color={
|
||||
overallSuccessRate === 100
|
||||
? STATUS_COLORS.success
|
||||
: overallSuccessRate >= 95
|
||||
? STATUS_COLORS.degraded
|
||||
: STATUS_COLORS.failed
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Flow Visualization */}
|
||||
<div className="relative overflow-hidden">
|
||||
{selectedProviderData ? (
|
||||
// Account-level flow view
|
||||
<AccountFlowViz
|
||||
providerData={selectedProviderData}
|
||||
onBack={() => setSelectedProvider(null)}
|
||||
/>
|
||||
) : (
|
||||
// Provider cards view
|
||||
<div className="p-6">
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-widest mb-4">
|
||||
Request Distribution by Provider
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{providerStats.map((ps) => {
|
||||
const successRate = getSuccessRate(ps.successCount, ps.failureCount);
|
||||
const providerColor = PROVIDER_COLORS[ps.provider.toLowerCase()] || '#6b7280';
|
||||
const isHovered = hoveredProvider === ps.provider;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={ps.provider}
|
||||
onClick={() => setSelectedProvider(ps.provider)}
|
||||
onMouseEnter={() => setHoveredProvider(ps.provider)}
|
||||
onMouseLeave={() => setHoveredProvider(null)}
|
||||
className={cn(
|
||||
'group relative rounded-xl p-4 text-left transition-all duration-300',
|
||||
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
||||
'border border-border/50 dark:border-white/[0.08]',
|
||||
'hover:border-opacity-50 hover:scale-[1.02] hover:shadow-lg',
|
||||
isHovered && 'ring-1'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
borderColor: isHovered ? providerColor : undefined,
|
||||
'--ring-color': providerColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<ProviderIcon provider={ps.provider} size={36} withBackground />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground tracking-tight">
|
||||
{ps.displayName}
|
||||
</h3>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{ps.accountCount} account{ps.accountCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-4 h-4 ml-auto text-muted-foreground transition-all',
|
||||
isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Inline success/failure stats - immediately visible */}
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Stats</span>
|
||||
<InlineStatsBadge success={ps.successCount} failure={ps.failureCount} />
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Success Rate</span>
|
||||
<span
|
||||
className="font-mono font-semibold"
|
||||
style={{
|
||||
color:
|
||||
successRate === 100
|
||||
? STATUS_COLORS.success
|
||||
: successRate >= 95
|
||||
? STATUS_COLORS.degraded
|
||||
: STATUS_COLORS.failed,
|
||||
}}
|
||||
>
|
||||
{successRate}%
|
||||
</span>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${successRate}%`,
|
||||
backgroundColor: providerColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account color dots */}
|
||||
<div className="flex gap-1 mt-3">
|
||||
{ps.accounts.slice(0, 5).map((acc) => (
|
||||
<div
|
||||
key={acc.id}
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: acc.color }}
|
||||
title={cleanEmail(acc.email)}
|
||||
/>
|
||||
))}
|
||||
{ps.accounts.length > 5 && (
|
||||
<span className="text-[10px] text-muted-foreground ml-1">
|
||||
+{ps.accounts.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary Card Component
|
||||
function SummaryCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-card/50 dark:bg-zinc-900/50 border border-border/50 dark:border-white/[0.06]">
|
||||
<div
|
||||
className="w-8 h-8 rounded-md flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: color ? `${color}15` : 'var(--muted)',
|
||||
color: color || 'var(--muted-foreground)',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">{label}</div>
|
||||
<div
|
||||
className="text-lg font-semibold font-mono leading-tight"
|
||||
style={{ color: color || 'var(--foreground)' }}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Docs Link Button
|
||||
*
|
||||
* Links to CCS documentation site for guides and reference.
|
||||
*/
|
||||
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const DOCS_URL = 'https://docs.ccs.kaitran.ca';
|
||||
|
||||
export function DocsLink() {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" asChild title="View documentation">
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +1,22 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CcsLogo } from '@/components/ccs-logo';
|
||||
import { CheckCircle2, AlertCircle, XCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HeroSectionProps {
|
||||
version?: string;
|
||||
healthStatus?: 'ok' | 'warning' | 'error';
|
||||
healthPassed?: number;
|
||||
healthTotal?: number;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
ok: {
|
||||
icon: CheckCircle2,
|
||||
label: 'All Systems Operational',
|
||||
color: 'text-green-600',
|
||||
badgeBg: 'bg-green-500/10 text-green-600 border-green-500/20',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertCircle,
|
||||
label: 'Some Issues Detected',
|
||||
color: 'text-yellow-500',
|
||||
badgeBg: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
label: 'Action Required',
|
||||
color: 'text-red-500',
|
||||
badgeBg: 'bg-red-500/10 text-red-500 border-red-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
export function HeroSection({
|
||||
version = '5.0.0',
|
||||
healthStatus = 'ok',
|
||||
healthPassed = 0,
|
||||
healthTotal = 0,
|
||||
}: HeroSectionProps) {
|
||||
const status = statusConfig[healthStatus];
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
export function HeroSection({ version = '5.0.0' }: HeroSectionProps) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl border bg-gradient-to-br from-background via-background to-muted/30 p-6">
|
||||
{/* Subtle background pattern */}
|
||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
{/* Left: Logo and Welcome */}
|
||||
<div className="flex items-center gap-4">
|
||||
<CcsLogo size="lg" showText={false} />
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">CCS Config</h1>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
v{version}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mt-1">Claude Code Switch Dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Health Status */}
|
||||
<div className={cn('flex items-center gap-3 px-4 py-2 rounded-lg border', status.badgeBg)}>
|
||||
<StatusIcon className={cn('w-5 h-5', status.color)} />
|
||||
<div>
|
||||
<p className={cn('text-sm font-medium', status.color)}>{status.label}</p>
|
||||
{healthTotal > 0 && (
|
||||
<p className="text-xs opacity-80">
|
||||
{healthPassed}/{healthTotal} checks passed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<CcsLogo size="lg" showText={false} />
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">CCS Config</h1>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
v{version}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mt-1">Claude Code Switch Dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { GitHubLink } from '@/components/github-link';
|
||||
import { DocsLink } from '@/components/docs-link';
|
||||
import { ConnectionIndicator } from '@/components/connection-indicator';
|
||||
import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -26,6 +27,7 @@ export function Layout() {
|
||||
<div className="font-semibold text-lg tracking-tight">CCS Config</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ConnectionIndicator />
|
||||
<DocsLink />
|
||||
<GitHubLink />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
@@ -31,20 +31,17 @@ export function ProfileDeck() {
|
||||
);
|
||||
}
|
||||
|
||||
// Mock data for demonstration
|
||||
const mockProfiles = profiles.map((profile, index: number) => ({
|
||||
// Use real profile data directly
|
||||
const normalizedProfiles = profiles.map((profile) => ({
|
||||
...profile,
|
||||
configured: profile.configured ?? false, // Ensure configured is always boolean
|
||||
isActive: index === 0, // First profile is active
|
||||
lastUsed: index === 0 ? '2 hours ago' : index === 1 ? '1 day ago' : undefined,
|
||||
model: index === 0 ? 'claude-3.5-sonnet' : index === 1 ? 'claude-3-haiku' : undefined,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Profiles</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{mockProfiles.map((profile) => (
|
||||
{normalizedProfiles.map((profile) => (
|
||||
<ProfileCard
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Provider Icon Component
|
||||
* Renders provider logos from /assets/providers/
|
||||
* Supports white background circle variant for dark themes
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PROVIDER_ASSETS, PROVIDER_COLORS } from '@/lib/provider-config';
|
||||
|
||||
interface ProviderIconProps {
|
||||
provider: string;
|
||||
className?: string;
|
||||
size?: number;
|
||||
/** White background circle variant for better visibility */
|
||||
withBackground?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderIcon({
|
||||
provider,
|
||||
className,
|
||||
size = 18,
|
||||
withBackground = false,
|
||||
}: ProviderIconProps) {
|
||||
const normalized = provider.toLowerCase();
|
||||
const assetPath = PROVIDER_ASSETS[normalized];
|
||||
|
||||
// Icon size is smaller when inside background circle
|
||||
const iconSize = withBackground ? Math.floor(size * 0.65) : size;
|
||||
|
||||
const iconElement = assetPath ? (
|
||||
<img
|
||||
src={assetPath}
|
||||
alt={`${provider} icon`}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
className="shrink-0 object-contain"
|
||||
/>
|
||||
) : (
|
||||
// Fallback: colored text letter
|
||||
<span
|
||||
className="font-bold"
|
||||
style={{
|
||||
color: PROVIDER_COLORS[normalized] || '#6b7280',
|
||||
fontSize: iconSize * 0.6,
|
||||
}}
|
||||
>
|
||||
{provider.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (withBackground) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-full bg-white border border-border flex items-center justify-center shadow-sm',
|
||||
className
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{iconElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Without background - original behavior for logos, colored circle for fallback
|
||||
if (assetPath) {
|
||||
return (
|
||||
<img
|
||||
src={assetPath}
|
||||
alt={`${provider} icon`}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn('shrink-0 rounded-sm object-contain', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const bgColor = PROVIDER_COLORS[normalized] || '#6b7280';
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-full flex items-center justify-center text-white font-bold',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: bgColor,
|
||||
fontSize: size * 0.5,
|
||||
}}
|
||||
>
|
||||
{provider.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
@@ -4,9 +4,25 @@
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
/** Per-account usage statistics */
|
||||
export interface AccountUsageStats {
|
||||
/** Account email or identifier */
|
||||
source: string;
|
||||
/** Number of successful requests */
|
||||
successCount: number;
|
||||
/** Number of failed requests */
|
||||
failureCount: number;
|
||||
/** Total tokens used */
|
||||
totalTokens: number;
|
||||
/** Last request timestamp */
|
||||
lastUsedAt?: string;
|
||||
}
|
||||
|
||||
/** CLIProxy usage statistics */
|
||||
export interface CliproxyStats {
|
||||
totalRequests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
tokens: {
|
||||
input: number;
|
||||
output: number;
|
||||
@@ -14,6 +30,8 @@ export interface CliproxyStats {
|
||||
};
|
||||
requestsByModel: Record<string, number>;
|
||||
requestsByProvider: Record<string, number>;
|
||||
/** Per-account usage breakdown */
|
||||
accountStats: Record<string, AccountUsageStats>;
|
||||
quotaExceededCount: number;
|
||||
retryCount: number;
|
||||
collectedAt: string;
|
||||
@@ -67,9 +85,9 @@ export function useCliproxyStats(enabled = true) {
|
||||
queryKey: ['cliproxy-stats'],
|
||||
queryFn: fetchCliproxyStats,
|
||||
enabled,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
refetchInterval: 5000, // Refresh every 5 seconds for near-real-time updates
|
||||
retry: 1,
|
||||
staleTime: 10000, // Consider data stale after 10 seconds
|
||||
staleTime: 3000, // Consider data stale after 3 seconds
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+134
-21
@@ -5,46 +5,46 @@
|
||||
:root {
|
||||
/* CCS Brand Colors - Calculated from Crail, Pampas, White, Cloudy */
|
||||
/* Primary: Neutral (Dark Warm Grey) - Demoted Crail from Primary */
|
||||
--primary: oklch(0.2 0.02 40);
|
||||
--primary: oklch(0.15 0.02 40); /* Darker for better contrast */
|
||||
--primary-foreground: oklch(0.9635 0.0067 97.35); /* Pampas */
|
||||
|
||||
--secondary: oklch(0.9 0.01 95); /* Light Cloudy */
|
||||
--secondary-foreground: oklch(0.2 0.02 40);
|
||||
--secondary: oklch(0.88 0.012 95); /* Slightly darker */
|
||||
--secondary-foreground: oklch(0.15 0.02 40);
|
||||
|
||||
/* Accent: Crail (Moved from Primary) */
|
||||
--accent: oklch(0.5971 0.1352 39.87); /* Crail */
|
||||
--accent: oklch(0.52 0.15 39.87); /* Crail - slightly darker */
|
||||
--accent-foreground: oklch(1 0 0); /* White */
|
||||
|
||||
--background: oklch(0.9635 0.0067 97.35); /* Pampas */
|
||||
--foreground: oklch(0.2 0.02 40); /* Dark warm grey */
|
||||
--foreground: oklch(0.15 0.02 40); /* Darker for better readability */
|
||||
|
||||
--muted: oklch(0.92 0.01 95);
|
||||
--muted-foreground: oklch(0.55 0.02 40);
|
||||
--muted: oklch(0.9 0.012 95);
|
||||
--muted-foreground: oklch(0.4 0.025 40); /* Much darker: 0.55 -> 0.40 */
|
||||
|
||||
--border: oklch(0.85 0.015 91.6); /* Based on Cloudy */
|
||||
--input: oklch(0.85 0.015 91.6);
|
||||
--border: oklch(0.78 0.02 91.6); /* Darker: 0.85 -> 0.78 */
|
||||
--input: oklch(0.78 0.02 91.6);
|
||||
|
||||
--ring: oklch(0.2 0.02 40); /* Match primary */
|
||||
--ring: oklch(0.15 0.02 40); /* Match primary */
|
||||
--radius: 0.5rem;
|
||||
|
||||
--popover: oklch(0.9635 0.0067 97.35); /* Match background */
|
||||
--popover-foreground: oklch(0.2 0.02 40); /* Match foreground */
|
||||
--popover: oklch(0.98 0.005 97.35); /* Slight depth */
|
||||
--popover-foreground: oklch(0.15 0.02 40); /* Match foreground */
|
||||
|
||||
--card: oklch(0.9635 0.0067 97.35); /* Match background */
|
||||
--card-foreground: oklch(0.2 0.02 40); /* Match foreground */
|
||||
--card: oklch(0.98 0.005 97.35); /* Slight depth from background */
|
||||
--card-foreground: oklch(0.15 0.02 40); /* Match foreground */
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325); /* Red 600 */
|
||||
--destructive-foreground: oklch(0.9635 0.0067 97.35); /* White */
|
||||
--destructive: oklch(0.5 0.22 27); /* Darker red for visibility */
|
||||
--destructive-foreground: oklch(0.98 0.005 97.35); /* White */
|
||||
|
||||
/* Sidebar colors - Light */
|
||||
--sidebar: oklch(0.9635 0.0067 97.35); /* Pampas */
|
||||
--sidebar-foreground: oklch(0.2 0.02 40);
|
||||
--sidebar-primary: oklch(0.2 0.02 40); /* Neutral */
|
||||
--sidebar-foreground: oklch(0.15 0.02 40);
|
||||
--sidebar-primary: oklch(0.15 0.02 40); /* Neutral - darker */
|
||||
--sidebar-primary-foreground: oklch(0.9635 0.0067 97.35);
|
||||
--sidebar-accent: oklch(0.5971 0.1352 39.87); /* Crail */
|
||||
--sidebar-accent: oklch(0.52 0.15 39.87); /* Crail - darker */
|
||||
--sidebar-accent-foreground: oklch(1 0 0);
|
||||
--sidebar-border: oklch(0.85 0.015 91.6);
|
||||
--sidebar-ring: oklch(0.2 0.02 40);
|
||||
--sidebar-border: oklch(0.78 0.02 91.6); /* Darker */
|
||||
--sidebar-ring: oklch(0.15 0.02 40);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -199,3 +199,116 @@
|
||||
.zoom-in-95 {
|
||||
animation-name: zoom-in;
|
||||
}
|
||||
|
||||
/* Flow visualization animations */
|
||||
@keyframes request-pulse {
|
||||
0% {
|
||||
stroke-opacity: 1;
|
||||
stroke-width: inherit;
|
||||
filter: drop-shadow(0 0 2px currentColor);
|
||||
}
|
||||
30% {
|
||||
stroke-opacity: 0.9;
|
||||
filter: drop-shadow(0 0 8px currentColor);
|
||||
}
|
||||
60% {
|
||||
stroke-opacity: 0.5;
|
||||
stroke-width: calc(inherit * 1.5);
|
||||
filter: drop-shadow(0 0 4px currentColor);
|
||||
}
|
||||
100% {
|
||||
stroke-opacity: 0;
|
||||
stroke-width: calc(inherit * 2);
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Traveling dot along path */
|
||||
@keyframes travel-dot {
|
||||
0% {
|
||||
offset-distance: 0%;
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
offset-distance: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 var(--glow-color, rgba(16, 185, 129, 0.3));
|
||||
transform: scale(1);
|
||||
}
|
||||
25% {
|
||||
box-shadow: 0 0 15px 3px var(--glow-color, rgba(16, 185, 129, 0.5));
|
||||
transform: scale(1.02);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 25px 6px var(--glow-color, rgba(16, 185, 129, 0.4));
|
||||
transform: scale(1.01);
|
||||
}
|
||||
75% {
|
||||
box-shadow: 0 0 12px 2px var(--glow-color, rgba(16, 185, 129, 0.35));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes subtle-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes border-glow {
|
||||
0%,
|
||||
100% {
|
||||
border-color: var(--glow-color, rgba(16, 185, 129, 0.2));
|
||||
}
|
||||
50% {
|
||||
border-color: var(--glow-color, rgba(16, 185, 129, 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
.animate-request-pulse {
|
||||
animation: request-pulse 2s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-glow-pulse {
|
||||
animation: glow-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-travel-dot {
|
||||
animation: travel-dot 1.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-subtle-float {
|
||||
animation: subtle-float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-icon-breathe {
|
||||
animation: icon-breathe 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-border-glow {
|
||||
animation: border-glow 2s ease-in-out infinite;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Provider Configuration
|
||||
* Shared constants for provider branding and assets
|
||||
*/
|
||||
|
||||
// Map provider names to asset filenames (only providers with actual logos)
|
||||
export const PROVIDER_ASSETS: Record<string, string> = {
|
||||
gemini: '/assets/providers/gemini-color.svg',
|
||||
agy: '/assets/providers/agy.png',
|
||||
codex: '/assets/providers/openai.svg',
|
||||
qwen: '/assets/providers/qwen-color.svg',
|
||||
};
|
||||
|
||||
// Provider brand colors
|
||||
export const PROVIDER_COLORS: Record<string, string> = {
|
||||
gemini: '#4285F4',
|
||||
agy: '#f3722c',
|
||||
codex: '#10a37f',
|
||||
vertex: '#4285F4',
|
||||
iflow: '#f94144',
|
||||
qwen: '#6236FF',
|
||||
};
|
||||
|
||||
// Provider display names
|
||||
const PROVIDER_NAMES: Record<string, string> = {
|
||||
gemini: 'Gemini',
|
||||
agy: 'Antigravity',
|
||||
codex: 'Codex',
|
||||
vertex: 'Vertex AI',
|
||||
iflow: 'iFlow',
|
||||
qwen: 'Qwen',
|
||||
};
|
||||
|
||||
// Map provider to display name
|
||||
export function getProviderDisplayName(provider: string): string {
|
||||
return PROVIDER_NAMES[provider.toLowerCase()] || provider;
|
||||
}
|
||||
+37
-15
@@ -5,21 +5,38 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function getModelColor(model: string): string {
|
||||
// Vibrant Tones Palette
|
||||
const colors = [
|
||||
'#f94144', // Strawberry Red
|
||||
'#f3722c', // Pumpkin Spice
|
||||
'#f8961e', // Carrot Orange
|
||||
'#f9844a', // Atomic Tangerine
|
||||
'#f9c74f', // Tuscan Sun
|
||||
'#90be6d', // Willow Green
|
||||
'#43aa8b', // Seaweed
|
||||
'#4d908e', // Dark Cyan
|
||||
'#577590', // Blue Slate
|
||||
'#277da1', // Cerulean
|
||||
];
|
||||
// Vibrant Tones Palette
|
||||
const VIBRANT_TONES = [
|
||||
'#f94144', // Strawberry Red
|
||||
'#f3722c', // Pumpkin Spice
|
||||
'#f8961e', // Carrot Orange
|
||||
'#f9844a', // Atomic Tangerine
|
||||
'#f9c74f', // Tuscan Sun
|
||||
'#90be6d', // Willow Green
|
||||
'#43aa8b', // Seaweed
|
||||
'#4d908e', // Dark Cyan
|
||||
'#577590', // Blue Slate
|
||||
'#277da1', // Cerulean
|
||||
];
|
||||
|
||||
// Provider color mapping (fixed colors for consistency)
|
||||
const PROVIDER_COLORS: Record<string, string> = {
|
||||
agy: '#f3722c', // Pumpkin
|
||||
gemini: '#277da1', // Cerulean
|
||||
codex: '#f8961e', // Carrot
|
||||
vertex: '#577590', // Blue Slate
|
||||
iflow: '#f94144', // Strawberry
|
||||
qwen: '#f9c74f', // Tuscan
|
||||
};
|
||||
|
||||
// Status colors (from Analytics Cost breakdown) - darker for light theme contrast
|
||||
export const STATUS_COLORS = {
|
||||
success: '#15803d', // Green-700 (was Seaweed #43aa8b)
|
||||
degraded: '#b45309', // Amber-700 (was Ochre #e09f3e)
|
||||
failed: '#b91c1c', // Red-700 (was Merlot #9e2a2b)
|
||||
} as const;
|
||||
|
||||
export function getModelColor(model: string): string {
|
||||
// FNV-1a hash algorithm
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < model.length; i++) {
|
||||
@@ -28,5 +45,10 @@ export function getModelColor(model: string): string {
|
||||
}
|
||||
|
||||
// Ensure positive index
|
||||
return colors[(hash >>> 0) % colors.length];
|
||||
return VIBRANT_TONES[(hash >>> 0) % VIBRANT_TONES.length];
|
||||
}
|
||||
|
||||
export function getProviderColor(provider: string): string {
|
||||
const normalized = provider.toLowerCase();
|
||||
return PROVIDER_COLORS[normalized] || getModelColor(provider);
|
||||
}
|
||||
+125
-149
@@ -1,25 +1,13 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { StatCard } from '@/components/stat-card';
|
||||
import { HeroSection } from '@/components/hero-section';
|
||||
import { QuickCommands } from '@/components/quick-commands';
|
||||
import { AuthMonitor } from '@/components/auth-monitor';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Key,
|
||||
Zap,
|
||||
Users,
|
||||
Activity,
|
||||
Plus,
|
||||
Stethoscope,
|
||||
BookOpen,
|
||||
FolderOpen,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react';
|
||||
import { useOverview } from '@/hooks/use-overview';
|
||||
import { useSharedSummary } from '@/hooks/use-shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
const HEALTH_VARIANTS = {
|
||||
ok: 'success',
|
||||
@@ -27,6 +15,51 @@ const HEALTH_VARIANTS = {
|
||||
error: 'error',
|
||||
} as const;
|
||||
|
||||
type StatVariant = 'default' | 'success' | 'warning' | 'error' | 'accent';
|
||||
|
||||
const variantStyles: Record<StatVariant, { iconBg: string; iconColor: string }> = {
|
||||
default: { iconBg: 'bg-muted', iconColor: 'text-muted-foreground' },
|
||||
success: { iconBg: 'bg-green-600/15', iconColor: 'text-green-700 dark:text-green-500' },
|
||||
warning: { iconBg: 'bg-amber-500/15', iconColor: 'text-amber-700 dark:text-amber-400' },
|
||||
error: { iconBg: 'bg-red-600/15', iconColor: 'text-red-700 dark:text-red-500' },
|
||||
accent: { iconBg: 'bg-accent/15', iconColor: 'text-accent' },
|
||||
};
|
||||
|
||||
function InlineStat({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
variant = 'default',
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
value: number | string;
|
||||
icon: LucideIcon;
|
||||
variant?: StatVariant;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const styles = variantStyles[variant];
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-2.5 rounded-lg border bg-card/50',
|
||||
'transition-all hover:bg-card hover:shadow-sm hover:-translate-y-0.5',
|
||||
'active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex items-center justify-center w-9 h-9 rounded-md', styles.iconBg)}>
|
||||
<Icon className={cn('w-4 h-4', styles.iconColor)} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">{title}</p>
|
||||
<p className={cn('text-lg font-bold font-mono leading-tight', styles.iconColor)}>{value}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: overview, isLoading: isOverviewLoading } = useOverview();
|
||||
@@ -35,8 +68,8 @@ export function HomePage() {
|
||||
if (isOverviewLoading || isSharedLoading) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Hero Skeleton */}
|
||||
<div className="rounded-xl border p-6">
|
||||
{/* Hero Row Skeleton */}
|
||||
<div className="rounded-xl border p-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div>
|
||||
@@ -44,42 +77,31 @@ export function HomePage() {
|
||||
<Skeleton className="h-4 w-[220px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Skeleton */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="space-y-3 p-4 border rounded-xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-4 w-20 mb-2" />
|
||||
<Skeleton className="h-7 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Skeleton */}
|
||||
<div className="border rounded-xl p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-[140px]" />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Skeleton className="h-10 w-[140px] rounded-md" />
|
||||
<Skeleton className="h-10 w-[120px] rounded-md" />
|
||||
<Skeleton className="h-10 w-[150px] rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Commands Skeleton */}
|
||||
<div className="border rounded-xl p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-[160px]" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-14 rounded-lg" />
|
||||
<Skeleton key={i} className="h-14 w-28 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Monitor Skeleton */}
|
||||
<div className="border rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-2.5 border-b flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="px-4 py-3 border-b">
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="px-4 py-2.5 flex items-center gap-3 border-b last:border-b-0">
|
||||
<Skeleton className="w-2.5 h-2.5 rounded-full" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-1.5 w-24 rounded-full" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,13 +112,57 @@ export function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Hero Section */}
|
||||
<HeroSection
|
||||
version={overview?.version}
|
||||
healthStatus={overview?.health?.status}
|
||||
healthPassed={overview?.health?.passed}
|
||||
healthTotal={overview?.health?.total}
|
||||
/>
|
||||
{/* Hero Row: Logo/Title + Inline Stats */}
|
||||
<div className="relative overflow-hidden rounded-xl border bg-gradient-to-br from-background via-background to-muted/30">
|
||||
{/* Subtle background pattern */}
|
||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Single Row Layout */}
|
||||
<div className="relative p-6 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
{/* Left: Logo + Title */}
|
||||
<HeroSection version={overview?.version} />
|
||||
|
||||
{/* Right: Inline Stats */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<InlineStat
|
||||
title="Profiles"
|
||||
value={overview?.profiles ?? 0}
|
||||
icon={Key}
|
||||
variant="accent"
|
||||
onClick={() => navigate('/api')}
|
||||
/>
|
||||
<InlineStat
|
||||
title="CLIProxy"
|
||||
value={overview?.cliproxy ?? 0}
|
||||
icon={Zap}
|
||||
variant="accent"
|
||||
onClick={() => navigate('/cliproxy')}
|
||||
/>
|
||||
<InlineStat
|
||||
title="Accounts"
|
||||
value={overview?.accounts ?? 0}
|
||||
icon={Users}
|
||||
variant="default"
|
||||
onClick={() => navigate('/accounts')}
|
||||
/>
|
||||
<InlineStat
|
||||
title="Health"
|
||||
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
|
||||
icon={Activity}
|
||||
variant={healthVariant}
|
||||
onClick={() => navigate('/health')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration Warning */}
|
||||
{shared?.symlinkStatus && !shared.symlinkStatus.valid && (
|
||||
@@ -107,98 +173,8 @@ export function HomePage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="API Profiles"
|
||||
value={overview?.profiles ?? 0}
|
||||
icon={Key}
|
||||
variant="accent"
|
||||
subtitle="Settings-based"
|
||||
onClick={() => navigate('/api')}
|
||||
/>
|
||||
<StatCard
|
||||
title="CLIProxy"
|
||||
value={overview?.cliproxy ?? 0}
|
||||
icon={Zap}
|
||||
variant="accent"
|
||||
subtitle={`${overview?.cliproxyProviders ?? 0} auth + ${overview?.cliproxyVariants ?? 0} custom`}
|
||||
onClick={() => navigate('/cliproxy')}
|
||||
/>
|
||||
<StatCard
|
||||
title="Accounts"
|
||||
value={overview?.accounts ?? 0}
|
||||
icon={Users}
|
||||
variant="default"
|
||||
subtitle="Isolated instances"
|
||||
onClick={() => navigate('/accounts')}
|
||||
/>
|
||||
<StatCard
|
||||
title="Health"
|
||||
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
|
||||
icon={Activity}
|
||||
variant={healthVariant}
|
||||
subtitle="System checks"
|
||||
onClick={() => navigate('/health')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
<Button onClick={() => navigate('/api')} className="gap-2">
|
||||
<Plus className="w-4 h-4" /> New Profile
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate('/health')} className="gap-2">
|
||||
<Stethoscope className="w-4 h-4" /> Run Doctor
|
||||
</Button>
|
||||
<Button variant="outline" asChild className="gap-2">
|
||||
<a href="https://docs.ccs.kaitran.ca" target="_blank" rel="noopener noreferrer">
|
||||
<BookOpen className="w-4 h-4" /> Documentation
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Commands */}
|
||||
<QuickCommands />
|
||||
|
||||
{/* Shared Data Summary */}
|
||||
<Card className="group hover:border-primary/50 transition-colors">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FolderOpen className="w-5 h-5 text-muted-foreground" />
|
||||
Shared Data
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/shared')}
|
||||
className="gap-1 opacity-70 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
View All <ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-6 text-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50">
|
||||
<span className="text-xl font-bold font-mono">{shared?.commands ?? 0}</span>
|
||||
<span className="text-muted-foreground">Commands</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50">
|
||||
<span className="text-xl font-bold font-mono">{shared?.skills ?? 0}</span>
|
||||
<span className="text-muted-foreground">Skills</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50">
|
||||
<span className="text-xl font-bold font-mono">{shared?.agents ?? 0}</span>
|
||||
<span className="text-muted-foreground">Agents</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Auth Monitor */}
|
||||
<AuthMonitor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+710
-35
@@ -1,46 +1,721 @@
|
||||
/**
|
||||
* Settings Page - Deprecated
|
||||
* Settings functionality has been moved to API Profiles page
|
||||
* Settings Page - WebSearch Configuration
|
||||
* Supports Gemini CLI and Grok CLI providers
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Construction, ArrowRight } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Globe,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
FileCode,
|
||||
Copy,
|
||||
Check,
|
||||
GripVertical,
|
||||
Terminal,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { CodeEditor } from '@/components/code-editor';
|
||||
|
||||
interface ProviderConfig {
|
||||
enabled?: boolean;
|
||||
model?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface WebSearchProvidersConfig {
|
||||
gemini?: ProviderConfig;
|
||||
grok?: ProviderConfig;
|
||||
opencode?: ProviderConfig;
|
||||
}
|
||||
|
||||
interface WebSearchConfig {
|
||||
enabled: boolean;
|
||||
providers?: WebSearchProvidersConfig;
|
||||
}
|
||||
|
||||
interface CliStatus {
|
||||
installed: boolean;
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
interface WebSearchStatus {
|
||||
geminiCli: CliStatus;
|
||||
grokCli: CliStatus;
|
||||
opencodeCli: CliStatus;
|
||||
readiness: {
|
||||
status: 'ready' | 'unavailable';
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
const [config, setConfig] = useState<WebSearchConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [status, setStatus] = useState<WebSearchStatus | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
// Config viewer state
|
||||
const [rawConfig, setRawConfig] = useState<string | null>(null);
|
||||
const [rawConfigLoading, setRawConfigLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
// Local model input state (to avoid saving on every keystroke)
|
||||
const [geminiModelInput, setGeminiModelInput] = useState('');
|
||||
const [opencodeModelInput, setOpencodeModelInput] = useState('');
|
||||
// Collapsible install hints state
|
||||
const [showGeminiHint, setShowGeminiHint] = useState(false);
|
||||
const [showOpencodeHint, setShowOpencodeHint] = useState(false);
|
||||
const [showGrokHint, setShowGrokHint] = useState(false);
|
||||
|
||||
<Card className="border-yellow-500/50 bg-yellow-500/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-yellow-600">
|
||||
<Construction className="w-5 h-5" />
|
||||
Page Relocated
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
The settings editor has been integrated into the <strong>API Profiles</strong> page for
|
||||
a better user experience.
|
||||
</p>
|
||||
<p className="text-muted-foreground">To edit environment variables for a profile:</p>
|
||||
<ol className="list-decimal list-inside text-muted-foreground space-y-1 ml-2">
|
||||
<li>Go to API Profiles page</li>
|
||||
<li>Click the actions menu (...) on any profile</li>
|
||||
<li>Select "Edit Settings"</li>
|
||||
</ol>
|
||||
<div className="pt-2">
|
||||
<Button asChild>
|
||||
<Link to="/api">
|
||||
Go to API Profiles
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Link>
|
||||
</Button>
|
||||
// Load config and status on mount
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
fetchStatus();
|
||||
fetchRawConfig();
|
||||
}, []);
|
||||
|
||||
// Sync local model inputs when config changes
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setGeminiModelInput(config.providers?.gemini?.model ?? 'gemini-2.5-flash');
|
||||
setOpencodeModelInput(config.providers?.opencode?.model ?? 'opencode/grok-code');
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch('/api/websearch');
|
||||
if (!res.ok) throw new Error('Failed to load WebSearch config');
|
||||
const data = await res.json();
|
||||
setConfig(data);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
setStatusLoading(true);
|
||||
const res = await fetch('/api/websearch/status');
|
||||
if (!res.ok) throw new Error('Failed to load status');
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch WebSearch status:', err);
|
||||
} finally {
|
||||
setStatusLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRawConfig = async () => {
|
||||
try {
|
||||
setRawConfigLoading(true);
|
||||
const res = await fetch('/api/config/raw');
|
||||
if (!res.ok) {
|
||||
setRawConfig(null);
|
||||
return;
|
||||
}
|
||||
const text = await res.text();
|
||||
setRawConfig(text);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch raw config:', err);
|
||||
setRawConfig(null);
|
||||
} finally {
|
||||
setRawConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (!rawConfig) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawConfig);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle Gemini provider
|
||||
const toggleGemini = () => {
|
||||
const providers = config?.providers || {};
|
||||
const currentState = providers.gemini?.enabled ?? false;
|
||||
const grokState = providers.grok?.enabled ?? false;
|
||||
const opencodeState = providers.opencode?.enabled ?? false;
|
||||
|
||||
saveConfig({
|
||||
enabled: !currentState || grokState || opencodeState, // Enable WebSearch if any provider is enabled
|
||||
providers: {
|
||||
...providers,
|
||||
gemini: {
|
||||
...providers.gemini,
|
||||
enabled: !currentState,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const saveConfig = async (updates: Partial<WebSearchConfig>) => {
|
||||
if (!config) return;
|
||||
|
||||
// Optimistic update - apply changes immediately to local state
|
||||
const optimisticConfig = { ...config, ...updates };
|
||||
setConfig(optimisticConfig);
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch('/api/websearch', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(optimisticConfig),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Failed to save');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setConfig(data.websearch);
|
||||
// Quick flash of success (shorter duration, less intrusive)
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 1500);
|
||||
// Silently refresh raw config without loading state
|
||||
fetch('/api/config/raw')
|
||||
.then((r) => (r.ok ? r.text() : null))
|
||||
.then((text) => text && setRawConfig(text))
|
||||
.catch(() => {});
|
||||
} catch (err) {
|
||||
// Revert optimistic update on error
|
||||
setConfig(config);
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false;
|
||||
const isGrokEnabled = config?.providers?.grok?.enabled ?? false;
|
||||
const isOpenCodeEnabled = config?.providers?.opencode?.enabled ?? false;
|
||||
|
||||
// Toggle Grok provider
|
||||
const toggleGrok = () => {
|
||||
const providers = config?.providers || {};
|
||||
const currentState = providers.grok?.enabled ?? false;
|
||||
|
||||
saveConfig({
|
||||
enabled: isGeminiEnabled || !currentState || isOpenCodeEnabled, // Enable WebSearch if any provider is enabled
|
||||
providers: {
|
||||
...providers,
|
||||
grok: {
|
||||
...providers.grok,
|
||||
enabled: !currentState,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle OpenCode provider
|
||||
const toggleOpenCode = () => {
|
||||
const providers = config?.providers || {};
|
||||
const currentState = providers.opencode?.enabled ?? false;
|
||||
|
||||
saveConfig({
|
||||
enabled: isGeminiEnabled || isGrokEnabled || !currentState, // Enable WebSearch if any provider is enabled
|
||||
providers: {
|
||||
...providers,
|
||||
opencode: {
|
||||
...providers.opencode,
|
||||
enabled: !currentState,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Save Gemini model on blur (only if changed)
|
||||
const saveGeminiModel = () => {
|
||||
const currentModel = config?.providers?.gemini?.model ?? 'gemini-2.5-flash';
|
||||
if (geminiModelInput !== currentModel) {
|
||||
const providers = config?.providers || {};
|
||||
saveConfig({
|
||||
providers: {
|
||||
...providers,
|
||||
gemini: {
|
||||
...providers.gemini,
|
||||
model: geminiModelInput,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Save OpenCode model on blur (only if changed)
|
||||
const saveOpencodeModel = () => {
|
||||
const currentModel = config?.providers?.opencode?.model ?? 'opencode/grok-code';
|
||||
if (opencodeModelInput !== currentModel) {
|
||||
const providers = config?.providers || {};
|
||||
saveConfig({
|
||||
providers: {
|
||||
...providers,
|
||||
opencode: {
|
||||
...providers.opencode,
|
||||
model: opencodeModelInput,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)] flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
<span className="text-lg">Loading configuration...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)]">
|
||||
<PanelGroup direction="horizontal" className="h-full">
|
||||
{/* Left Panel - WebSearch Controls */}
|
||||
<Panel defaultSize={40} minSize={30} maxSize={55}>
|
||||
<div className="h-full border-r flex flex-col bg-muted/30 relative">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-6 h-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">WebSearch</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
CLI-based web search for third-party profiles
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast-style alerts - absolute positioned, no layout shift */}
|
||||
<div
|
||||
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
|
||||
error || success
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-2 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2 shadow-lg">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm font-medium">Saved</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-5 space-y-6">
|
||||
{/* Status Summary */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'}
|
||||
</p>
|
||||
{statusLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Checking status...</p>
|
||||
) : status?.readiness ? (
|
||||
<p className="text-sm text-muted-foreground">{status.readiness.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={fetchStatus} disabled={statusLoading}>
|
||||
<RefreshCw className={`w-4 h-4 ${statusLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* CLI Providers */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-medium">Providers</h3>
|
||||
|
||||
{/* Gemini CLI Provider */}
|
||||
<div
|
||||
className={`rounded-lg border transition-colors ${
|
||||
isGeminiEnabled ? 'border-primary border-l-4' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal
|
||||
className={`w-5 h-5 ${isGeminiEnabled ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-mono font-medium">gemini</p>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
||||
FREE
|
||||
</span>
|
||||
{status?.geminiCli?.installed ? (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
||||
installed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
|
||||
not installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Google Gemini CLI (1000 req/day free)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isGeminiEnabled}
|
||||
onCheckedChange={toggleGemini}
|
||||
disabled={saving || !status?.geminiCli?.installed}
|
||||
/>
|
||||
</div>
|
||||
{/* Model input when enabled */}
|
||||
{isGeminiEnabled && (
|
||||
<div className="px-4 pb-4 pt-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Model:
|
||||
</label>
|
||||
<Input
|
||||
value={geminiModelInput}
|
||||
onChange={(e) => setGeminiModelInput(e.target.value)}
|
||||
onBlur={saveGeminiModel}
|
||||
placeholder="gemini-2.5-flash"
|
||||
className="h-8 text-sm font-mono"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Installation hint when not installed - inside card */}
|
||||
{!status?.geminiCli?.installed && !statusLoading && (
|
||||
<div className="px-4 pb-4 pt-0 border-t border-border/50">
|
||||
<button
|
||||
onClick={() => setShowGeminiHint(!showGeminiHint)}
|
||||
className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400 hover:underline w-full py-2"
|
||||
>
|
||||
{showGeminiHint ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
How to install Gemini CLI
|
||||
</button>
|
||||
{showGeminiHint && (
|
||||
<div className="mt-2 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20 text-sm">
|
||||
<p className="text-amber-700 dark:text-amber-300 mb-2">
|
||||
Install globally (FREE tier available):
|
||||
</p>
|
||||
<code className="text-sm bg-amber-100 dark:bg-amber-900/40 px-2 py-1 rounded font-mono block mb-2">
|
||||
npm install -g @google/gemini-cli
|
||||
</code>
|
||||
<a
|
||||
href="https://github.com/google-gemini/gemini-cli"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-amber-700 dark:text-amber-300 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View documentation
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OpenCode CLI Provider */}
|
||||
<div
|
||||
className={`rounded-lg border transition-colors ${
|
||||
isOpenCodeEnabled ? 'border-primary border-l-4' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal
|
||||
className={`w-5 h-5 ${isOpenCodeEnabled ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-mono font-medium">opencode</p>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
||||
FREE
|
||||
</span>
|
||||
{status?.opencodeCli?.installed ? (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
||||
installed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
|
||||
not installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
OpenCode (web search via Zen)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isOpenCodeEnabled}
|
||||
onCheckedChange={toggleOpenCode}
|
||||
disabled={saving || !status?.opencodeCli?.installed}
|
||||
/>
|
||||
</div>
|
||||
{/* Model input when enabled */}
|
||||
{isOpenCodeEnabled && (
|
||||
<div className="px-4 pb-4 pt-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Model:
|
||||
</label>
|
||||
<Input
|
||||
value={opencodeModelInput}
|
||||
onChange={(e) => setOpencodeModelInput(e.target.value)}
|
||||
onBlur={saveOpencodeModel}
|
||||
placeholder="opencode/grok-code"
|
||||
className="h-8 text-sm font-mono"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Installation hint when not installed - inside card */}
|
||||
{!status?.opencodeCli?.installed && !statusLoading && (
|
||||
<div className="px-4 pb-4 pt-0 border-t border-border/50">
|
||||
<button
|
||||
onClick={() => setShowOpencodeHint(!showOpencodeHint)}
|
||||
className="flex items-center gap-2 text-sm text-purple-600 dark:text-purple-400 hover:underline w-full py-2"
|
||||
>
|
||||
{showOpencodeHint ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
How to install OpenCode
|
||||
</button>
|
||||
{showOpencodeHint && (
|
||||
<div className="mt-2 p-3 rounded-md bg-purple-50 dark:bg-purple-900/20 text-sm">
|
||||
<p className="text-purple-700 dark:text-purple-300 mb-2">
|
||||
Install globally (FREE tier available):
|
||||
</p>
|
||||
<code className="text-sm bg-purple-100 dark:bg-purple-900/40 px-2 py-1 rounded font-mono block mb-2">
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
</code>
|
||||
<a
|
||||
href="https://github.com/sst/opencode"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-700 dark:text-purple-300 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View documentation
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grok CLI Provider */}
|
||||
<div
|
||||
className={`rounded-lg border transition-colors ${
|
||||
isGrokEnabled ? 'border-primary border-l-4' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal
|
||||
className={`w-5 h-5 ${isGrokEnabled ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-mono font-medium">grok</p>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 font-medium">
|
||||
GROK_API_KEY
|
||||
</span>
|
||||
{status?.grokCli?.installed ? (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
||||
installed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
|
||||
not installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
xAI Grok CLI (web + X search)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isGrokEnabled}
|
||||
onCheckedChange={toggleGrok}
|
||||
disabled={saving || !status?.grokCli?.installed}
|
||||
/>
|
||||
</div>
|
||||
{/* Installation hint when not installed - inside card */}
|
||||
{!status?.grokCli?.installed && !statusLoading && (
|
||||
<div className="px-4 pb-4 pt-0 border-t border-border/50">
|
||||
<button
|
||||
onClick={() => setShowGrokHint(!showGrokHint)}
|
||||
className="flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline w-full py-2"
|
||||
>
|
||||
{showGrokHint ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
How to install Grok CLI
|
||||
</button>
|
||||
{showGrokHint && (
|
||||
<div className="mt-2 p-3 rounded-md bg-blue-50 dark:bg-blue-900/20 text-sm">
|
||||
<p className="text-blue-700 dark:text-blue-300 mb-2">
|
||||
Install globally (requires xAI API key):
|
||||
</p>
|
||||
<code className="text-sm bg-blue-100 dark:bg-blue-900/40 px-2 py-1 rounded font-mono block mb-2">
|
||||
npm install -g @vibe-kit/grok-cli
|
||||
</code>
|
||||
<a
|
||||
href="https://github.com/superagent-ai/grok-cli"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 dark:text-blue-300 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View documentation
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t bg-background">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
fetchConfig();
|
||||
fetchRawConfig();
|
||||
}}
|
||||
disabled={loading || saving}
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Panel>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<PanelResizeHandle className="w-2 bg-border hover:bg-primary/20 transition-colors cursor-col-resize flex items-center justify-center group">
|
||||
<GripVertical className="w-3 h-3 text-muted-foreground group-hover:text-primary" />
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Right Panel - Config Viewer */}
|
||||
<Panel defaultSize={60} minSize={35}>
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b bg-background flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileCode className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-semibold">config.yaml</h2>
|
||||
<p className="text-sm text-muted-foreground">~/.ccs/config.yaml</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={copyToClipboard} disabled={!rawConfig}>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchRawConfig}
|
||||
disabled={rawConfigLoading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${rawConfigLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config Content - scrollable */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{rawConfigLoading ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : rawConfig ? (
|
||||
<CodeEditor
|
||||
value={rawConfig}
|
||||
onChange={() => {}}
|
||||
language="yaml"
|
||||
readonly
|
||||
minHeight="auto"
|
||||
className="min-h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<FileCode className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p>Config file not found</p>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded mt-2 inline-block">
|
||||
ccs migrate
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -5,7 +5,6 @@
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
|
||||
Reference in new issue
Block a user