mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 02:22:55 +08:00
refactor: generic provider effort handling
This commit is contained in:
@@ -12,11 +12,13 @@
|
|||||||
* - WebSocket message streaming
|
* - WebSocket message streaming
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
|
|
||||||
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
|
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
|
||||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
||||||
@@ -42,8 +44,7 @@ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEO
|
|||||||
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
|
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
|
||||||
|
|
||||||
function resolveClaudeEffort(model, effort, modelsDefinition = CLAUDE_FALLBACK_MODELS) {
|
function resolveClaudeEffort(model, effort, modelsDefinition = CLAUDE_FALLBACK_MODELS) {
|
||||||
const selectedModel = modelsDefinition.OPTIONS
|
const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model) || null;
|
||||||
.find((option) => option.value === model) || null;
|
|
||||||
const allowedEfforts = selectedModel?.effort?.values
|
const allowedEfforts = selectedModel?.effort?.values
|
||||||
?.map((value) => value.value) || [];
|
?.map((value) => value.value) || [];
|
||||||
return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
|
return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
|
||||||
|
|||||||
@@ -91,29 +91,35 @@ const readCodexPriority = (value: unknown): number => (
|
|||||||
typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER
|
typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({
|
const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => {
|
||||||
value: model.slug as string,
|
const effortValues = Array.isArray(model.supported_reasoning_levels)
|
||||||
label: readOptionalString(model.display_name) ?? (model.slug as string),
|
? model.supported_reasoning_levels
|
||||||
description: readOptionalString(model.description),
|
.map((level) => {
|
||||||
effort: Array.isArray(model.supported_reasoning_levels) && model.supported_reasoning_levels.length > 0
|
const value = readOptionalString(level?.effort);
|
||||||
? {
|
if (!value) {
|
||||||
default: readOptionalString(model.default_reasoning_level) ?? undefined,
|
return null;
|
||||||
values: model.supported_reasoning_levels
|
}
|
||||||
.map((level) => {
|
|
||||||
const value = readOptionalString(level?.effort);
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
value,
|
value,
|
||||||
description: readOptionalString(level?.description),
|
description: readOptionalString(level?.description),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((level): level is NonNullable<typeof level> => Boolean(level)),
|
.filter((level): level is NonNullable<typeof level> => Boolean(level))
|
||||||
}
|
: [];
|
||||||
: undefined,
|
|
||||||
});
|
return {
|
||||||
|
value: model.slug as string,
|
||||||
|
label: readOptionalString(model.display_name) ?? (model.slug as string),
|
||||||
|
description: readOptionalString(model.description),
|
||||||
|
effort: effortValues.length > 0
|
||||||
|
? {
|
||||||
|
default: readOptionalString(model.default_reasoning_level) ?? undefined,
|
||||||
|
values: effortValues,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
|
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
|
||||||
const sortedModels = [...models]
|
const sortedModels = [...models]
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ type ProviderCapabilities = {
|
|||||||
supportsPermissionRequests: boolean;
|
supportsPermissionRequests: boolean;
|
||||||
/** Whether the token-usage endpoint has data for this provider. */
|
/** Whether the token-usage endpoint has data for this provider. */
|
||||||
supportsTokenUsage: boolean;
|
supportsTokenUsage: boolean;
|
||||||
|
/** Whether the provider runtime can accept model-level reasoning effort. */
|
||||||
|
supportsEffort: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +40,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
|||||||
supportsAbort: true,
|
supportsAbort: true,
|
||||||
supportsPermissionRequests: true,
|
supportsPermissionRequests: true,
|
||||||
supportsTokenUsage: true,
|
supportsTokenUsage: true,
|
||||||
|
supportsEffort: true,
|
||||||
},
|
},
|
||||||
cursor: {
|
cursor: {
|
||||||
provider: 'cursor',
|
provider: 'cursor',
|
||||||
@@ -47,6 +50,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
|||||||
supportsAbort: true,
|
supportsAbort: true,
|
||||||
supportsPermissionRequests: false,
|
supportsPermissionRequests: false,
|
||||||
supportsTokenUsage: false,
|
supportsTokenUsage: false,
|
||||||
|
supportsEffort: false,
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
@@ -56,6 +60,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
|||||||
supportsAbort: true,
|
supportsAbort: true,
|
||||||
supportsPermissionRequests: false,
|
supportsPermissionRequests: false,
|
||||||
supportsTokenUsage: true,
|
supportsTokenUsage: true,
|
||||||
|
supportsEffort: true,
|
||||||
},
|
},
|
||||||
gemini: {
|
gemini: {
|
||||||
provider: 'gemini',
|
provider: 'gemini',
|
||||||
@@ -65,6 +70,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
|||||||
supportsAbort: true,
|
supportsAbort: true,
|
||||||
supportsPermissionRequests: false,
|
supportsPermissionRequests: false,
|
||||||
supportsTokenUsage: true,
|
supportsTokenUsage: true,
|
||||||
|
supportsEffort: false,
|
||||||
},
|
},
|
||||||
opencode: {
|
opencode: {
|
||||||
provider: 'opencode',
|
provider: 'opencode',
|
||||||
@@ -74,6 +80,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
|||||||
supportsAbort: true,
|
supportsAbort: true,
|
||||||
supportsPermissionRequests: false,
|
supportsPermissionRequests: false,
|
||||||
supportsTokenUsage: true,
|
supportsTokenUsage: true,
|
||||||
|
supportsEffort: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
12
src/components/chat/constants/providerEffort.ts
Normal file
12
src/components/chat/constants/providerEffort.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { LLMProvider, ProviderModelOption } from '../../../types/app';
|
||||||
|
|
||||||
|
export const DEFAULT_EFFORT_VALUE = 'default';
|
||||||
|
|
||||||
|
export const FALLBACK_PROVIDER_EFFORT_VALUES: Partial<Record<LLMProvider, readonly string[]>> = {
|
||||||
|
claude: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||||
|
codex: ['low', 'medium', 'high', 'xhigh'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toProviderEffortOptions = (
|
||||||
|
values: readonly string[],
|
||||||
|
): NonNullable<ProviderModelOption['effort']>['values'] => values.map((value) => ({ value }));
|
||||||
@@ -34,11 +34,11 @@ interface UseChatComposerStateArgs {
|
|||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
permissionMode: PermissionMode | string;
|
permissionMode: PermissionMode | string;
|
||||||
cyclePermissionMode: () => void;
|
cyclePermissionMode: () => void;
|
||||||
|
resolvePermissionModeForProvider: (provider: LLMProvider, requestedMode: PermissionMode | string) => PermissionMode;
|
||||||
cursorModel: string;
|
cursorModel: string;
|
||||||
claudeModel: string;
|
claudeModel: string;
|
||||||
codexModel: string;
|
codexModel: string;
|
||||||
claudeEffort: string;
|
currentProviderEffort: string;
|
||||||
codexEffort: string;
|
|
||||||
geminiModel: string;
|
geminiModel: string;
|
||||||
opencodeModel: string;
|
opencodeModel: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -163,17 +163,6 @@ const getNotificationSessionSummary = (
|
|||||||
return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback;
|
return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLatestProviderEffort = (
|
|
||||||
provider: LLMProvider,
|
|
||||||
fallbackEffort: string,
|
|
||||||
): string => {
|
|
||||||
if (provider !== 'claude' && provider !== 'codex') {
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
return safeLocalStorage.getItem(`${provider}-effort`) || fallbackEffort;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useChatComposerState({
|
export function useChatComposerState({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -181,11 +170,11 @@ export function useChatComposerState({
|
|||||||
provider,
|
provider,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
cyclePermissionMode,
|
cyclePermissionMode,
|
||||||
|
resolvePermissionModeForProvider,
|
||||||
cursorModel,
|
cursorModel,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
claudeEffort,
|
currentProviderEffort,
|
||||||
codexEffort,
|
|
||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -743,14 +732,9 @@ export function useChatComposerState({
|
|||||||
: provider === 'gemini'
|
: provider === 'gemini'
|
||||||
? geminiModel
|
? geminiModel
|
||||||
: provider === 'opencode'
|
: provider === 'opencode'
|
||||||
? opencodeModel
|
? opencodeModel
|
||||||
: claudeModel;
|
: claudeModel;
|
||||||
const effort =
|
const effort = currentProviderEffort;
|
||||||
provider === 'claude'
|
|
||||||
? getLatestProviderEffort(provider, claudeEffort)
|
|
||||||
: provider === 'codex'
|
|
||||||
? getLatestProviderEffort(provider, codexEffort)
|
|
||||||
: 'default';
|
|
||||||
|
|
||||||
// One message shape for every provider. The backend resolves the
|
// One message shape for every provider. The backend resolves the
|
||||||
// provider, project path, and provider-native resume id from the
|
// provider, project path, and provider-native resume id from the
|
||||||
@@ -762,9 +746,7 @@ export function useChatComposerState({
|
|||||||
options: {
|
options: {
|
||||||
model,
|
model,
|
||||||
effort,
|
effort,
|
||||||
// Codex has no plan mode; downgrade rather than sending an
|
permissionMode: resolvePermissionModeForProvider(provider, permissionMode),
|
||||||
// unsupported value to its runtime.
|
|
||||||
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
|
|
||||||
toolsSettings,
|
toolsSettings,
|
||||||
skipPermissions: toolsSettings?.skipPermissions || false,
|
skipPermissions: toolsSettings?.skipPermissions || false,
|
||||||
sessionSummary,
|
sessionSummary,
|
||||||
@@ -790,9 +772,8 @@ export function useChatComposerState({
|
|||||||
selectedSession,
|
selectedSession,
|
||||||
attachedImages,
|
attachedImages,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
claudeEffort,
|
|
||||||
codexModel,
|
codexModel,
|
||||||
codexEffort,
|
currentProviderEffort,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
cursorModel,
|
cursorModel,
|
||||||
executeCommand,
|
executeCommand,
|
||||||
@@ -803,6 +784,7 @@ export function useChatComposerState({
|
|||||||
onSessionEstablished,
|
onSessionEstablished,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
provider,
|
provider,
|
||||||
|
resolvePermissionModeForProvider,
|
||||||
resetCommandMenuState,
|
resetCommandMenuState,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
|
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
|
||||||
import type {
|
import type {
|
||||||
@@ -9,6 +10,11 @@ import type {
|
|||||||
ProviderModelsCacheInfo,
|
ProviderModelsCacheInfo,
|
||||||
ProviderModelsDefinition,
|
ProviderModelsDefinition,
|
||||||
} from '../../../types/app';
|
} from '../../../types/app';
|
||||||
|
import {
|
||||||
|
DEFAULT_EFFORT_VALUE,
|
||||||
|
FALLBACK_PROVIDER_EFFORT_VALUES,
|
||||||
|
toProviderEffortOptions,
|
||||||
|
} from '../constants/providerEffort';
|
||||||
|
|
||||||
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
||||||
claude: 'default',
|
claude: 'default',
|
||||||
@@ -18,12 +24,7 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
|
|||||||
opencode: 'anthropic/claude-sonnet-4-5',
|
opencode: 'anthropic/claude-sonnet-4-5',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_EFFORT_VALUE = 'default';
|
const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
||||||
|
|
||||||
const FALLBACK_EFFORT_VALUES: Partial<Record<LLMProvider, string[]>> = {
|
|
||||||
claude: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
||||||
codex: ['low', 'medium', 'high', 'xhigh'],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback permission-mode matrix used only until the backend capability
|
* Fallback permission-mode matrix used only until the backend capability
|
||||||
@@ -47,6 +48,7 @@ type ProviderCapabilities = {
|
|||||||
supportsAbort: boolean;
|
supportsAbort: boolean;
|
||||||
supportsPermissionRequests: boolean;
|
supportsPermissionRequests: boolean;
|
||||||
supportsTokenUsage: boolean;
|
supportsTokenUsage: boolean;
|
||||||
|
supportsEffort?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProviderCapabilitiesApiResponse = {
|
type ProviderCapabilitiesApiResponse = {
|
||||||
@@ -80,7 +82,7 @@ type ChangeActiveModelApiResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) {
|
export function useChatProviderState({ selectedSession, selectedProject: _selectedProject }: UseChatProviderStateArgs) {
|
||||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
|
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
|
||||||
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
|
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
|
||||||
const [provider, setProvider] = useState<LLMProvider>(() => {
|
const [provider, setProvider] = useState<LLMProvider>(() => {
|
||||||
@@ -95,11 +97,11 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
const [codexModel, setCodexModel] = useState<string>(() => {
|
const [codexModel, setCodexModel] = useState<string>(() => {
|
||||||
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
|
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
|
||||||
});
|
});
|
||||||
const [claudeEffort, setClaudeEffort] = useState<string>(() => {
|
const [providerEfforts, setProviderEfforts] = useState<Partial<Record<LLMProvider, string>>>(() => {
|
||||||
return localStorage.getItem('claude-effort') || DEFAULT_EFFORT_VALUE;
|
return PROVIDERS.reduce<Partial<Record<LLMProvider, string>>>((acc, targetProvider) => {
|
||||||
});
|
acc[targetProvider] = localStorage.getItem(`${targetProvider}-effort`) || DEFAULT_EFFORT_VALUE;
|
||||||
const [codexEffort, setCodexEffort] = useState<string>(() => {
|
return acc;
|
||||||
return localStorage.getItem('codex-effort') || DEFAULT_EFFORT_VALUE;
|
}, {});
|
||||||
});
|
});
|
||||||
const [geminiModel, setGeminiModel] = useState<string>(() => {
|
const [geminiModel, setGeminiModel] = useState<string>(() => {
|
||||||
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
|
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
|
||||||
@@ -160,20 +162,15 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => {
|
const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => {
|
||||||
if (targetProvider === 'claude') {
|
setProviderEfforts((previous) => (
|
||||||
setClaudeEffort(effort);
|
previous[targetProvider] === effort
|
||||||
localStorage.setItem('claude-effort', effort);
|
? previous
|
||||||
return;
|
: { ...previous, [targetProvider]: effort }
|
||||||
}
|
));
|
||||||
|
localStorage.setItem(`${targetProvider}-effort`, effort);
|
||||||
if (targetProvider === 'codex') {
|
|
||||||
setCodexEffort(effort);
|
|
||||||
localStorage.setItem('codex-effort', effort);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
|
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
|
||||||
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
|
|
||||||
const requestId = providerModelsRequestIdRef.current + 1;
|
const requestId = providerModelsRequestIdRef.current + 1;
|
||||||
providerModelsRequestIdRef.current = requestId;
|
providerModelsRequestIdRef.current = requestId;
|
||||||
const isHardRefresh = options.bypassCache === true;
|
const isHardRefresh = options.bypassCache === true;
|
||||||
@@ -186,7 +183,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
providers.map(async (p) => {
|
PROVIDERS.map(async (p) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options.bypassCache) {
|
if (options.bypassCache) {
|
||||||
params.set('bypassCache', 'true');
|
params.set('bypassCache', 'true');
|
||||||
@@ -210,7 +207,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
const nextCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
|
const nextCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
|
||||||
const nextCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>> = {};
|
const nextCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>> = {};
|
||||||
|
|
||||||
providers.forEach((p, i) => {
|
PROVIDERS.forEach((p, i) => {
|
||||||
const entry = results[i];
|
const entry = results[i];
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return;
|
return;
|
||||||
@@ -271,6 +268,23 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
|
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
|
||||||
}, [providerCapabilities]);
|
}, [providerCapabilities]);
|
||||||
|
|
||||||
|
const getDefaultPermissionModeForProvider = useCallback((targetProvider: LLMProvider): PermissionMode => {
|
||||||
|
const modes = getPermissionModesForProvider(targetProvider);
|
||||||
|
const capabilityDefault = providerCapabilities?.[targetProvider]?.defaultPermissionMode as PermissionMode | undefined;
|
||||||
|
if (capabilityDefault && modes.includes(capabilityDefault)) {
|
||||||
|
return capabilityDefault;
|
||||||
|
}
|
||||||
|
return modes[0] ?? 'default';
|
||||||
|
}, [getPermissionModesForProvider, providerCapabilities]);
|
||||||
|
|
||||||
|
const getSupportsEffortForProvider = useCallback((targetProvider: LLMProvider): boolean => {
|
||||||
|
const capabilitySupport = providerCapabilities?.[targetProvider]?.supportsEffort;
|
||||||
|
if (typeof capabilitySupport === 'boolean') {
|
||||||
|
return capabilitySupport;
|
||||||
|
}
|
||||||
|
return Boolean(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider]?.length);
|
||||||
|
}, [providerCapabilities]);
|
||||||
|
|
||||||
const pickStoredOrCurrent = (
|
const pickStoredOrCurrent = (
|
||||||
storageKey: string,
|
storageKey: string,
|
||||||
current: string,
|
current: string,
|
||||||
@@ -302,9 +316,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
targetProvider: LLMProvider,
|
targetProvider: LLMProvider,
|
||||||
model: string,
|
model: string,
|
||||||
): string[] => {
|
): string[] => {
|
||||||
|
if (!getSupportsEffortForProvider(targetProvider)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const option = getModelOption(targetProvider, model);
|
const option = getModelOption(targetProvider, model);
|
||||||
return option?.effort?.values.map((value) => value.value) ?? FALLBACK_EFFORT_VALUES[targetProvider] ?? [];
|
if (option) {
|
||||||
}, [getModelOption]);
|
return option.effort?.values.map((value) => value.value) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider] ?? [])];
|
||||||
|
}, [getModelOption, getSupportsEffortForProvider]);
|
||||||
|
|
||||||
const reconcileStoredEffort = useCallback((
|
const reconcileStoredEffort = useCallback((
|
||||||
targetProvider: LLMProvider,
|
targetProvider: LLMProvider,
|
||||||
@@ -316,16 +338,10 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
return DEFAULT_EFFORT_VALUE;
|
return DEFAULT_EFFORT_VALUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageKey = `${targetProvider}-effort`;
|
if (currentEffort === DEFAULT_EFFORT_VALUE || !currentEffort) {
|
||||||
const storedEffort = localStorage.getItem(storageKey);
|
|
||||||
if (storedEffort === DEFAULT_EFFORT_VALUE || storedEffort === null) {
|
|
||||||
return DEFAULT_EFFORT_VALUE;
|
return DEFAULT_EFFORT_VALUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowedValues.includes(storedEffort)) {
|
|
||||||
return storedEffort;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowedValues.includes(currentEffort)) {
|
if (allowedValues.includes(currentEffort)) {
|
||||||
return currentEffort;
|
return currentEffort;
|
||||||
}
|
}
|
||||||
@@ -333,6 +349,14 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
return DEFAULT_EFFORT_VALUE;
|
return DEFAULT_EFFORT_VALUE;
|
||||||
}, [getAllowedEffortValues]);
|
}, [getAllowedEffortValues]);
|
||||||
|
|
||||||
|
const providerModels = useMemo<Record<LLMProvider, string>>(() => ({
|
||||||
|
claude: claudeModel,
|
||||||
|
cursor: cursorModel,
|
||||||
|
codex: codexModel,
|
||||||
|
gemini: geminiModel,
|
||||||
|
opencode: opencodeModel,
|
||||||
|
}), [claudeModel, cursorModel, codexModel, geminiModel, opencodeModel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const claude = providerModelCatalog.claude;
|
const claude = providerModelCatalog.claude;
|
||||||
if (claude) {
|
if (claude) {
|
||||||
@@ -346,16 +370,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
}
|
}
|
||||||
}, [providerModelCatalog.claude, claudeModel]);
|
}, [providerModelCatalog.claude, claudeModel]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const next = reconcileStoredEffort('claude', claudeModel, claudeEffort);
|
|
||||||
if (next !== claudeEffort) {
|
|
||||||
setClaudeEffort(next);
|
|
||||||
}
|
|
||||||
if ((localStorage.getItem('claude-effort') || DEFAULT_EFFORT_VALUE) !== next) {
|
|
||||||
localStorage.setItem('claude-effort', next);
|
|
||||||
}
|
|
||||||
}, [claudeEffort, claudeModel, reconcileStoredEffort]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cursor = providerModelCatalog.cursor;
|
const cursor = providerModelCatalog.cursor;
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
@@ -382,16 +396,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
}
|
}
|
||||||
}, [providerModelCatalog.codex, codexModel]);
|
}, [providerModelCatalog.codex, codexModel]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const next = reconcileStoredEffort('codex', codexModel, codexEffort);
|
|
||||||
if (next !== codexEffort) {
|
|
||||||
setCodexEffort(next);
|
|
||||||
}
|
|
||||||
if ((localStorage.getItem('codex-effort') || DEFAULT_EFFORT_VALUE) !== next) {
|
|
||||||
localStorage.setItem('codex-effort', next);
|
|
||||||
}
|
|
||||||
}, [codexEffort, codexModel, reconcileStoredEffort]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const gemini = providerModelCatalog.gemini;
|
const gemini = providerModelCatalog.gemini;
|
||||||
if (gemini) {
|
if (gemini) {
|
||||||
@@ -418,6 +422,27 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
}
|
}
|
||||||
}, [providerModelCatalog.opencode, opencodeModel]);
|
}, [providerModelCatalog.opencode, opencodeModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextEfforts: Partial<Record<LLMProvider, string>> = {};
|
||||||
|
let hasUpdates = false;
|
||||||
|
|
||||||
|
for (const targetProvider of PROVIDERS) {
|
||||||
|
const currentEffort = providerEfforts[targetProvider] ?? DEFAULT_EFFORT_VALUE;
|
||||||
|
const nextEffort = reconcileStoredEffort(targetProvider, providerModels[targetProvider], currentEffort);
|
||||||
|
if (nextEffort === currentEffort) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextEfforts[targetProvider] = nextEffort;
|
||||||
|
localStorage.setItem(`${targetProvider}-effort`, nextEffort);
|
||||||
|
hasUpdates = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUpdates) {
|
||||||
|
setProviderEfforts((previous) => ({ ...previous, ...nextEfforts }));
|
||||||
|
}
|
||||||
|
}, [providerEfforts, providerModels, reconcileStoredEffort]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSession?.id) {
|
if (!selectedSession?.id) {
|
||||||
return;
|
return;
|
||||||
@@ -425,8 +450,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
|
|
||||||
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
|
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
|
||||||
const validModes = getPermissionModesForProvider(provider);
|
const validModes = getPermissionModesForProvider(provider);
|
||||||
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
|
setPermissionMode(
|
||||||
}, [selectedSession?.id, provider, getPermissionModesForProvider]);
|
savedMode && validModes.includes(savedMode)
|
||||||
|
? savedMode
|
||||||
|
: getDefaultPermissionModeForProvider(provider),
|
||||||
|
);
|
||||||
|
}, [selectedSession?.id, provider, getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
|
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
|
||||||
@@ -480,6 +509,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
}
|
}
|
||||||
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]);
|
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]);
|
||||||
|
|
||||||
|
const resolvePermissionModeForProvider = useCallback((
|
||||||
|
targetProvider: LLMProvider,
|
||||||
|
requestedMode: PermissionMode | string,
|
||||||
|
): PermissionMode => {
|
||||||
|
const validModes = getPermissionModesForProvider(targetProvider);
|
||||||
|
return validModes.includes(requestedMode as PermissionMode)
|
||||||
|
? requestedMode as PermissionMode
|
||||||
|
: getDefaultPermissionModeForProvider(targetProvider);
|
||||||
|
}, [getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
|
||||||
|
|
||||||
const selectProviderModel = useCallback(async (
|
const selectProviderModel = useCallback(async (
|
||||||
targetProvider: LLMProvider,
|
targetProvider: LLMProvider,
|
||||||
model: string,
|
model: string,
|
||||||
@@ -515,6 +554,20 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
};
|
};
|
||||||
}, [setStoredProviderModel]);
|
}, [setStoredProviderModel]);
|
||||||
|
|
||||||
|
const currentProviderEffort = providerEfforts[provider] ?? DEFAULT_EFFORT_VALUE;
|
||||||
|
const currentProviderEffortOptions = useMemo(() => {
|
||||||
|
if (!getSupportsEffortForProvider(provider)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = getModelOption(provider, providerModels[provider]);
|
||||||
|
if (option) {
|
||||||
|
return option.effort?.values ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return toProviderEffortOptions(FALLBACK_PROVIDER_EFFORT_VALUES[provider] ?? []);
|
||||||
|
}, [getModelOption, getSupportsEffortForProvider, provider, providerModels]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
setProvider,
|
setProvider,
|
||||||
@@ -524,10 +577,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
setClaudeModel,
|
setClaudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
setCodexModel,
|
setCodexModel,
|
||||||
claudeEffort,
|
currentProviderEffort,
|
||||||
setClaudeEffort,
|
currentProviderEffortOptions,
|
||||||
codexEffort,
|
|
||||||
setCodexEffort,
|
|
||||||
geminiModel,
|
geminiModel,
|
||||||
setGeminiModel,
|
setGeminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
@@ -544,5 +595,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
|
|||||||
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
|
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
|
||||||
selectProviderModel,
|
selectProviderModel,
|
||||||
setStoredProviderEffort,
|
setStoredProviderEffort,
|
||||||
|
resolvePermissionModeForProvider,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ import ChatMessagesPane from './subcomponents/ChatMessagesPane';
|
|||||||
import ChatComposer from './subcomponents/ChatComposer';
|
import ChatComposer from './subcomponents/ChatComposer';
|
||||||
import CommandResultModal from './subcomponents/CommandResultModal';
|
import CommandResultModal from './subcomponents/CommandResultModal';
|
||||||
|
|
||||||
const FALLBACK_COMPOSER_EFFORT_OPTIONS = {
|
|
||||||
claude: ['low', 'medium', 'high', 'xhigh', 'max'].map((value) => ({ value })),
|
|
||||||
codex: ['low', 'medium', 'high', 'xhigh'].map((value) => ({ value })),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function ChatInterface({
|
function ChatInterface({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -75,10 +70,8 @@ function ChatInterface({
|
|||||||
setClaudeModel,
|
setClaudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
setCodexModel,
|
setCodexModel,
|
||||||
claudeEffort,
|
currentProviderEffort,
|
||||||
setClaudeEffort,
|
currentProviderEffortOptions,
|
||||||
codexEffort,
|
|
||||||
setCodexEffort,
|
|
||||||
geminiModel,
|
geminiModel,
|
||||||
setGeminiModel,
|
setGeminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
@@ -94,6 +87,7 @@ function ChatInterface({
|
|||||||
hardRefreshProviderModels,
|
hardRefreshProviderModels,
|
||||||
selectProviderModel,
|
selectProviderModel,
|
||||||
setStoredProviderEffort,
|
setStoredProviderEffort,
|
||||||
|
resolvePermissionModeForProvider,
|
||||||
} = useChatProviderState({
|
} = useChatProviderState({
|
||||||
selectedSession,
|
selectedSession,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
@@ -208,8 +202,7 @@ function ChatInterface({
|
|||||||
cursorModel,
|
cursorModel,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
codexModel,
|
codexModel,
|
||||||
claudeEffort,
|
currentProviderEffort,
|
||||||
codexEffort,
|
|
||||||
geminiModel,
|
geminiModel,
|
||||||
opencodeModel,
|
opencodeModel,
|
||||||
isLoading: isProcessing,
|
isLoading: isProcessing,
|
||||||
@@ -226,39 +219,9 @@ function ChatInterface({
|
|||||||
addMessage,
|
addMessage,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
|
resolvePermissionModeForProvider,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentComposerModel = useMemo(() => {
|
|
||||||
if (provider === 'claude') return claudeModel;
|
|
||||||
if (provider === 'codex') return codexModel;
|
|
||||||
if (provider === 'gemini') return geminiModel;
|
|
||||||
if (provider === 'opencode') return opencodeModel;
|
|
||||||
return cursorModel;
|
|
||||||
}, [provider, claudeModel, codexModel, geminiModel, opencodeModel, cursorModel]);
|
|
||||||
|
|
||||||
const composerEffort = useMemo(() => {
|
|
||||||
if (provider === 'claude') return claudeEffort;
|
|
||||||
if (provider === 'codex') return codexEffort;
|
|
||||||
return 'default';
|
|
||||||
}, [provider, claudeEffort, codexEffort]);
|
|
||||||
|
|
||||||
const composerEffortOptions = useMemo(() => {
|
|
||||||
const modelOption = providerModelCatalog[provider]?.OPTIONS.find((option) => option.value === currentComposerModel);
|
|
||||||
if (modelOption?.effort?.values?.length) {
|
|
||||||
return modelOption.effort.values;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === 'claude') {
|
|
||||||
return FALLBACK_COMPOSER_EFFORT_OPTIONS.claude;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === 'codex') {
|
|
||||||
return FALLBACK_COMPOSER_EFFORT_OPTIONS.codex;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}, [providerModelCatalog, provider, currentComposerModel]);
|
|
||||||
|
|
||||||
// On WebSocket reconnect, re-fetch the current session's messages from the
|
// On WebSocket reconnect, re-fetch the current session's messages from the
|
||||||
// server so missed streaming events are shown, then re-subscribe — the
|
// server so missed streaming events are shown, then re-subscribe — the
|
||||||
// `chat_subscribed` ack restores or clears the activity indicator, replays
|
// `chat_subscribed` ack restores or clears the activity indicator, replays
|
||||||
@@ -413,8 +376,8 @@ function ChatInterface({
|
|||||||
onAbortSession={handleAbortSession}
|
onAbortSession={handleAbortSession}
|
||||||
permissionMode={permissionMode}
|
permissionMode={permissionMode}
|
||||||
onModeSwitch={cyclePermissionMode}
|
onModeSwitch={cyclePermissionMode}
|
||||||
effort={composerEffort}
|
effort={currentProviderEffort}
|
||||||
availableEffortOptions={composerEffortOptions}
|
availableEffortOptions={currentProviderEffortOptions}
|
||||||
onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)}
|
onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)}
|
||||||
tokenBudget={tokenBudget}
|
tokenBudget={tokenBudget}
|
||||||
onShowTokenUsage={showCostModal}
|
onShowTokenUsage={showCostModal}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import type {
|
import type {
|
||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
ClipboardEvent,
|
ClipboardEvent,
|
||||||
@@ -178,7 +177,7 @@ export default function ChatComposer({
|
|||||||
left: textareaRect ? textareaRect.left : 16,
|
left: textareaRect ? textareaRect.left : 16,
|
||||||
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
|
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
|
||||||
};
|
};
|
||||||
}, [input, isCommandMenuOpen, textareaRef]);
|
}, [isCommandMenuOpen, textareaRef]);
|
||||||
|
|
||||||
// Voice state is hosted here (not in the mic button) so the main Send button can stop
|
// Voice state is hosted here (not in the mic button) so the main Send button can stop
|
||||||
// recording and send the transcript in one tap, the way the mic button drops it in the box.
|
// recording and send the transcript in one tap, the way the mic button drops it in the box.
|
||||||
@@ -219,16 +218,18 @@ export default function ChatComposer({
|
|||||||
|
|
||||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
setIsEffortDropdownOpen(false);
|
setIsEffortDropdownOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('pointerdown', handlePointerDown);
|
document.addEventListener('pointerdown', handlePointerDown);
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('pointerdown', handlePointerDown);
|
document.removeEventListener('pointerdown', handlePointerDown);
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
};
|
};
|
||||||
}, [isEffortDropdownOpen]);
|
}, [isEffortDropdownOpen]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user