mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
feat: add Gemini CLI provider integration (#647)
* feat: add Gemini CLI provider for AI model execution - Add GeminiProvider class extending CliProvider for Gemini CLI integration - Add Gemini models (Gemini 3 Pro/Flash Preview, 2.5 Pro/Flash/Flash-Lite) - Add gemini-models.ts with model definitions and types - Update ModelProvider type to include 'gemini' - Add isGeminiModel() to provider-utils.ts for model detection - Register Gemini provider in provider-factory with priority 4 - Add Gemini setup detection routes (status, auth, deauth) - Add GeminiCliStatus to setup store for UI state management - Add Gemini to PROVIDER_ICON_COMPONENTS for UI icon display - Add GEMINI_MODELS to model-display for dropdown population - Support thinking levels: off, low, medium, high Based on https://github.com/google-gemini/gemini-cli * chore: update package-lock.json * feat(ui): add Gemini provider to settings and setup wizard - Add GeminiCliStatus component for CLI detection display - Add GeminiSettingsTab component for global settings - Update provider-tabs.tsx to include Gemini as 5th tab - Update providers-setup-step.tsx with Gemini provider detection - Add useGeminiCliStatus hook for querying CLI status - Add getGeminiStatus, authGemini, deauthGemini to HTTP API client - Add gemini query key for React Query - Fix GeminiModelId type to not double-prefix model IDs * feat(ui): add Gemini to settings sidebar navigation - Add 'gemini-provider' to SettingsViewId type - Add GeminiIcon and gemini-provider to navigation config - Add gemini-provider to NAV_ID_TO_PROVIDER mapping - Add gemini-provider case in settings-view switch - Export GeminiSettingsTab from providers index This fixes the missing Gemini entry in the AI Providers sidebar menu. * feat(ui): add Gemini model configuration in settings - Create GeminiModelConfiguration component for model selection - Add enabledGeminiModels and geminiDefaultModel state to app-store - Add setEnabledGeminiModels, setGeminiDefaultModel, toggleGeminiModel actions - Update GeminiSettingsTab to show model configuration when CLI is installed - Import GeminiModelId and getAllGeminiModelIds from types This adds the ability to configure which Gemini models are available in the feature modal, similar to other providers like Codex and OpenCode. * feat(ui): add Gemini models to all model dropdowns - Add GEMINI_MODELS to model-constants.ts for UI dropdowns - Add Gemini to ALL_MODELS array used throughout the app - Add GeminiIcon to PROFILE_ICONS mapping - Fix GEMINI_MODELS in model-display.ts to use correct model IDs - Update getModelDisplayName to handle Gemini models correctly Gemini models now appear in all model selection dropdowns including Model Defaults, Feature Defaults, and feature card settings. * fix(gemini): fix CLI integration and event handling - Fix model ID prefix handling: strip gemini- prefix in agent-service, add it back in buildCliArgs for CLI invocation - Fix event normalization to match actual Gemini CLI output format: - type: 'init' (not 'system') - type: 'message' with role (not 'assistant') - tool_name/tool_id/parameters/output field names - Add --sandbox false and --approval-mode yolo for faster execution - Remove thinking level selector from UI (Gemini CLI doesn't support it) - Update auth status to show errors properly * test: update provider-factory tests for Gemini provider - Add GeminiProvider import and spy mock - Update expected provider count from 4 to 5 - Add test for GeminiProvider inclusion - Add gemini key to checkAllProviders test * fix(gemini): address PR review feedback - Fix npm package name from @anthropic-ai/gemini-cli to @google/gemini-cli - Fix comments in gemini-provider.ts to match actual CLI output format - Convert sync fs operations to async using fs/promises * fix(settings): add Gemini and Codex settings to sync Add enabledGeminiModels, geminiDefaultModel, enabledCodexModels, and codexDefaultModel to SETTINGS_FIELDS_TO_SYNC for persistence across sessions. * fix(gemini): address additional PR review feedback - Use 'Speed' badge for non-thinking Gemini models (consistency) - Fix installCommand mapping in gemini-settings-tab.tsx - Add hasEnvApiKey to GeminiCliStatus interface for API parity - Clarify GeminiThinkingLevel comment (CLI doesn't support --thinking-level) * fix(settings): restore Codex and Gemini settings from server Add sanitization and restoration logic for enabledCodexModels, codexDefaultModel, enabledGeminiModels, and geminiDefaultModel in refreshSettingsFromServer() to match the fields in SETTINGS_FIELDS_TO_SYNC. * feat(gemini): normalize tool names and fix workspace restrictions - Add tool name mapping to normalize Gemini CLI tool names to standard names (e.g., write_todos -> TodoWrite, read_file -> Read) - Add normalizeGeminiToolInput to convert write_todos format to TodoWrite format (description -> content, handle cancelled status) - Pass --include-directories with cwd to fix workspace restriction errors when Gemini CLI has a different cached workspace from previous sessions --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
7773db559d
commit
f480386905
@@ -0,0 +1,250 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SkeletonPulse } from '@/components/ui/skeleton';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { CheckCircle2, AlertCircle, RefreshCw, Key } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CliStatus } from '../shared/types';
|
||||
import { GeminiIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
export type GeminiAuthMethod =
|
||||
| 'api_key' // API key authentication
|
||||
| 'google_login' // Google OAuth authentication
|
||||
| 'vertex_ai' // Vertex AI authentication
|
||||
| 'none';
|
||||
|
||||
export interface GeminiAuthStatus {
|
||||
authenticated: boolean;
|
||||
method: GeminiAuthMethod;
|
||||
hasApiKey?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
hasCredentialsFile?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function getAuthMethodLabel(method: GeminiAuthMethod): string {
|
||||
switch (method) {
|
||||
case 'api_key':
|
||||
return 'API Key';
|
||||
case 'google_login':
|
||||
return 'Google OAuth';
|
||||
case 'vertex_ai':
|
||||
return 'Vertex AI';
|
||||
default:
|
||||
return method || 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
interface GeminiCliStatusProps {
|
||||
status: CliStatus | null;
|
||||
authStatus?: GeminiAuthStatus | null;
|
||||
isChecking: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function GeminiCliStatusSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<SkeletonPulse className="w-9 h-9 rounded-xl" />
|
||||
<SkeletonPulse className="h-6 w-36" />
|
||||
</div>
|
||||
<SkeletonPulse className="w-9 h-9 rounded-lg" />
|
||||
</div>
|
||||
<div className="ml-12">
|
||||
<SkeletonPulse className="h-4 w-80" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Installation status skeleton */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
|
||||
<SkeletonPulse className="w-10 h-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<SkeletonPulse className="h-4 w-40" />
|
||||
<SkeletonPulse className="h-3 w-32" />
|
||||
<SkeletonPulse className="h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Auth status skeleton */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
|
||||
<SkeletonPulse className="w-10 h-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<SkeletonPulse className="h-4 w-28" />
|
||||
<SkeletonPulse className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeminiCliStatus({
|
||||
status,
|
||||
authStatus,
|
||||
isChecking,
|
||||
onRefresh,
|
||||
}: GeminiCliStatusProps) {
|
||||
if (!status) return <GeminiCliStatusSkeleton />;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20">
|
||||
<GeminiIcon className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Gemini CLI</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={isChecking}
|
||||
data-testid="refresh-gemini-cli"
|
||||
title="Refresh Gemini CLI detection"
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-lg',
|
||||
'hover:bg-accent/50 hover:scale-105',
|
||||
'transition-all duration-200'
|
||||
)}
|
||||
>
|
||||
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Gemini CLI provides access to Google's Gemini AI models with thinking capabilities.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{status.success && status.status === 'installed' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-emerald-400">Gemini CLI Installed</p>
|
||||
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
|
||||
{status.method && (
|
||||
<p>
|
||||
Method: <span className="font-mono">{status.method}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.version && (
|
||||
<p>
|
||||
Version: <span className="font-mono">{status.version}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.path && (
|
||||
<p className="truncate" title={status.path}>
|
||||
Path: <span className="font-mono text-[10px]">{status.path}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Status */}
|
||||
{authStatus?.authenticated ? (
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
|
||||
<div className="text-xs text-emerald-400/70 mt-1.5">
|
||||
{authStatus.method !== 'none' && (
|
||||
<p>
|
||||
Method:{' '}
|
||||
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-red-500/15 flex items-center justify-center border border-red-500/20 shrink-0 mt-0.5">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-400">Authentication Failed</p>
|
||||
{authStatus?.error && (
|
||||
<p className="text-xs text-red-400/70 mt-1">{authStatus.error}</p>
|
||||
)}
|
||||
<p className="text-xs text-red-400/70 mt-2">
|
||||
Run <code className="font-mono bg-red-500/10 px-1 rounded">gemini</code>{' '}
|
||||
interactively in your terminal to log in with Google, or set the{' '}
|
||||
<code className="font-mono bg-red-500/10 px-1 rounded">GEMINI_API_KEY</code>{' '}
|
||||
environment variable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.recommendation && (
|
||||
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-400">Gemini CLI Not Detected</p>
|
||||
<p className="text-xs text-amber-400/70 mt-1">
|
||||
{status.recommendation || 'Install Gemini CLI to use Google Gemini models.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.installCommands && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
|
||||
<div className="space-y-2">
|
||||
{status.installCommands.npm && (
|
||||
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||
npm
|
||||
</p>
|
||||
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||
{status.installCommands.npm}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{status.installCommands.macos && (
|
||||
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||
macOS/Linux
|
||||
</p>
|
||||
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||
{status.installCommands.macos}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const NAV_ID_TO_PROVIDER: Record<string, ModelProvider> = {
|
||||
'cursor-provider': 'cursor',
|
||||
'codex-provider': 'codex',
|
||||
'opencode-provider': 'opencode',
|
||||
'gemini-provider': 'gemini',
|
||||
};
|
||||
|
||||
interface SettingsNavigationProps {
|
||||
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
Code2,
|
||||
Webhook,
|
||||
} from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||
import {
|
||||
AnthropicIcon,
|
||||
CursorIcon,
|
||||
OpenAIIcon,
|
||||
OpenCodeIcon,
|
||||
GeminiIcon,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||
|
||||
export interface NavigationItem {
|
||||
@@ -51,6 +57,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
||||
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
|
||||
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
|
||||
{ id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon },
|
||||
{ id: 'gemini-provider', label: 'Gemini', icon: GeminiIcon },
|
||||
],
|
||||
},
|
||||
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SettingsViewId =
|
||||
| 'cursor-provider'
|
||||
| 'codex-provider'
|
||||
| 'opencode-provider'
|
||||
| 'gemini-provider'
|
||||
| 'mcp-servers'
|
||||
| 'prompts'
|
||||
| 'model-defaults'
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CursorModelId,
|
||||
CodexModelId,
|
||||
OpencodeModelId,
|
||||
GeminiModelId,
|
||||
GroupedModel,
|
||||
PhaseModelEntry,
|
||||
ClaudeCompatibleProvider,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
CLAUDE_MODELS,
|
||||
CURSOR_MODELS,
|
||||
OPENCODE_MODELS,
|
||||
GEMINI_MODELS,
|
||||
THINKING_LEVELS,
|
||||
THINKING_LEVEL_LABELS,
|
||||
REASONING_EFFORT_LEVELS,
|
||||
@@ -39,6 +41,7 @@ import {
|
||||
OpenRouterIcon,
|
||||
GlmIcon,
|
||||
MiniMaxIcon,
|
||||
GeminiIcon,
|
||||
getProviderIconForModel,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -168,6 +171,7 @@ export function PhaseModelSelector({
|
||||
const expandedProviderTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
enabledCursorModels,
|
||||
enabledGeminiModels,
|
||||
favoriteModels,
|
||||
toggleFavoriteModel,
|
||||
codexModels,
|
||||
@@ -322,6 +326,11 @@ export function PhaseModelSelector({
|
||||
return enabledCursorModels.includes(model.id as CursorModelId);
|
||||
});
|
||||
|
||||
// Filter Gemini models to only show enabled ones
|
||||
const availableGeminiModels = GEMINI_MODELS.filter((model) => {
|
||||
return enabledGeminiModels.includes(model.id as GeminiModelId);
|
||||
});
|
||||
|
||||
// Helper to find current selected model details
|
||||
const currentModel = useMemo(() => {
|
||||
const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
|
||||
@@ -359,6 +368,16 @@ export function PhaseModelSelector({
|
||||
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
|
||||
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
|
||||
|
||||
// Check Gemini models
|
||||
// Note: Gemini CLI doesn't support thinking level configuration
|
||||
const geminiModel = availableGeminiModels.find((m) => m.id === selectedModel);
|
||||
if (geminiModel) {
|
||||
return {
|
||||
...geminiModel,
|
||||
icon: GeminiIcon,
|
||||
};
|
||||
}
|
||||
|
||||
// Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons
|
||||
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
|
||||
if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) };
|
||||
@@ -459,6 +478,7 @@ export function PhaseModelSelector({
|
||||
selectedProviderId,
|
||||
selectedThinkingLevel,
|
||||
availableCursorModels,
|
||||
availableGeminiModels,
|
||||
transformedCodexModels,
|
||||
dynamicOpencodeModels,
|
||||
enabledProviders,
|
||||
@@ -524,17 +544,20 @@ export function PhaseModelSelector({
|
||||
|
||||
// Check if providers are disabled (needed for rendering conditions)
|
||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||
const isGeminiDisabled = disabledProviders.includes('gemini');
|
||||
|
||||
// Group models (filtering out disabled providers)
|
||||
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
|
||||
const { favorites, claude, cursor, codex, gemini, opencode } = useMemo(() => {
|
||||
const favs: typeof CLAUDE_MODELS = [];
|
||||
const cModels: typeof CLAUDE_MODELS = [];
|
||||
const curModels: typeof CURSOR_MODELS = [];
|
||||
const codModels: typeof transformedCodexModels = [];
|
||||
const gemModels: typeof GEMINI_MODELS = [];
|
||||
const ocModels: ModelOption[] = [];
|
||||
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
const isCodexDisabled = disabledProviders.includes('codex');
|
||||
const isGeminiDisabledInner = disabledProviders.includes('gemini');
|
||||
const isOpencodeDisabled = disabledProviders.includes('opencode');
|
||||
|
||||
// Process Claude Models (skip if provider is disabled)
|
||||
@@ -570,6 +593,17 @@ export function PhaseModelSelector({
|
||||
});
|
||||
}
|
||||
|
||||
// Process Gemini Models (skip if provider is disabled)
|
||||
if (!isGeminiDisabledInner) {
|
||||
availableGeminiModels.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
gemModels.push(model);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process OpenCode Models (skip if provider is disabled)
|
||||
if (!isOpencodeDisabled) {
|
||||
allOpencodeModels.forEach((model) => {
|
||||
@@ -586,11 +620,13 @@ export function PhaseModelSelector({
|
||||
claude: cModels,
|
||||
cursor: curModels,
|
||||
codex: codModels,
|
||||
gemini: gemModels,
|
||||
opencode: ocModels,
|
||||
};
|
||||
}, [
|
||||
favoriteModels,
|
||||
availableCursorModels,
|
||||
availableGeminiModels,
|
||||
transformedCodexModels,
|
||||
allOpencodeModels,
|
||||
disabledProviders,
|
||||
@@ -1027,6 +1063,60 @@ export function PhaseModelSelector({
|
||||
);
|
||||
};
|
||||
|
||||
// Render Gemini model item - simple selector without thinking level
|
||||
// Note: Gemini CLI doesn't support a --thinking-level flag, thinking is model-internal
|
||||
const renderGeminiModelItem = (model: (typeof GEMINI_MODELS)[0]) => {
|
||||
const isSelected = selectedModel === model.id;
|
||||
const isFavorite = favoriteModels.includes(model.id);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={model.label}
|
||||
onSelect={() => {
|
||||
onChange({ model: model.id as GeminiModelId });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="group flex items-center justify-between py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<GeminiIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||
{model.label}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{model.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
|
||||
isFavorite
|
||||
? 'text-yellow-500 opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavoriteModel(model.id);
|
||||
}}
|
||||
>
|
||||
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
|
||||
</Button>
|
||||
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
// Render ClaudeCompatibleProvider model item with thinking level support
|
||||
const renderProviderModelItem = (
|
||||
provider: ClaudeCompatibleProvider,
|
||||
@@ -1839,6 +1929,10 @@ export function PhaseModelSelector({
|
||||
if (model.provider === 'codex') {
|
||||
return renderCodexModelItem(model as (typeof transformedCodexModels)[0]);
|
||||
}
|
||||
// Gemini model
|
||||
if (model.provider === 'gemini') {
|
||||
return renderGeminiModelItem(model as (typeof GEMINI_MODELS)[0]);
|
||||
}
|
||||
// OpenCode model
|
||||
if (model.provider === 'opencode') {
|
||||
return renderOpencodeModelItem(model);
|
||||
@@ -1917,6 +2011,12 @@ export function PhaseModelSelector({
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{!isGeminiDisabled && gemini.length > 0 && (
|
||||
<CommandGroup heading="Gemini Models">
|
||||
{gemini.map((model) => renderGeminiModelItem(model))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{opencodeSections.length > 0 && (
|
||||
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
|
||||
{opencodeSections.map((section, sectionIndex) => (
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GeminiModelId } from '@automaker/types';
|
||||
import { GeminiIcon } from '@/components/ui/provider-icon';
|
||||
import { GEMINI_MODEL_MAP } from '@automaker/types';
|
||||
|
||||
interface GeminiModelConfigurationProps {
|
||||
enabledGeminiModels: GeminiModelId[];
|
||||
geminiDefaultModel: GeminiModelId;
|
||||
isSaving: boolean;
|
||||
onDefaultModelChange: (model: GeminiModelId) => void;
|
||||
onModelToggle: (model: GeminiModelId, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
interface GeminiModelInfo {
|
||||
id: GeminiModelId;
|
||||
label: string;
|
||||
description: string;
|
||||
supportsThinking: boolean;
|
||||
}
|
||||
|
||||
// Build model info from the GEMINI_MODEL_MAP
|
||||
const GEMINI_MODEL_INFO: Record<GeminiModelId, GeminiModelInfo> = Object.fromEntries(
|
||||
Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => [
|
||||
id as GeminiModelId,
|
||||
{
|
||||
id: id as GeminiModelId,
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
supportsThinking: config.supportsThinking,
|
||||
},
|
||||
])
|
||||
) as Record<GeminiModelId, GeminiModelInfo>;
|
||||
|
||||
export function GeminiModelConfiguration({
|
||||
enabledGeminiModels,
|
||||
geminiDefaultModel,
|
||||
isSaving,
|
||||
onDefaultModelChange,
|
||||
onModelToggle,
|
||||
}: GeminiModelConfigurationProps) {
|
||||
const availableModels = Object.values(GEMINI_MODEL_INFO);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20">
|
||||
<GeminiIcon className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Model Configuration
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure which Gemini models are available in the feature modal
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Default Model</Label>
|
||||
<Select
|
||||
value={geminiDefaultModel}
|
||||
onValueChange={(v) => onDefaultModelChange(v as GeminiModelId)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{model.label}</span>
|
||||
{model.supportsThinking && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Thinking
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Available Models</Label>
|
||||
<div className="grid gap-3">
|
||||
{availableModels.map((model) => {
|
||||
const isEnabled = enabledGeminiModels.includes(model.id);
|
||||
const isDefault = model.id === geminiDefaultModel;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
|
||||
disabled={isSaving || isDefault}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{model.label}</span>
|
||||
{model.supportsThinking && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Thinking
|
||||
</Badge>
|
||||
)}
|
||||
{isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{model.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { GeminiCliStatus, GeminiCliStatusSkeleton } from '../cli-status/gemini-cli-status';
|
||||
import { GeminiModelConfiguration } from './gemini-model-configuration';
|
||||
import { ProviderToggle } from './provider-toggle';
|
||||
import { useGeminiCliStatus } from '@/hooks/queries';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
||||
import type { GeminiAuthStatus } from '../cli-status/gemini-cli-status';
|
||||
import type { GeminiModelId } from '@automaker/types';
|
||||
|
||||
export function GeminiSettingsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { enabledGeminiModels, geminiDefaultModel, setGeminiDefaultModel, toggleGeminiModel } =
|
||||
useAppStore();
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// React Query hooks for data fetching
|
||||
const {
|
||||
data: cliStatusData,
|
||||
isLoading: isCheckingGeminiCli,
|
||||
refetch: refetchCliStatus,
|
||||
} = useGeminiCliStatus();
|
||||
|
||||
const isCliInstalled = cliStatusData?.installed ?? false;
|
||||
|
||||
// Transform CLI status to the expected format
|
||||
const cliStatus = useMemo((): SharedCliStatus | null => {
|
||||
if (!cliStatusData) return null;
|
||||
return {
|
||||
success: cliStatusData.success ?? false,
|
||||
status: cliStatusData.installed ? 'installed' : 'not_installed',
|
||||
method: cliStatusData.auth?.method,
|
||||
version: cliStatusData.version,
|
||||
path: cliStatusData.path,
|
||||
recommendation: cliStatusData.recommendation,
|
||||
// Server sends installCommand (singular), transform to expected format
|
||||
installCommands: cliStatusData.installCommand
|
||||
? { npm: cliStatusData.installCommand }
|
||||
: cliStatusData.installCommands,
|
||||
};
|
||||
}, [cliStatusData]);
|
||||
|
||||
// Transform auth status to the expected format
|
||||
const authStatus = useMemo((): GeminiAuthStatus | null => {
|
||||
if (!cliStatusData?.auth) return null;
|
||||
return {
|
||||
authenticated: cliStatusData.auth.authenticated,
|
||||
method: (cliStatusData.auth.method as GeminiAuthStatus['method']) || 'none',
|
||||
hasApiKey: cliStatusData.auth.hasApiKey,
|
||||
hasEnvApiKey: cliStatusData.auth.hasEnvApiKey,
|
||||
error: cliStatusData.auth.error,
|
||||
};
|
||||
}, [cliStatusData]);
|
||||
|
||||
// Refresh all gemini-related queries
|
||||
const handleRefreshGeminiCli = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.cli.gemini() });
|
||||
await refetchCliStatus();
|
||||
toast.success('Gemini CLI refreshed');
|
||||
}, [queryClient, refetchCliStatus]);
|
||||
|
||||
const handleDefaultModelChange = useCallback(
|
||||
(model: GeminiModelId) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
setGeminiDefaultModel(model);
|
||||
toast.success('Default model updated');
|
||||
} catch {
|
||||
toast.error('Failed to update default model');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[setGeminiDefaultModel]
|
||||
);
|
||||
|
||||
const handleModelToggle = useCallback(
|
||||
(model: GeminiModelId, enabled: boolean) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
toggleGeminiModel(model, enabled);
|
||||
} catch {
|
||||
toast.error('Failed to update models');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[toggleGeminiModel]
|
||||
);
|
||||
|
||||
// Show skeleton only while checking CLI status initially
|
||||
if (!cliStatus && isCheckingGeminiCli) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<GeminiCliStatusSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Provider Visibility Toggle */}
|
||||
<ProviderToggle provider="gemini" providerLabel="Gemini" />
|
||||
|
||||
<GeminiCliStatus
|
||||
status={cliStatus}
|
||||
authStatus={authStatus}
|
||||
isChecking={isCheckingGeminiCli}
|
||||
onRefresh={handleRefreshGeminiCli}
|
||||
/>
|
||||
|
||||
{/* Model Configuration - Only show when CLI is installed */}
|
||||
{isCliInstalled && (
|
||||
<GeminiModelConfiguration
|
||||
enabledGeminiModels={enabledGeminiModels}
|
||||
geminiDefaultModel={geminiDefaultModel}
|
||||
isSaving={isSaving}
|
||||
onDefaultModelChange={handleDefaultModelChange}
|
||||
onModelToggle={handleModelToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeminiSettingsTab;
|
||||
@@ -3,3 +3,4 @@ export { ClaudeSettingsTab } from './claude-settings-tab';
|
||||
export { CursorSettingsTab } from './cursor-settings-tab';
|
||||
export { CodexSettingsTab } from './codex-settings-tab';
|
||||
export { OpencodeSettingsTab } from './opencode-settings-tab';
|
||||
export { GeminiSettingsTab } from './gemini-settings-tab';
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import {
|
||||
AnthropicIcon,
|
||||
CursorIcon,
|
||||
OpenAIIcon,
|
||||
GeminiIcon,
|
||||
OpenCodeIcon,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import { CursorSettingsTab } from './cursor-settings-tab';
|
||||
import { ClaudeSettingsTab } from './claude-settings-tab';
|
||||
import { CodexSettingsTab } from './codex-settings-tab';
|
||||
import { OpencodeSettingsTab } from './opencode-settings-tab';
|
||||
import { GeminiSettingsTab } from './gemini-settings-tab';
|
||||
|
||||
interface ProviderTabsProps {
|
||||
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode';
|
||||
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini';
|
||||
}
|
||||
|
||||
export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue={defaultTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4 mb-6">
|
||||
<TabsList className="grid w-full grid-cols-5 mb-6">
|
||||
<TabsTrigger value="claude" className="flex items-center gap-2">
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
@@ -28,9 +34,13 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||
Codex
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="opencode" className="flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" />
|
||||
<OpenCodeIcon className="w-4 h-4" />
|
||||
OpenCode
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="gemini" className="flex items-center gap-2">
|
||||
<GeminiIcon className="w-4 h-4" />
|
||||
Gemini
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="claude">
|
||||
@@ -48,6 +58,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||
<TabsContent value="opencode">
|
||||
<OpencodeSettingsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gemini">
|
||||
<GeminiSettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user