feat(ui): 集成 Provider 管理到 web 和 desktop 应用

- 新增 ProviderEditor 组件用于编辑提供商配置
- ProvidersPanel 添加编辑按钮集成 ProviderEditor
- ChatPage 添加 onOpenProviders 工具栏按钮
- web/desktop App.tsx 集成 ProvidersPanel 面板
This commit is contained in:
2025-12-13 02:01:09 +08:00
parent 6ec6fe2f9f
commit 26e8646518
7 changed files with 627 additions and 4 deletions
+6
View File
@@ -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)}
/>
) : (
<div className="flex-1 flex items-center justify-center h-full">
@@ -142,6 +145,9 @@ export function App() {
{/* Checkpoints 面板 */}
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} />}
{/* Providers 面板 */}
{showProviders && <ProvidersPanel onClose={() => setShowProviders(false)} />}
{/* Toast 通知 */}
<Toaster />
</div>
+17 -2
View File
@@ -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({
<ConnectionStatus />
{/* 工具栏按钮 */}
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints) && (
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders) && (
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
{/* Checkpoints 按钮 */}
{onOpenCheckpoints && (
@@ -146,6 +148,19 @@ export function ChatPage({
</motion.button>
)}
{/* Providers 按钮 */}
{onOpenProviders && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenProviders}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="Model Providers"
>
<Server size={20} />
</motion.button>
)}
{/* Agents 按钮 */}
{onOpenAgents && (
<motion.button
@@ -0,0 +1,547 @@
/**
* ProviderEditor Component
*
* Provider configuration editor: API Key, Base URL, enabled state, custom models
*/
import { useState, useEffect } from 'react';
import {
X,
Save,
Server,
Key,
Globe,
AlertCircle,
Plus,
Trash2,
Eye,
EyeOff,
Cpu,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
import { Button } from '../primitives/Button';
import { Input } from '../primitives/Input';
import {
getProvider,
updateProviderConfig,
addProviderModel,
deleteProviderModel,
type ProviderDetail,
type ProviderConfig,
type ModelInfo,
} from '../api/client.js';
interface ProviderEditorProps {
/** Provider ID to edit */
providerId: string;
onClose: () => void;
onSave: () => void;
/** Enable responsive layout */
responsive?: boolean;
}
export function ProviderEditor({
providerId,
onClose,
onSave,
responsive = false,
}: ProviderEditorProps) {
// Provider data
const [provider, setProvider] = useState<ProviderDetail | null>(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<string | null>(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<ProviderConfig> = {
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 (
<AnimatePresence>
<motion.div
variants={modalOverlay}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
className={cn(
'fixed inset-0 bg-black/50 flex z-60',
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
)}
onClick={onClose}
>
<motion.div
variants={modalContent}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={(e) => 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 */}
<div
className={cn(
'flex items-center justify-between border-b border-gray-700',
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
)}
>
{responsive && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
)}
<div className={cn(responsive && 'mt-2 md:mt-0')}>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Server size={20} className="text-primary-400" />
{loading ? 'Loading...' : provider?.name || providerId}
</h2>
{provider && (
<p className="text-xs text-gray-500">
{provider.builtin ? 'Built-in Provider' : 'Custom Provider'}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={handleSave}
disabled={saving || loading}
className={cn(responsive && 'min-h-[44px]')}
>
<Save size={16} className="mr-1" />
{saving ? 'Saving...' : 'Save'}
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<X size={20} />
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary-500" />
</div>
) : (
<>
{/* Error */}
{error && (
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
<AlertCircle size={16} className="mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Enabled Toggle */}
<div className="flex items-center justify-between p-3 bg-gray-900/50 rounded-lg">
<div>
<span className="text-sm font-medium">Enabled</span>
<p className="text-xs text-gray-500">Use this provider for model selection</p>
</div>
<button
type="button"
onClick={() => setEnabled(!enabled)}
className={cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
enabled ? 'bg-primary-500' : 'bg-gray-600'
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
enabled ? 'translate-x-6' : 'translate-x-1'
)}
/>
</button>
</div>
{/* API Key Section */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2">
<Key size={14} />
API Key Configuration
</h3>
{/* API Key Input */}
<div>
<label className="block text-xs text-gray-400 mb-1">API Key</label>
<div className="relative">
<Input
type={showApiKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider?.config.hasApiKey ? '••••••••' : 'Enter API key'}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300"
>
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{provider?.config.hasApiKey && (
<p className="text-xs text-green-400 mt-1">API key is configured</p>
)}
</div>
{/* API Key Env Var */}
<div>
<label className="block text-xs text-gray-400 mb-1">
Environment Variable (alternative)
</label>
<Input
value={apiKeyEnvVar}
onChange={(e) => setApiKeyEnvVar(e.target.value)}
placeholder={provider?.apiKeyEnvVar || 'PROVIDER_API_KEY'}
/>
<p className="text-xs text-gray-500 mt-1">
If no API key is set, this env var will be used
</p>
</div>
</div>
{/* Base URL Section */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2">
<Globe size={14} />
Endpoint Configuration
</h3>
<div>
<label className="block text-xs text-gray-400 mb-1">Base URL</label>
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={provider?.baseUrl || 'https://api.provider.com/v1'}
/>
{provider?.builtin && (
<p className="text-xs text-gray-500 mt-1">
Leave empty to use default endpoint
</p>
)}
</div>
</div>
{/* Custom Models Section */}
{provider?.allowCustomModels && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2">
<Cpu size={14} />
Custom Models ({provider.config.customModels.length})
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAddModel(!showAddModel)}
className="text-xs text-primary-400"
>
<Plus size={14} className="mr-1" />
Add Model
</Button>
</div>
{/* Add Model Form */}
<AnimatePresence>
{showAddModel && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="p-3 bg-gray-900/50 rounded-lg space-y-3 border border-gray-700">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-400 mb-1">Model ID</label>
<Input
value={newModelId}
onChange={(e) => setNewModelId(e.target.value)}
placeholder="gpt-4o-custom"
className="text-sm"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Display Name</label>
<Input
value={newModelName}
onChange={(e) => setNewModelName(e.target.value)}
placeholder="GPT-4o Custom"
className="text-sm"
/>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={newModelVision}
onChange={(e) => setNewModelVision(e.target.checked)}
className="w-4 h-4 rounded border-gray-600 bg-gray-900 text-primary-500"
/>
Vision
</label>
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={newModelTools}
onChange={(e) => setNewModelTools(e.target.checked)}
className="w-4 h-4 rounded border-gray-600 bg-gray-900 text-primary-500"
/>
Function Calling
</label>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowAddModel(false);
setNewModelId('');
setNewModelName('');
}}
>
Cancel
</Button>
<Button size="sm" onClick={handleAddModel}>
Add
</Button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Custom Models List */}
{provider.config.customModels.length > 0 ? (
<div className="space-y-1">
{provider.config.customModels.map((model) => (
<div
key={model.id}
className="flex items-center justify-between p-2 bg-gray-900/50 rounded-lg text-sm"
>
<div>
<span className="text-gray-200">{model.name}</span>
<span className="text-gray-500 ml-2">({model.id})</span>
<div className="flex items-center gap-2 mt-0.5">
{model.capabilities?.vision && (
<span className="text-[10px] text-gray-500">Vision</span>
)}
{model.capabilities?.functionCalling && (
<span className="text-[10px] text-gray-500">Tools</span>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteModel(model.id)}
className="text-red-400 hover:text-red-300 p-1"
>
<Trash2 size={14} />
</Button>
</div>
))}
</div>
) : (
<p className="text-xs text-gray-500 text-center py-2">
No custom models added yet
</p>
)}
</div>
)}
{/* Built-in Models Info */}
{provider && (
<div className="text-xs text-gray-500 p-3 bg-gray-900/30 rounded-lg">
<p className="font-medium mb-1">Built-in Models:</p>
<div className="flex flex-wrap gap-1">
{provider.models.slice(0, 5).map((m) => (
<span key={m.id} className="px-1.5 py-0.5 bg-gray-800 rounded">
{m.name}
</span>
))}
{provider.models.length > 5 && (
<span className="px-1.5 py-0.5 text-gray-400">
+{provider.models.length - 5} more
</span>
)}
</div>
</div>
)}
</>
)}
</div>
{/* Footer */}
<div
className={cn(
'border-t border-gray-700 flex justify-end gap-2',
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
)}
>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="default" onClick={handleSave} disabled={saving || loading}>
<Save size={16} className="mr-1" />
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
@@ -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<string | null>(null);
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
const [newProvider, setNewProvider] = useState<Partial<CustomProviderDefinition>>({});
const [newModel, setNewModel] = useState<Partial<ModelInfo>>({});
@@ -356,6 +359,17 @@ export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelPr
)}
</Button>
{/* Edit Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setEditingProviderId(provider.id)}
className="text-gray-400 hover:text-gray-300"
title="Configure"
>
<Settings size={14} />
</Button>
{/* Delete (only for custom) */}
{!provider.builtin && (
<Button
@@ -744,6 +758,25 @@ export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelPr
</motion.div>
)}
</AnimatePresence>
{/* Provider Editor */}
{editingProviderId && (
<ProviderEditor
providerId={editingProviderId}
onClose={() => setEditingProviderId(null)}
onSave={() => {
setEditingProviderId(null);
// Clear cached details and reload
setProviderDetails((prev) => {
const newDetails = { ...prev };
delete newDetails[editingProviderId];
return newDetails;
});
loadProviders();
}}
responsive={responsive}
/>
)}
</motion.div>
</AnimatePresence>
);
+1
View File
@@ -183,6 +183,7 @@ export { AgentsPanel } from './components/AgentsPanel.js';
export { AgentEditor } from './components/AgentEditor.js';
export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js';
export { ProvidersPanel } from './components/ProvidersPanel.js';
export { ProviderEditor } from './components/ProviderEditor.js';
export { CheckpointPanel } from './components/CheckpointPanel.js';
export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js';
export { RestoreDialog } from './components/RestoreDialog.js';
+6
View File
@@ -14,6 +14,7 @@ import {
HooksPanel,
AgentsPanel,
CheckpointPanel,
ProvidersPanel,
Toaster,
listSessions,
createSession,
@@ -31,6 +32,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);
// 初始化:加载或创建会话
@@ -120,6 +122,7 @@ export function App() {
onOpenHooks={() => setShowHooks(true)}
onOpenAgents={() => setShowAgents(true)}
onOpenCheckpoints={() => setShowCheckpoints(true)}
onOpenProviders={() => setShowProviders(true)}
/>
) : (
<div className="flex-1 flex items-center justify-center h-full">
@@ -183,6 +186,9 @@ export function App() {
{/* Checkpoints 面板 */}
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} responsive />}
{/* Providers 面板 */}
{showProviders && <ProvidersPanel onClose={() => setShowProviders(false)} responsive />}
{/* 移动端底部文件按钮 */}
<button
onClick={() => setShowFileBrowser(true)}
+17 -2
View File
@@ -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,
@@ -28,6 +28,7 @@ interface ChatPageProps {
onOpenHooks?: () => void;
onOpenAgents?: () => void;
onOpenCheckpoints?: () => void;
onOpenProviders?: () => void;
}
export function ChatPage({
@@ -43,6 +44,7 @@ export function ChatPage({
onOpenHooks,
onOpenAgents,
onOpenCheckpoints,
onOpenProviders,
}: ChatPageProps) {
const {
messages,
@@ -140,7 +142,7 @@ export function ChatPage({
<ConnectionStatus />
{/* 工具栏按钮 */}
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints) && (
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders) && (
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
{/* Checkpoints 按钮 */}
{onOpenCheckpoints && (
@@ -155,6 +157,19 @@ export function ChatPage({
</motion.button>
)}
{/* Providers 按钮 */}
{onOpenProviders && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenProviders}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="Model Providers"
>
<Server size={20} />
</motion.button>
)}
{/* Agents 按钮 */}
{onOpenAgents && (
<motion.button