feat: Add webhook push functionality for bot message events (#1768)

* Initial plan

* Backend: Add webhook persistence model, service, API endpoints and message push functionality

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Frontend: Rename API Keys to API Integration, add webhook management UI with tabs

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Fix frontend linting issues and formatting

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* chore: perf ui in api integration dialog

* perf: webhook data pack structure

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
Copilot
2025-11-10 22:41:25 +08:00
committed by GitHub
parent 32215e9a3f
commit 42421d171e
14 changed files with 1033 additions and 382 deletions

View File

@@ -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('/<int:webhook_id>', 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()

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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(),
)

View File

@@ -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,

View File

@@ -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}')

View File

@@ -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<ApiKey[]>([]);
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
const [loading, setLoading] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyDescription, setNewKeyDescription] = useState('');
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
const [deleteKeyId, setDeleteKeyId] = useState<number | null>(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<number | null>(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 (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
// 如果删除确认框是打开的,不允许关闭主对话框
if (!newOpen && (deleteKeyId || deleteWebhookId)) {
return;
}
onOpenChange(newOpen);
}}
>
<DialogContent className="sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
{t('common.apiKeys')}
</TabsTrigger>
<TabsTrigger
className="px-5 py-4 cursor-pointer"
value="webhooks"
>
{t('common.webhooks')}
</TabsTrigger>
</TabsList>
{/* API Keys Tab */}
<TabsContent value="apikeys" className="space-y-4">
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.apiKeyHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noApiKeys')}
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.apiKeyValue')}</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((key) => (
<TableRow key={key.id}>
<TableCell>
<div>
<div className="font-medium">{key.name}</div>
{key.description && (
<div className="text-sm text-muted-foreground">
{key.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(key.key)}
</code>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyKey(key.key)}
title={t('common.copyApiKey')}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(key.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* Webhooks Tab */}
<TabsContent value="webhooks" className="space-y-4">
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.webhookHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateWebhookDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createWebhook')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : webhooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noWebhooks')}
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.webhookUrl')}</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell>
<div>
<div className="font-medium">{webhook.name}</div>
{webhook.description && (
<div className="text-sm text-muted-foreground">
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded break-all">
{webhook.url}
</code>
</TableCell>
<TableCell>
<Switch
checked={webhook.enabled}
onCheckedChange={() =>
handleToggleWebhook(webhook)
}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteWebhookId(webhook.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create API Key Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.createApiKey')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">{t('common.name')}</label>
<Input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder={t('common.name')}
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">
{t('common.description')}
</label>
<Input
value={newKeyDescription}
onChange={(e) => setNewKeyDescription(e.target.value)}
placeholder={t('common.description')}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleCreateApiKey}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Show Created Key Dialog */}
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
<DialogDescription>
{t('common.apiKeyCreatedMessage')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t('common.apiKeyValue')}
</label>
<div className="flex gap-2 mt-1">
<Input value={createdKey?.key || ''} readOnly />
<Button
onClick={() => createdKey && handleCopyKey(createdKey.key)}
variant="outline"
size="icon"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setCreatedKey(null)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create Webhook Dialog */}
<Dialog
open={showCreateWebhookDialog}
onOpenChange={setShowCreateWebhookDialog}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.createWebhook')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">{t('common.name')}</label>
<Input
value={newWebhookName}
onChange={(e) => setNewWebhookName(e.target.value)}
placeholder={t('common.webhookName')}
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">
{t('common.webhookUrl')}
</label>
<Input
value={newWebhookUrl}
onChange={(e) => setNewWebhookUrl(e.target.value)}
placeholder="https://example.com/webhook"
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">
{t('common.description')}
</label>
<Input
value={newWebhookDescription}
onChange={(e) => setNewWebhookDescription(e.target.value)}
placeholder={t('common.description')}
className="mt-1"
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={newWebhookEnabled}
onCheckedChange={setNewWebhookEnabled}
/>
<label className="text-sm font-medium">
{t('common.webhookEnabled')}
</label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateWebhookDialog(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleCreateWebhook}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete API Key Confirmation Dialog */}
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
<DialogDescription>
{t('common.apiKeyCreatedMessage')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t('common.apiKeyValue')}
</label>
<div className="flex gap-2 mt-1">
<Input value={createdKey?.key || ''} readOnly />
<Button
onClick={() => createdKey && handleCopyKey(createdKey.key)}
variant="outline"
size="icon"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setCreatedKey(null)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteKeyId}>
<AlertDialogPortal>
<AlertDialogOverlay
className="z-[60]"
onClick={() => setDeleteKeyId(null)}
/>
<AlertDialogPrimitive.Content
className="fixed left-[50%] top-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg"
onEscapeKeyDown={() => setDeleteKeyId(null)}
>
<AlertDialogHeader>
<AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('common.apiKeyDeleteConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteKeyId(null)}>
{t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteKeyId && handleDeleteApiKey(deleteKeyId)}
>
{t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
</AlertDialog>
{/* Delete Webhook Confirmation Dialog */}
<AlertDialog open={!!deleteWebhookId}>
<AlertDialogPortal>
<AlertDialogOverlay
className="z-[60]"
onClick={() => setDeleteWebhookId(null)}
/>
<AlertDialogPrimitive.Content
className="fixed left-[50%] top-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg"
onEscapeKeyDown={() => setDeleteWebhookId(null)}
>
<AlertDialogHeader>
<AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('common.webhookDeleteConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteWebhookId(null)}>
{t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
deleteWebhookId && handleDeleteWebhook(deleteWebhookId)
}
>
{t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
</AlertDialog>
</>
);
}

View File

@@ -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<ApiKey[]>([]);
const [loading, setLoading] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyDescription, setNewKeyDescription] = useState('');
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
const [deleteKeyId, setDeleteKeyId] = useState<number | null>(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 (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
// 如果删除确认框是打开的,不允许关闭主对话框
if (!newOpen && deleteKeyId) {
return;
}
onOpenChange(newOpen);
}}
>
<DialogContent className="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>{t('common.manageApiKeys')}</DialogTitle>
<DialogDescription>
<span className="cursor-pointer flex items-center gap-1">
{t('common.apiKeyHint')}
<div
onClick={() => {
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"
>
<svg
className="w-[1rem] h-[1rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path>
</svg>
</div>
</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex justify-end">
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noApiKeys')}
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.apiKeyValue')}</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((key) => (
<TableRow key={key.id}>
<TableCell>
<div>
<div className="font-medium">{key.name}</div>
{key.description && (
<div className="text-sm text-muted-foreground">
{key.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(key.key)}
</code>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyKey(key.key)}
title={t('common.copyApiKey')}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(key.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create API Key Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.createApiKey')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">{t('common.name')}</label>
<Input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder={t('common.name')}
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">
{t('common.description')}
</label>
<Input
value={newKeyDescription}
onChange={(e) => setNewKeyDescription(e.target.value)}
placeholder={t('common.description')}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleCreateApiKey}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Show Created Key Dialog */}
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
<DialogDescription>
{t('common.apiKeyCreatedMessage')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t('common.apiKeyValue')}
</label>
<div className="flex gap-2 mt-1">
<Input value={createdKey?.key || ''} readOnly />
<Button
onClick={() => createdKey && handleCopyKey(createdKey.key)}
variant="outline"
size="icon"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setCreatedKey(null)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteKeyId}>
<AlertDialogPortal>
<AlertDialogOverlay
className="z-[60]"
onClick={() => setDeleteKeyId(null)}
/>
<AlertDialogPrimitive.Content
className="fixed left-[50%] top-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg"
onEscapeKeyDown={() => setDeleteKeyId(null)}
>
<AlertDialogHeader>
<AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('common.apiKeyDeleteConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteKeyId(null)}>
{t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteKeyId && handleDeleteApiKey(deleteKeyId)}
>
{t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
</AlertDialog>
</>
);
}

View File

@@ -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({
<path d="M10.7577 11.8281L18.6066 3.97919L20.0208 5.3934L18.6066 6.80761L21.0815 9.28249L19.6673 10.6967L17.1924 8.22183L15.7782 9.63604L17.8995 11.7574L16.4853 13.1716L14.364 11.0503L12.1719 13.2423C13.4581 15.1837 13.246 17.8251 11.5355 19.5355C9.58291 21.4882 6.41709 21.4882 4.46447 19.5355C2.51184 17.5829 2.51184 14.4171 4.46447 12.4645C6.17493 10.754 8.81633 10.5419 10.7577 11.8281ZM10.1213 18.1213C11.2929 16.9497 11.2929 15.0503 10.1213 13.8787C8.94975 12.7071 7.05025 12.7071 5.87868 13.8787C4.70711 15.0503 4.70711 16.9497 5.87868 18.1213C7.05025 19.2929 8.94975 19.2929 10.1213 18.1213Z"></path>
</svg>
}
name={t('common.apiKeys')}
name={t('common.apiIntegration')}
/>
<Popover
@@ -345,7 +345,7 @@ export default function HomeSidebar({
open={passwordChangeOpen}
onOpenChange={setPasswordChangeOpen}
/>
<ApiKeyManagementDialog
<ApiIntegrationDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
/>

View File

@@ -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.',
},

View File

@@ -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 キーをコピーしてください。',
},

View File

@@ -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 密钥。',
},

View File

@@ -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 金鑰。',
},