diff --git a/pkg/api/http/controller/groups/webhooks.py b/pkg/api/http/controller/groups/webhooks.py new file mode 100644 index 00000000..1149f89b --- /dev/null +++ b/pkg/api/http/controller/groups/webhooks.py @@ -0,0 +1,49 @@ +import quart + +from .. import group + + +@group.group_class('webhooks', '/api/v1/webhooks') +class WebhooksRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST']) + async def _() -> str: + if quart.request.method == 'GET': + webhooks = await self.ap.webhook_service.get_webhooks() + return self.success(data={'webhooks': webhooks}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + name = json_data.get('name', '') + url = json_data.get('url', '') + description = json_data.get('description', '') + enabled = json_data.get('enabled', True) + + if not name: + return self.http_status(400, -1, 'Name is required') + if not url: + return self.http_status(400, -1, 'URL is required') + + webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled) + return self.success(data={'webhook': webhook}) + + @self.route('/', methods=['GET', 'PUT', 'DELETE']) + async def _(webhook_id: int) -> str: + if quart.request.method == 'GET': + webhook = await self.ap.webhook_service.get_webhook(webhook_id) + if webhook is None: + return self.http_status(404, -1, 'Webhook not found') + return self.success(data={'webhook': webhook}) + + elif quart.request.method == 'PUT': + json_data = await quart.request.json + name = json_data.get('name') + url = json_data.get('url') + description = json_data.get('description') + enabled = json_data.get('enabled') + + await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled) + return self.success() + + elif quart.request.method == 'DELETE': + await self.ap.webhook_service.delete_webhook(webhook_id) + return self.success() diff --git a/pkg/api/http/service/webhook.py b/pkg/api/http/service/webhook.py new file mode 100644 index 00000000..b3a67118 --- /dev/null +++ b/pkg/api/http/service/webhook.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import sqlalchemy + +from ....core import app +from ....entity.persistence import webhook + + +class WebhookService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_webhooks(self) -> list[dict]: + """Get all webhooks""" + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(webhook.Webhook)) + + webhooks = result.all() + return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks] + + async def create_webhook(self, name: str, url: str, description: str = '', enabled: bool = True) -> dict: + """Create a new webhook""" + webhook_data = {'name': name, 'url': url, 'description': description, 'enabled': enabled} + + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(webhook.Webhook).values(**webhook_data)) + + # Retrieve the created webhook + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.url == url).order_by(webhook.Webhook.id.desc()) + ) + created_webhook = result.first() + + return self.ap.persistence_mgr.serialize_model(webhook.Webhook, created_webhook) + + async def get_webhook(self, webhook_id: int) -> dict | None: + """Get a specific webhook by ID""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == webhook_id) + ) + + wh = result.first() + + if wh is None: + return None + + return self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) + + async def update_webhook( + self, webhook_id: int, name: str = None, url: str = None, description: str = None, enabled: bool = None + ) -> None: + """Update a webhook's metadata""" + update_data = {} + if name is not None: + update_data['name'] = name + if url is not None: + update_data['url'] = url + if description is not None: + update_data['description'] = description + if enabled is not None: + update_data['enabled'] = enabled + + if update_data: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(webhook.Webhook).where(webhook.Webhook.id == webhook_id).values(**update_data) + ) + + async def delete_webhook(self, webhook_id: int) -> None: + """Delete a webhook""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(webhook.Webhook).where(webhook.Webhook.id == webhook_id) + ) + + async def get_enabled_webhooks(self) -> list[dict]: + """Get all enabled webhooks""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.enabled == True) + ) + + webhooks = result.all() + return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks] diff --git a/pkg/core/app.py b/pkg/core/app.py index 9b29fdc7..88cd0d50 100644 --- a/pkg/core/app.py +++ b/pkg/core/app.py @@ -6,6 +6,7 @@ import traceback import os from ..platform import botmgr as im_mgr +from ..platform.webhook_pusher import WebhookPusher from ..provider.session import sessionmgr as llm_session_mgr from ..provider.modelmgr import modelmgr as llm_model_mgr from ..provider.tools import toolmgr as llm_tool_mgr @@ -24,6 +25,7 @@ from ..api.http.service import bot as bot_service from ..api.http.service import knowledge as knowledge_service from ..api.http.service import mcp as mcp_service from ..api.http.service import apikey as apikey_service +from ..api.http.service import webhook as webhook_service from ..discover import engine as discover_engine from ..storage import mgr as storagemgr from ..utils import logcache @@ -45,6 +47,8 @@ class Application: platform_mgr: im_mgr.PlatformManager = None + webhook_pusher: WebhookPusher = None + cmd_mgr: cmdmgr.CommandManager = None sess_mgr: llm_session_mgr.SessionManager = None @@ -125,6 +129,8 @@ class Application: apikey_service: apikey_service.ApiKeyService = None + webhook_service: webhook_service.WebhookService = None + def __init__(self): pass diff --git a/pkg/core/stages/build_app.py b/pkg/core/stages/build_app.py index d40ba2ab..407b929d 100644 --- a/pkg/core/stages/build_app.py +++ b/pkg/core/stages/build_app.py @@ -12,6 +12,7 @@ from ...provider.modelmgr import modelmgr as llm_model_mgr from ...provider.tools import toolmgr as llm_tool_mgr from ...rag.knowledge import kbmgr as rag_mgr from ...platform import botmgr as im_mgr +from ...platform.webhook_pusher import WebhookPusher from ...persistence import mgr as persistencemgr from ...api.http.controller import main as http_controller from ...api.http.service import user as user_service @@ -21,6 +22,7 @@ from ...api.http.service import bot as bot_service from ...api.http.service import knowledge as knowledge_service from ...api.http.service import mcp as mcp_service from ...api.http.service import apikey as apikey_service +from ...api.http.service import webhook as webhook_service from ...discover import engine as discover_engine from ...storage import mgr as storagemgr from ...utils import logcache @@ -93,6 +95,10 @@ class BuildAppStage(stage.BootingStage): await im_mgr_inst.initialize() ap.platform_mgr = im_mgr_inst + # Initialize webhook pusher + webhook_pusher_inst = WebhookPusher(ap) + ap.webhook_pusher = webhook_pusher_inst + pipeline_mgr = pipelinemgr.PipelineManager(ap) await pipeline_mgr.initialize() ap.pipeline_mgr = pipeline_mgr @@ -134,5 +140,8 @@ class BuildAppStage(stage.BootingStage): apikey_service_inst = apikey_service.ApiKeyService(ap) ap.apikey_service = apikey_service_inst + webhook_service_inst = webhook_service.WebhookService(ap) + ap.webhook_service = webhook_service_inst + ctrl = controller.Controller(ap) ap.ctrl = ctrl diff --git a/pkg/entity/persistence/webhook.py b/pkg/entity/persistence/webhook.py new file mode 100644 index 00000000..326ab6c4 --- /dev/null +++ b/pkg/entity/persistence/webhook.py @@ -0,0 +1,22 @@ +import sqlalchemy + +from .base import Base + + +class Webhook(Base): + """Webhook for pushing bot events to external systems""" + + __tablename__ = 'webhooks' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + url = sqlalchemy.Column(sqlalchemy.String(1024), nullable=False) + description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='') + enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/pkg/platform/botmgr.py b/pkg/platform/botmgr.py index eb708d9b..dca24f96 100644 --- a/pkg/platform/botmgr.py +++ b/pkg/platform/botmgr.py @@ -13,6 +13,7 @@ from ..entity.persistence import bot as persistence_bot from ..entity.errors import platform as platform_errors from .logger import EventLogger +from .webhook_pusher import WebhookPusher import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.platform.events as platform_events @@ -66,6 +67,14 @@ class RuntimeBot: message_session_id=f'person_{event.sender.id}', ) + # Push to webhooks + if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher: + asyncio.create_task( + self.ap.webhook_pusher.push_person_message( + event, self.bot_entity.uuid, adapter.__class__.__name__ + ) + ) + await self.ap.query_pool.add_query( bot_uuid=self.bot_entity.uuid, launcher_type=provider_session.LauncherTypes.PERSON, @@ -91,6 +100,14 @@ class RuntimeBot: message_session_id=f'group_{event.group.id}', ) + # Push to webhooks + if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher: + asyncio.create_task( + self.ap.webhook_pusher.push_group_message( + event, self.bot_entity.uuid, adapter.__class__.__name__ + ) + ) + await self.ap.query_pool.add_query( bot_uuid=self.bot_entity.uuid, launcher_type=provider_session.LauncherTypes.GROUP, diff --git a/pkg/platform/webhook_pusher.py b/pkg/platform/webhook_pusher.py new file mode 100644 index 00000000..ab34bfad --- /dev/null +++ b/pkg/platform/webhook_pusher.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import asyncio +import logging +import aiohttp +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..core import app + +import langbot_plugin.api.entities.builtin.platform.events as platform_events + + +class WebhookPusher: + """Push bot events to configured webhooks""" + + ap: app.Application + logger: logging.Logger + + def __init__(self, ap: app.Application): + self.ap = ap + self.logger = self.ap.logger + + async def push_person_message(self, event: platform_events.FriendMessage, bot_uuid: str, adapter_name: str) -> None: + """Push person message event to webhooks""" + try: + webhooks = await self.ap.webhook_service.get_enabled_webhooks() + if not webhooks: + return + + # Build payload + payload = { + 'uuid': str(uuid.uuid4()), # unique id for the event + 'event_type': 'bot.person_message', + 'data': { + 'bot_uuid': bot_uuid, + 'adapter_name': adapter_name, + 'sender': { + 'id': str(event.sender.id), + 'name': getattr(event.sender, 'name', ''), + }, + 'message': event.message_chain.model_dump(), + 'timestamp': event.time if hasattr(event, 'time') else None, + }, + } + + # Push to all webhooks asynchronously + tasks = [self._push_to_webhook(webhook['url'], payload) for webhook in webhooks] + await asyncio.gather(*tasks, return_exceptions=True) + + except Exception as e: + self.logger.error(f'Failed to push person message to webhooks: {e}') + + async def push_group_message(self, event: platform_events.GroupMessage, bot_uuid: str, adapter_name: str) -> None: + """Push group message event to webhooks""" + try: + webhooks = await self.ap.webhook_service.get_enabled_webhooks() + if not webhooks: + return + + # Build payload + payload = { + 'uuid': str(uuid.uuid4()), # unique id for the event + 'event_type': 'bot.group_message', + 'data': { + 'bot_uuid': bot_uuid, + 'adapter_name': adapter_name, + 'group': { + 'id': str(event.group.id), + 'name': getattr(event.group, 'name', ''), + }, + 'sender': { + 'id': str(event.sender.id), + 'name': getattr(event.sender, 'name', ''), + }, + 'message': event.message_chain.model_dump(), + 'timestamp': event.time if hasattr(event, 'time') else None, + }, + } + + # Push to all webhooks asynchronously + tasks = [self._push_to_webhook(webhook['url'], payload) for webhook in webhooks] + await asyncio.gather(*tasks, return_exceptions=True) + + except Exception as e: + self.logger.error(f'Failed to push group message to webhooks: {e}') + + async def _push_to_webhook(self, url: str, payload: dict) -> None: + """Push payload to a single webhook URL""" + try: + async with aiohttp.ClientSession() as session: + async with session.post( + url, + json=payload, + headers={'Content-Type': 'application/json'}, + timeout=aiohttp.ClientTimeout(total=15), + ) as response: + if response.status >= 400: + self.logger.warning(f'Webhook {url} returned status {response.status}') + else: + self.logger.debug(f'Successfully pushed to webhook {url}') + except asyncio.TimeoutError: + self.logger.warning(f'Timeout pushing to webhook {url}') + except Exception as e: + self.logger.warning(f'Error pushing to webhook {url}: {e}') diff --git a/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx b/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx new file mode 100644 index 00000000..078d7dea --- /dev/null +++ b/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx @@ -0,0 +1,678 @@ +'use client'; + +import * as React from 'react'; +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { Copy, Trash2, Plus } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Switch } from '@/components/ui/switch'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogPortal, + AlertDialogOverlay, +} from '@/components/ui/alert-dialog'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import { backendClient } from '@/app/infra/http'; + +interface ApiKey { + id: number; + name: string; + key: string; + description: string; + created_at: string; +} + +interface Webhook { + id: number; + name: string; + url: string; + description: string; + enabled: boolean; + created_at: string; +} + +interface ApiIntegrationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function ApiIntegrationDialog({ + open, + onOpenChange, +}: ApiIntegrationDialogProps) { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState('apikeys'); + const [apiKeys, setApiKeys] = useState([]); + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(false); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [newKeyName, setNewKeyName] = useState(''); + const [newKeyDescription, setNewKeyDescription] = useState(''); + const [createdKey, setCreatedKey] = useState(null); + const [deleteKeyId, setDeleteKeyId] = useState(null); + + // Webhook state + const [showCreateWebhookDialog, setShowCreateWebhookDialog] = useState(false); + const [newWebhookName, setNewWebhookName] = useState(''); + const [newWebhookUrl, setNewWebhookUrl] = useState(''); + const [newWebhookDescription, setNewWebhookDescription] = useState(''); + const [newWebhookEnabled, setNewWebhookEnabled] = useState(true); + const [deleteWebhookId, setDeleteWebhookId] = useState(null); + + // 清理 body 样式,防止对话框关闭后页面无法交互 + useEffect(() => { + if (!deleteKeyId && !deleteWebhookId) { + const cleanup = () => { + document.body.style.removeProperty('pointer-events'); + }; + + cleanup(); + const timer = setTimeout(cleanup, 100); + return () => clearTimeout(timer); + } + }, [deleteKeyId, deleteWebhookId]); + + useEffect(() => { + if (open) { + loadApiKeys(); + loadWebhooks(); + } + }, [open]); + + const loadApiKeys = async () => { + setLoading(true); + try { + const response = (await backendClient.get('/api/v1/apikeys')) as { + keys: ApiKey[]; + }; + setApiKeys(response.keys || []); + } catch (error) { + toast.error(`Failed to load API keys: ${error}`); + } finally { + setLoading(false); + } + }; + + const handleCreateApiKey = async () => { + if (!newKeyName.trim()) { + toast.error(t('common.apiKeyNameRequired')); + return; + } + + try { + const response = (await backendClient.post('/api/v1/apikeys', { + name: newKeyName, + description: newKeyDescription, + })) as { key: ApiKey }; + + setCreatedKey(response.key); + toast.success(t('common.apiKeyCreated')); + setNewKeyName(''); + setNewKeyDescription(''); + setShowCreateDialog(false); + loadApiKeys(); + } catch (error) { + toast.error(`Failed to create API key: ${error}`); + } + }; + + const handleDeleteApiKey = async (keyId: number) => { + try { + await backendClient.delete(`/api/v1/apikeys/${keyId}`); + toast.success(t('common.apiKeyDeleted')); + loadApiKeys(); + setDeleteKeyId(null); + } catch (error) { + toast.error(`Failed to delete API key: ${error}`); + } + }; + + const handleCopyKey = (key: string) => { + navigator.clipboard.writeText(key); + toast.success(t('common.apiKeyCopied')); + }; + + const maskApiKey = (key: string) => { + if (key.length <= 8) return key; + return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`; + }; + + // Webhook methods + const loadWebhooks = async () => { + setLoading(true); + try { + const response = (await backendClient.get('/api/v1/webhooks')) as { + webhooks: Webhook[]; + }; + setWebhooks(response.webhooks || []); + } catch (error) { + toast.error(`Failed to load webhooks: ${error}`); + } finally { + setLoading(false); + } + }; + + const handleCreateWebhook = async () => { + if (!newWebhookName.trim()) { + toast.error(t('common.webhookNameRequired')); + return; + } + if (!newWebhookUrl.trim()) { + toast.error(t('common.webhookUrlRequired')); + return; + } + + try { + await backendClient.post('/api/v1/webhooks', { + name: newWebhookName, + url: newWebhookUrl, + description: newWebhookDescription, + enabled: newWebhookEnabled, + }); + + toast.success(t('common.webhookCreated')); + setNewWebhookName(''); + setNewWebhookUrl(''); + setNewWebhookDescription(''); + setNewWebhookEnabled(true); + setShowCreateWebhookDialog(false); + loadWebhooks(); + } catch (error) { + toast.error(`Failed to create webhook: ${error}`); + } + }; + + const handleDeleteWebhook = async (webhookId: number) => { + try { + await backendClient.delete(`/api/v1/webhooks/${webhookId}`); + toast.success(t('common.webhookDeleted')); + loadWebhooks(); + setDeleteWebhookId(null); + } catch (error) { + toast.error(`Failed to delete webhook: ${error}`); + } + }; + + const handleToggleWebhook = async (webhook: Webhook) => { + try { + await backendClient.put(`/api/v1/webhooks/${webhook.id}`, { + enabled: !webhook.enabled, + }); + loadWebhooks(); + } catch (error) { + toast.error(`Failed to update webhook: ${error}`); + } + }; + + return ( + <> + { + // 如果删除确认框是打开的,不允许关闭主对话框 + if (!newOpen && (deleteKeyId || deleteWebhookId)) { + return; + } + onOpenChange(newOpen); + }} + > + + + {t('common.manageApiIntegration')} + + + + + + {t('common.apiKeys')} + + + {t('common.webhooks')} + + + + {/* API Keys Tab */} + +
+ {t('common.apiKeyHint')} +
+ +
+ +
+ + {loading ? ( +
+ {t('common.loading')} +
+ ) : apiKeys.length === 0 ? ( +
+ {t('common.noApiKeys')} +
+ ) : ( +
+ + + + {t('common.name')} + {t('common.apiKeyValue')} + + {t('common.actions')} + + + + + {apiKeys.map((key) => ( + + +
+
{key.name}
+ {key.description && ( +
+ {key.description} +
+ )} +
+
+ + + {maskApiKey(key.key)} + + + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+ + {/* Webhooks Tab */} + +
+ {t('common.webhookHint')} +
+ +
+ +
+ + {loading ? ( +
+ {t('common.loading')} +
+ ) : webhooks.length === 0 ? ( +
+ {t('common.noWebhooks')} +
+ ) : ( +
+ + + + {t('common.name')} + {t('common.webhookUrl')} + + {t('common.webhookEnabled')} + + + {t('common.actions')} + + + + + {webhooks.map((webhook) => ( + + +
+
{webhook.name}
+ {webhook.description && ( +
+ {webhook.description} +
+ )} +
+
+ + + {webhook.url} + + + + + handleToggleWebhook(webhook) + } + /> + + + + +
+ ))} +
+
+
+ )} +
+
+ + + + +
+
+ + {/* Create API Key Dialog */} + + + + {t('common.createApiKey')} + +
+
+ + setNewKeyName(e.target.value)} + placeholder={t('common.name')} + className="mt-1" + /> +
+
+ + setNewKeyDescription(e.target.value)} + placeholder={t('common.description')} + className="mt-1" + /> +
+
+ + + + +
+
+ + {/* Show Created Key Dialog */} + setCreatedKey(null)}> + + + {t('common.apiKeyCreated')} + + {t('common.apiKeyCreatedMessage')} + + +
+
+ +
+ + +
+
+
+ + + +
+
+ + {/* Create Webhook Dialog */} + + + + {t('common.createWebhook')} + +
+
+ + setNewWebhookName(e.target.value)} + placeholder={t('common.webhookName')} + className="mt-1" + /> +
+
+ + setNewWebhookUrl(e.target.value)} + placeholder="https://example.com/webhook" + className="mt-1" + /> +
+
+ + setNewWebhookDescription(e.target.value)} + placeholder={t('common.description')} + className="mt-1" + /> +
+
+ + +
+
+ + + + +
+
+ + {/* Delete API Key Confirmation Dialog */} + setCreatedKey(null)}> + + + {t('common.apiKeyCreated')} + + {t('common.apiKeyCreatedMessage')} + + +
+
+ +
+ + +
+
+
+ + + +
+
+ + {/* Delete Confirmation Dialog */} + + + setDeleteKeyId(null)} + /> + setDeleteKeyId(null)} + > + + {t('common.confirmDelete')} + + {t('common.apiKeyDeleteConfirm')} + + + + setDeleteKeyId(null)}> + {t('common.cancel')} + + deleteKeyId && handleDeleteApiKey(deleteKeyId)} + > + {t('common.delete')} + + + + + + + {/* Delete Webhook Confirmation Dialog */} + + + setDeleteWebhookId(null)} + /> + setDeleteWebhookId(null)} + > + + {t('common.confirmDelete')} + + {t('common.webhookDeleteConfirm')} + + + + setDeleteWebhookId(null)}> + {t('common.cancel')} + + + deleteWebhookId && handleDeleteWebhook(deleteWebhookId) + } + > + {t('common.delete')} + + + + + + + ); +} diff --git a/web/src/app/home/components/api-key-management-dialog/ApiKeyManagementDialog.tsx b/web/src/app/home/components/api-key-management-dialog/ApiKeyManagementDialog.tsx deleted file mode 100644 index 35a3781b..00000000 --- a/web/src/app/home/components/api-key-management-dialog/ApiKeyManagementDialog.tsx +++ /dev/null @@ -1,379 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; -import { Copy, Trash2, Plus } from 'lucide-react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogPortal, - AlertDialogOverlay, -} from '@/components/ui/alert-dialog'; -import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; -import { backendClient } from '@/app/infra/http'; -import { extractI18nObject } from '@/i18n/I18nProvider'; - -interface ApiKey { - id: number; - name: string; - key: string; - description: string; - created_at: string; -} - -interface ApiKeyManagementDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export default function ApiKeyManagementDialog({ - open, - onOpenChange, -}: ApiKeyManagementDialogProps) { - const { t } = useTranslation(); - const [apiKeys, setApiKeys] = useState([]); - const [loading, setLoading] = useState(false); - const [showCreateDialog, setShowCreateDialog] = useState(false); - const [newKeyName, setNewKeyName] = useState(''); - const [newKeyDescription, setNewKeyDescription] = useState(''); - const [createdKey, setCreatedKey] = useState(null); - const [deleteKeyId, setDeleteKeyId] = useState(null); - - // 清理 body 样式,防止对话框关闭后页面无法交互 - useEffect(() => { - if (!deleteKeyId) { - const cleanup = () => { - document.body.style.removeProperty('pointer-events'); - }; - - cleanup(); - const timer = setTimeout(cleanup, 100); - return () => clearTimeout(timer); - } - }, [deleteKeyId]); - - useEffect(() => { - if (open) { - loadApiKeys(); - } - }, [open]); - - const loadApiKeys = async () => { - setLoading(true); - try { - const response = (await backendClient.get('/api/v1/apikeys')) as { - keys: ApiKey[]; - }; - setApiKeys(response.keys || []); - } catch (error) { - toast.error(`Failed to load API keys: ${error}`); - } finally { - setLoading(false); - } - }; - - const handleCreateApiKey = async () => { - if (!newKeyName.trim()) { - toast.error(t('common.apiKeyNameRequired')); - return; - } - - try { - const response = (await backendClient.post('/api/v1/apikeys', { - name: newKeyName, - description: newKeyDescription, - })) as { key: ApiKey }; - - setCreatedKey(response.key); - toast.success(t('common.apiKeyCreated')); - setNewKeyName(''); - setNewKeyDescription(''); - setShowCreateDialog(false); - loadApiKeys(); - } catch (error) { - toast.error(`Failed to create API key: ${error}`); - } - }; - - const handleDeleteApiKey = async (keyId: number) => { - try { - await backendClient.delete(`/api/v1/apikeys/${keyId}`); - toast.success(t('common.apiKeyDeleted')); - loadApiKeys(); - setDeleteKeyId(null); - } catch (error) { - toast.error(`Failed to delete API key: ${error}`); - } - }; - - const handleCopyKey = (key: string) => { - navigator.clipboard.writeText(key); - toast.success(t('common.apiKeyCopied')); - }; - - const maskApiKey = (key: string) => { - if (key.length <= 8) return key; - return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`; - }; - - return ( - <> - { - // 如果删除确认框是打开的,不允许关闭主对话框 - if (!newOpen && deleteKeyId) { - return; - } - onOpenChange(newOpen); - }} - > - - - {t('common.manageApiKeys')} - - - {t('common.apiKeyHint')} -
{ - window.open( - extractI18nObject({ - zh_Hans: 'https://docs.langbot.app/zh/tags/readme', - en_US: 'https://docs.langbot.app/en/tags/readme', - }), - '_blank', - ); - }} - className="cursor-pointer" - > - - - -
-
-
-
- -
-
- -
- - {loading ? ( -
- {t('common.loading')} -
- ) : apiKeys.length === 0 ? ( -
- {t('common.noApiKeys')} -
- ) : ( -
- - - - {t('common.name')} - {t('common.apiKeyValue')} - - {t('common.actions')} - - - - - {apiKeys.map((key) => ( - - -
-
{key.name}
- {key.description && ( -
- {key.description} -
- )} -
-
- - - {maskApiKey(key.key)} - - - -
- - -
-
-
- ))} -
-
-
- )} -
- - - - -
-
- - {/* Create API Key Dialog */} - - - - {t('common.createApiKey')} - -
-
- - setNewKeyName(e.target.value)} - placeholder={t('common.name')} - className="mt-1" - /> -
-
- - setNewKeyDescription(e.target.value)} - placeholder={t('common.description')} - className="mt-1" - /> -
-
- - - - -
-
- - {/* Show Created Key Dialog */} - setCreatedKey(null)}> - - - {t('common.apiKeyCreated')} - - {t('common.apiKeyCreatedMessage')} - - -
-
- -
- - -
-
-
- - - -
-
- - {/* Delete Confirmation Dialog */} - - - setDeleteKeyId(null)} - /> - setDeleteKeyId(null)} - > - - {t('common.confirmDelete')} - - {t('common.apiKeyDeleteConfirm')} - - - - setDeleteKeyId(null)}> - {t('common.cancel')} - - deleteKeyId && handleDeleteApiKey(deleteKeyId)} - > - {t('common.delete')} - - - - - - - ); -} diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 444268b4..c3d80b22 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -25,7 +25,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { LanguageSelector } from '@/components/ui/language-selector'; import { Badge } from '@/components/ui/badge'; import PasswordChangeDialog from '@/app/home/components/password-change-dialog/PasswordChangeDialog'; -import ApiKeyManagementDialog from '@/app/home/components/api-key-management-dialog/ApiKeyManagementDialog'; +import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog'; // TODO 侧边导航栏要加动画 export default function HomeSidebar({ @@ -236,7 +236,7 @@ export default function HomeSidebar({ } - name={t('common.apiKeys')} + name={t('common.apiIntegration')} /> - diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index f8935631..cf21c231 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -59,7 +59,9 @@ const enUS = { changePasswordSuccess: 'Password changed successfully', changePasswordFailed: 'Failed to change password, please check your current password', + apiIntegration: 'API Integration', apiKeys: 'API Keys', + manageApiIntegration: 'Manage API Integration', manageApiKeys: 'Manage API Keys', createApiKey: 'Create API Key', apiKeyName: 'API Key Name', @@ -74,6 +76,20 @@ const enUS = { noApiKeys: 'No API keys configured', apiKeyHint: 'API keys allow external systems to access LangBot Service APIs', + webhooks: 'Webhooks', + createWebhook: 'Create Webhook', + webhookName: 'Webhook Name', + webhookUrl: 'Webhook URL', + webhookDescription: 'Webhook Description', + webhookEnabled: 'Enabled', + webhookCreated: 'Webhook created successfully', + webhookDeleted: 'Webhook deleted successfully', + webhookDeleteConfirm: 'Are you sure you want to delete this webhook?', + webhookNameRequired: 'Webhook name is required', + webhookUrlRequired: 'Webhook URL is required', + noWebhooks: 'No webhooks configured', + webhookHint: + 'Webhooks allow LangBot to push person and group message events to external systems', actions: 'Actions', apiKeyCreatedMessage: 'Please copy this API key.', }, diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 197632d5..3be362c3 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -60,7 +60,9 @@ const jaJP = { changePasswordSuccess: 'パスワードの変更に成功しました', changePasswordFailed: 'パスワードの変更に失敗しました。現在のパスワードを確認してください', + apiIntegration: 'API統合', apiKeys: 'API キー', + manageApiIntegration: 'API統合の管理', manageApiKeys: 'API キーの管理', createApiKey: 'API キーを作成', apiKeyName: 'API キー名', @@ -75,6 +77,20 @@ const jaJP = { noApiKeys: 'API キーが設定されていません', apiKeyHint: 'API キーを使用すると、外部システムが LangBot Service API にアクセスできます', + webhooks: 'Webhooks', + createWebhook: 'Webhook を作成', + webhookName: 'Webhook 名', + webhookUrl: 'Webhook URL', + webhookDescription: 'Webhook の説明', + webhookEnabled: '有効', + webhookCreated: 'Webhook が正常に作成されました', + webhookDeleted: 'Webhook が正常に削除されました', + webhookDeleteConfirm: 'この Webhook を削除してもよろしいですか?', + webhookNameRequired: 'Webhook 名は必須です', + webhookUrlRequired: 'Webhook URL は必須です', + noWebhooks: 'Webhook が設定されていません', + webhookHint: + 'Webhook を使用すると、LangBot は個人メッセージとグループメッセージイベントを外部システムにプッシュできます', actions: 'アクション', apiKeyCreatedMessage: 'この API キーをコピーしてください。', }, diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index daa0cd0a..54cbb2eb 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -58,7 +58,9 @@ const zhHans = { passwordsDoNotMatch: '两次输入的密码不一致', changePasswordSuccess: '密码修改成功', changePasswordFailed: '密码修改失败,请检查当前密码是否正确', + apiIntegration: 'API 集成', apiKeys: 'API 密钥', + manageApiIntegration: '管理 API 集成', manageApiKeys: '管理 API 密钥', createApiKey: '创建 API 密钥', apiKeyName: 'API 密钥名称', @@ -72,6 +74,19 @@ const zhHans = { apiKeyCopied: 'API 密钥已复制到剪贴板', noApiKeys: '暂无 API 密钥', apiKeyHint: 'API 密钥允许外部系统访问 LangBot 的 Service API', + webhooks: 'Webhooks', + createWebhook: '创建 Webhook', + webhookName: 'Webhook 名称', + webhookUrl: 'Webhook URL', + webhookDescription: 'Webhook 描述', + webhookEnabled: '是否启用', + webhookCreated: 'Webhook 创建成功', + webhookDeleted: 'Webhook 删除成功', + webhookDeleteConfirm: '确定要删除此 Webhook 吗?', + webhookNameRequired: 'Webhook 名称不能为空', + webhookUrlRequired: 'Webhook URL 不能为空', + noWebhooks: '暂无 Webhook', + webhookHint: 'Webhook 允许 LangBot 将个人消息和群消息事件推送到外部系统', actions: '操作', apiKeyCreatedMessage: '请复制此 API 密钥。', }, diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 59b66a41..a0060e5f 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -58,7 +58,9 @@ const zhHant = { passwordsDoNotMatch: '兩次輸入的密碼不一致', changePasswordSuccess: '密碼修改成功', changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確', + apiIntegration: 'API 整合', apiKeys: 'API 金鑰', + manageApiIntegration: '管理 API 整合', manageApiKeys: '管理 API 金鑰', createApiKey: '建立 API 金鑰', apiKeyName: 'API 金鑰名稱', @@ -72,6 +74,19 @@ const zhHant = { apiKeyCopied: 'API 金鑰已複製到剪貼簿', noApiKeys: '暫無 API 金鑰', apiKeyHint: 'API 金鑰允許外部系統訪問 LangBot 的 Service API', + webhooks: 'Webhooks', + createWebhook: '建立 Webhook', + webhookName: 'Webhook 名稱', + webhookUrl: 'Webhook URL', + webhookDescription: 'Webhook 描述', + webhookEnabled: '是否啟用', + webhookCreated: 'Webhook 建立成功', + webhookDeleted: 'Webhook 刪除成功', + webhookDeleteConfirm: '確定要刪除此 Webhook 嗎?', + webhookNameRequired: 'Webhook 名稱不能為空', + webhookUrlRequired: 'Webhook URL 不能為空', + noWebhooks: '暫無 Webhook', + webhookHint: 'Webhook 允許 LangBot 將個人訊息和群組訊息事件推送到外部系統', actions: '操作', apiKeyCreatedMessage: '請複製此 API 金鑰。', },