mirror of
https://github.com/langbot-app/LangBot.git
synced 2025-11-25 03:15:06 +08:00
feat: Add API key authentication system for external service access (#1757)
* Initial plan * feat: Add API key authentication system backend Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * feat: Add API key management UI in frontend sidebar Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * fix: Correct import paths in API controller groups Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * fix: Address code review feedback - add i18n and validation Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * refactor: Enable API key auth on existing endpoints instead of creating separate service API - Added USER_TOKEN_OR_API_KEY auth type that accepts both authentication methods - Removed separate /api/service/v1/models endpoints - Updated existing endpoints (models, bots, pipelines) to accept API keys - External services can now use API keys to access all existing LangBot APIs - Updated documentation to reflect unified API approach Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * docs: Add OpenAPI specification for API key authenticated endpoints Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * chore: rename openapi spec * perf: ui and i18n * fix: ui bug * chore: tidy docs * chore: fix linter errors --------- 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:
291
docs/API_KEY_AUTH.md
Normal file
291
docs/API_KEY_AUTH.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# API Key Authentication
|
||||
|
||||
LangBot now supports API key authentication for external systems to access its HTTP service API.
|
||||
|
||||
## Managing API Keys
|
||||
|
||||
API keys can be managed through the web interface:
|
||||
|
||||
1. Log in to the LangBot web interface
|
||||
2. Click the "API Keys" button at the bottom of the sidebar
|
||||
3. Create, view, copy, or delete API keys as needed
|
||||
|
||||
## Using API Keys
|
||||
|
||||
### Authentication Headers
|
||||
|
||||
Include your API key in the request header using one of these methods:
|
||||
|
||||
**Method 1: X-API-Key header (Recommended)**
|
||||
```
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
```
|
||||
|
||||
**Method 2: Authorization Bearer token**
|
||||
```
|
||||
Authorization: Bearer lbk_your_api_key_here
|
||||
```
|
||||
|
||||
## Available APIs
|
||||
|
||||
All existing LangBot APIs now support **both user token and API key authentication**. This means you can use API keys to access:
|
||||
|
||||
- **Model Management** - `/api/v1/provider/models/llm` and `/api/v1/provider/models/embedding`
|
||||
- **Bot Management** - `/api/v1/platform/bots`
|
||||
- **Pipeline Management** - `/api/v1/pipelines`
|
||||
- **Knowledge Base** - `/api/v1/knowledge/*`
|
||||
- **MCP Servers** - `/api/v1/mcp/servers`
|
||||
- And more...
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
Each endpoint accepts **either**:
|
||||
1. **User Token** (via `Authorization: Bearer <user_jwt_token>`) - for web UI and authenticated users
|
||||
2. **API Key** (via `X-API-Key` or `Authorization: Bearer <api_key>`) - for external services
|
||||
|
||||
## Example: Model Management
|
||||
|
||||
### List All LLM Models
|
||||
|
||||
```http
|
||||
GET /api/v1/provider/models/llm
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"models": [
|
||||
{
|
||||
"uuid": "model-uuid",
|
||||
"name": "GPT-4",
|
||||
"description": "OpenAI GPT-4 model",
|
||||
"requester": "openai-chat-completions",
|
||||
"requester_config": {...},
|
||||
"abilities": ["chat", "vision"],
|
||||
"created_at": "2024-01-01T00:00:00",
|
||||
"updated_at": "2024-01-01T00:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create a New LLM Model
|
||||
|
||||
```http
|
||||
POST /api/v1/provider/models/llm
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "My Custom Model",
|
||||
"description": "Description of the model",
|
||||
"requester": "openai-chat-completions",
|
||||
"requester_config": {
|
||||
"model": "gpt-4",
|
||||
"args": {}
|
||||
},
|
||||
"api_keys": [
|
||||
{
|
||||
"name": "default",
|
||||
"keys": ["sk-..."]
|
||||
}
|
||||
],
|
||||
"abilities": ["chat"],
|
||||
"extra_args": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Update an LLM Model
|
||||
|
||||
```http
|
||||
PUT /api/v1/provider/models/llm/{model_uuid}
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Updated Model Name",
|
||||
"description": "Updated description",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Delete an LLM Model
|
||||
|
||||
```http
|
||||
DELETE /api/v1/provider/models/llm/{model_uuid}
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
```
|
||||
|
||||
## Example: Bot Management
|
||||
|
||||
### List All Bots
|
||||
|
||||
```http
|
||||
GET /api/v1/platform/bots
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
```
|
||||
|
||||
### Create a New Bot
|
||||
|
||||
```http
|
||||
POST /api/v1/platform/bots
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "My Bot",
|
||||
"adapter": "telegram",
|
||||
"config": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Pipeline Management
|
||||
|
||||
### List All Pipelines
|
||||
|
||||
```http
|
||||
GET /api/v1/pipelines
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
```
|
||||
|
||||
### Create a New Pipeline
|
||||
|
||||
```http
|
||||
POST /api/v1/pipelines
|
||||
X-API-Key: lbk_your_api_key_here
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "My Pipeline",
|
||||
"config": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -1,
|
||||
"msg": "No valid authentication provided (user token or API key required)"
|
||||
}
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -1,
|
||||
"msg": "Invalid API key"
|
||||
}
|
||||
```
|
||||
|
||||
### 404 Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -1,
|
||||
"msg": "Resource not found"
|
||||
}
|
||||
```
|
||||
|
||||
### 500 Internal Server Error
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -2,
|
||||
"msg": "Error message details"
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Keep API keys secure**: Store them securely and never commit them to version control
|
||||
2. **Use HTTPS**: Always use HTTPS in production to encrypt API key transmission
|
||||
3. **Rotate keys regularly**: Create new API keys periodically and delete old ones
|
||||
4. **Use descriptive names**: Give your API keys meaningful names to track their usage
|
||||
5. **Delete unused keys**: Remove API keys that are no longer needed
|
||||
6. **Use X-API-Key header**: Prefer using the `X-API-Key` header for clarity
|
||||
|
||||
## Example: Python Client
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_KEY = "lbk_your_api_key_here"
|
||||
BASE_URL = "http://your-langbot-server:5300"
|
||||
|
||||
headers = {
|
||||
"X-API-Key": API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# List all models
|
||||
response = requests.get(f"{BASE_URL}/api/v1/provider/models/llm", headers=headers)
|
||||
models = response.json()["data"]["models"]
|
||||
|
||||
print(f"Found {len(models)} models")
|
||||
for model in models:
|
||||
print(f"- {model['name']}: {model['description']}")
|
||||
|
||||
# Create a new bot
|
||||
bot_data = {
|
||||
"name": "My Telegram Bot",
|
||||
"adapter": "telegram",
|
||||
"config": {
|
||||
"token": "your-telegram-token"
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/v1/platform/bots",
|
||||
headers=headers,
|
||||
json=bot_data
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
bot_uuid = response.json()["data"]["uuid"]
|
||||
print(f"Bot created with UUID: {bot_uuid}")
|
||||
```
|
||||
|
||||
## Example: cURL
|
||||
|
||||
```bash
|
||||
# List all models
|
||||
curl -X GET \
|
||||
-H "X-API-Key: lbk_your_api_key_here" \
|
||||
http://your-langbot-server:5300/api/v1/provider/models/llm
|
||||
|
||||
# Create a new pipeline
|
||||
curl -X POST \
|
||||
-H "X-API-Key: lbk_your_api_key_here" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "My Pipeline",
|
||||
"config": {...}
|
||||
}' \
|
||||
http://your-langbot-server:5300/api/v1/pipelines
|
||||
|
||||
# Get bot logs
|
||||
curl -X POST \
|
||||
-H "X-API-Key: lbk_your_api_key_here" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"from_index": -1,
|
||||
"max_count": 10
|
||||
}' \
|
||||
http://your-langbot-server:5300/api/v1/platform/bots/{bot_uuid}/logs
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The same endpoints work for both the web UI (with user tokens) and external services (with API keys)
|
||||
- No need to learn different API paths - use the existing API documentation with API key authentication
|
||||
- All endpoints that previously required user authentication now also accept API keys
|
||||
|
||||
1944
docs/service-api-openapi.json
Normal file
1944
docs/service-api-openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,8 @@ class AuthType(enum.Enum):
|
||||
|
||||
NONE = 'none'
|
||||
USER_TOKEN = 'user-token'
|
||||
API_KEY = 'api-key'
|
||||
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'
|
||||
|
||||
|
||||
class RouterGroup(abc.ABC):
|
||||
@@ -87,6 +89,63 @@ class RouterGroup(abc.ABC):
|
||||
except Exception as e:
|
||||
return self.http_status(401, -1, str(e))
|
||||
|
||||
elif auth_type == AuthType.API_KEY:
|
||||
# get API key from Authorization header or X-API-Key header
|
||||
api_key = quart.request.headers.get('X-API-Key', '')
|
||||
if not api_key:
|
||||
auth_header = quart.request.headers.get('Authorization', '')
|
||||
if auth_header.startswith('Bearer '):
|
||||
api_key = auth_header.replace('Bearer ', '')
|
||||
|
||||
if not api_key:
|
||||
return self.http_status(401, -1, 'No valid API key provided')
|
||||
|
||||
try:
|
||||
is_valid = await self.ap.apikey_service.verify_api_key(api_key)
|
||||
if not is_valid:
|
||||
return self.http_status(401, -1, 'Invalid API key')
|
||||
except Exception as e:
|
||||
return self.http_status(401, -1, str(e))
|
||||
|
||||
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:
|
||||
# Try API key first (check X-API-Key header)
|
||||
api_key = quart.request.headers.get('X-API-Key', '')
|
||||
|
||||
if api_key:
|
||||
# API key authentication
|
||||
try:
|
||||
is_valid = await self.ap.apikey_service.verify_api_key(api_key)
|
||||
if not is_valid:
|
||||
return self.http_status(401, -1, 'Invalid API key')
|
||||
except Exception as e:
|
||||
return self.http_status(401, -1, str(e))
|
||||
else:
|
||||
# Try user token authentication (Authorization header)
|
||||
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
|
||||
if not token:
|
||||
return self.http_status(401, -1, 'No valid authentication provided (user token or API key required)')
|
||||
|
||||
try:
|
||||
user_email = await self.ap.user_service.verify_jwt_token(token)
|
||||
|
||||
# check if this account exists
|
||||
user = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if not user:
|
||||
return self.http_status(401, -1, 'User not found')
|
||||
|
||||
# check if f accepts user_email parameter
|
||||
if 'user_email' in f.__code__.co_varnames:
|
||||
kwargs['user_email'] = user_email
|
||||
except Exception:
|
||||
# If user token fails, maybe it's an API key in Authorization header
|
||||
try:
|
||||
is_valid = await self.ap.apikey_service.verify_api_key(token)
|
||||
if not is_valid:
|
||||
return self.http_status(401, -1, 'Invalid authentication credentials')
|
||||
except Exception as e:
|
||||
return self.http_status(401, -1, str(e))
|
||||
|
||||
try:
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
|
||||
43
pkg/api/http/controller/groups/apikeys.py
Normal file
43
pkg/api/http/controller/groups/apikeys.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('apikeys', '/api/v1/apikeys')
|
||||
class ApiKeysRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
keys = await self.ap.apikey_service.get_api_keys()
|
||||
return self.success(data={'keys': keys})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name', '')
|
||||
description = json_data.get('description', '')
|
||||
|
||||
if not name:
|
||||
return self.http_status(400, -1, 'Name is required')
|
||||
|
||||
key = await self.ap.apikey_service.create_api_key(name, description)
|
||||
return self.success(data={'key': key})
|
||||
|
||||
@self.route('/<int:key_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||
async def _(key_id: int) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
key = await self.ap.apikey_service.get_api_key(key_id)
|
||||
if key is None:
|
||||
return self.http_status(404, -1, 'API key not found')
|
||||
return self.success(data={'key': key})
|
||||
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name')
|
||||
description = json_data.get('description')
|
||||
|
||||
await self.ap.apikey_service.update_api_key(key_id, name, description)
|
||||
return self.success()
|
||||
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.apikey_service.delete_api_key(key_id)
|
||||
return self.success()
|
||||
@@ -8,7 +8,7 @@ from ... import group
|
||||
@group.group_class('pipelines', '/api/v1/pipelines')
|
||||
class PipelinesRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
sort_by = quart.request.args.get('sort_by', 'created_at')
|
||||
@@ -23,11 +23,11 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={'uuid': pipeline_uuid})
|
||||
|
||||
@self.route('/_/metadata', methods=['GET'])
|
||||
@self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()})
|
||||
|
||||
@self.route('/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'])
|
||||
@self.route('/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(pipeline_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
|
||||
@@ -47,7 +47,7 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<pipeline_uuid>/extensions', methods=['GET', 'PUT'])
|
||||
@self.route('/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(pipeline_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
# Get current extensions and available plugins
|
||||
|
||||
@@ -6,7 +6,7 @@ from ... import group
|
||||
@group.group_class('bots', '/api/v1/platform/bots')
|
||||
class BotsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data={'bots': await self.ap.bot_service.get_bots()})
|
||||
@@ -15,7 +15,7 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
bot_uuid = await self.ap.bot_service.create_bot(json_data)
|
||||
return self.success(data={'uuid': bot_uuid})
|
||||
|
||||
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'])
|
||||
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
bot = await self.ap.bot_service.get_bot(bot_uuid)
|
||||
@@ -30,7 +30,7 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
await self.ap.bot_service.delete_bot(bot_uuid)
|
||||
return self.success()
|
||||
|
||||
@self.route('/<bot_uuid>/logs', methods=['POST'])
|
||||
@self.route('/<bot_uuid>/logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
from_index = json_data.get('from_index', -1)
|
||||
|
||||
@@ -6,7 +6,7 @@ from ... import group
|
||||
@group.group_class('models/llm', '/api/v1/provider/models/llm')
|
||||
class LLMModelsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()})
|
||||
@@ -17,7 +17,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'])
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(model_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
model = await self.ap.llm_model_service.get_llm_model(model_uuid)
|
||||
@@ -37,7 +37,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<model_uuid>/test', methods=['POST'])
|
||||
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(model_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
|
||||
@@ -49,7 +49,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
@group.group_class('models/embedding', '/api/v1/provider/models/embedding')
|
||||
class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()})
|
||||
@@ -60,7 +60,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={'uuid': model_uuid})
|
||||
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'])
|
||||
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(model_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
model = await self.ap.embedding_models_service.get_embedding_model(model_uuid)
|
||||
@@ -80,7 +80,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<model_uuid>/test', methods=['POST'])
|
||||
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(model_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
|
||||
|
||||
79
pkg/api/http/service/apikey.py
Normal file
79
pkg/api/http/service/apikey.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import apikey
|
||||
|
||||
|
||||
class ApiKeyService:
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_api_keys(self) -> list[dict]:
|
||||
"""Get all API keys"""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(apikey.ApiKey))
|
||||
|
||||
keys = result.all()
|
||||
return [self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) for key in keys]
|
||||
|
||||
async def create_api_key(self, name: str, description: str = '') -> dict:
|
||||
"""Create a new API key"""
|
||||
# Generate a secure random API key
|
||||
key = f'lbk_{secrets.token_urlsafe(32)}'
|
||||
|
||||
key_data = {'name': name, 'key': key, 'description': description}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(apikey.ApiKey).values(**key_data))
|
||||
|
||||
# Retrieve the created key
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
|
||||
)
|
||||
created_key = result.first()
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, created_key)
|
||||
|
||||
async def get_api_key(self, key_id: int) -> dict | None:
|
||||
"""Get a specific API key by ID"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id)
|
||||
)
|
||||
|
||||
key = result.first()
|
||||
|
||||
if key is None:
|
||||
return None
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key)
|
||||
|
||||
async def verify_api_key(self, key: str) -> bool:
|
||||
"""Verify if an API key is valid"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
|
||||
)
|
||||
|
||||
key_obj = result.first()
|
||||
return key_obj is not None
|
||||
|
||||
async def delete_api_key(self, key_id: int) -> None:
|
||||
"""Delete an API key"""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id)
|
||||
)
|
||||
|
||||
async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None:
|
||||
"""Update an API key's metadata (name, description)"""
|
||||
update_data = {}
|
||||
if name is not None:
|
||||
update_data['name'] = name
|
||||
if description is not None:
|
||||
update_data['description'] = description
|
||||
|
||||
if update_data:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data)
|
||||
)
|
||||
@@ -23,6 +23,7 @@ from ..api.http.service import pipeline as pipeline_service
|
||||
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 ..discover import engine as discover_engine
|
||||
from ..storage import mgr as storagemgr
|
||||
from ..utils import logcache
|
||||
@@ -122,6 +123,8 @@ class Application:
|
||||
|
||||
mcp_service: mcp_service.MCPService = None
|
||||
|
||||
apikey_service: apikey_service.ApiKeyService = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from ...api.http.service import pipeline as pipeline_service
|
||||
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 ...discover import engine as discover_engine
|
||||
from ...storage import mgr as storagemgr
|
||||
from ...utils import logcache
|
||||
@@ -130,5 +131,8 @@ class BuildAppStage(stage.BootingStage):
|
||||
mcp_service_inst = mcp_service.MCPService(ap)
|
||||
ap.mcp_service = mcp_service_inst
|
||||
|
||||
apikey_service_inst = apikey_service.ApiKeyService(ap)
|
||||
ap.apikey_service = apikey_service_inst
|
||||
|
||||
ctrl = controller.Controller(ap)
|
||||
ap.ctrl = ctrl
|
||||
|
||||
21
pkg/entity/persistence/apikey.py
Normal file
21
pkg/entity/persistence/apikey.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""API Key for external service authentication"""
|
||||
|
||||
__tablename__ = 'api_keys'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True)
|
||||
description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='')
|
||||
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(),
|
||||
)
|
||||
@@ -0,0 +1,353 @@
|
||||
'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';
|
||||
|
||||
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>{t('common.apiKeyHint')}</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +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';
|
||||
|
||||
// TODO 侧边导航栏要加动画
|
||||
export default function HomeSidebar({
|
||||
@@ -45,6 +46,7 @@ export default function HomeSidebar({
|
||||
const { t } = useTranslation();
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [passwordChangeOpen, setPasswordChangeOpen] = useState(false);
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
const [languageSelectorOpen, setLanguageSelectorOpen] = useState(false);
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
|
||||
@@ -220,6 +222,23 @@ export default function HomeSidebar({
|
||||
name={t('common.helpDocs')}
|
||||
/>
|
||||
|
||||
<SidebarChild
|
||||
onClick={() => {
|
||||
setApiKeyDialogOpen(true);
|
||||
}}
|
||||
isSelected={false}
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<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')}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
open={popoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -326,6 +345,10 @@ export default function HomeSidebar({
|
||||
open={passwordChangeOpen}
|
||||
onOpenChange={setPasswordChangeOpen}
|
||||
/>
|
||||
<ApiKeyManagementDialog
|
||||
open={apiKeyDialogOpen}
|
||||
onOpenChange={setApiKeyDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,23 @@ const enUS = {
|
||||
changePasswordSuccess: 'Password changed successfully',
|
||||
changePasswordFailed:
|
||||
'Failed to change password, please check your current password',
|
||||
apiKeys: 'API Keys',
|
||||
manageApiKeys: 'Manage API Keys',
|
||||
createApiKey: 'Create API Key',
|
||||
apiKeyName: 'API Key Name',
|
||||
apiKeyDescription: 'API Key Description',
|
||||
apiKeyValue: 'API Key Value',
|
||||
apiKeyCreated: 'API key created successfully',
|
||||
apiKeyDeleted: 'API key deleted successfully',
|
||||
apiKeyDeleteConfirm: 'Are you sure you want to delete this API key?',
|
||||
apiKeyNameRequired: 'API key name is required',
|
||||
copyApiKey: 'Copy API Key',
|
||||
apiKeyCopied: 'API key copied to clipboard',
|
||||
noApiKeys: 'No API keys configured',
|
||||
apiKeyHint:
|
||||
'API keys allow external systems to access LangBot Service APIs',
|
||||
actions: 'Actions',
|
||||
apiKeyCreatedMessage: 'Please copy this API key.',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Page not found',
|
||||
|
||||
@@ -59,6 +59,23 @@ const jaJP = {
|
||||
changePasswordSuccess: 'パスワードの変更に成功しました',
|
||||
changePasswordFailed:
|
||||
'パスワードの変更に失敗しました。現在のパスワードを確認してください',
|
||||
apiKeys: 'API キー',
|
||||
manageApiKeys: 'API キーの管理',
|
||||
createApiKey: 'API キーを作成',
|
||||
apiKeyName: 'API キー名',
|
||||
apiKeyDescription: 'API キーの説明',
|
||||
apiKeyValue: 'API キー値',
|
||||
apiKeyCreated: 'API キーの作成に成功しました',
|
||||
apiKeyDeleted: 'API キーの削除に成功しました',
|
||||
apiKeyDeleteConfirm: 'この API キーを削除してもよろしいですか?',
|
||||
apiKeyNameRequired: 'API キー名は必須です',
|
||||
copyApiKey: 'API キーをコピー',
|
||||
apiKeyCopied: 'API キーをクリップボードにコピーしました',
|
||||
noApiKeys: 'API キーが設定されていません',
|
||||
apiKeyHint:
|
||||
'API キーを使用すると、外部システムが LangBot Service API にアクセスできます',
|
||||
actions: 'アクション',
|
||||
apiKeyCreatedMessage: 'この API キーをコピーしてください。',
|
||||
},
|
||||
notFound: {
|
||||
title: 'ページが見つかりません',
|
||||
|
||||
@@ -57,6 +57,22 @@ const zhHans = {
|
||||
passwordsDoNotMatch: '两次输入的密码不一致',
|
||||
changePasswordSuccess: '密码修改成功',
|
||||
changePasswordFailed: '密码修改失败,请检查当前密码是否正确',
|
||||
apiKeys: 'API 密钥',
|
||||
manageApiKeys: '管理 API 密钥',
|
||||
createApiKey: '创建 API 密钥',
|
||||
apiKeyName: 'API 密钥名称',
|
||||
apiKeyDescription: 'API 密钥描述',
|
||||
apiKeyValue: 'API 密钥值',
|
||||
apiKeyCreated: 'API 密钥创建成功',
|
||||
apiKeyDeleted: 'API 密钥删除成功',
|
||||
apiKeyDeleteConfirm: '确定要删除此 API 密钥吗?',
|
||||
apiKeyNameRequired: 'API 密钥名称不能为空',
|
||||
copyApiKey: '复制 API 密钥',
|
||||
apiKeyCopied: 'API 密钥已复制到剪贴板',
|
||||
noApiKeys: '暂无 API 密钥',
|
||||
apiKeyHint: 'API 密钥允许外部系统访问 LangBot 的 Service API',
|
||||
actions: '操作',
|
||||
apiKeyCreatedMessage: '请复制此 API 密钥。',
|
||||
},
|
||||
notFound: {
|
||||
title: '页面不存在',
|
||||
|
||||
@@ -57,6 +57,22 @@ const zhHant = {
|
||||
passwordsDoNotMatch: '兩次輸入的密碼不一致',
|
||||
changePasswordSuccess: '密碼修改成功',
|
||||
changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確',
|
||||
apiKeys: 'API 金鑰',
|
||||
manageApiKeys: '管理 API 金鑰',
|
||||
createApiKey: '建立 API 金鑰',
|
||||
apiKeyName: 'API 金鑰名稱',
|
||||
apiKeyDescription: 'API 金鑰描述',
|
||||
apiKeyValue: 'API 金鑰值',
|
||||
apiKeyCreated: 'API 金鑰建立成功',
|
||||
apiKeyDeleted: 'API 金鑰刪除成功',
|
||||
apiKeyDeleteConfirm: '確定要刪除此 API 金鑰嗎?',
|
||||
apiKeyNameRequired: 'API 金鑰名稱不能為空',
|
||||
copyApiKey: '複製 API 金鑰',
|
||||
apiKeyCopied: 'API 金鑰已複製到剪貼簿',
|
||||
noApiKeys: '暫無 API 金鑰',
|
||||
apiKeyHint: 'API 金鑰允許外部系統訪問 LangBot 的 Service API',
|
||||
actions: '操作',
|
||||
apiKeyCreatedMessage: '請複製此 API 金鑰。',
|
||||
},
|
||||
notFound: {
|
||||
title: '頁面不存在',
|
||||
|
||||
Reference in New Issue
Block a user