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 && ( +
+ + {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 && ( + +
+
+
+ + setNewModelId(e.target.value)} + placeholder="gpt-4o-custom" + className="text-sm" + /> +
+
+ + setNewModelName(e.target.value)} + placeholder="GPT-4o Custom" + className="text-sm" + /> +
+
+
+ + +
+
+ + +
+
+
+ )} +
+ + {/* 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 && (