diff --git a/AGENTS.md b/AGENTS.md index 4fb546042..bc016a26e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,11 +87,22 @@ Config keys to verify in `data/config.yaml` / `src/langbot/templates/config.yaml - When changing Service API routes or capabilities used by `lbctl`, check compatibility with the separate `langbot-cli` repository. - New schema changes use Alembic under `src/langbot/pkg/persistence/alembic/versions/`. LangBot 4.x does not support upgrading 3.x databases. - New platform behavior belongs in platform adapters only for platform translation; pipeline/business logic belongs in `pkg/pipeline/` or services. -- User-facing strings must support i18n (`en_US`, `zh_Hans`; include `ja_JP` where the repo already does). +- User-facing strings must support i18n; follow the backend language rules below for server-generated content. - Code comments and docstrings must be English. - Keep compatibility and security in mind; LangBot is used in both self-hosted/community and toB deployments. - Commit message format: `(): `. +## Backend Message Language + +- LangBot-owned business errors, runtime notices, and default messages sent to users must use the owning Workspace's message language. Keep this independent of each member's WebUI language; an unset Workspace language defaults to `en_US`. +- Reuse `src/langbot/pkg/i18n/` and the existing `message_language` entry in `WorkspaceMetadata`. Use `t()` with stable English templates and named parameters; add matching keys and placeholders to all eight catalogs: `en_US`, `zh_Hans`, `zh_Hant`, `ja_JP`, `es_ES`, `ru_RU`, `th_TH`, and `vi_VN`. Avoid new translation frameworks or duplicate language settings. +- Resolve language from a trusted Workspace or bot execution context, and isolate it across concurrent requests, tasks, and adapter callbacks. Browser language hints may supply account-only or unauthenticated response language, but must not override a resolved Workspace or authorize tenant selection. Reading/changing the setting requires `workspace.view` / `workspace.update`. +- Pipeline notices should be configurable in Pipeline output settings through `pipeline_notice()`. Blank overrides inherit the Workspace translation. Preserve user-authored text literally except for documented placeholders, and retain existing visibility switches. Do not rewrite historical messages or custom text when the language changes. +- China-only adapters and configuration/field validation diagnostics do not require localization. An adapter also serving international users is not exempt merely because it supports a Chinese platform. +- Preserve raw provider/plugin diagnostics, tracebacks, protocol values, and machine-readable error/status codes. Translate LangBot's own category or explanation around them; do not translate arbitrary errors with substring replacement. Plugin-authored messages remain the plugin's localization responsibility. +- Keep changes small and maintainable. Reuse existing persistence and response boundaries; delete obsolete fields only after verifying they have no remaining readers, including compatibility paths. +- When changing language behavior, verify catalog/placeholder parity, Workspace isolation and permissions, and Pipeline inheritance/custom overrides. Update affected configuration snapshots and API/MCP documentation. Implementation details and focused test locations: [Backend message language](docs/architecture/backend-i18n.md). + ## Runtime Pitfalls - Local stdio Plugin Runtime disconnects do not auto-reconnect; restart LangBot if that path breaks. diff --git a/docs/architecture/backend-i18n.md b/docs/architecture/backend-i18n.md new file mode 100644 index 000000000..bf5a4ba35 --- /dev/null +++ b/docs/architecture/backend-i18n.md @@ -0,0 +1,68 @@ +# Backend message language + +Workspace settings expose one **Workspace language**, independent of each member's +WebUI language. It selects LangBot-owned API business errors, model error prefixes, +Pipeline default notices and international platform replies. Supported locales are +`en_US`, `zh_Hans`, `zh_Hant`, `ja_JP`, `es_ES`, `ru_RU`, `th_TH` and `vi_VN`. +Workspaces without a setting default to English. Existing custom messages and +historical records are not rewritten. + +## Storage and boundaries + +`pkg/i18n/workspace.py` stores `message_language` in existing `WorkspaceMetadata`; +no database migration or instance-wide configuration is needed. Reads and writes +use tenant-scoped units of work. GET/PUT `/api/v1/workspaces/current/language` +require `workspace.view` / `workspace.update`; matching MCP tools are +`get_workspace_message_language` and `set_workspace_message_language`. + +An authenticated Workspace takes precedence over the browser's +`X-LangBot-Language` header. The header supplies presentation language only for +account-only or unauthenticated responses and never selects a tenant. HTTP, MCP, +Pipeline and adapter boundaries set a ContextVar so concurrent Workspaces do not +share a language. Adapter SDK thread callbacks use their owning bot's cached +Workspace language, never the calling request's language. + +The per-application cache holds at most 256 Workspaces for 30 seconds. Local +writes update the cache immediately; other processes refresh at the next async +message/request boundary after expiry. A running operation keeps its selected +language. Synchronous SDK callbacks use the most recently loaded value. + +## Pipeline overrides + +The output `misc` section accepts these optional string settings: + +| Setting | Default purpose | Placeholder | +| --- | --- | --- | +| `failure-hint` | Request/runner execution failure | none | +| `rate-limit-hint` | Rate limit reached | none | +| `content-filter-hint` | Message rejected by content filter | none | +| `content-filter-error-hint` | Content check failed | none | +| `tool-call-hint` | Tool call notification | `{tools}` | +| `runner-not-found-hint` | Configured runner missing | `{runner}` | +| `runner-not-authorized-hint` | Runner authorization failure | none | +| `runner-unavailable-hint` | Runner temporarily unavailable | none | + +Blank values inherit the Workspace translation. Custom text is emitted literally +with only the documented placeholder replaced. The previous shipped +`failure-hint` value `Request failed.` also inherits language; this avoids a data +migration. Existing output switches (including tool-call visibility and exception +handling) retain their behavior. + +## Adding messages + +Use `pkg.i18n.t()` with a stable English template and named parameters. Add the +same key and placeholders to all eight JSON catalogs. The catalog parity test +checks completeness and formatting. `pipeline_notice()` handles configurable +notices. Exact aliases at the API response boundary cover legacy business wording; +do not add substring replacement of arbitrary error text. + +China-only adapters, field validation diagnostics, raw provider/plugin errors, +tracebacks, protocol values and machine-readable status codes are outside this +translation layer. Their values remain useful for diagnostics and interoperability. +Model errors translate LangBot's category/prefix while retaining upstream detail. +Plugin-authored content must use the plugin's own localization. System prompts and +user-authored content are not translated. + +Locale JSON files are included in Python package data. Focused coverage lives in +`tests/unit_tests/test_backend_i18n.py`, the Workspace HTTP integration tests and +`web/tests/e2e/workspace-language.spec.ts`. diff --git a/pyproject.toml b/pyproject.toml index f4e887645..3da5b3e6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,7 +129,7 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools] -package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "pkg/platform/adapters/**", "web/dist/**", "pkg/persistence/alembic/**"] } +package-data = { "langbot" = ["templates/**", "pkg/i18n/locales/*.json", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "pkg/platform/adapters/**", "web/dist/**", "pkg/persistence/alembic/**"] } [dependency-groups] dev = [ diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index edea7b762..c0c8cedbe 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -212,6 +212,18 @@ Monitoring record filters accept `mode` (`all`, `real`, `debug`) and execution, not the individual model/tool call outcome. Calls without a recorded execution link are excluded when an execution filter is active. +### Workspace message language + +`get_workspace_message_language` reads the current Workspace's backend language +(`workspace.view`). `set_workspace_message_language(language)` requires +`workspace.update`; supported values are `en_US`, `zh_Hans`, `zh_Hant`, `ja_JP`, +`es_ES`, `ru_RU`, `th_TH`, `vi_VN`. HTTP equivalents are GET/PUT +`/api/v1/workspaces/current/language` (PUT body: `{"language":"ja_JP"}`). +The API key's Workspace remains authoritative. The setting controls new backend +messages and default bot notices, independently of personal UI language. +Pipeline `output.misc` hint fields override defaults; empty values inherit this +setting. Existing custom text and historical logs are not rewritten. + ### Workspace default LLM model `get_starred_model` reads the Workspace-wide favorite. `set_starred_model` diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index bb9fcc048..a290c25c1 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -17,6 +17,8 @@ from ....workspace.errors import WorkspaceNotFoundError from ....cloud.entitlements import EntitlementUnavailableError from ....core.errors import TaskCapacityError from ....provider.modelmgr.codex_errors import CodexProviderError +from ....i18n import DEFAULT_LANGUAGE, language, language_scope, t, error_text +from ....i18n.workspace import get_workspace_language from ..authz import ( AuthenticationDeniedError, AuthorizationError, @@ -137,7 +139,7 @@ class RouterGroup(abc.ABC): # receive RequestContext or enforce Workspace permissions. self._inject_handler_context(f, kwargs, user_email, None, account) except Exception as e: - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) elif auth_type == AuthType.USER_TOKEN: # get token from Authorization header @@ -160,7 +162,7 @@ class RouterGroup(abc.ABC): require_permission(request_context, permission) self._inject_handler_context(f, kwargs, user_email, request_context) except Exception as e: - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) elif auth_type == AuthType.API_KEY: # get API key from Authorization header or X-API-Key header @@ -179,7 +181,7 @@ class RouterGroup(abc.ABC): require_permission(request_context, permission) self._inject_handler_context(f, kwargs, None, request_context) except Exception as e: - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) elif auth_type == AuthType.USER_TOKEN_OR_API_KEY: token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') @@ -193,7 +195,7 @@ class RouterGroup(abc.ABC): require_permission(request_context, permission) self._inject_handler_context(f, kwargs, None, request_context) except Exception as e: - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) # Try API key first (check X-API-Key header) elif api_key := quart.request.headers.get('X-API-Key', ''): # API key authentication @@ -203,7 +205,7 @@ class RouterGroup(abc.ABC): require_permission(request_context, permission) self._inject_handler_context(f, kwargs, None, request_context) except Exception as e: - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) else: # Try user token authentication (Authorization header) if not token: @@ -223,7 +225,7 @@ class RouterGroup(abc.ABC): # Authentication succeeded and authorization was # evaluated. Do not reinterpret a denied user token # as an API key, which would mask the stable 403/404. - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) except Exception: # If user token fails, maybe it's an API key in Authorization header try: @@ -232,10 +234,12 @@ class RouterGroup(abc.ABC): require_permission(request_context, permission) self._inject_handler_context(f, kwargs, None, request_context) except Exception as e: - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) observed_at = time.monotonic() try: + if request_context is not None: + language.set(await get_workspace_language(self.ap, request_context.workspace_uuid)) if request_context is not None: with bounded_executor.blocking_work_scope(request_context.workspace_uuid): persistence_mgr = getattr( @@ -319,7 +323,11 @@ class RouterGroup(abc.ABC): ) return self.internal_error_response(request_id) - new_f = handler_error + async def new_f(*args, **kwargs): + # Reset even on errors; child tasks inherit this request's locale. + with language_scope(quart.request.headers.get('X-LangBot-Language', DEFAULT_LANGUAGE)): + return await handler_error(*args, **kwargs) + # Quart/Flask requires a unique endpoint name even when the same URL # intentionally has separate handlers for different HTTP methods. # Include the method set so CRUD routes can declare distinct @@ -687,6 +695,14 @@ class RouterGroup(abc.ABC): 'Invalid authentication credentials', ) + async def _localized_auth_error_response(self, error: Exception): + # A resolved context is trusted even when its permissions deny this + # operation. Never look up a language using the unverified selector. + context = getattr(quart.g, 'request_context', None) + if context is not None: + language.set(await get_workspace_language(self.ap, context.workspace_uuid)) + return self._auth_error_response(error) + def request_id(self) -> str: """Return one stable request ID for authentication, logs, and errors.""" @@ -707,7 +723,7 @@ class RouterGroup(abc.ABC): response = quart.jsonify( { 'code': 'internal_error', - 'msg': 'Internal server error', + 'msg': t('Internal server error'), 'request_id': resolved_request_id, } ) @@ -729,7 +745,7 @@ class RouterGroup(abc.ABC): return quart.jsonify( { 'code': code, - 'msg': msg, + 'msg': error_text(code, msg), } ) diff --git a/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py b/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py index bf3fc03bd..700eab509 100644 --- a/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py +++ b/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py @@ -2,6 +2,8 @@ from __future__ import annotations +from .....i18n import t + import asyncio import contextlib import json @@ -45,20 +47,20 @@ def debug_stream_response(service, context, agent_uuid: str, payload: dict) -> q if isinstance(exc, RunnerExecutionError): code, message = exc.error_code or 'runner_execution_failed', exc.message elif isinstance(exc, RunnerNotFoundError): - code, message = 'runner_not_found', 'The configured Agent runner is unavailable' + code, message = 'runner_not_found', t('Agent runner temporarily unavailable. Please try again.') elif isinstance(exc, RunnerNotAuthorizedError): - code, message = 'runner_not_authorized', 'The configured Agent runner is not authorized' + code, message = 'runner_not_authorized', t('Agent runner not authorized for this pipeline.') elif isinstance(exc, RunnerProtocolError): - code, message = 'runner_protocol_error', 'The Agent runner returned an invalid response' + code, message = 'runner_protocol_error', t('The Agent runner returned an invalid response.') elif isinstance(exc, ValueError): code, message = 'invalid_request', str(exc) elif isinstance(exc, RunnerError): - code, message = 'runner_error', 'The Agent runner could not complete this test' + code, message = 'runner_error', t('Agent runner execution failed.') else: logger = getattr(application, 'logger', None) if logger is not None: logger.exception('Agent debug stream execution failed') - code, message = 'runner_error', 'The Agent debug execution failed' + code, message = 'runner_error', t('Agent runner execution failed.') await queue.put({'kind': 'error', 'code': code, 'msg': message}) task = asyncio.create_task(execute()) diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py index 88deea8e6..9f32a63d3 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py @@ -21,6 +21,8 @@ import httpx import quart from ... import group +from ......i18n import language, t +from ......i18n.workspace import get_workspace_language from ......utils import httpclient, paths from ......platform.sources.websocket_manager import WebSocketScope, is_valid_session_id, ws_connection_manager from .websocket_chat import create_scoped_duplex_tasks, wait_for_duplex_tasks @@ -73,6 +75,7 @@ class EmbedRouterGroup(group.RouterGroup): bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid) pipeline_uuid = bot.get_pipeline_target_for_event_type('message.received') if bot is not None else None if bot is not None and bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.enable and pipeline_uuid: + language.set(await get_workspace_language(self.ap, bot.execution_context.workspace_uuid)) return bot, pipeline_uuid return None, None @@ -284,7 +287,7 @@ class EmbedRouterGroup(group.RouterGroup): return self.http_status(404, -1, 'WebSocket adapter not found') websocket_adapter.reset_session(pipeline_uuid, session_type, session_id) - return self.success(data={'message': 'Session reset successfully'}) + return self.success(data={'message': t('Session reset successfully')}) except Exception as e: logger.error(f'Failed to reset embed session: {e}', exc_info=True) @@ -338,7 +341,9 @@ class EmbedRouterGroup(group.RouterGroup): runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid) if runtime_bot is None: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'})) + await quart.websocket.send( + json.dumps({'type': 'error', 'message': t('Bot not found or not available')}) + ) return session_type = quart.websocket.args.get('session_type', 'person') @@ -357,13 +362,15 @@ class EmbedRouterGroup(group.RouterGroup): await self._authenticate_websocket(runtime_bot) await self._assert_execution_active(runtime_bot) except Exception: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'})) + await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Unauthorized')})) return try: proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context) websocket_adapter = proxy_bot.adapter if not websocket_adapter: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'})) + await quart.websocket.send( + json.dumps({'type': 'error', 'message': t('WebSocket adapter not found')}) + ) return connection = await ws_connection_manager.add_connection( @@ -428,7 +435,7 @@ class EmbedRouterGroup(group.RouterGroup): except Exception as e: logger.error(f'Embed WebSocket connection error: {e}', exc_info=True) try: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'})) + await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Internal server error')})) except Exception: pass @@ -451,7 +458,7 @@ class EmbedRouterGroup(group.RouterGroup): try: current_bot = await self._resolve_connected_bot(owner_bot, pipeline_uuid) except Exception: - await connection.send_queue.put({'type': 'error', 'message': 'Bot is unavailable'}) + await connection.send_queue.put({'type': 'error', 'message': t('Bot is unavailable')}) break await websocket_adapter.handle_websocket_message(connection, data, owner_bot=current_bot) elif message_type == 'disconnect': @@ -460,7 +467,7 @@ class EmbedRouterGroup(group.RouterGroup): pass except json.JSONDecodeError: - await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'}) + await connection.send_queue.put({'type': 'error', 'message': t('Invalid JSON format')}) except Exception as e: logger.error(f'Embed receive error: {e}', exc_info=True) diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py index 16406de90..649293e10 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py @@ -2,6 +2,9 @@ from __future__ import annotations +from ......i18n import language, t +from ......i18n.workspace import get_workspace_language + import asyncio import datetime import json @@ -217,8 +220,9 @@ class WebSocketChatRouterGroup(group.RouterGroup): try: request_context, token = await self._authenticate_websocket() except Exception: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'})) + await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Unauthorized')})) return + language.set(await get_workspace_language(self.ap, request_context.workspace_uuid)) session_type = quart.websocket.args.get('session_type', 'person') if session_type not in ['person', 'group']: await quart.websocket.send( @@ -229,7 +233,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): try: websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid) if websocket_adapter is None: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Pipeline not found'})) + await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Pipeline not found')})) return connection = await ws_connection_manager.add_connection( @@ -294,7 +298,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): except Exception: logger.error('Dashboard WebSocket connection error', exc_info=True) try: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'})) + await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Internal server error')})) except Exception: pass @@ -419,7 +423,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): try: request_context = await self._revalidate_websocket_authorization(request_context, token) except Exception: - await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'}) + await connection.send_queue.put({'type': 'error', 'message': t('Unauthorized')}) break await websocket_adapter.handle_websocket_message(connection, data) elif message_type == 'disconnect': @@ -427,7 +431,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): else: logger.warning(f'Unknown WebSocket message type: {message_type}') except json.JSONDecodeError: - await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'}) + await connection.send_queue.put({'type': 'error', 'message': t('Invalid JSON format')}) except Exception: logger.error('Dashboard WebSocket receive error', exc_info=True) diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py index 4715ceb95..759e5aa8a 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -1,3 +1,5 @@ +from langbot.pkg.i18n import t, DEFAULT_LANGUAGE +from langbot.pkg.i18n.workspace import get_workspace_language import asyncio import dataclasses import mimetypes @@ -213,16 +215,20 @@ class AdaptersRouterGroup(group.RouterGroup): @self.route('/slack/setup/callback', methods=['GET'], auth_type=group.AuthType.NONE) async def slack_callback(): + session = slack_setup.sessions.get(quart.request.args.get('state', '')) + locale = DEFAULT_LANGUAGE + if session is not None: + locale = await get_workspace_language(self.ap, session['context'].workspace_uuid) try: await slack_setup.finish( quart.request.args.get('state', ''), quart.request.args.get('code', ''), quart.request.args.get('error'), ) - message = 'Slack authorization completed. Return to LangBot to finish setup. / Slack 授权完成,请返回 LangBot 完成配置。' + message = t('Slack authorization completed. Return to LangBot to finish setup.', locale=locale) status = 200 except SlackSetupError: - message = 'Slack authorization failed or expired. Return to LangBot for details. / Slack 授权失败或已过期,请返回 LangBot 查看详情。' + message = t('Slack authorization failed or expired. Return to LangBot for details.', locale=locale) status = 400 return quart.Response( message, diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index eb8b09592..28024ca5d 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -1,5 +1,7 @@ from __future__ import annotations +from .....i18n import t + import asyncio import base64 import collections.abc @@ -305,7 +307,11 @@ class PluginsRouterGroup(group.RouterGroup): mcp_servers = await self.ap.mcp_service.get_mcp_servers(request_context) total_extensions = len(plugins) + len(mcp_servers) if total_extensions >= max_extensions: - return self.http_status(400, -1, f'Maximum number of extensions ({max_extensions}) reached') + return self.http_status( + 400, + -1, + t('Maximum number of {resource} ({limit}) reached', resource=t('extensions'), limit=max_extensions), + ) return None @staticmethod diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index 39dc9c577..020d2f7fd 100644 --- a/src/langbot/pkg/api/http/controller/groups/workspaces.py +++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py @@ -14,6 +14,8 @@ from .....workspace.errors import WorkspaceNotFoundError from .....workspace.invitation_delivery import InvitationDeliveryService from .....operation_trace import service as settings_service from .. import group +from .....i18n import SUPPORTED_LANGUAGES +from .....i18n.workspace import get_workspace_language, set_workspace_language def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]: @@ -74,6 +76,34 @@ class WorkspacesRouterGroup(group.RouterGroup): return await operation() async def initialize(self) -> None: + @self.route( + '/current/language', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.WORKSPACE_VIEW, + ) + async def get_language(request_context: RequestContext): + return self.success( + data={ + 'language': await get_workspace_language(self.ap, request_context.workspace_uuid, refresh=True), + 'supported_languages': list(SUPPORTED_LANGUAGES), + } + ) + + @self.route( + '/current/language', + methods=['PUT'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.WORKSPACE_UPDATE, + ) + async def update_language(request_context: RequestContext): + body = await quart.request.get_json() + locale = body.get('language') if isinstance(body, dict) else None + if locale not in SUPPORTED_LANGUAGES: + return self.http_status(400, 'invalid_language', 'Unsupported Workspace language') + locale = await set_workspace_language(self.ap, request_context.workspace_uuid, locale) + return self.success(data={'language': locale}) + @self.route('/bootstrap', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN) async def _(account) -> typing.Any: """List the active Workspaces available to an authenticated Account. diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py index 394c3b3dd..c0f0b34bf 100644 --- a/src/langbot/pkg/api/http/service/agent.py +++ b/src/langbot/pkg/api/http/service/agent.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import t + from contextlib import aclosing import datetime @@ -432,7 +434,7 @@ class AgentService: pipeline_uuid = await self.ap.pipeline_service.create_pipeline( context, { - 'name': agent_data.get('name') or 'New Pipeline', + 'name': agent_data.get('name') or t('New Pipeline'), 'description': agent_data.get('description') or '', 'emoji': agent_data.get('emoji') or '⚙️', 'config': {}, @@ -461,7 +463,7 @@ class AgentService: values = { 'workspace_uuid': workspace_uuid, 'uuid': new_uuid, - 'name': agent_data.get('name') or 'New Agent', + 'name': agent_data.get('name') or t('New Agent'), 'description': agent_data.get('description') or '', 'emoji': agent_data.get('emoji') or ('🧩' if kind == AGENT_KIND_EVENT_PROCESSOR else '🤖'), 'kind': kind, diff --git a/src/langbot/pkg/api/http/service/knowledge.py b/src/langbot/pkg/api/http/service/knowledge.py index 5897aa578..e85d16fd8 100644 --- a/src/langbot/pkg/api/http/service/knowledge.py +++ b/src/langbot/pkg/api/http/service/knowledge.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import t + import sqlalchemy from ....api.http.authz import WorkspaceRequiredError @@ -63,7 +65,13 @@ class KnowledgeService: if max_knowledge_bases >= 0: knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context) if len(knowledge_bases) >= max_knowledge_bases: - raise ValueError(f'Maximum number of knowledge bases ({max_knowledge_bases}) reached') + raise ValueError( + t( + 'Maximum number of {resource} ({limit}) reached', + resource=t('knowledge bases'), + limit=max_knowledge_bases, + ) + ) knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id') if not knowledge_engine_plugin_id: diff --git a/src/langbot/pkg/api/http/service/mcp.py b/src/langbot/pkg/api/http/service/mcp.py index 89a78f79a..3e5eb7339 100644 --- a/src/langbot/pkg/api/http/service/mcp.py +++ b/src/langbot/pkg/api/http/service/mcp.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import t + import copy import re import uuid @@ -228,7 +230,9 @@ class MCPService: .where(persistence_plugin.PluginSetting.workspace_uuid == workspace_uuid) ) if (mcp_count_result.scalar() or 0) + (plugin_count_result.scalar() or 0) >= max_extensions: - raise ValueError(f'Maximum number of extensions ({max_extensions}) reached') + raise ValueError( + t('Maximum number of {resource} ({limit}) reached', resource=t('extensions'), limit=max_extensions) + ) payload = dict(server_data) payload.pop('workspace_uuid', None) @@ -246,7 +250,7 @@ class MCPService: ) ) if existing_result.first() is not None: - raise ValueError(f'MCP server already exists: {server_name}') + raise ValueError(t('MCP server already exists: {name}', name=server_name)) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(payload)) created = await self._get_mcp_server_by_uuid_raw(execution_context, payload['uuid']) @@ -332,7 +336,7 @@ class MCPService: raise ValueError('MCP server name is required') duplicate = await self._get_mcp_server_by_name_raw(execution_context, payload['name']) if duplicate is not None and duplicate['uuid'] != server_uuid: - raise ValueError(f'MCP server already exists: {payload["name"]}') + raise ValueError(t('MCP server already exists: {name}', name=payload['name'])) effective_server = {**old_server, **payload} # Existing disabled rows remain readable/deletable. Switching away diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py index 601d79d30..bfd20bfaa 100644 --- a/src/langbot/pkg/api/http/service/model.py +++ b/src/langbot/pkg/api/http/service/model.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import t + import uuid import sqlalchemy @@ -146,7 +148,13 @@ async def _validate_provider_supports( if not support_type: return if model_type not in support_type: - raise ValueError(f'Provider requester "{requester_name}" does not support {model_type} models') + raise ValueError( + t( + 'Provider {provider} does not support {model_type} models', + provider=requester_name, + model_type=model_type, + ) + ) async def _require_workspace_provider( diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 5cd5766d0..60926178f 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import t + import sqlalchemy import argon2 import jwt @@ -423,12 +425,12 @@ class UserService: user_obj = await self.get_user_by_email(user_email) if user_obj is None: - raise ValueError('用户不存在') + raise ValueError(t('Resource not found')) self._require_active_account(user_obj) # Check if this user has a local password set if not user_obj.password: - raise ValueError('请使用 LangBot 账号登录') + raise ValueError(t('Please sign in with your LangBot Account')) await self._verify_password(user_obj.password, password) diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 77fa0ade2..7bef96fc5 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -19,11 +19,14 @@ from __future__ import annotations import json import typing +import functools from mcp.server.fastmcp import FastMCP from ..http.authz import Permission, require_permission from .context import get_request_context +from ...i18n import language_scope +from ...i18n.workspace import get_workspace_language, set_workspace_language if typing.TYPE_CHECKING: from ...core import app as app_module @@ -78,6 +81,20 @@ class LangBotMCPServer: ap = self.ap mcp = self + @mcp.tool(description='Get the Workspace language used for backend messages and default bot replies.') + async def get_workspace_message_language() -> str: + context = _authorized(Permission.WORKSPACE_VIEW) + return _dump({'language': await get_workspace_language(ap, context.workspace_uuid, refresh=True)}) + + @mcp.tool( + description='Set the Workspace message language. Requires workspace.update; does not change personal UI language.' + ) + async def set_workspace_message_language( + language: typing.Literal['en_US', 'zh_Hans', 'zh_Hant', 'ja_JP', 'es_ES', 'ru_RU', 'th_TH', 'vi_VN'], + ) -> str: + context = _authorized(Permission.WORKSPACE_UPDATE) + return _dump({'language': await set_workspace_language(ap, context.workspace_uuid, language)}) + # ----- System (read-only) -------------------------------------- # @mcp.tool(description='Get basic LangBot system/runtime information (version, edition).') async def get_system_info() -> str: @@ -571,7 +588,12 @@ class LangBotMCPServer: """Register a tool boundary before its authorization and service call.""" def register(fn): - observed = fn + @functools.wraps(fn) + async def observed(*args, **kwargs): + context = get_request_context() + with language_scope(await get_workspace_language(self.ap, context.workspace_uuid)): + return await fn(*args, **kwargs) + return self.mcp.tool(**options)(observed) return register diff --git a/src/langbot/pkg/core/taskmgr.py b/src/langbot/pkg/core/taskmgr.py index 6c5df7b81..b3ae3d1c7 100644 --- a/src/langbot/pkg/core/taskmgr.py +++ b/src/langbot/pkg/core/taskmgr.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ..i18n import t + import asyncio import json import typing @@ -202,7 +204,7 @@ class TaskWrapper: """Return the stable task projection exposed to API-key callers.""" if self.task.cancelled(): status = 'cancelled' - error = {'type': 'task_cancelled', 'message': 'Task was cancelled'} + error = {'type': 'task_cancelled', 'message': t('Task was cancelled')} result = None elif not self.task.done(): status = 'running' @@ -212,7 +214,7 @@ class TaskWrapper: exception = self.assume_exception() if exception is not None: status = 'failed' - error = {'type': 'task_failed', 'message': 'Task execution failed'} + error = {'type': 'task_failed', 'message': t('Task execution failed')} result = None else: status = 'succeeded' diff --git a/src/langbot/pkg/i18n/__init__.py b/src/langbot/pkg/i18n/__init__.py new file mode 100644 index 000000000..113b4f909 --- /dev/null +++ b/src/langbot/pkg/i18n/__init__.py @@ -0,0 +1,197 @@ +"""Small, dependency-free catalog for LangBot-owned runtime messages. + +English message templates are the stable catalog IDs. Diagnostic text from +providers, plugins and validators is deliberately not translated. +""" + +from __future__ import annotations + +import contextlib +import contextvars +import json +from pathlib import Path + +DEFAULT_LANGUAGE = 'en_US' +SUPPORTED_LANGUAGES = ('en_US', 'zh_Hans', 'zh_Hant', 'ja_JP', 'es_ES', 'ru_RU', 'th_TH', 'vi_VN') +language = contextvars.ContextVar('langbot_message_language', default=DEFAULT_LANGUAGE) +_catalogs = { + locale: json.loads((Path(__file__).parent / 'locales' / f'{locale}.json').read_text(encoding='utf-8')) + for locale in SUPPORTED_LANGUAGES +} + +# Normalize existing API wording at the response boundary. Only exact owned +# messages are matched; validators and third-party diagnostics pass through. +_ALIASES = { + **{ + name: 'Resource not found' + for name in ( + 'bot not found', + 'model not found', + 'provider not found', + 'plugin not found', + 'Server not found', + 'Skill not found', + 'Pipeline not found', + 'pipeline not found', + 'knowledge base not found', + 'Knowledge base not found', + 'User not found', + 'Account not found', + 'agent not found', + 'API key not found', + 'Message not found', + 'Task not found', + 'Passkey not found', + 'Webhook not found', + 'Bot not found or not available', + 'Bot is unavailable', + 'WebSocket adapter not found', + 'adapter not found', + 'requester not found', + 'icon not found', + 'Session not found', + ) + }, + 'Unauthorized': 'Invalid authentication credentials', + 'No valid user token provided': 'Invalid authentication credentials', + 'No valid API key provided': 'Invalid authentication credentials', + 'Invalid username or password': 'Invalid authentication credentials', + 'Invalid TOTP code': 'Invalid TOTP or recovery code', + 'Too many attempts, try again later': 'Too many requests. Please try again later.', + 'The configured Agent runner is unavailable': 'Agent runner temporarily unavailable. Please try again.', + 'The configured Agent runner is not authorized': 'Agent runner not authorized for this pipeline.', + 'The Agent runner could not complete this test': 'Agent runner execution failed.', + 'Forbidden': 'Permission denied', + 'Member removal permission is required': 'Permission denied', + 'The Agent runner returned an invalid response': 'The Agent runner returned an invalid response.', + 'Invalid or expired state': 'Authorization failed. Please try signing in again.', + 'Space OAuth Workspace binding mismatch': 'Authorization failed. Please try signing in again.', + 'Space OAuth response is missing the Cloud Workspace binding': 'Authorization failed. Please try signing in again.', + 'Space launch requires Cloud mode': 'This feature is unavailable in this Workspace.', + 'An Account is required': 'Please sign in with your LangBot Account', + 'ChatGPT authorization requires an authorized workspace user': 'Permission denied', + 'ChatGPT authorization is busy. Please retry shortly.': 'Server is busy. Please try again later.', + 'ChatGPT authorization network error. Please retry.': 'Connection failed', + 'ChatGPT credential storage failed. Please retry.': 'Failed to save ChatGPT credentials. Please retry.', + 'ChatGPT Codex response was not completed': 'ChatGPT Codex response failed or was incomplete. Please retry.', + 'ChatGPT Codex stream ended before completion. Please retry.': 'ChatGPT Codex response failed or was incomplete. Please retry.', + 'Not permitted': 'Permission denied', + 'Unauthorized or session expired': 'Invalid authentication credentials', + 'TOTP is not enabled for that Account': 'TOTP is not enabled for this Account', + 'No pending TOTP enrolment for this Account': 'TOTP is not enabled for this Account', + 'Too many active credential exchanges': 'Server is busy. Please try again later.', + 'Timeout waiting for QR code': 'Request timed out', + 'Model availability is unavailable': 'This feature is unavailable in this Workspace.', + 'Survey not available': 'This feature is unavailable in this Workspace.', + **{ + message: 'Submission failed. Please try again.' + for message in ( + 'Failed to submit response', + 'Failed to submit feedback', + 'Failed to dismiss', + ) + }, + **{ + message: 'File size exceeds the limit.' + for message in ( + 'Image size exceeds 10MB limit.', + 'file size exceeds 10MB limit', + 'attachment too large', + ) + }, + **{ + message: 'Authorization failed. Please try signing in again.' + for message in ( + 'Space launch failed', + 'Space OAuth failed', + 'LangBot Account binding failed', + 'Failed to get access token from Space', + 'ChatGPT returned an incomplete authorization. Please sign in again.', + 'ChatGPT authorization was cancelled or replaced. Please retry.', + 'ChatGPT returned an invalid authorization response', + 'ChatGPT returned an invalid device code', + 'ChatGPT returned an invalid device code expiry', + 'ChatGPT device authorization failed. Please start again.', + 'ChatGPT returned an incomplete device authorization', + 'ChatGPT token exchange failed. Please start sign-in again.', + ) + }, +} + + +def normalize_language(value: str | None) -> str: + value = (value or '').replace('-', '_') + aliases = { + 'zh_CN': 'zh_Hans', + 'zh_TW': 'zh_Hant', + 'en': 'en_US', + 'ja': 'ja_JP', + 'es': 'es_ES', + 'ru': 'ru_RU', + 'th': 'th_TH', + 'vi': 'vi_VN', + } + value = aliases.get(value, value) + return value if value in SUPPORTED_LANGUAGES else DEFAULT_LANGUAGE + + +@contextlib.contextmanager +def language_scope(locale: str): + token = language.set(normalize_language(locale)) + try: + yield + finally: + language.reset(token) + + +def t(message: str, *, locale: str | None = None, **params) -> str: + selected = normalize_language(locale or language.get()) + # Preserve established English diagnostic wording and resource names. + # Aliases only share translations; they do not rewrite the source language. + key = _ALIASES.get(message, message) + text = message if selected == DEFAULT_LANGUAGE else _catalogs[selected].get(key, message) + return text.format(**params) if params else text + + +def error_text(code: int | str, message: str) -> str: + """Stable business codes take precedence over variable diagnostic wording.""" + if language.get() == DEFAULT_LANGUAGE: + return message + messages = { + 'permission_denied': 'Permission denied', + 'resource_not_found': 'Resource not found', + 'membership_not_found': 'Resource not found', + 'invalid_authentication': 'Invalid authentication credentials', + 'task_capacity_exceeded': 'Server is busy. Please try again later.', + 'blocking_work_capacity_exceeded': 'Server is busy. Please try again later.', + 'edition_limit': 'This feature is unavailable in this Workspace.', + 'entitlement_unavailable': 'This feature is unavailable in this Workspace.', + 'last_owner_required': 'The Workspace must retain an owner.', + 'already_a_member': 'This user is already a member.', + 'invitation_email_mismatch': 'The invitation email does not match your Account', + 'totp_invalid_code': 'Invalid TOTP or recovery code', + 'totp_challenge_invalid': 'Invalid TOTP or recovery code', + 'totp_already_enrolled': 'TOTP is already enabled for this Account', + 'totp_not_enrolled': 'TOTP is not enabled for this Account', + } + if code in ('invitation_invalid', 'invitation_expired', 'invitation_revoked', 'invitation_used'): + return t('Invalid or expired invitation') + return t(messages.get(code, message)) + + +def pipeline_notice(query, name: str, default: str, **params) -> str: + """Empty overrides inherit the Workspace language; user text stays literal. + + Only documented placeholders are substituted, so arbitrary braces in custom + messages cannot cause a formatting exception. + """ + config = query.pipeline_config.get('output', {}).get('misc', {}) + custom = config.get(name) + # This was the shipped default before language inheritance existed. + if name == 'failure-hint' and custom == 'Request failed.': + custom = None + if not isinstance(custom, str) or not custom.strip(): + return t(default, **params) + for key, value in params.items(): + custom = custom.replace('{' + key + '}', str(value)) + return custom diff --git a/src/langbot/pkg/i18n/adapters.py b/src/langbot/pkg/i18n/adapters.py new file mode 100644 index 000000000..899b57a19 --- /dev/null +++ b/src/langbot/pkg/i18n/adapters.py @@ -0,0 +1,31 @@ +"""Language boundaries for adapter-owned replies outside the Pipeline.""" + +import functools + +from . import DEFAULT_LANGUAGE, language_scope, t +from .workspace import _cache, get_workspace_language + + +def _owner(adapter): + logger = getattr(adapter, 'logger', None) + context = getattr(logger, 'execution_context', None) + workspace_uuid = getattr(context, 'workspace_uuid', None) + return getattr(logger, 'ap', None), workspace_uuid if isinstance(workspace_uuid, str) else None + + +def adapter_t(adapter, message: str, **params) -> str: + """Synchronous SDK callbacks use the bot's own cached language, never a peer's.""" + ap, workspace_uuid = _owner(adapter) + cached = _cache(ap).get(workspace_uuid) if ap is not None and workspace_uuid else None + return t(message, locale=cached[0] if cached else DEFAULT_LANGUAGE, **params) + + +def localized_adapter(method): + @functools.wraps(method) + async def wrapped(self, *args, **kwargs): + ap, workspace_uuid = _owner(self) + locale = await get_workspace_language(ap, workspace_uuid) if ap is not None else DEFAULT_LANGUAGE + with language_scope(locale): + return await method(self, *args, **kwargs) + + return wrapped diff --git a/src/langbot/pkg/i18n/locales/en_US.json b/src/langbot/pkg/i18n/locales/en_US.json new file mode 100644 index 000000000..dd07ff255 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/en_US.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "Request failed.", + "Too many requests. Please try again later.": "Too many requests. Please try again later.", + "Your message contains inappropriate content. Please revise it.": "Your message contains inappropriate content. Please revise it.", + "Content checking failed. Please contact the administrator.": "Content checking failed. Please contact the administrator.", + "Calling {tools}...": "Calling {tools}...", + "Agent runner not found: {runner}": "Agent runner not found: {runner}", + "Agent runner not authorized for this pipeline.": "Agent runner not authorized for this pipeline.", + "Agent runner temporarily unavailable. Please try again.": "Agent runner temporarily unavailable. Please try again.", + "Agent runner execution failed.": "Agent runner execution failed.", + "The Agent runner returned an invalid response.": "The Agent runner returned an invalid response.", + "Model request failed": "Model request failed", + "Context length exceeded": "Context length exceeded", + "Invalid request parameters": "Invalid request parameters", + "Invalid API key": "Invalid API key", + "Invalid model or endpoint": "Invalid model or endpoint", + "Rate limit exceeded or insufficient balance": "Rate limit exceeded or insufficient balance", + "Request timed out": "Request timed out", + "Connection failed": "Connection failed", + "API error": "API error", + "Unknown error": "Unknown error", + "Internal server error": "Internal server error", + "Resource not found": "Resource not found", + "Permission denied": "Permission denied", + "Invalid authentication credentials": "Invalid authentication credentials", + "MCP runtime failed": "MCP runtime failed", + "Task execution failed": "Task execution failed", + "Task was cancelled": "Task was cancelled", + "Maximum number of {resource} ({limit}) reached": "Maximum number of {resource} ({limit}) reached", + "extensions": "extensions", + "knowledge bases": "knowledge bases", + "MCP server already exists: {name}": "MCP server already exists: {name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models is managed by Cloud and cannot be modified", + "Please sign in with your LangBot Account": "Please sign in with your LangBot Account", + "Current password is incorrect": "Current password is incorrect", + "Invalid TOTP or recovery code": "Invalid TOTP or recovery code", + "No local password set, please set a password first": "No local password set, please set a password first", + "This LangBot Account is already bound to another user": "This LangBot Account is already bound to another user", + "Invalid recovery key": "Invalid recovery key", + "Done": "Done", + "Thank you for your feedback": "Thank you for your feedback", + "Failed to process feedback": "Failed to process feedback", + "Helpful": "Helpful", + "Not helpful": "Not helpful", + "Select an option": "Select an option", + "Enter a value": "Enter a value", + "Submit": "Submit", + "Submitted": "Submitted", + "Thinking": "Thinking", + "AI-generated": "AI-generated", + "AI-generated content. Refer to the cited sources for details.": "AI-generated content. Refer to the cited sources for details.", + "No bridges configured.": "No bridges configured.", + "Signing in to all bridges again...": "Signing in to all bridges again...", + "Bridge status:": "Bridge status:", + "Signed in ✓": "Signed in ✓", + "Not signed in ✗": "Not signed in ✗", + "None": "None", + "Bridge {bridge}: skipped (login command or DM room missing).": "Bridge {bridge}: skipped (login command or DM room missing).", + "Bridge {bridge}: sending {command}...": "Bridge {bridge}: sending {command}...", + "Bridge {bridge}: sign-out failed.": "Bridge {bridge}: sign-out failed.", + "Bridge {bridge}: sign-in started.": "Bridge {bridge}: sign-in started.", + "Slack authorization completed. Return to LangBot to finish setup.": "Slack authorization completed. Return to LangBot to finish setup.", + "Slack authorization failed or expired. Return to LangBot for details.": "Slack authorization failed or expired. Return to LangBot for details.", + "TOTP is already enabled for this Account": "TOTP is already enabled for this Account", + "TOTP is not enabled for this Account": "TOTP is not enabled for this Account", + "Invalid or expired passkey challenge": "Invalid or expired passkey challenge", + "Passkey credential not recognized": "Passkey credential not recognized", + "Account is disabled": "Account is disabled", + "Modifying login info is disabled": "Modifying login info is disabled", + "Invalid GitHub repository URL": "Invalid GitHub repository URL", + "Server is busy. Please try again later.": "Server is busy. Please try again later.", + "This feature is unavailable in this Workspace.": "This feature is unavailable in this Workspace.", + "The Workspace must retain an owner.": "The Workspace must retain an owner.", + "Invalid or expired invitation": "Invalid or expired invitation", + "This user is already a member.": "This user is already a member.", + "Voice connection failed": "Voice connection failed", + "User is not in the voice channel": "User is not in the voice channel", + "Voice service is unavailable": "Voice service is unavailable", + "Submission failed. Please try again.": "Submission failed. Please try again.", + "File size exceeds the limit.": "File size exceeds the limit.", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "File size exceeds 10MB limit. Please split large files into smaller parts.", + "Authorization failed. Please try signing in again.": "Authorization failed. Please try signing in again.", + "Bind the LangBot Account with the same email as this local Account": "Bind the LangBot Account with the same email as this local Account", + "Only local accounts can bind to Space": "Only local accounts can bind to Space", + "System already initialized": "System already initialized", + "System not initialized": "System not initialized", + "Password login is disabled on LangBot Cloud": "Password login is disabled on LangBot Cloud", + "This edition supports one Workspace per instance": "This edition supports one Workspace per instance", + "Cloud Workspaces are created by the SaaS control plane": "Cloud Workspaces are created by the SaaS control plane", + "Login with your LangBot Account to accept this invitation": "Login with your LangBot Account to accept this invitation", + "RAG migration is not needed": "RAG migration is not needed", + "New Agent": "New Agent", + "New Pipeline": "New Pipeline", + "Provider {provider} does not support {model_type} models": "Provider {provider} does not support {model_type} models", + "Invalid JSON format": "Invalid JSON format", + "ChatGPT sign-in required. Open this provider and sign in again.": "ChatGPT sign-in required. Open this provider and sign in again.", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT denied this request. Check subscription and workspace permissions.", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT rejected the model or request. Check the selected model and request settings.", + "ChatGPT Codex upstream request failed. Please retry later.": "ChatGPT Codex upstream request failed. Please retry later.", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "ChatGPT subscription usage limit reached. Please retry later or check your plan.", + "ChatGPT Codex response failed or was incomplete. Please retry.": "ChatGPT Codex response failed or was incomplete. Please retry.", + "ChatGPT token refresh temporarily failed. Please retry.": "ChatGPT token refresh temporarily failed. Please retry.", + "A second factor is required": "A second factor is required", + "Sign in or provide registration details to accept this invitation": "Sign in or provide registration details to accept this invitation", + "Sign out before creating the invited local Account": "Sign out before creating the invited local Account", + "Turnstile verification failed": "Turnstile verification failed", + "The invitation email does not match your Account": "The invitation email does not match your Account", + "Failed to save ChatGPT credentials. Please retry.": "Failed to save ChatGPT credentials. Please retry.", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.", + "Option {number}": "Option {number}", + "Confirmation needed": "Confirmation needed", + "Session reset successfully": "Session reset successfully" +} diff --git a/src/langbot/pkg/i18n/locales/es_ES.json b/src/langbot/pkg/i18n/locales/es_ES.json new file mode 100644 index 000000000..116aa09a8 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/es_ES.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "La solicitud ha fallado.", + "Too many requests. Please try again later.": "Demasiadas solicitudes. Inténtalo más tarde.", + "Your message contains inappropriate content. Please revise it.": "Tu mensaje contiene contenido inapropiado. Modifícalo.", + "Content checking failed. Please contact the administrator.": "No se pudo verificar el contenido. Contacta con el administrador.", + "Calling {tools}...": "Llamando a {tools}…", + "Agent runner not found: {runner}": "No se encontró el ejecutor del agente: {runner}", + "Agent runner not authorized for this pipeline.": "Este flujo no tiene autorización para usar el ejecutor del agente.", + "Agent runner temporarily unavailable. Please try again.": "El ejecutor del agente no está disponible temporalmente. Reinténtalo.", + "Agent runner execution failed.": "La ejecución del agente ha fallado.", + "The Agent runner returned an invalid response.": "El ejecutor del agente devolvió una respuesta no válida.", + "Model request failed": "Error en la solicitud al modelo", + "Context length exceeded": "Se superó el límite de contexto", + "Invalid request parameters": "Parámetros de solicitud no válidos", + "Invalid API key": "Clave API no válida", + "Invalid model or endpoint": "Modelo o endpoint no válido", + "Rate limit exceeded or insufficient balance": "Límite de solicitudes excedido o saldo insuficiente", + "Request timed out": "La solicitud agotó el tiempo de espera", + "Connection failed": "Error de conexión", + "API error": "Error de API", + "Unknown error": "Error desconocido", + "Internal server error": "Error interno del servidor", + "Resource not found": "Recurso no encontrado", + "Permission denied": "No tienes permiso para realizar esta acción", + "Invalid authentication credentials": "Error de autenticación. Vuelve a iniciar sesión", + "MCP runtime failed": "Error de ejecución de MCP", + "Task execution failed": "La tarea ha fallado", + "Task was cancelled": "La tarea se ha cancelado", + "Maximum number of {resource} ({limit}) reached": "Se alcanzó el máximo de {resource} ({limit})", + "extensions": "extensiones", + "knowledge bases": "bases de conocimiento", + "MCP server already exists: {name}": "El servidor MCP ya existe: {name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models está gestionado por Cloud y no se puede modificar", + "Please sign in with your LangBot Account": "Inicia sesión con tu cuenta de LangBot", + "Current password is incorrect": "La contraseña actual es incorrecta", + "Invalid TOTP or recovery code": "Código TOTP o de recuperación no válido", + "No local password set, please set a password first": "No hay contraseña local. Configura una primero", + "This LangBot Account is already bound to another user": "Esta cuenta de LangBot ya está vinculada a otro usuario", + "Invalid recovery key": "Clave de recuperación no válida", + "Done": "Hecho", + "Thank you for your feedback": "Gracias por tus comentarios", + "Failed to process feedback": "No se pudieron procesar los comentarios", + "Helpful": "Útil", + "Not helpful": "No es útil", + "Select an option": "Selecciona una opción", + "Enter a value": "Introduce un valor", + "Submit": "Enviar", + "Submitted": "Enviado", + "Thinking": "Razonamiento", + "AI-generated": "Generado por IA", + "AI-generated content. Refer to the cited sources for details.": "Contenido generado por IA. Consulta las fuentes citadas para más detalles.", + "No bridges configured.": "No hay puentes configurados.", + "Signing in to all bridges again...": "Iniciando sesión de nuevo en todos los puentes…", + "Bridge status:": "Estado de los puentes:", + "Signed in ✓": "Sesión iniciada ✓", + "Not signed in ✗": "Sin sesión ✗", + "None": "Ninguno", + "Bridge {bridge}: skipped (login command or DM room missing).": "Puente {bridge}: omitido (falta comando de inicio de sesión o sala DM).", + "Bridge {bridge}: sending {command}...": "Puente {bridge}: enviando {command}…", + "Bridge {bridge}: sign-out failed.": "Puente {bridge}: no se pudo cerrar sesión.", + "Bridge {bridge}: sign-in started.": "Puente {bridge}: inicio de sesión en curso.", + "Slack authorization completed. Return to LangBot to finish setup.": "Autorización de Slack completada. Vuelve a LangBot para terminar la configuración.", + "Slack authorization failed or expired. Return to LangBot for details.": "La autorización de Slack falló o caducó. Consulta los detalles en LangBot.", + "TOTP is already enabled for this Account": "Esta cuenta ya tiene TOTP activado", + "TOTP is not enabled for this Account": "Esta cuenta no tiene TOTP activado", + "Invalid or expired passkey challenge": "El desafío de clave de acceso no es válido o ha caducado", + "Passkey credential not recognized": "No se reconoce la clave de acceso", + "Account is disabled": "La cuenta está desactivada", + "Modifying login info is disabled": "No se permite modificar los datos de inicio de sesión", + "Invalid GitHub repository URL": "URL del repositorio GitHub no válida", + "Server is busy. Please try again later.": "El servidor está ocupado. Inténtalo más tarde.", + "This feature is unavailable in this Workspace.": "Esta función no está disponible en este espacio de trabajo.", + "The Workspace must retain an owner.": "El espacio de trabajo debe conservar un propietario.", + "Invalid or expired invitation": "Invitación no válida o caducada", + "This user is already a member.": "Este usuario ya es miembro.", + "Voice connection failed": "Error de conexión de voz", + "User is not in the voice channel": "El usuario no está en el canal de voz", + "Voice service is unavailable": "El servicio de voz no está disponible", + "Submission failed. Please try again.": "El envío ha fallado. Reinténtalo.", + "File size exceeds the limit.": "El archivo supera el tamaño permitido.", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "El archivo supera los 10 MB. Divídelo en partes más pequeñas.", + "Authorization failed. Please try signing in again.": "La autorización ha fallado. Vuelve a iniciar sesión.", + "Bind the LangBot Account with the same email as this local Account": "Vincula una cuenta de LangBot con el mismo correo que esta cuenta local", + "Only local accounts can bind to Space": "Solo las cuentas locales pueden vincularse a Space", + "System already initialized": "El sistema ya está inicializado", + "System not initialized": "El sistema no está inicializado", + "Password login is disabled on LangBot Cloud": "El acceso con contraseña está desactivado en LangBot Cloud", + "This edition supports one Workspace per instance": "Esta edición admite un espacio de trabajo por instancia", + "Cloud Workspaces are created by the SaaS control plane": "Los espacios de trabajo Cloud se crean desde el panel SaaS", + "Login with your LangBot Account to accept this invitation": "Inicia sesión con tu cuenta de LangBot para aceptar la invitación", + "RAG migration is not needed": "No es necesaria la migración de RAG", + "New Agent": "Nuevo agente", + "New Pipeline": "Nuevo flujo", + "Provider {provider} does not support {model_type} models": "El proveedor {provider} no admite modelos {model_type}", + "Invalid JSON format": "Formato JSON no válido", + "ChatGPT sign-in required. Open this provider and sign in again.": "Debes iniciar sesión en ChatGPT. Abre este proveedor y vuelve a iniciar sesión.", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT limitó la solicitud por frecuencia o uso. Inténtalo más tarde o revisa tu plan.", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT rechazó la solicitud. Revisa la suscripción y los permisos del espacio de trabajo.", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT rechazó el modelo o la solicitud. Revisa el modelo seleccionado y la configuración.", + "ChatGPT Codex upstream request failed. Please retry later.": "Falló la solicitud a ChatGPT Codex. Inténtalo más tarde.", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "Se alcanzó el límite de uso de tu suscripción de ChatGPT. Inténtalo más tarde o revisa tu plan.", + "ChatGPT Codex response failed or was incomplete. Please retry.": "La respuesta de ChatGPT Codex falló o está incompleta. Inténtalo de nuevo.", + "ChatGPT token refresh temporarily failed. Please retry.": "No se pudo renovar el token de ChatGPT temporalmente. Inténtalo de nuevo.", + "A second factor is required": "Se requiere un segundo factor de autenticación", + "Sign in or provide registration details to accept this invitation": "Inicia sesión o proporciona los datos de registro para aceptar esta invitación", + "Sign out before creating the invited local Account": "Cierra sesión antes de crear la cuenta local invitada", + "Turnstile verification failed": "La verificación de Turnstile falló. Inténtalo de nuevo", + "The invitation email does not match your Account": "El correo de la invitación no coincide con tu cuenta", + "Failed to save ChatGPT credentials. Please retry.": "No se pudieron guardar las credenciales de ChatGPT. Inténtalo de nuevo.", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "No se pudo iniciar el acceso por dispositivo. Activa el inicio de sesión con código de dispositivo en la configuración de ChatGPT.", + "Option {number}": "Opción {number}", + "Confirmation needed": "Se requiere confirmación", + "Session reset successfully": "Sesión restablecida" +} diff --git a/src/langbot/pkg/i18n/locales/ja_JP.json b/src/langbot/pkg/i18n/locales/ja_JP.json new file mode 100644 index 000000000..292bb1b56 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/ja_JP.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "リクエストに失敗しました。", + "Too many requests. Please try again later.": "リクエストが多すぎます。しばらくしてから再試行してください。", + "Your message contains inappropriate content. Please revise it.": "不適切な内容が含まれています。メッセージを修正してください。", + "Content checking failed. Please contact the administrator.": "コンテンツの確認に失敗しました。管理者にお問い合わせください。", + "Calling {tools}...": "{tools} を呼び出しています…", + "Agent runner not found: {runner}": "Agent ランナーが見つかりません:{runner}", + "Agent runner not authorized for this pipeline.": "このパイプラインには Agent ランナーの使用権限がありません。", + "Agent runner temporarily unavailable. Please try again.": "Agent ランナーは一時的に利用できません。再試行してください。", + "Agent runner execution failed.": "Agent の実行に失敗しました。", + "The Agent runner returned an invalid response.": "Agent ランナーが無効な応答を返しました。", + "Model request failed": "モデルへのリクエストに失敗しました", + "Context length exceeded": "コンテキスト長の上限を超えました", + "Invalid request parameters": "リクエストのパラメータが無効です", + "Invalid API key": "API キーが無効です", + "Invalid model or endpoint": "モデルまたはエンドポイントが無効です", + "Rate limit exceeded or insufficient balance": "リクエスト制限を超えたか、残高が不足しています", + "Request timed out": "リクエストがタイムアウトしました", + "Connection failed": "接続に失敗しました", + "API error": "API エラー", + "Unknown error": "不明なエラー", + "Internal server error": "サーバー内部エラー", + "Resource not found": "リソースが見つかりません", + "Permission denied": "この操作の権限がありません", + "Invalid authentication credentials": "認証に失敗しました。再度ログインしてください", + "MCP runtime failed": "MCP の実行に失敗しました", + "Task execution failed": "タスクの実行に失敗しました", + "Task was cancelled": "タスクがキャンセルされました", + "Maximum number of {resource} ({limit}) reached": "{resource}の上限数({limit})に達しました", + "extensions": "拡張機能", + "knowledge bases": "ナレッジベース", + "MCP server already exists: {name}": "MCP サーバーは既に存在します:{name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models はクラウドで管理されているため変更できません", + "Please sign in with your LangBot Account": "LangBot アカウントでログインしてください", + "Current password is incorrect": "現在のパスワードが正しくありません", + "Invalid TOTP or recovery code": "認証コードまたはリカバリーコードが無効です", + "No local password set, please set a password first": "ローカルパスワードが未設定です。先に設定してください", + "This LangBot Account is already bound to another user": "この LangBot アカウントは別のユーザーに連携されています", + "Invalid recovery key": "リカバリーキーが無効です", + "Done": "完了しました", + "Thank you for your feedback": "フィードバックありがとうございます", + "Failed to process feedback": "フィードバックの処理に失敗しました", + "Helpful": "役に立った", + "Not helpful": "役に立たなかった", + "Select an option": "選択してください", + "Enter a value": "入力してください", + "Submit": "送信", + "Submitted": "送信済み", + "Thinking": "思考過程", + "AI-generated": "AI による生成コンテンツ", + "AI-generated content. Refer to the cited sources for details.": "AI が生成した内容です。詳細は引用元を参照してください。", + "No bridges configured.": "ブリッジが設定されていません。", + "Signing in to all bridges again...": "すべてのブリッジに再ログインしています…", + "Bridge status:": "ブリッジの状態:", + "Signed in ✓": "ログイン済み ✓", + "Not signed in ✗": "未ログイン ✗", + "None": "なし", + "Bridge {bridge}: skipped (login command or DM room missing).": "ブリッジ {bridge}:スキップしました(ログインコマンドまたは DM ルームがありません)。", + "Bridge {bridge}: sending {command}...": "ブリッジ {bridge}:{command} を送信中…", + "Bridge {bridge}: sign-out failed.": "ブリッジ {bridge}:ログアウトに失敗しました。", + "Bridge {bridge}: sign-in started.": "ブリッジ {bridge}:ログインを開始しました。", + "Slack authorization completed. Return to LangBot to finish setup.": "Slack の認証が完了しました。LangBot に戻って設定を完了してください。", + "Slack authorization failed or expired. Return to LangBot for details.": "Slack の認証に失敗したか期限切れです。詳細は LangBot で確認してください。", + "TOTP is already enabled for this Account": "このアカウントでは二段階認証が有効です", + "TOTP is not enabled for this Account": "このアカウントでは二段階認証が無効です", + "Invalid or expired passkey challenge": "パスキーの認証要求が無効または期限切れです", + "Passkey credential not recognized": "このパスキーは認識できません", + "Account is disabled": "アカウントは無効です", + "Modifying login info is disabled": "ログイン情報の変更は無効です", + "Invalid GitHub repository URL": "GitHub リポジトリの URL が無効です", + "Server is busy. Please try again later.": "サーバーが混雑しています。後で再試行してください。", + "This feature is unavailable in this Workspace.": "このワークスペースではこの機能を利用できません。", + "The Workspace must retain an owner.": "ワークスペースにはオーナーが必要です。", + "Invalid or expired invitation": "招待が無効または期限切れです", + "This user is already a member.": "このユーザーは既にメンバーです。", + "Voice connection failed": "音声接続に失敗しました", + "User is not in the voice channel": "ユーザーは音声チャンネルに参加していません", + "Voice service is unavailable": "音声サービスを利用できません", + "Submission failed. Please try again.": "送信に失敗しました。再試行してください。", + "File size exceeds the limit.": "ファイルサイズが上限を超えています。", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "ファイルが 10MB を超えています。分割してアップロードしてください。", + "Authorization failed. Please try signing in again.": "認証に失敗しました。再度ログインしてください。", + "Bind the LangBot Account with the same email as this local Account": "このローカルアカウントと同じメールアドレスの LangBot アカウントを連携してください", + "Only local accounts can bind to Space": "Space と連携できるのはローカルアカウントのみです", + "System already initialized": "システムは初期化済みです", + "System not initialized": "システムが初期化されていません", + "Password login is disabled on LangBot Cloud": "LangBot Cloud ではパスワードログインは無効です", + "This edition supports one Workspace per instance": "このエディションではインスタンスごとにワークスペースは1つです", + "Cloud Workspaces are created by the SaaS control plane": "クラウドのワークスペースは SaaS 管理画面で作成します", + "Login with your LangBot Account to accept this invitation": "招待を承諾するには LangBot アカウントでログインしてください", + "RAG migration is not needed": "RAG の移行は不要です", + "New Agent": "新しい Agent", + "New Pipeline": "新しいパイプライン", + "Provider {provider} does not support {model_type} models": "プロバイダー {provider} は {model_type} モデルに対応していません", + "Invalid JSON format": "JSON 形式が無効です", + "ChatGPT sign-in required. Open this provider and sign in again.": "ChatGPT へのログインが必要です。このプロバイダーを開いて再ログインしてください。", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT のリクエストが制限されました(頻度または使用量)。後で再試行するか、プランを確認してください。", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT がリクエストを拒否しました。サブスクリプションとワークスペースの権限を確認してください。", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT がモデルまたはリクエストを拒否しました。選択したモデルとリクエスト設定を確認してください。", + "ChatGPT Codex upstream request failed. Please retry later.": "ChatGPT Codex の上流リクエストに失敗しました。後で再試行してください。", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "ChatGPT のサブスクリプション使用量が上限に達しました。後で再試行するか、プランを確認してください。", + "ChatGPT Codex response failed or was incomplete. Please retry.": "ChatGPT Codex の応答に失敗したか、応答が不完全です。再試行してください。", + "ChatGPT token refresh temporarily failed. Please retry.": "ChatGPT トークンの更新に一時的に失敗しました。再試行してください。", + "A second factor is required": "二要素認証が必要です", + "Sign in or provide registration details to accept this invitation": "招待を承諾するにはログインするか、登録情報を入力してください", + "Sign out before creating the invited local Account": "招待されたローカルアカウントを作成する前にログアウトしてください", + "Turnstile verification failed": "人間確認に失敗しました。再試行してください", + "The invitation email does not match your Account": "招待のメールアドレスがアカウントと一致しません", + "Failed to save ChatGPT credentials. Please retry.": "ChatGPT 認証情報の保存に失敗しました。再試行してください。", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "ChatGPT のデバイスログインを開始できません。ChatGPT の設定でデバイスコードログインを有効にしてください。", + "Option {number}": "選択肢 {number}", + "Confirmation needed": "確認が必要です", + "Session reset successfully": "セッションをリセットしました" +} diff --git a/src/langbot/pkg/i18n/locales/ru_RU.json b/src/langbot/pkg/i18n/locales/ru_RU.json new file mode 100644 index 000000000..ed9529018 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/ru_RU.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "Запрос не выполнен.", + "Too many requests. Please try again later.": "Слишком много запросов. Повторите позже.", + "Your message contains inappropriate content. Please revise it.": "Сообщение содержит недопустимый контент. Измените его.", + "Content checking failed. Please contact the administrator.": "Ошибка проверки содержимого. Обратитесь к администратору.", + "Calling {tools}...": "Вызов {tools}…", + "Agent runner not found: {runner}": "Исполнитель агента не найден: {runner}", + "Agent runner not authorized for this pipeline.": "У конвейера нет разрешения на использование исполнителя агента.", + "Agent runner temporarily unavailable. Please try again.": "Исполнитель агента временно недоступен. Повторите попытку.", + "Agent runner execution failed.": "Ошибка выполнения агента.", + "The Agent runner returned an invalid response.": "Исполнитель агента вернул некорректный ответ.", + "Model request failed": "Ошибка запроса к модели", + "Context length exceeded": "Превышена длина контекста", + "Invalid request parameters": "Некорректные параметры запроса", + "Invalid API key": "Недействительный API-ключ", + "Invalid model or endpoint": "Некорректная модель или адрес API", + "Rate limit exceeded or insufficient balance": "Превышен лимит запросов или недостаточно средств", + "Request timed out": "Время ожидания запроса истекло", + "Connection failed": "Ошибка подключения", + "API error": "Ошибка API", + "Unknown error": "Неизвестная ошибка", + "Internal server error": "Внутренняя ошибка сервера", + "Resource not found": "Ресурс не найден", + "Permission denied": "Нет разрешения на это действие", + "Invalid authentication credentials": "Ошибка аутентификации. Войдите снова", + "MCP runtime failed": "Ошибка выполнения MCP", + "Task execution failed": "Ошибка выполнения задачи", + "Task was cancelled": "Задача отменена", + "Maximum number of {resource} ({limit}) reached": "Достигнуто максимальное число {resource} ({limit})", + "extensions": "расширений", + "knowledge bases": "баз знаний", + "MCP server already exists: {name}": "Сервер MCP уже существует: {name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models управляется облаком и не может быть изменён", + "Please sign in with your LangBot Account": "Войдите с учётной записью LangBot", + "Current password is incorrect": "Текущий пароль неверен", + "Invalid TOTP or recovery code": "Неверный код TOTP или восстановления", + "No local password set, please set a password first": "Локальный пароль не задан. Сначала задайте пароль", + "This LangBot Account is already bound to another user": "Эта учётная запись LangBot уже связана с другим пользователем", + "Invalid recovery key": "Недействительный ключ восстановления", + "Done": "Готово", + "Thank you for your feedback": "Спасибо за отзыв", + "Failed to process feedback": "Не удалось обработать отзыв", + "Helpful": "Полезно", + "Not helpful": "Не полезно", + "Select an option": "Выберите вариант", + "Enter a value": "Введите значение", + "Submit": "Отправить", + "Submitted": "Отправлено", + "Thinking": "Ход рассуждений", + "AI-generated": "Создано ИИ", + "AI-generated content. Refer to the cited sources for details.": "Контент создан ИИ. Подробности смотрите в указанных источниках.", + "No bridges configured.": "Мосты не настроены.", + "Signing in to all bridges again...": "Повторный вход во все мосты…", + "Bridge status:": "Состояние мостов:", + "Signed in ✓": "Вход выполнен ✓", + "Not signed in ✗": "Вход не выполнен ✗", + "None": "Нет", + "Bridge {bridge}: skipped (login command or DM room missing).": "Мост {bridge}: пропущен (нет команды входа или комнаты DM).", + "Bridge {bridge}: sending {command}...": "Мост {bridge}: отправка {command}…", + "Bridge {bridge}: sign-out failed.": "Мост {bridge}: ошибка выхода.", + "Bridge {bridge}: sign-in started.": "Мост {bridge}: вход начат.", + "Slack authorization completed. Return to LangBot to finish setup.": "Авторизация Slack завершена. Вернитесь в LangBot для завершения настройки.", + "Slack authorization failed or expired. Return to LangBot for details.": "Авторизация Slack не удалась или истекла. Подробности в LangBot.", + "TOTP is already enabled for this Account": "Для этой учётной записи TOTP уже включён", + "TOTP is not enabled for this Account": "Для этой учётной записи TOTP не включён", + "Invalid or expired passkey challenge": "Запрос проверки ключа доступа недействителен или истёк", + "Passkey credential not recognized": "Ключ доступа не распознан", + "Account is disabled": "Учётная запись отключена", + "Modifying login info is disabled": "Изменение данных входа отключено", + "Invalid GitHub repository URL": "Некорректный URL репозитория GitHub", + "Server is busy. Please try again later.": "Сервер занят. Повторите позже.", + "This feature is unavailable in this Workspace.": "Эта функция недоступна в данном рабочем пространстве.", + "The Workspace must retain an owner.": "У рабочего пространства должен остаться владелец.", + "Invalid or expired invitation": "Приглашение недействительно или истекло", + "This user is already a member.": "Этот пользователь уже является участником.", + "Voice connection failed": "Ошибка голосового соединения", + "User is not in the voice channel": "Пользователь не в голосовом канале", + "Voice service is unavailable": "Голосовая служба недоступна", + "Submission failed. Please try again.": "Не удалось отправить. Повторите попытку.", + "File size exceeds the limit.": "Размер файла превышает ограничение.", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "Размер файла превышает 10 МБ. Разделите его на части.", + "Authorization failed. Please try signing in again.": "Авторизация не удалась. Войдите снова.", + "Bind the LangBot Account with the same email as this local Account": "Свяжите учётную запись LangBot с тем же адресом почты, что и локальная", + "Only local accounts can bind to Space": "К Space можно привязать только локальные учётные записи", + "System already initialized": "Система уже инициализирована", + "System not initialized": "Система не инициализирована", + "Password login is disabled on LangBot Cloud": "В LangBot Cloud вход по паролю отключён", + "This edition supports one Workspace per instance": "Эта редакция поддерживает одно рабочее пространство на экземпляр", + "Cloud Workspaces are created by the SaaS control plane": "Облачные рабочие пространства создаются в панели SaaS", + "Login with your LangBot Account to accept this invitation": "Войдите в LangBot, чтобы принять приглашение", + "RAG migration is not needed": "Миграция RAG не требуется", + "New Agent": "Новый агент", + "New Pipeline": "Новый конвейер", + "Provider {provider} does not support {model_type} models": "Провайдер {provider} не поддерживает модели {model_type}", + "Invalid JSON format": "Некорректный формат JSON", + "ChatGPT sign-in required. Open this provider and sign in again.": "Требуется вход в ChatGPT. Откройте этого провайдера и войдите снова.", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "Запрос ChatGPT ограничен по частоте или использованию. Повторите позже или проверьте тариф.", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT отклонил запрос. Проверьте подписку и права рабочего пространства.", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT отклонил модель или запрос. Проверьте выбранную модель и настройки запроса.", + "ChatGPT Codex upstream request failed. Please retry later.": "Ошибка запроса к ChatGPT Codex. Повторите позже.", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "Достигнут лимит использования подписки ChatGPT. Повторите позже или проверьте тариф.", + "ChatGPT Codex response failed or was incomplete. Please retry.": "Ошибка или неполный ответ ChatGPT Codex. Повторите запрос.", + "ChatGPT token refresh temporarily failed. Please retry.": "Временная ошибка обновления токена ChatGPT. Повторите попытку.", + "A second factor is required": "Требуется второй фактор аутентификации", + "Sign in or provide registration details to accept this invitation": "Войдите или укажите данные регистрации, чтобы принять приглашение", + "Sign out before creating the invited local Account": "Выйдите перед созданием приглашённой локальной учётной записи", + "Turnstile verification failed": "Проверка Turnstile не пройдена. Повторите попытку", + "The invitation email does not match your Account": "Адрес приглашения не совпадает с вашей учётной записью", + "Failed to save ChatGPT credentials. Please retry.": "Не удалось сохранить учётные данные ChatGPT. Повторите попытку.", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "Не удалось начать вход с устройства. Включите вход по коду устройства в настройках ChatGPT.", + "Option {number}": "Вариант {number}", + "Confirmation needed": "Требуется подтверждение", + "Session reset successfully": "Сессия сброшена" +} diff --git a/src/langbot/pkg/i18n/locales/th_TH.json b/src/langbot/pkg/i18n/locales/th_TH.json new file mode 100644 index 000000000..ff40282cd --- /dev/null +++ b/src/langbot/pkg/i18n/locales/th_TH.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "คำขอล้มเหลว", + "Too many requests. Please try again later.": "มีคำขอมากเกินไป โปรดลองอีกครั้งภายหลัง", + "Your message contains inappropriate content. Please revise it.": "ข้อความมีเนื้อหาไม่เหมาะสม โปรดแก้ไข", + "Content checking failed. Please contact the administrator.": "ตรวจสอบเนื้อหาไม่สำเร็จ โปรดติดต่อผู้ดูแลระบบ", + "Calling {tools}...": "กำลังเรียกใช้ {tools}…", + "Agent runner not found: {runner}": "ไม่พบตัวรัน Agent: {runner}", + "Agent runner not authorized for this pipeline.": "ไปป์ไลน์นี้ไม่ได้รับอนุญาตให้ใช้ตัวรัน Agent", + "Agent runner temporarily unavailable. Please try again.": "ตัวรัน Agent ไม่พร้อมใช้งานชั่วคราว โปรดลองอีกครั้ง", + "Agent runner execution failed.": "การทำงานของ Agent ล้มเหลว", + "The Agent runner returned an invalid response.": "ตัวรัน Agent ส่งการตอบกลับที่ไม่ถูกต้อง", + "Model request failed": "คำขอไปยังโมเดลล้มเหลว", + "Context length exceeded": "ความยาวบริบทเกินขีดจำกัด", + "Invalid request parameters": "พารามิเตอร์คำขอไม่ถูกต้อง", + "Invalid API key": "คีย์ API ไม่ถูกต้อง", + "Invalid model or endpoint": "โมเดลหรือจุดเชื่อมต่อไม่ถูกต้อง", + "Rate limit exceeded or insufficient balance": "เกินขีดจำกัดคำขอหรือยอดเงินไม่เพียงพอ", + "Request timed out": "คำขอหมดเวลา", + "Connection failed": "การเชื่อมต่อล้มเหลว", + "API error": "ข้อผิดพลาด API", + "Unknown error": "ข้อผิดพลาดที่ไม่ทราบสาเหตุ", + "Internal server error": "ข้อผิดพลาดภายในเซิร์ฟเวอร์", + "Resource not found": "ไม่พบทรัพยากร", + "Permission denied": "ไม่มีสิทธิ์ดำเนินการนี้", + "Invalid authentication credentials": "ยืนยันตัวตนไม่สำเร็จ โปรดเข้าสู่ระบบอีกครั้ง", + "MCP runtime failed": "การทำงานของ MCP ล้มเหลว", + "Task execution failed": "การทำงานของงานล้มเหลว", + "Task was cancelled": "งานถูกยกเลิกแล้ว", + "Maximum number of {resource} ({limit}) reached": "ถึงจำนวนสูงสุดของ{resource} ({limit}) แล้ว", + "extensions": "ส่วนขยาย", + "knowledge bases": "ฐานความรู้", + "MCP server already exists: {name}": "มีเซิร์ฟเวอร์ MCP อยู่แล้ว: {name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models จัดการโดย Cloud และไม่สามารถแก้ไขได้", + "Please sign in with your LangBot Account": "โปรดเข้าสู่ระบบด้วยบัญชี LangBot", + "Current password is incorrect": "รหัสผ่านปัจจุบันไม่ถูกต้อง", + "Invalid TOTP or recovery code": "รหัส TOTP หรือรหัสกู้คืนไม่ถูกต้อง", + "No local password set, please set a password first": "ยังไม่ได้ตั้งรหัสผ่านในระบบ โปรดตั้งรหัสผ่านก่อน", + "This LangBot Account is already bound to another user": "บัญชี LangBot นี้เชื่อมโยงกับผู้ใช้อื่นแล้ว", + "Invalid recovery key": "คีย์กู้คืนไม่ถูกต้อง", + "Done": "เสร็จแล้ว", + "Thank you for your feedback": "ขอบคุณสำหรับความคิดเห็น", + "Failed to process feedback": "ประมวลผลความคิดเห็นไม่สำเร็จ", + "Helpful": "มีประโยชน์", + "Not helpful": "ไม่มีประโยชน์", + "Select an option": "โปรดเลือก", + "Enter a value": "โปรดป้อนค่า", + "Submit": "ส่ง", + "Submitted": "ส่งแล้ว", + "Thinking": "กระบวนการคิด", + "AI-generated": "สร้างโดย AI", + "AI-generated content. Refer to the cited sources for details.": "เนื้อหาสร้างโดย AI ดูรายละเอียดจากแหล่งอ้างอิง", + "No bridges configured.": "ยังไม่ได้ตั้งค่าบริดจ์", + "Signing in to all bridges again...": "กำลังเข้าสู่ระบบบริดจ์ทั้งหมดอีกครั้ง…", + "Bridge status:": "สถานะบริดจ์:", + "Signed in ✓": "เข้าสู่ระบบแล้ว ✓", + "Not signed in ✗": "ยังไม่ได้เข้าสู่ระบบ ✗", + "None": "ไม่มี", + "Bridge {bridge}: skipped (login command or DM room missing).": "บริดจ์ {bridge}: ข้ามแล้ว (ไม่มีคำสั่งเข้าสู่ระบบหรือห้อง DM)", + "Bridge {bridge}: sending {command}...": "บริดจ์ {bridge}: กำลังส่ง {command}…", + "Bridge {bridge}: sign-out failed.": "บริดจ์ {bridge}: ออกจากระบบไม่สำเร็จ", + "Bridge {bridge}: sign-in started.": "บริดจ์ {bridge}: เริ่มเข้าสู่ระบบแล้ว", + "Slack authorization completed. Return to LangBot to finish setup.": "อนุญาต Slack สำเร็จแล้ว กลับไปที่ LangBot เพื่อดำเนินการตั้งค่าให้เสร็จ", + "Slack authorization failed or expired. Return to LangBot for details.": "อนุญาต Slack ไม่สำเร็จหรือหมดอายุ ดูรายละเอียดใน LangBot", + "TOTP is already enabled for this Account": "บัญชีนี้เปิดใช้ TOTP แล้ว", + "TOTP is not enabled for this Account": "บัญชีนี้ยังไม่ได้เปิดใช้ TOTP", + "Invalid or expired passkey challenge": "คำขอยืนยัน Passkey ไม่ถูกต้องหรือหมดอายุ", + "Passkey credential not recognized": "ไม่รู้จัก Passkey นี้", + "Account is disabled": "บัญชีถูกปิดใช้งาน", + "Modifying login info is disabled": "ไม่อนุญาตให้แก้ไขข้อมูลเข้าสู่ระบบ", + "Invalid GitHub repository URL": "URL ที่เก็บ GitHub ไม่ถูกต้อง", + "Server is busy. Please try again later.": "เซิร์ฟเวอร์ไม่ว่าง โปรดลองอีกครั้งภายหลัง", + "This feature is unavailable in this Workspace.": "พื้นที่ทำงานนี้ไม่สามารถใช้ฟีเจอร์นี้ได้", + "The Workspace must retain an owner.": "พื้นที่ทำงานต้องมีเจ้าของอย่างน้อยหนึ่งคน", + "Invalid or expired invitation": "คำเชิญไม่ถูกต้องหรือหมดอายุ", + "This user is already a member.": "ผู้ใช้นี้เป็นสมาชิกอยู่แล้ว", + "Voice connection failed": "การเชื่อมต่อเสียงล้มเหลว", + "User is not in the voice channel": "ผู้ใช้ไม่ได้อยู่ในช่องเสียง", + "Voice service is unavailable": "บริการเสียงไม่พร้อมใช้งาน", + "Submission failed. Please try again.": "ส่งไม่สำเร็จ โปรดลองอีกครั้ง", + "File size exceeds the limit.": "ขนาดไฟล์เกินขีดจำกัด", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "ไฟล์ใหญ่กว่า 10MB โปรดแบ่งเป็นไฟล์ย่อย", + "Authorization failed. Please try signing in again.": "การอนุญาตล้มเหลว โปรดเข้าสู่ระบบอีกครั้ง", + "Bind the LangBot Account with the same email as this local Account": "เชื่อมโยงบัญชี LangBot ที่ใช้อีเมลเดียวกับบัญชีในระบบนี้", + "Only local accounts can bind to Space": "เฉพาะบัญชีในระบบเท่านั้นที่เชื่อมโยงกับ Space ได้", + "System already initialized": "ระบบเริ่มต้นแล้ว", + "System not initialized": "ระบบยังไม่ได้เริ่มต้น", + "Password login is disabled on LangBot Cloud": "LangBot Cloud ปิดการเข้าสู่ระบบด้วยรหัสผ่าน", + "This edition supports one Workspace per instance": "รุ่นนี้รองรับพื้นที่ทำงานหนึ่งแห่งต่ออินสแตนซ์", + "Cloud Workspaces are created by the SaaS control plane": "พื้นที่ทำงาน Cloud สร้างจากแผงควบคุม SaaS", + "Login with your LangBot Account to accept this invitation": "เข้าสู่ระบบด้วยบัญชี LangBot เพื่อรับคำเชิญ", + "RAG migration is not needed": "ไม่จำเป็นต้องย้าย RAG", + "New Agent": "Agent ใหม่", + "New Pipeline": "ไปป์ไลน์ใหม่", + "Provider {provider} does not support {model_type} models": "ผู้ให้บริการ {provider} ไม่รองรับโมเดล {model_type}", + "Invalid JSON format": "รูปแบบ JSON ไม่ถูกต้อง", + "ChatGPT sign-in required. Open this provider and sign in again.": "ต้องเข้าสู่ระบบ ChatGPT โปรดเปิดผู้ให้บริการนี้แล้วเข้าสู่ระบบอีกครั้ง", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "คำขอ ChatGPT ถูกจำกัดตามความถี่หรือปริมาณการใช้งาน โปรดลองภายหลังหรือตรวจสอบแผนของคุณ", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT ปฏิเสธคำขอ โปรดตรวจสอบการสมัครสมาชิกและสิทธิ์ของพื้นที่ทำงาน", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT ปฏิเสธโมเดลหรือคำขอ โปรดตรวจสอบโมเดลและการตั้งค่าคำขอ", + "ChatGPT Codex upstream request failed. Please retry later.": "คำขอไปยัง ChatGPT Codex ล้มเหลว โปรดลองอีกครั้งภายหลัง", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "การใช้งานสมาชิก ChatGPT ถึงขีดจำกัดแล้ว โปรดลองภายหลังหรือตรวจสอบแผนของคุณ", + "ChatGPT Codex response failed or was incomplete. Please retry.": "การตอบกลับของ ChatGPT Codex ล้มเหลวหรือไม่ครบถ้วน โปรดลองอีกครั้ง", + "ChatGPT token refresh temporarily failed. Please retry.": "ไม่สามารถรีเฟรชโทเค็น ChatGPT ได้ชั่วคราว โปรดลองอีกครั้ง", + "A second factor is required": "ต้องยืนยันตัวตนสองขั้นตอน", + "Sign in or provide registration details to accept this invitation": "เข้าสู่ระบบหรือกรอกข้อมูลลงทะเบียนเพื่อยอมรับคำเชิญนี้", + "Sign out before creating the invited local Account": "ออกจากระบบก่อนสร้างบัญชีภายในที่ได้รับเชิญ", + "Turnstile verification failed": "การยืนยัน Turnstile ล้มเหลว โปรดลองอีกครั้ง", + "The invitation email does not match your Account": "อีเมลคำเชิญไม่ตรงกับบัญชีของคุณ", + "Failed to save ChatGPT credentials. Please retry.": "บันทึกข้อมูลรับรอง ChatGPT ไม่สำเร็จ โปรดลองอีกครั้ง", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "ไม่สามารถเริ่มเข้าสู่ระบบด้วยอุปกรณ์ได้ โปรดเปิดการเข้าสู่ระบบด้วยรหัสอุปกรณ์ในการตั้งค่า ChatGPT", + "Option {number}": "ตัวเลือก {number}", + "Confirmation needed": "ต้องยืนยัน", + "Session reset successfully": "รีเซ็ตเซสชันแล้ว" +} diff --git a/src/langbot/pkg/i18n/locales/vi_VN.json b/src/langbot/pkg/i18n/locales/vi_VN.json new file mode 100644 index 000000000..39b3110a8 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/vi_VN.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "Yêu cầu thất bại.", + "Too many requests. Please try again later.": "Quá nhiều yêu cầu. Vui lòng thử lại sau.", + "Your message contains inappropriate content. Please revise it.": "Tin nhắn chứa nội dung không phù hợp. Vui lòng chỉnh sửa.", + "Content checking failed. Please contact the administrator.": "Kiểm tra nội dung thất bại. Vui lòng liên hệ quản trị viên.", + "Calling {tools}...": "Đang gọi {tools}…", + "Agent runner not found: {runner}": "Không tìm thấy trình chạy Agent: {runner}", + "Agent runner not authorized for this pipeline.": "Pipeline này không được phép sử dụng trình chạy Agent.", + "Agent runner temporarily unavailable. Please try again.": "Trình chạy Agent tạm thời không khả dụng. Vui lòng thử lại.", + "Agent runner execution failed.": "Thực thi Agent thất bại.", + "The Agent runner returned an invalid response.": "Trình chạy Agent trả về phản hồi không hợp lệ.", + "Model request failed": "Yêu cầu đến mô hình thất bại", + "Context length exceeded": "Đã vượt quá độ dài ngữ cảnh", + "Invalid request parameters": "Tham số yêu cầu không hợp lệ", + "Invalid API key": "Khóa API không hợp lệ", + "Invalid model or endpoint": "Mô hình hoặc địa chỉ API không hợp lệ", + "Rate limit exceeded or insufficient balance": "Vượt giới hạn yêu cầu hoặc số dư không đủ", + "Request timed out": "Yêu cầu đã hết thời gian chờ", + "Connection failed": "Kết nối thất bại", + "API error": "Lỗi API", + "Unknown error": "Lỗi không xác định", + "Internal server error": "Lỗi máy chủ nội bộ", + "Resource not found": "Không tìm thấy tài nguyên", + "Permission denied": "Bạn không có quyền thực hiện thao tác này", + "Invalid authentication credentials": "Xác thực thất bại. Vui lòng đăng nhập lại", + "MCP runtime failed": "Thực thi MCP thất bại", + "Task execution failed": "Thực thi tác vụ thất bại", + "Task was cancelled": "Tác vụ đã bị hủy", + "Maximum number of {resource} ({limit}) reached": "Đã đạt số lượng tối đa của {resource} ({limit})", + "extensions": "tiện ích mở rộng", + "knowledge bases": "cơ sở tri thức", + "MCP server already exists: {name}": "Máy chủ MCP đã tồn tại: {name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models được quản lý bởi Cloud và không thể chỉnh sửa", + "Please sign in with your LangBot Account": "Vui lòng đăng nhập bằng tài khoản LangBot", + "Current password is incorrect": "Mật khẩu hiện tại không đúng", + "Invalid TOTP or recovery code": "Mã TOTP hoặc mã khôi phục không hợp lệ", + "No local password set, please set a password first": "Chưa có mật khẩu cục bộ. Vui lòng đặt mật khẩu trước", + "This LangBot Account is already bound to another user": "Tài khoản LangBot này đã được liên kết với người dùng khác", + "Invalid recovery key": "Khóa khôi phục không hợp lệ", + "Done": "Hoàn tất", + "Thank you for your feedback": "Cảm ơn bạn đã phản hồi", + "Failed to process feedback": "Không thể xử lý phản hồi", + "Helpful": "Hữu ích", + "Not helpful": "Không hữu ích", + "Select an option": "Chọn một mục", + "Enter a value": "Nhập giá trị", + "Submit": "Gửi", + "Submitted": "Đã gửi", + "Thinking": "Quá trình suy nghĩ", + "AI-generated": "Được tạo bởi AI", + "AI-generated content. Refer to the cited sources for details.": "Nội dung do AI tạo. Xem nguồn trích dẫn để biết chi tiết.", + "No bridges configured.": "Chưa cấu hình cầu nối nào.", + "Signing in to all bridges again...": "Đang đăng nhập lại vào tất cả cầu nối…", + "Bridge status:": "Trạng thái cầu nối:", + "Signed in ✓": "Đã đăng nhập ✓", + "Not signed in ✗": "Chưa đăng nhập ✗", + "None": "Không có", + "Bridge {bridge}: skipped (login command or DM room missing).": "Cầu nối {bridge}: đã bỏ qua (thiếu lệnh đăng nhập hoặc phòng DM).", + "Bridge {bridge}: sending {command}...": "Cầu nối {bridge}: đang gửi {command}…", + "Bridge {bridge}: sign-out failed.": "Cầu nối {bridge}: đăng xuất thất bại.", + "Bridge {bridge}: sign-in started.": "Cầu nối {bridge}: đã bắt đầu đăng nhập.", + "Slack authorization completed. Return to LangBot to finish setup.": "Đã cấp quyền Slack. Quay lại LangBot để hoàn tất thiết lập.", + "Slack authorization failed or expired. Return to LangBot for details.": "Cấp quyền Slack thất bại hoặc đã hết hạn. Xem chi tiết trong LangBot.", + "TOTP is already enabled for this Account": "Tài khoản này đã bật TOTP", + "TOTP is not enabled for this Account": "Tài khoản này chưa bật TOTP", + "Invalid or expired passkey challenge": "Yêu cầu xác minh passkey không hợp lệ hoặc đã hết hạn", + "Passkey credential not recognized": "Không nhận diện được passkey", + "Account is disabled": "Tài khoản đã bị vô hiệu hóa", + "Modifying login info is disabled": "Không được phép sửa thông tin đăng nhập", + "Invalid GitHub repository URL": "URL kho GitHub không hợp lệ", + "Server is busy. Please try again later.": "Máy chủ đang bận. Vui lòng thử lại sau.", + "This feature is unavailable in this Workspace.": "Tính năng này không khả dụng trong không gian làm việc này.", + "The Workspace must retain an owner.": "Không gian làm việc phải có ít nhất một chủ sở hữu.", + "Invalid or expired invitation": "Lời mời không hợp lệ hoặc đã hết hạn", + "This user is already a member.": "Người dùng này đã là thành viên.", + "Voice connection failed": "Kết nối thoại thất bại", + "User is not in the voice channel": "Người dùng không ở trong kênh thoại", + "Voice service is unavailable": "Dịch vụ thoại không khả dụng", + "Submission failed. Please try again.": "Gửi thất bại. Vui lòng thử lại.", + "File size exceeds the limit.": "Kích thước tệp vượt quá giới hạn.", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "Tệp vượt quá 10MB. Vui lòng chia thành các phần nhỏ hơn.", + "Authorization failed. Please try signing in again.": "Cấp quyền thất bại. Vui lòng đăng nhập lại.", + "Bind the LangBot Account with the same email as this local Account": "Liên kết tài khoản LangBot có cùng email với tài khoản cục bộ này", + "Only local accounts can bind to Space": "Chỉ tài khoản cục bộ mới có thể liên kết với Space", + "System already initialized": "Hệ thống đã được khởi tạo", + "System not initialized": "Hệ thống chưa được khởi tạo", + "Password login is disabled on LangBot Cloud": "LangBot Cloud không cho phép đăng nhập bằng mật khẩu", + "This edition supports one Workspace per instance": "Phiên bản này hỗ trợ một không gian làm việc cho mỗi phiên bản triển khai", + "Cloud Workspaces are created by the SaaS control plane": "Không gian làm việc Cloud được tạo từ bảng điều khiển SaaS", + "Login with your LangBot Account to accept this invitation": "Đăng nhập bằng tài khoản LangBot để chấp nhận lời mời", + "RAG migration is not needed": "Không cần di chuyển RAG", + "New Agent": "Agent mới", + "New Pipeline": "Pipeline mới", + "Provider {provider} does not support {model_type} models": "Nhà cung cấp {provider} không hỗ trợ mô hình {model_type}", + "Invalid JSON format": "Định dạng JSON không hợp lệ", + "ChatGPT sign-in required. Open this provider and sign in again.": "Cần đăng nhập ChatGPT. Mở nhà cung cấp này và đăng nhập lại.", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "Yêu cầu ChatGPT bị giới hạn tần suất hoặc mức sử dụng. Hãy thử lại sau hoặc kiểm tra gói của bạn.", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT đã từ chối yêu cầu. Kiểm tra gói đăng ký và quyền của không gian làm việc.", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT đã từ chối mô hình hoặc yêu cầu. Kiểm tra mô hình đã chọn và cài đặt yêu cầu.", + "ChatGPT Codex upstream request failed. Please retry later.": "Yêu cầu tới ChatGPT Codex thất bại. Vui lòng thử lại sau.", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "Đã đạt giới hạn sử dụng gói ChatGPT. Hãy thử lại sau hoặc kiểm tra gói của bạn.", + "ChatGPT Codex response failed or was incomplete. Please retry.": "Phản hồi ChatGPT Codex thất bại hoặc chưa hoàn tất. Vui lòng thử lại.", + "ChatGPT token refresh temporarily failed. Please retry.": "Tạm thời không thể làm mới token ChatGPT. Vui lòng thử lại.", + "A second factor is required": "Cần xác thực hai yếu tố", + "Sign in or provide registration details to accept this invitation": "Đăng nhập hoặc cung cấp thông tin đăng ký để chấp nhận lời mời", + "Sign out before creating the invited local Account": "Đăng xuất trước khi tạo tài khoản cục bộ được mời", + "Turnstile verification failed": "Xác minh Turnstile thất bại. Vui lòng thử lại.", + "The invitation email does not match your Account": "Email trong lời mời không khớp với tài khoản của bạn", + "Failed to save ChatGPT credentials. Please retry.": "Không thể lưu thông tin xác thực ChatGPT. Vui lòng thử lại.", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "Không thể bắt đầu đăng nhập thiết bị ChatGPT. Hãy bật đăng nhập bằng mã thiết bị trong cài đặt ChatGPT.", + "Option {number}": "Lựa chọn {number}", + "Confirmation needed": "Cần xác nhận", + "Session reset successfully": "Đã đặt lại phiên" +} diff --git a/src/langbot/pkg/i18n/locales/zh_Hans.json b/src/langbot/pkg/i18n/locales/zh_Hans.json new file mode 100644 index 000000000..29a12e840 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/zh_Hans.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "请求失败。", + "Too many requests. Please try again later.": "请求过于频繁,请稍后重试。", + "Your message contains inappropriate content. Please revise it.": "消息中存在不合适的内容,请修改。", + "Content checking failed. Please contact the administrator.": "内容检查失败,请联系管理员。", + "Calling {tools}...": "正在调用 {tools}…", + "Agent runner not found: {runner}": "找不到 Agent 运行器:{runner}", + "Agent runner not authorized for this pipeline.": "此流水线未获授权使用该 Agent 运行器。", + "Agent runner temporarily unavailable. Please try again.": "Agent 运行器暂时不可用,请重试。", + "Agent runner execution failed.": "Agent 执行失败。", + "The Agent runner returned an invalid response.": "Agent 运行器返回了无效响应。", + "Model request failed": "模型请求失败", + "Context length exceeded": "上下文长度超限", + "Invalid request parameters": "请求参数错误", + "Invalid API key": "API 密钥无效", + "Invalid model or endpoint": "模型或接口地址无效", + "Rate limit exceeded or insufficient balance": "请求过于频繁或余额不足", + "Request timed out": "请求超时", + "Connection failed": "连接失败", + "API error": "API 错误", + "Unknown error": "未知错误", + "Internal server error": "服务器内部错误", + "Resource not found": "资源不存在", + "Permission denied": "没有执行此操作的权限", + "Invalid authentication credentials": "身份验证失败,请重新登录", + "MCP runtime failed": "MCP 运行失败", + "Task execution failed": "任务执行失败", + "Task was cancelled": "任务已取消", + "Maximum number of {resource} ({limit}) reached": "{resource}数量已达到上限({limit})", + "extensions": "扩展", + "knowledge bases": "知识库", + "MCP server already exists: {name}": "MCP 服务器已存在:{name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models 由云端管理,无法修改", + "Please sign in with your LangBot Account": "请使用 LangBot 账号登录", + "Current password is incorrect": "当前密码不正确", + "Invalid TOTP or recovery code": "验证码或恢复码无效", + "No local password set, please set a password first": "尚未设置本地密码,请先设置密码", + "This LangBot Account is already bound to another user": "此 LangBot 账号已绑定其他用户", + "Invalid recovery key": "恢复密钥无效", + "Done": "操作成功", + "Thank you for your feedback": "感谢您的反馈", + "Failed to process feedback": "反馈处理失败", + "Helpful": "有帮助", + "Not helpful": "无帮助", + "Select an option": "请选择", + "Enter a value": "请输入", + "Submit": "提交", + "Submitted": "已提交", + "Thinking": "思考过程", + "AI-generated": "内容由 AI 生成", + "AI-generated content. Refer to the cited sources for details.": "内容由 AI 生成,详情请参考引用来源。", + "No bridges configured.": "没有配置任何桥。", + "Signing in to all bridges again...": "正在重新登录所有桥…", + "Bridge status:": "桥状态:", + "Signed in ✓": "已登录 ✓", + "Not signed in ✗": "未登录 ✗", + "None": "无", + "Bridge {bridge}: skipped (login command or DM room missing).": "桥 {bridge}:已跳过(缺少登录命令或私聊房间)。", + "Bridge {bridge}: sending {command}...": "桥 {bridge}:正在发送 {command}…", + "Bridge {bridge}: sign-out failed.": "桥 {bridge}:退出登录失败。", + "Bridge {bridge}: sign-in started.": "桥 {bridge}:已开始登录。", + "Slack authorization completed. Return to LangBot to finish setup.": "Slack 授权完成,请返回 LangBot 完成配置。", + "Slack authorization failed or expired. Return to LangBot for details.": "Slack 授权失败或已过期,请返回 LangBot 查看详情。", + "TOTP is already enabled for this Account": "此账号已启用两步验证", + "TOTP is not enabled for this Account": "此账号尚未启用两步验证", + "Invalid or expired passkey challenge": "通行密钥验证请求无效或已过期", + "Passkey credential not recognized": "无法识别此通行密钥", + "Account is disabled": "账号已停用", + "Modifying login info is disabled": "不允许修改登录信息", + "Invalid GitHub repository URL": "GitHub 仓库地址无效", + "Server is busy. Please try again later.": "服务器繁忙,请稍后重试。", + "This feature is unavailable in this Workspace.": "此工作区无法使用该功能。", + "The Workspace must retain an owner.": "工作区必须保留一位所有者。", + "Invalid or expired invitation": "邀请无效或已过期", + "This user is already a member.": "此用户已是工作区成员。", + "Voice connection failed": "语音连接失败", + "User is not in the voice channel": "用户不在语音频道中", + "Voice service is unavailable": "语音服务不可用", + "Submission failed. Please try again.": "提交失败,请重试。", + "File size exceeds the limit.": "文件大小超过限制。", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "文件大小超过 10MB,请拆分后上传。", + "Authorization failed. Please try signing in again.": "授权失败,请重新登录。", + "Bind the LangBot Account with the same email as this local Account": "请绑定与此本地账号邮箱相同的 LangBot 账号", + "Only local accounts can bind to Space": "只有本地账号可以绑定 Space", + "System already initialized": "系统已初始化", + "System not initialized": "系统尚未初始化", + "Password login is disabled on LangBot Cloud": "LangBot Cloud 不支持密码登录", + "This edition supports one Workspace per instance": "此版本每个实例仅支持一个工作区", + "Cloud Workspaces are created by the SaaS control plane": "云工作区由 SaaS 控制台创建", + "Login with your LangBot Account to accept this invitation": "请登录 LangBot 账号以接受邀请", + "RAG migration is not needed": "无需迁移知识库", + "New Agent": "新 Agent", + "New Pipeline": "新流水线", + "Provider {provider} does not support {model_type} models": "提供商 {provider} 不支持 {model_type} 模型", + "Invalid JSON format": "JSON 格式无效", + "ChatGPT sign-in required. Open this provider and sign in again.": "需要登录 ChatGPT。请打开此提供商并重新登录。", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT 请求受限(请求频率或用量限制)。请稍后重试或检查套餐。", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT 拒绝了此请求。请检查订阅和工作区权限。", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT 拒绝了模型或请求。请检查所选模型和请求设置。", + "ChatGPT Codex upstream request failed. Please retry later.": "ChatGPT Codex 上游请求失败,请稍后重试。", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "ChatGPT 订阅用量已达上限。请稍后重试或检查套餐。", + "ChatGPT Codex response failed or was incomplete. Please retry.": "ChatGPT Codex 响应失败或不完整,请重试。", + "ChatGPT token refresh temporarily failed. Please retry.": "ChatGPT 令牌暂时刷新失败,请重试。", + "A second factor is required": "需要二次验证", + "Sign in or provide registration details to accept this invitation": "请登录或填写注册信息以接受此邀请", + "Sign out before creating the invited local Account": "请先退出登录,再创建受邀本地账号", + "Turnstile verification failed": "人机验证失败,请重试", + "The invitation email does not match your Account": "邀请邮箱与您的账号不匹配", + "Failed to save ChatGPT credentials. Please retry.": "保存 ChatGPT 凭据失败,请重试。", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "无法启动 ChatGPT 设备登录。请在 ChatGPT 设置中启用设备代码登录。", + "Option {number}": "选项 {number}", + "Confirmation needed": "需要确认", + "Session reset successfully": "会话已重置" +} diff --git a/src/langbot/pkg/i18n/locales/zh_Hant.json b/src/langbot/pkg/i18n/locales/zh_Hant.json new file mode 100644 index 000000000..15f5e90c3 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/zh_Hant.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "請求失敗。", + "Too many requests. Please try again later.": "請求過於頻繁,請稍後重試。", + "Your message contains inappropriate content. Please revise it.": "訊息中存在不適當的內容,請修改。", + "Content checking failed. Please contact the administrator.": "內容檢查失敗,請聯絡管理員。", + "Calling {tools}...": "正在呼叫 {tools}…", + "Agent runner not found: {runner}": "找不到 Agent 執行器:{runner}", + "Agent runner not authorized for this pipeline.": "此流水線未獲授權使用該 Agent 執行器。", + "Agent runner temporarily unavailable. Please try again.": "Agent 執行器暫時無法使用,請重試。", + "Agent runner execution failed.": "Agent 執行失敗。", + "The Agent runner returned an invalid response.": "Agent 執行器傳回了無效回應。", + "Model request failed": "模型請求失敗", + "Context length exceeded": "上下文長度超出限制", + "Invalid request parameters": "請求參數錯誤", + "Invalid API key": "API 金鑰無效", + "Invalid model or endpoint": "模型或介面位址無效", + "Rate limit exceeded or insufficient balance": "請求過於頻繁或餘額不足", + "Request timed out": "請求逾時", + "Connection failed": "連線失敗", + "API error": "API 錯誤", + "Unknown error": "未知錯誤", + "Internal server error": "伺服器內部錯誤", + "Resource not found": "資源不存在", + "Permission denied": "沒有執行此操作的權限", + "Invalid authentication credentials": "身分驗證失敗,請重新登入", + "MCP runtime failed": "MCP 執行失敗", + "Task execution failed": "任務執行失敗", + "Task was cancelled": "任務已取消", + "Maximum number of {resource} ({limit}) reached": "{resource}數量已達上限({limit})", + "extensions": "擴充功能", + "knowledge bases": "知識庫", + "MCP server already exists: {name}": "MCP 伺服器已存在:{name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models 由雲端管理,無法修改", + "Please sign in with your LangBot Account": "請使用 LangBot 帳號登入", + "Current password is incorrect": "目前密碼不正確", + "Invalid TOTP or recovery code": "驗證碼或復原碼無效", + "No local password set, please set a password first": "尚未設定本機密碼,請先設定密碼", + "This LangBot Account is already bound to another user": "此 LangBot 帳號已綁定其他使用者", + "Invalid recovery key": "復原金鑰無效", + "Done": "操作成功", + "Thank you for your feedback": "感謝您的回饋", + "Failed to process feedback": "回饋處理失敗", + "Helpful": "有幫助", + "Not helpful": "沒有幫助", + "Select an option": "請選擇", + "Enter a value": "請輸入", + "Submit": "提交", + "Submitted": "已提交", + "Thinking": "思考過程", + "AI-generated": "內容由 AI 產生", + "AI-generated content. Refer to the cited sources for details.": "內容由 AI 產生,詳情請參考引用來源。", + "No bridges configured.": "尚未設定任何橋接。", + "Signing in to all bridges again...": "正在重新登入所有橋接…", + "Bridge status:": "橋接狀態:", + "Signed in ✓": "已登入 ✓", + "Not signed in ✗": "未登入 ✗", + "None": "無", + "Bridge {bridge}: skipped (login command or DM room missing).": "橋接 {bridge}:已略過(缺少登入命令或私訊房間)。", + "Bridge {bridge}: sending {command}...": "橋接 {bridge}:正在傳送 {command}…", + "Bridge {bridge}: sign-out failed.": "橋接 {bridge}:登出失敗。", + "Bridge {bridge}: sign-in started.": "橋接 {bridge}:已開始登入。", + "Slack authorization completed. Return to LangBot to finish setup.": "Slack 授權完成,請返回 LangBot 完成設定。", + "Slack authorization failed or expired. Return to LangBot for details.": "Slack 授權失敗或已過期,請返回 LangBot 查看詳情。", + "TOTP is already enabled for this Account": "此帳號已啟用兩步驗證", + "TOTP is not enabled for this Account": "此帳號尚未啟用兩步驗證", + "Invalid or expired passkey challenge": "通行金鑰驗證請求無效或已過期", + "Passkey credential not recognized": "無法識別此通行金鑰", + "Account is disabled": "帳號已停用", + "Modifying login info is disabled": "不允許修改登入資訊", + "Invalid GitHub repository URL": "GitHub 儲存庫網址無效", + "Server is busy. Please try again later.": "伺服器忙碌中,請稍後重試。", + "This feature is unavailable in this Workspace.": "此工作區無法使用該功能。", + "The Workspace must retain an owner.": "工作區必須保留一位擁有者。", + "Invalid or expired invitation": "邀請無效或已過期", + "This user is already a member.": "此使用者已是工作區成員。", + "Voice connection failed": "語音連線失敗", + "User is not in the voice channel": "使用者不在語音頻道中", + "Voice service is unavailable": "語音服務無法使用", + "Submission failed. Please try again.": "提交失敗,請重試。", + "File size exceeds the limit.": "檔案大小超過限制。", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "檔案大小超過 10MB,請分割後上傳。", + "Authorization failed. Please try signing in again.": "授權失敗,請重新登入。", + "Bind the LangBot Account with the same email as this local Account": "請綁定與此本機帳號電子郵件相同的 LangBot 帳號", + "Only local accounts can bind to Space": "只有本機帳號可以綁定 Space", + "System already initialized": "系統已初始化", + "System not initialized": "系統尚未初始化", + "Password login is disabled on LangBot Cloud": "LangBot Cloud 不支援密碼登入", + "This edition supports one Workspace per instance": "此版本每個執行個體僅支援一個工作區", + "Cloud Workspaces are created by the SaaS control plane": "雲端工作區由 SaaS 控制台建立", + "Login with your LangBot Account to accept this invitation": "請登入 LangBot 帳號以接受邀請", + "RAG migration is not needed": "無需遷移知識庫", + "New Agent": "新 Agent", + "New Pipeline": "新流水線", + "Provider {provider} does not support {model_type} models": "提供商 {provider} 不支援 {model_type} 模型", + "Invalid JSON format": "JSON 格式無效", + "ChatGPT sign-in required. Open this provider and sign in again.": "需要登入 ChatGPT。請開啟此供應商並重新登入。", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT 請求受限(請求頻率或用量限制)。請稍後重試或檢查方案。", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT 拒絕了此請求。請檢查訂閱和工作區權限。", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT 拒絕了模型或請求。請檢查所選模型和請求設定。", + "ChatGPT Codex upstream request failed. Please retry later.": "ChatGPT Codex 上游請求失敗,請稍後重試。", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "ChatGPT 訂閱用量已達上限。請稍後重試或檢查方案。", + "ChatGPT Codex response failed or was incomplete. Please retry.": "ChatGPT Codex 回應失敗或不完整,請重試。", + "ChatGPT token refresh temporarily failed. Please retry.": "ChatGPT 權杖暫時更新失敗,請重試。", + "A second factor is required": "需要雙重驗證", + "Sign in or provide registration details to accept this invitation": "請登入或填寫註冊資料以接受此邀請", + "Sign out before creating the invited local Account": "請先登出,再建立受邀本機帳號", + "Turnstile verification failed": "人機驗證失敗,請重試", + "The invitation email does not match your Account": "邀請信箱與您的帳號不符", + "Failed to save ChatGPT credentials. Please retry.": "儲存 ChatGPT 憑證失敗,請重試。", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "無法啟動 ChatGPT 裝置登入。請在 ChatGPT 設定中啟用裝置代碼登入。", + "Option {number}": "選項 {number}", + "Confirmation needed": "需要確認", + "Session reset successfully": "對話已重設" +} diff --git a/src/langbot/pkg/i18n/workspace.py b/src/langbot/pkg/i18n/workspace.py new file mode 100644 index 000000000..0527b818b --- /dev/null +++ b/src/langbot/pkg/i18n/workspace.py @@ -0,0 +1,71 @@ +"""Workspace-owned message language, stored in the existing tenant metadata.""" + +from __future__ import annotations + +import time +from collections import OrderedDict + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert + +from ..entity.persistence.metadata import WorkspaceMetadata +from . import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, normalize_language + +LANGUAGE_KEY = 'message_language' +_CACHE_TTL = 30 +_CACHE_SIZE = 256 + + +def _cache(ap) -> OrderedDict: + cache = getattr(ap, '_message_language_cache', None) + if cache is None: + cache = OrderedDict() + ap._message_language_cache = cache + return cache + + +async def get_workspace_language(ap, workspace_uuid: str, *, refresh: bool = False) -> str: + if not workspace_uuid: + return DEFAULT_LANGUAGE + cache = _cache(ap) + cached = cache.get(workspace_uuid) + if not refresh and cached and cached[1] > time.monotonic(): + return cached[0] + manager = getattr(ap, 'persistence_mgr', None) + if manager is None or not callable(getattr(manager, 'tenant_uow', None)): + return DEFAULT_LANGUAGE + async with manager.tenant_uow(workspace_uuid) as uow: + result = await uow.execute( + sa.select(WorkspaceMetadata.value).where( + WorkspaceMetadata.workspace_uuid == workspace_uuid, + WorkspaceMetadata.key == LANGUAGE_KEY, + ) + ) + locale = normalize_language(result.scalar_one_or_none()) + cache[workspace_uuid] = (locale, time.monotonic() + _CACHE_TTL) + cache.move_to_end(workspace_uuid) + while len(cache) > _CACHE_SIZE: + cache.popitem(last=False) + return locale + + +async def set_workspace_language(ap, workspace_uuid: str, locale: str) -> str: + if locale not in SUPPORTED_LANGUAGES: + raise ValueError('Unsupported Workspace language') + async with ap.persistence_mgr.tenant_uow(workspace_uuid) as uow: + dialect = ap.persistence_mgr.get_db_engine().dialect.name + insert = pg_insert if dialect == 'postgresql' else sqlite_insert + statement = insert(WorkspaceMetadata).values(workspace_uuid=workspace_uuid, key=LANGUAGE_KEY, value=locale) + await uow.execute( + statement.on_conflict_do_update( + index_elements=['workspace_uuid', 'key'], + set_={'value': locale}, + ) + ) + # Synchronous platform callbacks may run in SDK threads. Publish the new + # value only after commit, so they see changes without restarting a bot. + _cache(ap)[workspace_uuid] = (locale, time.monotonic() + _CACHE_TTL) + while len(_cache(ap)) > _CACHE_SIZE: + _cache(ap).popitem(last=False) + return locale diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py b/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py index 8c74150b0..444fd4798 100644 --- a/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py +++ b/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import pipeline_notice + from .. import entities from .. import filter as filter_model import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @@ -58,6 +60,10 @@ class BaiduCloudExamine(filter_model.ContentFilter): return entities.FilterResult( level=entities.ResultLevel.BLOCK, replacement=message, - user_notice='消息中存在不合适的内容, 请修改', + user_notice=pipeline_notice( + query, + 'content-filter-hint', + 'Your message contains inappropriate content. Please revise it.', + ), console_notice=f'百度云判定结果:{conclusion}', ) diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py index bfd8ca772..32a3a5f30 100644 --- a/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py +++ b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import pipeline_notice + from .. import filter as filter_model from .. import entities import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @@ -35,13 +37,19 @@ class BanWordFilter(filter_model.ContentFilter): return entities.FilterResult( level=entities.ResultLevel.BLOCK, replacement='', - user_notice='内容检查规则执行失败,请联系管理员', + user_notice=pipeline_notice( + query, 'content-filter-error-hint', 'Content checking failed. Please contact the administrator.' + ), console_notice=f'Sensitive-word regex rejected: {exc}', ) return entities.FilterResult( level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS, replacement=current, - user_notice='消息中存在不合适的内容, 请修改' if found else '', + user_notice=pipeline_notice( + query, 'content-filter-hint', 'Your message contains inappropriate content. Please revise it.' + ) + if found + else '', console_notice='', ) diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py index e15be688f..fc7aaaf31 100644 --- a/src/langbot/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -20,6 +20,8 @@ from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType from ..workspace.errors import WorkspaceError, WorkspaceInvariantError from .config_coercion import coerce_pipeline_config from .pool import get_query_execution_context +from ..i18n import language_scope +from ..i18n.workspace import get_workspace_language from ..agent.runner.config_resolver import RunnerConfigResolver from .extension_preferences import normalize_extension_preferences @@ -414,6 +416,11 @@ class RuntimePipeline: return event_type[:160] or 'message.received' async def process_query(self, query: pipeline_query.Query): + workspace_uuid = getattr(query, 'workspace_uuid', None) or getattr(self, 'workspace_uuid', '') + with language_scope(await get_workspace_language(self.ap, workspace_uuid)): + return await self._process_query_with_language(query) + + async def _process_query_with_language(self, query: pipeline_query.Query): from ..telemetry import trace as trace_mod from ..telemetry.execution import ingress from ..telemetry.execution import record as record_execution diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index 1bc6143b9..5226d56b0 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import pipeline_notice + import uuid import typing import traceback @@ -238,14 +240,20 @@ class ChatMessageHandler(handler.MessageHandler): # Handle specific runner errors with appropriate messages if isinstance(e, RunnerNotFoundError): - user_notice = f'Agent runner not found: {e.runner_id}' + user_notice = pipeline_notice( + query, 'runner-not-found-hint', 'Agent runner not found: {runner}', runner=e.runner_id + ) elif isinstance(e, RunnerNotAuthorizedError): - user_notice = 'Agent runner not authorized for this pipeline' + user_notice = pipeline_notice( + query, 'runner-not-authorized-hint', 'Agent runner not authorized for this pipeline.' + ) elif isinstance(e, RunnerExecutionError): if e.retryable: - user_notice = 'Agent runner temporarily unavailable. Please try again.' + user_notice = pipeline_notice( + query, 'runner-unavailable-hint', 'Agent runner temporarily unavailable. Please try again.' + ) else: - user_notice = 'Agent runner execution failed.' + user_notice = pipeline_notice(query, 'failure-hint', 'Agent runner execution failed.') else: # Use existing exception handling exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint') @@ -253,7 +261,7 @@ class ChatMessageHandler(handler.MessageHandler): if exception_handling == 'show-error': user_notice = f'{e}' elif exception_handling == 'show-hint': - user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.') + user_notice = pipeline_notice(query, 'failure-hint', 'Request failed.') else: # hide user_notice = None diff --git a/src/langbot/pkg/pipeline/ratelimit/ratelimit.py b/src/langbot/pkg/pipeline/ratelimit/ratelimit.py index cab62b8d5..8ee1bb639 100644 --- a/src/langbot/pkg/pipeline/ratelimit/ratelimit.py +++ b/src/langbot/pkg/pipeline/ratelimit/ratelimit.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ...i18n import pipeline_notice + import typing from .. import entities, stage @@ -62,7 +64,7 @@ class RateLimit(stage.PipelineStage): result_type=entities.ResultType.INTERRUPT, new_query=query, console_notice=f'根据限速规则忽略 {query.launcher_type.value}:{query.launcher_id} 消息', - user_notice='请求数超过限速器设定值,已丢弃本消息。', + user_notice=pipeline_notice(query, 'rate-limit-hint', 'Too many requests. Please try again later.'), ) elif stage_inst_name == 'ReleaseRateLimitOccupancy': await self.algo.release_access( diff --git a/src/langbot/pkg/pipeline/wrapper/wrapper.py b/src/langbot/pkg/pipeline/wrapper/wrapper.py index 9ea275e1d..2dcda3a05 100644 --- a/src/langbot/pkg/pipeline/wrapper/wrapper.py +++ b/src/langbot/pkg/pipeline/wrapper/wrapper.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ...i18n import pipeline_notice + import typing from .. import entities @@ -175,7 +177,9 @@ class ResponseWrapper(stage.PipelineStage): if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用 function_names = [tc.function.name for tc in result.tool_calls] - reply_text = f'Call {".".join(function_names)}...' + reply_text = pipeline_notice( + query, 'tool-call-hint', 'Calling {tools}...', tools=', '.join(function_names) + ) # Only surface the tool-call notice when the pipeline option # is enabled. Emitting it unconditionally would become the diff --git a/src/langbot/pkg/platform/adapters/lark/adapter.py b/src/langbot/pkg/platform/adapters/lark/adapter.py index df820ae74..aa04ae8a7 100644 --- a/src/langbot/pkg/platform/adapters/lark/adapter.py +++ b/src/langbot/pkg/platform/adapters/lark/adapter.py @@ -1,5 +1,7 @@ from __future__ import annotations +from langbot.pkg.i18n.adapters import adapter_t, localized_adapter + from langbot.pkg.platform.sources.lark import ( LarkAdapter as LegacyLarkAdapter, NonBlockingLarkWSClient, @@ -347,6 +349,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte self.request_tenant_access_token(tenant_key) return self.tenant_access_tokens.get(tenant_key, {}).get('token') + @localized_adapter async def send_message( self, target_type: str, @@ -380,6 +383,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte message_id=message_ids[-1] if message_ids else '', raw={'message_ids': message_ids} ) + @localized_adapter async def reply_message( self, message_source: platform_events.MessageEvent, @@ -538,6 +542,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte raise RuntimeError(f'Lark card update failed: {response.code} {response.msg}') self.closed_streaming_cards.add(card_id) + @localized_adapter async def reply_message_chunk( self, message_source: platform_events.MessageEvent, @@ -592,6 +597,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte self.card_last_update_dict.pop(card_id, None) self.closed_streaming_cards.discard(card_id) + @localized_adapter async def call_platform_api(self, action: str, params: dict = {}) -> dict: if action == 'interaction.request': return await send_interaction(self, params) @@ -632,6 +638,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte return f'{event.group.id}_{thread_id}' return None + @localized_adapter async def handle_unified_webhook(self, bot_uuid: str, path: str, request): try: data = await request.json @@ -668,7 +675,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte feedback_event = self._feedback_event_from_webhook(data) if feedback_event and platform_events.FeedbackEvent in self.listeners: await self.listeners[platform_events.FeedbackEvent](feedback_event, self) - return {'toast': {'type': 'success', 'content': '感谢您的反馈'}} + return {'toast': {'type': 'success', 'content': adapter_t(self, 'Thank you for your feedback')}} await self._dispatch_eba_event(LarkEventConverter.platform_specific(data, event_type, data)) return {'code': 200, 'message': 'ok'} except Exception: @@ -689,6 +696,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte def _webhook_header(self, data: dict): return type('LarkWebhookHeader', (), data.get('header', {}))() + @localized_adapter async def run_async(self): self.event_loop = asyncio.get_running_loop() if self.config.get('app_type', 'self') != 'isv': @@ -892,12 +900,13 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte self._submit_coro(self.listeners[platform_events.FeedbackEvent](feedback_event, self)) from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse - return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}}) + return P2CardActionTriggerResponse( + {'toast': {'type': 'success', 'content': adapter_t(self, 'Thank you for your feedback')}} + ) - @staticmethod - def _interaction_action_response(event: platform_events.PlatformSpecificEvent) -> dict[str, typing.Any]: + def _interaction_action_response(self, event: platform_events.PlatformSpecificEvent) -> dict[str, typing.Any]: response: dict[str, typing.Any] = { - 'toast': {'type': 'success', 'content': 'Submitted / 已提交'}, + 'toast': {'type': 'success', 'content': adapter_t(self, 'Submitted')}, } if not event.data.get('cardkit'): response['card'] = { @@ -907,7 +916,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte 'elements': [ { 'tag': 'div', - 'text': {'tag': 'lark_md', 'content': '**Submitted / 已提交**'}, + 'text': {'tag': 'lark_md', 'content': '**' + adapter_t(self, 'Submitted') + '**'}, } ], }, diff --git a/src/langbot/pkg/platform/adapters/lark/interaction.py b/src/langbot/pkg/platform/adapters/lark/interaction.py index 9c9873125..8896f6316 100644 --- a/src/langbot/pkg/platform/adapters/lark/interaction.py +++ b/src/langbot/pkg/platform/adapters/lark/interaction.py @@ -2,6 +2,8 @@ from __future__ import annotations +from langbot.pkg.i18n import t + import json import time import typing @@ -61,8 +63,7 @@ def _field_form_elements( component_name = 'lbi_field_0' label = str(field.get('label') or field_id) placeholder = str( - field.get('placeholder') - or ('Select an option / 请选择' if field_type == 'select' else 'Enter a value / 请输入') + field.get('placeholder') or (t('Select an option') if field_type == 'select' else t('Enter a value')) ) required = bool(field.get('required')) if field_type == 'select': @@ -122,7 +123,7 @@ def _field_form_elements( submit_button = { 'tag': 'button', 'name': 'lbi_submit', - 'text': {'tag': 'plain_text', 'content': 'Submit / 提交'}, + 'text': {'tag': 'plain_text', 'content': t('Submit')}, 'type': 'primary', 'width': 'fill', 'form_action_type': 'submit', diff --git a/src/langbot/pkg/platform/adapters/lark/stream_card.py b/src/langbot/pkg/platform/adapters/lark/stream_card.py index 332400a82..cd5adbd56 100644 --- a/src/langbot/pkg/platform/adapters/lark/stream_card.py +++ b/src/langbot/pkg/platform/adapters/lark/stream_card.py @@ -2,6 +2,8 @@ import re +from langbot.pkg.i18n import t + def split_content(content: str) -> tuple[str, str]: reasoning, answer = [], [] @@ -28,7 +30,7 @@ def streaming_text(content: str) -> str: if len(reasoning) > 360: preview = '…' + preview quote = '> ' + preview.replace('\n', '\n> ') - return '**思考过程 / Thinking**\n\n' + quote + ('\n\n---\n\n' + answer if answer else '') + return '**' + t('Thinking') + '**\n\n' + quote + ('\n\n---\n\n' + answer if answer else '') def build_card(content: str = '', *, streaming: bool = False, finished: bool = True) -> dict: @@ -42,7 +44,7 @@ def build_card(content: str = '', *, streaming: bool = False, finished: bool = T { 'tag': 'collapsible_panel', 'expanded': not finished, - 'header': {'title': {'tag': 'plain_text', 'content': '思考过程 / Thinking'}}, + 'header': {'title': {'tag': 'plain_text', 'content': t('Thinking')}}, 'elements': [{'tag': 'markdown', 'content': reasoning}], } ) @@ -58,9 +60,7 @@ def build_card(content: str = '', *, streaming: bool = False, finished: bool = T 'width': 'weighted', 'weight': 1, 'vertical_align': 'center', - 'elements': [ - {'tag': 'markdown', 'content': '内容由 AI 生成 / AI-generated', 'text_size': 'notation'} - ], + 'elements': [{'tag': 'markdown', 'content': t('AI-generated'), 'text_size': 'notation'}], }, { 'tag': 'column', diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index dfb99d292..93b7a8d2f 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -1,5 +1,8 @@ from __future__ import annotations +from ..i18n import language_scope +from ..i18n.workspace import get_workspace_language + import asyncio import contextlib import dataclasses @@ -1713,21 +1716,22 @@ class RuntimeBot: def tenant_scoped_listener(listener): @functools.wraps(listener) async def wrapped(*args, **kwargs): - tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None) - cloud_runtime = ( - getattr( - getattr(self.ap.persistence_mgr, 'mode', None), - 'value', - None, + with language_scope(await get_workspace_language(self.ap, self.workspace_uuid)): + tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None) + cloud_runtime = ( + getattr( + getattr(self.ap.persistence_mgr, 'mode', None), + 'value', + None, + ) + == 'cloud_runtime' ) - == 'cloud_runtime' - ) - if cloud_runtime: - if not callable(tenant_scope): - raise RuntimeError('Cloud platform callbacks require a tenant scope') - async with tenant_scope(self.workspace_uuid): - return await listener(*args, **kwargs) - return await listener(*args, **kwargs) + if cloud_runtime: + if not callable(tenant_scope): + raise RuntimeError('Cloud platform callbacks require a tenant scope') + async with tenant_scope(self.workspace_uuid): + return await listener(*args, **kwargs) + return await listener(*args, **kwargs) return wrapped diff --git a/src/langbot/pkg/platform/sources/discord.py b/src/langbot/pkg/platform/sources/discord.py index e7119e391..1d9a29234 100644 --- a/src/langbot/pkg/platform/sources/discord.py +++ b/src/langbot/pkg/platform/sources/discord.py @@ -1,5 +1,8 @@ from __future__ import annotations +from langbot.pkg.i18n import t +from langbot.pkg.i18n.adapters import adapter_t, localized_adapter + import discord from discord import ui as discord_ui @@ -219,11 +222,13 @@ class VoiceConnectionManager: # 获取服务器和频道对象 guild = self.bot.get_guild(guild_id) if not guild: - raise VoiceConnectionError(f'无法找到服务器 {guild_id}', 'GUILD_NOT_FOUND', guild_id) + raise VoiceConnectionError(t('Resource not found') + f': {guild_id}', 'GUILD_NOT_FOUND', guild_id) channel = guild.get_channel(channel_id) if not channel or not isinstance(channel, discord.VoiceChannel): - raise VoiceConnectionError(f'无法找到语音频道 {channel_id}', 'CHANNEL_NOT_FOUND', guild_id) + raise VoiceConnectionError( + t('Resource not found') + f': {channel_id}', 'CHANNEL_NOT_FOUND', guild_id + ) # 验证用户是否在语音频道中(如果提供了用户ID) if user_id: @@ -258,12 +263,14 @@ class VoiceConnectionManager: return voice_client except discord.ClientException as e: - raise VoiceNetworkError(f'Discord 客户端错误: {str(e)}') + raise VoiceNetworkError(t('Voice connection failed') + f': {e}') except discord.opus.OpusNotLoaded as e: - raise VoiceConnectionError(f'Opus 编码器未加载: {str(e)}', 'OPUS_NOT_LOADED', guild_id) + raise VoiceConnectionError( + t('Voice service is unavailable') + f': Opus: {e}', 'OPUS_NOT_LOADED', guild_id + ) except Exception as e: await self.logger.error(f'连接语音频道时发生未知错误: {str(e)}') - raise VoiceConnectionError(f'连接失败: {str(e)}', 'UNKNOWN_ERROR', guild_id) + raise VoiceConnectionError(t('Voice connection failed') + f': {e}', 'UNKNOWN_ERROR', guild_id) async def leave_voice_channel(self, guild_id: int) -> bool: """ @@ -494,11 +501,13 @@ class VoiceConnectionManager: """ member = guild.get_member(user_id) if not member: - raise VoicePermissionError(f'无法找到用户 {user_id}', ['member_not_found'], user_id, channel.id) + raise VoicePermissionError( + t('Resource not found') + f': {user_id}', ['member_not_found'], user_id, channel.id + ) if not member.voice or member.voice.channel != channel: raise VoicePermissionError( - f'用户 {member.display_name} 不在语音频道 {channel.name} 中', + t('User is not in the voice channel') + f': {member.display_name}, {channel.name}', ['user_not_in_channel'], user_id, channel.id, @@ -528,7 +537,7 @@ class VoiceConnectionManager: if missing_permissions: raise VoicePermissionError( - f'机器人在频道 {channel.name} 中缺少权限: {", ".join(missing_permissions)}', + t('Permission denied') + f': {channel.name}: {", ".join(missing_permissions)}', missing_permissions, channel_id=channel.id, ) @@ -843,7 +852,7 @@ class DiscordFormView(discord_ui.View): # silently — most Dify forms have ≤10 actions in practice. for idx, action in enumerate(actions[:25]): action_id = str(action.get('id') or '') - label = str(action.get('title') or action_id or f'Option {idx + 1}') + label = str(action.get('title') or action_id or adapter_t(adapter, 'Option {number}', number=idx + 1)) style = self._STYLE_MAP.get( str(action.get('button_style') or '').lower(), discord.ButtonStyle.secondary, @@ -966,6 +975,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self._pending_forms.pop(next(iter(self._pending_forms)), None) # Voice functionality methods + @localized_adapter async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient: """ 加入语音频道 @@ -990,7 +1000,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): VoiceConnectionError: 其他连接错误 """ if not self.voice_manager: - raise VoiceConnectionError('语音管理器未初始化', 'MANAGER_NOT_READY') + raise VoiceConnectionError(t('Voice service is unavailable'), 'MANAGER_NOT_READY') return await self.voice_manager.join_voice_channel(guild_id, channel_id, user_id) @@ -1358,7 +1368,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) return - node_title = str(form_data.get('node_title') or 'Confirmation needed') + node_title = str(form_data.get('node_title') or adapter_t(self, 'Confirmation needed')) form_content = str(form_data.get('form_content') or '').strip() # Two paths: diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index b4c4dc827..c6c2c9455 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -1,5 +1,7 @@ from __future__ import annotations +from langbot.pkg.i18n.adapters import adapter_t, localized_adapter + import lark_oapi from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody, CreateFileRequest, CreateFileRequestBody import traceback @@ -1240,7 +1242,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse - return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}}) + return P2CardActionTriggerResponse( + {'toast': {'type': 'success', 'content': adapter_t(self, 'Done')}} + ) if action_value == '有帮助': feedback_type = 1 @@ -1249,7 +1253,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): else: from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse - return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}}) + return P2CardActionTriggerResponse( + {'toast': {'type': 'success', 'content': adapter_t(self, 'Done')}} + ) operator = getattr(event.event, 'operator', None) context = getattr(event.event, 'context', None) @@ -1286,13 +1292,17 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse - return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}}) + return P2CardActionTriggerResponse( + {'toast': {'type': 'success', 'content': adapter_t(self, 'Thank you for your feedback')}} + ) except Exception: traceback.print_exc() schedule_on_app_loop(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')) from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse - return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}}) + return P2CardActionTriggerResponse( + {'toast': {'type': 'error', 'content': adapter_t(self, 'Failed to process feedback')}} + ) event_handler = ( lark_oapi.EventDispatcherHandler.builder('', '') @@ -1545,6 +1555,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return True return False + @localized_adapter async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) @@ -1809,7 +1820,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'elements': [ { 'tag': 'markdown', - 'content': '以上内容由 AI 生成,仅供参考。更多详细、准确信息可点击引用链接查看', + 'content': '' + + adapter_t( + self, 'AI-generated content. Refer to the cited sources for details.' + ) + + '', 'text_align': 'left', 'text_size': 'notation', 'margin': '4px 0px 0px 0px', @@ -1840,7 +1855,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'width': 'fill', 'size': 'medium', 'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'}, - 'hover_tips': {'tag': 'plain_text', 'content': '有帮助'}, + 'hover_tips': {'tag': 'plain_text', 'content': adapter_t(self, 'Helpful')}, 'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}], 'margin': '0px 0px 0px 0px', } @@ -1864,7 +1879,10 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'width': 'default', 'size': 'medium', 'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'}, - 'hover_tips': {'tag': 'plain_text', 'content': '无帮助'}, + 'hover_tips': { + 'tag': 'plain_text', + 'content': adapter_t(self, 'Not helpful'), + }, 'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}], 'margin': '0px 0px 0px 0px', } @@ -2064,6 +2082,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) return new_card_id + @localized_adapter async def reply_message( self, message_source: platform_events.MessageEvent, @@ -2174,6 +2193,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): f'client.im.v1.message.reply ({media["msg_type"]}) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) + @localized_adapter async def reply_message_chunk( self, message_source: platform_events.MessageEvent, @@ -2491,7 +2511,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'tag': 'select_static', 'name': component_name, 'label': {'tag': 'plain_text', 'content': field_name}, - 'placeholder': {'tag': 'plain_text', 'content': '请选择'}, + 'placeholder': {'tag': 'plain_text', 'content': adapter_t(self, 'Select an option')}, 'options': [ { 'text': {'tag': 'plain_text', 'content': option}, @@ -2525,7 +2545,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'tag': 'input', 'name': component_name, 'label': {'tag': 'plain_text', 'content': field_name}, - 'placeholder': {'tag': 'plain_text', 'content': '请输入'}, + 'placeholder': {'tag': 'plain_text', 'content': adapter_t(self, 'Enter a value')}, 'default_value': _dify_default_value(field), 'width': 'fill', 'required': False, @@ -2820,7 +2840,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'elements': [ { 'tag': 'markdown', - 'content': '以上内容由 AI 生成,仅供参考。更多详细、准确信息可点击引用链接查看', + 'content': '' + + adapter_t( + self, 'AI-generated content. Refer to the cited sources for details.' + ) + + '', 'text_align': 'left', 'text_size': 'notation', 'margin': '4px 0px 0px 0px', @@ -2855,7 +2879,10 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'width': 'fill', 'size': 'medium', 'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'}, - 'hover_tips': {'tag': 'plain_text', 'content': '有帮助'}, + 'hover_tips': { + 'tag': 'plain_text', + 'content': adapter_t(self, 'Helpful'), + }, 'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}], 'margin': '0px 0px 0px 0px', } @@ -2879,7 +2906,10 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'width': 'default', 'size': 'medium', 'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'}, - 'hover_tips': {'tag': 'plain_text', 'content': '无帮助'}, + 'hover_tips': { + 'tag': 'plain_text', + 'content': adapter_t(self, 'Not helpful'), + }, 'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}], 'margin': '0px 0px 0px 0px', } @@ -2973,6 +3003,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): else: return data['type'] + @localized_adapter async def handle_unified_webhook(self, bot_uuid: str, path: str, request): """处理统一 webhook 请求。 Args: @@ -3132,14 +3163,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): }, ) - return {'toast': {'type': 'success', 'content': '操作成功'}} + return {'toast': {'type': 'success', 'content': adapter_t(self, 'Done')}} if action_value == '有帮助': feedback_type = 1 elif action_value == '无帮助': feedback_type = 2 else: - return {'toast': {'type': 'success', 'content': '操作成功'}} + return {'toast': {'type': 'success', 'content': adapter_t(self, 'Done')}} user_id = operator.get('open_id') or operator.get('user_id') open_chat_id = context_data.get('open_chat_id') @@ -3171,10 +3202,10 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if platform_events.FeedbackEvent in self.listeners: await self.listeners[platform_events.FeedbackEvent](feedback_event, self) - return {'toast': {'type': 'success', 'content': '感谢您的反馈'}} + return {'toast': {'type': 'success', 'content': adapter_t(self, 'Thank you for your feedback')}} except Exception: await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}') - return {'toast': {'type': 'error', 'content': '反馈处理失败'}} + return {'toast': {'type': 'error', 'content': adapter_t(self, 'Failed to process feedback')}} elif 'im.chat.member.bot.added_v1' == type: try: @@ -3227,6 +3258,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') return {'code': 500, 'message': 'error'} + @localized_adapter async def run_async(self): enable_webhook = self.config['enable-webhook'] diff --git a/src/langbot/pkg/platform/sources/matrix.py b/src/langbot/pkg/platform/sources/matrix.py index f0a9822e3..3fc311392 100644 --- a/src/langbot/pkg/platform/sources/matrix.py +++ b/src/langbot/pkg/platform/sources/matrix.py @@ -1,5 +1,8 @@ from __future__ import annotations +from langbot.pkg.i18n import t +from langbot.pkg.i18n.adapters import localized_adapter + import typing import asyncio import traceback @@ -666,25 +669,26 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): except Exception: await self.logger.error(f'[{bridge.user_id}] Bridge status check fatal error: {traceback.format_exc()}') + @localized_adapter async def _handle_relogin_command(self, room_id: str): """Handle !relogin command: logout then re-login all bridges.""" if not self._bridges: await self.client.room_send( room_id=room_id, message_type='m.room.message', - content={'msgtype': 'm.text', 'body': '没有配置任何桥。'}, + content={'msgtype': 'm.text', 'body': t('No bridges configured.')}, ) return - lines = ['开始重新登录所有桥...'] + lines = [t('Signing in to all bridges again...')] for bridge in self._bridges: if not bridge.login_command or not bridge.dm_room_id: - lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)') + lines.append(t('Bridge {bridge}: skipped (login command or DM room missing).', bridge=bridge.user_id)) continue # Use configured logout command, fallback to deriving from login command logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') - lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...') + lines.append(t('Bridge {bridge}: sending {command}...', bridge=bridge.user_id, command=logout_cmd)) # Cancel existing tasks if bridge.login_task and not bridge.login_task.done(): @@ -700,14 +704,14 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): content={'msgtype': 'm.text', 'body': logout_cmd}, ) except Exception as e: - lines.append(f'[{bridge.user_id}] logout 发送失败: {e}') + lines.append(t('Bridge {bridge}: sign-out failed.', bridge=bridge.user_id) + f' {e}') await asyncio.sleep(2) # Reset state and restart login bridge.logged_in = False self._restart_bridge_login(bridge) - lines.append(f'[{bridge.user_id}] 已触发重新登录') + lines.append(t('Bridge {bridge}: sign-in started.', bridge=bridge.user_id)) await self.client.room_send( room_id=room_id, @@ -715,20 +719,21 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): content={'msgtype': 'm.text', 'body': '\n'.join(lines)}, ) + @localized_adapter async def _handle_status_command(self, room_id: str): """Handle !status command: show bridge states.""" if not self._bridges: await self.client.room_send( room_id=room_id, message_type='m.room.message', - content={'msgtype': 'm.text', 'body': '没有配置任何桥。'}, + content={'msgtype': 'm.text', 'body': t('No bridges configured.')}, ) return - lines = ['桥状态:'] + lines = [t('Bridge status:')] for bridge in self._bridges: - status = '已登录 ✓' if bridge.logged_in else '未登录 ✗' - dm = bridge.dm_room_id or '无' + status = t('Signed in ✓') if bridge.logged_in else t('Not signed in ✗') + dm = bridge.dm_room_id or t('None') lines.append(f'• {bridge.user_id}: {status} (DM: {dm})') await self.client.room_send( room_id=room_id, diff --git a/src/langbot/pkg/provider/modelmgr/codex_auth.py b/src/langbot/pkg/provider/modelmgr/codex_auth.py index 571a67b40..1f6b618ec 100644 --- a/src/langbot/pkg/provider/modelmgr/codex_auth.py +++ b/src/langbot/pkg/provider/modelmgr/codex_auth.py @@ -23,6 +23,7 @@ from ...api.http.context import PrincipalType, RequestContext from ...api.http.authz import Permission, has_permission from ...api.http.service.tenant import require_workspace_uuid from ...workspace.errors import WorkspaceNotFoundError +from ...i18n import t REQUESTER = 'openai-codex' BASE_URL = 'https://chatgpt.com/backend-api/codex' @@ -77,7 +78,7 @@ def _tokens(data: dict, previous: dict | None = None) -> dict: or not math.isfinite(expires_at) or expires_at <= time.time() ): - raise ValueError('ChatGPT returned an incomplete authorization. Please sign in again.') + raise ValueError(t('ChatGPT returned an incomplete authorization. Please sign in again.')) return { 'access_token': access, 'refresh_token': refresh, @@ -99,7 +100,7 @@ class CodexAuth: try: return await self.ap.persistence_mgr.execute_async(statement) except Exception: - raise ValueError('ChatGPT credential storage failed. Please retry.') from None + raise ValueError(t('ChatGPT credential storage failed. Please retry.')) from None async def _read(self, workspace: str, provider: str) -> dict | None: result = await self._execute(sa.select(CodexCredential).where(*self._where(workspace, provider))) @@ -107,7 +108,7 @@ class CodexAuth: row = result.first() return dict(row._mapping) if row is not None else None except Exception: - raise ValueError('ChatGPT credential storage failed. Please retry.') from None + raise ValueError(t('ChatGPT credential storage failed. Please retry.')) from None async def _provider(self, context, provider: str, *, user: bool = False) -> str: workspace = require_workspace_uuid(context) @@ -117,7 +118,7 @@ class CodexAuth: or not context.account_uuid or not has_permission(context, Permission.PROVIDER_SECRET_MANAGE) ): - raise ValueError('ChatGPT authorization requires an authorized workspace user') + raise ValueError(t('ChatGPT authorization requires an authorized workspace user')) result = await self._execute( sa.select(ModelProvider.requester).where( ModelProvider.workspace_uuid == workspace, ModelProvider.uuid == provider @@ -147,9 +148,9 @@ class CodexAuth: if result.rowcount == 1: break if await self._read(workspace, provider) is None: - raise ValueError(LOGIN_REQUIRED) + raise ValueError(t(LOGIN_REQUIRED)) if time.monotonic() >= deadline: - raise ValueError('ChatGPT authorization is busy. Please retry shortly.') + raise ValueError(t('ChatGPT authorization is busy. Please retry shortly.')) await asyncio.sleep(0.1) try: yield owner @@ -171,7 +172,7 @@ class CodexAuth: .values(payload=payload, version=CodexCredential.version + 1) ) if result.rowcount != 1: - raise ValueError('ChatGPT authorization was cancelled or replaced. Please retry.') + raise ValueError(t('ChatGPT authorization was cancelled or replaced. Please retry.')) async def _post(self, path: str, *, data=None, json_body=None) -> httpx.Response: try: @@ -186,7 +187,7 @@ class CodexAuth: 25, ) except (httpx.HTTPError, TimeoutError): - raise ValueError('ChatGPT authorization network error. Please retry.') from None + raise ValueError(t('ChatGPT authorization network error. Please retry.')) from None @staticmethod def _json(response: httpx.Response) -> dict: @@ -196,7 +197,7 @@ class CodexAuth: raise ValueError return value except ValueError: - raise ValueError('ChatGPT returned an invalid authorization response') from None + raise ValueError(t('ChatGPT returned an invalid authorization response')) from None async def status(self, context, provider: str) -> dict: workspace = await self._provider(context, provider, user=True) @@ -215,7 +216,9 @@ class CodexAuth: async with self._lease(workspace, provider) as owner: response = await self._post('/api/accounts/deviceauth/usercode', json_body={'client_id': CLIENT_ID}) if response.status_code != 200: - raise ValueError('Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.') + raise ValueError( + t('Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.') + ) data = self._json(response) try: code = data.get('user_code') or data['usercode'] @@ -224,7 +227,7 @@ class CodexAuth: if not isinstance(code, str) or not isinstance(device, str) or not code or not device: raise ValueError except (KeyError, ValueError, TypeError): - raise ValueError('ChatGPT returned an invalid device code') from None + raise ValueError(t('ChatGPT returned an invalid device code')) from None now = time.time() try: expiry = data.get('expires_at') @@ -241,7 +244,7 @@ class CodexAuth: raise ValueError expires_at = min(now + 900, expires_at) except (ValueError, TypeError): - raise ValueError('ChatGPT returned an invalid device code expiry') from None + raise ValueError(t('ChatGPT returned an invalid device code expiry')) from None pending = { 'authorization_id': secrets.token_urlsafe(32), 'user_code': code, @@ -306,12 +309,12 @@ class CodexAuth: if response.status_code != 200: payload.pop('pending', None) await self._save(workspace, provider, owner, payload) - raise ValueError('ChatGPT device authorization failed. Please start again.') + raise ValueError(t('ChatGPT device authorization failed. Please start again.')) data = self._json(response) if not data.get('authorization_code') or not data.get('code_verifier'): payload.pop('pending', None) await self._save(workspace, provider, owner, payload) - raise ValueError('ChatGPT returned an incomplete device authorization') + raise ValueError(t('ChatGPT returned an incomplete device authorization')) # Keep an attempt tombstone so cancel can preempt exchange, but never replay a code. pending['consumed'] = True await self._save(workspace, provider, owner, payload) @@ -326,7 +329,7 @@ class CodexAuth: }, ) if response.status_code != 200: - raise ValueError('ChatGPT token exchange failed. Please start sign-in again.') + raise ValueError(t('ChatGPT token exchange failed. Please start sign-in again.')) tokens = _tokens(self._json(response)) await self._save( workspace, @@ -384,7 +387,7 @@ class CodexAuth: payload = row['payload'] if row else {} tokens = payload.get('tokens') if not tokens or payload.get('invalid'): - raise ValueError(LOGIN_REQUIRED) + raise ValueError(t(LOGIN_REQUIRED)) if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token: return tokens async with self._lease(workspace, provider, refresh=True) as owner: @@ -392,7 +395,7 @@ class CodexAuth: payload = dict(row['payload']) tokens = payload.get('tokens') if not tokens or payload.get('invalid'): - raise ValueError(LOGIN_REQUIRED) + raise ValueError(t(LOGIN_REQUIRED)) if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token: return tokens response = await self._post( @@ -411,9 +414,9 @@ class CodexAuth: payload.pop('tokens', None) payload.pop('completed', None) await self._save(workspace, provider, owner, payload) - raise ValueError(LOGIN_REQUIRED) + raise ValueError(t(LOGIN_REQUIRED)) if response.status_code != 200: - raise ValueError('ChatGPT token refresh temporarily failed. Please retry.') + raise ValueError(t('ChatGPT token refresh temporarily failed. Please retry.')) refreshed = _tokens(self._json(response), tokens) payload['tokens'] = refreshed await self._save(workspace, provider, owner, payload) diff --git a/src/langbot/pkg/provider/modelmgr/codex_errors.py b/src/langbot/pkg/provider/modelmgr/codex_errors.py index a7ceb0301..5efa21a7f 100644 --- a/src/langbot/pkg/provider/modelmgr/codex_errors.py +++ b/src/langbot/pkg/provider/modelmgr/codex_errors.py @@ -1,10 +1,12 @@ """Explicitly safe Codex failures; never construct messages from upstream bodies.""" +from ...i18n import t + class CodexProviderError(ValueError): """A known provider failure safe to expose at the HTTP boundary.""" def __init__(self, message: str, status_code: int = 502, error_code: str = 'codex_upstream_failure'): - super().__init__(message) + super().__init__(t(message)) self.status_code = status_code self.error_code = error_code diff --git a/src/langbot/pkg/provider/modelmgr/errors.py b/src/langbot/pkg/provider/modelmgr/errors.py index dc3b35b6f..2d2a5d5b6 100644 --- a/src/langbot/pkg/provider/modelmgr/errors.py +++ b/src/langbot/pkg/provider/modelmgr/errors.py @@ -1,5 +1,8 @@ +from ...i18n import t + + class RequesterError(Exception): """Base class for all Requester errors.""" def __init__(self, message: str): - super().__init__('模型请求失败: ' + message) + super().__init__(t('Model request failed') + ': ' + message) diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index e927a3baf..b97d87930 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -2,6 +2,8 @@ from __future__ import annotations +from ....i18n import t + import typing import asyncio @@ -1037,22 +1039,22 @@ class LiteLLMRequester(requester.ProviderAPIRequester): """Convert LiteLLM exceptions to RequesterError. Never returns, always raises.""" # Check more specific exceptions first (they inherit from base exceptions) if isinstance(e, litellm.ContextWindowExceededError): - raise errors.RequesterError(f'上下文长度超限: {str(e)}') + raise errors.RequesterError(t('Context length exceeded') + f': {e}') if isinstance(e, litellm.BadRequestError): - raise errors.RequesterError(f'请求参数错误: {str(e)}') + raise errors.RequesterError(t('Invalid request parameters') + f': {e}') if isinstance(e, litellm.AuthenticationError): - raise errors.RequesterError(f'API key 无效: {str(e)}') + raise errors.RequesterError(t('Invalid API key') + f': {e}') if isinstance(e, litellm.NotFoundError): - raise errors.RequesterError(f'模型或路径无效: {str(e)}') + raise errors.RequesterError(t('Invalid model or endpoint') + f': {e}') if isinstance(e, litellm.RateLimitError): - raise errors.RequesterError(f'请求过于频繁或余额不足: {str(e)}') + raise errors.RequesterError(t('Rate limit exceeded or insufficient balance') + f': {e}') if isinstance(e, litellm.Timeout): - raise errors.RequesterError(f'请求超时: {str(e)}') + raise errors.RequesterError(t('Request timed out') + f': {e}') if isinstance(e, litellm.APIConnectionError): - raise errors.RequesterError(f'连接错误: {str(e)}') + raise errors.RequesterError(t('Connection failed') + f': {e}') if isinstance(e, litellm.APIError): - raise errors.RequesterError(f'API 错误: {str(e)}') - raise errors.RequesterError(f'未知错误: {str(e)}') + raise errors.RequesterError(t('API error') + f': {e}') + raise errors.RequesterError(t('Unknown error') + f': {e}') async def _build_completion_args( self, @@ -1523,9 +1525,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester): body = await httpclient.response_text(e.response) except Exception: pass - raise errors.RequesterError(f'rerank 请求失败 (HTTP {e.response.status_code}): {body or str(e)}') + raise errors.RequesterError(t('API error') + f' (HTTP {e.response.status_code}): {body or str(e)}') except httpx.HTTPError as e: - raise errors.RequesterError(f'rerank 连接错误: {str(e)}') + raise errors.RequesterError(t('Connection failed') + f': {e}') raw_results = data.get('results', []) if isinstance(data, dict) else [] results = [] diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 7b5a83afd..3cb8b8cf5 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import t + import dataclasses import enum import json @@ -1490,7 +1492,7 @@ class RuntimeMCPSession: # Raw transport exceptions may echo command arguments, headers, or # environment values. Detailed diagnostics belong in AUDIT_VIEW # logs; resource-list responses expose only a stable status. - 'error_message': 'MCP runtime failed' if self.error_message else None, + 'error_message': t('MCP runtime failed') if self.error_message else None, 'error_code': self._public_error_code if self.error_message else None, 'error_phase': self.error_phase.value if self.error_phase else None, 'retry_count': self.retry_count, diff --git a/src/langbot/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json index 23666ab03..b692c3e0e 100644 --- a/src/langbot/templates/default-pipeline-config.json +++ b/src/langbot/templates/default-pipeline-config.json @@ -55,7 +55,7 @@ }, "misc": { "exception-handling": "show-hint", - "failure-hint": "Request failed.", + "failure-hint": "", "at-sender": true, "quote-origin": true, "track-function-calls": false, diff --git a/src/langbot/templates/metadata/pipeline/output.yaml b/src/langbot/templates/metadata/pipeline/output.yaml index d5e0fae07..d3acfc693 100644 --- a/src/langbot/templates/metadata/pipeline/output.yaml +++ b/src/langbot/templates/metadata/pipeline/output.yaml @@ -106,11 +106,81 @@ stages: en_US: Failure Hint Text zh_Hans: 失败提示文本 description: - en_US: The text to display when a request fails. Only effective when Exception Handling Strategy is set to "Show Failure Hint" - zh_Hans: 请求失败时显示的提示文本,仅在异常处理策略设置为"仅文字提示"时生效 + en_US: Leave empty to use the Workspace language. Used for request and runner execution failures; custom text is not translated. + zh_Hans: 留空时使用工作区语言的默认提示。用于请求和运行器执行失败;自定义文本不会自动翻译。 type: string required: false - default: 'Request failed.' + default: '' + - name: rate-limit-hint + label: + en_US: Rate Limit Hint + zh_Hans: 限速提示 + description: + en_US: "Leave empty to use the Workspace language." + zh_Hans: "留空时使用工作区语言的默认提示。" + type: string + required: false + default: '' + - name: content-filter-hint + label: + en_US: Content Filter Hint + zh_Hans: 内容过滤提示 + description: + en_US: "Leave empty to use the Workspace language." + zh_Hans: "留空时使用工作区语言的默认提示。" + type: string + required: false + default: '' + - name: content-filter-error-hint + label: + en_US: Content Check Failure Hint + zh_Hans: 内容检查失败提示 + description: + en_US: "Leave empty to use the Workspace language." + zh_Hans: "留空时使用工作区语言的默认提示。" + type: string + required: false + default: '' + - name: tool-call-hint + label: + en_US: Tool Call Hint + zh_Hans: 工具调用提示 + description: + en_US: "Leave empty to use the Workspace language. Use {tools} for tool names. Requires Track Function Calls." + zh_Hans: "留空时使用工作区语言的默认提示。 可用 {tools} 插入工具名称,需开启跟踪函数调用。" + type: string + required: false + default: '' + - name: runner-not-found-hint + label: + en_US: Runner Missing Hint + zh_Hans: 运行器不存在提示 + description: + en_US: "Leave empty to use the Workspace language. Use {runner} for the runner ID." + zh_Hans: "留空时使用工作区语言的默认提示。 可用 {runner} 插入运行器 ID。" + type: string + required: false + default: '' + - name: runner-not-authorized-hint + label: + en_US: Runner Authorization Hint + zh_Hans: 运行器未授权提示 + description: + en_US: "Leave empty to use the Workspace language." + zh_Hans: "留空时使用工作区语言的默认提示。" + type: string + required: false + default: '' + - name: runner-unavailable-hint + label: + en_US: Runner Unavailable Hint + zh_Hans: 运行器不可用提示 + description: + en_US: "Leave empty to use the Workspace language." + zh_Hans: "留空时使用工作区语言的默认提示。" + type: string + required: false + default: '' - name: at-sender label: en_US: At Sender diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 7a6254d01..aba1765cc 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -928,3 +928,64 @@ async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspa ) assert current.status_code == 404 assert (await current.get_json())['code'] == 'resource_not_found' + + +async def test_workspace_language_persists_and_controls_api_errors(workspace_api): + from langbot.pkg.i18n.workspace import get_workspace_language, set_workspace_language + + application, client, engine, owner_token = workspace_api + current = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token)) + current = (await current.get_json())['data'] + workspace_uuid = current['workspace']['uuid'] + owner_uuid = current['membership']['account_uuid'] + headers = {**_auth(owner_token, workspace_uuid), 'X-LangBot-Language': 'ja-JP'} + initial = await client.get('/api/v1/workspaces/current/language', headers=headers) + assert (await initial.get_json())['data']['language'] == 'en_US' + + updated = await client.put('/api/v1/workspaces/current/language', headers=headers, json={'language': 'zh_Hans'}) + assert updated.status_code == 200 + assert await get_workspace_language(application, workspace_uuid) == 'zh_Hans' + restarted = SimpleNamespace(persistence_mgr=application.persistence_mgr) + assert await get_workspace_language(restarted, workspace_uuid) == 'zh_Hans' + + invalid = await client.put('/api/v1/workspaces/current/language', headers=headers, json={'language': '../bad'}) + assert invalid.status_code == 400 + assert await get_workspace_language(application, workspace_uuid, refresh=True) == 'zh_Hans' + + # A second Workspace has independent persisted metadata and cache entries. + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.insert(Workspace).values( + uuid='language-other', + instance_uuid='instance-workspace-api', + name='Other', + slug='language-other', + source='cloud_projection', + type='team', + ) + ) + await set_workspace_language(application, 'language-other', 'es_ES') + assert await get_workspace_language(application, 'language-other') == 'es_ES' + assert await get_workspace_language(application, workspace_uuid) == 'zh_Hans' + + # Read-only members can inspect the setting but cannot modify it. The + # verified Workspace setting wins over the browser language hint. + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.update(WorkspaceMembership) + .where( + WorkspaceMembership.workspace_uuid == workspace_uuid, + WorkspaceMembership.account_uuid == owner_uuid, + ) + .values(role='viewer') + ) + denied = await client.put('/api/v1/workspaces/current/language', headers=headers, json={'language': 'ja_JP'}) + assert denied.status_code == 403 + assert (await denied.get_json())['msg'] == '没有执行此操作的权限' + visible = await client.get('/api/v1/workspaces/current/language', headers=headers) + assert visible.status_code == 200 + assert (await visible.get_json())['data']['language'] == 'zh_Hans' + + # An untrusted selector never causes us to read another tenant's language. + unauthorized = await client.get('/api/v1/workspaces/current/language', headers=_auth(owner_token, 'language-other')) + assert unauthorized.status_code in (403, 404) diff --git a/tests/unit_tests/api/http/test_route_tenant_scope.py b/tests/unit_tests/api/http/test_route_tenant_scope.py index 4b4cb96e6..26e5ae271 100644 --- a/tests/unit_tests/api/http/test_route_tenant_scope.py +++ b/tests/unit_tests/api/http/test_route_tenant_scope.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from langbot.pkg.api.http.controller import group from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode +from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata pytestmark = pytest.mark.asyncio @@ -20,6 +21,8 @@ async def test_authenticated_route_does_not_hold_database_session_during_externa release = asyncio.Event() observations: list[bool] = [] engine = create_async_engine('sqlite+aiosqlite:///:memory:') + async with engine.begin() as connection: + await connection.run_sync(WorkspaceMetadata.__table__.create) persistence = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME) persistence.db = SimpleNamespace(get_engine=lambda: engine) @@ -60,7 +63,7 @@ async def test_authenticated_route_does_not_hold_database_session_during_externa ) ) try: - await entered.wait() + await asyncio.wait_for(entered.wait(), timeout=5) assert observations == [True] release.set() response = await request diff --git a/tests/unit_tests/api/service/test_provider_cascade.py b/tests/unit_tests/api/service/test_provider_cascade.py index 1fd58e98d..971dafc3b 100644 --- a/tests/unit_tests/api/service/test_provider_cascade.py +++ b/tests/unit_tests/api/service/test_provider_cascade.py @@ -15,6 +15,7 @@ from langbot.pkg.api.http.controller.groups.provider.providers import ModelProvi from langbot.pkg.api.http.service.provider import ModelProviderService from langbot.pkg.entity.persistence.model import CodexCredential, EmbeddingModel, LLMModel, ModelProvider, RerankModel from langbot.pkg.entity.persistence.user import User +from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata from langbot.pkg.entity.persistence.workspace import Workspace from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode from langbot.pkg.provider.modelmgr.modelmgr import ModelManager @@ -51,7 +52,7 @@ async def deletion(tmp_path): } async with engine.begin() as conn: - for table in (User, Workspace, ModelProvider, CodexCredential, *MODEL_TYPES): + for table in (User, Workspace, WorkspaceMetadata, ModelProvider, CodexCredential, *MODEL_TYPES): await conn.run_sync(table.__table__.create) for workspace in contexts: await conn.execute( diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py index 12b440b32..add2ea7b3 100644 --- a/tests/unit_tests/api/service/test_user_service.py +++ b/tests/unit_tests/api/service/test_user_service.py @@ -357,7 +357,7 @@ class TestUserServiceAuthenticate: service = UserService(ap) # Execute & Verify - with pytest.raises(ValueError, match='用户不存在'): + with pytest.raises(ValueError, match='Resource not found'): await service.authenticate('nonexistent@example.com', 'password') async def test_authenticate_space_user_without_password_raises_error(self): @@ -377,7 +377,7 @@ class TestUserServiceAuthenticate: service = UserService(ap) # Execute & Verify - with pytest.raises(ValueError, match='请使用 LangBot 账号登录'): + with pytest.raises(ValueError, match='Please sign in with your LangBot Account'): await service.authenticate('space@example.com', 'password') @@ -418,7 +418,6 @@ class TestUserServiceGenerateJwtToken: assert token is not None - class TestUserServiceVerifyJwtToken: """Tests for verify_jwt_token method.""" diff --git a/tests/unit_tests/api/test_extensions_runtime_fence.py b/tests/unit_tests/api/test_extensions_runtime_fence.py index 12f36917e..02bff861f 100644 --- a/tests/unit_tests/api/test_extensions_runtime_fence.py +++ b/tests/unit_tests/api/test_extensions_runtime_fence.py @@ -99,7 +99,11 @@ async def test_extensions_route_redacts_plugin_secrets_without_mutating_runtime_ @pytest.mark.asyncio async def test_extensions_parallel_reads_open_explicit_child_task_scopes(): + from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + async with engine.begin() as connection: + await connection.run_sync(WorkspaceMetadata.__table__.create) account = SimpleNamespace(uuid='account-a', user='owner@example.com') ap = SimpleNamespace() ap.persistence_mgr = PersistenceManager(ap) diff --git a/tests/unit_tests/api/test_mcp_server.py b/tests/unit_tests/api/test_mcp_server.py index 228b23ffa..3615f8286 100644 --- a/tests/unit_tests/api/test_mcp_server.py +++ b/tests/unit_tests/api/test_mcp_server.py @@ -65,3 +65,15 @@ async def test_mcp_server_exposes_bot_event_route_tools(): assert 'test_bot_event_route' not in tool_names assert 'list_processors' in tool_names assert 'list_agents' not in tool_names + assert 'get_workspace_message_language' in tool_names + language_tool = next(tool for tool in tools if tool.name == 'set_workspace_message_language') + assert set(language_tool.inputSchema['properties']['language']['enum']) == { + 'en_US', + 'zh_Hans', + 'zh_Hant', + 'ja_JP', + 'es_ES', + 'ru_RU', + 'th_TH', + 'vi_VN', + } diff --git a/tests/unit_tests/pipeline/conftest.py b/tests/unit_tests/pipeline/conftest.py index 22d81e2b4..07567c1f7 100644 --- a/tests/unit_tests/pipeline/conftest.py +++ b/tests/unit_tests/pipeline/conftest.py @@ -88,6 +88,12 @@ class MockApplication: def _create_mock_persistence_manager(self): persistence_mgr = AsyncMock() persistence_mgr.execute_async = AsyncMock() + result = Mock(scalar_one_or_none=Mock(return_value=None)) + uow = AsyncMock() + uow.execute = AsyncMock(return_value=result) + boundary = AsyncMock() + boundary.__aenter__.return_value = uow + persistence_mgr.tenant_uow = Mock(return_value=boundary) return persistence_mgr def _create_mock_query_pool(self): diff --git a/tests/unit_tests/pipeline/test_banwords.py b/tests/unit_tests/pipeline/test_banwords.py index 2dcaf2cf4..a33a0a2e1 100644 --- a/tests/unit_tests/pipeline/test_banwords.py +++ b/tests/unit_tests/pipeline/test_banwords.py @@ -82,7 +82,7 @@ async def test_invalid_sensitive_word_regex_still_blocks(): result = await filt.process(Mock(), 'any message') assert result.level == entities.ResultLevel.BLOCK - assert result.user_notice == '内容检查规则执行失败,请联系管理员' + assert result.user_notice == 'Content checking failed. Please contact the administrator.' assert 'rejected' in result.console_notice.lower() or 'invalid' in result.console_notice.lower() @@ -97,7 +97,7 @@ async def test_oversized_word_list_is_blocked(): assert result.level == entities.ResultLevel.BLOCK assert result.replacement == '' - assert result.user_notice == '内容检查规则执行失败,请联系管理员' + assert result.user_notice == 'Content checking failed. Please contact the administrator.' assert 'at most 256 regex patterns are allowed' in result.console_notice.lower() diff --git a/tests/unit_tests/platform/test_lark_eba_adapter.py b/tests/unit_tests/platform/test_lark_eba_adapter.py index 209e9a2a6..9db1d3c28 100644 --- a/tests/unit_tests/platform/test_lark_eba_adapter.py +++ b/tests/unit_tests/platform/test_lark_eba_adapter.py @@ -881,9 +881,9 @@ async def test_lark_webhook_dispatches_native_form_submission(): response = await adapter.handle_unified_webhook('bot-1', '', request) - assert response['toast'] == {'type': 'success', 'content': 'Submitted / 已提交'} + assert response['toast'] == {'type': 'success', 'content': 'Submitted'} assert response['card']['type'] == 'raw' - assert response['card']['data']['elements'][0]['text']['content'] == '**Submitted / 已提交**' + assert response['card']['data']['elements'][0]['text']['content'] == '**Submitted**' assert len(calls) == 1 assert calls[0].action == 'interaction.submitted' assert calls[0].data['callback_token'] == 'callback-token' @@ -912,7 +912,7 @@ async def test_lark_webhook_cardkit_submission_uses_async_card_update_only(): response = await adapter.handle_unified_webhook('bot-1', '', request) - assert response == {'toast': {'type': 'success', 'content': 'Submitted / 已提交'}} + assert response == {'toast': {'type': 'success', 'content': 'Submitted'}} def test_lark_stream_card_separates_multiple_reasoning_rounds(): diff --git a/tests/unit_tests/provider/test_litellmchat.py b/tests/unit_tests/provider/test_litellmchat.py index f73fac2e4..311367d5f 100644 --- a/tests/unit_tests/provider/test_litellmchat.py +++ b/tests/unit_tests/provider/test_litellmchat.py @@ -767,7 +767,7 @@ class TestHandleLiteLLMError: with pytest.raises(errors.RequesterError) as exc_info: requester._handle_litellm_error(error) - assert '请求参数错误' in str(exc_info.value) + assert 'Invalid request parameters' in str(exc_info.value) def test_authentication_error(self): """Test AuthenticationError translation""" @@ -778,7 +778,7 @@ class TestHandleLiteLLMError: with pytest.raises(errors.RequesterError) as exc_info: requester._handle_litellm_error(error) - assert 'API key 无效' in str(exc_info.value) + assert 'Invalid API key' in str(exc_info.value) def test_rate_limit_error(self): """Test RateLimitError translation""" @@ -789,7 +789,7 @@ class TestHandleLiteLLMError: with pytest.raises(errors.RequesterError) as exc_info: requester._handle_litellm_error(error) - assert '请求过于频繁' in str(exc_info.value) + assert 'Rate limit exceeded' in str(exc_info.value) def test_timeout_error(self): """Test Timeout translation""" @@ -800,7 +800,7 @@ class TestHandleLiteLLMError: with pytest.raises(errors.RequesterError) as exc_info: requester._handle_litellm_error(error) - assert '请求超时' in str(exc_info.value) + assert 'Request timed out' in str(exc_info.value) def test_context_window_error(self): """Test ContextWindowExceededError translation""" @@ -811,7 +811,7 @@ class TestHandleLiteLLMError: with pytest.raises(errors.RequesterError) as exc_info: requester._handle_litellm_error(error) - assert '上下文长度超限' in str(exc_info.value) + assert 'Context length exceeded' in str(exc_info.value) def test_unknown_error(self): """Test unknown error translation""" @@ -820,7 +820,7 @@ class TestHandleLiteLLMError: with pytest.raises(errors.RequesterError) as exc_info: requester._handle_litellm_error(Exception('unknown')) - assert '未知错误' in str(exc_info.value) + assert 'Unknown error' in str(exc_info.value) class TestInvokeLLM: @@ -976,7 +976,7 @@ class TestInvokeLLM: messages=messages, ) - assert 'API key 无效' in str(exc_info.value) + assert 'Invalid API key' in str(exc_info.value) class TestInvokeEmbedding: diff --git a/tests/unit_tests/test_backend_i18n.py b/tests/unit_tests/test_backend_i18n.py new file mode 100644 index 000000000..a43f4113d --- /dev/null +++ b/tests/unit_tests/test_backend_i18n.py @@ -0,0 +1,107 @@ +"""Runtime language isolation, catalog completeness, and Pipeline overrides.""" + +import asyncio +import json +import string +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from langbot.pkg import i18n +from langbot.pkg.i18n.adapters import adapter_t, localized_adapter +from langbot.pkg.provider.modelmgr.errors import RequesterError + + +def test_catalogs_have_matching_keys_and_placeholders(): + folder = Path(i18n.__file__).parent / 'locales' + catalogs = { + locale: json.loads((folder / f'{locale}.json').read_text(encoding='utf-8')) + for locale in i18n.SUPPORTED_LANGUAGES + } + english = catalogs['en_US'] + formatter = string.Formatter() + + def fields(text): + return {name for _, name, _, _ in formatter.parse(text) if name is not None} + + for locale, catalog in catalogs.items(): + assert catalog.keys() == english.keys(), locale + for key, text in catalog.items(): + assert text.strip(), (locale, key) + assert fields(text) == fields(key), (locale, key) + + +@pytest.mark.asyncio +async def test_concurrent_requests_and_children_keep_their_language(): + async def render(locale): + with i18n.language_scope(locale): + + async def child(): + await asyncio.sleep(0) + return str(RequesterError(i18n.t('Request timed out'))) + + return await asyncio.create_task(child()) + + assert await asyncio.gather(render('zh_Hans'), render('ja_JP'), render('en_US')) == [ + '模型请求失败: 请求超时', + 'モデルへのリクエストに失敗しました: リクエストがタイムアウトしました', + 'Model request failed: Request timed out', + ] + assert i18n.language.get() == 'en_US' + with pytest.raises(RuntimeError), i18n.language_scope('zh_Hans'): + raise RuntimeError('interrupted') + assert i18n.language.get() == 'en_US' + + +def test_pipeline_overrides_inherit_language_and_preserve_literal_braces(): + query = SimpleNamespace(pipeline_config={'output': {'misc': {}}}) + with i18n.language_scope('zh_Hans'): + assert i18n.pipeline_notice(query, 'failure-hint', 'Request failed.') == '请求失败。' + query.pipeline_config['output']['misc']['failure-hint'] = 'Request failed.' + assert i18n.pipeline_notice(query, 'failure-hint', 'Request failed.') == '请求失败。' + query.pipeline_config['output']['misc']['failure-hint'] = 'My custom notice {literal}' + assert i18n.pipeline_notice(query, 'failure-hint', 'Request failed.') == 'My custom notice {literal}' + query.pipeline_config['output']['misc']['tool-call-hint'] = 'Using {tools}; keep {other}' + assert i18n.pipeline_notice(query, 'tool-call-hint', 'Calling {tools}...', tools='search') == ( + 'Using search; keep {other}' + ) + + +def test_diagnostics_are_never_parsed_or_rewritten(): + diagnostic = 'upstream: {"error": "some provider detail"}' + with i18n.language_scope('zh_Hans'): + assert i18n.t(diagnostic) == diagnostic + assert i18n.error_text('permission_denied', 'Missing Workspace permission: workspace.update') == ( + '没有执行此操作的权限' + ) + assert i18n.t('Please do not replace model not found in user content') == ( + 'Please do not replace model not found in user content' + ) + + +@pytest.mark.asyncio +async def test_adapter_callbacks_use_their_owner_not_the_callers_language(): + app = SimpleNamespace(_message_language_cache={'bot-workspace': ('ja_JP', float('inf'))}) + + class Adapter: + logger = SimpleNamespace(ap=app, execution_context=SimpleNamespace(workspace_uuid='bot-workspace')) + + @localized_adapter + async def reply(self): + return i18n.t('Submitted') + + with i18n.language_scope('zh_Hans'): + assert await Adapter().reply() == '送信済み' + assert adapter_t(Adapter(), 'Submitted') == '送信済み' + assert i18n.language.get() == 'zh_Hans' + + +def test_lark_card_uses_selected_language(): + from langbot.pkg.platform.adapters.lark.stream_card import build_card + + with i18n.language_scope('ja_JP'): + card = json.dumps(build_card('reasonanswer'), ensure_ascii=False) + assert '思考過程' in card + assert 'AI による生成コンテンツ' in card + assert 'Thinking' not in card diff --git a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx index ecd3e08cb..14dda966f 100644 --- a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx +++ b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx @@ -51,6 +51,17 @@ const ASSIGNABLE_ROLES: Exclude[] = [ 'viewer', ]; +const MESSAGE_LANGUAGES: Record = { + en_US: 'English', + zh_Hans: '简体中文', + zh_Hant: '繁體中文', + ja_JP: '日本語', + es_ES: 'Español', + ru_RU: 'Русский', + th_TH: 'ไทย', + vi_VN: 'Tiếng Việt', +}; + export default function WorkspaceSettingsPanel({ active, }: WorkspaceSettingsPanelProps) { @@ -61,6 +72,9 @@ export default function WorkspaceSettingsPanel({ const [members, setMembers] = useState([]); const [invitations, setInvitations] = useState([]); const [loading, setLoading] = useState(false); + const [messageLanguage, setMessageLanguage] = useState('en_US'); + const [supportedLanguages, setSupportedLanguages] = useState([]); + const [savingLanguage, setSavingLanguage] = useState(false); const [inviteEmail, setInviteEmail] = useState(''); const [inviteRole, setInviteRole] = useState>('viewer'); @@ -86,8 +100,13 @@ export default function WorkspaceSettingsPanel({ const loadWorkspace = useCallback(async () => { setLoading(true); try { - const current = await backendClient.getCurrentWorkspace(); + const [current, languageSettings] = await Promise.all([ + backendClient.getCurrentWorkspace(), + backendClient.getWorkspaceLanguage(), + ]); setWorkspaceInfo(current); + setMessageLanguage(languageSettings.language); + setSupportedLanguages(languageSettings.supported_languages); const [memberResponse, invitationResponse] = await Promise.all([ current.permissions.includes('member.view') @@ -106,6 +125,19 @@ export default function WorkspaceSettingsPanel({ } }, [t]); + async function saveLanguage(locale: string) { + setSavingLanguage(true); + try { + const result = await backendClient.setWorkspaceLanguage(locale); + setMessageLanguage(result.language); + toast.success(t('workspaceLanguage.saved')); + } catch { + toast.error(t('workspaceLanguage.saveFailed')); + } finally { + setSavingLanguage(false); + } + } + useEffect(() => { if (active) void loadWorkspace(); }, [active, loadWorkspace]); @@ -222,6 +254,41 @@ export default function WorkspaceSettingsPanel({ +
+
+

+ {t('workspaceLanguage.label')} +

+

+ {t('workspaceLanguage.description')} +

+
+ +
{canInvite && (
diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index c9a42a07e..1c56ac215 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -111,6 +111,17 @@ import type { * 负责与后端 API 的所有交互 */ export class BackendClient extends BaseHttpClient { + public getWorkspaceLanguage(): Promise<{ + language: string; + supported_languages: string[]; + }> { + return this.get('/api/v1/workspaces/current/language'); + } + + public setWorkspaceLanguage(language: string): Promise<{ language: string }> { + return this.put('/api/v1/workspaces/current/language', { language }); + } + constructor(baseURL: string) { super(baseURL, false); } diff --git a/web/src/app/infra/http/BaseHttpClient.ts b/web/src/app/infra/http/BaseHttpClient.ts index d1c86022a..a4d3e8026 100644 --- a/web/src/app/infra/http/BaseHttpClient.ts +++ b/web/src/app/infra/http/BaseHttpClient.ts @@ -11,6 +11,7 @@ import { } from './workspaceContext'; import { setCurrentWorkspaceSnapshot } from './currentWorkspaceStore'; import { clearWorkspaceBootstrapSnapshot } from './workspaceBootstrapStore'; +import i18n from 'i18next'; type JSONValue = string | number | boolean | JSONObject | JSONArray | null; interface JSONObject { diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 500e484e4..a0a421313 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1,6 +1,13 @@ import slackSetup from './slack-setup/en-US'; import pipelineMigration from './pipeline-migration/en-US'; const enUS = { + workspaceLanguage: { + label: 'Workspace language', + description: + 'Language for backend messages and default bot replies. Your interface language stays independent.', + saved: 'Language saved', + saveFailed: 'Failed to save language', + }, slackSetup, inflight: { autoCollapse: 'Auto-collapse', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index f0639285f..a0478b8ee 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1,5 +1,12 @@ import pipelineMigration from './pipeline-migration/es-ES'; const esES = { + workspaceLanguage: { + label: 'Idioma del espacio de trabajo', + description: + 'Idioma de los mensajes del servidor y respuestas predeterminadas del bot. No cambia tu interfaz.', + saved: 'Idioma guardado', + saveFailed: 'No se pudo guardar el idioma', + }, inflight: { autoCollapse: 'Plegar auto.', empty: 'No hay eventos en proceso', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index ceb64102c..6932be4db 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1,6 +1,13 @@ import slackSetup from './slack-setup/ja-JP'; import pipelineMigration from './pipeline-migration/ja-JP'; const jaJP = { + workspaceLanguage: { + label: 'ワークスペースの言語', + description: + 'バックエンドの通知とボットの既定の返信に使用します。個人の表示言語には影響しません。', + saved: '言語設定を保存しました', + saveFailed: '言語設定の保存に失敗しました', + }, slackSetup, inflight: { autoCollapse: '自動折りたたみ', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index da83671ab..1c3170b2a 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1,5 +1,12 @@ import pipelineMigration from './pipeline-migration/ru-RU'; const ruRU = { + workspaceLanguage: { + label: 'Язык рабочего пространства', + description: + 'Язык сообщений сервера и стандартных ответов бота. Язык интерфейса не меняется.', + saved: 'Язык сохранён', + saveFailed: 'Не удалось сохранить язык', + }, inflight: { autoCollapse: 'Автосворачивание', empty: 'Нет событий в обработке', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 3ffc83053..7dc14a6c2 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1,5 +1,12 @@ import pipelineMigration from './pipeline-migration/th-TH'; const thTH = { + workspaceLanguage: { + label: 'ภาษาของพื้นที่ทำงาน', + description: + 'ใช้สำหรับข้อความจากระบบและคำตอบเริ่มต้นของบอต โดยไม่เปลี่ยนภาษาอินเทอร์เฟซของคุณ', + saved: 'บันทึกภาษาแล้ว', + saveFailed: 'บันทึกภาษาไม่สำเร็จ', + }, inflight: { autoCollapse: 'ยุบอัตโนมัติ', empty: 'ไม่มีเหตุการณ์ที่กำลังประมวลผล', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index b5ec94671..932905864 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1,5 +1,12 @@ import pipelineMigration from './pipeline-migration/vi-VN'; const viVN = { + workspaceLanguage: { + label: 'Ngôn ngữ không gian làm việc', + description: + 'Dùng cho thông báo máy chủ và phản hồi mặc định của bot. Không thay đổi ngôn ngữ giao diện cá nhân.', + saved: 'Đã lưu ngôn ngữ', + saveFailed: 'Không thể lưu ngôn ngữ', + }, inflight: { autoCollapse: 'Tự thu gọn', empty: 'Không có sự kiện đang xử lý', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 1c4ea076b..124b555c3 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1,6 +1,12 @@ import slackSetup from './slack-setup/zh-Hans'; import pipelineMigration from './pipeline-migration/zh-Hans'; const zhHans = { + workspaceLanguage: { + label: '工作区语言', + description: '用于后端提示和机器人的默认回复,不影响个人界面语言。', + saved: '语言设置已保存', + saveFailed: '语言设置保存失败', + }, slackSetup, inflight: { autoCollapse: '自动折叠', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index ca2a7239f..dd2c08701 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1,6 +1,12 @@ import slackSetup from './slack-setup/zh-Hant'; import pipelineMigration from './pipeline-migration/zh-Hant'; const zhHant = { + workspaceLanguage: { + label: '工作區語言', + description: '用於後端提示和機器人的預設回覆,不影響個人介面語言。', + saved: '語言設定已儲存', + saveFailed: '語言設定儲存失敗', + }, slackSetup, inflight: { autoCollapse: '自動摺疊', diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index f78fc8e22..32e0fb207 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -750,6 +750,22 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { return fulfillJson(route, { workspaces: state.workspaces }); } + if (path === '/api/v1/workspaces/current/language') { + return fulfillJson(route, { + language: 'en_US', + supported_languages: [ + 'en_US', + 'zh_Hans', + 'zh_Hant', + 'ja_JP', + 'es_ES', + 'ru_RU', + 'th_TH', + 'vi_VN', + ], + }); + } + if (path === '/api/v1/workspaces/current') { const selectedWorkspaceUuid = request.headers()['x-workspace-id']; const entry = state.workspaces.find( diff --git a/web/tests/e2e/fixtures/runner-migration-contract.json b/web/tests/e2e/fixtures/runner-migration-contract.json index 892e827f4..1c066a061 100644 --- a/web/tests/e2e/fixtures/runner-migration-contract.json +++ b/web/tests/e2e/fixtures/runner-migration-contract.json @@ -5765,12 +5765,110 @@ "zh_Hans": "失败提示文本" }, "description": { - "en_US": "The text to display when a request fails. Only effective when Exception Handling Strategy is set to \"Show Failure Hint\"", - "zh_Hans": "请求失败时显示的提示文本,仅在异常处理策略设置为\"仅文字提示\"时生效" + "en_US": "Leave empty to use the Workspace language. Used for request and runner execution failures; custom text is not translated.", + "zh_Hans": "留空时使用工作区语言的默认提示。用于请求和运行器执行失败;自定义文本不会自动翻译。" }, "type": "string", "required": false, - "default": "Request failed." + "default": "" + }, + { + "name": "rate-limit-hint", + "label": { + "en_US": "Rate Limit Hint", + "zh_Hans": "限速提示" + }, + "description": { + "en_US": "Leave empty to use the Workspace language.", + "zh_Hans": "留空时使用工作区语言的默认提示。" + }, + "type": "string", + "required": false, + "default": "" + }, + { + "name": "content-filter-hint", + "label": { + "en_US": "Content Filter Hint", + "zh_Hans": "内容过滤提示" + }, + "description": { + "en_US": "Leave empty to use the Workspace language.", + "zh_Hans": "留空时使用工作区语言的默认提示。" + }, + "type": "string", + "required": false, + "default": "" + }, + { + "name": "content-filter-error-hint", + "label": { + "en_US": "Content Check Failure Hint", + "zh_Hans": "内容检查失败提示" + }, + "description": { + "en_US": "Leave empty to use the Workspace language.", + "zh_Hans": "留空时使用工作区语言的默认提示。" + }, + "type": "string", + "required": false, + "default": "" + }, + { + "name": "tool-call-hint", + "label": { + "en_US": "Tool Call Hint", + "zh_Hans": "工具调用提示" + }, + "description": { + "en_US": "Leave empty to use the Workspace language. Use {tools} for tool names. Requires Track Function Calls.", + "zh_Hans": "留空时使用工作区语言的默认提示。 可用 {tools} 插入工具名称,需开启跟踪函数调用。" + }, + "type": "string", + "required": false, + "default": "" + }, + { + "name": "runner-not-found-hint", + "label": { + "en_US": "Runner Missing Hint", + "zh_Hans": "运行器不存在提示" + }, + "description": { + "en_US": "Leave empty to use the Workspace language. Use {runner} for the runner ID.", + "zh_Hans": "留空时使用工作区语言的默认提示。 可用 {runner} 插入运行器 ID。" + }, + "type": "string", + "required": false, + "default": "" + }, + { + "name": "runner-not-authorized-hint", + "label": { + "en_US": "Runner Authorization Hint", + "zh_Hans": "运行器未授权提示" + }, + "description": { + "en_US": "Leave empty to use the Workspace language.", + "zh_Hans": "留空时使用工作区语言的默认提示。" + }, + "type": "string", + "required": false, + "default": "" + }, + { + "name": "runner-unavailable-hint", + "label": { + "en_US": "Runner Unavailable Hint", + "zh_Hans": "运行器不可用提示" + }, + "description": { + "en_US": "Leave empty to use the Workspace language.", + "zh_Hans": "留空时使用工作区语言的默认提示。" + }, + "type": "string", + "required": false, + "default": "" }, { "name": "at-sender", diff --git a/web/tests/e2e/workspace-language.spec.ts b/web/tests/e2e/workspace-language.spec.ts new file mode 100644 index 000000000..7ff0a5fc1 --- /dev/null +++ b/web/tests/e2e/workspace-language.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test'; +import { + installLangBotApiMocks, + makeWorkspaceEntry, +} from './fixtures/langbot-api'; + +test('workspace language persists independently of personal UI language', async ({ + page, +}, testInfo) => { + const workspace = makeWorkspaceEntry( + 'workspace-language', + 'Language Workspace', + 'local', + ); + workspace.permissions = ['workspace.view', 'workspace.update']; + await installLangBotApiMocks(page, { + authenticated: true, + workspaces: [workspace], + }); + let language = 'en_US'; + await page.route('**/api/v1/workspaces/current/language', async (route) => { + if (route.request().method() === 'PUT') { + expect(route.request().headers()['x-workspace-id']).toBe( + 'workspace-language', + ); + language = route.request().postDataJSON().language; + } + await route.fulfill({ + json: { + code: 0, + msg: 'ok', + data: { + language, + supported_languages: ['en_US', 'zh_Hans', 'ja_JP'], + }, + }, + }); + }); + await page.goto('/home?action=showWorkspaceSettings'); + const select = page.getByRole('combobox', { + name: 'Workspace language', + exact: true, + }); + await expect(select).toHaveText('English'); + await select.click(); + await page.getByRole('option', { name: '日本語', exact: true }).click(); + await expect(select).toHaveText('日本語'); + await expect(page.getByText('Language saved', { exact: true })).toBeVisible(); + await page.reload(); + await page.goto('/home?action=showWorkspaceSettings'); + await expect(select).toHaveText('日本語'); + await expect( + page.getByText('Workspace language', { exact: true }), + ).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath('workspace-language.png'), + fullPage: true, + }); +}); + +test('viewer can read but cannot edit workspace language', async ({ page }) => { + const workspace = makeWorkspaceEntry( + 'workspace-language', + 'Language Workspace', + 'local', + ); + workspace.membership.role = 'viewer'; + workspace.permissions = ['workspace.view']; + await installLangBotApiMocks(page, { + authenticated: true, + workspaces: [workspace], + }); + await page.goto('/home?action=showWorkspaceSettings'); + const select = page.getByRole('combobox', { + name: 'Workspace language', + exact: true, + }); + await expect(select).toHaveText('English'); + await expect(select).toBeDisabled(); +});