feat(ui): 添加外部服务配置面板 (ServicesPanel)

- 新增 ServicesPanel 组件,用于配置 Tavily 等第三方服务的 API Key
- 添加 Services API 客户端方法 (listServices, updateService, deleteService)
- 在工具栏菜单中添加 "External Services" 入口
- 支持 API Key 的配置、启用/禁用和删除
This commit is contained in:
2025-12-30 14:34:32 +08:00
parent c3db79c00d
commit 4108b112f9
5 changed files with 528 additions and 1 deletions
+59
View File
@@ -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>
);
}
+7
View File
@@ -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';
+6
View File
@@ -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
+4 -1
View File
@@ -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 },