mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-06 21:25:34 +08:00
Compare commits
4 Commits
fix/tool-r
...
chore/upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3d0f9037d | ||
|
|
957f53fb99 | ||
|
|
cdcac182d4 | ||
|
|
94785bfa57 |
@@ -11,7 +11,8 @@ export const CLAUDE_MODELS = {
|
|||||||
{
|
{
|
||||||
value: "default",
|
value: "default",
|
||||||
label: "Default (recommended)",
|
label: "Default (recommended)",
|
||||||
description: "Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
|
description:
|
||||||
|
"Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "sonnet",
|
value: "sonnet",
|
||||||
@@ -23,6 +24,12 @@ export const CLAUDE_MODELS = {
|
|||||||
label: "Sonnet (1M context)",
|
label: "Sonnet (1M context)",
|
||||||
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
|
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "opus[1m]",
|
||||||
|
label: "Opus 4.8 (1M context)",
|
||||||
|
description:
|
||||||
|
"Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "haiku",
|
value: "haiku",
|
||||||
label: "Haiku",
|
label: "Haiku",
|
||||||
|
|||||||
@@ -18,18 +18,23 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
|||||||
{
|
{
|
||||||
value: 'default',
|
value: 'default',
|
||||||
label: 'Default (recommended)',
|
label: 'Default (recommended)',
|
||||||
description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok',
|
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'sonnet',
|
value: "sonnet",
|
||||||
label: 'Sonnet',
|
label: "Sonnet",
|
||||||
description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok',
|
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'sonnet[1m]',
|
value: 'sonnet[1m]',
|
||||||
label: 'Sonnet (1M context)',
|
label: 'Sonnet (1M context)',
|
||||||
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
|
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'opus[1m]',
|
||||||
|
label: 'Opus 4.8 (1M context)',
|
||||||
|
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'haiku',
|
value: 'haiku',
|
||||||
label: 'Haiku',
|
label: 'Haiku',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
|
|||||||
|
|
||||||
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
||||||
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
||||||
|
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
|
||||||
|
|
||||||
type ProviderModelsServiceDependencies = {
|
type ProviderModelsServiceDependencies = {
|
||||||
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
||||||
@@ -232,10 +233,42 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
|
|||||||
return request;
|
return request;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadDirectModels = (
|
||||||
|
provider: LLMProvider,
|
||||||
|
): Promise<ProviderModelsResult> => {
|
||||||
|
const request = resolveProvider(provider).models.getSupportedModels()
|
||||||
|
.then((models) => {
|
||||||
|
const currentTime = now();
|
||||||
|
return {
|
||||||
|
models,
|
||||||
|
cache: {
|
||||||
|
updatedAt: new Date(currentTime).toISOString(),
|
||||||
|
expiresAt: new Date(currentTime).toISOString(),
|
||||||
|
source: 'fresh' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pendingRequests.delete(provider);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingRequests.set(provider, request);
|
||||||
|
return request;
|
||||||
|
};
|
||||||
|
|
||||||
const getProviderModels = async (
|
const getProviderModels = async (
|
||||||
provider: LLMProvider,
|
provider: LLMProvider,
|
||||||
options: ProviderModelsOptions = {},
|
options: ProviderModelsOptions = {},
|
||||||
): Promise<ProviderModelsResult> => {
|
): Promise<ProviderModelsResult> => {
|
||||||
|
if (UNCACHED_PROVIDERS.has(provider)) {
|
||||||
|
const pendingRequest = pendingRequests.get(provider);
|
||||||
|
if (pendingRequest) {
|
||||||
|
return pendingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadDirectModels(provider);
|
||||||
|
}
|
||||||
|
|
||||||
if (options.bypassCache) {
|
if (options.bypassCache) {
|
||||||
const pendingRequest = pendingRequests.get(provider);
|
const pendingRequest = pendingRequests.get(provider);
|
||||||
if (pendingRequest) {
|
if (pendingRequest) {
|
||||||
|
|||||||
@@ -130,6 +130,37 @@ test('provider models are cached for the three-day ttl', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('claude provider models are always loaded directly from the provider', async () => {
|
||||||
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-claude-direct-'));
|
||||||
|
let loadCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const service = createProviderModelsService({
|
||||||
|
cachePath: path.join(tempRoot, 'models-cache.json'),
|
||||||
|
resolveProvider: (provider) => ({
|
||||||
|
models: {
|
||||||
|
getSupportedModels: async () => {
|
||||||
|
loadCount += 1;
|
||||||
|
return createModels(`${provider}-${loadCount}`);
|
||||||
|
},
|
||||||
|
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
||||||
|
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await service.getProviderModels('claude');
|
||||||
|
const second = await service.getProviderModels('claude');
|
||||||
|
|
||||||
|
assert.equal(loadCount, 2);
|
||||||
|
assert.equal(first.models.DEFAULT, 'claude-1');
|
||||||
|
assert.equal(second.models.DEFAULT, 'claude-2');
|
||||||
|
assert.equal(second.cache.source, 'fresh');
|
||||||
|
} finally {
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('provider model cache is persisted across service instances', async () => {
|
test('provider model cache is persisted across service instances', async () => {
|
||||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
|
||||||
const cachePath = path.join(tempRoot, 'models-cache.json');
|
const cachePath = path.join(tempRoot, 'models-cache.json');
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ import type { NormalizedMessage } from '../../../stores/useSessionStore';
|
|||||||
import type { ChatMessage, SubagentChildTool } from '../types/types';
|
import type { ChatMessage, SubagentChildTool } from '../types/types';
|
||||||
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
|
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
|
||||||
|
|
||||||
function formatToolResultContent(content: unknown): string {
|
|
||||||
const text = typeof content === 'string' ? content : JSON.stringify(content);
|
|
||||||
const toolUseErrorMatch = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/.exec(text.trim());
|
|
||||||
return toolUseErrorMatch ? toolUseErrorMatch[1] : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert NormalizedMessage[] from the session store into ChatMessage[]
|
* Convert NormalizedMessage[] from the session store into ChatMessage[]
|
||||||
* that the existing UI components expect.
|
* that the existing UI components expect.
|
||||||
@@ -26,12 +20,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
|
|
||||||
// First pass: collect tool results for attachment
|
// First pass: collect tool results for attachment
|
||||||
const toolResultMap = new Map<string, NormalizedMessage>();
|
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||||
const toolUseIds = new Set<string>();
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (msg.kind === 'tool_use' && msg.toolId) {
|
|
||||||
toolUseIds.add(msg.toolId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||||
toolResultMap.set(msg.toolId, msg);
|
toolResultMap.set(msg.toolId, msg);
|
||||||
}
|
}
|
||||||
@@ -108,7 +97,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
|
|
||||||
const toolResult = tr
|
const toolResult = tr
|
||||||
? {
|
? {
|
||||||
content: formatToolResultContent(tr.content),
|
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
||||||
isError: Boolean(tr.isError),
|
isError: Boolean(tr.isError),
|
||||||
toolUseResult: (tr as any).toolUseResult,
|
toolUseResult: (tr as any).toolUseResult,
|
||||||
}
|
}
|
||||||
@@ -202,25 +191,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
// tool_result is handled via attachment to tool_use above
|
// tool_result is handled via attachment to tool_use above
|
||||||
case 'tool_result': {
|
case 'tool_result':
|
||||||
if (msg.toolId && toolUseIds.has(msg.toolId)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = formatToolResultContent(msg.content || '');
|
|
||||||
if (!content.trim()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
converted.push({
|
|
||||||
type: msg.isError ? 'error' : 'assistant',
|
|
||||||
content,
|
|
||||||
timestamp: msg.timestamp,
|
|
||||||
toolId: msg.toolId,
|
|
||||||
...sharedMetadata,
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -564,15 +564,11 @@ export function shouldHideToolResult(toolName: string, toolResult: any): boolean
|
|||||||
|
|
||||||
if (!config.result) return false;
|
if (!config.result) return false;
|
||||||
|
|
||||||
// Hidden/success-only configs suppress noisy successful output, but errors
|
|
||||||
// still need to be visible so failed tool calls are diagnosable.
|
|
||||||
if (toolResult?.isError) return false;
|
|
||||||
|
|
||||||
// Always hidden
|
// Always hidden
|
||||||
if (config.result.hidden) return true;
|
if (config.result.hidden) return true;
|
||||||
|
|
||||||
// Hide on success only
|
// Hide on success only
|
||||||
if (config.result.hideOnSuccess && toolResult) {
|
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@@ -9,10 +8,10 @@ import type {
|
|||||||
Provider,
|
Provider,
|
||||||
} from '../../types/types';
|
} from '../../types/types';
|
||||||
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||||
|
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
||||||
import type { Project } from '../../../../types/app';
|
import type { Project } from '../../../../types/app';
|
||||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
||||||
|
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
import MessageCopyControl from './MessageCopyControl';
|
import MessageCopyControl from './MessageCopyControl';
|
||||||
|
|
||||||
@@ -42,9 +41,10 @@ type InteractiveOption = {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PermissionGrantState = 'idle' | 'granted' | 'error';
|
||||||
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||||
|
|
||||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||||
((prevMessage.type === 'assistant') ||
|
((prevMessage.type === 'assistant') ||
|
||||||
@@ -53,6 +53,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
(prevMessage.type === 'error'));
|
(prevMessage.type === 'error'));
|
||||||
const messageRef = useRef<HTMLDivElement | null>(null);
|
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||||
|
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
|
||||||
const userCopyContent = String(message.content || '');
|
const userCopyContent = String(message.content || '');
|
||||||
const formattedMessageContent = useMemo(
|
const formattedMessageContent = useMemo(
|
||||||
() => formatUsageLimitText(String(message.content || '')),
|
() => formatUsageLimitText(String(message.content || '')),
|
||||||
@@ -71,6 +73,10 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
!message.isThinking;
|
!message.isThinking;
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPermissionGrantState('idle');
|
||||||
|
}, [permissionSuggestion?.entry, message.toolId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = messageRef.current;
|
const node = messageRef.current;
|
||||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
if (!autoExpandTools || !node || !message.isToolUse) return;
|
||||||
@@ -235,6 +241,55 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
||||||
{String(message.toolResult.content || '')}
|
{String(message.toolResult.content || '')}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
|
{permissionSuggestion && (
|
||||||
|
<div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!onGrantToolPermission) return;
|
||||||
|
const result = onGrantToolPermission(permissionSuggestion);
|
||||||
|
if (result?.success) {
|
||||||
|
setPermissionGrantState('granted');
|
||||||
|
} else {
|
||||||
|
setPermissionGrantState('error');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
|
||||||
|
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||||
|
? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
|
||||||
|
: 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||||
|
? t('permissions.added')
|
||||||
|
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
|
||||||
|
</button>
|
||||||
|
{onShowSettings && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
|
||||||
|
className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
|
||||||
|
>
|
||||||
|
{t('permissions.openSettings')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
|
||||||
|
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
|
||||||
|
</div>
|
||||||
|
{permissionGrantState === 'error' && (
|
||||||
|
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||||||
|
{t('permissions.error')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
|
||||||
|
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
|
||||||
|
{t('permissions.retry')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user