feat: provider/model system refactor — switch provider from chat input
Refactors the provider/model selection system so users can switch providers
directly from the chat input model selector, instead of having to go to
Settings to activate/deactivate providers.
types/index.ts:
- Add ProviderModelGroup interface for grouped provider+model data
- Extend SendMessageRequest, ClaudeStreamOptions, ChatSession with provider_id
db.ts:
- Migration: add provider_id column to chat_sessions
- Migration: convert is_active=1 provider to default_provider_id setting
- New helpers: updateSessionProviderId, getDefaultProviderId, setDefaultProviderId
providers/models/route.ts:
- Rewrite to return { groups, default_provider_id } instead of flat model list
- Each configured provider becomes a group; env vars become "Environment" group
- Deduplicate models within a group (e.g. GLM sonnet/opus both → "GLM-4.7" once)
chat/route.ts:
- Accept provider_id from request body
- Resolution chain: request → session → default_provider_id → env vars
- provider_id='env' is sentinel for environment variable fallback
- Pass resolved provider object to streamClaude()
claude-client.ts:
- Use options.provider ?? getActiveProvider() so explicit provider takes priority
ChatView.tsx:
- Track currentProviderId state, pass provider_id in fetch body
- New handleProviderModelChange callback
MessageInput.tsx:
- New props: providerId, onProviderModelChange
- Replace dynamicModels/activeProviderName with providerGroups/defaultProviderId
- Model selector UI: grouped by provider with section headers, ✓ for active item
- Persist both model and provider ID to localStorage
chat/page.tsx, chat/[id]/page.tsx:
- Track and pass provider_id through the component chain
ProviderManager.tsx:
- Remove handleActivate/handleDeactivate, activatingId, Apply/Disable buttons
- Remove Active badge and green border styling
- Simplify env variable banner
- Add provider-changed event dispatch to save/delete handlers
providers/route.ts:
- GET response now includes default_provider_id field
i18n (en.ts, zh.ts):
- Add provider.default, provider.setDefault, provider.environment translations
Backward compatible: old sessions without provider_id default to '' which
triggers the fallback chain. is_active column preserved but no longer written.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf14791de0
commit
448738d4f3
13 files changed
+290
-250
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { streamClaude } from '@/lib/claude-client';
|
||||
import { addMessage, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, getSetting, getActiveProvider } from '@/lib/db';
|
||||
import { addMessage, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId } from '@/lib/db';
|
||||
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -10,8 +10,8 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number } = await request.json();
|
||||
const { session_id, content, model, mode, files, toolTimeout } = body;
|
||||
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string } = await request.json();
|
||||
const { session_id, content, model, mode, files, toolTimeout, provider_id } = body;
|
||||
|
||||
if (!session_id || !content) {
|
||||
return new Response(JSON.stringify({ error: 'session_id and content are required' }), {
|
||||
@@ -62,11 +62,36 @@ export async function POST(request: NextRequest) {
|
||||
if (effectiveModel && effectiveModel !== session.model) {
|
||||
updateSessionModel(session_id, effectiveModel);
|
||||
}
|
||||
const activeProvider = getActiveProvider();
|
||||
const providerName = activeProvider?.name || '';
|
||||
|
||||
// Resolve provider: explicit provider_id > default_provider_id > environment variables
|
||||
let resolvedProvider: import('@/types').ApiProvider | undefined;
|
||||
const effectiveProviderId = provider_id || session.provider_id || '';
|
||||
if (effectiveProviderId && effectiveProviderId !== 'env') {
|
||||
resolvedProvider = getProvider(effectiveProviderId);
|
||||
if (!resolvedProvider) {
|
||||
// Requested provider not found, try default
|
||||
const defaultId = getDefaultProviderId();
|
||||
if (defaultId) {
|
||||
resolvedProvider = getProvider(defaultId);
|
||||
}
|
||||
}
|
||||
} else if (!effectiveProviderId) {
|
||||
// No provider specified, try default
|
||||
const defaultId = getDefaultProviderId();
|
||||
if (defaultId) {
|
||||
resolvedProvider = getProvider(defaultId);
|
||||
}
|
||||
}
|
||||
// effectiveProviderId === 'env' → resolvedProvider stays undefined → uses env vars
|
||||
|
||||
const providerName = resolvedProvider?.name || '';
|
||||
if (providerName !== (session.provider_name || '')) {
|
||||
updateSessionProvider(session_id, providerName);
|
||||
}
|
||||
const persistProviderId = effectiveProviderId || provider_id || '';
|
||||
if (persistProviderId !== (session.provider_id || '')) {
|
||||
updateSessionProviderId(session_id, persistProviderId);
|
||||
}
|
||||
|
||||
// Determine permission mode from chat mode: code → acceptEdits, plan → plan, ask → default (no tools)
|
||||
const effectiveMode = mode || session.mode || 'code';
|
||||
@@ -122,6 +147,7 @@ export async function POST(request: NextRequest) {
|
||||
permissionMode,
|
||||
files: fileAttachments,
|
||||
toolTimeoutSeconds: toolTimeout || 300,
|
||||
provider: resolvedProvider,
|
||||
});
|
||||
|
||||
// Tee the stream: one for client, one for collecting the response
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getActiveProvider } from '@/lib/db';
|
||||
import type { ErrorResponse } from '@/types';
|
||||
import { getAllProviders, getDefaultProviderId } from '@/lib/db';
|
||||
import type { ErrorResponse, ProviderModelGroup } from '@/types';
|
||||
|
||||
// Default Claude model options
|
||||
const DEFAULT_MODELS = [
|
||||
@@ -53,31 +53,67 @@ const PROVIDER_MODEL_LABELS: Record<string, { value: string; label: string }[]>
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Deduplicate models: if multiple aliases map to the same label, keep only the first one.
|
||||
*/
|
||||
function deduplicateModels(models: { value: string; label: string }[]): { value: string; label: string }[] {
|
||||
const seen = new Set<string>();
|
||||
const result: { value: string; label: string }[] = [];
|
||||
for (const m of models) {
|
||||
if (!seen.has(m.label)) {
|
||||
seen.add(m.label);
|
||||
result.push(m);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const activeProvider = getActiveProvider();
|
||||
const providers = getAllProviders();
|
||||
const groups: ProviderModelGroup[] = [];
|
||||
|
||||
// No active provider or anthropic type -> default Claude models
|
||||
if (!activeProvider || activeProvider.provider_type === 'anthropic') {
|
||||
return NextResponse.json({
|
||||
// Check for environment variables
|
||||
const hasEnvKey = !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN);
|
||||
if (hasEnvKey) {
|
||||
groups.push({
|
||||
provider_id: 'env',
|
||||
provider_name: 'Environment',
|
||||
provider_type: 'anthropic',
|
||||
models: DEFAULT_MODELS,
|
||||
provider_name: activeProvider?.name || 'Anthropic',
|
||||
});
|
||||
}
|
||||
|
||||
// Custom/other provider -> match by base_url
|
||||
const matched = PROVIDER_MODEL_LABELS[activeProvider.base_url];
|
||||
if (matched) {
|
||||
return NextResponse.json({
|
||||
models: matched,
|
||||
provider_name: activeProvider.name,
|
||||
// Build a group for each configured provider
|
||||
for (const provider of providers) {
|
||||
const matched = PROVIDER_MODEL_LABELS[provider.base_url];
|
||||
const rawModels = matched || DEFAULT_MODELS;
|
||||
const models = deduplicateModels(rawModels);
|
||||
|
||||
groups.push({
|
||||
provider_id: provider.id,
|
||||
provider_name: provider.name,
|
||||
provider_type: provider.provider_type,
|
||||
models,
|
||||
});
|
||||
}
|
||||
|
||||
// Unknown provider -> return defaults with provider name
|
||||
// If no groups at all (no env, no providers), show default Anthropic group
|
||||
if (groups.length === 0) {
|
||||
groups.push({
|
||||
provider_id: 'env',
|
||||
provider_name: 'Anthropic',
|
||||
provider_type: 'anthropic',
|
||||
models: DEFAULT_MODELS,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine default provider
|
||||
const defaultProviderId = getDefaultProviderId() || groups[0].provider_id;
|
||||
|
||||
return NextResponse.json({
|
||||
models: DEFAULT_MODELS,
|
||||
provider_name: activeProvider.name,
|
||||
groups,
|
||||
default_provider_id: defaultProviderId,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAllProviders, createProvider } from '@/lib/db';
|
||||
import { getAllProviders, createProvider, getSetting } from '@/lib/db';
|
||||
import type { ProviderResponse, ErrorResponse, CreateProviderRequest, ApiProvider } from '@/types';
|
||||
|
||||
function maskApiKey(provider: ApiProvider): ApiProvider {
|
||||
@@ -38,7 +38,11 @@ export async function GET() {
|
||||
try {
|
||||
const providers = getAllProviders().map(maskApiKey);
|
||||
const envDetected = detectEnvVars();
|
||||
return NextResponse.json({ providers, env_detected: envDetected });
|
||||
return NextResponse.json({
|
||||
providers,
|
||||
env_detected: envDetected,
|
||||
default_provider_id: getSetting('default_provider_id') || '',
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to get providers' },
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionTitle, setSessionTitle] = useState<string>('');
|
||||
const [sessionModel, setSessionModel] = useState<string>('');
|
||||
const [sessionProviderId, setSessionProviderId] = useState<string>('');
|
||||
const [sessionMode, setSessionMode] = useState<string>('');
|
||||
const [projectName, setProjectName] = useState<string>('');
|
||||
const [sessionWorkingDir, setSessionWorkingDir] = useState<string>('');
|
||||
@@ -94,6 +95,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
|
||||
setSessionTitle(title);
|
||||
setPanelSessionTitle(title);
|
||||
setSessionModel(data.session.model || '');
|
||||
setSessionProviderId(data.session.provider_id || '');
|
||||
setSessionMode(data.session.mode || 'code');
|
||||
setProjectName(data.session.project_name || '');
|
||||
}
|
||||
@@ -233,7 +235,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ChatView key={id} sessionId={id} initialMessages={messages} initialHasMore={hasMore} modelName={sessionModel} initialMode={sessionMode} />
|
||||
<ChatView key={id} sessionId={id} initialMessages={messages} initialHasMore={hasMore} modelName={sessionModel} initialMode={sessionMode} providerId={sessionProviderId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export default function NewChatPage() {
|
||||
const [workingDir, setWorkingDir] = useState('');
|
||||
const [mode, setMode] = useState('code');
|
||||
const [currentModel, setCurrentModel] = useState('sonnet');
|
||||
const [currentProviderId, setCurrentProviderId] = useState('');
|
||||
const [pendingPermission, setPendingPermission] = useState<PermissionRequestEvent | null>(null);
|
||||
const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null);
|
||||
const [streamingToolOutput, setStreamingToolOutput] = useState('');
|
||||
@@ -143,7 +144,7 @@ export default function NewChatPage() {
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: session.id, content, mode, model: currentModel }),
|
||||
body: JSON.stringify({ session_id: session.id, content, mode, model: currentModel, provider_id: currentProviderId }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -310,7 +311,7 @@ export default function NewChatPage() {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[isStreaming, router, workingDir, mode, currentModel, setPendingApprovalSessionId]
|
||||
[isStreaming, router, workingDir, mode, currentModel, currentProviderId, setPendingApprovalSessionId]
|
||||
);
|
||||
|
||||
const handleCommand = useCallback((command: string) => {
|
||||
@@ -369,6 +370,11 @@ export default function NewChatPage() {
|
||||
isStreaming={isStreaming}
|
||||
modelName={currentModel}
|
||||
onModelChange={setCurrentModel}
|
||||
providerId={currentProviderId}
|
||||
onProviderModelChange={(pid, model) => {
|
||||
setCurrentProviderId(pid);
|
||||
setCurrentModel(model);
|
||||
}}
|
||||
workingDirectory={workingDir}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
|
||||
@@ -24,9 +24,10 @@ interface ChatViewProps {
|
||||
initialHasMore?: boolean;
|
||||
modelName?: string;
|
||||
initialMode?: string;
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode }: ChatViewProps) {
|
||||
export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode, providerId }: ChatViewProps) {
|
||||
const { setStreamingSessionId, workingDirectory, setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
|
||||
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
||||
const [hasMore, setHasMore] = useState(initialHasMore);
|
||||
@@ -39,6 +40,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
const [statusText, setStatusText] = useState<string | undefined>();
|
||||
const [mode, setMode] = useState(initialMode || 'code');
|
||||
const [currentModel, setCurrentModel] = useState(modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet');
|
||||
const [currentProviderId, setCurrentProviderId] = useState(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
|
||||
const [pendingPermission, setPendingPermission] = useState<PermissionRequestEvent | null>(null);
|
||||
const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null);
|
||||
const [streamingToolOutput, setStreamingToolOutput] = useState('');
|
||||
@@ -64,6 +66,12 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
}).catch(() => { /* silent — will apply on next message */ });
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
const handleProviderModelChange = useCallback((newProviderId: string, model: string) => {
|
||||
setCurrentProviderId(newProviderId);
|
||||
setCurrentModel(model);
|
||||
}, []);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Abort active stream on unmount (e.g., session switch via key={id} remount).
|
||||
@@ -266,6 +274,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
content,
|
||||
mode,
|
||||
model: currentModel,
|
||||
provider_id: currentProviderId,
|
||||
...(files && files.length > 0 ? { files } : {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
@@ -498,7 +507,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
window.dispatchEvent(new CustomEvent('refresh-file-tree'));
|
||||
}
|
||||
},
|
||||
[sessionId, isStreaming, setStreamingSessionId, setPendingApprovalSessionId, mode, currentModel]
|
||||
[sessionId, isStreaming, setStreamingSessionId, setPendingApprovalSessionId, mode, currentModel, currentProviderId]
|
||||
);
|
||||
|
||||
// Keep sendMessageRef in sync so timeout auto-retry can call it
|
||||
@@ -605,6 +614,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
sessionId={sessionId}
|
||||
modelName={currentModel}
|
||||
onModelChange={setCurrentModel}
|
||||
providerId={currentProviderId}
|
||||
onProviderModelChange={handleProviderModelChange}
|
||||
workingDirectory={workingDirectory}
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
usePromptInputAttachments,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import type { ChatStatus } from 'ai';
|
||||
import type { FileAttachment } from '@/types';
|
||||
import type { FileAttachment, ProviderModelGroup } from '@/types';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
// Accepted file types for upload
|
||||
@@ -58,6 +58,8 @@ interface MessageInputProps {
|
||||
sessionId?: string;
|
||||
modelName?: string;
|
||||
onModelChange?: (model: string) => void;
|
||||
providerId?: string;
|
||||
onProviderModelChange?: (providerId: string, model: string) => void;
|
||||
workingDirectory?: string;
|
||||
mode?: string;
|
||||
onModeChange?: (mode: string) => void;
|
||||
@@ -331,6 +333,8 @@ export function MessageInput({
|
||||
sessionId,
|
||||
modelName,
|
||||
onModelChange,
|
||||
providerId,
|
||||
onProviderModelChange,
|
||||
workingDirectory,
|
||||
mode = 'code',
|
||||
onModeChange,
|
||||
@@ -349,40 +353,53 @@ export function MessageInput({
|
||||
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [badge, setBadge] = useState<CommandBadge | null>(null);
|
||||
const [dynamicModels, setDynamicModels] = useState<{ value: string; label: string }[]>(DEFAULT_MODEL_OPTIONS);
|
||||
const [activeProviderName, setActiveProviderName] = useState<string | null>(null);
|
||||
const [providerGroups, setProviderGroups] = useState<ProviderModelGroup[]>([]);
|
||||
const [defaultProviderId, setDefaultProviderId] = useState<string>('');
|
||||
const [aiSuggestions, setAiSuggestions] = useState<PopoverItem[]>([]);
|
||||
const [aiSearchLoading, setAiSearchLoading] = useState(false);
|
||||
const aiSearchAbortRef = useRef<AbortController | null>(null);
|
||||
const aiSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Fetch models from API
|
||||
const fetchModels = useCallback(() => {
|
||||
// Fetch provider groups from API
|
||||
const fetchProviderModels = useCallback(() => {
|
||||
fetch('/api/providers/models')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.models && data.models.length > 0) {
|
||||
setDynamicModels(data.models);
|
||||
if (data.groups && data.groups.length > 0) {
|
||||
setProviderGroups(data.groups);
|
||||
} else {
|
||||
setDynamicModels(DEFAULT_MODEL_OPTIONS);
|
||||
setProviderGroups([{
|
||||
provider_id: 'env',
|
||||
provider_name: 'Anthropic',
|
||||
provider_type: 'anthropic',
|
||||
models: DEFAULT_MODEL_OPTIONS,
|
||||
}]);
|
||||
}
|
||||
setActiveProviderName(data.provider_name || null);
|
||||
setDefaultProviderId(data.default_provider_id || '');
|
||||
})
|
||||
.catch(() => {
|
||||
setDynamicModels(DEFAULT_MODEL_OPTIONS);
|
||||
setActiveProviderName(null);
|
||||
setProviderGroups([{
|
||||
provider_id: 'env',
|
||||
provider_name: 'Anthropic',
|
||||
provider_type: 'anthropic',
|
||||
models: DEFAULT_MODEL_OPTIONS,
|
||||
}]);
|
||||
setDefaultProviderId('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load models on mount and listen for provider changes
|
||||
useEffect(() => {
|
||||
fetchModels();
|
||||
const handler = () => fetchModels();
|
||||
fetchProviderModels();
|
||||
const handler = () => fetchProviderModels();
|
||||
window.addEventListener('provider-changed', handler);
|
||||
return () => window.removeEventListener('provider-changed', handler);
|
||||
}, [fetchModels]);
|
||||
}, [fetchProviderModels]);
|
||||
|
||||
const MODEL_OPTIONS = dynamicModels;
|
||||
// Derive flat model list for current provider (used by currentModelOption lookup)
|
||||
const currentProviderIdValue = providerId || defaultProviderId || (providerGroups[0]?.provider_id ?? '');
|
||||
const currentGroup = providerGroups.find(g => g.provider_id === currentProviderIdValue) || providerGroups[0];
|
||||
const MODEL_OPTIONS = currentGroup?.models || DEFAULT_MODEL_OPTIONS;
|
||||
|
||||
// Fetch files for @ mention
|
||||
const fetchFiles = useCallback(async (filter: string) => {
|
||||
@@ -1066,40 +1083,50 @@ export function MessageInput({
|
||||
onClick={() => setModelMenuOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="text-xs font-mono">{currentModelOption.label}</span>
|
||||
{activeProviderName && activeProviderName !== 'Anthropic' && (
|
||||
<span className="text-[9px] text-muted-foreground">{activeProviderName}</span>
|
||||
{currentGroup && currentGroup.provider_name !== 'Anthropic' && currentGroup.provider_name !== 'Environment' && (
|
||||
<span className="text-[9px] text-muted-foreground">{currentGroup.provider_name}</span>
|
||||
)}
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} className={cn("h-2.5 w-2.5 transition-transform duration-200", modelMenuOpen && "rotate-180")} />
|
||||
</PromptInputButton>
|
||||
|
||||
{modelMenuOpen && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-48 rounded-lg border bg-popover shadow-lg overflow-hidden z-50">
|
||||
{activeProviderName && (
|
||||
<div className="px-3 py-1.5 text-[10px] text-muted-foreground border-b">
|
||||
{activeProviderName}
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-52 rounded-lg border bg-popover shadow-lg overflow-hidden z-50 max-h-80 overflow-y-auto">
|
||||
{providerGroups.map((group, groupIdx) => (
|
||||
<div key={group.provider_id}>
|
||||
{/* Group header */}
|
||||
<div className={cn(
|
||||
"px-3 py-1.5 text-[10px] font-medium text-muted-foreground",
|
||||
groupIdx > 0 && "border-t"
|
||||
)}>
|
||||
{group.provider_name}
|
||||
</div>
|
||||
{/* Models in group */}
|
||||
<div className="py-0.5">
|
||||
{group.models.map((opt) => {
|
||||
const isActive = opt.value === currentModelValue && group.provider_id === currentProviderIdValue;
|
||||
return (
|
||||
<button
|
||||
key={`${group.provider_id}-${opt.value}`}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm transition-colors",
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
onModelChange?.(opt.value);
|
||||
onProviderModelChange?.(group.provider_id, opt.value);
|
||||
localStorage.setItem('codepilot:last-model', opt.value);
|
||||
localStorage.setItem('codepilot:last-provider-id', group.provider_id);
|
||||
setModelMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-xs">{opt.label}</span>
|
||||
{isActive && <span className="text-xs">✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="py-1">
|
||||
{MODEL_OPTIONS.map((opt) => {
|
||||
const isActive = opt.value === currentModelValue;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
onModelChange?.(opt.value);
|
||||
localStorage.setItem('codepilot:last-model', opt.value);
|
||||
setModelMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-xs">{opt.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,9 +19,7 @@ import {
|
||||
Loading02Icon,
|
||||
Delete02Icon,
|
||||
PencilEdit01Icon,
|
||||
Tick01Icon,
|
||||
ServerStack01Icon,
|
||||
Cancel01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { ProviderForm } from "./ProviderForm";
|
||||
import type { ProviderFormData } from "./ProviderForm";
|
||||
@@ -59,9 +57,6 @@ export function ProviderManager() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<ApiProvider | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Activating state
|
||||
const [activatingId, setActivatingId] = useState<string | null>(null);
|
||||
|
||||
const fetchProviders = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
@@ -119,6 +114,7 @@ export function ProviderManager() {
|
||||
setProviders((prev) =>
|
||||
prev.map((p) => (p.id === editingProvider.id ? result.provider : p))
|
||||
);
|
||||
window.dispatchEvent(new Event('provider-changed'));
|
||||
} else {
|
||||
const res = await fetch("/api/providers", {
|
||||
method: "POST",
|
||||
@@ -131,6 +127,7 @@ export function ProviderManager() {
|
||||
}
|
||||
const result = await res.json();
|
||||
setProviders((prev) => [...prev, result.provider]);
|
||||
window.dispatchEvent(new Event('provider-changed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,6 +140,7 @@ export function ProviderManager() {
|
||||
});
|
||||
if (res.ok) {
|
||||
setProviders((prev) => prev.filter((p) => p.id !== deleteTarget.id));
|
||||
window.dispatchEvent(new Event('provider-changed'));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -152,50 +150,6 @@ export function ProviderManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (provider: ApiProvider) => {
|
||||
setActivatingId(provider.id);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${provider.id}/activate`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (res.ok) {
|
||||
// Mark the activated provider as active, deactivate others
|
||||
setProviders((prev) =>
|
||||
prev.map((p) => ({
|
||||
...p,
|
||||
is_active: p.id === provider.id ? 1 : 0,
|
||||
}))
|
||||
);
|
||||
window.dispatchEvent(new Event('provider-changed'));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setActivatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (provider: ApiProvider) => {
|
||||
setActivatingId(provider.id);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${provider.id}/activate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ active: false }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setProviders((prev) =>
|
||||
prev.map((p) => ({ ...p, is_active: 0 }))
|
||||
);
|
||||
window.dispatchEvent(new Event('provider-changed'));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setActivatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = [...providers].sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
return (
|
||||
@@ -212,7 +166,7 @@ export function ProviderManager() {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Manage API providers for Claude Code. The active provider will be used for all sessions.
|
||||
Configure API providers. Select provider and model from the chat input.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" className="gap-1" onClick={handleAdd}>
|
||||
@@ -229,49 +183,25 @@ export function ProviderManager() {
|
||||
)}
|
||||
|
||||
{/* Environment variable detection banner */}
|
||||
{!loading && Object.keys(envDetected).length > 0 && (() => {
|
||||
const hasActiveProvider = providers.some(p => p.is_active === 1);
|
||||
return (
|
||||
<div className={`rounded-md border p-3 ${
|
||||
hasActiveProvider
|
||||
? "border-border/50 bg-muted/30"
|
||||
: "border-green-500/30 bg-green-500/5"
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className={`text-xs font-medium ${
|
||||
hasActiveProvider
|
||||
? "text-muted-foreground"
|
||||
: "text-green-700 dark:text-green-400"
|
||||
}`}>
|
||||
{t('provider.envDetected')}
|
||||
</p>
|
||||
{hasActiveProvider ? (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 text-muted-foreground">
|
||||
Overridden
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-green-600 dark:text-green-400 border-green-500/30">
|
||||
In use
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{Object.entries(envDetected).map(([key, value]) => (
|
||||
<p key={key} className={`text-xs font-mono ${
|
||||
hasActiveProvider ? "text-muted-foreground/60 line-through" : "text-muted-foreground"
|
||||
}`}>
|
||||
{key}={value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{hasActiveProvider && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Active provider takes priority. Disable it to use environment variables.
|
||||
</p>
|
||||
)}
|
||||
{!loading && Object.keys(envDetected).length > 0 && (
|
||||
<div className="rounded-md border p-3 border-green-500/30 bg-green-500/5">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="text-xs font-medium text-green-700 dark:text-green-400">
|
||||
{t('provider.envDetected')}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-green-600 dark:text-green-400 border-green-500/30">
|
||||
{t('provider.configured')}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="space-y-0.5">
|
||||
{Object.entries(envDetected).map(([key, value]) => (
|
||||
<p key={key} className="text-xs font-mono text-muted-foreground">
|
||||
{key}={value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
@@ -314,95 +244,50 @@ export function ProviderManager() {
|
||||
{/* Provider list */}
|
||||
{!loading && sorted.length > 0 && (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto min-h-0">
|
||||
{sorted.map((provider) => {
|
||||
const isActive = provider.is_active === 1;
|
||||
const isActivating = activatingId === provider.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={`rounded-lg border p-3 transition-colors ${
|
||||
isActive
|
||||
? "border-border bg-green-500/5"
|
||||
: "border-border/50 hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{provider.name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{provider.provider_type}
|
||||
</Badge>
|
||||
{isActive && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-green-600 dark:text-green-400 border-green-500/30">
|
||||
{t('provider.active')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{provider.base_url && (
|
||||
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">
|
||||
{provider.base_url}
|
||||
</p>
|
||||
)}
|
||||
{sorted.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="rounded-lg border p-3 transition-colors border-border/50 hover:border-border"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{provider.name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{provider.provider_type}
|
||||
</Badge>
|
||||
</div>
|
||||
{provider.base_url && (
|
||||
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">
|
||||
{provider.base_url}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isActive ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={isActivating}
|
||||
onClick={() => handleDeactivate(provider)}
|
||||
className="gap-1 text-muted-foreground"
|
||||
>
|
||||
{isActivating ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="h-3 w-3" />
|
||||
)}
|
||||
Disable
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={isActivating}
|
||||
onClick={() => handleActivate(provider)}
|
||||
className="gap-1"
|
||||
>
|
||||
{isActivating ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Tick01Icon} className="h-3 w-3" />
|
||||
)}
|
||||
Apply
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title="Edit"
|
||||
onClick={() => handleEdit(provider)}
|
||||
>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title="Delete"
|
||||
onClick={() => setDeleteTarget(provider)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title="Edit"
|
||||
onClick={() => handleEdit(provider)}
|
||||
>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title="Delete"
|
||||
onClick={() => setDeleteTarget(provider)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -135,6 +135,9 @@ const en = {
|
||||
'provider.saving': 'Saving...',
|
||||
'provider.update': 'Update',
|
||||
'provider.envDetected': 'Detected from environment',
|
||||
'provider.default': 'Default',
|
||||
'provider.setDefault': 'Set as Default',
|
||||
'provider.environment': 'Environment',
|
||||
|
||||
// ── Right panel / Files ─────────────────────────────────────
|
||||
'panel.files': 'Files',
|
||||
|
||||
@@ -132,6 +132,9 @@ const zh: Record<TranslationKey, string> = {
|
||||
'provider.saving': '保存中...',
|
||||
'provider.update': '更新',
|
||||
'provider.envDetected': '从环境变量检测到',
|
||||
'provider.default': '默认',
|
||||
'provider.setDefault': '设为默认',
|
||||
'provider.environment': '环境变量',
|
||||
|
||||
// ── Right panel / Files ─────────────────────────────────────
|
||||
'panel.files': '文件',
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
NotificationHookInput,
|
||||
PostToolUseHookInput,
|
||||
} from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent, FileAttachment } from '@/types';
|
||||
import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent, FileAttachment, ApiProvider } from '@/types';
|
||||
import { isImageFile } from '@/types';
|
||||
import { registerPendingPermission } from './permission-registry';
|
||||
import { registerConversation, unregisterConversation } from './conversation-registry';
|
||||
@@ -242,7 +242,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
return new ReadableStream<string>({
|
||||
async start(controller) {
|
||||
// Hoist activeProvider so it's accessible in the catch block for error messages
|
||||
const activeProvider = getActiveProvider();
|
||||
const activeProvider: ApiProvider | undefined = options.provider ?? getActiveProvider();
|
||||
|
||||
try {
|
||||
// Build env for the Claude Code subprocess.
|
||||
|
||||
@@ -153,6 +153,20 @@ function migrateDb(db: Database.Database): void {
|
||||
if (!colNames.includes('provider_name')) {
|
||||
db.exec("ALTER TABLE chat_sessions ADD COLUMN provider_name TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!colNames.includes('provider_id')) {
|
||||
db.exec("ALTER TABLE chat_sessions ADD COLUMN provider_id TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
|
||||
// Migrate is_active provider to default_provider_id setting
|
||||
const defaultProviderSetting = db.prepare("SELECT value FROM settings WHERE key = 'default_provider_id'").get() as { value: string } | undefined;
|
||||
if (!defaultProviderSetting) {
|
||||
const activeProvider = db.prepare('SELECT id FROM api_providers WHERE is_active = 1 LIMIT 1').get() as { id: string } | undefined;
|
||||
if (activeProvider) {
|
||||
db.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('default_provider_id', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||
).run(activeProvider.id);
|
||||
}
|
||||
}
|
||||
|
||||
const msgColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
|
||||
const msgColNames = msgColumns.map(c => c.name);
|
||||
@@ -274,6 +288,19 @@ export function updateSessionProvider(id: string, providerName: string): void {
|
||||
db.prepare('UPDATE chat_sessions SET provider_name = ? WHERE id = ?').run(providerName, id);
|
||||
}
|
||||
|
||||
export function updateSessionProviderId(id: string, providerId: string): void {
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE chat_sessions SET provider_id = ? WHERE id = ?').run(providerId, id);
|
||||
}
|
||||
|
||||
export function getDefaultProviderId(): string | undefined {
|
||||
return getSetting('default_provider_id') || undefined;
|
||||
}
|
||||
|
||||
export function setDefaultProviderId(id: string): void {
|
||||
setSetting('default_provider_id', id);
|
||||
}
|
||||
|
||||
export function updateSessionWorkingDirectory(id: string, workingDirectory: string): void {
|
||||
const db = getDb();
|
||||
const projectName = path.basename(workingDirectory);
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ChatSession {
|
||||
mode?: 'code' | 'plan' | 'ask';
|
||||
needs_approval?: boolean;
|
||||
provider_name: string;
|
||||
provider_id: string;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@@ -112,6 +113,13 @@ export interface ApiProvider {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProviderModelGroup {
|
||||
provider_id: string; // provider DB id, or 'env' for environment variables
|
||||
provider_name: string;
|
||||
provider_type: string;
|
||||
models: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface CreateProviderRequest {
|
||||
name: string;
|
||||
provider_type?: string;
|
||||
@@ -168,6 +176,7 @@ export interface SendMessageRequest {
|
||||
content: string;
|
||||
model?: string;
|
||||
mode?: string;
|
||||
provider_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMCPConfigRequest {
|
||||
@@ -438,4 +447,5 @@ export interface ClaudeStreamOptions {
|
||||
permissionMode?: string;
|
||||
files?: FileAttachment[];
|
||||
toolTimeoutSeconds?: number;
|
||||
provider?: ApiProvider;
|
||||
}
|
||||
Reference in new issue
Block a user