From 14c53d575f5eda6e49861d23c9eb22bfa6bf7058 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 00:16:03 -0500 Subject: [PATCH] feat(websearch): add model config + improve hook UX - Add model field to Gemini/OpenCode WebSearch providers - Config.yaml now explicitly stores model (gemini-2.5-flash, opencode/grok-code) - UI: blur-to-save for model inputs (no save on every keystroke) - UI: collapsible install hints inside provider cards - UI: left accent border for enabled providers (better light theme contrast) - Hook: use systemMessage for info-styled confirmation - Hook: cleaner result format without defensive "NOT an error" language --- lib/hooks/websearch-transformer.cjs | 29 +- src/config/unified-config-loader.ts | 28 +- src/config/unified-config-types.ts | 14 +- src/utils/websearch-manager.ts | 3 + src/web-server/routes.ts | 4 + ui/src/pages/settings.tsx | 448 +++++++++++++++++----------- 6 files changed, 342 insertions(+), 184 deletions(-) diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index bdb40ec4..4384a63c 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -11,6 +11,7 @@ * 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) @@ -258,13 +259,16 @@ function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { 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 ${config.model} --yolo -p "..."`); + console.error(`[CCS Hook] Executing: gemini --model ${model} --yolo -p "..."`); } const spawnResult = spawnSync( 'gemini', - ['--model', config.model, '--yolo', '-p', prompt], + ['--model', model, '--yolo', '-p', prompt], { encoding: 'utf8', timeout: timeoutMs, @@ -441,29 +445,40 @@ function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { */ function formatSearchResults(query, content, providerName) { return [ - '=== WEBSEARCH COMPLETED SUCCESSFULLY ===', - `(via ${providerName} - this is NOT an error)`, + `[WebSearch Result via ${providerName}]`, '', `Query: "${query}"`, '', content, '', - '=========================================', - 'Use this information to answer the user. Search again if needed.', + '---', + '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', - permissionDecisionReason: formatSearchResults(query, content, providerName), + // Full results here - Claude reads this + permissionDecisionReason: formattedResults, }, }; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 9dbf877f..04394694 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -83,14 +83,17 @@ export function loadUnifiedConfig(): UnifiedConfig | null { return null; } - // Auto-upgrade if version is outdated (regenerates YAML with new comments) + // Auto-upgrade if version is outdated (regenerates YAML with new comments and fields) if ((parsed.version ?? 1) < UNIFIED_CONFIG_VERSION) { - parsed.version = 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(parsed); + 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 } @@ -137,6 +140,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { 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 @@ -277,12 +281,18 @@ function generateYamlWithComments(config: UnifiedConfig): string { 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('# providers:'); - lines.push('# gemini: Gemini CLI (FREE) - npm i -g @google/gemini-cli'); - lines.push('# opencode: OpenCode (FREE) - curl -fsSL https://opencode.ai/install | bash'); - lines.push('# grok: Grok CLI (paid) - npm i -g @vibe-kit/grok-cli (needs GROK_API_KEY)'); lines.push('#'); - lines.push('# enabled: Master switch - auto-disables when no providers enabled'); + 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 @@ -362,6 +372,7 @@ export function setDefaultProfile(name: string): void { */ export interface GeminiWebSearchInfo { enabled: boolean; + model: string; timeout: number; } @@ -386,6 +397,7 @@ export function getWebSearchConfig(): { 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, }; diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 4027c2d2..75cbfe31 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -12,7 +12,7 @@ /** * Unified config version. * Version 2 = YAML unified format - * Version 3 = WebSearch config comments update + * Version 3 = WebSearch config with model configuration for Gemini/OpenCode */ export const UNIFIED_CONFIG_VERSION = 3; @@ -107,6 +107,8 @@ export interface PreferencesConfig { 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; } @@ -237,6 +239,16 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { 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, }, }, diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index 82d52e5c..f4a7721c 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -654,6 +654,9 @@ export function getWebSearchHookEnv(): Record { // 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); } diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 9d54701b..52690630 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -1477,6 +1477,10 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => { 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 ?? diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index ea4b2b1c..d14175db 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'; 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, @@ -20,22 +21,21 @@ import { GripVertical, Terminal, ExternalLink, + ChevronDown, + ChevronUp, } from 'lucide-react'; import { CodeEditor } from '@/components/code-editor'; interface ProviderConfig { enabled?: boolean; - timeout?: number; -} - -interface OpenCodeProviderConfig extends ProviderConfig { model?: string; + timeout?: number; } interface WebSearchProvidersConfig { gemini?: ProviderConfig; grok?: ProviderConfig; - opencode?: OpenCodeProviderConfig; + opencode?: ProviderConfig; } interface WebSearchConfig { @@ -71,6 +71,13 @@ export function SettingsPage() { const [rawConfig, setRawConfig] = useState(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); // Load config and status on mount useEffect(() => { @@ -79,6 +86,14 @@ export function SettingsPage() { 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); @@ -235,6 +250,40 @@ export function SettingsPage() { }); }; + // 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 (
@@ -313,201 +362,264 @@ export function SettingsPage() { {/* Gemini CLI Provider */}
-
- -
-
-

gemini

- - FREE - - {status?.geminiCli?.installed ? ( +
+
+ +
+
+

gemini

- installed + FREE - ) : ( - - not installed - - )} + {status?.geminiCli?.installed ? ( + + installed + + ) : ( + + not installed + + )} +
+

+ Google Gemini CLI (1000 req/day free) +

-

- Google Gemini CLI (1000 req/day free) -

+
- -
- - {/* Gemini Installation hint when not installed */} - {!status?.geminiCli?.installed && !statusLoading && ( -
-

- Gemini CLI not installed -

-

- Install globally (FREE tier available): -

- - npm install -g @google/gemini-cli - - + )} + {/* Installation hint when not installed - inside card */} + {!status?.geminiCli?.installed && !statusLoading && ( +
+ + {showGeminiHint && ( +
+

+ Install globally (FREE tier available): +

+ + npm install -g @google/gemini-cli + + + + View documentation + +
+ )}
-
- )} + )} +
{/* OpenCode CLI Provider */}
-
- -
-
-

opencode

- - FREE - - {status?.opencodeCli?.installed ? ( +
+
+ +
+
+

opencode

- installed + FREE - ) : ( - - not installed - - )} + {status?.opencodeCli?.installed ? ( + + installed + + ) : ( + + not installed + + )} +
+

+ OpenCode (web search via Zen) +

-

- OpenCode (web search via Zen) -

+
- -
- - {/* OpenCode Installation hint when not installed */} - {!status?.opencodeCli?.installed && !statusLoading && ( -
-

- OpenCode not installed -

-

- Install globally (FREE tier available): -

- - curl -fsSL https://opencode.ai/install | bash - - + )} + {/* Installation hint when not installed - inside card */} + {!status?.opencodeCli?.installed && !statusLoading && ( +
+ + {showOpencodeHint && ( +
+

+ Install globally (FREE tier available): +

+ + curl -fsSL https://opencode.ai/install | bash + + + + View documentation + +
+ )}
-
- )} + )} +
{/* Grok CLI Provider */}
-
- -
-
-

grok

- - GROK_API_KEY - - {status?.grokCli?.installed ? ( - - installed +
+
+ +
+
+

grok

+ + GROK_API_KEY - ) : ( - - not installed - - )} + {status?.grokCli?.installed ? ( + + installed + + ) : ( + + not installed + + )} +
+

+ xAI Grok CLI (web + X search) +

-

- xAI Grok CLI (web + X search) -

+
- -
- - {/* Grok Installation hint when not installed */} - {!status?.grokCli?.installed && !statusLoading && ( -
-

- Grok CLI not installed -

-

- Install globally (requires xAI API key): -

- - npm install -g @vibe-kit/grok-cli - -
- + + {showGrokHint && ( +
+

+ Install globally (requires xAI API key): +

+ + npm install -g @vibe-kit/grok-cli + + + + View documentation + +
+ )}
-
- )} + )} +