feat(ui): 添加外部服务配置面板 (ServicesPanel)
- 新增 ServicesPanel 组件,用于配置 Tavily 等第三方服务的 API Key - 添加 Services API 客户端方法 (listServices, updateService, deleteService) - 在工具栏菜单中添加 "External Services" 入口 - 支持 API Key 的配置、启用/禁用和删除
This commit is contained in:
@@ -1215,3 +1215,62 @@ export async function refreshProjectTokenStats(projectId: string): Promise<{
|
||||
}> {
|
||||
return request('POST', `/stats/projects/${encodeURIComponent(projectId)}/refresh`);
|
||||
}
|
||||
|
||||
// ============ Services API ============
|
||||
|
||||
/** 服务列表项 */
|
||||
export interface ServiceListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
website: string;
|
||||
enabled: boolean;
|
||||
hasApiKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有服务列表
|
||||
*/
|
||||
export async function listServices(): Promise<{
|
||||
success: boolean;
|
||||
data: ServiceListItem[];
|
||||
error?: string;
|
||||
}> {
|
||||
return request('GET', '/services');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个服务详情
|
||||
*/
|
||||
export async function getService(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: ServiceListItem;
|
||||
error?: string;
|
||||
}> {
|
||||
return request('GET', `/services/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新服务配置
|
||||
*/
|
||||
export async function updateService(
|
||||
id: string,
|
||||
config: { apiKey?: string; enabled?: boolean }
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
return request('PUT', `/services/${encodeURIComponent(id)}`, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除服务配置(移除 API Key)
|
||||
*/
|
||||
export async function deleteService(id: string): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
return request('DELETE', `/services/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* ServicesPanel Component
|
||||
*
|
||||
* Third-party services configuration panel (e.g., Tavily for web search)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
X,
|
||||
RefreshCw,
|
||||
Globe,
|
||||
Key,
|
||||
Check,
|
||||
Loader2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
} 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 { Switch } from '../primitives/Switch';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import {
|
||||
listServices,
|
||||
updateService,
|
||||
deleteService,
|
||||
type ServiceListItem,
|
||||
} from '../api/client.js';
|
||||
|
||||
interface ServicesPanelProps {
|
||||
onClose: () => void;
|
||||
/** Enable responsive layout */
|
||||
responsive?: boolean;
|
||||
}
|
||||
|
||||
export function ServicesPanel({ onClose, responsive = false }: ServicesPanelProps) {
|
||||
// Data state
|
||||
const [services, setServices] = useState<ServiceListItem[]>([]);
|
||||
|
||||
// UI state
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [savingService, setSavingService] = useState<string | null>(null);
|
||||
|
||||
// Edit state
|
||||
const [editingService, setEditingService] = useState<string | null>(null);
|
||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
// Load services list
|
||||
const loadServices = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
const result = await listServices();
|
||||
if (result.success) {
|
||||
setServices(result.data);
|
||||
if (showToast) {
|
||||
toast.success('Services refreshed');
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to load services');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load services');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadServices().finally(() => setLoading(false));
|
||||
}, [loadServices]);
|
||||
|
||||
// Refresh
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadServices(true);
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
// Start editing a service
|
||||
const handleStartEdit = async (serviceId: string) => {
|
||||
setEditingService(serviceId);
|
||||
setApiKeyInput('');
|
||||
setShowApiKey(false);
|
||||
};
|
||||
|
||||
// Save service config
|
||||
const handleSaveService = async (serviceId: string, enabled: boolean) => {
|
||||
setSavingService(serviceId);
|
||||
try {
|
||||
const payload: { apiKey?: string; enabled?: boolean } = { enabled };
|
||||
if (apiKeyInput) {
|
||||
payload.apiKey = apiKeyInput;
|
||||
}
|
||||
|
||||
const result = await updateService(serviceId, payload);
|
||||
if (result.success) {
|
||||
toast.success(`${serviceId} configuration saved`);
|
||||
setEditingService(null);
|
||||
setApiKeyInput('');
|
||||
loadServices();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to save configuration');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save configuration');
|
||||
} finally {
|
||||
setSavingService(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle service enabled status
|
||||
const handleToggleEnabled = async (service: ServiceListItem) => {
|
||||
setSavingService(service.id);
|
||||
try {
|
||||
const result = await updateService(service.id, { enabled: !service.enabled });
|
||||
if (result.success) {
|
||||
toast.success(`${service.name} ${!service.enabled ? 'enabled' : 'disabled'}`);
|
||||
loadServices();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to update service');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update service');
|
||||
} finally {
|
||||
setSavingService(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete service config (remove API key)
|
||||
const handleDeleteConfig = async (serviceId: string) => {
|
||||
if (!confirm(`Are you sure you want to remove the API key for "${serviceId}"?`)) return;
|
||||
|
||||
setSavingService(serviceId);
|
||||
try {
|
||||
const result = await deleteService(serviceId);
|
||||
if (result.success) {
|
||||
toast.success(`${serviceId} configuration removed`);
|
||||
loadServices();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to remove configuration');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to remove configuration');
|
||||
} finally {
|
||||
setSavingService(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading skeleton
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="space-y-3 p-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-4 bg-surface-base/50 rounded-lg">
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-12 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Service item component
|
||||
const ServiceItem = ({ service }: { service: ServiceListItem }) => {
|
||||
const isEditing = editingService === service.id;
|
||||
const isSaving = savingService === service.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-surface-base/50 rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Service Header */}
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-500/10 flex items-center justify-center">
|
||||
<Globe size={20} className="text-primary-400" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-fg-secondary">{service.name}</span>
|
||||
<a
|
||||
href={service.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-fg-subtle hover:text-primary-400 transition-colors"
|
||||
title="Visit website"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-fg-subtle truncate">{service.description}</p>
|
||||
<div className="text-xs text-fg-subtle flex items-center gap-2 mt-1">
|
||||
{service.hasApiKey ? (
|
||||
<span className="text-green-400 flex items-center gap-1">
|
||||
<Key size={10} />
|
||||
API Key configured
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-yellow-400 flex items-center gap-1">
|
||||
<Key size={10} />
|
||||
No API Key
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-fg-muted">
|
||||
{service.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={service.enabled}
|
||||
onCheckedChange={() => handleToggleEnabled(service)}
|
||||
disabled={isSaving || !service.hasApiKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Configure Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStartEdit(service.id)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Key size={14} className="mr-1" />
|
||||
)}
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Panel */}
|
||||
<AnimatePresence>
|
||||
{isEditing && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-4 pb-4 pt-2 border-t border-line/50 space-y-4">
|
||||
{/* API Key Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-fg-muted flex items-center gap-2">
|
||||
<Key size={14} />
|
||||
API Key
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
placeholder={service.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-fg-subtle hover:text-fg-secondary"
|
||||
>
|
||||
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Get your API key from{' '}
|
||||
<a
|
||||
href={service.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-400 hover:underline"
|
||||
>
|
||||
{service.website}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{service.hasApiKey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteConfig(service.id)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Remove API Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingService(null);
|
||||
setApiKeyInput('');
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSaveService(service.id, service.enabled)}
|
||||
disabled={isSaving || (!apiKeyInput && !service.hasApiKey)}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 size={14} className="animate-spin mr-1" />
|
||||
) : (
|
||||
<Check size={14} className="mr-1" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
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-50',
|
||||
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-surface-subtle 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-line',
|
||||
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-surface-emphasis rounded-full md:hidden" />
|
||||
)}
|
||||
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Globe size={20} className="text-primary-400" />
|
||||
External Services
|
||||
</h2>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Configure API keys for third-party services
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title="Refresh"
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<LoadingSkeleton />
|
||||
) : services.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-fg-subtle">
|
||||
<Globe size={48} className="mb-4 opacity-50" />
|
||||
<p className="text-center">No services available</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={cn('space-y-3', responsive ? 'p-4' : 'p-4')}
|
||||
>
|
||||
{services.map((service) => (
|
||||
<ServiceItem key={service.id} service={service} />
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-line text-xs text-fg-subtle text-center',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
Config stored in{' '}
|
||||
<code className="font-mono bg-surface-base px-1 rounded">~/.ai-assistant/providers.json</code>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -105,6 +105,12 @@ export {
|
||||
stopLSPServer,
|
||||
getRunningLSPServers,
|
||||
getLSPDiagnostics,
|
||||
// Services API
|
||||
listServices,
|
||||
getService,
|
||||
updateService,
|
||||
deleteService,
|
||||
type ServiceListItem,
|
||||
} from './api/client.js';
|
||||
|
||||
// Types
|
||||
@@ -235,6 +241,7 @@ 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 { ServicesPanel } from './components/ServicesPanel.js';
|
||||
export { CheckpointPanel } from './components/CheckpointPanel.js';
|
||||
export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js';
|
||||
export { RestoreDialog } from './components/RestoreDialog.js';
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AgentsPanel,
|
||||
CheckpointPanel,
|
||||
ProvidersPanel,
|
||||
ServicesPanel,
|
||||
LSPPanel,
|
||||
DiagnosticsPanel,
|
||||
SessionPanel,
|
||||
@@ -37,6 +38,7 @@ export function App() {
|
||||
const [showAgents, setShowAgents] = useState(false);
|
||||
const [showCheckpoints, setShowCheckpoints] = useState(false);
|
||||
const [showProviders, setShowProviders] = useState(false);
|
||||
const [showServices, setShowServices] = useState(false);
|
||||
const [showLSP, setShowLSP] = useState(false);
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||
const [showSessions, setShowSessions] = useState(false);
|
||||
@@ -194,6 +196,7 @@ export function App() {
|
||||
onOpenAgents={() => setShowAgents(true)}
|
||||
onOpenCheckpoints={() => setShowCheckpoints(true)}
|
||||
onOpenProviders={() => setShowProviders(true)}
|
||||
onOpenServices={() => setShowServices(true)}
|
||||
onOpenLSP={() => setShowLSP(true)}
|
||||
onOpenDiagnostics={() => setShowDiagnostics(true)}
|
||||
onOpenSessions={() => setShowSessions(true)}
|
||||
@@ -236,6 +239,9 @@ export function App() {
|
||||
{/* Providers 面板 */}
|
||||
{showProviders && <ProvidersPanel onClose={() => setShowProviders(false)} responsive />}
|
||||
|
||||
{/* Services 面板 */}
|
||||
{showServices && <ServicesPanel onClose={() => setShowServices(false)} responsive />}
|
||||
|
||||
{/* LSP 面板 */}
|
||||
{showLSP && (
|
||||
<LSPPanel
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { MessageSquare, Terminal, Plug, Zap, Bot, History, Server, MessagesSquare } from 'lucide-react';
|
||||
import { MessageSquare, Terminal, Plug, Zap, Bot, History, Server, MessagesSquare, Globe } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -33,6 +33,7 @@ interface ChatPageProps {
|
||||
onOpenAgents?: () => void;
|
||||
onOpenCheckpoints?: () => void;
|
||||
onOpenProviders?: () => void;
|
||||
onOpenServices?: () => void;
|
||||
onOpenLSP?: () => void;
|
||||
onOpenDiagnostics?: () => void;
|
||||
onOpenSessions?: () => void;
|
||||
@@ -61,6 +62,7 @@ export function ChatPage({
|
||||
onOpenAgents,
|
||||
onOpenCheckpoints,
|
||||
onOpenProviders,
|
||||
onOpenServices,
|
||||
onOpenLSP,
|
||||
onOpenDiagnostics,
|
||||
onOpenSessions,
|
||||
@@ -197,6 +199,7 @@ export function ChatPage({
|
||||
items={[
|
||||
{ icon: History, label: 'Checkpoints', onClick: onOpenCheckpoints },
|
||||
{ icon: Server, label: 'Model Providers', onClick: onOpenProviders },
|
||||
{ icon: Globe, label: 'External Services', onClick: onOpenServices },
|
||||
{ icon: Bot, label: 'Agent Presets', onClick: onOpenAgents },
|
||||
{ icon: Zap, label: 'Hooks', onClick: onOpenHooks },
|
||||
{ icon: Plug, label: 'MCP Servers', onClick: onOpenMCP },
|
||||
|
||||
Reference in New Issue
Block a user