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:
7418andClaude Opus 4.6 committed 2026-02-26 00:22:18 +08:00
1 parent cf14791de0
commit 448738d4f3
13 files changed
+290 -250

No files matched your search

+31 -5
View File
@@ -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
+52 -16
View File
@@ -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>(
+6 -2
View File
@@ -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' },
+3 -1
View File
@@ -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>
);
}
+8 -2
View File
@@ -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}
+13 -2
View File
@@ -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}
+70 -43
View File
@@ -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>
+62 -177
View File
@@ -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>
)}
+3
View File
@@ -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',
+3
View File
@@ -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': '文件',
+2 -2
View File
@@ -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.
+27
View File
@@ -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);
+10
View File
@@ -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;
}