From 4108b112f92a6fea33708dace519e5614e984d27 Mon Sep 17 00:00:00 2001 From: kurihada Date: Tue, 30 Dec 2025 14:34:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E6=9C=8D=E5=8A=A1=E9=85=8D=E7=BD=AE=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=20(ServicesPanel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ServicesPanel 组件,用于配置 Tavily 等第三方服务的 API Key - 添加 Services API 客户端方法 (listServices, updateService, deleteService) - 在工具栏菜单中添加 "External Services" 入口 - 支持 API Key 的配置、启用/禁用和删除 --- packages/ui/src/api/client.ts | 59 +++ packages/ui/src/components/ServicesPanel.tsx | 452 +++++++++++++++++++ packages/ui/src/index.ts | 7 + packages/web/src/App.tsx | 6 + packages/web/src/pages/Chat.tsx | 5 +- 5 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/ServicesPanel.tsx diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index aa46539..ded70ee 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -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)}`); +} diff --git a/packages/ui/src/components/ServicesPanel.tsx b/packages/ui/src/components/ServicesPanel.tsx new file mode 100644 index 0000000..b1ea645 --- /dev/null +++ b/packages/ui/src/components/ServicesPanel.tsx @@ -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([]); + + // UI state + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [savingService, setSavingService] = useState(null); + + // Edit state + const [editingService, setEditingService] = useState(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 = () => ( +
+ {[1, 2].map((i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ); + + // Service item component + const ServiceItem = ({ service }: { service: ServiceListItem }) => { + const isEditing = editingService === service.id; + const isSaving = savingService === service.id; + + return ( + + {/* Service Header */} +
+ {/* Icon */} +
+ +
+ + {/* Info */} +
+
+ {service.name} + + + +
+

{service.description}

+
+ {service.hasApiKey ? ( + + + API Key configured + + ) : ( + + + No API Key + + )} +
+
+ + {/* Actions */} +
+ {/* Enable Toggle */} +
+ + {service.enabled ? 'Enabled' : 'Disabled'} + + handleToggleEnabled(service)} + disabled={isSaving || !service.hasApiKey} + /> +
+ + {/* Configure Button */} + +
+
+ + {/* Edit Panel */} + + {isEditing && ( + +
+ {/* API Key Input */} +
+ +
+
+ setApiKeyInput(e.target.value)} + placeholder={service.hasApiKey ? '••••••••••••••••' : 'Enter API key'} + className="pr-10" + /> + +
+
+

+ Get your API key from{' '} + + {service.website} + +

+
+ + {/* Actions */} +
+
+ {service.hasApiKey && ( + + )} +
+
+ + +
+
+
+
+ )} +
+
+ ); + }; + + return ( + + + 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 */} +
+ {responsive && ( +
+ )} +
+

+ + External Services +

+

+ Configure API keys for third-party services +

+
+
+ + +
+
+ + {/* Services List */} +
+ {loading ? ( + + ) : services.length === 0 ? ( +
+ +

No services available

+
+ ) : ( + + {services.map((service) => ( + + ))} + + )} +
+ + {/* Footer */} +
+ Config stored in{' '} + ~/.ai-assistant/providers.json +
+ + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e086111..8741395 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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'; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 4072991..994da96 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -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 && setShowProviders(false)} responsive />} + {/* Services 面板 */} + {showServices && setShowServices(false)} responsive />} + {/* LSP 面板 */} {showLSP && ( 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 },