feat(ui): 集成 Provider 管理到 web 和 desktop 应用
- 新增 ProviderEditor 组件用于编辑提供商配置 - ProvidersPanel 添加编辑按钮集成 ProviderEditor - ChatPage 添加 onOpenProviders 工具栏按钮 - web/desktop App.tsx 集成 ProvidersPanel 面板
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user