feat: integrate thinking level support across various components

- Enhanced multiple server and UI components to include an optional thinking level parameter, improving the configurability of model interactions.
- Updated request handlers and services to manage and pass the thinking level, ensuring consistent data handling across the application.
- Refactored UI components to display and manage the selected model along with its thinking level, enhancing user experience and clarity.
- Adjusted the Electron API and HTTP client to support the new thinking level parameter in requests, ensuring seamless integration.

This update significantly improves the application's ability to adapt reasoning capabilities based on user-defined thinking levels, enhancing overall performance and user satisfaction.
This commit is contained in:
Shirone
2026-01-02 17:52:03 +01:00
parent 69f3ba9724
commit 2b942a6cb1
17 changed files with 233 additions and 254 deletions

View File

@@ -1,30 +1,27 @@
import { useState } from 'react';
import { Settings2, X, RotateCcw } from 'lucide-react';
import * as React from 'react';
import { Settings2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppStore } from '@/store/app-store';
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import { PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants';
import { PhaseModelSelector } from '@/components/views/settings-view/phase-models/phase-model-selector';
/**
* Extract model string from PhaseModelEntry or string
* Normalize PhaseModelEntry or string to PhaseModelEntry
*/
function extractModel(entry: PhaseModelEntry | string | null): ModelAlias | CursorModelId | null {
if (!entry) return null;
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
return { model: entry as ModelAlias | CursorModelId };
}
return entry.model;
return entry;
}
export interface ModelOverrideTriggerProps {
/** Current effective model (from global settings or explicit override) */
currentModel: ModelAlias | CursorModelId;
/** Current effective model entry (from global settings or explicit override) */
currentModelEntry: PhaseModelEntry;
/** Callback when user selects override */
onModelChange: (model: ModelAlias | CursorModelId | null) => void;
onModelChange: (entry: PhaseModelEntry | null) => void;
/** Optional: which phase this is for (shows global default) */
phase?: PhaseModelKey;
/** Size variants for different contexts */
@@ -37,24 +34,8 @@ export interface ModelOverrideTriggerProps {
className?: string;
}
function getModelLabel(modelId: ModelAlias | CursorModelId): string {
// Check Claude models
const claudeModel = CLAUDE_MODELS.find((m) => m.id === modelId);
if (claudeModel) return claudeModel.label;
// Check Cursor models (without cursor- prefix)
const cursorModel = CURSOR_MODELS.find((m) => m.id === `${PROVIDER_PREFIXES.cursor}${modelId}`);
if (cursorModel) return cursorModel.label;
// Check Cursor models (with cursor- prefix)
const cursorModelDirect = CURSOR_MODELS.find((m) => m.id === modelId);
if (cursorModelDirect) return cursorModelDirect.label;
return modelId;
}
export function ModelOverrideTrigger({
currentModel,
currentModelEntry,
onModelChange,
phase,
size = 'sm',
@@ -62,29 +43,31 @@ export function ModelOverrideTrigger({
isOverridden = false,
className,
}: ModelOverrideTriggerProps) {
const [open, setOpen] = useState(false);
const { phaseModels, enabledCursorModels } = useAppStore();
const { phaseModels } = useAppStore();
// Get the global default for this phase (extract model string from PhaseModelEntry)
const globalDefault = phase ? extractModel(phaseModels[phase]) : null;
const handleChange = (entry: PhaseModelEntry) => {
// If the new entry matches the global default, clear the override
// Otherwise, set it as override
if (phase) {
const globalDefault = phaseModels[phase];
const normalizedGlobal = normalizeEntry(globalDefault);
// Filter Cursor models to only show enabled ones
const availableCursorModels = CURSOR_MODELS.filter((model) => {
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
return enabledCursorModels.includes(cursorId);
});
// Compare models (and thinking levels if both have them)
const modelsMatch = entry.model === normalizedGlobal.model;
const thinkingMatch =
(entry.thinkingLevel || 'none') === (normalizedGlobal.thinkingLevel || 'none');
const handleSelect = (model: ModelAlias | CursorModelId) => {
onModelChange(model);
setOpen(false);
if (modelsMatch && thinkingMatch) {
onModelChange(null); // Clear override
} else {
onModelChange(entry); // Set override
}
} else {
onModelChange(entry);
}
};
const handleClear = () => {
onModelChange(null);
setOpen(false);
};
// Size classes
// Size classes for icon variant
const sizeClasses = {
sm: 'h-6 w-6',
md: 'h-8 w-8',
@@ -97,155 +80,47 @@ export function ModelOverrideTrigger({
lg: 'w-5 h-5',
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{variant === 'icon' ? (
<button
className={cn(
'relative rounded-md flex items-center justify-center',
// For icon variant, wrap PhaseModelSelector and hide text/chevron with CSS
if (variant === 'icon') {
return (
<div className={cn('relative inline-block', className)}>
<div className="relative [&_button>span]:hidden [&_button>svg:last-child]:hidden [&_button]:p-0 [&_button]:min-w-0 [&_button]:w-auto [&_button]:h-auto [&_button]:border-0 [&_button]:bg-transparent">
<PhaseModelSelector
value={currentModelEntry}
onChange={handleChange}
compact
triggerClassName={cn(
'relative rounded-md',
'transition-colors duration-150',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
sizeClasses[size],
className
)}
title={`Model: ${getModelLabel(currentModel)}${isOverridden ? ' (overridden)' : ''}`}
>
<Settings2 className={iconSizes[size]} />
{isOverridden && (
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-500 rounded-full" />
)}
</button>
) : variant === 'button' ? (
<Button
variant="outline"
size={size === 'md' ? 'default' : size}
className={cn('gap-2', className)}
>
<Settings2 className={iconSizes[size]} />
<span className="text-xs">{getModelLabel(currentModel)}</span>
{isOverridden && <div className="w-1.5 h-1.5 bg-brand-500 rounded-full" />}
</Button>
) : (
<button
className={cn(
'inline-flex items-center gap-1.5 text-xs',
'text-muted-foreground hover:text-foreground',
'transition-colors duration-150',
className
)}
>
<span>Using {getModelLabel(currentModel)}</span>
<Settings2 className="w-3 h-3" />
{isOverridden && <div className="w-1.5 h-1.5 bg-brand-500 rounded-full" />}
</button>
)}
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-border/50">
<div>
<h4 className="text-sm font-medium">Model Override</h4>
{globalDefault && (
<p className="text-xs text-muted-foreground">
Default: {getModelLabel(globalDefault)}
</p>
)}
</div>
<button
onClick={() => setOpen(false)}
className="text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4" />
</button>
disabled={false}
align="end"
/>
</div>
{/* Content */}
<div className="p-3 space-y-4">
{/* Claude Models */}
<div className="space-y-2">
<h5 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Claude
</h5>
<div className="grid grid-cols-3 gap-2">
{CLAUDE_MODELS.map((model) => {
const isActive = currentModel === model.id;
return (
<button
key={model.id}
onClick={() => handleSelect(model.id as ModelAlias)}
className={cn(
'px-3 py-2 rounded-lg text-xs font-medium text-center',
'transition-all duration-150',
isActive
? ['bg-brand-500/20 text-brand-500', 'border border-brand-500/40']
: [
'bg-accent/50 text-muted-foreground',
'border border-transparent',
'hover:bg-accent hover:text-foreground',
]
)}
>
{model.label.replace('Claude ', '')}
</button>
);
})}
</div>
</div>
{/* Cursor Models */}
{availableCursorModels.length > 0 && (
<div className="space-y-2">
<h5 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Cursor
</h5>
<div className="grid grid-cols-2 gap-2">
{availableCursorModels.slice(0, 6).map((model) => {
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
const isActive = currentModel === cursorId;
return (
<button
key={model.id}
onClick={() => handleSelect(cursorId)}
className={cn(
'px-3 py-2 rounded-lg text-xs font-medium text-center truncate',
'transition-all duration-150',
isActive
? ['bg-purple-500/20 text-purple-400', 'border border-purple-500/40']
: [
'bg-accent/50 text-muted-foreground',
'border border-transparent',
'hover:bg-accent hover:text-foreground',
]
)}
title={model.description}
>
{model.label}
</button>
);
})}
</div>
</div>
)}
</div>
{/* Footer */}
{isOverridden && (
<div className="p-3 border-t border-border/50">
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="w-full gap-2 text-muted-foreground hover:text-foreground"
>
<RotateCcw className="w-3.5 h-3.5" />
Use Global Default
</Button>
</div>
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-500 rounded-full z-10 pointer-events-none" />
)}
</PopoverContent>
</Popover>
</div>
);
}
// For button and inline variants, use PhaseModelSelector in compact mode
return (
<div className={cn('relative', className)}>
<PhaseModelSelector
value={currentModelEntry}
onChange={handleChange}
compact
triggerClassName={variant === 'button' ? className : undefined}
disabled={false}
/>
{isOverridden && (
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-500 rounded-full z-10" />
)}
</div>
);
}

View File

@@ -6,22 +6,34 @@ export interface UseModelOverrideOptions {
/** Which phase this override is for */
phase: PhaseModelKey;
/** Initial override value (optional) */
initialOverride?: ModelAlias | CursorModelId | null;
initialOverride?: PhaseModelEntry | null;
}
export interface UseModelOverrideResult {
/** The effective model (override or global default) */
/** The effective model entry (override or global default) */
effectiveModelEntry: PhaseModelEntry;
/** The effective model string (for backward compatibility with APIs that only accept strings) */
effectiveModel: ModelAlias | CursorModelId;
/** Whether the model is currently overridden */
isOverridden: boolean;
/** Set a model override */
setOverride: (model: ModelAlias | CursorModelId | null) => void;
setOverride: (entry: PhaseModelEntry | null) => void;
/** Clear the override and use global default */
clearOverride: () => void;
/** The global default for this phase */
globalDefault: ModelAlias | CursorModelId;
globalDefault: PhaseModelEntry;
/** The current override value (null if not overridden) */
override: ModelAlias | CursorModelId | null;
override: PhaseModelEntry | null;
}
/**
* Normalize PhaseModelEntry or string to PhaseModelEntry
*/
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
if (typeof entry === 'string') {
return { model: entry as ModelAlias | CursorModelId };
}
return entry;
}
/**
@@ -38,18 +50,18 @@ function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModel
* Hook for managing model overrides per phase
*
* Provides a simple way to allow users to override the global phase model
* for a specific run or context.
* for a specific run or context. Now supports PhaseModelEntry with thinking levels.
*
* @example
* ```tsx
* function EnhanceDialog() {
* const { effectiveModel, isOverridden, setOverride, clearOverride } = useModelOverride({
* const { effectiveModelEntry, isOverridden, setOverride, clearOverride } = useModelOverride({
* phase: 'enhancementModel',
* });
*
* return (
* <ModelOverrideTrigger
* currentModel={effectiveModel}
* currentModelEntry={effectiveModelEntry}
* onModelChange={setOverride}
* phase="enhancementModel"
* isOverridden={isOverridden}
@@ -63,19 +75,25 @@ export function useModelOverride({
initialOverride = null,
}: UseModelOverrideOptions): UseModelOverrideResult {
const { phaseModels } = useAppStore();
const [override, setOverrideState] = useState<ModelAlias | CursorModelId | null>(initialOverride);
const [override, setOverrideState] = useState<PhaseModelEntry | null>(
initialOverride ? normalizeEntry(initialOverride) : null
);
// Extract model string from PhaseModelEntry (handles both old string format and new object format)
const globalDefault = extractModel(phaseModels[phase]);
// Normalize global default to PhaseModelEntry
const globalDefault = normalizeEntry(phaseModels[phase]);
const effectiveModel = useMemo(() => {
const effectiveModelEntry = useMemo(() => {
return override ?? globalDefault;
}, [override, globalDefault]);
const effectiveModel = useMemo(() => {
return effectiveModelEntry.model;
}, [effectiveModelEntry]);
const isOverridden = override !== null;
const setOverride = useCallback((model: ModelAlias | CursorModelId | null) => {
setOverrideState(model);
const setOverride = useCallback((entry: PhaseModelEntry | null) => {
setOverrideState(entry ? normalizeEntry(entry) : null);
}, []);
const clearOverride = useCallback(() => {
@@ -83,6 +101,7 @@ export function useModelOverride({
}, []);
return {
effectiveModelEntry,
effectiveModel,
isOverridden,
setOverride,