mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-04 16:13:40 +00:00
400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
/**
|
|
* WebSearch Status and Readiness
|
|
*
|
|
* Provides status checking and display functions for WebSearch.
|
|
*
|
|
* @module utils/websearch/status
|
|
*/
|
|
|
|
import { existsSync, readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { ok, warn, fail, info } from '../ui';
|
|
|
|
import { getCcsDir } from '../config-manager';
|
|
import { getAgyCliStatus } from './agy';
|
|
import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli';
|
|
import { getGrokCliStatus } from './grok-cli';
|
|
import { getOpenCodeCliStatus } from './opencode-cli';
|
|
import { getWebSearchApiKeyStates } from './provider-secrets';
|
|
import { normalizeSearxngBaseUrl, type WebSearchCliInfo, type WebSearchStatus } from './types';
|
|
import { getWebSearchConfig } from '../../config/config-loader-facade';
|
|
import type { WebSearchConfigSnapshot } from './launch-state';
|
|
|
|
const PROVIDER_STATE_FILE = 'websearch-provider-state.json';
|
|
|
|
type ProviderCooldown = {
|
|
reason: string;
|
|
until: number;
|
|
};
|
|
|
|
function hasEnvValue(name: string): boolean {
|
|
return (process.env[name] || '').trim().length > 0;
|
|
}
|
|
|
|
function hasValidSearxngUrl(url: string | undefined): boolean {
|
|
const normalized = normalizeSearxngBaseUrl(url);
|
|
return normalized !== null && normalized !== '';
|
|
}
|
|
|
|
function getProviderStatePath(): string {
|
|
return join(getCcsDir(), 'cache', PROVIDER_STATE_FILE);
|
|
}
|
|
|
|
function readProviderCooldowns(now = Date.now()): Record<string, ProviderCooldown> {
|
|
try {
|
|
const statePath = getProviderStatePath();
|
|
if (!existsSync(statePath)) {
|
|
return {};
|
|
}
|
|
|
|
const parsed = JSON.parse(readFileSync(statePath, 'utf8')) as {
|
|
cooldowns?: Record<string, { reason?: unknown; until?: unknown }>;
|
|
};
|
|
const nextCooldowns: Record<string, ProviderCooldown> = {};
|
|
|
|
for (const [providerId, entry] of Object.entries(parsed.cooldowns || {})) {
|
|
const until = Number.parseInt(String(entry?.until || ''), 10);
|
|
if (!Number.isFinite(until) || until <= now) {
|
|
continue;
|
|
}
|
|
|
|
nextCooldowns[providerId] = {
|
|
reason: typeof entry?.reason === 'string' ? entry.reason : 'rate_limited',
|
|
until,
|
|
};
|
|
}
|
|
|
|
return nextCooldowns;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function formatCooldownDuration(until: number, now = Date.now()): string {
|
|
const remainingSec = Math.max(1, Math.ceil((until - now) / 1000));
|
|
if (remainingSec >= 3600) {
|
|
return `~${Math.ceil(remainingSec / 3600)}h`;
|
|
}
|
|
if (remainingSec >= 60) {
|
|
return `~${Math.ceil(remainingSec / 60)}m`;
|
|
}
|
|
return `~${remainingSec}s`;
|
|
}
|
|
|
|
function formatCooldownReason(reason: string): string {
|
|
switch (reason) {
|
|
case 'quota_exhausted':
|
|
return 'quota exhaustion';
|
|
case 'rate_limited':
|
|
return 'rate limiting';
|
|
default:
|
|
return 'a temporary provider error';
|
|
}
|
|
}
|
|
|
|
function applyCooldownStatus(
|
|
provider: WebSearchCliInfo,
|
|
cooldowns: Record<string, ProviderCooldown>,
|
|
now = Date.now()
|
|
): WebSearchCliInfo {
|
|
if (!(provider.enabled && provider.available)) {
|
|
return provider;
|
|
}
|
|
|
|
const cooldown = cooldowns[provider.id];
|
|
if (!cooldown) {
|
|
return provider;
|
|
}
|
|
|
|
return {
|
|
...provider,
|
|
available: false,
|
|
detail: `Cooling down ${formatCooldownDuration(cooldown.until, now)} after ${formatCooldownReason(cooldown.reason)}`,
|
|
};
|
|
}
|
|
|
|
function getLegacyProviderStatuses(
|
|
wsConfig: WebSearchConfigSnapshot,
|
|
options?: { includeVersions?: boolean }
|
|
): WebSearchCliInfo[] {
|
|
const fetchVersion = options?.includeVersions !== false;
|
|
const agyEnabled = wsConfig.providers?.agy?.enabled ?? false;
|
|
const geminiEnabled = wsConfig.providers?.gemini?.enabled ?? false;
|
|
const grokEnabled = wsConfig.providers?.grok?.enabled ?? false;
|
|
const opencodeEnabled = wsConfig.providers?.opencode?.enabled ?? false;
|
|
|
|
const agyStatus = agyEnabled ? getAgyCliStatus({ fetchVersion }) : { installed: false };
|
|
const geminiStatus = geminiEnabled ? getGeminiCliStatus({ fetchVersion }) : { installed: false };
|
|
const grokStatus = grokEnabled ? getGrokCliStatus({ fetchVersion }) : { installed: false };
|
|
const opencodeStatus = opencodeEnabled
|
|
? getOpenCodeCliStatus({ fetchVersion })
|
|
: { installed: false };
|
|
const geminiAuthed = geminiEnabled && geminiStatus.installed && isGeminiAuthenticated();
|
|
return [
|
|
{
|
|
id: 'agy',
|
|
kind: 'legacy-cli',
|
|
name: 'Antigravity CLI',
|
|
command: 'agy',
|
|
enabled: wsConfig.providers?.agy?.enabled ?? false,
|
|
available: agyStatus.installed,
|
|
version: agyStatus.version ?? null,
|
|
installCommand: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
|
|
docsUrl: 'https://antigravity.google/cli',
|
|
requiresApiKey: false,
|
|
description: 'Recommended LLM CLI fallback with Google web search (Gemini CLI successor).',
|
|
detail: agyStatus.installed
|
|
? agyStatus.version
|
|
? `Installed (${agyStatus.version})`
|
|
: 'Installed'
|
|
: 'Not installed',
|
|
},
|
|
{
|
|
id: 'gemini',
|
|
kind: 'legacy-cli',
|
|
name: 'Gemini CLI',
|
|
command: 'gemini',
|
|
enabled: wsConfig.providers?.gemini?.enabled ?? false,
|
|
available: geminiAuthed,
|
|
version: geminiStatus.version ?? null,
|
|
installCommand: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
|
|
docsUrl: 'https://antigravity.google/cli',
|
|
requiresApiKey: false,
|
|
description:
|
|
'Deprecated legacy fallback (Google retired the gemini CLI). Prefer Antigravity.',
|
|
detail: geminiStatus.installed
|
|
? geminiAuthed
|
|
? 'Authenticated'
|
|
: "Run 'gemini' to login"
|
|
: 'Not installed (retired - use Antigravity)',
|
|
},
|
|
{
|
|
id: 'opencode',
|
|
kind: 'legacy-cli',
|
|
name: 'OpenCode',
|
|
command: 'opencode',
|
|
enabled: wsConfig.providers?.opencode?.enabled ?? false,
|
|
available: opencodeStatus.installed,
|
|
version: opencodeStatus.version ?? null,
|
|
installCommand: 'curl -fsSL https://opencode.ai/install | bash',
|
|
docsUrl: 'https://github.com/sst/opencode',
|
|
requiresApiKey: false,
|
|
description: 'Optional legacy LLM fallback via OpenCode.',
|
|
detail: opencodeStatus.installed ? 'Installed' : 'Not installed',
|
|
},
|
|
{
|
|
id: 'grok',
|
|
kind: 'legacy-cli',
|
|
name: 'Grok CLI',
|
|
command: 'grok',
|
|
enabled: wsConfig.providers?.grok?.enabled ?? false,
|
|
available: grokStatus.installed && hasEnvValue('GROK_API_KEY'),
|
|
version: grokStatus.version ?? null,
|
|
installCommand: 'npm install -g @vibe-kit/grok-cli',
|
|
docsUrl: 'https://github.com/superagent-ai/grok-cli',
|
|
requiresApiKey: true,
|
|
apiKeyEnvVar: 'GROK_API_KEY',
|
|
description: 'Optional legacy LLM fallback with xAI Grok.',
|
|
detail: grokStatus.installed
|
|
? hasEnvValue('GROK_API_KEY')
|
|
? 'Ready'
|
|
: 'Set GROK_API_KEY'
|
|
: 'Not installed',
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get all WebSearch providers with their current status.
|
|
*/
|
|
export function getWebSearchCliProviders(
|
|
wsConfig: WebSearchConfigSnapshot = getWebSearchConfig(),
|
|
options?: { includeVersions?: boolean }
|
|
): WebSearchCliInfo[] {
|
|
const apiKeyStates = getWebSearchApiKeyStates();
|
|
const cooldowns = readProviderCooldowns();
|
|
const providers: WebSearchCliInfo[] = [
|
|
{
|
|
id: 'exa',
|
|
kind: 'backend',
|
|
name: 'Exa',
|
|
enabled: wsConfig.providers?.exa?.enabled ?? false,
|
|
available: (wsConfig.providers?.exa?.enabled ?? false) && apiKeyStates.exa.available,
|
|
version: null,
|
|
docsUrl: 'https://docs.exa.ai/reference/search',
|
|
requiresApiKey: true,
|
|
apiKeyEnvVar: 'EXA_API_KEY',
|
|
description: 'API-backed search with strong relevance and content extraction.',
|
|
detail: apiKeyStates.exa.available
|
|
? `API key detected (${wsConfig.providers?.exa?.max_results ?? 5} results)`
|
|
: apiKeyStates.exa.configured
|
|
? 'Stored in dashboard, but Global Env is disabled'
|
|
: 'Set EXA_API_KEY',
|
|
},
|
|
{
|
|
id: 'tavily',
|
|
kind: 'backend',
|
|
name: 'Tavily',
|
|
enabled: wsConfig.providers?.tavily?.enabled ?? false,
|
|
available: (wsConfig.providers?.tavily?.enabled ?? false) && apiKeyStates.tavily.available,
|
|
version: null,
|
|
docsUrl: 'https://docs.tavily.com/documentation/api-reference/endpoint/search',
|
|
requiresApiKey: true,
|
|
apiKeyEnvVar: 'TAVILY_API_KEY',
|
|
description: 'Search API optimized for agent workflows and concise web result synthesis.',
|
|
detail: apiKeyStates.tavily.available
|
|
? `API key detected (${wsConfig.providers?.tavily?.max_results ?? 5} results)`
|
|
: apiKeyStates.tavily.configured
|
|
? 'Stored in dashboard, but Global Env is disabled'
|
|
: 'Set TAVILY_API_KEY',
|
|
},
|
|
{
|
|
id: 'brave',
|
|
kind: 'backend',
|
|
name: 'Brave Search',
|
|
enabled: wsConfig.providers?.brave?.enabled ?? false,
|
|
available: (wsConfig.providers?.brave?.enabled ?? false) && apiKeyStates.brave.available,
|
|
version: null,
|
|
docsUrl: 'https://brave.com/search/api/',
|
|
requiresApiKey: true,
|
|
apiKeyEnvVar: 'BRAVE_API_KEY',
|
|
description: 'API-backed web search with cleaner result metadata.',
|
|
detail: apiKeyStates.brave.available
|
|
? `API key detected (${wsConfig.providers?.brave?.max_results ?? 5} results)`
|
|
: apiKeyStates.brave.configured
|
|
? 'Stored in dashboard, but Global Env is disabled'
|
|
: 'Set BRAVE_API_KEY',
|
|
},
|
|
{
|
|
id: 'searxng',
|
|
kind: 'backend',
|
|
name: 'SearXNG',
|
|
enabled: wsConfig.providers?.searxng?.enabled ?? false,
|
|
available:
|
|
(wsConfig.providers?.searxng?.enabled ?? false) &&
|
|
hasValidSearxngUrl(wsConfig.providers?.searxng?.url),
|
|
version: null,
|
|
docsUrl: 'https://docs.searxng.org/dev/search_api.html',
|
|
requiresApiKey: false,
|
|
description: 'Configurable SearXNG JSON backend for self-hosted or public instances.',
|
|
detail: hasValidSearxngUrl(wsConfig.providers?.searxng?.url)
|
|
? `Configured (${wsConfig.providers?.searxng?.max_results ?? 5} results)`
|
|
: 'Set a valid SearXNG base URL',
|
|
},
|
|
{
|
|
id: 'duckduckgo',
|
|
kind: 'backend',
|
|
name: 'DuckDuckGo',
|
|
enabled: wsConfig.providers?.duckduckgo?.enabled ?? true,
|
|
available: wsConfig.providers?.duckduckgo?.enabled ?? true,
|
|
version: null,
|
|
docsUrl: 'https://duckduckgo.com',
|
|
requiresApiKey: false,
|
|
description: 'Default built-in HTML search backend. Zero setup.',
|
|
detail: `Built-in (${wsConfig.providers?.duckduckgo?.max_results ?? 5} results)`,
|
|
},
|
|
];
|
|
|
|
return [...providers, ...getLegacyProviderStatuses(wsConfig, options)].map((provider) =>
|
|
applyCooldownStatus(provider, cooldowns)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if any WebSearch provider is currently ready.
|
|
*/
|
|
export function hasAnyWebSearchCli(): boolean {
|
|
return getWebSearchCliProviders().some((provider) => provider.enabled && provider.available);
|
|
}
|
|
|
|
/**
|
|
* Get setup hints when no providers are ready.
|
|
*/
|
|
export function getCliInstallHints(): string[] {
|
|
if (hasAnyWebSearchCli()) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
'WebSearch: no ready providers',
|
|
' Enable DuckDuckGo in Settings > WebSearch for zero-setup search',
|
|
' Or enable SearXNG and set a valid base URL (must support /search?format=json)',
|
|
' Or export EXA_API_KEY, TAVILY_API_KEY, or BRAVE_API_KEY for API-backed search',
|
|
' Optional LLM CLI fallback: curl -fsSL https://antigravity.google/cli/install.sh | bash',
|
|
];
|
|
}
|
|
|
|
export function buildWebSearchReadiness(
|
|
enabled: boolean,
|
|
providers: WebSearchCliInfo[]
|
|
): WebSearchStatus {
|
|
if (!enabled) {
|
|
return {
|
|
readiness: 'unavailable',
|
|
message: 'Disabled in config',
|
|
providers,
|
|
};
|
|
}
|
|
|
|
const enabledProviders = providers.filter((provider) => provider.enabled);
|
|
const readyProviders = enabledProviders.filter((provider) => provider.available);
|
|
|
|
if (readyProviders.length > 0) {
|
|
return {
|
|
readiness: 'ready',
|
|
message: `Ready (${readyProviders.map((provider) => provider.name).join(' + ')})`,
|
|
providers,
|
|
};
|
|
}
|
|
|
|
if (enabledProviders.length > 0) {
|
|
return {
|
|
readiness: 'needs_setup',
|
|
message: enabledProviders
|
|
.map((provider) => `${provider.name}: ${provider.detail}`)
|
|
.join(' | '),
|
|
providers,
|
|
};
|
|
}
|
|
|
|
return {
|
|
readiness: 'unavailable',
|
|
message: 'Enable at least one provider in Settings > WebSearch',
|
|
providers,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get WebSearch readiness status for display.
|
|
*/
|
|
export function getWebSearchReadiness(
|
|
wsConfig: WebSearchConfigSnapshot = getWebSearchConfig()
|
|
): WebSearchStatus {
|
|
const providers = getWebSearchCliProviders(wsConfig, { includeVersions: false });
|
|
return buildWebSearchReadiness(wsConfig.enabled, providers);
|
|
}
|
|
|
|
/**
|
|
* Display WebSearch status (single line, equilibrium UX).
|
|
*/
|
|
export function displayWebSearchStatus(
|
|
wsConfig: WebSearchConfigSnapshot = getWebSearchConfig()
|
|
): void {
|
|
const status = getWebSearchReadiness(wsConfig);
|
|
|
|
switch (status.readiness) {
|
|
case 'ready':
|
|
console.error(ok(`WebSearch: ${status.message}`));
|
|
break;
|
|
case 'needs_setup':
|
|
console.error(warn(`WebSearch: ${status.message}`));
|
|
break;
|
|
case 'unavailable':
|
|
console.error(fail(`WebSearch: ${status.message}`));
|
|
for (const [index, hint] of getCliInstallHints().entries()) {
|
|
console.error(index === 0 ? info(hint) : hint);
|
|
}
|
|
break;
|
|
}
|
|
}
|