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,
|
HooksPanel,
|
||||||
AgentsPanel,
|
AgentsPanel,
|
||||||
CheckpointPanel,
|
CheckpointPanel,
|
||||||
|
ProvidersPanel,
|
||||||
Toaster,
|
Toaster,
|
||||||
listSessions,
|
listSessions,
|
||||||
createSession,
|
createSession,
|
||||||
@@ -29,6 +30,7 @@ export function App() {
|
|||||||
const [showHooks, setShowHooks] = useState(false);
|
const [showHooks, setShowHooks] = useState(false);
|
||||||
const [showAgents, setShowAgents] = useState(false);
|
const [showAgents, setShowAgents] = useState(false);
|
||||||
const [showCheckpoints, setShowCheckpoints] = useState(false);
|
const [showCheckpoints, setShowCheckpoints] = useState(false);
|
||||||
|
const [showProviders, setShowProviders] = useState(false);
|
||||||
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
||||||
|
|
||||||
// 初始化:加载或创建会话
|
// 初始化:加载或创建会话
|
||||||
@@ -104,6 +106,7 @@ export function App() {
|
|||||||
onOpenHooks={() => setShowHooks(true)}
|
onOpenHooks={() => setShowHooks(true)}
|
||||||
onOpenAgents={() => setShowAgents(true)}
|
onOpenAgents={() => setShowAgents(true)}
|
||||||
onOpenCheckpoints={() => setShowCheckpoints(true)}
|
onOpenCheckpoints={() => setShowCheckpoints(true)}
|
||||||
|
onOpenProviders={() => setShowProviders(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center h-full">
|
<div className="flex-1 flex items-center justify-center h-full">
|
||||||
@@ -142,6 +145,9 @@ export function App() {
|
|||||||
{/* Checkpoints 面板 */}
|
{/* Checkpoints 面板 */}
|
||||||
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} />}
|
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} />}
|
||||||
|
|
||||||
|
{/* Providers 面板 */}
|
||||||
|
{showProviders && <ProvidersPanel onClose={() => setShowProviders(false)} />}
|
||||||
|
|
||||||
{/* Toast 通知 */}
|
{/* Toast 通知 */}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
@@ -25,6 +25,7 @@ interface ChatPageProps {
|
|||||||
onOpenHooks?: () => void;
|
onOpenHooks?: () => void;
|
||||||
onOpenAgents?: () => void;
|
onOpenAgents?: () => void;
|
||||||
onOpenCheckpoints?: () => void;
|
onOpenCheckpoints?: () => void;
|
||||||
|
onOpenProviders?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPage({
|
export function ChatPage({
|
||||||
@@ -38,6 +39,7 @@ export function ChatPage({
|
|||||||
onOpenHooks,
|
onOpenHooks,
|
||||||
onOpenAgents,
|
onOpenAgents,
|
||||||
onOpenCheckpoints,
|
onOpenCheckpoints,
|
||||||
|
onOpenProviders,
|
||||||
}: ChatPageProps) {
|
}: ChatPageProps) {
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -131,7 +133,7 @@ export function ChatPage({
|
|||||||
<ConnectionStatus />
|
<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">
|
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
||||||
{/* Checkpoints 按钮 */}
|
{/* Checkpoints 按钮 */}
|
||||||
{onOpenCheckpoints && (
|
{onOpenCheckpoints && (
|
||||||
@@ -146,6 +148,19 @@ export function ChatPage({
|
|||||||
</motion.button>
|
</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 按钮 */}
|
{/* Agents 按钮 */}
|
||||||
{onOpenAgents && (
|
{onOpenAgents && (
|
||||||
<motion.button
|
<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,
|
Key,
|
||||||
Globe,
|
Globe,
|
||||||
Zap,
|
Zap,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
type ModelInfo,
|
type ModelInfo,
|
||||||
type CustomProviderDefinition,
|
type CustomProviderDefinition,
|
||||||
} from '../api/client.js';
|
} from '../api/client.js';
|
||||||
|
import { ProviderEditor } from './ProviderEditor.js';
|
||||||
|
|
||||||
interface ProvidersPanelProps {
|
interface ProvidersPanelProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -63,6 +65,7 @@ export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelPr
|
|||||||
// Editor state
|
// Editor state
|
||||||
const [showAddProvider, setShowAddProvider] = useState(false);
|
const [showAddProvider, setShowAddProvider] = useState(false);
|
||||||
const [showAddModel, setShowAddModel] = useState<string | null>(null);
|
const [showAddModel, setShowAddModel] = useState<string | null>(null);
|
||||||
|
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
|
||||||
const [newProvider, setNewProvider] = useState<Partial<CustomProviderDefinition>>({});
|
const [newProvider, setNewProvider] = useState<Partial<CustomProviderDefinition>>({});
|
||||||
const [newModel, setNewModel] = useState<Partial<ModelInfo>>({});
|
const [newModel, setNewModel] = useState<Partial<ModelInfo>>({});
|
||||||
|
|
||||||
@@ -356,6 +359,17 @@ export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelPr
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</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) */}
|
{/* Delete (only for custom) */}
|
||||||
{!provider.builtin && (
|
{!provider.builtin && (
|
||||||
<Button
|
<Button
|
||||||
@@ -744,6 +758,25 @@ export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelPr
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export { AgentsPanel } from './components/AgentsPanel.js';
|
|||||||
export { AgentEditor } from './components/AgentEditor.js';
|
export { AgentEditor } from './components/AgentEditor.js';
|
||||||
export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js';
|
export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js';
|
||||||
export { ProvidersPanel } from './components/ProvidersPanel.js';
|
export { ProvidersPanel } from './components/ProvidersPanel.js';
|
||||||
|
export { ProviderEditor } from './components/ProviderEditor.js';
|
||||||
export { CheckpointPanel } from './components/CheckpointPanel.js';
|
export { CheckpointPanel } from './components/CheckpointPanel.js';
|
||||||
export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js';
|
export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js';
|
||||||
export { RestoreDialog } from './components/RestoreDialog.js';
|
export { RestoreDialog } from './components/RestoreDialog.js';
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
HooksPanel,
|
HooksPanel,
|
||||||
AgentsPanel,
|
AgentsPanel,
|
||||||
CheckpointPanel,
|
CheckpointPanel,
|
||||||
|
ProvidersPanel,
|
||||||
Toaster,
|
Toaster,
|
||||||
listSessions,
|
listSessions,
|
||||||
createSession,
|
createSession,
|
||||||
@@ -31,6 +32,7 @@ export function App() {
|
|||||||
const [showHooks, setShowHooks] = useState(false);
|
const [showHooks, setShowHooks] = useState(false);
|
||||||
const [showAgents, setShowAgents] = useState(false);
|
const [showAgents, setShowAgents] = useState(false);
|
||||||
const [showCheckpoints, setShowCheckpoints] = useState(false);
|
const [showCheckpoints, setShowCheckpoints] = useState(false);
|
||||||
|
const [showProviders, setShowProviders] = useState(false);
|
||||||
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
||||||
|
|
||||||
// 初始化:加载或创建会话
|
// 初始化:加载或创建会话
|
||||||
@@ -120,6 +122,7 @@ export function App() {
|
|||||||
onOpenHooks={() => setShowHooks(true)}
|
onOpenHooks={() => setShowHooks(true)}
|
||||||
onOpenAgents={() => setShowAgents(true)}
|
onOpenAgents={() => setShowAgents(true)}
|
||||||
onOpenCheckpoints={() => setShowCheckpoints(true)}
|
onOpenCheckpoints={() => setShowCheckpoints(true)}
|
||||||
|
onOpenProviders={() => setShowProviders(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center h-full">
|
<div className="flex-1 flex items-center justify-center h-full">
|
||||||
@@ -183,6 +186,9 @@ export function App() {
|
|||||||
{/* Checkpoints 面板 */}
|
{/* Checkpoints 面板 */}
|
||||||
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} responsive />}
|
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} responsive />}
|
||||||
|
|
||||||
|
{/* Providers 面板 */}
|
||||||
|
{showProviders && <ProvidersPanel onClose={() => setShowProviders(false)} responsive />}
|
||||||
|
|
||||||
{/* 移动端底部文件按钮 */}
|
{/* 移动端底部文件按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFileBrowser(true)}
|
onClick={() => setShowFileBrowser(true)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
@@ -28,6 +28,7 @@ interface ChatPageProps {
|
|||||||
onOpenHooks?: () => void;
|
onOpenHooks?: () => void;
|
||||||
onOpenAgents?: () => void;
|
onOpenAgents?: () => void;
|
||||||
onOpenCheckpoints?: () => void;
|
onOpenCheckpoints?: () => void;
|
||||||
|
onOpenProviders?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPage({
|
export function ChatPage({
|
||||||
@@ -43,6 +44,7 @@ export function ChatPage({
|
|||||||
onOpenHooks,
|
onOpenHooks,
|
||||||
onOpenAgents,
|
onOpenAgents,
|
||||||
onOpenCheckpoints,
|
onOpenCheckpoints,
|
||||||
|
onOpenProviders,
|
||||||
}: ChatPageProps) {
|
}: ChatPageProps) {
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -140,7 +142,7 @@ export function ChatPage({
|
|||||||
<ConnectionStatus />
|
<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">
|
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
||||||
{/* Checkpoints 按钮 */}
|
{/* Checkpoints 按钮 */}
|
||||||
{onOpenCheckpoints && (
|
{onOpenCheckpoints && (
|
||||||
@@ -155,6 +157,19 @@ export function ChatPage({
|
|||||||
</motion.button>
|
</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 按钮 */}
|
{/* Agents 按钮 */}
|
||||||
{onOpenAgents && (
|
{onOpenAgents && (
|
||||||
<motion.button
|
<motion.button
|
||||||
|
|||||||
Reference in New Issue
Block a user