diff --git a/packages/desktop/src/App.tsx b/packages/desktop/src/App.tsx
index 82cc4e7..cc67cd1 100644
--- a/packages/desktop/src/App.tsx
+++ b/packages/desktop/src/App.tsx
@@ -12,6 +12,7 @@ import {
HooksPanel,
AgentsPanel,
CheckpointPanel,
+ ProvidersPanel,
Toaster,
listSessions,
createSession,
@@ -29,6 +30,7 @@ export function App() {
const [showHooks, setShowHooks] = useState(false);
const [showAgents, setShowAgents] = useState(false);
const [showCheckpoints, setShowCheckpoints] = useState(false);
+ const [showProviders, setShowProviders] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话
@@ -104,6 +106,7 @@ export function App() {
onOpenHooks={() => setShowHooks(true)}
onOpenAgents={() => setShowAgents(true)}
onOpenCheckpoints={() => setShowCheckpoints(true)}
+ onOpenProviders={() => setShowProviders(true)}
/>
) : (
@@ -142,6 +145,9 @@ export function App() {
{/* Checkpoints 面板 */}
{showCheckpoints &&
setShowCheckpoints(false)} />}
+ {/* Providers 面板 */}
+ {showProviders && setShowProviders(false)} />}
+
{/* Toast 通知 */}
diff --git a/packages/desktop/src/pages/Chat.tsx b/packages/desktop/src/pages/Chat.tsx
index 9eed7c0..b3120b3 100644
--- a/packages/desktop/src/pages/Chat.tsx
+++ b/packages/desktop/src/pages/Chat.tsx
@@ -3,7 +3,7 @@
*/
import { useEffect, useRef } from 'react';
-import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot, History } from 'lucide-react';
+import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot, History, Server } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
useChat,
@@ -25,6 +25,7 @@ interface ChatPageProps {
onOpenHooks?: () => void;
onOpenAgents?: () => void;
onOpenCheckpoints?: () => void;
+ onOpenProviders?: () => void;
}
export function ChatPage({
@@ -38,6 +39,7 @@ export function ChatPage({
onOpenHooks,
onOpenAgents,
onOpenCheckpoints,
+ onOpenProviders,
}: ChatPageProps) {
const {
messages,
@@ -131,7 +133,7 @@ export function ChatPage({
{/* 工具栏按钮 */}
- {(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints) && (
+ {(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders) && (
{/* Checkpoints 按钮 */}
{onOpenCheckpoints && (
@@ -146,6 +148,19 @@ export function ChatPage({
)}
+ {/* Providers 按钮 */}
+ {onOpenProviders && (
+
+
+
+ )}
+
{/* Agents 按钮 */}
{onOpenAgents && (
void;
+ onSave: () => void;
+ /** Enable responsive layout */
+ responsive?: boolean;
+}
+
+export function ProviderEditor({
+ providerId,
+ onClose,
+ onSave,
+ responsive = false,
+}: ProviderEditorProps) {
+ // Provider data
+ const [provider, setProvider] = useState(null);
+
+ // Form state
+ const [apiKey, setApiKey] = useState('');
+ const [apiKeyEnvVar, setApiKeyEnvVar] = useState('');
+ const [baseUrl, setBaseUrl] = useState('');
+ const [enabled, setEnabled] = useState(true);
+
+ // Custom model form
+ const [showAddModel, setShowAddModel] = useState(false);
+ const [newModelId, setNewModelId] = useState('');
+ const [newModelName, setNewModelName] = useState('');
+ const [newModelVision, setNewModelVision] = useState(false);
+ const [newModelTools, setNewModelTools] = useState(false);
+
+ // UI state
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [showApiKey, setShowApiKey] = useState(false);
+
+ // Load provider data
+ useEffect(() => {
+ async function loadProvider() {
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await getProvider(providerId);
+ if (result.success && result.data) {
+ setProvider(result.data);
+ // Initialize form with current config
+ const config = result.data.config;
+ // API key is not returned for security, but we show if it's configured via hasApiKey
+ setApiKey('');
+ setApiKeyEnvVar(result.data.apiKeyEnvVar || '');
+ setBaseUrl(config.baseUrl || result.data.baseUrl || '');
+ setEnabled(config.enabled !== false);
+ } else {
+ setError(result.error || 'Failed to load provider');
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load provider');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ loadProvider();
+ }, [providerId]);
+
+ // Save configuration
+ const handleSave = async () => {
+ setSaving(true);
+ setError(null);
+
+ try {
+ const config: Partial = {
+ enabled,
+ };
+
+ // Only include changed values
+ if (apiKey.trim()) {
+ config.apiKey = apiKey;
+ }
+ if (apiKeyEnvVar.trim() && apiKeyEnvVar !== provider?.apiKeyEnvVar) {
+ config.apiKeyEnvVar = apiKeyEnvVar;
+ }
+ if (baseUrl.trim() && baseUrl !== provider?.baseUrl) {
+ config.baseUrl = baseUrl;
+ }
+
+ const result = await updateProviderConfig(providerId, config);
+ if (result.success) {
+ toast.success(`Provider "${providerId}" updated`);
+ onSave();
+ } else {
+ setError(result.error || 'Failed to save');
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ // Add custom model
+ const handleAddModel = async () => {
+ if (!newModelId.trim() || !newModelName.trim()) {
+ toast.error('Model ID and Name are required');
+ return;
+ }
+
+ try {
+ const model: ModelInfo = {
+ id: newModelId.trim(),
+ name: newModelName.trim(),
+ capabilities: {
+ vision: newModelVision,
+ functionCalling: newModelTools,
+ },
+ };
+
+ const result = await addProviderModel(providerId, model);
+ if (result.success) {
+ toast.success(`Model "${newModelId}" added`);
+ // Reset form
+ setNewModelId('');
+ setNewModelName('');
+ setNewModelVision(false);
+ setNewModelTools(false);
+ setShowAddModel(false);
+ // Reload provider
+ const updated = await getProvider(providerId);
+ if (updated.success && updated.data) {
+ setProvider(updated.data);
+ }
+ } else {
+ toast.error(result.error || 'Failed to add model');
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to add model');
+ }
+ };
+
+ // Delete custom model
+ const handleDeleteModel = async (modelId: string) => {
+ if (!confirm(`Delete model "${modelId}"?`)) return;
+
+ try {
+ const result = await deleteProviderModel(providerId, modelId);
+ if (result.success) {
+ toast.success(`Model "${modelId}" deleted`);
+ // Reload provider
+ const updated = await getProvider(providerId);
+ if (updated.success && updated.data) {
+ setProvider(updated.data);
+ }
+ } else {
+ toast.error(result.error || 'Failed to delete model');
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to delete model');
+ }
+ };
+
+ return (
+
+
+ e.stopPropagation()}
+ className={cn(
+ 'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col',
+ responsive
+ ? 'w-full md:w-full md:max-w-lg md:mx-4 rounded-t-2xl md:rounded-lg'
+ : 'rounded-lg w-full max-w-lg mx-4'
+ )}
+ >
+ {/* Header */}
+
+ {responsive && (
+
+ )}
+
+
+
+ {loading ? 'Loading...' : provider?.name || providerId}
+
+ {provider && (
+
+ {provider.builtin ? 'Built-in Provider' : 'Custom Provider'}
+
+ )}
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {loading ? (
+
+ ) : (
+ <>
+ {/* Error */}
+ {error && (
+
+ )}
+
+ {/* Enabled Toggle */}
+
+
+
Enabled
+
Use this provider for model selection
+
+
+
+
+ {/* API Key Section */}
+
+
+
+ API Key Configuration
+
+
+ {/* API Key Input */}
+
+
+
+ setApiKey(e.target.value)}
+ placeholder={provider?.config.hasApiKey ? '••••••••' : 'Enter API key'}
+ className="pr-10"
+ />
+
+
+ {provider?.config.hasApiKey && (
+
API key is configured
+ )}
+
+
+ {/* API Key Env Var */}
+
+
+
setApiKeyEnvVar(e.target.value)}
+ placeholder={provider?.apiKeyEnvVar || 'PROVIDER_API_KEY'}
+ />
+
+ If no API key is set, this env var will be used
+
+
+
+
+ {/* Base URL Section */}
+
+
+
+ Endpoint Configuration
+
+
+
+
+
setBaseUrl(e.target.value)}
+ placeholder={provider?.baseUrl || 'https://api.provider.com/v1'}
+ />
+ {provider?.builtin && (
+
+ Leave empty to use default endpoint
+
+ )}
+
+
+
+ {/* Custom Models Section */}
+ {provider?.allowCustomModels && (
+
+
+
+
+ Custom Models ({provider.config.customModels.length})
+
+
+
+
+ {/* Add Model Form */}
+
+ {showAddModel && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Custom Models List */}
+ {provider.config.customModels.length > 0 ? (
+
+ {provider.config.customModels.map((model) => (
+
+
+
{model.name}
+
({model.id})
+
+ {model.capabilities?.vision && (
+ Vision
+ )}
+ {model.capabilities?.functionCalling && (
+ Tools
+ )}
+
+
+
+
+ ))}
+
+ ) : (
+
+ No custom models added yet
+
+ )}
+
+ )}
+
+ {/* Built-in Models Info */}
+ {provider && (
+
+
Built-in Models:
+
+ {provider.models.slice(0, 5).map((m) => (
+
+ {m.name}
+
+ ))}
+ {provider.models.length > 5 && (
+
+ +{provider.models.length - 5} more
+
+ )}
+
+
+ )}
+ >
+ )}
+
+
+ {/* Footer */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/ProvidersPanel.tsx b/packages/ui/src/components/ProvidersPanel.tsx
index 0170a38..c8f2993 100644
--- a/packages/ui/src/components/ProvidersPanel.tsx
+++ b/packages/ui/src/components/ProvidersPanel.tsx
@@ -20,6 +20,7 @@ import {
Key,
Globe,
Zap,
+ Settings,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
@@ -41,6 +42,7 @@ import {
type ModelInfo,
type CustomProviderDefinition,
} from '../api/client.js';
+import { ProviderEditor } from './ProviderEditor.js';
interface ProvidersPanelProps {
onClose: () => void;
@@ -63,6 +65,7 @@ export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelPr
// Editor state
const [showAddProvider, setShowAddProvider] = useState(false);
const [showAddModel, setShowAddModel] = useState(null);
+ const [editingProviderId, setEditingProviderId] = useState(null);
const [newProvider, setNewProvider] = useState>({});
const [newModel, setNewModel] = useState>({});
@@ -356,6 +359,17 @@ export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelPr
)}
+ {/* Edit Button */}
+
+
{/* Delete (only for custom) */}
{!provider.builtin && (